优化
This commit is contained in:
+34
-16
@@ -9,6 +9,21 @@ import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface DispatchCandidateMapper {
|
||||
/** 自动预留托盘不是实收;尚未开始收货时仍以计划来源预排,不生成库存或实托装载事实。 */
|
||||
String PLAN_ONLY = "(p.warehouse_pallet_id IS NULL OR (COALESCE(p.assignment_status,'')='RESERVED' AND COALESCE(p.actual_package_count,0)=0))";
|
||||
/** 在途计划的全部真实来源必须已离港;人工节点覆盖后不再借用原柜节点。 */
|
||||
String DEPARTED_PLAN =
|
||||
"EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation da JOIN ll_inbound_plan_line dl " +
|
||||
"ON dl.id=da.line_id WHERE da.pallet_id=p.id AND dl.customer_id=ip.customer_id) AND NOT EXISTS (" +
|
||||
"SELECT 1 FROM ll_inbound_plan_pallet_allocation da JOIN ll_inbound_plan_line dl " +
|
||||
"ON dl.id=da.line_id LEFT JOIN ll_order departure_order " +
|
||||
"ON departure_order.id=dl.order_id AND departure_order.customer_id=dl.customer_id WHERE da.pallet_id=p.id AND dl.customer_id=ip.customer_id AND " +
|
||||
"(departure_order.id IS NULL OR COALESCE(departure_order.order_type,'')!='FIRST_LEG' OR NOT (" +
|
||||
"CASE WHEN COALESCE(departure_order.manual_node_override,FALSE)=TRUE THEN EXISTS (" +
|
||||
"SELECT 1 FROM ll_order_manual_node dn WHERE dn.order_id=departure_order.id AND dn.customer_id=departure_order.customer_id " +
|
||||
"AND dn.node_code='ACTUAL_DEPARTURE' AND dn.node_at IS NOT NULL) ELSE EXISTS (" +
|
||||
"SELECT 1 FROM ll_container_shipping_gate dg WHERE dg.container_booking_id=departure_order.source_container_booking_id " +
|
||||
"AND dg.node_sequence>=40 AND dg.actual_at IS NOT NULL) END)))";
|
||||
// Only the receiving party's confirmed appointment is a transport reference; it is not a loading plan.
|
||||
String CONFIRMED_APPOINTMENT_AT =
|
||||
"(SELECT CASE WHEN COUNT(DISTINCT fa.id)=1 THEN MAX(fa.appointment_at) END FROM ll_inbound_plan_pallet_allocation pa " +
|
||||
@@ -41,7 +56,7 @@ public interface DispatchCandidateMapper {
|
||||
List<String> findCustomerScopesByPlanPalletIds(@Param("planPalletIds") List<Long> planPalletIds);
|
||||
|
||||
String AVAILABLE_PLAN_BODY =
|
||||
"SELECT p.plan_id AS planId,p.id AS planPalletId,p.warehouse_pallet_id AS warehousePalletId," +
|
||||
"SELECT p.plan_id AS planId,p.id AS planPalletId,CASE WHEN " + PLAN_ONLY + " THEN NULL ELSE p.warehouse_pallet_id END AS warehousePalletId," +
|
||||
"MIN(l.order_id) AS orderId," +
|
||||
"MIN(ip.customer_id) AS customerId,MIN(o.customer_name) AS customerName," +
|
||||
"MIN(ip.plan_no) AS planNo,MIN(o.order_type) AS orderType,MIN(o.volume_weight_kg) AS volumeWeightKg," +
|
||||
@@ -53,16 +68,16 @@ public interface DispatchCandidateMapper {
|
||||
"MIN(ip.container_no) AS containerNo," +
|
||||
CONFIRMED_APPOINTMENT_AT + " AS appointmentAt,NULL AS expectedLoadingAt," +
|
||||
// 仓ID文本统一保留128字符,避免混合CASE的NONE排序规则及无长度CHAR截断;来源优先级与身份等值不变。
|
||||
"CAST(CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(pos.overseas_warehouse_id) " +
|
||||
"CAST(CASE WHEN NOT " + PLAN_ONLY + " THEN (SELECT MIN(pos.overseas_warehouse_id) " +
|
||||
"FROM ll_inventory_position pos WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id " +
|
||||
"AND pos.available_packages > 0) ELSE COALESCE(NULLIF(CAST(ip.overseas_warehouse_id AS CHAR(128)),'')," +
|
||||
"(SELECT NULLIF(TRIM(source_order.overseas_warehouse_id),'') FROM ll_order source_order " +
|
||||
"WHERE source_order.id=ip.source_order_id AND source_order.customer_id=ip.customer_id LIMIT 1)) END AS CHAR(128)) AS overseasWarehouseId," +
|
||||
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(b.overseas_warehouse_code) " +
|
||||
"CASE WHEN NOT " + PLAN_ONLY + " THEN (SELECT MIN(b.overseas_warehouse_code) " +
|
||||
"FROM ll_inventory_position pos JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=pos.customer_id " +
|
||||
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0) " +
|
||||
"ELSE MIN(o.overseas_warehouse_code) END AS overseasWarehouseCode," +
|
||||
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(b.overseas_warehouse_name) " +
|
||||
"CASE WHEN NOT " + PLAN_ONLY + " THEN (SELECT MIN(b.overseas_warehouse_name) " +
|
||||
"FROM ll_inventory_position pos JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=pos.customer_id " +
|
||||
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0) " +
|
||||
"ELSE MIN(o.overseas_warehouse_name) END AS overseasWarehouseName,MIN(o.order_no) AS orderNo," +
|
||||
@@ -70,11 +85,11 @@ public interface DispatchCandidateMapper {
|
||||
"p.pallet_no AS palletNo,p.destination_warehouse_id AS destinationWarehouseId," +
|
||||
"p.destination_warehouse_code AS destinationWarehouseCode,p.destination_warehouse_name AS destinationWarehouseName," +
|
||||
"COALESCE(NULLIF(MIN(l.fba_warehouse_code),''),NULLIF(p.destination_warehouse_code,'')) AS fbaWarehouseCode," +
|
||||
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN COALESCE((SELECT SUM(pos.available_packages) " +
|
||||
"CASE WHEN NOT " + PLAN_ONLY + " THEN COALESCE((SELECT SUM(pos.available_packages) " +
|
||||
"FROM ll_inventory_position pos WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id),0) " +
|
||||
"ELSE COALESCE(SUM(CASE WHEN a.picked_package_count > 0 THEN a.picked_package_count " +
|
||||
"ELSE a.planned_package_count END),0) END AS packageCount,p.allocated_volume_cbm AS volumeCbm," +
|
||||
"CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END AS sourceStage," +
|
||||
"CASE WHEN " + PLAN_ONLY + " THEN 'PLAN' ELSE 'RECEIVED' END AS sourceStage," +
|
||||
"(SELECT GROUP_CONCAT(DISTINCT c.carton_mark ORDER BY c.carton_mark SEPARATOR ',') " +
|
||||
"FROM ll_inbound_plan_carton c WHERE c.plan_pallet_id=p.id AND NOT EXISTS (" +
|
||||
"SELECT 1 FROM ll_relabel_item ri JOIN ll_inventory_position rp ON rp.id=ri.source_inventory_position_id " +
|
||||
@@ -108,12 +123,13 @@ public interface DispatchCandidateMapper {
|
||||
"<if test='orderNo != null and orderNo != \"\"'> AND (o.order_no LIKE CONCAT('%',#{orderNo},'%') OR l.inbound_code LIKE CONCAT('%',#{orderNo},'%'))</if> " +
|
||||
"<if test='fbaWarehouseCode != null and fbaWarehouseCode != \"\"'> AND l.fba_warehouse_code=#{fbaWarehouseCode}</if> " +
|
||||
"<if test='sourceStage != null and sourceStage != \"\"'> AND " +
|
||||
"CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END=#{sourceStage}</if> " +
|
||||
"CASE WHEN " + PLAN_ONLY + " THEN 'PLAN' ELSE 'RECEIVED' END=#{sourceStage}</if> " +
|
||||
"AND NOT EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation ha " +
|
||||
"JOIN ll_inbound_plan_line hl ON hl.id=ha.line_id " +
|
||||
"JOIN ll_order ho ON ho.id=hl.order_id AND ho.customer_id=ip.customer_id " +
|
||||
"WHERE ha.pallet_id=p.id AND ho.detained=1) " +
|
||||
"AND (p.warehouse_pallet_id IS NULL OR EXISTS (SELECT 1 FROM ll_inventory_position pos " +
|
||||
"AND (NOT " + PLAN_ONLY + " OR (" + DEPARTED_PLAN + ")) " +
|
||||
"AND (" + PLAN_ONLY + " OR EXISTS (SELECT 1 FROM ll_inventory_position pos " +
|
||||
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0)) " +
|
||||
"AND NOT EXISTS (SELECT 1 FROM ll_dispatch_pallet_lock d " +
|
||||
"WHERE d.customer_id=ip.customer_id AND d.status='ACTIVE' " +
|
||||
@@ -150,7 +166,7 @@ public interface DispatchCandidateMapper {
|
||||
@Param("fbaWarehouseCode") String fbaWarehouseCode,@Param("sourceStage") String sourceStage,@Param("draftToken") String draftToken);
|
||||
|
||||
String SELECTED_PLAN_SQL = "<script>" +
|
||||
"SELECT p.plan_id AS planId,p.id AS planPalletId,p.warehouse_pallet_id AS warehousePalletId," +
|
||||
"SELECT p.plan_id AS planId,p.id AS planPalletId,CASE WHEN " + PLAN_ONLY + " THEN NULL ELSE p.warehouse_pallet_id END AS warehousePalletId," +
|
||||
"MIN(l.order_id) AS orderId," +
|
||||
"MIN(ip.customer_id) AS customerId,MIN(o.customer_name) AS customerName," +
|
||||
"MIN(ip.plan_no) AS planNo,MIN(o.order_type) AS orderType,MIN(o.volume_weight_kg) AS volumeWeightKg," +
|
||||
@@ -161,16 +177,16 @@ public interface DispatchCandidateMapper {
|
||||
" / NULLIF((SELECT COUNT(*) FROM ll_inbound_plan_pallet_allocation a2 WHERE a2.line_id=a.line_id),0) END),0) ELSE NULL END AS grossWeightKg," +
|
||||
"MIN(ip.container_no) AS containerNo," +
|
||||
CONFIRMED_APPOINTMENT_AT + " AS appointmentAt,NULL AS expectedLoadingAt," +
|
||||
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(pos.overseas_warehouse_id) " +
|
||||
"CASE WHEN NOT " + PLAN_ONLY + " THEN (SELECT MIN(pos.overseas_warehouse_id) " +
|
||||
"FROM ll_inventory_position pos WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id " +
|
||||
"AND pos.available_packages > 0) ELSE COALESCE(NULLIF(CAST(ip.overseas_warehouse_id AS CHAR),'')," +
|
||||
"(SELECT NULLIF(TRIM(source_order.overseas_warehouse_id),'') FROM ll_order source_order " +
|
||||
"WHERE source_order.id=ip.source_order_id AND source_order.customer_id=ip.customer_id LIMIT 1)) END AS overseasWarehouseId," +
|
||||
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(b.overseas_warehouse_code) " +
|
||||
"CASE WHEN NOT " + PLAN_ONLY + " THEN (SELECT MIN(b.overseas_warehouse_code) " +
|
||||
"FROM ll_inventory_position pos JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=pos.customer_id " +
|
||||
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0) " +
|
||||
"ELSE MIN(o.overseas_warehouse_code) END AS overseasWarehouseCode," +
|
||||
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(b.overseas_warehouse_name) " +
|
||||
"CASE WHEN NOT " + PLAN_ONLY + " THEN (SELECT MIN(b.overseas_warehouse_name) " +
|
||||
"FROM ll_inventory_position pos JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=pos.customer_id " +
|
||||
"WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0) " +
|
||||
"ELSE MIN(o.overseas_warehouse_name) END AS overseasWarehouseName,MIN(o.order_no) AS orderNo," +
|
||||
@@ -178,11 +194,11 @@ public interface DispatchCandidateMapper {
|
||||
"p.pallet_no AS palletNo,p.destination_warehouse_id AS destinationWarehouseId," +
|
||||
"p.destination_warehouse_code AS destinationWarehouseCode,p.destination_warehouse_name AS destinationWarehouseName," +
|
||||
"COALESCE(NULLIF(MIN(l.fba_warehouse_code),''),NULLIF(p.destination_warehouse_code,'')) AS fbaWarehouseCode," +
|
||||
"CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN COALESCE((SELECT SUM(pos.available_packages) " +
|
||||
"CASE WHEN NOT " + PLAN_ONLY + " THEN COALESCE((SELECT SUM(pos.available_packages) " +
|
||||
"FROM ll_inventory_position pos WHERE pos.customer_id=#{customerId} AND pos.pallet_id=p.warehouse_pallet_id),0) " +
|
||||
"ELSE COALESCE(SUM(CASE WHEN a.picked_package_count > 0 THEN a.picked_package_count " +
|
||||
"ELSE a.planned_package_count END),0) END AS packageCount,p.allocated_volume_cbm AS volumeCbm," +
|
||||
"CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END AS sourceStage," +
|
||||
"CASE WHEN " + PLAN_ONLY + " THEN 'PLAN' ELSE 'RECEIVED' END AS sourceStage," +
|
||||
"(SELECT GROUP_CONCAT(DISTINCT c.carton_mark ORDER BY c.carton_mark SEPARATOR ',') " +
|
||||
"FROM ll_inbound_plan_carton c WHERE c.plan_pallet_id=p.id AND NOT EXISTS (" +
|
||||
"SELECT 1 FROM ll_relabel_item ri JOIN ll_inventory_position rp ON rp.id=ri.source_inventory_position_id " +
|
||||
@@ -216,7 +232,8 @@ public interface DispatchCandidateMapper {
|
||||
"JOIN ll_inbound_plan_line hl ON hl.id=ha.line_id " +
|
||||
"JOIN ll_order ho ON ho.id=hl.order_id AND ho.customer_id=ip.customer_id " +
|
||||
"WHERE ha.pallet_id=p.id AND ho.detained=1) " +
|
||||
"AND (p.warehouse_pallet_id IS NULL OR EXISTS (SELECT 1 FROM ll_inventory_position pos " +
|
||||
"AND (NOT " + PLAN_ONLY + " OR (" + DEPARTED_PLAN + ")) " +
|
||||
"AND (" + PLAN_ONLY + " OR EXISTS (SELECT 1 FROM ll_inventory_position pos " +
|
||||
"WHERE pos.customer_id=#{customerId} AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0)) " +
|
||||
"AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " +
|
||||
"JOIN ll_inventory_position tp ON tp.id=ta.inventory_position_id " +
|
||||
@@ -261,7 +278,8 @@ public interface DispatchCandidateMapper {
|
||||
"<if test='customerId != null and customerId != \"\"'>AND a.customer_id=#{customerId} </if>" +
|
||||
"<if test='relabelNo != null and relabelNo != \"\"'>AND a.relabel_no=#{relabelNo} </if>" +
|
||||
"<if test='orderNo != null and orderNo != \"\"'>AND (a.relabel_no LIKE CONCAT('%',#{orderNo},'%') OR ri.source_inbound_code LIKE CONCAT('%',#{orderNo},'%') OR ri.source_inbound_order_no LIKE CONCAT('%',#{orderNo},'%')) </if>" +
|
||||
"AND pos.hold_packages > 0 " +
|
||||
// 未绑定实托的位置不是可选运输来源;完整申请中的缺失组成仍由提供者返回阻断原因。
|
||||
"AND pos.hold_packages > 0 AND pos.pallet_id > 0 " +
|
||||
"AND NOT EXISTS (SELECT 1 FROM ll_dispatch_item di JOIN ll_dispatch_order existing_dispatch " +
|
||||
"ON existing_dispatch.id=di.dispatch_order_id " +
|
||||
"WHERE di.customer_id=a.customer_id AND di.source_stage='RELABEL' AND di.order_refs=a.relabel_no " +
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package com.baiyue.lastleg.dispatch.mapper;
|
||||
|
||||
import org.apache.ibatis.annotations.Insert;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Options;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import java.time.LocalDate;
|
||||
|
||||
/** 排车日期流水的原子分配与连接级结果读取,必须加入同一个短事务。 */
|
||||
@Mapper
|
||||
public interface DispatchNumberSequenceMapper {
|
||||
/** 日期主键串行递增,同时在当前数据库连接记住本次实际分配值。 */
|
||||
@Insert("INSERT INTO ll_dispatch_daily_sequence(business_date,last_sequence) VALUES(#{date},LAST_INSERT_ID(1)) "
|
||||
+ "ON DUPLICATE KEY UPDATE last_sequence=LAST_INSERT_ID(last_sequence+1)")
|
||||
int allocate(@Param("date") LocalDate date);
|
||||
|
||||
/** 连接级值不能使用会话或二级查询缓存,须读取本事务刚刚分配的值。 */
|
||||
@Select("SELECT LAST_INSERT_ID()")
|
||||
@Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE)
|
||||
Long allocatedValue();
|
||||
}
|
||||
+23
@@ -11,6 +11,8 @@ public interface DispatchSourceMapper {
|
||||
long allocatedPackages, long pickedPackages, long version) { }
|
||||
record Receipt(long id, long orderId, long inventoryPositionId, long packages, String inboundCode) { }
|
||||
record ReturnedReceipt(long id,long orderId,long inventoryPositionId,long packages) { }
|
||||
record PalletReceipt(long palletId,long id,long orderId,long inventoryPositionId,long packages,String inboundCode) { }
|
||||
record PalletReturnedReceipt(long palletId,long id,long orderId,long inventoryPositionId,long packages) { }
|
||||
record Claim(Long id, Long dispatchId, String draftToken) { }
|
||||
record RelabelBasis(String status,String fulfillmentStatus,LocalDateTime updatedAt,LocalDateTime customerConfirmedAt) { }
|
||||
record NativeApplication(String customerId,String relabelNo) { }
|
||||
@@ -112,6 +114,27 @@ public interface DispatchSourceMapper {
|
||||
@Select("SELECT r.id,r.order_id AS orderId,r.inventory_position_id AS inventoryPositionId,r.received_packages AS packages FROM ll_return_stock_receipt r JOIN ll_inventory_position p ON p.id=r.inventory_position_id AND p.customer_id=r.customer_id WHERE r.customer_id=#{customer} AND p.pallet_id=#{pallet} ORDER BY r.id")
|
||||
List<ReturnedReceipt> returnedReceipts(@Param("customer")String customer,@Param("pallet")Long pallet);
|
||||
|
||||
/** 同客户整页实托一次读齐库存版本,仍保留原仓身份供服务校验。 */
|
||||
@Select("<script>SELECT id,balance_id AS balanceId,pallet_id AS palletId,overseas_warehouse_id AS warehouseId,"
|
||||
+ "available_packages AS availablePackages,hold_packages AS holdPackages,allocated_packages AS allocatedPackages,"
|
||||
+ "picked_packages AS pickedPackages,version FROM ll_inventory_position WHERE customer_id=#{customer} AND pallet_id IN "
|
||||
+ "<foreach collection='pallets' item='pallet' open='(' separator=',' close=')'>#{pallet}</foreach> ORDER BY id</script>")
|
||||
@Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE)
|
||||
List<Stock> stocks(@Param("customer")String customer,@Param("pallets")List<Long> pallets);
|
||||
|
||||
/** 批读保留托盘身份和原实收顺序,拆分后与单托版本依据完全相同。 */
|
||||
@Select("<script>SELECT warehouse_pallet_id AS palletId,id,order_id AS orderId,inventory_position_id AS inventoryPositionId,"
|
||||
+ "received_packages AS packages,inbound_code AS inboundCode FROM ll_inbound_stock_receipt WHERE customer_id=#{customer} AND warehouse_pallet_id IN "
|
||||
+ "<foreach collection='pallets' item='pallet' open='(' separator=',' close=')'>#{pallet}</foreach> ORDER BY id</script>")
|
||||
List<PalletReceipt> palletReceipts(@Param("customer")String customer,@Param("pallets")List<Long> pallets);
|
||||
|
||||
/** 退回实收按客户与当前实托位置批读,不混入另一客户的同托记录。 */
|
||||
@Select("<script>SELECT p.pallet_id AS palletId,r.id,r.order_id AS orderId,r.inventory_position_id AS inventoryPositionId,"
|
||||
+ "r.received_packages AS packages FROM ll_return_stock_receipt r JOIN ll_inventory_position p "
|
||||
+ "ON p.id=r.inventory_position_id AND p.customer_id=r.customer_id WHERE r.customer_id=#{customer} AND p.pallet_id IN "
|
||||
+ "<foreach collection='pallets' item='pallet' open='(' separator=',' close=')'>#{pallet}</foreach> ORDER BY r.id</script>")
|
||||
List<PalletReturnedReceipt> palletReturnedReceipts(@Param("customer")String customer,@Param("pallets")List<Long> pallets);
|
||||
|
||||
/** 只使本客户本完整来源键的到期草稿成员失效,物理键由独立物理层管理。 */
|
||||
@Update("UPDATE ll_dispatch_pallet_lock SET status='EXPIRED',active_key=NULL,updated_at=#{at} "
|
||||
+ "WHERE status='ACTIVE' AND expires_at IS NOT NULL AND expires_at<=#{at} AND customer_id=#{customer} AND source_pallet_key=#{key}")
|
||||
|
||||
+6
-6
@@ -2,7 +2,7 @@ package com.baiyue.lastleg.dispatch.repository;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchNumberSequenceMapper;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -11,17 +11,17 @@ import java.time.LocalDate;
|
||||
/** 整车单号独立日流水;独立短事务归还序列锁后才进入车辆、司机及货物锁。 */
|
||||
@Repository
|
||||
public class DispatchNumberSequence {
|
||||
private final JdbcTemplate jdbc;
|
||||
private final DispatchNumberSequenceMapper mapper;
|
||||
|
||||
/** 流水与排车主单使用相同服务数据库。 */
|
||||
public DispatchNumberSequence(JdbcTemplate jdbc) { this.jdbc = jdbc; }
|
||||
public DispatchNumberSequence(DispatchNumberSequenceMapper mapper) { this.mapper = mapper; }
|
||||
|
||||
/** 唯一日期行原子递增,连接内取回本次值;失败不复用已分配的业务号。 */
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public long next(LocalDate date) {
|
||||
jdbc.update("INSERT INTO ll_dispatch_daily_sequence(business_date,last_sequence) VALUES(?,LAST_INSERT_ID(1)) "
|
||||
+ "ON DUPLICATE KEY UPDATE last_sequence=LAST_INSERT_ID(last_sequence+1)", date);
|
||||
Long value = jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class);
|
||||
// 两个实际 Mapper 操作加入本次 REQUIRES_NEW,连接级结果不跨连接也不读缓存。
|
||||
mapper.allocate(date);
|
||||
Long value = mapper.allocatedValue();
|
||||
if (value == null || value < 1) throw new IllegalStateException("排车日流水分配失败");
|
||||
if (value > 9999) throw new BizException(ResultCode.CONFLICT, "当日排车单四位流水号已用满");
|
||||
return value;
|
||||
|
||||
+18
@@ -75,6 +75,24 @@ public class DispatchSourceRepository {
|
||||
public List<DispatchSourceMapper.Receipt> receipts(String customer, Long pallet) { return source.receipts(customer,pallet); }
|
||||
/** 同一实托的退回实收不得从来源证明丢失。 */
|
||||
public List<DispatchSourceMapper.ReturnedReceipt> returnedReceipts(String customer,Long pallet){return source.returnedReceipts(customer,pallet);}
|
||||
/** 一条来源的库存与实收版本依据;无实托的在途计划三项均为空。 */
|
||||
public record SourceFacts(List<DispatchSourceMapper.Stock> stock,List<DispatchSourceMapper.Receipt> receipts,
|
||||
List<DispatchSourceMapper.ReturnedReceipt> returnedReceipts) { }
|
||||
/** 同客户的候选托盘只用三次查询;拆分顺序与原单托查询一致,不跨请求缓存。 */
|
||||
public java.util.Map<Long,SourceFacts> sourceFacts(String customer,List<Long> pallets) {
|
||||
if(pallets.isEmpty())return java.util.Map.of();
|
||||
var stocks=source.stocks(customer,pallets).stream().collect(java.util.stream.Collectors.groupingBy(DispatchSourceMapper.Stock::palletId));
|
||||
var receipts=source.palletReceipts(customer,pallets).stream().collect(java.util.stream.Collectors.groupingBy(
|
||||
DispatchSourceMapper.PalletReceipt::palletId,java.util.stream.Collectors.mapping(
|
||||
row->new DispatchSourceMapper.Receipt(row.id(),row.orderId(),row.inventoryPositionId(),row.packages(),row.inboundCode()),java.util.stream.Collectors.toList())));
|
||||
var returned=source.palletReturnedReceipts(customer,pallets).stream().collect(java.util.stream.Collectors.groupingBy(
|
||||
DispatchSourceMapper.PalletReturnedReceipt::palletId,java.util.stream.Collectors.mapping(
|
||||
row->new DispatchSourceMapper.ReturnedReceipt(row.id(),row.orderId(),row.inventoryPositionId(),row.packages()),java.util.stream.Collectors.toList())));
|
||||
var result=new java.util.LinkedHashMap<Long,SourceFacts>();
|
||||
for(var pallet:pallets)result.put(pallet,new SourceFacts(List.copyOf(stocks.getOrDefault(pallet,List.of())),
|
||||
List.copyOf(receipts.getOrDefault(pallet,List.of())),List.copyOf(returned.getOrDefault(pallet,List.of()))));
|
||||
return java.util.Collections.unmodifiableMap(result);
|
||||
}
|
||||
/** 释放已到期草稿键。 */
|
||||
public int expire(String customer, String key, LocalDateTime at) { return source.expire(customer,key,at); }
|
||||
/** 锁定当前活动资源占用。 */
|
||||
|
||||
+11
@@ -32,6 +32,7 @@ import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
|
||||
/** 真整车创建、加货与查询;客户只属于明细,不属于整车头。 */
|
||||
@Service
|
||||
public class DispatchAggregateService implements DispatchExecutionPort,DispatchAppointmentAmendmentPort {
|
||||
private static final org.slf4j.Logger LOG=org.slf4j.LoggerFactory.getLogger(DispatchAggregateService.class);
|
||||
@jakarta.annotation.Resource private DispatchNumberGenerator numbers;
|
||||
@jakarta.annotation.Resource private DispatchAppointmentAllocations appointments;
|
||||
@jakarta.annotation.Resource private DispatchPhysicalClaimService physicalClaims;
|
||||
@@ -149,9 +150,13 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
if(query.planIds()!=null && (query.planIds().size()>500 || query.planIds().stream().anyMatch(id->id==null || id<=0))) throw bad("计划筛选不合法");
|
||||
var normalized=new DispatchPlanningModels.CandidateQuery(query.warehouseId(),query.planIds(),trim(query.orderNo()),
|
||||
trim(query.destinationWarehouseCode()),trim(query.sourceStage()),trim(query.draftToken()));
|
||||
long started=System.nanoTime();
|
||||
var rows=sourceRules.wholeCandidates(actor.operatorId(),normalized);
|
||||
long candidatesRead=System.nanoTime();
|
||||
var groups=sourceRules.selectionGroups(rows);
|
||||
long groupsRead=System.nanoTime();
|
||||
var batchFacts=appointments.candidates(rows);
|
||||
long batchesRead=System.nanoTime();
|
||||
var result=new ArrayList<DispatchPlanningModels.Candidate>();
|
||||
for(var row:rows) {
|
||||
SourceType type=Set.of("RELABEL","RETURN_RELABEL").contains(Objects.toString(row.sourceStage(),""))?SourceType.RELABEL:
|
||||
@@ -181,6 +186,12 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
|
||||
row.overseasWarehouseCode(),row.overseasWarehouseName(),row.orderNo(),row.inboundCode(),row.planNo(),row.palletNo(),row.containerNo(),
|
||||
destination,row.packageCount()==null?0:row.packageCount(),row.grossWeightKg(),row.volumeCbm(),"SOURCE_SUGGESTION",List.copyOf(references),List.copyOf(reasons),group));
|
||||
}
|
||||
long finished=System.nanoTime();
|
||||
// 慢查询只记录阶段耗时和条数,不记录客户、订单、仓库或草稿凭据。
|
||||
if(finished-started>=1_000_000_000L)LOG.warn(
|
||||
"dispatch_candidates_slow count={} candidatesMs={} groupsMs={} appointmentsMs={} assembleMs={} totalMs={}",
|
||||
rows.size(),(candidatesRead-started)/1_000_000L,(groupsRead-candidatesRead)/1_000_000L,
|
||||
(batchesRead-groupsRead)/1_000_000L,(finished-batchesRead)/1_000_000L,(finished-started)/1_000_000L);
|
||||
return new DispatchPlanningModels.CandidateList(List.copyOf(result),Instant.now().truncatedTo(ChronoUnit.MILLIS),true);
|
||||
}
|
||||
|
||||
|
||||
+69
-8
@@ -4,6 +4,7 @@ import com.baiyue.lastleg.dispatch.entity.DispatchItem;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchCandidateMapper;
|
||||
import com.baiyue.lastleg.dispatch.mapper.DispatchDraftMapper;
|
||||
import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
|
||||
import com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository.SourceFacts;
|
||||
import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
@@ -34,16 +35,64 @@ public class DispatchService {
|
||||
|
||||
/** 同一完整组只解析一次,隐藏的真实成员也随服务器组返回。 */
|
||||
public Map<String,SelectionGroup> selectionGroups(List<DispatchPalletCandidate> rows) {
|
||||
var reads=new CandidateReadScope(rows);
|
||||
var result=new LinkedHashMap<String,SelectionGroup>();
|
||||
for(var row:rows) {
|
||||
String key=wholeSourceKey(selection(row,null));
|
||||
if(result.containsKey(key))continue;
|
||||
var group=sourceGroup(row,false);
|
||||
var group=sourceGroup(row,false,reads);
|
||||
for(var source:group.view().sources())result.put(source.sourceKey(),group.view());
|
||||
}
|
||||
return Collections.unmodifiableMap(result);
|
||||
}
|
||||
|
||||
/** 仅当前候选事务内批读普通来源;写入和锁内校验继续逐组读取当前事实。 */
|
||||
private final class CandidateReadScope {
|
||||
private final Map<String,Set<Long>> loaded=new HashMap<>();
|
||||
private final Map<String,Map<Long,List<DispatchPalletCandidate>>> available=new HashMap<>();
|
||||
private final Map<String,Map<Long,SourceFacts>> sourceFacts=new HashMap<>();
|
||||
|
||||
/** 同页同仓托盘一次读取,避免每一行再次执行完整计划和退回来源投影。 */
|
||||
private CandidateReadScope(List<DispatchPalletCandidate> rows) {
|
||||
var pallets=new TreeMap<String,Set<Long>>();
|
||||
for(var row:rows)if(row.warehousePalletId()!=null)
|
||||
pallets.computeIfAbsent(row.overseasWarehouseId(),key->new TreeSet<>()).add(row.warehousePalletId());
|
||||
pallets.forEach((warehouse,ids)->read(warehouse,List.copyOf(ids)));
|
||||
loadFacts(rows);
|
||||
}
|
||||
|
||||
/** 换标闭包发现页外实托时补读缺失集合,不拿筛选后的页面充当完整组成。 */
|
||||
private List<DispatchPalletCandidate> read(String warehouse,List<Long> pallets) {
|
||||
var known=loaded.computeIfAbsent(warehouse,key->new HashSet<>());
|
||||
var byPallet=available.computeIfAbsent(warehouse,key->new HashMap<>());
|
||||
var missing=pallets.stream().filter(id->!known.contains(id)).distinct().sorted().toList();
|
||||
if(!missing.isEmpty()) {
|
||||
var rows=wholeSources.physicalAvailable(warehouse,missing);
|
||||
for(var row:rows)
|
||||
byPallet.computeIfAbsent(row.warehousePalletId(),key->new ArrayList<>()).add(row);
|
||||
loadFacts(rows);
|
||||
known.addAll(missing);
|
||||
}
|
||||
return pallets.stream().flatMap(id->byPallet.getOrDefault(id,List.of()).stream()).toList();
|
||||
}
|
||||
|
||||
/** 只补齐本请求尚未读取的客户实托,隐藏组成员也必须有完整版本依据。 */
|
||||
private void loadFacts(Collection<DispatchPalletCandidate> rows) {
|
||||
var missing=new TreeMap<String,Set<Long>>();
|
||||
for(var row:rows)if(row.warehousePalletId()!=null
|
||||
&& !sourceFacts.getOrDefault(row.customerId(),Map.of()).containsKey(row.warehousePalletId()))
|
||||
missing.computeIfAbsent(row.customerId(),key->new TreeSet<>()).add(row.warehousePalletId());
|
||||
missing.forEach((customer,pallets)->sourceFacts.computeIfAbsent(customer,key->new HashMap<>())
|
||||
.putAll(wholeSources.sourceFacts(customer,List.copyOf(pallets))));
|
||||
}
|
||||
|
||||
/** 在途计划没有库存身份,不向数据库发出三个必为空的实收查询。 */
|
||||
private SourceFacts facts(DispatchPalletCandidate row) {
|
||||
return row.warehousePalletId()==null?new SourceFacts(List.of(),List.of(),List.of()):
|
||||
Objects.requireNonNull(sourceFacts.getOrDefault(row.customerId(),Map.of()).get(row.warehousePalletId()),"实托版本依据缺失");
|
||||
}
|
||||
}
|
||||
|
||||
/** 草稿单次操作先从实际入口解析完整组,不让前端循环单来源凑整托。 */
|
||||
public SelectionGroup selectionGroup(String warehouse,DispatchCommands.CargoSelection requested) {
|
||||
var row=readSelection(warehouse,requested);return sourceGroup(row,false).view();
|
||||
@@ -78,6 +127,11 @@ public class DispatchService {
|
||||
|
||||
/** 实托→申请→整申请实托求固定点;未证实来源只阻断,不补造第一客户或伪业务号。 */
|
||||
private SourceGroup sourceGroup(DispatchPalletCandidate seed,boolean locked) {
|
||||
return sourceGroup(seed,locked,null);
|
||||
}
|
||||
|
||||
/** 只读候选复用同事务批读,单组和写入路径不复用候选读取作用域。 */
|
||||
private SourceGroup sourceGroup(DispatchPalletCandidate seed,boolean locked,CandidateReadScope reads) {
|
||||
var rows=new TreeMap<String,DispatchPalletCandidate>();rows.put(wholeSourceKey(selection(seed,null)),seed);
|
||||
var pallets=new TreeSet<Long>();if(seed.warehousePalletId()!=null)pallets.add(seed.warehousePalletId());
|
||||
var applications=new TreeMap<String,String>();var loaded=new HashSet<String>();var reasons=new LinkedHashSet<String>();
|
||||
@@ -90,7 +144,9 @@ public class DispatchService {
|
||||
if(facts==null)throw new ResponseStatusException(HttpStatus.CONFLICT,"实托完整来源快照缺失");
|
||||
reasons.addAll(facts.blockReasons());
|
||||
if(!facts.complete() && facts.blockReasons().isEmpty())reasons.add("实托实际绑定组成未核齐");
|
||||
facts.positions().stream().map(RelabelDispatchSourcePort.Position::warehousePalletId).filter(Objects::nonNull).forEach(pallets::add);
|
||||
// 缺失的实托身份保留在事实与阻断原因中,不能拿零值扩组或传给严格的实托查询入口。
|
||||
facts.positions().stream().map(RelabelDispatchSourcePort.Position::warehousePalletId)
|
||||
.filter(id->id!=null && id>0).forEach(pallets::add);
|
||||
for(var app:facts.applications())applications.put(app.relabelNo(),app.customerId());
|
||||
var codes=rows.values().stream().map(DispatchPalletCandidate::palletNo).filter(Objects::nonNull).distinct().toList();
|
||||
for(var app:wholeSources.nativeApplications(seed.overseasWarehouseId(),codes))applications.put(app.relabelNo(),app.customerId());
|
||||
@@ -109,7 +165,8 @@ public class DispatchService {
|
||||
// 原生退货的资格由其真实箱生产者负责;普通位置提供者明确提示走原生分支。
|
||||
reasons.removeIf(reason->nativeNos.stream().anyMatch(no->reason.startsWith(no+":")));
|
||||
if(!pallets.isEmpty()) {
|
||||
var available=wholeSources.physicalAvailable(seed.overseasWarehouseId(),List.copyOf(pallets));
|
||||
var available=reads==null?wholeSources.physicalAvailable(seed.overseasWarehouseId(),List.copyOf(pallets)):
|
||||
reads.read(seed.overseasWarehouseId(),List.copyOf(pallets));
|
||||
if(available.size()>1000)reasons.add("实托可用来源超过完整核验上限");
|
||||
var buckets=available.stream().filter(r->r.packageCount()!=null && r.packageCount()>0).collect(Collectors.groupingBy(r->r.customerId()+":"+r.warehousePalletId(),TreeMap::new,Collectors.toList()));
|
||||
for(var entry:buckets.entrySet()) {
|
||||
@@ -130,7 +187,8 @@ public class DispatchService {
|
||||
reasons.add("实托还有未完整选择或重复计入的真实可用库存来源");
|
||||
}
|
||||
}
|
||||
var bases=new TreeMap<String,String>();for(var entry:rows.entrySet())bases.put(entry.getKey(),baseSourceVersion(entry.getValue(),entry.getKey(),false));
|
||||
if(reads!=null)reads.loadFacts(rows.values());
|
||||
var bases=new TreeMap<String,String>();for(var entry:rows.entrySet())bases.put(entry.getKey(),baseSourceVersion(entry.getValue(),entry.getKey(),reads));
|
||||
String version=wholeHashes.source(Arrays.asList(bases,facts==null?null:facts.compositionVersion(),pallets));
|
||||
var versions=new TreeMap<String,String>();bases.forEach((key,basis)->versions.put(key,wholeHashes.source(List.of(basis,version))));
|
||||
var sources=new ArrayList<SelectionGroupSource>();
|
||||
@@ -397,7 +455,7 @@ public class DispatchService {
|
||||
}
|
||||
|
||||
/** 单来源基础摘要不递归包含组版本,组版本再绑定全部成员基础摘要。 */
|
||||
private String baseSourceVersion(DispatchPalletCandidate row,String key,boolean lockStock) {
|
||||
private String baseSourceVersion(DispatchPalletCandidate row,String key,CandidateReadScope reads) {
|
||||
var facts = new java.util.TreeMap<String,Object>();
|
||||
facts.put("sourceKey",key); facts.put("customerId",row.customerId()); facts.put("warehouseId",row.overseasWarehouseId());
|
||||
facts.put("planPalletId",row.planPalletId()); facts.put("returnReceiptId",row.returnReceiptId());
|
||||
@@ -409,12 +467,15 @@ public class DispatchService {
|
||||
facts.put("grossWeightKg",row.grossWeightKg()); facts.put("volumeCbm",row.volumeCbm());
|
||||
facts.put("cartonMarks",row.relabelCartonMarks());
|
||||
if(key.startsWith("RELABEL:")) facts.put("relabelBasis",wholeSources.relabelBasis(key.substring(8,key.lastIndexOf(':'))));
|
||||
var stock=wholeSources.stock(row.customerId(),row.warehousePalletId(),lockStock);
|
||||
var basis=row.warehousePalletId()==null?new SourceFacts(List.of(),List.of(),List.of()):
|
||||
reads!=null?reads.facts(row):new SourceFacts(wholeSources.stock(row.customerId(),row.warehousePalletId(),false),
|
||||
wholeSources.receipts(row.customerId(),row.warehousePalletId()),wholeSources.returnedReceipts(row.customerId(),row.warehousePalletId()));
|
||||
var stock=basis.stock();
|
||||
if(stock.stream().anyMatch(position->!Objects.equals(row.overseasWarehouseId(),position.warehouseId())))
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT,"实托库存起运仓不一致,请核对来源");
|
||||
facts.put("stock",stock);
|
||||
facts.put("receipts",wholeSources.receipts(row.customerId(),row.warehousePalletId()));
|
||||
facts.put("returnedReceipts",wholeSources.returnedReceipts(row.customerId(),row.warehousePalletId()));
|
||||
facts.put("receipts",basis.receipts());
|
||||
facts.put("returnedReceipts",basis.returnedReceipts());
|
||||
return wholeHashes.source(facts);
|
||||
}
|
||||
|
||||
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
package com.baiyue.lastleg.fleet.outsourcedbusiness.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.fleet.outsourcedbusiness.model.OutsourcedBusinessModels.*;
|
||||
import com.baiyue.lastleg.fleet.outsourcedbusiness.service.OutsourcedBusinessService;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.Query;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.Actor;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.*;
|
||||
import org.springframework.http.*;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
/** 外配八类路径固定独立权限,不接收浏览器业务作用域。 */
|
||||
@RestController
|
||||
@Validated
|
||||
@RequestMapping("/api/platform/v1/last-leg/fleet/outsourced-business")
|
||||
public class OutsourcedBusinessController {
|
||||
private final OutsourcedBusinessService service;
|
||||
private final OwnedBusinessSupport access;
|
||||
/** 会话只转真实平台操作者及其原权限集合。 */
|
||||
public OutsourcedBusinessController(OutsourcedBusinessService service,OwnedBusinessSupport access){this.service=service;this.access=access;}
|
||||
/** 同筛选分页和完整阶段统计一次返回。 */
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAuthority('platform:fleet:outsourced-business:list')")
|
||||
public ApiResponse<ListResult> list(@AuthenticationPrincipal AuthenticatedUser user,@Valid Query query){return ApiResponse.success(service.list(actor(user),query));}
|
||||
/** 列表权限包含详情,不借原排车或自有写权限。 */
|
||||
@GetMapping("/{dispatchNo}")
|
||||
@PreAuthorize("hasAuthority('platform:fleet:outsourced-business:list')")
|
||||
public ApiResponse<Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo){return ApiResponse.success(service.detail(actor(user),dispatchNo));}
|
||||
/** 原主单和本页附加历史保持各自版本字段。 */
|
||||
@GetMapping("/{dispatchNo}/history")
|
||||
@PreAuthorize("hasAuthority('platform:fleet:outsourced-business:list')")
|
||||
public ApiResponse<HistoryPage> history(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,
|
||||
@RequestParam(defaultValue="1") @Min(1) Integer page,@RequestParam(defaultValue="20") @Min(1) @Max(200) Integer size) {
|
||||
return ApiResponse.success(service.history(actor(user),dispatchNo,page,size));
|
||||
}
|
||||
/** 只登记整车金额和逐目的仓到达,不分摊或触发财务。 */
|
||||
@PutMapping("/{dispatchNo}/operations")
|
||||
@PreAuthorize("hasAuthority('platform:fleet:outsourced-business:edit')")
|
||||
public ApiResponse<Detail> save(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@Valid @RequestBody SaveOperations request) {
|
||||
return ApiResponse.success(service.save(actor(user),dispatchNo,request));
|
||||
}
|
||||
/** 上传仅准备照片文件,幂等键必须在multipart内。 */
|
||||
@PostMapping(value="/{dispatchNo}/photos/{slot}/uploads",consumes=MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@PreAuthorize("hasAuthority('platform:fleet:outsourced-business:upload')")
|
||||
public ApiResponse<Photo> upload(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot slot,
|
||||
@RequestParam @Size(min=8,max=128) String idempotencyKey,@RequestPart("file") MultipartFile file) {
|
||||
return ApiResponse.success(service.upload(actor(user),dispatchNo,slot,idempotencyKey,file));
|
||||
}
|
||||
/** 绑定使用独立原键和运营附加版本,失败保留fileId。 */
|
||||
@PutMapping("/{dispatchNo}/photos/{slot}")
|
||||
@PreAuthorize("hasAuthority('platform:fleet:outsourced-business:upload')")
|
||||
public ApiResponse<Detail> bind(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot slot,@Valid @RequestBody BindPhoto request) {
|
||||
return ApiResponse.success(service.bind(actor(user),dispatchNo,slot,request));
|
||||
}
|
||||
/** 当前或指定历史引用经过外配归属与权限校验后代理读取。 */
|
||||
@GetMapping("/{dispatchNo}/photos/{slot}/files/{fileId}/content")
|
||||
@PreAuthorize("hasAuthority('platform:fleet:outsourced-business:list')")
|
||||
public ResponseEntity<byte[]> content(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot slot,
|
||||
@PathVariable @Positive long fileId,@RequestParam(required=false) @Min(1) Long revisionVersion) {
|
||||
var result=service.content(actor(user),dispatchNo,slot,fileId,revisionVersion);
|
||||
return ResponseEntity.ok().cacheControl(CacheControl.noStore()).contentType(MediaType.parseMediaType(result.photo().contentType()))
|
||||
.header("X-Content-Type-Options","nosniff").body(result.content());
|
||||
}
|
||||
/** 原键仅返回原操作者的结果,不自动补写或换键重试。 */
|
||||
@GetMapping("/{dispatchNo}/commands/{idempotencyKey}")
|
||||
@PreAuthorize("hasAuthority('platform:fleet:outsourced-business:list')")
|
||||
public ApiResponse<Command> command(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable String idempotencyKey) {
|
||||
return ApiResponse.success(service.command(actor(user),dispatchNo,idempotencyKey));
|
||||
}
|
||||
/** 固定外配入口保留会话真实权限,不接受任何浏览器作用域。 */
|
||||
private Actor actor(AuthenticatedUser user){return access.actor(user,OwnedBusinessScope.OUTSOURCED);}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package com.baiyue.lastleg.fleet.outsourcedbusiness.model;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchViews;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.Arrival;
|
||||
import com.fasterxml.jackson.annotation.JsonAnySetter;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.*;
|
||||
import java.util.List;
|
||||
|
||||
/** 外配公开合同单独表达五框、承运金额和到达,不公开里程油耗字段。 */
|
||||
public final class OutsourcedBusinessModels {
|
||||
/** DTO容器不允许实例化。 */
|
||||
private OutsourcedBusinessModels() {}
|
||||
public static final String LIST="platform:fleet:outsourced-business:list";
|
||||
public static final String EDIT="platform:fleet:outsourced-business:edit";
|
||||
public static final String UPLOAD="platform:fleet:outsourced-business:upload";
|
||||
public enum PhotoSlot { LOADING_SCENE,LOADING_LIST,PRE_DEPARTURE_CHECK,DRIVER_LICENSE,RECEIPT }
|
||||
public record Operations(@Valid OwnedBusinessModels.Money money,@NotNull List<@NotNull @Valid Arrival> arrivals) {
|
||||
/** 不依赖全局Jackson未知字段开关,明确拒绝油耗、里程及浏览器归属。 */
|
||||
@JsonAnySetter public void unsupported(String field,Object value){throw unsupportedField(field);}
|
||||
}
|
||||
public record SaveOperations(@NotBlank String recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey,
|
||||
@NotNull @Valid Operations operations,@Size(max=1000) String reason) {
|
||||
/** 固定外配入口不接收浏览器作用域或其他协议字段。 */
|
||||
@JsonAnySetter public void unsupported(String field,Object value){throw unsupportedField(field);}
|
||||
}
|
||||
public record BindPhoto(@NotBlank String recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey,@NotBlank String fileId) {
|
||||
/** 绑定只接受原版本、原键和File编号,不能提交车辆归属。 */
|
||||
@JsonAnySetter public void unsupported(String field,Object value){throw unsupportedField(field);}
|
||||
}
|
||||
public record Vehicle(String vehicleId,String plate,String ownership,String carrierName) {}
|
||||
public record PhotoSummary(int loadingCount) {}
|
||||
public record Photo(PhotoSlot slot,String category,String fileId,String fileName,String contentType,String sizeBytes,String uploadedAt,String savedAt) {}
|
||||
public record Row(String dispatchNo,OwnedBusinessModels.Stage stage,String plannedDepartureAt,String loadingCompletedAt,String departedAt,String deliveryCompletedAt,
|
||||
OwnedBusinessModels.Origin origin,Vehicle vehicle,OwnedBusinessModels.Driver driver,List<OwnedBusinessModels.Destination> destinations,
|
||||
Integer actualPalletCount,Operations operations,String unitPrice,String recordVersion,String dispatchRecordVersion,
|
||||
PhotoSummary photoSummary,OwnedBusinessModels.Actions actions,List<String> sourceIssues) {}
|
||||
public record ListResult(List<Row> records,int page,int size,int total,OwnedBusinessModels.Summary summary) {}
|
||||
public record Detail(Row row,DispatchViews.Detail dispatch,List<OwnedBusinessModels.Cargo> cargo,List<Photo> photos) {}
|
||||
public record History(String eventSource,String recordVersion,String dispatchRecordVersion,String action,String operatorName,String occurredAt,String reason,Operations operations,List<Photo> photos) {}
|
||||
public record HistoryPage(List<History> records,int page,int size,int total) {}
|
||||
public record Command(String dispatchNo,String action,String idempotencyKey,String state,String recordVersion,boolean replayed,Object result,String errorCode,boolean retryAllowed) {}
|
||||
/** 不支持字段作为明确业务输入错误,不能静默隐藏或降级解析。 */
|
||||
private static BizException unsupportedField(String field){return new BizException(ResultCode.BAD_REQUEST,"外配业务不支持字段:"+field);}
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
package com.baiyue.lastleg.fleet.outsourcedbusiness.service;
|
||||
|
||||
import com.baiyue.lastleg.fleet.outsourcedbusiness.model.OutsourcedBusinessModels.*;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessQueryService;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessRecordService;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessPhotoService;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessProjection;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import java.util.List;
|
||||
import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.bad;
|
||||
import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.conflict;
|
||||
|
||||
/** 固定外配入口复用四表及原键机制,不伪造权限、不复制业务写实现。 */
|
||||
@Service
|
||||
public class OutsourcedBusinessService {
|
||||
private static final OwnedBusinessScope SCOPE=OwnedBusinessScope.OUTSOURCED;
|
||||
private final OwnedBusinessQueryService queries;
|
||||
private final OwnedBusinessRecordService records;
|
||||
private final OwnedBusinessPhotoService photos;
|
||||
private final OwnedBusinessProjection projection;
|
||||
private final OwnedBusinessSupport codec;
|
||||
/** 只通过内部服务复用事务与存储,Controller不接触Repository。 */
|
||||
public OutsourcedBusinessService(OwnedBusinessQueryService queries,OwnedBusinessRecordService records,OwnedBusinessPhotoService photos,
|
||||
OwnedBusinessProjection projection,OwnedBusinessSupport codec){this.queries=queries;this.records=records;this.photos=photos;this.projection=projection;this.codec=codec;}
|
||||
/** 列表承运商随批量来源读取,四项统计保持同筛选完整集合。 */
|
||||
public ListResult list(OwnedBusinessModels.Actor actor,OwnedBusinessModels.Query query) {
|
||||
var result=queries.listScoped(actor,query,SCOPE);var page=result.result();
|
||||
return new ListResult(page.records().stream().map(r->row(r,result.carrierNames().get(r.dispatchNo()))).toList(),page.page(),page.size(),page.total(),page.summary());
|
||||
}
|
||||
/** 三个页签读取同一原排车详情和外配附加记录。 */
|
||||
public Detail detail(OwnedBusinessModels.Actor actor,String number){return detail(queries.detail(actor,number,SCOPE));}
|
||||
/** 原主单与运营历史共用分页,不在外配DTO输出油耗字段。 */
|
||||
public HistoryPage history(OwnedBusinessModels.Actor actor,String number,Integer page,Integer size) {
|
||||
var result=queries.history(actor,number,page,size,SCOPE);
|
||||
return new HistoryPage(result.records().stream().map(h->new History(h.eventSource(),h.recordVersion(),h.dispatchRecordVersion(),h.action(),h.operatorName(),
|
||||
h.occurredAt(),h.reason(),h.operations()==null?null:operations(h.operations()),h.photos().stream().map(this::photo).toList())).toList(),result.page(),result.size(),result.total());
|
||||
}
|
||||
/** 外配只生成金额和到达的规范内部请求,不接收归属或隐藏油耗写入。 */
|
||||
public Detail save(OwnedBusinessModels.Actor actor,String number,SaveOperations request) {
|
||||
if(request==null || request.operations()==null)throw bad("运营登记内容缺失");
|
||||
var ops=request.operations();
|
||||
return detail(records.save(actor,number,new OwnedBusinessModels.SaveOperations(request.recordVersion(),request.idempotencyKey(),
|
||||
new OwnedBusinessModels.Operations(ops.money(),null,null,ops.arrivals()),request.reason()),SCOPE));
|
||||
}
|
||||
/** 五框使用已发布装车File分类,上传与绑定分别保留稳定键。 */
|
||||
public Photo upload(OwnedBusinessModels.Actor actor,String number,PhotoSlot slot,String key,MultipartFile file) {
|
||||
return photo(photos.upload(actor,number,slot(slot),key,file,SCOPE));
|
||||
}
|
||||
/** 原File编号由共享绑定事务校验,不创建第二份附件或写原主单。 */
|
||||
public Detail bind(OwnedBusinessModels.Actor actor,String number,PhotoSlot slot,BindPhoto request) {
|
||||
if(request==null)throw bad("照片绑定请求缺失");
|
||||
return detail(photos.bind(actor,number,slot(slot),new OwnedBusinessModels.BindPhoto(request.recordVersion(),request.idempotencyKey(),request.fileId()),SCOPE));
|
||||
}
|
||||
/** 当前或历史引用读取始终限定原车、照片框和外配权限。 */
|
||||
public OwnedBusinessPhotoService.Download content(OwnedBusinessModels.Actor actor,String number,PhotoSlot slot,long fileId,Long revision) {
|
||||
return photos.content(actor,number,slot(slot),fileId,revision,SCOPE);
|
||||
}
|
||||
/** 原键结果转换成外配原始快照,不重算金额或使用现在的车辆档案。 */
|
||||
public Command command(OwnedBusinessModels.Actor actor,String number,String key) {
|
||||
var original=photos.command(actor,number,key,SCOPE);Object result=null;
|
||||
if(original.result()!=null)result=switch(original.action()) {
|
||||
case "SAVE_OPERATIONS","BIND_PHOTO"->detail(codec.read(codec.write(original.result()),OwnedBusinessModels.Detail.class));
|
||||
case "UPLOAD_PHOTO"->photo(codec.read(codec.write(original.result()),OwnedBusinessModels.Photo.class));
|
||||
default->throw conflict("原命令类型不属于外配业务");
|
||||
};
|
||||
return new Command(original.dispatchNo(),original.action(),original.idempotencyKey(),original.state(),original.recordVersion(),original.replayed(),result,original.errorCode(),original.retryAllowed());
|
||||
}
|
||||
/** 回执公开形状不带内部里程、油耗或油耗照片摘要。 */
|
||||
private Detail detail(OwnedBusinessModels.Detail detail) {
|
||||
return new Detail(row(detail.row(),projection.carrierName(detail)),detail.dispatch(),detail.cargo(),detail.photos().stream().map(this::photo).toList());
|
||||
}
|
||||
/** 临时车辆合法保留空vehicleId;承运商缺失保留null供页面显示破折号。 */
|
||||
private Row row(OwnedBusinessModels.Row r,String carrierName) {
|
||||
if(!"DISPATCHED".equals(r.vehicle().ownership()) || r.photoSummary().fuelCount()!=0)throw conflict("外配运营快照与冻结归属不一致");
|
||||
return new Row(r.dispatchNo(),r.stage(),r.plannedDepartureAt(),r.loadingCompletedAt(),r.departedAt(),r.deliveryCompletedAt(),r.origin(),
|
||||
new Vehicle(r.vehicle().vehicleId(),r.vehicle().plate(),r.vehicle().ownership(),carrierName==null || carrierName.isBlank()?null:carrierName),r.driver(),r.destinations(),
|
||||
r.actualPalletCount(),operations(r.operations()),r.unitPrice(),r.recordVersion(),r.dispatchRecordVersion(),new PhotoSummary(r.photoSummary().loadingCount()),r.actions(),r.sourceIssues());
|
||||
}
|
||||
/** 拒绝异常持久化油耗,不用DTO隐藏不受支持的数据。 */
|
||||
private Operations operations(OwnedBusinessModels.Operations ops) {
|
||||
if(ops.actualDistanceMiles()!=null || ops.fuelConsumptionLitresPer100Km()!=null)throw conflict("外配业务不能包含里程或油耗登记");
|
||||
return new Operations(ops.money(),ops.arrivals());
|
||||
}
|
||||
/** 外配照片枚举只有五框,异常油耗历史不能借复用向外输出。 */
|
||||
private Photo photo(OwnedBusinessModels.Photo p) {
|
||||
SCOPE.requireSlot(p.slot());return new Photo(PhotoSlot.valueOf(p.slot().name()),p.category(),p.fileId(),p.fileName(),p.contentType(),p.sizeBytes(),p.uploadedAt(),p.savedAt());
|
||||
}
|
||||
/** 显式枚举转换不接受别名、兼容值或浏览器分类。 */
|
||||
private OwnedBusinessModels.PhotoSlot slot(PhotoSlot slot){if(slot==null)throw bad("请选择装车照片框");return OwnedBusinessModels.PhotoSlot.valueOf(slot.name());}
|
||||
}
|
||||
+9
-3
@@ -68,14 +68,20 @@ public interface OwnedBusinessMapper {
|
||||
#{reason},#{snapshotJson},#{occurredAt})
|
||||
""")
|
||||
int insertHistory(History row);
|
||||
/** 历史按本页版本顺序分页。 */
|
||||
/** 两表文本转连接字符规则后合并,长度取原值以保留中文及真实尾空格;不改原历史或JSON。 */
|
||||
@Select("""
|
||||
SELECT * FROM (
|
||||
SELECT id,dispatch_id,record_version,NULL AS dispatch_record_version,'OPERATIONS' AS event_source,
|
||||
action,operator_id,reason,snapshot_json,occurred_at FROM ll_fleet_owned_business_history WHERE dispatch_id=#{id}
|
||||
RPAD(CAST(action AS CHAR(40)),CHAR_LENGTH(action),' ') AS action,
|
||||
RPAD(CAST(operator_id AS CHAR(128)),CHAR_LENGTH(operator_id),' ') AS operator_id,
|
||||
RPAD(CAST(reason AS CHAR(2000)),CHAR_LENGTH(reason),' ') AS reason,
|
||||
snapshot_json,occurred_at FROM ll_fleet_owned_business_history WHERE dispatch_id=#{id}
|
||||
UNION ALL
|
||||
SELECT id,dispatch_id,NULL AS record_version,record_version AS dispatch_record_version,'DISPATCH' AS event_source,
|
||||
action,operator_id,reason,snapshot_json,occurred_at FROM ll_dispatch_history WHERE dispatch_id=#{id}
|
||||
RPAD(CAST(action AS CHAR(40)),CHAR_LENGTH(action),' ') AS action,
|
||||
RPAD(CAST(operator_id AS CHAR(128)),CHAR_LENGTH(operator_id),' ') AS operator_id,
|
||||
RPAD(CAST(reason AS CHAR(2000)),CHAR_LENGTH(reason),' ') AS reason,
|
||||
snapshot_json,occurred_at FROM ll_dispatch_history WHERE dispatch_id=#{id}
|
||||
) events ORDER BY occurred_at DESC,event_source,id DESC LIMIT #{limit} OFFSET #{offset}
|
||||
""")
|
||||
List<History> history(@Param("id") long id,@Param("offset") int offset,@Param("limit") int limit);
|
||||
|
||||
+5
-1
@@ -16,10 +16,14 @@ public interface OwnedBusinessQueryMapper {
|
||||
o.loading_completion_hash AS owned_loading_completion_hash,o.loading_completed_at AS owned_loading_completed_at
|
||||
FROM ll_dispatch_order d LEFT JOIN ll_fleet_owned_business_ownership o ON o.dispatch_id=d.id
|
||||
""";
|
||||
/** 摘要按列上限128字节完整比较,不受新旧表默认排序规则影响,不能省略二进制宽度。 */
|
||||
String FILTER="""
|
||||
d.status='ACTIVE' AND d.stage IN ('AFTER_LOADING','DELIVERING','DELIVERED')
|
||||
AND d.loading_completed_at IS NOT NULL AND d.loading_confirmation_json IS NOT NULL
|
||||
AND o.vehicle_ownership='OWNED' AND o.loading_completion_hash=d.loading_completion_hash
|
||||
AND o.vehicle_ownership=#{q.scope.ownership}
|
||||
AND CAST(o.loading_completion_hash AS BINARY(128))=CAST(d.loading_completion_hash AS BINARY(128))
|
||||
AND o.loading_completed_at=d.loading_completed_at
|
||||
AND (o.resource_mode='MASTER' OR (#{q.scope.ownership}='DISPATCHED' AND o.resource_mode='EXTERNAL'))
|
||||
<if test='q.from != null'> AND d.planned_departure_at >= #{q.from}</if>
|
||||
<if test='q.to != null'> AND d.planned_departure_at < #{q.to}</if>
|
||||
<if test='q.stage != null'> AND d.stage=#{q.stage}</if>
|
||||
|
||||
+1
-1
@@ -52,6 +52,6 @@ public final class OwnedBusinessRows {
|
||||
private Long allocatedPackages;
|
||||
}
|
||||
public record Search(int offset,int limit,String keyword,String warehouseId,String stage,
|
||||
LocalDateTime from,LocalDateTime to) {}
|
||||
LocalDateTime from,LocalDateTime to,OwnedBusinessScope scope) {}
|
||||
@Data public static class StageCount { private String stage;private Long quantity; }
|
||||
}
|
||||
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package com.baiyue.lastleg.fleet.ownedbusiness.model;
|
||||
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.PhotoSlot;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
|
||||
/** 仅服务端固定入口选择的作用域;不得成为浏览器请求字段或权限别名。 */
|
||||
public enum OwnedBusinessScope {
|
||||
OWNED("OWNED","platform:fleet:owned-business:"),
|
||||
OUTSOURCED("DISPATCHED","platform:fleet:outsourced-business:");
|
||||
private final String ownership,prefix;
|
||||
/** 车辆归属与入口权限一一固定,不能由请求指定。 */
|
||||
OwnedBusinessScope(String ownership,String prefix){this.ownership=ownership;this.prefix=prefix;}
|
||||
/** SQL仅绑定此可信枚举的归属值,不拼接客户端片段。 */
|
||||
public String getOwnership(){return ownership;}
|
||||
/** 查看权限包含本作用域详情、历史和照片读取。 */
|
||||
public String listPermission(){return prefix+"list";}
|
||||
/** 运营写权限独立,外配权限不映射成自有权限。 */
|
||||
public String editPermission(){return prefix+"edit";}
|
||||
/** 照片写权限独立,不扩大到原排车。 */
|
||||
public String uploadPermission(){return prefix+"upload";}
|
||||
/** 外配只支持装车五框,服务调用同样不能绕过。 */
|
||||
public void requireSlot(PhotoSlot slot){if(slot==null || this==OUTSOURCED && !"LOADING".equals(slot.category()))throw new BizException(ResultCode.BAD_REQUEST,"当前业务不支持该照片框");}
|
||||
/** 不存在只显示当前业务归属,不泄露内部身份。 */
|
||||
public BizException missing(){return new BizException(ResultCode.NOT_FOUND,"排车单不存在或不属于当前"+(this==OWNED?"自有":"外配")+"业务");}
|
||||
}
|
||||
+31
-10
@@ -3,6 +3,7 @@ package com.baiyue.lastleg.fleet.ownedbusiness.service;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.gateway.OwnedBusinessFileGateway;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.gateway.HttpOwnedBusinessFileGateway;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.*;
|
||||
@@ -32,7 +33,12 @@ public class OwnedBusinessPhotoService {
|
||||
public record Download(Photo photo,byte[] content) {}
|
||||
/** 写前落意图,绑定失败须保留原fileId,不能再上传。 */
|
||||
public Photo upload(Actor actor,String number,PhotoSlot slot,String key,MultipartFile multipart) {
|
||||
codec.require(actor,UPLOAD);codec.key(key);var source=queries.requireOwned(number);
|
||||
return upload(actor,number,slot,key,multipart,OwnedBusinessScope.OWNED);
|
||||
}
|
||||
/** 归属与实际上传权限在写File意图前核验,外配拒绝两个油耗框。 */
|
||||
public Photo upload(Actor actor,String number,PhotoSlot slot,String key,MultipartFile multipart,OwnedBusinessScope scope) {
|
||||
codec.require(actor,scope.uploadPermission());scope.requireSlot(slot);codec.key(key);
|
||||
var source=scope==OwnedBusinessScope.OWNED?queries.requireOwned(number):queries.requireScoped(number,scope);
|
||||
if(slot==null || multipart==null)throw bad("请选择照片及分类");
|
||||
if(multipart.isEmpty() || multipart.getSize()>HttpOwnedBusinessFileGateway.MAX_SIZE)throw bad("照片须为非空JPG/PNG,最大10MiB");
|
||||
byte[] bytes;try{bytes=multipart.getBytes();}catch(IOException error){throw bad("无法读取待上传照片");}
|
||||
@@ -49,41 +55,56 @@ public class OwnedBusinessPhotoService {
|
||||
commands.finishUpload(reservation.command(),"SUCCEEDED",result,null);return result;
|
||||
} catch(RuntimeException failure) {
|
||||
// 连接异常不能证明File没有写入;持久保留未知结果,禁止自动补造。
|
||||
try{commands.finishUpload(reservation.command(),"UNKNOWN_RESULT",null,"OWNED_UPLOAD_UNKNOWN_RESULT");}
|
||||
try{commands.finishUpload(reservation.command(),"UNKNOWN_RESULT",null,scope==OwnedBusinessScope.OWNED?"OWNED_UPLOAD_UNKNOWN_RESULT":"OUTSOURCED_UPLOAD_UNKNOWN_RESULT");}
|
||||
catch(RuntimeException recordingFailure){failure.addSuppressed(recordingFailure);}
|
||||
throw conflict("照片上传结果未知,请保留原请求键查询,勿重新上传");
|
||||
}
|
||||
}
|
||||
/** 重放先于File复核,保证原成功结果可以精确恢复。 */
|
||||
public Detail bind(Actor actor,String number,PhotoSlot slot,BindPhoto request) {
|
||||
codec.require(actor,UPLOAD);if(request==null || slot==null)throw bad("照片绑定请求缺失");
|
||||
return bind(actor,number,slot,request,OwnedBusinessScope.OWNED);
|
||||
}
|
||||
/** 原绑定键恢复仍先检查固定业务域和当前会话,不重新上传。 */
|
||||
public Detail bind(Actor actor,String number,PhotoSlot slot,BindPhoto request,OwnedBusinessScope scope) {
|
||||
codec.require(actor,scope.uploadPermission());if(request==null || slot==null)throw bad("照片绑定请求缺失");scope.requireSlot(slot);
|
||||
codec.key(request.idempotencyKey());long version=codec.version(request.recordVersion());long fileId=codec.version(request.fileId());
|
||||
if(fileId<=0)throw bad("文件编号无效");
|
||||
var source=queries.requireOwned(number);var previous=commands.existing(source.getId(),request.idempotencyKey(),actor);
|
||||
var source=scope==OwnedBusinessScope.OWNED?queries.requireOwned(number):queries.requireScoped(number,scope);var previous=commands.existing(source.getId(),request.idempotencyKey(),actor);
|
||||
if(previous!=null) {
|
||||
commands.same(previous,"BIND_PHOTO",version,new OwnedBusinessRecordService.PhotoBinding(slot,request));
|
||||
if(!"SUCCEEDED".equals(previous.getStatus()))throw conflict("绑定结果尚未明确,请查询原命令");
|
||||
return codec.read(previous.getResultJson(),Detail.class);
|
||||
}
|
||||
return records.bind(actor,number,slot,request,photo(slot,files.metadata(source.getId(),slot,fileId)));
|
||||
var verified=photo(slot,files.metadata(source.getId(),slot,fileId));
|
||||
return scope==OwnedBusinessScope.OWNED?records.bind(actor,number,slot,request,verified):records.bind(actor,number,slot,request,verified,scope);
|
||||
}
|
||||
/** 只有当前或指定真实历史版本中的引用允许代理读取。 */
|
||||
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
||||
public Download content(Actor actor,String number,PhotoSlot slot,long fileId,Long revision) {
|
||||
codec.require(actor,LIST);var source=queries.requireOwned(number);List<Photo> bound;
|
||||
return content(actor,number,slot,fileId,revision,OwnedBusinessScope.OWNED);
|
||||
}
|
||||
/** 五框历史授权仍由同车同版本的真实引用限定。 */
|
||||
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
||||
public Download content(Actor actor,String number,PhotoSlot slot,long fileId,Long revision,OwnedBusinessScope scope) {
|
||||
codec.require(actor,scope.listPermission());scope.requireSlot(slot);var source=scope==OwnedBusinessScope.OWNED?queries.requireOwned(number):queries.requireScoped(number,scope);List<Photo> bound;
|
||||
if(revision==null)bound=projection.photos(repository.record(source.getId()));
|
||||
else {
|
||||
if(revision<1)throw bad("历史版本无效");var row=repository.revision(source.getId(),revision);
|
||||
if(row==null)throw missing();bound=codec.read(row.getSnapshotJson(),Snapshot.class).photos();
|
||||
if(row==null)throw scope.missing();bound=codec.read(row.getSnapshotJson(),Snapshot.class).photos();
|
||||
}
|
||||
var photo=bound.stream().filter(p->p.slot()==slot && String.valueOf(fileId).equals(p.fileId())).findFirst().orElseThrow(OwnedBusinessSupport::missing);
|
||||
var photo=bound.stream().filter(p->p.slot()==slot && String.valueOf(fileId).equals(p.fileId())).findFirst().orElseThrow(scope::missing);
|
||||
return new Download(photo,files.content(source.getId(),slot,fileId));
|
||||
}
|
||||
/** 恢复先校验原操作者,不披露他人载荷。 */
|
||||
@Transactional(readOnly=true)
|
||||
public Command command(Actor actor,String number,String key) {
|
||||
codec.require(actor,LIST);codec.key(key);var source=queries.requireOwned(number);var row=commands.existing(source.getId(),key,actor);
|
||||
if(row==null)throw missing();Object result=row.getResultJson()==null?null:codec.read(row.getResultJson(),Object.class);
|
||||
return command(actor,number,key,OwnedBusinessScope.OWNED);
|
||||
}
|
||||
/** 原键查询不能越过归属隔离,外配权限不授权自有账本。 */
|
||||
@Transactional(readOnly=true)
|
||||
public Command command(Actor actor,String number,String key,OwnedBusinessScope scope) {
|
||||
codec.require(actor,scope.listPermission());codec.key(key);var source=scope==OwnedBusinessScope.OWNED?queries.requireOwned(number):queries.requireScoped(number,scope);var row=commands.existing(source.getId(),key,actor);
|
||||
if(row==null)throw scope.missing();Object result=row.getResultJson()==null?null:codec.read(row.getResultJson(),Object.class);
|
||||
String version=row.getExpectedVersion()==null?null:String.valueOf(Math.addExact(row.getExpectedVersion(),1));
|
||||
return new Command(number,row.getAction(),key,row.getStatus(),version,true,result,row.getErrorCode(),false);
|
||||
}
|
||||
|
||||
+16
-2
@@ -4,6 +4,7 @@ import com.baiyue.lastleg.dispatch.port.DispatchContracts.ConfirmedLoad;
|
||||
import com.baiyue.lastleg.dispatch.port.DispatchContracts.Resources;
|
||||
import com.baiyue.lastleg.fleet.trip.FleetDispatchGateway;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.*;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Record;
|
||||
import org.springframework.stereotype.Component;
|
||||
@@ -45,9 +46,15 @@ public class OwnedBusinessProjection {
|
||||
}
|
||||
/** 一车一行;目的地与预约只行内展开,不重复整车金额或托数。 */
|
||||
public Row row(Source source,Record record,List<CargoRow> cargo,List<Batch> batches,Actor actor) {
|
||||
return row(source,record,cargo,batches,actor,OwnedBusinessScope.OWNED);
|
||||
}
|
||||
/** 两类固定入口复用原事实装配,公开DTO由各域独立转换。 */
|
||||
public Row row(Source source,Record record,List<CargoRow> cargo,List<Batch> batches,Actor actor,OwnedBusinessScope scope) {
|
||||
var resources=codec.read(source.getResourcesJson(),Resources.class);
|
||||
if(resources.vehicle()==null)throw OwnedBusinessSupport.conflict("原车辆快照缺失");
|
||||
var ops=operations(record);var photos=photos(record);int pallets=palletCount(source);
|
||||
if(scope==OwnedBusinessScope.OUTSOURCED && (ops.actualDistanceMiles()!=null || ops.fuelConsumptionLitresPer100Km()!=null
|
||||
|| photos.stream().anyMatch(p->!"LOADING".equals(p.category()))))throw OwnedBusinessSupport.conflict("外配运营记录含不支持的里程、油耗或照片");
|
||||
var arrivals=new HashMap<String,String>();for(var a:ops.arrivals())arrivals.put(a.destinationKey(),a.actualArrivedAt());
|
||||
var destinations=new LinkedHashMap<String,Destination>();
|
||||
for(var item:cargo) {
|
||||
@@ -61,11 +68,18 @@ public class OwnedBusinessProjection {
|
||||
return new Row(source.getDispatchNo(),Stage.valueOf(source.getStage()),codec.iso(source.getPlannedDepartureAt()),
|
||||
codec.iso(source.getLoadingCompletedAt()),codec.iso(source.getDepartedAt()),codec.iso(source.getDeliveryCompletedAt()),
|
||||
new Origin(source.getOverseasWarehouseId(),source.getOverseasWarehouseCode(),source.getOverseasWarehouseName()),
|
||||
new Vehicle(String.valueOf(resources.vehicle().vehicleId()),resources.vehicle().plate(),"OWNED"),
|
||||
new Vehicle(resources.vehicle().vehicleId()==null?null:String.valueOf(resources.vehicle().vehicleId()),resources.vehicle().plate(),scope.getOwnership()),
|
||||
new Driver(driver==null?null:driver.name(),driver==null?null:driver.phone()),List.copyOf(destinations.values()),pallets,ops,price,
|
||||
record==null?"0":String.valueOf(record.getRecordVersion()),String.valueOf(source.getRecordVersion()),
|
||||
new PhotoSummary((int)photos.stream().filter(p->"LOADING".equals(p.category())).count(),(int)photos.stream().filter(p->"FUEL".equals(p.category())).count()),
|
||||
new Actions(gate(actor,EDIT),gate(actor,UPLOAD)),pallets==0?List.of("实际装车清单无实托,单价未计算"):List.of());
|
||||
new Actions(gate(actor,scope.editPermission()),gate(actor,scope.uploadPermission())),pallets==0?List.of("实际装车清单无实托,单价未计算"):List.of());
|
||||
}
|
||||
/** 承运商仅取原资源快照,列表不逐车查询当前档案。 */
|
||||
public String carrierName(Source source){return codec.read(source.getResourcesJson(),Resources.class).vehicle().carrierName();}
|
||||
/** 原键回执的承运商沿原详情快照,不重新读取当前资源。 */
|
||||
public String carrierName(Detail detail) {
|
||||
return detail.dispatch()==null || detail.dispatch().vehicleInfo()==null || detail.dispatch().vehicleInfo().vehicle()==null
|
||||
?null:detail.dispatch().vehicleInfo().vehicle().carrierName();
|
||||
}
|
||||
/** 附加权限独立于原排车写动作。 */
|
||||
private Gate gate(Actor actor,String permission) {
|
||||
|
||||
+44
-14
@@ -1,6 +1,7 @@
|
||||
package com.baiyue.lastleg.fleet.ownedbusiness.service;
|
||||
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Source;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Search;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Record;
|
||||
@@ -29,12 +30,19 @@ public class OwnedBusinessQueryService {
|
||||
/** 四项统计与分页使用同一过滤和快照,GET不写库。 */
|
||||
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
||||
public ListResult list(Actor actor,Query query) {
|
||||
codec.require(actor,LIST);if(query==null)throw bad("查询条件缺失");
|
||||
return listScoped(actor,query,OwnedBusinessScope.OWNED).result();
|
||||
}
|
||||
/** 内部批量结果额外带原承运商;不扩展自有公开Row,不逐车补查。 */
|
||||
public record ScopedListResult(ListResult result,Map<String,String> carrierNames) {}
|
||||
/** 可信作用域只由服务调用方指定,权限与SQL同时限定同一域。 */
|
||||
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
||||
public ScopedListResult listScoped(Actor actor,Query query,OwnedBusinessScope scope) {
|
||||
codec.require(actor,scope.listPermission());if(query==null)throw bad("查询条件缺失");
|
||||
int page=page(query.page()),size=size(query.size());String keyword=optional(query.keyword(),128),warehouse=optional(query.warehouseId(),64);
|
||||
Instant from=codec.instant(query.plannedDepartureFrom()),to=codec.instant(query.plannedDepartureTo());
|
||||
if(from!=null && to!=null && !from.isBefore(to))throw bad("计划发车时间范围无效");
|
||||
int offset;try{offset=Math.multiplyExact(page-1,size);}catch(ArithmeticException error){throw bad("页码过大");}
|
||||
var search=new Search(offset,size,keyword,warehouse,query.stage()==null?null:query.stage().name(),codec.utc(from),codec.utc(to));
|
||||
var search=new Search(offset,size,keyword,warehouse,query.stage()==null?null:query.stage().name(),codec.utc(from),codec.utc(to),scope);
|
||||
var counts=repository.summary(search);int after=0,delivering=0,delivered=0;
|
||||
for(var c:counts) {
|
||||
int n=Math.toIntExact(c.getQuantity());
|
||||
@@ -44,18 +52,26 @@ public class OwnedBusinessQueryService {
|
||||
var sources=repository.page(search);var ids=sources.stream().map(Source::getId).toList();
|
||||
var records=repository.records(ids).stream().collect(Collectors.toMap(Record::getDispatchId,r->r));
|
||||
var cargo=repository.cargo(ids);var batches=repository.batches(ids);
|
||||
var rows=sources.stream().map(s->{eligible(s);return projection.row(s,records.get(s.getId()),
|
||||
var rows=sources.stream().map(s->{eligible(s,scope);return projection.row(s,records.get(s.getId()),
|
||||
cargo.stream().filter(c->Objects.equals(c.getDispatchOrderId(),s.getId())).toList(),
|
||||
batches.stream().filter(b->Objects.equals(b.getDispatchId(),s.getId())).toList(),actor);}).toList();
|
||||
return new ListResult(rows,page,size,total,new Summary(total,after,delivering,delivered));
|
||||
batches.stream().filter(b->Objects.equals(b.getDispatchId(),s.getId())).toList(),actor,scope);}).toList();
|
||||
var carriers=new LinkedHashMap<String,String>();for(var source:sources)carriers.put(source.getDispatchNo(),projection.carrierName(source));
|
||||
return new ScopedListResult(new ListResult(rows,page,size,total,new Summary(total,after,delivering,delivered)),Collections.unmodifiableMap(carriers));
|
||||
}
|
||||
/** 对原排车详情做组合,不创建第二份主单或履约状态。 */
|
||||
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
||||
public Detail detail(Actor actor,String number){codec.require(actor,LIST);return detailForWrite(actor,requireOwned(number));}
|
||||
/** 外配入口只能读取其冻结归属,不能借自有权限取得原详情。 */
|
||||
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
||||
public Detail detail(Actor actor,String number,OwnedBusinessScope scope){codec.require(actor,scope.listPermission());return detailForWrite(actor,requireScoped(number,scope),scope);}
|
||||
/** 写入已经有独立权限;回执仍由相同真实来源组成。 */
|
||||
public Detail detailForWrite(Actor actor,Source source) {
|
||||
eligible(source);var ids=List.of(source.getId());var cargo=repository.cargo(ids);
|
||||
var row=projection.row(source,repository.record(source.getId()),cargo,repository.batches(ids),actor);
|
||||
return detailForWrite(actor,source,OwnedBusinessScope.OWNED);
|
||||
}
|
||||
/** 写回执也执行固定归属校验,调用者已核对本域独立写权限。 */
|
||||
public Detail detailForWrite(Actor actor,Source source,OwnedBusinessScope scope) {
|
||||
eligible(source,scope);var ids=List.of(source.getId());var cargo=repository.cargo(ids);
|
||||
var row=projection.row(source,repository.record(source.getId()),cargo,repository.batches(ids),actor,scope);
|
||||
var original=dispatch.detail(new DispatchContracts.Actor(actor.operatorId(),ActorMode.PLATFORM,null),source.getDispatchNo());
|
||||
return new Detail(row,original,cargo.stream().map(c->new Cargo(c.getSourceKey(),c.getCustomerId(),c.getCustomerName(),
|
||||
c.getOrderNo(),c.getInboundCode(),c.getPalletNo(),projection.destinationKey(c),String.valueOf(c.getPackageCount()))).toList(),
|
||||
@@ -64,23 +80,37 @@ public class OwnedBusinessQueryService {
|
||||
/** 缺少历史归属必须明确拒绝,不能按当前车辆主档降级猜测。 */
|
||||
public Source requireOwned(String number) {
|
||||
if(number==null || number.isBlank() || number.length()>128)throw bad("请指定排车单号");
|
||||
var source=repository.source(number);if(source==null)throw missing();eligible(source);return source;
|
||||
var source=repository.source(number);if(source==null)throw missing();eligible(source,OwnedBusinessScope.OWNED);return source;
|
||||
}
|
||||
/** 共享附加表仍按原装车归属隔离,不接受浏览器业务域参数。 */
|
||||
public Source requireScoped(String number,OwnedBusinessScope scope) {
|
||||
if(scope==OwnedBusinessScope.OWNED)return requireOwned(number);
|
||||
if(number==null || number.isBlank() || number.length()>128)throw bad("请指定排车单号");
|
||||
var source=repository.source(number);if(source==null)throw scope.missing();eligible(source,scope);return source;
|
||||
}
|
||||
/** 阶段、真实装车hash及车辆快照须与捕获证据一致。 */
|
||||
private void eligible(Source source) {
|
||||
if(source.getOwnedVehicleOwnership()==null)throw conflict("本单缺少装车时车辆归属证据,不能归入自有业务");
|
||||
if(!"OWNED".equals(source.getOwnedVehicleOwnership()))throw missing();
|
||||
private void eligible(Source source,OwnedBusinessScope scope) {
|
||||
if(source.getOwnedVehicleOwnership()==null)throw conflict("本单缺少装车时车辆归属证据,不能归入"+(scope==OwnedBusinessScope.OWNED?"自有":"外配")+"业务");
|
||||
if(!scope.getOwnership().equals(source.getOwnedVehicleOwnership()))throw scope.missing();
|
||||
if(!"ACTIVE".equals(source.getStatus()) || source.getStage()==null || !Set.of("AFTER_LOADING","DELIVERING","DELIVERED").contains(source.getStage())
|
||||
|| source.getLoadingCompletedAt()==null || source.getLoadingConfirmationJson()==null)throw conflict("本单尚无合法装车完成事实");
|
||||
var resource=codec.read(source.getResourcesJson(),DispatchContracts.Resources.class).vehicle();
|
||||
if(resource==null || resource.vehicleId()==null || !Objects.equals(source.getOwnedVehicleId(),resource.vehicleId())
|
||||
|| !"MASTER".equals(source.getOwnedResourceMode()) || !Objects.equals(source.getOwnedLoadingCompletionHash(),source.getLoadingCompletionHash())
|
||||
boolean master=resource!=null && resource.mode()!=null && "MASTER".equals(resource.mode().name()) && "MASTER".equals(source.getOwnedResourceMode())
|
||||
&& resource.vehicleId()!=null && Objects.equals(source.getOwnedVehicleId(),resource.vehicleId());
|
||||
boolean external=scope==OwnedBusinessScope.OUTSOURCED && resource!=null && resource.mode()!=null && "EXTERNAL".equals(resource.mode().name())
|
||||
&& "EXTERNAL".equals(source.getOwnedResourceMode()) && resource.vehicleId()==null && source.getOwnedVehicleId()==null;
|
||||
if((!master && !external) || !Objects.equals(source.getOwnedLoadingCompletionHash(),source.getLoadingCompletionHash())
|
||||
|| !Objects.equals(source.getOwnedLoadingCompletedAt(),source.getLoadingCompletedAt()))throw conflict("车辆归属与原装车事实不一致");
|
||||
}
|
||||
/** 操作记录只返回真实附加版本,不制造初始示例事件。 */
|
||||
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
||||
public HistoryPage history(Actor actor,String number,Integer requestedPage,Integer requestedSize) {
|
||||
codec.require(actor,LIST);var source=requireOwned(number);int page=page(requestedPage),size=size(requestedSize);
|
||||
return history(actor,number,requestedPage,requestedSize,OwnedBusinessScope.OWNED);
|
||||
}
|
||||
/** 两类业务复用历史分页,但每次读取先核对权限和冻结归属。 */
|
||||
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
|
||||
public HistoryPage history(Actor actor,String number,Integer requestedPage,Integer requestedSize,OwnedBusinessScope scope) {
|
||||
codec.require(actor,scope.listPermission());var source=requireScoped(number,scope);int page=page(requestedPage),size=size(requestedSize);
|
||||
int offset;try{offset=Math.multiplyExact(page-1,size);}catch(ArithmeticException error){throw bad("页码过大");}
|
||||
return new HistoryPage(repository.history(source.getId(),offset,size).stream().map(h->{
|
||||
var snapshot="OPERATIONS".equals(h.getEventSource())?codec.read(h.getSnapshotJson(),Snapshot.class):null;
|
||||
|
||||
+22
-8
@@ -2,6 +2,7 @@ package com.baiyue.lastleg.fleet.ownedbusiness.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Record;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Source;
|
||||
@@ -30,9 +31,16 @@ public class OwnedBusinessRecordService {
|
||||
/** 整车只保留一份可更正/清空的money,实际油耗为手工L/100km。 */
|
||||
@Transactional(isolation=Isolation.READ_COMMITTED)
|
||||
public Detail save(Actor actor,String number,SaveOperations request) {
|
||||
codec.require(actor,EDIT);if(request==null || request.operations()==null)throw bad("运营登记内容缺失");
|
||||
return save(actor,number,request,OwnedBusinessScope.OWNED);
|
||||
}
|
||||
/** 共享CAS与命令账本;固定外配域不允许保存里程或油耗。 */
|
||||
@Transactional(isolation=Isolation.READ_COMMITTED)
|
||||
public Detail save(Actor actor,String number,SaveOperations request,OwnedBusinessScope scope) {
|
||||
codec.require(actor,scope.editPermission());if(request==null || request.operations()==null)throw bad("运营登记内容缺失");
|
||||
if(scope==OwnedBusinessScope.OUTSOURCED && (request.operations().actualDistanceMiles()!=null || request.operations().fuelConsumptionLitresPer100Km()!=null))
|
||||
throw bad("外配业务不支持里程或油耗登记");
|
||||
codec.key(request.idempotencyKey());long version=codec.version(request.recordVersion());
|
||||
var source=queries.requireOwned(number);var previous=commands.existing(source.getId(),request.idempotencyKey(),actor);
|
||||
var source=scope==OwnedBusinessScope.OWNED?queries.requireOwned(number):queries.requireScoped(number,scope);var previous=commands.existing(source.getId(),request.idempotencyKey(),actor);
|
||||
if(previous!=null){commands.same(previous,"SAVE_OPERATIONS",version,request);return result(previous);}
|
||||
if(request.reason()!=null && request.reason().length()>1000)throw bad("操作说明过长");
|
||||
var ops=normalize(source,request.operations());var row=lock(source,actor);
|
||||
@@ -42,13 +50,18 @@ public class OwnedBusinessRecordService {
|
||||
row.setCurrency(ops.money()==null?null:ops.money().currency());row.setTotalAmount(ops.money()==null?null:new BigDecimal(ops.money().amount()));
|
||||
row.setActualDistanceMiles(decimal(ops.actualDistanceMiles()));row.setFuelConsumptionLitresPer100Km(decimal(ops.fuelConsumptionLitresPer100Km()));
|
||||
row.setArrivalsJson(codec.write(ops.arrivals()));
|
||||
return commit(source,row,actor,version,"SAVE_OPERATIONS",request.idempotencyKey(),request,request.reason());
|
||||
return commit(source,row,actor,version,"SAVE_OPERATIONS",request.idempotencyKey(),request,request.reason(),scope);
|
||||
}
|
||||
/** File元数据已经在HTTP外层校验,绑定事务只处理本页行锁。 */
|
||||
@Transactional(isolation=Isolation.READ_COMMITTED)
|
||||
public Detail bind(Actor actor,String number,PhotoSlot slot,BindPhoto request,Photo verified) {
|
||||
codec.require(actor,UPLOAD);if(request==null || slot==null || verified==null)throw bad("照片绑定内容缺失");
|
||||
codec.key(request.idempotencyKey());long version=codec.version(request.recordVersion());var source=queries.requireOwned(number);
|
||||
return bind(actor,number,slot,request,verified,OwnedBusinessScope.OWNED);
|
||||
}
|
||||
/** 外配五框在事务内再次校验,不能靠调用共享写服务绕过。 */
|
||||
@Transactional(isolation=Isolation.READ_COMMITTED)
|
||||
public Detail bind(Actor actor,String number,PhotoSlot slot,BindPhoto request,Photo verified,OwnedBusinessScope scope) {
|
||||
codec.require(actor,scope.uploadPermission());if(request==null || slot==null || verified==null)throw bad("照片绑定内容缺失");scope.requireSlot(slot);
|
||||
codec.key(request.idempotencyKey());long version=codec.version(request.recordVersion());var source=scope==OwnedBusinessScope.OWNED?queries.requireOwned(number):queries.requireScoped(number,scope);
|
||||
Object identity=new PhotoBinding(slot,request);
|
||||
var old=commands.existing(source.getId(),request.idempotencyKey(),actor);
|
||||
if(old!=null){commands.same(old,"BIND_PHOTO",version,identity);return result(old);}
|
||||
@@ -60,7 +73,7 @@ public class OwnedBusinessRecordService {
|
||||
photos.put(slot,new Photo(slot,slot.category(),verified.fileId(),verified.fileName(),verified.contentType(),
|
||||
verified.sizeBytes(),verified.uploadedAt(),Instant.now().toString()));
|
||||
row.setPhotosJson(codec.write(List.copyOf(photos.values())));
|
||||
return commit(source,row,actor,version,"BIND_PHOTO",request.idempotencyKey(),identity,photoReason(slot));
|
||||
return commit(source,row,actor,version,"BIND_PHOTO",request.idempotencyKey(),identity,photoReason(slot),scope);
|
||||
}
|
||||
/** 操作记录使用准确中文槽位,不向用户展示内部枚举。 */
|
||||
private String photoReason(PhotoSlot slot) {
|
||||
@@ -104,13 +117,14 @@ public class OwnedBusinessRecordService {
|
||||
/** 版本变化必须由浏览器刷新,不能覆盖其他操作者的登记。 */
|
||||
private void requireVersion(Record row,long expected){if(row.getRecordVersion()==null || row.getRecordVersion()!=expected)throw conflict("运营记录版本已变化,请刷新后重试");}
|
||||
/** 只保存附加版本和不可变历史,真实主单读取失败也必须回滚。 */
|
||||
private Detail commit(Source source,Record row,Actor actor,long expected,String action,String key,Object request,String reason) {
|
||||
private Detail commit(Source source,Record row,Actor actor,long expected,String action,String key,Object request,String reason,OwnedBusinessScope scope) {
|
||||
row.setRecordVersion(Math.addExact(expected,1));row.setUpdatedBy(actor.operatorId());row.setUpdatedAt(codec.utc(Instant.now()));
|
||||
if(repository.update(row,expected)!=1)throw conflict("运营记录版本竞争失败");
|
||||
var history=new OwnedBusinessRows.History();history.setId(IdWorker.getId());history.setDispatchId(source.getId());history.setRecordVersion(row.getRecordVersion());
|
||||
history.setAction(action);history.setOperatorId(actor.operatorId());history.setReason(reason);history.setOccurredAt(row.getUpdatedAt());
|
||||
history.setSnapshotJson(codec.write(new Snapshot(projection.operations(row),projection.photos(row),actor.operatorName())));repository.history(history);
|
||||
var result=queries.detailForWrite(actor,source);commands.success(source.getId(),actor,key,action,expected,request,result);return result;
|
||||
var result=scope==OwnedBusinessScope.OWNED?queries.detailForWrite(actor,source):queries.detailForWrite(actor,source,scope);
|
||||
commands.success(source.getId(),actor,key,action,expected,request,result);return result;
|
||||
}
|
||||
/** 重放返回原成功结果,不能从现在的主单/附加版本重算。 */
|
||||
private Detail result(OwnedBusinessRows.Command command) {
|
||||
|
||||
+6
-1
@@ -4,6 +4,7 @@ import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
|
||||
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.JavaType;
|
||||
@@ -24,9 +25,13 @@ public class OwnedBusinessSupport {
|
||||
public OwnedBusinessSupport(ObjectMapper json){this.json=json;}
|
||||
/** 平台身份和操作者均取可信会话,不接收浏览器归属参数。 */
|
||||
public Actor actor(AuthenticatedUser user) {
|
||||
return actor(user,OwnedBusinessScope.OWNED);
|
||||
}
|
||||
/** 仅错误文案按固定入口区分,权限仍原样取会话,绝不补造角色。 */
|
||||
public Actor actor(AuthenticatedUser user,OwnedBusinessScope scope) {
|
||||
if(user==null)throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
||||
if(!"PLATFORM".equals(user.appCode()) || !Long.valueOf(0).equals(user.tenantId()))
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN,"仅平台可管理自有业务");
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN,"仅平台可管理"+(scope==OwnedBusinessScope.OWNED?"自有":"外配")+"业务");
|
||||
String operator=user.userId()==null?user.username():user.userId().toString();
|
||||
if(operator==null || operator.isBlank())throw new ResponseStatusException(HttpStatus.FORBIDDEN,"缺少有效操作账号");
|
||||
String name=user.displayName()==null || user.displayName().isBlank()?user.username():user.displayName();
|
||||
|
||||
+4
-4
@@ -18,10 +18,10 @@ public interface AppointmentWorkDispatchMapper {
|
||||
record ReferenceRow(long dispatchId,String batchKey,String batchNo,Long recordVersion,String customerId,String sourceKey,String destinationKey,int packages) { }
|
||||
record CandidateSource(long planPalletId,String customerId,String sourceStage,boolean locked,Long orderId,String destinationKey,Integer plannedPackages,Integer countedPackages) { }
|
||||
record CandidateBatch(String batchKey,String batchNo,Long recordVersion,String customerId,Long orderId,String sourceKey,String destinationKey,Integer packages,long allocated) { }
|
||||
/** 一次读取整页真实客户/托盘的逐票实物及全来源有效占用。 */
|
||||
/** 一次读取整页逐票来源及占用;仅预留但未收货的托盘仍按计划箱数预排。 */
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT p.id plan_pallet_id,p.customer_id,CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END source_stage,
|
||||
SELECT p.id plan_pallet_id,p.customer_id,CASE WHEN p.warehouse_pallet_id IS NULL OR (COALESCE(p.assignment_status,'')='RESERVED' AND COALESCE(p.actual_package_count,0)=0) THEN 'PLAN' ELSE 'RECEIVED' END source_stage,
|
||||
EXISTS(SELECT 1 FROM ll_dispatch_pallet_lock k WHERE (k.plan_pallet_id=p.id OR (p.warehouse_pallet_id IS NOT NULL AND k.warehouse_pallet_id=p.warehouse_pallet_id))
|
||||
AND k.status='ACTIVE' AND (k.dispatch_order_id IS NOT NULL OR k.expires_at>CURRENT_TIMESTAMP)) locked,
|
||||
l.order_id,CASE WHEN l.destination_type='PRIVATE' THEN CONCAT('CUSTOMER:',l.order_id) ELSE CONCAT('FBA:',l.fba_warehouse_code) END destination_key,
|
||||
@@ -31,7 +31,7 @@ public interface AppointmentWorkDispatchMapper {
|
||||
LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id AND a.customer_id=p.customer_id
|
||||
LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=a.customer_id
|
||||
WHERE <foreach collection='items' item='i' open='(' separator=' OR ' close=')'>(p.id=#{i.planPalletId} AND p.customer_id=#{i.customerId})</foreach>
|
||||
GROUP BY p.id,p.customer_id,p.warehouse_pallet_id,l.order_id,l.destination_type,l.fba_warehouse_code
|
||||
GROUP BY p.id,p.customer_id,p.warehouse_pallet_id,p.assignment_status,p.actual_package_count,l.order_id,l.destination_type,l.fba_warehouse_code
|
||||
ORDER BY p.id,l.order_id,l.destination_type,l.fba_warehouse_code LIMIT 20001
|
||||
</script>
|
||||
""")
|
||||
@@ -98,7 +98,7 @@ public interface AppointmentWorkDispatchMapper {
|
||||
List<TransportRow> transportRows(@Param("batches") List<String> batches,@Param("trips") List<String> trips);
|
||||
@Select("SELECT i.id item_id,i.plan_pallet_id,i.customer_id,i.source_stage,i.package_count packages FROM ll_dispatch_item i JOIN ll_dispatch_order d ON d.id=i.dispatch_order_id WHERE d.dispatch_no=#{number}") List<DispatchPhysical> dispatchPhysical(String number);
|
||||
@Select("SELECT id item_id,plan_pallet_id,customer_id,source_stage,package_count packages FROM ll_dispatch_item WHERE dispatch_order_id=#{id} ORDER BY id") List<DispatchPhysical> dispatchPhysicalById(long id);
|
||||
@Select("SELECT DISTINCT p.id plan_pallet_id,p.pallet_no,p.plan_id,n.plan_no,p.customer_id,CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END source_stage,EXISTS(SELECT 1 FROM ll_dispatch_pallet_lock k WHERE (k.plan_pallet_id=p.id OR (p.warehouse_pallet_id IS NOT NULL AND k.warehouse_pallet_id=p.warehouse_pallet_id)) AND k.status='ACTIVE' AND (k.dispatch_order_id IS NOT NULL OR k.expires_at>CURRENT_TIMESTAMP)) locked FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan n ON n.id=p.plan_id JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id JOIN ll_inbound_plan_line l ON l.id=a.line_id JOIN ll_inbound_fba_appointment_item i ON i.order_id=l.order_id AND i.customer_id=l.customer_id AND i.active=1 WHERE i.appointment_id=#{id} ORDER BY p.id LIMIT 501")
|
||||
@Select("SELECT DISTINCT p.id plan_pallet_id,p.pallet_no,p.plan_id,n.plan_no,p.customer_id,CASE WHEN p.warehouse_pallet_id IS NULL OR (COALESCE(p.assignment_status,'')='RESERVED' AND COALESCE(p.actual_package_count,0)=0) THEN 'PLAN' ELSE 'RECEIVED' END source_stage,EXISTS(SELECT 1 FROM ll_dispatch_pallet_lock k WHERE (k.plan_pallet_id=p.id OR (p.warehouse_pallet_id IS NOT NULL AND k.warehouse_pallet_id=p.warehouse_pallet_id)) AND k.status='ACTIVE' AND (k.dispatch_order_id IS NOT NULL OR k.expires_at>CURRENT_TIMESTAMP)) locked FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan n ON n.id=p.plan_id JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id JOIN ll_inbound_plan_line l ON l.id=a.line_id JOIN ll_inbound_fba_appointment_item i ON i.order_id=l.order_id AND i.customer_id=l.customer_id AND i.active=1 WHERE i.appointment_id=#{id} ORDER BY p.id LIMIT 501")
|
||||
List<Candidate> candidates(long id);
|
||||
@Select("SELECT l.order_id,l.customer_id,CASE WHEN l.destination_type='PRIVATE' THEN CONCAT('CUSTOMER:',l.order_id) ELSE CONCAT('FBA:',l.fba_warehouse_code) END destination_key,CASE WHEN COUNT(a.planned_package_count)=COUNT(a.line_id) THEN SUM(a.planned_package_count) END planned_packages,CASE WHEN COUNT(a.counted_package_count)=COUNT(a.line_id) THEN SUM(a.counted_package_count) END counted_packages FROM ll_inbound_plan_pallet_allocation a JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=a.customer_id WHERE a.pallet_id=#{pallet} AND a.customer_id=#{customer} GROUP BY l.order_id,l.customer_id,l.destination_type,l.fba_warehouse_code")
|
||||
List<Physical> physical(@Param("pallet") long pallet,@Param("customer") String customer);
|
||||
|
||||
+2
@@ -68,7 +68,9 @@ public class RelabelDispatchSourceRepository {
|
||||
if (position == null) position = mapper.position(item.getSourceInventoryPositionId());
|
||||
if (position == null) continue;
|
||||
positions.put(position.getId(), position);
|
||||
// 无效身份只作为本申请缺失组成保留,不能把其他未绑定位置误连为同一实托。
|
||||
if (warehouseId.equals(position.getOverseasWarehouseId()) && position.getPalletId() != null
|
||||
&& position.getPalletId() > 0
|
||||
&& !visitedPallets.contains(position.getPalletId())) pending.add(position.getPalletId());
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -149,8 +149,9 @@ public class RelabelDispatchSourceService implements RelabelDispatchSourcePort {
|
||||
long notified = number(item.getNotifiedPackageCount());
|
||||
if (!Objects.equals(item.getCustomerId(), app.getCustomerId()) || position == null
|
||||
|| !Objects.equals(position.getCustomerId(), app.getCustomerId())
|
||||
|| !Objects.equals(warehouseId, position.getOverseasWarehouseId()) || position.getPalletId() == null) {
|
||||
reasons.add(app.getRelabelNo() + ":明细当前位置缺失或客户、仓库归属不一致");
|
||||
|| !Objects.equals(warehouseId, position.getOverseasWarehouseId()) || position.getPalletId() == null
|
||||
|| position.getPalletId() <= 0) {
|
||||
reasons.add(app.getRelabelNo() + ":明细当前位置缺失、实托身份无效或客户、仓库归属不一致");
|
||||
bound = false;
|
||||
}
|
||||
if (item.getReturnCartonId() != null || "RETURN_BULK_SKU_LABEL".equals(item.getOperationType())) {
|
||||
|
||||
Reference in New Issue
Block a user