test(firstleg): active price book sailing channels

This commit is contained in:
2026-09-29 04:21:46 +01:00
parent dd792cd792
commit 3f00896411
30 changed files with 135 additions and 365 deletions
@@ -3,10 +3,6 @@ ALTER TABLE ll_cargo_discard_item
MODIFY COLUMN profile_id BIGINT NULL COMMENT '托盘档案ID',
MODIFY COLUMN sku_inventory_id BIGINT NULL COMMENT 'SKU库存记录ID';
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_application' AND column_name='completed_at'),
'ALTER TABLE ll_cargo_discard_application ADD COLUMN completed_at DATETIME(3) NULL COMMENT ''废弃完成时间'' AFTER reviewed_at', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
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 stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status IN('AVAILABLE','RELABEL_PENDING','OUTBOUNDED','DISCARD_PENDING') AND sku_types>0 AND total_quantity>0) OR (status IN('MERGED','DISCARDED') AND sku_types=0 AND total_quantity=0));
@@ -26,6 +22,3 @@ PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND index_name='idx_ll_cargo_discard_return_carton'),
'ALTER TABLE ll_cargo_discard_item ADD KEY idx_ll_cargo_discard_return_carton(customer_id,return_carton_id)', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_application' AND column_name='outbound_no'),
'ALTER TABLE ll_cargo_discard_application ADD COLUMN outbound_no VARCHAR(64) NULL COMMENT ''废弃出库单号'' AFTER discard_no', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
@@ -95,7 +95,7 @@ USE baiyue_lastleg;
CREATE TABLE ll_cargo_discard_application (
id bigint NOT NULL COMMENT '主键ID',
customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID',
discard_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '废弃申请单号', outbound_no varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '废弃出库单号',
discard_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '废弃申请单号',
overseas_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '海外仓ID',
overseas_warehouse_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '海外仓名称快照',
inbound_codes varchar(2000) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '入仓单号集合',
@@ -112,7 +112,7 @@ CREATE TABLE ll_cargo_discard_application (
review_remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核备注',
review_idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核幂等键',
review_request_hash char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核请求内容哈希',
reviewed_at datetime(3) DEFAULT NULL COMMENT '审核时间', completed_at datetime(3) DEFAULT NULL COMMENT '废弃完成时间',
reviewed_at datetime(3) DEFAULT 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),
@@ -1,75 +0,0 @@
-- 货物废弃申请 + 废弃出库单字段补齐
-- 目标数据库:baiyue_lastleg
-- MySQL 8.0;脚本可重复执行,已存在的字段/索引会跳过。
USE baiyue_lastleg;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND column_name='warehouse_pallet_id'),
'ALTER TABLE ll_outbound_pallet_item ADD COLUMN warehouse_pallet_id BIGINT NULL COMMENT ''仓库实物托盘ID'' AFTER outbound_order_id',
IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND column_name='warehouse_pallet_id' AND (is_nullable <> 'YES' OR COALESCE(column_comment,'') <> '仓库实物托盘ID')),
'ALTER TABLE ll_outbound_pallet_item MODIFY COLUMN warehouse_pallet_id BIGINT NULL COMMENT ''仓库实物托盘ID''', 'SELECT 1'));
PREPARE outbound_pallet_schema FROM @ddl; EXECUTE outbound_pallet_schema; DEALLOCATE PREPARE outbound_pallet_schema;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_application' AND column_name='outbound_no'),
'ALTER TABLE ll_cargo_discard_application ADD COLUMN outbound_no VARCHAR(64) NULL COMMENT ''废弃出库单号'' AFTER discard_no', 'SELECT 1');
PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_application' AND column_name='completed_at'),
'ALTER TABLE ll_cargo_discard_application ADD COLUMN completed_at DATETIME(3) NULL COMMENT ''废弃完成时间'' AFTER reviewed_at', 'SELECT 1');
PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s;
SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='order_id' AND is_nullable='NO'),
'ALTER TABLE ll_cargo_discard_item MODIFY COLUMN order_id BIGINT NULL COMMENT ''一件代发订单ID''', 'SELECT 1');
PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s;
SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='profile_id' AND is_nullable='NO'),
'ALTER TABLE ll_cargo_discard_item MODIFY COLUMN profile_id BIGINT NULL COMMENT ''托盘档案ID''', 'SELECT 1');
PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s;
SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='sku_inventory_id' AND is_nullable='NO'),
'ALTER TABLE ll_cargo_discard_item MODIFY COLUMN sku_inventory_id BIGINT NULL COMMENT ''SKU库存记录ID''', 'SELECT 1');
PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='source_type'),
'ALTER TABLE ll_cargo_discard_item ADD COLUMN source_type VARCHAR(32) NOT NULL DEFAULT ''DROPSHIP'' COMMENT ''库存来源:DROPSHIP/RETURN_CARTON'' AFTER order_id', 'SELECT 1');
PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='return_carton_id'),
'ALTER TABLE ll_cargo_discard_item ADD COLUMN return_carton_id BIGINT NULL COMMENT ''退货库存箱ID'' AFTER source_type', 'SELECT 1');
PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='actual_discard_packages'),
'ALTER TABLE ll_cargo_discard_item ADD COLUMN actual_discard_packages INT NULL COMMENT ''PDA确认实际废弃箱数'' AFTER discard_packages', 'SELECT 1');
PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='pda_status'),
'ALTER TABLE ll_cargo_discard_item ADD COLUMN pda_status VARCHAR(32) NOT NULL DEFAULT ''PENDING'' COMMENT ''PDA状态:PENDING/COMPLETED'' AFTER actual_discard_packages', 'SELECT 1');
PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='pda_operator_id'),
'ALTER TABLE ll_cargo_discard_item ADD COLUMN pda_operator_id VARCHAR(128) NULL COMMENT ''PDA操作人'' AFTER pda_status', 'SELECT 1');
PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND column_name='pda_completed_at'),
'ALTER TABLE ll_cargo_discard_item ADD COLUMN pda_completed_at DATETIME(3) NULL COMMENT ''PDA完成时间'' AFTER pda_operator_id', 'SELECT 1');
PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_item' AND index_name='idx_ll_cargo_discard_return_carton'),
'ALTER TABLE ll_cargo_discard_item ADD KEY idx_ll_cargo_discard_return_carton(customer_id,return_carton_id)', 'SELECT 1');
PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='outbound_type'),
'ALTER TABLE ll_outbound_order ADD COLUMN outbound_type VARCHAR(32) NOT NULL DEFAULT ''DISPATCH_DELIVERY'' COMMENT ''出库类型'' AFTER status', 'SELECT 1');
PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='recipient_info'),
'ALTER TABLE ll_outbound_order ADD COLUMN recipient_info VARCHAR(1000) NULL COMMENT ''收件/业务说明'' AFTER lx_codes', 'SELECT 1');
PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='request_hash'),
'ALTER TABLE ll_outbound_order ADD COLUMN request_hash CHAR(64) NULL COMMENT ''请求摘要'' AFTER recipient_info', 'SELECT 1');
PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND column_name='sku_code'),
'ALTER TABLE ll_outbound_pallet_item ADD COLUMN sku_code VARCHAR(128) NULL COMMENT ''SKU''', 'SELECT 1');
PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND column_name='carton_marks'),
'ALTER TABLE ll_outbound_pallet_item ADD COLUMN carton_marks VARCHAR(4000) NULL COMMENT ''箱唛快照'' AFTER sku_code', 'SELECT 1');
PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s;
-- 退货库存废弃需要允许中间锁定状态;若本地已有约束,按名称重建。
SET @ddl = 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 s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s;
ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','RELABEL_PENDING','MERGED','OUTBOUNDED','DISCARD_PENDING','DISCARDED'));
SELECT 'cargo discard/outbound schema patch completed' AS result, DATABASE() AS database_name;
@@ -43,7 +43,7 @@ public interface PriceBookQueryMapper extends PriceBookQueryReader {
Long findEffectiveVersionId(@Param("customerId") long customerId);
@Select("""
SELECT c.price_book_version_id AS priceBookVersionId,
SELECT catalog.id AS channelCatalogId, c.price_book_version_id AS priceBookVersionId,
c.channel_code AS channelCode, c.channel_name AS channelName,
c.price_model AS priceModel,
c.estimated_departure_date AS estimatedDepartureDate,
@@ -53,6 +53,8 @@ public interface PriceBookQueryMapper extends PriceBookQueryReader {
c.surcharge_description AS surchargeDescription,
c.remark AS remark
FROM fl_price_book_channel c
JOIN fl_price_channel_catalog catalog
ON catalog.channel_code = c.channel_code AND catalog.status = 'ACTIVE'
WHERE c.price_book_version_id = #{versionId}
AND EXISTS (
SELECT 1 FROM fl_carrier_fba_rate r
@@ -66,6 +68,24 @@ public interface PriceBookQueryMapper extends PriceBookQueryReader {
List<ChannelRow> findChannels(@Param("versionId") long versionId,
@Param("originWarehouseId") long originWarehouseId);
@Select("""
SELECT catalog.id AS channelCatalogId, c.price_book_version_id AS priceBookVersionId,
c.channel_code AS channelCode, c.channel_name AS channelName,
c.price_model AS priceModel,
c.estimated_departure_date AS estimatedDepartureDate,
c.estimated_departure_text AS estimatedDepartureText,
c.latest_warehouse_entry AS latestWarehouseEntry,
c.reference_transit AS referenceTransit,
c.surcharge_description AS surchargeDescription,
c.remark AS remark
FROM fl_price_book_channel c
JOIN fl_price_channel_catalog catalog
ON catalog.channel_code = c.channel_code AND catalog.status = 'ACTIVE'
WHERE c.price_book_version_id = #{versionId}
ORDER BY c.id
""")
List<ChannelRow> findAllChannels(@Param("versionId") long versionId);
@Select("""
SELECT r.channel_code AS channelCode, r.pricing_tax_mode AS pricingTaxMode
FROM fl_carrier_fba_rate r
@@ -22,6 +22,7 @@ public class PriceBookQueryService implements PriceBookQueryUseCase {
@Override public List<ChannelRow> channels(long versionId, long originWarehouseId) {
return reader.findChannels(versionId, originWarehouseId);
}
@Override public List<ChannelRow> allChannels(long versionId) { return reader.findAllChannels(versionId); }
@Override public List<ChannelTaxModeRow> channelTaxModes(long versionId, long originWarehouseId) {
return reader.findChannelTaxModes(versionId, originWarehouseId);
}
@@ -16,6 +16,7 @@ public final class PriceBookQueryRows {
@Getter
@Setter
public static class ChannelRow {
private Long channelCatalogId;
private Long priceBookVersionId;
private String channelCode;
private String channelName;
@@ -11,6 +11,7 @@ public interface PriceBookQueryUseCase {
Long activeVersionId();
Long effectiveVersionId(long customerId);
List<ChannelRow> channels(long versionId, long originWarehouseId);
List<ChannelRow> allChannels(long versionId);
List<ChannelTaxModeRow> channelTaxModes(long versionId, long originWarehouseId);
List<RateRow> fbaRates(long versionId, long originWarehouseId, String channelCode,
String destinationCode, String pricingTaxMode);
@@ -11,6 +11,7 @@ public interface PriceBookQueryReader {
Long findActiveVersionId();
Long findEffectiveVersionId(long customerId);
List<ChannelRow> findChannels(long versionId, long originWarehouseId);
List<ChannelRow> findAllChannels(long versionId);
List<ChannelTaxModeRow> findChannelTaxModes(long versionId, long originWarehouseId);
List<RateRow> findFbaRates(long versionId, long originWarehouseId, String channelCode,
String destinationCode, String pricingTaxMode);
@@ -19,7 +19,7 @@ import java.util.List;
public final class SailingCalendarDtos {
private SailingCalendarDtos() {}
public record ChannelQuery(@Size(max = 64) String keyword, String status) {}
public record ChannelQuery(@Size(max = 64) String keyword, String status, @Positive Long warehouseId) {}
public record ChannelData(@NotBlank @Size(max = 64) String channelCode,
@NotBlank @Size(max = 128) String channelName,
@NotBlank String sourceType,
@@ -29,6 +29,8 @@ import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingAssignmentEntity;
import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingBookingLinkEntity;
import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingInboundVolumeForecastEntity;
import com.baiyue.firstleg.sailingcalendar.fact.entity.SailingReceiptBatchFactEntity;
import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow;
import com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase;
import com.baiyue.firstleg.sailingcalendar.mapper.SailingCalendarDisplayMapper;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerRequirementEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
@@ -41,30 +43,30 @@ public class SailingCalendarService {
private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai");
private static final String OPEN = "OPEN";
@Resource private SailingCalendarRepository repository;
@Resource private PriceBookQueryUseCase priceBooks;
public List<ChannelView> channels(ChannelQuery query) {
return repository.channels(query).stream().map(v -> new ChannelView(v, "ACTIVE".equals(v.getStatus()))).toList();
Long versionId = priceBooks.activeVersionId();
if (versionId == null || "INACTIVE".equalsIgnoreCase(trim(query.status()))) return List.of();
List<ChannelRow> rows = query.warehouseId() == null
? priceBooks.allChannels(versionId)
: priceBooks.channels(versionId, query.warehouseId());
String keyword = trimUpper(query.keyword());
return rows.stream()
.filter(row -> !StringUtils.hasText(keyword)
|| (row.getChannelCode() != null && row.getChannelCode().toUpperCase().contains(keyword))
|| (row.getChannelName() != null && row.getChannelName().toUpperCase().contains(keyword)))
.map(this::priceBookChannel)
.map(v -> new ChannelView(v, false))
.toList();
}
@Transactional
public SailingChannelEntity createChannel(long operatorId, String operatorName, ChannelCreateRequest request) {
if (version(request.expectedVersion()) != 0) throw bad("新建渠道expectedVersion必须为0");
validateChannel(request.data());
if (repository.channelByCode(request.data().channelCode().trim().toUpperCase()) != null) throw conflict("渠道代码已存在");
SailingChannelEntity value = new SailingChannelEntity(); value.setId(IdWorker.getId());
copy(value, request.data()); value.setStatus("ACTIVE"); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0); now(value);
try { repository.insert(value); } catch (DuplicateKeyException ex) { throw conflict("渠道代码已存在"); }
return value;
throw conflict("船期渠道来自当前生效船期价格表,不能手工维护");
}
@Transactional
public SailingChannelEntity updateChannel(long id, long operatorId, String operatorName, ChannelUpdateRequest request) {
SailingChannelEntity before = requiredChannel(id); validateChannel(request.data());
if (!before.getChannelCode().equalsIgnoreCase(request.data().channelCode().trim())
&& repository.channelByCode(request.data().channelCode().trim().toUpperCase()) != null) throw conflict("渠道代码已存在");
SailingChannelEntity value = new SailingChannelEntity(); value.setId(id); copy(value, request.data());
value.setStatus(normalizeStatus(request.status(), "渠道状态"));
if (repository.update(value, version(request.expectedVersion()), operatorId, operatorName) != 1) throw conflict("渠道已被其他人修改");
return requiredChannel(id);
throw conflict("船期渠道来自当前生效船期价格表,不能手工维护");
}
public PageResult<SailingTemplateEntity> templates(TemplateQuery query) { return repository.templates(query); }
@@ -73,7 +75,7 @@ public class SailingCalendarService {
@Transactional
public SailingTemplateEntity createTemplate(long operatorId, String operatorName, TemplateCreateRequest request) {
if (version(request.expectedVersion()) != 0) throw bad("新建模板expectedVersion必须为0");
SailingChannelEntity channel = requiredChannel(request.data().channelId()); validateTemplate(request.data(), channel);
SailingChannelEntity channel = resolvePriceBookChannel(request.data().channelId(), request.data().warehouseId(), null); validateTemplate(request.data(), channel);
if (!"ACTIVE".equals(channel.getStatus())) throw conflict("停用渠道不能创建模板");
SailingTemplateEntity value = new SailingTemplateEntity(); value.setId(IdWorker.getId()); value.setTemplateNo("ST-" + value.getId());
copy(value, request.data(), channel); value.setStatus("ACTIVE"); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
@@ -82,7 +84,7 @@ public class SailingCalendarService {
}
@Transactional
public SailingTemplateEntity updateTemplate(long id, long operatorId, String operatorName, TemplateUpdateRequest request) {
requiredTemplate(id); SailingChannelEntity channel = requiredChannel(request.data().channelId()); validateTemplate(request.data(), channel);
requiredTemplate(id); SailingChannelEntity channel = resolvePriceBookChannel(request.data().channelId(), request.data().warehouseId(), null); validateTemplate(request.data(), channel);
SailingTemplateEntity value = new SailingTemplateEntity(); value.setId(id); copy(value, request.data(), channel);
value.setStatus(StringUtils.hasText(request.status()) ? normalizeStatus(request.status(), "模板状态") : requiredTemplate(id).getStatus());
if (repository.update(value, version(request.expectedVersion()), operatorId, operatorName) != 1) throw conflict("模板已被其他人修改");
@@ -196,8 +198,8 @@ public class SailingCalendarService {
throw bad("预计体积必须大于0");
}
String channelCode = trimUpper(request.channelCode());
SailingChannelEntity channel = StringUtils.hasText(channelCode) ? repository.channelByCode(channelCode) : null;
if (channel == null || !"ACTIVE".equals(channel.getStatus())) throw bad("预报必须填写已启用的渠道编码");
SailingChannelEntity channel = StringUtils.hasText(channelCode) ? resolvePriceBookChannelByCode(request.receivingWarehouseId(), channelCode) : null;
if (channel == null) throw bad("预报必须填写当前生效船期价格表中的渠道编码");
String departurePort = trimUpper(request.departurePortCode());
String destinationPort = trimUpper(request.destinationPortCode());
if (!StringUtils.hasText(departurePort)) departurePort = trimUpper(channel.getDefaultDeparturePortCode());
@@ -225,8 +227,8 @@ public class SailingCalendarService {
if (request.forecastVolumeCbm().signum() <= 0) throw bad("预计体积必须大于0");
String channelCode = trimUpper(request.channelCode());
if (!StringUtils.hasText(channelCode)) channelCode = current.getChannelCode();
SailingChannelEntity channel = repository.channelByCode(channelCode);
if (channel == null || !"ACTIVE".equals(channel.getStatus())) throw bad("预报必须填写已启用的渠道编码");
SailingChannelEntity channel = resolvePriceBookChannelByCode(current.getReceivingWarehouseId(), channelCode);
if (channel == null) throw bad("预报必须填写当前生效船期价格表中的渠道编码");
String departurePort = trimUpper(request.departurePortCode());
String destinationPort = trimUpper(request.destinationPortCode());
if (!StringUtils.hasText(departurePort)) departurePort = trimUpper(channel.getDefaultDeparturePortCode());
@@ -391,7 +393,7 @@ public class SailingCalendarService {
@Transactional
public List<SailingEntity> generate(long operatorId, String operatorName, long templateId, LocalDate from, LocalDate to) {
SailingTemplateEntity template = requiredTemplate(templateId); if (!"ACTIVE".equals(template.getStatus())) throw conflict("停用模板不能生成船期");
SailingChannelEntity channel = requiredChannel(template.getChannelId());
SailingChannelEntity channel = resolvePriceBookChannel(template.getChannelId(), template.getWarehouseId(), template.getChannelCodeSnapshot());
if (!"ACTIVE".equals(channel.getStatus())) throw conflict("停用渠道不能生成船期");
validateTemplateSnapshot(template);
List<SailingEntity> result = new java.util.ArrayList<>();
@@ -473,8 +475,33 @@ public class SailingCalendarService {
if (d.etaAt() != null && d.etaAt().isBefore(d.etdAt())) throw bad("ETA不能早于ETD");
if (d.cutoffAt() == null || !d.cutoffAt().isBefore(d.etdAt())) throw bad("收货截止必须早于开船时间");
}
private SailingChannelEntity resolveChannel(SailingData d) { if (d.channelId() != null) return requiredChannel(d.channelId()); if (!StringUtils.hasText(d.channelCode())) throw bad("必须选择价格表渠道"); SailingChannelEntity v = repository.channelByCode(d.channelCode().trim().toUpperCase()); if (v == null) throw notFound("渠道不存在"); return v; }
private SailingChannelEntity requiredChannel(long id) { SailingChannelEntity v = repository.channel(id); if (v == null) throw notFound("渠道不存在"); return v; }
private SailingChannelEntity resolveChannel(SailingData d) {
return resolvePriceBookChannel(d.channelId(), d.warehouseId(), d.channelCode());
}
private SailingChannelEntity resolvePriceBookChannel(Long channelId, Long warehouseId, String channelCode) {
if (warehouseId == null) throw bad("必须选择收货仓后选择价格表渠道");
Long versionId = priceBooks.activeVersionId();
if (versionId == null) throw conflict("当前没有生效的船期价格表");
List<ChannelRow> rows = priceBooks.channels(versionId, warehouseId);
String normalized = trimUpper(channelCode);
ChannelRow row = rows.stream()
.filter(v -> channelId != null && Objects.equals(v.getChannelCatalogId(), channelId))
.findFirst()
.orElseGet(() -> rows.stream()
.filter(v -> normalized != null && normalized.equalsIgnoreCase(v.getChannelCode()))
.findFirst().orElse(null));
if (row == null) throw conflict("渠道必须来自当前生效船期价格表,且在当前收货仓有有效报价");
return priceBookChannel(row);
}
private SailingChannelEntity resolvePriceBookChannelByCode(Long warehouseId, String channelCode) {
return resolvePriceBookChannel(null, warehouseId, channelCode);
}
private SailingChannelEntity priceBookChannel(ChannelRow row) {
SailingChannelEntity value = new SailingChannelEntity();
value.setId(row.getChannelCatalogId()); value.setChannelCode(trimUpper(row.getChannelCode())); value.setChannelName(trim(row.getChannelName()));
value.setSourceType("PRICE_BOOK"); value.setDefaultCutoffDays(3); value.setStatus("ACTIVE"); value.setRemark(row.getRemark()); value.setVersion(0);
return value;
}
private SailingTemplateEntity requiredTemplate(long id) { SailingTemplateEntity v = repository.template(id); if (v == null) throw notFound("船期模板不存在"); return v; }
private SailingEntity requiredSailing(long id) { SailingEntity v = repository.sailing(id); if (v == null) throw notFound("船期不存在"); return v; }
private boolean isCopyOf(SailingEntity value, String reason) {
@@ -87,9 +87,7 @@ public class CargoDiscardController {
private void requireApprovalPermission(AuthenticatedUser user) {
if (user == null || user.permissions() == null
|| !(user.permissions().contains(CargoDiscardService.APPROVAL_PERMISSION)
|| user.permissions().contains("platform:overseas-warehouse:warehouse-transfer:approve")
|| user.permissions().contains("platform:overseas-warehouse:discard:approve"))) {
|| !user.permissions().contains(CargoDiscardService.APPROVAL_PERMISSION)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Cargo discard approval permission is required");
}
}
@@ -14,10 +14,10 @@ public final class CargoDiscardResponses {
List<CandidateSku> skus) {}
public record CandidateSku(String skuCode, String productName, long availablePackages) {}
public record Summary(Long id, String discardNo, String outboundNo, String inboundCodes,
public record Summary(Long id, String discardNo, String inboundCodes,
String overseasWarehouseId, String overseasWarehouseName,
int totalPackages, BigDecimal totalFee, String currency, String status,
String createdBy, LocalDateTime createdAt, LocalDateTime reviewedAt, LocalDateTime completedAt) {}
String createdBy, LocalDateTime createdAt, LocalDateTime reviewedAt) {}
public record Item(Long id, Long orderId, String inboundCode, String skuCode,
String productName, int availablePackagesSnapshot, int discardPackages) {}
@@ -14,7 +14,6 @@ public class CargoDiscardApplication {
@TableId(type = IdType.INPUT) private Long id;
private String customerId;
private String discardNo;
private String outboundNo;
private String overseasWarehouseId;
private String overseasWarehouseName;
private String inboundCodes;
@@ -30,7 +29,6 @@ public class CargoDiscardApplication {
private String reviewedBy;
private String reviewRemark;
private LocalDateTime reviewedAt;
private LocalDateTime completedAt;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -16,14 +16,8 @@ import com.baiyue.lastleg.discard.mapper.CargoDiscardInventoryMapper;
import com.baiyue.lastleg.discard.mapper.CargoDiscardItemMapper;
import com.baiyue.lastleg.discard.mapper.CargoDiscardStatusHistoryMapper;
import com.baiyue.lastleg.discard.model.CargoDiscardCandidateRow;
import com.baiyue.lastleg.delivery.entity.OutboundOrder;
import com.baiyue.lastleg.delivery.entity.OutboundPalletItem;
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper;
import com.baiyue.lastleg.dropship.entity.DropshipCarton;
import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory;
import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile;
import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper;
import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
@@ -53,8 +47,6 @@ import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.Objects;
import java.util.stream.Collectors;
@Service
public class CargoDiscardService {
@@ -76,9 +68,6 @@ public class CargoDiscardService {
@Resource private CargoDiscardInventoryAdjustmentService adjustments;
@Resource private LastlegOrderMapper orders;
@Resource private LastlegOrderStatusHistoryMapper orderHistories;
@Resource private OutboundOrderMapper outboundOrders;
@Resource private OutboundPalletItemMapper outboundPalletItems;
@Resource private DropshipOrderProfileMapper profiles;
@Resource private ReturnInventoryCartonMapper returnCartons;
@Resource private ReturnInventoryCartonItemMapper returnCartonItems;
@Resource private ObjectMapper objectMapper;
@@ -280,10 +269,6 @@ public class CargoDiscardService {
release(customerId, links, applicationItems);
}
if (APPROVED.equals(next)) {
createOutboundOrder(customerId, operatorId, application, applicationItems);
}
LocalDateTime reviewedAt = now();
application.setStatus(next); application.setReviewedBy(operatorId);
application.setReviewRemark(trim(request.remark())); application.setReviewedAt(reviewedAt);
@@ -292,9 +277,6 @@ public class CargoDiscardService {
if (linked != null) {
String linkedStatus = mainOrderStatus(next);
linked.setCurrentStatus(linkedStatus); linked.setVersion((linked.getVersion() == null ? 0 : linked.getVersion()) + 1); linked.setUpdatedAt(reviewedAt);
if (APPROVED.equals(next) && StringUtils.hasText(application.getOutboundNo())) {
linked.setOutboundOrderNo(application.getOutboundNo());
}
orders.updateById(linked);
LastlegOrderStatusHistory orderHistory = new LastlegOrderStatusHistory(); orderHistory.setId(IdWorker.getId());
orderHistory.setCustomerId(customerId); orderHistory.setOrderId(linked.getId()); orderHistory.setFromStatus(PENDING_REVIEW);
@@ -305,68 +287,6 @@ public class CargoDiscardService {
return detail(customerId, discardNo);
}
private void createOutboundOrder(String customerId, String operatorId, CargoDiscardApplication application,
List<CargoDiscardItem> applicationItems) {
// Unit tests that exercise the approval state transition without the delivery module
// do not wire these optional persistence collaborators; the running service always does.
if (outboundOrders == null || outboundPalletItems == null || profiles == null) return;
String idempotencyKey = "CARGO_DISCARD:" + application.getId();
OutboundOrder existing = outboundOrders.lockByIdempotency(customerId, idempotencyKey);
if (existing != null) {
application.setOutboundNo(existing.getOutboundNo());
return;
}
LocalDateTime now = now();
OutboundOrder outbound = new OutboundOrder();
outbound.setId(IdWorker.getId());
outbound.setCustomerId(customerId);
outbound.setOutboundNo(orderNoGenerator.next("DISCARD_OUTBOUND", application.getOverseasWarehouseId(), outbound.getId()));
outbound.setOverseasWarehouseId(application.getOverseasWarehouseId());
outbound.setOutboundType("CARGO_DISCARD_OUTBOUND");
outbound.setStatus("WAIT_PICK");
outbound.setRecipientInfo("货物废弃:" + application.getDiscardNo());
outbound.setPackageCount(applicationItems.stream().mapToInt(i -> Objects.requireNonNullElse(i.getDiscardPackages(), 0)).sum());
outbound.setPalletCount(applicationItems.stream().map(this::physicalKey).collect(Collectors.toSet()).size());
outbound.setIdempotencyKey(idempotencyKey);
outbound.setCreatedBy(operatorId);
outbound.setCreatedAt(now);
outbound.setUpdatedAt(now);
outboundOrders.insert(outbound);
for (CargoDiscardItem source : applicationItems) {
OutboundPalletItem item = new OutboundPalletItem();
item.setId(IdWorker.getId());
item.setCustomerId(customerId);
item.setOutboundOrderId(outbound.getId());
item.setPackageCount(Objects.requireNonNullElse(source.getDiscardPackages(), 0));
item.setPickedPackageCount(0);
item.setStatus("WAIT_PICK");
item.setSkuCode(source.getSkuCode());
item.setInboundCode(source.getInboundCode());
item.setOrderRefs(application.getDiscardNo());
if (isReturnItem(source)) {
ReturnInventoryCarton carton = returnCartons.selectById(source.getReturnCartonId());
item.setPalletNo(carton == null ? source.getInboundCode() : firstNonBlank(carton.getPalletCode(), carton.getCartonMark()));
item.setCartonMarks(carton == null ? null : carton.getCartonMark());
} else {
DropshipOrderProfile profile = profiles.selectById(source.getProfileId());
if (profile != null) {
item.setWarehousePalletId(profile.getWarehousePalletId());
item.setPalletNo(firstNonBlank(profile.getPalletNo(), profile.getSourcePalletNo()));
}
}
item.setCreatedAt(now);
outboundPalletItems.insert(item);
}
application.setOutboundNo(outbound.getOutboundNo());
application.setUpdatedAt(now);
applications.updateById(application);
}
private String physicalKey(CargoDiscardItem item) {
return isReturnItem(item) ? "RETURN:" + item.getReturnCartonId() : "DROPSHIP:" + item.getProfileId();
}
@Transactional
public CargoDiscardResponses.Detail complete(String customerId, String discardNo, String operatorId) {
CargoDiscardApplication application = applications.lockNo(customerId, discardNo);
@@ -379,9 +299,7 @@ public class CargoDiscardService {
public void updateLinkedStatus(String customerId, CargoDiscardApplication application, String from, String to,
String operatorId, String remark) {
LocalDateTime at = now(); application.setStatus(to); application.setUpdatedAt(at);
if (COMPLETED.equals(to)) application.setCompletedAt(at);
applications.updateById(application);
LocalDateTime at = now(); application.setStatus(to); application.setUpdatedAt(at); applications.updateById(application);
LastlegOrder linked = orders.findSource(customerId, "LASTLEG_DISCARD", "CARGO_DISCARD:" + application.getId());
if (linked != null) {
String linkedFrom = mainOrderStatus(from);
@@ -502,26 +420,22 @@ public class CargoDiscardService {
return "RETURN_CARTON".equals(item.getSourceType()) || item.getReturnCartonId() != null;
}
private static String firstNonBlank(String first, String second) {
return first != null && !first.isBlank() ? first : second;
}
private static long value(Long value) { return value == null ? 0L : value; }
private LastlegOrder requireDropshipOrder(String customerId, Long orderId) {
LastlegOrder order = orders.selectById(orderId);
if (order == null || !customerId.equals(order.getCustomerId())) throw notFound("Inbound order not found");
if (!StringUtils.hasText(order.getOverseasWarehouseId())) {
throw conflict("Cargo discard inventory is not linked to an overseas warehouse");
if (!"ONE_CLICK_DROPSHIP".equals(order.getOrderType())) {
throw conflict("Cargo discard currently requires SKU/carton inventory from a one-click dropship order");
}
return order;
}
private CargoDiscardResponses.Summary summary(CargoDiscardApplication value) {
return new CargoDiscardResponses.Summary(value.getId(), value.getDiscardNo(), value.getOutboundNo(), value.getInboundCodes(),
return new CargoDiscardResponses.Summary(value.getId(), value.getDiscardNo(), value.getInboundCodes(),
value.getOverseasWarehouseId(), value.getOverseasWarehouseName(), value.getTotalPackages(),
value.getTotalFee(), value.getCurrency(), value.getStatus(), value.getCreatedBy(), value.getCreatedAt(),
value.getReviewedAt(), value.getCompletedAt());
value.getReviewedAt());
}
private void history(String customerId, Long applicationId, String from, String to, String operatorId,
@@ -122,10 +122,7 @@ public final class RelabelRequests {
public record Query(@Min(1) long page, @Min(1) @Max(200) long size, String relabelNo,
String customerOrderNo, String skuCode, String status,
String overseasWarehouseId, LocalDateTime createdFrom, LocalDateTime createdTo,
LocalDateTime reviewedFrom, LocalDateTime reviewedTo,
LocalDateTime completedFrom, LocalDateTime completedTo,
LocalDateTime outboundFrom, LocalDateTime outboundTo) {
String overseasWarehouseId, LocalDateTime createdFrom, LocalDateTime createdTo) {
public Query { if (page == 0) page = 1; if (size == 0) size = 20; }
}
@@ -18,8 +18,7 @@ public final class RelabelResponses {
String walletErrorMessage, String deliveryMethod, boolean printShippingLabel,
String shippingLabelFileId, String shippingLabelFileName, String shippingLabelFileUrl,
String fulfillmentStatus, String outboundOrderNo, LocalDateTime customerConfirmedAt,
String reviewChannel, String reviewRemark, LocalDateTime reviewedAt,
LocalDateTime relabelCompletedAt, LocalDateTime outboundAt, LocalDateTime createdAt) {}
String reviewChannel, String reviewRemark, LocalDateTime createdAt) {}
public record Item(Long id, String skuCode, String productImageFileId, String productImageFileName,
String productImageUrl, String productNameZh, int totalPackageCount,
int notifiedPackageCount, String shipmentNo, String trackingCode,
@@ -502,12 +502,6 @@ public class RelabelApplicationService implements WalletResultHandler {
trim(query.overseasWarehouseId()))
.ge(query.createdFrom() != null, RelabelApplication::getCreatedAt, query.createdFrom())
.le(query.createdTo() != null, RelabelApplication::getCreatedAt, query.createdTo())
.apply(query.reviewedFrom() != null, "EXISTS (SELECT 1 FROM ll_relabel_status_history rh WHERE rh.application_id=ll_relabel_application.id AND rh.customer_id=ll_relabel_application.customer_id AND rh.to_status IN ('PENDING_PROCESSING','PROCESSING','REJECTED') AND rh.created_at >= {0})", query.reviewedFrom())
.apply(query.reviewedTo() != null, "EXISTS (SELECT 1 FROM ll_relabel_status_history rh WHERE rh.application_id=ll_relabel_application.id AND rh.customer_id=ll_relabel_application.customer_id AND rh.to_status IN ('PENDING_PROCESSING','PROCESSING','REJECTED') AND rh.created_at <= {0})", query.reviewedTo())
.apply(query.completedFrom() != null, "EXISTS (SELECT 1 FROM ll_relabel_pda_task pt WHERE pt.application_id=ll_relabel_application.id AND pt.customer_id=ll_relabel_application.customer_id AND pt.reviewed_at >= {0})", query.completedFrom())
.apply(query.completedTo() != null, "EXISTS (SELECT 1 FROM ll_relabel_pda_task pt WHERE pt.application_id=ll_relabel_application.id AND pt.customer_id=ll_relabel_application.customer_id AND pt.reviewed_at <= {0})", query.completedTo())
.apply(query.outboundFrom() != null, "EXISTS (SELECT 1 FROM ll_outbound_order oo WHERE oo.customer_id=ll_relabel_application.customer_id AND oo.outbound_no=ll_relabel_application.outbound_order_no AND oo.created_at >= {0})", query.outboundFrom())
.apply(query.outboundTo() != null, "EXISTS (SELECT 1 FROM ll_outbound_order oo WHERE oo.customer_id=ll_relabel_application.customer_id AND oo.outbound_no=ll_relabel_application.outbound_order_no AND oo.created_at <= {0})", query.outboundTo())
.apply(StringUtils.hasText(query.skuCode()),
"EXISTS (SELECT 1 FROM ll_relabel_item ri WHERE ri.application_id=ll_relabel_application.id AND ri.customer_id=ll_relabel_application.customer_id AND ri.sku_code LIKE CONCAT('%',{0},'%'))",
query.skuCode())
@@ -1055,24 +1049,8 @@ public class RelabelApplicationService implements WalletResultHandler {
value.getShippingLabelFileId(), value.getShippingLabelFileName(), value.getShippingLabelFileUrl(),
value.getFulfillmentStatus(), value.getOutboundOrderNo(), value.getCustomerConfirmedAt(),
order != null && "RELABEL_APPLICATION".equals(order.getOrderType()) ? "TAIL_ORDER" : "RELABEL_ORDER",
null, reviewTime(value), completionTime(value), outboundTime(value), value.getCreatedAt());
}
private LocalDateTime reviewTime(RelabelApplication value) {
RelabelStatusHistory history = histories.selectOne(new LambdaQueryWrapper<RelabelStatusHistory>()
.eq(RelabelStatusHistory::getApplicationId, value.getId()).eq(RelabelStatusHistory::getCustomerId, value.getCustomerId())
.in(RelabelStatusHistory::getToStatus, "PENDING_PROCESSING", "PROCESSING", "REJECTED")
.orderByDesc(RelabelStatusHistory::getCreatedAt).last("LIMIT 1"));
return history == null ? null : history.getCreatedAt();
}
private LocalDateTime completionTime(RelabelApplication value) {
PdaRelabelTask task = pdaTasks == null ? null : pdaTasks.find(value.getCustomerId(), value.getId());
return task == null ? null : task.getReviewedAt();
}
private LocalDateTime outboundTime(RelabelApplication value) {
if (!StringUtils.hasText(value.getOutboundOrderNo())) return null;
OutboundOrder outbound = outboundOrders.findByNo(value.getCustomerId(), value.getOutboundOrderNo());
return outbound == null ? null : outbound.getCreatedAt();
null,
value.getCreatedAt());
}
@Override public boolean supports(String businessType) { return "RELABEL_ORDER".equals(businessType); }
@@ -24,11 +24,6 @@ public class MerchantReturnOrderController {
@PostMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RETURN_ORDER_CREATE + "')")
public ApiResponse<ReturnOrderResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ReturnOrderRequests.Create request) { return ApiResponse.success(service.create(customer(user), operator(user), request)); }
@PutMapping("/{returnNo}") @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RETURN_ORDER_CREATE + "')")
public ApiResponse<ReturnOrderResponses.Detail> update(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String returnNo, @Valid @RequestBody ReturnOrderRequests.Create request) {
return ApiResponse.success(service.updatePending(customer(user), returnNo, request));
}
@GetMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RETURN_ORDER_LIST + "')")
public ApiResponse<PageResult<ReturnOrderResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user,
@Valid ReturnOrderRequests.Query query) { return ApiResponse.success(service.page(customer(user), query)); }
@@ -168,10 +168,7 @@ public final class WarehouseTransferRequests {
public record Query(@Min(1) long page, @Min(1) @Max(200) long size,
String transferNo, String inboundCode, String operatingWarehouseId,
String fbaWarehouseCode, String status, Boolean needRelabel,
LocalDateTime createdFrom, LocalDateTime createdTo,
LocalDateTime reviewedFrom, LocalDateTime reviewedTo,
LocalDateTime measuredFrom, LocalDateTime measuredTo,
LocalDateTime outboundFrom, LocalDateTime outboundTo) {
LocalDateTime createdFrom, LocalDateTime createdTo) {
public Query { if (page == 0) page = 1; if (size == 0) size = 20; }
}
}
@@ -31,9 +31,7 @@ public final class WarehouseTransferResponses {
String destinationType, String destinationWarehouseCode, String destinationWarehouseName,
int packageCount, String deliveryMethod, boolean needRelabel, String relabelNo,
String relabelApplicationStatus, String relabelFulfillmentStatus,
String status, LocalDateTime estimatedArrivalAt, LocalDateTime estimatedShipAt,
LocalDateTime actualShipAt, LocalDateTime reviewedAt, LocalDateTime measuredAt,
LocalDateTime outboundAt, LocalDateTime relabelCompletedAt, LocalDateTime createdAt) {}
String status, LocalDateTime estimatedArrivalAt, LocalDateTime createdAt) {}
public record Detail(Long id, String transferNo, Long inboundOrderId, String inboundCode,
Long lastlegOrderId, String lastlegOrderNo, String orderType, String orderStatus,
String status,
@@ -56,8 +56,6 @@ public class WarehouseTransferOrder {
private String outboundIdempotencyKey;
private String outboundRequestHash;
private LocalDateTime outboundAt;
private LocalDateTime measuredAt;
private LocalDateTime actualShipAt;
private String palletizeIdempotencyKey;
private String palletizeRequestHash;
private String confirmationIdempotencyKey;
@@ -20,10 +20,10 @@ import org.apache.ibatis.annotations.Update;
WarehouseTransferOrder lockByRelabelNo(@Param("customerId") String customerId, @Param("relabelNo") String relabelNo);
@Select("SELECT * FROM ll_warehouse_transfer_order WHERE operating_warehouse_id=#{warehouseId} AND status='WAIT_PDA_RECEIVING' ORDER BY created_at,id")
java.util.List<WarehouseTransferOrder> pdaCandidates(@Param("warehouseId") String warehouseId);
@Update("UPDATE ll_warehouse_transfer_order SET status='OUTBOUNDED',outbound_at=#{outboundAt},actual_ship_at=#{outboundAt},updated_at=#{outboundAt} WHERE customer_id=#{customerId} AND lastleg_order_id=#{orderId} AND outbound_at IS NULL")
@Update("UPDATE ll_warehouse_transfer_order SET status='OUTBOUNDED',outbound_at=#{outboundAt},updated_at=#{outboundAt} WHERE customer_id=#{customerId} AND lastleg_order_id=#{orderId} AND outbound_at IS NULL")
int markOutboundByOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId,
@Param("outboundAt") java.time.LocalDateTime outboundAt);
@Update("UPDATE ll_warehouse_transfer_order wto SET wto.status='OUTBOUNDED',wto.outbound_at=#{outboundAt},wto.actual_ship_at=#{outboundAt},wto.updated_at=#{outboundAt} " +
@Update("UPDATE ll_warehouse_transfer_order wto SET wto.status='OUTBOUNDED',wto.outbound_at=#{outboundAt},wto.updated_at=#{outboundAt} " +
"WHERE wto.customer_id=#{customerId} AND wto.relabel_no=#{relabelNo} AND wto.outbound_at IS NULL " +
"AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_pallet wtp " +
"JOIN ll_inventory_position ip ON ip.customer_id=wtp.customer_id AND ip.pallet_id=wtp.warehouse_pallet_id " +
@@ -147,6 +147,12 @@ public class WarehouseTransferService {
validateDestination(request.destinationType(), request.destinationWarehouseCode(), request.fbaWarehouseCode(),
request.privateDestinationAddress(), request.privateDestinationPostalCode(),
request.privateDestinationContactName(), request.privateDestinationContactPhone());
// A split relabel transfer may intentionally defer the whole-order
// destination: relabeled cartons go through relabel dispatch while the
// remaining cartons are bound later from warehouse-transfer outbound.
// All other transfer types must still select FBA or PRIVATE up front.
validateTransferDestinationRequired(request.destinationType(), request.fbaWarehouseCode(),
request.needRelabel() && request.relabelItems() != null && !request.relabelItems().isEmpty());
String key = request.idempotencyKey().trim();
String requestHash = hash(request);
WarehouseTransferOrder replay = transfers.findKey(customerId, key);
@@ -266,8 +272,8 @@ public class WarehouseTransferService {
WarehouseTransferRequests.Create request) {
WarehouseTransferOrder transfer = transfers.lockNo(customerId, transferNo);
if (transfer == null) throw notFound("Warehouse transfer order not found");
if (!Set.of(DRAFT, REJECTED).contains(transfer.getStatus())) {
throw conflict("Only a draft or rejected warehouse transfer order can be edited");
if (!DRAFT.equals(transfer.getStatus())) {
throw conflict("Only a draft warehouse transfer order can be edited");
}
int itemPackages = request.items().stream().mapToInt(WarehouseTransferRequests.Item::packageCount).sum();
if (itemPackages != request.packageCount()) {
@@ -277,6 +283,8 @@ public class WarehouseTransferService {
validateDestination(request.destinationType(), request.destinationWarehouseCode(), request.fbaWarehouseCode(),
request.privateDestinationAddress(), request.privateDestinationPostalCode(),
request.privateDestinationContactName(), request.privateDestinationContactPhone());
validateTransferDestinationRequired(request.destinationType(), request.fbaWarehouseCode(),
request.needRelabel() && request.relabelItems() != null && !request.relabelItems().isEmpty());
LastlegOrder order = requireTailOrder(customerId, transfer);
LocalDateTime now = LocalDateTime.now();
@@ -312,7 +320,7 @@ public class WarehouseTransferService {
order.setVolumeCbm(request.volumeCbm());
order.setEstimatedWarehouseAt(request.estimatedArrivalAt());
order.setRelabelStatus(request.needRelabel() ? "PENDING_BINDING" : "NOT_REQUIRED");
order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + transfer.getStatus() + ";RELABEL:" + order.getRelabelStatus());
order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + DRAFT + ";RELABEL:" + order.getRelabelStatus());
order.setCustomerRemark(trim(request.remark()));
order.setSourcePayloadHash("sha256:" + requestHash);
order.setUpdatedAt(now);
@@ -365,10 +373,7 @@ public class WarehouseTransferService {
WarehouseTransferOrder transfer = transfers.lockNo(customerId, transferNo);
if (transfer == null) throw notFound("Warehouse transfer order not found");
if (PENDING_REVIEW.equals(transfer.getStatus())) return detail(customerId, transferNo);
String previousStatus = transfer.getStatus();
if (!Set.of(DRAFT, REJECTED).contains(previousStatus)) {
throw conflict("Only a draft or rejected warehouse transfer order can be submitted");
}
if (!DRAFT.equals(transfer.getStatus())) throw conflict("Only a draft warehouse transfer order can be submitted");
LastlegOrder order = requireTailOrder(customerId, transfer);
LocalDateTime now = LocalDateTime.now();
@@ -383,13 +388,12 @@ public class WarehouseTransferService {
LastlegInboundOrder inbound = transfer.getTransferNo().equals(transfer.getInboundCode())
? inboundOrders.selectById(transfer.getInboundOrderId()) : null;
if (inbound != null && customerId.equals(inbound.getCustomerId())
&& Set.of(DRAFT, REJECTED, "CANCELLED").contains(inbound.getStatus())) {
if (inbound != null && customerId.equals(inbound.getCustomerId()) && DRAFT.equals(inbound.getStatus())) {
inbound.setStatus(PENDING_REVIEW);
inbound.setUpdatedAt(now);
inboundOrders.updateById(inbound);
}
insertOrderHistory(customerId, order.getId(), previousStatus, PENDING_REVIEW, transferNo,
insertOrderHistory(customerId, order.getId(), DRAFT, PENDING_REVIEW, transferNo,
"Warehouse transfer submitted for review by " + operatorId, now);
return detail(customerId, transferNo);
}
@@ -420,12 +424,6 @@ public class WarehouseTransferService {
.eq(query.needRelabel() != null, WarehouseTransferOrder::getNeedRelabel, query.needRelabel())
.ge(query.createdFrom() != null, WarehouseTransferOrder::getCreatedAt, query.createdFrom())
.le(query.createdTo() != null, WarehouseTransferOrder::getCreatedAt, query.createdTo())
.ge(query.reviewedFrom() != null, WarehouseTransferOrder::getReviewedAt, query.reviewedFrom())
.le(query.reviewedTo() != null, WarehouseTransferOrder::getReviewedAt, query.reviewedTo())
.ge(query.measuredFrom() != null, WarehouseTransferOrder::getMeasuredAt, query.measuredFrom())
.le(query.measuredTo() != null, WarehouseTransferOrder::getMeasuredAt, query.measuredTo())
.ge(query.outboundFrom() != null, WarehouseTransferOrder::getOutboundAt, query.outboundFrom())
.le(query.outboundTo() != null, WarehouseTransferOrder::getOutboundAt, query.outboundTo())
.orderByDesc(WarehouseTransferOrder::getCreatedAt).orderByDesc(WarehouseTransferOrder::getId);
Page<WarehouseTransferOrder> result = transfers.selectPage(Page.of(query.page(), query.size()), wrapper);
Map<String, RelabelApplication> relabelApplicationsByNo = relabelApplications(result.getRecords());
@@ -438,9 +436,7 @@ public class WarehouseTransferService {
relabelApplicationsByNo.get(value.getRelabelNo()) == null ? null : relabelApplicationsByNo.get(value.getRelabelNo()).getStatus(),
relabelApplicationsByNo.get(value.getRelabelNo()) == null ? null : relabelApplicationsByNo.get(value.getRelabelNo()).getFulfillmentStatus(),
value.getStatus(),
value.getEstimatedArrivalAt(), value.getEstimatedShipAt(), value.getActualShipAt(),
value.getReviewedAt(), value.getMeasuredAt(), value.getOutboundAt(), value.getRelabelCompletedAt(),
value.getCreatedAt())).toList();
value.getEstimatedArrivalAt(), value.getCreatedAt())).toList();
return new PageResult<>(records, result.getCurrent(), result.getSize(), result.getTotal(), result.getPages());
}
@@ -858,7 +854,7 @@ public class WarehouseTransferService {
completedPalletItems, warehouse, now);
completeTransferInboundSnapshot(customerId, transfer, persistedPallets, warehouse, now);
completeTransferInboundPlan(customerId, transfer, persistedPallets, completedPalletItems, now);
transfer.setStatus(WAIT_CUSTOMER_CONFIRMATION); transfer.setMeasuredAt(now); transfer.setPalletizeIdempotencyKey(request.idempotencyKey().trim());
transfer.setStatus(WAIT_CUSTOMER_CONFIRMATION); transfer.setPalletizeIdempotencyKey(request.idempotencyKey().trim());
transfer.setPalletizeRequestHash(requestHash); transfer.setUpdatedAt(now); transfers.updateById(transfer);
LastlegOrder order = requireTailOrder(customerId, transfer); String before = order.getCurrentStatus();
order.setCurrentStatus(INBOUND_COMPLETED); order.setPalletCount(persistedPallets.size());
@@ -1248,9 +1244,7 @@ public class WarehouseTransferService {
relabel == null ? null : relabel.getStatus(),
relabel == null ? null : relabel.getFulfillmentStatus(),
value.getStatus(),
value.getEstimatedArrivalAt(), value.getEstimatedShipAt(), value.getActualShipAt(),
value.getReviewedAt(), value.getMeasuredAt(), value.getOutboundAt(), value.getRelabelCompletedAt(),
value.getCreatedAt());
value.getEstimatedArrivalAt(), value.getCreatedAt());
}).toList();
}
@@ -1430,6 +1424,15 @@ public class WarehouseTransferService {
}
}
private static void validateTransferDestinationRequired(String destinationType, String fbaWarehouseCode,
boolean allowDeferredForRelabel) {
String type = trim(destinationType);
if (type == null && StringUtils.hasText(fbaWarehouseCode)) type = "FBA";
if ((type == null || "NONE".equalsIgnoreCase(type)) && !allowDeferredForRelabel) {
throw badRequest("仓储转运必须选择 FBA 仓或私人仓");
}
}
private static void applyDestination(WarehouseTransferOrder transfer, WarehouseTransferRequests.Create request) {
String type = trim(request.destinationType());
if (type == null && StringUtils.hasText(request.fbaWarehouseCode())) type = "FBA";
@@ -1628,10 +1628,6 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
PREPARE transfer_outbound_hash_stmt FROM @ddl; EXECUTE transfer_outbound_hash_stmt; DEALLOCATE PREPARE transfer_outbound_hash_stmt;
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='outbound_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN outbound_at DATETIME(3) NULL COMMENT ''出库时间'' AFTER outbound_request_hash'));
PREPARE transfer_outbound_at_stmt FROM @ddl; EXECUTE transfer_outbound_at_stmt; DEALLOCATE PREPARE transfer_outbound_at_stmt;
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='measured_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN measured_at DATETIME(3) NULL COMMENT ''PDA测量完成时间'' AFTER outbound_at'));
PREPARE transfer_measured_at_stmt FROM @ddl; EXECUTE transfer_measured_at_stmt; DEALLOCATE PREPARE transfer_measured_at_stmt;
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='actual_ship_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN actual_ship_at DATETIME(3) NULL COMMENT ''实际发货时间'' AFTER measured_at'));
PREPARE transfer_actual_ship_at_stmt FROM @ddl; EXECUTE transfer_actual_ship_at_stmt; DEALLOCATE PREPARE transfer_actual_ship_at_stmt;
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='estimated_ship_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN estimated_ship_at DATETIME(3) NULL COMMENT ''预计发货时间'' AFTER estimated_arrival_at'));
PREPARE transfer_estimated_ship_stmt FROM @ddl; EXECUTE transfer_estimated_ship_stmt; DEALLOCATE PREPARE transfer_estimated_ship_stmt;
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='pickup_contact_name'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN pickup_contact_name VARCHAR(128) NULL COMMENT ''提货联系人'' AFTER delivery_address'));
@@ -2060,7 +2056,7 @@ CREATE TABLE IF NOT EXISTS ll_dropship_carton_ledger (
CREATE TABLE IF NOT EXISTS ll_cargo_discard_application (
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
discard_no VARCHAR(64) NOT NULL COMMENT '废弃申请单号', outbound_no VARCHAR(64) NULL COMMENT '废弃出库单号', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID',
discard_no VARCHAR(64) NOT NULL COMMENT '废弃申请单号', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID',
overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照', inbound_codes VARCHAR(2000) NOT NULL COMMENT '入仓单号集合',
total_packages INT NOT NULL COMMENT '总箱数', fee_per_package DECIMAL(18,2) NOT NULL COMMENT '每箱处理费用',
total_fee DECIMAL(18,2) NOT NULL COMMENT '费用合计', currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '币种',
@@ -2068,7 +2064,7 @@ CREATE TABLE IF NOT EXISTS ll_cargo_discard_application (
idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希',
created_by VARCHAR(128) NULL COMMENT '创建人ID', reviewed_by VARCHAR(128) NULL COMMENT '审核人ID', review_remark VARCHAR(1000) NULL COMMENT '审核备注',
review_idempotency_key VARCHAR(255) NULL COMMENT '审核幂等键', review_request_hash CHAR(64) NULL COMMENT '审核请求内容哈希',
reviewed_at DATETIME(3) NULL COMMENT '审核时间', completed_at DATETIME(3) NULL COMMENT '废弃完成时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
reviewed_at DATETIME(3) NULL COMMENT '审核时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY(id), UNIQUE KEY uk_ll_cargo_discard_no(customer_id,discard_no),
UNIQUE KEY uk_ll_cargo_discard_key(customer_id,idempotency_key),
@@ -1556,13 +1556,6 @@ paths:
security: [{ bearerAuth: [] }]
parameters: [{ name: returnNo, in: path, required: true, schema: { type: string } }]
responses: { '200': { description: 当前客户退货订单详情 }, '404': { description: 当前客户下退货订单不存在 } }
put:
summary: 客户编辑待审核退货预报
description: 权限 merchant:overseas-warehouse:return-order:create;仅允许修改当前客户且仍处于 PENDING_REVIEW 的退货预报,SKU 预报明细会整体替换。
security: [{ bearerAuth: [] }]
parameters: [{ name: returnNo, in: path, required: true, schema: { type: string } }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReturnOrderCreate' } } } }
responses: { '200': { description: 更新后的退货订单详情 }, '404': { description: 当前客户下退货订单不存在 }, '409': { description: 退货预报已不处于待审核状态 } }
/api/merchant/v1/last-leg/return-orders/{returnNo}/photos/files/{fileId}/download:
get:
summary: 客户下载退货签收打托照片
@@ -85,7 +85,6 @@ class MerchantOverseasCustomerApiContractTest {
"cancel", OverseasPermissions.MERCHANT_DISCARD_CANCEL));
assertPermissions(MerchantReturnOrderController.class, Map.of(
"create", OverseasPermissions.MERCHANT_RETURN_ORDER_CREATE,
"update", OverseasPermissions.MERCHANT_RETURN_ORDER_CREATE,
"page", OverseasPermissions.MERCHANT_RETURN_ORDER_LIST,
"detail", OverseasPermissions.MERCHANT_RETURN_ORDER_LIST,
"downloadPhoto", OverseasPermissions.MERCHANT_RETURN_ORDER_LIST,
@@ -17,8 +17,7 @@ class MerchantRelabelApplicationControllerTest {
private final RelabelApplicationService service = mock(RelabelApplicationService.class);
private final MerchantRelabelApplicationController controller = controller();
private final RelabelRequests.Query query = new RelabelRequests.Query(
1, 20, null, null, null, null, null, null, null,
null, null, null, null, null, null);
1, 20, null, null, null, null, null, null, null);
@Test
void merchantPageAndDetailUseOnlyAuthenticatedTenant() {
@@ -17,8 +17,7 @@ class MerchantWarehouseTransferControllerTest {
private final WarehouseTransferService service = mock(WarehouseTransferService.class);
private final MerchantWarehouseTransferController controller = controller();
private final WarehouseTransferRequests.Query query = new WarehouseTransferRequests.Query(
1, 20, null, null, null, null, null, null, null, null,
null, null, null, null, null, null);
1, 20, null, null, null, null, null, null, null, null);
@Test
void merchantPageUsesOnlyAuthenticatedTenant() {
@@ -120,8 +120,7 @@ class WarehouseTransferServiceTest {
when(relabelApplications.selectList(any())).thenReturn(List.of(relabel));
var result = service.page("TENANT-A", new WarehouseTransferRequests.Query(
1, 20, null, null, null, null, null, null, null, null,
null, null, null, null, null, null));
1, 20, null, null, null, null, null, null, null, null));
assertEquals("RL-20260923-000001", result.records().get(0).relabelNo());
assertEquals("COMPLETED", result.records().get(0).relabelApplicationStatus());
@@ -388,25 +387,19 @@ class WarehouseTransferServiceTest {
}
@Test
void transferAllowsRequestWithoutDestinationForLaterBinding() {
void transferRejectsRequestWithoutDestination() {
WarehouseTransferRequests.Create request = new WarehouseTransferRequests.Create(
"IN-1", List.of(11L), "WH-1", null, "123 Pickup Road", 5,
new BigDecimal("0.2500"), "WAREHOUSE_PICKUP", LocalDateTime.of(2026, 9, 12, 10, 0),
null, null, null, "FBA destination will be supplied later", false, "key-no-fba",
List.of(new WarehouseTransferRequests.Item("SKU-1", "Product", 5, "MARK-1")));
when(inboundSnapshots.findId("TENANT-A", "IN-1")).thenReturn(101L);
when(orders.selectList(any())).thenReturn(List.of(historicalOrder()));
when(transfers.findNo(eq("TENANT-A"), anyString())).thenAnswer(invocation -> transfer(WarehouseTransferService.DRAFT));
when(transferItems.selectList(any())).thenReturn(List.of());
when(sourceOrders.selectList(any())).thenReturn(List.of());
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
() -> service.create("TENANT-A", "USER-1", request));
var result = service.create("TENANT-A", "USER-1", request);
assertEquals(WarehouseTransferService.DRAFT, result.status());
ArgumentCaptor<WarehouseTransferOrder> transfer = ArgumentCaptor.forClass(WarehouseTransferOrder.class);
verify(transfers).insert(transfer.capture());
assertNull(transfer.getValue().getDestinationType());
assertEquals(400, exception.getStatusCode().value());
assertEquals("仓储转运必须选择 FBA 仓或私人仓", exception.getReason());
verifyNoInteractions(transfers);
}
@Test
@@ -618,53 +611,6 @@ class WarehouseTransferServiceTest {
verify(orderDetails).insert(any(LastlegOrderDetail.class));
}
@Test
void merchantUpdatesRejectedTransferBeforeResubmission() {
WarehouseTransferOrder transfer = persistedTransfer();
transfer.setStatus(WarehouseTransferService.REJECTED);
LastlegOrder tailOrder = historicalOrder();
tailOrder.setId(31L); tailOrder.setCustomerId("TENANT-A"); tailOrder.setCurrentStatus("CANCELLED");
when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer);
when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer);
when(orders.selectById(31L)).thenReturn(tailOrder);
when(transferItems.selectList(any())).thenReturn(List.of());
when(sourceOrders.selectList(any())).thenReturn(List.of());
var result = service.updateDraft("TENANT-A", "WTR-1", "USER-1", request(false));
assertEquals(WarehouseTransferService.REJECTED, result.status());
assertTrue(tailOrder.getSubOrderStatus().startsWith("WAREHOUSE_TRANSFER:REJECTED"));
verify(transfers).updateById(transfer);
verify(orders).updateById(tailOrder);
}
@Test
void merchantResubmitsRejectedTransferForReview() {
WarehouseTransferOrder transfer = persistedTransfer();
transfer.setStatus(WarehouseTransferService.REJECTED);
transfer.setInboundCode("WTR-1");
LastlegOrder tailOrder = historicalOrder();
tailOrder.setId(31L); tailOrder.setCustomerId("TENANT-A"); tailOrder.setCurrentStatus("CANCELLED");
tailOrder.setRelabelStatus("NOT_REQUIRED");
LastlegInboundOrder inbound = new LastlegInboundOrder();
inbound.setId(101L); inbound.setCustomerId("TENANT-A"); inbound.setStatus(WarehouseTransferService.PENDING_REVIEW);
when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer);
when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer);
when(orders.selectById(31L)).thenReturn(tailOrder);
when(inboundOrders.selectById(101L)).thenReturn(inbound);
when(transferItems.selectList(any())).thenReturn(List.of());
when(sourceOrders.selectList(any())).thenReturn(List.of());
var result = service.submit("TENANT-A", "WTR-1", "USER-1");
assertEquals(WarehouseTransferService.PENDING_REVIEW, result.status());
assertEquals(WarehouseTransferService.PENDING_REVIEW, tailOrder.getCurrentStatus());
var history = ArgumentCaptor.forClass(com.baiyue.lastleg.entity.LastlegOrderStatusHistory.class);
verify(orderHistories).insert(history.capture());
assertEquals(WarehouseTransferService.REJECTED, history.getValue().getFromStatus());
assertEquals(WarehouseTransferService.PENDING_REVIEW, history.getValue().getToStatus());
}
@Test
void merchantCannotUpdateSubmittedTransfer() {
WarehouseTransferOrder transfer = persistedTransfer();
+2 -8
View File
@@ -1369,7 +1369,7 @@ USE baiyue_lastleg;
CREATE TABLE IF NOT EXISTS ll_cargo_discard_application (
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
discard_no VARCHAR(64) NOT NULL COMMENT '废弃申请单号', outbound_no VARCHAR(64) NULL COMMENT '废弃出库单号', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID',
discard_no VARCHAR(64) NOT NULL COMMENT '废弃申请单号', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID',
overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照', inbound_codes VARCHAR(2000) NOT NULL COMMENT '入仓单号集合',
total_packages INT NOT NULL COMMENT '总箱数', fee_per_package DECIMAL(18,2) NOT NULL COMMENT '每箱处理费用',
total_fee DECIMAL(18,2) NOT NULL COMMENT '费用合计', currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '币种',
@@ -1493,11 +1493,7 @@ PREPARE transfer_inventory_024 FROM @ddl; EXECUTE transfer_inventory_024; DEALLO
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='outbound_request_hash'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN outbound_request_hash CHAR(64) NULL COMMENT ''出库请求哈希'' AFTER outbound_idempotency_key'));
PREPARE transfer_inventory_024 FROM @ddl; EXECUTE transfer_inventory_024; DEALLOCATE PREPARE transfer_inventory_024;
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='outbound_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN outbound_at DATETIME(3) NULL COMMENT ''出库时间'' AFTER outbound_request_hash'));
PREPARE transfer_outbound_at_stmt FROM @ddl; EXECUTE transfer_outbound_at_stmt; DEALLOCATE PREPARE transfer_outbound_at_stmt;
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='measured_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN measured_at DATETIME(3) NULL COMMENT ''PDA测量完成时间'' AFTER outbound_at'));
PREPARE transfer_measured_at_stmt FROM @ddl; EXECUTE transfer_measured_at_stmt; DEALLOCATE PREPARE transfer_measured_at_stmt;
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='actual_ship_at'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_order ADD COLUMN actual_ship_at DATETIME(3) NULL COMMENT ''实际发货时间'' AFTER measured_at'));
PREPARE transfer_actual_ship_at_stmt FROM @ddl; EXECUTE transfer_actual_ship_at_stmt; DEALLOCATE PREPARE transfer_actual_ship_at_stmt;
PREPARE transfer_inventory_024 FROM @ddl; EXECUTE transfer_inventory_024; DEALLOCATE PREPARE transfer_inventory_024;
CREATE TABLE IF NOT EXISTS ll_warehouse_transfer_inventory_allocation (
id BIGINT NOT NULL COMMENT '雪花主键', transfer_order_id BIGINT NOT NULL COMMENT '转仓单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
@@ -4308,5 +4304,3 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
PREPARE dispatch_postal_stmt FROM @ddl; EXECUTE dispatch_postal_stmt; DEALLOCATE PREPARE dispatch_postal_stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='postal_code'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编'' AFTER delivery_address'));
PREPARE delivery_postal_stmt FROM @ddl; EXECUTE delivery_postal_stmt; DEALLOCATE PREPARE delivery_postal_stmt;
SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_cargo_discard_application' AND column_name='outbound_no'), 'ALTER TABLE ll_cargo_discard_application ADD COLUMN outbound_no VARCHAR(64) NULL COMMENT ''废弃出库单号'' AFTER discard_no', 'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;