派车提交

This commit is contained in:
2026-08-17 13:38:48 +08:00
parent d6fe79b0f2
commit 3082167411
101 changed files with 5353 additions and 32 deletions
@@ -3,8 +3,11 @@ package com.baiyue.warehouse.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.warehouse.dto.WarehouseResourceLockRequests;
import com.baiyue.warehouse.dto.WarehouseResourceLockResponse;
import com.baiyue.warehouse.dto.WarehousePalletScrapResponse;
import com.baiyue.warehouse.entity.WarehouseLocationEntity;
import com.baiyue.warehouse.security.InternalServiceTokenGuard;
import com.baiyue.warehouse.service.WarehouseResourceLockService;
import com.baiyue.warehouse.service.WarehouseResourceService;
import jakarta.validation.Valid;
import jakarta.annotation.Resource;
import org.springframework.web.bind.annotation.PathVariable;
@@ -18,6 +21,7 @@ import org.springframework.web.bind.annotation.RestController;
@RequestMapping("/internal/v1/warehouse-resource-locks")
public class InternalWarehouseResourceLockController {
@Resource private WarehouseResourceLockService service;
@Resource private WarehouseResourceService resourceService;
@Resource private InternalServiceTokenGuard tokenGuard;
@PostMapping
@@ -45,4 +49,22 @@ public class InternalWarehouseResourceLockController {
tokenGuard.verify(token);
return ApiResponse.success(service.scrap(request.tenantId(), lockId));
}
@PostMapping("/pallets/{palletId}/scrap")
public ApiResponse<WarehousePalletScrapResponse> scrapPallet(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@PathVariable long palletId,
@Valid @RequestBody WarehouseResourceLockRequests.CloseRequest request) {
tokenGuard.verify(token);
return ApiResponse.success(service.scrapByPallet(request.tenantId(), palletId));
}
@PostMapping("/default-locations/ensure")
public ApiResponse<WarehouseLocationEntity> ensureDefaultLocation(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@Valid @RequestBody WarehouseResourceLockRequests.DefaultLocationRequest request) {
tokenGuard.verify(token);
return ApiResponse.success(resourceService.ensureDefaultVirtualLocation(
request.tenantId(), request.warehouseId()));
}
}
@@ -16,7 +16,7 @@ public final class PalletRequests {
}
public record SaveRequest(
@NotBlank @Size(max = 64) String palletCode,
@Size(max = 128) String palletCode,
@NotBlank @Size(max = 32) String palletType,
@DecimalMin(value = "0.0000", inclusive = false) BigDecimal capacityCbm,
@NotNull @DecimalMin("0.00") BigDecimal unitPrice,
@@ -0,0 +1,4 @@
package com.baiyue.warehouse.dto;
public record WarehousePalletScrapResponse(long palletId, long warehouseId, Long locationId, String status) {
}
@@ -17,4 +17,9 @@ public final class WarehouseResourceLockRequests {
) {}
public record CloseRequest(@Positive long tenantId) {}
public record DefaultLocationRequest(
@Positive long tenantId,
@Positive long warehouseId
) {}
}
@@ -2,6 +2,21 @@ package com.baiyue.warehouse.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.warehouse.entity.WarehouseLocationEntity;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Param;
public interface WarehouseLocationMapper extends BaseMapper<WarehouseLocationEntity> {
/**
* The unique location-code index makes this safe for concurrent first use of an older warehouse.
*/
@Insert("""
INSERT IGNORE INTO wh_location
(id, tenant_id, warehouse_id, zone_code, location_code, location_type,
max_load_kg, status, remark, deleted, version)
VALUES
(#{location.id}, #{location.tenantId}, #{location.warehouseId}, #{location.zoneCode},
#{location.locationCode}, #{location.locationType}, #{location.maxLoadKg},
#{location.status}, #{location.remark}, 0, 0)
""")
int insertDefaultVirtualLocationIgnore(@Param("location") WarehouseLocationEntity location);
}
@@ -15,6 +15,11 @@ public interface WarehouseResourceLockMapper extends BaseMapper<WarehouseResourc
WarehouseResourceLockEntity findByReservationKey(@Param("tenantId") long tenantId,
@Param("reservationKey") String reservationKey);
@Select("SELECT * FROM wh_resource_lock WHERE tenant_id=#{tenantId} AND pallet_id=#{palletId} " +
"AND status='ACTIVE' ORDER BY locked_at DESC LIMIT 1")
WarehouseResourceLockEntity findActiveByPallet(@Param("tenantId") long tenantId,
@Param("palletId") long palletId);
@Update("""
UPDATE wh_pallet
SET status = 'OCCUPIED', location_id = #{locationId}, updated_at = CURRENT_TIMESTAMP(3), version = version + 1
@@ -42,6 +47,17 @@ public interface WarehouseResourceLockMapper extends BaseMapper<WarehouseResourc
int releasePallet(@Param("tenantId") long tenantId, @Param("warehouseId") long warehouseId,
@Param("palletId") long palletId, @Param("locationId") long locationId);
@Update("""
UPDATE wh_pallet
SET status = 'IDLE', updated_at = CURRENT_TIMESTAMP(3), version = version + 1
WHERE id = #{palletId} AND tenant_id = #{tenantId} AND warehouse_id = #{warehouseId}
AND location_id = #{locationId} AND status = 'OCCUPIED' AND deleted = 0
""")
int releasePalletAtVirtualLocation(@Param("tenantId") long tenantId,
@Param("warehouseId") long warehouseId,
@Param("palletId") long palletId,
@Param("locationId") long locationId);
@Update("""
UPDATE wh_location
SET status = 'IDLE', updated_at = CURRENT_TIMESTAMP(3), version = version + 1
@@ -60,6 +76,21 @@ public interface WarehouseResourceLockMapper extends BaseMapper<WarehouseResourc
int scrapPallet(@Param("tenantId") long tenantId, @Param("warehouseId") long warehouseId,
@Param("palletId") long palletId, @Param("locationId") long locationId);
@Update("""
UPDATE wh_pallet
SET status = 'SCRAPPED', updated_at = CURRENT_TIMESTAMP(3), version = version + 1
WHERE id = #{palletId} AND tenant_id = #{tenantId} AND warehouse_id = #{warehouseId}
AND location_id = #{locationId} AND status = 'OCCUPIED' AND deleted = 0
""")
int scrapPalletAtVirtualLocation(@Param("tenantId") long tenantId,
@Param("warehouseId") long warehouseId,
@Param("palletId") long palletId,
@Param("locationId") long locationId);
@Update("UPDATE wh_pallet SET status='SCRAPPED',updated_at=CURRENT_TIMESTAMP(3),version=version+1 " +
"WHERE tenant_id=#{tenantId} AND id=#{palletId} AND status='OCCUPIED' AND deleted=0")
int scrapPalletWithoutLock(@Param("tenantId") long tenantId, @Param("palletId") long palletId);
@Update("""
UPDATE wh_resource_lock
SET status = #{status}, released_at = CURRENT_TIMESTAMP(3), updated_at = CURRENT_TIMESTAMP(3)
@@ -7,9 +7,11 @@ import com.baiyue.warehouse.dto.WarehouseRequests;
import com.baiyue.warehouse.entity.ServiceCapabilityEntity;
import com.baiyue.warehouse.entity.WarehouseAddressEntity;
import com.baiyue.warehouse.entity.WarehouseEntity;
import com.baiyue.warehouse.entity.WarehouseLocationEntity;
import com.baiyue.warehouse.mapper.ServiceCapabilityMapper;
import com.baiyue.warehouse.mapper.WarehouseAddressMapper;
import com.baiyue.warehouse.mapper.WarehouseMapper;
import com.baiyue.warehouse.mapper.WarehouseLocationMapper;
import org.springframework.stereotype.Repository;
import org.springframework.util.StringUtils;
import jakarta.annotation.Resource;
@@ -22,6 +24,7 @@ import java.util.List;
@Repository
public class WarehouseRepository {
@Resource private WarehouseMapper warehouses;
@Resource private WarehouseLocationMapper locations;
@Resource private WarehouseAddressMapper addresses;
@Resource private ServiceCapabilityMapper capabilities;
@@ -29,6 +32,17 @@ public class WarehouseRepository {
warehouses.insert(warehouse);
}
public void insertLocation(WarehouseLocationEntity location) {
locations.insert(location);
}
public boolean existsLocationCode(long tenantId, long warehouseId, String locationCode) {
return locations.selectCount(new LambdaQueryWrapper<WarehouseLocationEntity>()
.eq(WarehouseLocationEntity::getTenantId, tenantId)
.eq(WarehouseLocationEntity::getWarehouseId, warehouseId)
.eq(WarehouseLocationEntity::getLocationCode, locationCode)) > 0;
}
public void saveWarehouse(WarehouseEntity warehouse) {
warehouses.updateById(warehouse);
}
@@ -26,6 +26,16 @@ public class WarehouseResourceLockRepository {
.eq(WarehouseResourceLockEntity::getId, lockId));
}
public WarehouseResourceLockEntity findActiveByPallet(long tenantId, long palletId) {
return locks.findActiveByPallet(tenantId, palletId);
}
public WarehousePalletEntity findPallet(long tenantId, long palletId) {
return pallets.selectOne(new LambdaQueryWrapper<WarehousePalletEntity>()
.eq(WarehousePalletEntity::getTenantId, tenantId)
.eq(WarehousePalletEntity::getId, palletId));
}
public WarehousePalletEntity findPallet(long tenantId, long warehouseId, long palletId) {
return pallets.selectOne(new LambdaQueryWrapper<WarehousePalletEntity>()
.eq(WarehousePalletEntity::getTenantId, tenantId)
@@ -54,10 +64,24 @@ public class WarehouseResourceLockRepository {
return locks.releasePallet(lock.getTenantId(), lock.getWarehouseId(), lock.getPalletId(), lock.getLocationId());
}
public int releasePalletAtVirtualLocation(WarehouseResourceLockEntity lock) {
return locks.releasePalletAtVirtualLocation(lock.getTenantId(), lock.getWarehouseId(),
lock.getPalletId(), lock.getLocationId());
}
public int scrapPallet(WarehouseResourceLockEntity lock) {
return locks.scrapPallet(lock.getTenantId(), lock.getWarehouseId(), lock.getPalletId(), lock.getLocationId());
}
public int scrapPalletWithoutLock(long tenantId, long palletId) {
return locks.scrapPalletWithoutLock(tenantId, palletId);
}
public int scrapPalletAtVirtualLocation(WarehouseResourceLockEntity lock) {
return locks.scrapPalletAtVirtualLocation(lock.getTenantId(), lock.getWarehouseId(),
lock.getPalletId(), lock.getLocationId());
}
public int releaseLocation(WarehouseResourceLockEntity lock) {
return locks.releaseLocation(lock.getTenantId(), lock.getWarehouseId(), lock.getLocationId());
}
@@ -27,10 +27,37 @@ public class WarehouseResourceRepository {
.eq(WarehouseEntity::getId, warehouseId)) > 0;
}
public WarehouseEntity findWarehouse(long tenantId, long warehouseId) {
return warehouses.selectOne(new LambdaQueryWrapper<WarehouseEntity>()
.eq(WarehouseEntity::getTenantId, tenantId)
.eq(WarehouseEntity::getId, warehouseId));
}
public WarehouseLocationEntity findDefaultVirtualLocation(long tenantId, long warehouseId) {
return locations.selectOne(new LambdaQueryWrapper<WarehouseLocationEntity>()
.eq(WarehouseLocationEntity::getTenantId, tenantId)
.eq(WarehouseLocationEntity::getWarehouseId, warehouseId)
.eq(WarehouseLocationEntity::getLocationType, "VIRTUAL")
.eq(WarehouseLocationEntity::getStatus, "ACTIVE")
.orderByAsc(WarehouseLocationEntity::getCreatedAt)
.last("LIMIT 1"));
}
public WarehouseLocationEntity findLocationByCode(long tenantId, long warehouseId, String locationCode) {
return locations.selectOne(new LambdaQueryWrapper<WarehouseLocationEntity>()
.eq(WarehouseLocationEntity::getTenantId, tenantId)
.eq(WarehouseLocationEntity::getWarehouseId, warehouseId)
.eq(WarehouseLocationEntity::getLocationCode, locationCode));
}
public void insertLocation(WarehouseLocationEntity entity) {
locations.insert(entity);
}
public int insertDefaultVirtualLocationIgnore(WarehouseLocationEntity entity) {
return locations.insertDefaultVirtualLocationIgnore(entity);
}
public void updateLocation(WarehouseLocationEntity entity) {
locations.updateById(entity);
}
@@ -10,11 +10,13 @@ import com.baiyue.warehouse.dto.WarehouseRequests;
import com.baiyue.warehouse.entity.ServiceCapabilityEntity;
import com.baiyue.warehouse.entity.WarehouseAddressEntity;
import com.baiyue.warehouse.entity.WarehouseEntity;
import com.baiyue.warehouse.entity.WarehouseLocationEntity;
import com.baiyue.warehouse.repository.WarehouseRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.math.BigDecimal;
/**
* 仓库应用服务,负责仓库主数据、地址和服务能力的租户级用例编排、事务控制与业务校验。
@@ -37,10 +39,28 @@ public class WarehouseApplicationService {
ensureCodeUnique(request.tenantId(), request.warehouseCode(), null);
WarehouseEntity warehouse = toEntity(request);
repository.insertWarehouse(warehouse);
ensureDefaultVirtualLocation(warehouse);
insertChildren(warehouse.getTenantId(), warehouse.getId(), request.address(), request.capabilities());
return warehouse.getId();
}
private void ensureDefaultVirtualLocation(WarehouseEntity warehouse) {
String locationCode = "LOC-" + warehouse.getWarehouseCode() + "-DEFAULT";
if (repository.existsLocationCode(warehouse.getTenantId(), warehouse.getId(), locationCode)) {
return;
}
WarehouseLocationEntity location = new WarehouseLocationEntity();
location.setTenantId(warehouse.getTenantId());
location.setWarehouseId(warehouse.getId());
location.setZoneCode("DEFAULT");
location.setLocationCode(locationCode);
location.setLocationType("VIRTUAL");
location.setMaxLoadKg(new BigDecimal("999999999.000"));
location.setStatus("ACTIVE");
location.setRemark("System default virtual location");
repository.insertLocation(location);
}
@Transactional
@BusinessAudit(
event = "WAREHOUSE_UPDATE",
@@ -4,6 +4,7 @@ import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.warehouse.dto.WarehouseResourceLockRequests;
import com.baiyue.warehouse.dto.WarehouseResourceLockResponse;
import com.baiyue.warehouse.dto.WarehousePalletScrapResponse;
import com.baiyue.warehouse.entity.WarehouseLocationEntity;
import com.baiyue.warehouse.entity.WarehousePalletEntity;
import com.baiyue.warehouse.entity.WarehouseResourceLockEntity;
@@ -33,7 +34,9 @@ public class WarehouseResourceLockService {
if (concurrent != null) return idempotentResult(concurrent, request);
throw new BizException(ResultCode.NOT_FOUND, "Pallet or location does not belong to the tenant warehouse");
}
if (!"IDLE".equals(pallet.getStatus()) || !"IDLE".equals(location.getStatus())) {
boolean virtualLocation = isVirtual(location);
boolean locationAvailable = virtualLocation ? "ACTIVE".equals(location.getStatus()) : "IDLE".equals(location.getStatus());
if (!"IDLE".equals(pallet.getStatus()) || !locationAvailable) {
WarehouseResourceLockEntity concurrent =
repository.findByReservationKey(request.tenantId(), request.reservationKey());
if (concurrent != null) return idempotentResult(concurrent, request);
@@ -42,7 +45,7 @@ public class WarehouseResourceLockService {
if (repository.occupyPallet(request.tenantId(), request.warehouseId(), request.palletId(), request.locationId()) != 1) {
return concurrentIdempotentOrConflict(request);
}
if (repository.occupyLocation(request.tenantId(), request.warehouseId(), request.locationId()) != 1) {
if (!virtualLocation && repository.occupyLocation(request.tenantId(), request.warehouseId(), request.locationId()) != 1) {
throw new BizException(ResultCode.CONFLICT, "Location is no longer IDLE");
}
@@ -65,7 +68,9 @@ public class WarehouseResourceLockService {
WarehouseResourceLockEntity lock = requireLock(tenantId, lockId);
if ("RELEASED".equals(lock.getStatus())) return WarehouseResourceLockResponse.from(lock);
requireActive(lock);
if (repository.releasePallet(lock) != 1 || repository.releaseLocation(lock) != 1
boolean virtualLocation = isVirtualLockLocation(lock);
int palletResult = virtualLocation ? repository.releasePalletAtVirtualLocation(lock) : repository.releasePallet(lock);
if (palletResult != 1 || (!virtualLocation && repository.releaseLocation(lock) != 1)
|| repository.close(lock, "RELEASED") != 1) {
throw new BizException(ResultCode.CONFLICT, "Resource lock state changed concurrently");
}
@@ -79,7 +84,9 @@ public class WarehouseResourceLockService {
WarehouseResourceLockEntity lock = requireLock(tenantId, lockId);
if ("SCRAPPED".equals(lock.getStatus())) return WarehouseResourceLockResponse.from(lock);
requireActive(lock);
if (repository.scrapPallet(lock) != 1 || repository.releaseLocation(lock) != 1
boolean virtualLocation = isVirtualLockLocation(lock);
int palletResult = virtualLocation ? repository.scrapPalletAtVirtualLocation(lock) : repository.scrapPallet(lock);
if (palletResult != 1 || (!virtualLocation && repository.releaseLocation(lock) != 1)
|| repository.close(lock, "SCRAPPED") != 1) {
throw new BizException(ResultCode.CONFLICT, "Resource lock state changed concurrently");
}
@@ -88,6 +95,25 @@ public class WarehouseResourceLockService {
return WarehouseResourceLockResponse.from(lock);
}
@Transactional
public WarehousePalletScrapResponse scrapByPallet(long tenantId, long palletId) {
WarehousePalletEntity pallet = repository.findPallet(tenantId, palletId);
if (pallet == null) throw new BizException(ResultCode.NOT_FOUND, "Pallet not found");
if ("SCRAPPED".equals(pallet.getStatus())) {
return new WarehousePalletScrapResponse(palletId, pallet.getWarehouseId(), pallet.getLocationId(), "SCRAPPED");
}
if (!"OCCUPIED".equals(pallet.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "Only an OCCUPIED pallet can be scrapped after shipment");
}
WarehouseResourceLockEntity lock = repository.findActiveByPallet(tenantId, palletId);
if (lock != null) {
scrap(tenantId, lock.getId());
} else if (repository.scrapPalletWithoutLock(tenantId, palletId) != 1) {
throw new BizException(ResultCode.CONFLICT, "Pallet state changed concurrently");
}
return new WarehousePalletScrapResponse(palletId, pallet.getWarehouseId(), pallet.getLocationId(), "SCRAPPED");
}
private WarehouseResourceLockResponse concurrentIdempotentOrConflict(
WarehouseResourceLockRequests.ReserveRequest request) {
WarehouseResourceLockEntity existing = repository.findByReservationKey(request.tenantId(), request.reservationKey());
@@ -118,4 +144,15 @@ public class WarehouseResourceLockService {
throw new BizException(ResultCode.CONFLICT, "Resource lock is not ACTIVE");
}
}
private boolean isVirtualLockLocation(WarehouseResourceLockEntity lock) {
WarehouseLocationEntity location = repository.findLocation(lock.getTenantId(), lock.getWarehouseId(), lock.getLocationId());
if (location == null) throw new BizException(ResultCode.NOT_FOUND, "Resource lock location not found");
return isVirtual(location);
}
private static boolean isVirtual(WarehouseLocationEntity location) {
return "VIRTUAL".equals(location.getLocationType());
}
}
@@ -7,12 +7,17 @@ import com.baiyue.warehouse.dto.LocationRequests;
import com.baiyue.warehouse.dto.PalletRequests;
import com.baiyue.warehouse.entity.WarehouseLocationEntity;
import com.baiyue.warehouse.entity.WarehousePalletEntity;
import com.baiyue.warehouse.entity.WarehouseEntity;
import com.baiyue.warehouse.repository.WarehouseResourceRepository;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import jakarta.annotation.Resource;
import java.util.Set;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import org.springframework.util.StringUtils;
@Service
public class WarehouseResourceService {
@@ -33,6 +38,11 @@ public class WarehouseResourceService {
@Transactional
public void updateLocation(long tenantId, long warehouseId, long id, LocationRequests.SaveRequest request) {
WarehouseLocationEntity existing = requireLocation(tenantId, warehouseId, id);
if ("VIRTUAL".equals(existing.getLocationType())
&& (!"VIRTUAL".equals(request.locationType()) || !"ACTIVE".equals(request.status())
|| !existing.getLocationCode().equals(request.locationCode()))) {
throw new BizException(ResultCode.CONFLICT, "Default virtual location must remain VIRTUAL and ACTIVE");
}
validateLocation(request, tenantId, warehouseId, id);
WarehouseLocationEntity entity = locationEntity(tenantId, warehouseId, request);
entity.setId(existing.getId());
@@ -52,7 +62,10 @@ public class WarehouseResourceService {
@Transactional
public void deleteLocation(long tenantId, long warehouseId, long id) {
requireLocation(tenantId, warehouseId, id);
WarehouseLocationEntity location = requireLocation(tenantId, warehouseId, id);
if ("VIRTUAL".equals(location.getLocationType())) {
throw new BizException(ResultCode.CONFLICT, "Default virtual location cannot be deleted");
}
if (repository.locationHasPallets(tenantId, warehouseId, id)) {
throw new BizException(ResultCode.CONFLICT, "库位仍绑定托盘,不能删除");
}
@@ -61,18 +74,69 @@ public class WarehouseResourceService {
@Transactional
public Long createPallet(long tenantId, long warehouseId, PalletRequests.SaveRequest request) {
requireWarehouse(tenantId, warehouseId);
validatePallet(request, tenantId, warehouseId, null);
WarehousePalletEntity entity = palletEntity(tenantId, warehouseId, request);
WarehouseEntity warehouse = requireWarehouseEntity(tenantId, warehouseId);
validatePalletStatus(request.status());
Long palletId = IdWorker.getId();
String palletCode = StringUtils.hasText(request.palletCode()) ? request.palletCode().trim()
: generatedPalletCode(warehouse.getWarehouseCode(), palletId);
Long locationId = request.locationId();
if (locationId == null) {
locationId = ensureDefaultVirtualLocation(tenantId, warehouseId).getId();
}
validatePallet(request, palletCode, locationId, tenantId, warehouseId, null);
WarehousePalletEntity entity = palletEntity(tenantId, warehouseId, request, palletCode, locationId);
entity.setId(palletId);
repository.insertPallet(entity);
return entity.getId();
}
/**
* Returns the warehouse-scoped virtual location used only as a pallet/inventory reference.
* It is deliberately never made IDLE/OCCUPIED by resource locking.
*/
@Transactional
public WarehouseLocationEntity ensureDefaultVirtualLocation(long tenantId, long warehouseId) {
WarehouseEntity warehouse = requireWarehouseEntity(tenantId, warehouseId);
String locationCode = defaultLocationCode(warehouse.getWarehouseCode());
WarehouseLocationEntity existing = repository.findLocationByCode(tenantId, warehouseId, locationCode);
if (existing != null) {
if (!"VIRTUAL".equals(existing.getLocationType()) || !"ACTIVE".equals(existing.getStatus())) {
throw new BizException(ResultCode.CONFLICT,
"Default virtual location code is occupied by a non-active virtual location");
}
return existing;
}
WarehouseLocationEntity location = new WarehouseLocationEntity();
location.setId(IdWorker.getId());
location.setTenantId(tenantId);
location.setWarehouseId(warehouseId);
location.setZoneCode("DEFAULT");
location.setLocationCode(locationCode);
location.setLocationType("VIRTUAL");
location.setMaxLoadKg(new java.math.BigDecimal("999999999.000"));
location.setStatus("ACTIVE");
location.setRemark("System default virtual location");
repository.insertDefaultVirtualLocationIgnore(location);
WarehouseLocationEntity resolved = repository.findLocationByCode(tenantId, warehouseId, locationCode);
if (resolved == null) {
throw new BizException(ResultCode.CONFLICT, "Default virtual location could not be created");
}
return resolved;
}
@Transactional
public void updatePallet(long tenantId, long warehouseId, long id, PalletRequests.SaveRequest request) {
WarehousePalletEntity existing = requirePallet(tenantId, warehouseId, id);
validatePallet(request, tenantId, warehouseId, id);
WarehousePalletEntity entity = palletEntity(tenantId, warehouseId, request);
if ("SCRAPPED".equals(existing.getStatus()) && !"SCRAPPED".equals(request.status())) {
throw new BizException(ResultCode.CONFLICT, "SCRAPPED pallet cannot be reused");
}
String palletCode = StringUtils.hasText(request.palletCode()) ? request.palletCode().trim()
: existing.getPalletCode();
Long locationId = request.locationId() == null ? existing.getLocationId() : request.locationId();
validatePallet(request, palletCode, locationId, tenantId, warehouseId, id);
WarehousePalletEntity entity = palletEntity(tenantId, warehouseId, request, palletCode, locationId);
entity.setId(existing.getId());
repository.updatePallet(entity);
}
@@ -94,6 +158,9 @@ public class WarehouseResourceService {
if ("OCCUPIED".equals(pallet.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "占用中的托盘不能删除");
}
if ("SCRAPPED".equals(pallet.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "Scrapped pallet must be retained and cannot be reused");
}
repository.deletePallet(id);
}
@@ -103,6 +170,14 @@ public class WarehouseResourceService {
}
}
private WarehouseEntity requireWarehouseEntity(long tenantId, long warehouseId) {
WarehouseEntity warehouse = repository.findWarehouse(tenantId, warehouseId);
if (warehouse == null) {
throw new BizException(ResultCode.NOT_FOUND, "Tenant warehouse not found");
}
return warehouse;
}
private WarehouseLocationEntity requireLocation(long tenantId, long warehouseId, long id) {
requireWarehouse(tenantId, warehouseId);
WarehouseLocationEntity entity = repository.findLocation(tenantId, warehouseId, id);
@@ -127,16 +202,20 @@ public class WarehouseResourceService {
}
}
private void validatePallet(PalletRequests.SaveRequest request, long tenantId,
long warehouseId, Long excludedId) {
if (!PALLET_STATUSES.contains(request.status())) {
throw new BizException("托盘状态仅支持 IDLE、OCCUPIED、SCRAPPED");
}
if (repository.palletCodeExists(tenantId, warehouseId, request.palletCode(), excludedId)) {
private void validatePallet(PalletRequests.SaveRequest request, String palletCode, Long locationId,
long tenantId, long warehouseId, Long excludedId) {
validatePalletStatus(request.status());
if (repository.palletCodeExists(tenantId, warehouseId, palletCode, excludedId)) {
throw new BizException(ResultCode.CONFLICT, "当前仓库下托盘编码已存在");
}
if (request.locationId() != null) {
requireLocation(tenantId, warehouseId, request.locationId());
if (locationId != null) {
requireLocation(tenantId, warehouseId, locationId);
}
}
private void validatePalletStatus(String status) {
if (!PALLET_STATUSES.contains(status)) {
throw new BizException("托盘状态仅支持 IDLE、OCCUPIED、SCRAPPED");
}
}
@@ -155,12 +234,13 @@ public class WarehouseResourceService {
}
private WarehousePalletEntity palletEntity(long tenantId, long warehouseId,
PalletRequests.SaveRequest request) {
PalletRequests.SaveRequest request,
String palletCode, Long locationId) {
WarehousePalletEntity entity = new WarehousePalletEntity();
entity.setTenantId(tenantId);
entity.setWarehouseId(warehouseId);
entity.setLocationId(request.locationId());
entity.setPalletCode(request.palletCode());
entity.setLocationId(locationId);
entity.setPalletCode(palletCode);
entity.setPalletType(request.palletType());
entity.setCapacityCbm(request.capacityCbm());
entity.setUnitPrice(request.unitPrice());
@@ -168,4 +248,13 @@ public class WarehouseResourceService {
entity.setRemark(request.remark());
return entity;
}
private static String generatedPalletCode(String warehouseCode, long palletId) {
return "PLT-" + warehouseCode + "-" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE)
+ "-" + Long.toUnsignedString(palletId);
}
private static String defaultLocationCode(String warehouseCode) {
return "LOC-" + warehouseCode + "-DEFAULT";
}
}
+14 -2
View File
@@ -29,7 +29,7 @@ CREATE TABLE IF NOT EXISTS wh_pallet (
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID',
location_id BIGINT UNSIGNED DEFAULT NULL COMMENT '同仓库内绑定的库位ID,可为空',
pallet_code VARCHAR(64) NOT NULL COMMENT '仓库内唯一托盘编码',
pallet_code VARCHAR(128) NOT NULL COMMENT '仓库内唯一托盘编码',
pallet_type VARCHAR(32) NOT NULL COMMENT '托盘类型',
capacity_cbm DECIMAL(18,4) NOT NULL DEFAULT 2.0000 COMMENT '托盘容量(立方米)',
unit_price DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '托盘单价',
@@ -159,13 +159,25 @@ ALTER TABLE wh_location
MODIFY COLUMN deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
MODIFY COLUMN version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号';
-- Every warehouse has one non-occupiable virtual location used for inventory history.
INSERT INTO wh_location (id, tenant_id, warehouse_id, zone_code, location_code, location_type,
max_load_kg, status, remark)
SELECT UUID_SHORT(), w.tenant_id, w.id, 'DEFAULT', CONCAT('LOC-', w.warehouse_code, '-DEFAULT'),
'VIRTUAL', 999999999.000, 'ACTIVE', 'System default virtual location'
FROM wh_warehouse w
WHERE NOT EXISTS (
SELECT 1 FROM wh_location l
WHERE l.tenant_id = w.tenant_id AND l.warehouse_id = w.id
AND l.location_code = CONCAT('LOC-', w.warehouse_code, '-DEFAULT') AND l.deleted = 0
);
ALTER TABLE wh_pallet COMMENT='仓库托盘表:按租户和仓库维护托盘、容量、单价及状态';
ALTER TABLE wh_pallet
MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '托盘ID(雪花ID)',
MODIFY COLUMN tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
MODIFY COLUMN warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '所属仓库ID',
MODIFY COLUMN location_id BIGINT UNSIGNED NULL COMMENT '同仓库内绑定的库位ID,可为空',
MODIFY COLUMN pallet_code VARCHAR(64) NOT NULL COMMENT '仓库内唯一托盘编码',
MODIFY COLUMN pallet_code VARCHAR(128) NOT NULL COMMENT '仓库内唯一托盘编码',
MODIFY COLUMN pallet_type VARCHAR(32) NOT NULL COMMENT '托盘类型',
MODIFY COLUMN capacity_cbm DECIMAL(18,4) NOT NULL DEFAULT 2.0000 COMMENT '托盘容量(立方米)',
MODIFY COLUMN unit_price DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '托盘单价',
@@ -0,0 +1,59 @@
package com.baiyue.warehouse.service;
import com.baiyue.warehouse.dto.WarehouseRequests;
import com.baiyue.warehouse.entity.WarehouseEntity;
import com.baiyue.warehouse.entity.WarehouseLocationEntity;
import com.baiyue.warehouse.repository.WarehouseRepository;
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 java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.verify;
@ExtendWith(MockitoExtension.class)
class WarehouseApplicationServiceTest {
@Mock WarehouseRepository repository;
private WarehouseApplicationService service;
@BeforeEach
void setUp() {
service = new WarehouseApplicationService();
ReflectionTestUtils.setField(service, "repository", repository);
}
@Test
void warehouseCreationAlsoCreatesActiveDefaultVirtualLocation() {
doAnswer(call -> {
((WarehouseEntity) call.getArgument(0)).setId(20001L);
return null;
}).when(repository).insertWarehouse(any());
service.create(request());
ArgumentCaptor<WarehouseLocationEntity> location = ArgumentCaptor.forClass(WarehouseLocationEntity.class);
verify(repository).insertLocation(location.capture());
assertThat(location.getValue().getTenantId()).isEqualTo(10001L);
assertThat(location.getValue().getWarehouseId()).isEqualTo(20001L);
assertThat(location.getValue().getLocationCode()).isEqualTo("LOC-US-LAX-01-DEFAULT");
assertThat(location.getValue().getLocationType()).isEqualTo("VIRTUAL");
assertThat(location.getValue().getStatus()).isEqualTo("ACTIVE");
}
private static WarehouseRequests.SaveRequest request() {
WarehouseRequests.AddressRequest address = new WarehouseRequests.AddressRequest(
"WAREHOUSE", "US", "CA", "Los Angeles", null, "90001",
"1 Logistics Ave", null, null, null);
return new WarehouseRequests.SaveRequest(10001L, "US-LAX-01", "LAX Warehouse",
"OVERSEAS", "US", "America/Los_Angeles", null, null, null,
"ACTIVE", null, address, List.of());
}
}
@@ -122,6 +122,7 @@ class WarehouseResourceLockServiceTest {
void releaseRestoresBothResourcesAndClosesLock() {
WarehouseResourceLockEntity lock = activeLock();
when(repository.find(TENANT, lock.getId())).thenReturn(lock);
when(repository.findLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(location("OCCUPIED"));
when(repository.releasePallet(lock)).thenReturn(1);
when(repository.releaseLocation(lock)).thenReturn(1);
when(repository.close(lock, "RELEASED")).thenReturn(1);
@@ -137,6 +138,7 @@ class WarehouseResourceLockServiceTest {
void scrapDiscardsPalletAndReleasesLocation() {
WarehouseResourceLockEntity lock = activeLock();
when(repository.find(TENANT, lock.getId())).thenReturn(lock);
when(repository.findLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(location("OCCUPIED"));
when(repository.scrapPallet(lock)).thenReturn(1);
when(repository.releaseLocation(lock)).thenReturn(1);
when(repository.close(lock, "SCRAPPED")).thenReturn(1);
@@ -161,6 +163,73 @@ class WarehouseResourceLockServiceTest {
verify(repository, never()).insert(any());
}
@Test
void virtualLocationIsNeverOccupiedOrReleased() {
WarehouseLocationEntity virtual = location("ACTIVE");
virtual.setLocationType("VIRTUAL");
when(repository.findPallet(TENANT, WAREHOUSE, PALLET)).thenReturn(pallet("IDLE"));
when(repository.findLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(virtual);
when(repository.occupyPallet(TENANT, WAREHOUSE, PALLET, LOCATION)).thenReturn(1);
doAnswer(call -> {
((WarehouseResourceLockEntity) call.getArgument(0)).setId(502L);
return null;
}).when(repository).insert(any());
service.reserve(request("virtual-reserve"));
verify(repository, never()).occupyLocation(TENANT, WAREHOUSE, LOCATION);
}
@Test
void scrappingAtVirtualLocationKeepsLocationUntouched() {
WarehouseResourceLockEntity lock = activeLock();
WarehouseLocationEntity virtual = location("ACTIVE");
virtual.setLocationType("VIRTUAL");
when(repository.find(TENANT, lock.getId())).thenReturn(lock);
when(repository.findLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(virtual);
when(repository.scrapPalletAtVirtualLocation(lock)).thenReturn(1);
when(repository.close(lock, "SCRAPPED")).thenReturn(1);
var result = service.scrap(TENANT, lock.getId());
assertThat(result.status()).isEqualTo("SCRAPPED");
verify(repository).scrapPalletAtVirtualLocation(lock);
verify(repository, never()).releaseLocation(lock);
}
@Test
void scrapByPalletClosesItsActiveLockAndKeepsLocationReference() {
WarehousePalletEntity pallet = pallet("OCCUPIED");
pallet.setWarehouseId(WAREHOUSE);
pallet.setLocationId(LOCATION);
WarehouseResourceLockEntity lock = activeLock();
when(repository.findPallet(TENANT, PALLET)).thenReturn(pallet);
when(repository.findActiveByPallet(TENANT, PALLET)).thenReturn(lock);
when(repository.find(TENANT, lock.getId())).thenReturn(lock);
WarehouseLocationEntity virtual = location("ACTIVE");
virtual.setLocationType("VIRTUAL");
when(repository.findLocation(TENANT, WAREHOUSE, LOCATION)).thenReturn(virtual);
when(repository.scrapPalletAtVirtualLocation(lock)).thenReturn(1);
when(repository.close(lock, "SCRAPPED")).thenReturn(1);
var result = service.scrapByPallet(TENANT, PALLET);
assertThat(result.status()).isEqualTo("SCRAPPED");
assertThat(result.locationId()).isEqualTo(LOCATION);
verify(repository).scrapPalletAtVirtualLocation(lock);
}
@Test
void scrapByPalletIsIdempotentWhenAlreadyScrapped() {
WarehousePalletEntity pallet = pallet("SCRAPPED");
pallet.setWarehouseId(WAREHOUSE);
pallet.setLocationId(LOCATION);
when(repository.findPallet(TENANT, PALLET)).thenReturn(pallet);
assertThat(service.scrapByPallet(TENANT, PALLET).status()).isEqualTo("SCRAPPED");
verify(repository, never()).findActiveByPallet(TENANT, PALLET);
}
private static WarehouseResourceLockRequests.ReserveRequest request(String key) {
return new WarehouseResourceLockRequests.ReserveRequest(TENANT, WAREHOUSE, PALLET, LOCATION,
"INBOUND_PLAN_PALLET", "plan-pallet-1", key);
@@ -7,6 +7,7 @@ import com.baiyue.warehouse.dto.LocationRequests;
import com.baiyue.warehouse.dto.PalletRequests;
import com.baiyue.warehouse.entity.WarehouseLocationEntity;
import com.baiyue.warehouse.entity.WarehousePalletEntity;
import com.baiyue.warehouse.entity.WarehouseEntity;
import com.baiyue.warehouse.repository.WarehouseResourceRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -89,6 +90,7 @@ class WarehouseResourceServiceTest {
@Test
void palletDefaultsToTwoCubicMetresAndBindsOwnedLocation() {
when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(true);
when(repository.findWarehouse(TENANT, WAREHOUSE)).thenReturn(warehouse());
WarehouseLocationEntity location = new WarehouseLocationEntity();
location.setId(30001L);
when(repository.findLocation(TENANT, WAREHOUSE, 30001L)).thenReturn(location);
@@ -111,6 +113,7 @@ class WarehouseResourceServiceTest {
@Test
void rejectsLocationFromAnotherWarehouseWhenBindingPallet() {
when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(true);
when(repository.findWarehouse(TENANT, WAREHOUSE)).thenReturn(warehouse());
when(repository.findLocation(TENANT, WAREHOUSE, 30002L)).thenReturn(null);
assertThatThrownBy(() -> service.createPallet(TENANT, WAREHOUSE,
@@ -147,8 +150,21 @@ class WarehouseResourceServiceTest {
}
@Test
void rejectsUnknownPalletStatus() {
void preventsDeletingScrappedPalletSoItsIdentityCannotBeReused() {
when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(true);
WarehousePalletEntity pallet = new WarehousePalletEntity();
pallet.setStatus("SCRAPPED");
when(repository.findPallet(TENANT, WAREHOUSE, 40003L)).thenReturn(pallet);
assertThatThrownBy(() -> service.deletePallet(TENANT, WAREHOUSE, 40003L))
.isInstanceOf(BizException.class)
.hasMessageContaining("cannot be reused");
verify(repository, never()).deletePallet(40003L);
}
@Test
void rejectsUnknownPalletStatus() {
when(repository.findWarehouse(TENANT, WAREHOUSE)).thenReturn(warehouse());
assertThatThrownBy(() -> service.createPallet(TENANT, WAREHOUSE,
new PalletRequests.SaveRequest("PLT-01", "STANDARD", null,
@@ -161,4 +177,84 @@ class WarehouseResourceServiceTest {
return new LocationRequests.SaveRequest("A", code, "STORAGE",
new BigDecimal("1000.000"), "ACTIVE", null);
}
@Test
void generatesRestartSafePalletCodeAndUsesDefaultVirtualLocation() {
when(repository.findWarehouse(TENANT, WAREHOUSE)).thenReturn(warehouse());
WarehouseLocationEntity location = new WarehouseLocationEntity();
location.setId(30003L);
location.setLocationType("VIRTUAL");
location.setStatus("ACTIVE");
when(repository.findLocationByCode(TENANT, WAREHOUSE, "LOC-US-LAX-01-DEFAULT"))
.thenReturn(location);
when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(true);
when(repository.findLocation(TENANT, WAREHOUSE, 30003L)).thenReturn(location);
service.createPallet(TENANT, WAREHOUSE,
new PalletRequests.SaveRequest(null, "STANDARD", null, BigDecimal.ONE, "IDLE", null, null));
ArgumentCaptor<WarehousePalletEntity> captor = ArgumentCaptor.forClass(WarehousePalletEntity.class);
verify(repository).insertPallet(captor.capture());
assertThat(captor.getValue().getPalletCode()).matches("PLT-US-LAX-01-[0-9]{8}-[0-9]+");
assertThat(captor.getValue().getLocationId()).isEqualTo(30003L);
assertThat(captor.getValue().getId()).isNotNull();
}
@Test
void defaultVirtualLocationIsInsertedIdempotentlyForAnExistingWarehouse() {
when(repository.findWarehouse(TENANT, WAREHOUSE)).thenReturn(warehouse());
WarehouseLocationEntity resolved = new WarehouseLocationEntity();
resolved.setId(30004L);
resolved.setLocationType("VIRTUAL");
resolved.setStatus("ACTIVE");
when(repository.findLocationByCode(TENANT, WAREHOUSE, "LOC-US-LAX-01-DEFAULT"))
.thenReturn(null, resolved);
WarehouseLocationEntity result = service.ensureDefaultVirtualLocation(TENANT, WAREHOUSE);
assertThat(result.getId()).isEqualTo(30004L);
ArgumentCaptor<WarehouseLocationEntity> location = ArgumentCaptor.forClass(WarehouseLocationEntity.class);
verify(repository).insertDefaultVirtualLocationIgnore(location.capture());
assertThat(location.getValue().getLocationCode()).isEqualTo("LOC-US-LAX-01-DEFAULT");
assertThat(location.getValue().getLocationType()).isEqualTo("VIRTUAL");
assertThat(location.getValue().getStatus()).isEqualTo("ACTIVE");
}
@Test
void rejectsAConflictingDefaultLocationCode() {
when(repository.findWarehouse(TENANT, WAREHOUSE)).thenReturn(warehouse());
WarehouseLocationEntity conflict = new WarehouseLocationEntity();
conflict.setLocationType("STORAGE");
conflict.setStatus("ACTIVE");
when(repository.findLocationByCode(TENANT, WAREHOUSE, "LOC-US-LAX-01-DEFAULT"))
.thenReturn(conflict);
assertThatThrownBy(() -> service.ensureDefaultVirtualLocation(TENANT, WAREHOUSE))
.isInstanceOf(BizException.class)
.hasMessageContaining("Default virtual location code");
verify(repository, never()).insertDefaultVirtualLocationIgnore(org.mockito.ArgumentMatchers.any());
}
@Test
void scrappedPalletCannotBeReactivated() {
when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(true);
WarehousePalletEntity existing = new WarehousePalletEntity();
existing.setId(40002L);
existing.setPalletCode("PLT-OLD");
existing.setStatus("SCRAPPED");
when(repository.findPallet(TENANT, WAREHOUSE, 40002L)).thenReturn(existing);
assertThatThrownBy(() -> service.updatePallet(TENANT, WAREHOUSE, 40002L,
new PalletRequests.SaveRequest(null, "STANDARD", null, BigDecimal.ONE, "IDLE", null, null)))
.isInstanceOf(BizException.class)
.hasMessageContaining("cannot be reused");
}
private static WarehouseEntity warehouse() {
WarehouseEntity warehouse = new WarehouseEntity();
warehouse.setId(WAREHOUSE);
warehouse.setTenantId(TENANT);
warehouse.setWarehouseCode("US-LAX-01");
return warehouse;
}
}