feat: add editable order collaboration remarks

This commit is contained in:
2026-09-14 16:53:09 +08:00
parent 26c37deb55
commit e0af45db02
32 changed files with 356 additions and 175 deletions
@@ -44,12 +44,13 @@ public interface OrderCargoClient {
Integer originalPackageCount, BigDecimal originalGrossWeightKg,
BigDecimal originalVolumeCbm, String skuNo, String cargoNameEn,
Integer unitsPerPackage, Integer totalQuantity, BigDecimal netWeightKg,
String cargoRemark, String customerRemark, String latestInternalNote) {
String cargoRemark, String customerRemark, String latestInternalNote,
String warehouseRemark, String customerServiceRemark) {
public CargoBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
Integer originalPackageCount, BigDecimal originalGrossWeightKg,
BigDecimal originalVolumeCbm) {
this(cargoId, orderId, itemNo, cargoName, originalPackageCount,
originalGrossWeightKg, originalVolumeCbm, null, null, null, null, null, null, null, null);
originalGrossWeightKg, originalVolumeCbm, null, null, null, null, null, null, null, null, null, null);
}
public CargoBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
Integer originalPackageCount, BigDecimal originalGrossWeightKg,
@@ -58,7 +59,7 @@ public interface OrderCargoClient {
String cargoRemark) {
this(cargoId, orderId, itemNo, cargoName, originalPackageCount, originalGrossWeightKg,
originalVolumeCbm, skuNo, cargoNameEn, unitsPerPackage, totalQuantity, netWeightKg,
cargoRemark, null, null);
cargoRemark, null, null, null, null);
}
}
@@ -38,8 +38,8 @@ public class PlatformSmartLoadingController {
public ApiResponse<Plan> recommend(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@Valid @RequestBody RecommendRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.recommend(planId,r,u.userId(),u.username()));}
@PatchMapping("/plans/{planId}/items/{itemId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')")
public ApiResponse<Plan> adjust(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@PathVariable @Positive long itemId,@Valid @RequestBody AdjustRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.adjust(planId,itemId,r,u.userId(),u.username()));}
@PatchMapping("/plans/{planId}/items/{itemId}/warehouse-remark") @PreAuthorize("hasAuthority('platform:firstleg:preload:edit')")
public ApiResponse<Plan> warehouseRemark(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@PathVariable @Positive long itemId,@Valid @RequestBody WarehouseRemarkRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.updateWarehouseRemark(planId,itemId,r,u.userId(),u.username()));}
@PatchMapping("/plans/{planId}/items/{itemId}/remarks") @PreAuthorize("hasAuthority('platform:firstleg:preload:edit')")
public ApiResponse<Plan> remarks(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@PathVariable @Positive long itemId,@Valid @RequestBody RemarksRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.updateRemarks(planId,itemId,r,u.userId(),u.username()));}
@DeleteMapping("/plans/{planId}/items/{itemId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:delete')")
public ApiResponse<Plan> delete(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@PathVariable @Positive long itemId,@Valid @RequestBody DeleteRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.delete(planId,itemId,r,u.userId(),u.username()));}
@DeleteMapping("/plans/{planId}/unpicked-items") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:delete')")
@@ -240,6 +240,7 @@ public final class LoadingListDtos {
String internalRemark,
String customerRemark,
String warehouseRemark,
String customerServiceRemark,
int loadSequence,
LocalDateTime frozenAt,
Integer itemVersion
@@ -83,7 +83,8 @@ public final class SmartLoadingDtos { private SmartLoadingDtos(){}
@NotNull @Min(0) Integer expectedItemVersion,@NotBlank @Size(max=128) String operationId) {}
public record RemoveUnpickedRequest(@NotNull @Min(0) Integer expectedPlanVersion,
@NotBlank @Size(max=128) String operationId) {}
public record WarehouseRemarkRequest(@Size(max=1000) String warehouseRemark,
public record RemarksRequest(@Size(max=1000) String internalRemark,
@Size(max=1000) String warehouseRemark, @Size(max=1000) String customerServiceRemark,
@NotNull @Min(0) Integer expectedPlanVersion, @NotNull @Min(0) Integer expectedItemVersion,
@NotBlank @Size(max=128) String operationId) {}
public record PlanCoLoadGroup(Long groupId,String groupNo,Long lockedPlanId,Integer memberOrderCount,
@@ -9,7 +9,7 @@ public class LoadingPlanItemEntity {
private String unitType; private Long palletId; private String palletNo; private Boolean indivisible;
private Integer palletCount; private BigDecimal grossWeightKg; private BigDecimal netWeightKg; private BigDecimal dimensionalWeightKg; private BigDecimal volumeCbm;
private String fbaTrackingNo; private String shipmentNo; private String shippingMark; private Integer loadSequence; private String positionSnapshot;
private String internalRemark; private String customerRemark; private String warehouseRemark; private LocalDateTime frozenAt; private Long frozenBy;
private String internalRemark; private String customerRemark; private String warehouseRemark; private String customerServiceRemark; private LocalDateTime frozenAt; private Long frozenBy;
private Long removedBy; private String removedByName; private LocalDateTime removedAt; @TableLogic private Boolean deleted;
@TableField(exist=false) private Long activeInboundDetailId;
@TableField(exist=false) private Integer pickedPackageCount;
@@ -194,7 +194,7 @@ public interface LoadingListQueryMapper {
* i.package_count / NULLIF(inbound.total_package_count,0)),0) pallet_count,
gross_weight_kg, net_weight_kg, volume_cbm, dimensional_weight_kg,
product_quantity, units_per_carton, fba_tracking_no, shipment_no,
shipping_mark, internal_remark, customer_remark, warehouse_remark,
shipping_mark, internal_remark, customer_remark, warehouse_remark, customer_service_remark,
load_sequence, frozen_at, i.version AS item_version
FROM fl_loading_plan_item i
JOIN fl_loading_plan p ON p.id=i.loading_plan_id
@@ -457,6 +457,7 @@ public interface LoadingListQueryMapper {
private String internalRemark;
private String customerRemark;
private String warehouseRemark;
private String customerServiceRemark;
private Integer loadSequence;
private LocalDateTime frozenAt;
private Integer itemVersion;
@@ -11,7 +11,8 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.firstl
int removeEditing(@Param("planId") long planId,@Param("itemId") long itemId,@Param("expectedVersion") int expectedVersion,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
@Update("""
UPDATE fl_loading_plan_item i
SET i.warehouse_remark=#{remark}, i.updated_by=#{operatorId}, i.updated_by_name=#{operatorName},
SET i.internal_remark=#{internalRemark}, i.warehouse_remark=#{warehouseRemark},
i.customer_service_remark=#{customerServiceRemark}, i.updated_by=#{operatorId}, i.updated_by_name=#{operatorName},
i.updated_at=CURRENT_TIMESTAMP(3), i.version=i.version+1
WHERE i.id=#{itemId} AND i.loading_plan_id=#{planId} AND i.deleted=0 AND i.frozen_at IS NULL
AND i.version=#{expectedItemVersion}
@@ -21,9 +22,12 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.firstl
AND p.plan_status IN ('EDITING','LOADING')
)
""")
int updateWarehouseRemark(@Param("planId") long planId, @Param("itemId") long itemId,
int updateRemarks(@Param("planId") long planId, @Param("itemId") long itemId,
@Param("touchedPlanVersion") int touchedPlanVersion,
@Param("expectedItemVersion") int expectedItemVersion,
@Param("remark") String remark, @Param("operatorId") long operatorId,
@Param("internalRemark") String internalRemark,
@Param("warehouseRemark") String warehouseRemark,
@Param("customerServiceRemark") String customerServiceRemark,
@Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
}
@@ -193,7 +193,7 @@ public class LoadingListRepository {
decimal(row.getGrossWeightKg()), decimal(row.getNetWeightKg()), decimal(row.getVolumeCbm()),
decimal(row.getDimensionalWeightKg()), value(row.getProductQuantity()),
integer(row.getUnitsPerCarton()), row.getFbaTrackingNo(), row.getShipmentNo(), row.getShippingMark(),
row.getInternalRemark(), row.getCustomerRemark(), row.getWarehouseRemark(),
row.getInternalRemark(), row.getCustomerRemark(), row.getWarehouseRemark(), row.getCustomerServiceRemark(),
integer(row.getLoadSequence()), row.getFrozenAt(), row.getItemVersion())).toList();
}
@@ -147,10 +147,11 @@ public class SmartLoadingRepository {
public int remove(long planId, long itemId, int version, long operatorId, String operatorName) {
return items.removeEditing(planId, itemId, version, operatorId, operatorName);
}
public int updateWarehouseRemark(long planId, long itemId, int touchedPlanVersion, int expectedItemVersion,
String remark, long operatorId, String operatorName) {
return items.updateWarehouseRemark(planId, itemId, touchedPlanVersion, expectedItemVersion,
remark, operatorId, operatorName);
public int updateRemarks(long planId, long itemId, int touchedPlanVersion, int expectedItemVersion,
String internalRemark, String warehouseRemark, String customerServiceRemark,
long operatorId, String operatorName) {
return items.updateRemarks(planId, itemId, touchedPlanVersion, expectedItemVersion,
internalRemark, warehouseRemark, customerServiceRemark, operatorId, operatorName);
}
public int startPicking(long planId, long operatorId, String operatorName) {
return plans.startPicking(planId, operatorId, operatorName);
@@ -1043,42 +1043,55 @@ public class SmartLoadingService {
}
@Transactional
@BusinessAudit(event = "SMART_LOADING_WAREHOUSE_REMARK", resourceType = "LOADING_PLAN",
resourceId = "#planId", description = "修改装柜仓库备注")
public Plan updateWarehouseRemark(long planId, long itemId, WarehouseRemarkRequest request,
long operatorId, String operatorName) {
@BusinessAudit(event = "SMART_LOADING_REMARKS", resourceType = "LOADING_PLAN",
resourceId = "#planId", description = "修改装柜协作备注")
public Plan updateRemarks(long planId, long itemId, RemarksRequest request,
long operatorId, String operatorName) {
String operation = operation(request.operationId());
String remark = trim(request.warehouseRemark());
if (!StringUtils.hasText(remark)) remark = null;
String requestHash = sha256("WAREHOUSE_REMARK|" + planId + '|' + itemId + '|'
+ Objects.toString(remark, "") + '|' + request.expectedPlanVersion() + '|'
String internalRemark = nullableRemark(request.internalRemark());
String warehouseRemark = nullableRemark(request.warehouseRemark());
String customerServiceRemark = nullableRemark(request.customerServiceRemark());
String requestHash = sha256("REMARKS|" + planId + '|' + itemId + '|'
+ Objects.toString(internalRemark, "") + '|' + Objects.toString(warehouseRemark, "") + '|'
+ Objects.toString(customerServiceRemark, "") + '|' + request.expectedPlanVersion() + '|'
+ request.expectedItemVersion());
Plan replay = replay(operation, planId, "WAREHOUSE_REMARK", itemId, requestHash);
Plan replay = replay(operation, planId, "REMARKS", itemId, requestHash);
if (replay != null) return replay;
LoadingPlanEntity plan = editing(planId, request.expectedPlanVersion(), operatorId, operatorName);
LoadingPlanItemEntity item = repository.item(planId, itemId);
if (item == null) throw new BizException(ResultCode.NOT_FOUND, "装柜明细不存在");
if (Objects.equals(item.getWarehouseRemark(), remark)) {
if (Objects.equals(item.getInternalRemark(), internalRemark)
&& Objects.equals(item.getWarehouseRemark(), warehouseRemark)
&& Objects.equals(item.getCustomerServiceRemark(), customerServiceRemark)) {
return view(plan, requireBooking(plan.getContainerBookingId()));
}
String before = json(values("warehouseRemark", item.getWarehouseRemark(), "version", item.getVersion()));
String before = json(values("internalRemark", item.getInternalRemark(),
"warehouseRemark", item.getWarehouseRemark(), "customerServiceRemark", item.getCustomerServiceRemark(),
"version", item.getVersion()));
try {
touch(plan, operatorId, operatorName);
if (repository.updateWarehouseRemark(planId, itemId, request.expectedPlanVersion() + 1,
request.expectedItemVersion(), remark, operatorId, operatorName) != 1) {
if (repository.updateRemarks(planId, itemId, request.expectedPlanVersion() + 1,
request.expectedItemVersion(), internalRemark, warehouseRemark, customerServiceRemark,
operatorId, operatorName) != 1) {
throw conflict("装柜计划或明细已被其他人修改,请刷新后重试");
}
} catch (DataAccessException exception) {
if (hasSqlState(exception, "45000")) {
throw conflict("装柜计划或明细状态已变化,仓库备注未保存,请刷新后重试");
throw conflict("装柜计划或明细状态已变化,备注未保存,请刷新后重试");
}
throw exception;
}
history(planId, itemId, "WAREHOUSE_REMARK", plan.getPlanStatus(), plan.getPlanStatus(), operation,
before, json(values("warehouseRemark", remark, "requestHash", requestHash)), operatorId, operatorName);
history(planId, itemId, "REMARKS", plan.getPlanStatus(), plan.getPlanStatus(), operation,
before, json(values("internalRemark", internalRemark, "warehouseRemark", warehouseRemark,
"customerServiceRemark", customerServiceRemark, "requestHash", requestHash)), operatorId, operatorName);
return view(repository.plan(planId), requireBooking(plan.getContainerBookingId()));
}
private static String nullableRemark(String value) {
String remark = trim(value);
return StringUtils.hasText(remark) ? remark : null;
}
@Transactional
@BusinessAudit(event = "SMART_LOADING_ITEM_DELETE", resourceType = "LOADING_PLAN_ITEM",
resourceId = "#itemId", description = "删除智能装柜明细")
@@ -1384,7 +1397,12 @@ public class SmartLoadingService {
i.setNetWeightKg(unit(c.unitNetWeightKg()).multiply(BigDecimal.valueOf(count)));
i.setVolumeCbm(unit(c.unitVolumeCbm()).multiply(BigDecimal.valueOf(count)));
i.setPalletCount(estimatedPalletCount(c, count));
applyCargo(i, cargo, count); i.setLoadSequence(seq); i.setDeleted(false); i.setCreatedBy(op); i.setCreatedByName(name); i.setUpdatedBy(op); i.setUpdatedByName(name); i.setVersion(0); return i;
applyCargo(i, cargo, count);
i.setCustomerRemark(cargo.customerRemark());
i.setInternalRemark(cargo.latestInternalNote());
i.setWarehouseRemark(cargo.warehouseRemark());
i.setCustomerServiceRemark(cargo.customerServiceRemark());
i.setLoadSequence(seq); i.setDeleted(false); i.setCreatedBy(op); i.setCreatedByName(name); i.setUpdatedBy(op); i.setUpdatedByName(name); i.setVersion(0); return i;
}
private int estimatedPalletCount(Candidate candidate, int packageCount) {
InboundOrderEntity inbound = repository.inbound(candidate.accountId(), candidate.inboundId());
@@ -1397,8 +1415,6 @@ public class SmartLoadingService {
item.setSkuCode(cargo.skuNo());
item.setProductNameEn(cargo.cargoNameEn());
item.setUnitsPerCarton(cargo.unitsPerPackage());
item.setCustomerRemark(cargo.customerRemark());
item.setInternalRemark(cargo.latestInternalNote());
int original = cargo.originalPackageCount() == null || cargo.originalPackageCount() <= 0
? boxes : cargo.originalPackageCount();
if (cargo.unitsPerPackage() != null) {
@@ -1419,9 +1435,7 @@ public class SmartLoadingService {
&& Objects.equals(current.getSkuCode(), desired.getSkuCode())
&& Objects.equals(current.getProductNameEn(), desired.getProductNameEn())
&& Objects.equals(current.getUnitsPerCarton(), desired.getUnitsPerCarton())
&& Objects.equals(current.getProductQuantity(), desired.getProductQuantity())
&& Objects.equals(current.getCustomerRemark(), desired.getCustomerRemark())
&& Objects.equals(current.getInternalRemark(), desired.getInternalRemark());
&& Objects.equals(current.getProductQuantity(), desired.getProductQuantity());
}
private static boolean decimalEquals(BigDecimal left, BigDecimal right) {
@@ -1434,7 +1448,8 @@ public class SmartLoadingService {
"palletCount", item.getPalletCount(), "skuCode", item.getSkuCode(),
"productNameEn", item.getProductNameEn(), "unitsPerCarton", item.getUnitsPerCarton(),
"productQuantity", item.getProductQuantity(), "customerRemark", item.getCustomerRemark(),
"internalRemark", item.getInternalRemark(), "version", item.getVersion());
"internalRemark", item.getInternalRemark(), "warehouseRemark", item.getWarehouseRemark(),
"customerServiceRemark", item.getCustomerServiceRemark(), "version", item.getVersion());
}
private OrderCargoClient.CargoBinding loadCargo(Candidate candidate) {
@@ -319,6 +319,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan_item (
internal_remark VARCHAR(1000) NULL COMMENT '平台内部备注',
customer_remark VARCHAR(1000) NULL COMMENT '客户备注快照',
warehouse_remark VARCHAR(1000) NULL COMMENT '仓库备注快照',
customer_service_remark VARCHAR(1000) NULL COMMENT '客服备注快照',
frozen_at DATETIME(3) NULL COMMENT '装柜完成后的永久冻结时间',
frozen_by BIGINT UNSIGNED NULL COMMENT '冻结操作人ID',
removed_by BIGINT UNSIGNED NULL COMMENT '推送前逻辑删除操作人ID',
@@ -358,6 +359,15 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan_item (
CONSTRAINT chk_loading_item_deleted CHECK (deleted IN (0, 1))
) ENGINE=InnoDB COMMENT='智能装柜客户货物明细;客户归属仅在明细层';
SET @loading_item_customer_service_remark_sql = IF(
EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_firstleg' AND table_name='fl_loading_plan_item' AND column_name='customer_service_remark'),
'SELECT 1',
'ALTER TABLE baiyue_firstleg.fl_loading_plan_item ADD COLUMN customer_service_remark VARCHAR(1000) NULL COMMENT ''客服备注快照'' AFTER warehouse_remark'
);
PREPARE loading_item_customer_service_remark_stmt FROM @loading_item_customer_service_remark_sql;
EXECUTE loading_item_customer_service_remark_stmt;
DEALLOCATE PREPARE loading_item_customer_service_remark_stmt;
CREATE TABLE IF NOT EXISTS fl_loading_pallet_label (
id BIGINT UNSIGNED NOT NULL COMMENT '散货托盘标签ID(雪花ID)',
loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划ID',
@@ -1036,7 +1046,9 @@ BEGIN
IF current_plan_status = 'LOADING' AND EXISTS (
SELECT 1 FROM fl_loading_pick_item
WHERE loading_plan_item_id = OLD.id AND picked_package_count > 0) THEN
IF NOT (NEW.warehouse_remark <=> OLD.warehouse_remark) THEN
IF NOT (NEW.internal_remark <=> OLD.internal_remark)
OR NOT (NEW.warehouse_remark <=> OLD.warehouse_remark)
OR NOT (NEW.customer_service_remark <=> OLD.customer_service_remark) THEN
IF NEW.package_count <> OLD.package_count OR NEW.deleted <> OLD.deleted
OR NOT (NEW.customer_name <=> OLD.customer_name)
OR NOT (NEW.inbound_id <=> OLD.inbound_id)
@@ -1064,14 +1076,13 @@ BEGIN
OR NOT (NEW.shipping_mark <=> OLD.shipping_mark)
OR NOT (NEW.load_sequence <=> OLD.load_sequence)
OR NOT (NEW.position_snapshot <=> OLD.position_snapshot)
OR NOT (NEW.internal_remark <=> OLD.internal_remark)
OR NOT (NEW.customer_remark <=> OLD.customer_remark)
OR NOT (NEW.frozen_at <=> OLD.frozen_at)
OR NOT (NEW.frozen_by <=> OLD.frozen_by)
OR NOT (NEW.removed_by <=> OLD.removed_by)
OR NOT (NEW.removed_by_name <=> OLD.removed_by_name)
OR NOT (NEW.removed_at <=> OLD.removed_at) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已有PDA拣货记录的装柜明细修改仓库备注时不得变更其他业务字段';
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已有PDA拣货记录的装柜明细修改备注时不得变更其他业务字段';
END IF;
ELSEIF NOT EXISTS (
SELECT 1 FROM fl_loading_pick_item
@@ -1106,7 +1117,6 @@ BEGIN
OR NOT (NEW.shipping_mark <=> OLD.shipping_mark)
OR NOT (NEW.load_sequence <=> OLD.load_sequence)
OR NOT (NEW.position_snapshot <=> OLD.position_snapshot)
OR NOT (NEW.internal_remark <=> OLD.internal_remark)
OR NOT (NEW.customer_remark <=> OLD.customer_remark)
OR NOT (NEW.frozen_at <=> OLD.frozen_at)
OR NOT (NEW.frozen_by <=> OLD.frozen_by)
@@ -774,20 +774,21 @@ paths:
content: {application/json: {schema: {$ref: '#/components/schemas/SmartLoadingRemoveUnpickedRequest'}}}
responses:
'200': {description: 成功或业务失败,data 为最新 SmartLoadingPlan}
/api/platform/v1/first-leg/smart-loading/plans/{planId}/items/{itemId}/warehouse-remark:
/api/platform/v1/first-leg/smart-loading/plans/{planId}/items/{itemId}/remarks:
patch:
summary: 修改装柜明细仓库备注
summary: 修改装柜明细协作备注
description: >-
需要 platform:firstleg:preload:edit 权限。EDITING/LOADING 可修改或清空 warehouseRemark;
需要 platform:firstleg:preload:edit 权限。客户备注只读;EDITING/LOADING 可修改或清空
internalRemark、warehouseRemark 和 customerServiceRemark;
COMPLETED、计划/明细版本冲突返回 A0409 且事务零写入,写入历史且与 PDA final complete 使用同一并发门禁。
成功响应中的 version 是递增后的 planVersion,items[].version 是递增后的 itemVersion;调用方同时使用
canEdit、items[].canAdjust/canRemove 与自身权限决定操作可用性。数量调整或动态增量不会覆盖已有 warehouseRemark。
canEdit、items[].canAdjust/canRemove 与自身权限决定操作可用性。数量调整或动态增量不会覆盖已有协作备注。
parameters:
- {name: planId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: itemId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
requestBody:
required: true
content: {application/json: {schema: {$ref: '#/components/schemas/SmartLoadingWarehouseRemarkRequest'}}}
content: {application/json: {schema: {$ref: '#/components/schemas/SmartLoadingRemarksRequest'}}}
responses:
'200': {description: 成功或业务失败,data 为最新 SmartLoadingPlan}
/openapi/v1/inbound-measurements:
@@ -2323,11 +2324,13 @@ components:
properties:
expectedPlanVersion: {type: integer, minimum: 0}
operationId: {type: string, minLength: 1, maxLength: 128}
SmartLoadingWarehouseRemarkRequest:
SmartLoadingRemarksRequest:
type: object
required: [expectedPlanVersion, expectedItemVersion, operationId]
properties:
internalRemark: {type: string, nullable: true, maxLength: 1000, description: 内部备注;null/空字符串均表示清空}
warehouseRemark: {type: string, nullable: true, maxLength: 1000, description: 仓库备注;null/空字符串均表示清空}
customerServiceRemark: {type: string, nullable: true, maxLength: 1000, description: 客服备注;null/空字符串均表示清空}
expectedPlanVersion: {type: integer, minimum: 0}
expectedItemVersion: {type: integer, minimum: 0}
operationId: {type: string, minLength: 1, maxLength: 128}
@@ -2518,8 +2521,9 @@ components:
inboundCode: {type: string, nullable: true}
customerName: {type: string, nullable: true}
customerRemark: {type: string, nullable: true, readOnly: true, description: 来源为订单 customerRemark}
internalRemark: {type: string, nullable: true, readOnly: true, description: 来源为订单最新内部备注,不使用 cargoRemark}
internalRemark: {type: string, nullable: true, description: 内部备注;预装阶段可修改}
warehouseRemark: {type: string, nullable: true, description: 仓库人员逐条维护,可在 EDITING/LOADING 修改}
customerServiceRemark: {type: string, nullable: true, description: 客服备注;预装阶段可修改}
packageCount: {type: integer, minimum: 1}
totalPackageCount: {type: integer, minimum: 1, description: 当前入仓明细可在计划内分配的总箱数,包含已用与未用箱数}
remainingPackageCount: {type: integer, minimum: 0, description: 当前入仓明细尚未分配到本计划的箱数}
@@ -405,6 +405,7 @@ components:
internalRemark: {type: string, nullable: true, description: 内部备注}
customerRemark: {type: string, nullable: true, description: 客户备注}
warehouseRemark: {type: string, nullable: true, description: 仓库备注}
customerServiceRemark: {type: string, nullable: true, description: 客服备注}
loadSequence: {type: integer, format: int32, minimum: 0, description: 装柜顺序}
frozenAt: {type: string, format: date-time, nullable: true, description: 明细冻结时间}
itemVersion: {type: integer, format: int32, minimum: 0, description: 明细乐观锁版本;更新仓库备注时作为 expectedItemVersion}
@@ -54,7 +54,7 @@ class PlatformSmartLoadingControllerContractTest {
authority("removeGroup","platform:firstleg:smart-loading:delete",AuthenticatedUser.class,long.class,long.class,RemoveGroupRequest.class);
authority("recommend","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,RecommendRequest.class);
authority("adjust","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,long.class,AdjustRequest.class);
authority("warehouseRemark","platform:firstleg:preload:edit",AuthenticatedUser.class,long.class,long.class,WarehouseRemarkRequest.class);
authority("remarks","platform:firstleg:preload:edit",AuthenticatedUser.class,long.class,long.class,RemarksRequest.class);
authority("delete","platform:firstleg:smart-loading:delete",AuthenticatedUser.class,long.class,long.class,DeleteRequest.class);
authority("removeUnpicked","platform:firstleg:smart-loading:delete",AuthenticatedUser.class,long.class,RemoveUnpickedRequest.class);
}
@@ -77,13 +77,13 @@ class PlatformSmartLoadingControllerContractTest {
.contains("SmartLoadingAdjustRequest:")
.contains("SmartLoadingDeleteRequest:")
.contains("SmartLoadingRemoveUnpickedRequest:")
.contains("SmartLoadingWarehouseRemarkRequest:")
.contains("/items/{itemId}/warehouse-remark:")
.contains("SmartLoadingRemarksRequest:")
.contains("/items/{itemId}/remarks:")
.contains("需要 platform:firstleg:preload:edit 权限")
.contains("warehouseRemark: {type: string, nullable: true, maxLength: 1000")
.contains("成功响应中的 version 是递增后的 planVersion")
.contains("items[].version 是递增后的 itemVersion")
.contains("数量调整或动态增量不会覆盖已有 warehouseRemark")
.contains("数量调整或动态增量不会覆盖已有协作备注")
.contains("柜内总容积和总重量分别允许达到柜型额定值的150%")
.contains("人工分配不限制订单国内仓及渠道与柜次一致")
.contains("智能推荐允许跨国内仓,但仍只选择柜次渠道")
@@ -122,8 +122,8 @@ class SmartLoadingSqlContractTest {
}
@Test
@DisplayName("仓库备注使用计划先行与明细单表CAS并允许已拣明细仅改备注")
void shouldUpdateWarehouseRemarkWithoutMultiTableTriggerConflict() throws Exception {
@DisplayName("可编辑备注使用计划先行与明细单表CAS并允许已拣明细仅改备注")
void shouldUpdateEditableRemarksWithoutMultiTableTriggerConflict() throws Exception {
String mapper = Files.readString(Path.of(
"src/main/java/com/baiyue/firstleg/mapper/LoadingPlanItemMapper.java"), StandardCharsets.UTF_8);
assertThat(mapper)
@@ -133,8 +133,8 @@ class SmartLoadingSqlContractTest {
.contains("i.frozen_at IS NULL")
.doesNotContain("UPDATE fl_loading_plan_item i JOIN fl_loading_plan p")
.doesNotContain("p.version=p.version+1");
assertWarehouseRemarkTriggerContract(migration());
assertWarehouseRemarkTriggerContract(unifiedMigration());
assertEditableRemarksTriggerContract(migration());
assertEditableRemarksTriggerContract(unifiedMigration());
}
private static void assertContainerLifecycleSchema(String sql) {
@@ -151,11 +151,13 @@ class SmartLoadingSqlContractTest {
.contains("column_name='merged_into_container_booking_id'");
}
private static void assertWarehouseRemarkTriggerContract(String sql) {
private static void assertEditableRemarksTriggerContract(String sql) {
assertThat(sql)
.contains("IF NOT (NEW.warehouse_remark <=> OLD.warehouse_remark) THEN")
.contains("IF NOT (NEW.internal_remark <=> OLD.internal_remark)")
.contains("OR NOT (NEW.warehouse_remark <=> OLD.warehouse_remark)")
.contains("OR NOT (NEW.customer_service_remark <=> OLD.customer_service_remark) THEN")
.contains("IF NEW.package_count <> OLD.package_count OR NEW.deleted <> OLD.deleted")
.contains("已有PDA拣货记录的装柜明细修改仓库备注时不得变更其他业务字段")
.contains("已有PDA拣货记录的装柜明细修改备注时不得变更其他业务字段")
.contains("ELSEIF NOT EXISTS (")
.contains("IF OLD.frozen_at IS NOT NULL THEN")
.contains("装柜完成后的明细永久冻结");
@@ -21,7 +21,7 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.WorkbenchContainer;
import com.baiyue.firstleg.dto.SmartLoadingDtos.WorkbenchContainerQuery;
import com.baiyue.firstleg.dto.SmartLoadingDtos.Plan;
import com.baiyue.firstleg.dto.SmartLoadingDtos.RecommendRequest;
import com.baiyue.firstleg.dto.SmartLoadingDtos.WarehouseRemarkRequest;
import com.baiyue.firstleg.dto.SmartLoadingDtos.RemarksRequest;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.entity.LoadingPlanEntity;
import com.baiyue.firstleg.entity.LoadingPlanHistoryEntity;
@@ -1267,7 +1267,7 @@ class SmartLoadingServiceTest {
when(cargoClient.detail(anyString(), eq(9L), eq(30L), eq(20L))).thenReturn(ApiResponse.success(
new OrderCargoClient.CargoBinding(20L, 30L, 1, "中文品名", 10,
new BigDecimal("20"), new BigDecimal("2"), "SKU-1", "English name",
4, 100, new BigDecimal("10"), "货物备注", "客户备注", "最新内部备注")));
4, 100, new BigDecimal("10"), "货物备注", "客户备注", "最新内部备注", "仓库备注", "客服备注")));
doAnswer(invocation -> {
Object value = invocation.getArgument(0);
if (value instanceof LoadingPlanItemEntity item) item.setId(88L);
@@ -1288,6 +1288,8 @@ class SmartLoadingServiceTest {
assertThat(saved.getNetWeightKg()).isEqualByComparingTo("7.5");
assertThat(saved.getCustomerRemark()).isEqualTo("客户备注");
assertThat(saved.getInternalRemark()).isEqualTo("最新内部备注");
assertThat(saved.getWarehouseRemark()).isEqualTo("仓库备注");
assertThat(saved.getCustomerServiceRemark()).isEqualTo("客服备注");
assertThat(saved.getInternalRemark()).isNotEqualTo("货物备注");
ArgumentCaptor<LoadingPlanHistoryEntity> historyCaptor = ArgumentCaptor.forClass(LoadingPlanHistoryEntity.class);
@@ -1451,20 +1453,20 @@ class SmartLoadingServiceTest {
when(repository.booking(99L)).thenReturn(booking());
when(repository.items(1L)).thenReturn(List.of(line));
when(repository.touch(1L, 2, 7L, "u")).thenReturn(1);
when(repository.updateWarehouseRemark(1L, 8L, 3, 0, null, 7L, "u")).thenReturn(1);
when(repository.updateRemarks(1L, 8L, 3, 0, null, null, null, 7L, "u")).thenReturn(1);
Plan result = service.updateWarehouseRemark(1L, 8L,
new WarehouseRemarkRequest(" ", 2, 0, "warehouse-clear"), 7L, "u");
Plan result = service.updateRemarks(1L, 8L,
new RemarksRequest(null, " ", null, 2, 0, "warehouse-clear"), 7L, "u");
assertThat(result.status()).isEqualTo("LOADING");
verify(loadingPickService).lockMutable(1L);
var order = inOrder(loadingPickService, repository);
order.verify(loadingPickService).lockMutable(1L);
order.verify(repository).touch(1L, 2, 7L, "u");
order.verify(repository).updateWarehouseRemark(1L, 8L, 3, 0, null, 7L, "u");
order.verify(repository).updateRemarks(1L, 8L, 3, 0, null, null, null, 7L, "u");
ArgumentCaptor<LoadingPlanHistoryEntity> history = ArgumentCaptor.forClass(LoadingPlanHistoryEntity.class);
verify(repository).insert(history.capture());
assertThat(history.getValue().getActionType()).isEqualTo("WAREHOUSE_REMARK");
assertThat(history.getValue().getActionType()).isEqualTo("REMARKS");
assertThat(history.getValue().getBeforeSnapshot()).contains("旧仓库备注");
}
@@ -1477,14 +1479,14 @@ class SmartLoadingServiceTest {
when(repository.booking(99L)).thenReturn(booking());
when(repository.items(1L)).thenReturn(List.of(line));
when(repository.touch(1L, 1, 7L, "u")).thenReturn(1);
when(repository.updateWarehouseRemark(1L, 8L, 2, 0, "待核对外箱", 7L, "u")).thenReturn(1);
when(repository.updateRemarks(1L, 8L, 2, 0, null, "待核对外箱", null, 7L, "u")).thenReturn(1);
Plan result = service.updateWarehouseRemark(1L, 8L,
new WarehouseRemarkRequest("待核对外箱", 1, 0, "warehouse-editing"), 7L, "u");
Plan result = service.updateRemarks(1L, 8L,
new RemarksRequest(null, "待核对外箱", null, 1, 0, "warehouse-editing"), 7L, "u");
assertThat(result.status()).isEqualTo("EDITING");
assertThat(result.version()).isEqualTo(2);
verify(repository).updateWarehouseRemark(1L, 8L, 2, 0, "待核对外箱", 7L, "u");
verify(repository).updateRemarks(1L, 8L, 2, 0, null, "待核对外箱", null, 7L, "u");
}
@Test
@@ -1497,13 +1499,13 @@ class SmartLoadingServiceTest {
when(repository.booking(99L)).thenReturn(booking());
when(repository.items(1L)).thenReturn(List.of(line));
Plan result = service.updateWarehouseRemark(1L, 8L,
new WarehouseRemarkRequest(" 待核对外箱 ", 2, 0, "warehouse-noop"), 7L, "u");
Plan result = service.updateRemarks(1L, 8L,
new RemarksRequest(null, " 待核对外箱 ", null, 2, 0, "warehouse-noop"), 7L, "u");
assertThat(result.version()).isEqualTo(2);
verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), anyString());
verify(repository, never()).updateWarehouseRemark(anyLong(), anyLong(), anyInt(), anyInt(),
any(), anyLong(), anyString());
verify(repository, never()).updateRemarks(anyLong(), anyLong(), anyInt(), anyInt(),
any(), any(), any(), anyLong(), anyString());
verify(repository, never()).insert(any(LoadingPlanHistoryEntity.class));
}
@@ -1514,10 +1516,10 @@ class SmartLoadingServiceTest {
when(repository.plan(1L)).thenReturn(plan);
when(repository.item(1L, 8L)).thenReturn(line);
when(repository.touch(1L, 2, 7L, "u")).thenReturn(1);
when(repository.updateWarehouseRemark(1L, 8L, 3, 0, "新备注", 7L, "u")).thenReturn(0);
when(repository.updateRemarks(1L, 8L, 3, 0, null, "新备注", null, 7L, "u")).thenReturn(0);
BizException error = catchThrowableOfType(() -> service.updateWarehouseRemark(1L, 8L,
new WarehouseRemarkRequest("新备注", 2, 0, "warehouse-conflict"), 7L, "u"), BizException.class);
BizException error = catchThrowableOfType(() -> service.updateRemarks(1L, 8L,
new RemarksRequest(null, "新备注", null, 2, 0, "warehouse-conflict"), 7L, "u"), BizException.class);
assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT);
verify(repository).touch(1L, 2, 7L, "u");
@@ -1531,15 +1533,15 @@ class SmartLoadingServiceTest {
when(repository.plan(1L)).thenReturn(plan);
when(repository.item(1L, 8L)).thenReturn(line);
when(repository.touch(1L, 2, 7L, "u")).thenReturn(1);
when(repository.updateWarehouseRemark(1L, 8L, 3, 0, "新备注", 7L, "u"))
when(repository.updateRemarks(1L, 8L, 3, 0, null, "新备注", null, 7L, "u"))
.thenThrow(new DataIntegrityViolationException("trigger",
new SQLException("状态冲突", "45000")));
BizException error = catchThrowableOfType(() -> service.updateWarehouseRemark(1L, 8L,
new WarehouseRemarkRequest("新备注", 2, 0, "warehouse-trigger"), 7L, "u"), BizException.class);
BizException error = catchThrowableOfType(() -> service.updateRemarks(1L, 8L,
new RemarksRequest(null, "新备注", null, 2, 0, "warehouse-trigger"), 7L, "u"), BizException.class);
assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT);
assertThat(error.getMessage()).contains("仓库备注未保存").contains("刷新后重试");
assertThat(error.getMessage()).contains("备注未保存").contains("刷新后重试");
verify(repository, never()).insert(any(LoadingPlanHistoryEntity.class));
}
@@ -1547,46 +1549,46 @@ class SmartLoadingServiceTest {
void stalePlanVersionRejectsWarehouseRemarkBeforeItemWrite() {
when(repository.plan(1L)).thenReturn(plan("LOADING", 3));
BizException error = catchThrowableOfType(() -> service.updateWarehouseRemark(1L, 8L,
new WarehouseRemarkRequest("新备注", 2, 0, "warehouse-stale-plan"), 7L, "u"), BizException.class);
BizException error = catchThrowableOfType(() -> service.updateRemarks(1L, 8L,
new RemarksRequest(null, "新备注", null, 2, 0, "warehouse-stale-plan"), 7L, "u"), BizException.class);
assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT);
verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), anyString());
verify(repository, never()).updateWarehouseRemark(anyLong(), anyLong(), anyInt(), anyInt(),
any(), anyLong(), anyString());
verify(repository, never()).updateRemarks(anyLong(), anyLong(), anyInt(), anyInt(),
any(), any(), any(), anyLong(), anyString());
}
@Test
void completedPlanRejectsWarehouseRemarkBeforeAnyWrite() {
when(repository.plan(1L)).thenReturn(plan("COMPLETED", 4));
BizException error = catchThrowableOfType(() -> service.updateWarehouseRemark(1L, 8L,
new WarehouseRemarkRequest("新备注", 4, 0, "warehouse-completed"), 7L, "u"), BizException.class);
BizException error = catchThrowableOfType(() -> service.updateRemarks(1L, 8L,
new RemarksRequest(null, "新备注", null, 4, 0, "warehouse-completed"), 7L, "u"), BizException.class);
assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT);
verify(loadingPickService, never()).lockMutable(anyLong());
verify(repository, never()).item(anyLong(), anyLong());
verify(repository, never()).updateWarehouseRemark(anyLong(), anyLong(), anyInt(), anyInt(),
any(), anyLong(), anyString());
verify(repository, never()).updateRemarks(anyLong(), anyLong(), anyInt(), anyInt(),
any(), any(), any(), anyLong(), anyString());
verify(repository, never()).insert(any(LoadingPlanHistoryEntity.class));
}
@Test
void exactWarehouseRemarkReplayIsReadOnly() {
String requestHash = sha256("WAREHOUSE_REMARK|1|8|备注|2|0");
String requestHash = sha256("REMARKS|1|8||备注||2|0");
when(repository.operation("warehouse-replay"))
.thenReturn(history(1L, 8L, "WAREHOUSE_REMARK", requestHash));
.thenReturn(history(1L, 8L, "REMARKS", requestHash));
when(repository.plan(1L)).thenReturn(plan("LOADING", 3));
when(repository.booking(99L)).thenReturn(booking());
when(repository.items(1L)).thenReturn(List.of(item(8, 9, 20, 1)));
Plan result = service.updateWarehouseRemark(1L, 8L,
new WarehouseRemarkRequest("备注", 2, 0, "warehouse-replay"), 7L, "u");
Plan result = service.updateRemarks(1L, 8L,
new RemarksRequest(null, "备注", null, 2, 0, "warehouse-replay"), 7L, "u");
assertThat(result.version()).isEqualTo(3);
verify(loadingPickService, never()).lockMutable(anyLong());
verify(repository, never()).updateWarehouseRemark(anyLong(), anyLong(), anyInt(), anyInt(),
any(), anyLong(), anyString());
verify(repository, never()).updateRemarks(anyLong(), anyLong(), anyInt(), anyInt(),
any(), any(), any(), anyLong(), anyString());
}
@Test