Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b53be23e8 | ||
|
|
c6bc3ac85b | ||
|
|
9f7cac22b3 | ||
|
|
17cf05c4e1 | ||
|
|
f37d52aead | ||
|
|
98f7d81bb1 |
@@ -5106,4 +5106,4 @@ CREATE TABLE ll_storage_rent_preparation (
|
||||
KEY idx_storage_rent_release (release_status,updated_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='仓租扫码资金预占与结算快照';
|
||||
|
||||
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 238 AS initialized_tables;
|
||||
SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 239 AS initialized_tables;
|
||||
|
||||
@@ -219,6 +219,7 @@ public class FinanceQueryService {
|
||||
row.outstandingAmount(), row.currency(), row.status(), row.version())).toList();
|
||||
}
|
||||
|
||||
/** 成本查询按来源付款事实回显金额,直接已付不能再次展示为待付款。 */
|
||||
public List<PayableSettlement> payableSettlements(Long customerId) {
|
||||
validateOptionalCustomer(customerId);
|
||||
String sql = "SELECT id,tenant_id,cost_no,cost_type,order_id,inbound_id,container_id,supplier_name_snapshot,fee_item_name,cost_amount,currency_code,status,created_at,version FROM fin_cost WHERE 1=1";
|
||||
@@ -227,9 +228,13 @@ public class FinanceQueryService {
|
||||
return jdbc.query(sql + " ORDER BY created_at DESC,id DESC",
|
||||
(rs, rowNum) -> {
|
||||
BigDecimal amount = rs.getBigDecimal("cost_amount");
|
||||
boolean directPaid = "SOURCE_DIRECT_PAID".equals(rs.getString("cost_type"));
|
||||
// 已确认直付成本已全额付款;作废历史不再计入有效已付,也不形成新应付。
|
||||
BigDecimal paid = directPaid && "CONFIRMED".equals(rs.getString("status")) ? amount : BigDecimal.ZERO;
|
||||
BigDecimal outstanding = directPaid ? BigDecimal.ZERO : amount;
|
||||
return new PayableSettlement(rs.getLong("id"), rs.getLong("tenant_id"), rs.getString("cost_no"), rs.getString("cost_type"),
|
||||
nullableLong(rs, "order_id"), nullableLong(rs, "inbound_id"), nullableLong(rs, "container_id"), rs.getString("supplier_name_snapshot"),
|
||||
rs.getString("fee_item_name"), amount, BigDecimal.ZERO, amount, rs.getString("currency_code"), rs.getString("status"),
|
||||
rs.getString("fee_item_name"), amount, paid, outstanding, rs.getString("currency_code"), rs.getString("status"),
|
||||
time(rs, "created_at"), rs.getInt("version"));
|
||||
}, args);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.baiyue.finance;
|
||||
|
||||
import com.baiyue.finance.service.FinanceQueryService;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/** 成本列表和详情须与直接已付事实一致,不能提示供应商费用仍待支付。 */
|
||||
class FinanceDirectPaidCostReadTest {
|
||||
private JdbcTemplate jdbc;
|
||||
private FinanceQueryService service;
|
||||
|
||||
/** 使用隔离内存表验证正式读取路径,不访问共享测试环境。 */
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
jdbc = new JdbcTemplate(new DriverManagerDataSource(
|
||||
"jdbc:h2:mem:cost-read-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""));
|
||||
jdbc.execute("CREATE TABLE fin_cost(id BIGINT PRIMARY KEY,tenant_id BIGINT,cost_no VARCHAR(128),"
|
||||
+ "cost_type VARCHAR(24),cost_origin VARCHAR(24),order_id BIGINT,inbound_id BIGINT,container_id BIGINT,"
|
||||
+ "supplier_name_snapshot VARCHAR(128),fee_item_name VARCHAR(128),cost_amount DECIMAL(18,2),"
|
||||
+ "currency_code VARCHAR(3),status VARCHAR(24),created_at TIMESTAMP,version INT)");
|
||||
jdbc.execute("CREATE TABLE fin_cost_allocation(id BIGINT,tenant_id BIGINT,cost_id BIGINT,order_id BIGINT,"
|
||||
+ "inbound_id BIGINT,volume_cbm DECIMAL(18,6),total_volume_cbm DECIMAL(18,6),allocation_rate DECIMAL(18,6),"
|
||||
+ "allocated_amount DECIMAL(18,2),rounding_adjustment BOOLEAN)");
|
||||
service = new FinanceQueryService(jdbc, new ObjectMapper());
|
||||
}
|
||||
|
||||
/** 头程与海外直付均已付款,列表和详情不能再次显示未付余额。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"BUSINESS_DIRECT_PAID", "FIRSTLEG_DIRECT_PAID"})
|
||||
void confirmedDirectPaidCostIsFullyPaidInListAndDetail(String origin) {
|
||||
insertCost(1, 11, "SOURCE_DIRECT_PAID", origin, "CONFIRMED");
|
||||
|
||||
var row = service.payableSettlements(11L).get(0);
|
||||
assertThat(row.payableAmount()).isEqualByComparingTo("0.03");
|
||||
assertThat(row.paidAmount()).isEqualByComparingTo("0.03");
|
||||
assertThat(row.outstandingAmount()).isZero();
|
||||
assertThat(service.cost(11, 1).cost()).isEqualTo(row);
|
||||
}
|
||||
|
||||
/** 已作废的直付历史保留原金额,但不再作为有效已付或待付款展示。 */
|
||||
@Test
|
||||
void voidedDirectPaidCostHasNoActivePaymentOrOutstandingAmount() {
|
||||
insertCost(1, 11, "SOURCE_DIRECT_PAID", "BUSINESS_DIRECT_PAID", "VOIDED");
|
||||
|
||||
var row = service.payableSettlements(11L).get(0);
|
||||
assertThat(row.payableAmount()).isEqualByComparingTo("0.03");
|
||||
assertThat(row.paidAmount()).isZero();
|
||||
assertThat(row.outstandingAmount()).isZero();
|
||||
assertThat(row.status()).isEqualTo("VOIDED");
|
||||
}
|
||||
|
||||
/** 人工补充成本没有直接已付事实,不能被自动视为付款完成。 */
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"SINGLE_ORDER", "CONTAINER_SHARED"})
|
||||
void manualCostKeepsItsExistingUnpaidAmounts(String type) {
|
||||
insertCost(1, 11, type, "MANUAL", "CONFIRMED");
|
||||
|
||||
var row = service.payableSettlements(11L).get(0);
|
||||
assertThat(row.paidAmount()).isZero();
|
||||
assertThat(row.outstandingAmount()).isEqualByComparingTo("0.03");
|
||||
}
|
||||
|
||||
/** 金额回显修复不能让其他客户读到成本列表或详情。 */
|
||||
@Test
|
||||
void costReadsKeepCustomerIsolation() {
|
||||
insertCost(1, 11, "SOURCE_DIRECT_PAID", "BUSINESS_DIRECT_PAID", "CONFIRMED");
|
||||
|
||||
assertThat(service.payableSettlements(12L)).isEmpty();
|
||||
assertThatThrownBy(() -> service.cost(12, 1)).hasMessageContaining("成本不存在或不属于客户");
|
||||
}
|
||||
|
||||
/** 仅构造测试所需的成本事实,金额对应本轮发现问题的小额样本。 */
|
||||
private void insertCost(long id, long customer, String type, String origin, String status) {
|
||||
jdbc.update("INSERT INTO fin_cost(id,tenant_id,cost_no,cost_type,cost_origin,order_id,container_id,"
|
||||
+ "supplier_name_snapshot,fee_item_name,cost_amount,currency_code,status,created_at,version) "
|
||||
+ "VALUES(?,?,?,?,?,101,201,'验收供应商','人工费',0.03,'CNY',?,CURRENT_TIMESTAMP,0)",
|
||||
id, customer, "COST-READ-TEST", type, origin, status);
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -215,7 +215,7 @@ class DispatchMergedRelabelPersistenceTest {
|
||||
assertThat(f.count("ll_dispatch_order")).isZero();assertThat(f.count("ll_dispatch_pallet_lock")).isZero();
|
||||
}
|
||||
|
||||
/** 只隔离测量和车队外部目录;来源提供者、候选、库存与所有排车Mapper均为实际实现。 */
|
||||
/** 隔离测量、车队及仓租门禁;来源提供者、候选、库存与所有排车Mapper均为实际实现。 */
|
||||
private static final class Merged {
|
||||
final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture();
|
||||
final DispatchService rules=new DispatchService();final DispatchPickingService picking;final DispatchPdaQueryService pda;
|
||||
@@ -261,6 +261,7 @@ class DispatchMergedRelabelPersistenceTest {
|
||||
ReflectionTestUtils.setField(attribution,"attributions",f.session.getMapper(DispatchPdaPickOrderMapper.class));ReflectionTestUtils.setField(attribution,"orders",f.session.getMapper(LastlegOrderMapper.class));ReflectionTestUtils.setField(attribution,"relabelSources",origins);
|
||||
picking=new DispatchPickingService(f.service,f.repository,sourceRepository,new DispatchPickingRepository(f.session.getMapper(DispatchPdaTaskMapper.class),f.session.getMapper(DispatchPdaPickMapper.class),f.session.getMapper(InventoryPositionMapper.class),f.session.getMapper(DispatchItemMapper.class)),attribution,inventory,mock(ReturnRelabelDispatchStock.class),new DispatchCommandLedger(new DispatchCommandRepository(f.session.getMapper(DispatchCommandMapper.class)),new DispatchCommandCodec(f.json)),new DispatchCommandHasher(f.json),f.json);
|
||||
ReflectionTestUtils.setField(picking,"sourceRules",rules);ReflectionTestUtils.setField(picking,"relabelSources",snapshots);
|
||||
ReflectionTestUtils.setField(picking,"storageRent",mock(com.baiyue.lastleg.storagerent.service.StorageRentService.class));
|
||||
pda=new DispatchPdaQueryService(new DispatchPdaReadRepository(f.session.getMapper(DispatchWholePdaReadMapper.class)),f.service);ReflectionTestUtils.setField(pda,"sourceOrigins",origins);
|
||||
balance(601,"101",5);position(101,601,"101",crossCustomer?2:5);
|
||||
if(crossCustomer){f.db.update("UPDATE ll_inventory_balance SET hold_packages=2 WHERE id=601");balance(602,"102",3);position(102,602,"102",3);}
|
||||
|
||||
+4
-2
@@ -424,9 +424,10 @@ class LastlegOrderServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void pageUsesPaginationAndPlatformFiltersWithoutCustomerScope() {
|
||||
void departedPageUsesPaginationAndPlatformFiltersWithoutCustomerScope() {
|
||||
var query = new LastlegOrderRequests.Query(3L, 25L, "LL-", "FIRST_LEG", "IN-", null,
|
||||
null, "", "PENDING_ASSIGNMENT", null, "LAX", null, null, null, null, null, null, null, null, null);
|
||||
null, "", "PENDING_ASSIGNMENT", null, "LAX", null, null, null, null, null,
|
||||
LastlegOrderRequests.SailingStage.AFTER_DEPARTURE, null, null, null);
|
||||
var resultPage = new com.baomidou.mybatisplus.extension.plugins.pagination.Page<LastlegOrder>(3, 25);
|
||||
resultPage.setTotal(0);
|
||||
when(orders.selectPage(any(), any())).thenReturn(resultPage);
|
||||
@@ -438,6 +439,7 @@ class LastlegOrderServiceTest {
|
||||
verify(orders).selectPage(page.capture(), any());
|
||||
assertEquals(3, page.getValue().getCurrent());
|
||||
assertEquals(25, page.getValue().getSize());
|
||||
verifyNoInteractions(preDeparture);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -279,6 +279,28 @@ test('task page permits explicit platform scope zero and positive customer scope
|
||||
}
|
||||
})
|
||||
|
||||
test('船节点分页接受已规范化的非 ISO 柜号,仍拒绝空值和未规范化输入', async () => {
|
||||
for (const trackingNo of ['JZXH10231043', 'TEST-GRAY-CAB-NOISO', 'ZCSU7881971']) {
|
||||
assert.equal(await taskPageStructure({ trackingNo }), true, trackingNo)
|
||||
}
|
||||
for (const trackingNo of ['', ' ', '\t', 'jzxh10231043', ' JZXH10231043', 'JZXH 10231043', null, undefined, 123]) {
|
||||
assert.equal(await taskPageStructure({ trackingNo }), false, String(trackingNo))
|
||||
}
|
||||
})
|
||||
|
||||
test('非 ISO 柜号沿本轮归属核验完成船节点详情及人工节点链', async () => {
|
||||
const fixture = markerOwnedContext()
|
||||
fixture.task.trackingNo = 'TEST-GRAY-CAB-NOISO'
|
||||
fixture.context.createdResources.phase3CarrierScheduleQuery.trackingNo = fixture.task.trackingNo
|
||||
|
||||
await runUntilPickupReady(fixture.context)
|
||||
|
||||
assert.equal(fixture.task.latestActualNode, 'PICKUP_READY')
|
||||
assert.ok(fixture.assertions.every((check) => check.passed))
|
||||
assert.ok(fixture.context.calls.filter((call) => call.method === 'POST')
|
||||
.every((call) => call.path === `${BASE}/${QUERY_ID}/manual-nodes`))
|
||||
})
|
||||
|
||||
test('task page still rejects negative, missing, numeric and noncanonical customer scopes', async () => {
|
||||
for (const customerId of ['-1', '-9007199254740993002', '', '00', '01', '+0', '0.0', 'customer', null, undefined, 0, 7]) {
|
||||
assert.equal(await taskPageStructure({ customerId }), false, `customerId=${String(customerId)}`)
|
||||
|
||||
@@ -10,7 +10,6 @@ export const STANDARD_NODES = Object.freeze([
|
||||
])
|
||||
export const EXCEPTION_CODES = Object.freeze(['CUSTOMS_HOLD', 'TERMINAL_HOLD', 'DOCUMENT_HOLD', 'UNKNOWN_HOLD'])
|
||||
export const NODE_SEQUENCES = Object.freeze(Object.fromEntries(STANDARD_NODES.map((node, index) => [node, (index + 1) * 10])))
|
||||
const ISO_CONTAINER_PATTERN = /^[A-Z]{3}[UJZ][0-9]{7}$/
|
||||
|
||||
function id(value) { return value == null ? '' : String(value) }
|
||||
function number(value) { return Number.isFinite(Number(value)) }
|
||||
@@ -20,16 +19,10 @@ function validLongId(value) { return typeof value === 'string' && /^[1-9]\d{15,}
|
||||
function validNumericId(value) { return typeof value === 'string' && /^\d+$/.test(value) && value !== '0' }
|
||||
function validCustomerScope(value) { return typeof value === 'string' && /^(?:0|[1-9]\d*)$/.test(value) }
|
||||
function validOperatorId(value) { return value === '0' || validNumericId(value) }
|
||||
// 柜号沿现行合同仅要求非空且已规范化,不能再以旧 ISO 格式或校验位拒绝合法业务事实。
|
||||
function validContainerNo(value) {
|
||||
if (typeof value !== 'string' || !ISO_CONTAINER_PATTERN.test(value)) return false
|
||||
const letterValues = [10, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 23, 24,
|
||||
25, 26, 27, 28, 29, 30, 31, 32, 34, 35, 36, 37, 38]
|
||||
let sum = 0
|
||||
for (let index = 0; index < 10; index += 1) {
|
||||
const code = value.charCodeAt(index)
|
||||
sum += (code >= 48 && code <= 57 ? code - 48 : letterValues[code - 65]) * (2 ** index)
|
||||
}
|
||||
return Number(value[10]) === (sum % 11) % 10
|
||||
return typeof value === 'string' && value.length > 0
|
||||
&& value === value.replace(/\s/g, '').toUpperCase()
|
||||
}
|
||||
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
|
||||
function rejected(response) { return response?.http === 200 && ['A0409', '409'].includes(codeOf(response)) }
|
||||
|
||||
Reference in New Issue
Block a user