Merge test into dev and resolve conflicts
This commit is contained in:
@@ -5,6 +5,7 @@ import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
|
||||
@@ -16,6 +17,11 @@ public interface IamOnboardingClient {
|
||||
@RequestHeader("X-Internal-Service-Token") String internalToken,
|
||||
@RequestBody OnboardingRequest request);
|
||||
|
||||
@GetMapping("/merchant-registrations/{authUserId}")
|
||||
ApiResponse<OnboardingResponse> registration(
|
||||
@RequestHeader("X-Internal-Service-Token") String internalToken,
|
||||
@PathVariable("authUserId") Long authUserId);
|
||||
|
||||
@DeleteMapping("/merchant-registrations/{authUserId}/compensation")
|
||||
ApiResponse<Void> compensate(
|
||||
@RequestHeader("X-Internal-Service-Token") String internalToken,
|
||||
|
||||
@@ -12,7 +12,7 @@ public record MerchantRegisterRequest(
|
||||
@NotBlank @Size(min = 8, max = 72) String password,
|
||||
@NotBlank @Size(max = 64) String displayName,
|
||||
@NotBlank @Size(max = 128) String companyName,
|
||||
@NotBlank @Size(max = 64) String legalPerson,
|
||||
@Size(max = 64) String legalPerson,
|
||||
@Schema(description = "首个企业管理员的登录手机号;注册时同时初始化企业联系电话,创建后两者独立维护")
|
||||
@NotBlank @Pattern(regexp = "^1[3-9]\\d{9}$") String phone,
|
||||
@Email @Size(max = 128) String contactEmail,
|
||||
|
||||
@@ -35,11 +35,20 @@ public class MerchantRegistrationService {
|
||||
@BusinessAudit(event = "MERCHANT_REGISTER", resourceType = "ACCOUNT",
|
||||
resourceId = "#request.username", description = "商户注册", captureAfter = false)
|
||||
public MerchantRegisterResponse register(MerchantRegisterRequest request) {
|
||||
if (accountRepository.existsByUsername(request.username())) {
|
||||
AuthAccount usernameAccount = accountRepository.findByUsername(request.username());
|
||||
AuthAccount phoneAccount = accountRepository.findByPhone(request.phone());
|
||||
if (usernameAccount != null && phoneAccount != null
|
||||
&& usernameAccount.getId().equals(phoneAccount.getId())
|
||||
&& "MERCHANT".equals(usernameAccount.getDefaultAppCode())
|
||||
&& "ACTIVE".equals(usernameAccount.getAccountStatus())
|
||||
&& passwordEncoder.matches(request.password(), usernameAccount.getPasswordHash())) {
|
||||
return existingRegistration(usernameAccount);
|
||||
}
|
||||
if (usernameAccount != null) {
|
||||
throw new BizException(ResultCode.CONFLICT, "用户名已存在");
|
||||
}
|
||||
if (accountRepository.existsByPhone(request.phone())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "手机号已存在");
|
||||
if (phoneAccount != null) {
|
||||
throw new BizException(ResultCode.CONFLICT, "该手机号已注册,请直接登录后继续完善企业资质");
|
||||
}
|
||||
long accountId = IdWorker.getId();
|
||||
String passwordHash = passwordEncoder.encode(request.password());
|
||||
@@ -111,6 +120,24 @@ public class MerchantRegistrationService {
|
||||
"LOGIN_AND_COMPLETE_QUALIFICATIONS");
|
||||
}
|
||||
|
||||
private MerchantRegisterResponse existingRegistration(AuthAccount account) {
|
||||
try {
|
||||
var response = onboardingClient.registration(internalToken, account.getId());
|
||||
if (response == null || !response.successful() || response.data() == null) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "注册状态暂时无法确认,请直接登录后继续完善企业资质");
|
||||
}
|
||||
var onboarding = response.data();
|
||||
if (!java.util.Set.of("PROFILE_REQUIRED", "REJECTED").contains(onboarding.tenantStatus())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "该账号已注册,请直接登录");
|
||||
}
|
||||
return new MerchantRegisterResponse(account.getId(), onboarding.tenantId(), onboarding.membershipId(),
|
||||
onboarding.tenantStatus(), onboarding.dimensionInitializationStatus(),
|
||||
"LOGIN_AND_COMPLETE_QUALIFICATIONS");
|
||||
} catch (FeignException exception) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "注册状态暂时无法确认,请直接登录后继续完善企业资质");
|
||||
}
|
||||
}
|
||||
|
||||
private void registerRollbackCompensation(long accountId, AtomicBoolean compensationCompleted) {
|
||||
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||
return;
|
||||
|
||||
@@ -23,6 +23,7 @@ import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.mockito.Mockito.never;
|
||||
|
||||
@SpringJUnitConfig(MerchantRegistrationService.class)
|
||||
@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token")
|
||||
@@ -61,6 +62,49 @@ class MerchantRegistrationServiceTest {
|
||||
assertEquals("PENDING", result.dimensionInitializationStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void exactIncompleteRegistrationRetryReturnsTheExistingRegistration() {
|
||||
AuthAccount account = new AuthAccount();
|
||||
account.setId(9001L);
|
||||
account.setUsername("registered_owner");
|
||||
account.setPhone("13800138000");
|
||||
account.setPasswordHash("encoded-password");
|
||||
account.setAccountStatus("ACTIVE");
|
||||
account.setDefaultAppCode("MERCHANT");
|
||||
when(accounts.findByUsername("registered_owner")).thenReturn(account);
|
||||
when(accounts.findByPhone("13800138000")).thenReturn(account);
|
||||
when(encoder.matches("Password123", "encoded-password")).thenReturn(true);
|
||||
when(onboarding.registration("internal-token", 9001L)).thenReturn(ApiResponse.success(
|
||||
new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED", "READY")));
|
||||
|
||||
var result = service.register(new MerchantRegisterRequest(
|
||||
"registered_owner", "Password123", "注册人", "测试企业", null,
|
||||
"13800138000", "owner@example.com", "CN", "深圳市宝安区"));
|
||||
|
||||
assertEquals(9001L, result.accountId());
|
||||
assertEquals(10001L, result.tenantId());
|
||||
assertEquals("READY", result.dimensionInitializationStatus());
|
||||
verify(onboarding, never()).register(eq("internal-token"), any());
|
||||
verify(accounts, never()).insert(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void phoneOwnedByAnotherUsernameDirectsTheUserBackToLogin() {
|
||||
AuthAccount account = new AuthAccount();
|
||||
account.setId(9001L);
|
||||
account.setUsername("registered_owner");
|
||||
account.setPhone("13800138000");
|
||||
when(accounts.findByPhone("13800138000")).thenReturn(account);
|
||||
|
||||
var failure = assertThrows(com.baiyue.common.core.exception.BizException.class,
|
||||
() -> service.register(new MerchantRegisterRequest(
|
||||
"another_owner", "Password123", "注册人", "测试企业", null,
|
||||
"13800138000", "owner@example.com", "CN", "深圳市宝安区")));
|
||||
|
||||
assertEquals("该手机号已注册,请直接登录后继续完善企业资质", failure.getMessage());
|
||||
verify(onboarding, never()).register(eq("internal-token"), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedAuthInsertCompensatesTheIamRegistration() {
|
||||
when(encoder.encode("Password123")).thenReturn("encoded-password");
|
||||
|
||||
+12
-2
@@ -141,8 +141,18 @@ public record FirstLegLoadingPlanCompletedV2(
|
||||
}
|
||||
}
|
||||
|
||||
/** 头程托盘冻结快照;普通入仓用于复用打印编号,一键代发还冻结 SKU/箱唛库存。 */
|
||||
public record PalletSnapshot(String palletNo, List<SkuPackageSnapshot> skuPackages) {}
|
||||
/** 普通 FBA 只复用 palletNo;一件代发还携带同一物理 DP 的完整清单摘要。 */
|
||||
public record PalletSnapshot(String palletNo, List<SkuPackageSnapshot> skuPackages,
|
||||
Integer manifestVersion, Integer manifestPackageCount,
|
||||
Integer manifestFragmentCount, String manifestHash) {
|
||||
public PalletSnapshot {
|
||||
skuPackages = skuPackages == null ? List.of() : List.copyOf(skuPackages);
|
||||
}
|
||||
|
||||
public PalletSnapshot(String palletNo, List<SkuPackageSnapshot> skuPackages) {
|
||||
this(palletNo, skuPackages, null, null, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
/** 托盘下来源入库明细、SKU、箱数、数量及箱号冻结快照;同 SKU 多明细按 sourceInboundDetailId 精确区分。 */
|
||||
public record SkuPackageSnapshot(String sourceInboundDetailId, String skuCode, String productName,
|
||||
|
||||
@@ -65,6 +65,8 @@ services:
|
||||
FIRSTLEG_EVENT_BRIDGE_URL: ${CN_EVENT_BRIDGE_TARGET_URL:-http://event-bridge-cn:9312}
|
||||
DOMESTIC_WAREHOUSE_URL: ${DOMESTIC_WAREHOUSE_URL}
|
||||
FILE_SERVICE_URL: ${FILE_SERVICE_URL}
|
||||
# Print 注册在国内 Nacos namespace;海外服务通过共享 infra-network 的 Docker DNS 直连。
|
||||
PRINT_SERVICE_URL: ${PRINT_SERVICE_URL:-http://print:9207}
|
||||
WAREHOUSE_OVERSEAS_SERVICE_URL: ${WAREHOUSE_OVERSEAS_SERVICE_URL:-http://warehouse-overseas-service:9305}
|
||||
|
||||
warehouse-overseas-service:
|
||||
|
||||
@@ -123,7 +123,6 @@ services:
|
||||
FINANCE_DB_URL: ${FINANCE_DB_URL}
|
||||
FINANCE_DB_USERNAME: ${FINANCE_DB_USERNAME}
|
||||
FINANCE_DB_PASSWORD: ${FINANCE_DB_PASSWORD}
|
||||
FINANCE_WALLET_DEFAULT_CREDIT_LIMIT: ${FINANCE_WALLET_DEFAULT_CREDIT_LIMIT:-0.00}
|
||||
OVERSEAS_EVENT_BRIDGE_URL: ${OVERSEAS_EVENT_BRIDGE_URL:-http://event-bridge-overseas:9312}
|
||||
|
||||
order:
|
||||
|
||||
+272
-34
@@ -329,7 +329,7 @@ USE baiyue_iam;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS iam_tenant (
|
||||
id BIGINT NOT NULL COMMENT '客户主档ID(雪花ID);0保留为平台技术作用域',
|
||||
tenant_code VARCHAR(64) NOT NULL COMMENT '全局唯一客户编码',
|
||||
tenant_code VARCHAR(8) NOT NULL COMMENT '客户编号:企业客户固定五位数字,平台技术主体为PLATFORM',
|
||||
tenant_name VARCHAR(128) NOT NULL COMMENT '客户企业名称',
|
||||
tenant_type VARCHAR(20) NOT NULL DEFAULT 'MERCHANT' COMMENT '主体类型:MERCHANT企业客户',
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'PROFILE_REQUIRED' COMMENT '客户状态:PROFILE_REQUIRED待完善资料、PENDING待审核、ACTIVE正常、REJECTED已驳回、DISABLED已停用',
|
||||
@@ -364,6 +364,15 @@ CREATE TABLE IF NOT EXISTS iam_tenant (
|
||||
KEY idx_iam_tenant_status (status, created_at)
|
||||
) ENGINE=InnoDB COMMENT='客户企业主档表:保存企业资料、审核结果和启停状态;ID同时作为客户数据隔离键';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS iam_number_sequence (
|
||||
sequence_key VARCHAR(64) NOT NULL COMMENT '业务编号类型',
|
||||
next_value BIGINT UNSIGNED NOT NULL COMMENT '下一待分配数字',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
PRIMARY KEY (sequence_key)
|
||||
) ENGINE=InnoDB COMMENT='IAM业务编号序列表';
|
||||
|
||||
INSERT IGNORE INTO iam_number_sequence(sequence_key, next_value) VALUES ('CUSTOMER_CODE', 1);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS iam_tenant_review (
|
||||
id BIGINT NOT NULL COMMENT '审核记录ID',
|
||||
tenant_id BIGINT NOT NULL COMMENT '被审核客户ID和数据隔离键',
|
||||
@@ -402,6 +411,7 @@ CREATE TABLE IF NOT EXISTS iam_tenant_qualification (
|
||||
qualification_type VARCHAR(32) NOT NULL COMMENT '客户资质类型',
|
||||
certificate_no VARCHAR(128) NULL COMMENT '证书编号',
|
||||
document_url VARCHAR(500) NULL COMMENT '资质文件存储地址',
|
||||
original_file_name VARCHAR(255) NOT NULL COMMENT '上传时原始文件名',
|
||||
valid_from DATE NULL COMMENT '有效期开始日期',
|
||||
valid_to DATE NULL COMMENT '有效期结束日期',
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT '资质审核状态:PENDING待审核、APPROVED已通过、REJECTED已驳回',
|
||||
@@ -1424,6 +1434,7 @@ CALL phase1_iam_add_column('iam_tenant', 'approved_at', "DATETIME(3) NULL COMMEN
|
||||
CALL phase1_iam_add_column('iam_tenant', 'approved_by', "BIGINT NULL COMMENT 'Reviewer ID'");
|
||||
CALL phase1_iam_add_column('iam_tenant', 'reject_reason', "VARCHAR(500) NULL COMMENT 'Rejection reason'");
|
||||
CALL phase1_iam_add_column('iam_tenant', 'legal_representative', "VARCHAR(64) NULL COMMENT '法定代表人' AFTER legal_name");
|
||||
CALL phase1_iam_add_column('iam_tenant_qualification', 'original_file_name', "VARCHAR(255) NULL COMMENT '上传时原始文件名' AFTER document_url");
|
||||
CALL phase1_iam_add_column('iam_tenant_qualification', 'review_comment', "VARCHAR(500) NULL COMMENT 'Review comment'");
|
||||
CALL phase1_iam_add_column('iam_membership', 'created_by', "BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Creator ID'");
|
||||
CALL phase1_iam_add_column('iam_membership', 'updated_by', "BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Updater ID'");
|
||||
@@ -1784,6 +1795,7 @@ CALL phase1_add_column('ord_order_cargo', 'product_height_cm', "DECIMAL(12,2) NU
|
||||
CALL phase1_add_column('ord_order_cargo', 'shipment_id', "VARCHAR(64) NULL COMMENT 'Cargo Amazon Shipment ID'");
|
||||
CALL phase1_add_column('ord_order_cargo', 'reference_id', "VARCHAR(64) NULL COMMENT 'Cargo Amazon Reference ID'");
|
||||
CALL phase1_add_column('ord_order_cargo', 'product_image_file_id', "BIGINT UNSIGNED NULL COMMENT 'Product image logical file ID'");
|
||||
CALL phase1_add_column('ord_order_cargo', 'packing_group_no', "VARCHAR(32) NULL COMMENT '混箱组合号;同组货物行共享同一批物理箱'");
|
||||
CALL phase1_add_column('ord_order_cargo', 'wooden_product', "TINYINT(1) NOT NULL DEFAULT 0 COMMENT '木制品权威判定快照;1时订单必须商检'");
|
||||
|
||||
UPDATE ord_order_cargo cargo
|
||||
@@ -2897,6 +2909,23 @@ CREATE TABLE IF NOT EXISTS fl_inbound_carton_label (
|
||||
KEY idx_carton_label_detail (tenant_id, inbound_id, inbound_detail_id),
|
||||
CONSTRAINT fk_carton_label_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
|
||||
) ENGINE=InnoDB COMMENT='首次打印前冻结的实际箱号与订单货物明细映射';
|
||||
CREATE TABLE IF NOT EXISTS fl_inbound_carton_label_sku (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '箱唛SKU快照ID',
|
||||
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
|
||||
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
|
||||
carton_label_id BIGINT UNSIGNED NOT NULL COMMENT '物理箱唛ID',
|
||||
inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '订单货物明细ID',
|
||||
sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码快照',
|
||||
cargo_name VARCHAR(255) NULL COMMENT '货物名称快照',
|
||||
expected_quantity INT UNSIGNED NOT NULL COMMENT '该物理箱内应收SKU数量',
|
||||
line_no INT UNSIGNED NOT NULL COMMENT '箱内SKU展示顺序',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_carton_label_sku_detail (carton_label_id,inbound_detail_id),
|
||||
KEY idx_carton_label_sku_code (tenant_id,inbound_id,sku_code),
|
||||
CONSTRAINT fk_carton_label_sku_label FOREIGN KEY (carton_label_id) REFERENCES fl_inbound_carton_label(id)
|
||||
) ENGINE=InnoDB COMMENT='物理箱唛内的多SKU冻结快照';
|
||||
|
||||
|
||||
-- 存量环境可能缺少稳定箱码映射兼容字段;CREATE TABLE IF NOT EXISTS 不会补列。
|
||||
DROP PROCEDURE IF EXISTS unified_carton_label_add_column;
|
||||
@@ -3843,7 +3872,7 @@ CREATE TABLE IF NOT EXISTS fl_price_book_version (
|
||||
original_file_name VARCHAR(255) NOT NULL COMMENT '原始Excel文件名',
|
||||
source_file_id BIGINT UNSIGNED NULL COMMENT 'File服务原始文件ID',
|
||||
source_sha256 CHAR(64) NULL COMMENT '原始文件SHA-256',
|
||||
status VARCHAR(24) NOT NULL DEFAULT 'PENDING_CONFIRMATION' COMMENT '价格版本状态:PENDING_CONFIRMATION待确认、ACTIVE已生效、SUPERSEDED已被替代、FAILED解析失败',
|
||||
status VARCHAR(24) NOT NULL DEFAULT 'PENDING_CONFIRMATION' COMMENT '价格版本状态:PENDING_CONFIRMATION待确认、ACTIVE已生效、SUPERSEDED已被替代、VOIDED已作废、FAILED解析失败',
|
||||
blocking_error_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '阻断错误数',
|
||||
warning_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '警告数',
|
||||
parse_issues_json JSON NULL COMMENT '上传解析问题及待确认重新校验结果',
|
||||
@@ -3854,6 +3883,9 @@ CREATE TABLE IF NOT EXISTS fl_price_book_version (
|
||||
published_by BIGINT UNSIGNED NULL COMMENT '确认并发布价格版本的操作人ID',
|
||||
published_at DATETIME(3) NULL COMMENT '确认并生效时间',
|
||||
superseded_at DATETIME(3) NULL COMMENT '被下一版本替换时间',
|
||||
voided_by BIGINT UNSIGNED NULL COMMENT '作废操作人ID',
|
||||
voided_at DATETIME(3) NULL COMMENT '作废时间',
|
||||
void_reason VARCHAR(500) NULL COMMENT '作废原因',
|
||||
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间',
|
||||
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记:0未删除、1已删除',
|
||||
@@ -3864,6 +3896,39 @@ CREATE TABLE IF NOT EXISTS fl_price_book_version (
|
||||
KEY idx_price_book_source_hash (tenant_id, source_sha256)
|
||||
) ENGINE=InnoDB COMMENT='平台公共船期价格整表版本';
|
||||
|
||||
-- Warehouse 增量段已在自身结束时删除同名过程;Firstleg 价格版本的存量补列需在当前库重新定义。
|
||||
DROP PROCEDURE IF EXISTS add_column_if_missing;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE add_column_if_missing(
|
||||
IN p_table_name VARCHAR(64),
|
||||
IN p_column_name VARCHAR(64),
|
||||
IN p_column_definition TEXT
|
||||
)
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = p_table_name
|
||||
AND column_name = p_column_name
|
||||
) THEN
|
||||
SET @add_column_sql = CONCAT(
|
||||
'ALTER TABLE `', REPLACE(p_table_name, '`', '``'),
|
||||
'` ADD COLUMN `', REPLACE(p_column_name, '`', '``'),
|
||||
'` ', p_column_definition
|
||||
);
|
||||
PREPARE add_column_statement FROM @add_column_sql;
|
||||
EXECUTE add_column_statement;
|
||||
DEALLOCATE PREPARE add_column_statement;
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
|
||||
CALL add_column_if_missing('fl_price_book_version', 'voided_by', 'BIGINT UNSIGNED NULL COMMENT ''作废操作人ID'' AFTER superseded_at');
|
||||
CALL add_column_if_missing('fl_price_book_version', 'voided_at', 'DATETIME(3) NULL COMMENT ''作废时间'' AFTER voided_by');
|
||||
CALL add_column_if_missing('fl_price_book_version', 'void_reason', 'VARCHAR(500) NULL COMMENT ''作废原因'' AFTER voided_at');
|
||||
DROP PROCEDURE IF EXISTS add_column_if_missing;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_price_book_channel (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '版本渠道快照ID',
|
||||
price_book_version_id BIGINT UNSIGNED NOT NULL COMMENT '整表价格版本ID',
|
||||
@@ -5060,6 +5125,7 @@ CREATE TABLE IF NOT EXISTS fl_container_booking (
|
||||
source_type VARCHAR(24) NOT NULL DEFAULT 'SIMULATION' COMMENT '记录来源:SIMULATION内部模拟柜、OCEAN_BOOKING订舱真实柜',
|
||||
record_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '记录状态:ACTIVE可操作、MERGED已合并历史',
|
||||
merged_into_container_booking_id BIGINT UNSIGNED NULL COMMENT '真实柜被模拟柜吸收后的目标稳定柜次ID',
|
||||
channel_group_code VARCHAR(64) NULL COMMENT '模拟柜计划渠道分组;关联真实订舱后继续保留',
|
||||
channel_code VARCHAR(64) NULL COMMENT '订舱渠道编码快照;存量回填前禁止混装',
|
||||
channel_name VARCHAR(128) NULL COMMENT '订舱渠道名称快照',
|
||||
ocean_booking_id BIGINT UNSIGNED NULL COMMENT '来源订舱ID',
|
||||
@@ -5168,11 +5234,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.columns WHERE table_schema=DATABASE()
|
||||
AND table_name='fl_container_booking' AND column_name='unloading_completed_at') THEN
|
||||
ALTER TABLE fl_container_booking ADD COLUMN unloading_completed_at DATETIME(3) NULL
|
||||
@@ -5484,6 +5553,7 @@ BEGIN
|
||||
DECLARE source_exists INT DEFAULT 0;
|
||||
DECLARE source_total_columns INT DEFAULT 0;
|
||||
DECLARE source_smart_columns INT DEFAULT 0;
|
||||
DECLARE source_current_columns INT DEFAULT 0;
|
||||
DECLARE source_inbound_columns INT DEFAULT 0;
|
||||
DECLARE target_exists INT DEFAULT 0;
|
||||
DECLARE target_total_columns INT DEFAULT 0;
|
||||
@@ -5528,7 +5598,45 @@ BEGIN
|
||||
OR (column_name = 'updated_at' AND data_type = 'datetime' AND datetime_precision = 3 AND is_nullable = 'NO' AND LOWER(column_default) LIKE 'current_timestamp%' AND LOWER(extra) LIKE '%on update current_timestamp%')
|
||||
);
|
||||
|
||||
IF source_total_columns = 11 AND source_smart_columns = 11 AND source_inbound_columns < 12
|
||||
SELECT COUNT(*) INTO source_current_columns
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' AND (
|
||||
(column_name = 'id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
|
||||
OR (column_name = 'loading_plan_id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
|
||||
OR (column_name = 'container_booking_id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
|
||||
OR (column_name = 'cabinet_no' AND LOWER(column_type) = 'varchar(64)' AND is_nullable = 'NO')
|
||||
OR (column_name = 'container_no' AND LOWER(column_type) = 'varchar(64)' AND is_nullable = 'YES')
|
||||
OR (column_name = 'task_status' AND LOWER(column_type) = 'varchar(24)' AND is_nullable = 'NO' AND column_default = 'LOADING')
|
||||
OR (column_name = 'started_by' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
|
||||
OR (column_name = 'started_by_name' AND LOWER(column_type) = 'varchar(64)' AND is_nullable = 'YES')
|
||||
OR (column_name = 'started_at' AND data_type = 'datetime' AND datetime_precision = 3 AND is_nullable = 'NO' AND LOWER(column_default) LIKE 'current_timestamp%')
|
||||
OR (column_name = 'completed_by' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'YES')
|
||||
OR (column_name = 'completed_by_name' AND LOWER(column_type) = 'varchar(64)' AND is_nullable = 'YES')
|
||||
OR (column_name = 'completed_at' AND data_type = 'datetime' AND datetime_precision = 3 AND is_nullable = 'YES')
|
||||
OR (column_name = 'version' AND LOWER(column_type) = 'int unsigned' AND is_nullable = 'NO' AND CAST(column_default AS CHAR) = '0')
|
||||
);
|
||||
|
||||
IF source_total_columns = 13 AND source_current_columns = 13
|
||||
AND (SELECT COUNT(*) FROM information_schema.statistics
|
||||
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task'
|
||||
AND index_name = 'uk_loading_pick_task_plan' AND non_unique = 0
|
||||
AND seq_in_index = 1 AND column_name = 'loading_plan_id') = 1
|
||||
AND (SELECT COUNT(*) FROM information_schema.statistics
|
||||
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task'
|
||||
AND index_name = 'uk_loading_pick_task_booking' AND non_unique = 0
|
||||
AND seq_in_index = 1 AND column_name = 'container_booking_id') = 1
|
||||
AND (SELECT COUNT(*) FROM information_schema.statistics
|
||||
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task'
|
||||
AND index_name = 'idx_loading_pick_container'
|
||||
AND ((seq_in_index = 1 AND column_name = 'container_no')
|
||||
OR (seq_in_index = 2 AND column_name = 'task_status')
|
||||
OR (seq_in_index = 3 AND column_name = 'started_at'))) = 3
|
||||
AND (SELECT COUNT(*) FROM information_schema.key_column_usage
|
||||
WHERE constraint_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task'
|
||||
AND column_name = 'loading_plan_id' AND referenced_table_name = 'fl_loading_plan'
|
||||
AND referenced_column_name = 'id') = 1 THEN
|
||||
SET source_exists = 1;
|
||||
ELSEIF source_total_columns = 11 AND source_smart_columns = 11 AND source_inbound_columns < 12
|
||||
AND (SELECT COUNT(*) FROM information_schema.statistics
|
||||
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task'
|
||||
AND index_name = 'uk_loading_pick_task_plan' AND non_unique = 0
|
||||
@@ -5842,6 +5950,29 @@ DELIMITER ;
|
||||
CALL repair_phase3_loading_pick_task_foreign_keys();
|
||||
DROP PROCEDURE repair_phase3_loading_pick_task_foreign_keys;
|
||||
|
||||
-- Existing deployments may predate simulated-container channel grouping. The
|
||||
-- update trigger below references this column, so upgrade it before compiling
|
||||
-- the trigger rather than waiting for the later channel-container data block.
|
||||
DROP PROCEDURE IF EXISTS phase3_add_container_channel_group;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE phase3_add_container_channel_group()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'baiyue_firstleg'
|
||||
AND table_name = 'fl_container_booking'
|
||||
AND column_name = 'channel_group_code'
|
||||
) THEN
|
||||
ALTER TABLE fl_container_booking
|
||||
ADD COLUMN channel_group_code VARCHAR(64) NULL
|
||||
COMMENT '模拟柜计划渠道分组;关联真实订舱后继续保留'
|
||||
AFTER merged_into_container_booking_id;
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
CALL phase3_add_container_channel_group();
|
||||
DROP PROCEDURE phase3_add_container_channel_group;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_container_booking_before_update;
|
||||
DELIMITER $$
|
||||
CREATE TRIGGER trg_container_booking_before_update
|
||||
@@ -5854,6 +5985,7 @@ BEGIN
|
||||
IF current_plan_status IN ('LOADING', 'COMPLETED') AND (
|
||||
NOT (OLD.id <=> NEW.id) OR NOT (OLD.booking_no <=> NEW.booking_no)
|
||||
OR NOT (OLD.cabinet_no <=> NEW.cabinet_no)
|
||||
OR NOT (OLD.channel_group_code <=> NEW.channel_group_code)
|
||||
OR NOT (OLD.channel_code <=> NEW.channel_code)
|
||||
OR NOT (OLD.channel_name <=> NEW.channel_name)
|
||||
OR NOT (OLD.ocean_booking_id <=> NEW.ocean_booking_id)
|
||||
@@ -6449,14 +6581,13 @@ FROM iam_role role
|
||||
JOIN iam_permission permission
|
||||
ON permission.tenant_id = 0
|
||||
AND permission.permission_code IN (
|
||||
'platform:firstleg:container:list', 'platform:firstleg:container:view',
|
||||
'platform:firstleg:container:list',
|
||||
'platform:firstleg:container:create',
|
||||
'platform:firstleg:container:edit', 'platform:firstleg:smart-loading:list',
|
||||
'platform:firstleg:smart-loading:assign', 'platform:firstleg:smart-loading:delete',
|
||||
'platform:firstleg:preload:list', 'platform:firstleg:preload:view',
|
||||
'platform:firstleg:preload:list',
|
||||
'platform:firstleg:preload:edit', 'platform:firstleg:preload:complete',
|
||||
'platform:firstleg:preload:confirm', 'platform:firstleg:loading-list:list',
|
||||
'platform:firstleg:loading-list:view')
|
||||
'platform:firstleg:preload:confirm', 'platform:firstleg:loading-list:list')
|
||||
WHERE role.tenant_id = 0
|
||||
AND role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR');
|
||||
|
||||
@@ -6581,13 +6712,13 @@ BEGIN
|
||||
IF (SELECT COUNT(DISTINCT permission_code) FROM baiyue_iam.iam_permission
|
||||
WHERE tenant_id = 0 AND app_code = 'PLATFORM' AND status = 'ACTIVE' AND deleted = 0
|
||||
AND permission_code IN (
|
||||
'platform:firstleg:container:list', 'platform:firstleg:container:view',
|
||||
'platform:firstleg:container:list',
|
||||
'platform:firstleg:container:create', 'platform:firstleg:container:edit',
|
||||
'platform:firstleg:smart-loading:list', 'platform:firstleg:smart-loading:assign',
|
||||
'platform:firstleg:smart-loading:delete', 'platform:firstleg:preload:list',
|
||||
'platform:firstleg:preload:view', 'platform:firstleg:preload:edit',
|
||||
'platform:firstleg:preload:edit',
|
||||
'platform:firstleg:preload:complete', 'platform:firstleg:preload:confirm',
|
||||
'platform:firstleg:loading-list:list', 'platform:firstleg:loading-list:view')) <> 14 THEN
|
||||
'platform:firstleg:loading-list:list')) <> 11 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:平台权限资源未完整创建';
|
||||
END IF;
|
||||
IF (SELECT COUNT(DISTINCT CONCAT(role.role_code, ':', permission.permission_code))
|
||||
@@ -6597,14 +6728,13 @@ BEGIN
|
||||
WHERE role.tenant_id = 0 AND role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR')
|
||||
AND permission.tenant_id = 0
|
||||
AND permission.permission_code IN (
|
||||
'platform:firstleg:container:list', 'platform:firstleg:container:view',
|
||||
'platform:firstleg:container:list',
|
||||
'platform:firstleg:container:create',
|
||||
'platform:firstleg:container:edit', 'platform:firstleg:smart-loading:list',
|
||||
'platform:firstleg:smart-loading:assign', 'platform:firstleg:smart-loading:delete',
|
||||
'platform:firstleg:preload:list', 'platform:firstleg:preload:view',
|
||||
'platform:firstleg:preload:list',
|
||||
'platform:firstleg:preload:edit', 'platform:firstleg:preload:complete',
|
||||
'platform:firstleg:preload:confirm', 'platform:firstleg:loading-list:list',
|
||||
'platform:firstleg:loading-list:view')) <> 28 THEN
|
||||
'platform:firstleg:preload:confirm', 'platform:firstleg:loading-list:list')) <> 22 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:平台角色授权未完整写入';
|
||||
END IF;
|
||||
END$$
|
||||
@@ -6754,8 +6884,8 @@ INSERT INTO tmp_wh_fixed_platform_warehouse
|
||||
VALUES
|
||||
(193000000000200001,193000000000300001,'CN-YIWU-01','义乌仓','DOMESTIC','CN','UTC+8','浙江省','义乌市','322000','义乌仓通用地址待维护'),
|
||||
(193000000000200002,193000000000300002,'CN-DONGGUAN-01','东莞仓','DOMESTIC','CN','UTC+8','广东省','东莞市','523000','东莞仓通用地址待维护'),
|
||||
(193000000000200003,193000000000300003,'US-LAX-01','洛杉矶仓','OVERSEAS','US','UTC-8','CA','Los Angeles','90001','Los Angeles warehouse generic address pending maintenance'),
|
||||
(193000000000200004,193000000000300004,'US-NYC-01','纽约仓','OVERSEAS','US','UTC-5','NY','New York','10001','New York warehouse generic address pending maintenance');
|
||||
(193000000000200003,193000000000300003,'US-LAX-01','洛杉矶仓','OVERSEAS','US','UTC-8','California','Rancho Cucamonga','91730','10767 7th Street'),
|
||||
(193000000000200004,193000000000300004,'US-NYC-01','纽约仓','OVERSEAS','US','UTC-5','New Jersey','Edison','08817','Door 13-14, 75 Carter Dr');
|
||||
|
||||
DROP PROCEDURE IF EXISTS assert_wh_fixed_platform_warehouse_seed;
|
||||
DELIMITER //
|
||||
@@ -6926,7 +7056,8 @@ ON DUPLICATE KEY UPDATE
|
||||
capacity_cbm = VALUES(capacity_cbm),
|
||||
weight_kg = VALUES(weight_kg),
|
||||
color = VALUES(color),
|
||||
remark = VALUES(remark);
|
||||
remark = VALUES(remark),
|
||||
deleted = 0;
|
||||
|
||||
DROP PROCEDURE IF EXISTS assert_domestic_pallet_seed;
|
||||
DELIMITER //
|
||||
@@ -6948,6 +7079,14 @@ BEGIN
|
||||
) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '国内作业仓木托盘、塑料托盘初始化不完整';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*)
|
||||
FROM wh_pallet
|
||||
WHERE tenant_id = 0
|
||||
AND deleted = 0
|
||||
AND ((warehouse_id = 193000000000200001 AND pallet_code IN ('TP-001','YW-TP-002'))
|
||||
OR (warehouse_id = 193000000000200002 AND pallet_code IN ('TP-002','DG-TP-002')))) <> 4 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '国内作业仓固定可复用托盘编码初始化不完整';
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
CALL assert_domestic_pallet_seed();
|
||||
@@ -13771,6 +13910,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_operatio
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_query_operation', 'status', '处理状态:PROCESSING处理中、SUCCESS成功');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'id', '标准船节点事实ID');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'query_id', '所属查询任务ID');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'container_booking_id', '所属真实货柜ID');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'event_key', '供应商编码与供应商事件ID组成的幂等键');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'provider_code', '轨迹供应商编码');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_carrier_schedule_node_fact', 'provider_event_id', '供应商稳定事件ID');
|
||||
@@ -14886,8 +15026,11 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_price_book_version', 'published
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_price_book_version', 'rate_count', '解析价格数');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_price_book_version', 'source_file_id', 'File服务原始文件ID');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_price_book_version', 'source_sha256', '原始文件SHA-256');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_price_book_version', 'status', '价格版本状态:PENDING_CONFIRMATION待确认、ACTIVE已生效、SUPERSEDED已被替代、FAILED解析失败');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_price_book_version', 'status', '价格版本状态:PENDING_CONFIRMATION待确认、ACTIVE已生效、SUPERSEDED已被替代、VOIDED已作废、FAILED解析失败');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_price_book_version', 'superseded_at', '被下一版本替换时间');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_price_book_version', 'voided_at', '作废时间');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_price_book_version', 'voided_by', '作废操作人ID');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_price_book_version', 'void_reason', '作废原因');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_price_book_version', 'tenant_id', '平台公共价格作用域固定为0');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_price_book_version', 'updated_at', '最后更新时间');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_price_book_version', 'updated_by', '最后修改人ID');
|
||||
@@ -15111,7 +15254,7 @@ CALL queue_column_comment('baiyue_iam', 'iam_tenant', 'reject_reason', '客户
|
||||
CALL queue_column_comment('baiyue_iam', 'iam_tenant', 'sales_owner_name', '销售负责人姓名');
|
||||
CALL queue_column_comment('baiyue_iam', 'iam_tenant', 'service_package', '客户服务套餐');
|
||||
CALL queue_column_comment('baiyue_iam', 'iam_tenant', 'status', '客户状态:PROFILE_REQUIRED待完善资料、PENDING待审核、ACTIVE正常、REJECTED已驳回、DISABLED已停用');
|
||||
CALL queue_column_comment('baiyue_iam', 'iam_tenant', 'tenant_code', '全局唯一客户编码');
|
||||
CALL queue_column_comment('baiyue_iam', 'iam_tenant', 'tenant_code', '客户编号:企业客户固定五位数字,平台技术主体为PLATFORM');
|
||||
CALL queue_column_comment('baiyue_iam', 'iam_tenant', 'tenant_name', '客户企业名称');
|
||||
CALL queue_column_comment('baiyue_iam', 'iam_tenant', 'tenant_type', '主体类型:MERCHANT企业客户');
|
||||
CALL queue_column_comment('baiyue_iam', 'iam_tenant', 'unified_social_credit_code', '统一社会信用代码');
|
||||
@@ -15136,6 +15279,7 @@ CALL queue_column_comment('baiyue_iam', 'iam_tenant_qualification', 'created_at'
|
||||
CALL queue_column_comment('baiyue_iam', 'iam_tenant_qualification', 'deleted', '逻辑删除标记');
|
||||
CALL queue_column_comment('baiyue_iam', 'iam_tenant_qualification', 'document_url', '资质文件存储地址');
|
||||
CALL queue_column_comment('baiyue_iam', 'iam_tenant_qualification', 'id', '资质记录ID');
|
||||
CALL queue_column_comment('baiyue_iam', 'iam_tenant_qualification', 'original_file_name', '上传时原始文件名');
|
||||
CALL queue_column_comment('baiyue_iam', 'iam_tenant_qualification', 'qualification_type', '客户资质类型');
|
||||
CALL queue_column_comment('baiyue_iam', 'iam_tenant_qualification', 'review_comment', '客户资质审核意见');
|
||||
CALL queue_column_comment('baiyue_iam', 'iam_tenant_qualification', 'status', '资质审核状态:PENDING待审核、APPROVED已通过、REJECTED已驳回');
|
||||
@@ -15686,6 +15830,12 @@ BEGIN
|
||||
'baiyue_firstleg','baiyue_file','baiyue_audit','baiyue_event_bridge','baiyue_finance'))<>9 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '统一迁移失败:九个业务数据库未完整创建';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema='baiyue_iam' AND table_name='iam_tenant_qualification'
|
||||
AND column_name='original_file_name') <> 1 THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = '统一迁移失败:IAM客户资质缺少原始文件名列';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema='baiyue_firstleg' AND table_name='fl_inbound_fee'
|
||||
AND ((column_name='baseline_chargeable_quantity' AND is_nullable='NO'
|
||||
@@ -15953,6 +16103,24 @@ BEGIN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = '统一迁移失败:客户四箱规尺寸配置字段不完整';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = 'baiyue_firstleg'
|
||||
AND table_name = 'fl_customer_charge_rule'
|
||||
AND column_name = 'initialization_key'
|
||||
AND character_maximum_length = 128) <> 1 THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = '统一迁移失败:客户默认计费规则初始化幂等字段未创建';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.statistics
|
||||
WHERE table_schema = 'baiyue_firstleg'
|
||||
AND table_name = 'fl_customer_charge_rule'
|
||||
AND index_name = 'idx_charge_rule_initialization'
|
||||
AND non_unique = 1
|
||||
AND ((seq_in_index = 1 AND column_name = 'tenant_id')
|
||||
OR (seq_in_index = 2 AND column_name = 'initialization_key'))) <> 2 THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = '统一迁移失败:客户默认计费规则初始化索引未完整创建';
|
||||
END IF;
|
||||
IF EXISTS (
|
||||
SELECT tenant.id
|
||||
FROM baiyue_iam.iam_tenant tenant
|
||||
@@ -16397,6 +16565,9 @@ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_firstleg' AND table_name='fl_container_booking' AND column_name='channel_code') THEN
|
||||
ALTER TABLE baiyue_firstleg.fl_container_booking ADD COLUMN channel_code VARCHAR(64) NULL COMMENT '订舱渠道编码快照' AFTER cabinet_no;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_firstleg' AND table_name='fl_container_booking' AND column_name='channel_group_code') THEN
|
||||
ALTER TABLE baiyue_firstleg.fl_container_booking ADD COLUMN channel_group_code VARCHAR(64) NULL COMMENT '模拟柜计划渠道分组;关联真实订舱后继续保留' AFTER merged_into_container_booking_id;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_firstleg' AND table_name='fl_container_booking' AND column_name='channel_name') THEN
|
||||
ALTER TABLE baiyue_firstleg.fl_container_booking ADD COLUMN channel_name VARCHAR(128) NULL COMMENT '订舱渠道名称快照' AFTER channel_code;
|
||||
END IF;
|
||||
@@ -16453,6 +16624,7 @@ BEGIN
|
||||
IF current_plan_status IN ('LOADING', 'COMPLETED') AND (
|
||||
NOT (OLD.id <=> NEW.id) OR NOT (OLD.booking_no <=> NEW.booking_no)
|
||||
OR NOT (OLD.cabinet_no <=> NEW.cabinet_no)
|
||||
OR NOT (OLD.channel_group_code <=> NEW.channel_group_code)
|
||||
OR NOT (OLD.channel_code <=> NEW.channel_code) OR NOT (OLD.channel_name <=> NEW.channel_name)
|
||||
OR NOT (OLD.ocean_booking_id <=> NEW.ocean_booking_id)
|
||||
OR NOT (OLD.ocean_booking_requirement_id <=> NEW.ocean_booking_requirement_id)
|
||||
@@ -18161,6 +18333,23 @@ CREATE TABLE IF NOT EXISTS fl_dropship_pallet_carton (
|
||||
CONSTRAINT fk_dropship_carton_pallet FOREIGN KEY (pallet_id) REFERENCES fl_dropship_pallet(id),
|
||||
CONSTRAINT fk_dropship_carton_label FOREIGN KEY (carton_label_id) REFERENCES fl_inbound_carton_label(id)
|
||||
) ENGINE=InnoDB COMMENT='一件代发托盘内逐箱SKU和入仓单明细';
|
||||
CREATE TABLE IF NOT EXISTS fl_dropship_pallet_carton_sku (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '清点箱SKU明细ID',
|
||||
carton_id BIGINT UNSIGNED NOT NULL COMMENT '清点物理箱ID',
|
||||
inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '入仓货物明细ID',
|
||||
sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码快照',
|
||||
cargo_name VARCHAR(255) NULL COMMENT '货物名称快照',
|
||||
expected_quantity INT UNSIGNED NOT NULL COMMENT '应收数量',
|
||||
actual_quantity INT UNSIGNED NOT NULL COMMENT '实收数量',
|
||||
discrepancy_status VARCHAR(24) NOT NULL COMMENT 'MATCH或ACCEPTED_VARIANCE',
|
||||
discrepancy_remark VARCHAR(500) NULL COMMENT '差异处理说明',
|
||||
line_no INT UNSIGNED NOT NULL COMMENT '箱内SKU顺序',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_dropship_carton_sku_detail (carton_id,inbound_detail_id),
|
||||
KEY idx_dropship_carton_sku_code (sku_code,carton_id),
|
||||
CONSTRAINT fk_dropship_carton_sku_carton FOREIGN KEY (carton_id) REFERENCES fl_dropship_pallet_carton(id)
|
||||
) ENGINE=InnoDB COMMENT='一件代发物理箱内逐SKU清点事实';
|
||||
|
||||
|
||||
-- 已存在开发库直接收敛到逐箱清点与整托分柜结构;新建库由上面的最新定义一次完成。
|
||||
DROP PROCEDURE IF EXISTS converge_dropship_palletization_schema;
|
||||
@@ -18404,12 +18593,13 @@ CREATE TABLE IF NOT EXISTS fin_wallet_account (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和隔离键',
|
||||
currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '当前仅CNY',
|
||||
actual_balance DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '账户实际余额,包含冻结资金',
|
||||
actual_balance DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '账户实际余额,授信消费可形成负余额',
|
||||
frozen_balance DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '冻结总额,包含实际资金与授信冻结',
|
||||
available_balance DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '总可用余额=实际余额+授信额度-已用授信-冻结总额',
|
||||
available_balance DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '当前总可用余额;授信失效后不再计入',
|
||||
credit_limit DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '授信额度',
|
||||
credit_used DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '已结算使用的授信',
|
||||
credit_used DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '授信有效期内累计已使用额度;到期后并入实际余额',
|
||||
credit_frozen DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '冻结总额中占用的授信',
|
||||
credit_expires_on DATE NULL COMMENT '授信到期日,当天仍有效;次日失效',
|
||||
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '余额乐观锁版本',
|
||||
created_by BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建操作人ID',
|
||||
updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '最后操作人ID',
|
||||
@@ -18419,7 +18609,7 @@ CREATE TABLE IF NOT EXISTS fin_wallet_account (
|
||||
UNIQUE KEY uk_fin_wallet_account_customer (customer_id, currency_code),
|
||||
CONSTRAINT chk_fin_wallet_account_customer CHECK (customer_id > 0),
|
||||
CONSTRAINT chk_fin_wallet_account_currency CHECK (currency_code = 'CNY'),
|
||||
CONSTRAINT chk_fin_wallet_account_balance CHECK (actual_balance >= 0 AND frozen_balance >= 0 AND available_balance >= 0 AND credit_limit >= 0 AND credit_used >= 0 AND credit_frozen >= 0 AND frozen_balance >= credit_frozen AND actual_balance >= frozen_balance-credit_frozen AND credit_limit >= credit_used+credit_frozen AND available_balance = actual_balance+credit_limit-credit_used-frozen_balance)
|
||||
CONSTRAINT chk_fin_wallet_account_balance CHECK (frozen_balance >= 0 AND credit_limit >= 0 AND credit_used >= 0 AND credit_frozen >= 0 AND frozen_balance >= credit_frozen)
|
||||
) ENGINE=InnoDB COMMENT='客户CNY资金账户主账';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fin_recharge_request (
|
||||
@@ -18461,6 +18651,7 @@ CREATE TABLE IF NOT EXISTS fin_wallet_ledger (
|
||||
credit_limit_after DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '变动后授信额度',
|
||||
credit_used_after DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '变动后已用授信',
|
||||
available_credit_after DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '变动后可用授信',
|
||||
credit_expires_on_after DATE NULL COMMENT '变动后授信到期日',
|
||||
source_type VARCHAR(32) NOT NULL COMMENT 'CLIENT_RECHARGE/MANUAL_RECHARGE/MANUAL_DEDUCTION等',
|
||||
source_no VARCHAR(128) NULL COMMENT '充值申请号或外部凭证号',
|
||||
occurred_date DATE NOT NULL COMMENT '业务发生日期',
|
||||
@@ -18475,8 +18666,8 @@ CREATE TABLE IF NOT EXISTS fin_wallet_ledger (
|
||||
KEY idx_fin_wallet_ledger_customer_time (customer_id, created_at, id),
|
||||
KEY idx_fin_wallet_ledger_source (customer_id, source_type, source_no),
|
||||
CONSTRAINT chk_fin_wallet_ledger_customer CHECK (customer_id > 0),
|
||||
CONSTRAINT chk_fin_wallet_ledger_amount CHECK (amount <> 0),
|
||||
CONSTRAINT chk_fin_wallet_ledger_balance CHECK (actual_balance_after >= 0 AND frozen_balance_after >= 0 AND available_balance_after >= 0 AND credit_limit_after >= 0 AND credit_used_after >= 0 AND available_credit_after >= 0 AND credit_limit_after >= credit_used_after+available_credit_after AND available_balance_after = actual_balance_after-frozen_balance_after+credit_limit_after-credit_used_after)
|
||||
CONSTRAINT chk_fin_wallet_ledger_amount CHECK (amount <> 0 OR change_type='CREDIT_LIMIT'),
|
||||
CONSTRAINT chk_fin_wallet_ledger_balance CHECK (frozen_balance_after >= 0 AND credit_limit_after >= 0 AND credit_used_after >= 0 AND available_credit_after >= 0)
|
||||
) ENGINE=InnoDB COMMENT='客户资金账户不可变余额流水';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fin_wallet_hold (
|
||||
@@ -18506,19 +18697,42 @@ DROP PROCEDURE IF EXISTS migrate_fin_wallet_credit;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE migrate_fin_wallet_credit()
|
||||
BEGIN
|
||||
DECLARE v_legacy_wallet_constraint BOOLEAN DEFAULT FALSE;
|
||||
DECLARE v_credit_expiry_missing BOOLEAN DEFAULT FALSE;
|
||||
SET v_legacy_wallet_constraint = EXISTS (
|
||||
SELECT 1 FROM information_schema.CHECK_CONSTRAINTS
|
||||
WHERE CONSTRAINT_SCHEMA='baiyue_finance'
|
||||
AND CONSTRAINT_NAME='chk_fin_wallet_account_balance'
|
||||
AND CHECK_CLAUSE LIKE '%actual_balance%'
|
||||
);
|
||||
SET v_credit_expiry_missing = NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA='baiyue_finance'
|
||||
AND TABLE_NAME='fin_wallet_account'
|
||||
AND COLUMN_NAME='credit_expires_on'
|
||||
);
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA='baiyue_finance' AND TABLE_NAME='fin_wallet_account' AND COLUMN_NAME='credit_limit') THEN
|
||||
ALTER TABLE fin_wallet_account
|
||||
ADD COLUMN credit_limit DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '授信额度' AFTER available_balance,
|
||||
ADD COLUMN credit_used DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '已结算使用的授信' AFTER credit_limit,
|
||||
ADD COLUMN credit_frozen DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '冻结总额中占用的授信' AFTER credit_used;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA='baiyue_finance' AND TABLE_NAME='fin_wallet_account' AND COLUMN_NAME='credit_expires_on') THEN
|
||||
ALTER TABLE fin_wallet_account ADD COLUMN credit_expires_on DATE NULL COMMENT '授信到期日,当天仍有效;次日失效' AFTER credit_frozen;
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM information_schema.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA='baiyue_finance' AND TABLE_NAME='fin_wallet_account' AND CONSTRAINT_NAME='chk_fin_wallet_account_balance' AND CONSTRAINT_TYPE='CHECK') THEN
|
||||
ALTER TABLE fin_wallet_account DROP CHECK chk_fin_wallet_account_balance;
|
||||
END IF;
|
||||
UPDATE fin_wallet_account
|
||||
SET available_balance=actual_balance+credit_limit-credit_used-frozen_balance;
|
||||
IF v_legacy_wallet_constraint OR v_credit_expiry_missing THEN
|
||||
UPDATE fin_wallet_account
|
||||
SET actual_balance=actual_balance-credit_used,
|
||||
credit_used=0,
|
||||
credit_expires_on=NULL;
|
||||
UPDATE fin_wallet_account
|
||||
SET available_balance=actual_balance-credit_used-frozen_balance;
|
||||
END IF;
|
||||
ALTER TABLE fin_wallet_account ADD CONSTRAINT chk_fin_wallet_account_balance
|
||||
CHECK (actual_balance >= 0 AND frozen_balance >= 0 AND available_balance >= 0 AND credit_limit >= 0 AND credit_used >= 0 AND credit_frozen >= 0 AND frozen_balance >= credit_frozen AND actual_balance >= frozen_balance-credit_frozen AND credit_limit >= credit_used+credit_frozen AND available_balance = actual_balance+credit_limit-credit_used-frozen_balance);
|
||||
CHECK (frozen_balance >= 0 AND credit_limit >= 0 AND credit_used >= 0 AND credit_frozen >= 0 AND frozen_balance >= credit_frozen);
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA='baiyue_finance' AND TABLE_NAME='fin_wallet_ledger' AND COLUMN_NAME='credit_limit_after') THEN
|
||||
ALTER TABLE fin_wallet_ledger
|
||||
@@ -18526,11 +18740,19 @@ BEGIN
|
||||
ADD COLUMN credit_used_after DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '变动后已用授信' AFTER credit_limit_after,
|
||||
ADD COLUMN available_credit_after DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '变动后可用授信' AFTER credit_used_after;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA='baiyue_finance' AND TABLE_NAME='fin_wallet_ledger' AND COLUMN_NAME='credit_expires_on_after') THEN
|
||||
ALTER TABLE fin_wallet_ledger ADD COLUMN credit_expires_on_after DATE NULL COMMENT '变动后授信到期日' AFTER available_credit_after;
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM information_schema.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA='baiyue_finance' AND TABLE_NAME='fin_wallet_ledger' AND CONSTRAINT_NAME='chk_fin_wallet_ledger_balance' AND CONSTRAINT_TYPE='CHECK') THEN
|
||||
ALTER TABLE fin_wallet_ledger DROP CHECK chk_fin_wallet_ledger_balance;
|
||||
END IF;
|
||||
ALTER TABLE fin_wallet_ledger ADD CONSTRAINT chk_fin_wallet_ledger_balance
|
||||
CHECK (actual_balance_after >= 0 AND frozen_balance_after >= 0 AND available_balance_after >= 0 AND credit_limit_after >= 0 AND credit_used_after >= 0 AND available_credit_after >= 0 AND credit_limit_after >= credit_used_after+available_credit_after AND available_balance_after = actual_balance_after-frozen_balance_after+credit_limit_after-credit_used_after);
|
||||
CHECK (frozen_balance_after >= 0 AND credit_limit_after >= 0 AND credit_used_after >= 0 AND available_credit_after >= 0);
|
||||
IF EXISTS (SELECT 1 FROM information_schema.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA='baiyue_finance' AND TABLE_NAME='fin_wallet_ledger' AND CONSTRAINT_NAME='chk_fin_wallet_ledger_amount' AND CONSTRAINT_TYPE='CHECK') THEN
|
||||
ALTER TABLE fin_wallet_ledger DROP CHECK chk_fin_wallet_ledger_amount;
|
||||
END IF;
|
||||
ALTER TABLE fin_wallet_ledger ADD CONSTRAINT chk_fin_wallet_ledger_amount
|
||||
CHECK (amount <> 0 OR change_type='CREDIT_LIMIT');
|
||||
END$$
|
||||
DELIMITER ;
|
||||
CALL migrate_fin_wallet_credit();
|
||||
@@ -19238,6 +19460,22 @@ PREPARE statement FROM @ddl; EXECUTE statement; DEALLOCATE PREPARE statement;
|
||||
ALTER TABLE fl_carrier_schedule_query
|
||||
ADD CONSTRAINT chk_carrier_schedule_query_customer CHECK (customer_id >= 0);
|
||||
|
||||
-- 退货预报关联尾程订单业务编号(测试阶段直接收敛当前结构)
|
||||
DROP PROCEDURE IF EXISTS baiyue_lastleg.converge_return_order_tail_reference;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE baiyue_lastleg.converge_return_order_tail_reference()
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_lastleg' AND table_name='ll_return_order' AND column_name='lastleg_order_id') THEN
|
||||
ALTER TABLE baiyue_lastleg.ll_return_order ADD COLUMN lastleg_order_id BIGINT NULL COMMENT '尾程订单ID' AFTER customer_reference_no;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_lastleg' AND table_name='ll_return_order' AND column_name='lastleg_order_no') THEN
|
||||
ALTER TABLE baiyue_lastleg.ll_return_order ADD COLUMN lastleg_order_no VARCHAR(32) NULL COMMENT '尾程订单号' AFTER lastleg_order_id;
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
CALL baiyue_lastleg.converge_return_order_tail_reference();
|
||||
DROP PROCEDURE IF EXISTS baiyue_lastleg.converge_return_order_tail_reference;
|
||||
|
||||
CALL baiyue_firstleg.assert_unified_final_schema();
|
||||
DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema;
|
||||
SELECT 'PASS' AS migration_status,
|
||||
|
||||
+265
-20
@@ -753,8 +753,9 @@ CREATE TABLE IF NOT EXISTS ll_inbound_plan_pallet (
|
||||
capacity_cbm DECIMAL(18,4) NOT NULL DEFAULT 2.0000 COMMENT '托盘容量,固定为2立方米',
|
||||
allocated_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '已分配体积(立方米)',
|
||||
sequence_no INT NOT NULL COMMENT '计划内托盘序号',
|
||||
version INT NOT NULL DEFAULT 0 COMMENT '收货累计值乐观锁版本',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_ll_inbound_pallet_no (pallet_no),
|
||||
KEY idx_ll_inbound_pallet_no (pallet_no),
|
||||
UNIQUE KEY uk_ll_inbound_pallet_sequence (plan_id, sequence_no),
|
||||
KEY idx_ll_inbound_pallet_plan (plan_id),
|
||||
KEY idx_ll_inbound_pallet_customer_plan (customer_id, plan_id)
|
||||
@@ -858,6 +859,8 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
|
||||
PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='actual_package_count'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN actual_package_count INT NOT NULL DEFAULT 0 COMMENT ''实收箱数'''));
|
||||
PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet' AND column_name='version'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet ADD COLUMN version INT NOT NULL DEFAULT 0 COMMENT ''收货累计值乐观锁版本'''));
|
||||
PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='planned_package_count'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN planned_package_count INT NOT NULL DEFAULT 0 COMMENT ''计划分配箱数'''));
|
||||
PREPARE inbound_schema_stmt FROM @ddl; EXECUTE inbound_schema_stmt; DEALLOCATE PREPARE inbound_schema_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_plan_pallet_allocation' AND column_name='counted_package_count'), 'SELECT 1', 'ALTER TABLE ll_inbound_plan_pallet_allocation ADD COLUMN counted_package_count INT NOT NULL DEFAULT 0 COMMENT ''清点箱数'''));
|
||||
@@ -1089,7 +1092,7 @@ CREATE TABLE IF NOT EXISTS ll_receiving_container_session (
|
||||
source_container_booking_id VARCHAR(128) NOT NULL COMMENT '稳定来源柜预订ID', container_fact_hash VARCHAR(64) NOT NULL COMMENT '领取时整柜订单与计划事实摘要',
|
||||
container_no VARCHAR(64) NOT NULL COMMENT '集装箱号', overseas_warehouse_id BIGINT NOT NULL COMMENT '平台预分配并由操作员Token匹配的海外接柜仓ID', overseas_warehouse_code VARCHAR(128) NOT NULL COMMENT '海外接柜仓业务编码快照', overseas_warehouse_name VARCHAR(255) NOT NULL COMMENT '仓库服务校验后的海外接柜仓名称快照', operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识',
|
||||
operator_name VARCHAR(255) NULL COMMENT '操作账号名称', active_operator_key VARCHAR(255) NULL COMMENT '未完成会话平台操作账号全局唯一锁键',
|
||||
active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号全局唯一锁键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_SIGN' COMMENT '会话状态:WAIT_SIGN、SIGNED、RECEIVING、COMPLETED',
|
||||
active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号全局唯一锁键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_SIGN' COMMENT '会话状态:WAIT_SIGN、SIGNED、RECEIVING、COMPLETED、COMPLETED_WITH_DIFFERENCE',
|
||||
sign_photos JSON NULL COMMENT '签收卸柜照片角色与文件ID列表', prepared_at DATETIME(3) NULL COMMENT '选择柜号时间',
|
||||
signed_at DATETIME(3) NULL COMMENT '签收时间', receiving_at DATETIME(3) NULL COMMENT '首次收货时间',
|
||||
completed_at DATETIME(3) NULL COMMENT '完成时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
@@ -1112,6 +1115,16 @@ CREATE TABLE IF NOT EXISTS ll_receiving_sku_receipt (
|
||||
KEY idx_ll_receiving_sku_plan (customer_id,plan_id,line_id)
|
||||
) ENGINE=InnoDB COMMENT='海外PDA柜号FBA SKU实收进度';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_receiving_fba_pallet_count (
|
||||
id BIGINT NOT NULL COMMENT 'FBA托盘现场累计箱数ID', session_id BIGINT NOT NULL COMMENT '柜号签收会话ID',
|
||||
group_key VARCHAR(255) NOT NULL COMMENT '柜号与FBA仓组成的收货组键', pallet_code VARCHAR(128) NOT NULL COMMENT '现有FBA托盘码',
|
||||
actual_packages INT NOT NULL COMMENT '该托现场累计实物箱数', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_receiving_fba_count(session_id,group_key,pallet_code),
|
||||
KEY idx_ll_receiving_fba_group(session_id,group_key)
|
||||
) ENGINE=InnoDB COMMENT='海外PDA普通FBA托盘现场累计与超收标记';
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_receiving_container_operation (
|
||||
id BIGINT NOT NULL COMMENT '柜号收货操作ID', session_id BIGINT NOT NULL COMMENT '柜号签收会话ID',
|
||||
operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', action VARCHAR(32) NOT NULL COMMENT '操作动作', idempotency_key VARCHAR(255) NOT NULL COMMENT '客户端幂等键',
|
||||
@@ -1387,6 +1400,9 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_item (
|
||||
package_count INT NOT NULL DEFAULT 0 COMMENT '箱数',
|
||||
volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '体积(立方米)',
|
||||
source_stage VARCHAR(32) NOT NULL COMMENT '数据来源阶段',
|
||||
carton_marks VARCHAR(4000) NULL COMMENT '换标箱唛映射快照',
|
||||
source_business_type VARCHAR(32) NULL COMMENT '来源业务类型',
|
||||
source_business_no VARCHAR(128) NULL COMMENT '来源业务单号',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_ll_dispatch_item_source (dispatch_order_id,plan_pallet_id,warehouse_pallet_id),
|
||||
@@ -1395,6 +1411,10 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_item (
|
||||
KEY idx_ll_dispatch_item_pallet (customer_id,plan_pallet_id,warehouse_pallet_id)
|
||||
) ENGINE=InnoDB COMMENT='派车货物冻结明细';
|
||||
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='carton_marks'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN carton_marks VARCHAR(4000) NULL COMMENT ''换标箱唛映射快照'' AFTER source_stage')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='source_business_type'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN source_business_type VARCHAR(32) NULL COMMENT ''来源业务类型'' AFTER carton_marks')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='source_business_no'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN source_business_no VARCHAR(128) NULL COMMENT ''来源业务单号'' AFTER source_business_type')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_dispatch_pallet_lock (
|
||||
id BIGINT NOT NULL COMMENT '主键ID',
|
||||
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
|
||||
@@ -1523,6 +1543,23 @@ CREATE TABLE IF NOT EXISTS ll_outbound_pda_task (
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_outbound_pda_task_order(customer_id,outbound_order_id), UNIQUE KEY uk_ll_outbound_pda_task_no(task_no),
|
||||
UNIQUE KEY uk_ll_outbound_pda_complete_key(customer_id,completion_idempotency_key)
|
||||
) ENGINE=InnoDB COMMENT='一件代发出库PDA拣货任务';
|
||||
SET @ddl = (SELECT IF(EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema=DATABASE() AND table_name='ll_outbound_pda_task' AND column_name='completion_idempotency_key'
|
||||
), 'SELECT 1',
|
||||
'ALTER TABLE ll_outbound_pda_task ADD COLUMN completion_idempotency_key VARCHAR(255) NULL COMMENT ''完成命令幂等键'' AFTER status'));
|
||||
PREPARE outbound_pda_completion_column_stmt FROM @ddl; EXECUTE outbound_pda_completion_column_stmt; DEALLOCATE PREPARE outbound_pda_completion_column_stmt;
|
||||
SET @index_ready = (SELECT COUNT(*)=2 AND MIN(non_unique)=0
|
||||
AND SUM(seq_in_index=1 AND column_name='customer_id')=1
|
||||
AND SUM(seq_in_index=2 AND column_name='completion_idempotency_key')=1
|
||||
FROM information_schema.statistics WHERE table_schema=DATABASE()
|
||||
AND table_name='ll_outbound_pda_task' AND index_name='uk_ll_outbound_pda_complete_key');
|
||||
SET @index_exists = (SELECT COUNT(*)>0 FROM information_schema.statistics WHERE table_schema=DATABASE()
|
||||
AND table_name='ll_outbound_pda_task' AND index_name='uk_ll_outbound_pda_complete_key');
|
||||
SET @ddl = IF(@index_ready, 'SELECT 1', IF(@index_exists,
|
||||
'ALTER TABLE ll_outbound_pda_task DROP INDEX uk_ll_outbound_pda_complete_key, ADD UNIQUE KEY uk_ll_outbound_pda_complete_key(customer_id,completion_idempotency_key)',
|
||||
'ALTER TABLE ll_outbound_pda_task ADD UNIQUE KEY uk_ll_outbound_pda_complete_key(customer_id,completion_idempotency_key)'));
|
||||
PREPARE outbound_pda_completion_index_stmt FROM @ddl; EXECUTE outbound_pda_completion_index_stmt; DEALLOCATE PREPARE outbound_pda_completion_index_stmt;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_outbound_pda_pick (
|
||||
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', task_id BIGINT NOT NULL COMMENT 'PDA任务ID', outbound_pallet_item_id BIGINT NOT NULL COMMENT '出库托盘明细ID',
|
||||
@@ -1550,9 +1587,9 @@ CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_order (
|
||||
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
|
||||
transfer_no VARCHAR(64) NOT NULL COMMENT '转仓单号', inbound_order_id BIGINT NOT NULL COMMENT '入仓单ID', inbound_code VARCHAR(128) NOT NULL COMMENT '入仓单号快照',
|
||||
lastleg_order_id BIGINT NOT NULL COMMENT '尾程订单ID', lastleg_order_no VARCHAR(32) NOT NULL COMMENT '尾程订单号',
|
||||
operating_warehouse_id VARCHAR(128) NOT NULL COMMENT '作业仓库ID', fba_warehouse_code VARCHAR(128) NOT NULL COMMENT 'FBA仓库编码',
|
||||
delivery_address VARCHAR(1000) NOT NULL COMMENT '派送地址', package_count INT NOT NULL COMMENT '箱数', delivery_method VARCHAR(64) NOT NULL COMMENT '派送方式',
|
||||
estimated_arrival_at DATETIME(3) NOT NULL COMMENT '预计到达时间', fba_shipment_no VARCHAR(128) NOT NULL COMMENT 'FBA货件编号',
|
||||
operating_warehouse_id VARCHAR(128) NOT NULL COMMENT '入库中转仓ID', fba_warehouse_code VARCHAR(128) NULL COMMENT '可选FBA目的仓编码',
|
||||
delivery_address VARCHAR(1000) NOT NULL COMMENT '提货地址或中转仓收货地址', package_count INT NOT NULL COMMENT '箱数', delivery_method VARCHAR(64) NOT NULL COMMENT '入仓方式',
|
||||
estimated_arrival_at DATETIME(3) NOT NULL COMMENT '预计入仓时间', fba_shipment_no VARCHAR(128) NULL COMMENT '可选FBA货件编号',
|
||||
tracking_no VARCHAR(255) NULL COMMENT '物流跟踪号', fba_box_nos VARCHAR(2000) NULL COMMENT 'FBA箱号集合', remark VARCHAR(1000) NULL COMMENT '备注',
|
||||
need_relabel TINYINT NOT NULL DEFAULT 0 COMMENT '是否需要换标', relabel_application_id BIGINT NULL COMMENT '换标申请单ID', relabel_no VARCHAR(64) NULL COMMENT '换标申请单号',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '业务状态', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键',
|
||||
@@ -1570,6 +1607,9 @@ CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_order (
|
||||
KEY idx_ll_wh_transfer_list (customer_id,status,created_at), KEY idx_ll_wh_transfer_inbound (customer_id,inbound_code)
|
||||
) ENGINE=InnoDB COMMENT='客户转仓单';
|
||||
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='fba_warehouse_code' AND is_nullable='NO'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT ''可选FBA目的仓编码'', MODIFY COLUMN fba_shipment_no VARCHAR(128) NULL COMMENT ''可选FBA货件编号'', MODIFY COLUMN delivery_address VARCHAR(1000) NOT NULL COMMENT ''提货地址或中转仓收货地址'', MODIFY COLUMN delivery_method VARCHAR(64) NOT NULL COMMENT ''入仓方式'', MODIFY COLUMN estimated_arrival_at DATETIME(3) NOT NULL COMMENT ''预计入仓时间''', 'SELECT 1'));
|
||||
PREPARE transfer_optional_fba_stmt FROM @ddl; EXECUTE transfer_optional_fba_stmt; DEALLOCATE PREPARE transfer_optional_fba_stmt;
|
||||
|
||||
-- 兼容已存在的旧版转仓单表:CREATE TABLE IF NOT EXISTS 不会补充新增字段。
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='allocation_idempotency_key'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN allocation_idempotency_key VARCHAR(255) NULL COMMENT ''库存分配幂等键'' AFTER status'));
|
||||
PREPARE transfer_allocation_key_stmt FROM @ddl; EXECUTE transfer_allocation_key_stmt; DEALLOCATE PREPARE transfer_allocation_key_stmt;
|
||||
@@ -1599,6 +1639,18 @@ CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_item (
|
||||
KEY idx_ll_wh_transfer_item_list (customer_id,transfer_order_id), KEY idx_ll_wh_transfer_item_sku (customer_id,sku_code)
|
||||
) ENGINE=InnoDB COMMENT='转仓货物冻结明细';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_carton (
|
||||
id BIGINT NOT NULL COMMENT '预报箱主键', transfer_order_id BIGINT NOT NULL COMMENT '转运单ID',
|
||||
transfer_item_id BIGINT NOT NULL COMMENT '转运申报明细ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
|
||||
carton_mark VARCHAR(255) NOT NULL COMMENT '系统生成的一箱一码箱唛', carton_index INT NOT NULL COMMENT '全单箱序号',
|
||||
total_cartons INT NOT NULL COMMENT '首次生成时全单总箱数', status VARCHAR(32) NOT NULL DEFAULT 'EXPECTED' COMMENT '箱状态:EXPECTED待实收',
|
||||
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_ll_wh_transfer_carton_mark (customer_id,carton_mark),
|
||||
UNIQUE KEY uk_ll_wh_transfer_carton_index (transfer_order_id,carton_index),
|
||||
KEY idx_ll_wh_transfer_carton_list (customer_id,transfer_order_id,status)
|
||||
) ENGINE=InnoDB COMMENT='仓储转运系统箱唛预报';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_inventory_allocation (
|
||||
id BIGINT NOT NULL COMMENT '雪花主键',
|
||||
transfer_order_id BIGINT NOT NULL COMMENT '转仓单ID',
|
||||
@@ -1656,6 +1708,15 @@ CREATE TABLE IF NOT EXISTS ll_relabel_application (
|
||||
label_deadline DATETIME(3) NOT NULL COMMENT '换标截止时间',
|
||||
contact_name VARCHAR(128) NOT NULL COMMENT '联系人姓名',
|
||||
contact_phone VARCHAR(64) NOT NULL COMMENT '联系人电话',
|
||||
delivery_method VARCHAR(32) NULL COMMENT '派送方式:TRUCK卡车、PICKUP自提、EXPRESS快递;仅涉及箱唛换标时必填',
|
||||
print_shipping_label TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否需要打印面单',
|
||||
shipping_label_file_id VARCHAR(128) NULL COMMENT '客户上传面单文件ID',
|
||||
shipping_label_file_name VARCHAR(255) NULL COMMENT '客户上传面单文件名',
|
||||
shipping_label_file_url VARCHAR(1000) NULL COMMENT '客户上传面单下载地址',
|
||||
fulfillment_status VARCHAR(32) NOT NULL DEFAULT 'WAIT_RELABEL' COMMENT '换标后履约状态',
|
||||
outbound_order_no VARCHAR(64) NULL COMMENT '自提或快递生成的换标出库单号',
|
||||
customer_confirmation_key VARCHAR(255) NULL COMMENT '客户确认完成幂等键',
|
||||
customer_confirmed_at DATETIME(3) NULL COMMENT '客户确认换标完成时间',
|
||||
remark VARCHAR(1000) NULL COMMENT '备注',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'PENDING_PROCESSING' COMMENT '业务状态',
|
||||
item_count INT NOT NULL DEFAULT 0 COMMENT '明细数量',
|
||||
@@ -1681,6 +1742,19 @@ CREATE TABLE IF NOT EXISTS ll_relabel_application (
|
||||
KEY idx_ll_relabel_application_warehouse (customer_id,overseas_warehouse_id)
|
||||
) ENGINE=InnoDB COMMENT='客户换标申请单';
|
||||
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='total_file_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN total_file_id VARCHAR(128) NULL COMMENT ''总换标PDF文件ID'' AFTER remark')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='total_file_name'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN total_file_name VARCHAR(255) NULL COMMENT ''总换标PDF文件名'' AFTER total_file_id')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='total_file_url'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN total_file_url VARCHAR(1000) NULL COMMENT ''总换标PDF预览地址'' AFTER total_file_name')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='delivery_method'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN delivery_method VARCHAR(32) NULL COMMENT ''派送方式:TRUCK卡车、PICKUP自提、EXPRESS快递;仅涉及箱唛换标时必填'' AFTER contact_phone')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='print_shipping_label'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN print_shipping_label TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否需要打印面单'' AFTER delivery_method')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='shipping_label_file_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN shipping_label_file_id VARCHAR(128) NULL COMMENT ''客户上传面单文件ID'' AFTER print_shipping_label')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='shipping_label_file_name'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN shipping_label_file_name VARCHAR(255) NULL COMMENT ''客户上传面单文件名'' AFTER shipping_label_file_id')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='shipping_label_file_url'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN shipping_label_file_url VARCHAR(1000) NULL COMMENT ''客户上传面单下载地址'' AFTER shipping_label_file_name')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='fulfillment_status'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN fulfillment_status VARCHAR(32) NOT NULL DEFAULT ''WAIT_RELABEL'' COMMENT ''换标后履约状态'' AFTER shipping_label_file_url')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='outbound_order_no'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN outbound_order_no VARCHAR(64) NULL COMMENT ''自提或快递生成的换标出库单号'' AFTER fulfillment_status')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='customer_confirmation_key'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN customer_confirmation_key VARCHAR(255) NULL COMMENT ''客户确认完成幂等键'' AFTER fulfillment_status')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_application' AND column_name='customer_confirmed_at'), 'SELECT 1', 'ALTER TABLE ll_relabel_application ADD COLUMN customer_confirmed_at DATETIME(3) NULL COMMENT ''客户确认换标完成时间'' AFTER customer_confirmation_key')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_relabel_item (
|
||||
id BIGINT NOT NULL COMMENT '主键ID',
|
||||
application_id BIGINT NOT NULL COMMENT '申请单ID',
|
||||
@@ -1692,7 +1766,7 @@ CREATE TABLE IF NOT EXISTS ll_relabel_item (
|
||||
product_name_zh VARCHAR(255) NOT NULL COMMENT '产品中文名称',
|
||||
total_package_count INT NOT NULL COMMENT '总箱数',
|
||||
notified_package_count INT NOT NULL COMMENT '已通知箱数',
|
||||
shipment_no VARCHAR(128) NOT NULL COMMENT '货件编号',
|
||||
shipment_no VARCHAR(128) NULL COMMENT '货件编号(无FBA去向时为空)',
|
||||
tracking_code VARCHAR(255) NULL COMMENT '物流跟踪编码',
|
||||
new_sku_code VARCHAR(128) NOT NULL COMMENT '换标后SKU编码',
|
||||
label_requirement VARCHAR(1000) NULL COMMENT '换标要求',
|
||||
@@ -1703,6 +1777,17 @@ CREATE TABLE IF NOT EXISTS ll_relabel_item (
|
||||
carton_mark VARCHAR(255) NULL COMMENT '客户自有箱唛',
|
||||
fba_carton_mark VARCHAR(255) NULL COMMENT '关联FBA箱唛',
|
||||
destination_warehouse_id VARCHAR(128) NULL COMMENT '该箱目的海外仓ID',
|
||||
destination_fba_warehouse_code VARCHAR(128) NULL COMMENT '可选FBA目的仓编码(与海外操作仓分离)',
|
||||
operation_type VARCHAR(32) NOT NULL DEFAULT 'CARTON_LABEL' COMMENT '换标模式:箱唛、SKU或组合换标',
|
||||
sku_relabel_items_json JSON NULL COMMENT '同箱SKU换标子明细快照(数量按件)',
|
||||
source_inventory_balance_id BIGINT NULL COMMENT '原库存余额ID(预入仓换标任务可为空)',
|
||||
source_inventory_position_id BIGINT NULL COMMENT '原库存库位ID(预入仓换标任务可为空)',
|
||||
source_inbound_order_id BIGINT NULL COMMENT '原入仓单ID(预入仓换标任务可为空)',
|
||||
source_inbound_order_no VARCHAR(128) NULL COMMENT '原入仓单号快照',
|
||||
source_inbound_code VARCHAR(128) NULL COMMENT '原进仓编码快照',
|
||||
source_warehouse_id VARCHAR(128) NOT NULL COMMENT '原海外中转仓ID',
|
||||
source_warehouse_code VARCHAR(128) NULL COMMENT '原海外中转仓编码快照',
|
||||
source_warehouse_name VARCHAR(255) NULL COMMENT '原海外中转仓名称快照',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_ll_relabel_item_application (customer_id,application_id),
|
||||
@@ -1710,6 +1795,13 @@ CREATE TABLE IF NOT EXISTS ll_relabel_item (
|
||||
KEY idx_ll_relabel_item_destination (customer_id,destination_warehouse_id,application_id)
|
||||
) ENGINE=InnoDB COMMENT='换标货物冻结明细';
|
||||
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='destination_fba_warehouse_code'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN destination_fba_warehouse_code VARCHAR(128) NULL COMMENT ''可选FBA目的仓编码(与海外操作仓分离)''')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='source_inventory_balance_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN source_inventory_balance_id BIGINT NULL COMMENT ''原库存余额ID(预入仓换标任务可为空)''')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='source_inventory_position_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN source_inventory_position_id BIGINT NULL COMMENT ''原库存库位ID(预入仓换标任务可为空)''')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='source_inbound_order_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN source_inbound_order_id BIGINT NULL COMMENT ''原入仓单ID(预入仓换标任务可为空)''')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name IN ('shipment_no','destination_fba_warehouse_code','source_inventory_balance_id','source_inventory_position_id','source_inbound_order_id') AND is_nullable='NO'), 'ALTER TABLE ll_relabel_item MODIFY COLUMN shipment_no VARCHAR(128) NULL COMMENT ''货件编号(无FBA去向时为空)'', MODIFY COLUMN destination_fba_warehouse_code VARCHAR(128) NULL COMMENT ''可选FBA目的仓编码(与海外操作仓分离)'', MODIFY COLUMN source_inventory_balance_id BIGINT NULL COMMENT ''原库存余额ID(预入仓换标任务可为空)'', MODIFY COLUMN source_inventory_position_id BIGINT NULL COMMENT ''原库存库位ID(预入仓换标任务可为空)'', MODIFY COLUMN source_inbound_order_id BIGINT NULL COMMENT ''原入仓单ID(预入仓换标任务可为空)''', 'SELECT 1'));
|
||||
PREPARE relabel_pre_inbound_stmt FROM @ddl; EXECUTE relabel_pre_inbound_stmt; DEALLOCATE PREPARE relabel_pre_inbound_stmt;
|
||||
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='remark'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN remark VARCHAR(1000) NULL COMMENT ''货物明细备注'' AFTER label_requirement')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='carton_mark'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN carton_mark VARCHAR(255) NULL COMMENT ''客户自有箱唛'' AFTER relabel_file_url')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='fba_carton_mark'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN fba_carton_mark VARCHAR(255) NULL COMMENT ''关联FBA箱唛'' AFTER carton_mark')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
@@ -1817,11 +1909,14 @@ CREATE TABLE IF NOT EXISTS ll_dropship_order_profile (
|
||||
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_id BIGINT NOT NULL COMMENT '订单ID',
|
||||
warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID', pallet_no VARCHAR(128) NULL COMMENT '托盘号',
|
||||
source_pallet_no VARCHAR(128) NOT NULL COMMENT '来源托盘号快照',
|
||||
manifest_version INT NOT NULL COMMENT '整托清单版本', manifest_package_count INT NOT NULL COMMENT '整托总箱数',
|
||||
manifest_fragment_count INT NOT NULL COMMENT '整托客户分片总数', manifest_hash CHAR(64) NOT NULL COMMENT '整托完整清单摘要',
|
||||
inventory_position_id BIGINT NULL COMMENT '库存仓位记录ID', status VARCHAR(32) NOT NULL DEFAULT 'PENDING_INBOUND' COMMENT '业务状态',
|
||||
idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', version BIGINT 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_ll_dropship_profile_order_pallet(customer_id,order_id,source_pallet_no),
|
||||
UNIQUE KEY uk_ll_dropship_profile_key(customer_id,idempotency_key), KEY idx_ll_dropship_profile_order(customer_id,order_id)
|
||||
UNIQUE KEY uk_ll_dropship_profile_key(customer_id,idempotency_key), KEY idx_ll_dropship_profile_order(customer_id,order_id),
|
||||
KEY idx_ll_dropship_profile_manifest(source_pallet_no,manifest_hash)
|
||||
) ENGINE=InnoDB COMMENT='一件代发托盘档案';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_dropship_carton (
|
||||
@@ -2355,6 +2450,112 @@ CREATE TABLE IF NOT EXISTS ll_destuff_pallet_cost_allocation (
|
||||
CONSTRAINT chk_ll_destuff_allocation CHECK(actual_volume_cbm>0 AND allocated_amount>=0)
|
||||
) ENGINE=InnoDB COMMENT='拆柜打托成本按客户实收体积分摊';
|
||||
|
||||
-- 客户退货预报、PDA 签收与仓储费起算批次
|
||||
CREATE TABLE IF NOT EXISTS ll_return_order (
|
||||
-- need_relabel is added below for existing deployments
|
||||
id BIGINT NOT NULL COMMENT '退货订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', return_no VARCHAR(128) NOT NULL COMMENT '退货订单号', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '操作海外仓ID', warehouse_contact_name VARCHAR(128) NULL COMMENT '中转仓联系人快照', warehouse_contact_phone VARCHAR(64) NULL COMMENT '中转仓联系电话快照', customer_reference_no VARCHAR(128) NULL COMMENT '客户参考号', status VARCHAR(32) NOT NULL COMMENT 'PENDING_RECEIPT、PARTIALLY_RECEIVED或COMPLETED', expected_sku_types INT NOT NULL COMMENT '预报SKU种类数', expected_quantity BIGINT NOT NULL COMMENT '预报总数量', received_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '累计实收总数量', remark VARCHAR(1000) NULL COMMENT '客户备注', idempotency_key VARCHAR(255) NOT NULL COMMENT '创建幂等键', request_hash CHAR(64) NOT NULL COMMENT '创建请求SHA-256', created_by VARCHAR(128) NULL COMMENT '创建操作人ID', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_order_customer_no(customer_id,return_no), UNIQUE KEY uk_ll_return_order_customer_key(customer_id,idempotency_key), KEY idx_ll_return_order_warehouse_status(overseas_warehouse_id,status,created_at), CONSTRAINT chk_ll_return_order_quantity CHECK(expected_sku_types>0 AND expected_quantity>0 AND received_quantity>=0 AND received_quantity<=expected_quantity)
|
||||
) ENGINE=InnoDB COMMENT='客户退货订单及SKU实收汇总';
|
||||
SET @ll_return_order_need_relabel = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='need_relabel'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN need_relabel TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否需要换标'' AFTER customer_reference_no')); PREPARE stmt FROM @ll_return_order_need_relabel; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='warehouse_contact_name'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN warehouse_contact_name VARCHAR(128) NULL COMMENT ''中转仓联系人快照'' AFTER overseas_warehouse_id')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='warehouse_contact_phone'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN warehouse_contact_phone VARCHAR(64) NULL COMMENT ''中转仓联系电话快照'' AFTER warehouse_contact_name')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='relabel_application_id'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN relabel_application_id BIGINT NULL COMMENT ''绑定换标申请单ID'' AFTER need_relabel')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='relabel_no'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN relabel_no VARCHAR(64) NULL COMMENT ''绑定换标单号'' AFTER relabel_application_id')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='relabel_status'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN relabel_status VARCHAR(32) NULL COMMENT ''换标状态'' AFTER relabel_no')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
CREATE TABLE IF NOT EXISTS ll_return_order_item (
|
||||
id BIGINT NOT NULL COMMENT '退货预报明细ID', return_order_id BIGINT NOT NULL COMMENT '退货订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name_zh VARCHAR(255) NULL COMMENT '中文品名', expected_quantity BIGINT NOT NULL COMMENT 'SKU预报数量', received_quantity BIGINT NOT NULL DEFAULT 0 COMMENT 'SKU累计实收数量', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_item_order_sku(return_order_id,sku_code), KEY idx_ll_return_item_sku(sku_code,received_quantity), CONSTRAINT chk_ll_return_item_quantity CHECK(expected_quantity>0 AND received_quantity>=0 AND received_quantity<=expected_quantity)
|
||||
) ENGINE=InnoDB COMMENT='客户退货订单的SKU预报与实收明细';
|
||||
CREATE TABLE IF NOT EXISTS ll_return_receipt (
|
||||
id BIGINT NOT NULL COMMENT 'PDA退货签收单ID', return_order_id BIGINT NOT NULL COMMENT '退货订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', receipt_no VARCHAR(128) NOT NULL COMMENT '签收单号', receipt_batch_no VARCHAR(128) NOT NULL COMMENT '签收批次号', tracking_no VARCHAR(128) NULL COMMENT '本次签收包裹的可选快递单号', bound_by VARCHAR(16) NOT NULL COMMENT '开始签收方式,RETURN_NO退货单号或SKU候选', status VARCHAR(32) NOT NULL COMMENT '签收状态,当前SIGNED', received_quantity BIGINT NOT NULL COMMENT '本次实收数量', operator_id VARCHAR(128) NOT NULL COMMENT 'PDA操作人ID', operator_name VARCHAR(128) NULL COMMENT 'PDA操作人名称', idempotency_key VARCHAR(255) NOT NULL COMMENT 'PDA确认幂等键', request_hash CHAR(64) NOT NULL COMMENT 'PDA确认请求SHA-256', signed_at DATETIME(3) NOT NULL COMMENT '签收时间', created_at DATETIME(3) NOT NULL COMMENT '创建时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_receipt_no(receipt_no), UNIQUE KEY uk_ll_return_receipt_batch(receipt_batch_no), UNIQUE KEY uk_ll_return_receipt_operator_key(operator_id,idempotency_key), KEY idx_ll_return_receipt_order(return_order_id,signed_at), CONSTRAINT chk_ll_return_receipt_quantity CHECK(received_quantity>0), CONSTRAINT chk_ll_return_receipt_bound CHECK(bound_by IN('RETURN_NO','SKU'))
|
||||
) ENGINE=InnoDB COMMENT='PDA退货实收签收事实';
|
||||
|
||||
-- 已创建开发库升级为“客户只预报 SKU,快递单号仅属于签收批次”的最终模型。
|
||||
SET @ll_return_item_drop_tracking_order_sku = (SELECT IF(COUNT(*) > 0, 'ALTER TABLE ll_return_order_item DROP INDEX uk_ll_return_item_order_tracking_sku', 'SELECT 1') FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_return_order_item' AND index_name='uk_ll_return_item_order_tracking_sku');
|
||||
PREPARE ll_return_item_drop_tracking_order_sku_stmt FROM @ll_return_item_drop_tracking_order_sku;
|
||||
EXECUTE ll_return_item_drop_tracking_order_sku_stmt;
|
||||
DEALLOCATE PREPARE ll_return_item_drop_tracking_order_sku_stmt;
|
||||
SET @ll_return_item_drop_tracking = (SELECT IF(COUNT(*) > 0, 'ALTER TABLE ll_return_order_item DROP INDEX idx_ll_return_item_tracking', 'SELECT 1') FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_return_order_item' AND index_name='idx_ll_return_item_tracking');
|
||||
PREPARE ll_return_item_drop_tracking_stmt FROM @ll_return_item_drop_tracking;
|
||||
EXECUTE ll_return_item_drop_tracking_stmt;
|
||||
DEALLOCATE PREPARE ll_return_item_drop_tracking_stmt;
|
||||
SET @ll_return_item_drop_order = (SELECT IF(COUNT(*) > 0, 'ALTER TABLE ll_return_order_item DROP INDEX idx_ll_return_item_order', 'SELECT 1') FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_return_order_item' AND index_name='idx_ll_return_item_order');
|
||||
PREPARE ll_return_item_drop_order_stmt FROM @ll_return_item_drop_order;
|
||||
EXECUTE ll_return_item_drop_order_stmt;
|
||||
DEALLOCATE PREPARE ll_return_item_drop_order_stmt;
|
||||
SET @ll_return_item_drop_tracking_no = (SELECT IF(COUNT(*) > 0, 'ALTER TABLE ll_return_order_item DROP COLUMN tracking_no', 'SELECT 1') FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order_item' AND column_name='tracking_no');
|
||||
PREPARE ll_return_item_drop_tracking_no_stmt FROM @ll_return_item_drop_tracking_no;
|
||||
EXECUTE ll_return_item_drop_tracking_no_stmt;
|
||||
DEALLOCATE PREPARE ll_return_item_drop_tracking_no_stmt;
|
||||
SET @ll_return_item_add_order_sku = (SELECT IF(COUNT(*) = 0, 'ALTER TABLE ll_return_order_item ADD UNIQUE KEY uk_ll_return_item_order_sku(return_order_id,sku_code)', 'SELECT 1') FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_return_order_item' AND index_name='uk_ll_return_item_order_sku');
|
||||
PREPARE ll_return_item_add_order_sku_stmt FROM @ll_return_item_add_order_sku;
|
||||
EXECUTE ll_return_item_add_order_sku_stmt;
|
||||
DEALLOCATE PREPARE ll_return_item_add_order_sku_stmt;
|
||||
SET @ll_return_item_add_sku_idx = (SELECT IF(COUNT(*) = 0, 'ALTER TABLE ll_return_order_item ADD KEY idx_ll_return_item_sku(sku_code,received_quantity)', 'SELECT 1') FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_return_order_item' AND index_name='idx_ll_return_item_sku');
|
||||
PREPARE ll_return_item_add_sku_idx_stmt FROM @ll_return_item_add_sku_idx;
|
||||
EXECUTE ll_return_item_add_sku_idx_stmt;
|
||||
DEALLOCATE PREPARE ll_return_item_add_sku_idx_stmt;
|
||||
UPDATE ll_return_receipt SET bound_by='RETURN_NO' WHERE bound_by='TRACKING';
|
||||
SET @ll_return_receipt_drop_bound_check = (SELECT IF(COUNT(*) > 0, 'ALTER TABLE ll_return_receipt DROP CHECK chk_ll_return_receipt_bound', 'SELECT 1') FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_receipt' AND constraint_name='chk_ll_return_receipt_bound');
|
||||
PREPARE ll_return_receipt_drop_bound_check_stmt FROM @ll_return_receipt_drop_bound_check;
|
||||
EXECUTE ll_return_receipt_drop_bound_check_stmt;
|
||||
DEALLOCATE PREPARE ll_return_receipt_drop_bound_check_stmt;
|
||||
SET @ll_return_receipt_alter = 'ALTER TABLE ll_return_receipt MODIFY COLUMN tracking_no VARCHAR(128) NULL COMMENT ''本次签收包裹的可选快递单号'', MODIFY COLUMN bound_by VARCHAR(16) NOT NULL COMMENT ''开始签收方式,RETURN_NO退货单号或SKU候选'', ADD CONSTRAINT chk_ll_return_receipt_bound CHECK(bound_by IN(''RETURN_NO'',''SKU''))';
|
||||
PREPARE ll_return_receipt_alter_stmt FROM @ll_return_receipt_alter;
|
||||
EXECUTE ll_return_receipt_alter_stmt;
|
||||
DEALLOCATE PREPARE ll_return_receipt_alter_stmt;
|
||||
CREATE TABLE IF NOT EXISTS ll_return_receipt_item (
|
||||
id BIGINT NOT NULL COMMENT '签收SKU明细ID', receipt_id BIGINT NOT NULL COMMENT 'PDA退货签收单ID', return_order_item_id BIGINT NOT NULL COMMENT '退货预报明细ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', received_quantity BIGINT NOT NULL COMMENT '本次SKU实收数量',
|
||||
PRIMARY KEY(id), KEY idx_ll_return_receipt_item_receipt(receipt_id), KEY idx_ll_return_receipt_item_order_item(return_order_item_id), CONSTRAINT chk_ll_return_receipt_item_quantity CHECK(received_quantity>0)
|
||||
) ENGINE=InnoDB COMMENT='PDA退货签收SKU实际数量明细';
|
||||
CREATE TABLE IF NOT EXISTS ll_return_receipt_batch (
|
||||
id BIGINT NOT NULL COMMENT '退货仓储计费批次ID', return_order_id BIGINT NOT NULL COMMENT '退货订单ID', receipt_id BIGINT NOT NULL COMMENT 'PDA退货签收单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', batch_no VARCHAR(128) NOT NULL COMMENT '签收批次号', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '操作海外仓ID', storage_billing_status VARCHAR(32) NOT NULL COMMENT '仓储计费状态,初始PENDING_PRICING', storage_started_at DATETIME(3) NOT NULL COMMENT '仓储计费起算时间', created_at DATETIME(3) NOT NULL COMMENT '创建时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_batch_no(batch_no), UNIQUE KEY uk_ll_return_batch_receipt(receipt_id), KEY idx_ll_return_batch_billing(customer_id,overseas_warehouse_id,storage_billing_status,storage_started_at)
|
||||
) ENGINE=InnoDB COMMENT='每次退货签收生成的仓储费起算批次';
|
||||
|
||||
-- 退货库存:签收即生成箱号,库存按客户、海外仓和SKU汇总;箱内明细保留给合箱与箱唛。
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='carton_mark'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN carton_mark VARCHAR(128) NULL COMMENT ''签收生成或绑定的退货箱号'' AFTER receipt_batch_no'));
|
||||
PREPARE return_receipt_carton_mark_stmt FROM @ddl; EXECUTE return_receipt_carton_mark_stmt; DEALLOCATE PREPARE return_receipt_carton_mark_stmt;
|
||||
CREATE TABLE IF NOT EXISTS ll_return_inventory_carton (
|
||||
id BIGINT NOT NULL COMMENT '退货库存箱ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', source_receipt_id BIGINT NULL COMMENT '来源PDA签收单ID', source_batch_no VARCHAR(128) NULL COMMENT '来源签收批次号', carton_mark VARCHAR(128) NOT NULL COMMENT '退货箱号和箱唛编码', status VARCHAR(32) NOT NULL COMMENT 'AVAILABLE可用或MERGED已合箱', sku_types INT NOT NULL COMMENT '箱内SKU种类数', total_quantity BIGINT NOT NULL COMMENT '箱内总件数', length DECIMAL(12,3) NULL COMMENT '箱长CM', width DECIMAL(12,3) NULL COMMENT '箱宽CM', height DECIMAL(12,3) NULL COMMENT '箱高CM', weight DECIMAL(12,3) NULL COMMENT '箱重KG', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', merged_at DATETIME(3) NULL COMMENT '合箱完成时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_carton_mark(carton_mark), UNIQUE KEY uk_ll_return_inventory_carton_receipt(source_receipt_id), KEY idx_ll_return_inventory_carton_customer(customer_id,overseas_warehouse_id,status,created_at), CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','OUTBOUNDED') AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0)), CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','MERGED','OUTBOUNDED'))
|
||||
) ENGINE=InnoDB COMMENT='退货实物库存箱及箱唛';
|
||||
-- 已合箱的来源箱保留审计记录,但其箱内库存必须归零;动态 SQL 兼容 Spring 的数据库初始化器。
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_quantity'), 'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_quantity', 'SELECT 1'));
|
||||
PREPARE return_inventory_carton_check_drop_stmt FROM @ddl; EXECUTE return_inventory_carton_check_drop_stmt; DEALLOCATE PREPARE return_inventory_carton_check_drop_stmt;
|
||||
ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','OUTBOUNDED') AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0));
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_status'), 'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_status', 'SELECT 1'));
|
||||
PREPARE return_inventory_carton_status_drop_stmt FROM @ddl; EXECUTE return_inventory_carton_status_drop_stmt; DEALLOCATE PREPARE return_inventory_carton_status_drop_stmt;
|
||||
ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','MERGED','OUTBOUNDED'));
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='source_return_no'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN source_return_no VARCHAR(2000) NULL COMMENT ''来源退货单号'' AFTER source_batch_no'));
|
||||
PREPARE return_inventory_carton_return_no_stmt FROM @ddl; EXECUTE return_inventory_carton_return_no_stmt; DEALLOCATE PREPARE return_inventory_carton_return_no_stmt;
|
||||
ALTER TABLE ll_return_inventory_carton MODIFY COLUMN source_batch_no TEXT NULL COMMENT '实际消耗来源签收批次号,多个批次逗号分隔', MODIFY COLUMN source_return_no TEXT NULL COMMENT '实际消耗来源退货单号,多个单号逗号分隔';
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='fba_carton_mark'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN fba_carton_mark VARCHAR(255) NULL COMMENT ''FBA箱唛'' AFTER carton_mark')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='destination_warehouse_code'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN destination_warehouse_code VARCHAR(128) NULL COMMENT ''目的仓编码'' AFTER fba_carton_mark')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='destination_warehouse_name'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN destination_warehouse_name VARCHAR(255) NULL COMMENT ''目的仓名称'' AFTER destination_warehouse_code')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='relabel_application_id'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN relabel_application_id BIGINT NULL COMMENT ''绑定换标申请单ID'' AFTER destination_warehouse_name')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='relabel_no'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN relabel_no VARCHAR(64) NULL COMMENT ''绑定换标单号'' AFTER relabel_application_id')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='relabel_status'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN relabel_status VARCHAR(32) NULL COMMENT ''换标状态'' AFTER relabel_no')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='relabel_completed_at'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN relabel_completed_at DATETIME(3) NULL COMMENT ''换标完成时间'' AFTER relabel_status')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
CREATE TABLE IF NOT EXISTS ll_return_inventory_carton_item (
|
||||
id BIGINT NOT NULL COMMENT '退货库存箱SKU明细ID', carton_id BIGINT NOT NULL COMMENT '退货库存箱ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name_zh VARCHAR(255) NULL COMMENT '中文品名', quantity BIGINT NOT NULL COMMENT '该SKU箱内数量',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_carton_item(carton_id,sku_code), KEY idx_ll_return_inventory_carton_item_customer(customer_id,sku_code), CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK(quantity>0)
|
||||
) ENGINE=InnoDB COMMENT='退货库存箱内SKU和数量';
|
||||
CREATE TABLE IF NOT EXISTS ll_return_inventory_balance (
|
||||
id BIGINT NOT NULL COMMENT '退货库存余额ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name_zh VARCHAR(255) NULL COMMENT '中文品名', total_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '实物总库存,仅实际出库扣减', available_quantity BIGINT NOT NULL COMMENT '未合箱可用上架数量', carton_quantity BIGINT NOT NULL COMMENT '包含该SKU的可用箱数', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_balance(customer_id,overseas_warehouse_id,sku_code), CONSTRAINT chk_ll_return_inventory_balance_quantity CHECK(total_quantity>=available_quantity AND available_quantity>=0 AND carton_quantity>=0)
|
||||
) ENGINE=InnoDB COMMENT='退货库存按客户海外仓SKU汇总余额';
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_balance' AND column_name='total_quantity'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_balance ADD COLUMN total_quantity BIGINT NULL COMMENT ''实物总库存,仅实际出库扣减'' AFTER product_name_zh'));
|
||||
PREPARE return_inventory_balance_total_stmt FROM @ddl; EXECUTE return_inventory_balance_total_stmt; DEALLOCATE PREPARE return_inventory_balance_total_stmt;
|
||||
-- 旧数据的可用量原先包含已合箱库存;升级时一次性拆分为总库存和未合箱可用库存。
|
||||
UPDATE ll_return_inventory_balance b LEFT JOIN (SELECT c.customer_id,c.overseas_warehouse_id,i.sku_code,SUM(i.quantity) merged_quantity FROM ll_return_inventory_carton c JOIN ll_return_inventory_carton_item i ON i.carton_id=c.id WHERE c.source_batch_no='MERGED' GROUP BY c.customer_id,c.overseas_warehouse_id,i.sku_code) m ON m.customer_id=b.customer_id AND m.overseas_warehouse_id=b.overseas_warehouse_id AND m.sku_code=b.sku_code SET b.total_quantity=b.available_quantity,b.available_quantity=GREATEST(0,b.available_quantity-COALESCE(m.merged_quantity,0)) WHERE b.total_quantity IS NULL;
|
||||
ALTER TABLE ll_return_inventory_balance MODIFY COLUMN total_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '实物总库存,仅实际出库扣减';
|
||||
CREATE TABLE IF NOT EXISTS ll_return_inventory_operation (
|
||||
id BIGINT NOT NULL COMMENT '退货库存PDA操作ID', operator_id VARCHAR(128) NOT NULL COMMENT 'PDA操作人ID', idempotency_key VARCHAR(255) NOT NULL COMMENT '操作幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容SHA-256', operation_type VARCHAR(32) NOT NULL COMMENT '操作类型,当前MERGE', result_carton_id BIGINT NULL COMMENT '合箱结果退货库存箱ID', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_operation_operator_key(operator_id,idempotency_key), KEY idx_ll_return_inventory_operation_result(result_carton_id)
|
||||
) ENGINE=InnoDB COMMENT='退货库存PDA合箱幂等操作审计';
|
||||
|
||||
-- END INLINE EMPTY-DATABASE BASELINE: LASTLEG
|
||||
|
||||
-- BEGIN INLINE EMPTY-DATABASE BASELINE: OVERSEAS WAREHOUSE
|
||||
@@ -2469,7 +2670,7 @@ CREATE TABLE IF NOT EXISTS wh_pallet_business_binding (
|
||||
business_type VARCHAR(32) NOT NULL COMMENT '业务类型', business_id VARCHAR(128) NOT NULL COMMENT '绑定业务记录ID',
|
||||
pallet_id BIGINT NOT NULL COMMENT '托盘ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
PRIMARY KEY (id), UNIQUE KEY uk_wh_pallet_binding (customer_id,warehouse_id,business_type,business_id),
|
||||
UNIQUE KEY uk_wh_pallet_binding_pallet (pallet_id)
|
||||
KEY idx_wh_pallet_binding_pallet (pallet_id)
|
||||
) ENGINE=InnoDB COMMENT='托盘业务绑定关系';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wh_pallet_operation (
|
||||
@@ -2525,7 +2726,7 @@ CREATE TABLE IF NOT EXISTS wh_pallet_batch_item (
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_wh_pallet_batch_item_business (customer_id,warehouse_id,business_key),
|
||||
UNIQUE KEY uk_wh_pallet_batch_item_sequence (batch_id,sequence_no),
|
||||
UNIQUE KEY uk_wh_pallet_batch_item_pallet (pallet_id),
|
||||
KEY idx_wh_pallet_batch_item_pallet (pallet_id),
|
||||
KEY idx_wh_pallet_batch_item_batch (batch_id,status)
|
||||
) ENGINE=InnoDB COMMENT='柜级入库计划批量托盘明细与资源锁关联';
|
||||
|
||||
@@ -3093,7 +3294,7 @@ CREATE TABLE IF NOT EXISTS ll_receiving_container_session (
|
||||
source_container_booking_id VARCHAR(128) NOT NULL COMMENT '稳定来源柜预订ID', container_fact_hash VARCHAR(64) NOT NULL COMMENT '领取时整柜订单与计划事实摘要', container_no VARCHAR(64) NOT NULL COMMENT '集装箱号',
|
||||
overseas_warehouse_id BIGINT NOT NULL COMMENT '操作员Token绑定并冻结的海外实际收货共享物理仓ID', overseas_warehouse_name VARCHAR(255) NOT NULL COMMENT '仓库服务校验后的海外实际收货共享物理仓名称快照',
|
||||
operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', operator_name VARCHAR(255) NULL COMMENT '操作账号名称', active_operator_key VARCHAR(255) NULL COMMENT '未完成会话平台操作账号全局唯一锁键',
|
||||
active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号全局唯一锁键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_SIGN' COMMENT '会话状态:WAIT_SIGN、SIGNED、RECEIVING、COMPLETED',
|
||||
active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号全局唯一锁键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_SIGN' COMMENT '会话状态:WAIT_SIGN、SIGNED、RECEIVING、COMPLETED、COMPLETED_WITH_DIFFERENCE',
|
||||
sign_photos JSON NULL COMMENT '签收卸柜照片角色与文件ID列表', prepared_at DATETIME(3) NULL COMMENT '选择柜号时间', signed_at DATETIME(3) NULL COMMENT '签收时间',
|
||||
receiving_at DATETIME(3) NULL COMMENT '首次收货时间', completed_at DATETIME(3) NULL COMMENT '完成时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', PRIMARY KEY(id),
|
||||
@@ -3142,7 +3343,7 @@ PREPARE phase4_receiving_drop_raw_photos_stmt FROM @ddl; EXECUTE phase4_receivin
|
||||
DROP PROCEDURE IF EXISTS assert_receiving_global_locks_migratable;
|
||||
UPDATE ll_receiving_container_session
|
||||
SET active_operator_key=NULL,active_container_key=NULL
|
||||
WHERE status='COMPLETED' AND (active_operator_key IS NOT NULL OR active_container_key IS NOT NULL);
|
||||
WHERE status IN ('COMPLETED','COMPLETED_WITH_DIFFERENCE') AND (active_operator_key IS NOT NULL OR active_container_key IS NOT NULL);
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE assert_receiving_global_locks_migratable()
|
||||
BEGIN
|
||||
@@ -3192,6 +3393,16 @@ CREATE TABLE IF NOT EXISTS ll_receiving_sku_receipt (
|
||||
KEY idx_ll_receiving_sku_plan (customer_id,plan_id,line_id)
|
||||
) ENGINE=InnoDB COMMENT='海外PDA柜号FBA SKU实收进度';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_receiving_fba_pallet_count (
|
||||
id BIGINT NOT NULL COMMENT 'FBA托盘现场累计箱数ID', session_id BIGINT NOT NULL COMMENT '柜号签收会话ID',
|
||||
group_key VARCHAR(255) NOT NULL COMMENT '柜号与FBA仓组成的收货组键', pallet_code VARCHAR(128) NOT NULL COMMENT '现有FBA托盘码',
|
||||
actual_packages INT NOT NULL COMMENT '该托现场累计实物箱数', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_receiving_fba_count(session_id,group_key,pallet_code),
|
||||
KEY idx_ll_receiving_fba_group(session_id,group_key)
|
||||
) ENGINE=InnoDB COMMENT='海外PDA普通FBA托盘现场累计与超收标记';
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_receiving_container_operation (
|
||||
id BIGINT NOT NULL COMMENT '柜号收货操作ID', session_id BIGINT NOT NULL COMMENT '柜号签收会话ID',
|
||||
operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', action VARCHAR(32) NOT NULL COMMENT '操作动作', idempotency_key VARCHAR(255) NOT NULL COMMENT '客户端幂等键',
|
||||
@@ -3646,6 +3857,21 @@ PREPARE phase5_structure_ddl FROM @ddl; EXECUTE phase5_structure_ddl; DEALLOCATE
|
||||
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND column_name='source_pallet_no'), 'SELECT 1', 'ALTER TABLE ll_dropship_order_profile ADD COLUMN source_pallet_no VARCHAR(128) NULL COMMENT ''来源托盘号快照'' AFTER pallet_no'));
|
||||
PREPARE phase5_structure_ddl FROM @ddl; EXECUTE phase5_structure_ddl; DEALLOCATE PREPARE phase5_structure_ddl;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND column_name='manifest_version'), 'SELECT 1', 'ALTER TABLE ll_dropship_order_profile ADD COLUMN manifest_version INT NOT NULL DEFAULT 1 COMMENT ''整托清单版本'' AFTER source_pallet_no'));
|
||||
PREPARE phase5_structure_ddl FROM @ddl; EXECUTE phase5_structure_ddl; DEALLOCATE PREPARE phase5_structure_ddl;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND column_name='manifest_package_count'), 'SELECT 1', 'ALTER TABLE ll_dropship_order_profile ADD COLUMN manifest_package_count INT NOT NULL DEFAULT 1 COMMENT ''整托总箱数'' AFTER manifest_version'));
|
||||
PREPARE phase5_structure_ddl FROM @ddl; EXECUTE phase5_structure_ddl; DEALLOCATE PREPARE phase5_structure_ddl;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND column_name='manifest_fragment_count'), 'SELECT 1', 'ALTER TABLE ll_dropship_order_profile ADD COLUMN manifest_fragment_count INT NOT NULL DEFAULT 1 COMMENT ''整托客户分片总数'' AFTER manifest_package_count'));
|
||||
PREPARE phase5_structure_ddl FROM @ddl; EXECUTE phase5_structure_ddl; DEALLOCATE PREPARE phase5_structure_ddl;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND column_name='manifest_hash'), 'SELECT 1', 'ALTER TABLE ll_dropship_order_profile ADD COLUMN manifest_hash CHAR(64) NOT NULL DEFAULT ''0000000000000000000000000000000000000000000000000000000000000000'' COMMENT ''整托完整清单摘要'' AFTER manifest_fragment_count'));
|
||||
PREPARE phase5_structure_ddl FROM @ddl; EXECUTE phase5_structure_ddl; DEALLOCATE PREPARE phase5_structure_ddl;
|
||||
ALTER TABLE ll_dropship_order_profile
|
||||
MODIFY COLUMN manifest_version INT NOT NULL COMMENT '整托清单版本',
|
||||
MODIFY COLUMN manifest_package_count INT NOT NULL COMMENT '整托总箱数',
|
||||
MODIFY COLUMN manifest_fragment_count INT NOT NULL COMMENT '整托客户分片总数',
|
||||
MODIFY COLUMN manifest_hash CHAR(64) NOT NULL COMMENT '整托完整清单摘要';
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND index_name='idx_ll_dropship_profile_manifest'), 'SELECT 1', 'ALTER TABLE ll_dropship_order_profile ADD KEY idx_ll_dropship_profile_manifest(source_pallet_no,manifest_hash)'));
|
||||
PREPARE phase5_structure_ddl FROM @ddl; EXECUTE phase5_structure_ddl; DEALLOCATE PREPARE phase5_structure_ddl;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND index_name='uk_ll_dropship_profile_order'), 'ALTER TABLE ll_dropship_order_profile DROP INDEX uk_ll_dropship_profile_order', 'SELECT 1'));
|
||||
PREPARE phase5_structure_ddl FROM @ddl; EXECUTE phase5_structure_ddl; DEALLOCATE PREPARE phase5_structure_ddl;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND index_name='uk_ll_dropship_profile_order_pallet'), 'SELECT 1', 'ALTER TABLE ll_dropship_order_profile ADD UNIQUE KEY uk_ll_dropship_profile_order_pallet(customer_id,order_id,source_pallet_no)'));
|
||||
@@ -3700,9 +3926,9 @@ BEGIN
|
||||
'll_dispatch_order','ll_dispatch_item','ll_dispatch_pallet_lock','ll_dispatch_draft','ll_dispatch_draft_container',
|
||||
'll_dispatch_pda_task','ll_dispatch_pda_pick','ll_dispatch_pda_operation','ll_dispatch_pda_pick_order',
|
||||
'll_outbound_order','ll_outbound_pallet_item','ll_outbound_pda_task','ll_outbound_pda_pick',
|
||||
'll_delivery_order','ll_delivery_fee','ll_delivery_operation',
|
||||
'll_delivery_order','ll_delivery_operation',
|
||||
'll_dropship_order_profile','ll_dropship_carton','ll_dropship_sku_inventory','ll_dropship_carton_ledger',
|
||||
'll_exception_order','ll_exception_return_item','ll_exception_order_command','ll_customer_visit')) <> 24 THEN
|
||||
'll_exception_order','ll_exception_return_item','ll_exception_order_command','ll_customer_visit')) <> 23 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期排车、出库、派送、一件代发、异常或回访表未完整部署';
|
||||
END IF;
|
||||
IF EXISTS (SELECT dispatch_no FROM ll_dispatch_order GROUP BY dispatch_no HAVING COUNT(*)>1) THEN
|
||||
@@ -3816,9 +4042,9 @@ BEGIN
|
||||
'll_dispatch_order','ll_dispatch_item','ll_dispatch_pallet_lock','ll_dispatch_draft','ll_dispatch_draft_container',
|
||||
'll_dispatch_pda_task','ll_dispatch_pda_pick','ll_dispatch_pda_operation','ll_dispatch_pda_pick_order',
|
||||
'll_outbound_order','ll_outbound_pallet_item','ll_outbound_pda_task','ll_outbound_pda_pick',
|
||||
'll_delivery_order','ll_delivery_fee','ll_delivery_operation',
|
||||
'll_delivery_order','ll_delivery_operation',
|
||||
'll_dropship_order_profile','ll_dropship_carton','ll_dropship_sku_inventory','ll_dropship_carton_ledger',
|
||||
'll_exception_order','ll_exception_return_item','ll_exception_order_command','ll_customer_visit')) <> 24 THEN
|
||||
'll_exception_order','ll_exception_return_item','ll_exception_order_command','ll_customer_visit')) <> 23 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期完整业务表集合未部署';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_lastleg'
|
||||
@@ -3834,6 +4060,16 @@ BEGIN
|
||||
AND column_type='varchar(128)' AND is_nullable='NO' AND column_comment='来源托盘号快照') <> 1 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='一件代发来源托盘号字段未收敛';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_lastleg'
|
||||
AND table_name='ll_dropship_order_profile'
|
||||
AND column_name IN ('manifest_version','manifest_package_count','manifest_fragment_count','manifest_hash')
|
||||
AND is_nullable='NO') <> 4 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='一件代发托盘清单字段未完整收敛';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema='baiyue_lastleg'
|
||||
AND table_name='ll_dropship_order_profile' AND index_name='idx_ll_dropship_profile_manifest') <> 2 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='一件代发托盘清单索引未部署';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_lastleg'
|
||||
AND table_name='ll_outbound_pda_task' AND column_name='completion_idempotency_key'
|
||||
AND column_type='varchar(255)' AND is_nullable='YES') <> 1 THEN
|
||||
@@ -3968,7 +4204,7 @@ BEGIN
|
||||
AND aggregate_version=0 AND last_event_id='baseline-platform-admin-authorization-v0'
|
||||
AND (JSON_LENGTH(roles_json) <> 1
|
||||
OR NOT JSON_CONTAINS(roles_json, JSON_QUOTE('PLATFORM_ADMIN'))
|
||||
OR JSON_LENGTH(authorities_json) <> 109
|
||||
OR JSON_LENGTH(authorities_json) <> 105
|
||||
OR NOT JSON_CONTAINS(authorities_json, JSON_QUOTE('platform:overseas-warehouse:last-mile-order:list'))
|
||||
OR NOT JSON_CONTAINS(authorities_json, JSON_QUOTE('platform:overseas-warehouse:inbound-order:list'))
|
||||
OR NOT JSON_CONTAINS(authorities_json, JSON_QUOTE('platform:overseas-warehouse:dispatch:list'))
|
||||
@@ -4405,8 +4641,12 @@ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_lastleg' AND table_name='ll_relabel_application' AND column_name='total_file_url' AND column_type='varchar(1000)') THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='缺少字段 ll_relabel_application.total_file_url';
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_lastleg' AND table_name='ll_inbound_plan_pallet' AND column_name='pallet_no' AND column_type='varchar(128)' AND is_nullable='NO') THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='入仓计划托盘码长度未就绪';
|
||||
IF (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_lastleg'
|
||||
AND table_name='ll_inbound_plan_pallet' AND column_name IN ('pallet_no','version')) <> 2
|
||||
OR NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_lastleg'
|
||||
AND table_name='ll_inbound_plan_pallet' AND column_name='pallet_no'
|
||||
AND column_type='varchar(128)' AND is_nullable='NO') THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='入仓计划托盘码或乐观锁版本字段未就绪';
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema='baiyue_lastleg' AND table_name='ll_delivery_document_revision' AND index_name='uk_delivery_document_command') THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='缺少索引 uk_delivery_document_command';
|
||||
@@ -4536,7 +4776,12 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
|
||||
PREPARE ll_delivery_order_bol_no_stmt FROM @ddl; EXECUTE ll_delivery_order_bol_no_stmt; DEALLOCATE PREPARE ll_delivery_order_bol_no_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order' AND column_name='lastleg_order_id'), 'SELECT 1', 'ALTER TABLE ll_return_order ADD COLUMN lastleg_order_id BIGINT NULL COMMENT ''关联尾程主订单ID'' AFTER customer_reference_no'));
|
||||
PREPARE ll_return_order_lastleg_id_stmt FROM @ddl; EXECUTE ll_return_order_lastleg_id_stmt; DEALLOCATE PREPARE ll_return_order_lastleg_id_stmt;
|
||||
UPDATE ll_return_order r JOIN ll_order o ON o.source_order_id = CAST(r.id AS CHAR) AND o.source_order_no = r.return_no AND o.order_type = 'RETURN_RELABEL' SET r.lastleg_order_id = o.id WHERE r.lastleg_order_id IS NULL;
|
||||
UPDATE ll_return_order r JOIN ll_order o
|
||||
ON o.source_order_id COLLATE utf8mb4_unicode_ci = CAST(r.id AS CHAR) COLLATE utf8mb4_unicode_ci
|
||||
AND o.source_order_no COLLATE utf8mb4_unicode_ci = r.return_no COLLATE utf8mb4_unicode_ci
|
||||
AND o.order_type = 'RETURN_RELABEL'
|
||||
SET r.lastleg_order_id = o.id
|
||||
WHERE r.lastleg_order_id IS NULL;
|
||||
|
||||
-- 柜级入库计划跨客户批量托盘预留、确认与失败补偿。
|
||||
USE baiyue_lastlag_warehouse;
|
||||
@@ -4562,7 +4807,7 @@ CREATE TABLE IF NOT EXISTS wh_pallet_batch_item (
|
||||
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_wh_pallet_batch_item_business (customer_id,warehouse_id,business_key),
|
||||
UNIQUE KEY uk_wh_pallet_batch_item_sequence (batch_id,sequence_no), UNIQUE KEY uk_wh_pallet_batch_item_pallet (pallet_id),
|
||||
UNIQUE KEY uk_wh_pallet_batch_item_sequence (batch_id,sequence_no), KEY idx_wh_pallet_batch_item_pallet (pallet_id),
|
||||
KEY idx_wh_pallet_batch_item_batch (batch_id,status)
|
||||
) ENGINE=InnoDB COMMENT='柜级入库计划批量托盘明细与资源锁关联';
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ public interface IamQualificationClient {
|
||||
|
||||
record QualificationRequest(Long tenantId, String qualificationType,
|
||||
String certificateNo, String documentUrl,
|
||||
String originalFileName,
|
||||
java.time.LocalDate validTo, boolean replace) { }
|
||||
record QualificationResponse(Long qualificationId, Long reviewId, String tenantStatus,
|
||||
String replacedDocumentUrl) { }
|
||||
@@ -38,12 +39,15 @@ public interface IamQualificationClient {
|
||||
String replacedDocumentUrl) { }
|
||||
record PlatformQualificationRequest(Long tenantId, String qualificationType,
|
||||
String certificateNo, String documentUrl,
|
||||
String originalFileName,
|
||||
java.time.LocalDate validTo, boolean replace) { }
|
||||
record RequiredPackageRequest(Long tenantId, String unifiedSocialCreditCode,
|
||||
String businessLicenseDocumentUrl,
|
||||
String businessLicenseOriginalFileName,
|
||||
java.time.LocalDate businessLicenseValidTo,
|
||||
boolean replaceBusinessLicense,
|
||||
String legalPersonIdDocumentUrl,
|
||||
String legalPersonIdOriginalFileName,
|
||||
java.time.LocalDate legalPersonIdValidTo,
|
||||
boolean replaceLegalPersonId) { }
|
||||
record RequiredPackageResponse(Long businessLicenseQualificationId,
|
||||
|
||||
+2
-1
@@ -53,7 +53,8 @@ public class PlatformQualificationFileController {
|
||||
try {
|
||||
var response = iamClient.submitPlatform(internalToken,
|
||||
new IamQualificationClient.PlatformQualificationRequest(
|
||||
tenantId, qualificationType, certificateNo, stored.documentUrl(), validTo, replace));
|
||||
tenantId, qualificationType, certificateNo, stored.documentUrl(),
|
||||
file.getOriginalFilename(), validTo, replace));
|
||||
if (response == null) {
|
||||
throw new IllegalStateException("IAM qualification response is empty");
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ public class QualificationFileController {
|
||||
try {
|
||||
var response = iamClient.submit(internalToken,
|
||||
new IamQualificationClient.QualificationRequest(tenantId, qualificationType,
|
||||
certificateNo, stored.documentUrl(), validTo, replace));
|
||||
certificateNo, stored.documentUrl(), file.getOriginalFilename(), validTo, replace));
|
||||
if (response == null) {
|
||||
throw new IllegalStateException("IAM qualification response is empty");
|
||||
}
|
||||
|
||||
@@ -44,12 +44,13 @@ public interface FileExportOutboxMapper extends BaseMapper<FileExportOutbox> {
|
||||
int releaseExpiredClaims();
|
||||
|
||||
@Update("""
|
||||
UPDATE file_export_outbox o JOIN file_export_task t
|
||||
ON t.id=o.task_id AND t.dispatch_version=o.dispatch_version
|
||||
SET o.status='PENDING',o.next_retry_at=CURRENT_TIMESTAMP(3),o.lease_owner=NULL,o.lease_until=NULL
|
||||
WHERE o.status='SENT' AND o.sent_at<=DATE_SUB(CURRENT_TIMESTAMP(3), INTERVAL #{staleSeconds} SECOND)
|
||||
AND t.status='PENDING' AND t.next_retry_at<=DATE_SUB(CURRENT_TIMESTAMP(3), INTERVAL #{graceSeconds} SECOND)
|
||||
LIMIT 20
|
||||
UPDATE file_export_outbox
|
||||
SET status='PENDING',next_retry_at=CURRENT_TIMESTAMP(3),lease_owner=NULL,lease_until=NULL
|
||||
WHERE id IN (SELECT id FROM (SELECT o.id FROM file_export_outbox o JOIN file_export_task t
|
||||
ON t.id=o.task_id AND t.dispatch_version=o.dispatch_version
|
||||
WHERE o.status='SENT' AND o.sent_at<=DATE_SUB(CURRENT_TIMESTAMP(3), INTERVAL #{staleSeconds} SECOND)
|
||||
AND t.status='PENDING' AND t.next_retry_at<=DATE_SUB(CURRENT_TIMESTAMP(3), INTERVAL #{graceSeconds} SECOND)
|
||||
ORDER BY o.sent_at,o.id LIMIT 20) stale)
|
||||
""")
|
||||
int redispatchSent(@Param("staleSeconds") int staleSeconds, @Param("graceSeconds") int graceSeconds);
|
||||
|
||||
|
||||
@@ -25,7 +25,8 @@ import java.util.UUID;
|
||||
public class LocalQualificationStorage {
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(LocalQualificationStorage.class);
|
||||
private static final long MAX_SIZE = 5L * 1024 * 1024;
|
||||
private static final Set<String> TYPES = Set.of("image/jpeg", "image/png", "application/pdf");
|
||||
private static final Set<String> TYPES = Set.of(
|
||||
"image/jpeg", "image/png", "image/bmp", "image/x-ms-bmp", "application/pdf");
|
||||
@Value("${baiyue.file.storage-root}") private String configuredRoot;
|
||||
private Path root;
|
||||
private Path qualificationRoot;
|
||||
@@ -53,11 +54,16 @@ public class LocalQualificationStorage {
|
||||
if (file == null || file.isEmpty()) throw new BizException("One qualification file is required / 请选择资质文件");
|
||||
if (file.getSize() > MAX_SIZE) throw new BizException("Qualification file must not exceed 5 MB / 资质文件不能超过 5MB");
|
||||
String contentType = file.getContentType() == null ? "" : file.getContentType().toLowerCase(Locale.ROOT);
|
||||
if (!TYPES.contains(contentType)) throw new BizException("Only PDF, JPG and PNG files are supported / 仅支持 PDF、JPG、PNG 文件");
|
||||
if (!TYPES.contains(contentType)) throw new BizException("Only PDF, JPG, PNG and BMP files are supported / 仅支持 PDF、JPG、PNG、BMP 文件");
|
||||
if (!matchesSignature(file, contentType)) {
|
||||
throw new BizException("The uploaded file content does not match its type / 文件内容与格式不匹配");
|
||||
}
|
||||
String extension = "image/png".equals(contentType) ? ".png" : ("application/pdf".equals(contentType) ? ".pdf" : ".jpg");
|
||||
String extension = switch (contentType) {
|
||||
case "image/png" -> ".png";
|
||||
case "image/bmp", "image/x-ms-bmp" -> ".bmp";
|
||||
case "application/pdf" -> ".pdf";
|
||||
default -> ".jpg";
|
||||
};
|
||||
String fileName = UUID.randomUUID() + extension;
|
||||
Path directory = tenantDirectory(tenantId);
|
||||
Path target = directory.resolve(fileName).normalize();
|
||||
@@ -92,6 +98,9 @@ public class LocalQualificationStorage {
|
||||
return read >= 5 && header[0] == 0x25 && header[1] == 0x50 && header[2] == 0x44
|
||||
&& header[3] == 0x46 && header[4] == 0x2d;
|
||||
}
|
||||
if ("image/bmp".equals(contentType) || "image/x-ms-bmp".equals(contentType)) {
|
||||
return read >= 2 && header[0] == 0x42 && header[1] == 0x4d;
|
||||
}
|
||||
return read >= 8 && (header[0] & 0xff) == 0x89 && header[1] == 0x50
|
||||
&& header[2] == 0x4e && header[3] == 0x47 && header[4] == 0x0d
|
||||
&& header[5] == 0x0a && header[6] == 0x1a && header[7] == 0x0a;
|
||||
@@ -101,7 +110,7 @@ public class LocalQualificationStorage {
|
||||
}
|
||||
|
||||
public Resource load(long tenantId, String fileName) {
|
||||
if (fileName == null || !fileName.matches("[0-9a-fA-F-]{36}\\.(jpg|png|pdf)")) {
|
||||
if (fileName == null || !fileName.matches("[0-9a-fA-F-]{36}\\.(jpg|png|bmp|pdf)")) {
|
||||
throw fileNotFound();
|
||||
}
|
||||
Path directory = tenantDirectory(tenantId);
|
||||
@@ -124,7 +133,7 @@ public class LocalQualificationStorage {
|
||||
String prefix = "/api/merchant/v1/files/qualifications/";
|
||||
if (!documentUrl.startsWith(prefix)) return;
|
||||
String fileName = documentUrl.substring(prefix.length());
|
||||
if (!fileName.matches("[0-9a-fA-F-]{36}\\.(jpg|png|pdf)")) return;
|
||||
if (!fileName.matches("[0-9a-fA-F-]{36}\\.(jpg|png|bmp|pdf)")) return;
|
||||
Path directory = tenantDirectory(tenantId);
|
||||
Path target = directory.resolve(fileName).normalize();
|
||||
if (!target.startsWith(directory)) return;
|
||||
|
||||
@@ -45,8 +45,10 @@ public class QualificationPackageService {
|
||||
String legalPersonIdDocumentUrl = storedLegalPersonId == null ? null : storedLegalPersonId.documentUrl();
|
||||
|
||||
var request = new IamQualificationClient.RequiredPackageRequest(
|
||||
tenantId, normalizedCreditCode, storedBusinessLicense.documentUrl(), businessLicenseValidTo,
|
||||
replaceBusinessLicense, legalPersonIdDocumentUrl, legalPersonIdValidTo, replaceLegalPersonId);
|
||||
tenantId, normalizedCreditCode, storedBusinessLicense.documentUrl(), businessLicense.getOriginalFilename(),
|
||||
businessLicenseValidTo, replaceBusinessLicense, legalPersonIdDocumentUrl,
|
||||
legalPersonId == null ? null : legalPersonId.getOriginalFilename(),
|
||||
legalPersonIdValidTo, replaceLegalPersonId);
|
||||
try {
|
||||
var response = platform
|
||||
? iamClient.submitPlatformRequiredPackage(internalToken, request)
|
||||
|
||||
@@ -29,7 +29,7 @@ paths:
|
||||
type: string
|
||||
format: binary
|
||||
maxLength: 5242880
|
||||
description: PDF/JPG/PNG,最大5MB,声明媒体类型必须与文件签名一致
|
||||
description: PDF/JPG/PNG/BMP,最大5MB,声明媒体类型必须与文件签名一致
|
||||
responses:
|
||||
'200':
|
||||
description: 成功或业务失败,失败时按 code 判断并保留 requestId/traceId
|
||||
@@ -139,12 +139,12 @@ components:
|
||||
businessLicense:
|
||||
type: string
|
||||
format: binary
|
||||
description: 营业执照,必填;PDF/JPG/PNG,最大5MB
|
||||
description: 营业执照,必填;PDF/JPG/PNG/BMP,最大5MB
|
||||
legalPersonId:
|
||||
type: string
|
||||
format: binary
|
||||
nullable: true
|
||||
description: 法人身份证,选填;PDF/JPG/PNG,最大5MB
|
||||
description: 法人身份证,选填;PDF/JPG/PNG/BMP,最大5MB
|
||||
responses:
|
||||
Error:
|
||||
description: 认证或授权失败,响应包含请求关联元数据
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ class QualificationAndOrderFileOpenApiContractTest {
|
||||
assertThat(yaml)
|
||||
.contains("/api/merchant/v1/files/qualifications:")
|
||||
.contains("enum: [LEGAL_PERSON_ID, FREIGHT_FORWARDER_CERTIFICATE, BANK_ACCOUNT_LICENSE]")
|
||||
.contains("merchant:qualification:upload", "maxLength: 5242880")
|
||||
.contains("merchant:qualification:upload", "maxLength: 5242880", "PDF/JPG/PNG/BMP")
|
||||
.contains("name: replace", "requestId:", "traceId:")
|
||||
.doesNotContain("enum: [BUSINESS_LICENSE, LEGAL_PERSON_ID, FREIGHT_FORWARDER_CERTIFICATE, BANK_ACCOUNT_LICENSE]");
|
||||
}
|
||||
|
||||
@@ -22,6 +22,9 @@ class ExportMapperContractTest {
|
||||
String sql=update(FileExportOutboxMapper.class,"redispatchSent");
|
||||
assertTrue(sql.contains("o.status='SENT'"));
|
||||
assertTrue(sql.contains("t.dispatch_version=o.dispatch_version"));
|
||||
assertTrue(sql.contains("WHERE id IN (SELECT id FROM (SELECT o.id"));
|
||||
assertTrue(sql.contains("ORDER BY o.sent_at,o.id LIMIT 20"));
|
||||
assertFalse(sql.contains("SET o.status"));
|
||||
assertFalse(sql.contains("dispatch_version=dispatch_version+1"));
|
||||
assertFalse(sql.contains("QUARANTINED"));
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ class LocalQualificationStorageTest {
|
||||
Path qualificationRoot = storageRoot.resolve("qualifications");
|
||||
assertTrue(Files.isDirectory(qualificationRoot));
|
||||
try (var files = Files.list(qualificationRoot)) {
|
||||
assertEquals(0, files.count());
|
||||
assertFalse(files.anyMatch(path -> path.getFileName().toString().startsWith(".file-service-probe-")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +78,47 @@ class LocalQualificationStorageTest {
|
||||
storage.load(10001L, stored.fileName()).getFile().toPath().toAbsolutePath());
|
||||
}
|
||||
|
||||
@Test
|
||||
void storesBmpQualificationWithEitherStandardMimeTypeAndKeepsTenantOwnership() throws Exception {
|
||||
storage.initialize();
|
||||
byte[] bmp = {0x42, 0x4d, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00};
|
||||
|
||||
for (String contentType : new String[] {"image/bmp", "image/x-ms-bmp"}) {
|
||||
var upload = new MockMultipartFile("file", "license.bmp", contentType, bmp);
|
||||
var stored = storage.store(10001L, upload);
|
||||
|
||||
assertTrue(stored.fileName().endsWith(".bmp"));
|
||||
assertEquals(stored.path().toAbsolutePath(),
|
||||
storage.load(10001L, stored.fileName()).getFile().toPath().toAbsolutePath());
|
||||
BizException crossTenant = assertThrows(BizException.class,
|
||||
() -> storage.load(10002L, stored.fileName()));
|
||||
assertEquals(ResultCode.NOT_FOUND, crossTenant.getResultCode());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsBmpMimeTypeWhenTheFileSignatureIsNotBmp() {
|
||||
storage.initialize();
|
||||
var upload = new MockMultipartFile("file", "license.bmp", "image/bmp", "not-bmp".getBytes());
|
||||
|
||||
BizException exception = assertThrows(BizException.class,
|
||||
() -> storage.store(10001L, upload));
|
||||
|
||||
assertTrue(exception.getMessage().contains("文件内容与格式不匹配"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void deletesBmpQualificationByItsValidatedDocumentUrl() {
|
||||
storage.initialize();
|
||||
byte[] bmp = {0x42, 0x4d, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00};
|
||||
var stored = storage.store(10001L,
|
||||
new MockMultipartFile("file", "license.bmp", "image/bmp", bmp));
|
||||
|
||||
storage.deleteByDocumentUrl(10001L, stored.documentUrl());
|
||||
|
||||
assertFalse(Files.exists(stored.path()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsQualificationFilesLargerThanTheClientFiveMegabyteLimit() {
|
||||
storage.initialize();
|
||||
|
||||
@@ -57,6 +57,8 @@ class QualificationPackageServiceTest {
|
||||
var captor = ArgumentCaptor.forClass(IamQualificationClient.RequiredPackageRequest.class);
|
||||
ordered.verify(fixture.iamClient).submitRequiredPackage(eq("internal-token"), captor.capture());
|
||||
assertEquals("91310101ABCDE12345", captor.getValue().unifiedSocialCreditCode());
|
||||
assertEquals("license.pdf", captor.getValue().businessLicenseOriginalFileName());
|
||||
assertEquals("legal-person.png", captor.getValue().legalPersonIdOriginalFileName());
|
||||
assertEquals(BUSINESS_URL, result.businessLicenseDocumentUrl());
|
||||
assertEquals(LEGAL_PERSON_URL, result.legalPersonIdDocumentUrl());
|
||||
}
|
||||
@@ -74,6 +76,7 @@ class QualificationPackageServiceTest {
|
||||
var captor = ArgumentCaptor.forClass(IamQualificationClient.RequiredPackageRequest.class);
|
||||
verify(fixture.iamClient).submitRequiredPackage(eq("internal-token"), captor.capture());
|
||||
assertNull(captor.getValue().legalPersonIdDocumentUrl());
|
||||
assertNull(captor.getValue().legalPersonIdOriginalFileName());
|
||||
assertNull(captor.getValue().legalPersonIdValidTo());
|
||||
assertNull(result.legalPersonIdQualificationId());
|
||||
assertNull(result.legalPersonIdDocumentUrl());
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
- 出账后的业务来源和账单不得原地覆盖或物理删除;调整、作废、收款分配和修订必须保留可追溯记录。金额使用定点小数,当前业务币种固定为 CNY,长整型 ID 跨前端按字符串传输。
|
||||
- 对账单出账后将客户确认状态置为 `PENDING_CONFIRMATION`;已出账账单调整后必须清除原确认快照并重新待确认,作废或重开时为 `NOT_REQUIRED`。客户只能确认本企业且状态为 `ISSUED`、`PARTIALLY_PAID` 或 `PAID` 的待确认账单,必须校验登录成员、乐观锁版本和幂等键,并记录确认人姓名快照与确认时间。
|
||||
- 对账单、应收结算单和应付结算单编号统一按北京时间生成 `REC/AR/AP-yyMMdd-####`,各前缀每日独立递增且单日最多 9999;不得回退为数据库 ID 拼接、随机数或跨时区日期。
|
||||
- 客户授信必须带晚于当天的到期日且不限制最远日期;到期日当天仍有效。有效期内消费先用实际资金,不足部分累计为已用授信;次日失效时已用授信自动并入实际余额并允许为负,充值先归还已用授信或负余额。Order 在客户提交新订单时必须拒绝负数实际余额,已存在的资金冻结仍按原业务单结算或释放。
|
||||
- 应收款统计只按正式应收结算单的生成月份汇总 CNY 应收、已收和未收,支持可选客户筛选和分页;导出必须通过 Finance Provider + File `FINANCE_RECEIVABLE_STATISTICS` 异步任务,不在 Controller 或 Admin 直接生成文件。
|
||||
- 费用入池只接受满足上游发布与结算条件的不可变来源版本;来源 ID、版本、客户归属、方向或金额变化时同时核对事件消费、主动快照查询、唯一约束和账单明细消费者。
|
||||
- 海外业务费用事件只接受非 `FIRSTLEG` 来源、`APPROVED` 审核快照和 `PUBLISHED` 活跃版本或明确 `VOIDED` 动作;按 `eventId + payloadHash` 防重放冲突,并以 `sourceSystem + sourceType + sourceFeeId + sourceVersion` 单调落库。事件携带完整客户、业务、方向、供应商、付款与来源哈希事实,Finance 不跨区同步回查 Lastleg。
|
||||
|
||||
@@ -33,6 +33,7 @@ public final class FinancePhase1Dtos {
|
||||
BigDecimal actualBalance, BigDecimal frozenBalance,
|
||||
BigDecimal availableBalance, BigDecimal creditLimit,
|
||||
BigDecimal creditUsed, BigDecimal availableCredit,
|
||||
LocalDate creditExpiresOn, boolean creditActive,
|
||||
int accountVersion,
|
||||
long pendingRechargeCount,
|
||||
String status, LocalDateTime lastChangedAt) {}
|
||||
|
||||
@@ -12,6 +12,7 @@ public final class FinanceWalletDtos {
|
||||
BigDecimal frozenBalance, BigDecimal availableBalance,
|
||||
BigDecimal creditLimit, BigDecimal creditUsed,
|
||||
BigDecimal creditFrozen, BigDecimal availableCredit,
|
||||
LocalDate creditExpiresOn, boolean creditActive,
|
||||
int version, LocalDateTime updatedAt) { }
|
||||
|
||||
public record WalletPage<T>(List<T> records, long total, int page, int size, int pages) {
|
||||
@@ -26,6 +27,7 @@ public final class FinanceWalletDtos {
|
||||
BigDecimal frozenBalanceAfter, BigDecimal availableBalanceAfter,
|
||||
BigDecimal creditLimitAfter, BigDecimal creditUsedAfter,
|
||||
BigDecimal availableCreditAfter,
|
||||
LocalDate creditExpiresOnAfter,
|
||||
String sourceType, String sourceNo, LocalDate occurredDate,
|
||||
String paymentMethod, String referenceNo, String operatorName,
|
||||
String remark, LocalDateTime createdAt) { }
|
||||
@@ -45,7 +47,7 @@ public final class FinanceWalletDtos {
|
||||
String paymentMethod, String referenceNo, String remark,
|
||||
int expectedAccountVersion) { }
|
||||
|
||||
public record CreditLimitRequest(BigDecimal creditLimit, String remark,
|
||||
public record CreditLimitRequest(BigDecimal creditLimit, LocalDate creditExpiresOn, String remark,
|
||||
int expectedAccountVersion) { }
|
||||
|
||||
public record WalletFreezeRequest(BigDecimal amount, String businessType,
|
||||
|
||||
+28
-16
@@ -12,6 +12,7 @@ import org.springframework.stereotype.Service;
|
||||
import java.math.BigDecimal;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalDate;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -21,18 +22,12 @@ public class FinanceCustomerAccountService {
|
||||
private final JdbcTemplate jdbc;
|
||||
private final IamCustomerClient iamCustomerClient;
|
||||
private final String internalToken;
|
||||
private final BigDecimal defaultCreditLimit;
|
||||
|
||||
public FinanceCustomerAccountService(JdbcTemplate jdbc, IamCustomerClient iamCustomerClient,
|
||||
@Value("${baiyue.security.internal-service-token:}") String internalToken,
|
||||
@Value("${baiyue.finance.wallet.default-credit-limit:0.00}") BigDecimal defaultCreditLimit) {
|
||||
@Value("${baiyue.security.internal-service-token:}") String internalToken) {
|
||||
this.jdbc = jdbc;
|
||||
this.iamCustomerClient = iamCustomerClient;
|
||||
this.internalToken = internalToken;
|
||||
if (defaultCreditLimit == null || defaultCreditLimit.signum() < 0 || defaultCreditLimit.scale() > 2) {
|
||||
throw new IllegalArgumentException("默认授信额度不能为负数且最多保留两位小数");
|
||||
}
|
||||
this.defaultCreditLimit = defaultCreditLimit.setScale(2);
|
||||
}
|
||||
|
||||
public PageEnvelope<AccountSummary> list(int page, int pageSize, String keyword, String status, Long customerId) {
|
||||
@@ -60,6 +55,7 @@ public class FinanceCustomerAccountService {
|
||||
value.outstandingPayable(),
|
||||
value.actualBalance(), value.frozenBalance(), value.availableBalance(),
|
||||
value.creditLimit(), value.creditUsed(), value.availableCredit(),
|
||||
value.creditExpiresOn(), value.creditActive(),
|
||||
value.accountVersion(), value.pendingRechargeCount(),
|
||||
customer.status(), value.lastChangedAt());
|
||||
}).toList();
|
||||
@@ -70,7 +66,7 @@ public class FinanceCustomerAccountService {
|
||||
if (customerIds.isEmpty()) return Map.of();
|
||||
String placeholders = String.join(",", customerIds.stream().map(id -> "?").toList());
|
||||
Map<Long, MutableMetrics> result = new HashMap<>();
|
||||
customerIds.forEach(id -> result.put(id, new MutableMetrics(defaultCreditLimit)));
|
||||
customerIds.forEach(id -> result.put(id, new MutableMetrics()));
|
||||
jdbc.queryForList("SELECT tenant_id customer_id,SUM(outstanding_amount) amount FROM fin_settlement WHERE tenant_id IN (" + placeholders + ") AND direction='RECEIVABLE' AND status<>'VOIDED' GROUP BY tenant_id", customerIds.toArray())
|
||||
.forEach(row -> metric(result, row, "outstandingReceivable", "amount"));
|
||||
jdbc.queryForList("SELECT customer_id,SUM(amount) amount FROM fin_fee_source f WHERE customer_id IN (" + placeholders + ") AND direction='RECEIVABLE' AND review_status='APPROVED' AND currency_code='CNY' AND amount>0 AND status='AVAILABLE' AND NOT EXISTS (SELECT 1 FROM fin_bill_line bl JOIN fin_bill b ON b.id=bl.bill_id AND b.tenant_id=bl.tenant_id WHERE bl.tenant_id=f.customer_id AND bl.source_id=f.id AND bl.source_version=f.source_fee_version AND b.status<>'VOIDED') GROUP BY customer_id", customerIds.toArray())
|
||||
@@ -83,7 +79,7 @@ public class FinanceCustomerAccountService {
|
||||
.forEach(row -> metric(result, row, "paidCost", "amount"));
|
||||
jdbc.queryForList("SELECT customer_id,SUM(amount) amount FROM (SELECT tenant_id customer_id,outstanding_amount amount FROM fin_settlement WHERE tenant_id IN (" + placeholders + ") AND direction='PAYABLE' AND status<>'VOIDED' UNION ALL SELECT f.customer_id,f.amount FROM fin_fee_source f WHERE f.customer_id IN (" + placeholders + ") AND f.direction='PAYABLE' AND f.settlement_mode='ON_ACCOUNT' AND f.review_status='APPROVED' AND f.currency_code='CNY' AND f.amount>0 AND f.status NOT IN ('VOIDED','SUPERSEDED') AND NOT EXISTS (SELECT 1 FROM fin_settlement_line sl JOIN fin_settlement s ON s.id=sl.settlement_id AND s.tenant_id=sl.tenant_id WHERE sl.tenant_id=f.customer_id AND sl.fee_source_id=f.id AND s.direction='PAYABLE' AND s.status<>'VOIDED')) payable_obligations GROUP BY customer_id", concat(customerIds, 2))
|
||||
.forEach(row -> metric(result, row, "outstandingPayable", "amount"));
|
||||
jdbc.queryForList("SELECT customer_id,actual_balance,frozen_balance,available_balance,credit_limit,credit_used,credit_frozen,version FROM fin_wallet_account WHERE customer_id IN (" + placeholders + ")", customerIds.toArray())
|
||||
jdbc.queryForList("SELECT customer_id,actual_balance,frozen_balance,available_balance,credit_limit,credit_used,credit_frozen,credit_expires_on,version FROM fin_wallet_account WHERE customer_id IN (" + placeholders + ")", customerIds.toArray())
|
||||
.forEach(row -> walletMetric(result, row));
|
||||
jdbc.queryForList("SELECT customer_id,COUNT(*) pending_count FROM fin_recharge_request WHERE customer_id IN (" + placeholders + ") AND status='PENDING' GROUP BY customer_id", customerIds.toArray())
|
||||
.forEach(row -> pendingMetric(result, row));
|
||||
@@ -100,10 +96,22 @@ public class FinanceCustomerAccountService {
|
||||
MutableMetrics target = result.get(number.longValue());
|
||||
target.actualBalance = decimal(row.get("actual_balance"));
|
||||
target.frozenBalance = decimal(row.get("frozen_balance"));
|
||||
target.availableBalance = decimal(row.get("available_balance"));
|
||||
target.creditLimit = decimal(row.get("credit_limit"));
|
||||
BigDecimal creditFrozen = decimal(row.get("credit_frozen"));
|
||||
target.creditExpiresOn = row.get("credit_expires_on") instanceof java.sql.Date value ? value.toLocalDate()
|
||||
: row.get("credit_expires_on") instanceof LocalDate value ? value : null;
|
||||
target.creditActive = target.creditLimit.signum() > 0 && target.creditExpiresOn != null
|
||||
&& !target.creditExpiresOn.isBefore(LocalDate.now());
|
||||
target.creditUsed = decimal(row.get("credit_used"));
|
||||
target.availableCredit = target.creditLimit.subtract(target.creditUsed).subtract(decimal(row.get("credit_frozen")));
|
||||
if (!target.creditActive && target.creditUsed.signum() > 0) {
|
||||
target.actualBalance = target.actualBalance.subtract(target.creditUsed);
|
||||
target.creditUsed = BigDecimal.ZERO;
|
||||
}
|
||||
target.availableCredit = target.creditActive
|
||||
? target.creditLimit.subtract(target.creditUsed).subtract(creditFrozen) : BigDecimal.ZERO;
|
||||
target.availableBalance = target.creditActive
|
||||
? target.actualBalance.subtract(target.frozenBalance.subtract(creditFrozen)).add(target.availableCredit)
|
||||
: target.actualBalance.subtract(target.frozenBalance);
|
||||
target.accountVersion = row.get("version") instanceof Number value ? value.intValue() : 0;
|
||||
}
|
||||
|
||||
@@ -158,13 +166,15 @@ public class FinanceCustomerAccountService {
|
||||
private BigDecimal creditLimit;
|
||||
private BigDecimal creditUsed = BigDecimal.ZERO;
|
||||
private BigDecimal availableCredit;
|
||||
private LocalDate creditExpiresOn;
|
||||
private boolean creditActive;
|
||||
private int accountVersion;
|
||||
private long pendingRechargeCount;
|
||||
private LocalDateTime lastChangedAt;
|
||||
private MutableMetrics(BigDecimal defaultCreditLimit) {
|
||||
this.creditLimit = defaultCreditLimit;
|
||||
this.availableCredit = defaultCreditLimit;
|
||||
this.availableBalance = defaultCreditLimit;
|
||||
private MutableMetrics() {
|
||||
this.creditLimit = BigDecimal.ZERO.setScale(2);
|
||||
this.availableCredit = BigDecimal.ZERO.setScale(2);
|
||||
this.availableBalance = BigDecimal.ZERO.setScale(2);
|
||||
}
|
||||
void set(String field, BigDecimal value) {
|
||||
switch (field) {
|
||||
@@ -179,6 +189,7 @@ public class FinanceCustomerAccountService {
|
||||
}
|
||||
Metrics freeze() { return new Metrics(outstandingReceivable, pendingBilling, receivedInPeriod, totalCost, paidCost, outstandingPayable,
|
||||
actualBalance, frozenBalance, availableBalance, creditLimit, creditUsed, availableCredit,
|
||||
creditExpiresOn, creditActive,
|
||||
accountVersion, pendingRechargeCount, lastChangedAt); }
|
||||
}
|
||||
|
||||
@@ -186,7 +197,8 @@ public class FinanceCustomerAccountService {
|
||||
BigDecimal receivedInPeriod, BigDecimal totalCost, BigDecimal paidCost,
|
||||
BigDecimal outstandingPayable, BigDecimal actualBalance, BigDecimal frozenBalance,
|
||||
BigDecimal availableBalance, BigDecimal creditLimit, BigDecimal creditUsed,
|
||||
BigDecimal availableCredit, int accountVersion, long pendingRechargeCount,
|
||||
BigDecimal availableCredit, LocalDate creditExpiresOn, boolean creditActive,
|
||||
int accountVersion, long pendingRechargeCount,
|
||||
LocalDateTime lastChangedAt) {
|
||||
}
|
||||
}
|
||||
|
||||
+31
-7
@@ -78,22 +78,46 @@ public class FinancePhase1OperationService {
|
||||
"COALESCE((SELECT available_balance FROM fin_wallet_account w WHERE w.customer_id=c.customer_id),0) available_balance," +
|
||||
"COALESCE((SELECT credit_limit FROM fin_wallet_account w WHERE w.customer_id=c.customer_id),0) credit_limit," +
|
||||
"COALESCE((SELECT credit_used FROM fin_wallet_account w WHERE w.customer_id=c.customer_id),0) credit_used," +
|
||||
"COALESCE((SELECT credit_frozen FROM fin_wallet_account w WHERE w.customer_id=c.customer_id),0) credit_frozen," +
|
||||
"COALESCE((SELECT credit_limit-credit_used-credit_frozen FROM fin_wallet_account w WHERE w.customer_id=c.customer_id),0) available_credit," +
|
||||
"(SELECT credit_expires_on FROM fin_wallet_account w WHERE w.customer_id=c.customer_id) credit_expires_on," +
|
||||
"COALESCE((SELECT version FROM fin_wallet_account w WHERE w.customer_id=c.customer_id),0) account_version," +
|
||||
"(SELECT COUNT(*) FROM fin_recharge_request rr WHERE rr.customer_id=c.customer_id AND rr.status='PENDING') pending_recharge_count," +
|
||||
"GREATEST(COALESCE((SELECT MAX(updated_at) FROM fin_bill b WHERE b.tenant_id=c.customer_id),'1970-01-01'),COALESCE((SELECT MAX(updated_at) FROM fin_receipt r WHERE r.tenant_id=c.customer_id),'1970-01-01'),COALESCE((SELECT MAX(updated_at) FROM fin_cost x WHERE x.tenant_id=c.customer_id),'1970-01-01'),COALESCE((SELECT MAX(updated_at) FROM fin_fee_source f WHERE f.customer_id=c.customer_id),'1970-01-01'),COALESCE((SELECT MAX(updated_at) FROM fin_settlement x WHERE x.tenant_id=c.customer_id),'1970-01-01')) last_changed_at" + base + " ORDER BY last_changed_at DESC,c.customer_id DESC LIMIT ? OFFSET ?";
|
||||
Object[] args = java.util.Arrays.copyOf(filterArgs, filterArgs.length + 2);
|
||||
args[args.length - 2] = pageSize; args[args.length - 1] = (page - 1) * pageSize;
|
||||
List<AccountSummary> rows = jdbc.query(select, (rs, row) -> new AccountSummary(rs.getLong("customer_id"), String.valueOf(rs.getLong("customer_id")), rs.getString("customer_name"), null, null,
|
||||
money(rs, "outstanding_receivable"), money(rs, "pending_billing"), money(rs, "received_period"),
|
||||
money(rs, "total_cost"), money(rs, "paid_cost"), money(rs, "outstanding_payable"),
|
||||
money(rs, "actual_balance"), money(rs, "frozen_balance"), money(rs, "available_balance"),
|
||||
money(rs, "credit_limit"), money(rs, "credit_used"), money(rs, "available_credit"),
|
||||
rs.getInt("account_version"), rs.getLong("pending_recharge_count"),
|
||||
rs.getString("account_status"), time(rs, "last_changed_at")), args);
|
||||
List<AccountSummary> rows = jdbc.query(select, (rs, row) -> accountSummaryRow(rs), args);
|
||||
return PageEnvelope.success(rows, page, pageSize, total == null ? 0 : total);
|
||||
}
|
||||
|
||||
private static AccountSummary accountSummaryRow(ResultSet rs) throws SQLException {
|
||||
BigDecimal actualBalance = money(rs, "actual_balance");
|
||||
BigDecimal frozenBalance = money(rs, "frozen_balance");
|
||||
BigDecimal creditLimit = money(rs, "credit_limit");
|
||||
BigDecimal creditFrozen = money(rs, "credit_frozen");
|
||||
LocalDate creditExpiresOn = rs.getDate("credit_expires_on") == null
|
||||
? null : rs.getDate("credit_expires_on").toLocalDate();
|
||||
boolean creditActive = creditLimit.signum() > 0 && creditExpiresOn != null
|
||||
&& !creditExpiresOn.isBefore(LocalDate.now());
|
||||
BigDecimal creditUsed = money(rs, "credit_used");
|
||||
if (!creditActive && creditUsed.signum() > 0) {
|
||||
actualBalance = actualBalance.subtract(creditUsed);
|
||||
creditUsed = BigDecimal.ZERO;
|
||||
}
|
||||
BigDecimal availableCredit = creditActive
|
||||
? creditLimit.subtract(creditUsed).subtract(creditFrozen) : BigDecimal.ZERO;
|
||||
BigDecimal availableBalance = creditActive
|
||||
? actualBalance.subtract(frozenBalance.subtract(creditFrozen)).add(availableCredit)
|
||||
: actualBalance.subtract(frozenBalance);
|
||||
return new AccountSummary(rs.getLong("customer_id"), String.valueOf(rs.getLong("customer_id")),
|
||||
rs.getString("customer_name"), null, null, money(rs, "outstanding_receivable"),
|
||||
money(rs, "pending_billing"), money(rs, "received_period"), money(rs, "total_cost"),
|
||||
money(rs, "paid_cost"), money(rs, "outstanding_payable"), actualBalance, frozenBalance,
|
||||
availableBalance, creditLimit, creditUsed, availableCredit, creditExpiresOn, creditActive,
|
||||
rs.getInt("account_version"), rs.getLong("pending_recharge_count"), rs.getString("account_status"),
|
||||
time(rs, "last_changed_at"));
|
||||
}
|
||||
|
||||
public PageEnvelope<LedgerEntry> listDerivedCustomerAccountLedger(long customerId, int page, int pageSize,
|
||||
String businessType, LocalDate startDate, LocalDate endDate) {
|
||||
customer(customerId); page = page(page); pageSize = pageSize(pageSize);
|
||||
|
||||
@@ -53,7 +53,6 @@ public class FinanceWalletService {
|
||||
private final String internalToken;
|
||||
private final String wechatQrCodeUrl;
|
||||
private final String alipayQrCodeUrl;
|
||||
private final BigDecimal defaultCreditLimit;
|
||||
|
||||
public FinanceWalletService(JdbcTemplate jdbc, ObjectMapper objectMapper,
|
||||
FinanceFileClient fileClient,
|
||||
@@ -61,7 +60,6 @@ public class FinanceWalletService {
|
||||
@Value("${baiyue.security.internal-service-token:}") String internalToken,
|
||||
@Value("${baiyue.finance.recharge.wechat-qr-code-url:}") String wechatQrCodeUrl,
|
||||
@Value("${baiyue.finance.recharge.alipay-qr-code-url:}") String alipayQrCodeUrl,
|
||||
@Value("${baiyue.finance.wallet.default-credit-limit:0.00}") BigDecimal defaultCreditLimit,
|
||||
@Value("${spring.profiles.active:}") String activeProfiles,
|
||||
@Value("${DEPLOY_ENV:}") String deployEnvironment) {
|
||||
this.jdbc = jdbc;
|
||||
@@ -69,7 +67,6 @@ public class FinanceWalletService {
|
||||
this.fileClient = fileClient;
|
||||
this.numberGenerator = numberGenerator;
|
||||
this.internalToken = internalToken;
|
||||
this.defaultCreditLimit = nonNegativeMoney(defaultCreditLimit, "默认授信额度");
|
||||
boolean development = "development".equalsIgnoreCase(deployEnvironment.trim())
|
||||
|| List.of(activeProfiles.split(",")).stream().map(String::trim).anyMatch("dev"::equalsIgnoreCase);
|
||||
this.wechatQrCodeUrl = rechargeQrCode(wechatQrCodeUrl, development);
|
||||
@@ -84,7 +81,7 @@ public class FinanceWalletService {
|
||||
public WalletAccount account(long customerId) {
|
||||
requirePositive(customerId, "客户");
|
||||
List<WalletAccount> rows = jdbc.query(
|
||||
"SELECT currency_code,actual_balance,frozen_balance,available_balance,credit_limit,credit_used,credit_frozen,version,updated_at FROM fin_wallet_account WHERE customer_id=?",
|
||||
"SELECT currency_code,actual_balance,frozen_balance,available_balance,credit_limit,credit_used,credit_frozen,credit_expires_on,version,updated_at FROM fin_wallet_account WHERE customer_id=?",
|
||||
(rs, n) -> accountRow(rs), customerId);
|
||||
return rows.isEmpty() ? zeroAccount() : rows.get(0);
|
||||
}
|
||||
@@ -202,7 +199,7 @@ public class FinanceWalletService {
|
||||
if (replay != null) return requiredLedger(replay, customerId);
|
||||
WalletAccount before = accountForUpdate(customerId);
|
||||
if (before.version() != request.expectedAccountVersion()) throw error("FIN-WALLET-409", "账户余额版本已变化,请刷新后重试");
|
||||
if ("DEDUCTION".equals(type) && before.availableBalance().compareTo(amount) < 0) {
|
||||
if ("DEDUCTION".equals(type) && before.creditActive() && before.availableBalance().compareTo(amount) < 0) {
|
||||
throw error("FIN-WALLET-INSUFFICIENT", "扣款金额不能超过当前可用余额");
|
||||
}
|
||||
BigDecimal signed = "RECHARGE".equals(type) ? amount : amount.negate();
|
||||
@@ -225,19 +222,26 @@ public class FinanceWalletService {
|
||||
requirePositive(customerId, "客户"); requirePositive(operatorId, "操作人");
|
||||
if (request == null || request.expectedAccountVersion() < 0) throw bad("授信额度请求无效");
|
||||
BigDecimal limit = nonNegativeMoney(request.creditLimit(), "授信额度");
|
||||
LocalDate expiresOn = request.creditExpiresOn();
|
||||
if (expiresOn == null || !expiresOn.isAfter(LocalDate.now())) {
|
||||
throw bad("授信到期日必须晚于今天");
|
||||
}
|
||||
Long replay = reserve(customerId, operatorId, "WALLET_CREDIT_LIMIT", idempotencyKey, hash(request));
|
||||
if (replay != null) return requiredLedger(replay, customerId);
|
||||
WalletAccount before = accountForUpdate(customerId);
|
||||
if (before.version() != request.expectedAccountVersion()) {
|
||||
throw error("FIN-WALLET-409", "账户余额版本已变化,请刷新后重试");
|
||||
}
|
||||
if (limit.compareTo(before.creditUsed().add(before.creditFrozen())) < 0) {
|
||||
BigDecimal existingDebt = before.actualBalance().signum() < 0
|
||||
? before.actualBalance().negate() : before.creditUsed();
|
||||
if (limit.compareTo(existingDebt.add(before.creditFrozen())) < 0) {
|
||||
throw error("FIN-WALLET-CREDIT-IN-USE", "授信额度不能低于已用与已冻结授信合计");
|
||||
}
|
||||
BigDecimal delta = limit.subtract(before.creditLimit());
|
||||
if (delta.signum() == 0) throw bad("授信额度未发生变化");
|
||||
WalletAccount after = accountState(before.actualBalance(), before.frozenBalance(), limit,
|
||||
before.creditUsed(), before.creditFrozen(), before.version());
|
||||
if (delta.signum() == 0 && expiresOn.equals(before.creditExpiresOn())) throw bad("授信额度和有效期均未发生变化");
|
||||
BigDecimal carriedDebt = existingDebt;
|
||||
WalletAccount after = accountState(before.actualBalance().max(BigDecimal.ZERO), before.frozenBalance(), limit,
|
||||
carriedDebt, before.creditFrozen(), expiresOn, before.version());
|
||||
updateAccount(customerId, after, before.version(), operatorId);
|
||||
WalletLedgerEntry entry = insertLedger(customerId, "CREDIT_LIMIT", delta, after,
|
||||
"CREDIT_LIMIT_ADJUSTMENT", null, LocalDate.now(), null, null,
|
||||
@@ -273,11 +277,12 @@ public class FinanceWalletService {
|
||||
throw error("FIN-WALLET-INSUFFICIENT", "可用余额和授信额度不足");
|
||||
}
|
||||
BigDecimal actualAvailable = before.actualBalance()
|
||||
.subtract(before.frozenBalance().subtract(before.creditFrozen()));
|
||||
.subtract(before.frozenBalance().subtract(before.creditFrozen())).max(BigDecimal.ZERO);
|
||||
BigDecimal actualFrozen = amount.min(actualAvailable);
|
||||
BigDecimal creditFrozen = amount.subtract(actualFrozen);
|
||||
WalletAccount after = accountState(before.actualBalance(), before.frozenBalance().add(amount),
|
||||
before.creditLimit(), before.creditUsed(), before.creditFrozen().add(creditFrozen), before.version());
|
||||
before.creditLimit(), before.creditUsed(), before.creditFrozen().add(creditFrozen),
|
||||
before.creditExpiresOn(), before.version());
|
||||
updateAccount(customerId, after, before.version(), 0);
|
||||
long holdId = insertHold(customerId, businessType, businessNo, amount, actualFrozen, creditFrozen,
|
||||
clean(request.remark(), 500));
|
||||
@@ -315,12 +320,18 @@ public class FinanceWalletService {
|
||||
WalletHold hold = findHold(customerId, businessType, businessNo, true);
|
||||
if (hold == null) throw error("FIN-WALLET-HOLD-404", "资金冻结记录不存在");
|
||||
if (!"FROZEN".equals(hold.status())) throw error("FIN-WALLET-HOLD-409", "资金冻结记录已处理");
|
||||
BigDecimal actual = settle ? before.actualBalance().subtract(hold.actualFrozenAmount()) : before.actualBalance();
|
||||
BigDecimal creditUsed = settle ? before.creditUsed().add(hold.creditFrozenAmount()) : before.creditUsed();
|
||||
BigDecimal actual = before.actualBalance();
|
||||
BigDecimal creditUsed = before.creditUsed();
|
||||
if (settle && before.creditActive()) {
|
||||
actual = actual.subtract(hold.actualFrozenAmount());
|
||||
creditUsed = creditUsed.add(hold.creditFrozenAmount());
|
||||
} else if (settle) {
|
||||
actual = actual.subtract(hold.amount());
|
||||
}
|
||||
BigDecimal frozen = before.frozenBalance().subtract(hold.amount());
|
||||
BigDecimal creditFrozen = before.creditFrozen().subtract(hold.creditFrozenAmount());
|
||||
WalletAccount after = accountState(actual, frozen, before.creditLimit(), creditUsed,
|
||||
creditFrozen, before.version());
|
||||
creditFrozen, before.creditExpiresOn(), before.version());
|
||||
updateAccount(customerId, after, before.version(), 0);
|
||||
String action = settle ? "SETTLED" : "RELEASED";
|
||||
WalletLedgerEntry ledger = insertLedger(customerId, settle ? "CONSUMPTION" : "UNFREEZE",
|
||||
@@ -346,17 +357,17 @@ public class FinanceWalletService {
|
||||
}
|
||||
|
||||
private WalletAccount accountForUpdate(long customerId) {
|
||||
jdbc.update("INSERT IGNORE INTO fin_wallet_account(customer_id,currency_code,actual_balance,frozen_balance,available_balance,credit_limit,credit_used,credit_frozen,version,created_by,updated_by) VALUES(?,'CNY',0,0,?,?,0,0,0,0,0)",
|
||||
customerId, defaultCreditLimit, defaultCreditLimit);
|
||||
jdbc.update("INSERT IGNORE INTO fin_wallet_account(customer_id,currency_code,actual_balance,frozen_balance,available_balance,credit_limit,credit_used,credit_frozen,credit_expires_on,version,created_by,updated_by) VALUES(?,'CNY',0,0,0,0,0,0,NULL,0,0,0)",
|
||||
customerId);
|
||||
return jdbc.queryForObject(
|
||||
"SELECT currency_code,actual_balance,frozen_balance,available_balance,credit_limit,credit_used,credit_frozen,version,updated_at FROM fin_wallet_account WHERE customer_id=? FOR UPDATE",
|
||||
"SELECT currency_code,actual_balance,frozen_balance,available_balance,credit_limit,credit_used,credit_frozen,credit_expires_on,version,updated_at FROM fin_wallet_account WHERE customer_id=? FOR UPDATE",
|
||||
(rs, n) -> accountRow(rs), customerId);
|
||||
}
|
||||
|
||||
private void updateAccount(long customerId, WalletAccount account, int expectedVersion, long operatorId) {
|
||||
int updated = jdbc.update("UPDATE fin_wallet_account SET actual_balance=?,frozen_balance=?,available_balance=?,credit_limit=?,credit_used=?,credit_frozen=?,version=version+1,updated_by=? WHERE customer_id=? AND version=?",
|
||||
int updated = jdbc.update("UPDATE fin_wallet_account SET actual_balance=?,frozen_balance=?,available_balance=?,credit_limit=?,credit_used=?,credit_frozen=?,credit_expires_on=?,version=version+1,updated_by=? WHERE customer_id=? AND version=?",
|
||||
account.actualBalance(), account.frozenBalance(), account.availableBalance(), account.creditLimit(),
|
||||
account.creditUsed(), account.creditFrozen(), operatorId, customerId, expectedVersion);
|
||||
account.creditUsed(), account.creditFrozen(), account.creditExpiresOn(), operatorId, customerId, expectedVersion);
|
||||
if (updated != 1) throw error("FIN-WALLET-409", "账户余额版本已变化,请刷新后重试");
|
||||
}
|
||||
|
||||
@@ -369,17 +380,17 @@ public class FinanceWalletService {
|
||||
KeyHolder holder = new GeneratedKeyHolder();
|
||||
jdbc.update(connection -> {
|
||||
PreparedStatement statement = connection.prepareStatement(
|
||||
"INSERT INTO fin_wallet_ledger(customer_id,business_no,change_type,amount,actual_balance_after,frozen_balance_after,available_balance_after,credit_limit_after,credit_used_after,available_credit_after,source_type,source_no,occurred_date,payment_method,reference_no,operator_id,operator_name,remark) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
"INSERT INTO fin_wallet_ledger(customer_id,business_no,change_type,amount,actual_balance_after,frozen_balance_after,available_balance_after,credit_limit_after,credit_used_after,available_credit_after,credit_expires_on_after,source_type,source_no,occurred_date,payment_method,reference_no,operator_id,operator_name,remark) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
Statement.RETURN_GENERATED_KEYS);
|
||||
statement.setLong(1, customerId); statement.setString(2, businessNo); statement.setString(3, changeType);
|
||||
statement.setBigDecimal(4, amount); statement.setBigDecimal(5, account.actualBalance());
|
||||
statement.setBigDecimal(6, account.frozenBalance()); statement.setBigDecimal(7, account.availableBalance());
|
||||
statement.setBigDecimal(8, account.creditLimit()); statement.setBigDecimal(9, account.creditUsed());
|
||||
statement.setBigDecimal(10, account.availableCredit()); statement.setString(11, sourceType);
|
||||
statement.setString(12, sourceNo); statement.setObject(13, occurredDate);
|
||||
statement.setString(14, paymentMethod); statement.setString(15, referenceNo);
|
||||
statement.setLong(16, operatorId); statement.setString(17, clean(operatorName, 128));
|
||||
statement.setString(18, clean(remark, 500)); return statement;
|
||||
statement.setBigDecimal(10, account.availableCredit()); statement.setObject(11, account.creditExpiresOn());
|
||||
statement.setString(12, sourceType); statement.setString(13, sourceNo); statement.setObject(14, occurredDate);
|
||||
statement.setString(15, paymentMethod); statement.setString(16, referenceNo);
|
||||
statement.setLong(17, operatorId); statement.setString(18, clean(operatorName, 128));
|
||||
statement.setString(19, clean(remark, 500)); return statement;
|
||||
}, holder);
|
||||
return requiredLedger(generatedId(holder), customerId);
|
||||
}
|
||||
@@ -392,48 +403,70 @@ public class FinanceWalletService {
|
||||
}
|
||||
|
||||
WalletAccount rechargeAccount(WalletAccount before, BigDecimal amount) {
|
||||
BigDecimal creditRepayment = amount.min(before.creditUsed());
|
||||
return accountState(before.actualBalance().add(amount.subtract(creditRepayment)), before.frozenBalance(),
|
||||
before.creditLimit(), before.creditUsed().subtract(creditRepayment), before.creditFrozen(), before.version());
|
||||
BigDecimal repaidCredit = before.creditActive() ? amount.min(before.creditUsed()) : BigDecimal.ZERO;
|
||||
return accountState(before.actualBalance().add(amount.subtract(repaidCredit)), before.frozenBalance(),
|
||||
before.creditLimit(), before.creditUsed().subtract(repaidCredit), before.creditFrozen(),
|
||||
before.creditExpiresOn(), before.version());
|
||||
}
|
||||
|
||||
WalletAccount deductAccount(WalletAccount before, BigDecimal amount) {
|
||||
BigDecimal actualAvailable = before.actualBalance()
|
||||
.subtract(before.frozenBalance().subtract(before.creditFrozen()));
|
||||
if (!before.creditActive()) {
|
||||
return accountState(before.actualBalance().subtract(amount), before.frozenBalance(),
|
||||
before.creditLimit(), BigDecimal.ZERO, before.creditFrozen(), before.creditExpiresOn(), before.version());
|
||||
}
|
||||
BigDecimal actualFrozen = before.frozenBalance().subtract(before.creditFrozen());
|
||||
BigDecimal actualAvailable = before.actualBalance().subtract(actualFrozen).max(BigDecimal.ZERO);
|
||||
BigDecimal actualDeduction = amount.min(actualAvailable);
|
||||
BigDecimal creditDeduction = amount.subtract(actualDeduction);
|
||||
return accountState(before.actualBalance().subtract(actualDeduction), before.frozenBalance(),
|
||||
before.creditLimit(), before.creditUsed().add(creditDeduction), before.creditFrozen(), before.version());
|
||||
before.creditLimit(), before.creditUsed().add(amount.subtract(actualDeduction)),
|
||||
before.creditFrozen(), before.creditExpiresOn(), before.version());
|
||||
}
|
||||
|
||||
WalletAccount accountState(BigDecimal actual, BigDecimal frozen, BigDecimal creditLimit,
|
||||
BigDecimal creditUsed, BigDecimal creditFrozen, int version) {
|
||||
BigDecimal availableCredit = creditLimit.subtract(creditUsed).subtract(creditFrozen);
|
||||
BigDecimal creditFrozen, LocalDate creditExpiresOn, int version) {
|
||||
return accountState(actual, frozen, creditLimit, BigDecimal.ZERO, creditFrozen, creditExpiresOn, version);
|
||||
}
|
||||
|
||||
WalletAccount accountState(BigDecimal actual, BigDecimal frozen, BigDecimal creditLimit,
|
||||
BigDecimal creditUsed, BigDecimal creditFrozen,
|
||||
LocalDate creditExpiresOn, int version) {
|
||||
boolean creditActive = creditLimit.signum() > 0 && creditExpiresOn != null
|
||||
&& !creditExpiresOn.isBefore(LocalDate.now());
|
||||
if (!creditActive && creditUsed.signum() > 0) {
|
||||
actual = actual.subtract(creditUsed);
|
||||
creditUsed = BigDecimal.ZERO.setScale(2);
|
||||
}
|
||||
BigDecimal availableCredit = creditActive
|
||||
? creditLimit.subtract(creditUsed).subtract(creditFrozen) : BigDecimal.ZERO.setScale(2);
|
||||
BigDecimal actualFrozen = frozen.subtract(creditFrozen);
|
||||
BigDecimal available = actual.subtract(actualFrozen).add(availableCredit);
|
||||
if (actual.signum() < 0 || frozen.signum() < 0 || creditLimit.signum() < 0 || creditUsed.signum() < 0
|
||||
|| creditFrozen.signum() < 0 || actualFrozen.signum() < 0 || actual.compareTo(actualFrozen) < 0
|
||||
|| availableCredit.signum() < 0 || available.signum() < 0) {
|
||||
BigDecimal available = creditActive
|
||||
? actual.subtract(actualFrozen).add(availableCredit)
|
||||
: actual.subtract(frozen);
|
||||
if (frozen.signum() < 0 || creditLimit.signum() < 0 || creditFrozen.signum() < 0
|
||||
|| creditUsed.signum() < 0 || actualFrozen.signum() < 0
|
||||
|| (creditActive && (actual.signum() < 0 || availableCredit.signum() < 0))) {
|
||||
throw error("FIN-WALLET-409", "账户余额状态无效");
|
||||
}
|
||||
return new WalletAccount("CNY", actual, frozen, available, creditLimit, creditUsed,
|
||||
creditFrozen, availableCredit, version, null);
|
||||
creditFrozen, availableCredit, creditExpiresOn, creditActive, version, null);
|
||||
}
|
||||
|
||||
private WalletAccount accountRow(java.sql.ResultSet rs) throws java.sql.SQLException {
|
||||
BigDecimal creditLimit = rs.getBigDecimal("credit_limit");
|
||||
BigDecimal creditUsed = rs.getBigDecimal("credit_used");
|
||||
BigDecimal creditFrozen = rs.getBigDecimal("credit_frozen");
|
||||
return new WalletAccount(rs.getString("currency_code"), rs.getBigDecimal("actual_balance"),
|
||||
rs.getBigDecimal("frozen_balance"), rs.getBigDecimal("available_balance"), creditLimit, creditUsed,
|
||||
creditFrozen, creditLimit.subtract(creditUsed).subtract(creditFrozen), rs.getInt("version"),
|
||||
LocalDate expiresOn = rs.getDate("credit_expires_on") == null ? null : rs.getDate("credit_expires_on").toLocalDate();
|
||||
WalletAccount calculated = accountState(rs.getBigDecimal("actual_balance"), rs.getBigDecimal("frozen_balance"),
|
||||
creditLimit, rs.getBigDecimal("credit_used"), creditFrozen, expiresOn, rs.getInt("version"));
|
||||
return new WalletAccount(calculated.currencyCode(), calculated.actualBalance(), calculated.frozenBalance(),
|
||||
calculated.availableBalance(), calculated.creditLimit(), calculated.creditUsed(), calculated.creditFrozen(),
|
||||
calculated.availableCredit(), calculated.creditExpiresOn(), calculated.creditActive(), calculated.version(),
|
||||
timestamp(rs.getTimestamp("updated_at")));
|
||||
}
|
||||
|
||||
private WalletAccount zeroAccount() {
|
||||
BigDecimal zero = BigDecimal.ZERO.setScale(2);
|
||||
return new WalletAccount("CNY", zero, zero, defaultCreditLimit, defaultCreditLimit, zero,
|
||||
zero, defaultCreditLimit, 0, null);
|
||||
return new WalletAccount("CNY", zero, zero, zero, zero, zero,
|
||||
zero, zero, null, false, 0, null);
|
||||
}
|
||||
|
||||
private long insertHold(long customerId, String businessType, String businessNo, BigDecimal amount,
|
||||
@@ -546,6 +579,7 @@ public class FinanceWalletService {
|
||||
rs.getBigDecimal("amount"), rs.getBigDecimal("actual_balance_after"), rs.getBigDecimal("frozen_balance_after"),
|
||||
rs.getBigDecimal("available_balance_after"), rs.getBigDecimal("credit_limit_after"),
|
||||
rs.getBigDecimal("credit_used_after"), rs.getBigDecimal("available_credit_after"),
|
||||
rs.getObject("credit_expires_on_after", LocalDate.class),
|
||||
rs.getString("source_type"), rs.getString("source_no"),
|
||||
rs.getObject("occurred_date", LocalDate.class), rs.getString("payment_method"), rs.getString("reference_no"),
|
||||
rs.getString("operator_name"), rs.getString("remark"), timestamp(rs.getTimestamp("created_at")));
|
||||
|
||||
@@ -443,12 +443,13 @@ CREATE TABLE IF NOT EXISTS fin_wallet_account (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和隔离键',
|
||||
currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '当前仅CNY',
|
||||
actual_balance DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '账户实际余额,包含冻结资金',
|
||||
actual_balance DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '账户实际余额,授信消费可形成负余额',
|
||||
frozen_balance DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '冻结总额,包含实际资金与授信冻结',
|
||||
available_balance DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '总可用余额=实际余额+授信额度-已用授信-冻结总额',
|
||||
available_balance DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '当前总可用余额;授信失效后不再计入',
|
||||
credit_limit DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '授信额度',
|
||||
credit_used DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '已结算使用的授信',
|
||||
credit_used DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '授信有效期内累计已使用额度;到期后并入实际余额',
|
||||
credit_frozen DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '冻结总额中占用的授信',
|
||||
credit_expires_on DATE NULL COMMENT '授信到期日,当天仍有效;次日失效',
|
||||
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '余额乐观锁版本',
|
||||
created_by BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建操作人ID',
|
||||
updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '最后操作人ID',
|
||||
@@ -458,7 +459,7 @@ CREATE TABLE IF NOT EXISTS fin_wallet_account (
|
||||
UNIQUE KEY uk_fin_wallet_account_customer (customer_id, currency_code),
|
||||
CONSTRAINT chk_fin_wallet_account_customer CHECK (customer_id > 0),
|
||||
CONSTRAINT chk_fin_wallet_account_currency CHECK (currency_code = 'CNY'),
|
||||
CONSTRAINT chk_fin_wallet_account_balance CHECK (actual_balance >= 0 AND frozen_balance >= 0 AND available_balance >= 0 AND credit_limit >= 0 AND credit_used >= 0 AND credit_frozen >= 0 AND frozen_balance >= credit_frozen AND actual_balance >= frozen_balance-credit_frozen AND credit_limit >= credit_used+credit_frozen AND available_balance = actual_balance+credit_limit-credit_used-frozen_balance)
|
||||
CONSTRAINT chk_fin_wallet_account_balance CHECK (frozen_balance >= 0 AND credit_limit >= 0 AND credit_used >= 0 AND credit_frozen >= 0 AND frozen_balance >= credit_frozen)
|
||||
) ENGINE=InnoDB COMMENT='客户CNY资金账户主账';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fin_recharge_request (
|
||||
@@ -500,6 +501,7 @@ CREATE TABLE IF NOT EXISTS fin_wallet_ledger (
|
||||
credit_limit_after DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '变动后授信额度',
|
||||
credit_used_after DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '变动后已用授信',
|
||||
available_credit_after DECIMAL(20,2) NOT NULL DEFAULT 0 COMMENT '变动后可用授信',
|
||||
credit_expires_on_after DATE NULL COMMENT '变动后授信到期日',
|
||||
source_type VARCHAR(32) NOT NULL COMMENT 'CLIENT_RECHARGE/MANUAL_RECHARGE/MANUAL_DEDUCTION等',
|
||||
source_no VARCHAR(128) NULL COMMENT '充值申请号或外部凭证号',
|
||||
occurred_date DATE NOT NULL COMMENT '业务发生日期',
|
||||
@@ -514,8 +516,8 @@ CREATE TABLE IF NOT EXISTS fin_wallet_ledger (
|
||||
KEY idx_fin_wallet_ledger_customer_time (customer_id, created_at, id),
|
||||
KEY idx_fin_wallet_ledger_source (customer_id, source_type, source_no),
|
||||
CONSTRAINT chk_fin_wallet_ledger_customer CHECK (customer_id > 0),
|
||||
CONSTRAINT chk_fin_wallet_ledger_amount CHECK (amount <> 0),
|
||||
CONSTRAINT chk_fin_wallet_ledger_balance CHECK (actual_balance_after >= 0 AND frozen_balance_after >= 0 AND available_balance_after >= 0 AND credit_limit_after >= 0 AND credit_used_after >= 0 AND available_credit_after >= 0 AND credit_limit_after >= credit_used_after+available_credit_after AND available_balance_after = actual_balance_after-frozen_balance_after+credit_limit_after-credit_used_after)
|
||||
CONSTRAINT chk_fin_wallet_ledger_amount CHECK (amount <> 0 OR change_type='CREDIT_LIMIT'),
|
||||
CONSTRAINT chk_fin_wallet_ledger_balance CHECK (frozen_balance_after >= 0 AND credit_limit_after >= 0 AND credit_used_after >= 0 AND available_credit_after >= 0)
|
||||
) ENGINE=InnoDB COMMENT='客户资金账户不可变余额流水';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fin_wallet_hold (
|
||||
|
||||
@@ -23,7 +23,7 @@ import static org.mockito.Mockito.when;
|
||||
|
||||
class FinanceCustomerAccountServiceTest {
|
||||
@Test
|
||||
void listsMasterCustomersWithConfiguredInitialCreditAndForwardsSearchFields() {
|
||||
void listsMasterCustomersWithoutImplicitPermanentCreditAndForwardsSearchFields() {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
IamCustomerClient iam = mock(IamCustomerClient.class);
|
||||
when(jdbc.queryForList(anyString(), any(Object[].class))).thenReturn(List.of());
|
||||
@@ -32,7 +32,7 @@ class FinanceCustomerAccountServiceTest {
|
||||
new IamCustomerClient.Customer(900000000000000001L, "TEN-001", "Alice Trading", "ACTIVE", "Alice", "13800138000")),
|
||||
1, 20, 1, 1)));
|
||||
|
||||
var result = new FinanceCustomerAccountService(jdbc, iam, "internal-token", new BigDecimal("8000.00"))
|
||||
var result = new FinanceCustomerAccountService(jdbc, iam, "internal-token")
|
||||
.list(1, 20, " Alice ", "active", 900000000000000001L);
|
||||
|
||||
AccountSummary row = result.data().get(0);
|
||||
@@ -41,9 +41,11 @@ class FinanceCustomerAccountServiceTest {
|
||||
assertThat(row.contactPhone()).isEqualTo("13800138000");
|
||||
assertThat(row.outstandingReceivable()).isZero();
|
||||
assertThat(row.pendingBilling()).isZero();
|
||||
assertThat(row.creditLimit()).isEqualByComparingTo("8000.00");
|
||||
assertThat(row.availableCredit()).isEqualByComparingTo("8000.00");
|
||||
assertThat(row.availableBalance()).isEqualByComparingTo("8000.00");
|
||||
assertThat(row.creditLimit()).isZero();
|
||||
assertThat(row.availableCredit()).isZero();
|
||||
assertThat(row.availableBalance()).isZero();
|
||||
assertThat(row.creditExpiresOn()).isNull();
|
||||
assertThat(row.creditActive()).isFalse();
|
||||
verify(iam).page("internal-token", 1, 20, "Alice", "ACTIVE", 900000000000000001L);
|
||||
}
|
||||
|
||||
@@ -56,7 +58,7 @@ class FinanceCustomerAccountServiceTest {
|
||||
new PageResult<>(List.of(new IamCustomerClient.Customer(9L, "C-9", "客户9", "ACTIVE", null, null)),
|
||||
1, 20, 1, 1)));
|
||||
|
||||
new FinanceCustomerAccountService(jdbc, iam, "internal-token", BigDecimal.ZERO).list(1, 20, null, null, 9L);
|
||||
new FinanceCustomerAccountService(jdbc, iam, "internal-token").list(1, 20, null, null, 9L);
|
||||
|
||||
var sql = org.mockito.ArgumentCaptor.forClass(String.class);
|
||||
verify(jdbc, atLeastOnce()).queryForList(sql.capture(), any(Object[].class));
|
||||
@@ -72,7 +74,7 @@ class FinanceCustomerAccountServiceTest {
|
||||
|
||||
@Test
|
||||
void rejectsUnsupportedFinanceAccountStatus() {
|
||||
var service = new FinanceCustomerAccountService(mock(JdbcTemplate.class), mock(IamCustomerClient.class), "token", BigDecimal.ZERO);
|
||||
var service = new FinanceCustomerAccountService(mock(JdbcTemplate.class), mock(IamCustomerClient.class), "token");
|
||||
assertThatThrownBy(() -> service.list(1, 20, null, "PENDING", null))
|
||||
.isInstanceOf(FinanceEventException.class)
|
||||
.extracting(error -> ((FinanceEventException) error).code())
|
||||
|
||||
@@ -375,7 +375,7 @@ class FinancePhase1OperationBehaviorTest {
|
||||
new BigDecimal("20.00"), new BigDecimal("30.00"), BigDecimal.ZERO,
|
||||
BigDecimal.ZERO, BigDecimal.ZERO,
|
||||
BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO,
|
||||
BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, 0, 0,
|
||||
BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, null, false, 0, 0,
|
||||
"ACTIVE", LocalDateTime.of(2026, 8, 11, 10, 0)));
|
||||
}
|
||||
if (sql.startsWith("SELECT id,bill_no") || sql.startsWith("SELECT id,receipt_no")
|
||||
|
||||
@@ -44,7 +44,7 @@ class FinanceSqlCommentContractTest {
|
||||
}
|
||||
}
|
||||
assertThat(tableCount).isEqualTo(23);
|
||||
assertThat(columnCount).isEqualTo(351);
|
||||
assertThat(columnCount).isEqualTo(353);
|
||||
assertThat(missing).as("缺少中文业务注释的Finance字段").isEmpty();
|
||||
}
|
||||
|
||||
|
||||
@@ -9,17 +9,34 @@ import java.nio.file.Path;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class FinanceWalletContractTest {
|
||||
@Test
|
||||
void unifiedMigrationDropsTheLegacyBalanceConstraintBeforeRewritingBalances() throws Exception {
|
||||
Path migration = Path.of("deploy/sql/头程数据库统一增量-MySQL8.sql");
|
||||
if (!Files.exists(migration)) migration = Path.of("..").resolve(migration);
|
||||
String sql = Files.readString(migration, StandardCharsets.UTF_8);
|
||||
int procedure = sql.indexOf("CREATE PROCEDURE migrate_fin_wallet_credit()");
|
||||
int dropConstraint = sql.indexOf("ALTER TABLE fin_wallet_account DROP CHECK chk_fin_wallet_account_balance", procedure);
|
||||
int rewriteBalance = sql.indexOf("SET actual_balance=actual_balance-credit_used", procedure);
|
||||
|
||||
assertThat(procedure).isGreaterThanOrEqualTo(0);
|
||||
assertThat(sql.substring(procedure, rewriteBalance))
|
||||
.contains("CHECK_CLAUSE LIKE '%actual_balance%'", "v_legacy_wallet_constraint OR v_credit_expiry_missing");
|
||||
assertThat(dropConstraint).isBetween(procedure, rewriteBalance - 1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void schemaKeepsBalancesConsistentAndRechargeReviewTraceable() throws Exception {
|
||||
String sql = Files.readString(Path.of("src/main/resources/db/finance.sql"), StandardCharsets.UTF_8);
|
||||
assertThat(sql)
|
||||
.contains("CREATE TABLE IF NOT EXISTS fin_wallet_account")
|
||||
.contains("available_balance = actual_balance+credit_limit-credit_used-frozen_balance")
|
||||
.contains("credit_limit >= credit_used+credit_frozen")
|
||||
.contains("credit_expires_on DATE NULL")
|
||||
.contains("actual_balance DECIMAL(20,2) NOT NULL DEFAULT 0")
|
||||
.contains("frozen_balance >= 0 AND credit_limit >= 0 AND credit_used >= 0")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fin_recharge_request")
|
||||
.contains("status IN ('PENDING','APPROVED','REJECTED')")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fin_wallet_ledger")
|
||||
.contains("amount <> 0")
|
||||
.contains("amount <> 0 OR change_type='CREDIT_LIMIT'")
|
||||
.contains("credit_expires_on_after DATE NULL")
|
||||
.contains("uk_fin_wallet_ledger_no")
|
||||
.contains("idx_fin_recharge_customer_status")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fin_wallet_hold")
|
||||
|
||||
+44
-64
@@ -1,7 +1,6 @@
|
||||
package com.baiyue.finance.service;
|
||||
|
||||
import com.baiyue.finance.client.FinanceFileClient;
|
||||
import com.baiyue.finance.dto.FinanceWalletDtos.AccountAdjustmentRequest;
|
||||
import com.baiyue.finance.dto.FinanceWalletDtos.WalletAccount;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -9,8 +8,6 @@ import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.core.RowMapper;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
@@ -48,7 +45,7 @@ class FinanceWalletBehaviorTest {
|
||||
void developmentProfileUsesTheNonPayableSampleQrCode() {
|
||||
var channels = new FinanceWalletService(mock(JdbcTemplate.class), new ObjectMapper().findAndRegisterModules(),
|
||||
mock(FinanceFileClient.class), mock(FinanceDocumentNumberGenerator.class), "token", "", "",
|
||||
BigDecimal.ZERO, "cn", "development").channels();
|
||||
"cn", "development").channels();
|
||||
|
||||
assertThat(channels).allSatisfy(channel -> {
|
||||
assertThat(channel.enabled()).isTrue();
|
||||
@@ -57,44 +54,29 @@ class FinanceWalletBehaviorTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void manualDeductionCannotExceedAvailableBalance() throws Exception {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
when(jdbc.queryForList(startsWith("SELECT request_hash"), any(Object[].class))).thenReturn(List.of());
|
||||
when(jdbc.update(startsWith("INSERT INTO fin_idempotency_record"), any(Object[].class))).thenReturn(1);
|
||||
when(jdbc.update(startsWith("INSERT IGNORE INTO fin_wallet_account"), any(Object[].class))).thenReturn(1);
|
||||
when(jdbc.queryForObject(startsWith("SELECT currency_code"), any(RowMapper.class), eq(9L))).thenAnswer(invocation -> {
|
||||
@SuppressWarnings("unchecked") RowMapper<Object> mapper = invocation.getArgument(1);
|
||||
ResultSet rs = mock(ResultSet.class);
|
||||
when(rs.getString("currency_code")).thenReturn("CNY");
|
||||
when(rs.getBigDecimal("actual_balance")).thenReturn(new BigDecimal("10.00"));
|
||||
when(rs.getBigDecimal("frozen_balance")).thenReturn(BigDecimal.ZERO);
|
||||
when(rs.getBigDecimal("available_balance")).thenReturn(new BigDecimal("10.00"));
|
||||
when(rs.getBigDecimal("credit_limit")).thenReturn(BigDecimal.ZERO);
|
||||
when(rs.getBigDecimal("credit_used")).thenReturn(BigDecimal.ZERO);
|
||||
when(rs.getBigDecimal("credit_frozen")).thenReturn(BigDecimal.ZERO);
|
||||
when(rs.getInt("version")).thenReturn(2);
|
||||
when(rs.getTimestamp("updated_at")).thenReturn(Timestamp.valueOf("2026-09-05 08:00:00"));
|
||||
return mapper.mapRow(rs, 0);
|
||||
});
|
||||
void deductionMayMakeActualBalanceNegative() {
|
||||
FinanceWalletService service = service(mock(JdbcTemplate.class), "x", "y");
|
||||
WalletAccount initial = service.accountState(new BigDecimal("10.00"), BigDecimal.ZERO,
|
||||
BigDecimal.ZERO, BigDecimal.ZERO, null, 2);
|
||||
|
||||
assertThatThrownBy(() -> service(jdbc, "x", "y").adjust(9L, 7L, "财务员", "deduct-1",
|
||||
new AccountAdjustmentRequest("DEDUCTION", new BigDecimal("10.01"), LocalDate.now(),
|
||||
"BANK_TRANSFER", "REF-1", "人工扣款", 2)))
|
||||
.isInstanceOf(FinanceEventException.class)
|
||||
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-WALLET-INSUFFICIENT");
|
||||
verify(jdbc, never()).update(startsWith("UPDATE fin_wallet_account SET actual_balance="), any(Object[].class));
|
||||
WalletAccount deducted = service.deductAccount(initial, new BigDecimal("10.01"));
|
||||
|
||||
assertThat(deducted.actualBalance()).isEqualByComparingTo("-0.01");
|
||||
assertThat(deducted.availableBalance()).isEqualByComparingTo("-0.01");
|
||||
assertThat(deducted.creditActive()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void configuredCreditLimitIsUsedForAFirstAccountRead() {
|
||||
void aMissingAccountNeverReceivesPermanentDefaultCredit() {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
when(jdbc.query(startsWith("SELECT currency_code"), any(RowMapper.class), eq(9L))).thenReturn(List.of());
|
||||
|
||||
var account = service(jdbc, "x", "y", new BigDecimal("8000.00")).account(9L);
|
||||
var account = service(jdbc, "x", "y").account(9L);
|
||||
|
||||
assertThat(account.creditLimit()).isEqualByComparingTo("8000.00");
|
||||
assertThat(account.availableCredit()).isEqualByComparingTo("8000.00");
|
||||
assertThat(account.availableBalance()).isEqualByComparingTo("8000.00");
|
||||
assertThat(account.creditLimit()).isZero();
|
||||
assertThat(account.availableCredit()).isZero();
|
||||
assertThat(account.availableBalance()).isZero();
|
||||
assertThat(account.creditExpiresOn()).isNull();
|
||||
verify(jdbc, never()).update(anyString(), any(Object[].class));
|
||||
}
|
||||
|
||||
@@ -102,7 +84,7 @@ class FinanceWalletBehaviorTest {
|
||||
void deductionUsesCashBeforeCreditAndRechargeRepaysCreditFirst() {
|
||||
FinanceWalletService service = service(mock(JdbcTemplate.class), "x", "y");
|
||||
WalletAccount initial = service.accountState(new BigDecimal("100.00"), BigDecimal.ZERO,
|
||||
new BigDecimal("50.00"), BigDecimal.ZERO, BigDecimal.ZERO, 3);
|
||||
new BigDecimal("50.00"), BigDecimal.ZERO, LocalDate.now().plusDays(7), 3);
|
||||
|
||||
WalletAccount deducted = service.deductAccount(initial, new BigDecimal("120.00"));
|
||||
assertThat(deducted.actualBalance()).isEqualByComparingTo("0.00");
|
||||
@@ -120,7 +102,7 @@ class FinanceWalletBehaviorTest {
|
||||
void deductionNeverConsumesCashReservedByAnActiveHold() {
|
||||
FinanceWalletService service = service(mock(JdbcTemplate.class), "x", "y");
|
||||
WalletAccount initial = service.accountState(new BigDecimal("100.00"), new BigDecimal("50.00"),
|
||||
new BigDecimal("80.00"), BigDecimal.ZERO, new BigDecimal("20.00"), 4);
|
||||
new BigDecimal("80.00"), new BigDecimal("20.00"), LocalDate.now().plusDays(7), 4);
|
||||
|
||||
WalletAccount deducted = service.deductAccount(initial, new BigDecimal("80.00"));
|
||||
|
||||
@@ -135,37 +117,40 @@ class FinanceWalletBehaviorTest {
|
||||
@Test
|
||||
void rechargeRepaysUsedCreditWithoutReleasingFrozenCredit() {
|
||||
FinanceWalletService service = service(mock(JdbcTemplate.class), "x", "y");
|
||||
WalletAccount initial = service.accountState(new BigDecimal("30.00"), new BigDecimal("25.00"),
|
||||
new BigDecimal("100.00"), new BigDecimal("40.00"), new BigDecimal("20.00"), 5);
|
||||
WalletAccount initial = service.accountState(new BigDecimal("5.00"), new BigDecimal("25.00"),
|
||||
new BigDecimal("100.00"), new BigDecimal("40.00"), new BigDecimal("20.00"),
|
||||
LocalDate.now().plusDays(7), 5);
|
||||
|
||||
WalletAccount recharged = service.rechargeAccount(initial, new BigDecimal("50.00"));
|
||||
|
||||
assertThat(recharged.actualBalance()).isEqualByComparingTo("40.00");
|
||||
assertThat(recharged.actualBalance()).isEqualByComparingTo("15.00");
|
||||
assertThat(recharged.creditUsed()).isEqualByComparingTo("0.00");
|
||||
assertThat(recharged.creditFrozen()).isEqualByComparingTo("20.00");
|
||||
assertThat(recharged.frozenBalance()).isEqualByComparingTo("25.00");
|
||||
assertThat(recharged.availableCredit()).isEqualByComparingTo("80.00");
|
||||
assertThat(recharged.availableBalance()).isEqualByComparingTo("115.00");
|
||||
assertThat(recharged.availableBalance()).isEqualByComparingTo("90.00");
|
||||
}
|
||||
|
||||
@Test
|
||||
void accountStateRejectsCreditUsedAndFrozenAboveTheLimit() {
|
||||
FinanceWalletService service = service(mock(JdbcTemplate.class), "x", "y");
|
||||
|
||||
assertThatThrownBy(() -> service.accountState(new BigDecimal("10.00"), new BigDecimal("25.00"),
|
||||
new BigDecimal("50.00"), new BigDecimal("31.00"), new BigDecimal("20.00"), 1))
|
||||
assertThatThrownBy(() -> service.accountState(BigDecimal.ZERO, new BigDecimal("20.00"),
|
||||
new BigDecimal("50.00"), new BigDecimal("31.00"), new BigDecimal("20.00"),
|
||||
LocalDate.now().plusDays(7), 1))
|
||||
.isInstanceOf(FinanceEventException.class)
|
||||
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-WALLET-409");
|
||||
}
|
||||
|
||||
@Test
|
||||
void accountStateRejectsFrozenCashAboveActualCash() {
|
||||
void accountStateAllowsBalanceToBecomeNegativeWhileFundsRemainFrozen() {
|
||||
FinanceWalletService service = service(mock(JdbcTemplate.class), "x", "y");
|
||||
|
||||
assertThatThrownBy(() -> service.accountState(new BigDecimal("9.99"), new BigDecimal("10.00"),
|
||||
new BigDecimal("20.00"), BigDecimal.ZERO, BigDecimal.ZERO, 1))
|
||||
.isInstanceOf(FinanceEventException.class)
|
||||
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-WALLET-409");
|
||||
WalletAccount account = service.accountState(new BigDecimal("9.99"), new BigDecimal("10.00"),
|
||||
new BigDecimal("20.00"), BigDecimal.ZERO, LocalDate.now().plusDays(7), 1);
|
||||
|
||||
assertThat(account.availableBalance()).isEqualByComparingTo("19.99");
|
||||
assertThat(account.creditActive()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -173,32 +158,27 @@ class FinanceWalletBehaviorTest {
|
||||
FinanceWalletService service = service(mock(JdbcTemplate.class), "x", "y");
|
||||
|
||||
assertThatThrownBy(() -> service.accountState(new BigDecimal("100.00"), new BigDecimal("10.00"),
|
||||
new BigDecimal("50.00"), BigDecimal.ZERO, new BigDecimal("10.01"), 1))
|
||||
new BigDecimal("50.00"), new BigDecimal("10.01"), LocalDate.now().plusDays(7), 1))
|
||||
.isInstanceOf(FinanceEventException.class)
|
||||
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-WALLET-409");
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidDefaultCreditConfigurationFailsClosed() {
|
||||
assertThatThrownBy(() -> service(mock(JdbcTemplate.class), "x", "y", new BigDecimal("-0.01")))
|
||||
.isInstanceOf(FinanceEventException.class)
|
||||
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-REQ-400");
|
||||
assertThatThrownBy(() -> service(mock(JdbcTemplate.class), "x", "y", new BigDecimal("1.001")))
|
||||
.isInstanceOf(FinanceEventException.class)
|
||||
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-REQ-400");
|
||||
assertThatThrownBy(() -> service(mock(JdbcTemplate.class), "x", "y", new BigDecimal("1000000000000.00")))
|
||||
.isInstanceOf(FinanceEventException.class)
|
||||
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-REQ-400");
|
||||
void expiredCreditNoLongerContributesToAvailableBalance() {
|
||||
FinanceWalletService service = service(mock(JdbcTemplate.class), "x", "y");
|
||||
|
||||
WalletAccount account = service.accountState(BigDecimal.ZERO, BigDecimal.ZERO,
|
||||
new BigDecimal("50.00"), new BigDecimal("20.00"), BigDecimal.ZERO,
|
||||
LocalDate.now().minusDays(1), 1);
|
||||
|
||||
assertThat(account.creditActive()).isFalse();
|
||||
assertThat(account.availableCredit()).isZero();
|
||||
assertThat(account.availableBalance()).isEqualByComparingTo("-20.00");
|
||||
}
|
||||
|
||||
private static FinanceWalletService service(JdbcTemplate jdbc, String wechat, String alipay) {
|
||||
return service(jdbc, wechat, alipay, BigDecimal.ZERO);
|
||||
}
|
||||
|
||||
private static FinanceWalletService service(JdbcTemplate jdbc, String wechat, String alipay,
|
||||
BigDecimal defaultCreditLimit) {
|
||||
return new FinanceWalletService(jdbc, new ObjectMapper().findAndRegisterModules(),
|
||||
mock(FinanceFileClient.class), mock(FinanceDocumentNumberGenerator.class), "token", wechat, alipay,
|
||||
defaultCreditLimit, "test", "test");
|
||||
"test", "test");
|
||||
}
|
||||
}
|
||||
+7
-6
@@ -19,8 +19,8 @@
|
||||
| 入库单扣货、履约门禁与凭证 | `service/InboundHoldService.java`、`InboundHoldGuard.java`、`InboundHoldEvidenceService.java`、`client/InboundHoldFileClient.java`;凭证固定按客户、入库单和 `INBOUND_HOLD_EVIDENCE` 精确归属,只有进入不可变扣货历史的文件可下载 |
|
||||
| Order 申报同步与渠道变更履约门禁 | `controller/InternalInboundOrderController.java` 的 `/order-channel-changes/validate`、`/order-declaration-updated`,`service/OrderDeclarationSyncService.java`、`mapper/OrderDeclarationSyncMapper.java`、`dto/OrderDeclarationSync.java`;契约 `src/main/resources/openapi/firstleg-v1.yaml`,只接受内部服务令牌 |
|
||||
| 平台测量/OCR 与箱规计费快照 | `controller/PlatformInboundMeasurementController.java`、`service/InboundMeasurementService.java`、`service/CartonMeasurementService.java`、`service/CustomerDimensionWeightingService.java`;手工单箱测量保存实际尺寸、可选外箱修正、`chargeRuleId` 与 `coefficientMode`,计算结果以服务端返回值为准 |
|
||||
| 箱唛箱码生成、打印与测量绑定 | 审核建单生成钩子 `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 时禁止生成箱唛,打印面显示 SKU;`box_code` 与二维码内容仍只携带稳定箱码。基线/升级 SQL 为 `src/main/resources/db/phase2-inbound-carton-label.sql`、`phase2-carton-label-versioning-migration.sql`、`../deploy/sql/头程数据库统一增量-MySQL8.sql` |
|
||||
| 一件代发测量后 PDA 合托 | `controller/PlatformDropshipPalletizationController.java`、`service/DropshipPalletizationService.java`、`mapper/DropshipPalletizationMapper.java`;测量通过进入 `WAIT_PALLETIZE` 并下发独立任务,不创建国内库存,封托生成 `DP` 托盘号,全部箱数完成后进入 `WAIT_SPLIT`。契约 `src/main/resources/openapi/dropship-palletization-v1.yaml` |
|
||||
| 箱唛箱码生成、打印与测量绑定 | 审核建单生成钩子 `controller/InternalInboundOrderController.java`;生成/解析 `service/InboundCartonLabelService.java`、`repository/InboundCartonLabelRepository.java`;平台与客户打印 `controller/PlatformInboundOrderController.java`、`MerchantInboundCartonLabelController.java`、`service/InboundCartonLabelPrintService.java`。一件代发箱唛以 `fl_inbound_carton_label` 表示物理箱,`fl_inbound_carton_label_sku` 冻结箱内一条或多条 SKU;生成时经 `client/OrderCargoClient.java` 获取。缺 SKU 时禁止生成,打印面显示箱内 SKU;`box_code` 与二维码内容仍只携带稳定箱码。基线/升级 SQL 为 `src/main/resources/db/phase2-inbound-carton-label.sql`、`phase2-carton-label-versioning-migration.sql`、`../deploy/sql/头程数据库统一增量-MySQL8.sql` |
|
||||
| 一件代发测量后 PDA 清点组托 | `controller/PlatformDropshipPalletizationController.java`、`service/DropshipPalletizationService.java`、`mapper/DropshipPalletizationMapper.java`;测量审核通过进入 `WAIT_PALLETIZE`,按预计托数预生成 `DP` 托盘码并下发独立任务,不创建国内库存;托盘信息单固定为每托一页 A4,包含托盘 Code 128 一维码、入仓码、总箱数与 SKU/商品名称/箱数/实点数量,不展示作业状态。PDA 逐箱清点,支持先扫箱或先扫托、归托和逐托封托,预计托数与已生成托盘数不一致时禁止作业,全部箱和全部预生成托盘完成后进入 `WAIT_SPLIT`。契约 `src/main/resources/openapi/dropship-palletization-v1.yaml` |
|
||||
| 墨家设备测量上传 | `controller/OpenApiInboundMeasurementController.java`、`dto/MojiaMeasurementDtos.java`、`service/MojiaMeasurementOpenApiService.java`、`security/MeasurementRequestAuthenticator.java`;接口契约见 `src/main/resources/openapi/firstleg-v1.yaml` 与 `../../docs/墨家设备测量上传接口说明-V3.md` |
|
||||
| 称重确认与整票库位块 | `service/MeasurementLocationOccupationService.java` 的 `ensureForConfirmedWeight/ensureForSubmission`、`InboundMeasurementService.java`、`MeasurementSubmissionService.java`、`client/WarehousePalletLocationClient.java`;Warehouse 块服务见 `../warehouse/src/main/java/com/baiyue/warehouse/service/LocationBlockService.java`,接口与上架引导见 `src/main/resources/openapi/firstleg-v1.yaml` |
|
||||
| 审核与初始计价 | `controller/PlatformInboundMeasurementReviewController.java`、`service/MeasurementReviewService.java`、`service/InitialInboundPricingService.java`、`dto/MeasurementApprovalResponse.java` |
|
||||
@@ -29,7 +29,7 @@
|
||||
| 客户专属价格簿、柜级阶梯运费与调价边界 | `controller/PlatformPriceBookController.java`、`service/PlatformPriceBookService.java`、`pricing/ActivePriceBookEstimator.java`、`service/FbaPricingService.java`、`pricing/ContainerTierAmountAllocator.java`;公共价格簿固定 `customerId=0`,客户专属版本按客户独立发布与替换,计价优先专属 ACTIVE、缺失才回退公共 ACTIVE。`REGION_WEIGHT_TIER` 对同柜同客户有效成员聚合实际计费量命中一个阶梯,再按成员计费量守恒分摊;装柜成员变化调用 `recalculateUnlockedContainerCustomerFees`,已确认/已发送或客户已确认的版本不得改写。人工调整以系统基准量为中心限制上下 10%(含边界),保存版本、原因和二次确认;一件代发 `WAIT_PALLETIZE` 可在预装确认前试算,`markInboundCalculated` 只更新费用并保留该托盘化状态 |
|
||||
| 入库费用明细 | `controller/PlatformInboundFeeDetailController.java`、`service/InboundFeeDetailService.java`、`dto/InboundFeeDetailDtos.java`;`PRIVATE_LAST_MILE_DELIVERY` 是独立私人仓尾程派送费,只允许 `destinationType=PRIVATE`,按稳定费用代码校验而不按中文名判断;Admin 消费端见 `../../apps/admin-web/src/modules/unified-orders/api/inboundFeeDetails.ts` |
|
||||
| 入库/柜次直接已付快照与凭证校验 | `service/FeePaymentSnapshotValidator.java`、`client/FeePaymentVoucherFileClient.java`;写入入口分别为 `InboundFeeDetailService.java`、`ContainerFeeDetailService.java`,File 业务类型固定 `FINANCE_VOUCHER` |
|
||||
| 客户运营统计/在途柜量 | `customerstatistics/controller/PlatformCustomerTransitStatisticsController.java`、`customerstatistics/service/CustomerTransitStatisticsService.java`;契约见 `src/main/resources/openapi/customer-statistics-v1.yaml` |
|
||||
| 首页实时指标、客户运营统计/在途柜量 | 首页物理在途柜 TEU 与待清关票数从 `dashboard/PlatformDashboardMetricsController.java` 进入,契约见 `src/main/resources/openapi/dashboard-metrics-v1.yaml`;客户维度统计为 `customerstatistics/adapter/in/web/PlatformCustomerTransitStatisticsController.java`、`customerstatistics/application/CustomerTransitStatisticsService.java`,契约见 `src/main/resources/openapi/customer-statistics-v1.yaml` |
|
||||
| 费用项目与价格簿 | `controller/PlatformFeeItemController.java`、`PlatformPriceBookController.java`、`MerchantPriceBookController.java` |
|
||||
| 客户入库结果与头程费用确认 | `controller/MerchantInboundBillingController.java`、`service/MerchantInboundBillingService.java`、`FbaPricingService.java`;查询与确认接口为 `/api/merchant/v1/first-leg/orders/{orderId}/inbound-billing`、`/confirm`,契约 `src/main/resources/openapi/firstleg-v1.yaml` |
|
||||
| 价格簿查询内部切片 | `pricebookquery/application/port/in/PriceBookQueryUseCase.java`、`application/PriceBookQueryService.java`、`application/port/out/PriceBookQueryReader.java`、`adapter/out/persistence/PriceBookQueryMapper.java`;查询行模型位于 `application/model/PriceBookQueryRows.java` |
|
||||
@@ -65,7 +65,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 打托实现。
|
||||
- 一件代发以 `fl_inbound_carton_label_sku` 作为物理箱内不可变 SKU 快照;一个稳定 `box_code` 可以对应多条 SKU,PDA 扫箱后必须一次清点全部 SKU,不得将 SKU 塞入二维码或以 SKU 替代箱码。实际打托后的查询必须能按“现场托盘 -> 客户 -> SKU -> 箱数”聚合;同一国内作业仓的多票允许共用一个开放托盘,单票全部归托不自动封托,只有参与该票装箱的开放托盘阻止该票完成,预生成但未使用的空托盘不得阻塞;禁止按货名、箱规或箱号猜测 SKU。修改本链路时同步检查 `OrderCargoClient`、Order 内部货物接口及 PDA 打托实现。
|
||||
- 测量审核通过后直接进入 `WAIT_SPLIT`,费用仅作为独立初算和人工维护结果,不得阻塞或回退订单主流程;计费量、单价和附加费调整必须版本化并由服务端重算金额。状态或 DTO 变更时逐个检查 Order/Warehouse/File/Integration 消费者。
|
||||
- 客户箱规配置必须恰好保持四档且默认算法在四行一致;`ADD` 以实测尺寸为基准对三个维度分别增加命中箱规的尺寸值,`MULTIPLY` 使用当前客户唯一默认计费规则,并由服务端按规则中的规格档位选择系数。测量行必须保存实际尺寸、选用规则和算法快照,页面不得自行重算后覆盖服务端结果;计价使用用户确认的重量/体积选择值并纳入幂等请求哈希。
|
||||
- 报关、订舱和拖车引用的供应商必须通过 `WarehouseSupplierSnapshotService` 按 ID 向 Warehouse 校验:报关使用 `CUSTOMS`,订舱使用 `BOOKING`(不区分国内/国外 scope),拖车使用 `TRUCKING`;供应商必须为 `ACTIVE`。请求中的名称只能作为一致性提示,持久化名称必须来自 Warehouse 快照,禁止用自由文本绕过 ID、类别或启用状态校验。修改任一供应商引用 DTO、OpenAPI 或内部客户端时,必须同步检查三条业务链、Warehouse `supplier-v1.yaml`、数据库快照字段和对应契约测试。
|
||||
@@ -85,9 +85,10 @@
|
||||
|
||||
## 国内货柜履约装柜与 PDA 资源结算
|
||||
|
||||
- 智能装柜全局工作台由服务端把业务渠道分组映射为实际渠道,并分别分页查询候选与 `WAIT_LOADING/EDITING/LOADING` 柜次;候选支持按创建日期和预计到货日期闭区间筛选,状态筛选使用最多四项的 `orderStatuses`,未传表示 `PENDING_REVIEW/WAIT_MEASURE/WAIT_SPLIT/LOADING` 全部未完成状态。状态只用于搜索和展示,能否加入以响应 `canAssign` 为准,由有效柜次占用、剩余箱数、容量、组锁和版本等事实决定;人工加入允许订单国内仓及渠道与柜次不同,智能推荐允许跨国内仓但仍只选择柜次渠道。容积和重量均允许达到柜型额定值的 150%,超过即拒绝。待审核事实必须经 `PendingReviewOrderClient` 重新核验 Order 权威 `SUBMITTED` 订单,待审核或待测量可先占位,但确认预装和启动 PDA 前仍必须进入 `WAIT_SPLIT`。计划明细通过 `totalPackageCount/remainingPackageCount` 返回可调总量和余量:测量前取 Order 原始申报箱数,进入 `WAIT_SPLIT/LOADING` 后取最新已审核测量箱数;只读补充失败时保守回退当前已分配数,调整写入仍重新查询并校验权威数量。同柜不可拆组只有全部成员命中状态集合时才返回,且必须成员完整、满箱并作为单个分页与分配单元,不能跨页或拆分。一次性移除未拣明细时,普通未拣项直接移除;同柜组仅全组未拣时整组移除,任一成员已拣则整组保留,单事务只递增一次计划版本。推荐顺序按入库时间先进先出。装柜计划状态为 `EDITING -> LOADING -> COMPLETED`,PDA 全部明细处理完成后自动完成。
|
||||
- 新增模拟柜只保存智能装柜的 9 类计划渠道 channel_group_code;未关联已放舱订舱时 channel_code/channel_name 为空,关联后才冻结订舱的实际渠道。智能装柜全局工作台和订舱关联候选均按计划渠道组匹配;服务端把业务渠道分组映射为实际渠道,并分别分页查询候选与 `WAIT_LOADING/EDITING/LOADING` 柜次;候选支持按创建日期和预计到货日期闭区间筛选,状态筛选使用最多四项的 `orderStatuses`,未传表示 `PENDING_REVIEW/WAIT_MEASURE/WAIT_SPLIT/LOADING` 全部未完成状态。状态只用于搜索和展示,能否加入以响应 `canAssign` 为准,由有效柜次占用、剩余箱数、容量、组锁和版本等事实决定;人工加入允许订单国内仓及渠道与柜次不同,智能推荐允许跨国内仓但仍只选择柜次渠道。容积和重量均允许达到柜型额定值的 150%,超过即拒绝。待审核事实必须经 `PendingReviewOrderClient` 重新核验 Order 权威 `SUBMITTED` 订单,待审核或待测量可先占位,但确认预装和启动 PDA 前仍必须进入 `WAIT_SPLIT`。计划明细通过 `totalPackageCount/remainingPackageCount` 返回可调总量和余量:测量前取 Order 原始申报箱数,进入 `WAIT_SPLIT/LOADING` 后取最新已审核测量箱数;只读补充失败时保守回退当前已分配数,调整写入仍重新查询并校验权威数量。同柜不可拆组只有全部成员命中状态集合时才返回,且必须成员完整、满箱并作为单个分页与分配单元,不能跨页或拆分。一次性移除未拣明细时,普通未拣项直接移除;同柜组仅全组未拣时整组移除,任一成员已拣则整组保留,单事务只递增一次计划版本。推荐顺序按入库时间先进先出。装柜计划状态为 `EDITING -> LOADING -> COMPLETED`,PDA 全部明细处理完成后自动完成。
|
||||
- 预装进入 `CONFIRMING/CONFIRMED` 只固化确认快照,不得锁死尚未完成柜次的智能装柜分配;装柜计划仍处于 `EDITING/LOADING` 时可继续新增,未产生实物拣货的明细可调整或移除,已拣明细保持锁定。
|
||||
- 客户入库费用只在登录客户范围、测量审核通过、`feeStatus=SENT` 且存在已发布明细时可见;确认必须具备 `merchant:order:edit`,携带当前 `expectedFeeVersion` 与幂等键。成功后记录确认成员和时间;客户未确认或该票尚未装柜完成时,平台仍可修改箱规规则、生成新费用版本并重新发送,新版本必须清空旧版客户确认快照。仅当“客户已确认且该票已装柜完成”同时成立时最终冻结;装柜完成不得代替客户自动确认。旧版本、未发送或重复异参确认均返回冲突,不做兼容覆盖。
|
||||
- PDA 拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;审核建单、重复打印、手工测量和墨家测量复用同一映射,版本字段只用于并发控制,不进入扫码文本。一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数或页面展示字段替代箱码。装柜允许跨国内仓,仓库与渠道限制统一由上游装柜编排负责;PDA 只按已生成任务读取各入仓单自身的 `CONFIRMED` 库位资源,不得再用柜次仓库过滤任务明细、库存快照或拣货写入。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。
|
||||
- PDA 普通散箱拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;审核建单、重复打印、手工测量和墨家测量复用同一映射,版本字段只用于并发控制,不进入扫码文本。一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数、入库单号或页面展示字段替代箱码。一件代发已封托的 `SEALED_PALLET` 是不可拆整托单元,PDA 可扫描计划快照中的 `palletNo`、用该托任一成员 `boxCode` 反查所属整托,也可在明细卡片手工确认整托;三种入口最终都必须携带权威 `palletNo` 以 `PICK_PALLET` 一次性提交,不能按成员箱逐箱累计、部分拣货或跳拣。PDA 首页从 Warehouse 主档选择启用国内仓,一件代发与装柜待办列表必须按所选 `warehouseId` 服务端过滤;装柜任务内部仍允许跨国内仓,仓库与渠道限制统一由上游装柜编排负责,PDA 只按已生成任务读取各入仓单自身的 `CONFIRMED` 库位资源,不得再用柜次仓库过滤任务明细、库存快照或拣货写入。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。
|
||||
- PDA 部分拣货生成二级子入库单时,数量必须守恒:`已拣数量 + 子单数量 = 拣货前主单可用数量`。子单只允许一层,不能继续拆分。
|
||||
- 首张称重图片确认时按 `max(已确认称重图片数, ceil(有效体积 / 默认托盘容量))` 尽力预留并确认整票库位块;有效体积先取入库单已落地的正数实测体积,否则对最新测量批次的正数 `totalVolumeCbm/originalVolumeCbm` 取较大值,再无有效值才查询 Order 最新客户申报体积,全部无效则拒绝。后续确认绑定最小空槽,所需槽数增长时通过 Warehouse `/blocks/expand` 原子追加,既有 `blockId/slotNo/reservationId/locationId/sharedOverflow` 不得变化。库位指引失败只记录警告,不阻断称重确认;测量提交后以最新已确认图片调用 `ensureForSubmission` 再次补齐和校验,失败同样不回滚已提交的测量主流程。Firstleg 必须显式保存 `weightResultId -> blockId/slotNo/reservationId/locationId/locationCode`,按 `confirmedAt + weightResultId` 稳定顺序绑定,禁止按列表下标推断;普通连续位不足的新增槽由 Warehouse 放入共享 A0,不阻断确认。
|
||||
- 删除称重图片只解除图片和托盘快照绑定,将槽位恢复为 `RESERVED_EMPTY`;槽位和整票块继续归当前入仓码。解绑失败时不得调用文件删除,文件删除失败时本地解绑和重量结果删除必须回滚。
|
||||
|
||||
@@ -65,18 +65,18 @@ public interface OrderCargoClient {
|
||||
record CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
|
||||
String skuNo, Integer packageCount, Integer unitsPerCarton,
|
||||
BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm,
|
||||
String shipmentId) {
|
||||
String shipmentId, String packingGroupNo) {
|
||||
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, null, lengthCm, widthCm, heightCm, null);
|
||||
packageCount, null, lengthCm, widthCm, heightCm, null, null);
|
||||
}
|
||||
public CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
|
||||
String skuNo, Integer packageCount, BigDecimal lengthCm,
|
||||
BigDecimal widthCm, BigDecimal heightCm, String shipmentId) {
|
||||
this(cargoId, orderId, itemNo, cargoName, skuNo,
|
||||
packageCount, null, lengthCm, widthCm, heightCm, shipmentId);
|
||||
packageCount, null, lengthCm, widthCm, heightCm, shipmentId, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,12 @@ public interface PrintPdfClient {
|
||||
@RequestHeader(value = "X-Request-Id", required = false) String requestId,
|
||||
@RequestBody FreightA4PdfRequest request);
|
||||
|
||||
@PostMapping(value = "/dropship-pallet-a4-documents", consumes = "application/json")
|
||||
ResponseEntity<byte[]> generateDropshipPalletA4Documents(
|
||||
@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestHeader(value = "X-Request-Id", required = false) String requestId,
|
||||
@RequestBody DropshipPalletA4PdfRequest request);
|
||||
|
||||
record InboundMarkPdfRequest(String templateCode, PageSpec pageSpec,
|
||||
String downloadFileName, List<Label> labels) {
|
||||
}
|
||||
@@ -44,4 +50,15 @@ public interface PrintPdfClient {
|
||||
|
||||
record CargoRow(String marksAndNos, String quantity, String packaging,
|
||||
String descriptionOfGoods, String grossWeight, String measurement) {}
|
||||
|
||||
record DropshipPalletA4PdfRequest(String templateCode, PageSpec pageSpec,
|
||||
String downloadFileName, List<DropshipPalletDocument> documents) {}
|
||||
|
||||
record DropshipPalletDocument(String palletNo, String inboundCodes, int cartonCount,
|
||||
int skuCount, BigDecimal weightKg, BigDecimal volumeCbm,
|
||||
String barcodeContent, List<DropshipPalletSkuRow> skuRows) {}
|
||||
|
||||
record DropshipPalletSkuRow(String inboundCode, String skuCode, String cargoName,
|
||||
int cartonCount, int skuQuantity) {}
|
||||
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** 获取平台仓库主数据,供虚拟柜校验国内中转仓使用。 */
|
||||
@FeignClient(name = "warehouse-service", contextId = "firstlegWarehouseMasterDataClient",
|
||||
url = "${baiyue.clients.warehouse.url:}", path = "/internal/warehouses")
|
||||
@@ -15,10 +17,17 @@ public interface WarehouseMasterDataClient {
|
||||
@PathVariable long tenantId,
|
||||
@PathVariable long warehouseId);
|
||||
|
||||
@GetMapping("/domestic")
|
||||
ApiResponse<List<WarehouseOption>> domestic(
|
||||
@RequestHeader("X-Internal-Service-Token") String token);
|
||||
|
||||
record Detail(Warehouse warehouse) {
|
||||
}
|
||||
|
||||
record Warehouse(Long id, Long tenantId, String warehouseCode, String internalCode, String warehouseName,
|
||||
String warehouseType, String status) {
|
||||
}
|
||||
|
||||
record WarehouseOption(Long id, String warehouseCode, String warehouseName) {
|
||||
}
|
||||
}
|
||||
|
||||
+31
-11
@@ -39,9 +39,10 @@ public class PlatformDropshipPalletizationController {
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:pda:view')")
|
||||
public ApiResponse<List<DropshipPalletizationDtos.PendingTask>> pending(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam @Positive long warehouseId,
|
||||
@RequestParam(required = false) @Size(max = 64) String keyword) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.pending(user.userId(), keyword));
|
||||
return ApiResponse.success(service.pending(user.userId(), warehouseId, keyword));
|
||||
}
|
||||
|
||||
@GetMapping("/tasks/{taskId}")
|
||||
@@ -92,15 +93,6 @@ public class PlatformDropshipPalletizationController {
|
||||
return ApiResponse.success(service.bindPending(taskId, request, user.userId(), user.username()));
|
||||
}
|
||||
|
||||
@PostMapping("/tasks/{taskId}/pallets/create-and-bind")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
|
||||
public ApiResponse<Task> createAndBind(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long taskId,
|
||||
@Valid @RequestBody DropshipPalletizationDtos.CreateAndBindRequest request) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.createAndBind(taskId, request, user.userId(), user.username()));
|
||||
}
|
||||
|
||||
@PostMapping("/tasks/{taskId}/cartons/{cartonId}/move")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
|
||||
public ApiResponse<Task> moveCarton(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@@ -121,6 +113,34 @@ public class PlatformDropshipPalletizationController {
|
||||
return ApiResponse.success(service.seal(taskId, palletId, request, user.userId(), user.username()));
|
||||
}
|
||||
|
||||
@GetMapping("/tasks/{taskId}/pallets/{palletId}/print")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:pda:view')")
|
||||
public ResponseEntity<byte[]> printTaskPallet(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long taskId,
|
||||
@PathVariable @Positive long palletId,
|
||||
@RequestParam @Positive long warehouseId,
|
||||
@RequestHeader(value = "X-Request-Id", required = false) String requestId) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
Task task = service.task(taskId, warehouseId, user.userId());
|
||||
Pallet pallet = task.pallets().stream()
|
||||
.filter(item -> item.palletId() == palletId).findFirst()
|
||||
.orElseThrow(() -> new com.baiyue.common.core.exception.BizException(
|
||||
com.baiyue.common.core.api.ResultCode.NOT_FOUND, "托盘不存在"));
|
||||
return printService.generateDropshipPallet(pallet, task.inboundCode(), requestId);
|
||||
}
|
||||
|
||||
@GetMapping("/tasks/{taskId}/pallets/print")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:pda:view')")
|
||||
public ResponseEntity<byte[]> printTaskPallets(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long taskId,
|
||||
@RequestParam @Positive long warehouseId,
|
||||
@RequestHeader(value = "X-Request-Id", required = false) String requestId) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
Task task = service.task(taskId, warehouseId, user.userId());
|
||||
return printService.generateDropshipPallets(
|
||||
service.generatedPallets(taskId, warehouseId, user.userId()), task.inboundCode(), requestId);
|
||||
}
|
||||
|
||||
@GetMapping("/inbound-orders/{inboundId}/pallets")
|
||||
@PreAuthorize("hasAuthority('firstleg:inbound:list')")
|
||||
public ApiResponse<List<Pallet>> inboundPallets(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@@ -142,6 +162,6 @@ public class PlatformDropshipPalletizationController {
|
||||
.filter(item -> item.palletId() == palletId).findFirst()
|
||||
.orElseThrow(() -> new com.baiyue.common.core.exception.BizException(
|
||||
com.baiyue.common.core.api.ResultCode.NOT_FOUND, "托盘不存在"));
|
||||
return printService.generateDropshipPallet(pallet, requestId);
|
||||
return printService.generateDropshipPallet(pallet, service.inboundCode(accountId, inboundId), requestId);
|
||||
}
|
||||
}
|
||||
|
||||
+13
-1
@@ -20,6 +20,8 @@ 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.PdaWarehouseCatalogService;
|
||||
import com.baiyue.firstleg.client.WarehouseMasterDataClient.WarehouseOption;
|
||||
import com.baiyue.firstleg.loading.service.CabinetLoadingEvidenceService;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.MediaType;
|
||||
@@ -51,6 +53,8 @@ public class PlatformLoadingPdaController {
|
||||
@Resource
|
||||
private LoadingPickService service;
|
||||
@Resource
|
||||
private PdaWarehouseCatalogService warehouseCatalog;
|
||||
@Resource
|
||||
private CabinetLoadingEvidenceService evidenceService;
|
||||
|
||||
@GetMapping("/tasks")
|
||||
@@ -66,10 +70,18 @@ public class PlatformLoadingPdaController {
|
||||
@GetMapping("/tasks/pending")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:pda:view')")
|
||||
public ApiResponse<List<PendingTask>> pendingTasks(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam @Positive long warehouseId,
|
||||
@RequestParam(required = false)
|
||||
@Size(max = 64) String keyword) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.pendingTasks(user.userId(), keyword));
|
||||
return ApiResponse.success(service.pendingTasks(user.userId(), warehouseId, keyword));
|
||||
}
|
||||
|
||||
@GetMapping("/warehouses")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:pda:view')")
|
||||
public ApiResponse<List<WarehouseOption>> warehouses(@AuthenticationPrincipal AuthenticatedUser user) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(warehouseCatalog.activeDomesticWarehouses());
|
||||
}
|
||||
|
||||
@GetMapping("/tasks/{taskId}")
|
||||
|
||||
+11
@@ -75,6 +75,17 @@ public class PlatformPriceBookController {
|
||||
return ApiResponse.success(service.publish(versionId, user.userId(), request));
|
||||
}
|
||||
|
||||
@PostMapping("/{versionId}/void")
|
||||
@PreAuthorize("hasAuthority('firstleg:fba-pricing:rate:edit')")
|
||||
@BusinessAudit(event = "FBA_PRICE_BOOK_VOID", resourceType = "PRICE_BOOK_VERSION", resourceId = "#versionId", description = "作废待确认船期价格表")
|
||||
public ApiResponse<PriceBookDtos.PriceBookVersionDetail> voidPending(
|
||||
@AuthenticationPrincipal com.baiyue.common.security.AuthenticatedUser user,
|
||||
@PathVariable long versionId,
|
||||
@Valid @RequestBody PriceBookDtos.PriceBookVoidRequest request) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.voidPending(versionId, user.userId(), request));
|
||||
}
|
||||
|
||||
@GetMapping("/{versionId}/source")
|
||||
@PreAuthorize("hasAuthority('firstleg:fba-pricing:rate:view')")
|
||||
public ResponseEntity<byte[]> source(
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.baiyue.firstleg.dashboard;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public final class DashboardMetricsDtos {
|
||||
private DashboardMetricsDtos() {}
|
||||
|
||||
public record OperationalMetrics(
|
||||
long inTransitContainerCount,
|
||||
BigDecimal inTransitTeu,
|
||||
long unknownContainerTypeCount,
|
||||
long pendingCustomsOrderCount,
|
||||
LocalDateTime asOf
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.baiyue.firstleg.dashboard;
|
||||
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Mapper
|
||||
public interface DashboardMetricsMapper {
|
||||
@Select("""
|
||||
SELECT transit.container_count inTransitContainerCount,
|
||||
transit.total_teu inTransitTeu,
|
||||
transit.unknown_container_type_count unknownContainerTypeCount,
|
||||
customs.pending_customs_order_count pendingCustomsOrderCount,
|
||||
CURRENT_TIMESTAMP(3) asOf
|
||||
FROM (
|
||||
SELECT COUNT(*) container_count,
|
||||
COALESCE(SUM(container.teu), 0) total_teu,
|
||||
COALESCE(SUM(CASE WHEN container.teu IS NULL THEN 1 ELSE 0 END), 0) unknown_container_type_count
|
||||
FROM (
|
||||
SELECT DISTINCT booking.id,
|
||||
CASE
|
||||
WHEN UPPER(TRIM(booking.container_type)) LIKE '20%' THEN 1.0
|
||||
WHEN UPPER(TRIM(booking.container_type)) LIKE '40%' THEN 2.0
|
||||
WHEN UPPER(TRIM(booking.container_type)) LIKE '45%' THEN 2.25
|
||||
ELSE NULL
|
||||
END teu
|
||||
FROM fl_container_booking booking
|
||||
JOIN fl_ocean_booking ocean ON ocean.id = booking.ocean_booking_id
|
||||
WHERE booking.actual_sailing_at IS NOT NULL
|
||||
AND booking.actual_warehouse_arrival_at IS NULL
|
||||
AND booking.record_status = 'ACTIVE'
|
||||
AND ocean.status <> 'CANCELLED'
|
||||
) container
|
||||
) transit
|
||||
CROSS JOIN (
|
||||
SELECT COUNT(DISTINCT inbound.order_id) pending_customs_order_count
|
||||
FROM fl_customs_container customs_container
|
||||
JOIN fl_loading_plan_item item
|
||||
ON item.loading_plan_id = customs_container.loading_plan_id AND item.deleted = 0
|
||||
JOIN fl_inbound_order inbound
|
||||
ON inbound.id = item.inbound_id AND inbound.tenant_id = item.account_id AND inbound.deleted = 0
|
||||
WHERE customs_container.status <> 'COMPLETED'
|
||||
) customs
|
||||
""")
|
||||
MetricsProjection read();
|
||||
|
||||
record MetricsProjection(Long inTransitContainerCount, BigDecimal inTransitTeu,
|
||||
Long unknownContainerTypeCount, Long pendingCustomsOrderCount,
|
||||
LocalDateTime asOf) {}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.baiyue.firstleg.dashboard;
|
||||
|
||||
import com.baiyue.firstleg.dashboard.DashboardMetricsDtos.OperationalMetrics;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
@Service
|
||||
public class DashboardMetricsService {
|
||||
@Resource private DashboardMetricsMapper mapper;
|
||||
|
||||
public OperationalMetrics read() {
|
||||
var row = mapper.read();
|
||||
if (row == null) return new OperationalMetrics(0, BigDecimal.ZERO, 0, 0, null);
|
||||
return new OperationalMetrics(value(row.inTransitContainerCount()),
|
||||
row.inTransitTeu() == null ? BigDecimal.ZERO : row.inTransitTeu(),
|
||||
value(row.unknownContainerTypeCount()), value(row.pendingCustomsOrderCount()), row.asOf());
|
||||
}
|
||||
|
||||
private static long value(Long value) {
|
||||
return value == null ? 0 : value;
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.baiyue.firstleg.dashboard;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.firstleg.dashboard.DashboardMetricsDtos.OperationalMetrics;
|
||||
import com.baiyue.firstleg.service.FirstlegScopeGuard;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/platform/v1/first-leg/dashboard")
|
||||
public class PlatformDashboardMetricsController {
|
||||
@Resource private DashboardMetricsService service;
|
||||
|
||||
@GetMapping("/operational-metrics")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:container:list') and hasAuthority('platform:firstleg:customs:list')")
|
||||
public ApiResponse<OperationalMetrics> metrics(@AuthenticationPrincipal AuthenticatedUser user) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.read());
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
package com.baiyue.firstleg.domain;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
/** 首版固定渠道目录;上传名称只能映射到这些稳定编码。 */
|
||||
public final class PriceBookChannelCatalog {
|
||||
private PriceBookChannelCatalog() {}
|
||||
|
||||
private static final Set<String> SEA_RAIL_CHANNEL_CODES = Set.of(
|
||||
"EXX_RAIL_NY", "NYEXX", "OA_CHICAGO_RAIL", "CPHT");
|
||||
|
||||
public static final List<ChannelDefinition> CHANNELS = List.of(
|
||||
fba("MATSON_CLX_TRUCK", "美森正班海卡", "美森正班海卡", "ZBYHK", "ZBYHWK"),
|
||||
fba("MATSON_MAX_TRUCK", "美森加班海卡", "美森加班海卡", "BYHK", "BYHWK"),
|
||||
@@ -37,6 +41,11 @@ public final class PriceBookChannelCatalog {
|
||||
inboundCodePrefix, overseasInboundCodePrefix);
|
||||
}
|
||||
|
||||
public static boolean isSeaRail(String channelCode) {
|
||||
return channelCode != null
|
||||
&& SEA_RAIL_CHANNEL_CODES.contains(channelCode.trim().toUpperCase(Locale.ROOT));
|
||||
}
|
||||
|
||||
public record ChannelDefinition(
|
||||
String channelCode,
|
||||
String displayName,
|
||||
|
||||
@@ -5,5 +5,6 @@ public enum PriceBookStatus {
|
||||
PENDING_CONFIRMATION,
|
||||
ACTIVE,
|
||||
SUPERSEDED,
|
||||
VOIDED,
|
||||
FAILED
|
||||
}
|
||||
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
package com.baiyue.firstleg.domain;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/** Stable planning groups used by simulated containers and smart-loading filters. */
|
||||
public final class SmartLoadingChannelGroupCatalog {
|
||||
private SmartLoadingChannelGroupCatalog() {}
|
||||
|
||||
public static final List<Definition> GROUPS = List.of(
|
||||
group("MATSON_CLX", "美森正班", "MATSON_CLX_TRUCK", "MATSON_CLX_PARCEL", "ZBYHK", "ZBYHWK", "ZBYHP"),
|
||||
group("MATSON_MAX", "美森加班", "MATSON_MAX_TRUCK", "MATSON_MAX_PARCEL", "BYHK", "BYHWK", "BYHP"),
|
||||
group("EXX", "EXX", "EXX_TRUCK", "EXX_RAIL_NY", "EXXHK", "EXXHWK", "NYEXX"),
|
||||
group("HEDE", "合德", "HEDE_TRUCK", "HEDE_PARCEL", "HDHK", "HDHWK", "HDHP"),
|
||||
group("OA_LA", "洛杉矶普船", "OA_LA_TRUCK", "OA_LA_FULL_TRUCK", "OA_PARCEL", "PCHK", "PCHWK", "PCKK", "PCHP"),
|
||||
group("OA_CHICAGO", "芝加哥普船", "OA_CHICAGO_RAIL", "CPHT"),
|
||||
group("OA_HOUSTON", "休斯顿普船", "OA_HOUSTON_TRUCK", "HPHK"),
|
||||
group("OA_SAVANNAH", "萨凡纳普船", "OA_SAVANNAH_TRUCK", "SPHK"),
|
||||
group("OA_NEW_YORK_TRUCK", "纽约普船海卡", "OA_NEW_YORK_TRUCK", "NPHK", "NPHWK")
|
||||
);
|
||||
|
||||
private static final Map<String, Definition> BY_CODE = indexByCode();
|
||||
private static final Map<String, Definition> BY_CHANNEL = indexByChannel();
|
||||
|
||||
public static Definition require(String value) {
|
||||
Definition result = BY_CODE.get(normalize(value));
|
||||
if (result == null) throw new IllegalArgumentException("不支持的渠道分组: " + value);
|
||||
return result;
|
||||
}
|
||||
|
||||
public static Definition resolveChannel(String channelCode) {
|
||||
return BY_CHANNEL.get(normalize(channelCode));
|
||||
}
|
||||
|
||||
public static boolean contains(String groupCode, String channelCode) {
|
||||
Definition group = BY_CODE.get(normalize(groupCode));
|
||||
return group != null && group.channelCodes().contains(normalize(channelCode));
|
||||
}
|
||||
|
||||
private static Definition group(String code, String name, String... channelCodes) {
|
||||
return new Definition(code, name, Set.of(channelCodes));
|
||||
}
|
||||
|
||||
private static String normalize(String value) {
|
||||
return value == null ? "" : value.trim().toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private static Map<String, Definition> indexByCode() {
|
||||
Map<String, Definition> result = new LinkedHashMap<>();
|
||||
GROUPS.forEach(group -> result.put(group.code(), group));
|
||||
return Map.copyOf(result);
|
||||
}
|
||||
|
||||
private static Map<String, Definition> indexByChannel() {
|
||||
Map<String, Definition> result = new LinkedHashMap<>();
|
||||
GROUPS.forEach(group -> group.channelCodes().forEach(channel -> result.put(channel, group)));
|
||||
return Map.copyOf(result);
|
||||
}
|
||||
|
||||
public record Definition(String code, String name, Set<String> channelCodes) {}
|
||||
}
|
||||
@@ -83,7 +83,7 @@ public final class ContainerDtos {
|
||||
) {}
|
||||
|
||||
@Schema(name = "ContainerSimulationCreateRequest", description = "新增内部模拟柜;内部柜号由服务端生成")
|
||||
public record SimulationCreateRequest(@NotBlank @Size(max = 64) String channelCode,
|
||||
public record SimulationCreateRequest(@NotBlank @Size(max = 64) String channelGroupCode,
|
||||
@NotNull @Positive Long warehouseId, @NotBlank @Size(max = 32) String containerType,
|
||||
@NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal capacityCbm,
|
||||
@NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal maxWeightKg) {}
|
||||
|
||||
@@ -4,7 +4,10 @@ import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import jakarta.validation.constraints.PositiveOrZero;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.Valid;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
@@ -15,19 +18,20 @@ public final class DropshipPalletizationDtos {
|
||||
|
||||
public record ClaimRequest(@Positive long warehouseId) {}
|
||||
|
||||
public record CreateAndBindRequest(@Positive long warehouseId,
|
||||
@NotBlank @Size(max = 128) String operationId) {}
|
||||
|
||||
public enum DiscrepancyAction { MATCH, ACCEPT_VARIANCE }
|
||||
|
||||
public record CountCartonRequest(@Positive long warehouseId,
|
||||
@NotBlank @Size(max = 80) String boxCode,
|
||||
@Positive int actualSkuQuantity,
|
||||
@NotNull DiscrepancyAction discrepancyAction,
|
||||
@Size(max = 500) String discrepancyRemark,
|
||||
@NotEmpty @Valid List<CountSkuLine> skuLines,
|
||||
@Pattern(regexp = "DP-[0-9]{6}-[0-9]{4}") String palletNo,
|
||||
@NotBlank @Size(max = 128) String operationId) {}
|
||||
|
||||
public record CountSkuLine(@Positive long inboundDetailId,
|
||||
@NotBlank @Size(max = 128) String skuCode,
|
||||
@PositiveOrZero int actualQuantity,
|
||||
@NotNull DiscrepancyAction discrepancyAction,
|
||||
@Size(max = 500) String discrepancyRemark) {}
|
||||
|
||||
public record BindPendingRequest(@Positive long warehouseId,
|
||||
@NotBlank @Pattern(regexp = "DP-[0-9]{6}-[0-9]{4}") String palletNo,
|
||||
@NotBlank @Size(max = 128) String operationId) {}
|
||||
@@ -49,7 +53,9 @@ public final class DropshipPalletizationDtos {
|
||||
public record Task(Long taskId, Long inboundId, String inboundCode, Long tenantId,
|
||||
String customerName, Long warehouseId, String warehouseName,
|
||||
Integer totalCartons, Integer countedCartons, Integer boundCartons,
|
||||
Integer unboundCartons, Integer discrepancyCartons, String taskStatus,
|
||||
Integer unboundCartons, Integer discrepancyCartons,
|
||||
Integer expectedPalletCount, Integer generatedPalletCount,
|
||||
Boolean palletSetupReady, String palletSetupMessage, String taskStatus,
|
||||
Long assigneeId, String assigneeName, Integer version,
|
||||
List<Pallet> pallets, List<Carton> unbound) {}
|
||||
|
||||
@@ -64,12 +70,14 @@ public final class DropshipPalletizationDtos {
|
||||
List<String> cartonMarks) {}
|
||||
|
||||
public record Carton(Long cartonId, String boxCode, Long inboundId, String inboundCode,
|
||||
Long inboundDetailId, String skuCode, String cargoName,
|
||||
Integer expectedSkuQuantity, Integer actualSkuQuantity,
|
||||
String discrepancyStatus, String discrepancyRemark,
|
||||
List<CartonSkuLine> skuLines,
|
||||
String countStatus, Long palletId, String palletNo,
|
||||
Boolean canCount, String blockReason) {}
|
||||
|
||||
public record CartonSkuLine(Long inboundDetailId, String skuCode, String cargoName,
|
||||
Integer expectedQuantity, Integer actualQuantity,
|
||||
String discrepancyStatus, String discrepancyRemark) {}
|
||||
|
||||
public record CountResult(Carton carton, Integer countedCartons, Integer boundCartons,
|
||||
Integer unboundCartons, Integer remainingCartons, Pallet pallet) {}
|
||||
}
|
||||
|
||||
@@ -28,8 +28,14 @@ public final class InboundMeasurementRequests {
|
||||
Long measurementId,
|
||||
@NotNull @PositiveOrZero Integer version,
|
||||
@Size(max = 255) String actualGoodsName,
|
||||
@Positive @Max(1000) Integer expectedPalletCount,
|
||||
@NotEmpty @Size(max = 500) List<@Valid ItemRequest> items
|
||||
) {
|
||||
public SaveDraftRequest(Long measurementId, Integer version, String actualGoodsName,
|
||||
List<ItemRequest> items) {
|
||||
this(measurementId, version, actualGoodsName, null, items);
|
||||
}
|
||||
|
||||
@JsonAnySetter
|
||||
public void rejectUnknownField(String fieldName, Object ignored) {
|
||||
throw new IllegalArgumentException("测量草稿不接受字段: " + fieldName);
|
||||
|
||||
@@ -19,6 +19,8 @@ public final class InboundMeasurementResponses {
|
||||
String companyName,
|
||||
String channelCode,
|
||||
String channelName,
|
||||
String fulfillmentType,
|
||||
Integer expectedPalletCount,
|
||||
String processStatus,
|
||||
String measureReviewStatus,
|
||||
@Schema(description = "测量完成后的推荐托盘类型,仅为建议,不代表已分配托盘",
|
||||
|
||||
@@ -47,7 +47,7 @@ public final class LoadingPickDtos {
|
||||
) {
|
||||
}
|
||||
|
||||
/** PDA 在线草稿只在员工提交时发送一次,服务端原子处理整批明细。 */
|
||||
/** PDA 已即时提交的明细不重复包含;收尾时服务端原子处理剩余全部待拣明细。 */
|
||||
public record BatchRequest(
|
||||
@NotNull @Min(1) Long warehouseId,
|
||||
@NotNull @Min(1) Long loadingPlanId,
|
||||
|
||||
@@ -38,7 +38,16 @@ public final class PriceBookDtos {
|
||||
LocalDateTime createdAt,
|
||||
Long publishedBy,
|
||||
LocalDateTime publishedAt,
|
||||
LocalDateTime supersededAt
|
||||
LocalDateTime supersededAt,
|
||||
Long voidedBy,
|
||||
LocalDateTime voidedAt,
|
||||
String voidReason
|
||||
) {}
|
||||
|
||||
public record PriceBookVoidRequest(
|
||||
@NotBlank(message = "作废原因不能为空")
|
||||
@Size(max = 500, message = "作废原因不能超过500个字符")
|
||||
String reason
|
||||
) {}
|
||||
|
||||
public record PriceBookVersionDetail(
|
||||
|
||||
@@ -49,7 +49,7 @@ public final class SmartLoadingDtos { private SmartLoadingDtos(){}
|
||||
}
|
||||
}
|
||||
public record WorkbenchContainer(Long bookingId, String bookingNo, String cabinetNo, String containerNo,
|
||||
Long warehouseId, String warehouseName, String channelCode, String channelName, String containerType,
|
||||
Long warehouseId, String warehouseName, String channelGroupCode, String channelCode, String channelName, String containerType,
|
||||
BigDecimal capacityCbm, BigDecimal maxWeightKg,
|
||||
Long planId, String planNo, String planStatus, Integer version) {}
|
||||
public record Candidate(Long accountId, Long inboundId, Long inboundDetailId, Long orderId, String orderNo, String inboundCode,
|
||||
|
||||
@@ -24,6 +24,7 @@ public class ContainerBookingEntity {
|
||||
private String sourceType;
|
||||
private String recordStatus;
|
||||
private Long mergedIntoContainerBookingId;
|
||||
private String channelGroupCode;
|
||||
private String channelCode;
|
||||
private String channelName;
|
||||
private Long oceanBookingId;
|
||||
|
||||
@@ -28,4 +28,7 @@ public class PriceBookVersionEntity extends BaseEntity {
|
||||
private Long publishedBy;
|
||||
private LocalDateTime publishedAt;
|
||||
private LocalDateTime supersededAt;
|
||||
private Long voidedBy;
|
||||
private LocalDateTime voidedAt;
|
||||
private String voidReason;
|
||||
}
|
||||
|
||||
+1
-2
@@ -134,8 +134,7 @@ public class LoadingModelBridgeService {
|
||||
var preload = repository.preload(tenantId, inboundId);
|
||||
if (preload == null) throw new BizException(ResultCode.NOT_FOUND, "入库单预装计划不存在");
|
||||
boolean running = "LOADING".equals(plan.status());
|
||||
if (!Set.of("PENDING", "CONFIRMED").contains(preload.status())
|
||||
&& !(running && "CONFIRMING".equals(preload.status()))) {
|
||||
if (!Set.of("PENDING", "CONFIRMING", "CONFIRMED").contains(preload.status())) {
|
||||
throw conflict("新增入库单预装状态不允许进入当前柜次");
|
||||
}
|
||||
PreloadLoadingPlanLinkEntity link = new PreloadLoadingPlanLinkEntity();
|
||||
|
||||
+102
-43
@@ -13,11 +13,6 @@ import java.util.List;
|
||||
/** 一件代发逐箱清点、待绑托与封托事实。 */
|
||||
@Mapper
|
||||
public interface DropshipPalletizationMapper {
|
||||
@Select("SELECT id FROM fl_dropship_pallet_task WHERE id=#{taskId} AND warehouse_id=#{warehouseId} FOR UPDATE")
|
||||
Long lockTask(@Param("taskId") long taskId,@Param("warehouseId") long warehouseId);
|
||||
|
||||
@Select("SELECT * FROM fl_dropship_pallet WHERE open_operation_id=#{operationId}")
|
||||
PalletRow palletByOpenOperation(@Param("operationId") String operationId);
|
||||
@Insert("""
|
||||
INSERT IGNORE INTO fl_dropship_pallet_task
|
||||
(id,tenant_id,inbound_id,warehouse_id,task_status,total_cartons,counted_cartons,allocated_cartons,discrepancy_cartons,version,created_at,updated_at)
|
||||
@@ -30,13 +25,16 @@ public interface DropshipPalletizationMapper {
|
||||
o.warehouse_name,t.total_cartons,t.counted_cartons,t.allocated_cartons bound_cartons,
|
||||
t.counted_cartons-t.allocated_cartons unbound_cartons,t.discrepancy_cartons,t.task_status,t.created_at
|
||||
FROM fl_dropship_pallet_task t JOIN fl_inbound_order o ON o.id=t.inbound_id AND o.tenant_id=t.tenant_id
|
||||
WHERE t.task_status IN ('PENDING','CLAIMED') AND (t.assignee_id IS NULL OR t.assignee_id=#{operatorId})
|
||||
WHERE t.task_status IN ('PENDING','CLAIMED') AND t.warehouse_id=#{warehouseId}
|
||||
AND (t.assignee_id IS NULL OR t.assignee_id=#{operatorId})
|
||||
AND (#{keyword} IS NULL OR o.inbound_code LIKE CONCAT('%',#{keyword},'%') OR o.customer_name LIKE CONCAT('%',#{keyword},'%'))
|
||||
ORDER BY t.created_at,t.id""")
|
||||
List<PendingTask> pending(@Param("operatorId") long operatorId,@Param("keyword") String keyword);
|
||||
List<PendingTask> pending(@Param("operatorId") long operatorId,@Param("warehouseId") long warehouseId,
|
||||
@Param("keyword") String keyword);
|
||||
|
||||
@Select("""
|
||||
SELECT t.*,o.inbound_code,o.customer_name,o.warehouse_name,o.process_status,o.fulfillment_type
|
||||
SELECT t.*,o.inbound_code,o.customer_name,o.warehouse_name,o.process_status,o.fulfillment_type,
|
||||
o.pallet_count expected_pallet_count
|
||||
FROM fl_dropship_pallet_task t JOIN fl_inbound_order o ON o.id=t.inbound_id AND o.tenant_id=t.tenant_id
|
||||
WHERE t.id=#{taskId} AND t.warehouse_id=#{warehouseId}""")
|
||||
TaskRow task(@Param("taskId") long taskId,@Param("warehouseId") long warehouseId);
|
||||
@@ -58,12 +56,31 @@ public interface DropshipPalletizationMapper {
|
||||
@Param("inboundId") long inboundId,@Param("warehouseId") long warehouseId,@Param("palletNo") String palletNo,
|
||||
@Param("operationId") String operationId,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
|
||||
|
||||
@Select("SELECT * FROM fl_dropship_pallet WHERE pallet_no=#{palletNo}") PalletRow palletByNo(@Param("palletNo") String palletNo);
|
||||
@Select("SELECT * FROM fl_dropship_pallet WHERE pallet_no=#{palletNo} FOR UPDATE") PalletRow palletByNo(@Param("palletNo") String palletNo);
|
||||
@Select("SELECT COUNT(*) FROM fl_dropship_pallet WHERE task_id=#{taskId}") int palletCount(@Param("taskId") long taskId);
|
||||
@Select("SELECT * FROM fl_dropship_pallet WHERE seal_operation_id=#{operationId}") PalletRow palletBySealOperation(@Param("operationId") String operationId);
|
||||
@Select("SELECT * FROM fl_dropship_pallet WHERE id=#{palletId} AND task_id=#{taskId} AND warehouse_id=#{warehouseId}")
|
||||
@Select("""
|
||||
SELECT p.* FROM fl_dropship_pallet p
|
||||
WHERE p.id=#{palletId} AND p.warehouse_id=#{warehouseId}
|
||||
AND (p.task_id=#{taskId} OR EXISTS(SELECT 1 FROM fl_dropship_pallet_carton c WHERE c.pallet_id=p.id AND c.task_id=#{taskId}))
|
||||
""")
|
||||
PalletRow pallet(@Param("taskId") long taskId,@Param("palletId") long palletId,@Param("warehouseId") long warehouseId);
|
||||
@Select("SELECT * FROM fl_dropship_pallet WHERE task_id=#{taskId} ORDER BY id") List<PalletRow> pallets(@Param("taskId") long taskId);
|
||||
@Select("SELECT p.* FROM fl_dropship_pallet p JOIN fl_dropship_pallet_task t ON t.id=p.task_id WHERE t.tenant_id=#{tenantId} AND t.inbound_id=#{inboundId} ORDER BY p.id")
|
||||
@Select("""
|
||||
SELECT p.* FROM fl_dropship_pallet p
|
||||
WHERE EXISTS(SELECT 1 FROM fl_dropship_pallet_carton c WHERE c.pallet_id=p.id AND c.task_id=#{taskId})
|
||||
ORDER BY p.id
|
||||
""") List<PalletRow> pallets(@Param("taskId") long taskId);
|
||||
@Select("SELECT * FROM fl_dropship_pallet WHERE task_id=#{taskId} ORDER BY id")
|
||||
List<PalletRow> generatedPallets(@Param("taskId") long taskId);
|
||||
@Select("""
|
||||
SELECT p.* FROM fl_dropship_pallet p
|
||||
WHERE ((p.tenant_id=#{tenantId} AND p.inbound_id=#{inboundId})
|
||||
AND EXISTS(SELECT 1 FROM fl_inbound_order own
|
||||
WHERE own.id=#{inboundId} AND own.tenant_id=#{tenantId} AND own.process_status='WAIT_PALLETIZE'))
|
||||
OR EXISTS(SELECT 1 FROM fl_dropship_pallet_carton c
|
||||
WHERE c.pallet_id=p.id AND c.tenant_id=#{tenantId} AND c.inbound_id=#{inboundId})
|
||||
ORDER BY p.id
|
||||
""")
|
||||
List<PalletRow> palletsByInbound(@Param("tenantId") long tenantId,@Param("inboundId") long inboundId);
|
||||
@Select("SELECT inbound_code FROM fl_inbound_order WHERE tenant_id=#{tenantId} AND id=#{inboundId} AND deleted=0")
|
||||
String inboundCode(@Param("tenantId") long tenantId,@Param("inboundId") long inboundId);
|
||||
@@ -73,6 +90,12 @@ public interface DropshipPalletizationMapper {
|
||||
FROM fl_inbound_carton_label l JOIN fl_dropship_pallet_task t ON t.inbound_id=l.inbound_id AND t.tenant_id=l.tenant_id
|
||||
WHERE t.id=#{taskId} AND l.box_code=#{boxCode} AND l.batch_status='ACTIVE'""")
|
||||
LabelRow label(@Param("taskId") long taskId,@Param("boxCode") String boxCode);
|
||||
@Select("""
|
||||
SELECT s.inbound_detail_id,s.sku_code,s.cargo_name,s.expected_quantity,s.line_no
|
||||
FROM fl_inbound_carton_label_sku s JOIN fl_inbound_carton_label l ON l.id=s.carton_label_id
|
||||
JOIN fl_dropship_pallet_task t ON t.inbound_id=l.inbound_id AND t.tenant_id=l.tenant_id
|
||||
WHERE t.id=#{taskId} AND l.id=#{labelId} ORDER BY s.line_no
|
||||
""") List<LabelSkuRow> labelSkus(@Param("taskId") long taskId,@Param("labelId") long labelId);
|
||||
@Select("SELECT c.*,p.pallet_no FROM fl_dropship_pallet_carton c LEFT JOIN fl_dropship_pallet p ON p.id=c.pallet_id WHERE c.count_operation_id=#{operationId}") CartonRow cartonByCountOperation(@Param("operationId") String operationId);
|
||||
@Select("SELECT c.*,p.pallet_no FROM fl_dropship_pallet_carton c LEFT JOIN fl_dropship_pallet p ON p.id=c.pallet_id WHERE c.task_id=#{taskId} AND c.id=#{cartonId}") CartonRow carton(@Param("taskId") long taskId,@Param("cartonId") long cartonId);
|
||||
@Select("SELECT c.*,p.pallet_no FROM fl_dropship_pallet_carton c LEFT JOIN fl_dropship_pallet p ON p.id=c.pallet_id WHERE c.task_id=#{taskId} AND c.carton_label_id=#{labelId}")
|
||||
@@ -94,6 +117,24 @@ public interface DropshipPalletizationMapper {
|
||||
@Param("discrepancyStatus") String discrepancyStatus,@Param("discrepancyRemark") String discrepancyRemark,
|
||||
@Param("operationId") String operationId,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
|
||||
|
||||
@Insert("""
|
||||
INSERT INTO fl_dropship_pallet_carton_sku
|
||||
(id,carton_id,inbound_detail_id,sku_code,cargo_name,expected_quantity,actual_quantity,
|
||||
discrepancy_status,discrepancy_remark,line_no)
|
||||
VALUES(#{id},#{cartonId},#{detailId},#{skuCode},#{cargoName},#{expectedQuantity},#{actualQuantity},
|
||||
#{discrepancyStatus},#{discrepancyRemark},#{lineNo})
|
||||
""")
|
||||
int insertCartonSku(@Param("id") long id,@Param("cartonId") long cartonId,@Param("detailId") long detailId,
|
||||
@Param("skuCode") String skuCode,@Param("cargoName") String cargoName,
|
||||
@Param("expectedQuantity") int expectedQuantity,@Param("actualQuantity") int actualQuantity,
|
||||
@Param("discrepancyStatus") String discrepancyStatus,@Param("discrepancyRemark") String discrepancyRemark,
|
||||
@Param("lineNo") int lineNo);
|
||||
|
||||
@Select("""
|
||||
SELECT inbound_detail_id,sku_code,cargo_name,expected_quantity,actual_quantity,discrepancy_status,discrepancy_remark,line_no
|
||||
FROM fl_dropship_pallet_carton_sku WHERE carton_id=#{cartonId} ORDER BY line_no
|
||||
""") List<CartonSkuRow> cartonSkus(@Param("cartonId") long cartonId);
|
||||
|
||||
@Update("""
|
||||
UPDATE fl_dropship_pallet_task SET counted_cartons=counted_cartons+1,allocated_cartons=allocated_cartons+#{boundCount},
|
||||
discrepancy_cartons=discrepancy_cartons+#{discrepancyCount},version=version+1,updated_at=CURRENT_TIMESTAMP(3)
|
||||
@@ -123,65 +164,83 @@ public interface DropshipPalletizationMapper {
|
||||
@Update("""
|
||||
UPDATE fl_dropship_pallet SET pallet_status='SEALED',seal_operation_id=#{operationId},completed_by=#{operatorId},
|
||||
completed_by_name=#{operatorName},completed_at=CURRENT_TIMESTAMP(3),version=version+1,updated_at=CURRENT_TIMESTAMP(3)
|
||||
WHERE id=#{palletId} AND task_id=#{taskId} AND pallet_status='OPEN' AND version=#{expectedVersion}
|
||||
WHERE id=#{palletId} AND warehouse_id=#{warehouseId} AND pallet_status='OPEN' AND version=#{expectedVersion}
|
||||
AND EXISTS(SELECT 1 FROM fl_dropship_pallet_carton c WHERE c.pallet_id=#{palletId})
|
||||
AND NOT EXISTS(SELECT 1 FROM fl_dropship_pallet_carton c WHERE c.task_id=#{taskId} AND c.pallet_id IS NULL)""")
|
||||
AND EXISTS(SELECT 1 FROM fl_dropship_pallet_carton c WHERE c.pallet_id=#{palletId} AND c.task_id=#{taskId})""")
|
||||
int seal(@Param("taskId") long taskId,@Param("palletId") long palletId,@Param("expectedVersion") int expectedVersion,
|
||||
@Param("operationId") String operationId,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
|
||||
@Select("SELECT COUNT(*) FROM fl_dropship_pallet WHERE task_id=#{taskId} AND pallet_status='OPEN'") int openPalletCount(@Param("taskId") long taskId);
|
||||
@Param("warehouseId") long warehouseId,@Param("operationId") String operationId,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
|
||||
@Select("""
|
||||
SELECT COUNT(DISTINCT p.id) FROM fl_dropship_pallet_carton c
|
||||
JOIN fl_dropship_pallet p ON p.id=c.pallet_id
|
||||
WHERE c.task_id=#{taskId} AND p.pallet_status='OPEN'
|
||||
""") int openPalletCount(@Param("taskId") long taskId);
|
||||
@Select("SELECT DISTINCT task_id FROM fl_dropship_pallet_carton WHERE pallet_id=#{palletId} ORDER BY task_id")
|
||||
List<Long> taskIdsByPallet(@Param("palletId") long palletId);
|
||||
@Update("""
|
||||
UPDATE fl_dropship_pallet_task SET task_status='COMPLETED',completed_at=CURRENT_TIMESTAMP(3),version=version+1,updated_at=CURRENT_TIMESTAMP(3)
|
||||
WHERE id=#{taskId} AND task_status='CLAIMED' AND counted_cartons=total_cartons AND allocated_cartons=total_cartons
|
||||
AND NOT EXISTS(SELECT 1 FROM fl_dropship_pallet p WHERE p.task_id=#{taskId} AND p.pallet_status='OPEN')""") int completeTask(@Param("taskId") long taskId);
|
||||
AND NOT EXISTS(SELECT 1 FROM fl_dropship_pallet_carton c JOIN fl_dropship_pallet p ON p.id=c.pallet_id
|
||||
WHERE c.task_id=#{taskId} AND p.pallet_status='OPEN')""") int completeTask(@Param("taskId") long taskId);
|
||||
@Update("""
|
||||
UPDATE fl_inbound_order o SET o.process_status='WAIT_SPLIT',o.pallet_count=(SELECT COUNT(*) FROM fl_dropship_pallet p
|
||||
WHERE p.inbound_id=o.id AND p.pallet_status='SEALED'),o.updated_by=#{operatorId},o.version=o.version+1
|
||||
UPDATE fl_inbound_order o SET o.process_status='WAIT_SPLIT',o.pallet_count=(SELECT COUNT(DISTINCT p.id)
|
||||
FROM fl_dropship_pallet_carton c JOIN fl_dropship_pallet p ON p.id=c.pallet_id
|
||||
WHERE c.tenant_id=o.tenant_id AND c.inbound_id=o.id AND p.pallet_status='SEALED'),o.updated_by=#{operatorId},o.version=o.version+1
|
||||
WHERE o.id=#{inboundId} AND o.tenant_id=#{tenantId} AND o.process_status='WAIT_PALLETIZE'
|
||||
AND EXISTS(SELECT 1 FROM fl_dropship_pallet_task t WHERE t.id=#{taskId} AND t.task_status='COMPLETED')""")
|
||||
int completeInbound(@Param("taskId") long taskId,@Param("tenantId") long tenantId,@Param("inboundId") long inboundId,@Param("operatorId") long operatorId);
|
||||
|
||||
@Select("SELECT c.*,p.pallet_no FROM fl_dropship_pallet_carton c LEFT JOIN fl_dropship_pallet p ON p.id=c.pallet_id WHERE c.pallet_id=#{palletId} ORDER BY c.sku_code,c.inbound_detail_id,c.id") List<CartonRow> cartons(@Param("palletId") long palletId);
|
||||
@Select("""
|
||||
SELECT c.*,p.pallet_no,o.inbound_code FROM fl_dropship_pallet_carton c
|
||||
LEFT JOIN fl_dropship_pallet p ON p.id=c.pallet_id
|
||||
JOIN fl_inbound_order o ON o.id=c.inbound_id AND o.tenant_id=c.tenant_id
|
||||
WHERE c.pallet_id=#{palletId} ORDER BY o.inbound_code,c.sku_code,c.inbound_detail_id,c.id
|
||||
""") List<CartonRow> cartons(@Param("palletId") long palletId);
|
||||
@Select("SELECT c.*,p.pallet_no FROM fl_dropship_pallet_carton c LEFT JOIN fl_dropship_pallet p ON p.id=c.pallet_id WHERE c.task_id=#{taskId} AND c.pallet_id IS NULL ORDER BY c.counted_at,c.id") List<CartonRow> unboundCartons(@Param("taskId") long taskId);
|
||||
@Select("""
|
||||
SELECT COALESCE(o.actual_gross_weight_kg*COUNT(c.id)/NULLIF(o.total_package_count,0),0) weight_kg,
|
||||
COALESCE(o.actual_volume_cbm*COUNT(c.id)/NULLIF(o.total_package_count,0),0) volume_cbm
|
||||
FROM fl_dropship_pallet p JOIN fl_inbound_order o ON o.id=p.inbound_id AND o.tenant_id=p.tenant_id
|
||||
LEFT JOIN fl_dropship_pallet_carton c ON c.pallet_id=p.id WHERE p.id=#{palletId}
|
||||
GROUP BY p.id,o.actual_gross_weight_kg,o.actual_volume_cbm,o.total_package_count""")
|
||||
SELECT COALESCE(SUM(o.actual_gross_weight_kg/NULLIF(o.total_package_count,0)),0) weight_kg,
|
||||
COALESCE(SUM(o.actual_volume_cbm/NULLIF(o.total_package_count,0)),0) volume_cbm
|
||||
FROM fl_dropship_pallet_carton c
|
||||
JOIN fl_inbound_order o ON o.id=c.inbound_id AND o.tenant_id=c.tenant_id
|
||||
WHERE c.pallet_id=#{palletId}""")
|
||||
PalletMetrics metrics(@Param("palletId") long palletId);
|
||||
@Select("""
|
||||
SELECT p.id pallet_id,p.pallet_no,'SEALED_PALLET' unit_type,TRUE indivisible,COUNT(c.id) carton_count,
|
||||
COUNT(DISTINCT c.sku_code) sku_count,
|
||||
COALESCE(o.actual_gross_weight_kg*COUNT(c.id)/NULLIF(o.total_package_count,0),0) weight_kg,
|
||||
COALESCE(o.actual_volume_cbm*COUNT(c.id)/NULLIF(o.total_package_count,0),0) volume_cbm
|
||||
FROM fl_dropship_pallet p JOIN fl_inbound_order o ON o.id=p.inbound_id AND o.tenant_id=p.tenant_id
|
||||
JOIN fl_dropship_pallet_carton c ON c.pallet_id=p.id
|
||||
WHERE p.inbound_id=#{inboundId} AND p.pallet_status='SEALED'
|
||||
SELECT p.id pallet_id,p.pallet_no,'SEALED_PALLET' unit_type,TRUE indivisible,COUNT(DISTINCT c.id) carton_count,
|
||||
(SELECT COUNT(DISTINCT sku.sku_code) FROM fl_dropship_pallet_carton pc
|
||||
JOIN fl_dropship_pallet_carton_sku sku ON sku.carton_id=pc.id WHERE pc.pallet_id=p.id) sku_count,
|
||||
COALESCE(SUM(o.actual_gross_weight_kg/NULLIF(o.total_package_count,0)),0) weight_kg,
|
||||
COALESCE(SUM(o.actual_volume_cbm/NULLIF(o.total_package_count,0)),0) volume_cbm
|
||||
FROM fl_dropship_pallet p JOIN fl_dropship_pallet_carton c ON c.pallet_id=p.id
|
||||
JOIN fl_inbound_order o ON o.id=c.inbound_id AND o.tenant_id=c.tenant_id
|
||||
WHERE EXISTS(SELECT 1 FROM fl_dropship_pallet_carton own WHERE own.pallet_id=p.id AND own.inbound_id=#{inboundId})
|
||||
AND p.pallet_status='SEALED'
|
||||
AND NOT EXISTS(SELECT 1 FROM fl_loading_plan_item i WHERE i.pallet_id=p.id AND i.deleted=0)
|
||||
GROUP BY p.id,p.pallet_no,o.actual_gross_weight_kg,o.actual_volume_cbm,o.total_package_count ORDER BY p.id
|
||||
GROUP BY p.id,p.pallet_no ORDER BY p.id
|
||||
""") List<PalletCandidate> palletCandidates(@Param("inboundId") long inboundId);
|
||||
|
||||
@Select("""
|
||||
SELECT p.id pallet_id,p.pallet_no,p.pallet_status,p.tenant_id account_id,p.inbound_id,o.order_id,o.order_no,
|
||||
SELECT p.id pallet_id,p.pallet_no,p.pallet_status,c.tenant_id account_id,c.inbound_id,o.order_id,o.order_no,
|
||||
o.inbound_code,o.warehouse_id,o.channel_code,o.channel_name,o.destination_warehouse_id,
|
||||
o.destination_code destination_warehouse_code,o.destination_name destination_warehouse_name,o.customer_name,
|
||||
c.inbound_detail_id,c.sku_code,MAX(c.cargo_name) cargo_name,COUNT(c.id) carton_count,
|
||||
SUM(c.actual_sku_quantity) actual_sku_quantity,
|
||||
s.inbound_detail_id,s.sku_code,MAX(s.cargo_name) cargo_name,COUNT(DISTINCT c.id) carton_count,
|
||||
SUM(s.actual_quantity) actual_sku_quantity,
|
||||
COALESCE(o.actual_gross_weight_kg/NULLIF(o.total_package_count,0),0) unit_gross_weight_kg,
|
||||
COALESCE(o.actual_net_weight_kg/NULLIF(o.total_package_count,0),0) unit_net_weight_kg,
|
||||
COALESCE(o.actual_volume_cbm/NULLIF(o.total_package_count,0),0) unit_volume_cbm
|
||||
FROM fl_dropship_pallet p JOIN fl_inbound_order o ON o.id=p.inbound_id AND o.tenant_id=p.tenant_id
|
||||
JOIN fl_dropship_pallet_carton c ON c.pallet_id=p.id WHERE p.id=#{palletId}
|
||||
GROUP BY p.id,p.pallet_no,p.pallet_status,p.tenant_id,p.inbound_id,o.order_id,o.order_no,o.inbound_code,
|
||||
FROM fl_dropship_pallet p JOIN fl_dropship_pallet_carton c ON c.pallet_id=p.id
|
||||
JOIN fl_dropship_pallet_carton_sku s ON s.carton_id=c.id
|
||||
JOIN fl_inbound_order o ON o.id=c.inbound_id AND o.tenant_id=c.tenant_id WHERE p.id=#{palletId}
|
||||
GROUP BY p.id,p.pallet_no,p.pallet_status,c.tenant_id,c.inbound_id,o.order_id,o.order_no,o.inbound_code,
|
||||
o.warehouse_id,o.channel_code,o.channel_name,o.destination_warehouse_id,o.destination_code,o.destination_name,
|
||||
o.customer_name,c.inbound_detail_id,c.sku_code,o.actual_gross_weight_kg,o.actual_net_weight_kg,o.actual_volume_cbm,o.total_package_count
|
||||
ORDER BY c.inbound_detail_id
|
||||
o.customer_name,s.inbound_detail_id,s.sku_code,o.actual_gross_weight_kg,o.actual_net_weight_kg,o.actual_volume_cbm,o.total_package_count
|
||||
ORDER BY s.inbound_detail_id
|
||||
""") List<PalletLine> sealedPalletLines(@Param("palletId") long palletId);
|
||||
|
||||
@Getter @Setter class TaskRow { private Long id,tenantId,inboundId,warehouseId,assigneeId; private String inboundCode,customerName,warehouseName,taskStatus,assigneeName,processStatus,fulfillmentType; private Integer totalCartons,countedCartons,allocatedCartons,discrepancyCartons,version; }
|
||||
@Getter @Setter class TaskRow { private Long id,tenantId,inboundId,warehouseId,assigneeId; private String inboundCode,customerName,warehouseName,taskStatus,assigneeName,processStatus,fulfillmentType; private Integer totalCartons,countedCartons,allocatedCartons,discrepancyCartons,expectedPalletCount,version; }
|
||||
@Getter @Setter class PalletRow { private Long id,taskId,tenantId,inboundId,warehouseId; private String palletNo,palletStatus,sealOperationId; private Integer version; private LocalDateTime completedAt; }
|
||||
@Getter @Setter class LabelRow { private Long cartonLabelId,inboundDetailId; private String skuCode,cargoName,inboundCode,boxCode; private Integer unitsPerCarton; }
|
||||
@Getter @Setter class CartonRow { private Long id,taskId,palletId,inboundId,inboundDetailId; private String palletNo,skuCode,cargoName,boxCode,discrepancyStatus,discrepancyRemark; private Integer expectedSkuQuantity,actualSkuQuantity; }
|
||||
@Getter @Setter class LabelSkuRow { private Long inboundDetailId; private String skuCode,cargoName; private Integer expectedQuantity,lineNo; }
|
||||
@Getter @Setter class CartonRow { private Long id,taskId,palletId,inboundId,inboundDetailId; private String palletNo,inboundCode,skuCode,cargoName,boxCode,discrepancyStatus,discrepancyRemark; private Integer expectedSkuQuantity,actualSkuQuantity; }
|
||||
@Getter @Setter class CartonSkuRow { private Long inboundDetailId; private String skuCode,cargoName,discrepancyStatus,discrepancyRemark; private Integer expectedQuantity,actualQuantity,lineNo; }
|
||||
record PalletMetrics(BigDecimal weightKg,BigDecimal volumeCbm) {}
|
||||
@Getter @Setter class PalletLine { private Long palletId,tenantId,accountId,inboundId,orderId,warehouseId,destinationWarehouseId,inboundDetailId; private String palletNo,palletStatus,orderNo,inboundCode,channelCode,channelName,destinationWarehouseCode,destinationWarehouseName,customerName,skuCode,cargoName; private Integer cartonCount,actualSkuQuantity; private BigDecimal unitGrossWeightKg,unitNetWeightKg,unitVolumeCbm; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.baiyue.firstleg.mapper;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import org.apache.ibatis.annotations.Insert;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** A physical carton label may freeze more than one SKU line. */
|
||||
@Mapper
|
||||
public interface InboundCartonLabelSkuMapper {
|
||||
@Insert("""
|
||||
INSERT INTO fl_inbound_carton_label_sku
|
||||
(id,tenant_id,inbound_id,carton_label_id,inbound_detail_id,sku_code,cargo_name,expected_quantity,line_no,created_at)
|
||||
VALUES(#{id},#{tenantId},#{inboundId},#{labelId},#{detailId},#{skuCode},#{cargoName},#{expectedQuantity},#{lineNo},CURRENT_TIMESTAMP(3))
|
||||
""")
|
||||
int insert(@Param("id") long id, @Param("tenantId") long tenantId,
|
||||
@Param("inboundId") long inboundId, @Param("labelId") long labelId,
|
||||
@Param("detailId") long detailId, @Param("skuCode") String skuCode,
|
||||
@Param("cargoName") String cargoName, @Param("expectedQuantity") int expectedQuantity,
|
||||
@Param("lineNo") int lineNo);
|
||||
|
||||
@Select("""
|
||||
SELECT inbound_detail_id,sku_code,cargo_name,expected_quantity,line_no
|
||||
FROM fl_inbound_carton_label_sku WHERE carton_label_id=#{labelId} ORDER BY line_no
|
||||
""")
|
||||
List<SkuRow> findByLabel(@Param("labelId") long labelId);
|
||||
|
||||
@Select("""
|
||||
SELECT carton_label_id,inbound_detail_id,sku_code,cargo_name,expected_quantity,line_no
|
||||
FROM fl_inbound_carton_label_sku WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId}
|
||||
ORDER BY carton_label_id,line_no
|
||||
""")
|
||||
List<SkuRow> findByInbound(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId);
|
||||
|
||||
@Getter @Setter
|
||||
class SkuRow {
|
||||
private Long inboundDetailId;
|
||||
private Long cartonLabelId;
|
||||
private String skuCode;
|
||||
private String cargoName;
|
||||
private Integer expectedQuantity;
|
||||
private Integer lineNo;
|
||||
}
|
||||
}
|
||||
@@ -85,7 +85,8 @@ public interface LoadingCompletedEventMapper {
|
||||
pallet.pallet_no AS 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
|
||||
item.product_name_zh,item.package_count,item.product_quantity,carton.box_code,
|
||||
carton.actual_sku_quantity
|
||||
FROM fl_dropship_pallet pallet
|
||||
JOIN fl_dropship_pallet_carton carton ON carton.pallet_id=pallet.id
|
||||
JOIN fl_loading_plan_item item ON item.loading_plan_id=#{planId}
|
||||
@@ -98,15 +99,17 @@ public interface LoadingCompletedEventMapper {
|
||||
|
||||
@Select("""
|
||||
SELECT COUNT(DISTINCT p.id) FROM fl_dropship_pallet p
|
||||
JOIN fl_loading_plan_item i ON i.loading_plan_id=#{planId} AND i.account_id=p.tenant_id
|
||||
AND i.inbound_id=p.inbound_id AND i.deleted=0 WHERE p.pallet_status='SEALED'
|
||||
JOIN fl_dropship_pallet_carton c ON c.pallet_id=p.id
|
||||
JOIN fl_loading_plan_item i ON i.loading_plan_id=#{planId} AND i.account_id=c.tenant_id
|
||||
AND i.inbound_id=c.inbound_id AND i.deleted=0 WHERE p.pallet_status='SEALED'
|
||||
""")
|
||||
int workPalletCount(@Param("planId") long planId);
|
||||
|
||||
@Select("""
|
||||
SELECT COUNT(DISTINCT p.id) FROM fl_dropship_pallet p
|
||||
JOIN fl_loading_plan_item i ON i.loading_plan_id=#{planId} AND i.account_id=p.tenant_id
|
||||
AND i.inbound_id=p.inbound_id AND i.deleted=0 WHERE p.pallet_status='OPEN'
|
||||
JOIN fl_dropship_pallet_carton c ON c.pallet_id=p.id
|
||||
JOIN fl_loading_plan_item i ON i.loading_plan_id=#{planId} AND i.account_id=c.tenant_id
|
||||
AND i.inbound_id=c.inbound_id AND i.deleted=0 WHERE p.pallet_status='OPEN'
|
||||
""")
|
||||
int openWorkPalletCount(@Param("planId") long planId);
|
||||
|
||||
@@ -138,6 +141,6 @@ public interface LoadingCompletedEventMapper {
|
||||
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;
|
||||
private Integer packageCount; private Integer productQuantity; private Integer actualSkuQuantity; private String boxCode;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,11 @@ public interface LoadingListQueryMapper {
|
||||
p.id AS plan_id, p.plan_no, p.container_booking_id, p.plan_status, p.remark,
|
||||
p.created_by, p.created_by_name, p.created_at, p.version,
|
||||
b.container_no, b.cabinet_no, b.warehouse_id, b.warehouse_name,
|
||||
b.channel_code,
|
||||
(SELECT MIN(outbound.confirmed_at) FROM fl_outbound_dispatch outbound
|
||||
WHERE outbound.container_booking_id = p.container_booking_id AND outbound.confirmed_at IS NOT NULL
|
||||
AND outbound.reversed_at IS NULL AND outbound.status = 'WAITING_FOR_LOADING') AS outbound_confirmed_at,
|
||||
b.actual_empty_reported_at,
|
||||
b.vessel_name, b.voyage_no, b.container_type,
|
||||
(SELECT COUNT(*) FROM fl_preload_loading_plan_link preload_link
|
||||
WHERE preload_link.loading_plan_id = p.id) AS preload_link_count,
|
||||
@@ -51,6 +56,7 @@ public interface LoadingListQueryMapper {
|
||||
p.id, p.plan_no, p.container_booking_id, p.plan_status, p.remark,
|
||||
p.created_by, p.created_by_name, p.created_at, p.version,
|
||||
b.container_no, b.cabinet_no, b.warehouse_id, b.warehouse_name,
|
||||
b.channel_code, b.actual_empty_reported_at,
|
||||
b.vessel_name, b.voyage_no, b.container_type
|
||||
""";
|
||||
|
||||
@@ -394,6 +400,9 @@ public interface LoadingListQueryMapper {
|
||||
private String cabinetNo;
|
||||
private Long warehouseId;
|
||||
private String warehouseName;
|
||||
private String channelCode;
|
||||
private LocalDateTime outboundConfirmedAt;
|
||||
private LocalDateTime actualEmptyReportedAt;
|
||||
private String vesselName;
|
||||
private String voyageNo;
|
||||
private String containerType;
|
||||
|
||||
@@ -199,7 +199,7 @@ public interface LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity>
|
||||
WHERE plan_item.id = fl_loading_pick_item.loading_plan_item_id
|
||||
AND plan_item.deleted = 0)
|
||||
AND item_status IN ('PENDING', 'PICKING')
|
||||
AND picked_package_count < #{finalCount}
|
||||
AND picked_package_count <= #{finalCount}
|
||||
AND #{finalCount} < planned_package_count
|
||||
""")
|
||||
int partial(@Param("id") long id, @Param("finalCount") int finalCount,
|
||||
|
||||
@@ -49,6 +49,7 @@ public interface LoadingPickTaskMapper extends BaseMapper<LoadingPickTaskEntity>
|
||||
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 booking.container_no = #{containerNo} AND booking.record_status = 'ACTIVE'
|
||||
AND booking.warehouse_id = #{warehouseId}
|
||||
AND task.task_status = 'LOADING'
|
||||
LIMIT 1
|
||||
""")
|
||||
@@ -58,12 +59,14 @@ public interface LoadingPickTaskMapper extends BaseMapper<LoadingPickTaskEntity>
|
||||
@Select("SELECT task.* 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 "
|
||||
+ "WHERE task.task_status='LOADING' AND booking.record_status='ACTIVE' "
|
||||
+ "AND booking.warehouse_id=#{warehouseId} "
|
||||
+ "AND booking.cabinet_no=#{cabinetNo} LIMIT 1")
|
||||
LoadingPickTaskEntity selectLoadingByCabinet(@Param("cabinetNo") String cabinetNo, @Param("warehouseId") long warehouseId);
|
||||
|
||||
@Select("SELECT task.* 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 "
|
||||
+ "WHERE task.task_status='LOADING' AND booking.record_status='ACTIVE' "
|
||||
+ "AND booking.warehouse_id=#{warehouseId} "
|
||||
+ "AND task.loading_plan_id=#{loadingPlanId} LIMIT 1")
|
||||
LoadingPickTaskEntity selectLoadingByPlan(@Param("loadingPlanId") long loadingPlanId, @Param("warehouseId") long warehouseId);
|
||||
|
||||
@@ -97,6 +100,7 @@ public interface LoadingPickTaskMapper extends BaseMapper<LoadingPickTaskEntity>
|
||||
JOIN fl_loading_pick_item pick_item ON pick_item.pick_task_id = task.id
|
||||
LEFT JOIN fl_loading_pick_assignment assignment ON assignment.pick_task_id = task.id
|
||||
WHERE task.task_status IN ('LOADING','COMPLETING','COMPLETION_FAILED') AND booking.record_status = 'ACTIVE'
|
||||
AND booking.warehouse_id = #{warehouseId}
|
||||
AND (assignment.pick_task_id IS NULL OR assignment.assignee_id = #{operatorId})
|
||||
<if test="keyword != null and keyword != ''">
|
||||
AND (booking.container_no LIKE CONCAT('%', #{keyword}, '%')
|
||||
@@ -112,6 +116,7 @@ public interface LoadingPickTaskMapper extends BaseMapper<LoadingPickTaskEntity>
|
||||
</script>
|
||||
""")
|
||||
List<PendingTaskRow> selectPendingForOperator(@Param("operatorId") long operatorId,
|
||||
@Param("warehouseId") long warehouseId,
|
||||
@Param("keyword") String keyword);
|
||||
|
||||
class PendingTaskRow {
|
||||
|
||||
@@ -321,7 +321,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT b.id bookingId,b.booking_no bookingNo,b.cabinet_no cabinetNo,b.container_no containerNo,
|
||||
b.warehouse_id warehouseId,b.warehouse_name warehouseName,b.channel_code channelCode,
|
||||
b.warehouse_id warehouseId,b.warehouse_name warehouseName,b.channel_group_code channelGroupCode,b.channel_code channelCode,
|
||||
b.channel_name channelName,b.container_type containerType,b.capacity_cbm capacityCbm,
|
||||
b.max_weight_kg maxWeightKg,p.id planId,p.plan_no planNo,
|
||||
CASE WHEN p.id IS NULL OR NOT EXISTS (SELECT 1 FROM fl_loading_plan_item state_item
|
||||
@@ -331,8 +331,8 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
|
||||
LEFT JOIN fl_loading_plan p ON p.container_booking_id=b.id
|
||||
WHERE b.record_status='ACTIVE'
|
||||
AND (p.id IS NULL OR p.plan_status IN ('EDITING','LOADING'))
|
||||
AND b.channel_code IN
|
||||
<foreach collection="actualChannels" item="channel" open="(" separator="," close=")">#{channel}</foreach>
|
||||
AND b.channel_group_code IN
|
||||
<foreach collection="channelGroups" item="channelGroup" open="(" separator="," close=")">#{channelGroup}</foreach>
|
||||
<if test="query.warehouseId != null">AND b.warehouse_id=#{query.warehouseId}</if>
|
||||
<if test="query.customerId != null or query.inboundCode != null or query.createdStart != null or query.createdEnd != null">
|
||||
AND EXISTS (
|
||||
@@ -351,7 +351,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
|
||||
""")
|
||||
Page<WorkbenchContainer> workbenchContainers(Page<WorkbenchContainer> page,
|
||||
@Param("query") WorkbenchContainerQuery query,
|
||||
@Param("actualChannels") Set<String> actualChannels);
|
||||
@Param("channelGroups") Set<String> channelGroups);
|
||||
|
||||
@Select("""
|
||||
SELECT o.tenant_id accountId,o.id inboundId,i.inbound_detail_id inboundDetailId,
|
||||
|
||||
@@ -29,7 +29,7 @@ public final class OceanBookingDtos {
|
||||
}
|
||||
|
||||
public record LinkCandidateQuery(@NotNull @Positive Long warehouseId,
|
||||
@NotBlank @Size(max = 64) String channelCode,
|
||||
@NotBlank @Size(max = 64) String channelGroupCode,
|
||||
@NotBlank @Size(max = 32) String containerType,
|
||||
@Min(1) Long page,
|
||||
@Min(1) @Max(200) Long size) {
|
||||
|
||||
+6
-2
@@ -54,6 +54,7 @@ public interface OceanBookingMapper extends BaseMapper<OceanBookingEntity> {
|
||||
Page<OceanBookingEntity> selectPage(Page<OceanBookingEntity> page, @Param("query") Query query);
|
||||
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT booking.id ocean_booking_id,booking.booking_no,requirement.id requirement_id,
|
||||
requirement.container_type,requirement.capacity_cbm,requirement.max_weight_kg,
|
||||
requirement.quantity required_quantity,
|
||||
@@ -75,13 +76,16 @@ public interface OceanBookingMapper extends BaseMapper<OceanBookingEntity> {
|
||||
AND allocated.requirement_id=requirement.id
|
||||
WHERE booking.status='RELEASED'
|
||||
AND booking.warehouse_id=#{query.warehouseId}
|
||||
AND booking.channel_code=#{query.channelCode}
|
||||
AND booking.channel_code IN
|
||||
<foreach collection="channelCodes" item="channelCode" open="(" separator="," close=")">#{channelCode}</foreach>
|
||||
AND requirement.container_type=#{query.containerType}
|
||||
AND requirement.quantity>COALESCE(allocated.allocated_quantity,0)
|
||||
ORDER BY booking.updated_at DESC,booking.id DESC,requirement.sort_no ASC,requirement.id ASC
|
||||
</script>
|
||||
""")
|
||||
Page<LinkCandidate> selectLinkCandidates(Page<LinkCandidate> page,
|
||||
@Param("query") LinkCandidateQuery query);
|
||||
@Param("query") LinkCandidateQuery query,
|
||||
@Param("channelCodes") java.util.Set<String> channelCodes);
|
||||
|
||||
@Update("""
|
||||
UPDATE fl_ocean_booking SET status=#{toStatus},
|
||||
|
||||
+2
-2
@@ -33,8 +33,8 @@ public class OceanBookingRepository {
|
||||
public PageResult<OceanBookingEntity> page(Query query) {
|
||||
return PageResult.from(bookings.selectPage(new Page<>(query.page(), query.size()), query));
|
||||
}
|
||||
public PageResult<LinkCandidate> linkCandidates(LinkCandidateQuery query) {
|
||||
return PageResult.from(bookings.selectLinkCandidates(new Page<>(query.page(), query.size()), query));
|
||||
public PageResult<LinkCandidate> linkCandidates(LinkCandidateQuery query, java.util.Set<String> channelCodes) {
|
||||
return PageResult.from(bookings.selectLinkCandidates(new Page<>(query.page(), query.size()), query, channelCodes));
|
||||
}
|
||||
public OceanBookingEntity find(long id) { return bookings.selectById(id); }
|
||||
public OceanBookingEntity findForUpdate(long id) { return bookings.selectForUpdate(id); }
|
||||
|
||||
+5
-19
@@ -15,7 +15,6 @@ import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
@@ -47,7 +46,7 @@ public class OceanBookingPrintService {
|
||||
String fileName = booking.getBookingNo() + "-" + suffix + ".pdf";
|
||||
PrintPdfClient.FreightA4PdfRequest request = new PrintPdfClient.FreightA4PdfRequest(
|
||||
template, A4, fileName, List.of(new PrintPdfClient.FreightDocument(
|
||||
fields(template, detail), List.of(cargo(detail.containerRequirements())))));
|
||||
fields(template, detail), List.of(emptyCargo()))));
|
||||
try {
|
||||
ResponseEntity<byte[]> response = print.generateFreightA4Documents(internalToken, requestId, request);
|
||||
MediaType contentType = response.getHeaders().getContentType();
|
||||
@@ -78,12 +77,12 @@ public class OceanBookingPrintService {
|
||||
fields.put("placeOfDelivery", text(booking.getPlaceOfDelivery()));
|
||||
fields.put("serviceTerm", text(booking.getServiceTerm()));
|
||||
fields.put("freightTerm", text(booking.getFreightTerm()));
|
||||
fields.put("totalPackagesInWords", Integer.toString(totalContainers(detail.containerRequirements())));
|
||||
fields.put("totalPackagesInWords", "");
|
||||
if (SHIPPING_INSTRUCTION.equals(template)) {
|
||||
fields.put("toCompany", text(booking.getBookingAgentName()));
|
||||
fields.put("jobNumber", text(booking.getBookingNo()));
|
||||
fields.put("etdDate", date(booking.getEstimatedSailingAt()));
|
||||
fields.put("cargoQuantity", Integer.toString(totalContainers(detail.containerRequirements())));
|
||||
fields.put("cargoQuantity", "");
|
||||
fields.put("carrier", text(booking.getShippingCompanyName()));
|
||||
fields.put("bookingAgent", text(booking.getBookingAgentName()));
|
||||
fields.put("remark", text(booking.getRemark()));
|
||||
@@ -99,18 +98,8 @@ public class OceanBookingPrintService {
|
||||
return fields;
|
||||
}
|
||||
|
||||
private static PrintPdfClient.CargoRow cargo(List<OceanBookingContainerRequirementEntity> requirements) {
|
||||
BigDecimal gross = requirements.stream().map(value -> value.getMaxWeightKg()
|
||||
.multiply(BigDecimal.valueOf(value.getQuantity()))).reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
BigDecimal volume = requirements.stream().map(value -> value.getCapacityCbm()
|
||||
.multiply(BigDecimal.valueOf(value.getQuantity()))).reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
return new PrintPdfClient.CargoRow("N/M", Integer.toString(totalContainers(requirements)),
|
||||
"CONTAINERS", requirementSummary(requirements), decimal(gross, 3) + " KGS",
|
||||
decimal(volume, 4) + " CBM");
|
||||
}
|
||||
|
||||
private static int totalContainers(List<OceanBookingContainerRequirementEntity> values) {
|
||||
return values.stream().mapToInt(OceanBookingContainerRequirementEntity::getQuantity).sum();
|
||||
private static PrintPdfClient.CargoRow emptyCargo() {
|
||||
return new PrintPdfClient.CargoRow("—", "—", "—", "—", "—", "—");
|
||||
}
|
||||
|
||||
private static String requirementSummary(List<OceanBookingContainerRequirementEntity> values) {
|
||||
@@ -128,9 +117,6 @@ public class OceanBookingPrintService {
|
||||
private static String date(java.time.LocalDateTime value) {
|
||||
return value == null ? "" : value.format(DateTimeFormatter.ISO_LOCAL_DATE);
|
||||
}
|
||||
private static String decimal(BigDecimal value, int scale) {
|
||||
return value.setScale(scale, RoundingMode.HALF_UP).toPlainString();
|
||||
}
|
||||
private static String first(String first, String fallback) { return !text(first).isEmpty() ? first.trim() : text(fallback); }
|
||||
private static String join(String first, String second) { return List.of(text(first), text(second)).stream().filter(value -> !value.isEmpty()).collect(Collectors.joining(" / ")); }
|
||||
private static String text(String value) { return value == null ? "" : value.trim(); }
|
||||
|
||||
+5
-1
@@ -10,6 +10,7 @@ import com.baiyue.firstleg.client.WarehousePartnerClient;
|
||||
import com.baiyue.firstleg.client.WarehouseSupplierClient;
|
||||
import com.baiyue.firstleg.oceanbooking.domain.OceanBookingStatus;
|
||||
import com.baiyue.firstleg.domain.ContainerSpecificationCatalog;
|
||||
import com.baiyue.firstleg.domain.SmartLoadingChannelGroupCatalog;
|
||||
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.*;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.*;
|
||||
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
|
||||
@@ -71,8 +72,11 @@ public class OceanBookingService {
|
||||
}
|
||||
|
||||
public PageResult<LinkCandidate> linkCandidates(LinkCandidateQuery query) {
|
||||
SmartLoadingChannelGroupCatalog.Definition group;
|
||||
try { group = SmartLoadingChannelGroupCatalog.require(query.channelGroupCode()); }
|
||||
catch (IllegalArgumentException exception) { throw badRequest(exception.getMessage()); }
|
||||
return repository.linkCandidates(new LinkCandidateQuery(query.warehouseId(),
|
||||
query.channelCode().trim(), query.containerType().trim(), query.page(), query.size()));
|
||||
group.code(), query.containerType().trim(), query.page(), query.size()), group.channelCodes());
|
||||
}
|
||||
|
||||
public List<ChannelOption> channels(long originWarehouseId) {
|
||||
|
||||
+7
@@ -47,6 +47,13 @@ public interface CarrierScheduleQueryMapper extends BaseMapper<CarrierScheduleQu
|
||||
@Param("oceanBookingId") long oceanBookingId,
|
||||
@Param("containerBookingId") long containerBookingId);
|
||||
|
||||
@Select("""
|
||||
SELECT * FROM fl_carrier_schedule_query
|
||||
WHERE container_booking_id=#{containerBookingId}
|
||||
ORDER BY id DESC LIMIT 1
|
||||
""")
|
||||
CarrierScheduleQueryEntity findByContainerBookingId(@Param("containerBookingId") long containerBookingId);
|
||||
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT b.id AS containerBookingId,q.id AS queryId,q.version AS queryVersion,
|
||||
|
||||
+3
@@ -41,6 +41,9 @@ public class CarrierScheduleQueryRepository {
|
||||
public CarrierScheduleQueryEntity findByBusinessScope(long customerId, long oceanBookingId, long containerBookingId) {
|
||||
return queries.findByBusinessScope(customerId, oceanBookingId, containerBookingId);
|
||||
}
|
||||
public CarrierScheduleQueryEntity findByContainerBookingId(long containerBookingId) {
|
||||
return queries.findByContainerBookingId(containerBookingId);
|
||||
}
|
||||
public List<ContainerActualFacts> containerActualFacts(List<Long> containerBookingIds) {
|
||||
return queries.selectContainerActualFacts(containerBookingIds);
|
||||
}
|
||||
|
||||
+28
-3
@@ -222,6 +222,7 @@ public class CarrierScheduleQueryService {
|
||||
}
|
||||
CarrierScheduleQueryEntity value = requiredLockedVersion(id, request.expectedVersion());
|
||||
CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode());
|
||||
requireManualActualAdvance(value, node, request.actualAt());
|
||||
CarrierScheduleQueryEntity before = copy(value);
|
||||
String providerEventId = sha256("MANUAL|" + id + '|' + key);
|
||||
CarrierScheduleNodeFactEntity fact = addNode(value, "MANUAL", providerEventId, node.name(), node,
|
||||
@@ -261,13 +262,27 @@ public class CarrierScheduleQueryService {
|
||||
ContainerBookingEntity container = portSchedules.containerBooking(containerBookingId);
|
||||
if (container == null) throw new BizException(ResultCode.NOT_FOUND, "货柜不存在");
|
||||
CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode());
|
||||
CarrierScheduleQueryEntity found = repository.findByContainerBookingId(containerBookingId);
|
||||
CarrierScheduleQueryEntity query = found == null ? null : requiredLocked(found.getId());
|
||||
String providerEventId = sha256("CONTAINER_MANUAL|" + containerBookingId + '|' + key);
|
||||
String payloadHash = hash("CONTAINER_MANUAL_NODE", containerBookingId, request);
|
||||
CarrierScheduleNodeFactEntity fact = addNode(null, containerBookingId, "MANUAL", providerEventId,
|
||||
CarrierScheduleNodeFactEntity fact = addNode(query == null ? null : query.getId(), containerBookingId,
|
||||
"MANUAL", providerEventId,
|
||||
node.name(), node, request.observedAt(), request.estimatedAt(), request.actualAt(),
|
||||
request.locationCode(), request.locationName(), "MANUAL:" + operatorId, payloadHash);
|
||||
if (fact != null) nodeOutbox.publishContainerNode(container.getOceanBookingId(), containerBookingId,
|
||||
fact, normalizedContainerNo(container));
|
||||
if (fact != null && query != null) {
|
||||
CarrierScheduleQueryEntity before = copy(query);
|
||||
requireManualActualAdvance(query, node, request.actualAt());
|
||||
boolean advanced = advanceActualNode(query, node, request.actualAt(), request.observedAt(),
|
||||
operatorId, operatorName);
|
||||
CarrierScheduleQueryEntity after = advanced ? required(query.getId()) : query;
|
||||
addHistory(query.getId(), "MANUAL_NODE", before.getVersion(), after.getVersion(), request.reason(),
|
||||
before, after, operatorId, operatorName);
|
||||
nodeOutbox.publish(after, fact, normalizedContainerNo(container));
|
||||
} else if (fact != null) {
|
||||
nodeOutbox.publishContainerNode(container.getOceanBookingId(), containerBookingId,
|
||||
fact, normalizedContainerNo(container));
|
||||
}
|
||||
return containerNodeDetail(containerBookingId, repository.nodesByContainer(containerBookingId));
|
||||
}
|
||||
|
||||
@@ -306,6 +321,16 @@ public class CarrierScheduleQueryService {
|
||||
return false;
|
||||
}
|
||||
|
||||
private void requireManualActualAdvance(CarrierScheduleQueryEntity value, CarrierScheduleNode node,
|
||||
LocalDateTime actualAt) {
|
||||
if (actualAt == null) return;
|
||||
CarrierScheduleNode current = value.getLatestStandardNode() == null ? null
|
||||
: CarrierScheduleNode.require(value.getLatestStandardNode());
|
||||
if ("STOPPED".equals(value.getStatus()) || current != null && node.sequence() <= current.sequence()) {
|
||||
throw conflict("实际船节点必须按业务顺序向后推进");
|
||||
}
|
||||
}
|
||||
|
||||
public boolean hasOutboundDepartureFact(long customerId, long oceanBookingId, long containerBookingId) {
|
||||
CarrierScheduleQueryEntity query = repository.findByBusinessScope(customerId, oceanBookingId,
|
||||
containerBookingId);
|
||||
|
||||
@@ -90,8 +90,8 @@ public class LoadingPickRepository {
|
||||
return tasks.selectLoadingByPlan(loadingPlanId, warehouseId);
|
||||
}
|
||||
|
||||
public List<PendingTaskRow> pendingTasks(long operatorId, String keyword) {
|
||||
return tasks.selectPendingForOperator(operatorId, keyword);
|
||||
public List<PendingTaskRow> pendingTasks(long operatorId, long warehouseId, String keyword) {
|
||||
return tasks.selectPendingForOperator(operatorId, warehouseId, keyword);
|
||||
}
|
||||
|
||||
public ContainerBookingEntity activeBookingByTask(long taskId) {
|
||||
|
||||
@@ -156,4 +156,15 @@ public class PriceBookRepository {
|
||||
.set(CarrierFbaRateEntity::getUpdatedBy, operatorId)
|
||||
.set(CarrierFbaRateEntity::getUpdatedAt, at));
|
||||
}
|
||||
|
||||
public void voidPending(PriceBookVersionEntity value, LocalDateTime at, long operatorId, String reason) {
|
||||
value.setStatus("VOIDED"); value.setVoidedAt(at); value.setVoidedBy(operatorId); value.setVoidReason(reason);
|
||||
value.setUpdatedBy(operatorId); value.setUpdatedAt(at); updateVersion(value);
|
||||
rates.update(null, new LambdaUpdateWrapper<CarrierFbaRateEntity>()
|
||||
.eq(CarrierFbaRateEntity::getPriceBookVersionId, value.getId())
|
||||
.eq(CarrierFbaRateEntity::getStatus, "PENDING_CONFIRMATION")
|
||||
.set(CarrierFbaRateEntity::getStatus, "VOIDED")
|
||||
.set(CarrierFbaRateEntity::getUpdatedBy, operatorId)
|
||||
.set(CarrierFbaRateEntity::getUpdatedAt, at));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,9 +49,9 @@ public class SmartLoadingRepository {
|
||||
return units.isEmpty() ? List.of() : candidates.globalCandidatesByUnits(units);
|
||||
}
|
||||
public PageResult<WorkbenchContainer> workbenchContainers(WorkbenchContainerQuery query,
|
||||
Set<String> actualChannels) {
|
||||
Set<String> channelGroups) {
|
||||
return PageResult.from(candidates.workbenchContainers(
|
||||
new Page<>(query.page(), query.size()), query, actualChannels));
|
||||
new Page<>(query.page(), query.size()), query, channelGroups));
|
||||
}
|
||||
public List<ChannelContainerCountRow> channelContainerCounts(long warehouseId) {
|
||||
return candidates.channelContainerCounts(warehouseId);
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.client.WarehouseMasterDataClient;
|
||||
import com.baiyue.firstleg.domain.SmartLoadingStatus;
|
||||
import com.baiyue.firstleg.domain.ContainerSpecificationCatalog;
|
||||
import com.baiyue.firstleg.domain.SmartLoadingChannelGroupCatalog;
|
||||
import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest;
|
||||
import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest;
|
||||
import com.baiyue.firstleg.dto.ContainerDtos.SimulationCreateRequest;
|
||||
@@ -55,13 +56,16 @@ public class ContainerService {
|
||||
WarehouseMasterDataClient.Warehouse warehouse = requireDomesticTransitWarehouse(request.warehouseId());
|
||||
Long versionId = priceBooks.activeVersionId();
|
||||
if (versionId == null) throw conflict("当前没有 ACTIVE 价格版本");
|
||||
ChannelRow channel = priceBooks.channels(versionId, warehouse.id()).stream()
|
||||
.filter(row -> request.channelCode().trim().equals(row.getChannelCode())).findFirst()
|
||||
.orElseThrow(() -> conflict("所选物流渠道已失效,请重新选择"));
|
||||
SmartLoadingChannelGroupCatalog.Definition channelGroup;
|
||||
try { channelGroup = SmartLoadingChannelGroupCatalog.require(request.channelGroupCode()); }
|
||||
catch (IllegalArgumentException exception) { throw conflict(exception.getMessage()); }
|
||||
boolean activeGroup = priceBooks.channels(versionId, warehouse.id()).stream()
|
||||
.map(ChannelRow::getChannelCode).anyMatch(channelGroup.channelCodes()::contains);
|
||||
if (!activeGroup) throw conflict("所选物流渠道分组在当前仓库已失效,请重新选择");
|
||||
ContainerBookingEntity value = new ContainerBookingEntity(); value.setId(IdWorker.getId());
|
||||
value.setBookingNo(documentNumbers.next("CB")); value.setCabinetNo(documentNumbers.next("CAB"));
|
||||
value.setSourceType("SIMULATION"); value.setRecordStatus("ACTIVE");
|
||||
value.setChannelCode(channel.getChannelCode()); value.setChannelName(channel.getChannelName());
|
||||
value.setChannelGroupCode(channelGroup.code()); value.setChannelCode(null); value.setChannelName(null);
|
||||
value.setWarehouseId(warehouse.id()); value.setWarehouseName(warehouse.warehouseName());
|
||||
value.setContainerType(specification.containerType()); value.setCapacityCbm(specification.capacityCbm());
|
||||
value.setMaxWeightKg(specification.maxWeightKg()); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
|
||||
@@ -85,9 +89,9 @@ public class ContainerService {
|
||||
String containerNo = requestedContainerNo == null ? null : Iso6346ContainerNumber.require(requestedContainerNo);
|
||||
if (containerNo != null && repository.containerNoExists(containerNo, id)) throw conflict("集装箱号已存在");
|
||||
if (!java.util.Objects.equals(container.getWarehouseId(), booking.getWarehouseId())
|
||||
|| !java.util.Objects.equals(container.getChannelCode(), booking.getChannelCode())
|
||||
|| !SmartLoadingChannelGroupCatalog.contains(container.getChannelGroupCode(), booking.getChannelCode())
|
||||
|| !java.util.Objects.equals(container.getContainerType(), requirement.getContainerType())) {
|
||||
throw conflict("模拟柜与真实订舱柜仓库、渠道或柜型不一致");
|
||||
throw conflict("模拟柜与真实订舱柜仓库、渠道分组或柜型不一致");
|
||||
}
|
||||
try {
|
||||
if (repository.linkReleasedBooking(id, request.oceanBookingId(), request.requirementId(), containerNo,
|
||||
|
||||
+100
-71
@@ -9,7 +9,6 @@ import com.baiyue.firstleg.entity.InboundOrderEntity;
|
||||
import com.baiyue.firstleg.mapper.DropshipPalletizationMapper;
|
||||
import com.baiyue.firstleg.mapper.DropshipPalletizationMapper.*;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
@@ -24,38 +23,24 @@ public class DropshipPalletizationService {
|
||||
@Resource private FirstlegDocumentNumberGenerator documentNumbers;
|
||||
|
||||
@Transactional
|
||||
public Task createAndBind(long taskId,CreateAndBindRequest request,long operatorId,String operatorName){
|
||||
if(mapper.lockTask(taskId,request.warehouseId())==null) throw new BizException(ResultCode.NOT_FOUND,"未找到当前仓库的一件代发清点组托任务");
|
||||
TaskRow task=requireTask(taskId,request.warehouseId());
|
||||
if(!Objects.equals(task.getAssigneeId(),operatorId)) throw conflict("任务不属于当前操作员");
|
||||
String operation=operation(request.operationId());
|
||||
PalletRow replay=mapper.palletByOpenOperation(operation);
|
||||
if(replay!=null){
|
||||
if(!Objects.equals(replay.getTaskId(),taskId)||!Objects.equals(replay.getWarehouseId(),request.warehouseId())
|
||||
||!Objects.equals(replay.getTenantId(),task.getTenantId())) throw conflict("创建托盘幂等号已用于不同任务");
|
||||
return view(task);
|
||||
}
|
||||
if(!"CLAIMED".equals(task.getTaskStatus())||!"WAIT_PALLETIZE".equals(task.getProcessStatus())) throw conflict("当前任务不允许创建托盘");
|
||||
int expected=mapper.unboundCount(taskId);
|
||||
if(expected==0) throw conflict("当前没有待绑托箱,请先清点箱码");
|
||||
long palletId=IdWorker.getId();
|
||||
String palletNo=documentNumbers.next("DP");
|
||||
mapper.insertPallet(palletId,taskId,task.getTenantId(),task.getInboundId(),task.getWarehouseId(),palletNo,operation,operatorId,operatorName);
|
||||
int bound=mapper.bindPending(taskId,palletId,operation,operatorId,operatorName);
|
||||
if(bound!=expected||mapper.adjustBound(taskId,operatorId,bound)!=1) throw conflict("待绑托箱已变化,请刷新后重试");
|
||||
return view(requireTask(taskId,request.warehouseId()));
|
||||
}
|
||||
|
||||
public void createTask(InboundOrderEntity inbound) {
|
||||
public void createTask(InboundOrderEntity inbound,long operatorId,String operatorName) {
|
||||
if (!"ONE_CLICK_DROPSHIP".equals(inbound.getFulfillmentType())) return;
|
||||
if (inbound.getTotalPackageCount()==null || inbound.getTotalPackageCount()<1) throw invalid("一件代发测量完成箱数无效,不能下发清点组托任务");
|
||||
mapper.insertTask(IdWorker.getId(),inbound.getTenantId(),inbound.getId(),inbound.getWarehouseId(),inbound.getTotalPackageCount());
|
||||
if (inbound.getPalletCount()==null || inbound.getPalletCount()<1) throw invalid("一件代发预计托数无效,不能生成托盘码");
|
||||
if (inbound.getPalletCount()>inbound.getTotalPackageCount()) throw invalid("一件代发预计托数不能超过总箱数");
|
||||
long taskId=IdWorker.getId();
|
||||
if(mapper.insertTask(taskId,inbound.getTenantId(),inbound.getId(),inbound.getWarehouseId(),inbound.getTotalPackageCount())!=1) return;
|
||||
for(int sequence=1;sequence<=inbound.getPalletCount();sequence++){
|
||||
mapper.insertPallet(IdWorker.getId(),taskId,inbound.getTenantId(),inbound.getId(),inbound.getWarehouseId(),
|
||||
documentNumbers.next("DP"),"dropship-precreate-"+taskId+"-"+sequence,operatorId,operatorName);
|
||||
}
|
||||
}
|
||||
|
||||
public List<PendingTask> pending(long operatorId,String keyword){ return mapper.pending(operatorId,normalize(keyword)); }
|
||||
public List<PendingTask> pending(long operatorId,long warehouseId,String keyword){ return mapper.pending(operatorId,warehouseId,normalize(keyword)); }
|
||||
|
||||
@Transactional
|
||||
public Task claim(long taskId,long warehouseId,long operatorId,String operatorName){
|
||||
requirePalletSetup(requireTask(taskId,warehouseId));
|
||||
if(mapper.claim(taskId,warehouseId,operatorId,operatorName)!=1) throw conflict("任务已被其他人员领取或状态已变化");
|
||||
return task(taskId,warehouseId,operatorId);
|
||||
}
|
||||
@@ -76,8 +61,11 @@ public class DropshipPalletizationService {
|
||||
if(!"CLAIMED".equals(task.getTaskStatus())) block="请先领取任务";
|
||||
else if(!Objects.equals(task.getAssigneeId(),operatorId)) block="任务不属于当前操作员";
|
||||
else if(!"WAIT_PALLETIZE".equals(task.getProcessStatus())) block="入仓单已不在待清点组托状态";
|
||||
return new Carton(null,label.getBoxCode(),task.getInboundId(),label.getInboundCode(),label.getInboundDetailId(),
|
||||
label.getSkuCode(),label.getCargoName(),label.getUnitsPerCarton(),null,null,null,"UNCOUNTED",null,null,
|
||||
else if(!palletSetupReady(task)) block=palletSetupMessage(task);
|
||||
List<CartonSkuLine> skuLines=labelSkuLines(taskId,label).stream().map(line ->
|
||||
new CartonSkuLine(line.getInboundDetailId(),line.getSkuCode(),line.getCargoName(),line.getExpectedQuantity(),
|
||||
null,null,null)).toList();
|
||||
return new Carton(null,label.getBoxCode(),task.getInboundId(),label.getInboundCode(),skuLines,"UNCOUNTED",null,null,
|
||||
block==null,block);
|
||||
}
|
||||
|
||||
@@ -88,23 +76,22 @@ public class DropshipPalletizationService {
|
||||
CartonRow replay=mapper.cartonByCountOperation(operation);
|
||||
if(replay!=null){
|
||||
if(!Objects.equals(replay.getTaskId(),taskId) || !Objects.equals(replay.getBoxCode(),request.boxCode().trim())
|
||||
|| !Objects.equals(replay.getActualSkuQuantity(),request.actualSkuQuantity())) throw conflict("清点幂等号已用于不同操作");
|
||||
|| !sameCountLines(mapper.cartonSkus(replay.getId()),request.skuLines())) throw conflict("清点幂等号已用于不同操作");
|
||||
return countResult(task,replay);
|
||||
}
|
||||
LabelRow label=requireLabel(taskId,request.boxCode());
|
||||
if(mapper.countedByLabel(taskId,label.getCartonLabelId())!=null) throw conflict("箱码已清点,不能重复清点");
|
||||
int expected=requireExpectedQuantity(label);
|
||||
boolean mismatch=expected!=request.actualSkuQuantity();
|
||||
String remark=normalize(request.discrepancyRemark());
|
||||
if(!mismatch && request.discrepancyAction()!=DiscrepancyAction.MATCH) throw invalid("实收数量一致时差异动作必须为 MATCH");
|
||||
if(mismatch && request.discrepancyAction()!=DiscrepancyAction.ACCEPT_VARIANCE) throw conflict("实收数量存在差异,请确认差异处理结果");
|
||||
if(mismatch && !StringUtils.hasText(remark)) throw invalid("接受数量差异时必须填写处理说明");
|
||||
PalletRow pallet=StringUtils.hasText(request.palletNo())
|
||||
? getOrCreatePallet(task,request.palletNo().trim(),operation,operatorId,operatorName):null;
|
||||
List<CountedLine> lines=validateCountLines(labelSkuLines(taskId,label),request.skuLines());
|
||||
boolean mismatch=lines.stream().anyMatch(CountedLine::mismatch);
|
||||
PalletRow pallet=StringUtils.hasText(request.palletNo())?requireAssignablePallet(task,request.palletNo().trim()):null;
|
||||
long cartonId=IdWorker.getId();
|
||||
CountedLine primary=lines.get(0);
|
||||
mapper.insertCarton(cartonId,taskId,pallet==null?null:pallet.getId(),task.getTenantId(),task.getInboundId(),
|
||||
label.getInboundDetailId(),label.getCartonLabelId(),label.getSkuCode(),label.getCargoName(),label.getBoxCode(),
|
||||
expected,request.actualSkuQuantity(),mismatch?"ACCEPTED_VARIANCE":"MATCH",remark,operation,operatorId,operatorName);
|
||||
primary.detailId(),label.getCartonLabelId(),primary.skuCode(),primary.cargoName(),label.getBoxCode(),
|
||||
primary.expected(),primary.actual(),mismatch?"ACCEPTED_VARIANCE":"MATCH",null,operation,operatorId,operatorName);
|
||||
int lineNo=0;
|
||||
for(CountedLine line:lines) mapper.insertCartonSku(IdWorker.getId(),cartonId,line.detailId(),line.skuCode(),line.cargoName(),
|
||||
line.expected(),line.actual(),line.mismatch()?"ACCEPTED_VARIANCE":"MATCH",line.remark(),++lineNo);
|
||||
if(mapper.addCounted(taskId,operatorId,pallet==null?0:1,mismatch?1:0)!=1) throw conflict("任务清点数量已变化,请刷新后重试");
|
||||
TaskRow refreshed=requireTask(taskId,request.warehouseId());
|
||||
return countResult(refreshed,mapper.carton(taskId,cartonId));
|
||||
@@ -115,7 +102,7 @@ public class DropshipPalletizationService {
|
||||
TaskRow task=requireWorkingTask(taskId,request.warehouseId(),operatorId);
|
||||
String operation=operation(request.operationId());
|
||||
if(mapper.bindOperationCount(taskId,operation)>0) return view(requireTask(taskId,request.warehouseId()));
|
||||
PalletRow pallet=getOrCreatePallet(task,request.palletNo().trim(),operation,operatorId,operatorName);
|
||||
PalletRow pallet=requireAssignablePallet(task,request.palletNo().trim());
|
||||
int expected=mapper.unboundCount(taskId);
|
||||
if(expected==0) throw conflict("当前没有待绑托箱");
|
||||
int bound=mapper.bindPending(taskId,pallet.getId(),operation,operatorId,operatorName);
|
||||
@@ -130,7 +117,7 @@ public class DropshipPalletizationService {
|
||||
if(carton==null) throw new BizException(ResultCode.NOT_FOUND,"清点箱不存在");
|
||||
if(carton.getPalletId()!=null) requireOpenPallet(taskId,carton.getPalletId(),request.warehouseId());
|
||||
PalletRow target=StringUtils.hasText(request.targetPalletNo())
|
||||
? getOrCreatePallet(task,request.targetPalletNo().trim(),operation(request.operationId()),operatorId,operatorName):null;
|
||||
? requireAssignablePallet(task,request.targetPalletNo().trim()):null;
|
||||
if(Objects.equals(carton.getPalletId(),target==null?null:target.getId())) return view(task);
|
||||
if(mapper.moveCarton(taskId,cartonId,target==null?null:target.getId(),operation(request.operationId()),operatorId,operatorName)!=1)
|
||||
throw conflict("清点箱已被其他操作修改,请刷新后重试");
|
||||
@@ -143,16 +130,17 @@ public class DropshipPalletizationService {
|
||||
public Task seal(long taskId,long palletId,SealPalletRequest request,long operatorId,String operatorName){
|
||||
TaskRow task=requireWorkingTask(taskId,request.warehouseId(),operatorId);
|
||||
PalletRow replay=mapper.palletBySealOperation(operation(request.operationId()));
|
||||
if(replay!=null){ if(!Objects.equals(replay.getTaskId(),taskId)||!Objects.equals(replay.getId(),palletId)) throw conflict("封托幂等号已用于其他托盘"); return view(requireTask(taskId,request.warehouseId())); }
|
||||
if(replay!=null){ if(!Objects.equals(replay.getId(),palletId)||!Objects.equals(replay.getWarehouseId(),request.warehouseId())) throw conflict("封托幂等号已用于其他托盘"); return view(requireTask(taskId,request.warehouseId())); }
|
||||
requireOpenPallet(taskId,palletId,request.warehouseId());
|
||||
if(mapper.unboundCount(taskId)>0) throw conflict("仍有已清点待绑托箱,不能封托");
|
||||
if(mapper.seal(taskId,palletId,request.expectedVersion(),operation(request.operationId()),operatorId,operatorName)!=1)
|
||||
if(mapper.seal(taskId,palletId,request.expectedVersion(),request.warehouseId(),operation(request.operationId()),operatorId,operatorName)!=1)
|
||||
throw conflict("空托盘、托盘状态或版本已变化,请刷新后重试");
|
||||
TaskRow refreshed=requireTask(taskId,request.warehouseId());
|
||||
if(Objects.equals(refreshed.getCountedCartons(),refreshed.getTotalCartons()) &&
|
||||
Objects.equals(refreshed.getAllocatedCartons(),refreshed.getTotalCartons()) && mapper.openPalletCount(taskId)==0){
|
||||
if(mapper.completeTask(taskId)!=1 || mapper.completeInbound(taskId,refreshed.getTenantId(),refreshed.getInboundId(),operatorId)!=1)
|
||||
throw conflict("组托完成状态已变化,请刷新后重试");
|
||||
for(Long participatingTaskId:mapper.taskIdsByPallet(palletId)){
|
||||
TaskRow participating=requireTask(participatingTaskId,request.warehouseId());
|
||||
if(Objects.equals(participating.getCountedCartons(),participating.getTotalCartons()) &&
|
||||
Objects.equals(participating.getAllocatedCartons(),participating.getTotalCartons()) && mapper.openPalletCount(participatingTaskId)==0){
|
||||
if(mapper.completeTask(participatingTaskId)!=1 || mapper.completeInbound(participatingTaskId,participating.getTenantId(),participating.getInboundId(),operatorId)!=1)
|
||||
throw conflict("组托完成状态已变化,请刷新后重试");
|
||||
}
|
||||
}
|
||||
return view(requireTask(taskId,request.warehouseId()));
|
||||
}
|
||||
@@ -162,18 +150,23 @@ public class DropshipPalletizationService {
|
||||
if(code==null) throw new BizException(ResultCode.NOT_FOUND,"入库单不存在");
|
||||
return mapper.palletsByInbound(tenantId,inboundId).stream().map(p->palletView(p,code)).toList();
|
||||
}
|
||||
public String inboundCode(long tenantId,long inboundId){
|
||||
String code=mapper.inboundCode(tenantId,inboundId);
|
||||
if(code==null) throw new BizException(ResultCode.NOT_FOUND,"入库单不存在");
|
||||
return code;
|
||||
}
|
||||
|
||||
private PalletRow getOrCreatePallet(TaskRow task,String palletNo,String operation,long operatorId,String operatorName){
|
||||
public List<Pallet> generatedPallets(long taskId,long warehouseId,long operatorId){
|
||||
TaskRow task=requireTask(taskId,warehouseId);
|
||||
if(task.getAssigneeId()!=null && task.getAssigneeId()!=operatorId) throw conflict("任务已由其他人员领取");
|
||||
return mapper.generatedPallets(taskId).stream().map(p->palletView(p,task.getInboundCode())).toList();
|
||||
}
|
||||
|
||||
private PalletRow requireAssignablePallet(TaskRow task,String palletNo){
|
||||
PalletRow existing=mapper.palletByNo(palletNo);
|
||||
if(existing!=null){
|
||||
if(!Objects.equals(existing.getTaskId(),task.getId()) || !Objects.equals(existing.getTenantId(),task.getTenantId())) throw conflict("托盘码已被其他客户或入仓任务占用");
|
||||
if(!"OPEN".equals(existing.getPalletStatus())) throw conflict("托盘已封托,不能继续绑定箱");
|
||||
return existing;
|
||||
}
|
||||
try{ mapper.insertPallet(IdWorker.getId(),task.getId(),task.getTenantId(),task.getInboundId(),task.getWarehouseId(),palletNo,operation,operatorId,operatorName); }
|
||||
catch(DuplicateKeyException e){ existing=mapper.palletByNo(palletNo); if(existing==null) throw conflict("托盘码已被并发占用"); }
|
||||
existing=mapper.palletByNo(palletNo);
|
||||
if(existing==null || !Objects.equals(existing.getTaskId(),task.getId())) throw conflict("托盘码已被其他任务占用");
|
||||
if(existing==null) throw new BizException(ResultCode.NOT_FOUND,"托盘码不存在,请扫描测量审核后生成的 DP 托盘码");
|
||||
if(!Objects.equals(existing.getWarehouseId(),task.getWarehouseId())) throw conflict("托盘码不属于当前作业仓库");
|
||||
if(!"OPEN".equals(existing.getPalletStatus())) throw conflict("托盘已封托,不能继续绑定箱");
|
||||
return existing;
|
||||
}
|
||||
|
||||
@@ -184,37 +177,73 @@ public class DropshipPalletizationService {
|
||||
}
|
||||
|
||||
private Task view(TaskRow row){
|
||||
List<Pallet> pallets=mapper.pallets(row.getId()).stream().map(p->palletView(p,row.getInboundCode())).toList();
|
||||
int expected=row.getExpectedPalletCount()==null?0:row.getExpectedPalletCount();
|
||||
int generated=mapper.palletCount(row.getId());
|
||||
boolean palletSetupReady=expected>0 && generated==expected;
|
||||
return new Task(row.getId(),row.getInboundId(),row.getInboundCode(),row.getTenantId(),row.getCustomerName(),row.getWarehouseId(),row.getWarehouseName(),
|
||||
row.getTotalCartons(),row.getCountedCartons(),row.getAllocatedCartons(),row.getCountedCartons()-row.getAllocatedCartons(),row.getDiscrepancyCartons(),
|
||||
row.getTaskStatus(),row.getAssigneeId(),row.getAssigneeName(),row.getVersion(),mapper.pallets(row.getId()).stream().map(p->palletView(p,row.getInboundCode())).toList(),
|
||||
expected,generated,palletSetupReady,palletSetupReady?null:palletSetupMessage(expected,generated),
|
||||
row.getTaskStatus(),row.getAssigneeId(),row.getAssigneeName(),row.getVersion(),pallets,
|
||||
mapper.unboundCartons(row.getId()).stream().map(c->cartonView(c,row.getInboundCode(),false,null)).toList());
|
||||
}
|
||||
|
||||
private Pallet palletView(PalletRow row,String inboundCode){
|
||||
List<CartonRow> cartons=mapper.cartons(row.getId()); Map<String,List<CartonRow>> groups=new LinkedHashMap<>();
|
||||
for(CartonRow c:cartons) groups.computeIfAbsent(c.getInboundDetailId()+"|"+c.getSkuCode(),ignored->new ArrayList<>()).add(c);
|
||||
List<SkuItem> items=groups.values().stream().map(values->{CartonRow first=values.get(0);return new SkuItem(first.getInboundId(),inboundCode,
|
||||
first.getInboundDetailId(),first.getSkuCode(),first.getCargoName(),values.size(),values.stream().mapToInt(v->v.getActualSkuQuantity()==null?0:v.getActualSkuQuantity()).sum(),
|
||||
values.stream().map(CartonRow::getBoxCode).toList());}).toList();
|
||||
List<CartonRow> cartons=mapper.cartons(row.getId()); Map<String,List<CartonSkuWithBox>> groups=new LinkedHashMap<>();
|
||||
for(CartonRow c:cartons) for(CartonSkuRow line:mapper.cartonSkus(c.getId()))
|
||||
groups.computeIfAbsent(c.getInboundId()+"|"+line.getInboundDetailId()+"|"+line.getSkuCode(),ignored->new ArrayList<>())
|
||||
.add(new CartonSkuWithBox(c,line));
|
||||
List<SkuItem> items=groups.values().stream().map(values->{CartonSkuWithBox first=values.get(0);return new SkuItem(first.carton().getInboundId(),first.carton().getInboundCode(),
|
||||
first.line().getInboundDetailId(),first.line().getSkuCode(),first.line().getCargoName(),values.size(),
|
||||
values.stream().mapToInt(v->v.line().getActualQuantity()==null?0:v.line().getActualQuantity()).sum(),
|
||||
values.stream().map(v->v.carton().getBoxCode()).toList());}).toList();
|
||||
int discrepancies=(int)cartons.stream().filter(c->"ACCEPTED_VARIANCE".equals(c.getDiscrepancyStatus())).count();
|
||||
PalletMetrics metrics=mapper.metrics(row.getId());
|
||||
return new Pallet(row.getId(),row.getPalletNo(),row.getPalletStatus(),cartons.size(),items.size(),discrepancies,
|
||||
metrics==null?BigDecimal.ZERO:metrics.weightKg(),metrics==null?BigDecimal.ZERO:metrics.volumeCbm(),row.getVersion(),items,
|
||||
cartons.stream().map(c->cartonView(c,inboundCode,false,null)).toList(),row.getCompletedAt());
|
||||
cartons.stream().map(c->cartonView(c,c.getInboundCode(),false,null)).toList(),row.getCompletedAt());
|
||||
}
|
||||
|
||||
private Carton cartonView(CartonRow row,String inboundCode,boolean canCount,String blockReason){
|
||||
return new Carton(row.getId(),row.getBoxCode(),row.getInboundId(),inboundCode,row.getInboundDetailId(),row.getSkuCode(),row.getCargoName(),
|
||||
row.getExpectedSkuQuantity(),row.getActualSkuQuantity(),row.getDiscrepancyStatus(),row.getDiscrepancyRemark(),
|
||||
List<CartonSkuLine> lines=mapper.cartonSkus(row.getId()).stream().map(line -> new CartonSkuLine(line.getInboundDetailId(),
|
||||
line.getSkuCode(),line.getCargoName(),line.getExpectedQuantity(),line.getActualQuantity(),line.getDiscrepancyStatus(),line.getDiscrepancyRemark())).toList();
|
||||
return new Carton(row.getId(),row.getBoxCode(),row.getInboundId(),inboundCode,lines,
|
||||
row.getPalletId()==null?"COUNTED_UNBOUND":"BOUND",row.getPalletId(),row.getPalletNo(),canCount,blockReason);
|
||||
}
|
||||
private LabelRow requireLabel(long taskId,String boxCode){ LabelRow label=mapper.label(taskId,boxCode.trim()); if(label==null) throw new BizException(ResultCode.NOT_FOUND,"箱码不属于当前一件代发入仓单"); if(!StringUtils.hasText(label.getSkuCode())) throw conflict("箱码缺少SKU快照,不能清点"); requireExpectedQuantity(label); return label; }
|
||||
private int requireExpectedQuantity(LabelRow label){ if(label.getUnitsPerCarton()==null||label.getUnitsPerCarton()<1) throw conflict("箱码缺少每箱SKU数量快照,不能清点"); return label.getUnitsPerCarton(); }
|
||||
private TaskRow requireWorkingTask(long taskId,long warehouseId,long operatorId){ TaskRow task=requireTask(taskId,warehouseId); if(!"CLAIMED".equals(task.getTaskStatus())) throw conflict("请先领取待清点组托任务"); if(!Objects.equals(task.getAssigneeId(),operatorId)) throw conflict("任务不属于当前操作员"); if(!"WAIT_PALLETIZE".equals(task.getProcessStatus())) throw conflict("入仓单已不在待清点组托状态"); return task; }
|
||||
private LabelRow requireLabel(long taskId,String boxCode){ LabelRow label=mapper.label(taskId,boxCode.trim()); if(label==null) throw new BizException(ResultCode.NOT_FOUND,"箱码不属于当前一件代发入仓单"); if(labelSkuLines(taskId,label).isEmpty()) throw conflict("箱码缺少SKU快照,不能清点"); return label; }
|
||||
private List<LabelSkuRow> labelSkuLines(long taskId,LabelRow label){ return mapper.labelSkus(taskId,label.getCartonLabelId()); }
|
||||
private List<CountedLine> validateCountLines(List<LabelSkuRow> expected,List<CountSkuLine> actual){
|
||||
Map<Long,CountSkuLine> submitted=new LinkedHashMap<>();
|
||||
for(CountSkuLine line:actual) if(submitted.put(line.inboundDetailId(),line)!=null) throw invalid("同一SKU明细不能重复清点");
|
||||
if(submitted.size()!=expected.size()) throw invalid("必须清点当前箱内全部SKU");
|
||||
List<CountedLine> result=new ArrayList<>();
|
||||
for(LabelSkuRow line:expected){
|
||||
CountSkuLine input=submitted.get(line.getInboundDetailId());
|
||||
if(input==null || !Objects.equals(line.getSkuCode(),input.skuCode().trim())) throw invalid("清点SKU与箱唛不一致");
|
||||
boolean mismatch=!Objects.equals(line.getExpectedQuantity(),input.actualQuantity()); String remark=normalize(input.discrepancyRemark());
|
||||
if(!mismatch && input.discrepancyAction()!=DiscrepancyAction.MATCH) throw invalid("实收数量一致时差异动作必须为 MATCH");
|
||||
if(mismatch && input.discrepancyAction()!=DiscrepancyAction.ACCEPT_VARIANCE) throw conflict("实收数量存在差异,请确认差异处理结果");
|
||||
if(mismatch && !StringUtils.hasText(remark)) throw invalid("接受数量差异时必须填写处理说明");
|
||||
result.add(new CountedLine(line.getInboundDetailId(),line.getSkuCode(),line.getCargoName(),line.getExpectedQuantity(),input.actualQuantity(),remark,mismatch));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
private boolean sameCountLines(List<CartonSkuRow> saved,List<CountSkuLine> submitted){
|
||||
if(saved.size()!=submitted.size()) return false;
|
||||
Map<Long,Integer> quantities=new HashMap<>(); for(CountSkuLine line:submitted) quantities.put(line.inboundDetailId(),line.actualQuantity());
|
||||
return saved.stream().allMatch(line->Objects.equals(line.getActualQuantity(),quantities.get(line.getInboundDetailId())));
|
||||
}
|
||||
private TaskRow requireWorkingTask(long taskId,long warehouseId,long operatorId){ TaskRow task=requireTask(taskId,warehouseId); if(!"CLAIMED".equals(task.getTaskStatus())) throw conflict("请先领取待清点组托任务"); if(!Objects.equals(task.getAssigneeId(),operatorId)) throw conflict("任务不属于当前操作员"); if(!"WAIT_PALLETIZE".equals(task.getProcessStatus())) throw conflict("入仓单已不在待清点组托状态"); requirePalletSetup(task); return task; }
|
||||
private void requirePalletSetup(TaskRow task){ if(!palletSetupReady(task)) throw conflict(palletSetupMessage(task)); }
|
||||
private boolean palletSetupReady(TaskRow task){ return task.getExpectedPalletCount()!=null && task.getExpectedPalletCount()>0 && mapper.palletCount(task.getId())==task.getExpectedPalletCount(); }
|
||||
private String palletSetupMessage(TaskRow task){ int expected=task.getExpectedPalletCount()==null?0:task.getExpectedPalletCount(); return palletSetupMessage(expected,mapper.palletCount(task.getId())); }
|
||||
private static String palletSetupMessage(int expected,int generated){ return "预计 "+expected+" 托,当前仅生成 "+generated+" 个托盘码,请联系后台处理后刷新"; }
|
||||
private TaskRow requireTask(long taskId,long warehouseId){ TaskRow task=mapper.task(taskId,warehouseId); if(task==null) throw new BizException(ResultCode.NOT_FOUND,"未找到当前仓库的一件代发清点组托任务"); return task; }
|
||||
private PalletRow requireOpenPallet(long taskId,long palletId,long warehouseId){ PalletRow p=mapper.pallet(taskId,palletId,warehouseId); if(p==null) throw new BizException(ResultCode.NOT_FOUND,"托盘不存在"); if(!"OPEN".equals(p.getPalletStatus())) throw conflict("托盘已封托,不能修改"); return p; }
|
||||
private static String operation(String v){String n=normalize(v);if(!StringUtils.hasText(n))throw invalid("操作幂等号不能为空");return n;}
|
||||
private static String normalize(String v){return StringUtils.hasText(v)?v.trim():null;}
|
||||
private static BizException conflict(String m){return new BizException(ResultCode.CONFLICT,m);}
|
||||
private static BizException invalid(String m){return new BizException(ResultCode.VALIDATION_FAILED,m);}
|
||||
private record CountedLine(long detailId,String skuCode,String cargoName,int expected,int actual,String remark,boolean mismatch){}
|
||||
private record CartonSkuWithBox(CartonRow carton,CartonSkuRow line){}
|
||||
}
|
||||
|
||||
@@ -98,8 +98,8 @@ public class FbaPricingService {
|
||||
if (published == null || !"SENT".equals(published.getFeeStatus())) {
|
||||
throw conflict("费用状态已变化,请刷新后重试");
|
||||
}
|
||||
if (feeRevisionLocked(inbound, published)) {
|
||||
throw conflict("客户已确认且该票已装柜完成,不允许再修改或重新发送费用");
|
||||
if (feeRevisionLocked(published)) {
|
||||
throw conflict("客户已确认,不允许再修改或重新发送费用");
|
||||
}
|
||||
}
|
||||
var measurement = measurementRepository.findLatestMeasurement(tenantId, inboundId);
|
||||
@@ -490,8 +490,8 @@ public class FbaPricingService {
|
||||
if (current == null || !List.of("CALCULATED", "CONFIRMED", "SENT").contains(current.getFeeStatus())) {
|
||||
throw conflict("入库单没有可修改的初算或已发布费用");
|
||||
}
|
||||
if (feeRevisionLocked(inbound, current)) {
|
||||
throw conflict("客户已确认且该票已装柜完成,不允许再修改费用");
|
||||
if (feeRevisionLocked(current)) {
|
||||
throw conflict("客户已确认,不允许再修改费用");
|
||||
}
|
||||
if (!request.expectedFeeVersion().equals(current.getFeeVersion())) {
|
||||
throw conflict("费用版本已变化,请刷新后重试");
|
||||
@@ -613,9 +613,8 @@ public class FbaPricingService {
|
||||
.contains(processStatus);
|
||||
}
|
||||
|
||||
private static boolean feeRevisionLocked(InboundPricingEntity inbound, InboundFeeEntity fee) {
|
||||
return fee != null && fee.getCustomerConfirmedAt() != null
|
||||
&& List.of("LOADED", "DELIVERED").contains(inbound.getProcessStatus());
|
||||
private static boolean feeRevisionLocked(InboundFeeEntity fee) {
|
||||
return fee != null && fee.getCustomerConfirmedAt() != null;
|
||||
}
|
||||
|
||||
private static PricingDestinationType destinationType(InboundPricingEntity inbound) {
|
||||
|
||||
+40
-15
@@ -25,6 +25,8 @@ import com.baiyue.firstleg.dto.DropshipPalletizationDtos.Pallet;
|
||||
@Service
|
||||
public class InboundCartonLabelPrintService {
|
||||
private static final BigDecimal PAGE_MM = new BigDecimal("100");
|
||||
private static final BigDecimal A4_WIDTH_MM = new BigDecimal("210");
|
||||
private static final BigDecimal A4_HEIGHT_MM = new BigDecimal("297");
|
||||
private static final List<String> SAFE_RESPONSE_HEADERS = List.of(
|
||||
HttpHeaders.CONTENT_DISPOSITION, HttpHeaders.CACHE_CONTROL, HttpHeaders.PRAGMA,
|
||||
"X-Print-Page-Count", "X-Request-Id", "X-Trace-Id");
|
||||
@@ -113,28 +115,51 @@ public class InboundCartonLabelPrintService {
|
||||
return ResponseEntity.status(response.getStatusCode()).headers(headers).body(response.getBody());
|
||||
}
|
||||
|
||||
/** 一件代发托盘标签:托盘号、入仓码、SKU箱数和托盘二维码。 */
|
||||
public ResponseEntity<byte[]> generateDropshipPallet(Pallet pallet, String requestId) {
|
||||
if (pallet == null || !StringUtils.hasText(pallet.palletNo()) || pallet.items().isEmpty()) {
|
||||
throw new BizException(ResultCode.CONFLICT, "只有已完成且包含货物的一件代发托盘可以打印");
|
||||
/** 一件代发 A4 托盘信息单:托盘号、Code 128 一维码和 SKU 归托明细。 */
|
||||
public ResponseEntity<byte[]> generateDropshipPallet(Pallet pallet, String inboundCode, String requestId) {
|
||||
return generateDropshipPallets(List.of(pallet), inboundCode, requestId);
|
||||
}
|
||||
|
||||
/** 一票预生成托盘码合并为一个多页 PDF,避免 PDA 展开或逐个下载大量空托盘。 */
|
||||
public ResponseEntity<byte[]> generateDropshipPallets(List<Pallet> pallets, String inboundCode, String requestId) {
|
||||
if (pallets == null || pallets.isEmpty()) {
|
||||
throw new BizException(ResultCode.CONFLICT, "托盘码尚未生成,不能打印");
|
||||
}
|
||||
String inboundCodes = pallet.items().stream().map(item -> item.inboundCode())
|
||||
.filter(StringUtils::hasText).distinct().collect(java.util.stream.Collectors.joining("/"));
|
||||
String skuSummary = pallet.items().stream()
|
||||
.map(item -> item.skuCode() + "×" + item.cartonCount())
|
||||
.collect(java.util.stream.Collectors.joining(" / "));
|
||||
PrintPdfClient.Label label = new PrintPdfClient.Label(pallet.palletNo(), inboundCodes,
|
||||
"CTNS:" + pallet.cartonCount(), skuSummary, pallet.palletNo(), null);
|
||||
PrintPdfClient.InboundMarkPdfRequest request = new PrintPdfClient.InboundMarkPdfRequest(
|
||||
"INBOUND_MARK_V1", new PrintPdfClient.PageSpec(PAGE_MM, PAGE_MM),
|
||||
pallet.palletNo() + "-托盘唛.pdf", List.of(label));
|
||||
List<PrintPdfClient.DropshipPalletDocument> documents = pallets.stream()
|
||||
.map(pallet -> dropshipPalletDocument(pallet, inboundCode)).toList();
|
||||
String fileName = pallets.size() == 1
|
||||
? pallets.get(0).palletNo() + "-A4托盘信息单.pdf"
|
||||
: inboundCode + "-" + pallets.size() + "托-A4托盘信息单.pdf";
|
||||
PrintPdfClient.DropshipPalletA4PdfRequest request = new PrintPdfClient.DropshipPalletA4PdfRequest(
|
||||
"DROPSHIP_PALLET_A4_V1", new PrintPdfClient.PageSpec(A4_WIDTH_MM, A4_HEIGHT_MM),
|
||||
fileName, documents);
|
||||
try {
|
||||
return requirePdf(printClient.generateInboundMarks(internalToken, requestId, request));
|
||||
return requirePdf(printClient.generateDropshipPalletA4Documents(internalToken, requestId, request));
|
||||
} catch (FeignException | NoFallbackAvailableException exception) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "打印服务暂不可用,请稍后重试");
|
||||
}
|
||||
}
|
||||
|
||||
private PrintPdfClient.DropshipPalletDocument dropshipPalletDocument(Pallet pallet, String inboundCode) {
|
||||
if (pallet == null || !StringUtils.hasText(pallet.palletNo())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "托盘码尚未生成,不能打印");
|
||||
}
|
||||
String inboundCodes = pallet.items().stream().map(item -> item.inboundCode())
|
||||
.filter(StringUtils::hasText).distinct().collect(java.util.stream.Collectors.joining("/"));
|
||||
if (!StringUtils.hasText(inboundCodes)) inboundCodes = inboundCode;
|
||||
List<PrintPdfClient.DropshipPalletSkuRow> skuRows = pallet.items().stream()
|
||||
.map(item -> new PrintPdfClient.DropshipPalletSkuRow(item.inboundCode(), item.skuCode(),
|
||||
item.cargoName(), number(item.cartonCount()), number(item.skuQuantity())))
|
||||
.toList();
|
||||
return new PrintPdfClient.DropshipPalletDocument(
|
||||
pallet.palletNo(), inboundCodes, number(pallet.cartonCount()), number(pallet.skuCount()),
|
||||
pallet.weightKg(), pallet.volumeCbm(), pallet.palletNo(), skuRows);
|
||||
}
|
||||
|
||||
private int number(Integer value) {
|
||||
return value == null ? 0 : value;
|
||||
}
|
||||
|
||||
private ResponseEntity<byte[]> generatePdf(String fileName, List<InboundCartonLabels.Label> labels,
|
||||
String requestId) {
|
||||
PrintPdfClient.InboundMarkPdfRequest request = new PrintPdfClient.InboundMarkPdfRequest(
|
||||
|
||||
@@ -10,6 +10,7 @@ import com.baiyue.firstleg.dto.InboundCartonLabels;
|
||||
import com.baiyue.firstleg.entity.InboundCartonLabelEntity;
|
||||
import com.baiyue.firstleg.entity.InboundOrderEntity;
|
||||
import com.baiyue.firstleg.repository.InboundCartonLabelRepository;
|
||||
import com.baiyue.firstleg.mapper.InboundCartonLabelSkuMapper;
|
||||
import feign.FeignException;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.cloud.client.circuitbreaker.NoFallbackAvailableException;
|
||||
@@ -24,6 +25,8 @@ import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
@@ -36,6 +39,7 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
|
||||
private static final Set<String> ALLOCATABLE_STATUSES = Set.of("PREPARING", "WAIT_MEASURE", "MEASURING");
|
||||
|
||||
@Resource private InboundCartonLabelRepository repository;
|
||||
@Resource private InboundCartonLabelSkuMapper labelSkus;
|
||||
@Resource private OrderCargoClient orderCargoClient;
|
||||
@Resource private PrintableOrderInboundClient printableOrderInboundClient;
|
||||
@Resource private InboundOrderCreationService inboundOrderCreationService;
|
||||
@@ -76,7 +80,8 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
|
||||
if (existingFingerprint.equals(fingerprint)) return batch(inbound, consistent);
|
||||
return batch(inbound, consistent);
|
||||
}
|
||||
int total = cargoes.stream().mapToInt(this::requirePackageCount).sum();
|
||||
List<CargoGroup> groups = cartonGroups(cargoes, inbound.getFulfillmentType());
|
||||
int total = groups.stream().mapToInt(CargoGroup::packageCount).sum();
|
||||
if (total < 1 || total > MAX_LABELS) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "唛头箱数必须为1至2000箱");
|
||||
}
|
||||
@@ -86,12 +91,20 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
|
||||
|
||||
int cartonIndex = 0;
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
for (OrderCargoClient.CargoLabelBinding cargo : cargoes) {
|
||||
int detailCount = requirePackageCount(cargo);
|
||||
for (CargoGroup group : groups) {
|
||||
OrderCargoClient.CargoLabelBinding cargo = group.cargoes().get(0);
|
||||
int detailCount = group.packageCount();
|
||||
for (int offset = 0; offset < detailCount; offset++) {
|
||||
cartonIndex++;
|
||||
InboundCartonLabelEntity label = label(inbound, cargo, total, cartonIndex, fingerprint, now);
|
||||
repository.insert(label);
|
||||
if ("ONE_CLICK_DROPSHIP".equals(inbound.getFulfillmentType())) {
|
||||
int lineNo = 0;
|
||||
for (OrderCargoClient.CargoLabelBinding line : group.cargoes()) {
|
||||
labelSkus.insert(IdWorker.getId(), inbound.getTenantId(), inbound.getId(), label.getId(),
|
||||
line.cargoId(), line.skuNo().trim(), line.cargoName(), line.unitsPerCarton(), ++lineNo);
|
||||
}
|
||||
}
|
||||
existing.add(label);
|
||||
}
|
||||
}
|
||||
@@ -195,6 +208,27 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
|
||||
return cargo.packageCount();
|
||||
}
|
||||
|
||||
private List<CargoGroup> cartonGroups(List<OrderCargoClient.CargoLabelBinding> cargoes, String fulfillmentType) {
|
||||
if (!"ONE_CLICK_DROPSHIP".equals(fulfillmentType)) {
|
||||
return cargoes.stream().map(cargo -> new CargoGroup(null, requirePackageCount(cargo), List.of(cargo))).toList();
|
||||
}
|
||||
Map<String, List<OrderCargoClient.CargoLabelBinding>> grouped = new LinkedHashMap<>();
|
||||
int single = 0;
|
||||
for (OrderCargoClient.CargoLabelBinding cargo : cargoes) {
|
||||
requirePackageCount(cargo);
|
||||
String group = StringUtils.hasText(cargo.packingGroupNo()) ? cargo.packingGroupNo().trim() : "__single_" + (++single);
|
||||
grouped.computeIfAbsent(group, ignored -> new ArrayList<>()).add(cargo);
|
||||
}
|
||||
return grouped.entrySet().stream().map(entry -> {
|
||||
List<OrderCargoClient.CargoLabelBinding> lines = entry.getValue();
|
||||
int count = lines.get(0).packageCount();
|
||||
if (lines.stream().anyMatch(line -> line.packageCount() != count)) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "同一混箱组合的箱数必须一致:" + entry.getKey());
|
||||
}
|
||||
return new CargoGroup(entry.getKey(), count, List.copyOf(lines));
|
||||
}).toList();
|
||||
}
|
||||
|
||||
private void requirePrintableInboundCode(String inboundCode, int total) {
|
||||
if (!StringUtils.hasText(inboundCode) || !inboundCode.matches("[A-Za-z0-9._:-]+")) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "入仓码不能生成可打印二维码");
|
||||
@@ -276,6 +310,7 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
|
||||
cargo.cargoName() == null ? "" : cargo.cargoName().trim(),
|
||||
cargo.skuNo() == null ? "" : cargo.skuNo().trim(), String.valueOf(cargo.packageCount()),
|
||||
String.valueOf(cargo.unitsPerCarton()),
|
||||
cargo.packingGroupNo() == null ? "" : cargo.packingGroupNo().trim(),
|
||||
decimalOrEmpty(cargo.lengthCm()), decimalOrEmpty(cargo.widthCm()), decimalOrEmpty(cargo.heightCm())))
|
||||
.collect(java.util.stream.Collectors.joining("\n"));
|
||||
try {
|
||||
@@ -296,11 +331,16 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
|
||||
|
||||
private InboundCartonLabels.Batch batch(InboundOrderEntity inbound,
|
||||
List<InboundCartonLabelEntity> entities) {
|
||||
List<InboundCartonLabelSkuMapper.SkuRow> skuRows = labelSkus.findByInbound(inbound.getTenantId(), inbound.getId());
|
||||
Map<Long, List<InboundCartonLabelSkuMapper.SkuRow>> skuRowsByLabel = skuRows == null ? Map.of()
|
||||
: skuRows.stream().collect(java.util.stream.Collectors.groupingBy(
|
||||
InboundCartonLabelSkuMapper.SkuRow::getCartonLabelId, LinkedHashMap::new,
|
||||
java.util.stream.Collectors.toList()));
|
||||
List<InboundCartonLabels.Label> labels = entities.stream().map(entity ->
|
||||
new InboundCartonLabels.Label(entity.getId(), entity.getInboundDetailId(), entity.getCargoItemNo(),
|
||||
entity.getCargoName(), entity.getDetailPackageCount(), entity.getBoxCode(),
|
||||
entity.getTotalCartonCount(), entity.getCartonIndex(), entity.getBoxSpecification(),
|
||||
entity.getLengthCm(), entity.getWidthCm(), entity.getHeightCm(), entity.getSkuCode(), inbound.getInboundCode(),
|
||||
entity.getLengthCm(), entity.getWidthCm(), entity.getHeightCm(), displaySkuCodes(entity, skuRowsByLabel), inbound.getInboundCode(),
|
||||
fallback(inbound.getWarehouseName(), "WAREHOUSE"),
|
||||
"CTNS:" + entity.getTotalCartonCount() + "-" + entity.getCartonIndex(),
|
||||
fallback(inbound.getChannelName(), fallback(inbound.getChannelCode(), "ROUTE"))))
|
||||
@@ -314,9 +354,30 @@ public class InboundCartonLabelService implements CartonLabelBindingResolver {
|
||||
return StringUtils.hasText(value) ? value.trim() : fallback;
|
||||
}
|
||||
|
||||
private String displaySkuCodes(InboundCartonLabelEntity label,
|
||||
Map<Long, List<InboundCartonLabelSkuMapper.SkuRow>> skuRowsByLabel) {
|
||||
List<InboundCartonLabelSkuMapper.SkuRow> lines = skuRowsByLabel.get(label.getId());
|
||||
if (lines == null || lines.isEmpty()) return label.getSkuCode();
|
||||
List<String> skuCodes = lines.stream().map(InboundCartonLabelSkuMapper.SkuRow::getSkuCode)
|
||||
.filter(StringUtils::hasText).map(String::trim).distinct().toList();
|
||||
if (skuCodes.isEmpty()) return label.getSkuCode();
|
||||
String joined = String.join(" / ", skuCodes);
|
||||
if (joined.codePointCount(0, joined.length()) <= 64) return joined;
|
||||
String suffix = " / +" + Math.max(0, skuCodes.size() - 1) + " SKU";
|
||||
int firstLimit = 64 - suffix.codePointCount(0, suffix.length());
|
||||
String first = skuCodes.get(0);
|
||||
if (first.codePointCount(0, first.length()) > firstLimit) {
|
||||
first = first.substring(0, first.offsetByCodePoints(0, firstLimit));
|
||||
}
|
||||
return first + suffix;
|
||||
}
|
||||
|
||||
private void requireIds(long tenantId, long inboundId) {
|
||||
if (tenantId <= 0 || inboundId <= 0) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "客户ID和入库单ID必须大于0");
|
||||
}
|
||||
}
|
||||
|
||||
private record CargoGroup(String groupNo, int packageCount,
|
||||
List<OrderCargoClient.CargoLabelBinding> cargoes) {}
|
||||
}
|
||||
|
||||
@@ -131,6 +131,15 @@ public class InboundMeasurementService {
|
||||
throw new BizException(ResultCode.CONFLICT,
|
||||
"测量已提交待审核,不能再保存整批测量数据;如需修改请先退回后重新提交");
|
||||
}
|
||||
if ("ONE_CLICK_DROPSHIP".equals(inbound.getFulfillmentType())
|
||||
&& (request.expectedPalletCount() == null || request.expectedPalletCount() < 1)) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "一件代发必须录入预计托数");
|
||||
}
|
||||
if ("ONE_CLICK_DROPSHIP".equals(inbound.getFulfillmentType())
|
||||
&& inbound.getTotalPackageCount() != null
|
||||
&& request.expectedPalletCount() > inbound.getTotalPackageCount()) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "一件代发预计托数不能超过总箱数");
|
||||
}
|
||||
validateInboundDetails(tenantId, inbound, request.items());
|
||||
request.items().stream().map(InboundMeasurementRequests.ItemRequest::chargeRuleId)
|
||||
.filter(java.util.Objects::nonNull).distinct().forEach(ruleId -> {
|
||||
@@ -162,6 +171,11 @@ public class InboundMeasurementService {
|
||||
item.overLimitWarning(), item.outerLengthCm(), item.outerWidthCm(), item.outerHeightCm(),
|
||||
item.chargeRuleId(), item.coefficientMode(), item.remark())).toList());
|
||||
InboundMeasurementEntity saved = batchService.saveBatch(command);
|
||||
if ("ONE_CLICK_DROPSHIP".equals(inbound.getFulfillmentType())) {
|
||||
inbound.setPalletCount(request.expectedPalletCount());
|
||||
inbound.setUpdatedBy(operatorId);
|
||||
if (!repository.updateInbound(inbound)) throw concurrentModification();
|
||||
}
|
||||
return new InboundMeasurementResponses.DraftSaved(saved.getId(), saved.getVersion(), saved.getStatus());
|
||||
}
|
||||
|
||||
@@ -247,7 +261,7 @@ public class InboundMeasurementService {
|
||||
ConfirmedWeightTotals confirmedTotals = confirmedWeightTotals(weights);
|
||||
if (confirmedTotals.palletCount() == 0) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED,
|
||||
"请至少上传并确认一张称重图片后再提交测量审核");
|
||||
"即使只有一箱,也必须上传并确认至少一张称重图片后再提交测量审核");
|
||||
}
|
||||
|
||||
requireConfirmedPackageTotal(inbound, items);
|
||||
@@ -261,7 +275,13 @@ public class InboundMeasurementService {
|
||||
inbound.setMeasureCompletedAt(LocalDateTime.now());
|
||||
inbound.setActualGrossWeightKg(confirmedTotals.grossWeightKg());
|
||||
inbound.setActualNetWeightKg(confirmedTotals.netWeightKg());
|
||||
inbound.setPalletCount(confirmedTotals.palletCount());
|
||||
if ("ONE_CLICK_DROPSHIP".equals(inbound.getFulfillmentType())) {
|
||||
if (inbound.getPalletCount() == null || inbound.getPalletCount() < 1) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "请先录入预计托数并保存,再提交测量审核");
|
||||
}
|
||||
} else {
|
||||
inbound.setPalletCount(confirmedTotals.palletCount());
|
||||
}
|
||||
if (measurement.getTotalVolumeCbm() != null) {
|
||||
inbound.setActualVolumeCbm(measurement.getTotalVolumeCbm());
|
||||
}
|
||||
@@ -547,7 +567,8 @@ public class InboundMeasurementService {
|
||||
InboundMeasurementResponses.DeclarationSnapshot declaration = latestDeclaration.snapshot();
|
||||
return new InboundMeasurementResponses.Detail(
|
||||
inbound.getId(), inbound.getInboundCode(), inbound.getCompanyName(), inbound.getChannelCode(),
|
||||
inbound.getChannelName(), inbound.getProcessStatus(), inbound.getMeasureReviewStatus(),
|
||||
inbound.getChannelName(), inbound.getFulfillmentType(), inbound.getPalletCount(),
|
||||
inbound.getProcessStatus(), inbound.getMeasureReviewStatus(),
|
||||
RECOMMENDED_PALLET_TYPE,
|
||||
review == null ? null : review.getReviewReason(), review == null ? null : review.getReviewerId(),
|
||||
review == null ? null : review.getReviewerName(), measurement == null ? null : measurement.getId(),
|
||||
|
||||
+46
-12
@@ -67,12 +67,14 @@ public class LoadingCompletedOutboxService {
|
||||
palletLabelService.prepare(planId, rows.get(0).getWarehouseId());
|
||||
Map<Long, List<String>> cartonMarks = cartonLabelPools(rows);
|
||||
List<PalletRow> palletRows = palletRows(planId);
|
||||
Map<String, PalletManifest> palletManifests = palletManifests(palletRows);
|
||||
List<com.baiyue.firstleg.mapper.LoadingPalletLabelMapper.LabelRow> standardLabels = palletLabelMapper.labels(planId);
|
||||
return rows.stream().collect(java.util.stream.Collectors.groupingBy(Row::getAccountId,
|
||||
java.util.TreeMap::new, java.util.stream.Collectors.toList()))
|
||||
.entrySet().stream().map(entry -> envelope(entry.getKey(), entry.getValue(),
|
||||
palletRows.stream().filter(item -> entry.getKey().equals(item.getAccountId())).toList(),
|
||||
standardLabels.stream().filter(item -> entry.getKey().equals(item.getAccountId())).toList(), cartonMarks))
|
||||
standardLabels.stream().filter(item -> entry.getKey().equals(item.getAccountId())).toList(), cartonMarks,
|
||||
palletManifests))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@@ -98,14 +100,16 @@ public class LoadingCompletedOutboxService {
|
||||
|
||||
private EventEnvelope envelope(long accountId, List<Row> rows, List<PalletRow> palletRows,
|
||||
List<com.baiyue.firstleg.mapper.LoadingPalletLabelMapper.LabelRow> standardLabels,
|
||||
Map<Long, List<String>> cartonMarks) {
|
||||
Map<Long, List<String>> cartonMarks,
|
||||
Map<String, PalletManifest> palletManifests) {
|
||||
Row first = rows.get(0);
|
||||
Map<Long, List<Row>> byInbound = rows.stream().collect(java.util.stream.Collectors.groupingBy(
|
||||
Row::getSourceInboundId, java.util.TreeMap::new, java.util.stream.Collectors.toList()));
|
||||
List<FirstLegLoadingPlanCompletedV2.Order> orders = byInbound.values().stream()
|
||||
.map(value -> order(value, palletRows.stream()
|
||||
.filter(item -> Objects.equals(item.getSourceInboundId(), value.get(0).getSourceInboundId())).toList(),
|
||||
standardLabels.stream().filter(item -> Objects.equals(item.getInboundId(), value.get(0).getSourceInboundId())).toList(), cartonMarks)).toList();
|
||||
standardLabels.stream().filter(item -> Objects.equals(item.getInboundId(), value.get(0).getSourceInboundId())).toList(),
|
||||
cartonMarks, palletManifests)).toList();
|
||||
var payload = new FirstLegLoadingPlanCompletedV2(String.valueOf(first.getLoadingPlanId()),
|
||||
first.getPlanNo(), string(first.getContainerBookingId()),
|
||||
ContainerNumbers.requireCanonical(first.getContainerNo()), first.getCabinetNo(),
|
||||
@@ -123,7 +127,8 @@ public class LoadingCompletedOutboxService {
|
||||
|
||||
private FirstLegLoadingPlanCompletedV2.Order order(List<Row> values, List<PalletRow> palletRows,
|
||||
List<com.baiyue.firstleg.mapper.LoadingPalletLabelMapper.LabelRow> standardLabels,
|
||||
Map<Long, List<String>> cartonMarks) {
|
||||
Map<Long, List<String>> cartonMarks,
|
||||
Map<String, PalletManifest> palletManifests) {
|
||||
List<Row> sorted = values.stream().sorted(Comparator.comparing(Row::getSourceInboundDetailId)).toList();
|
||||
Row first = sorted.get(0);
|
||||
List<FirstLegLoadingPlanCompletedV2.Item> items = sorted.stream().map(row ->
|
||||
@@ -138,7 +143,7 @@ public class LoadingCompletedOutboxService {
|
||||
String fulfillmentType = fulfillmentType(sorted);
|
||||
String orderType = dropship(fulfillmentType) ? "ONE_CLICK_DROPSHIP" : "FIRST_LEG";
|
||||
List<FirstLegLoadingPlanCompletedV2.PalletSnapshot> pallets = dropship(fulfillmentType)
|
||||
? palletSnapshots(sorted, palletRows) : standardLabels.stream()
|
||||
? palletSnapshots(sorted, palletRows, palletManifests) : standardLabels.stream()
|
||||
.map(label -> new FirstLegLoadingPlanCompletedV2.PalletSnapshot(label.getPalletCode(), List.of())).toList();
|
||||
if ("ONE_CLICK_DROPSHIP".equals(orderType) && pallets.isEmpty()) {
|
||||
throw new BizException(ResultCode.CONFLICT, "一键代发入仓单缺少托盘冻结快照");
|
||||
@@ -257,10 +262,11 @@ 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) {
|
||||
private List<FirstLegLoadingPlanCompletedV2.PalletSnapshot> palletSnapshots(List<Row> values, List<PalletRow> source,
|
||||
Map<String, PalletManifest> manifests) {
|
||||
if (source.isEmpty()) return List.of();
|
||||
if (source.stream().anyMatch(item -> Boolean.TRUE.equals(item.getActualBinding()))) {
|
||||
return actualPalletSnapshots(values, source);
|
||||
return actualPalletSnapshots(values, source, manifests);
|
||||
}
|
||||
Map<String, List<PalletRow>> byPallet = source.stream().collect(java.util.stream.Collectors.groupingBy(
|
||||
p -> (p.getAllocationNo() == null ? "" : p.getAllocationNo()) + "|" + p.getPalletCode(),
|
||||
@@ -314,7 +320,7 @@ public class LoadingCompletedOutboxService {
|
||||
|
||||
/** 现场逐箱扫描已给出精确箱到托关系,不再使用旧的按托盘容量顺序推算。 */
|
||||
private List<FirstLegLoadingPlanCompletedV2.PalletSnapshot> actualPalletSnapshots(
|
||||
List<Row> values, List<PalletRow> source) {
|
||||
List<Row> values, List<PalletRow> source, Map<String, PalletManifest> manifests) {
|
||||
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(
|
||||
@@ -334,14 +340,17 @@ public class LoadingCompletedOutboxService {
|
||||
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()));
|
||||
int quantity = detailRows.getValue().stream().map(PalletRow::getActualSkuQuantity)
|
||||
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum();
|
||||
if (quantity <= 0) throw new BizException(ResultCode.CONFLICT, "一件代发现场托盘缺少实点 SKU 数量");
|
||||
skus.add(new FirstLegLoadingPlanCompletedV2.SkuPackageSnapshot(detail.getSourceInboundDetailId().toString(), detail.getSkuCode(),
|
||||
detail.getProductNameZh(), marks.size(), quantity, marks));
|
||||
actualPackages += marks.size();
|
||||
}
|
||||
result.add(new FirstLegLoadingPlanCompletedV2.PalletSnapshot(pallet.getKey(), skus));
|
||||
PalletManifest manifest = manifests.get(pallet.getKey());
|
||||
if (manifest == null) throw new BizException(ResultCode.CONFLICT, "一件代发托盘缺少完整清单摘要");
|
||||
result.add(new FirstLegLoadingPlanCompletedV2.PalletSnapshot(pallet.getKey(), skus,
|
||||
manifest.version(), manifest.packageCount(), manifest.fragmentCount(), manifest.hash()));
|
||||
}
|
||||
if (actualPackages != sumInt(values, Row::getPackageCount)) {
|
||||
throw new BizException(ResultCode.CONFLICT, "一件代发现场托盘箱唛数与实装箱数不一致");
|
||||
@@ -349,12 +358,37 @@ public class LoadingCompletedOutboxService {
|
||||
return result;
|
||||
}
|
||||
|
||||
private Map<String, PalletManifest> palletManifests(List<PalletRow> rows) {
|
||||
Map<String, List<PalletRow>> grouped = rows.stream().filter(row -> Boolean.TRUE.equals(row.getActualBinding()))
|
||||
.collect(java.util.stream.Collectors.groupingBy(PalletRow::getPalletCode, LinkedHashMap::new,
|
||||
java.util.stream.Collectors.toList()));
|
||||
Map<String, PalletManifest> result = new LinkedHashMap<>();
|
||||
grouped.forEach((palletNo, values) -> {
|
||||
List<PalletRow> sorted = values.stream().sorted(Comparator
|
||||
.comparing(PalletRow::getAccountId).thenComparing(PalletRow::getSourceInboundId)
|
||||
.thenComparing(PalletRow::getSourceInboundDetailId).thenComparing(PalletRow::getBoxCode)).toList();
|
||||
String canonical = sorted.stream().map(value -> value.getAccountId() + "|" + value.getSourceInboundId()
|
||||
+ "|" + value.getSourceInboundDetailId() + "|" + value.getSkuCode() + "|" + value.getBoxCode()
|
||||
+ "|" + value.getActualSkuQuantity()).collect(java.util.stream.Collectors.joining("\n"));
|
||||
int fragments = (int) sorted.stream().map(PalletRow::getAccountId).distinct().count();
|
||||
result.put(palletNo, new PalletManifest(1, sorted.size(), fragments,
|
||||
manifestHash(canonical)));
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
private String manifestHash(String canonical) {
|
||||
String value = EventPayloadHash.sha256(canonical.getBytes(StandardCharsets.UTF_8));
|
||||
return value.substring("sha256:".length());
|
||||
}
|
||||
|
||||
private static boolean dropship(String value) {
|
||||
return "ONE_CLICK_DROPSHIP".equals(value);
|
||||
}
|
||||
|
||||
private record DetailSnapshot(Long detailId, String skuCode, String productName,
|
||||
Integer packageCount, Integer productQuantity, List<String> marks) {}
|
||||
private record PalletManifest(int version, int packageCount, int fragmentCount, String hash) {}
|
||||
|
||||
private void insert(EventEnvelope envelope) {
|
||||
InboundOutboxEntity value = new InboundOutboxEntity();
|
||||
|
||||
@@ -26,6 +26,7 @@ import com.baiyue.firstleg.repository.LoadingListRepository;
|
||||
import com.baiyue.firstleg.repository.LoadingPickRepository;
|
||||
import com.baiyue.firstleg.repository.ContainerRepository;
|
||||
import com.baiyue.firstleg.domain.Iso6346ContainerNumber;
|
||||
import com.baiyue.firstleg.domain.PriceBookChannelCatalog;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@@ -138,7 +139,9 @@ public class LoadingListService {
|
||||
}
|
||||
public BusinessDocument containerLogisticsDocument(long bookingId,String type){ requireLogisticsType(type); requireBookingPlan(bookingId); return document(repository.document(bookingId,type)); }
|
||||
public BusinessDocument uploadContainerLogisticsDocument(long bookingId,String type,long operatorId,String operatorName,String key,int expectedVersion,String reason,MultipartFile file){
|
||||
requireLogisticsType(type); String businessType=fileType(type); return uploadDocument(requireBookingPlan(bookingId),type,businessType,bookingId,Long.toString(bookingId),operatorId,operatorName,key,expectedVersion,reason,file);
|
||||
requireLogisticsType(type); String businessType=fileType(type); AggregateRow plan=requireBookingPlan(bookingId);
|
||||
requireDeliveryOrderUploadAllowed(plan);
|
||||
return uploadDocument(plan,type,businessType,bookingId,Long.toString(bookingId),operatorId,operatorName,key,expectedVersion,reason,file);
|
||||
}
|
||||
public DocumentDownload downloadContainerDocument(long bookingId,String type,long fileId){
|
||||
requireLogisticsType(type); String businessType=fileType(type); requireBookingPlan(bookingId); ContainerDocumentEntity d=requireDocumentEntity(bookingId,type); if(!Long.valueOf(fileId).equals(d.getFileId())) throw notFound("货柜资料不存在");
|
||||
@@ -190,6 +193,17 @@ public class LoadingListService {
|
||||
public record DocumentDownload(String fileName,MediaType contentType,byte[] content) {}
|
||||
private static void requireLogisticsType(String type){ if(!DELIVERY_ORDER.equals(type)) throw badRequest("documentType只能是DELIVERY_ORDER"); }
|
||||
private static String fileType(String type){ if (DELIVERY_ORDER.equals(type)) return DO_FILE_TYPE; throw badRequest("documentType只能是DELIVERY_ORDER"); }
|
||||
private static void requireDeliveryOrderUploadAllowed(AggregateRow plan) {
|
||||
if (!"COMPLETED".equals(plan.getPlanStatus()) || plan.getOutboundConfirmedAt() == null) {
|
||||
throw conflict("货柜进入海外履约后才可上传DO资料");
|
||||
}
|
||||
if (plan.getActualEmptyReportedAt() != null) {
|
||||
throw conflict("货柜已还柜,不可再上传DO资料");
|
||||
}
|
||||
if (PriceBookChannelCatalog.isSeaRail(plan.getChannelCode())) {
|
||||
throw conflict("海铁渠道不适用DO资料");
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@BusinessAudit(event = "CONTAINER_CLEARANCE_DOCUMENT_ATTACH", resourceType = "CONTAINER",
|
||||
|
||||
@@ -233,9 +233,9 @@ public class LoadingPickService {
|
||||
return view(requiredTask(taskId), warehouseId);
|
||||
}
|
||||
|
||||
public List<PendingTask> pendingTasks(long operatorId, String keyword) {
|
||||
public List<PendingTask> pendingTasks(long operatorId, long warehouseId, String keyword) {
|
||||
String normalizedKeyword = StringUtils.hasText(keyword) ? keyword.trim() : null;
|
||||
return repository.pendingTasks(operatorId, normalizedKeyword).stream()
|
||||
return repository.pendingTasks(operatorId, warehouseId, normalizedKeyword).stream()
|
||||
.map(row -> new PendingTask(row.getTaskId(), row.getLoadingPlanId(), row.getContainerBookingId(),
|
||||
row.getCabinetNo(), row.getContainerNo(),
|
||||
row.getSourceType(), row.getRecordStatus(), row.getMergedIntoContainerBookingId(),
|
||||
@@ -405,7 +405,7 @@ public class LoadingPickService {
|
||||
requirePackageUnit(item);
|
||||
if (repository.partial(itemId, request.finalPickedPackageCount(), request.version(),
|
||||
request.warehouseId()) != 1) {
|
||||
throw conflict("部分拣货箱数必须大于已拣箱数且小于计划箱数,或明细版本已变化");
|
||||
throw conflict("部分拣货最终箱数不能小于已拣箱数且必须小于计划箱数,或明细版本已变化");
|
||||
}
|
||||
insertHistory(taskId, itemId, operationId, requestHash, "PARTIAL",
|
||||
request.finalPickedPackageCount(), null, operatorId, operatorName);
|
||||
@@ -1068,9 +1068,9 @@ public class LoadingPickService {
|
||||
int current = count(item.getPickedPackageCount());
|
||||
int planned = count(item.getPlannedPackageCount());
|
||||
if (requestItem.finalPickedPackageCount() == null
|
||||
|| requestItem.finalPickedPackageCount() <= current
|
||||
|| requestItem.finalPickedPackageCount() < current
|
||||
|| requestItem.finalPickedPackageCount() >= planned) {
|
||||
throw conflict("PARTIAL最终箱数必须大于已拣且小于计划箱数");
|
||||
throw conflict("PARTIAL最终箱数不能小于已拣且必须小于计划箱数");
|
||||
}
|
||||
} else if ("SKIP".equals(action)) {
|
||||
if (requestItem.pickedPackageCount() != null || requestItem.finalPickedPackageCount() != null) {
|
||||
|
||||
@@ -92,14 +92,18 @@ public class MeasurementReviewService {
|
||||
"人工确认箱数合计与入库单总箱数不一致,不能审核通过");
|
||||
}
|
||||
var weights = measurements.listWeights(tenantId, inboundId);
|
||||
if (weights.isEmpty() || weights.stream().anyMatch(weight ->
|
||||
if (weights.isEmpty()) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED,
|
||||
"该票测量已提交但没有称重图片;请先退回测量,上传并确认至少一张称重图片后重新提交(即使只有一箱也必须称重)");
|
||||
}
|
||||
if (weights.stream().anyMatch(weight ->
|
||||
!"CONFIRMED".equals(weight.getRecognitionStatus())
|
||||
|| weight.getGrossWeightKg() == null
|
||||
|| weight.getGrossWeightKg().signum() <= 0
|
||||
|| weight.getNetWeightKg() == null
|
||||
|| weight.getNetWeightKg().signum() <= 0)) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED,
|
||||
"所有称重结果必须已确认且包含大于零的毛重和货物净重,才能审核通过");
|
||||
"该票存在未确认或重量无效的称重图片;请先在审核弹框逐张修正并确认后再审核");
|
||||
}
|
||||
BigDecimal grossWeight = weights.stream()
|
||||
.map(com.baiyue.firstleg.entity.InboundWeightResultEntity::getGrossWeightKg)
|
||||
@@ -139,7 +143,7 @@ public class MeasurementReviewService {
|
||||
repository.insertReview(reviewRecord(tenantId, inboundId, request.measurementId(), result,
|
||||
request.reason(), false, null, false, operatorId, operatorName));
|
||||
if (approved && "ONE_CLICK_DROPSHIP".equals(order.getFulfillmentType())) {
|
||||
dropshipPalletization.createTask(order);
|
||||
dropshipPalletization.createTask(order, operatorId, operatorName);
|
||||
}
|
||||
String approvedStatus = "ONE_CLICK_DROPSHIP".equals(order.getFulfillmentType())
|
||||
? "WAIT_PALLETIZE" : "WAIT_SPLIT";
|
||||
@@ -179,7 +183,7 @@ public class MeasurementReviewService {
|
||||
throw concurrentConflict();
|
||||
}
|
||||
if ("ONE_CLICK_DROPSHIP".equals(order.getFulfillmentType())) {
|
||||
dropshipPalletization.createTask(order);
|
||||
dropshipPalletization.createTask(order, operatorId, operatorName);
|
||||
}
|
||||
repository.insertReview(reviewRecord(tenantId, inboundId, request.measurementId(), "REJECTED",
|
||||
null, true, request.reason(), true, operatorId, operatorName));
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.client.WarehouseMasterDataClient;
|
||||
import com.baiyue.firstleg.client.WarehouseMasterDataClient.WarehouseOption;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** 为国内 PDA 提供启用中的国内仓选择项,主档仍由 Warehouse Service 唯一维护。 */
|
||||
@Service
|
||||
public class PdaWarehouseCatalogService {
|
||||
@Resource private WarehouseMasterDataClient warehouses;
|
||||
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
|
||||
|
||||
public List<WarehouseOption> activeDomesticWarehouses() {
|
||||
try {
|
||||
ApiResponse<List<WarehouseOption>> response = warehouses.domestic(internalToken);
|
||||
if (response == null || !response.successful() || response.data() == null) {
|
||||
throw unavailable();
|
||||
}
|
||||
return response.data();
|
||||
} catch (BizException exception) {
|
||||
throw exception;
|
||||
} catch (RuntimeException exception) {
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
|
||||
private static BizException unavailable() {
|
||||
return new BizException(ResultCode.SERVICE_UNAVAILABLE, "国内仓目录暂时不可用,请稍后重试");
|
||||
}
|
||||
}
|
||||
@@ -158,6 +158,11 @@ public class PlatformPriceBookService {
|
||||
if (candidate.getTenantId() == PriceBookVersionEntity.PLATFORM_SCOPE) repository.lockPlatformVersions();
|
||||
else repository.lockVersions(candidate.getTenantId());
|
||||
PriceBookVersionEntity version = requirePendingConfirmation(versionId);
|
||||
List<PriceBookVersionEntity> activeVersions = version.getTenantId() == PriceBookVersionEntity.PLATFORM_SCOPE
|
||||
? repository.listActiveForUpdate() : repository.listActiveForUpdate(version.getTenantId());
|
||||
if (activeVersions.stream().anyMatch(active -> active.getVersionNo() >= version.getVersionNo())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "该待确认版本早于当前生效版本,请作废后重新上传");
|
||||
}
|
||||
List<PriceBookDtos.ParsedChannel> channels = repository.listChannels(versionId).stream().map(this::channelDto).toList();
|
||||
List<PriceBookDtos.ParsedRate> rates = repository.listRates(versionId).stream().map(this::rateDto).toList();
|
||||
MappingResult mapped = applyMappings(rates, confirmation);
|
||||
@@ -174,14 +179,25 @@ public class PlatformPriceBookService {
|
||||
version.setChannelCount(channels.size());
|
||||
version.setRateCount(mapped.rates().size());
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
(version.getTenantId() == PriceBookVersionEntity.PLATFORM_SCOPE
|
||||
? repository.listActiveForUpdate() : repository.listActiveForUpdate(version.getTenantId())).stream()
|
||||
activeVersions.stream()
|
||||
.filter(active -> !Objects.equals(active.getId(), versionId))
|
||||
.forEach(active -> repository.supersede(active, now, operatorId));
|
||||
repository.activate(version, now, operatorId);
|
||||
return detail(versionId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PriceBookDtos.PriceBookVersionDetail voidPending(long versionId, long operatorId,
|
||||
PriceBookDtos.PriceBookVoidRequest request) {
|
||||
PriceBookVersionEntity candidate = requireVersion(versionId);
|
||||
if (candidate.getTenantId() == PriceBookVersionEntity.PLATFORM_SCOPE) repository.lockPlatformVersions();
|
||||
else repository.lockVersions(candidate.getTenantId());
|
||||
PriceBookVersionEntity version = requirePendingConfirmation(versionId);
|
||||
String reason = request.reason().trim();
|
||||
repository.voidPending(version, LocalDateTime.now(), operatorId, reason);
|
||||
return detail(versionId);
|
||||
}
|
||||
|
||||
public ResponseEntity<byte[]> source(long versionId, long operatorId) {
|
||||
PriceBookVersionEntity version = requireVersion(versionId);
|
||||
if (version.getSourceFileId() == null) throw new BizException(ResultCode.NOT_FOUND, "价格表原文件不存在");
|
||||
@@ -562,7 +578,7 @@ public class PlatformPriceBookService {
|
||||
value.getSourceFileId(), value.getSourceSha256(), status,
|
||||
value.getChannelCount(), value.getRateCount(), value.getBlockingErrorCount(), value.getWarningCount(),
|
||||
value.getParseIssuesJson(), value.getCreatedBy(), value.getCreatedAt(), value.getPublishedBy(),
|
||||
value.getPublishedAt(), value.getSupersededAt());
|
||||
value.getPublishedAt(), value.getSupersededAt(), value.getVoidedBy(), value.getVoidedAt(), value.getVoidReason());
|
||||
}
|
||||
|
||||
private static void requireScope(long customerId) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.audit.BusinessAudit;
|
||||
import com.baiyue.firstleg.dto.SmartLoadingDtos.*;
|
||||
import com.baiyue.firstleg.domain.SmartLoadingChannelGroupCatalog;
|
||||
import com.baiyue.firstleg.client.OrderCargoClient;
|
||||
import com.baiyue.firstleg.client.PendingReviewOrderClient;
|
||||
import com.baiyue.firstleg.entity.*;
|
||||
@@ -42,17 +43,6 @@ import java.util.*;
|
||||
@Service
|
||||
public class SmartLoadingService {
|
||||
private static final BigDecimal CAPACITY_LIMIT_RATIO = new BigDecimal("1.50");
|
||||
private static final Map<String, Set<String>> WORKBENCH_CHANNEL_GROUPS = Map.of(
|
||||
"MATSON_CLX", Set.of("MATSON_CLX_TRUCK", "MATSON_CLX_PARCEL"),
|
||||
"MATSON_MAX", Set.of("MATSON_MAX_TRUCK", "MATSON_MAX_PARCEL"),
|
||||
"EXX", Set.of("EXX_TRUCK", "EXX_RAIL_NY"),
|
||||
"HEDE", Set.of("HEDE_TRUCK", "HEDE_PARCEL"),
|
||||
"OA_LA", Set.of("OA_LA_TRUCK", "OA_LA_FULL_TRUCK", "OA_PARCEL"),
|
||||
"OA_CHICAGO", Set.of("OA_CHICAGO_RAIL"),
|
||||
"OA_HOUSTON", Set.of("OA_HOUSTON_TRUCK"),
|
||||
"OA_SAVANNAH", Set.of("OA_SAVANNAH_TRUCK"),
|
||||
"OA_NEW_YORK_TRUCK", Set.of("OA_NEW_YORK_TRUCK")
|
||||
);
|
||||
@Resource private SmartLoadingRepository repository;
|
||||
@Resource private com.baiyue.firstleg.mapper.DropshipPalletizationMapper dropshipPallets;
|
||||
@Resource private OrderCargoClient orderCargoClient;
|
||||
@@ -102,7 +92,7 @@ public class SmartLoadingService {
|
||||
public List<Candidate> candidates(long bookingId, long warehouseId) {
|
||||
ContainerBookingEntity booking = requireBooking(bookingId);
|
||||
if (!Objects.equals(booking.getWarehouseId(), warehouseId)) throw validation("柜次仓库与候选仓库不一致");
|
||||
if (!StringUtils.hasText(booking.getChannelCode())) throw validation("柜次缺少渠道快照,请先完成存量数据回填");
|
||||
if (!StringUtils.hasText(booking.getChannelGroupCode())) throw validation("柜次缺少计划渠道,请重新创建模拟柜");
|
||||
List<Candidate> values = candidates(warehouseId).stream()
|
||||
.filter(candidate -> sameChannel(booking, candidate) && sameCustomerScope(booking, candidate)).toList();
|
||||
return enrichColoadCandidates(booking, repository.planByBooking(bookingId), values);
|
||||
@@ -266,7 +256,7 @@ public class SmartLoadingService {
|
||||
&& query.createdStart().isAfter(query.createdEnd())) {
|
||||
throw validation("创建开始日期不能晚于结束日期");
|
||||
}
|
||||
return repository.workbenchContainers(query, actualChannels(query.channelGroupCodes()));
|
||||
return repository.workbenchContainers(query, normalizedGroups(query.channelGroupCodes()));
|
||||
}
|
||||
|
||||
private List<InboundCandidate> inboundCandidates(List<Candidate> details) {
|
||||
@@ -384,13 +374,17 @@ public class SmartLoadingService {
|
||||
private static Set<String> actualChannels(List<String> groups) {
|
||||
Set<String> actual = new LinkedHashSet<>();
|
||||
for (String value : groups) {
|
||||
String normalized = value == null ? "" : value.trim().toUpperCase(Locale.ROOT);
|
||||
Set<String> mapped = WORKBENCH_CHANNEL_GROUPS.get(normalized);
|
||||
if (mapped == null) throw validation("不支持的渠道分组: " + value);
|
||||
actual.addAll(mapped);
|
||||
try { actual.addAll(SmartLoadingChannelGroupCatalog.require(value).channelCodes()); }
|
||||
catch (IllegalArgumentException exception) { throw validation(exception.getMessage()); }
|
||||
}
|
||||
return actual;
|
||||
}
|
||||
private static Set<String> normalizedGroups(List<String> groups) {
|
||||
return groups.stream().map(value -> {
|
||||
try { return SmartLoadingChannelGroupCatalog.require(value).code(); }
|
||||
catch (IllegalArgumentException exception) { throw validation(exception.getMessage()); }
|
||||
}).collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new));
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Plan draftByBooking(long bookingId) {
|
||||
@@ -1689,8 +1683,10 @@ public class SmartLoadingService {
|
||||
}
|
||||
private ContainerBookingEntity requireBooking(long id){ContainerBookingEntity b=repository.booking(id);if(b==null)throw new BizException(ResultCode.NOT_FOUND,"货柜预订不存在");return b;}
|
||||
private static boolean sameChannel(ContainerBookingEntity booking, Candidate candidate) {
|
||||
return StringUtils.hasText(booking.getChannelCode()) && StringUtils.hasText(candidate.channelCode())
|
||||
&& booking.getChannelCode().equals(candidate.channelCode());
|
||||
SmartLoadingChannelGroupCatalog.Definition candidateGroup =
|
||||
SmartLoadingChannelGroupCatalog.resolveChannel(candidate.channelCode());
|
||||
return StringUtils.hasText(booking.getChannelGroupCode()) && candidateGroup != null
|
||||
&& booking.getChannelGroupCode().equals(candidateGroup.code());
|
||||
}
|
||||
private static boolean sameCustomerScope(ContainerBookingEntity booking, Candidate candidate) {
|
||||
return booking.getCustomerId() == null || Objects.equals(booking.getCustomerId(), candidate.accountId());
|
||||
|
||||
@@ -82,8 +82,8 @@ public class SplitPreloadService {
|
||||
}
|
||||
PreloadPlanEntity plan = repository.findPlan(tenantId, inboundId);
|
||||
if (plan != null) {
|
||||
if (!PreloadStatus.PENDING.equals(plan.getStatus())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "预装计划已进入确认流程,不能修改智能装柜分配");
|
||||
if (!reusableForLoadingAllocation(plan.getStatus())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "预装计划当前状态不能修改智能装柜分配");
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -96,17 +96,13 @@ public class SplitPreloadService {
|
||||
repository.createPlan(plan);
|
||||
} catch (DuplicateKeyException exception) {
|
||||
PreloadPlanEntity concurrent = repository.findPlan(tenantId, inboundId);
|
||||
if (concurrent == null || !PreloadStatus.PENDING.equals(concurrent.getStatus())) {
|
||||
if (concurrent == null || !reusableForLoadingAllocation(concurrent.getStatus())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "预装计划已被并发修改,请刷新后重试");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A running PDA task may restore or add an unpicked allocation after the plan-level preload
|
||||
* confirmation. A running PDA task owns the mutation lock, so CONFIRMING and CONFIRMED
|
||||
* lifecycles may be reused while the restored loading item is synchronised atomically.
|
||||
*/
|
||||
/** Reuses the confirmed preload lifecycle while a running PDA task owns the mutation lock. */
|
||||
@Transactional
|
||||
public void ensureForRunningLoadingAllocation(long tenantId, long inboundId, long operatorId,
|
||||
String operatorName) {
|
||||
@@ -126,10 +122,8 @@ public class SplitPreloadService {
|
||||
throw new BizException(ResultCode.CONFLICT, "装柜中的订单缺少既有预装计划,不能新增分配");
|
||||
}
|
||||
if (plan != null) {
|
||||
if (!PreloadStatus.PENDING.equals(plan.getStatus())
|
||||
&& !(running && Set.of(PreloadStatus.CONFIRMING, PreloadStatus.CONFIRMED)
|
||||
.contains(plan.getStatus()))) {
|
||||
throw new BizException(ResultCode.CONFLICT, "预装计划已进入确认流程,不能修改智能装柜分配");
|
||||
if (!reusableForLoadingAllocation(plan.getStatus())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "预装计划当前状态不能修改智能装柜分配");
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -142,12 +136,17 @@ public class SplitPreloadService {
|
||||
repository.createPlan(plan);
|
||||
} catch (DuplicateKeyException exception) {
|
||||
PreloadPlanEntity concurrent = repository.findPlan(tenantId, inboundId);
|
||||
if (concurrent == null || !PreloadStatus.PENDING.equals(concurrent.getStatus())) {
|
||||
if (concurrent == null || !reusableForLoadingAllocation(concurrent.getStatus())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "预装计划已被并发修改,请刷新后重试");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean reusableForLoadingAllocation(String status) {
|
||||
return Set.of(PreloadStatus.PENDING, PreloadStatus.CONFIRMING, PreloadStatus.CONFIRMED)
|
||||
.contains(status);
|
||||
}
|
||||
|
||||
public PreloadPlanResponse detail(long tenantId, long inboundId) {
|
||||
requireInbound(tenantId, inboundId);
|
||||
return response(requirePlan(tenantId, inboundId));
|
||||
|
||||
@@ -33,3 +33,19 @@ CREATE TABLE IF NOT EXISTS fl_inbound_carton_label (
|
||||
KEY idx_carton_label_detail (tenant_id, inbound_id, inbound_detail_id),
|
||||
CONSTRAINT fk_carton_label_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
|
||||
) ENGINE=InnoDB COMMENT='首次打印前冻结的实际箱号与订单货物明细映射';
|
||||
CREATE TABLE IF NOT EXISTS fl_inbound_carton_label_sku (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '箱唛SKU快照ID',
|
||||
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
|
||||
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
|
||||
carton_label_id BIGINT UNSIGNED NOT NULL COMMENT '物理箱唛ID',
|
||||
inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '订单货物明细ID',
|
||||
sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码快照',
|
||||
cargo_name VARCHAR(255) NULL COMMENT '货物名称快照',
|
||||
expected_quantity INT UNSIGNED NOT NULL COMMENT '该物理箱内应收SKU数量',
|
||||
line_no INT UNSIGNED NOT NULL COMMENT '箱内SKU展示顺序',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_carton_label_sku_detail (carton_label_id,inbound_detail_id),
|
||||
KEY idx_carton_label_sku_code (tenant_id,inbound_id,sku_code),
|
||||
CONSTRAINT fk_carton_label_sku_label FOREIGN KEY (carton_label_id) REFERENCES fl_inbound_carton_label(id)
|
||||
) ENGINE=InnoDB COMMENT='物理箱唛内的多SKU冻结快照';
|
||||
|
||||
@@ -230,6 +230,23 @@ CREATE TABLE IF NOT EXISTS fl_inbound_carton_label (
|
||||
KEY idx_carton_label_detail (tenant_id, inbound_id, inbound_detail_id),
|
||||
CONSTRAINT fk_carton_label_inbound FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order (id)
|
||||
) ENGINE=InnoDB COMMENT='首次打印前冻结的实际箱号与订单货物明细映射';
|
||||
CREATE TABLE IF NOT EXISTS fl_inbound_carton_label_sku (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '箱唛SKU快照ID',
|
||||
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
|
||||
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
|
||||
carton_label_id BIGINT UNSIGNED NOT NULL COMMENT '物理箱唛ID',
|
||||
inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '订单货物明细ID',
|
||||
sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码快照',
|
||||
cargo_name VARCHAR(255) NULL COMMENT '货物名称快照',
|
||||
expected_quantity INT UNSIGNED NOT NULL COMMENT '该物理箱内应收SKU数量',
|
||||
line_no INT UNSIGNED NOT NULL COMMENT '箱内SKU展示顺序',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_carton_label_sku_detail (carton_label_id,inbound_detail_id),
|
||||
KEY idx_carton_label_sku_code (tenant_id,inbound_id,sku_code),
|
||||
CONSTRAINT fk_carton_label_sku_label FOREIGN KEY (carton_label_id) REFERENCES fl_inbound_carton_label(id)
|
||||
) ENGINE=InnoDB COMMENT='物理箱唛内的多SKU冻结快照';
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_inbound_measurement (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '测量批次ID',
|
||||
@@ -541,7 +558,7 @@ CREATE TABLE IF NOT EXISTS fl_price_book_version (
|
||||
original_file_name VARCHAR(255) NOT NULL COMMENT '原始Excel文件名',
|
||||
source_file_id BIGINT UNSIGNED NULL COMMENT 'File服务原始文件ID',
|
||||
source_sha256 CHAR(64) NULL COMMENT '原始文件SHA-256',
|
||||
status VARCHAR(24) NOT NULL DEFAULT 'PENDING_CONFIRMATION' COMMENT '价格版本状态:PENDING_CONFIRMATION待确认、ACTIVE已生效、SUPERSEDED已被替代、FAILED解析失败',
|
||||
status VARCHAR(24) NOT NULL DEFAULT 'PENDING_CONFIRMATION' COMMENT '价格版本状态:PENDING_CONFIRMATION待确认、ACTIVE已生效、SUPERSEDED已被替代、VOIDED已作废、FAILED解析失败',
|
||||
blocking_error_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '阻断错误数',
|
||||
warning_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '警告数',
|
||||
parse_issues_json JSON NULL COMMENT '上传解析问题及待确认重新校验结果',
|
||||
@@ -552,6 +569,9 @@ CREATE TABLE IF NOT EXISTS fl_price_book_version (
|
||||
published_by BIGINT UNSIGNED NULL COMMENT '确认并发布价格版本的操作人ID',
|
||||
published_at DATETIME(3) NULL COMMENT '确认并生效时间',
|
||||
superseded_at DATETIME(3) NULL COMMENT '被下一版本替换时间',
|
||||
voided_by BIGINT UNSIGNED NULL COMMENT '作废操作人ID',
|
||||
voided_at DATETIME(3) NULL COMMENT '作废时间',
|
||||
void_reason VARCHAR(500) NULL COMMENT '作废原因',
|
||||
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间',
|
||||
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记:0未删除、1已删除',
|
||||
@@ -1030,3 +1050,19 @@ CREATE TABLE IF NOT EXISTS fl_dropship_pallet_carton (
|
||||
CONSTRAINT fk_dropship_carton_pallet FOREIGN KEY (pallet_id) REFERENCES fl_dropship_pallet(id),
|
||||
CONSTRAINT fk_dropship_carton_label FOREIGN KEY (carton_label_id) REFERENCES fl_inbound_carton_label(id)
|
||||
) ENGINE=InnoDB COMMENT='一件代发托盘内逐箱SKU和入仓单明细';
|
||||
CREATE TABLE IF NOT EXISTS fl_dropship_pallet_carton_sku (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '清点箱SKU明细ID',
|
||||
carton_id BIGINT UNSIGNED NOT NULL COMMENT '清点物理箱ID',
|
||||
inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '入仓货物明细ID',
|
||||
sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码快照',
|
||||
cargo_name VARCHAR(255) NULL COMMENT '货物名称快照',
|
||||
expected_quantity INT UNSIGNED NOT NULL COMMENT '应收数量',
|
||||
actual_quantity INT UNSIGNED NOT NULL COMMENT '实收数量',
|
||||
discrepancy_status VARCHAR(24) NOT NULL COMMENT 'MATCH或ACCEPTED_VARIANCE',
|
||||
discrepancy_remark VARCHAR(500) NULL COMMENT '差异处理说明',
|
||||
line_no INT UNSIGNED NOT NULL COMMENT '箱内SKU顺序',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_dropship_carton_sku_detail (carton_id,inbound_detail_id),
|
||||
KEY idx_dropship_carton_sku_code (sku_code,carton_id),
|
||||
CONSTRAINT fk_dropship_carton_sku_carton FOREIGN KEY (carton_id) REFERENCES fl_dropship_pallet_carton(id)
|
||||
) ENGINE=InnoDB COMMENT='一件代发物理箱内逐SKU清点事实';
|
||||
|
||||
@@ -34,6 +34,9 @@ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='fl_container_booking' AND column_name='channel_code') THEN
|
||||
ALTER TABLE fl_container_booking ADD COLUMN channel_code VARCHAR(64) NULL COMMENT '订舱渠道编码快照' AFTER cabinet_no;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='fl_container_booking' AND column_name='channel_group_code') THEN
|
||||
ALTER TABLE fl_container_booking ADD COLUMN channel_group_code VARCHAR(64) NULL COMMENT '模拟柜计划渠道分组;关联真实订舱后继续保留' AFTER merged_into_container_booking_id;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='fl_container_booking' AND column_name='channel_name') THEN
|
||||
ALTER TABLE fl_container_booking ADD COLUMN channel_name VARCHAR(128) NULL COMMENT '订舱渠道名称快照' AFTER channel_code;
|
||||
END IF;
|
||||
@@ -93,6 +96,7 @@ BEGIN
|
||||
IF current_plan_status IN ('LOADING', 'COMPLETED') AND (
|
||||
NOT (OLD.id <=> NEW.id) OR NOT (OLD.booking_no <=> NEW.booking_no)
|
||||
OR NOT (OLD.cabinet_no <=> NEW.cabinet_no)
|
||||
OR NOT (OLD.channel_group_code <=> NEW.channel_group_code)
|
||||
OR NOT (OLD.channel_code <=> NEW.channel_code) OR NOT (OLD.channel_name <=> NEW.channel_name)
|
||||
OR NOT (OLD.ocean_booking_id <=> NEW.ocean_booking_id)
|
||||
OR NOT (OLD.ocean_booking_requirement_id <=> NEW.ocean_booking_requirement_id)
|
||||
|
||||
@@ -41,6 +41,7 @@ CREATE TABLE IF NOT EXISTS fl_container_booking (
|
||||
source_type VARCHAR(24) NOT NULL DEFAULT 'SIMULATION' COMMENT '记录来源:SIMULATION内部模拟柜、OCEAN_BOOKING订舱真实柜',
|
||||
record_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '记录状态:ACTIVE可操作、MERGED已合并历史',
|
||||
merged_into_container_booking_id BIGINT UNSIGNED NULL COMMENT '真实柜被模拟柜吸收后的目标稳定柜次ID',
|
||||
channel_group_code VARCHAR(64) NULL COMMENT '模拟柜计划渠道分组;关联真实订舱后继续保留',
|
||||
channel_code VARCHAR(64) NULL COMMENT '订舱渠道编码快照;存量回填前禁止混装',
|
||||
channel_name VARCHAR(128) NULL COMMENT '订舱渠道名称快照',
|
||||
ocean_booking_id BIGINT UNSIGNED NULL COMMENT '来源订舱ID',
|
||||
@@ -149,11 +150,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.columns WHERE table_schema=DATABASE()
|
||||
AND table_name='fl_container_booking' AND column_name='unloading_completed_at') THEN
|
||||
ALTER TABLE fl_container_booking ADD COLUMN unloading_completed_at DATETIME(3) NULL
|
||||
@@ -465,6 +469,7 @@ BEGIN
|
||||
DECLARE source_exists INT DEFAULT 0;
|
||||
DECLARE source_total_columns INT DEFAULT 0;
|
||||
DECLARE source_smart_columns INT DEFAULT 0;
|
||||
DECLARE source_current_columns INT DEFAULT 0;
|
||||
DECLARE source_inbound_columns INT DEFAULT 0;
|
||||
DECLARE target_exists INT DEFAULT 0;
|
||||
DECLARE target_total_columns INT DEFAULT 0;
|
||||
@@ -509,7 +514,45 @@ BEGIN
|
||||
OR (column_name = 'updated_at' AND data_type = 'datetime' AND datetime_precision = 3 AND is_nullable = 'NO' AND LOWER(column_default) LIKE 'current_timestamp%' AND LOWER(extra) LIKE '%on update current_timestamp%')
|
||||
);
|
||||
|
||||
IF source_total_columns = 11 AND source_smart_columns = 11 AND source_inbound_columns < 12
|
||||
SELECT COUNT(*) INTO source_current_columns
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' AND (
|
||||
(column_name = 'id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
|
||||
OR (column_name = 'loading_plan_id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
|
||||
OR (column_name = 'container_booking_id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
|
||||
OR (column_name = 'cabinet_no' AND LOWER(column_type) = 'varchar(64)' AND is_nullable = 'NO')
|
||||
OR (column_name = 'container_no' AND LOWER(column_type) = 'varchar(64)' AND is_nullable = 'YES')
|
||||
OR (column_name = 'task_status' AND LOWER(column_type) = 'varchar(24)' AND is_nullable = 'NO' AND column_default = 'LOADING')
|
||||
OR (column_name = 'started_by' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
|
||||
OR (column_name = 'started_by_name' AND LOWER(column_type) = 'varchar(64)' AND is_nullable = 'YES')
|
||||
OR (column_name = 'started_at' AND data_type = 'datetime' AND datetime_precision = 3 AND is_nullable = 'NO' AND LOWER(column_default) LIKE 'current_timestamp%')
|
||||
OR (column_name = 'completed_by' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'YES')
|
||||
OR (column_name = 'completed_by_name' AND LOWER(column_type) = 'varchar(64)' AND is_nullable = 'YES')
|
||||
OR (column_name = 'completed_at' AND data_type = 'datetime' AND datetime_precision = 3 AND is_nullable = 'YES')
|
||||
OR (column_name = 'version' AND LOWER(column_type) = 'int unsigned' AND is_nullable = 'NO' AND CAST(column_default AS CHAR) = '0')
|
||||
);
|
||||
|
||||
IF source_total_columns = 13 AND source_current_columns = 13
|
||||
AND (SELECT COUNT(*) FROM information_schema.statistics
|
||||
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task'
|
||||
AND index_name = 'uk_loading_pick_task_plan' AND non_unique = 0
|
||||
AND seq_in_index = 1 AND column_name = 'loading_plan_id') = 1
|
||||
AND (SELECT COUNT(*) FROM information_schema.statistics
|
||||
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task'
|
||||
AND index_name = 'uk_loading_pick_task_booking' AND non_unique = 0
|
||||
AND seq_in_index = 1 AND column_name = 'container_booking_id') = 1
|
||||
AND (SELECT COUNT(*) FROM information_schema.statistics
|
||||
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task'
|
||||
AND index_name = 'idx_loading_pick_container'
|
||||
AND ((seq_in_index = 1 AND column_name = 'container_no')
|
||||
OR (seq_in_index = 2 AND column_name = 'task_status')
|
||||
OR (seq_in_index = 3 AND column_name = 'started_at'))) = 3
|
||||
AND (SELECT COUNT(*) FROM information_schema.key_column_usage
|
||||
WHERE constraint_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task'
|
||||
AND column_name = 'loading_plan_id' AND referenced_table_name = 'fl_loading_plan'
|
||||
AND referenced_column_name = 'id') = 1 THEN
|
||||
SET source_exists = 1;
|
||||
ELSEIF source_total_columns = 11 AND source_smart_columns = 11 AND source_inbound_columns < 12
|
||||
AND (SELECT COUNT(*) FROM information_schema.statistics
|
||||
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task'
|
||||
AND index_name = 'uk_loading_pick_task_plan' AND non_unique = 0
|
||||
@@ -1430,14 +1473,13 @@ FROM iam_role role
|
||||
JOIN iam_permission permission
|
||||
ON permission.tenant_id = 0
|
||||
AND permission.permission_code IN (
|
||||
'platform:firstleg:container:list', 'platform:firstleg:container:view',
|
||||
'platform:firstleg:container:list',
|
||||
'platform:firstleg:container:create',
|
||||
'platform:firstleg:container:edit', 'platform:firstleg:smart-loading:list',
|
||||
'platform:firstleg:smart-loading:assign', 'platform:firstleg:smart-loading:delete',
|
||||
'platform:firstleg:preload:list', 'platform:firstleg:preload:view',
|
||||
'platform:firstleg:preload:list',
|
||||
'platform:firstleg:preload:edit', 'platform:firstleg:preload:complete',
|
||||
'platform:firstleg:preload:confirm', 'platform:firstleg:loading-list:list',
|
||||
'platform:firstleg:loading-list:view')
|
||||
'platform:firstleg:preload:confirm', 'platform:firstleg:loading-list:list')
|
||||
WHERE role.tenant_id = 0
|
||||
AND role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR');
|
||||
|
||||
@@ -1562,13 +1604,13 @@ BEGIN
|
||||
IF (SELECT COUNT(DISTINCT permission_code) FROM baiyue_iam.iam_permission
|
||||
WHERE tenant_id = 0 AND app_code = 'PLATFORM' AND status = 'ACTIVE' AND deleted = 0
|
||||
AND permission_code IN (
|
||||
'platform:firstleg:container:list', 'platform:firstleg:container:view',
|
||||
'platform:firstleg:container:list',
|
||||
'platform:firstleg:container:create', 'platform:firstleg:container:edit',
|
||||
'platform:firstleg:smart-loading:list', 'platform:firstleg:smart-loading:assign',
|
||||
'platform:firstleg:smart-loading:delete', 'platform:firstleg:preload:list',
|
||||
'platform:firstleg:preload:view', 'platform:firstleg:preload:edit',
|
||||
'platform:firstleg:preload:edit',
|
||||
'platform:firstleg:preload:complete', 'platform:firstleg:preload:confirm',
|
||||
'platform:firstleg:loading-list:list', 'platform:firstleg:loading-list:view')) <> 14 THEN
|
||||
'platform:firstleg:loading-list:list')) <> 11 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:平台权限资源未完整创建';
|
||||
END IF;
|
||||
IF (SELECT COUNT(DISTINCT CONCAT(role.role_code, ':', permission.permission_code))
|
||||
@@ -1578,14 +1620,13 @@ BEGIN
|
||||
WHERE role.tenant_id = 0 AND role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR')
|
||||
AND permission.tenant_id = 0
|
||||
AND permission.permission_code IN (
|
||||
'platform:firstleg:container:list', 'platform:firstleg:container:view',
|
||||
'platform:firstleg:container:list',
|
||||
'platform:firstleg:container:create',
|
||||
'platform:firstleg:container:edit', 'platform:firstleg:smart-loading:list',
|
||||
'platform:firstleg:smart-loading:assign', 'platform:firstleg:smart-loading:delete',
|
||||
'platform:firstleg:preload:list', 'platform:firstleg:preload:view',
|
||||
'platform:firstleg:preload:list',
|
||||
'platform:firstleg:preload:edit', 'platform:firstleg:preload:complete',
|
||||
'platform:firstleg:preload:confirm', 'platform:firstleg:loading-list:list',
|
||||
'platform:firstleg:loading-list:view')) <> 28 THEN
|
||||
'platform:firstleg:preload:confirm', 'platform:firstleg:loading-list:list')) <> 22 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:平台角色授权未完整写入';
|
||||
END IF;
|
||||
END$$
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Platform dashboard operational metrics
|
||||
version: 1.0.0
|
||||
paths:
|
||||
/api/platform/v1/first-leg/dashboard/operational-metrics:
|
||||
get:
|
||||
summary: 查询首页货柜与清关实时指标
|
||||
description: >-
|
||||
在途柜量按已实际开船且尚未到达海外仓的有效物理柜去重,TEU 按 20*=1、40*=2、45*=2.25
|
||||
折算,未知箱型不计入 TEU;待清关票数按已进入整柜清关且清关未完成的去重订单统计。
|
||||
本接口只返回当前时点事实,不提供历史环比。
|
||||
responses:
|
||||
'200':
|
||||
description: 查询成功
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/OperationalMetricsEnvelope'
|
||||
'401': {description: 需要登录}
|
||||
'403': {description: 需要货柜列表与报关列表权限}
|
||||
components:
|
||||
schemas:
|
||||
OperationalMetricsEnvelope:
|
||||
type: object
|
||||
required: [code, message, data]
|
||||
properties:
|
||||
code: {type: string, example: '200'}
|
||||
message: {type: string}
|
||||
data: {$ref: '#/components/schemas/OperationalMetrics'}
|
||||
OperationalMetrics:
|
||||
type: object
|
||||
required:
|
||||
- inTransitContainerCount
|
||||
- inTransitTeu
|
||||
- unknownContainerTypeCount
|
||||
- pendingCustomsOrderCount
|
||||
properties:
|
||||
inTransitContainerCount:
|
||||
type: integer
|
||||
format: int64
|
||||
minimum: 0
|
||||
description: 已开船未到海外仓的物理柜数
|
||||
inTransitTeu:
|
||||
type: number
|
||||
format: decimal
|
||||
multipleOf: 0.01
|
||||
minimum: 0
|
||||
description: 已知箱型的在途柜 TEU 合计
|
||||
unknownContainerTypeCount:
|
||||
type: integer
|
||||
format: int64
|
||||
minimum: 0
|
||||
description: 未计入 TEU 的未知箱型在途柜数
|
||||
pendingCustomsOrderCount:
|
||||
type: integer
|
||||
format: int64
|
||||
minimum: 0
|
||||
description: 已进入整柜清关且清关未完成的去重订单数
|
||||
asOf:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
description: 数据库统计时点
|
||||
@@ -1,13 +1,14 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: 一件代发国内清点组托 API
|
||||
version: 2.0.0
|
||||
description: 测量、计费后独立下发。箱码逐箱确认 SKU 实收数量;托盘码可先扫或后扫,封托后作为不可拆分柜单元。
|
||||
version: 3.0.0
|
||||
description: 测量环节录入预计托数,审核通过后预生成全部 DP 托盘码并可合并打印。PDA 支持先扫箱或先扫托,同一国内作业仓可跨票共用开放托盘,实物装满后再封托。
|
||||
paths:
|
||||
/api/platform/v1/first-leg/pda-dropship/tasks/pending:
|
||||
get:
|
||||
summary: 查询待领取或本人已领取任务
|
||||
x-required-authority: platform:firstleg:pda:view
|
||||
parameters: [{$ref: '#/components/parameters/WarehouseId'}, {name: keyword, in: query, required: false, schema: {type: string, maxLength: 64}}]
|
||||
responses: {'200': {description: OK}}
|
||||
/api/platform/v1/first-leg/pda-dropship/tasks/{taskId}:
|
||||
get:
|
||||
@@ -33,8 +34,8 @@ paths:
|
||||
responses: {'200': {description: OK}}
|
||||
/api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/cartons/count:
|
||||
post:
|
||||
summary: 逐箱确认 SKU 实收数量,可同时绑定当前托盘
|
||||
description: palletNo 为空时进入 COUNTED_UNBOUND;不为空时幂等获取或创建 OPEN 托盘并绑定。数量不一致必须 ACCEPT_VARIANCE 且填写说明。
|
||||
summary: 逐箱确认 SKU 实收数量
|
||||
description: 未传 palletNo 时进入待归托区;传入同一国内作业仓的 OPEN 托盘码时直接归托。数量不一致必须 ACCEPT_VARIANCE 且填写说明。
|
||||
x-required-authority: platform:firstleg:pda:work
|
||||
parameters: [{$ref: '#/components/parameters/TaskId'}]
|
||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/CountCartonRequest'}}}}
|
||||
@@ -46,14 +47,6 @@ paths:
|
||||
parameters: [{$ref: '#/components/parameters/TaskId'}]
|
||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/BindPendingRequest'}}}}
|
||||
responses: {'200': {description: OK}}
|
||||
/api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/pallets/create-and-bind:
|
||||
post:
|
||||
summary: 系统生成 DP 托盘码并原子绑定全部待绑箱
|
||||
description: 仅当前仓库本人已领取的待组托任务可执行。没有待绑箱时不发码、不建空托。operationId 在同任务和仓库重放不重复创建;不同任务使用同幂等号拒绝。返回当前 Task。
|
||||
x-required-authority: platform:firstleg:pda:work
|
||||
parameters: [{$ref: '#/components/parameters/TaskId'}]
|
||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/CreateAndBindRequest'}}}}
|
||||
responses: {'200': {description: Task}}
|
||||
/api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/cartons/{cartonId}/move:
|
||||
post:
|
||||
summary: 换托,targetPalletNo 为空时退回待绑区
|
||||
@@ -65,13 +58,32 @@ paths:
|
||||
responses: {'200': {description: OK}}
|
||||
/api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/pallets/{palletId}/seal:
|
||||
post:
|
||||
summary: 封托;禁止空托、待绑箱、版本冲突和重复归托
|
||||
summary: 封托;禁止空托、版本冲突和重复归托
|
||||
x-required-authority: platform:firstleg:pda:work
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/TaskId'}
|
||||
- {name: palletId, in: path, required: true, schema: {type: integer, format: int64}}
|
||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/SealPalletRequest'}}}}
|
||||
responses: {'200': {description: OK}}
|
||||
/api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/pallets/{palletId}/print:
|
||||
get:
|
||||
summary: PDA 获取预生成托盘码的 A4 托盘信息单
|
||||
description: 测量审核通过并生成托盘码后即可获取;每个托盘固定一页 A4,包含托盘号及其 Code 128 一维码、入仓码、箱数以及已归托 SKU、商品名称和实点数量,不展示作业状态。空托显示暂无已归托 SKU。仅当前仓库、当前操作员可访问。
|
||||
x-required-authority: platform:firstleg:pda:view
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/TaskId'}
|
||||
- {name: palletId, in: path, required: true, schema: {type: integer, format: int64}}
|
||||
- {$ref: '#/components/parameters/WarehouseId'}
|
||||
responses:
|
||||
'200': {description: A4 托盘信息单 PDF, content: {application/pdf: {schema: {type: string, format: binary}}}}
|
||||
/api/platform/v1/first-leg/pda-dropship/tasks/{taskId}/pallets/print:
|
||||
get:
|
||||
summary: 合并获取本票全部预生成托盘码的多页 A4 PDF
|
||||
description: 每个预生成托盘固定一页,PDA 无需展开托盘列表或逐个下载,最多按本票预计托数生成。
|
||||
x-required-authority: platform:firstleg:pda:view
|
||||
parameters: [{$ref: '#/components/parameters/TaskId'}, {$ref: '#/components/parameters/WarehouseId'}]
|
||||
responses:
|
||||
'200': {description: 多页 A4 托盘信息单 PDF, content: {application/pdf: {schema: {type: string, format: binary}}}}
|
||||
/api/platform/v1/first-leg/pda-dropship/inbound-orders/{inboundId}/pallets:
|
||||
get:
|
||||
summary: Admin 查询入仓单权威托盘结果
|
||||
@@ -79,7 +91,7 @@ paths:
|
||||
responses: {'200': {description: OK}}
|
||||
/api/platform/v1/first-leg/pda-dropship/inbound-orders/{inboundId}/pallets/{palletId}/print:
|
||||
get:
|
||||
summary: 打印托盘码、入仓码和 SKU 实收汇总
|
||||
summary: 获取包含托盘码、入仓码和 SKU 实收汇总的 A4 托盘信息单
|
||||
x-required-authority: firstleg:inbound:list
|
||||
responses: {'200': {description: PDF}}
|
||||
components:
|
||||
@@ -87,6 +99,22 @@ components:
|
||||
TaskId: {name: taskId, in: path, required: true, schema: {type: integer, format: int64}}
|
||||
WarehouseId: {name: warehouseId, in: query, required: true, schema: {type: integer, format: int64}}
|
||||
schemas:
|
||||
Task:
|
||||
type: object
|
||||
description: 一件代发清点组托任务;palletSetupReady=false 时禁止领取、解析箱码和提交清点。
|
||||
required: [taskId, totalCartons, countedCartons, boundCartons, unboundCartons, expectedPalletCount, generatedPalletCount, palletSetupReady, taskStatus, pallets]
|
||||
properties:
|
||||
taskId: {type: integer, format: int64}
|
||||
totalCartons: {type: integer, minimum: 1}
|
||||
countedCartons: {type: integer, minimum: 0}
|
||||
boundCartons: {type: integer, minimum: 0}
|
||||
unboundCartons: {type: integer, minimum: 0}
|
||||
expectedPalletCount: {type: integer, minimum: 1}
|
||||
generatedPalletCount: {type: integer, minimum: 0}
|
||||
palletSetupReady: {type: boolean}
|
||||
palletSetupMessage: {type: string, nullable: true}
|
||||
taskStatus: {type: string, enum: [PENDING, CLAIMED, COMPLETED]}
|
||||
pallets: {type: array, items: {$ref: '#/components/schemas/Pallet'}}
|
||||
Carton:
|
||||
type: object
|
||||
properties:
|
||||
@@ -94,18 +122,23 @@ components:
|
||||
boxCode: {type: string}
|
||||
inboundId: {type: integer, format: int64}
|
||||
inboundCode: {type: string}
|
||||
inboundDetailId: {type: integer, format: int64}
|
||||
skuCode: {type: string}
|
||||
cargoName: {type: string, nullable: true}
|
||||
expectedSkuQuantity: {type: integer, minimum: 1}
|
||||
actualSkuQuantity: {type: integer, minimum: 1, nullable: true}
|
||||
discrepancyStatus: {type: string, enum: [MATCH, ACCEPTED_VARIANCE], nullable: true}
|
||||
discrepancyRemark: {type: string, nullable: true}
|
||||
skuLines: {type: array, minItems: 1, items: {$ref: '#/components/schemas/CartonSkuLine'}, description: 当前物理箱内全部SKU清点行}
|
||||
countStatus: {type: string, enum: [UNCOUNTED, COUNTED_UNBOUND, BOUND]}
|
||||
palletId: {type: integer, format: int64, nullable: true}
|
||||
palletNo: {$ref: '#/components/schemas/NullablePalletNo'}
|
||||
canCount: {type: boolean}
|
||||
blockReason: {type: string, nullable: true}
|
||||
CartonSkuLine:
|
||||
type: object
|
||||
required: [inboundDetailId, skuCode, expectedQuantity]
|
||||
properties:
|
||||
inboundDetailId: {type: integer, format: int64}
|
||||
skuCode: {type: string}
|
||||
cargoName: {type: string, nullable: true}
|
||||
expectedQuantity: {type: integer, minimum: 1}
|
||||
actualQuantity: {type: integer, minimum: 0, nullable: true}
|
||||
discrepancyStatus: {type: string, enum: [MATCH, ACCEPTED_VARIANCE], nullable: true}
|
||||
discrepancyRemark: {type: string, nullable: true}
|
||||
Pallet:
|
||||
type: object
|
||||
properties:
|
||||
@@ -127,14 +160,23 @@ components:
|
||||
properties: {warehouseId: {type: integer, format: int64}}
|
||||
CountCartonRequest:
|
||||
type: object
|
||||
required: [warehouseId, boxCode, actualSkuQuantity, discrepancyAction, operationId]
|
||||
required: [warehouseId, boxCode, skuLines, operationId]
|
||||
properties:
|
||||
warehouseId: {type: integer, format: int64}
|
||||
boxCode: {type: string, maxLength: 80}
|
||||
actualSkuQuantity: {type: integer, minimum: 1}
|
||||
discrepancyAction: {type: string, enum: [MATCH, ACCEPT_VARIANCE]}
|
||||
discrepancyRemark: {type: string, maxLength: 500, nullable: true}
|
||||
palletNo: {$ref: '#/components/schemas/NullablePalletNo'}
|
||||
skuLines:
|
||||
type: array
|
||||
minItems: 1
|
||||
items:
|
||||
type: object
|
||||
required: [inboundDetailId, skuCode, actualQuantity, discrepancyAction]
|
||||
properties:
|
||||
inboundDetailId: {type: integer, format: int64}
|
||||
skuCode: {type: string, maxLength: 128}
|
||||
actualQuantity: {type: integer, minimum: 0}
|
||||
discrepancyAction: {type: string, enum: [MATCH, ACCEPT_VARIANCE]}
|
||||
discrepancyRemark: {type: string, maxLength: 500, nullable: true}
|
||||
operationId: {type: string, maxLength: 128}
|
||||
BindPendingRequest:
|
||||
type: object
|
||||
@@ -158,10 +200,4 @@ components:
|
||||
expectedVersion: {type: integer, minimum: 1}
|
||||
operationId: {type: string, maxLength: 128}
|
||||
PalletNo: {type: string, pattern: '^DP-[0-9]{6}-[0-9]{4}$'}
|
||||
CreateAndBindRequest:
|
||||
type: object
|
||||
required: [warehouseId, operationId]
|
||||
properties:
|
||||
warehouseId: {type: integer, format: int64, minimum: 1}
|
||||
operationId: {type: string, minLength: 1, maxLength: 128}
|
||||
NullablePalletNo: {type: string, pattern: '^DP-[0-9]{6}-[0-9]{4}$', nullable: true}
|
||||
|
||||
@@ -112,6 +112,13 @@ paths:
|
||||
parameters: [{$ref: '#/components/parameters/VersionId'}]
|
||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/PriceBookConfirmationRequest'}}}}
|
||||
responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}}
|
||||
/api/platform/v1/fba-pricing/carrier-rates/price-books/{versionId}/void:
|
||||
post:
|
||||
summary: 作废待确认船期价格表版本
|
||||
description: 仅允许作废 PENDING_CONFIRMATION 版本;保留版本、原始文件和审计记录,作废后不可再确认生效。需要 firstleg:fba-pricing:rate:edit 权限。
|
||||
parameters: [{$ref: '#/components/parameters/VersionId'}]
|
||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/PriceBookVoidRequest'}}}}
|
||||
responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}}
|
||||
/api/platform/v1/fba-pricing/carrier-rates/price-books/{versionId}/source:
|
||||
get:
|
||||
summary: 下载船期价格表原始文件
|
||||
@@ -528,7 +535,7 @@ components:
|
||||
effectiveTo: {type: string, format: date-time, nullable: true}
|
||||
PriceBookStatus:
|
||||
type: string
|
||||
enum: [PENDING_CONFIRMATION, ACTIVE, SUPERSEDED, FAILED]
|
||||
enum: [PENDING_CONFIRMATION, ACTIVE, SUPERSEDED, VOIDED, FAILED]
|
||||
PriceModel:
|
||||
type: string
|
||||
enum: [FBA_WAREHOUSE_RATE, REGION_WEIGHT_TIER]
|
||||
@@ -564,6 +571,9 @@ components:
|
||||
publishedBy: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
|
||||
publishedAt: {type: string, format: date-time, nullable: true}
|
||||
supersededAt: {type: string, format: date-time, nullable: true}
|
||||
voidedBy: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
|
||||
voidedAt: {type: string, format: date-time, nullable: true}
|
||||
voidReason: {type: string, maxLength: 500, nullable: true}
|
||||
PriceBookVersionDetail:
|
||||
type: object
|
||||
required: [version, channels, rates, issues]
|
||||
@@ -600,6 +610,11 @@ components:
|
||||
type: array
|
||||
maxItems: 2
|
||||
items: {$ref: '#/components/schemas/PriceBookOverseasMapping'}
|
||||
PriceBookVoidRequest:
|
||||
type: object
|
||||
required: [reason]
|
||||
properties:
|
||||
reason: {type: string, minLength: 1, maxLength: 500, description: 人工作废原因}
|
||||
PriceBookOriginMapping:
|
||||
type: object
|
||||
required: [originWarehouseCode, warehouseId]
|
||||
|
||||
@@ -1224,7 +1224,7 @@ paths:
|
||||
/api/platform/v1/inbounds/{inboundId}/measurements/approve:
|
||||
post:
|
||||
summary: 审核通过测量
|
||||
description: 需要 firstleg:inbound:measurement-review 权限,且 Order 权威订单必须已审核通过。订单待审核期间可保留并提交到仓测量数据,但不能审核测量。标准头程审核通过后进入 WAIT_SPLIT;一件代发进入 WAIT_PALLETIZE,完成逐箱清点和封托后再进入 WAIT_SPLIT。审核时按该次测量数据尽力生成 CALCULATED 初算费用;价格未覆盖或初算异常只返回非阻断提示,不影响审核结果和后续履约状态。
|
||||
description: 需要 firstleg:inbound:measurement-review 权限,且 Order 权威订单必须已审核通过。订单待审核期间可保留并提交到仓测量数据,但不能审核测量。审核通过前必须存在至少一张已确认的称重图片;历史异常数据若已提交但没有称重图片,必须先退回测量后补录。标准头程审核通过后进入 WAIT_SPLIT;一件代发进入 WAIT_PALLETIZE,完成逐箱清点和封托后再进入 WAIT_SPLIT。审核时按该次测量数据尽力生成 CALCULATED 初算费用;价格未覆盖或初算异常只返回非阻断提示,不影响审核结果和后续履约状态。
|
||||
parameters:
|
||||
- {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
- {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
@@ -1245,7 +1245,7 @@ paths:
|
||||
/api/platform/v1/inbounds/{inboundId}/measurements/submit:
|
||||
post:
|
||||
summary: 提交测量完成
|
||||
description: 提交只校验并复用称重图片确认阶段已经 OCCUPIED 且绑定完整的权威整票库位块,然后进入客服待审核;不会首次分配、重建或更换已显示槽位。Order 仍待审核时允许提交并冻结到仓测量证据,但不得继续审核测量或进入计费、分柜和装柜。
|
||||
description: 即使只有一箱,也必须先上传并确认至少一张称重图片。提交只校验并复用称重图片确认阶段已经 OCCUPIED 且绑定完整的权威整票库位块,然后进入客服待审核;不会首次分配、重建或更换已显示槽位。Order 仍待审核时允许提交并冻结到仓测量证据,但不得继续审核测量或进入计费、分柜和装柜。
|
||||
parameters:
|
||||
- {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
- {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
@@ -1605,6 +1605,8 @@ components:
|
||||
lxCode: {type: string, nullable: true, description: 一件代发线路或履约产品编码快照}
|
||||
channelCode: {type: string, nullable: true}
|
||||
channelName: {type: string, nullable: true}
|
||||
fulfillmentType: {type: string, nullable: true, enum: [STANDARD, ONE_CLICK_DROPSHIP]}
|
||||
expectedPalletCount: {type: integer, minimum: 1, maximum: 1000, nullable: true, description: 一件代发预计托数;测量审核通过后按此数量预生成 DP 托盘码}
|
||||
taxMode: {type: string, nullable: true}
|
||||
pricingTaxMode: {type: string, nullable: true}
|
||||
containerMode: {type: string, nullable: true}
|
||||
@@ -2000,6 +2002,7 @@ components:
|
||||
measurementId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
|
||||
version: {type: integer, minimum: 0}
|
||||
actualGoodsName: {type: string, maxLength: 255, nullable: true}
|
||||
expectedPalletCount: {type: integer, minimum: 1, maximum: 1000, nullable: true, description: 一件代发必填;普通入库可不传}
|
||||
items:
|
||||
type: array
|
||||
minItems: 1
|
||||
@@ -2174,7 +2177,7 @@ components:
|
||||
allowedActions: {type: array, items: {type: string, enum: [SELECT_FLEET, SCHEDULE_PICKUP, RECORD_PICKUP, RECORD_EMPTY_RETURN, SEND_PICKUP_EMAIL]}}
|
||||
ContainerBooking:
|
||||
type: object
|
||||
required: [id, bookingNo, cabinetNo, sourceType, recordStatus, warehouseId, warehouseName, containerType, capacityCbm, maxWeightKg, planStatus, exceptionActive, destinationClearanceDocumentStatus, customsDeclarationDocumentStatus, deliveryOrderDocumentStatus, version]
|
||||
required: [id, bookingNo, cabinetNo, sourceType, recordStatus, warehouseId, warehouseName, channelGroupCode, containerType, capacityCbm, maxWeightKg, planStatus, exceptionActive, destinationClearanceDocumentStatus, customsDeclarationDocumentStatus, deliveryOrderDocumentStatus, version]
|
||||
properties:
|
||||
id: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
planId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 关联智能装柜计划ID;未创建计划时为空}
|
||||
@@ -2185,8 +2188,9 @@ components:
|
||||
sourceType: {type: string, enum: [SIMULATION, OCEAN_BOOKING], description: SIMULATION 为内部模拟柜;OCEAN_BOOKING 为订舱登记直接生成的真实柜}
|
||||
recordStatus: {type: string, enum: [ACTIVE, MERGED], description: 正常列表和详情仅返回 ACTIVE;真实订舱柜被模拟柜消费后变为 MERGED 且不再作为可操作柜返回}
|
||||
mergedIntoContainerBookingId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: MERGED 源真实柜指向保留的模拟柜稳定 containerBookingId;ACTIVE 时为空}
|
||||
channelCode: {type: string, maxLength: 64, nullable: true, description: Missing legacy snapshots are blocked from loading until explicitly backfilled}
|
||||
channelName: {type: string, maxLength: 128, nullable: true}
|
||||
channelGroupCode: {type: string, maxLength: 64, enum: [MATSON_CLX, MATSON_MAX, EXX, HEDE, OA_LA, OA_CHICAGO, OA_HOUSTON, OA_SAVANNAH, OA_NEW_YORK_TRUCK], description: 模拟柜创建时确定的九类计划渠道分组,关联真实订舱后继续保留}
|
||||
channelCode: {type: string, maxLength: 64, nullable: true, description: 关联真实订舱后冻结的实际物流渠道编码}
|
||||
channelName: {type: string, maxLength: 128, nullable: true, description: 关联真实订舱后冻结的实际物流渠道名称}
|
||||
oceanBookingId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
|
||||
oceanBookingRequirementId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
|
||||
warehouseId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
@@ -2444,8 +2448,8 @@ components:
|
||||
pages: {type: string, pattern: '^[0-9]+$'}
|
||||
SmartLoadingWorkbenchContainer:
|
||||
type: object
|
||||
required: [bookingId, bookingNo, cabinetNo, warehouseId, warehouseName, channelCode,
|
||||
channelName, containerType, capacityCbm, maxWeightKg, planStatus, version]
|
||||
required: [bookingId, bookingNo, cabinetNo, warehouseId, warehouseName, channelGroupCode,
|
||||
containerType, capacityCbm, maxWeightKg, planStatus, version]
|
||||
properties:
|
||||
bookingId: {type: string, pattern: '^[1-9][0-9]*$', description: containerBookingId 技术关联}
|
||||
bookingNo: {type: string, description: 货柜登记业务号}
|
||||
@@ -2453,8 +2457,9 @@ components:
|
||||
containerNo: {type: string, nullable: true, description: 真实集装箱号,允许在装柜前后补}
|
||||
warehouseId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
warehouseName: {type: string}
|
||||
channelCode: {type: string}
|
||||
channelName: {type: string}
|
||||
channelGroupCode: {type: string, enum: [MATSON_CLX, MATSON_MAX, EXX, HEDE, OA_LA, OA_CHICAGO, OA_HOUSTON, OA_SAVANNAH, OA_NEW_YORK_TRUCK], description: 智能装柜匹配使用的计划渠道分组}
|
||||
channelCode: {type: string, nullable: true, description: 关联真实订舱后冻结的实际物流渠道编码}
|
||||
channelName: {type: string, nullable: true, description: 关联真实订舱后冻结的实际物流渠道名称}
|
||||
containerType: {type: string}
|
||||
capacityCbm: {type: number, nullable: true, description: 柜型基础容积;装柜上限按正式规则计算}
|
||||
maxWeightKg: {type: number, nullable: true, description: 柜型基础载重;装柜上限按正式规则计算}
|
||||
@@ -2706,9 +2711,9 @@ components:
|
||||
reason: {type: string, maxLength: 500}
|
||||
ContainerSimulationCreateRequest:
|
||||
type: object
|
||||
required: [channelCode, warehouseId, containerType, capacityCbm, maxWeightKg]
|
||||
required: [channelGroupCode, warehouseId, containerType, capacityCbm, maxWeightKg]
|
||||
properties:
|
||||
channelCode: {type: string, maxLength: 64, description: 当前 ACTIVE 价格簿中的真实物流渠道}
|
||||
channelGroupCode: {type: string, maxLength: 64, enum: [MATSON_CLX, MATSON_MAX, EXX, HEDE, OA_LA, OA_CHICAGO, OA_HOUSTON, OA_SAVANNAH, OA_NEW_YORK_TRUCK], description: 智能装柜使用的九类计划渠道分组}
|
||||
warehouseId: {type: integer, format: int64, minimum: 1}
|
||||
containerType: {type: string, enum: [20GP, 40GP, 40HC, 40NR, 45HC], description: 与订舱共用的平台固定柜型}
|
||||
capacityCbm: {type: number, minimum: 0, exclusiveMinimum: true, description: 必须等于柜型固定容量}
|
||||
|
||||
@@ -89,7 +89,7 @@ paths:
|
||||
/api/platform/v1/ocean-bookings/{oceanBookingId}/prints/shipping-instruction:
|
||||
get:
|
||||
summary: Render the current saved booking as an export shipping instruction PDF
|
||||
description: Firstleg maps the authoritative saved aggregate (DRAFT or RELEASED, with missing fields displayed as —) to SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1 and synchronously proxies the generated PDF.
|
||||
description: Firstleg maps the authoritative saved aggregate (DRAFT or RELEASED, with missing fields displayed as —) to SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1 and synchronously proxies the generated PDF. Container requirements are not treated as actual cargo quantity, weight, volume, marks, packaging, or goods descriptions.
|
||||
x-permission: platform:firstleg:ocean-booking:list
|
||||
parameters: [{$ref: '#/components/parameters/OceanBookingId'}]
|
||||
responses:
|
||||
@@ -97,7 +97,7 @@ paths:
|
||||
/api/platform/v1/ocean-bookings/{oceanBookingId}/prints/bill-of-lading-confirmation:
|
||||
get:
|
||||
summary: Render the current saved booking as a bill-of-lading confirmation PDF
|
||||
description: Firstleg maps the authoritative saved aggregate (DRAFT or RELEASED, with missing fields displayed as —) to BILL_OF_LADING_CONFIRMATION_A4_V1 and synchronously proxies the generated PDF.
|
||||
description: Firstleg maps the authoritative saved aggregate (DRAFT or RELEASED, with missing fields displayed as —) to BILL_OF_LADING_CONFIRMATION_A4_V1 and synchronously proxies the generated PDF. Container requirements remain a container summary and are not treated as actual cargo facts.
|
||||
x-permission: platform:firstleg:ocean-booking:list
|
||||
parameters: [{$ref: '#/components/parameters/OceanBookingId'}]
|
||||
responses:
|
||||
@@ -107,7 +107,8 @@ paths:
|
||||
summary: Page released booking requirement candidates for one container
|
||||
description: >-
|
||||
Requires platform:firstleg:ocean-booking:list. Returns only linkable requirement candidates:
|
||||
RELEASED, remainingQuantity greater than zero, and exact warehouseId, channelCode and containerType.
|
||||
RELEASED, remainingQuantity greater than zero, exact warehouseId and containerType, and a concrete
|
||||
booking channel belonging to channelGroupCode.
|
||||
Capacity and max weight are derived display snapshots and never eligibility fields. Allocated quantity
|
||||
counts only ACTIVE containers still pointing to the same booking requirement and is calculated by one
|
||||
bounded set query; booking detail calls are not used. An empty result is a normal successful page.
|
||||
@@ -115,7 +116,7 @@ paths:
|
||||
x-permission: platform:firstleg:ocean-booking:list
|
||||
parameters:
|
||||
- {name: warehouseId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}}
|
||||
- {name: channelCode, in: query, required: true, schema: {type: string, maxLength: 64}}
|
||||
- {name: channelGroupCode, in: query, required: true, schema: {type: string, maxLength: 64}}
|
||||
- {name: containerType, in: query, required: true, schema: {type: string, maxLength: 32}}
|
||||
- {name: page, in: query, required: false, schema: {type: integer, format: int64, minimum: 1, default: 1}}
|
||||
- {name: size, in: query, required: false, schema: {type: integer, format: int64, minimum: 1, maximum: 200, default: 20}}
|
||||
|
||||
@@ -39,10 +39,10 @@ paths:
|
||||
/api/platform/v1/containers/{id}/documents/{documentType}/upload:
|
||||
post:
|
||||
operationId: uploadContainerDocument
|
||||
description: '权限:platform:firstleg:terminal-drayage:document:edit;仅国外工作台DO,无完成动作'
|
||||
description: '权限:platform:firstleg:terminal-drayage:document:edit;仅已进入海外履约且未还柜的非海铁渠道可上传DO,无完成动作'
|
||||
parameters: [{$ref: '#/components/parameters/ContainerId'}, {$ref: '#/components/parameters/DocumentType'}, {name: Idempotency-Key, in: header, required: true, schema: {type: string}}]
|
||||
requestBody: {required: true, content: {multipart/form-data: {schema: {type: object, required: [file, expectedVersion], properties: {file: {type: string, format: binary}, expectedVersion: {type: integer, minimum: 0}, operationReason: {type: string, maxLength: 500}}}}}}
|
||||
responses: {'200': {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/DocumentEnvelope'}}}}}
|
||||
responses: {'200': {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/DocumentEnvelope'}}}}, '409': {description: '尚未进入海外履约、海铁渠道不适用DO或货柜已还柜'}}
|
||||
/api/platform/v1/containers/{id}/documents/{documentType}/{fileId}/download:
|
||||
get:
|
||||
operationId: downloadContainerDocument
|
||||
|
||||
@@ -3,6 +3,17 @@ info:
|
||||
title: Phase 3 Container PDA Picking API
|
||||
version: 1.0.0
|
||||
paths:
|
||||
/api/platform/v1/first-leg/pda-loading/warehouses:
|
||||
get:
|
||||
summary: List active domestic warehouses available to the PDA operator
|
||||
responses:
|
||||
'200':
|
||||
description: Warehouse master-data options used to scope PDA pending-task lists
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items: {$ref: '#/components/schemas/WarehouseOption'}
|
||||
/api/platform/v1/first-leg/pda-loading/tasks:
|
||||
get:
|
||||
summary: Find the active container picking task in one platform warehouse
|
||||
@@ -18,6 +29,7 @@ paths:
|
||||
get:
|
||||
summary: List active tasks available to the authenticated PDA operator
|
||||
parameters:
|
||||
- {name: warehouseId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}}
|
||||
- {name: keyword, in: query, required: false, schema: {type: string, maxLength: 64}}
|
||||
responses:
|
||||
'200':
|
||||
@@ -175,6 +187,13 @@ components:
|
||||
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}}
|
||||
schemas:
|
||||
WarehouseOption:
|
||||
type: object
|
||||
required: [id, warehouseCode, warehouseName]
|
||||
properties:
|
||||
id: {type: integer, format: int64}
|
||||
warehouseCode: {type: string}
|
||||
warehouseName: {type: string}
|
||||
Task:
|
||||
type: object
|
||||
required: [id, loadingPlanId, containerBookingId, inboundId, inboundVersion, loadingVersion, cabinetNo, sourceType, recordStatus, taskStatus, workflowStatus, version, items]
|
||||
|
||||
+5
-1
@@ -27,7 +27,11 @@ class InboundMeasurementEvidenceOpenApiContractTest {
|
||||
Map<String, Object> submitOperation = stringMap(stringMap(paths.get(
|
||||
"/api/platform/v1/inbounds/{inboundId}/measurements/submit")).get("post"));
|
||||
assertThat(submitOperation.get("description").toString())
|
||||
.contains("只校验并复用", "不会首次分配");
|
||||
.contains("即使只有一箱", "上传并确认至少一张称重图片", "只校验并复用", "不会首次分配");
|
||||
Map<String, Object> approveOperation = stringMap(stringMap(paths.get(
|
||||
"/api/platform/v1/inbounds/{inboundId}/measurements/approve")).get("post"));
|
||||
assertThat(approveOperation.get("description").toString())
|
||||
.contains("已确认的称重图片", "历史异常数据", "先退回测量");
|
||||
|
||||
Map<String, Object> schemas = stringMap(stringMap(document.get("components")).get("schemas"));
|
||||
Map<String, Object> detailProperties = properties(schemas, "InboundMeasurementDetail");
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user