fix: complete development acceptance paths
This commit is contained in:
@@ -10201,6 +10201,8 @@ SELECT r.id,p.id,0 FROM iam_role r JOIN iam_permission p ON p.tenant_id=0 AND p.
|
|||||||
AND p.permission_code IN('platform:firstleg:customs:list','platform:firstleg:customs:manage')
|
AND p.permission_code IN('platform:firstleg:customs:list','platform:firstleg:customs:manage')
|
||||||
WHERE r.tenant_id=0 AND r.role_code IN('PLATFORM_ADMIN','ORDER_OPERATOR');
|
WHERE r.tenant_id=0 AND r.role_code IN('PLATFORM_ADMIN','ORDER_OPERATOR');
|
||||||
|
|
||||||
|
USE baiyue_firstleg;
|
||||||
|
|
||||||
/* Phase 3: link inbound preload plans to one or more container loading plans. */
|
/* Phase 3: link inbound preload plans to one or more container loading plans. */
|
||||||
CREATE TABLE IF NOT EXISTS fl_preload_loading_plan_link (
|
CREATE TABLE IF NOT EXISTS fl_preload_loading_plan_link (
|
||||||
id BIGINT UNSIGNED NOT NULL COMMENT '关联ID',
|
id BIGINT UNSIGNED NOT NULL COMMENT '关联ID',
|
||||||
@@ -12586,6 +12588,8 @@ CALL queue_table_comment('baiyue_order', 'ord_order_status_history', '订单状
|
|||||||
CALL queue_table_comment('baiyue_order', 'ord_outbox_event', '订单事务Outbox表:与订单业务变更在同一本地事务写入,供后台发布器可靠投递RocketMQ,保证数据库状态与领域事件最终一致;不是普通操作日志');
|
CALL queue_table_comment('baiyue_order', 'ord_outbox_event', '订单事务Outbox表:与订单业务变更在同一本地事务写入,供后台发布器可靠投递RocketMQ,保证数据库状态与领域事件最终一致;不是普通操作日志');
|
||||||
CALL queue_table_comment('baiyue_order', 'sys_audit_outbox', '可靠审计事件本地发送箱');
|
CALL queue_table_comment('baiyue_order', 'sys_audit_outbox', '可靠审计事件本地发送箱');
|
||||||
CALL queue_table_comment('baiyue_warehouse', 'sys_audit_outbox', '可靠审计事件本地发送箱');
|
CALL queue_table_comment('baiyue_warehouse', 'sys_audit_outbox', '可靠审计事件本地发送箱');
|
||||||
|
CALL queue_table_comment('baiyue_firstleg', 'fl_order_declaration_event', 'Order客户申报事件永久幂等账本');
|
||||||
|
CALL queue_table_comment('baiyue_firstleg', 'fl_order_declaration_snapshot', 'Order客户申报版本快照;与箱码、实测、托盘、PDA和计费事实隔离');
|
||||||
CALL queue_table_comment('baiyue_warehouse', 'wh_express_company', '平台公共快递公司主数据,固定归属 tenant_id=0');
|
CALL queue_table_comment('baiyue_warehouse', 'wh_express_company', '平台公共快递公司主数据,固定归属 tenant_id=0');
|
||||||
CALL queue_table_comment('baiyue_warehouse', 'wh_location', '二期库位基础资料,默认一托盘对应一个库位且不作容量校验');
|
CALL queue_table_comment('baiyue_warehouse', 'wh_location', '二期库位基础资料,默认一托盘对应一个库位且不作容量校验');
|
||||||
CALL queue_table_comment('baiyue_warehouse', 'wh_location_occupancy', '已确认的库位占用事实记录,托盘字段仅为主数据快照');
|
CALL queue_table_comment('baiyue_warehouse', 'wh_location_occupancy', '已确认的库位占用事实记录,托盘字段仅为主数据快照');
|
||||||
@@ -17214,7 +17218,24 @@ CALL queue_column_comment('baiyue_firstleg','fl_loading_cabinet_evidence','updat
|
|||||||
CALL queue_column_comment('baiyue_firstleg','fl_loading_cabinet_evidence','updated_by_name','最后修改操作人名称快照');
|
CALL queue_column_comment('baiyue_firstleg','fl_loading_cabinet_evidence','updated_by_name','最后修改操作人名称快照');
|
||||||
CALL queue_column_comment('baiyue_firstleg','fl_loading_cabinet_evidence','created_at','创建时间');
|
CALL queue_column_comment('baiyue_firstleg','fl_loading_cabinet_evidence','created_at','创建时间');
|
||||||
CALL queue_column_comment('baiyue_firstleg','fl_loading_cabinet_evidence','updated_at','最后更新时间');
|
CALL queue_column_comment('baiyue_firstleg','fl_loading_cabinet_evidence','updated_at','最后更新时间');
|
||||||
|
CALL queue_column_comment('baiyue_firstleg','fl_order_declaration_event','tenant_id','客户ID和隔离键');
|
||||||
|
CALL queue_column_comment('baiyue_firstleg','fl_order_declaration_event','event_id','永久保留的Order Outbox事件ID');
|
||||||
|
CALL queue_column_comment('baiyue_firstleg','fl_order_declaration_event','order_id','Order订单ID');
|
||||||
|
CALL queue_column_comment('baiyue_firstleg','fl_order_declaration_event','order_version','事件携带的Order聚合版本');
|
||||||
|
CALL queue_column_comment('baiyue_firstleg','fl_order_declaration_event','request_hash','完整事件SHA-256,用于异参重放拒绝');
|
||||||
CALL queue_column_comment('baiyue_firstleg','fl_order_declaration_event','consumed_at','事件消费完成时间');
|
CALL queue_column_comment('baiyue_firstleg','fl_order_declaration_event','consumed_at','事件消费完成时间');
|
||||||
|
CALL queue_column_comment('baiyue_firstleg','fl_order_declaration_snapshot','tenant_id','客户ID和隔离键');
|
||||||
|
CALL queue_column_comment('baiyue_firstleg','fl_order_declaration_snapshot','order_id','Order订单ID');
|
||||||
|
CALL queue_column_comment('baiyue_firstleg','fl_order_declaration_snapshot','event_id','Order Outbox事件ID');
|
||||||
|
CALL queue_column_comment('baiyue_firstleg','fl_order_declaration_snapshot','order_version','Order聚合版本,严格单调消费');
|
||||||
|
CALL queue_column_comment('baiyue_firstleg','fl_order_declaration_snapshot','request_hash','完整事件SHA-256');
|
||||||
|
CALL queue_column_comment('baiyue_firstleg','fl_order_declaration_snapshot','customer_remark','客户备注快照');
|
||||||
|
CALL queue_column_comment('baiyue_firstleg','fl_order_declaration_snapshot','expected_arrival_at','预计到仓时间快照');
|
||||||
|
CALL queue_column_comment('baiyue_firstleg','fl_order_declaration_snapshot','channel_code','渠道编码快照');
|
||||||
|
CALL queue_column_comment('baiyue_firstleg','fl_order_declaration_snapshot','channel_name','渠道名称快照');
|
||||||
|
CALL queue_column_comment('baiyue_firstleg','fl_order_declaration_snapshot','cargoes_json','客户申报货物行完整快照,不覆盖实测事实');
|
||||||
|
CALL queue_column_comment('baiyue_firstleg','fl_order_declaration_snapshot','changes_json','本版本申报字段变化摘要');
|
||||||
|
CALL queue_column_comment('baiyue_firstleg','fl_order_declaration_snapshot','occurred_at','Order事件发生时间');
|
||||||
CALL queue_column_comment('baiyue_firstleg','fl_order_declaration_snapshot','created_at','快照创建时间');
|
CALL queue_column_comment('baiyue_firstleg','fl_order_declaration_snapshot','created_at','快照创建时间');
|
||||||
CALL queue_column_comment('baiyue_firstleg','fl_order_declaration_snapshot','updated_at','快照更新时间');
|
CALL queue_column_comment('baiyue_firstleg','fl_order_declaration_snapshot','updated_at','快照更新时间');
|
||||||
SET @unified_ddl = IF(
|
SET @unified_ddl = IF(
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ public class BusinessFeeSourceEventService {
|
|||||||
system, source.sourceType(), feeId);
|
system, source.sourceType(), feeId);
|
||||||
jdbc.update("UPDATE fin_cost SET status='VOIDED',voided_at=CURRENT_TIMESTAMP(3),void_reason='来源版本已替代',version=version+1,updated_by=0 WHERE cost_origin='BUSINESS_DIRECT_PAID' AND source_system=? AND source_type=? AND source_fee_id=? AND status='CONFIRMED'",
|
jdbc.update("UPDATE fin_cost SET status='VOIDED',voided_at=CURRENT_TIMESTAMP(3),void_reason='来源版本已替代',version=version+1,updated_by=0 WHERE cost_origin='BUSINESS_DIRECT_PAID' AND source_system=? AND source_type=? AND source_fee_id=? AND status='CONFIRMED'",
|
||||||
system, source.sourceType(), feeId);
|
system, source.sourceType(), feeId);
|
||||||
jdbc.update("INSERT INTO fin_fee_source(source_system,source_fee_id,source_fee_version,customer_id,source_type,source_no,business_type,business_id,business_no,inbound_code,warehouse_id,direction,order_id,inbound_id,container_id,container_booking_id,container_no,fee_item_code,fee_item_name,amount,currency_code,settlement_mode,paid_at,payment_method,payment_reference_no,payment_voucher_file_ids,source_hash,source_snapshot,status) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?, 'AVAILABLE')",
|
jdbc.update("INSERT INTO fin_fee_source(source_system,source_fee_id,source_fee_version,customer_id,source_type,source_no,business_type,business_id,business_no,inbound_code,warehouse_id,direction,order_id,inbound_id,container_id,container_booking_id,container_no,fee_item_code,fee_item_name,amount,currency_code,settlement_mode,paid_at,payment_method,payment_reference_no,payment_voucher_file_ids,source_hash,source_snapshot,review_status,status) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?, 'APPROVED','AVAILABLE')",
|
||||||
system, feeId, source.sourceVersion(), positive(source.customerId(), "customerId"),
|
system, feeId, source.sourceVersion(), positive(source.customerId(), "customerId"),
|
||||||
source.sourceType(), trim(source.sourceNo()), trim(source.businessType()), number(source.businessId()),
|
source.sourceType(), trim(source.sourceNo()), trim(source.businessType()), number(source.businessId()),
|
||||||
trim(source.businessNo()), trim(source.inboundCode()), number(source.warehouseId()), source.direction(),
|
trim(source.businessNo()), trim(source.inboundCode()), number(source.warehouseId()), source.direction(),
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import static org.assertj.core.api.Assertions.assertThat;
|
|||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.anyInt;
|
import static org.mockito.ArgumentMatchers.anyInt;
|
||||||
import static org.mockito.ArgumentMatchers.anyString;
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.ArgumentMatchers.argThat;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.ArgumentMatchers.startsWith;
|
import static org.mockito.ArgumentMatchers.startsWith;
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
@@ -50,7 +51,8 @@ class BusinessFeeSourceEventServiceTest {
|
|||||||
assertThat(service.consume(event)).isEqualTo(901L);
|
assertThat(service.consume(event)).isEqualTo(901L);
|
||||||
|
|
||||||
ArgumentCaptor<Object[]> values = ArgumentCaptor.forClass(Object[].class);
|
ArgumentCaptor<Object[]> values = ArgumentCaptor.forClass(Object[].class);
|
||||||
verify(jdbc).update(startsWith("INSERT INTO fin_fee_source"), values.capture());
|
verify(jdbc).update(argThat(sql -> sql.startsWith("INSERT INTO fin_fee_source")
|
||||||
|
&& sql.contains("review_status,status") && sql.contains("'APPROVED','AVAILABLE'")), values.capture());
|
||||||
assertThat(values.getValue()[3]).isEqualTo(10001L);
|
assertThat(values.getValue()[3]).isEqualTo(10001L);
|
||||||
assertThat(values.getValue()[5]).isEqualTo("PO-US-001");
|
assertThat(values.getValue()[5]).isEqualTo("PO-US-001");
|
||||||
assertThat(values.getValue()[9]).isNull();
|
assertThat(values.getValue()[9]).isNull();
|
||||||
|
|||||||
+6
-7
@@ -24,7 +24,6 @@ import org.springframework.web.bind.annotation.GetMapping;
|
|||||||
import org.springframework.web.bind.annotation.PathVariable;
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
|
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
@@ -81,30 +80,30 @@ public class InventoryQueryController {
|
|||||||
@GetMapping(value = BASE_PATH + "/balances/export", produces = "text/csv")
|
@GetMapping(value = BASE_PATH + "/balances/export", produces = "text/csv")
|
||||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_EXPORT + "')")
|
@PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_EXPORT + "')")
|
||||||
@Operation(summary = "Export filtered inbound-order inventory as UTF-8 CSV", description = "Required authority: " + OverseasPermissions.INVENTORY_EXPORT)
|
@Operation(summary = "Export filtered inbound-order inventory as UTF-8 CSV", description = "Required authority: " + OverseasPermissions.INVENTORY_EXPORT)
|
||||||
public ResponseEntity<StreamingResponseBody> exportBalances(
|
public ResponseEntity<byte[]> exportBalances(
|
||||||
@AuthenticationPrincipal AuthenticatedUser user,
|
@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
@RequestParam(required = false) String customerId,
|
@RequestParam(required = false) String customerId,
|
||||||
@Valid InventoryQueryRequests.BalanceQuery query) {
|
@Valid InventoryQueryRequests.BalanceQuery query) {
|
||||||
String scope = platformScope(user, customerId);
|
String scope = platformScope(user, customerId);
|
||||||
return csv(output -> service.writeBalancesCsv(scope, query, output), "inventory-balances.csv");
|
return csv(service.exportBalances(scope, query), "inventory-balances.csv");
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping(value = BASE_PATH + "/ledger/export", produces = "text/csv")
|
@GetMapping(value = BASE_PATH + "/ledger/export", produces = "text/csv")
|
||||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_EXPORT + "')")
|
@PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_EXPORT + "')")
|
||||||
@Operation(summary = "Export filtered inventory movements as UTF-8 CSV", description = "Required authority: " + OverseasPermissions.INVENTORY_EXPORT)
|
@Operation(summary = "Export filtered inventory movements as UTF-8 CSV", description = "Required authority: " + OverseasPermissions.INVENTORY_EXPORT)
|
||||||
public ResponseEntity<StreamingResponseBody> exportLedgers(
|
public ResponseEntity<byte[]> exportLedgers(
|
||||||
@AuthenticationPrincipal AuthenticatedUser user,
|
@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
@RequestParam(required = false) String customerId,
|
@RequestParam(required = false) String customerId,
|
||||||
@Valid InventoryQueryRequests.LedgerQuery query) {
|
@Valid InventoryQueryRequests.LedgerQuery query) {
|
||||||
String scope = platformScope(user, customerId);
|
String scope = platformScope(user, customerId);
|
||||||
return csv(output -> service.writeLedgersCsv(scope, query, output), "inventory-ledger.csv");
|
return csv(service.exportLedgers(scope, query), "inventory-ledger.csv");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ResponseEntity<StreamingResponseBody> csv(StreamingResponseBody stream, String filename) {
|
private static ResponseEntity<byte[]> csv(byte[] content, String filename) {
|
||||||
return ResponseEntity.ok()
|
return ResponseEntity.ok()
|
||||||
.contentType(new MediaType("text", "csv", StandardCharsets.UTF_8))
|
.contentType(new MediaType("text", "csv", StandardCharsets.UTF_8))
|
||||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=" + filename)
|
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=" + filename)
|
||||||
.body(stream);
|
.body(content);
|
||||||
}
|
}
|
||||||
|
|
||||||
private String platformScope(AuthenticatedUser user, String requestedCustomerId) {
|
private String platformScope(AuthenticatedUser user, String requestedCustomerId) {
|
||||||
|
|||||||
+2
-12
@@ -81,12 +81,7 @@ public class InventoryQueryService {
|
|||||||
|
|
||||||
public byte[] exportBalances(String customerId, InventoryQueryRequests.BalanceQuery query) {
|
public byte[] exportBalances(String customerId, InventoryQueryRequests.BalanceQuery query) {
|
||||||
try (ByteArrayOutputStream output = new ByteArrayOutputStream()) {
|
try (ByteArrayOutputStream output = new ByteArrayOutputStream()) {
|
||||||
validateStatus(query.inventoryStatus(), BALANCE_STATUSES);
|
writeBalancesCsv(customerId, query, output);
|
||||||
output.write(("\uFEFFcustomerId,customerName,overseasWarehouseCode,overseasWarehouseName,destinationWarehouseCode,destinationWarehouseName,inboundOrderId,inboundOrderNo,inboundCode,skuCode,productNameZh,productNameEn,totalPackages,availablePackages,holdPackages,allocatedPackages,pickedPackages,inTransitPackages,receivingPackages,procurementPackages\r\n").getBytes(StandardCharsets.UTF_8));
|
|
||||||
for (InventoryBalance value : mapper.balances(customerId, query, 0, 10_000)) {
|
|
||||||
InventoryBalanceView v = balanceView(value);
|
|
||||||
output.write((String.join(",", csv(v.customerId()), csv(v.customerName()), csv(v.overseasWarehouseCode()), csv(v.overseasWarehouseName()), csv(v.destinationWarehouseCode()), csv(v.destinationWarehouseName()), csv(v.inboundOrderId()), csv(v.inboundOrderNo()), csv(v.inboundCode()), csv(v.skuCode()), csv(v.productNameZh()), csv(v.productNameEn()), csv(v.totalPackages()), csv(v.availablePackages()), csv(v.holdPackages()), csv(v.allocatedPackages()), csv(v.pickedPackages()), csv(v.inTransitPackages()), csv(v.receivingPackages()), csv(v.procurementPackages())) + "\r\n").getBytes(StandardCharsets.UTF_8));
|
|
||||||
}
|
|
||||||
return output.toByteArray();
|
return output.toByteArray();
|
||||||
} catch (IOException e) { throw new IllegalStateException("Unable to export inventory balances", e); }
|
} catch (IOException e) { throw new IllegalStateException("Unable to export inventory balances", e); }
|
||||||
}
|
}
|
||||||
@@ -117,12 +112,7 @@ public class InventoryQueryService {
|
|||||||
|
|
||||||
public byte[] exportLedgers(String customerId, InventoryQueryRequests.LedgerQuery query) {
|
public byte[] exportLedgers(String customerId, InventoryQueryRequests.LedgerQuery query) {
|
||||||
try (ByteArrayOutputStream output = new ByteArrayOutputStream()) {
|
try (ByteArrayOutputStream output = new ByteArrayOutputStream()) {
|
||||||
validateDateRange(query);
|
writeLedgersCsv(customerId, query, output);
|
||||||
output.write(("\uFEFFoccurredAt,customerId,overseasWarehouseId,destinationWarehouseCode,inboundOrderId,inboundOrderNo,inboundCode,businessType,businessOrderNo,outboundOrderNo,palletNo,batchNo,statusFrom,statusTo,beforePackages,deltaPackages,afterPackages,operatorId,remark\r\n").getBytes(StandardCharsets.UTF_8));
|
|
||||||
for (InventoryLedger value : mapper.ledgers(customerId, query, 0, 10_000)) {
|
|
||||||
InventoryLedgerView v = ledgerView(value);
|
|
||||||
output.write((String.join(",", csv(v.occurredAt()), csv(v.customerId()), csv(v.overseasWarehouseId()), csv(v.destinationWarehouseCode()), csv(v.inboundOrderId()), csv(v.inboundOrderNo()), csv(v.inboundCode()), csv(v.businessType()), csv(v.businessOrderNo()), csv(v.outboundOrderNo()), csv(v.palletNo()), csv(v.batchNo()), csv(v.statusFrom()), csv(v.statusTo()), csv(v.beforePackages()), csv(v.deltaPackages()), csv(v.afterPackages()), csv(v.operatorId()), csv(v.remark())) + "\r\n").getBytes(StandardCharsets.UTF_8));
|
|
||||||
}
|
|
||||||
return output.toByteArray();
|
return output.toByteArray();
|
||||||
} catch (IOException e) { throw new IllegalStateException("Unable to export inventory ledger", e); }
|
} catch (IOException e) { throw new IllegalStateException("Unable to export inventory ledger", e); }
|
||||||
}
|
}
|
||||||
|
|||||||
+29
@@ -4,6 +4,7 @@ import com.baiyue.common.security.AuthenticatedUser;
|
|||||||
import com.baiyue.lastleg.inventory.query.dto.InventoryQueryRequests;
|
import com.baiyue.lastleg.inventory.query.dto.InventoryQueryRequests;
|
||||||
import com.baiyue.lastleg.inventory.query.service.InventoryQueryService;
|
import com.baiyue.lastleg.inventory.query.service.InventoryQueryService;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.test.util.ReflectionTestUtils;
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
@@ -13,6 +14,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
|
|||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
class InventoryQueryControllerTest {
|
class InventoryQueryControllerTest {
|
||||||
private final InventoryQueryService service = mock(InventoryQueryService.class);
|
private final InventoryQueryService service = mock(InventoryQueryService.class);
|
||||||
@@ -44,6 +46,33 @@ class InventoryQueryControllerTest {
|
|||||||
verify(service).dashboard(eq(null), eq(query));
|
verify(service).dashboard(eq(null), eq(query));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void balanceExportIsMaterializedBeforeReturningResponse() {
|
||||||
|
byte[] csv = "\uFEFFcustomerId\r\n10001\r\n".getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||||
|
when(service.exportBalances("10001", query)).thenReturn(csv);
|
||||||
|
|
||||||
|
ResponseEntity<byte[]> response = controller.exportBalances(user(0L, "PLATFORM"), "10001", query);
|
||||||
|
|
||||||
|
org.junit.jupiter.api.Assertions.assertArrayEquals(csv, response.getBody());
|
||||||
|
org.junit.jupiter.api.Assertions.assertEquals("text/csv;charset=UTF-8",
|
||||||
|
response.getHeaders().getContentType().toString());
|
||||||
|
verify(service).exportBalances("10001", query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void ledgerExportIsMaterializedBeforeReturningResponse() {
|
||||||
|
InventoryQueryRequests.LedgerQuery ledgerQuery = new InventoryQueryRequests.LedgerQuery(
|
||||||
|
1L, 20L, null, null, null, null, null, null, null, null, null, null, null, null, null);
|
||||||
|
byte[] csv = "\uFEFFoccurredAt\r\n".getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||||
|
when(service.exportLedgers("10001", ledgerQuery)).thenReturn(csv);
|
||||||
|
|
||||||
|
ResponseEntity<byte[]> response = controller.exportLedgers(
|
||||||
|
user(0L, "PLATFORM"), "10001", ledgerQuery);
|
||||||
|
|
||||||
|
org.junit.jupiter.api.Assertions.assertArrayEquals(csv, response.getBody());
|
||||||
|
verify(service).exportLedgers("10001", ledgerQuery);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void unauthenticatedPositionDetailIsRejected() {
|
void unauthenticatedPositionDetailIsRejected() {
|
||||||
InventoryQueryRequests.PositionQuery positionQuery =
|
InventoryQueryRequests.PositionQuery positionQuery =
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ test('成本与未结清结算单选择严格命中客户金额和可支付状
|
|||||||
{ id: 2, customerId: '101', amount: 17.31, feeItemName: body.feeItemName },
|
{ id: 2, customerId: '101', amount: 17.31, feeItemName: body.feeItemName },
|
||||||
], body)?.id, 2)
|
], body)?.id, 2)
|
||||||
assert.equal(findOpenSettlement([
|
assert.equal(findOpenSettlement([
|
||||||
|
{ settlementId: 3, outstandingAmount: 0, status: 'ISSUED' },
|
||||||
{ settlementId: 1, outstandingAmount: 0, status: 'PAID' },
|
{ settlementId: 1, outstandingAmount: 0, status: 'PAID' },
|
||||||
{ settlementId: 2, outstandingAmount: 9, status: 'ISSUED' },
|
{ settlementId: 2, outstandingAmount: 9, status: 'ISSUED' },
|
||||||
])?.settlementId, 2)
|
])?.settlementId, 2)
|
||||||
@@ -40,6 +41,7 @@ test('HTTP 模块覆盖直接已付投影、AP 排除、幂等冲突、冻结和
|
|||||||
'收款同键同参重放且异参冲突', 'AP 付款同键同参重放且异参冲突',
|
'收款同键同参重放且异参冲突', 'AP 付款同键同参重放且异参冲突',
|
||||||
'拒绝编辑、调整和作废且零漂移', '第二客户不能读取或确认他人账单',
|
'拒绝编辑、调整和作废且零漂移', '第二客户不能读取或确认他人账单',
|
||||||
'foreign-receipt', 'foreign-payable', 'merchant-qualification-fixture.mjs',
|
'foreign-receipt', 'foreign-payable', 'merchant-qualification-fixture.mjs',
|
||||||
|
'multi-bill-receipt', 'ap-partial-payment', 'settlementKey',
|
||||||
]) assert.ok(source.includes(token), token)
|
]) assert.ok(source.includes(token), token)
|
||||||
assert.match(source, /fixture\.marker !== context\.marker/)
|
assert.match(source, /fixture\.marker !== context\.marker/)
|
||||||
assert.match(source, /禁止借历史财务单据/)
|
assert.match(source, /禁止借历史财务单据/)
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ function key(context, operation) {
|
|||||||
return `${context.marker}-finance-direct-${operation}`.replace(/[^A-Za-z0-9_.:-]/g, '-').slice(0, 128)
|
return `${context.marker}-finance-direct-${operation}`.replace(/[^A-Za-z0-9_.:-]/g, '-').slice(0, 128)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function settlementKey(context, operation) {
|
||||||
|
return `${context.marker}-finance-settlement-${operation}`.replace(/[^A-Za-z0-9_.:-]/g, '-').slice(0, 128)
|
||||||
|
}
|
||||||
|
|
||||||
function write(context, method, path, body, operation, token = context.token, overseas = false) {
|
function write(context, method, path, body, operation, token = context.token, overseas = false) {
|
||||||
const headers = { 'Idempotency-Key': key(context, operation) }
|
const headers = { 'Idempotency-Key': key(context, operation) }
|
||||||
return overseas
|
return overseas
|
||||||
@@ -75,6 +79,7 @@ export function findDirectPaidCost(items, body) {
|
|||||||
|
|
||||||
export function findOpenSettlement(items) {
|
export function findOpenSettlement(items) {
|
||||||
return rows(items).find((item) => settlementId(item)
|
return rows(items).find((item) => settlementId(item)
|
||||||
|
&& outstanding(item) > 0
|
||||||
&& !['VOIDED', 'PAID'].includes(id(item?.status))
|
&& !['VOIDED', 'PAID'].includes(id(item?.status))
|
||||||
&& !['VOIDED', 'PAID'].includes(id(item?.paymentStatus))) ?? null
|
&& !['VOIDED', 'PAID'].includes(id(item?.paymentStatus))) ?? null
|
||||||
}
|
}
|
||||||
@@ -134,8 +139,11 @@ async function verifyDirectPaidProjection(context, fixture, published) {
|
|||||||
let source = null
|
let source = null
|
||||||
let costs = []
|
let costs = []
|
||||||
let summary = null
|
let summary = null
|
||||||
for (let attempt = 0; attempt < 40; attempt += 1) {
|
let sourcesResponse = null
|
||||||
const [sourcesResponse, costsResponse, summaryResponse] = await Promise.all([
|
let costsResponse = null
|
||||||
|
let summaryResponse = null
|
||||||
|
for (let attempt = 0; attempt < 120; attempt += 1) {
|
||||||
|
;[sourcesResponse, costsResponse, summaryResponse] = await Promise.all([
|
||||||
context.request('GET', `${FINANCE}/fee-sources?page=1&pageSize=200&customerId=${encodeURIComponent(fixture.customerId)}&direction=PAYABLE`),
|
context.request('GET', `${FINANCE}/fee-sources?page=1&pageSize=200&customerId=${encodeURIComponent(fixture.customerId)}&direction=PAYABLE`),
|
||||||
context.request('GET', `${FINANCE}/cost-entries?customerId=${encodeURIComponent(fixture.customerId)}`),
|
context.request('GET', `${FINANCE}/cost-entries?customerId=${encodeURIComponent(fixture.customerId)}`),
|
||||||
context.request('GET', `${FINANCE}/customer-accounts/${encodeURIComponent(fixture.customerId)}/summary`),
|
context.request('GET', `${FINANCE}/customer-accounts/${encodeURIComponent(fixture.customerId)}/summary`),
|
||||||
@@ -151,7 +159,7 @@ async function verifyDirectPaidProjection(context, fixture, published) {
|
|||||||
context.assert(MODULE, 'DIRECT_PAID 生成 BUSINESS_DIRECT_PAID 成本并进入客户总成本和已付成本', null,
|
context.assert(MODULE, 'DIRECT_PAID 生成 BUSINESS_DIRECT_PAID 成本并进入客户总成本和已付成本', null,
|
||||||
Boolean(source && cost) && Number(summary?.totalCost) >= published.body.amount
|
Boolean(source && cost) && Number(summary?.totalCost) >= published.body.amount
|
||||||
&& Number(summary?.paidCost) >= published.body.amount,
|
&& Number(summary?.paidCost) >= published.body.amount,
|
||||||
`source=${sourceId(source) || 'missing'}, cost=${costId(cost) || 'missing'}, totalCost=${summary?.totalCost ?? 'missing'}, paidCost=${summary?.paidCost ?? 'missing'}`, OPTIONS)
|
`source=${sourceId(source) || 'missing'}, cost=${costId(cost) || 'missing'}, totalCost=${summary?.totalCost ?? 'missing'}, paidCost=${summary?.paidCost ?? 'missing'}; sources=${diagnostic(sourcesResponse)}; costs=${diagnostic(costsResponse)}; summary=${diagnostic(summaryResponse)}`, OPTIONS)
|
||||||
const apPoolResponse = await context.request('GET',
|
const apPoolResponse = await context.request('GET',
|
||||||
`${FINANCE}/fee-pool?customerId=${encodeURIComponent(fixture.customerId)}&direction=PAYABLE`)
|
`${FINANCE}/fee-pool?customerId=${encodeURIComponent(fixture.customerId)}&direction=PAYABLE`)
|
||||||
const apListResponse = await context.request('GET',
|
const apListResponse = await context.request('GET',
|
||||||
@@ -164,45 +172,43 @@ async function verifyDirectPaidProjection(context, fixture, published) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function verifyPaymentIdempotency(context, fixture) {
|
async function verifyPaymentIdempotency(context, fixture) {
|
||||||
const ar = findOpenSettlement(fixture.receivableSettlements)
|
const receivables = rows(fixture.receivableSettlements).filter((item) => settlementId(item) && statementId(item))
|
||||||
const ap = findOpenSettlement(fixture.payableSettlements)
|
const ap = rows(fixture.payableSettlements).find((item) => settlementId(item))
|
||||||
if (!ar || !ap) {
|
const priorPayment = fixture.payments?.partial
|
||||||
blocker(context, '取得未结清 AR/AP 用于收付款幂等',
|
const priorPaymentTransaction = rows(priorPayment?.transactions)[0]
|
||||||
`AR=${settlementId(ar) || 'missing'}, AP=${settlementId(ap) || 'missing'};需在 finance-settlement-full 消费前执行本模块`, null,
|
if (receivables.length < 3 || !ap || !fixture.receipts?.multi || !priorPayment || !priorPaymentTransaction) {
|
||||||
'open-finance-settlement-unavailable')
|
blocker(context, '取得前序收付款成功结果用于幂等重放',
|
||||||
|
`AR=${receivables.length}, AP=${settlementId(ap) || 'missing'}, receipt=${id(fixture.receipts?.multi?.id) || 'missing'}, payment=${settlementId(priorPayment) || 'missing'};需在 finance-settlement-full 后执行本模块`, null,
|
||||||
|
'completed-finance-payment-fixture-unavailable')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const arDetailResponse = await context.request('GET', `${FINANCE}/settlements/${encodeURIComponent(settlementId(ar))}`)
|
const allocationAmount = Number(fixture.multiBillAllocationAmount || 1)
|
||||||
const arDetail = context.apiSuccess(MODULE, '读取未结清 AR 详情用于收款幂等', arDetailResponse,
|
const allocations = receivables.slice(0, 3).map((item) => ({ billId: Number(statementId(item)), amount: allocationAmount }))
|
||||||
diagnostic(arDetailResponse), OPTIONS)
|
const receiptBody = { customerId: id(fixture.customerId), receivedDate: today(), amount: allocationAmount * allocations.length,
|
||||||
const receiptAmount = Math.min(0.01, outstanding(arDetail))
|
|
||||||
const receiptBody = { customerId: id(fixture.customerId), receivedDate: today(), amount: receiptAmount,
|
|
||||||
currency: 'CNY', paymentMethod: 'BANK_TRANSFER', voucherFileIds: [id(fixture.voucherFileId)],
|
currency: 'CNY', paymentMethod: 'BANK_TRANSFER', voucherFileIds: [id(fixture.voucherFileId)],
|
||||||
remark: context.marker, allocations: [{ billId: statementId(ar), amount: receiptAmount }] }
|
remark: context.marker, allocations }
|
||||||
const receipt = await write(context, 'POST', `${FINANCE}/receipts`, receiptBody, 'receipt-replay')
|
const receiptHeaders = { 'Idempotency-Key': settlementKey(context, 'multi-bill-receipt') }
|
||||||
const receiptReplay = await write(context, 'POST', `${FINANCE}/receipts`, receiptBody, 'receipt-replay')
|
const receiptReplay = await context.request('POST', `${FINANCE}/receipts`, receiptBody, context.token, receiptHeaders)
|
||||||
const receiptConflict = await write(context, 'POST', `${FINANCE}/receipts`,
|
const receiptConflict = await context.request('POST', `${FINANCE}/receipts`,
|
||||||
{ ...receiptBody, remark: `${context.marker}-different` }, 'receipt-replay')
|
{ ...receiptBody, remark: `${context.marker}-different` }, context.token, receiptHeaders)
|
||||||
context.assert(MODULE, '收款同键同参重放且异参冲突', receiptReplay,
|
context.assert(MODULE, '收款同键同参重放且异参冲突', receiptReplay,
|
||||||
ok(receipt) && ok(receiptReplay)
|
ok(receiptReplay)
|
||||||
&& id(data(receipt)?.receiptId ?? data(receipt)?.id) === id(data(receiptReplay)?.receiptId ?? data(receiptReplay)?.id)
|
&& id(fixture.receipts.multi?.id) === id(data(receiptReplay)?.receiptId ?? data(receiptReplay)?.id)
|
||||||
&& rejected(receiptConflict),
|
&& rejected(receiptConflict),
|
||||||
`receipt=${id(data(receipt)?.receiptId ?? data(receipt)?.id) || 'missing'}; conflict=${diagnostic(receiptConflict)}`, OPTIONS)
|
`receipt=${id(data(receiptReplay)?.receiptId ?? data(receiptReplay)?.id) || 'missing'}; conflict=${diagnostic(receiptConflict)}`, OPTIONS)
|
||||||
|
|
||||||
const apDetailResponse = await context.request('GET', `${FINANCE}/settlements/${encodeURIComponent(settlementId(ap))}`)
|
const paymentBody = { transactionDate: id(priorPaymentTransaction.transactionDate) || today(),
|
||||||
const apDetail = data(apDetailResponse)
|
amount: Number(priorPaymentTransaction.amount), currency: 'CNY', method: 'BANK_TRANSFER',
|
||||||
const paymentAmount = Math.min(0.01, outstanding(apDetail))
|
referenceNo: `${context.marker}-AP`, voucherFileIds: [id(fixture.voucherFileId)],
|
||||||
const paymentBody = { transactionDate: today(), amount: paymentAmount, currency: 'CNY', method: 'BANK_TRANSFER',
|
expectedVersion: Math.max(0, version(priorPayment) - 1), remark: context.marker }
|
||||||
referenceNo: `${context.marker}-AP-IDEMPOTENT`, voucherFileIds: [id(fixture.voucherFileId)],
|
|
||||||
expectedVersion: version(apDetail), remark: context.marker }
|
|
||||||
const path = `${FINANCE}/settlements/${encodeURIComponent(settlementId(ap))}/transactions`
|
const path = `${FINANCE}/settlements/${encodeURIComponent(settlementId(ap))}/transactions`
|
||||||
const payment = await write(context, 'POST', path, paymentBody, 'ap-replay')
|
const paymentHeaders = { 'Idempotency-Key': settlementKey(context, 'ap-partial-payment') }
|
||||||
const paymentReplay = await write(context, 'POST', path, paymentBody, 'ap-replay')
|
const paymentReplay = await context.request('POST', path, paymentBody, context.token, paymentHeaders)
|
||||||
const paymentConflict = await write(context, 'POST', path,
|
const paymentConflict = await context.request('POST', path,
|
||||||
{ ...paymentBody, referenceNo: `${paymentBody.referenceNo}-different` }, 'ap-replay')
|
{ ...paymentBody, referenceNo: `${paymentBody.referenceNo}-different` }, context.token, paymentHeaders)
|
||||||
context.assert(MODULE, 'AP 付款同键同参重放且异参冲突', paymentReplay,
|
context.assert(MODULE, 'AP 付款同键同参重放且异参冲突', paymentReplay,
|
||||||
ok(payment) && ok(paymentReplay) && rejected(paymentConflict)
|
ok(paymentReplay) && rejected(paymentConflict)
|
||||||
&& version(data(paymentReplay)) === version(data(payment)),
|
&& version(data(paymentReplay)) >= version(priorPayment),
|
||||||
`settlementId=${settlementId(ap)}, version=${version(data(paymentReplay))}; conflict=${diagnostic(paymentConflict)}`, OPTIONS)
|
`settlementId=${settlementId(ap)}, version=${version(data(paymentReplay))}; conflict=${diagnostic(paymentConflict)}`, OPTIONS)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,26 @@ import { fileURLToPath } from 'node:url'
|
|||||||
import { stableLinkSnapshot } from './phase3-loading-model-bridge.mjs'
|
import { stableLinkSnapshot } from './phase3-loading-model-bridge.mjs'
|
||||||
|
|
||||||
const source = readFileSync(fileURLToPath(new URL('./phase3-loading-model-bridge.mjs', import.meta.url)), 'utf8')
|
const source = readFileSync(fileURLToPath(new URL('./phase3-loading-model-bridge.mjs', import.meta.url)), 'utf8')
|
||||||
|
const unifiedSql = readFileSync(fileURLToPath(new URL('../../deploy/sql/头程数据库统一增量-MySQL8.sql', import.meta.url)), 'utf8')
|
||||||
|
|
||||||
|
test('unified migration selects firstleg before creating the loading model bridge', () => {
|
||||||
|
assert.match(
|
||||||
|
unifiedSql,
|
||||||
|
/USE baiyue_firstleg;\s*\/\* Phase 3: link inbound preload plans to one or more container loading plans\. \*\/\s*CREATE TABLE IF NOT EXISTS fl_preload_loading_plan_link/,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('unified migration converges declaration ledger table comments before final schema validation', () => {
|
||||||
|
assert.match(unifiedSql, /queue_table_comment\('baiyue_firstleg', 'fl_order_declaration_event', '[^']*[一-龥][^']*'\)/)
|
||||||
|
assert.match(unifiedSql, /queue_table_comment\('baiyue_firstleg', 'fl_order_declaration_snapshot', '[^']*[一-龥][^']*'\)/)
|
||||||
|
for (const column of ['tenant_id', 'event_id', 'order_id', 'order_version', 'request_hash', 'consumed_at']) {
|
||||||
|
assert.match(unifiedSql, new RegExp(`queue_column_comment\\('baiyue_firstleg','fl_order_declaration_event','${column}','[^']*[一-龥][^']*'\\)`))
|
||||||
|
}
|
||||||
|
for (const column of ['tenant_id', 'order_id', 'event_id', 'order_version', 'request_hash', 'customer_remark',
|
||||||
|
'expected_arrival_at', 'channel_code', 'channel_name', 'cargoes_json', 'changes_json', 'occurred_at', 'created_at', 'updated_at']) {
|
||||||
|
assert.match(unifiedSql, new RegExp(`queue_column_comment\\('baiyue_firstleg','fl_order_declaration_snapshot','${column}','[^']*[一-龥][^']*'\\)`))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test('bridge acceptance treats an existing pair as a read-only idempotent replay', () => {
|
test('bridge acceptance treats an existing pair as a read-only idempotent replay', () => {
|
||||||
assert.match(source, /已由推送建立的同一桥接 pair 重放返回原关联/)
|
assert.match(source, /已由推送建立的同一桥接 pair 重放返回原关联/)
|
||||||
|
|||||||
@@ -13,6 +13,29 @@ const PHASE4_READ_CODES = [
|
|||||||
'platform:overseas-business:looseboard:list',
|
'platform:overseas-business:looseboard:list',
|
||||||
]
|
]
|
||||||
const PHASE4_WRITE_SUFFIXES = [':create', ':edit', ':status', ':export', ':adjust', ':delete', ':review']
|
const PHASE4_WRITE_SUFFIXES = [':create', ':edit', ':status', ':export', ':adjust', ':delete', ':review']
|
||||||
|
const PHASE5_OPERATOR_CODES = [
|
||||||
|
'platform:overseas-warehouse:dispatch:list',
|
||||||
|
'platform:overseas-warehouse:dispatch:edit',
|
||||||
|
'platform:overseas-warehouse:dispatch:cancel',
|
||||||
|
'platform:overseas-warehouse:dispatch:transfer-delivery',
|
||||||
|
'platform:overseas-warehouse:outbound:list',
|
||||||
|
'platform:overseas-warehouse:outbound:create',
|
||||||
|
'platform:overseas-warehouse:outbound:cancel',
|
||||||
|
'platform:overseas-warehouse:delivery:list',
|
||||||
|
'platform:overseas-warehouse:exception-order:list',
|
||||||
|
'platform:overseas-warehouse:exception-order:create',
|
||||||
|
'platform:overseas-warehouse:delivery:return',
|
||||||
|
'platform:overseas-warehouse:delivery:status',
|
||||||
|
'platform:overseas-warehouse:customer-visit:list',
|
||||||
|
'platform:overseas-warehouse:customer-visit:edit',
|
||||||
|
]
|
||||||
|
const PHASE5_TALLY_CODES = [
|
||||||
|
'platform:overseas-warehouse:dispatch:list',
|
||||||
|
'platform:overseas-warehouse:outbound:list',
|
||||||
|
'platform:overseas-warehouse:delivery:list',
|
||||||
|
'platform:overseas-warehouse:exception-order:list',
|
||||||
|
'platform:overseas-warehouse:customer-visit:list',
|
||||||
|
]
|
||||||
|
|
||||||
function id(value) { return value == null ? '' : String(value) }
|
function id(value) { return value == null ? '' : String(value) }
|
||||||
|
|
||||||
@@ -62,38 +85,41 @@ export async function run(context) {
|
|||||||
`permissions=${[...overseasPdaCodes].sort().join(',')}`, OPTIONS)
|
`permissions=${[...overseasPdaCodes].sort().join(',')}`, OPTIONS)
|
||||||
|
|
||||||
const operatorCodes = roleCodes('WAREHOUSE_OPERATOR')
|
const operatorCodes = roleCodes('WAREHOUSE_OPERATOR')
|
||||||
context.assert(MODULE, '操作员具备国内入仓权限与四期十项只读权限', rolesResponse,
|
context.assert(MODULE, '操作员具备国内入仓权限、四期只读权限与五期履约权限', rolesResponse,
|
||||||
operatorCodes.has('platform:firstleg:inbound:loading:view')
|
operatorCodes.has('platform:firstleg:inbound:loading:view')
|
||||||
&& operatorCodes.has('platform:firstleg:inbound:loading:edit')
|
&& operatorCodes.has('platform:firstleg:inbound:loading:edit')
|
||||||
&& operatorCodes.size === 15
|
&& operatorCodes.size === 29
|
||||||
&& PHASE4_READ_CODES.every((code) => operatorCodes.has(code))
|
&& PHASE4_READ_CODES.every((code) => operatorCodes.has(code))
|
||||||
|
&& PHASE5_OPERATOR_CODES.every((code) => operatorCodes.has(code))
|
||||||
&& !operatorCodes.has('platform:firstleg:inbound:loading:complete')
|
&& !operatorCodes.has('platform:firstleg:inbound:loading:complete')
|
||||||
&& !operatorCodes.has('platform:firstleg:pda:assign')
|
&& !operatorCodes.has('platform:firstleg:pda:assign')
|
||||||
&& !operatorCodes.has('platform:firstleg:outbound-dispatch:confirm')
|
&& !operatorCodes.has('platform:firstleg:outbound-dispatch:confirm')
|
||||||
&& !operatorCodes.has('platform:firstleg:outbound-dispatch:reverse')
|
&& !operatorCodes.has('platform:firstleg:outbound-dispatch:reverse')
|
||||||
&& [...operatorCodes].filter((code) => code.startsWith('platform:overseas-'))
|
&& [...operatorCodes].filter((code) => code.startsWith('platform:overseas-'))
|
||||||
.every((code) => !PHASE4_WRITE_SUFFIXES.some((suffix) => code.endsWith(suffix))),
|
.every((code) => PHASE4_READ_CODES.includes(code) || PHASE5_OPERATOR_CODES.includes(code)),
|
||||||
`permissionCount=${operatorCodes.size}`, OPTIONS)
|
`permissionCount=${operatorCodes.size}`, OPTIONS)
|
||||||
|
|
||||||
const tallyCodes = roleCodes('WAREHOUSE_TALLY')
|
const tallyCodes = roleCodes('WAREHOUSE_TALLY')
|
||||||
context.assert(MODULE, '理货员可后台维护证据和复核但不能完成装柜', rolesResponse,
|
context.assert(MODULE, '理货员可维护证据、复核并只读查看海外履约', rolesResponse,
|
||||||
tallyCodes.has('platform:firstleg:inbound:loading:view')
|
tallyCodes.has('platform:firstleg:inbound:loading:view')
|
||||||
&& tallyCodes.has('platform:firstleg:inbound:loading:edit')
|
&& tallyCodes.has('platform:firstleg:inbound:loading:edit')
|
||||||
&& tallyCodes.has('platform:firstleg:pda:review')
|
&& tallyCodes.has('platform:firstleg:pda:review')
|
||||||
&& tallyCodes.size === 22
|
&& tallyCodes.size === 28
|
||||||
&& PHASE4_READ_CODES.every((code) => tallyCodes.has(code))
|
&& PHASE4_READ_CODES.every((code) => tallyCodes.has(code))
|
||||||
|
&& PHASE5_TALLY_CODES.every((code) => tallyCodes.has(code))
|
||||||
&& !tallyCodes.has('platform:firstleg:inbound:loading:complete')
|
&& !tallyCodes.has('platform:firstleg:inbound:loading:complete')
|
||||||
&& !tallyCodes.has('platform:firstleg:outbound-dispatch:confirm')
|
&& !tallyCodes.has('platform:firstleg:outbound-dispatch:confirm')
|
||||||
&& [...tallyCodes].filter((code) => code.startsWith('platform:overseas-'))
|
&& [...tallyCodes].filter((code) => code.startsWith('platform:overseas-'))
|
||||||
.every((code) => !PHASE4_WRITE_SUFFIXES.some((suffix) => code.endsWith(suffix))),
|
.every((code) => PHASE4_READ_CODES.includes(code) || PHASE5_TALLY_CODES.includes(code)),
|
||||||
`permissionCount=${tallyCodes.size}`, OPTIONS)
|
`permissionCount=${tallyCodes.size}`, OPTIONS)
|
||||||
|
|
||||||
context.assert(MODULE, '四期list权限同时用于列表与详情且不隐含写权限', rolesResponse,
|
context.assert(MODULE, '四期只读与五期岗位权限集合严格匹配', rolesResponse,
|
||||||
PHASE4_READ_CODES.every((code) => code.endsWith(':list'))
|
PHASE4_READ_CODES.every((code) => code.endsWith(':list'))
|
||||||
&& [...operatorCodes, ...tallyCodes]
|
&& PHASE5_TALLY_CODES.every((code) => code.endsWith(':list'))
|
||||||
.filter((code) => code.startsWith('platform:overseas-'))
|
&& PHASE5_OPERATOR_CODES.some((code) => PHASE4_WRITE_SUFFIXES.some((suffix) => code.endsWith(suffix)))
|
||||||
.every((code) => PHASE4_READ_CODES.includes(code)),
|
&& PHASE5_OPERATOR_CODES.every((code) => operatorCodes.has(code))
|
||||||
`phase4ReadCodes=${PHASE4_READ_CODES.length}`, OPTIONS)
|
&& PHASE5_TALLY_CODES.every((code) => tallyCodes.has(code)),
|
||||||
|
`phase4ReadCodes=${PHASE4_READ_CODES.length}, phase5OperatorCodes=${PHASE5_OPERATOR_CODES.length}, phase5TallyCodes=${PHASE5_TALLY_CODES.length}`, OPTIONS)
|
||||||
|
|
||||||
context.assert(MODULE, '仓库系统角色不包含任何客户侧权限', rolesResponse,
|
context.assert(MODULE, '仓库系统角色不包含任何客户侧权限', rolesResponse,
|
||||||
[...operatorCodes, ...domesticPdaCodes, ...overseasPdaCodes, ...tallyCodes]
|
[...operatorCodes, ...domesticPdaCodes, ...overseasPdaCodes, ...tallyCodes]
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { readFileSync } from 'node:fs'
|
|||||||
|
|
||||||
const acceptance = readFileSync(new URL('./phase3-warehouse-rbac.mjs', import.meta.url), 'utf8')
|
const acceptance = readFileSync(new URL('./phase3-warehouse-rbac.mjs', import.meta.url), 'utf8')
|
||||||
const iamSql = readFileSync(new URL('../../iam/src/main/resources/db/lastleg-permissions.sql', import.meta.url), 'utf8')
|
const iamSql = readFileSync(new URL('../../iam/src/main/resources/db/lastleg-permissions.sql', import.meta.url), 'utf8')
|
||||||
|
const phase5Sql = readFileSync(new URL('../../iam/src/main/resources/db/phase5-delivery-permissions.sql', import.meta.url), 'utf8')
|
||||||
const unifiedSql = readFileSync(new URL('../../deploy/sql/头程数据库统一增量-MySQL8.sql', import.meta.url), 'utf8')
|
const unifiedSql = readFileSync(new URL('../../deploy/sql/头程数据库统一增量-MySQL8.sql', import.meta.url), 'utf8')
|
||||||
|
|
||||||
const listCodes = [
|
const listCodes = [
|
||||||
@@ -19,19 +20,42 @@ const listCodes = [
|
|||||||
'platform:overseas-business:looseboard:list',
|
'platform:overseas-business:looseboard:list',
|
||||||
]
|
]
|
||||||
|
|
||||||
test('warehouse operator and tally use the exact phase-four list/detail permission set', () => {
|
test('warehouse operator and tally use the exact phase-four and phase-five permission sets', () => {
|
||||||
for (const code of listCodes) {
|
for (const code of listCodes) {
|
||||||
assert.match(acceptance, new RegExp(code.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')))
|
assert.match(acceptance, new RegExp(code.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')))
|
||||||
assert.match(iamSql, new RegExp(`'WAREHOUSE_OPERATOR','${code}'`))
|
assert.match(iamSql, new RegExp(`'WAREHOUSE_OPERATOR','${code}'`))
|
||||||
assert.match(iamSql, new RegExp(`'WAREHOUSE_TALLY','${code}'`))
|
assert.match(iamSql, new RegExp(`'WAREHOUSE_TALLY','${code}'`))
|
||||||
}
|
}
|
||||||
assert.match(acceptance, /operatorCodes\.size === 15/)
|
assert.match(acceptance, /operatorCodes\.size === 29/)
|
||||||
assert.match(acceptance, /tallyCodes\.size === 22/)
|
assert.match(acceptance, /tallyCodes\.size === 28/)
|
||||||
assert.match(acceptance, /PHASE4_WRITE_SUFFIXES/)
|
assert.match(acceptance, /PHASE4_WRITE_SUFFIXES/)
|
||||||
assert.match(iamSql, /assert_phase4_warehouse_role_minimum/)
|
assert.match(iamSql, /assert_phase4_warehouse_role_minimum/)
|
||||||
assert.match(iamSql, /permission_code REGEXP ':\(create\|edit\|status\|export\|adjust\|delete\|review\)\$'/)
|
assert.match(iamSql, /permission_code REGEXP ':\(create\|edit\|status\|export\|adjust\|delete\|review\)\$'/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('warehouse roles include the exact phase-five fulfillment responsibilities', () => {
|
||||||
|
assert.match(acceptance, /PHASE5_OPERATOR_CODES/)
|
||||||
|
assert.match(acceptance, /PHASE5_TALLY_CODES/)
|
||||||
|
for (const code of [
|
||||||
|
'platform:overseas-warehouse:dispatch:list',
|
||||||
|
'platform:overseas-warehouse:dispatch:edit',
|
||||||
|
'platform:overseas-warehouse:dispatch:cancel',
|
||||||
|
'platform:overseas-warehouse:dispatch:transfer-delivery',
|
||||||
|
'platform:overseas-warehouse:outbound:list',
|
||||||
|
'platform:overseas-warehouse:outbound:create',
|
||||||
|
'platform:overseas-warehouse:outbound:cancel',
|
||||||
|
'platform:overseas-warehouse:delivery:list',
|
||||||
|
'platform:overseas-warehouse:exception-order:list',
|
||||||
|
'platform:overseas-warehouse:exception-order:create',
|
||||||
|
'platform:overseas-warehouse:delivery:return',
|
||||||
|
'platform:overseas-warehouse:delivery:status',
|
||||||
|
'platform:overseas-warehouse:customer-visit:list',
|
||||||
|
'platform:overseas-warehouse:customer-visit:edit'
|
||||||
|
]) {
|
||||||
|
assert.match(phase5Sql, new RegExp(code.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test('domestic and overseas PDA operators use isolated authorities', () => {
|
test('domestic and overseas PDA operators use isolated authorities', () => {
|
||||||
assert.match(acceptance, /domesticPdaCodes\.size === 2/)
|
assert.match(acceptance, /domesticPdaCodes\.size === 2/)
|
||||||
assert.match(acceptance, /overseasPdaCodes\.size === 1/)
|
assert.match(acceptance, /overseasPdaCodes\.size === 1/)
|
||||||
|
|||||||
+9
-7
@@ -24,9 +24,9 @@ function fixture() {
|
|||||||
const fees = new Map()
|
const fees = new Map()
|
||||||
let sequence = 7000
|
let sequence = 7000
|
||||||
const businessRows = {
|
const businessRows = {
|
||||||
'/api/platform/v1/last-leg/overseas-procurements': { id: '3001', orderNo: 'PO-1', customerId: '1001' },
|
'/api/platform/v1/last-leg/overseas-procurements': { id: '3001', orderNo: 'PO-1', customerId: '1101' },
|
||||||
'/api/platform/v1/last-leg/us-truck-orders': { id: '3002', orderNo: 'TK-1', customerId: '1001' },
|
'/api/platform/v1/last-leg/us-truck-orders': { id: '3002', orderNo: 'TK-1', customerId: '1201' },
|
||||||
'/api/platform/v1/last-leg/local-looseboard-orders': { id: '3003', orderNo: 'LB-1', customerId: '1001' },
|
'/api/platform/v1/last-leg/local-looseboard-orders': { id: '3003', orderNo: 'LB-1', customerId: '1301' },
|
||||||
}
|
}
|
||||||
const context = {
|
const context = {
|
||||||
marker,
|
marker,
|
||||||
@@ -36,9 +36,9 @@ function fixture() {
|
|||||||
createdResources: {
|
createdResources: {
|
||||||
merchantQualificationFixture: { tenantId: '1001' },
|
merchantQualificationFixture: { tenantId: '1001' },
|
||||||
phase4OverseasBusiness: {
|
phase4OverseasBusiness: {
|
||||||
purchase: { orderNo: 'PO-1', customerId: '1001' },
|
purchase: { orderNo: 'PO-1', customerId: '1101' },
|
||||||
truck: { orderNo: 'TK-1', customerId: '1001' },
|
truck: { orderNo: 'TK-1', customerId: '1201' },
|
||||||
looseboard: { orderNo: 'LB-1', customerId: '1001' },
|
looseboard: { orderNo: 'LB-1', customerId: '1301' },
|
||||||
},
|
},
|
||||||
phase4OverseasPda: { customerId: '2001', overseasWarehouseId: '9001' },
|
phase4OverseasPda: { customerId: '2001', overseasWarehouseId: '9001' },
|
||||||
},
|
},
|
||||||
@@ -77,7 +77,9 @@ function fixture() {
|
|||||||
const existing = commands.get(`CREATE:${key}`)
|
const existing = commands.get(`CREATE:${key}`)
|
||||||
if (existing) return existing.hash === hash ? response(existing.fee) : response(null, 'A0409')
|
if (existing) return existing.hash === hash ? response(existing.fee) : response(null, 'A0409')
|
||||||
const expectedCustomer = body.businessId === '4001' ? '2001'
|
const expectedCustomer = body.businessId === '4001' ? '2001'
|
||||||
: ['3001', '3002', '3003'].includes(String(body.businessId)) ? '1001' : body.customerId
|
: body.businessId === '3001' ? '1101'
|
||||||
|
: body.businessId === '3002' ? '1201'
|
||||||
|
: body.businessId === '3003' ? '1301' : body.customerId
|
||||||
if (body.businessId && body.customerId !== expectedCustomer) return response(null, 'A0400')
|
if (body.businessId && body.customerId !== expectedCustomer) return response(null, 'A0400')
|
||||||
const fee = { ...structuredClone(body), sourceFeeId: String(++sequence), sourceVersion: 1,
|
const fee = { ...structuredClone(body), sourceFeeId: String(++sequence), sourceVersion: 1,
|
||||||
reviewStatus: 'DRAFT', publicationStatus: 'UNPUBLISHED', sourceStatus: 'ACTIVE' }
|
reviewStatus: 'DRAFT', publicationStatus: 'UNPUBLISHED', sourceStatus: 'ACTIVE' }
|
||||||
|
|||||||
@@ -61,11 +61,14 @@ async function resolveFixtures(context) {
|
|||||||
const merchantCustomerId = id(context.createdResources?.merchantQualificationFixture?.tenantId
|
const merchantCustomerId = id(context.createdResources?.merchantQualificationFixture?.tenantId
|
||||||
|| business?.purchase?.customerId || business?.truck?.customerId || business?.looseboard?.customerId)
|
|| business?.purchase?.customerId || business?.truck?.customerId || business?.looseboard?.customerId)
|
||||||
const procurement = await resolveListedBusiness(context,
|
const procurement = await resolveListedBusiness(context,
|
||||||
'/api/platform/v1/last-leg/overseas-procurements', business?.purchase, merchantCustomerId, '海外采购')
|
'/api/platform/v1/last-leg/overseas-procurements', business?.purchase,
|
||||||
|
id(business?.purchase?.customerId), '海外采购')
|
||||||
const truck = await resolveListedBusiness(context,
|
const truck = await resolveListedBusiness(context,
|
||||||
'/api/platform/v1/last-leg/us-truck-orders', business?.truck, merchantCustomerId, '美国卡车')
|
'/api/platform/v1/last-leg/us-truck-orders', business?.truck,
|
||||||
|
id(business?.truck?.customerId), '美国卡车')
|
||||||
const looseboard = await resolveListedBusiness(context,
|
const looseboard = await resolveListedBusiness(context,
|
||||||
'/api/platform/v1/last-leg/local-looseboard-orders', business?.looseboard, merchantCustomerId, '本土散板')
|
'/api/platform/v1/last-leg/local-looseboard-orders', business?.looseboard,
|
||||||
|
id(business?.looseboard?.customerId), '本土散板')
|
||||||
|
|
||||||
const pda = context.createdResources?.phase4OverseasPda
|
const pda = context.createdResources?.phase4OverseasPda
|
||||||
const inboundCustomerId = id(pda?.customerId)
|
const inboundCustomerId = id(pda?.customerId)
|
||||||
|
|||||||
Reference in New Issue
Block a user