This commit is contained in:
2026-08-19 09:42:07 +08:00
parent ff1d330c2d
commit b5864bfcda
56 changed files with 497 additions and 306 deletions
@@ -47,7 +47,6 @@ public final class ContainerDtos {
@Schema(name = "ContainerSaveRequest", description = "货柜基础资料;智能装柜草稿阶段可完整编辑")
public record SaveRequest(
@Size(max = 64) String containerNo,
@NotBlank @Size(max = 64) String cabinetNo,
@NotNull @Positive Long warehouseId,
@Size(max = 128) String warehouseName,
@Size(max = 128) String billOfLadingNo,
@@ -171,11 +171,11 @@ public class ContainerFeeDetailService {
if (supplierId == null || supplierId <= 0) {
throw new BizException(ResultCode.VALIDATION_FAILED, "应付柜次费用必须选择有效供应商");
}
String category = FeeItemSupplierCategory.requirePayable(item.getSupplierCategory());
String category = FeeItemSupplierCategory.normalize(item.getSupplierCategory());
var snapshot = suppliers.fee(supplierId, category);
value.setSupplierId(snapshot.supplierId());
value.setSupplierName(snapshot.name());
value.setSupplierCategory(snapshot.category());
value.setSupplierCategory(category);
}
private void refreshSupplierSnapshot(ContainerFeeDetailEntity value, FeeItemEntity item,
@@ -139,7 +139,7 @@ public class ContainerService {
ContainerSpecificationCatalog.requireMatch(request.data().containerType(), request.data().capacityCbm(), request.data().maxWeightKg());
WarehouseMasterDataClient.Warehouse warehouse = requireDomesticTransitWarehouse(request.data().warehouseId());
var trucking = suppliers.trucking(request.data().terminalTruckingCompanyId(), request.data().terminalTruckingCompany());
ensureUnique(trim(request.data().containerNo()), request.data().cabinetNo().trim(), id);
ensureUnique(trim(request.data().containerNo()), current.getCabinetNo(), id);
copy(current, request.data());
current.setWarehouseId(warehouse.id()); current.setWarehouseName(warehouse.warehouseName());
current.setTerminalTruckingCompanyId(trucking == null ? null : trucking.supplierId());
@@ -222,7 +222,7 @@ public class ContainerService {
}
private static void copy(ContainerBookingEntity v, SaveRequest r) {
var specification = ContainerSpecificationCatalog.requireMatch(r.containerType(), r.capacityCbm(), r.maxWeightKg());
v.setContainerNo(trim(r.containerNo())); v.setCabinetNo(r.cabinetNo().trim());
v.setContainerNo(trim(r.containerNo()));
v.setWarehouseId(r.warehouseId()); v.setWarehouseName(trim(r.warehouseName()));
v.setBillOfLadingNo(trim(r.billOfLadingNo())); v.setVesselName(trim(r.vesselName()));
v.setVoyageNo(trim(r.voyageNo())); v.setContainerType(specification.containerType());
@@ -104,11 +104,6 @@ public class FeeItemService {
private static void copy(FeeItemEntity value, SaveRequest request) {
boolean payable = Boolean.TRUE.equals(request.pickupPayable())
|| Boolean.TRUE.equals(request.warehousePayable());
if (payable && (request.supplierCategory() == null
|| request.supplierCategory().isBlank())) {
throw new BizException(ResultCode.VALIDATION_FAILED,
"启用应付方向时必须配置供应商类别");
}
value.setCategory(request.category().trim());
value.setTradeScope(request.tradeScope());
value.setFeeCode(request.feeCode() == null || request.feeCode().isBlank()
@@ -6,7 +6,7 @@ import com.baiyue.common.core.exception.BizException;
import java.util.Locale;
import java.util.Set;
/** 费用项目显式供应商类别校验;未配置的应付费用失败关闭。 */
/** 费用项目可选供应商类别约束校验。 */
public final class FeeItemSupplierCategory {
private static final Set<String> ALLOWED = Set.of(
"BOOKING", "CUSTOMS", "TRUCKING", "CERTIFICATE");
@@ -23,12 +23,4 @@ public final class FeeItemSupplierCategory {
return normalized;
}
public static String requirePayable(String supplierCategory) {
String category = normalize(supplierCategory);
if (category == null) {
throw new BizException(ResultCode.CONFLICT,
"费用项目未配置供应商类别,不能用于应付费用");
}
return category;
}
}
@@ -414,11 +414,11 @@ public class InboundFeeDetailService {
if (supplierId == null || supplierId <= 0) {
throw new BizException(ResultCode.VALIDATION_FAILED, "应付入库费用必须选择有效供应商");
}
String category = FeeItemSupplierCategory.requirePayable(item.getSupplierCategory());
String category = FeeItemSupplierCategory.normalize(item.getSupplierCategory());
var snapshot = suppliers.fee(supplierId, category);
value.setSupplierId(snapshot.supplierId());
value.setSupplierName(snapshot.name());
value.setSupplierCategory(snapshot.category());
value.setSupplierCategory(category);
}
private void refreshSupplierSnapshot(InboundFeeDetailEntity value, FeeItemEntity item,
@@ -48,7 +48,7 @@ public class TruckAppointmentService {
@Transactional public TruckAppointmentAttachmentEntity upload(long id,String type,MultipartFile file,long uid){String attachmentType=attachmentType(type);TruckAppointmentEntity appointment=required(id);if(appointment.getCustomerId()==null||appointment.getCustomerId()<=0)throw bad("预约缺少客户归属,无法上传附件");try{var response=files.upload(internalToken,uid,appointment.getCustomerId(),"TRUCK_APPOINTMENT_ATTACHMENT",Long.toString(id),file);if(response==null||!response.successful()||response.data()==null)throw bad("附件上传失败");var m=response.data();TruckAppointmentAttachmentEntity a=new TruckAppointmentAttachmentEntity();a.setId(IdWorker.getId());a.setAppointmentId(id);a.setFileId(m.fileId());a.setAttachmentType(attachmentType);a.setFileName(m.fileName());a.setFileHash(m.sha256());a.setVisibility("PLATFORM");a.setUploadedBy(uid);a.setUploadedAt(LocalDateTime.now());repo.attachment(a);return a;}catch(FeignException ex){throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"文件服务暂不可用");}}
@Transactional public TruckAppointmentAttachmentEntity attach(long id,AttachmentRequest r,long uid){String attachmentType=attachmentType(r.attachmentType());TruckAppointmentEntity appointment=required(id); if(appointment.getCustomerId()==null||appointment.getCustomerId()<=0)throw bad("预约缺少客户归属,无法引用附件"); TruckAppointmentFileClient.Metadata metadata;try{var response=files.metadata(internalToken,appointment.getCustomerId(),"TRUCK_APPOINTMENT_ATTACHMENT",Long.toString(id),r.fileId());if(response==null||!response.successful()||response.data()==null)throw bad("附件不存在或归属不匹配");metadata=response.data();}catch(FeignException ex){throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"文件服务暂不可用");} TruckAppointmentAttachmentEntity a=new TruckAppointmentAttachmentEntity();a.setId(IdWorker.getId());a.setAppointmentId(id);a.setFileId(metadata.fileId());a.setAttachmentType(attachmentType);a.setFileName(metadata.fileName());a.setFileHash(metadata.sha256());a.setVisibility("PLATFORM");a.setUploadedBy(uid);a.setUploadedAt(LocalDateTime.now());repo.attachment(a);return a;}
@Transactional public void deleteAttachment(long id,long attachmentId){required(id);if(repo.deleteAttachment(id,attachmentId)!=1)throw new BizException(ResultCode.NOT_FOUND,"附件引用不存在");}
@Transactional public TruckAppointmentEntity create(long uid,String name,SaveRequest r){if(r.expectedVersion()==null||r.expectedVersion()!=0)throw bad("新增 expectedVersion 必须为0");validateRequired(r); var customer=customers.active(r.customerId(),r.customerName()); AssociationContext links=validateAssociations(r,customer.id()); var carrier=suppliers.trucking(r.carrierId(),r.carrierName()); TruckAppointmentEntity e=new TruckAppointmentEntity(); e.setId(IdWorker.getId()); e.setAppointmentNo(documentNumbers.next("TA")); e.setStatus(TruckAppointmentStatus.DRAFT); e.setRevision(0); e.setVersion(0); e.setCreatedAt(LocalDateTime.now()); e.setUpdatedAt(e.getCreatedAt()); copy(e,r,links,customer.tenantName(),carrier.name()); repo.insert(e); record(e,null,e.getStatus().name(),"CREATE",null,uid,name); return e;}
@Transactional public TruckAppointmentEntity create(long uid,String name,SaveRequest r){if(r.expectedVersion()==null||r.expectedVersion()!=0)throw bad("新增 expectedVersion 必须为0");validateRequired(r); var customer=customers.active(r.customerId(),r.customerName()); AssociationContext links=validateAssociations(r,customer.id()); var carrier=suppliers.trucking(r.carrierId(),r.carrierName()); TruckAppointmentEntity e=new TruckAppointmentEntity(); e.setId(IdWorker.getId()); e.setAppointmentNo(documentNumbers.next("TA")); e.setStatus(TruckAppointmentStatus.DRAFT); e.setRevision(0); e.setVersion(0); e.setCreatedAt(LocalDateTime.now()); e.setUpdatedAt(e.getCreatedAt()); copy(e,r,links,customer.tenantName(),carrier.name()); repo.insert(e); record(e,null,e.getStatus().name(),"CREATE",null,uid,name); return enrich(e);}
@Transactional public TruckAppointmentEntity edit(long id,long uid,String name,SaveRequest r){validateRequired(r);TruckAppointmentEntity e=required(id); checkVersion(e,r.expectedVersion()); if(!(e.getStatus()==TruckAppointmentStatus.DRAFT||e.getStatus()==TruckAppointmentStatus.PENDING_DISPATCH||e.getStatus()==TruckAppointmentStatus.RESCHEDULED))throw conflict("当前状态不可编辑"); var customer=customers.active(r.customerId(),r.customerName()); AssociationContext links=validateAssociations(r,customer.id()); var carrier=suppliers.trucking(r.carrierId(),r.carrierName()); copy(e,r,links,customer.tenantName(),carrier.name()); if(repo.updateEdit(e,r.expectedVersion())!=1)throw conflict("版本已变化,请刷新后重试"); record(e,e.getStatus().name(),e.getStatus().name(),"EDIT",null,uid,name); return required(id);}
@Transactional public TruckAppointmentEntity action(long id,String action,ActionRequest r,String idem,String clientHash,long uid,String name){String requestHash=hash(action,id,r);verifyClientHash(clientHash,requestHash);TruckAppointmentEntity replay=claimOrReplay(id,action,idem,requestHash);if(replay!=null)return replay;TruckAppointmentEntity e=required(id); checkVersion(e,r.expectedVersion()); if("RESCHEDULE".equals(action))throw conflict("改约请使用专用接口"); TruckAppointmentStatus from=e.getStatus(),to=next(from,action); String dispatchBill="DISPATCH".equals(action)?requireDispatchReady(e):null; int changed="DISPATCH".equals(action)?repo.dispatch(id,from.name(),dispatchBill,e.getVersion()):repo.transition(id,from.name(),to.name(),e.getVersion()); if(changed!=1)throw conflict("状态或版本已变化,请刷新后重试"); TruckAppointmentEntity after=required(id);record(after,from.name(),to.name(),action,r.reason(),uid,name);return completeOperation(id,action,idem,after);}
@Transactional public TruckAppointmentEntity reschedule(long id,LocalDateTime at,ActionRequest r,String idem,String clientHash,long uid,String name){String action="RESCHEDULE";String requestHash=hash(action,id,new ReschedulePayload(at,r));verifyClientHash(clientHash,requestHash);TruckAppointmentEntity replay=claimOrReplay(id,action,idem,requestHash);if(replay!=null)return replay;TruckAppointmentEntity e=required(id);checkVersion(e,r.expectedVersion()); if(e.getStatus()==TruckAppointmentStatus.COMPLETED||e.getStatus()==TruckAppointmentStatus.CANCELLED)throw conflict("当前状态不可改约"); String from=e.getStatus().name(); if(repo.reschedule(id,at,r.reason(),e.getVersion())!=1)throw conflict("状态或版本已变化,请刷新后重试");TruckAppointmentEntity after=required(id);record(after,from,"RESCHEDULED",action,r.reason(),uid,name);return completeOperation(id,action,idem,after);}
@@ -26,12 +26,9 @@ public class WarehouseSupplierSnapshotService {
return resolve(supplierId, suppliedName, "CUSTOMS");
}
/** 费用来源只提交供应商 ID,名称与类别均以 Warehouse 返回快照为准。 */
/** 费用来源只提交供应商 ID;类别为空时仅校验 ACTIVE,名称始终取 Warehouse 快照。 */
public WarehouseSupplierClient.Snapshot fee(Long supplierId, String category) {
if (!StringUtils.hasText(category)) {
throw new BizException(ResultCode.CONFLICT, "费用项目未配置供应商类别");
}
return resolve(supplierId, null, category);
return resolve(supplierId, null, FeeItemSupplierCategory.normalize(category));
}
private WarehouseSupplierClient.Snapshot resolve(Long supplierId, String suppliedName,
@@ -46,7 +43,7 @@ public class WarehouseSupplierSnapshotService {
if (response == null || !response.successful() || response.data() == null
|| response.data().supplierId() != supplierId
|| !"ACTIVE".equals(response.data().status())
|| !category.equals(response.data().category())
|| (StringUtils.hasText(category) && !category.equals(response.data().category()))
|| !StringUtils.hasText(response.data().name())) {
throw new BizException(ResultCode.CONFLICT, "所选供应商不存在、未启用或类别不匹配");
}
@@ -7,7 +7,7 @@ CREATE TABLE IF NOT EXISTS fl_fee_item (
category VARCHAR(64) NOT NULL COMMENT '费用分类',
trade_scope VARCHAR(20) NOT NULL COMMENT 'DOMESTIC国内、INTERNATIONAL国际',
fee_code VARCHAR(50) NOT NULL COMMENT '稳定费用编码;平台公共主数据固定编码',
supplier_category VARCHAR(16) NULL COMMENT 'PAYABLE供应商类别;未配置则失败关闭',
supplier_category VARCHAR(16) NULL COMMENT 'PAYABLE供应商类别约束;为空时可选任意ACTIVE供应商',
fee_type_name VARCHAR(128) NOT NULL COMMENT '费用类型名称',
pickup_payable TINYINT(1) NOT NULL DEFAULT 0 COMMENT '提柜应付是否参与',
pickup_receivable TINYINT(1) NOT NULL DEFAULT 0 COMMENT '提柜应收是否参与',
@@ -23,7 +23,7 @@ DELIMITER ;
CALL phase2_publish_add_column('fl_fee_item', 'fee_code',
"VARCHAR(50) NULL COMMENT '稳定费用编码;系统费用使用固定编码' AFTER trade_scope");
CALL phase2_publish_add_column('fl_fee_item', 'supplier_category',
"VARCHAR(16) NULL COMMENT 'PAYABLE供应商类别;未配置则失败关闭' AFTER fee_code");
"VARCHAR(16) NULL COMMENT 'PAYABLE供应商类别约束;为空时可选任意ACTIVE供应商' AFTER fee_code");
ALTER TABLE fl_fee_item
MODIFY COLUMN tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台公共费用项目作用域,固定为0;物理字段名沿用 tenant_id',
COMMENT = '平台公共费用项目主数据';
@@ -304,8 +304,6 @@ components:
currency: {type: string, pattern: '^[A-Z]{3}$'}
amount: {type: number, minimum: 0}
supplierId: {type: string, nullable: true}
supplierName: {type: string, maxLength: 200, nullable: true, readOnly: true}
supplierCategory: {type: string, enum: [BOOKING, CUSTOMS, TRUCKING, CERTIFICATE], nullable: true}
remark: {type: string, maxLength: 500, nullable: true}
InboundFeeDetailVersionRequest:
type: object
@@ -332,7 +330,7 @@ components:
direction: {type: string, enum: [RECEIVABLE, PAYABLE]}
supplierId: {type: string, nullable: true}
supplierName: {type: string, nullable: true, readOnly: true}
supplierCategory: {type: string, enum: [BOOKING, CUSTOMS, TRUCKING, CERTIFICATE], nullable: true}
supplierCategory: {type: string, enum: [BOOKING, CUSTOMS, TRUCKING, CERTIFICATE], nullable: true, description: 费用项目供应商类别约束快照;为空表示仅要求供应商ACTIVE}
businessDate: {type: string, format: date}
customerId: {type: string, nullable: true}
customerName: {type: string, nullable: true}
@@ -100,7 +100,7 @@ components:
category: {type: string, maxLength: 64}
tradeScope: {type: string, enum: [DOMESTIC, INTERNATIONAL]}
feeCode: {type: string, maxLength: 50, pattern: '^[A-Za-z0-9_-]+$'}
supplierCategory: {type: string, enum: [BOOKING, CUSTOMS, TRUCKING, CERTIFICATE], nullable: true, description: pickupPayable或warehousePayable为true时必填}
supplierCategory: {type: string, enum: [BOOKING, CUSTOMS, TRUCKING, CERTIFICATE], nullable: true, description: PAYABLE供应商类别约束;为空时可选任意ACTIVE Warehouse Supplier}
feeTypeName: {type: string, maxLength: 128}
pickupPayable: {type: boolean}
pickupReceivable: {type: boolean}
@@ -116,7 +116,7 @@ components:
category: {type: string}
tradeScope: {type: string, enum: [DOMESTIC, INTERNATIONAL]}
feeCode: {type: string}
supplierCategory: {type: string, enum: [BOOKING, CUSTOMS, TRUCKING, CERTIFICATE], nullable: true}
supplierCategory: {type: string, enum: [BOOKING, CUSTOMS, TRUCKING, CERTIFICATE], nullable: true, description: PAYABLE供应商类别约束;为空时可选任意ACTIVE Warehouse Supplier}
feeTypeName: {type: string}
pickupPayable: {type: boolean}
pickupReceivable: {type: boolean}
@@ -70,6 +70,30 @@ paths:
requireInboundCodePrefix: {type: boolean}
responses:
'200': {description: 成功时 data 返回 channelCode;requireInboundCodePrefix=true 时同时返回 inboundCodePrefix}
/internal/firstleg/v1/inbounds/{inboundId}/fees/{feeId}/source-snapshot:
get:
summary: Finance 按入库费用来源版本精确回查不可变快照
description: sourceVersion 必填;只返回 sourceFeeId 与该版本精确匹配的已发布费用,不回退到其他版本。
security:
- internalServiceToken: []
parameters:
- {name: inboundId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
- {name: feeId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
- {name: sourceVersion, in: query, required: true, schema: {type: integer, minimum: 1}}
responses:
'200': {description: 成功时 data 为 FinanceFeeSourceSnapshot;不存在精确版本时返回业务错误}
/internal/firstleg/v1/containers/{containerId}/fees/{feeId}/source-snapshot:
get:
summary: Finance 按柜次费用来源版本精确回查不可变快照
description: sourceVersion 必填;只返回 sourceFeeId 与该版本精确匹配的已发布费用,不回退到当前可变版本。
security:
- internalServiceToken: []
parameters:
- {name: containerId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
- {name: feeId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
- {name: sourceVersion, in: query, required: true, schema: {type: integer, minimum: 1}}
responses:
'200': {description: 成功时 data 为 FinanceFeeSourceSnapshot;不存在精确版本时返回业务错误}
/api/platform/v1/first-leg/customer-dimension-config:
get:
summary: 查询客户固定四箱规尺寸配置
@@ -1495,10 +1519,9 @@ components:
description: 单证聚合状态;DO/PU 无完成动作,因此只返回 MISSING 或 UPLOADED
ContainerSaveRequest:
type: object
required: [cabinetNo, warehouseId, warehouseName, containerType, capacityCbm, maxWeightKg]
required: [warehouseId, warehouseName, containerType, capacityCbm, maxWeightKg]
properties:
containerNo: {type: string, maxLength: 64, nullable: true}
cabinetNo: {type: string, maxLength: 64}
warehouseId: {type: string, pattern: '^[1-9][0-9]*$'}
warehouseName: {type: string, maxLength: 128}
billOfLadingNo: {type: string, maxLength: 128, nullable: true}
@@ -1820,3 +1843,39 @@ components:
expectedSourceVersion: {type: integer, minimum: 1}
businessDate: {type: string, format: date}
amount: {type: number, minimum: 0}
FinanceFeeSourceSnapshot:
type: object
description: Finance 消费的版本化费用来源快照;supplierCategory 为空表示仅约束供应商 ACTIVE,非空才约束明确类别。
required: [tenantId, sourceType, feeId, feeVersion, customerId, customerName, feeItemName, direction, businessDate, currency, totalAmount, reviewStatus, publicationStatus, sourceHash, snapshotVersion]
properties:
tenantId: {type: integer, format: int64}
sourceType: {type: string, enum: [INBOUND_FEE, CONTAINER_FEE]}
feeId: {type: integer, format: int64}
feeVersion: {type: integer, minimum: 1}
inboundId: {type: integer, format: int64, nullable: true}
inboundCode: {type: string, nullable: true}
orderId: {type: integer, format: int64, nullable: true}
containerId: {type: integer, format: int64, nullable: true}
containerBookingId: {type: integer, format: int64, nullable: true}
containerNo: {type: string, nullable: true}
customerId: {type: integer, format: int64}
customerName: {type: string}
supplierId: {type: integer, format: int64, nullable: true}
supplierName: {type: string, nullable: true}
supplierCategory: {type: string, enum: [BOOKING, CUSTOMS, TRUCKING, CERTIFICATE], nullable: true, description: PAYABLE供应商类别约束;为空时仅要求供应商ACTIVE}
feeItemId: {type: integer, format: int64, nullable: true}
feeItemCode: {type: string, nullable: true}
feeItemName: {type: string}
direction: {type: string, enum: [RECEIVABLE, PAYABLE]}
businessDate: {type: string, format: date}
currency: {type: string, enum: [CNY]}
totalAmount: {type: number, minimum: 0}
reviewStatus: {type: string, enum: [APPROVED]}
publicationStatus: {type: string, enum: [PUBLISHED]}
publishedAt: {type: string, format: date-time, nullable: true}
chargeableQuantity: {type: number, nullable: true}
chargeUnit: {type: string, nullable: true}
actualGrossWeightKg: {type: number, nullable: true}
actualVolumeCbm: {type: number, nullable: true}
sourceHash: {type: string}
snapshotVersion: {type: integer, minimum: 1}
@@ -29,7 +29,7 @@ paths:
application/json:
schema: {$ref: '#/components/schemas/SaveRequest'}
responses:
'200': {description: Created appointment, content: {application/json: {schema: {$ref: '#/components/schemas/DetailStableReferences'}}}}
'200': {description: Created appointment, content: {application/json: {schema: {$ref: '#/components/schemas/DetailEnvelope'}}}}
/api/platform/v1/truck-appointments/{id}:
parameters: [{$ref: '#/components/parameters/AppointmentId'}]
get:
@@ -138,6 +138,18 @@ components:
IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}}
RequestHash: {name: X-Request-Hash, in: header, required: false, schema: {type: string, pattern: '^[0-9a-fA-F]{64}$'}}
schemas:
DetailEnvelope:
type: object
required: [code, message, data, timestamp]
properties:
code: {type: string, example: '200'}
message: {type: string}
data:
allOf: [{$ref: '#/components/schemas/DetailStableReferences'}]
nullable: true
requestId: {type: string, nullable: true}
traceId: {type: string, nullable: true}
timestamp: {type: string, format: date-time}
AttachmentRequest:
type: object
required: [fileId, attachmentType]