功能提交
This commit is contained in:
File diff suppressed because it is too large
Load Diff
+19
-3
@@ -25,9 +25,25 @@
|
|||||||
| 一件代发接收、SKU/箱唛库存 | `dropship/service/DropshipOrderService.java`、`dropship/entity/`、`dropship/mapper/` |
|
| 一件代发接收、SKU/箱唛库存 | `dropship/service/DropshipOrderService.java`、`dropship/entity/`、`dropship/mapper/` |
|
||||||
| 货物废弃、待审锁箱、审批扣减 | `discard/controller/CargoDiscardController.java`、`discard/service/CargoDiscardService.java` |
|
| 货物废弃、待审锁箱、审批扣减 | `discard/controller/CargoDiscardController.java`、`discard/service/CargoDiscardService.java` |
|
||||||
| 海外采购、客户下单、采购审核、手工出库与采购库存 | `procurement/controller/`、`procurement/service/OverseasProcurementService.java` |
|
| 海外采购、客户下单、采购审核、手工出库与采购库存 | `procurement/controller/`、`procurement/service/OverseasProcurementService.java` |
|
||||||
| 数据库 | `src/main/resources/db/lastleg.sql` |
|
| 数据库基线、表索引与中文注释契约 | `src/main/resources/db/lastleg.sql`、`src/test/java/com/baiyue/lastleg/schema/SchemaChineseCommentContractTest.java` |
|
||||||
| 测试 | `src/test/java/com/baiyue/lastleg/` |
|
| 测试 | `src/test/java/com/baiyue/lastleg/` |
|
||||||
|
|
||||||
|
## 数据库表索引与注释约束
|
||||||
|
|
||||||
|
| 业务域 | 表名 | 中文含义 |
|
||||||
|
|---|---|---|
|
||||||
|
| 核心订单与事件 | `ll_loading_*`、`ll_event_*`、`ll_order*`、`ll_inbound_order*` | 事件收件、尾程订单、明细及状态历史 |
|
||||||
|
| 入仓计划与作业 | `ll_inbound_plan*`、`ll_inbound_pda*`、`ll_inbound_pallet_merge`、`ll_inbound_fba_appointment*` | 入仓计划、托盘分配、PDA 作业、合托及 FBA 预约 |
|
||||||
|
| 库存 | `ll_inventory_*`、`ll_inbound_stock_receipt` | 库存余额、仓位、流水、命令幂等及实收快照 |
|
||||||
|
| 派车与出库 | `ll_dispatch_*`、`ll_outbound_*`、`ll_delivery_order` | 派车、PDA 拣货、出库及派送 |
|
||||||
|
| 转仓与换标 | `ll_warehouse_transfer_*`、`ll_relabel_*` | 转仓单、库存转移、目标收货及换标 |
|
||||||
|
| 一件代发与废弃 | `ll_dropship_*`、`ll_cargo_discard_*` | 托盘/箱唛库存、流水和废弃审批 |
|
||||||
|
| 采购与卡车 | `ll_overseas_procurement_*`、`ll_us_truck_*` | 海外采购及美国卡车运输 |
|
||||||
|
|
||||||
|
- 新增或修改表时,表 `COMMENT` 和每个普通字段的 `COMMENT` 必须填写准确的中文业务含义,不得只写英文、字段名或类型名。
|
||||||
|
- `SchemaChineseCommentContractTest` 自动扫描全部 `ll_` 表,无需维护表名白名单;新增字段必须同步更新基线 SQL、建表增量和存量备注迁移,增量脚本的 `ADD/MODIFY COLUMN` 不得丢失中文备注。
|
||||||
|
- 表备注、字段备注和索引变更必须可重复执行;存量库的备注修订通过统一增量脚本落地,不依赖基线建表语句。
|
||||||
|
|
||||||
## 事件建单规则
|
## 事件建单规则
|
||||||
|
|
||||||
- 只接受冻结契约支持的事件类型和版本;校验事件 ID、租户、区域、Payload 哈希及必要字段。
|
- 只接受冻结契约支持的事件类型和版本;校验事件 ID、租户、区域、Payload 哈希及必要字段。
|
||||||
@@ -43,8 +59,8 @@
|
|||||||
- 平台查询范围由可信身份/平台权限决定;商户查询只能使用认证身份中的客户 ID。
|
- 平台查询范围由可信身份/平台权限决定;商户查询只能使用认证身份中的客户 ID。
|
||||||
- 列表、详情、导出和更新必须使用同一租户边界,不能仅在列表过滤而让详情或更新越权。
|
- 列表、详情、导出和更新必须使用同一租户边界,不能仅在列表过滤而让详情或更新越权。
|
||||||
- 目的仓变更必须写状态/操作历史或审计证据,并使用版本控制防止并发覆盖。
|
- 目的仓变更必须写状态/操作历史或审计证据,并使用版本控制防止并发覆盖。
|
||||||
- 货物废弃只使用可核对 SKU 和箱唛的一件代发库存;提交申请时将箱锁定为 `DISCARD_PENDING`,具备 `lastleg:cargo-discard:approve` 权限的审核人通过后才扣减 SKU 箱数并写逐箱流水,拒绝时释放锁箱。
|
- 货物废弃只使用可核对 SKU 和箱唛的一件代发库存。创建申请只能将箱从 `AVAILABLE` 锁定为 `DISCARD_PENDING`,不得扣减 SKU 箱数;实际扣库存节点固定为具备 `lastleg:cargo-discard:approve` 权限的仓库/PDA 审核人确认废弃时,并且必须在审核事务内调用专用 discard adjustment。该 adjustment 必须将箱改为 `DISCARDED`、写逐箱业务流水及唯一幂等键,并校验 `扣减前箱数 - 废弃箱数 = 扣减后箱数` 且逐箱数等于申请总箱数;任一环节失败须整体回滚。拒绝申请只释放锁箱,不得扣库存。
|
||||||
- 海外采购客户下单固定进入 `PENDING_REVIEW`;审核通过后后台才可维护入库量和新增出库记录。采购库存固定按 `inbound_quantity - outbound_quantity` 计算,禁止直接覆盖库存量或产生负库存;审核、编辑和出库均须按可信租户范围执行。
|
- 海外采购客户下单固定进入 `PENDING_REVIEW`;采购表的 `inbound_quantity - outbound_quantity` 只表示采购业务单据余量,不得写入或冒充 `available_packages`。审核/下单只能停留在采购单据(或明确的 `procurement_packages` 桶);只有海外仓 PDA 完成实收后,`InboundStockReceiptService` 才能以 `PDA_STOCK_IN:{taskId}:{allocationId}` 幂等键调用库存 `stockIn` 转入可用库存。禁止通过通用平台接口绕过 PDA 入可用库存。
|
||||||
|
|
||||||
## 修改与验证
|
## 修改与验证
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.baiyue.lastleg.discard;
|
||||||
|
|
||||||
|
import org.mybatis.spring.annotation.MapperScan;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@MapperScan("com.baiyue.lastleg.discard.mapper")
|
||||||
|
public class DiscardMapperConfiguration {
|
||||||
|
}
|
||||||
+4
-1
@@ -60,7 +60,10 @@ public interface CargoDiscardInventoryMapper {
|
|||||||
@Param("skuCode") String skuCode);
|
@Param("skuCode") String skuCode);
|
||||||
|
|
||||||
@Update("UPDATE ll_dropship_sku_inventory SET package_count=package_count-#{packages},updated_at=NOW(3) " +
|
@Update("UPDATE ll_dropship_sku_inventory SET package_count=package_count-#{packages},updated_at=NOW(3) " +
|
||||||
"WHERE customer_id=#{customerId} AND id=#{id} AND package_count>=#{packages}")
|
"WHERE customer_id=#{customerId} AND id=#{id} AND package_count>=#{packages}")
|
||||||
int deductSkuInventory(@Param("customerId") String customerId, @Param("id") Long id,
|
int deductSkuInventory(@Param("customerId") String customerId, @Param("id") Long id,
|
||||||
@Param("packages") int packages);
|
@Param("packages") int packages);
|
||||||
|
|
||||||
|
@Select("SELECT package_count FROM ll_dropship_sku_inventory WHERE customer_id=#{customerId} AND id=#{id}")
|
||||||
|
Long skuPackages(@Param("customerId") String customerId, @Param("id") Long id);
|
||||||
}
|
}
|
||||||
|
|||||||
+98
@@ -0,0 +1,98 @@
|
|||||||
|
package com.baiyue.lastleg.discard.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||||
|
import com.baiyue.lastleg.discard.entity.CargoDiscardApplication;
|
||||||
|
import com.baiyue.lastleg.discard.entity.CargoDiscardCarton;
|
||||||
|
import com.baiyue.lastleg.discard.entity.CargoDiscardItem;
|
||||||
|
import com.baiyue.lastleg.discard.mapper.CargoDiscardInventoryMapper;
|
||||||
|
import com.baiyue.lastleg.dropship.entity.DropshipCarton;
|
||||||
|
import com.baiyue.lastleg.dropship.entity.DropshipCartonLedger;
|
||||||
|
import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory;
|
||||||
|
import com.baiyue.lastleg.dropship.mapper.DropshipCartonLedgerMapper;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Propagation;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/** Final inventory mutation for a warehouse-approved cargo discard. */
|
||||||
|
@Service
|
||||||
|
public class CargoDiscardInventoryAdjustmentService {
|
||||||
|
@Resource private CargoDiscardInventoryMapper inventory;
|
||||||
|
@Resource private DropshipCartonLedgerMapper cartonLedgers;
|
||||||
|
|
||||||
|
@Transactional(propagation = Propagation.MANDATORY)
|
||||||
|
public AdjustmentResult discard(String customerId, CargoDiscardApplication application,
|
||||||
|
List<CargoDiscardItem> items,
|
||||||
|
Map<Long, List<CargoDiscardCarton>> linksByItem,
|
||||||
|
String operatorId, LocalDateTime occurredAt) {
|
||||||
|
long beforePackages = 0;
|
||||||
|
long discardedPackages = 0;
|
||||||
|
long afterPackages = 0;
|
||||||
|
for (CargoDiscardItem item : items) {
|
||||||
|
DropshipSkuInventory sku = inventory.lockSkuInventory(customerId, item.getSkuInventoryId());
|
||||||
|
if (sku == null) throw conflict("SKU inventory is missing during discard approval: " + item.getSkuCode());
|
||||||
|
long before = value(sku.getPackageCount());
|
||||||
|
int discarded = item.getDiscardPackages();
|
||||||
|
if (before < discarded || inventory.deductSkuInventory(customerId, sku.getId(), discarded) != 1) {
|
||||||
|
throw conflict("SKU inventory is insufficient during discard approval: " + item.getSkuCode());
|
||||||
|
}
|
||||||
|
List<CargoDiscardCarton> links = linksByItem.getOrDefault(item.getId(), List.of());
|
||||||
|
if (links.size() != discarded) throw conflict("Reserved discard cartons are incomplete");
|
||||||
|
for (CargoDiscardCarton link : links) {
|
||||||
|
DropshipCarton carton = inventory.lockCarton(customerId, link.getCartonId());
|
||||||
|
if (carton == null || inventory.changeCartonStatus(customerId, carton.getId(),
|
||||||
|
"DISCARD_PENDING", "DISCARDED") != 1) throw conflict("Reserved cargo status changed before approval");
|
||||||
|
cartonLedgers.insert(discardFact(customerId, application, item, carton, operatorId, occurredAt));
|
||||||
|
}
|
||||||
|
Long persistedAfter = inventory.skuPackages(customerId, sku.getId());
|
||||||
|
long expectedAfter = before - discarded;
|
||||||
|
if (persistedAfter == null || persistedAfter != expectedAfter) {
|
||||||
|
throw conflict("Cargo discard package conservation check failed for SKU " + item.getSkuCode());
|
||||||
|
}
|
||||||
|
beforePackages = Math.addExact(beforePackages, before);
|
||||||
|
discardedPackages = Math.addExact(discardedPackages, discarded);
|
||||||
|
afterPackages = Math.addExact(afterPackages, persistedAfter);
|
||||||
|
}
|
||||||
|
if (beforePackages - discardedPackages != afterPackages
|
||||||
|
|| discardedPackages != application.getTotalPackages()) {
|
||||||
|
throw conflict("Cargo discard application package conservation check failed");
|
||||||
|
}
|
||||||
|
return new AdjustmentResult(beforePackages, discardedPackages, afterPackages);
|
||||||
|
}
|
||||||
|
|
||||||
|
private DropshipCartonLedger discardFact(String customerId, CargoDiscardApplication application,
|
||||||
|
CargoDiscardItem item, DropshipCarton carton,
|
||||||
|
String operatorId, LocalDateTime occurredAt) {
|
||||||
|
DropshipCartonLedger fact = new DropshipCartonLedger();
|
||||||
|
fact.setId(IdWorker.getId()); fact.setCustomerId(customerId); fact.setOrderId(item.getOrderId());
|
||||||
|
fact.setCartonId(carton.getId()); fact.setCartonMark(carton.getCartonMark()); fact.setSkuCode(item.getSkuCode());
|
||||||
|
fact.setOperationType("DISCARD"); fact.setBeforePackages(1L); fact.setDeltaPackages(-1L); fact.setAfterPackages(0L);
|
||||||
|
fact.setBusinessOrderNo(application.getDiscardNo());
|
||||||
|
fact.setIdempotencyKey("CARGO_DISCARD:" + application.getId() + ":" + carton.getId());
|
||||||
|
fact.setRemark("Warehouse discard approved by " + operatorId + remarkSuffix(application.getRemark()));
|
||||||
|
fact.setOccurredAt(occurredAt); fact.setCreatedAt(occurredAt);
|
||||||
|
return fact;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long value(Long value) { return value == null ? 0L : value; }
|
||||||
|
private static String remarkSuffix(String value) {
|
||||||
|
return value == null || value.isBlank() ? "" : "; " + value.trim();
|
||||||
|
}
|
||||||
|
private static ResponseStatusException conflict(String message) {
|
||||||
|
return new ResponseStatusException(HttpStatus.CONFLICT, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public record AdjustmentResult(long beforePackages, long discardedPackages, long afterPackages) {
|
||||||
|
public String auditRemark(String reviewRemark) {
|
||||||
|
String prefix = reviewRemark == null || reviewRemark.isBlank() ? "" : reviewRemark.trim() + "; ";
|
||||||
|
return prefix + "inventoryBefore=" + beforePackages + ", discarded=" + discardedPackages
|
||||||
|
+ ", inventoryAfter=" + afterPackages;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,9 +17,7 @@ import com.baiyue.lastleg.discard.mapper.CargoDiscardItemMapper;
|
|||||||
import com.baiyue.lastleg.discard.mapper.CargoDiscardStatusHistoryMapper;
|
import com.baiyue.lastleg.discard.mapper.CargoDiscardStatusHistoryMapper;
|
||||||
import com.baiyue.lastleg.discard.model.CargoDiscardCandidateRow;
|
import com.baiyue.lastleg.discard.model.CargoDiscardCandidateRow;
|
||||||
import com.baiyue.lastleg.dropship.entity.DropshipCarton;
|
import com.baiyue.lastleg.dropship.entity.DropshipCarton;
|
||||||
import com.baiyue.lastleg.dropship.entity.DropshipCartonLedger;
|
|
||||||
import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory;
|
import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory;
|
||||||
import com.baiyue.lastleg.dropship.mapper.DropshipCartonLedgerMapper;
|
|
||||||
import com.baiyue.lastleg.entity.LastlegOrder;
|
import com.baiyue.lastleg.entity.LastlegOrder;
|
||||||
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
@@ -57,7 +55,7 @@ public class CargoDiscardService {
|
|||||||
@Resource private CargoDiscardCartonMapper discardCartons;
|
@Resource private CargoDiscardCartonMapper discardCartons;
|
||||||
@Resource private CargoDiscardStatusHistoryMapper histories;
|
@Resource private CargoDiscardStatusHistoryMapper histories;
|
||||||
@Resource private CargoDiscardInventoryMapper inventory;
|
@Resource private CargoDiscardInventoryMapper inventory;
|
||||||
@Resource private DropshipCartonLedgerMapper cartonLedgers;
|
@Resource private CargoDiscardInventoryAdjustmentService adjustments;
|
||||||
@Resource private LastlegOrderMapper orders;
|
@Resource private LastlegOrderMapper orders;
|
||||||
@Resource private ObjectMapper objectMapper;
|
@Resource private ObjectMapper objectMapper;
|
||||||
@Value("${baiyue.lastleg.cargo-discard.fee-per-package-usd:5.00}")
|
@Value("${baiyue.lastleg.cargo-discard.fee-per-package-usd:5.00}")
|
||||||
@@ -175,14 +173,20 @@ public class CargoDiscardService {
|
|||||||
List<CargoDiscardCarton> links = discardCartons.listApplication(customerId, application.getId());
|
List<CargoDiscardCarton> links = discardCartons.listApplication(customerId, application.getId());
|
||||||
Map<Long, List<CargoDiscardCarton>> linksByItem = links.stream()
|
Map<Long, List<CargoDiscardCarton>> linksByItem = links.stream()
|
||||||
.collect(java.util.stream.Collectors.groupingBy(CargoDiscardCarton::getItemId));
|
.collect(java.util.stream.Collectors.groupingBy(CargoDiscardCarton::getItemId));
|
||||||
if (APPROVED.equals(next)) approve(customerId, application, applicationItems, linksByItem, operatorId, now());
|
String historyRemark = request.remark();
|
||||||
else release(customerId, links);
|
if (APPROVED.equals(next)) {
|
||||||
|
CargoDiscardInventoryAdjustmentService.AdjustmentResult result = adjustments.discard(
|
||||||
|
customerId, application, applicationItems, linksByItem, operatorId, now());
|
||||||
|
historyRemark = result.auditRemark(request.remark());
|
||||||
|
} else {
|
||||||
|
release(customerId, links);
|
||||||
|
}
|
||||||
|
|
||||||
LocalDateTime reviewedAt = now();
|
LocalDateTime reviewedAt = now();
|
||||||
application.setStatus(next); application.setReviewedBy(operatorId);
|
application.setStatus(next); application.setReviewedBy(operatorId);
|
||||||
application.setReviewRemark(trim(request.remark())); application.setReviewedAt(reviewedAt);
|
application.setReviewRemark(trim(request.remark())); application.setReviewedAt(reviewedAt);
|
||||||
application.setUpdatedAt(reviewedAt); applications.updateById(application);
|
application.setUpdatedAt(reviewedAt); applications.updateById(application);
|
||||||
history(customerId, application.getId(), PENDING_REVIEW, next, operatorId, request.remark(), reviewedAt);
|
history(customerId, application.getId(), PENDING_REVIEW, next, operatorId, historyRemark, reviewedAt);
|
||||||
return detail(customerId, discardNo);
|
return detail(customerId, discardNo);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,31 +223,6 @@ public class CargoDiscardService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void approve(String customerId, CargoDiscardApplication application, List<CargoDiscardItem> applicationItems,
|
|
||||||
Map<Long, List<CargoDiscardCarton>> linksByItem, String operatorId, LocalDateTime occurredAt) {
|
|
||||||
for (CargoDiscardItem item : applicationItems) {
|
|
||||||
DropshipSkuInventory sku = inventory.lockSkuInventory(customerId, item.getSkuInventoryId());
|
|
||||||
if (sku == null || inventory.deductSkuInventory(customerId, sku.getId(), item.getDiscardPackages()) != 1) {
|
|
||||||
throw conflict("SKU inventory is insufficient during discard approval: " + item.getSkuCode());
|
|
||||||
}
|
|
||||||
List<CargoDiscardCarton> links = linksByItem.getOrDefault(item.getId(), List.of());
|
|
||||||
if (links.size() != item.getDiscardPackages()) throw conflict("Reserved discard cartons are incomplete");
|
|
||||||
for (CargoDiscardCarton link : links) {
|
|
||||||
DropshipCarton carton = inventory.lockCarton(customerId, link.getCartonId());
|
|
||||||
if (carton == null || inventory.changeCartonStatus(customerId, carton.getId(),
|
|
||||||
"DISCARD_PENDING", "DISCARDED") != 1) throw conflict("Reserved cargo status changed before approval");
|
|
||||||
DropshipCartonLedger fact = new DropshipCartonLedger();
|
|
||||||
fact.setId(IdWorker.getId()); fact.setCustomerId(customerId); fact.setOrderId(item.getOrderId());
|
|
||||||
fact.setCartonId(carton.getId()); fact.setCartonMark(carton.getCartonMark()); fact.setSkuCode(item.getSkuCode());
|
|
||||||
fact.setOperationType("DISCARD"); fact.setBeforePackages(1L); fact.setDeltaPackages(-1L); fact.setAfterPackages(0L);
|
|
||||||
fact.setBusinessOrderNo(application.getDiscardNo());
|
|
||||||
fact.setIdempotencyKey("CARGO_DISCARD:" + application.getId() + ":" + carton.getId());
|
|
||||||
fact.setRemark("Approved by " + operatorId + remarkSuffix(application.getRemark()));
|
|
||||||
fact.setOccurredAt(occurredAt); fact.setCreatedAt(occurredAt); cartonLedgers.insert(fact);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void release(String customerId, List<CargoDiscardCarton> links) {
|
private void release(String customerId, List<CargoDiscardCarton> links) {
|
||||||
for (CargoDiscardCarton link : links) {
|
for (CargoDiscardCarton link : links) {
|
||||||
if (inventory.changeCartonStatus(customerId, link.getCartonId(), "DISCARD_PENDING", "AVAILABLE") != 1) {
|
if (inventory.changeCartonStatus(customerId, link.getCartonId(), "DISCARD_PENDING", "AVAILABLE") != 1) {
|
||||||
@@ -295,7 +274,6 @@ public class CargoDiscardService {
|
|||||||
private static LocalDateTime now() { return LocalDateTime.now(); }
|
private static LocalDateTime now() { return LocalDateTime.now(); }
|
||||||
private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); }
|
private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); }
|
||||||
private static String required(String value, String message) { String result = trim(value); if (result == null) throw conflict(message); return result; }
|
private static String required(String value, String message) { String result = trim(value); if (result == null) throw conflict(message); return result; }
|
||||||
private static String remarkSuffix(String value) { String normalized = trim(value); return normalized == null ? "" : "; " + normalized; }
|
|
||||||
private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
|
private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
|
||||||
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
|
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
|
||||||
private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); }
|
private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); }
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ public interface DispatchCandidateMapper {
|
|||||||
"WHERE d.customer_id=ip.customer_id AND d.status='ACTIVE' AND (" +
|
"WHERE d.customer_id=ip.customer_id AND d.status='ACTIVE' AND (" +
|
||||||
"d.source_pallet_key=CONCAT('PLAN:',p.id) OR (p.warehouse_pallet_id IS NOT NULL AND " +
|
"d.source_pallet_key=CONCAT('PLAN:',p.id) OR (p.warehouse_pallet_id IS NOT NULL AND " +
|
||||||
"d.source_pallet_key=CONCAT('RECEIVED:',p.warehouse_pallet_id)))) " +
|
"d.source_pallet_key=CONCAT('RECEIVED:',p.warehouse_pallet_id)))) " +
|
||||||
|
"AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " +
|
||||||
|
"JOIN ll_inventory_position tp ON tp.id=ta.inventory_position_id " +
|
||||||
|
"WHERE ta.customer_id=#{customerId} AND ta.status='RESERVED' AND tp.pallet_id=p.warehouse_pallet_id) " +
|
||||||
"GROUP BY p.plan_id,p.id,p.warehouse_pallet_id,p.pallet_no,p.destination_warehouse_id," +
|
"GROUP BY p.plan_id,p.id,p.warehouse_pallet_id,p.pallet_no,p.destination_warehouse_id," +
|
||||||
"p.destination_warehouse_code,p.destination_warehouse_name,p.allocated_volume_cbm " +
|
"p.destination_warehouse_code,p.destination_warehouse_name,p.allocated_volume_cbm " +
|
||||||
"ORDER BY p.plan_id,p.sequence_no" +
|
"ORDER BY p.plan_id,p.sequence_no" +
|
||||||
@@ -63,6 +66,9 @@ public interface DispatchCandidateMapper {
|
|||||||
"LEFT JOIN ll_order o ON o.id=l.order_id " +
|
"LEFT JOIN ll_order o ON o.id=l.order_id " +
|
||||||
"WHERE ip.customer_id=#{customerId} AND p.id IN " +
|
"WHERE ip.customer_id=#{customerId} AND p.id IN " +
|
||||||
"<foreach collection='planPalletIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
|
"<foreach collection='planPalletIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
|
||||||
|
"AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " +
|
||||||
|
"JOIN ll_inventory_position tp ON tp.id=ta.inventory_position_id " +
|
||||||
|
"WHERE ta.customer_id=#{customerId} AND ta.status='RESERVED' AND tp.pallet_id=p.warehouse_pallet_id) " +
|
||||||
"GROUP BY p.plan_id,p.id,p.warehouse_pallet_id,p.pallet_no,p.destination_warehouse_id," +
|
"GROUP BY p.plan_id,p.id,p.warehouse_pallet_id,p.pallet_no,p.destination_warehouse_id," +
|
||||||
"p.destination_warehouse_code,p.destination_warehouse_name,p.allocated_volume_cbm " +
|
"p.destination_warehouse_code,p.destination_warehouse_name,p.allocated_volume_cbm " +
|
||||||
"ORDER BY p.id FOR UPDATE" +
|
"ORDER BY p.id FOR UPDATE" +
|
||||||
|
|||||||
+1
-1
@@ -202,7 +202,7 @@ public class DispatchPdaWorkflowService {
|
|||||||
String key = "DISPATCH:" + order.getDispatchNo() + ":PICK:" + pick.getId() + ":SHIP";
|
String key = "DISPATCH:" + order.getDispatchNo() + ":PICK:" + pick.getId() + ":SHIP";
|
||||||
inventory.ship(customerId, operatorId, new InventoryCommandRequests.Move(customerId,
|
inventory.ship(customerId, operatorId, new InventoryCommandRequests.Move(customerId,
|
||||||
pick.getInventoryPositionId(), n(pick.getPackages()),
|
pick.getInventoryPositionId(), n(pick.getPackages()),
|
||||||
order.getDispatchNo(), order.getDispatchNo(), null, key, request.remark()));
|
outbound.getOutboundNo(), outbound.getOutboundNo(), null, key, request.remark()));
|
||||||
shipped += n(pick.getPackages());
|
shipped += n(pick.getPackages());
|
||||||
}
|
}
|
||||||
LocalDateTime now = LocalDateTime.now();
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.baiyue.lastleg.dropship;
|
||||||
|
|
||||||
|
import org.mybatis.spring.annotation.MapperScan;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@MapperScan("com.baiyue.lastleg.dropship.mapper")
|
||||||
|
public class DropshipMapperConfiguration {
|
||||||
|
}
|
||||||
@@ -7,6 +7,9 @@ public interface DropshipCartonMapper extends BaseMapper<DropshipCarton> {
|
|||||||
@Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND order_id=#{orderId} AND carton_mark=#{cartonMark} LIMIT 1")
|
@Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND order_id=#{orderId} AND carton_mark=#{cartonMark} LIMIT 1")
|
||||||
DropshipCarton find(@Param("customerId") String customerId, @Param("orderId") Long orderId, @Param("cartonMark") String cartonMark);
|
DropshipCarton find(@Param("customerId") String customerId, @Param("orderId") Long orderId, @Param("cartonMark") String cartonMark);
|
||||||
|
|
||||||
|
@Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND profile_id=#{profileId} ORDER BY carton_sequence")
|
||||||
|
List<DropshipCarton> listProfile(@Param("customerId") String customerId, @Param("profileId") Long profileId);
|
||||||
|
|
||||||
@Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY profile_id,carton_sequence")
|
@Select("SELECT * FROM ll_dropship_carton WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY profile_id,carton_sequence")
|
||||||
List<DropshipCarton> listOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId);
|
List<DropshipCarton> listOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId);
|
||||||
}
|
}
|
||||||
|
|||||||
+66
-12
@@ -5,7 +5,7 @@ import com.baiyue.lastleg.dropship.dto.DropshipRequests;
|
|||||||
import com.baiyue.lastleg.dropship.entity.*;
|
import com.baiyue.lastleg.dropship.entity.*;
|
||||||
import com.baiyue.lastleg.dropship.mapper.*;
|
import com.baiyue.lastleg.dropship.mapper.*;
|
||||||
import com.baiyue.lastleg.entity.LastlegOrder;
|
import com.baiyue.lastleg.entity.LastlegOrder;
|
||||||
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
|
import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt;
|
||||||
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
||||||
import jakarta.annotation.Resource;
|
import jakarta.annotation.Resource;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
@@ -17,6 +17,8 @@ import java.time.LocalDateTime;
|
|||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class DropshipOrderService {
|
public class DropshipOrderService {
|
||||||
@@ -25,7 +27,6 @@ public class DropshipOrderService {
|
|||||||
@Resource private DropshipSkuInventoryMapper skuInventory;
|
@Resource private DropshipSkuInventoryMapper skuInventory;
|
||||||
@Resource private DropshipCartonMapper cartons;
|
@Resource private DropshipCartonMapper cartons;
|
||||||
@Resource private DropshipCartonLedgerMapper ledger;
|
@Resource private DropshipCartonLedgerMapper ledger;
|
||||||
@Resource private WarehouseResourceGateway warehouse;
|
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public int receive(DropshipRequests.Receive request) {
|
public int receive(DropshipRequests.Receive request) {
|
||||||
@@ -55,9 +56,7 @@ public class DropshipOrderService {
|
|||||||
validateMarks(request);
|
validateMarks(request);
|
||||||
LocalDateTime now = LocalDateTime.now();
|
LocalDateTime now = LocalDateTime.now();
|
||||||
for (DropshipRequests.Pallet inputPallet : request.pallets()) {
|
for (DropshipRequests.Pallet inputPallet : request.pallets()) {
|
||||||
WarehouseResourceGateway.WarehousePalletReference pallet = warehouse.ensureDropshipPallet(
|
DropshipOrderProfile profile = profile(request, order, inputPallet, now);
|
||||||
request.customerId(), request.warehouseId(), request.orderId() + ":" + inputPallet.palletNo().trim());
|
|
||||||
DropshipOrderProfile profile = profile(request, order, inputPallet, pallet, now);
|
|
||||||
profiles.insert(profile);
|
profiles.insert(profile);
|
||||||
int sequence = 0;
|
int sequence = 0;
|
||||||
for (DropshipRequests.SkuPackages input : inputPallet.skuPackages()) {
|
for (DropshipRequests.SkuPackages input : inputPallet.skuPackages()) {
|
||||||
@@ -70,9 +69,9 @@ public class DropshipOrderService {
|
|||||||
for (String mark : input.cartonMarks()) {
|
for (String mark : input.cartonMarks()) {
|
||||||
DropshipCarton carton = new DropshipCarton();
|
DropshipCarton carton = new DropshipCarton();
|
||||||
carton.setId(IdWorker.getId()); carton.setCustomerId(request.customerId()); carton.setOrderId(order.getId());
|
carton.setId(IdWorker.getId()); carton.setCustomerId(request.customerId()); carton.setOrderId(order.getId());
|
||||||
carton.setProfileId(profile.getId()); carton.setWarehousePalletId(pallet.palletId()); carton.setPalletNo(pallet.palletNo());
|
carton.setProfileId(profile.getId()); carton.setWarehousePalletId(null); carton.setPalletNo(null);
|
||||||
carton.setSkuCode(input.skuCode()); carton.setCartonMark(mark.trim()); carton.setCartonSequence(++sequence);
|
carton.setSkuCode(input.skuCode()); carton.setCartonMark(mark.trim()); carton.setCartonSequence(++sequence);
|
||||||
carton.setStatus("AVAILABLE"); carton.setVersion(0L); carton.setCreatedAt(now); carton.setUpdatedAt(now);
|
carton.setStatus("PENDING_INBOUND"); carton.setVersion(0L); carton.setCreatedAt(now); carton.setUpdatedAt(now);
|
||||||
cartons.insert(carton);
|
cartons.insert(carton);
|
||||||
ledger.insert(stockInFact(request, order, carton, now));
|
ledger.insert(stockInFact(request, order, carton, now));
|
||||||
}
|
}
|
||||||
@@ -100,30 +99,85 @@ public class DropshipOrderService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private DropshipOrderProfile profile(DropshipRequests.Receive request, LastlegOrder order,
|
private DropshipOrderProfile profile(DropshipRequests.Receive request, LastlegOrder order,
|
||||||
DropshipRequests.Pallet input,
|
DropshipRequests.Pallet input, LocalDateTime now) {
|
||||||
WarehouseResourceGateway.WarehousePalletReference pallet, LocalDateTime now) {
|
|
||||||
DropshipOrderProfile profile = new DropshipOrderProfile();
|
DropshipOrderProfile profile = new DropshipOrderProfile();
|
||||||
profile.setId(IdWorker.getId()); profile.setCustomerId(request.customerId()); profile.setOrderId(order.getId());
|
profile.setId(IdWorker.getId()); profile.setCustomerId(request.customerId()); profile.setOrderId(order.getId());
|
||||||
profile.setWarehousePalletId(pallet.palletId()); profile.setPalletNo(pallet.palletNo());
|
|
||||||
profile.setSourcePalletNo(input.palletNo().trim());
|
profile.setSourcePalletNo(input.palletNo().trim());
|
||||||
profile.setStatus("RECEIVED"); profile.setVersion(0L);
|
profile.setStatus("PENDING_INBOUND"); profile.setVersion(0L);
|
||||||
profile.setIdempotencyKey(request.idempotencyKey() + ":" + input.palletNo().trim());
|
profile.setIdempotencyKey(request.idempotencyKey() + ":" + input.palletNo().trim());
|
||||||
profile.setCreatedAt(now); profile.setUpdatedAt(now);
|
profile.setCreatedAt(now); profile.setUpdatedAt(now);
|
||||||
return profile;
|
return profile;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void activateAfterStockIn(String customerId, Long orderId, Long taskId,
|
||||||
|
List<InboundStockReceipt> receipts) {
|
||||||
|
List<DropshipOrderProfile> profilesForOrder = profiles.listOrder(customerId, orderId);
|
||||||
|
if (profilesForOrder.isEmpty()) return;
|
||||||
|
if (profilesForOrder.stream().allMatch(profile -> "ACTIVE".equals(profile.getStatus()))) return;
|
||||||
|
if (profilesForOrder.stream().anyMatch(profile -> "ACTIVE".equals(profile.getStatus()))) {
|
||||||
|
throw conflict("Dropship pallet activation is incomplete; repair profiles before retrying");
|
||||||
|
}
|
||||||
|
Map<Long, List<InboundStockReceipt>> receiptsByPallet = receipts.stream()
|
||||||
|
.filter(receipt -> receipt.getWarehousePalletId() != null)
|
||||||
|
.collect(Collectors.groupingBy(InboundStockReceipt::getWarehousePalletId));
|
||||||
|
if (receiptsByPallet.size() != profilesForOrder.size()) {
|
||||||
|
throw conflict("PDA pallet count does not match dropship source pallet count");
|
||||||
|
}
|
||||||
|
List<DropshipOrderProfile> orderedProfiles = profilesForOrder.stream()
|
||||||
|
.sorted(java.util.Comparator.comparing(DropshipOrderProfile::getSourcePalletNo)).toList();
|
||||||
|
List<List<InboundStockReceipt>> orderedReceipts = receiptsByPallet.values().stream()
|
||||||
|
.sorted(java.util.Comparator.comparing(value -> value.get(0).getWarehousePalletId())).toList();
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
for (int index = 0; index < orderedProfiles.size(); index++) {
|
||||||
|
DropshipOrderProfile profile = orderedProfiles.get(index);
|
||||||
|
List<InboundStockReceipt> actual = orderedReceipts.get(index);
|
||||||
|
List<DropshipCarton> profileCartons = cartons.listProfile(customerId, profile.getId());
|
||||||
|
long expected = profileCartons.size();
|
||||||
|
long received = actual.stream().mapToLong(value -> value.getReceivedPackages() == null ? 0 : value.getReceivedPackages()).sum();
|
||||||
|
if (expected != received) throw conflict("PDA package count does not match dropship carton snapshot");
|
||||||
|
Long positionId = actual.get(0).getInventoryPositionId();
|
||||||
|
if (actual.stream().anyMatch(value -> !java.util.Objects.equals(positionId, value.getInventoryPositionId()))) {
|
||||||
|
throw conflict("Dropship source pallet maps to multiple inventory positions");
|
||||||
|
}
|
||||||
|
InboundStockReceipt first = actual.get(0);
|
||||||
|
profile.setWarehousePalletId(first.getWarehousePalletId()); profile.setPalletNo(first.getPalletNo());
|
||||||
|
profile.setInventoryPositionId(positionId); profile.setStatus("ACTIVE"); profile.setVersion(value(profile.getVersion()) + 1);
|
||||||
|
profile.setUpdatedAt(now); profiles.updateById(profile);
|
||||||
|
for (DropshipCarton carton : profileCartons) {
|
||||||
|
carton.setWarehousePalletId(first.getWarehousePalletId()); carton.setPalletNo(first.getPalletNo());
|
||||||
|
carton.setStatus("AVAILABLE"); carton.setVersion(value(carton.getVersion()) + 1); carton.setUpdatedAt(now);
|
||||||
|
cartons.updateById(carton);
|
||||||
|
DropshipCartonLedger activation = stockInActivation(customerId, orderId, taskId, carton, now);
|
||||||
|
ledger.insert(activation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private DropshipCartonLedger stockInActivation(String customerId, Long orderId, Long taskId,
|
||||||
|
DropshipCarton carton, LocalDateTime now) {
|
||||||
|
DropshipCartonLedger fact = new DropshipCartonLedger();
|
||||||
|
fact.setId(IdWorker.getId()); fact.setCustomerId(customerId); fact.setOrderId(orderId);
|
||||||
|
fact.setCartonId(carton.getId()); fact.setCartonMark(carton.getCartonMark()); fact.setSkuCode(carton.getSkuCode());
|
||||||
|
fact.setOperationType("STOCK_IN"); fact.setBeforePackages(0L); fact.setDeltaPackages(1L); fact.setAfterPackages(1L);
|
||||||
|
fact.setIdempotencyKey("PDA_STOCK_IN:" + taskId + ":" + carton.getId()); fact.setOccurredAt(now); fact.setCreatedAt(now);
|
||||||
|
return fact;
|
||||||
|
}
|
||||||
|
|
||||||
private DropshipCartonLedger stockInFact(DropshipRequests.Receive request, LastlegOrder order,
|
private DropshipCartonLedger stockInFact(DropshipRequests.Receive request, LastlegOrder order,
|
||||||
DropshipCarton carton, LocalDateTime now) {
|
DropshipCarton carton, LocalDateTime now) {
|
||||||
DropshipCartonLedger fact = new DropshipCartonLedger();
|
DropshipCartonLedger fact = new DropshipCartonLedger();
|
||||||
fact.setId(IdWorker.getId()); fact.setCustomerId(request.customerId()); fact.setOrderId(order.getId());
|
fact.setId(IdWorker.getId()); fact.setCustomerId(request.customerId()); fact.setOrderId(order.getId());
|
||||||
fact.setCartonId(carton.getId()); fact.setCartonMark(carton.getCartonMark()); fact.setSkuCode(carton.getSkuCode());
|
fact.setCartonId(carton.getId()); fact.setCartonMark(carton.getCartonMark()); fact.setSkuCode(carton.getSkuCode());
|
||||||
fact.setOperationType("STOCK_IN"); fact.setBeforePackages(0L); fact.setDeltaPackages(1L); fact.setAfterPackages(1L);
|
fact.setOperationType("SNAPSHOT_CREATED"); fact.setBeforePackages(0L); fact.setDeltaPackages(0L); fact.setAfterPackages(0L);
|
||||||
fact.setBusinessOrderNo(order.getOrderNo());
|
fact.setBusinessOrderNo(order.getOrderNo());
|
||||||
fact.setIdempotencyKey(request.idempotencyKey() + ":" + carton.getCartonMark());
|
fact.setIdempotencyKey(request.idempotencyKey() + ":" + carton.getCartonMark());
|
||||||
fact.setOccurredAt(now); fact.setCreatedAt(now);
|
fact.setOccurredAt(now); fact.setCreatedAt(now);
|
||||||
return fact;
|
return fact;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static long value(Long value) { return value == null ? 0L : value; }
|
||||||
|
|
||||||
private static ResponseStatusException conflict(String message) {
|
private static ResponseStatusException conflict(String message) {
|
||||||
return new ResponseStatusException(HttpStatus.CONFLICT, message);
|
return new ResponseStatusException(HttpStatus.CONFLICT, message);
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-5
@@ -13,9 +13,11 @@ import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
|
|||||||
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse;
|
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse;
|
||||||
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
|
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
|
||||||
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
||||||
|
import com.baiyue.lastleg.dropship.service.DropshipOrderService;
|
||||||
import jakarta.annotation.Resource;
|
import jakarta.annotation.Resource;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
@@ -23,6 +25,7 @@ import java.util.Comparator;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/** Converts final PDA package counts into inbound-order/pallet inventory. */
|
/** Converts final PDA package counts into inbound-order/pallet inventory. */
|
||||||
@@ -31,12 +34,12 @@ public class InboundStockReceiptService {
|
|||||||
@Resource private LastlegOrderMapper orders;
|
@Resource private LastlegOrderMapper orders;
|
||||||
@Resource private InboundStockReceiptMapper receipts;
|
@Resource private InboundStockReceiptMapper receipts;
|
||||||
@Resource private InventoryCommandService inventory;
|
@Resource private InventoryCommandService inventory;
|
||||||
|
@Resource private DropshipOrderService dropshipOrders;
|
||||||
|
|
||||||
|
@Transactional
|
||||||
public void stockIn(String customerId, InboundPlan plan, InboundPdaTask task,
|
public void stockIn(String customerId, InboundPlan plan, InboundPdaTask task,
|
||||||
List<InboundPlanLine> lines, List<InboundPlanPalletAllocation> allocations,
|
List<InboundPlanLine> lines, List<InboundPlanPalletAllocation> allocations,
|
||||||
List<InboundPlanPallet> pallets, LocalDateTime receivedAt) {
|
List<InboundPlanPallet> pallets, LocalDateTime receivedAt) {
|
||||||
if (!receipts.listTask(customerId, task.getId()).isEmpty()) return;
|
|
||||||
|
|
||||||
Map<Long, LastlegOrder> orderById = orders.selectBatchIds(lines.stream()
|
Map<Long, LastlegOrder> orderById = orders.selectBatchIds(lines.stream()
|
||||||
.map(InboundPlanLine::getOrderId).collect(Collectors.toSet())).stream()
|
.map(InboundPlanLine::getOrderId).collect(Collectors.toSet())).stream()
|
||||||
.filter(order -> customerId.equals(order.getCustomerId()))
|
.filter(order -> customerId.equals(order.getCustomerId()))
|
||||||
@@ -48,6 +51,20 @@ public class InboundStockReceiptService {
|
|||||||
.sorted(Comparator.comparing(InboundPlanPalletAllocation::getId))
|
.sorted(Comparator.comparing(InboundPlanPalletAllocation::getId))
|
||||||
.collect(Collectors.groupingBy(InboundPlanPalletAllocation::getLineId));
|
.collect(Collectors.groupingBy(InboundPlanPalletAllocation::getLineId));
|
||||||
|
|
||||||
|
Set<Long> expectedAllocationIds = allocationsByLine.values().stream().flatMap(List::stream)
|
||||||
|
.map(InboundPlanPalletAllocation::getId).collect(Collectors.toSet());
|
||||||
|
List<InboundStockReceipt> existing = receipts.listTask(customerId, task.getId());
|
||||||
|
if (!existing.isEmpty()) {
|
||||||
|
Set<Long> receivedAllocationIds = existing.stream().map(InboundStockReceipt::getAllocationId)
|
||||||
|
.filter(Objects::nonNull).collect(Collectors.toSet());
|
||||||
|
if (receivedAllocationIds.equals(expectedAllocationIds)) {
|
||||||
|
activateDropship(customerId, task.getId(), orderById, existing);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw conflict("PDA stock-in receipts are incomplete; repair the task receipts before retrying");
|
||||||
|
}
|
||||||
|
|
||||||
|
List<InboundStockReceipt> created = new java.util.ArrayList<>();
|
||||||
for (InboundPlanLine line : lines) {
|
for (InboundPlanLine line : lines) {
|
||||||
LastlegOrder order = orderById.get(line.getOrderId());
|
LastlegOrder order = orderById.get(line.getOrderId());
|
||||||
if (order == null) throw conflict("Inbound line tail order is missing or outside the tenant");
|
if (order == null) throw conflict("Inbound line tail order is missing or outside the tenant");
|
||||||
@@ -59,13 +76,14 @@ public class InboundStockReceiptService {
|
|||||||
throw conflict("PDA picked packages must equal the inbound order package total");
|
throw conflict("PDA picked packages must equal the inbound order package total");
|
||||||
}
|
}
|
||||||
for (InboundPlanPalletAllocation allocation : lineAllocations) {
|
for (InboundPlanPalletAllocation allocation : lineAllocations) {
|
||||||
persist(customerId, plan, task, line, allocation,
|
created.add(persist(customerId, plan, task, line, allocation,
|
||||||
requirePallet(palletById, allocation), order, receivedAt);
|
requirePallet(palletById, allocation), order, receivedAt));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
activateDropship(customerId, task.getId(), orderById, created);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void persist(String customerId, InboundPlan plan, InboundPdaTask task, InboundPlanLine line,
|
private InboundStockReceipt persist(String customerId, InboundPlan plan, InboundPdaTask task, InboundPlanLine line,
|
||||||
InboundPlanPalletAllocation allocation, InboundPlanPallet pallet,
|
InboundPlanPalletAllocation allocation, InboundPlanPallet pallet,
|
||||||
LastlegOrder order, LocalDateTime receivedAt) {
|
LastlegOrder order, LocalDateTime receivedAt) {
|
||||||
long packages = value(allocation.getPickedPackageCount());
|
long packages = value(allocation.getPickedPackageCount());
|
||||||
@@ -106,6 +124,20 @@ public class InboundStockReceiptService {
|
|||||||
receipt.setReceivedAt(receivedAt);
|
receipt.setReceivedAt(receivedAt);
|
||||||
receipt.setCreatedAt(receivedAt);
|
receipt.setCreatedAt(receivedAt);
|
||||||
receipts.insert(receipt);
|
receipts.insert(receipt);
|
||||||
|
return receipt;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void activateDropship(String customerId, Long taskId, Map<Long, LastlegOrder> orderById,
|
||||||
|
List<InboundStockReceipt> stockReceipts) {
|
||||||
|
Map<Long, List<InboundStockReceipt>> receiptsByOrder = stockReceipts.stream()
|
||||||
|
.filter(receipt -> receipt.getOrderId() != null)
|
||||||
|
.collect(Collectors.groupingBy(InboundStockReceipt::getOrderId));
|
||||||
|
for (LastlegOrder order : orderById.values()) {
|
||||||
|
if ("ONE_CLICK_DROPSHIP".equals(order.getOrderType())) {
|
||||||
|
dropshipOrders.activateAfterStockIn(customerId, order.getId(), taskId,
|
||||||
|
receiptsByOrder.getOrDefault(order.getId(), List.of()));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static InboundPlanPallet requirePallet(Map<Long, InboundPlanPallet> pallets,
|
private static InboundPlanPallet requirePallet(Map<Long, InboundPlanPallet> pallets,
|
||||||
|
|||||||
@@ -175,6 +175,10 @@ public class InboundPlanService {
|
|||||||
|
|
||||||
private void validateOrders(List<SourceOrderSnapshot> orders) {
|
private void validateOrders(List<SourceOrderSnapshot> orders) {
|
||||||
for (SourceOrderSnapshot order : orders) {
|
for (SourceOrderSnapshot order : orders) {
|
||||||
|
if (!"PENDING_ASSIGNMENT".equals(order.currentStatus())) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.CONFLICT,
|
||||||
|
"Inbound code " + order.inboundCode() + " is not ready for an inbound plan");
|
||||||
|
}
|
||||||
if (order.destinationCount() != 1 || order.destinationMismatchCount() != 0 || warehouseKey(order) == null) {
|
if (order.destinationCount() != 1 || order.destinationMismatchCount() != 0 || warehouseKey(order) == null) {
|
||||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
|
||||||
"Inbound code " + order.inboundCode()
|
"Inbound code " + order.inboundCode()
|
||||||
|
|||||||
-7
@@ -23,13 +23,6 @@ import org.springframework.web.server.ResponseStatusException;
|
|||||||
public class InventoryCommandController {
|
public class InventoryCommandController {
|
||||||
@Resource private InventoryCommandService service;
|
@Resource private InventoryCommandService service;
|
||||||
|
|
||||||
@PostMapping("/stock-in")
|
|
||||||
@Operation(summary = "Put inbound-order packages into an overseas warehouse pallet")
|
|
||||||
public ApiResponse<InventoryCommandResponse> stockIn(@AuthenticationPrincipal AuthenticatedUser user,
|
|
||||||
@Valid @RequestBody InventoryCommandRequests.StockIn request) {
|
|
||||||
return ApiResponse.success(service.stockIn(scope(user, request.customerId()), operator(user), request));
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/hold")
|
@PostMapping("/hold")
|
||||||
@Operation(summary = "Move available inbound-order packages to hold")
|
@Operation(summary = "Move available inbound-order packages to hold")
|
||||||
public ApiResponse<InventoryCommandResponse> hold(@AuthenticationPrincipal AuthenticatedUser user,
|
public ApiResponse<InventoryCommandResponse> hold(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
|||||||
+2
-1
@@ -3,6 +3,7 @@ package com.baiyue.lastleg.inventory.command.dto;
|
|||||||
import jakarta.validation.constraints.NotBlank;
|
import jakarta.validation.constraints.NotBlank;
|
||||||
import jakarta.validation.constraints.NotNull;
|
import jakarta.validation.constraints.NotNull;
|
||||||
import jakarta.validation.constraints.Positive;
|
import jakarta.validation.constraints.Positive;
|
||||||
|
import jakarta.validation.constraints.Min;
|
||||||
import jakarta.validation.constraints.Size;
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
public final class InventoryCommandRequests {
|
public final class InventoryCommandRequests {
|
||||||
@@ -26,7 +27,7 @@ public final class InventoryCommandRequests {
|
|||||||
String batchNo,
|
String batchNo,
|
||||||
Long warehouseLocationId,
|
Long warehouseLocationId,
|
||||||
String warehouseLocationCode,
|
String warehouseLocationCode,
|
||||||
@Positive long packages,
|
@Min(0) long packages,
|
||||||
String businessOrderNo,
|
String businessOrderNo,
|
||||||
String sourceEventId,
|
String sourceEventId,
|
||||||
@NotBlank @Size(max = 255) String idempotencyKey,
|
@NotBlank @Size(max = 255) String idempotencyKey,
|
||||||
|
|||||||
+3
@@ -328,6 +328,9 @@ public class InventoryCommandService {
|
|||||||
ledger.setBeforePackages(beforePackages);
|
ledger.setBeforePackages(beforePackages);
|
||||||
ledger.setDeltaPackages(deltaPackages);
|
ledger.setDeltaPackages(deltaPackages);
|
||||||
ledger.setAfterPackages(afterPackages);
|
ledger.setAfterPackages(afterPackages);
|
||||||
|
ledger.setBeforeQuantity(0L);
|
||||||
|
ledger.setDeltaQuantity(0L);
|
||||||
|
ledger.setAfterQuantity(0L);
|
||||||
ledger.setOperatorId(trim(operatorId));
|
ledger.setOperatorId(trim(operatorId));
|
||||||
ledger.setRemark(trim(remark));
|
ledger.setRemark(trim(remark));
|
||||||
ledger.setOccurredAt(LocalDateTime.now());
|
ledger.setOccurredAt(LocalDateTime.now());
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ public class InventoryLedger {
|
|||||||
private Long beforePackages;
|
private Long beforePackages;
|
||||||
private Long deltaPackages;
|
private Long deltaPackages;
|
||||||
private Long afterPackages;
|
private Long afterPackages;
|
||||||
|
/** Quantity snapshot is retained even though current PDA operations are carton-count based. */
|
||||||
|
private Long beforeQuantity;
|
||||||
|
private Long deltaQuantity;
|
||||||
|
private Long afterQuantity;
|
||||||
private String operatorId;
|
private String operatorId;
|
||||||
private String remark;
|
private String remark;
|
||||||
private LocalDateTime occurredAt;
|
private LocalDateTime occurredAt;
|
||||||
|
|||||||
@@ -27,10 +27,10 @@ public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> {
|
|||||||
int increasePicked(@Param("customerId") String customerId, @Param("id") Long id,
|
int increasePicked(@Param("customerId") String customerId, @Param("id") Long id,
|
||||||
@Param("packages") long packages);
|
@Param("packages") long packages);
|
||||||
|
|
||||||
@Update("UPDATE ll_order SET picked_package_count=picked_package_count-#{packages}," +
|
@Update("UPDATE ll_order SET current_status=CASE WHEN shipped_package_count+#{packages}=package_count " +
|
||||||
"shipped_package_count=shipped_package_count+#{packages},outbound_order_no=#{outboundNo}," +
|
"THEN 'COMPLETED' ELSE 'PARTIALLY_SHIPPED' END," +
|
||||||
"current_status=CASE WHEN shipped_package_count+#{packages}=package_count " +
|
"picked_package_count=picked_package_count-#{packages}," +
|
||||||
"THEN 'COMPLETED' ELSE 'PARTIALLY_SHIPPED' END,updated_at=NOW() " +
|
"shipped_package_count=shipped_package_count+#{packages},outbound_order_no=#{outboundNo},updated_at=NOW() " +
|
||||||
"WHERE customer_id=#{customerId} AND id=#{id} AND picked_package_count >= #{packages} " +
|
"WHERE customer_id=#{customerId} AND id=#{id} AND picked_package_count >= #{packages} " +
|
||||||
"AND shipped_package_count+#{packages} <= package_count")
|
"AND shipped_package_count+#{packages} <= package_count")
|
||||||
int transferPickedToShipped(@Param("customerId") String customerId, @Param("id") Long id,
|
int transferPickedToShipped(@Param("customerId") String customerId, @Param("id") Long id,
|
||||||
|
|||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
package com.baiyue.lastleg.procurement;
|
||||||
|
|
||||||
|
import org.mybatis.spring.annotation.MapperScan;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@MapperScan("com.baiyue.lastleg.procurement.mapper")
|
||||||
|
public class ProcurementMapperConfiguration {
|
||||||
|
}
|
||||||
+21
@@ -37,6 +37,27 @@ public class WarehouseTransferController {
|
|||||||
return ApiResponse.success(service.review(scope(user, customerId), transferNo, operator(user), request));
|
return ApiResponse.success(service.review(scope(user, customerId), transferNo, operator(user), request));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{transferNo}/allocations")
|
||||||
|
public ApiResponse<WarehouseTransferResponses.Detail> allocate(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@RequestParam(required = false) String customerId, @PathVariable String transferNo,
|
||||||
|
@Valid @RequestBody WarehouseTransferRequests.Allocate request) {
|
||||||
|
return ApiResponse.success(service.allocate(scope(user, customerId), transferNo, operator(user), request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{transferNo}/outbound")
|
||||||
|
public ApiResponse<WarehouseTransferResponses.Detail> outbound(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@RequestParam(required = false) String customerId, @PathVariable String transferNo,
|
||||||
|
@Valid @RequestBody WarehouseTransferRequests.Outbound request) {
|
||||||
|
return ApiResponse.success(service.outbound(scope(user, customerId), transferNo, operator(user), request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{transferNo}/receipts")
|
||||||
|
public ApiResponse<WarehouseTransferResponses.Detail> receive(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@RequestParam(required = false) String customerId, @PathVariable String transferNo,
|
||||||
|
@Valid @RequestBody WarehouseTransferRequests.Receipt request) {
|
||||||
|
return ApiResponse.success(service.receive(scope(user, customerId), transferNo, operator(user), request));
|
||||||
|
}
|
||||||
|
|
||||||
private void requireApprovalPermission(AuthenticatedUser user) {
|
private void requireApprovalPermission(AuthenticatedUser user) {
|
||||||
if (user == null || user.permissions() == null
|
if (user == null || user.permissions() == null
|
||||||
|| !user.permissions().contains("lastleg:warehouse-transfer:approve")) {
|
|| !user.permissions().contains("lastleg:warehouse-transfer:approve")) {
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import jakarta.validation.constraints.NotEmpty;
|
|||||||
import jakarta.validation.constraints.NotNull;
|
import jakarta.validation.constraints.NotNull;
|
||||||
import jakarta.validation.constraints.Positive;
|
import jakarta.validation.constraints.Positive;
|
||||||
import jakarta.validation.constraints.Size;
|
import jakarta.validation.constraints.Size;
|
||||||
|
import jakarta.validation.constraints.DecimalMin;
|
||||||
|
import java.math.BigDecimal;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@@ -19,6 +21,7 @@ public final class WarehouseTransferRequests {
|
|||||||
@NotBlank @Size(max = 128) String fbaWarehouseCode,
|
@NotBlank @Size(max = 128) String fbaWarehouseCode,
|
||||||
@NotBlank @Size(max = 1000) String deliveryAddress,
|
@NotBlank @Size(max = 1000) String deliveryAddress,
|
||||||
@Positive int packageCount,
|
@Positive int packageCount,
|
||||||
|
@NotNull @DecimalMin(value = "0.0001") BigDecimal volumeCbm,
|
||||||
@NotBlank @Size(max = 64) String deliveryMethod,
|
@NotBlank @Size(max = 64) String deliveryMethod,
|
||||||
@NotNull LocalDateTime estimatedArrivalAt,
|
@NotNull LocalDateTime estimatedArrivalAt,
|
||||||
@NotBlank @Size(max = 128) String fbaShipmentNo,
|
@NotBlank @Size(max = 128) String fbaShipmentNo,
|
||||||
@@ -33,6 +36,37 @@ public final class WarehouseTransferRequests {
|
|||||||
public record Review(@NotBlank @Size(max = 32) String decision,
|
public record Review(@NotBlank @Size(max = 32) String decision,
|
||||||
@Size(max = 1000) String remark) {}
|
@Size(max = 1000) String remark) {}
|
||||||
|
|
||||||
|
public record Allocate(
|
||||||
|
@NotBlank @Size(max = 255) String idempotencyKey,
|
||||||
|
@NotEmpty @Size(max = 500) List<@Valid Allocation> allocations
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public record Allocation(
|
||||||
|
@NotNull Long inventoryPositionId,
|
||||||
|
@NotNull Long palletId,
|
||||||
|
@Positive int packageCount
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public record Outbound(@NotBlank @Size(max = 255) String idempotencyKey,
|
||||||
|
@Size(max = 1000) String remark) {}
|
||||||
|
|
||||||
|
public record Receipt(
|
||||||
|
@NotNull Long sourceInventoryPositionId,
|
||||||
|
@NotBlank @Size(max = 128) String destinationOverseasWarehouseId,
|
||||||
|
@Size(max = 128) String destinationOverseasWarehouseCode,
|
||||||
|
@Size(max = 255) String destinationOverseasWarehouseName,
|
||||||
|
@Size(max = 128) String destinationWarehouseId,
|
||||||
|
@NotBlank @Size(max = 128) String destinationWarehouseCode,
|
||||||
|
@Size(max = 255) String destinationWarehouseName,
|
||||||
|
@NotNull Long destinationPalletId,
|
||||||
|
@NotBlank @Size(max = 128) String destinationPalletNo,
|
||||||
|
@Size(max = 128) String batchNo,
|
||||||
|
Long warehouseLocationId,
|
||||||
|
@Size(max = 128) String warehouseLocationCode,
|
||||||
|
@NotBlank @Size(max = 255) String idempotencyKey,
|
||||||
|
@Size(max = 1000) String remark
|
||||||
|
) {}
|
||||||
|
|
||||||
public record Item(
|
public record Item(
|
||||||
@NotBlank @Size(max = 128) String skuCode,
|
@NotBlank @Size(max = 128) String skuCode,
|
||||||
@NotBlank @Size(max = 255) String productNameZh,
|
@NotBlank @Size(max = 255) String productNameZh,
|
||||||
|
|||||||
+5
-1
@@ -7,11 +7,15 @@ public final class WarehouseTransferResponses {
|
|||||||
private WarehouseTransferResponses() {}
|
private WarehouseTransferResponses() {}
|
||||||
public record Item(String skuCode, String productNameZh, int packageCount, String shippingMark) {}
|
public record Item(String skuCode, String productNameZh, int packageCount, String shippingMark) {}
|
||||||
public record SourceOrder(Long lastlegOrderId, String lastlegOrderNo, Long inboundOrderId, String inboundCode) {}
|
public record SourceOrder(Long lastlegOrderId, String lastlegOrderNo, Long inboundOrderId, String inboundCode) {}
|
||||||
|
public record Allocation(Long inventoryPositionId, Long inboundOrderId, String inboundCode,
|
||||||
|
Long palletId, String palletNo, int packageCount, String status,
|
||||||
|
Long destinationInventoryPositionId) {}
|
||||||
public record Detail(Long id, String transferNo, Long inboundOrderId, String inboundCode,
|
public record Detail(Long id, String transferNo, Long inboundOrderId, String inboundCode,
|
||||||
Long lastlegOrderId, String lastlegOrderNo, String orderType, String orderStatus,
|
Long lastlegOrderId, String lastlegOrderNo, String orderType, String orderStatus,
|
||||||
boolean needRelabel, Long relabelApplicationId, String relabelNo, String relabelStatus,
|
boolean needRelabel, Long relabelApplicationId, String relabelNo, String relabelStatus,
|
||||||
String operatingWarehouseId, String fbaWarehouseCode, String deliveryAddress,
|
String operatingWarehouseId, String fbaWarehouseCode, String deliveryAddress,
|
||||||
int packageCount, String deliveryMethod, LocalDateTime estimatedArrivalAt,
|
int packageCount, String deliveryMethod, LocalDateTime estimatedArrivalAt,
|
||||||
String fbaShipmentNo, String trackingNo, String fbaBoxNos, String remark,
|
String fbaShipmentNo, String trackingNo, String fbaBoxNos, String remark,
|
||||||
List<SourceOrder> sourceOrders, List<Item> items, LocalDateTime createdAt) {}
|
List<SourceOrder> sourceOrders, List<Item> items, List<Allocation> allocations,
|
||||||
|
LocalDateTime outboundAt, LocalDateTime createdAt) {}
|
||||||
}
|
}
|
||||||
|
|||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
package com.baiyue.lastleg.transfer.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@TableName("ll_warehouse_transfer_inventory_allocation")
|
||||||
|
public class WarehouseTransferInventoryAllocation {
|
||||||
|
@TableId(type = IdType.INPUT) private Long id;
|
||||||
|
private Long transferOrderId;
|
||||||
|
private String customerId;
|
||||||
|
private Long inventoryPositionId;
|
||||||
|
private Long inventoryBalanceId;
|
||||||
|
private Long inboundOrderId;
|
||||||
|
private String inboundCode;
|
||||||
|
private Long palletId;
|
||||||
|
private String palletNo;
|
||||||
|
private Integer packageCount;
|
||||||
|
private String status;
|
||||||
|
private String activePositionKey;
|
||||||
|
private LocalDateTime allocatedAt;
|
||||||
|
private LocalDateTime shippedAt;
|
||||||
|
private LocalDateTime receivedAt;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
@@ -30,6 +30,11 @@ public class WarehouseTransferOrder {
|
|||||||
private Long relabelApplicationId;
|
private Long relabelApplicationId;
|
||||||
private String relabelNo;
|
private String relabelNo;
|
||||||
private String status;
|
private String status;
|
||||||
|
private String allocationIdempotencyKey;
|
||||||
|
private String allocationRequestHash;
|
||||||
|
private String outboundIdempotencyKey;
|
||||||
|
private String outboundRequestHash;
|
||||||
|
private LocalDateTime outboundAt;
|
||||||
private String idempotencyKey;
|
private String idempotencyKey;
|
||||||
private String requestHash;
|
private String requestHash;
|
||||||
private String createdBy;
|
private String createdBy;
|
||||||
|
|||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
package com.baiyue.lastleg.transfer.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@TableName("ll_warehouse_transfer_receipt")
|
||||||
|
public class WarehouseTransferReceipt {
|
||||||
|
@TableId(type = IdType.INPUT) private Long id;
|
||||||
|
private Long transferOrderId;
|
||||||
|
private Long allocationId;
|
||||||
|
private String customerId;
|
||||||
|
private Long sourceInventoryPositionId;
|
||||||
|
private Long destinationInventoryBalanceId;
|
||||||
|
private Long destinationInventoryPositionId;
|
||||||
|
private Integer packageCount;
|
||||||
|
private String idempotencyKey;
|
||||||
|
private String requestHash;
|
||||||
|
private String inventoryOperationKey;
|
||||||
|
private String createdBy;
|
||||||
|
private LocalDateTime receivedAt;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
package com.baiyue.lastleg.transfer.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.baiyue.lastleg.transfer.entity.WarehouseTransferInventoryAllocation;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface WarehouseTransferInventoryAllocationMapper extends BaseMapper<WarehouseTransferInventoryAllocation> {
|
||||||
|
@Select("SELECT * FROM ll_warehouse_transfer_inventory_allocation WHERE customer_id=#{customerId} " +
|
||||||
|
"AND transfer_order_id=#{transferOrderId} ORDER BY id FOR UPDATE")
|
||||||
|
List<WarehouseTransferInventoryAllocation> lockTransfer(@Param("customerId") String customerId,
|
||||||
|
@Param("transferOrderId") Long transferOrderId);
|
||||||
|
|
||||||
|
@Select("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE customer_id=#{customerId} " +
|
||||||
|
"AND warehouse_pallet_id=#{palletId} AND status='ACTIVE'")
|
||||||
|
int countActiveDispatchLock(@Param("customerId") String customerId, @Param("palletId") Long palletId);
|
||||||
|
}
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
package com.baiyue.lastleg.transfer.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.baiyue.lastleg.transfer.entity.WarehouseTransferReceipt;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface WarehouseTransferReceiptMapper extends BaseMapper<WarehouseTransferReceipt> {
|
||||||
|
@Select("SELECT * FROM ll_warehouse_transfer_receipt WHERE customer_id=#{customerId} " +
|
||||||
|
"AND transfer_order_id=#{transferOrderId} AND source_inventory_position_id=#{sourcePositionId} LIMIT 1")
|
||||||
|
WarehouseTransferReceipt findSource(@Param("customerId") String customerId,
|
||||||
|
@Param("transferOrderId") Long transferOrderId,
|
||||||
|
@Param("sourcePositionId") Long sourcePositionId);
|
||||||
|
}
|
||||||
+240
-3
@@ -8,6 +8,12 @@ import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
|
|||||||
import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper;
|
import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper;
|
||||||
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
||||||
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
|
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
|
||||||
|
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
|
||||||
|
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse;
|
||||||
|
import com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper;
|
||||||
|
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
|
||||||
|
import com.baiyue.lastleg.inventory.entity.InventoryBalance;
|
||||||
|
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
|
||||||
import com.baiyue.lastleg.relabel.entity.RelabelApplication;
|
import com.baiyue.lastleg.relabel.entity.RelabelApplication;
|
||||||
import com.baiyue.lastleg.relabel.entity.RelabelItem;
|
import com.baiyue.lastleg.relabel.entity.RelabelItem;
|
||||||
import com.baiyue.lastleg.relabel.entity.RelabelStatusHistory;
|
import com.baiyue.lastleg.relabel.entity.RelabelStatusHistory;
|
||||||
@@ -19,15 +25,20 @@ import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
|
|||||||
import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests;
|
import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests;
|
||||||
import com.baiyue.lastleg.transfer.dto.WarehouseTransferResponses;
|
import com.baiyue.lastleg.transfer.dto.WarehouseTransferResponses;
|
||||||
import com.baiyue.lastleg.transfer.entity.WarehouseTransferItem;
|
import com.baiyue.lastleg.transfer.entity.WarehouseTransferItem;
|
||||||
|
import com.baiyue.lastleg.transfer.entity.WarehouseTransferInventoryAllocation;
|
||||||
import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder;
|
import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder;
|
||||||
|
import com.baiyue.lastleg.transfer.entity.WarehouseTransferReceipt;
|
||||||
import com.baiyue.lastleg.transfer.entity.WarehouseTransferSourceOrder;
|
import com.baiyue.lastleg.transfer.entity.WarehouseTransferSourceOrder;
|
||||||
import com.baiyue.lastleg.transfer.mapper.InboundSnapshotMapper;
|
import com.baiyue.lastleg.transfer.mapper.InboundSnapshotMapper;
|
||||||
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferItemMapper;
|
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferItemMapper;
|
||||||
|
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferInventoryAllocationMapper;
|
||||||
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper;
|
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper;
|
||||||
|
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferReceiptMapper;
|
||||||
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferSourceOrderMapper;
|
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferSourceOrderMapper;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import jakarta.annotation.Resource;
|
import jakarta.annotation.Resource;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.dao.DuplicateKeyException;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.StringUtils;
|
||||||
@@ -38,7 +49,12 @@ import java.security.MessageDigest;
|
|||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.util.HexFormat;
|
import java.util.HexFormat;
|
||||||
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.function.Function;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class WarehouseTransferService {
|
public class WarehouseTransferService {
|
||||||
@@ -48,10 +64,16 @@ public class WarehouseTransferService {
|
|||||||
public static final String APPROVED = "APPROVED";
|
public static final String APPROVED = "APPROVED";
|
||||||
public static final String REJECTED = "REJECTED";
|
public static final String REJECTED = "REJECTED";
|
||||||
public static final String PENDING_ASSIGNMENT = "PENDING_ASSIGNMENT";
|
public static final String PENDING_ASSIGNMENT = "PENDING_ASSIGNMENT";
|
||||||
|
public static final String ALLOCATED = "ALLOCATED";
|
||||||
|
public static final String OUTBOUNDED = "OUTBOUNDED";
|
||||||
|
public static final String PARTIALLY_RECEIVED = "PARTIALLY_RECEIVED";
|
||||||
|
public static final String RECEIVED = "RECEIVED";
|
||||||
|
|
||||||
@Resource private WarehouseTransferOrderMapper transfers;
|
@Resource private WarehouseTransferOrderMapper transfers;
|
||||||
@Resource private WarehouseTransferItemMapper transferItems;
|
@Resource private WarehouseTransferItemMapper transferItems;
|
||||||
@Resource private WarehouseTransferSourceOrderMapper sourceOrders;
|
@Resource private WarehouseTransferSourceOrderMapper sourceOrders;
|
||||||
|
@Resource private WarehouseTransferInventoryAllocationMapper inventoryAllocations;
|
||||||
|
@Resource private WarehouseTransferReceiptMapper transferReceipts;
|
||||||
@Resource private InboundSnapshotMapper inboundSnapshots;
|
@Resource private InboundSnapshotMapper inboundSnapshots;
|
||||||
@Resource private LastlegOrderMapper orders;
|
@Resource private LastlegOrderMapper orders;
|
||||||
@Resource private LastlegOrderDetailMapper orderDetails;
|
@Resource private LastlegOrderDetailMapper orderDetails;
|
||||||
@@ -60,6 +82,8 @@ public class WarehouseTransferService {
|
|||||||
@Resource private RelabelItemMapper relabelItems;
|
@Resource private RelabelItemMapper relabelItems;
|
||||||
@Resource private RelabelStatusHistoryMapper relabelHistories;
|
@Resource private RelabelStatusHistoryMapper relabelHistories;
|
||||||
@Resource private LastlegOrderNoGenerator orderNoGenerator;
|
@Resource private LastlegOrderNoGenerator orderNoGenerator;
|
||||||
|
@Resource private InventoryCommandService inventory;
|
||||||
|
@Resource private InventoryCommandMapper inventoryCommands;
|
||||||
@Resource private ObjectMapper objectMapper;
|
@Resource private ObjectMapper objectMapper;
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -148,6 +172,21 @@ public class WarehouseTransferService {
|
|||||||
.eq(WarehouseTransferSourceOrder::getTransferOrderId, transfer.getId()).orderByAsc(WarehouseTransferSourceOrder::getId))
|
.eq(WarehouseTransferSourceOrder::getTransferOrderId, transfer.getId()).orderByAsc(WarehouseTransferSourceOrder::getId))
|
||||||
.stream().map(value -> new WarehouseTransferResponses.SourceOrder(value.getSourceLastlegOrderId(),
|
.stream().map(value -> new WarehouseTransferResponses.SourceOrder(value.getSourceLastlegOrderId(),
|
||||||
value.getSourceLastlegOrderNo(), value.getInboundOrderId(), value.getInboundCode())).toList();
|
value.getSourceLastlegOrderNo(), value.getInboundOrderId(), value.getInboundCode())).toList();
|
||||||
|
Map<Long, WarehouseTransferReceipt> receiptByAllocation = transferReceipts.selectList(
|
||||||
|
new LambdaQueryWrapper<WarehouseTransferReceipt>().eq(WarehouseTransferReceipt::getCustomerId, customerId)
|
||||||
|
.eq(WarehouseTransferReceipt::getTransferOrderId, transfer.getId()))
|
||||||
|
.stream().collect(Collectors.toMap(WarehouseTransferReceipt::getAllocationId, Function.identity()));
|
||||||
|
List<WarehouseTransferResponses.Allocation> allocations = inventoryAllocations.selectList(
|
||||||
|
new LambdaQueryWrapper<WarehouseTransferInventoryAllocation>()
|
||||||
|
.eq(WarehouseTransferInventoryAllocation::getCustomerId, customerId)
|
||||||
|
.eq(WarehouseTransferInventoryAllocation::getTransferOrderId, transfer.getId())
|
||||||
|
.orderByAsc(WarehouseTransferInventoryAllocation::getId))
|
||||||
|
.stream().map(value -> {
|
||||||
|
WarehouseTransferReceipt receipt = receiptByAllocation.get(value.getId());
|
||||||
|
return new WarehouseTransferResponses.Allocation(value.getInventoryPositionId(), value.getInboundOrderId(),
|
||||||
|
value.getInboundCode(), value.getPalletId(), value.getPalletNo(), value.getPackageCount(),
|
||||||
|
value.getStatus(), receipt == null ? null : receipt.getDestinationInventoryPositionId());
|
||||||
|
}).toList();
|
||||||
String relabelStatus = null;
|
String relabelStatus = null;
|
||||||
if (transfer.getRelabelNo() != null) {
|
if (transfer.getRelabelNo() != null) {
|
||||||
RelabelApplication relabel = relabelApplications.findNo(customerId, transfer.getRelabelNo());
|
RelabelApplication relabel = relabelApplications.findNo(customerId, transfer.getRelabelNo());
|
||||||
@@ -162,7 +201,8 @@ public class WarehouseTransferService {
|
|||||||
relabelStatus,
|
relabelStatus,
|
||||||
transfer.getOperatingWarehouseId(), transfer.getFbaWarehouseCode(), transfer.getDeliveryAddress(),
|
transfer.getOperatingWarehouseId(), transfer.getFbaWarehouseCode(), transfer.getDeliveryAddress(),
|
||||||
transfer.getPackageCount(), transfer.getDeliveryMethod(), transfer.getEstimatedArrivalAt(), transfer.getFbaShipmentNo(),
|
transfer.getPackageCount(), transfer.getDeliveryMethod(), transfer.getEstimatedArrivalAt(), transfer.getFbaShipmentNo(),
|
||||||
transfer.getTrackingNo(), transfer.getFbaBoxNos(), transfer.getRemark(), sources, items, transfer.getCreatedAt());
|
transfer.getTrackingNo(), transfer.getFbaBoxNos(), transfer.getRemark(), sources, items, allocations,
|
||||||
|
transfer.getOutboundAt(), transfer.getCreatedAt());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -194,6 +234,194 @@ public class WarehouseTransferService {
|
|||||||
return detail(customerId, transferNo);
|
return detail(customerId, transferNo);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public WarehouseTransferResponses.Detail allocate(String customerId, String transferNo, String operatorId,
|
||||||
|
WarehouseTransferRequests.Allocate request) {
|
||||||
|
WarehouseTransferOrder transfer = transfers.lockNo(customerId, transferNo);
|
||||||
|
if (transfer == null) throw notFound("Warehouse transfer order not found");
|
||||||
|
String key = request.idempotencyKey().trim();
|
||||||
|
String requestHash = hash(request);
|
||||||
|
if (ALLOCATED.equals(transfer.getStatus()) && key.equals(transfer.getAllocationIdempotencyKey())) {
|
||||||
|
if (!requestHash.equals(transfer.getAllocationRequestHash())) {
|
||||||
|
throw conflict("Allocation idempotency key was reused with different inventory positions");
|
||||||
|
}
|
||||||
|
return detail(customerId, transferNo);
|
||||||
|
}
|
||||||
|
if (!APPROVED.equals(transfer.getStatus())) {
|
||||||
|
throw conflict("Only an approved warehouse transfer can allocate inventory");
|
||||||
|
}
|
||||||
|
List<WarehouseTransferRequests.Allocation> requested = request.allocations().stream()
|
||||||
|
.sorted(java.util.Comparator.comparing(WarehouseTransferRequests.Allocation::inventoryPositionId)).toList();
|
||||||
|
Set<Long> uniquePositions = new HashSet<>();
|
||||||
|
long totalPackages = 0;
|
||||||
|
for (WarehouseTransferRequests.Allocation allocation : requested) {
|
||||||
|
if (!uniquePositions.add(allocation.inventoryPositionId())) {
|
||||||
|
throw badRequest("Inventory positions must be unique within a warehouse transfer");
|
||||||
|
}
|
||||||
|
totalPackages = Math.addExact(totalPackages, allocation.packageCount());
|
||||||
|
}
|
||||||
|
if (totalPackages != transfer.getPackageCount()) {
|
||||||
|
throw badRequest("Allocated package total must equal warehouse transfer package count");
|
||||||
|
}
|
||||||
|
Set<Long> sourceInboundIds = sourceOrders.selectList(new LambdaQueryWrapper<WarehouseTransferSourceOrder>()
|
||||||
|
.eq(WarehouseTransferSourceOrder::getCustomerId, customerId)
|
||||||
|
.eq(WarehouseTransferSourceOrder::getTransferOrderId, transfer.getId()))
|
||||||
|
.stream().map(WarehouseTransferSourceOrder::getInboundOrderId).collect(Collectors.toSet());
|
||||||
|
if (sourceInboundIds.isEmpty()) throw conflict("Warehouse transfer has no source inbound orders");
|
||||||
|
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
for (WarehouseTransferRequests.Allocation source : requested) {
|
||||||
|
InventoryPosition position = inventoryCommands.lockPosition(customerId, source.inventoryPositionId());
|
||||||
|
if (position == null) throw notFound("Inventory position not found: " + source.inventoryPositionId());
|
||||||
|
InventoryBalance balance = inventoryCommands.lockBalance(customerId, position.getBalanceId());
|
||||||
|
if (balance == null) throw conflict("Inventory position balance is missing");
|
||||||
|
if (!source.palletId().equals(position.getPalletId())) {
|
||||||
|
throw conflict("Inventory position does not belong to pallet " + source.palletId());
|
||||||
|
}
|
||||||
|
if (inventoryAllocations.countActiveDispatchLock(customerId, position.getPalletId()) > 0) {
|
||||||
|
throw conflict("Inventory pallet is already locked by an active dispatch");
|
||||||
|
}
|
||||||
|
if (!sourceInboundIds.contains(balance.getInboundOrderId())) {
|
||||||
|
throw conflict("Inventory position is not linked to a selected source inbound order");
|
||||||
|
}
|
||||||
|
if (packages(position.getAvailablePackages()) < source.packageCount()) {
|
||||||
|
throw conflict("Inventory position does not have enough available packages");
|
||||||
|
}
|
||||||
|
WarehouseTransferInventoryAllocation allocation = new WarehouseTransferInventoryAllocation();
|
||||||
|
allocation.setId(IdWorker.getId()); allocation.setTransferOrderId(transfer.getId());
|
||||||
|
allocation.setCustomerId(customerId); allocation.setInventoryPositionId(position.getId());
|
||||||
|
allocation.setInventoryBalanceId(balance.getId()); allocation.setInboundOrderId(balance.getInboundOrderId());
|
||||||
|
allocation.setInboundCode(balance.getInboundCode()); allocation.setPalletId(position.getPalletId());
|
||||||
|
allocation.setPalletNo(position.getPalletNo()); allocation.setPackageCount(source.packageCount());
|
||||||
|
allocation.setStatus("RESERVED"); allocation.setActivePositionKey(customerId + ":" + position.getId());
|
||||||
|
allocation.setAllocatedAt(now); allocation.setCreatedAt(now); allocation.setUpdatedAt(now);
|
||||||
|
try {
|
||||||
|
inventoryAllocations.insert(allocation);
|
||||||
|
} catch (DuplicateKeyException exception) {
|
||||||
|
throw conflict("Inventory position is already reserved by another warehouse transfer");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
transfer.setStatus(ALLOCATED); transfer.setAllocationIdempotencyKey(key);
|
||||||
|
transfer.setAllocationRequestHash(requestHash); transfer.setUpdatedAt(now); transfers.updateById(transfer);
|
||||||
|
LastlegOrder order = requireTailOrder(customerId, transfer);
|
||||||
|
String before = order.getCurrentStatus(); order.setCurrentStatus("READY_TO_OUTBOUND"); order.setUpdatedAt(now);
|
||||||
|
orders.updateById(order);
|
||||||
|
insertOrderHistory(order.getId(), before, order.getCurrentStatus(), transferNo,
|
||||||
|
"Warehouse transfer inventory allocated", now);
|
||||||
|
return detail(customerId, transferNo);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public WarehouseTransferResponses.Detail outbound(String customerId, String transferNo, String operatorId,
|
||||||
|
WarehouseTransferRequests.Outbound request) {
|
||||||
|
WarehouseTransferOrder transfer = transfers.lockNo(customerId, transferNo);
|
||||||
|
if (transfer == null) throw notFound("Warehouse transfer order not found");
|
||||||
|
String key = request.idempotencyKey().trim();
|
||||||
|
String requestHash = hash(request);
|
||||||
|
if (Set.of(OUTBOUNDED, PARTIALLY_RECEIVED, RECEIVED).contains(transfer.getStatus())
|
||||||
|
&& key.equals(transfer.getOutboundIdempotencyKey())) {
|
||||||
|
if (!requestHash.equals(transfer.getOutboundRequestHash())) {
|
||||||
|
throw conflict("Outbound idempotency key was reused with different data");
|
||||||
|
}
|
||||||
|
return detail(customerId, transferNo);
|
||||||
|
}
|
||||||
|
if (!ALLOCATED.equals(transfer.getStatus())) {
|
||||||
|
throw conflict("Warehouse transfer inventory must be allocated before outbound");
|
||||||
|
}
|
||||||
|
List<WarehouseTransferInventoryAllocation> allocations = inventoryAllocations.lockTransfer(customerId, transfer.getId());
|
||||||
|
if (allocations.isEmpty() || allocations.stream().mapToLong(WarehouseTransferInventoryAllocation::getPackageCount).sum()
|
||||||
|
!= transfer.getPackageCount()) {
|
||||||
|
throw conflict("Warehouse transfer inventory allocation is incomplete");
|
||||||
|
}
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
for (WarehouseTransferInventoryAllocation allocation : allocations) {
|
||||||
|
if (!"RESERVED".equals(allocation.getStatus())) throw conflict("Warehouse transfer allocation was already processed");
|
||||||
|
String keyBase = "WAREHOUSE_TRANSFER:" + transferNo + ":" + allocation.getInventoryPositionId();
|
||||||
|
InventoryCommandRequests.Move move = new InventoryCommandRequests.Move(customerId,
|
||||||
|
allocation.getInventoryPositionId(), allocation.getPackageCount(), transferNo, transferNo,
|
||||||
|
"WAREHOUSE_TRANSFER_OUTBOUND:" + transferNo, keyBase + ":ALLOCATE", trim(request.remark()));
|
||||||
|
inventory.allocate(customerId, operatorId, move);
|
||||||
|
inventory.pick(customerId, operatorId, new InventoryCommandRequests.Move(customerId,
|
||||||
|
allocation.getInventoryPositionId(), allocation.getPackageCount(), transferNo, transferNo,
|
||||||
|
"WAREHOUSE_TRANSFER_OUTBOUND:" + transferNo, keyBase + ":PICK", trim(request.remark())));
|
||||||
|
inventory.ship(customerId, operatorId, new InventoryCommandRequests.Move(customerId,
|
||||||
|
allocation.getInventoryPositionId(), allocation.getPackageCount(), transferNo, transferNo,
|
||||||
|
"WAREHOUSE_TRANSFER_OUTBOUND:" + transferNo, keyBase + ":SHIP", trim(request.remark())));
|
||||||
|
allocation.setStatus("SHIPPED"); allocation.setActivePositionKey(null);
|
||||||
|
allocation.setShippedAt(now); allocation.setUpdatedAt(now); inventoryAllocations.updateById(allocation);
|
||||||
|
}
|
||||||
|
transfer.setStatus(OUTBOUNDED); transfer.setOutboundIdempotencyKey(key);
|
||||||
|
transfer.setOutboundRequestHash(requestHash); transfer.setOutboundAt(now); transfer.setUpdatedAt(now);
|
||||||
|
transfers.updateById(transfer);
|
||||||
|
LastlegOrder order = requireTailOrder(customerId, transfer);
|
||||||
|
String before = order.getCurrentStatus(); order.setCurrentStatus("IN_TRANSIT"); order.setUpdatedAt(now);
|
||||||
|
orders.updateById(order);
|
||||||
|
insertOrderHistory(order.getId(), before, order.getCurrentStatus(), transferNo,
|
||||||
|
"Warehouse transfer left source warehouse", now);
|
||||||
|
return detail(customerId, transferNo);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public WarehouseTransferResponses.Detail receive(String customerId, String transferNo, String operatorId,
|
||||||
|
WarehouseTransferRequests.Receipt request) {
|
||||||
|
WarehouseTransferOrder transfer = transfers.lockNo(customerId, transferNo);
|
||||||
|
if (transfer == null) throw notFound("Warehouse transfer order not found");
|
||||||
|
if (!Set.of(OUTBOUNDED, PARTIALLY_RECEIVED).contains(transfer.getStatus())) {
|
||||||
|
if (RECEIVED.equals(transfer.getStatus())) {
|
||||||
|
WarehouseTransferReceipt replay = transferReceipts.findSource(customerId, transfer.getId(),
|
||||||
|
request.sourceInventoryPositionId());
|
||||||
|
if (replay != null && replay.getRequestHash().equals(hash(request))) return detail(customerId, transferNo);
|
||||||
|
}
|
||||||
|
throw conflict("Only outbound warehouse transfer inventory can be received");
|
||||||
|
}
|
||||||
|
List<WarehouseTransferInventoryAllocation> allocations = inventoryAllocations.lockTransfer(customerId, transfer.getId());
|
||||||
|
WarehouseTransferInventoryAllocation allocation = allocations.stream()
|
||||||
|
.filter(value -> request.sourceInventoryPositionId().equals(value.getInventoryPositionId()))
|
||||||
|
.findFirst().orElseThrow(() -> notFound("Source inventory position is not allocated to this transfer"));
|
||||||
|
String requestHash = hash(request);
|
||||||
|
WarehouseTransferReceipt existing = transferReceipts.findSource(customerId, transfer.getId(),
|
||||||
|
request.sourceInventoryPositionId());
|
||||||
|
if (existing != null) {
|
||||||
|
if (!requestHash.equals(existing.getRequestHash())) {
|
||||||
|
throw conflict("Transfer source position was already received with different destination data");
|
||||||
|
}
|
||||||
|
return detail(customerId, transferNo);
|
||||||
|
}
|
||||||
|
if (!"SHIPPED".equals(allocation.getStatus())) throw conflict("Transfer allocation is not awaiting receipt");
|
||||||
|
|
||||||
|
String operationKey = "WAREHOUSE_TRANSFER_RECEIPT:" + transferNo + ":" + allocation.getInventoryPositionId();
|
||||||
|
InventoryCommandResponse result = inventory.stockIn(customerId, operatorId,
|
||||||
|
new InventoryCommandRequests.StockIn(customerId, null,
|
||||||
|
request.destinationOverseasWarehouseId().trim(), trim(request.destinationOverseasWarehouseCode()),
|
||||||
|
trim(request.destinationOverseasWarehouseName()), trim(request.destinationWarehouseId()),
|
||||||
|
request.destinationWarehouseCode().trim(), trim(request.destinationWarehouseName()),
|
||||||
|
transfer.getLastlegOrderId(), transfer.getLastlegOrderNo(), transferNo,
|
||||||
|
request.destinationPalletId(), request.destinationPalletNo().trim(),
|
||||||
|
trim(request.batchNo()) == null ? transferNo : request.batchNo().trim(), request.warehouseLocationId(),
|
||||||
|
trim(request.warehouseLocationCode()), allocation.getPackageCount(), transferNo,
|
||||||
|
"WAREHOUSE_TRANSFER_RECEIPT:" + transferNo, operationKey, trim(request.remark())));
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
WarehouseTransferReceipt receipt = new WarehouseTransferReceipt();
|
||||||
|
receipt.setId(IdWorker.getId()); receipt.setTransferOrderId(transfer.getId()); receipt.setAllocationId(allocation.getId());
|
||||||
|
receipt.setCustomerId(customerId); receipt.setSourceInventoryPositionId(allocation.getInventoryPositionId());
|
||||||
|
receipt.setDestinationInventoryBalanceId(result.balanceId()); receipt.setDestinationInventoryPositionId(result.positionId());
|
||||||
|
receipt.setPackageCount(allocation.getPackageCount()); receipt.setIdempotencyKey(request.idempotencyKey().trim());
|
||||||
|
receipt.setRequestHash(requestHash); receipt.setInventoryOperationKey(operationKey); receipt.setCreatedBy(trim(operatorId));
|
||||||
|
receipt.setReceivedAt(now); receipt.setCreatedAt(now); transferReceipts.insert(receipt);
|
||||||
|
allocation.setStatus("RECEIVED"); allocation.setReceivedAt(now); allocation.setUpdatedAt(now);
|
||||||
|
inventoryAllocations.updateById(allocation);
|
||||||
|
|
||||||
|
boolean complete = allocations.stream().allMatch(value -> value.getId().equals(allocation.getId())
|
||||||
|
|| "RECEIVED".equals(value.getStatus()));
|
||||||
|
transfer.setStatus(complete ? RECEIVED : PARTIALLY_RECEIVED); transfer.setUpdatedAt(now); transfers.updateById(transfer);
|
||||||
|
LastlegOrder order = requireTailOrder(customerId, transfer);
|
||||||
|
String before = order.getCurrentStatus(); order.setCurrentStatus(complete ? RECEIVED : PARTIALLY_RECEIVED);
|
||||||
|
order.setUpdatedAt(now); orders.updateById(order);
|
||||||
|
insertOrderHistory(order.getId(), before, order.getCurrentStatus(), transferNo,
|
||||||
|
"Warehouse transfer inventory received at destination", now);
|
||||||
|
return detail(customerId, transferNo);
|
||||||
|
}
|
||||||
|
|
||||||
private LastlegOrder createTailOrder(String customerId, Long inboundOrderId, String transferNo,
|
private LastlegOrder createTailOrder(String customerId, Long inboundOrderId, String transferNo,
|
||||||
WarehouseTransferRequests.Create request, String requestHash, LocalDateTime now) {
|
WarehouseTransferRequests.Create request, String requestHash, LocalDateTime now) {
|
||||||
LastlegOrder order = new LastlegOrder();
|
LastlegOrder order = new LastlegOrder();
|
||||||
@@ -203,8 +431,8 @@ public class WarehouseTransferService {
|
|||||||
order.setSourceInboundId("WAREHOUSE_TRANSFER:" + transferNo); order.setRootInboundId(String.valueOf(inboundOrderId));
|
order.setSourceInboundId("WAREHOUSE_TRANSFER:" + transferNo); order.setRootInboundId(String.valueOf(inboundOrderId));
|
||||||
order.setSourceOrderNo(transferNo); order.setOverseasWarehouseId(request.operatingWarehouseId().trim());
|
order.setSourceOrderNo(transferNo); order.setOverseasWarehouseId(request.operatingWarehouseId().trim());
|
||||||
order.setFbaWarehouseCode(request.fbaWarehouseCode().trim()); order.setPackageCount(request.packageCount());
|
order.setFbaWarehouseCode(request.fbaWarehouseCode().trim()); order.setPackageCount(request.packageCount());
|
||||||
order.setContainerNo(transferNo); order.setTransferWarehouseId(request.operatingWarehouseId().trim());
|
order.setVolumeCbm(request.volumeCbm());
|
||||||
order.setTransferWarehouseCode(request.operatingWarehouseId().trim());
|
order.setContainerNo(transferNo);
|
||||||
order.setPalletCount(0); order.setPickedPackageCount(0); order.setShippedPackageCount(0);
|
order.setPalletCount(0); order.setPickedPackageCount(0); order.setShippedPackageCount(0);
|
||||||
order.setEstimatedWarehouseAt(request.estimatedArrivalAt());
|
order.setEstimatedWarehouseAt(request.estimatedArrivalAt());
|
||||||
order.setCurrentStatus(PENDING_REVIEW); order.setCustomerRemark(trim(request.remark()));
|
order.setCurrentStatus(PENDING_REVIEW); order.setCustomerRemark(trim(request.remark()));
|
||||||
@@ -277,6 +505,14 @@ public class WarehouseTransferService {
|
|||||||
history.setCreatedAt(now); orderHistories.insert(history);
|
history.setCreatedAt(now); orderHistories.insert(history);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private LastlegOrder requireTailOrder(String customerId, WarehouseTransferOrder transfer) {
|
||||||
|
LastlegOrder order = orders.selectById(transfer.getLastlegOrderId());
|
||||||
|
if (order == null || !customerId.equals(order.getCustomerId())) {
|
||||||
|
throw conflict("Linked tail-leg order is missing");
|
||||||
|
}
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
|
||||||
private String hash(Object value) {
|
private String hash(Object value) {
|
||||||
try {
|
try {
|
||||||
byte[] json = objectMapper.copy().findAndRegisterModules().writeValueAsString(value).getBytes(StandardCharsets.UTF_8);
|
byte[] json = objectMapper.copy().findAndRegisterModules().writeValueAsString(value).getBytes(StandardCharsets.UTF_8);
|
||||||
@@ -286,6 +522,7 @@ public class WarehouseTransferService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
|
private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
|
||||||
|
private static long packages(Long value) { return value == null ? 0L : value; }
|
||||||
private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
|
private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
|
||||||
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
|
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
|
||||||
private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); }
|
private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); }
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.baiyue.lastleg.trucking;
|
||||||
|
|
||||||
|
import org.mybatis.spring.annotation.MapperScan;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@MapperScan("com.baiyue.lastleg.trucking.mapper")
|
||||||
|
public class TruckingMapperConfiguration {
|
||||||
|
}
|
||||||
+9
@@ -13,6 +13,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
|||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PatchMapping;
|
import org.springframework.web.bind.annotation.PatchMapping;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
@@ -46,6 +47,14 @@ public class PlatformUsTruckOrderController {
|
|||||||
return ApiResponse.success(service.changeStatus(requiredCustomer(customerId), orderNo, operator(user), request));
|
return ApiResponse.success(service.changeStatus(requiredCustomer(customerId), orderNo, operator(user), request));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{orderNo}/receive-pallets")
|
||||||
|
public ApiResponse<UsTruckOrderResponses.Detail> receivePallets(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@RequestParam String customerId, @PathVariable String orderNo,
|
||||||
|
@Valid @RequestBody UsTruckOrderRequests.ReceivePallets request) {
|
||||||
|
requirePermission(user);
|
||||||
|
return ApiResponse.success(service.receivePallets(requiredCustomer(customerId), orderNo, operator(user), request));
|
||||||
|
}
|
||||||
|
|
||||||
private void requirePlatform(AuthenticatedUser user) {
|
private void requirePlatform(AuthenticatedUser user) {
|
||||||
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
||||||
if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) {
|
if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) {
|
||||||
|
|||||||
@@ -9,18 +9,20 @@ import jakarta.validation.constraints.Size;
|
|||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
public final class UsTruckOrderRequests {
|
public final class UsTruckOrderRequests {
|
||||||
private UsTruckOrderRequests() {}
|
private UsTruckOrderRequests() {}
|
||||||
|
|
||||||
public record Create(
|
public record Create(
|
||||||
@NotBlank @Size(max = 32) String transportType,
|
@Size(max = 32) String transportType,
|
||||||
@NotBlank @Size(max = 255) String origin,
|
@NotBlank @Size(max = 255) String origin,
|
||||||
@Size(max = 500) String originAddress,
|
@Size(max = 500) String originAddress,
|
||||||
@NotBlank @Size(max = 255) String destination,
|
@NotBlank @Size(max = 255) String destination,
|
||||||
@Size(max = 500) String destinationAddress,
|
@Size(max = 500) String destinationAddress,
|
||||||
@NotBlank @Size(max = 32) String containerType,
|
@Size(max = 32) String containerType,
|
||||||
@NotNull @Min(0) Integer packageCount,
|
@NotNull @Min(1) Integer palletCount,
|
||||||
|
@Min(0) Integer packageCount,
|
||||||
@NotNull @DecimalMin("0.00") BigDecimal totalWeightKg,
|
@NotNull @DecimalMin("0.00") BigDecimal totalWeightKg,
|
||||||
@NotNull @DecimalMin("0.00") BigDecimal totalVolumeCbm,
|
@NotNull @DecimalMin("0.00") BigDecimal totalVolumeCbm,
|
||||||
@DecimalMin("0.00") BigDecimal budgetAmount,
|
@DecimalMin("0.00") BigDecimal budgetAmount,
|
||||||
@@ -29,7 +31,19 @@ public final class UsTruckOrderRequests {
|
|||||||
@NotBlank @Size(max = 128) String contactInfo,
|
@NotBlank @Size(max = 128) String contactInfo,
|
||||||
@NotNull LocalDate requestedDate,
|
@NotNull LocalDate requestedDate,
|
||||||
@Size(max = 1000) String remark,
|
@Size(max = 1000) String remark,
|
||||||
@NotBlank @Size(max = 255) String idempotencyKey) {}
|
@NotBlank @Size(max = 255) String idempotencyKey) {
|
||||||
|
/** Backward-compatible constructor for clients still sending legacy transport fields. */
|
||||||
|
public Create(String transportType, String origin, String originAddress, String destination,
|
||||||
|
String destinationAddress, String containerType, Integer packageCount,
|
||||||
|
BigDecimal totalWeightKg, BigDecimal totalVolumeCbm, BigDecimal budgetAmount,
|
||||||
|
String currency, String contactName, String contactInfo, LocalDate requestedDate,
|
||||||
|
String remark, String idempotencyKey) {
|
||||||
|
this(transportType, origin, originAddress, destination, destinationAddress, containerType,
|
||||||
|
packageCount == null || packageCount < 1 ? 1 : packageCount, packageCount,
|
||||||
|
totalWeightKg, totalVolumeCbm, budgetAmount, currency, contactName, contactInfo,
|
||||||
|
requestedDate, remark, idempotencyKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public record Query(
|
public record Query(
|
||||||
@Min(1) long page, @Min(1) @Max(200) long size,
|
@Min(1) long page, @Min(1) @Max(200) long size,
|
||||||
@@ -44,6 +58,7 @@ public final class UsTruckOrderRequests {
|
|||||||
@Size(max = 255) String destination,
|
@Size(max = 255) String destination,
|
||||||
@Size(max = 500) String destinationAddress,
|
@Size(max = 500) String destinationAddress,
|
||||||
@Size(max = 32) String containerType,
|
@Size(max = 32) String containerType,
|
||||||
|
@Min(1) Integer palletCount,
|
||||||
@Min(0) Integer packageCount,
|
@Min(0) Integer packageCount,
|
||||||
@DecimalMin("0.00") BigDecimal totalWeightKg,
|
@DecimalMin("0.00") BigDecimal totalWeightKg,
|
||||||
@DecimalMin("0.00") BigDecimal totalVolumeCbm,
|
@DecimalMin("0.00") BigDecimal totalVolumeCbm,
|
||||||
@@ -58,4 +73,17 @@ public final class UsTruckOrderRequests {
|
|||||||
@NotNull @Min(0) Long version,
|
@NotNull @Min(0) Long version,
|
||||||
@NotBlank @Size(max = 32) String status,
|
@NotBlank @Size(max = 32) String status,
|
||||||
@Size(max = 1000) String remark) {}
|
@Size(max = 1000) String remark) {}
|
||||||
|
|
||||||
|
public record ReceivePallets(
|
||||||
|
@NotNull @Min(0) Long version,
|
||||||
|
@NotNull @Min(1) Integer actualPalletCount,
|
||||||
|
@Min(0) Integer packageCount,
|
||||||
|
@Size(max = 500) List<@Min(0) Integer> palletPackageCounts,
|
||||||
|
@NotBlank @Size(max = 255) String idempotencyKey,
|
||||||
|
@Size(max = 1000) String remark) {
|
||||||
|
public ReceivePallets(Long version, Integer actualPalletCount, Integer packageCount,
|
||||||
|
String idempotencyKey, String remark) {
|
||||||
|
this(version, actualPalletCount, packageCount, List.of(), idempotencyKey, remark);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ public final class UsTruckOrderResponses {
|
|||||||
|
|
||||||
public record Summary(
|
public record Summary(
|
||||||
Long id, String customerId, String orderNo, String transportType,
|
Long id, String customerId, String orderNo, String transportType,
|
||||||
String origin, String destination, String containerType, int packageCount,
|
String origin, String destination, String containerType, int palletCount, int packageCount,
|
||||||
|
int receivedPalletCount, int allocatedPalletCount, int shippedPalletCount,
|
||||||
BigDecimal totalWeightKg, BigDecimal totalVolumeCbm, BigDecimal budgetAmount,
|
BigDecimal totalWeightKg, BigDecimal totalVolumeCbm, BigDecimal budgetAmount,
|
||||||
String currency, String contactName, String contactInfo, LocalDate requestedDate,
|
String currency, String contactName, String contactInfo, LocalDate requestedDate,
|
||||||
String status, long version, LocalDateTime createdAt, LocalDateTime updatedAt) {}
|
String status, long version, LocalDateTime createdAt, LocalDateTime updatedAt) {}
|
||||||
@@ -18,7 +19,10 @@ public final class UsTruckOrderResponses {
|
|||||||
public record History(
|
public record History(
|
||||||
String fromStatus, String toStatus, String operatorId, String remark, LocalDateTime createdAt) {}
|
String fromStatus, String toStatus, String operatorId, String remark, LocalDateTime createdAt) {}
|
||||||
|
|
||||||
|
public record Pallet(Long id, String palletNo, int sequenceNo, String source, String status,
|
||||||
|
Long warehousePalletId, Integer packageCount, LocalDateTime receivedAt) {}
|
||||||
|
|
||||||
public record Detail(
|
public record Detail(
|
||||||
Summary summary, String originAddress, String destinationAddress, String remark,
|
Summary summary, String originAddress, String destinationAddress, String remark,
|
||||||
String createdBy, List<History> histories) {}
|
String createdBy, List<Pallet> pallets, List<History> histories) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,11 @@ public class UsTruckOrder {
|
|||||||
private String destination;
|
private String destination;
|
||||||
private String destinationAddress;
|
private String destinationAddress;
|
||||||
private String containerType;
|
private String containerType;
|
||||||
|
private Integer palletCount;
|
||||||
private Integer packageCount;
|
private Integer packageCount;
|
||||||
|
private Integer receivedPalletCount;
|
||||||
|
private Integer allocatedPalletCount;
|
||||||
|
private Integer shippedPalletCount;
|
||||||
private BigDecimal totalWeightKg;
|
private BigDecimal totalWeightKg;
|
||||||
private BigDecimal totalVolumeCbm;
|
private BigDecimal totalVolumeCbm;
|
||||||
private BigDecimal budgetAmount;
|
private BigDecimal budgetAmount;
|
||||||
@@ -33,6 +37,8 @@ public class UsTruckOrder {
|
|||||||
private String remark;
|
private String remark;
|
||||||
private String idempotencyKey;
|
private String idempotencyKey;
|
||||||
private String requestHash;
|
private String requestHash;
|
||||||
|
private String receiptIdempotencyKey;
|
||||||
|
private String receiptRequestHash;
|
||||||
private String createdBy;
|
private String createdBy;
|
||||||
private Long version;
|
private Long version;
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.baiyue.lastleg.trucking.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@TableName("ll_us_truck_order_pallet")
|
||||||
|
public class UsTruckOrderPallet {
|
||||||
|
@TableId(type = IdType.INPUT) private Long id;
|
||||||
|
private Long orderId;
|
||||||
|
private String customerId;
|
||||||
|
private String palletNo;
|
||||||
|
private Integer sequenceNo;
|
||||||
|
private String source;
|
||||||
|
private String status;
|
||||||
|
private Long warehousePalletId;
|
||||||
|
private Integer packageCount;
|
||||||
|
private LocalDateTime receivedAt;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
package com.baiyue.lastleg.trucking.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.baiyue.lastleg.trucking.entity.UsTruckOrderPallet;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface UsTruckOrderPalletMapper extends BaseMapper<UsTruckOrderPallet> {
|
||||||
|
@Select("SELECT * FROM ll_us_truck_order_pallet WHERE customer_id=#{customerId} AND order_id=#{orderId} ORDER BY sequence_no")
|
||||||
|
List<UsTruckOrderPallet> listOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId);
|
||||||
|
}
|
||||||
+138
-13
@@ -7,8 +7,10 @@ import com.baiyue.common.core.api.PageResult;
|
|||||||
import com.baiyue.lastleg.trucking.dto.UsTruckOrderRequests;
|
import com.baiyue.lastleg.trucking.dto.UsTruckOrderRequests;
|
||||||
import com.baiyue.lastleg.trucking.dto.UsTruckOrderResponses;
|
import com.baiyue.lastleg.trucking.dto.UsTruckOrderResponses;
|
||||||
import com.baiyue.lastleg.trucking.entity.UsTruckOrder;
|
import com.baiyue.lastleg.trucking.entity.UsTruckOrder;
|
||||||
|
import com.baiyue.lastleg.trucking.entity.UsTruckOrderPallet;
|
||||||
import com.baiyue.lastleg.trucking.entity.UsTruckOrderStatusHistory;
|
import com.baiyue.lastleg.trucking.entity.UsTruckOrderStatusHistory;
|
||||||
import com.baiyue.lastleg.trucking.mapper.UsTruckOrderMapper;
|
import com.baiyue.lastleg.trucking.mapper.UsTruckOrderMapper;
|
||||||
|
import com.baiyue.lastleg.trucking.mapper.UsTruckOrderPalletMapper;
|
||||||
import com.baiyue.lastleg.trucking.mapper.UsTruckOrderStatusHistoryMapper;
|
import com.baiyue.lastleg.trucking.mapper.UsTruckOrderStatusHistoryMapper;
|
||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
@@ -34,17 +36,39 @@ import java.util.function.Consumer;
|
|||||||
@Service
|
@Service
|
||||||
public class UsTruckOrderService {
|
public class UsTruckOrderService {
|
||||||
public static final String MANAGE_PERMISSION = "lastleg:us-trucking:manage";
|
public static final String MANAGE_PERMISSION = "lastleg:us-trucking:manage";
|
||||||
public static final String PENDING_CONFIRMATION = "PENDING_CONFIRMATION";
|
public static final String PENDING_REVIEW = "PENDING_REVIEW";
|
||||||
|
/** Kept for source compatibility; new orders use PENDING_REVIEW. */
|
||||||
|
@Deprecated public static final String PENDING_CONFIRMATION = PENDING_REVIEW;
|
||||||
|
public static final String WAITING_INBOUND = "WAITING_INBOUND";
|
||||||
|
public static final String RECEIVING = "RECEIVING";
|
||||||
|
public static final String WAITING_CONSOLIDATION = "WAITING_CONSOLIDATION";
|
||||||
|
public static final String PALLET_OVERAGE_REVIEW = "PALLET_OVERAGE_REVIEW";
|
||||||
|
public static final String PARTIALLY_ALLOCATED = "PARTIALLY_ALLOCATED";
|
||||||
|
public static final String ALLOCATED = "ALLOCATED";
|
||||||
public static final String PENDING_DISPATCH = "PENDING_DISPATCH";
|
public static final String PENDING_DISPATCH = "PENDING_DISPATCH";
|
||||||
public static final String DISPATCHED = "DISPATCHED";
|
public static final String DISPATCHED = "DISPATCHED";
|
||||||
public static final String IN_TRANSIT = "IN_TRANSIT";
|
public static final String IN_TRANSIT = "IN_TRANSIT";
|
||||||
public static final String COMPLETED = "COMPLETED";
|
public static final String COMPLETED = "COMPLETED";
|
||||||
|
public static final String REJECTED = "REJECTED";
|
||||||
public static final String CANCELLED = "CANCELLED";
|
public static final String CANCELLED = "CANCELLED";
|
||||||
private static final Set<String> STATUSES = Set.of(PENDING_CONFIRMATION, PENDING_DISPATCH, DISPATCHED,
|
private static final Set<String> STATUSES = Set.of(PENDING_REVIEW, WAITING_INBOUND, RECEIVING,
|
||||||
IN_TRANSIT, COMPLETED, CANCELLED);
|
WAITING_CONSOLIDATION, PALLET_OVERAGE_REVIEW, PARTIALLY_ALLOCATED, ALLOCATED,
|
||||||
|
PENDING_DISPATCH, DISPATCHED, IN_TRANSIT, COMPLETED, REJECTED, CANCELLED);
|
||||||
|
private static final java.util.Map<String, Set<String>> TRANSITIONS = java.util.Map.ofEntries(
|
||||||
|
java.util.Map.entry(PENDING_REVIEW, Set.of(WAITING_INBOUND, REJECTED, CANCELLED)),
|
||||||
|
java.util.Map.entry(WAITING_INBOUND, Set.of(RECEIVING, CANCELLED)),
|
||||||
|
java.util.Map.entry(RECEIVING, Set.of(WAITING_CONSOLIDATION, PALLET_OVERAGE_REVIEW, CANCELLED)),
|
||||||
|
java.util.Map.entry(PALLET_OVERAGE_REVIEW, Set.of(WAITING_CONSOLIDATION, CANCELLED)),
|
||||||
|
java.util.Map.entry(WAITING_CONSOLIDATION, Set.of(PARTIALLY_ALLOCATED, ALLOCATED, CANCELLED)),
|
||||||
|
java.util.Map.entry(PARTIALLY_ALLOCATED, Set.of(ALLOCATED, CANCELLED)),
|
||||||
|
java.util.Map.entry(ALLOCATED, Set.of(PENDING_DISPATCH, CANCELLED)),
|
||||||
|
java.util.Map.entry(PENDING_DISPATCH, Set.of(DISPATCHED, CANCELLED)),
|
||||||
|
java.util.Map.entry(DISPATCHED, Set.of(IN_TRANSIT)),
|
||||||
|
java.util.Map.entry(IN_TRANSIT, Set.of(COMPLETED)));
|
||||||
private static final Set<String> TRANSPORT_TYPES = Set.of("PORT_PICKUP", "WAREHOUSE_TO_WAREHOUSE", "WAREHOUSE_TO_DOOR");
|
private static final Set<String> TRANSPORT_TYPES = Set.of("PORT_PICKUP", "WAREHOUSE_TO_WAREHOUSE", "WAREHOUSE_TO_DOOR");
|
||||||
|
|
||||||
@Resource private UsTruckOrderMapper orders;
|
@Resource private UsTruckOrderMapper orders;
|
||||||
|
@Resource private UsTruckOrderPalletMapper pallets;
|
||||||
@Resource private UsTruckOrderStatusHistoryMapper histories;
|
@Resource private UsTruckOrderStatusHistoryMapper histories;
|
||||||
@Resource private ObjectMapper objectMapper;
|
@Resource private ObjectMapper objectMapper;
|
||||||
|
|
||||||
@@ -54,20 +78,22 @@ public class UsTruckOrderService {
|
|||||||
String hash = hash(request);
|
String hash = hash(request);
|
||||||
UsTruckOrder replay = orders.findKey(customerId, key);
|
UsTruckOrder replay = orders.findKey(customerId, key);
|
||||||
if (replay != null) return replay(customerId, replay, hash);
|
if (replay != null) return replay(customerId, replay, hash);
|
||||||
validateTransport(request.transportType());
|
if (StringUtils.hasText(request.transportType())) validateTransport(request.transportType());
|
||||||
LocalDateTime now = LocalDateTime.now();
|
LocalDateTime now = LocalDateTime.now();
|
||||||
UsTruckOrder order = new UsTruckOrder();
|
UsTruckOrder order = new UsTruckOrder();
|
||||||
order.setId(IdWorker.getId());
|
order.setId(IdWorker.getId());
|
||||||
order.setCustomerId(customerId);
|
order.setCustomerId(customerId);
|
||||||
order.setOrderNo("TK" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + order.getId());
|
order.setOrderNo("TK" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + order.getId());
|
||||||
order.setTransportType(request.transportType().trim().toUpperCase());
|
order.setTransportType(StringUtils.hasText(request.transportType()) ? norm(request.transportType()) : null);
|
||||||
order.setOrigin(trim(request.origin())); order.setOriginAddress(trim(request.originAddress()));
|
order.setOrigin(trim(request.origin())); order.setOriginAddress(trim(request.originAddress()));
|
||||||
order.setDestination(trim(request.destination())); order.setDestinationAddress(trim(request.destinationAddress()));
|
order.setDestination(trim(request.destination())); order.setDestinationAddress(trim(request.destinationAddress()));
|
||||||
order.setContainerType(request.containerType().trim().toUpperCase()); order.setPackageCount(request.packageCount());
|
order.setContainerType(StringUtils.hasText(request.containerType()) ? norm(request.containerType()) : null);
|
||||||
|
order.setPalletCount(request.palletCount()); order.setPackageCount(value(request.packageCount()));
|
||||||
|
order.setReceivedPalletCount(0); order.setAllocatedPalletCount(0); order.setShippedPalletCount(0);
|
||||||
order.setTotalWeightKg(request.totalWeightKg()); order.setTotalVolumeCbm(request.totalVolumeCbm());
|
order.setTotalWeightKg(request.totalWeightKg()); order.setTotalVolumeCbm(request.totalVolumeCbm());
|
||||||
order.setBudgetAmount(request.budgetAmount()); order.setCurrency(defaultCurrency(request.currency()));
|
order.setBudgetAmount(request.budgetAmount()); order.setCurrency(defaultCurrency(request.currency()));
|
||||||
order.setContactName(request.contactName().trim()); order.setContactInfo(request.contactInfo().trim());
|
order.setContactName(request.contactName().trim()); order.setContactInfo(request.contactInfo().trim());
|
||||||
order.setRequestedDate(request.requestedDate()); order.setStatus(PENDING_CONFIRMATION);
|
order.setRequestedDate(request.requestedDate()); order.setStatus(PENDING_REVIEW);
|
||||||
order.setRemark(trim(request.remark())); order.setIdempotencyKey(key); order.setRequestHash(hash);
|
order.setRemark(trim(request.remark())); order.setIdempotencyKey(key); order.setRequestHash(hash);
|
||||||
order.setCreatedBy(operatorId); order.setVersion(0L); order.setCreatedAt(now); order.setUpdatedAt(now);
|
order.setCreatedBy(operatorId); order.setVersion(0L); order.setCreatedAt(now); order.setUpdatedAt(now);
|
||||||
try { orders.insert(order); }
|
try { orders.insert(order); }
|
||||||
@@ -76,7 +102,7 @@ public class UsTruckOrderService {
|
|||||||
if (concurrent != null) return replay(customerId, concurrent, hash);
|
if (concurrent != null) return replay(customerId, concurrent, hash);
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
history(order, null, PENDING_CONFIRMATION, operatorId, request.remark(), now);
|
history(order, null, PENDING_REVIEW, operatorId, request.remark(), now);
|
||||||
return detail(customerId, order.getOrderNo());
|
return detail(customerId, order.getOrderNo());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,11 +136,13 @@ public class UsTruckOrderService {
|
|||||||
public UsTruckOrderResponses.Detail update(String customerId, String orderNo, UsTruckOrderRequests.Update request) {
|
public UsTruckOrderResponses.Detail update(String customerId, String orderNo, UsTruckOrderRequests.Update request) {
|
||||||
UsTruckOrder order = lock(customerId, orderNo);
|
UsTruckOrder order = lock(customerId, orderNo);
|
||||||
if (!request.version().equals(value(order.getVersion()))) throw conflict("Order was changed; refresh and retry");
|
if (!request.version().equals(value(order.getVersion()))) throw conflict("Order was changed; refresh and retry");
|
||||||
if (!PENDING_CONFIRMATION.equals(order.getStatus())) throw conflict("Only pending orders can be edited");
|
if (!PENDING_REVIEW.equals(order.getStatus())) throw conflict("Only pending review orders can be edited");
|
||||||
if (request.transportType() != null) { validateTransport(request.transportType()); order.setTransportType(norm(request.transportType())); }
|
if (request.transportType() != null) { validateTransport(request.transportType()); order.setTransportType(norm(request.transportType())); }
|
||||||
setText(request.origin(), order::setOrigin); setPresent(request.originAddress(), order::setOriginAddress);
|
setText(request.origin(), order::setOrigin); setPresent(request.originAddress(), order::setOriginAddress);
|
||||||
setText(request.destination(), order::setDestination); setPresent(request.destinationAddress(), order::setDestinationAddress);
|
setText(request.destination(), order::setDestination); setPresent(request.destinationAddress(), order::setDestinationAddress);
|
||||||
setText(request.containerType(), v -> order.setContainerType(norm(v))); if (request.packageCount() != null) order.setPackageCount(request.packageCount());
|
setText(request.containerType(), v -> order.setContainerType(norm(v)));
|
||||||
|
if (request.palletCount() != null) order.setPalletCount(request.palletCount());
|
||||||
|
if (request.packageCount() != null) order.setPackageCount(request.packageCount());
|
||||||
if (request.totalWeightKg() != null) order.setTotalWeightKg(request.totalWeightKg()); if (request.totalVolumeCbm() != null) order.setTotalVolumeCbm(request.totalVolumeCbm());
|
if (request.totalWeightKg() != null) order.setTotalWeightKg(request.totalWeightKg()); if (request.totalVolumeCbm() != null) order.setTotalVolumeCbm(request.totalVolumeCbm());
|
||||||
if (request.budgetAmount() != null) order.setBudgetAmount(request.budgetAmount()); if (request.currency() != null) order.setCurrency(defaultCurrency(request.currency()));
|
if (request.budgetAmount() != null) order.setBudgetAmount(request.budgetAmount()); if (request.currency() != null) order.setCurrency(defaultCurrency(request.currency()));
|
||||||
setText(request.contactName(), order::setContactName); setText(request.contactInfo(), order::setContactInfo);
|
setText(request.contactName(), order::setContactName); setText(request.contactInfo(), order::setContactInfo);
|
||||||
@@ -128,15 +156,112 @@ public class UsTruckOrderService {
|
|||||||
UsTruckOrder order = lock(customerId, orderNo);
|
UsTruckOrder order = lock(customerId, orderNo);
|
||||||
if (!request.version().equals(value(order.getVersion()))) throw conflict("Order was changed; refresh and retry");
|
if (!request.version().equals(value(order.getVersion()))) throw conflict("Order was changed; refresh and retry");
|
||||||
String next = norm(request.status()); if (!STATUSES.contains(next)) throw badRequest("Unsupported truck order status");
|
String next = norm(request.status()); if (!STATUSES.contains(next)) throw badRequest("Unsupported truck order status");
|
||||||
String from = order.getStatus(); if (!next.equals(from)) { order.setStatus(next); touch(order); orders.updateById(order); history(order, from, next, operatorId, request.remark(), LocalDateTime.now()); }
|
String from = order.getStatus();
|
||||||
|
if (!next.equals(from)) {
|
||||||
|
if (!TRANSITIONS.getOrDefault(from, Set.of()).contains(next)) {
|
||||||
|
throw conflict("Unsupported truck order transition: " + from + " -> " + next);
|
||||||
|
}
|
||||||
|
order.setStatus(next);
|
||||||
|
if (PENDING_REVIEW.equals(from) && WAITING_INBOUND.equals(next)) createPlannedPallets(order);
|
||||||
|
touch(order); orders.updateById(order);
|
||||||
|
history(order, from, next, operatorId, request.remark(), LocalDateTime.now());
|
||||||
|
}
|
||||||
|
return detail(customerId, orderNo);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public UsTruckOrderResponses.Detail receivePallets(String customerId, String orderNo, String operatorId,
|
||||||
|
UsTruckOrderRequests.ReceivePallets request) {
|
||||||
|
UsTruckOrder order = lock(customerId, orderNo);
|
||||||
|
String key = request.idempotencyKey().trim();
|
||||||
|
String requestHash = hash(request);
|
||||||
|
if (key.equals(order.getReceiptIdempotencyKey())) {
|
||||||
|
if (!requestHash.equals(order.getReceiptRequestHash())) {
|
||||||
|
throw conflict("Receipt idempotency key was reused with different pallet data");
|
||||||
|
}
|
||||||
|
return detail(customerId, orderNo);
|
||||||
|
}
|
||||||
|
if (!request.version().equals(value(order.getVersion()))) throw conflict("Order was changed; refresh and retry");
|
||||||
|
if (!Set.of(WAITING_INBOUND, RECEIVING).contains(order.getStatus())) {
|
||||||
|
throw conflict("Only waiting or receiving orders can receive pallets");
|
||||||
|
}
|
||||||
|
|
||||||
|
createPlannedPallets(order);
|
||||||
|
List<UsTruckOrderPallet> current = pallets.listOrder(customerId, order.getId());
|
||||||
|
int planned = value(order.getPalletCount());
|
||||||
|
int actual = request.actualPalletCount();
|
||||||
|
List<Integer> packageCounts = request.palletPackageCounts() == null ? List.of()
|
||||||
|
: request.palletPackageCounts();
|
||||||
|
if (!packageCounts.isEmpty() && packageCounts.size() != actual) {
|
||||||
|
throw badRequest("palletPackageCounts must be empty or contain one value per actual pallet");
|
||||||
|
}
|
||||||
|
if (!packageCounts.isEmpty() && request.packageCount() != null
|
||||||
|
&& packageCounts.stream().mapToInt(Integer::intValue).sum() != request.packageCount()) {
|
||||||
|
throw badRequest("Per-pallet package counts must equal the received package total");
|
||||||
|
}
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
for (int sequence = 1; sequence <= actual; sequence++) {
|
||||||
|
UsTruckOrderPallet pallet = sequence <= current.size() ? current.get(sequence - 1)
|
||||||
|
: newPallet(order, sequence, "PDA_OVERAGE", now);
|
||||||
|
pallet.setStatus(sequence > planned ? "OVERAGE_PENDING_REVIEW" : "RECEIVED");
|
||||||
|
if (!packageCounts.isEmpty()) pallet.setPackageCount(packageCounts.get(sequence - 1));
|
||||||
|
else if (actual == 1) pallet.setPackageCount(request.packageCount());
|
||||||
|
pallet.setReceivedAt(now);
|
||||||
|
pallet.setUpdatedAt(now);
|
||||||
|
if (pallet.getId() == null) {
|
||||||
|
pallet.setId(IdWorker.getId());
|
||||||
|
pallets.insert(pallet);
|
||||||
|
} else {
|
||||||
|
pallets.updateById(pallet);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
order.setReceivedPalletCount(actual);
|
||||||
|
order.setReceiptIdempotencyKey(key);
|
||||||
|
order.setReceiptRequestHash(requestHash);
|
||||||
|
String from = order.getStatus();
|
||||||
|
String next = actual > planned ? PALLET_OVERAGE_REVIEW
|
||||||
|
: actual == planned ? WAITING_CONSOLIDATION : RECEIVING;
|
||||||
|
order.setStatus(next);
|
||||||
|
touch(order);
|
||||||
|
orders.updateById(order);
|
||||||
|
history(order, from, next, operatorId, request.remark(), now);
|
||||||
return detail(customerId, orderNo);
|
return detail(customerId, orderNo);
|
||||||
}
|
}
|
||||||
|
|
||||||
private UsTruckOrder lock(String customerId, String orderNo) { UsTruckOrder o = orders.lockNo(customerId, orderNo); if (o == null) throw notFound(); return o; }
|
private UsTruckOrder lock(String customerId, String orderNo) { UsTruckOrder o = orders.lockNo(customerId, orderNo); if (o == null) throw notFound(); return o; }
|
||||||
private UsTruckOrderResponses.Detail replay(String customerId, UsTruckOrder order, String hash) { if (!hash.equals(order.getRequestHash())) throw conflict("Idempotency key was reused with different truck data"); return detail(customerId, order.getOrderNo()); }
|
private UsTruckOrderResponses.Detail replay(String customerId, UsTruckOrder order, String hash) { if (!hash.equals(order.getRequestHash())) throw conflict("Idempotency key was reused with different truck data"); return detail(customerId, order.getOrderNo()); }
|
||||||
private UsTruckOrderResponses.Detail detail(UsTruckOrder o, List<UsTruckOrderStatusHistory> hs) { return new UsTruckOrderResponses.Detail(summary(o), o.getOriginAddress(), o.getDestinationAddress(), o.getRemark(), o.getCreatedBy(), hs.stream().map(h -> new UsTruckOrderResponses.History(h.getFromStatus(), h.getToStatus(), h.getOperatorId(), h.getRemark(), h.getCreatedAt())).toList()); }
|
private UsTruckOrderResponses.Detail detail(UsTruckOrder o, List<UsTruckOrderStatusHistory> hs) {
|
||||||
private UsTruckOrderResponses.Summary summary(UsTruckOrder o) { return new UsTruckOrderResponses.Summary(o.getId(), o.getCustomerId(), o.getOrderNo(), o.getTransportType(), o.getOrigin(), o.getDestination(), o.getContainerType(), value(o.getPackageCount()), o.getTotalWeightKg(), o.getTotalVolumeCbm(), o.getBudgetAmount(), o.getCurrency(), o.getContactName(), o.getContactInfo(), o.getRequestedDate(), o.getStatus(), value(o.getVersion()), o.getCreatedAt(), o.getUpdatedAt()); }
|
List<UsTruckOrderResponses.Pallet> palletDetails = pallets.listOrder(o.getCustomerId(), o.getId()).stream()
|
||||||
|
.map(this::pallet)
|
||||||
|
.toList();
|
||||||
|
return new UsTruckOrderResponses.Detail(summary(o), o.getOriginAddress(), o.getDestinationAddress(),
|
||||||
|
o.getRemark(), o.getCreatedBy(), palletDetails,
|
||||||
|
hs.stream().map(h -> new UsTruckOrderResponses.History(h.getFromStatus(), h.getToStatus(),
|
||||||
|
h.getOperatorId(), h.getRemark(), h.getCreatedAt())).toList());
|
||||||
|
}
|
||||||
|
private UsTruckOrderResponses.Pallet pallet(UsTruckOrderPallet p) {
|
||||||
|
return new UsTruckOrderResponses.Pallet(p.getId(), p.getPalletNo(), value(p.getSequenceNo()),
|
||||||
|
p.getSource(), p.getStatus(), p.getWarehousePalletId(), p.getPackageCount(), p.getReceivedAt());
|
||||||
|
}
|
||||||
|
private UsTruckOrderResponses.Summary summary(UsTruckOrder o) { return new UsTruckOrderResponses.Summary(o.getId(), o.getCustomerId(), o.getOrderNo(), o.getTransportType(), o.getOrigin(), o.getDestination(), o.getContainerType(), value(o.getPalletCount()), value(o.getPackageCount()), value(o.getReceivedPalletCount()), value(o.getAllocatedPalletCount()), value(o.getShippedPalletCount()), o.getTotalWeightKg(), o.getTotalVolumeCbm(), o.getBudgetAmount(), o.getCurrency(), o.getContactName(), o.getContactInfo(), o.getRequestedDate(), o.getStatus(), value(o.getVersion()), o.getCreatedAt(), o.getUpdatedAt()); }
|
||||||
private void history(UsTruckOrder o, String from, String to, String operator, String remark, LocalDateTime at) { UsTruckOrderStatusHistory h = new UsTruckOrderStatusHistory(); h.setId(IdWorker.getId()); h.setOrderId(o.getId()); h.setCustomerId(o.getCustomerId()); h.setFromStatus(from); h.setToStatus(to); h.setOperatorId(operator); h.setRemark(trim(remark)); h.setCreatedAt(at); histories.insert(h); }
|
private void history(UsTruckOrder o, String from, String to, String operator, String remark, LocalDateTime at) { UsTruckOrderStatusHistory h = new UsTruckOrderStatusHistory(); h.setId(IdWorker.getId()); h.setOrderId(o.getId()); h.setCustomerId(o.getCustomerId()); h.setFromStatus(from); h.setToStatus(to); h.setOperatorId(operator); h.setRemark(trim(remark)); h.setCreatedAt(at); histories.insert(h); }
|
||||||
|
private void createPlannedPallets(UsTruckOrder order) {
|
||||||
|
int existing = pallets.listOrder(order.getCustomerId(), order.getId()).size();
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
for (int sequence = existing + 1; sequence <= value(order.getPalletCount()); sequence++) {
|
||||||
|
UsTruckOrderPallet pallet = newPallet(order, sequence, "ORDER_PLAN", now);
|
||||||
|
pallet.setId(IdWorker.getId());
|
||||||
|
pallets.insert(pallet);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private UsTruckOrderPallet newPallet(UsTruckOrder order, int sequence, String source, LocalDateTime now) {
|
||||||
|
UsTruckOrderPallet pallet = new UsTruckOrderPallet();
|
||||||
|
pallet.setOrderId(order.getId()); pallet.setCustomerId(order.getCustomerId());
|
||||||
|
pallet.setPalletNo(order.getOrderNo() + "-P" + String.format("%03d", sequence));
|
||||||
|
pallet.setSequenceNo(sequence); pallet.setSource(source); pallet.setStatus("EXPECTED");
|
||||||
|
pallet.setCreatedAt(now); pallet.setUpdatedAt(now);
|
||||||
|
return pallet;
|
||||||
|
}
|
||||||
private void validateQuery(UsTruckOrderRequests.Query q) { if (StringUtils.hasText(q.status()) && !STATUSES.contains(norm(q.status()))) throw badRequest("Unsupported truck order status"); if (StringUtils.hasText(q.transportType())) validateTransport(q.transportType()); if (q.requestedFrom() != null && q.requestedTo() != null && q.requestedFrom().isAfter(q.requestedTo())) throw badRequest("requestedFrom must not be after requestedTo"); }
|
private void validateQuery(UsTruckOrderRequests.Query q) { if (StringUtils.hasText(q.status()) && !STATUSES.contains(norm(q.status()))) throw badRequest("Unsupported truck order status"); if (StringUtils.hasText(q.transportType())) validateTransport(q.transportType()); if (q.requestedFrom() != null && q.requestedTo() != null && q.requestedFrom().isAfter(q.requestedTo())) throw badRequest("requestedFrom must not be after requestedTo"); }
|
||||||
private void validateTransport(String v) { if (!TRANSPORT_TYPES.contains(norm(v))) throw badRequest("Unsupported transport type"); }
|
private void validateTransport(String v) { if (!TRANSPORT_TYPES.contains(norm(v))) throw badRequest("Unsupported transport type"); }
|
||||||
private String hash(Object v) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(objectMapper.writeValueAsBytes(v))); } catch (JsonProcessingException | NoSuchAlgorithmException e) { throw new IllegalStateException("Cannot hash truck request", e); } }
|
private String hash(Object v) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(objectMapper.writeValueAsBytes(v))); } catch (JsonProcessingException | NoSuchAlgorithmException e) { throw new IllegalStateException("Cannot hash truck request", e); } }
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+164
@@ -0,0 +1,164 @@
|
|||||||
|
package com.baiyue.lastleg.discard;
|
||||||
|
|
||||||
|
import com.baiyue.lastleg.discard.dto.CargoDiscardRequests;
|
||||||
|
import com.baiyue.lastleg.discard.service.CargoDiscardInventoryAdjustmentService;
|
||||||
|
import com.baiyue.lastleg.discard.service.CargoDiscardService;
|
||||||
|
import org.mybatis.spring.annotation.MapperScan;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.SpringBootConfiguration;
|
||||||
|
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
@SpringBootTest(classes = CargoDiscardMysqlAcceptanceTest.AcceptanceApplication.class, properties = {
|
||||||
|
"spring.sql.init.mode=never",
|
||||||
|
"spring.cloud.nacos.discovery.enabled=false",
|
||||||
|
"spring.cloud.nacos.discovery.register-enabled=false"
|
||||||
|
})
|
||||||
|
@EnabledIfSystemProperty(named = "cargo.discard.acceptance", matches = "true")
|
||||||
|
class CargoDiscardMysqlAcceptanceTest {
|
||||||
|
private static final String CUSTOMER = "990001";
|
||||||
|
|
||||||
|
@Autowired private JdbcTemplate jdbc;
|
||||||
|
@Autowired private CargoDiscardService service;
|
||||||
|
|
||||||
|
@SpringBootConfiguration
|
||||||
|
@EnableAutoConfiguration
|
||||||
|
@Import({CargoDiscardService.class, CargoDiscardInventoryAdjustmentService.class})
|
||||||
|
@MapperScan({
|
||||||
|
"com.baiyue.lastleg.mapper",
|
||||||
|
"com.baiyue.lastleg.discard.mapper",
|
||||||
|
"com.baiyue.lastleg.dropship.mapper"
|
||||||
|
})
|
||||||
|
static class AcceptanceApplication {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createApprovalRejectionPendingAndInsufficientStockScenarios() {
|
||||||
|
resetAcceptanceTenant();
|
||||||
|
seedOrder(990001001L, "RK-ACCEPT-APPROVE", "JF-ACCEPT-001", 20);
|
||||||
|
seedProfile(990001101L, 990001001L, "PALLET-A");
|
||||||
|
seedSku(990001201L, 990001001L, 990001101L, "SKU-BOX-RED", "红色折叠收纳箱", 12);
|
||||||
|
seedSku(990001202L, 990001001L, 990001101L, "SKU-RACK-KITCHEN", "厨房置物架", 8);
|
||||||
|
seedCartons(990001001L, 990001101L, 990001301L, "PALLET-A", "SKU-BOX-RED", "RED", 12);
|
||||||
|
seedCartons(990001001L, 990001101L, 990001401L, "PALLET-A", "SKU-RACK-KITCHEN", "RACK", 8);
|
||||||
|
|
||||||
|
seedOrder(990001002L, "RK-ACCEPT-REJECT", "JF-ACCEPT-002", 10);
|
||||||
|
seedProfile(990001102L, 990001002L, "PALLET-B");
|
||||||
|
seedSku(990001203L, 990001002L, 990001102L, "SKU-LAMP-WHITE", "白色台灯", 10);
|
||||||
|
seedCartons(990001002L, 990001102L, 990001501L, "PALLET-B", "SKU-LAMP-WHITE", "LAMP", 10);
|
||||||
|
|
||||||
|
seedOrder(990001003L, "RK-ACCEPT-PENDING", "JF-ACCEPT-003", 7);
|
||||||
|
seedProfile(990001103L, 990001003L, "PALLET-C");
|
||||||
|
seedSku(990001204L, 990001003L, 990001103L, "SKU-MAT-BLUE", "蓝色瑜伽垫", 7);
|
||||||
|
seedCartons(990001003L, 990001103L, 990001601L, "PALLET-C", "SKU-MAT-BLUE", "MAT", 7);
|
||||||
|
|
||||||
|
var approveRequest = new CargoDiscardRequests.Create(List.of(
|
||||||
|
new CargoDiscardRequests.Item(990001001L, "SKU-BOX-RED", 3),
|
||||||
|
new CargoDiscardRequests.Item(990001001L, "SKU-RACK-KITCHEN", 2)),
|
||||||
|
"ACCEPT-DISCARD-APPROVE", "验收:破损货物,同意废弃");
|
||||||
|
var approved = service.create(CUSTOMER, "acceptance-creator", approveRequest);
|
||||||
|
assertEquals(20L, skuPackages(990001001L));
|
||||||
|
approved = service.review(CUSTOMER, approved.summary().discardNo(), "acceptance-reviewer",
|
||||||
|
new CargoDiscardRequests.Review("APPROVE", "仓库复核破损属实"));
|
||||||
|
assertEquals(CargoDiscardService.APPROVED, approved.summary().status());
|
||||||
|
assertEquals(15L, skuPackages(990001001L));
|
||||||
|
|
||||||
|
var rejected = service.create(CUSTOMER, "acceptance-creator", new CargoDiscardRequests.Create(
|
||||||
|
List.of(new CargoDiscardRequests.Item(990001002L, "SKU-LAMP-WHITE", 4)),
|
||||||
|
"ACCEPT-DISCARD-REJECT", "验收:外箱可修复"));
|
||||||
|
assertEquals(10L, skuPackages(990001002L));
|
||||||
|
rejected = service.review(CUSTOMER, rejected.summary().discardNo(), "acceptance-reviewer",
|
||||||
|
new CargoDiscardRequests.Review("REJECT", "复核后无需废弃"));
|
||||||
|
assertEquals(CargoDiscardService.REJECTED, rejected.summary().status());
|
||||||
|
assertEquals(10L, skuPackages(990001002L));
|
||||||
|
|
||||||
|
var pending = service.create(CUSTOMER, "acceptance-creator", new CargoDiscardRequests.Create(
|
||||||
|
List.of(new CargoDiscardRequests.Item(990001003L, "SKU-MAT-BLUE", 2)),
|
||||||
|
"ACCEPT-DISCARD-PENDING", "验收:等待仓库审核"));
|
||||||
|
assertEquals(CargoDiscardService.PENDING_REVIEW, pending.summary().status());
|
||||||
|
assertEquals(7L, skuPackages(990001003L));
|
||||||
|
|
||||||
|
assertThrows(ResponseStatusException.class, () -> service.create(CUSTOMER, "acceptance-creator",
|
||||||
|
new CargoDiscardRequests.Create(List.of(
|
||||||
|
new CargoDiscardRequests.Item(990001003L, "SKU-MAT-BLUE", 99)),
|
||||||
|
"ACCEPT-DISCARD-INSUFFICIENT", "验收:库存不足应失败")));
|
||||||
|
|
||||||
|
assertEquals(1, count("SELECT COUNT(*) FROM ll_cargo_discard_application WHERE customer_id=? AND status='APPROVED'"));
|
||||||
|
assertEquals(1, count("SELECT COUNT(*) FROM ll_cargo_discard_application WHERE customer_id=? AND status='REJECTED'"));
|
||||||
|
assertEquals(1, count("SELECT COUNT(*) FROM ll_cargo_discard_application WHERE customer_id=? AND status='PENDING_REVIEW'"));
|
||||||
|
assertEquals(5, count("SELECT COUNT(*) FROM ll_dropship_carton WHERE customer_id=? AND order_id=990001001 AND status='DISCARDED'"));
|
||||||
|
assertEquals(0, count("SELECT COUNT(*) FROM ll_dropship_carton WHERE customer_id=? AND order_id=990001002 AND status='DISCARD_PENDING'"));
|
||||||
|
assertEquals(2, count("SELECT COUNT(*) FROM ll_dropship_carton WHERE customer_id=? AND order_id=990001003 AND status='DISCARD_PENDING'"));
|
||||||
|
assertEquals(5, count("SELECT COUNT(*) FROM ll_dropship_carton_ledger WHERE customer_id=? AND operation_type='DISCARD'"));
|
||||||
|
assertEquals(5, count("SELECT COUNT(DISTINCT idempotency_key) FROM ll_dropship_carton_ledger WHERE customer_id=? AND operation_type='DISCARD'"));
|
||||||
|
String audit = jdbc.queryForObject("SELECT remark FROM ll_cargo_discard_status_history WHERE customer_id=? " +
|
||||||
|
"AND to_status='APPROVED'", String.class, CUSTOMER);
|
||||||
|
assertTrue(audit.contains("inventoryBefore=20, discarded=5, inventoryAfter=15"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void resetAcceptanceTenant() {
|
||||||
|
jdbc.update("DELETE FROM ll_dropship_carton_ledger WHERE customer_id=?", CUSTOMER);
|
||||||
|
jdbc.update("DELETE FROM ll_cargo_discard_status_history WHERE customer_id=?", CUSTOMER);
|
||||||
|
jdbc.update("DELETE FROM ll_cargo_discard_carton WHERE customer_id=?", CUSTOMER);
|
||||||
|
jdbc.update("DELETE FROM ll_cargo_discard_item WHERE customer_id=?", CUSTOMER);
|
||||||
|
jdbc.update("DELETE FROM ll_cargo_discard_application WHERE customer_id=?", CUSTOMER);
|
||||||
|
jdbc.update("DELETE FROM ll_dropship_carton WHERE customer_id=?", CUSTOMER);
|
||||||
|
jdbc.update("DELETE FROM ll_dropship_sku_inventory WHERE customer_id=?", CUSTOMER);
|
||||||
|
jdbc.update("DELETE FROM ll_dropship_order_profile WHERE customer_id=?", CUSTOMER);
|
||||||
|
jdbc.update("DELETE FROM ll_order WHERE customer_id=?", CUSTOMER);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void seedOrder(long id, String inboundCode, String orderNo, int packages) {
|
||||||
|
jdbc.update("INSERT INTO ll_order(id,order_no,customer_id,order_type,inbound_code,source_region," +
|
||||||
|
"source_inbound_id,customer_name,overseas_warehouse_id,overseas_warehouse_name,pallet_count," +
|
||||||
|
"package_count,product_quantity,current_status,source_event_id,source_loading_plan_id,source_payload_hash," +
|
||||||
|
"inbound_order_no,created_at,updated_at) VALUES(?,?,?,'ONE_CLICK_DROPSHIP',?,'ACCEPTANCE',?," +
|
||||||
|
"'废弃验收客户','WH-US-LAX','美西洛杉矶仓',1,?,?, 'WAREHOUSED',?,?,?, ?,NOW(3),NOW(3))",
|
||||||
|
id, orderNo, CUSTOMER, inboundCode, "ACCEPT-" + id, packages, packages * 10,
|
||||||
|
"ACCEPT-EVENT-" + id, "ACCEPT-PLAN-" + id, "sha256:acceptance-" + id, orderNo);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void seedProfile(long id, long orderId, String palletNo) {
|
||||||
|
jdbc.update("INSERT INTO ll_dropship_order_profile(id,customer_id,order_id,warehouse_pallet_id,pallet_no," +
|
||||||
|
"source_pallet_no,status,idempotency_key,version,created_at,updated_at) " +
|
||||||
|
"VALUES(?,?,?, ?,?,?,'RECEIVED',?,0,NOW(3),NOW(3))",
|
||||||
|
id, CUSTOMER, orderId, id, palletNo, "SRC-" + palletNo, "ACCEPT-PROFILE-" + id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void seedSku(long id, long orderId, long profileId, String sku, String name, int packages) {
|
||||||
|
jdbc.update("INSERT INTO ll_dropship_sku_inventory(id,customer_id,order_id,profile_id,sku_code,product_name," +
|
||||||
|
"package_count,product_quantity,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,NOW(3),NOW(3))",
|
||||||
|
id, CUSTOMER, orderId, profileId, sku, name, packages, packages * 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void seedCartons(long orderId, long profileId, long firstId, String palletNo,
|
||||||
|
String sku, String markPrefix, int packages) {
|
||||||
|
for (int index = 1; index <= packages; index++) {
|
||||||
|
jdbc.update("INSERT INTO ll_dropship_carton(id,customer_id,order_id,profile_id,warehouse_pallet_id," +
|
||||||
|
"pallet_no,sku_code,carton_mark,carton_sequence,status,version,created_at,updated_at) " +
|
||||||
|
"VALUES(?,?,?,?,?,?,?,?,?,'AVAILABLE',0,NOW(3),NOW(3))",
|
||||||
|
firstId + index - 1, CUSTOMER, orderId, profileId, profileId, palletNo, sku,
|
||||||
|
"ACCEPT-" + markPrefix + "-" + String.format("%03d", index), index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private long skuPackages(long orderId) {
|
||||||
|
return jdbc.queryForObject("SELECT SUM(package_count) FROM ll_dropship_sku_inventory WHERE customer_id=? AND order_id=?",
|
||||||
|
Long.class, CUSTOMER, orderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int count(String sql) {
|
||||||
|
return jdbc.queryForObject(sql, Integer.class, CUSTOMER);
|
||||||
|
}
|
||||||
|
}
|
||||||
+108
@@ -0,0 +1,108 @@
|
|||||||
|
package com.baiyue.lastleg.discard.service;
|
||||||
|
|
||||||
|
import com.baiyue.lastleg.discard.entity.CargoDiscardApplication;
|
||||||
|
import com.baiyue.lastleg.discard.entity.CargoDiscardCarton;
|
||||||
|
import com.baiyue.lastleg.discard.entity.CargoDiscardItem;
|
||||||
|
import com.baiyue.lastleg.discard.mapper.CargoDiscardInventoryMapper;
|
||||||
|
import com.baiyue.lastleg.dropship.entity.DropshipCarton;
|
||||||
|
import com.baiyue.lastleg.dropship.entity.DropshipCartonLedger;
|
||||||
|
import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory;
|
||||||
|
import com.baiyue.lastleg.dropship.mapper.DropshipCartonLedgerMapper;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
import org.springframework.transaction.annotation.Propagation;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyLong;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class CargoDiscardInventoryAdjustmentServiceTest {
|
||||||
|
@Mock CargoDiscardInventoryMapper inventory;
|
||||||
|
@Mock DropshipCartonLedgerMapper ledgers;
|
||||||
|
private CargoDiscardInventoryAdjustmentService service;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
service = new CargoDiscardInventoryAdjustmentService();
|
||||||
|
ReflectionTestUtils.setField(service, "inventory", inventory);
|
||||||
|
ReflectionTestUtils.setField(service, "cartonLedgers", ledgers);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void discardRequiresExistingTransactionAndConservesPackages() throws Exception {
|
||||||
|
Transactional transactional = CargoDiscardInventoryAdjustmentService.class
|
||||||
|
.getMethod("discard", String.class, CargoDiscardApplication.class, List.class, Map.class,
|
||||||
|
String.class, LocalDateTime.class).getAnnotation(Transactional.class);
|
||||||
|
assertEquals(Propagation.MANDATORY, transactional.propagation());
|
||||||
|
CargoDiscardApplication application = application(2);
|
||||||
|
CargoDiscardItem item = item(2);
|
||||||
|
when(inventory.lockSkuInventory("100", 301L)).thenReturn(sku(5));
|
||||||
|
when(inventory.deductSkuInventory("100", 301L, 2)).thenReturn(1);
|
||||||
|
when(inventory.skuPackages("100", 301L)).thenReturn(3L);
|
||||||
|
when(inventory.lockCarton(eq("100"), anyLong())).thenAnswer(invocation -> carton(invocation.getArgument(1)));
|
||||||
|
when(inventory.changeCartonStatus(eq("100"), anyLong(), eq("DISCARD_PENDING"), eq("DISCARDED"))).thenReturn(1);
|
||||||
|
|
||||||
|
var result = service.discard("100", application, List.of(item),
|
||||||
|
Map.of(item.getId(), List.of(link(1L), link(2L))), "9", LocalDateTime.now());
|
||||||
|
|
||||||
|
assertEquals(5, result.beforePackages());
|
||||||
|
assertEquals(2, result.discardedPackages());
|
||||||
|
assertEquals(3, result.afterPackages());
|
||||||
|
ArgumentCaptor<DropshipCartonLedger> facts = ArgumentCaptor.forClass(DropshipCartonLedger.class);
|
||||||
|
verify(ledgers, times(2)).insert(facts.capture());
|
||||||
|
assertTrue(facts.getAllValues().stream().allMatch(value -> "DISCARD".equals(value.getOperationType())
|
||||||
|
&& value.getIdempotencyKey().startsWith("CARGO_DISCARD:401:")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void discardRejectsAnyPackageConservationMismatch() {
|
||||||
|
CargoDiscardApplication application = application(2);
|
||||||
|
CargoDiscardItem item = item(2);
|
||||||
|
when(inventory.lockSkuInventory("100", 301L)).thenReturn(sku(5));
|
||||||
|
when(inventory.deductSkuInventory("100", 301L, 2)).thenReturn(1);
|
||||||
|
when(inventory.skuPackages("100", 301L)).thenReturn(4L);
|
||||||
|
when(inventory.lockCarton(eq("100"), anyLong())).thenAnswer(invocation -> carton(invocation.getArgument(1)));
|
||||||
|
when(inventory.changeCartonStatus(eq("100"), anyLong(), eq("DISCARD_PENDING"), eq("DISCARDED"))).thenReturn(1);
|
||||||
|
|
||||||
|
assertThrows(ResponseStatusException.class, () -> service.discard("100", application, List.of(item),
|
||||||
|
Map.of(item.getId(), List.of(link(1L), link(2L))), "9", LocalDateTime.now()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private CargoDiscardApplication application(int packages) {
|
||||||
|
CargoDiscardApplication value = new CargoDiscardApplication();
|
||||||
|
value.setId(401L); value.setDiscardNo("FH-1"); value.setTotalPackages(packages); value.setRemark("damaged");
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
private CargoDiscardItem item(int packages) {
|
||||||
|
CargoDiscardItem value = new CargoDiscardItem();
|
||||||
|
value.setId(501L); value.setOrderId(101L); value.setSkuInventoryId(301L); value.setSkuCode("SKU-1");
|
||||||
|
value.setDiscardPackages(packages); return value;
|
||||||
|
}
|
||||||
|
private DropshipSkuInventory sku(long packages) {
|
||||||
|
DropshipSkuInventory value = new DropshipSkuInventory(); value.setId(301L); value.setPackageCount(packages); return value;
|
||||||
|
}
|
||||||
|
private CargoDiscardCarton link(long id) {
|
||||||
|
CargoDiscardCarton value = new CargoDiscardCarton(); value.setItemId(501L); value.setCartonId(id); return value;
|
||||||
|
}
|
||||||
|
private DropshipCarton carton(long id) {
|
||||||
|
DropshipCarton value = new DropshipCarton(); value.setId(id); value.setCartonMark("C-" + id); return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
-11
@@ -12,7 +12,6 @@ import com.baiyue.lastleg.discard.mapper.CargoDiscardItemMapper;
|
|||||||
import com.baiyue.lastleg.discard.mapper.CargoDiscardStatusHistoryMapper;
|
import com.baiyue.lastleg.discard.mapper.CargoDiscardStatusHistoryMapper;
|
||||||
import com.baiyue.lastleg.dropship.entity.DropshipCarton;
|
import com.baiyue.lastleg.dropship.entity.DropshipCarton;
|
||||||
import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory;
|
import com.baiyue.lastleg.dropship.entity.DropshipSkuInventory;
|
||||||
import com.baiyue.lastleg.dropship.mapper.DropshipCartonLedgerMapper;
|
|
||||||
import com.baiyue.lastleg.entity.LastlegOrder;
|
import com.baiyue.lastleg.entity.LastlegOrder;
|
||||||
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
@@ -46,7 +45,7 @@ class CargoDiscardServiceTest {
|
|||||||
@Mock CargoDiscardCartonMapper discardCartons;
|
@Mock CargoDiscardCartonMapper discardCartons;
|
||||||
@Mock CargoDiscardStatusHistoryMapper histories;
|
@Mock CargoDiscardStatusHistoryMapper histories;
|
||||||
@Mock CargoDiscardInventoryMapper inventory;
|
@Mock CargoDiscardInventoryMapper inventory;
|
||||||
@Mock DropshipCartonLedgerMapper cartonLedgers;
|
@Mock CargoDiscardInventoryAdjustmentService adjustments;
|
||||||
@Mock LastlegOrderMapper orders;
|
@Mock LastlegOrderMapper orders;
|
||||||
|
|
||||||
private CargoDiscardService service;
|
private CargoDiscardService service;
|
||||||
@@ -63,7 +62,7 @@ class CargoDiscardServiceTest {
|
|||||||
ReflectionTestUtils.setField(service, "discardCartons", discardCartons);
|
ReflectionTestUtils.setField(service, "discardCartons", discardCartons);
|
||||||
ReflectionTestUtils.setField(service, "histories", histories);
|
ReflectionTestUtils.setField(service, "histories", histories);
|
||||||
ReflectionTestUtils.setField(service, "inventory", inventory);
|
ReflectionTestUtils.setField(service, "inventory", inventory);
|
||||||
ReflectionTestUtils.setField(service, "cartonLedgers", cartonLedgers);
|
ReflectionTestUtils.setField(service, "adjustments", adjustments);
|
||||||
ReflectionTestUtils.setField(service, "orders", orders);
|
ReflectionTestUtils.setField(service, "orders", orders);
|
||||||
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper());
|
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper());
|
||||||
ReflectionTestUtils.setField(service, "feePerPackage", new BigDecimal("5.00"));
|
ReflectionTestUtils.setField(service, "feePerPackage", new BigDecimal("5.00"));
|
||||||
@@ -107,17 +106,15 @@ class CargoDiscardServiceTest {
|
|||||||
storedLinks.add(link(item.getId(), 1L));
|
storedLinks.add(link(item.getId(), 1L));
|
||||||
storedLinks.add(link(item.getId(), 2L));
|
storedLinks.add(link(item.getId(), 2L));
|
||||||
when(applications.lockNo("100", "FH-1")).thenReturn(application);
|
when(applications.lockNo("100", "FH-1")).thenReturn(application);
|
||||||
when(inventory.lockSkuInventory("100", 301L)).thenReturn(skuInventory());
|
when(adjustments.discard(eq("100"), eq(application), any(), any(), eq("9"), any()))
|
||||||
when(inventory.deductSkuInventory("100", 301L, 2)).thenReturn(1);
|
.thenReturn(new CargoDiscardInventoryAdjustmentService.AdjustmentResult(3, 2, 1));
|
||||||
when(inventory.lockCarton(eq("100"), anyLong())).thenAnswer(invocation -> carton(invocation.getArgument(1)));
|
|
||||||
when(inventory.changeCartonStatus(eq("100"), anyLong(), eq("DISCARD_PENDING"), eq("DISCARDED"))).thenReturn(1);
|
|
||||||
|
|
||||||
var detail = service.review("100", "FH-1", "9", new CargoDiscardRequests.Review("APPROVE", "ok"));
|
var detail = service.review("100", "FH-1", "9", new CargoDiscardRequests.Review("APPROVE", "ok"));
|
||||||
|
|
||||||
assertEquals(CargoDiscardService.APPROVED, detail.summary().status());
|
assertEquals(CargoDiscardService.APPROVED, detail.summary().status());
|
||||||
verify(inventory).deductSkuInventory("100", 301L, 2);
|
verify(adjustments).discard(eq("100"), eq(application), any(), any(), eq("9"), any());
|
||||||
verify(cartonLedgers, times(2)).insert(any(com.baiyue.lastleg.dropship.entity.DropshipCartonLedger.class));
|
assertEquals("ok; inventoryBefore=3, discarded=2, inventoryAfter=1",
|
||||||
verify(inventory, times(2)).changeCartonStatus(eq("100"), anyLong(), eq("DISCARD_PENDING"), eq("DISCARDED"));
|
storedHistories.get(storedHistories.size() - 1).getRemark());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -134,7 +131,7 @@ class CargoDiscardServiceTest {
|
|||||||
|
|
||||||
assertEquals(CargoDiscardService.REJECTED, detail.summary().status());
|
assertEquals(CargoDiscardService.REJECTED, detail.summary().status());
|
||||||
verify(inventory, never()).deductSkuInventory(any(), anyLong(), anyInt());
|
verify(inventory, never()).deductSkuInventory(any(), anyLong(), anyInt());
|
||||||
verify(cartonLedgers, never()).insert(any(com.baiyue.lastleg.dropship.entity.DropshipCartonLedger.class));
|
verify(adjustments, never()).discard(any(), any(), any(), any(), any(), any());
|
||||||
}
|
}
|
||||||
|
|
||||||
private LastlegOrder order() {
|
private LastlegOrder order() {
|
||||||
|
|||||||
@@ -21,4 +21,13 @@ class DispatchSchemaContractTest {
|
|||||||
assertTrue(sql.contains("active_key"));
|
assertTrue(sql.contains("active_key"));
|
||||||
assertTrue(sql.contains("order_refs"));
|
assertTrue(sql.contains("order_refs"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void dispatchCandidatesExcludePalletsReservedByWarehouseTransfers() throws Exception {
|
||||||
|
String mapper = Files.readString(Path.of(
|
||||||
|
"src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java"));
|
||||||
|
assertTrue(mapper.contains("ll_warehouse_transfer_inventory_allocation"));
|
||||||
|
assertTrue(mapper.contains("ta.status='RESERVED'"));
|
||||||
|
assertTrue(mapper.contains("tp.pallet_id=p.warehouse_pallet_id"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+13
@@ -8,6 +8,7 @@ import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
|
|||||||
import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt;
|
import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt;
|
||||||
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
||||||
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
|
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
|
||||||
|
import org.apache.ibatis.annotations.Update;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
import org.mockito.ArgumentCaptor;
|
import org.mockito.ArgumentCaptor;
|
||||||
@@ -18,8 +19,10 @@ import org.springframework.test.util.ReflectionTestUtils;
|
|||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
import static org.mockito.ArgumentMatchers.*;
|
import static org.mockito.ArgumentMatchers.*;
|
||||||
import static org.mockito.Mockito.*;
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
@@ -29,6 +32,16 @@ class DispatchPickOrderAttributionServiceTest {
|
|||||||
@Mock private LastlegOrderMapper orders;
|
@Mock private LastlegOrderMapper orders;
|
||||||
@Mock private LastlegOrderStatusHistoryMapper history;
|
@Mock private LastlegOrderStatusHistoryMapper history;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void completionStatusIsCalculatedBeforeMysqlMutatesShippedPackages() throws NoSuchMethodException {
|
||||||
|
Update update = LastlegOrderMapper.class.getMethod("transferPickedToShipped",
|
||||||
|
String.class, Long.class, long.class, String.class).getAnnotation(Update.class);
|
||||||
|
String sql = String.join("", Arrays.asList(update.value())).replaceAll("\\s+", " ");
|
||||||
|
|
||||||
|
assertTrue(sql.indexOf("current_status=CASE")
|
||||||
|
< sql.indexOf("shipped_package_count=shipped_package_count+"));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void attributesPackagesAcrossInboundOrdersInReceiptFifoOrder() {
|
void attributesPackagesAcrossInboundOrdersInReceiptFifoOrder() {
|
||||||
DispatchPickOrderAttributionService service = service();
|
DispatchPickOrderAttributionService service = service();
|
||||||
|
|||||||
@@ -20,11 +20,16 @@ class DropshipSchemaContractTest {
|
|||||||
"key idx_ll_order_container(customer_id,container_no)",
|
"key idx_ll_order_container(customer_id,container_no)",
|
||||||
"unique key uk_ll_dropship_profile_order_pallet(customer_id,order_id,source_pallet_no)",
|
"unique key uk_ll_dropship_profile_order_pallet(customer_id,order_id,source_pallet_no)",
|
||||||
"unique key uk_ll_dropship_sku_inventory(profile_id,sku_code)",
|
"unique key uk_ll_dropship_sku_inventory(profile_id,sku_code)",
|
||||||
"key idx_ll_dropship_profile_order(customer_id,order_id)");
|
"key idx_ll_dropship_profile_order(customer_id,order_id)",
|
||||||
|
"warehouse_pallet_id bigint null comment '仓库实物托盘id'",
|
||||||
|
"pallet_no varchar(128) null comment '托盘号'",
|
||||||
|
"status varchar(32) not null default 'pending_inbound'");
|
||||||
assertThat(migration).contains(
|
assertThat(migration).contains(
|
||||||
"add key idx_ll_order_container (customer_id,container_no)",
|
"add key idx_ll_order_container (customer_id,container_no)",
|
||||||
"add unique key uk_ll_dropship_profile_order_pallet(customer_id,order_id,source_pallet_no)",
|
"add unique key uk_ll_dropship_profile_order_pallet(customer_id,order_id,source_pallet_no)",
|
||||||
"add unique key uk_ll_dropship_sku_profile(profile_id,sku_code)",
|
"add unique key uk_ll_dropship_sku_profile(profile_id,sku_code)",
|
||||||
"add key idx_ll_dropship_profile_order(customer_id,order_id)");
|
"add key idx_ll_dropship_profile_order(customer_id,order_id)",
|
||||||
|
"modify column warehouse_pallet_id bigint null",
|
||||||
|
"modify column pallet_no varchar(128) null");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+76
-4
@@ -11,6 +11,7 @@ import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper;
|
|||||||
import com.baiyue.lastleg.dropship.mapper.DropshipSkuInventoryMapper;
|
import com.baiyue.lastleg.dropship.mapper.DropshipSkuInventoryMapper;
|
||||||
import com.baiyue.lastleg.entity.LastlegOrder;
|
import com.baiyue.lastleg.entity.LastlegOrder;
|
||||||
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
|
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
|
||||||
|
import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt;
|
||||||
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
@@ -46,9 +47,6 @@ class DropshipOrderServiceTest {
|
|||||||
order.setProductQuantity(30);
|
order.setProductQuantity(30);
|
||||||
when(orders.selectById(10L)).thenReturn(order);
|
when(orders.selectById(10L)).thenReturn(order);
|
||||||
when(profiles.listOrder("C-1", 10L)).thenReturn(List.of());
|
when(profiles.listOrder("C-1", 10L)).thenReturn(List.of());
|
||||||
when(warehouse.ensureDropshipPallet(eq("C-1"), eq(20L), anyString()))
|
|
||||||
.thenReturn(new WarehouseResourceGateway.WarehousePalletReference(101L, "WH-P-1", 1L))
|
|
||||||
.thenReturn(new WarehouseResourceGateway.WarehousePalletReference(102L, "WH-P-2", 1L));
|
|
||||||
|
|
||||||
DropshipRequests.Receive request = new DropshipRequests.Receive("C-1", 10L, 20L, "EVENT-1",
|
DropshipRequests.Receive request = new DropshipRequests.Receive("C-1", 10L, 20L, "EVENT-1",
|
||||||
List.of(
|
List.of(
|
||||||
@@ -60,7 +58,7 @@ class DropshipOrderServiceTest {
|
|||||||
List.of("MARK-3"))))));
|
List.of("MARK-3"))))));
|
||||||
|
|
||||||
assertThat(service.receive(request)).isEqualTo(3);
|
assertThat(service.receive(request)).isEqualTo(3);
|
||||||
verify(warehouse, times(2)).ensureDropshipPallet(eq("C-1"), eq(20L), anyString());
|
verifyNoInteractions(warehouse);
|
||||||
verify(profiles, times(2)).insert(any(DropshipOrderProfile.class));
|
verify(profiles, times(2)).insert(any(DropshipOrderProfile.class));
|
||||||
verify(skuInventory, times(2)).insert(any(DropshipSkuInventory.class));
|
verify(skuInventory, times(2)).insert(any(DropshipSkuInventory.class));
|
||||||
verify(cartons, times(3)).insert(any(DropshipCarton.class));
|
verify(cartons, times(3)).insert(any(DropshipCarton.class));
|
||||||
@@ -72,5 +70,79 @@ class DropshipOrderServiceTest {
|
|||||||
.containsExactly("SOURCE-P-1", "SOURCE-P-2");
|
.containsExactly("SOURCE-P-1", "SOURCE-P-2");
|
||||||
assertThat(profilesCaptor.getAllValues()).extracting(DropshipOrderProfile::getCustomerId)
|
assertThat(profilesCaptor.getAllValues()).extracting(DropshipOrderProfile::getCustomerId)
|
||||||
.containsOnly("C-1");
|
.containsOnly("C-1");
|
||||||
|
assertThat(profilesCaptor.getAllValues()).extracting(DropshipOrderProfile::getStatus)
|
||||||
|
.containsOnly("PENDING_INBOUND");
|
||||||
|
assertThat(profilesCaptor.getAllValues()).extracting(DropshipOrderProfile::getWarehousePalletId)
|
||||||
|
.containsOnlyNulls();
|
||||||
|
ArgumentCaptor<DropshipCarton> cartonCaptor = ArgumentCaptor.forClass(DropshipCarton.class);
|
||||||
|
verify(cartons, times(3)).insert(cartonCaptor.capture());
|
||||||
|
assertThat(cartonCaptor.getAllValues()).extracting(DropshipCarton::getStatus)
|
||||||
|
.containsOnly("PENDING_INBOUND");
|
||||||
|
ArgumentCaptor<DropshipCartonLedger> ledgerCaptor = ArgumentCaptor.forClass(DropshipCartonLedger.class);
|
||||||
|
verify(ledger, times(3)).insert(ledgerCaptor.capture());
|
||||||
|
assertThat(ledgerCaptor.getAllValues()).extracting(DropshipCartonLedger::getOperationType)
|
||||||
|
.containsOnly("SNAPSHOT_CREATED");
|
||||||
|
assertThat(ledgerCaptor.getAllValues()).extracting(DropshipCartonLedger::getDeltaPackages)
|
||||||
|
.containsOnly(0L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void activatesMultiplePalletsOnlyAfterPdaStockIn() {
|
||||||
|
DropshipOrderProfile first = profile(1L, "SOURCE-P-1");
|
||||||
|
DropshipOrderProfile second = profile(2L, "SOURCE-P-2");
|
||||||
|
when(profiles.listOrder("C-1", 10L)).thenReturn(List.of(first, second));
|
||||||
|
DropshipCarton firstCarton = carton(11L, 1L, "MARK-1");
|
||||||
|
DropshipCarton secondCarton = carton(12L, 2L, "MARK-2");
|
||||||
|
when(cartons.listProfile("C-1", 1L)).thenReturn(List.of(firstCarton));
|
||||||
|
when(cartons.listProfile("C-1", 2L)).thenReturn(List.of(secondCarton));
|
||||||
|
|
||||||
|
service.activateAfterStockIn("C-1", 10L, 50L, List.of(
|
||||||
|
receipt(101L, "WH-P-1", 201L, 1), receipt(102L, "WH-P-2", 202L, 1)));
|
||||||
|
|
||||||
|
assertThat(first.getStatus()).isEqualTo("ACTIVE");
|
||||||
|
assertThat(first.getWarehousePalletId()).isEqualTo(101L);
|
||||||
|
assertThat(firstCarton.getStatus()).isEqualTo("AVAILABLE");
|
||||||
|
assertThat(firstCarton.getWarehousePalletId()).isEqualTo(101L);
|
||||||
|
verify(profiles, times(2)).updateById(any(DropshipOrderProfile.class));
|
||||||
|
verify(cartons, times(2)).updateById(any(DropshipCarton.class));
|
||||||
|
ArgumentCaptor<DropshipCartonLedger> facts = ArgumentCaptor.forClass(DropshipCartonLedger.class);
|
||||||
|
verify(ledger, times(2)).insert(facts.capture());
|
||||||
|
assertThat(facts.getAllValues()).extracting(DropshipCartonLedger::getOperationType).containsOnly("STOCK_IN");
|
||||||
|
assertThat(facts.getAllValues()).extracting(DropshipCartonLedger::getDeltaPackages).containsOnly(1L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void repeatedPdaActivationIsANoOp() {
|
||||||
|
DropshipOrderProfile active = profile(1L, "SOURCE-P-1");
|
||||||
|
active.setStatus("ACTIVE");
|
||||||
|
when(profiles.listOrder("C-1", 10L)).thenReturn(List.of(active));
|
||||||
|
|
||||||
|
service.activateAfterStockIn("C-1", 10L, 50L,
|
||||||
|
List.of(receipt(101L, "WH-P-1", 201L, 1)));
|
||||||
|
|
||||||
|
verify(profiles, never()).updateById(any(DropshipOrderProfile.class));
|
||||||
|
verify(cartons, never()).updateById(any(DropshipCarton.class));
|
||||||
|
verify(ledger, never()).insert(any(DropshipCartonLedger.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DropshipOrderProfile profile(long id, String sourcePalletNo) {
|
||||||
|
DropshipOrderProfile profile = new DropshipOrderProfile();
|
||||||
|
profile.setId(id); profile.setCustomerId("C-1"); profile.setOrderId(10L);
|
||||||
|
profile.setSourcePalletNo(sourcePalletNo); profile.setStatus("PENDING_INBOUND"); profile.setVersion(0L);
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DropshipCarton carton(long id, long profileId, String mark) {
|
||||||
|
DropshipCarton carton = new DropshipCarton();
|
||||||
|
carton.setId(id); carton.setCustomerId("C-1"); carton.setOrderId(10L); carton.setProfileId(profileId);
|
||||||
|
carton.setCartonMark(mark); carton.setSkuCode("SKU-A"); carton.setStatus("PENDING_INBOUND"); carton.setVersion(0L);
|
||||||
|
return carton;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static InboundStockReceipt receipt(long palletId, String palletNo, long positionId, long packages) {
|
||||||
|
InboundStockReceipt receipt = new InboundStockReceipt();
|
||||||
|
receipt.setOrderId(10L); receipt.setWarehousePalletId(palletId); receipt.setPalletNo(palletNo);
|
||||||
|
receipt.setInventoryPositionId(positionId); receipt.setReceivedPackages(packages);
|
||||||
|
return receipt;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+50
-3
@@ -13,6 +13,7 @@ import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
|
|||||||
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse;
|
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse;
|
||||||
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
|
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
|
||||||
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
||||||
|
import com.baiyue.lastleg.dropship.service.DropshipOrderService;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
@@ -41,6 +42,7 @@ class InboundStockReceiptServiceTest {
|
|||||||
@Mock LastlegOrderMapper orders;
|
@Mock LastlegOrderMapper orders;
|
||||||
@Mock InboundStockReceiptMapper receipts;
|
@Mock InboundStockReceiptMapper receipts;
|
||||||
@Mock InventoryCommandService inventory;
|
@Mock InventoryCommandService inventory;
|
||||||
|
@Mock DropshipOrderService dropshipOrders;
|
||||||
private InboundStockReceiptService service;
|
private InboundStockReceiptService service;
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
@@ -49,6 +51,7 @@ class InboundStockReceiptServiceTest {
|
|||||||
ReflectionTestUtils.setField(service, "orders", orders);
|
ReflectionTestUtils.setField(service, "orders", orders);
|
||||||
ReflectionTestUtils.setField(service, "receipts", receipts);
|
ReflectionTestUtils.setField(service, "receipts", receipts);
|
||||||
ReflectionTestUtils.setField(service, "inventory", inventory);
|
ReflectionTestUtils.setField(service, "inventory", inventory);
|
||||||
|
ReflectionTestUtils.setField(service, "dropshipOrders", dropshipOrders);
|
||||||
when(receipts.listTask("100", 2L)).thenReturn(List.of());
|
when(receipts.listTask("100", 2L)).thenReturn(List.of());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,14 +113,58 @@ class InboundStockReceiptServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void existingTaskReceiptMakesStockInReplayANoOp() {
|
void existingTaskReceiptMakesStockInReplayANoOp() {
|
||||||
|
Fixture fixture = fixture(10, 5, 5, 10);
|
||||||
|
InboundPlanPallet secondPallet = pallet(22L, 2002L);
|
||||||
|
InboundPlanPalletAllocation secondAllocation = allocation(32L, 12L, 22L, 5);
|
||||||
|
InboundStockReceipt firstReceipt = new InboundStockReceipt();
|
||||||
|
firstReceipt.setAllocationId(fixture.allocation.getId()); firstReceipt.setOrderId(fixture.order.getId());
|
||||||
|
InboundStockReceipt secondReceipt = new InboundStockReceipt();
|
||||||
|
secondReceipt.setAllocationId(secondAllocation.getId()); secondReceipt.setOrderId(fixture.order.getId());
|
||||||
|
when(receipts.listTask("100", fixture.task.getId())).thenReturn(List.of(firstReceipt, secondReceipt));
|
||||||
|
when(orders.selectBatchIds(any())).thenReturn(List.of(fixture.order));
|
||||||
|
|
||||||
|
service.stockIn("100", fixture.plan, fixture.task, List.of(fixture.line),
|
||||||
|
List.of(fixture.allocation, secondAllocation), List.of(fixture.pallet, secondPallet), LocalDateTime.now());
|
||||||
|
|
||||||
|
verify(inventory, never()).stockIn(any(), any(), any());
|
||||||
|
verify(receipts, never()).insert(any(InboundStockReceipt.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void partialTaskReceiptsAreRejected() {
|
||||||
|
Fixture fixture = fixture(10, 5, 5, 10);
|
||||||
|
InboundPlanPallet secondPallet = pallet(22L, 2002L);
|
||||||
|
InboundPlanPalletAllocation secondAllocation = allocation(32L, 12L, 22L, 5);
|
||||||
|
InboundStockReceipt partial = new InboundStockReceipt();
|
||||||
|
partial.setAllocationId(fixture.allocation.getId()); partial.setOrderId(fixture.order.getId());
|
||||||
|
when(receipts.listTask("100", fixture.task.getId())).thenReturn(List.of(partial));
|
||||||
|
when(orders.selectBatchIds(any())).thenReturn(List.of(fixture.order));
|
||||||
|
|
||||||
|
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
|
||||||
|
() -> service.stockIn("100", fixture.plan, fixture.task,
|
||||||
|
List.of(fixture.line), List.of(fixture.allocation, secondAllocation),
|
||||||
|
List.of(fixture.pallet, secondPallet), LocalDateTime.now()));
|
||||||
|
assertEquals(409, exception.getStatusCode().value());
|
||||||
|
|
||||||
|
verify(inventory, never()).stockIn(any(), any(), any());
|
||||||
|
verify(dropshipOrders, never()).activateAfterStockIn(any(), any(), any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void activatesDropshipAfterGenericStockInReceiptIsCreated() {
|
||||||
Fixture fixture = fixture(5, 5, 5, 10);
|
Fixture fixture = fixture(5, 5, 5, 10);
|
||||||
when(receipts.listTask("100", fixture.task.getId())).thenReturn(List.of(new InboundStockReceipt()));
|
fixture.order.setOrderType("ONE_CLICK_DROPSHIP");
|
||||||
|
when(orders.selectBatchIds(any())).thenReturn(List.of(fixture.order));
|
||||||
|
when(inventory.stockIn(eq("100"), eq("operator"), any(InventoryCommandRequests.StockIn.class)))
|
||||||
|
.thenReturn(new InventoryCommandResponse(8L, "STOCK_IN", 9L, 10L, 5, 0, 0, 0, false, false));
|
||||||
|
|
||||||
service.stockIn("100", fixture.plan, fixture.task, List.of(fixture.line),
|
service.stockIn("100", fixture.plan, fixture.task, List.of(fixture.line),
|
||||||
List.of(fixture.allocation), List.of(fixture.pallet), LocalDateTime.now());
|
List.of(fixture.allocation), List.of(fixture.pallet), LocalDateTime.now());
|
||||||
|
|
||||||
verify(inventory, never()).stockIn(any(), any(), any());
|
ArgumentCaptor<List<InboundStockReceipt>> activationReceipts = ArgumentCaptor.forClass(List.class);
|
||||||
verify(receipts, never()).insert(any(InboundStockReceipt.class));
|
verify(dropshipOrders).activateAfterStockIn(eq("100"), eq(101L), eq(2L), activationReceipts.capture());
|
||||||
|
assertEquals(1, activationReceipts.getValue().size());
|
||||||
|
assertEquals(10L, activationReceipts.getValue().get(0).getInventoryPositionId());
|
||||||
}
|
}
|
||||||
|
|
||||||
private Fixture fixture(int required, int planned, int picked, int quantity) {
|
private Fixture fixture(int required, int planned, int picked, int quantity) {
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ class InventorySchemaContractTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void entitiesExposeInboundOrderPackageDimensionOnly() throws NoSuchFieldException {
|
void entitiesExposeInboundOrderPackageDimensionAndLegacyQuantityPlaceholders() throws NoSuchFieldException {
|
||||||
assertNotNull(InventoryBalance.class.getDeclaredField("inboundOrderId"));
|
assertNotNull(InventoryBalance.class.getDeclaredField("inboundOrderId"));
|
||||||
assertNotNull(InventoryBalance.class.getDeclaredField("inboundCode"));
|
assertNotNull(InventoryBalance.class.getDeclaredField("inboundCode"));
|
||||||
assertNotNull(InventoryLedger.class.getDeclaredField("inboundOrderId"));
|
assertNotNull(InventoryLedger.class.getDeclaredField("inboundOrderId"));
|
||||||
@@ -103,7 +103,9 @@ class InventorySchemaContractTest {
|
|||||||
assertThrowsNoField(InventoryPosition.class, "productQuantity");
|
assertThrowsNoField(InventoryPosition.class, "productQuantity");
|
||||||
assertNotNull(InventoryLedger.class.getDeclaredField("skuCode"));
|
assertNotNull(InventoryLedger.class.getDeclaredField("skuCode"));
|
||||||
assertNotNull(InventoryLedger.class.getDeclaredField("cartonMark"));
|
assertNotNull(InventoryLedger.class.getDeclaredField("cartonMark"));
|
||||||
assertThrowsNoField(InventoryLedger.class, "deltaQuantity");
|
assertNotNull(InventoryLedger.class.getDeclaredField("beforeQuantity"));
|
||||||
|
assertNotNull(InventoryLedger.class.getDeclaredField("deltaQuantity"));
|
||||||
|
assertNotNull(InventoryLedger.class.getDeclaredField("afterQuantity"));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void assertThrowsNoField(Class<?> type, String fieldName) {
|
private static void assertThrowsNoField(Class<?> type, String fieldName) {
|
||||||
|
|||||||
+11
@@ -76,6 +76,17 @@ class InventoryCommandServiceTest {
|
|||||||
verify(operations).updateById(any(InventoryOperation.class));
|
verify(operations).updateById(any(InventoryOperation.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void stockInAllowsPalletPositionWithZeroOptionalPackages() {
|
||||||
|
when(balances.lockDimension("100", "WH-1", "ONT8", 101L, "IN-CODE-1")).thenReturn(null);
|
||||||
|
|
||||||
|
var result = service.stockIn("100", "7", stockIn("IN-1", "stock-in-zero", 0, 0));
|
||||||
|
|
||||||
|
assertEquals(0, result.availablePackages());
|
||||||
|
verify(positions).insert(any(InventoryPosition.class));
|
||||||
|
verify(ledgers).insert(any(InventoryLedger.class));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void holdRejectsWhenAvailablePackagesAreInsufficient() {
|
void holdRejectsWhenAvailablePackagesAreInsufficient() {
|
||||||
InventoryBalance balance = balance(200L, 5);
|
InventoryBalance balance = balance(200L, 5);
|
||||||
|
|||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
package com.baiyue.lastleg.procurement;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
class OverseasProcurementInventoryBoundaryTest {
|
||||||
|
@Test
|
||||||
|
void procurementRemainsABusinessDocumentAndDoesNotMoveAvailableInventory() throws Exception {
|
||||||
|
String source = Files.readString(Path.of(
|
||||||
|
"src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java"),
|
||||||
|
StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
assertTrue(source.contains("PENDING_REVIEW"));
|
||||||
|
assertFalse(source.contains("InventoryCommandService"));
|
||||||
|
assertFalse(source.contains("stockIn("));
|
||||||
|
assertFalse(source.contains("setAvailablePackages"));
|
||||||
|
assertFalse(source.contains("availablePackages"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+140
@@ -0,0 +1,140 @@
|
|||||||
|
package com.baiyue.lastleg.schema;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
class SchemaChineseCommentContractTest {
|
||||||
|
|
||||||
|
private static final Pattern CHINESE = Pattern.compile("[\\u4e00-\\u9fff]");
|
||||||
|
private static final Pattern TABLE = Pattern.compile(
|
||||||
|
"(?is)CREATE\\s+TABLE\\s+IF\\s+NOT\\s+EXISTS\\s+(ll_[a-z0-9_]+)"
|
||||||
|
+ "\\s*\\((.*?)\\)\\s*ENGINE\\s*=\\s*InnoDB\\s+COMMENT\\s*=\\s*'([^']*)'\\s*;");
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void allLastlegTablesAndColumnsHaveChineseComments() throws Exception {
|
||||||
|
String baseline = Files.readString(
|
||||||
|
Path.of("src", "main", "resources", "db", "lastleg.sql"), StandardCharsets.UTF_8);
|
||||||
|
String incremental = Files.readString(
|
||||||
|
Path.of("..", "scripts", "db", "incremental.sql"), StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
List<TableDefinition> baselineTables = assertAllChineseComments(baseline, "基线 SQL", 55);
|
||||||
|
assertAllChineseComments(incremental, "增量 SQL", 49);
|
||||||
|
assertCommentMigrationCovers(incremental, baselineTables);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void newlyAddedColumnsCarryChineseCommentsInBaselineAndIncrementalSql() throws Exception {
|
||||||
|
String baseline = Files.readString(
|
||||||
|
Path.of("src", "main", "resources", "db", "lastleg.sql"), StandardCharsets.UTF_8);
|
||||||
|
String incremental = Files.readString(
|
||||||
|
Path.of("..", "scripts", "db", "incremental.sql"), StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
List<String> columns = List.of(
|
||||||
|
"allocation_idempotency_key", "allocation_request_hash", "outbound_idempotency_key",
|
||||||
|
"outbound_request_hash", "outbound_at", "pallet_count", "received_pallet_count",
|
||||||
|
"allocated_pallet_count", "shipped_pallet_count", "receipt_idempotency_key",
|
||||||
|
"receipt_request_hash"
|
||||||
|
);
|
||||||
|
for (String column : columns) {
|
||||||
|
assertColumnHasChineseComment(baseline, column, "基线 SQL");
|
||||||
|
assertColumnHasChineseComment(incremental, column, "增量 SQL");
|
||||||
|
assertIncrementalAddColumnHasChineseComment(incremental, column);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<TableDefinition> assertAllChineseComments(String sql, String source, int expectedCount) {
|
||||||
|
List<TableDefinition> tables = new ArrayList<>();
|
||||||
|
Matcher matcher = TABLE.matcher(sql);
|
||||||
|
while (matcher.find()) {
|
||||||
|
String table = matcher.group(1);
|
||||||
|
assertTrue(CHINESE.matcher(matcher.group(3)).find(), source + " 表备注必须包含中文: " + table);
|
||||||
|
List<String> columns = splitTopLevel(matcher.group(2));
|
||||||
|
assertFalse(columns.isEmpty(), source + " 表定义没有字段: " + table);
|
||||||
|
List<String> columnNames = new ArrayList<>();
|
||||||
|
for (String definition : columns) {
|
||||||
|
if (isConstraint(definition)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String column = definition.stripLeading().split("\\s+", 2)[0].replace("`", "");
|
||||||
|
columnNames.add(column);
|
||||||
|
assertTrue(Pattern.compile("(?is)\\bCOMMENT\\s+'[^']*[\\u4e00-\\u9fff][^']*'")
|
||||||
|
.matcher(definition).find(),
|
||||||
|
source + " 字段备注必须包含中文: " + table + "." + column);
|
||||||
|
}
|
||||||
|
tables.add(new TableDefinition(table, columnNames));
|
||||||
|
}
|
||||||
|
assertTrue(tables.size() >= expectedCount,
|
||||||
|
source + " 解析到的尾程表数量不足,实际=" + tables.size() + ",期望至少=" + expectedCount);
|
||||||
|
return tables;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertCommentMigrationCovers(String incremental, List<TableDefinition> tables) {
|
||||||
|
int marker = incremental.indexOf("2026-08-18 / 025 Chinese table and column comments");
|
||||||
|
assertTrue(marker >= 0, "增量 SQL 缺少 025 中文备注迁移");
|
||||||
|
String migration = incremental.substring(marker);
|
||||||
|
for (TableDefinition table : tables) {
|
||||||
|
assertTrue(migration.contains("table_name='" + table.name() + "'"),
|
||||||
|
"025 备注迁移缺少表: " + table.name());
|
||||||
|
for (String column : table.columns()) {
|
||||||
|
assertTrue(migration.contains("table_name='" + table.name()
|
||||||
|
+ "' AND column_name='" + column + "'"),
|
||||||
|
"025 备注迁移缺少字段: " + table.name() + "." + column);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isConstraint(String definition) {
|
||||||
|
String normalized = definition.stripLeading().toUpperCase();
|
||||||
|
return normalized.startsWith("PRIMARY ") || normalized.startsWith("UNIQUE ")
|
||||||
|
|| normalized.startsWith("KEY ") || normalized.startsWith("CONSTRAINT ")
|
||||||
|
|| normalized.startsWith("FOREIGN ") || normalized.startsWith("CHECK ");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertColumnHasChineseComment(String sql, String column, String source) {
|
||||||
|
Pattern columnPattern = Pattern.compile(
|
||||||
|
"(?is)\\b" + Pattern.quote(column) + "\\b[^,;]*?COMMENT\\s+''?[^',;]*[\\u4e00-\\u9fff][^',;]*''?");
|
||||||
|
assertTrue(columnPattern.matcher(sql).find(), source + " 新增字段缺少中文备注: " + column);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertIncrementalAddColumnHasChineseComment(String sql, String column) {
|
||||||
|
Pattern addColumnPattern = Pattern.compile(
|
||||||
|
"(?is)ADD\\s+COLUMN\\s+" + Pattern.quote(column)
|
||||||
|
+ "\\b[^;]*?COMMENT\\s+''[^';]*[\\u4e00-\\u9fff][^';]*''");
|
||||||
|
assertTrue(addColumnPattern.matcher(sql).find(), "增量 SQL 的 ADD COLUMN 缺少中文备注: " + column);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<String> splitTopLevel(String body) {
|
||||||
|
List<String> definitions = new ArrayList<>();
|
||||||
|
int start = 0;
|
||||||
|
int parentheses = 0;
|
||||||
|
boolean quoted = false;
|
||||||
|
for (int i = 0; i < body.length(); i++) {
|
||||||
|
char current = body.charAt(i);
|
||||||
|
if (current == '\'' && (i == 0 || body.charAt(i - 1) != '\\')) {
|
||||||
|
quoted = !quoted;
|
||||||
|
} else if (!quoted && current == '(') {
|
||||||
|
parentheses++;
|
||||||
|
} else if (!quoted && current == ')') {
|
||||||
|
parentheses--;
|
||||||
|
} else if (!quoted && parentheses == 0 && current == ',') {
|
||||||
|
definitions.add(body.substring(start, i).trim());
|
||||||
|
start = i + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
definitions.add(body.substring(start).trim());
|
||||||
|
return definitions.stream().filter(value -> !value.isEmpty()).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private record TableDefinition(String name, List<String> columns) {
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-1
@@ -11,11 +11,15 @@ class WarehouseTransferSchemaContractTest {
|
|||||||
void baselineAndIncrementalContainTransferAndHistoricalOrderBindingTables() throws Exception {
|
void baselineAndIncrementalContainTransferAndHistoricalOrderBindingTables() throws Exception {
|
||||||
String baseline = Files.readString(Path.of("src", "main", "resources", "db", "lastleg.sql"), StandardCharsets.UTF_8).toLowerCase();
|
String baseline = Files.readString(Path.of("src", "main", "resources", "db", "lastleg.sql"), StandardCharsets.UTF_8).toLowerCase();
|
||||||
String migration = Files.readString(Path.of("..", "scripts", "db", "incremental.sql"), StandardCharsets.UTF_8).toLowerCase();
|
String migration = Files.readString(Path.of("..", "scripts", "db", "incremental.sql"), StandardCharsets.UTF_8).toLowerCase();
|
||||||
for (String table : new String[]{"ll_warehouse_transfer_order", "ll_warehouse_transfer_source_order", "ll_warehouse_transfer_item"}) {
|
for (String table : new String[]{"ll_warehouse_transfer_order", "ll_warehouse_transfer_source_order",
|
||||||
|
"ll_warehouse_transfer_item", "ll_warehouse_transfer_inventory_allocation",
|
||||||
|
"ll_warehouse_transfer_receipt"}) {
|
||||||
assertTrue(baseline.contains("create table if not exists " + table));
|
assertTrue(baseline.contains("create table if not exists " + table));
|
||||||
assertTrue(migration.contains("create table if not exists " + table));
|
assertTrue(migration.contains("create table if not exists " + table));
|
||||||
}
|
}
|
||||||
assertTrue(migration.contains("information_schema.statistics"));
|
assertTrue(migration.contains("information_schema.statistics"));
|
||||||
assertTrue(migration.contains("uk_ll_wh_transfer_source"));
|
assertTrue(migration.contains("uk_ll_wh_transfer_source"));
|
||||||
|
assertTrue(migration.contains("uk_ll_wh_transfer_active_position"));
|
||||||
|
assertTrue(migration.contains("uk_ll_wh_transfer_receipt_source"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+194
-1
@@ -4,6 +4,11 @@ import com.baiyue.lastleg.entity.LastlegOrder;
|
|||||||
import com.baiyue.lastleg.relabel.entity.RelabelApplication;
|
import com.baiyue.lastleg.relabel.entity.RelabelApplication;
|
||||||
import com.baiyue.lastleg.relabel.entity.RelabelItem;
|
import com.baiyue.lastleg.relabel.entity.RelabelItem;
|
||||||
import com.baiyue.lastleg.relabel.entity.RelabelStatusHistory;
|
import com.baiyue.lastleg.relabel.entity.RelabelStatusHistory;
|
||||||
|
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandResponse;
|
||||||
|
import com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper;
|
||||||
|
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
|
||||||
|
import com.baiyue.lastleg.inventory.entity.InventoryBalance;
|
||||||
|
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
|
||||||
import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper;
|
import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper;
|
||||||
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
||||||
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
|
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
|
||||||
@@ -13,10 +18,13 @@ import com.baiyue.lastleg.relabel.mapper.RelabelStatusHistoryMapper;
|
|||||||
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
|
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
|
||||||
import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests;
|
import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests;
|
||||||
import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder;
|
import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder;
|
||||||
|
import com.baiyue.lastleg.transfer.entity.WarehouseTransferInventoryAllocation;
|
||||||
import com.baiyue.lastleg.transfer.entity.WarehouseTransferSourceOrder;
|
import com.baiyue.lastleg.transfer.entity.WarehouseTransferSourceOrder;
|
||||||
import com.baiyue.lastleg.transfer.mapper.InboundSnapshotMapper;
|
import com.baiyue.lastleg.transfer.mapper.InboundSnapshotMapper;
|
||||||
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferItemMapper;
|
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferItemMapper;
|
||||||
|
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferInventoryAllocationMapper;
|
||||||
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper;
|
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper;
|
||||||
|
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferReceiptMapper;
|
||||||
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferSourceOrderMapper;
|
import com.baiyue.lastleg.transfer.mapper.WarehouseTransferSourceOrderMapper;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
@@ -29,17 +37,23 @@ import org.springframework.test.util.ReflectionTestUtils;
|
|||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.math.BigDecimal;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.Mockito.never;
|
import static org.mockito.Mockito.never;
|
||||||
import static org.mockito.Mockito.lenient;
|
import static org.mockito.Mockito.lenient;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.verifyNoInteractions;
|
||||||
|
import static org.mockito.Mockito.inOrder;
|
||||||
|
import static org.mockito.Mockito.doThrow;
|
||||||
|
import static org.mockito.Mockito.clearInvocations;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
@ExtendWith(MockitoExtension.class)
|
||||||
@@ -47,6 +61,8 @@ class WarehouseTransferServiceTest {
|
|||||||
@Mock WarehouseTransferOrderMapper transfers;
|
@Mock WarehouseTransferOrderMapper transfers;
|
||||||
@Mock WarehouseTransferItemMapper transferItems;
|
@Mock WarehouseTransferItemMapper transferItems;
|
||||||
@Mock WarehouseTransferSourceOrderMapper sourceOrders;
|
@Mock WarehouseTransferSourceOrderMapper sourceOrders;
|
||||||
|
@Mock WarehouseTransferInventoryAllocationMapper inventoryAllocations;
|
||||||
|
@Mock WarehouseTransferReceiptMapper transferReceipts;
|
||||||
@Mock InboundSnapshotMapper inboundSnapshots;
|
@Mock InboundSnapshotMapper inboundSnapshots;
|
||||||
@Mock LastlegOrderMapper orders;
|
@Mock LastlegOrderMapper orders;
|
||||||
@Mock LastlegOrderDetailMapper orderDetails;
|
@Mock LastlegOrderDetailMapper orderDetails;
|
||||||
@@ -55,6 +71,8 @@ class WarehouseTransferServiceTest {
|
|||||||
@Mock RelabelItemMapper relabelItems;
|
@Mock RelabelItemMapper relabelItems;
|
||||||
@Mock RelabelStatusHistoryMapper relabelHistories;
|
@Mock RelabelStatusHistoryMapper relabelHistories;
|
||||||
@Mock LastlegOrderNoGenerator orderNoGenerator;
|
@Mock LastlegOrderNoGenerator orderNoGenerator;
|
||||||
|
@Mock InventoryCommandService inventory;
|
||||||
|
@Mock InventoryCommandMapper inventoryCommands;
|
||||||
private WarehouseTransferService service;
|
private WarehouseTransferService service;
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
@@ -63,6 +81,8 @@ class WarehouseTransferServiceTest {
|
|||||||
ReflectionTestUtils.setField(service, "transfers", transfers);
|
ReflectionTestUtils.setField(service, "transfers", transfers);
|
||||||
ReflectionTestUtils.setField(service, "transferItems", transferItems);
|
ReflectionTestUtils.setField(service, "transferItems", transferItems);
|
||||||
ReflectionTestUtils.setField(service, "sourceOrders", sourceOrders);
|
ReflectionTestUtils.setField(service, "sourceOrders", sourceOrders);
|
||||||
|
ReflectionTestUtils.setField(service, "inventoryAllocations", inventoryAllocations);
|
||||||
|
ReflectionTestUtils.setField(service, "transferReceipts", transferReceipts);
|
||||||
ReflectionTestUtils.setField(service, "inboundSnapshots", inboundSnapshots);
|
ReflectionTestUtils.setField(service, "inboundSnapshots", inboundSnapshots);
|
||||||
ReflectionTestUtils.setField(service, "orders", orders);
|
ReflectionTestUtils.setField(service, "orders", orders);
|
||||||
ReflectionTestUtils.setField(service, "orderDetails", orderDetails);
|
ReflectionTestUtils.setField(service, "orderDetails", orderDetails);
|
||||||
@@ -71,8 +91,12 @@ class WarehouseTransferServiceTest {
|
|||||||
ReflectionTestUtils.setField(service, "relabelItems", relabelItems);
|
ReflectionTestUtils.setField(service, "relabelItems", relabelItems);
|
||||||
ReflectionTestUtils.setField(service, "relabelHistories", relabelHistories);
|
ReflectionTestUtils.setField(service, "relabelHistories", relabelHistories);
|
||||||
ReflectionTestUtils.setField(service, "orderNoGenerator", orderNoGenerator);
|
ReflectionTestUtils.setField(service, "orderNoGenerator", orderNoGenerator);
|
||||||
|
ReflectionTestUtils.setField(service, "inventory", inventory);
|
||||||
|
ReflectionTestUtils.setField(service, "inventoryCommands", inventoryCommands);
|
||||||
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules());
|
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules());
|
||||||
lenient().when(orderNoGenerator.next()).thenReturn("LMO-TRANSFER-1");
|
lenient().when(orderNoGenerator.next()).thenReturn("LMO-TRANSFER-1");
|
||||||
|
lenient().when(inventoryAllocations.selectList(any())).thenReturn(List.of());
|
||||||
|
lenient().when(transferReceipts.selectList(any())).thenReturn(List.of());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -92,6 +116,7 @@ class WarehouseTransferServiceTest {
|
|||||||
assertEquals(WarehouseTransferService.RETURN_RELABEL_ORDER_TYPE, tailOrder.getValue().getOrderType());
|
assertEquals(WarehouseTransferService.RETURN_RELABEL_ORDER_TYPE, tailOrder.getValue().getOrderType());
|
||||||
assertEquals(WarehouseTransferService.PENDING_REVIEW, tailOrder.getValue().getCurrentStatus());
|
assertEquals(WarehouseTransferService.PENDING_REVIEW, tailOrder.getValue().getCurrentStatus());
|
||||||
assertEquals("LMO-TRANSFER-1", tailOrder.getValue().getOrderNo());
|
assertEquals("LMO-TRANSFER-1", tailOrder.getValue().getOrderNo());
|
||||||
|
assertNull(tailOrder.getValue().getTransferWarehouseId());
|
||||||
verify(sourceOrders).insert(any(WarehouseTransferSourceOrder.class));
|
verify(sourceOrders).insert(any(WarehouseTransferSourceOrder.class));
|
||||||
verify(relabelApplications).insert(any(RelabelApplication.class));
|
verify(relabelApplications).insert(any(RelabelApplication.class));
|
||||||
verify(relabelItems).insert(any(RelabelItem.class));
|
verify(relabelItems).insert(any(RelabelItem.class));
|
||||||
@@ -139,6 +164,126 @@ class WarehouseTransferServiceTest {
|
|||||||
assertEquals(WarehouseTransferService.PENDING_ASSIGNMENT, result.orderStatus());
|
assertEquals(WarehouseTransferService.PENDING_ASSIGNMENT, result.orderStatus());
|
||||||
verify(transfers).updateById(transfer);
|
verify(transfers).updateById(transfer);
|
||||||
verify(orders).updateById(tailOrder);
|
verify(orders).updateById(tailOrder);
|
||||||
|
verifyNoInteractions(inventory);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void approvedTransferAllocatesExactSourceInboundPalletWithoutMovingInventory() {
|
||||||
|
WarehouseTransferOrder transfer = transfer("APPROVED");
|
||||||
|
LastlegOrder tailOrder = tailOrder();
|
||||||
|
WarehouseTransferSourceOrder source = sourceOrder();
|
||||||
|
InventoryPosition position = position(501L, 601L, 701L, 5L);
|
||||||
|
InventoryBalance balance = balance(601L, 101L, "IN-1");
|
||||||
|
when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer);
|
||||||
|
when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer);
|
||||||
|
when(sourceOrders.selectList(any())).thenReturn(List.of(source));
|
||||||
|
when(inventoryCommands.lockPosition("TENANT-A", 501L)).thenReturn(position);
|
||||||
|
when(inventoryCommands.lockBalance("TENANT-A", 601L)).thenReturn(balance);
|
||||||
|
when(orders.selectById(31L)).thenReturn(tailOrder);
|
||||||
|
when(transferItems.selectList(any())).thenReturn(List.of());
|
||||||
|
|
||||||
|
var result = service.allocate("TENANT-A", "WTR-1", "USER-1",
|
||||||
|
new WarehouseTransferRequests.Allocate("ALLOC-1",
|
||||||
|
List.of(new WarehouseTransferRequests.Allocation(501L, 701L, 5))));
|
||||||
|
|
||||||
|
ArgumentCaptor<WarehouseTransferInventoryAllocation> captured =
|
||||||
|
ArgumentCaptor.forClass(WarehouseTransferInventoryAllocation.class);
|
||||||
|
verify(inventoryAllocations).insert(captured.capture());
|
||||||
|
assertEquals(101L, captured.getValue().getInboundOrderId());
|
||||||
|
assertEquals(701L, captured.getValue().getPalletId());
|
||||||
|
assertEquals(5, captured.getValue().getPackageCount());
|
||||||
|
assertEquals(WarehouseTransferService.ALLOCATED, transfer.getStatus());
|
||||||
|
assertEquals("READY_TO_OUTBOUND", result.orderStatus());
|
||||||
|
verifyNoInteractions(inventory);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void outboundMovesExactPackagesAllocatePickShipInOrderAndReceiptStocksInSameCount() {
|
||||||
|
WarehouseTransferOrder transfer = transfer(WarehouseTransferService.ALLOCATED);
|
||||||
|
LastlegOrder tailOrder = tailOrder();
|
||||||
|
WarehouseTransferInventoryAllocation allocation = allocation();
|
||||||
|
when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer);
|
||||||
|
when(transfers.findNo("TENANT-A", "WTR-1")).thenReturn(transfer);
|
||||||
|
when(inventoryAllocations.lockTransfer("TENANT-A", 21L)).thenReturn(List.of(allocation));
|
||||||
|
when(orders.selectById(31L)).thenReturn(tailOrder);
|
||||||
|
when(transferItems.selectList(any())).thenReturn(List.of());
|
||||||
|
when(sourceOrders.selectList(any())).thenReturn(List.of());
|
||||||
|
|
||||||
|
service.outbound("TENANT-A", "WTR-1", "USER-1",
|
||||||
|
new WarehouseTransferRequests.Outbound("OUT-1", "departed"));
|
||||||
|
|
||||||
|
var ordered = inOrder(inventory);
|
||||||
|
ArgumentCaptor<com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests.Move> move =
|
||||||
|
ArgumentCaptor.forClass(com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests.Move.class);
|
||||||
|
ordered.verify(inventory).allocate(eq("TENANT-A"), eq("USER-1"), move.capture());
|
||||||
|
ordered.verify(inventory).pick(eq("TENANT-A"), eq("USER-1"), move.capture());
|
||||||
|
ordered.verify(inventory).ship(eq("TENANT-A"), eq("USER-1"), move.capture());
|
||||||
|
assertTrue(move.getAllValues().stream().allMatch(value -> value.positionId() == 501L && value.packages() == 5));
|
||||||
|
assertEquals(WarehouseTransferService.OUTBOUNDED, transfer.getStatus());
|
||||||
|
assertEquals("SHIPPED", allocation.getStatus());
|
||||||
|
|
||||||
|
clearInvocations(inventory);
|
||||||
|
service.outbound("TENANT-A", "WTR-1", "USER-1",
|
||||||
|
new WarehouseTransferRequests.Outbound("OUT-1", "departed"));
|
||||||
|
verifyNoInteractions(inventory);
|
||||||
|
|
||||||
|
when(transferReceipts.findSource("TENANT-A", 21L, 501L)).thenReturn(null);
|
||||||
|
when(inventory.stockIn(eq("TENANT-A"), eq("USER-1"), any())).thenReturn(
|
||||||
|
new InventoryCommandResponse(1L, "STOCK_IN", 801L, 901L, 5, 0, 0, 0, false, false));
|
||||||
|
service.receive("TENANT-A", "WTR-1", "USER-1", receiptRequest());
|
||||||
|
|
||||||
|
ArgumentCaptor<com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests.StockIn> stockIn =
|
||||||
|
ArgumentCaptor.forClass(com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests.StockIn.class);
|
||||||
|
verify(inventory).stockIn(eq("TENANT-A"), eq("USER-1"), stockIn.capture());
|
||||||
|
assertEquals(5, stockIn.getValue().packages());
|
||||||
|
assertEquals("WTR-1", stockIn.getValue().businessOrderNo());
|
||||||
|
assertEquals("WAREHOUSE_TRANSFER_RECEIPT:WTR-1:501", stockIn.getValue().idempotencyKey());
|
||||||
|
assertEquals(WarehouseTransferService.RECEIVED, transfer.getStatus());
|
||||||
|
assertEquals("RECEIVED", allocation.getStatus());
|
||||||
|
|
||||||
|
ArgumentCaptor<com.baiyue.lastleg.transfer.entity.WarehouseTransferReceipt> receipt =
|
||||||
|
ArgumentCaptor.forClass(com.baiyue.lastleg.transfer.entity.WarehouseTransferReceipt.class);
|
||||||
|
verify(transferReceipts).insert(receipt.capture());
|
||||||
|
when(transferReceipts.findSource("TENANT-A", 21L, 501L)).thenReturn(receipt.getValue());
|
||||||
|
clearInvocations(inventory);
|
||||||
|
service.receive("TENANT-A", "WTR-1", "USER-1", receiptRequest());
|
||||||
|
verifyNoInteractions(inventory);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void outboundFailureDoesNotAdvanceTransferOrPersistAllocationShipment() {
|
||||||
|
WarehouseTransferOrder transfer = transfer(WarehouseTransferService.ALLOCATED);
|
||||||
|
WarehouseTransferInventoryAllocation allocation = allocation();
|
||||||
|
when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer);
|
||||||
|
when(inventoryAllocations.lockTransfer("TENANT-A", 21L)).thenReturn(List.of(allocation));
|
||||||
|
doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "insufficient"))
|
||||||
|
.when(inventory).pick(eq("TENANT-A"), eq("USER-1"), any());
|
||||||
|
|
||||||
|
assertThrows(ResponseStatusException.class, () -> service.outbound("TENANT-A", "WTR-1", "USER-1",
|
||||||
|
new WarehouseTransferRequests.Outbound("OUT-1", null)));
|
||||||
|
|
||||||
|
verify(inventory, never()).ship(eq("TENANT-A"), eq("USER-1"), any());
|
||||||
|
verify(inventoryAllocations, never()).updateById(any(WarehouseTransferInventoryAllocation.class));
|
||||||
|
verify(transfers, never()).updateById(transfer);
|
||||||
|
assertEquals(WarehouseTransferService.ALLOCATED, transfer.getStatus());
|
||||||
|
assertEquals("RESERVED", allocation.getStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void allocationRejectsPositionOutsideSelectedInboundOrder() {
|
||||||
|
WarehouseTransferOrder transfer = transfer("APPROVED");
|
||||||
|
when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer);
|
||||||
|
when(sourceOrders.selectList(any())).thenReturn(List.of(sourceOrder()));
|
||||||
|
when(inventoryCommands.lockPosition("TENANT-A", 501L)).thenReturn(position(501L, 601L, 701L, 5L));
|
||||||
|
when(inventoryCommands.lockBalance("TENANT-A", 601L)).thenReturn(balance(601L, 999L, "OTHER"));
|
||||||
|
|
||||||
|
ResponseStatusException exception = assertThrows(ResponseStatusException.class, () ->
|
||||||
|
service.allocate("TENANT-A", "WTR-1", "USER-1", new WarehouseTransferRequests.Allocate("ALLOC-1",
|
||||||
|
List.of(new WarehouseTransferRequests.Allocation(501L, 701L, 5)))));
|
||||||
|
|
||||||
|
assertEquals(409, exception.getStatusCode().value());
|
||||||
|
verify(inventoryAllocations, never()).insert(any(WarehouseTransferInventoryAllocation.class));
|
||||||
|
verifyNoInteractions(inventory);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -156,7 +301,7 @@ class WarehouseTransferServiceTest {
|
|||||||
|
|
||||||
private static WarehouseTransferRequests.Create request(boolean needRelabel) {
|
private static WarehouseTransferRequests.Create request(boolean needRelabel) {
|
||||||
return new WarehouseTransferRequests.Create("IN-1", List.of(11L), "WH-1", "ONT8",
|
return new WarehouseTransferRequests.Create("IN-1", List.of(11L), "WH-1", "ONT8",
|
||||||
"123 FBA Road", 5, "TRUCK", LocalDateTime.of(2026, 8, 20, 10, 0), "FBA-SHP-1",
|
"123 FBA Road", 5, new BigDecimal("0.2500"), "TRUCK", LocalDateTime.of(2026, 8, 20, 10, 0), "FBA-SHP-1",
|
||||||
"TRACK-1", "BOX-1-5", "remark", needRelabel, "key-1",
|
"TRACK-1", "BOX-1-5", "remark", needRelabel, "key-1",
|
||||||
List.of(new WarehouseTransferRequests.Item("SKU-1", "Product", 5, "MARK-1")));
|
List.of(new WarehouseTransferRequests.Item("SKU-1", "Product", 5, "MARK-1")));
|
||||||
}
|
}
|
||||||
@@ -174,4 +319,52 @@ class WarehouseTransferServiceTest {
|
|||||||
transfer.setLastlegOrderNo("LMO-TRANSFER-1"); transfer.setNeedRelabel(false); transfer.setPackageCount(5);
|
transfer.setLastlegOrderNo("LMO-TRANSFER-1"); transfer.setNeedRelabel(false); transfer.setPackageCount(5);
|
||||||
return transfer;
|
return transfer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static WarehouseTransferOrder transfer(String status) {
|
||||||
|
WarehouseTransferOrder transfer = persistedTransfer();
|
||||||
|
transfer.setTransferNo("WTR-1"); transfer.setStatus(status); transfer.setPackageCount(5);
|
||||||
|
return transfer;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LastlegOrder tailOrder() {
|
||||||
|
LastlegOrder order = historicalOrder();
|
||||||
|
order.setId(31L); order.setCurrentStatus("PENDING_ASSIGNMENT");
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WarehouseTransferSourceOrder sourceOrder() {
|
||||||
|
WarehouseTransferSourceOrder source = new WarehouseTransferSourceOrder();
|
||||||
|
source.setId(41L); source.setTransferOrderId(21L); source.setCustomerId("TENANT-A");
|
||||||
|
source.setInboundOrderId(101L); source.setInboundCode("IN-1");
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static InventoryPosition position(long id, long balanceId, long palletId, long available) {
|
||||||
|
InventoryPosition position = new InventoryPosition();
|
||||||
|
position.setId(id); position.setCustomerId("TENANT-A"); position.setBalanceId(balanceId);
|
||||||
|
position.setPalletId(palletId); position.setPalletNo("PALLET-1"); position.setAvailablePackages(available);
|
||||||
|
return position;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static InventoryBalance balance(long id, long inboundOrderId, String inboundCode) {
|
||||||
|
InventoryBalance balance = new InventoryBalance();
|
||||||
|
balance.setId(id); balance.setCustomerId("TENANT-A"); balance.setInboundOrderId(inboundOrderId);
|
||||||
|
balance.setInboundCode(inboundCode);
|
||||||
|
return balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WarehouseTransferInventoryAllocation allocation() {
|
||||||
|
WarehouseTransferInventoryAllocation allocation = new WarehouseTransferInventoryAllocation();
|
||||||
|
allocation.setId(51L); allocation.setTransferOrderId(21L); allocation.setCustomerId("TENANT-A");
|
||||||
|
allocation.setInventoryPositionId(501L); allocation.setInventoryBalanceId(601L);
|
||||||
|
allocation.setInboundOrderId(101L); allocation.setInboundCode("IN-1"); allocation.setPalletId(701L);
|
||||||
|
allocation.setPalletNo("PALLET-1"); allocation.setPackageCount(5); allocation.setStatus("RESERVED");
|
||||||
|
return allocation;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WarehouseTransferRequests.Receipt receiptRequest() {
|
||||||
|
return new WarehouseTransferRequests.Receipt(501L, "WH-DEST", "WH-D", "Destination warehouse",
|
||||||
|
"FBA-ID", "ONT8", "Amazon ONT8", 801L, "PALLET-DEST", "WTR-1", 901L,
|
||||||
|
"LOC-DEST", "RECEIVE-1", "received");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ class UsTruckOrderSchemaContractTest {
|
|||||||
}
|
}
|
||||||
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_us_truck_order"));
|
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_us_truck_order"));
|
||||||
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_us_truck_order_status_history"));
|
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_us_truck_order_status_history"));
|
||||||
|
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_us_truck_order_pallet"));
|
||||||
|
assertTrue(sql.contains("pallet_count INT NOT NULL DEFAULT 1"));
|
||||||
|
assertTrue(sql.contains("received_pallet_count INT NOT NULL DEFAULT 0"));
|
||||||
|
assertTrue(sql.contains("receipt_idempotency_key VARCHAR(255) NULL"));
|
||||||
assertTrue(sql.contains("UNIQUE KEY uk_ll_us_truck_order_key(customer_id,idempotency_key)"));
|
assertTrue(sql.contains("UNIQUE KEY uk_ll_us_truck_order_key(customer_id,idempotency_key)"));
|
||||||
assertTrue(sql.contains("KEY idx_ll_us_truck_history(customer_id,order_id,created_at)"));
|
assertTrue(sql.contains("KEY idx_ll_us_truck_history(customer_id,order_id,created_at)"));
|
||||||
}
|
}
|
||||||
|
|||||||
+52
-3
@@ -2,8 +2,10 @@ package com.baiyue.lastleg.trucking.service;
|
|||||||
|
|
||||||
import com.baiyue.lastleg.trucking.dto.UsTruckOrderRequests;
|
import com.baiyue.lastleg.trucking.dto.UsTruckOrderRequests;
|
||||||
import com.baiyue.lastleg.trucking.entity.UsTruckOrder;
|
import com.baiyue.lastleg.trucking.entity.UsTruckOrder;
|
||||||
|
import com.baiyue.lastleg.trucking.entity.UsTruckOrderPallet;
|
||||||
import com.baiyue.lastleg.trucking.entity.UsTruckOrderStatusHistory;
|
import com.baiyue.lastleg.trucking.entity.UsTruckOrderStatusHistory;
|
||||||
import com.baiyue.lastleg.trucking.mapper.UsTruckOrderMapper;
|
import com.baiyue.lastleg.trucking.mapper.UsTruckOrderMapper;
|
||||||
|
import com.baiyue.lastleg.trucking.mapper.UsTruckOrderPalletMapper;
|
||||||
import com.baiyue.lastleg.trucking.mapper.UsTruckOrderStatusHistoryMapper;
|
import com.baiyue.lastleg.trucking.mapper.UsTruckOrderStatusHistoryMapper;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
@@ -17,6 +19,7 @@ import org.springframework.web.server.ResponseStatusException;
|
|||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
@@ -30,6 +33,7 @@ import static org.mockito.Mockito.when;
|
|||||||
@ExtendWith(MockitoExtension.class)
|
@ExtendWith(MockitoExtension.class)
|
||||||
class UsTruckOrderServiceTest {
|
class UsTruckOrderServiceTest {
|
||||||
@Mock UsTruckOrderMapper orders;
|
@Mock UsTruckOrderMapper orders;
|
||||||
|
@Mock UsTruckOrderPalletMapper pallets;
|
||||||
@Mock UsTruckOrderStatusHistoryMapper histories;
|
@Mock UsTruckOrderStatusHistoryMapper histories;
|
||||||
private UsTruckOrderService service;
|
private UsTruckOrderService service;
|
||||||
|
|
||||||
@@ -37,8 +41,10 @@ class UsTruckOrderServiceTest {
|
|||||||
void setUp() {
|
void setUp() {
|
||||||
service = new UsTruckOrderService();
|
service = new UsTruckOrderService();
|
||||||
ReflectionTestUtils.setField(service, "orders", orders);
|
ReflectionTestUtils.setField(service, "orders", orders);
|
||||||
|
ReflectionTestUtils.setField(service, "pallets", pallets);
|
||||||
ReflectionTestUtils.setField(service, "histories", histories);
|
ReflectionTestUtils.setField(service, "histories", histories);
|
||||||
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules());
|
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules());
|
||||||
|
org.mockito.Mockito.lenient().when(pallets.listOrder(anyString(), any())).thenReturn(List.of());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -63,9 +69,9 @@ class UsTruckOrderServiceTest {
|
|||||||
when(histories.listOrder("100", 1L)).thenReturn(List.of());
|
when(histories.listOrder("100", 1L)).thenReturn(List.of());
|
||||||
|
|
||||||
var result = service.changeStatus("100", "TK1", "7",
|
var result = service.changeStatus("100", "TK1", "7",
|
||||||
new UsTruckOrderRequests.ChangeStatus(0L, "PENDING_DISPATCH", "accepted"));
|
new UsTruckOrderRequests.ChangeStatus(0L, "WAITING_INBOUND", "accepted"));
|
||||||
|
|
||||||
assertEquals(UsTruckOrderService.PENDING_DISPATCH, result.summary().status());
|
assertEquals(UsTruckOrderService.WAITING_INBOUND, result.summary().status());
|
||||||
assertEquals(1, result.summary().version());
|
assertEquals(1, result.summary().version());
|
||||||
verify(histories).insert(any(UsTruckOrderStatusHistory.class));
|
verify(histories).insert(any(UsTruckOrderStatusHistory.class));
|
||||||
}
|
}
|
||||||
@@ -78,6 +84,47 @@ class UsTruckOrderServiceTest {
|
|||||||
assertEquals(409, error.getStatusCode().value());
|
assertEquals(409, error.getStatusCode().value());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void detailIncludesBoundPallets() {
|
||||||
|
UsTruckOrder order = order(UsTruckOrderService.WAITING_INBOUND, 1L);
|
||||||
|
UsTruckOrderPallet pallet = new UsTruckOrderPallet();
|
||||||
|
pallet.setId(10L); pallet.setPalletNo("PLT-001"); pallet.setSequenceNo(1);
|
||||||
|
pallet.setSource("SYSTEM"); pallet.setStatus("OCCUPIED");
|
||||||
|
pallet.setWarehousePalletId(20L); pallet.setPackageCount(12);
|
||||||
|
when(orders.findNo("100", "TK1")).thenReturn(order);
|
||||||
|
when(histories.listOrder("100", 1L)).thenReturn(List.of());
|
||||||
|
when(pallets.listOrder("100", 1L)).thenReturn(List.of(pallet));
|
||||||
|
|
||||||
|
var detail = service.detail("100", "TK1");
|
||||||
|
|
||||||
|
assertEquals(1, detail.pallets().size());
|
||||||
|
assertEquals("PLT-001", detail.pallets().get(0).palletNo());
|
||||||
|
assertEquals(12, detail.pallets().get(0).packageCount());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void receivingMorePalletsCreatesOverageAndKeepsDeclaredCount() {
|
||||||
|
UsTruckOrder order = order(UsTruckOrderService.WAITING_INBOUND, 0L);
|
||||||
|
order.setPalletCount(1);
|
||||||
|
when(orders.lockNo("100", "TK1")).thenReturn(order);
|
||||||
|
when(orders.findNo("100", "TK1")).thenReturn(order);
|
||||||
|
when(histories.listOrder("100", 1L)).thenReturn(List.of());
|
||||||
|
List<UsTruckOrderPallet> savedPallets = new ArrayList<>();
|
||||||
|
when(pallets.listOrder("100", 1L)).thenAnswer(call -> List.copyOf(savedPallets));
|
||||||
|
when(pallets.insert(any(UsTruckOrderPallet.class))).thenAnswer(call -> {
|
||||||
|
savedPallets.add(call.getArgument(0));
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
var result = service.receivePallets("100", "TK1", "7",
|
||||||
|
new UsTruckOrderRequests.ReceivePallets(0L, 3, 0, "receive-1", "actual three pallets"));
|
||||||
|
|
||||||
|
assertEquals(1, result.summary().palletCount());
|
||||||
|
assertEquals(3, result.summary().receivedPalletCount());
|
||||||
|
assertEquals(UsTruckOrderService.PALLET_OVERAGE_REVIEW, result.summary().status());
|
||||||
|
verify(pallets, org.mockito.Mockito.times(3)).insert(any(UsTruckOrderPallet.class));
|
||||||
|
}
|
||||||
|
|
||||||
private UsTruckOrderRequests.Create create(String key) {
|
private UsTruckOrderRequests.Create create(String key) {
|
||||||
return new UsTruckOrderRequests.Create("PORT_PICKUP", "Los Angeles Port", "Pier 1", "Ontario, CA",
|
return new UsTruckOrderRequests.Create("PORT_PICKUP", "Los Angeles Port", "Pier 1", "Ontario, CA",
|
||||||
"Warehouse A", "40HQ", 100, new BigDecimal("12600"), new BigDecimal("55.2"),
|
"Warehouse A", "40HQ", 100, new BigDecimal("12600"), new BigDecimal("55.2"),
|
||||||
@@ -87,7 +134,9 @@ class UsTruckOrderServiceTest {
|
|||||||
private UsTruckOrder order(String status, long version) {
|
private UsTruckOrder order(String status, long version) {
|
||||||
UsTruckOrder order = new UsTruckOrder(); order.setId(1L); order.setCustomerId("100"); order.setOrderNo("TK1");
|
UsTruckOrder order = new UsTruckOrder(); order.setId(1L); order.setCustomerId("100"); order.setOrderNo("TK1");
|
||||||
order.setTransportType("PORT_PICKUP"); order.setOrigin("Los Angeles"); order.setDestination("Ontario");
|
order.setTransportType("PORT_PICKUP"); order.setOrigin("Los Angeles"); order.setDestination("Ontario");
|
||||||
order.setContainerType("40HQ"); order.setPackageCount(1); order.setStatus(status); order.setVersion(version);
|
order.setContainerType("40HQ"); order.setPalletCount(1); order.setPackageCount(0);
|
||||||
|
order.setReceivedPalletCount(0); order.setAllocatedPalletCount(0); order.setShippedPalletCount(0);
|
||||||
|
order.setStatus(status); order.setVersion(version);
|
||||||
order.setCreatedAt(LocalDateTime.now()); order.setUpdatedAt(order.getCreatedAt()); return order;
|
order.setCreatedAt(LocalDateTime.now()); order.setUpdatedAt(order.getCreatedAt()); return order;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2410
-280
File diff suppressed because it is too large
Load Diff
+16
-2
@@ -15,7 +15,7 @@
|
|||||||
| 内部资源锁定、释放、报废 | `controller/InternalWarehouseResourceLockController.java` |
|
| 内部资源锁定、释放、报废 | `controller/InternalWarehouseResourceLockController.java` |
|
||||||
| 仓库与资源服务 | `service/WarehouseApplicationService.java`、`WarehouseResourceService.java`、`WarehouseResourceLockService.java` |
|
| 仓库与资源服务 | `service/WarehouseApplicationService.java`、`WarehouseResourceService.java`、`WarehouseResourceLockService.java` |
|
||||||
| 内部服务令牌 | `security/InternalServiceTokenGuard.java` |
|
| 内部服务令牌 | `security/InternalServiceTokenGuard.java` |
|
||||||
| 数据库 | `src/main/resources/db/warehouse.sql` |
|
| 数据库基线与中文注释契约 | `src/main/resources/db/warehouse.sql`、`src/test/java/com/baiyue/warehouse/schema/SchemaChineseCommentContractTest.java` |
|
||||||
| 测试 | `src/test/java/com/baiyue/warehouse/` |
|
| 测试 | `src/test/java/com/baiyue/warehouse/` |
|
||||||
|
|
||||||
## 数据与安全规则
|
## 数据与安全规则
|
||||||
@@ -24,8 +24,22 @@
|
|||||||
- 资源锁定以业务幂等键和 reservation 标识防重;释放/报废操作必须验证当前状态。
|
- 资源锁定以业务幂等键和 reservation 标识防重;释放/报废操作必须验证当前状态。
|
||||||
- 内部资源接口必须校验服务令牌;普通用户不得直接调用。
|
- 内部资源接口必须校验服务令牌;普通用户不得直接调用。
|
||||||
|
|
||||||
|
## 数据库表索引与注释约束
|
||||||
|
|
||||||
|
| 业务域 | 表名 | 中文含义 |
|
||||||
|
|---|---|---|
|
||||||
|
| 库位 | `wh_location` | 海外仓库位资源 |
|
||||||
|
| 托盘 | `wh_pallet` | 海外仓实物托盘资源 |
|
||||||
|
| 资源锁 | `wh_resource_lock` | 仓储资源锁定记录 |
|
||||||
|
| 仓库 | `wh_warehouse` | 海外仓档案 |
|
||||||
|
| 仓库地址 | `wh_warehouse_address` | 海外仓地址 |
|
||||||
|
| 服务能力 | `wh_service_capability` | 海外仓服务能力 |
|
||||||
|
| 托盘绑定 | `wh_pallet_business_binding` | 托盘业务绑定关系 |
|
||||||
|
|
||||||
|
- 新增或修改表时,表 `COMMENT` 和每个普通字段的 `COMMENT` 必须填写准确的中文业务含义。
|
||||||
|
- `SchemaChineseCommentContractTest` 自动扫描全部 `wh_` 表;新增字段必须同步更新基线 SQL、统一增量 SQL 和存量备注迁移。
|
||||||
|
|
||||||
## 修改与验证
|
## 修改与验证
|
||||||
|
|
||||||
- 字段、注释和索引同步维护基线 SQL 与统一增量 SQL。
|
- 字段、注释和索引同步维护基线 SQL 与统一增量 SQL。
|
||||||
- 定向验证:`mvn -pl warehouse -am test`。
|
- 定向验证:`mvn -pl warehouse -am test`。
|
||||||
|
|
||||||
|
|||||||
@@ -48,25 +48,25 @@ CREATE TABLE IF NOT EXISTS wh_pallet (
|
|||||||
) ENGINE=InnoDB COMMENT='仓库托盘表:按租户和仓库维护托盘、容量、单价及状态';
|
) ENGINE=InnoDB COMMENT='仓库托盘表:按租户和仓库维护托盘、容量、单价及状态';
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS wh_resource_lock (
|
CREATE TABLE IF NOT EXISTS wh_resource_lock (
|
||||||
id BIGINT UNSIGNED NOT NULL COMMENT 'Snowflake resource lock ID',
|
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID',
|
||||||
customer_id BIGINT UNSIGNED NOT NULL COMMENT 'Owning tenant ID',
|
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户租户ID',
|
||||||
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT 'Owning warehouse ID',
|
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '仓库ID',
|
||||||
pallet_id BIGINT UNSIGNED NOT NULL COMMENT 'Reserved pallet ID',
|
pallet_id BIGINT UNSIGNED NOT NULL COMMENT '托盘ID',
|
||||||
location_id BIGINT UNSIGNED NOT NULL COMMENT 'Reserved location ID',
|
location_id BIGINT UNSIGNED NOT NULL COMMENT '库位ID',
|
||||||
business_type VARCHAR(50) NOT NULL COMMENT 'Calling business type',
|
business_type VARCHAR(50) NOT NULL COMMENT '业务类型',
|
||||||
business_id VARCHAR(100) NOT NULL COMMENT 'Calling business identifier',
|
business_id VARCHAR(100) NOT NULL COMMENT '业务记录ID',
|
||||||
reservation_key VARCHAR(160) NOT NULL COMMENT 'Caller supplied idempotency key',
|
reservation_key VARCHAR(160) NOT NULL COMMENT '资源预留幂等键',
|
||||||
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT 'ACTIVE, RELEASED or SCRAPPED',
|
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '资源锁状态',
|
||||||
locked_at DATETIME(3) NOT NULL COMMENT 'Reservation time',
|
locked_at DATETIME(3) NOT NULL COMMENT '锁定时间',
|
||||||
released_at DATETIME(3) DEFAULT NULL COMMENT 'Release or scrap time',
|
released_at DATETIME(3) DEFAULT NULL COMMENT '释放时间',
|
||||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
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),
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
UNIQUE KEY uk_resource_lock_reservation (customer_id, reservation_key),
|
UNIQUE KEY uk_resource_lock_reservation (customer_id, reservation_key),
|
||||||
KEY idx_resource_lock_pallet (customer_id, warehouse_id, pallet_id, status),
|
KEY idx_resource_lock_pallet (customer_id, warehouse_id, pallet_id, status),
|
||||||
KEY idx_resource_lock_location (customer_id, warehouse_id, location_id, status),
|
KEY idx_resource_lock_location (customer_id, warehouse_id, location_id, status),
|
||||||
KEY idx_resource_lock_business (customer_id, business_type, business_id)
|
KEY idx_resource_lock_business (customer_id, business_type, business_id)
|
||||||
) ENGINE=InnoDB COMMENT='Atomic pallet and location reservation records';
|
) ENGINE=InnoDB COMMENT='仓储资源锁定记录';
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS wh_warehouse (
|
CREATE TABLE IF NOT EXISTS wh_warehouse (
|
||||||
id BIGINT UNSIGNED NOT NULL COMMENT '仓库ID(雪花ID)',
|
id BIGINT UNSIGNED NOT NULL COMMENT '仓库ID(雪花ID)',
|
||||||
@@ -160,12 +160,12 @@ ALTER TABLE wh_location
|
|||||||
MODIFY COLUMN version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号';
|
MODIFY COLUMN version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号';
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS wh_pallet_business_binding (
|
CREATE TABLE IF NOT EXISTS wh_pallet_business_binding (
|
||||||
id BIGINT NOT NULL, customer_id BIGINT NOT NULL, warehouse_id BIGINT NOT NULL,
|
id BIGINT NOT NULL COMMENT '主键ID', customer_id BIGINT NOT NULL COMMENT '客户租户ID', warehouse_id BIGINT NOT NULL COMMENT '仓库ID',
|
||||||
business_type VARCHAR(32) NOT NULL, business_id VARCHAR(128) NOT NULL,
|
business_type VARCHAR(32) NOT NULL COMMENT '业务类型', business_id VARCHAR(128) NOT NULL COMMENT '绑定业务记录ID',
|
||||||
pallet_id BIGINT NOT NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
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),
|
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)
|
UNIQUE KEY uk_wh_pallet_binding_pallet (pallet_id)
|
||||||
) ENGINE=InnoDB COMMENT='Customer business binding to real warehouse pallet';
|
) ENGINE=InnoDB COMMENT='托盘业务绑定关系';
|
||||||
|
|
||||||
-- Every warehouse has one non-occupiable virtual location used for inventory history.
|
-- Every warehouse has one non-occupiable virtual location used for inventory history.
|
||||||
INSERT INTO wh_location (id, customer_id, warehouse_id, zone_code, location_code, location_type,
|
INSERT INTO wh_location (id, customer_id, warehouse_id, zone_code, location_code, location_type,
|
||||||
|
|||||||
+98
@@ -0,0 +1,98 @@
|
|||||||
|
package com.baiyue.warehouse.schema;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
class SchemaChineseCommentContractTest {
|
||||||
|
|
||||||
|
private static final Pattern TABLE = Pattern.compile(
|
||||||
|
"(?is)CREATE\\s+TABLE\\s+IF\\s+NOT\\s+EXISTS\\s+(wh_[a-z0-9_]+)"
|
||||||
|
+ "\\s*\\((.*?)\\)\\s*ENGINE\\s*=\\s*InnoDB\\s+COMMENT\\s*=\\s*'([^']*)'\\s*;");
|
||||||
|
private static final Pattern CHINESE = Pattern.compile("[\\u4e00-\\u9fff]");
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void allWarehouseTablesAndColumnsHaveChineseCommentsAndMigrationCoverage() throws Exception {
|
||||||
|
String baseline = Files.readString(
|
||||||
|
Path.of("src", "main", "resources", "db", "warehouse.sql"), StandardCharsets.UTF_8);
|
||||||
|
String incremental = Files.readString(
|
||||||
|
Path.of("..", "scripts", "db", "incremental.sql"), StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
List<TableDefinition> tables = parseAndAssert(baseline, "仓储基线 SQL");
|
||||||
|
assertEquals(7, tables.size(), "仓储表数量变化时必须同步表索引和备注迁移");
|
||||||
|
parseAndAssert(incremental, "统一增量 SQL");
|
||||||
|
|
||||||
|
int marker = incremental.indexOf("2026-08-18 / 025 Chinese table and column comments");
|
||||||
|
assertTrue(marker >= 0, "统一增量 SQL 缺少 025 中文备注迁移");
|
||||||
|
String migration = incremental.substring(marker);
|
||||||
|
for (TableDefinition table : tables) {
|
||||||
|
assertTrue(migration.contains("table_name='" + table.name() + "'"),
|
||||||
|
"025 备注迁移缺少表: " + table.name());
|
||||||
|
for (String column : table.columns()) {
|
||||||
|
assertTrue(migration.contains("table_name='" + table.name()
|
||||||
|
+ "' AND column_name='" + column + "'"),
|
||||||
|
"025 备注迁移缺少字段: " + table.name() + "." + column);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<TableDefinition> parseAndAssert(String sql, String source) {
|
||||||
|
List<TableDefinition> tables = new ArrayList<>();
|
||||||
|
Matcher matcher = TABLE.matcher(sql);
|
||||||
|
while (matcher.find()) {
|
||||||
|
String table = matcher.group(1);
|
||||||
|
assertTrue(CHINESE.matcher(matcher.group(3)).find(), source + " 表备注必须包含中文: " + table);
|
||||||
|
List<String> columns = new ArrayList<>();
|
||||||
|
for (String definition : splitTopLevel(matcher.group(2))) {
|
||||||
|
String normalized = definition.stripLeading().toUpperCase();
|
||||||
|
if (normalized.matches("^(PRIMARY|UNIQUE|KEY|CONSTRAINT|FOREIGN|CHECK)\\b.*")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String column = definition.stripLeading().split("\\s+", 2)[0].replace("`", "");
|
||||||
|
columns.add(column);
|
||||||
|
assertTrue(Pattern.compile("(?is)\\bCOMMENT\\s+'[^']*[\\u4e00-\\u9fff][^']*'")
|
||||||
|
.matcher(definition).find(),
|
||||||
|
source + " 字段备注必须包含中文: " + table + "." + column);
|
||||||
|
}
|
||||||
|
assertFalse(columns.isEmpty(), source + " 表定义没有字段: " + table);
|
||||||
|
tables.add(new TableDefinition(table, columns));
|
||||||
|
}
|
||||||
|
assertFalse(tables.isEmpty(), source + " 未解析到仓储表");
|
||||||
|
return tables;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<String> splitTopLevel(String body) {
|
||||||
|
List<String> definitions = new ArrayList<>();
|
||||||
|
int start = 0;
|
||||||
|
int parentheses = 0;
|
||||||
|
boolean quoted = false;
|
||||||
|
for (int i = 0; i < body.length(); i++) {
|
||||||
|
char current = body.charAt(i);
|
||||||
|
if (current == '\'' && (i == 0 || body.charAt(i - 1) != '\\')) {
|
||||||
|
quoted = !quoted;
|
||||||
|
} else if (!quoted && current == '(') {
|
||||||
|
parentheses++;
|
||||||
|
} else if (!quoted && current == ')') {
|
||||||
|
parentheses--;
|
||||||
|
} else if (!quoted && parentheses == 0 && current == ',') {
|
||||||
|
definitions.add(body.substring(start, i).trim());
|
||||||
|
start = i + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
definitions.add(body.substring(start).trim());
|
||||||
|
return definitions.stream().filter(value -> !value.isEmpty()).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private record TableDefinition(String name, List<String> columns) {
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user