This commit is contained in:
2026-08-12 18:16:39 +08:00
parent 5a1cd862c9
commit 4063225352
13 changed files with 166 additions and 12 deletions
@@ -187,14 +187,14 @@ public class FinanceService {
for (var entry : byBill.entrySet()) {
BigDecimal applied = entry.getValue();
jdbc.update("INSERT INTO fin_receipt_bill(tenant_id,receipt_id,bill_id,applied_amount,created_by) VALUES(?,?,?,?,?)", tenantId, receiptId, entry.getKey(), applied, operatorId);
jdbc.update("UPDATE fin_bill SET received_amount=received_amount+?, outstanding_amount=outstanding_amount-?, status=CASE WHEN outstanding_amount-?=0 THEN 'PAID' ELSE 'PARTIALLY_PAID' END, updated_by=?, version=version+1 WHERE id=? AND tenant_id=?", applied, applied, applied, operatorId, entry.getKey(), tenantId);
jdbc.update("UPDATE fin_bill SET received_amount=received_amount+?, outstanding_amount=outstanding_amount-?, status=CASE WHEN outstanding_amount=0 THEN 'PAID' ELSE 'PARTIALLY_PAID' END, updated_by=?, version=version+1 WHERE id=? AND tenant_id=?", applied, applied, operatorId, entry.getKey(), tenantId);
Integer settlementCount = jdbc.queryForObject(
"SELECT COUNT(*) FROM fin_settlement WHERE tenant_id=? AND direction='RECEIVABLE' AND reconciliation_statement_id=? AND status<>'VOIDED'",
Integer.class, tenantId, entry.getKey());
if (settlementCount != null && settlementCount > 0) {
int settlementUpdated = jdbc.update(
"UPDATE fin_settlement SET paid_amount=paid_amount+?,outstanding_amount=outstanding_amount-?,payment_status=CASE WHEN outstanding_amount-?=0 THEN 'PAID' ELSE 'PARTIALLY_PAID' END,status=CASE WHEN outstanding_amount-?=0 THEN 'PAID' ELSE 'PARTIALLY_PAID' END,updated_by=?,version=version+1 WHERE tenant_id=? AND direction='RECEIVABLE' AND reconciliation_statement_id=? AND status IN ('ISSUED','PARTIALLY_PAID') AND outstanding_amount>=?",
applied, applied, applied, applied, operatorId, tenantId, entry.getKey(), applied);
"UPDATE fin_settlement SET paid_amount=paid_amount+?,outstanding_amount=outstanding_amount-?,payment_status=CASE WHEN outstanding_amount=0 THEN 'PAID' ELSE 'PARTIALLY_PAID' END,status=CASE WHEN outstanding_amount=0 THEN 'PAID' ELSE 'PARTIALLY_PAID' END,updated_by=?,version=version+1 WHERE tenant_id=? AND direction='RECEIVABLE' AND reconciliation_statement_id=? AND status IN ('ISSUED','PARTIALLY_PAID') AND outstanding_amount>=?",
applied, applied, operatorId, tenantId, entry.getKey(), applied);
if (settlementUpdated != 1) throw new BizException(ResultCode.CONFLICT, "应收结算单状态或余额已变化");
}
}
@@ -201,6 +201,9 @@ public class FinanceSettlementService {
"SELECT id,tenant_id,direction,currency_code,total_amount,paid_amount,outstanding_amount,status,version " +
"FROM fin_settlement WHERE id=? FOR UPDATE", "结算单不存在", settlementId);
long customerId = number(settlement, "tenant_id").longValue();
Long replay = reserve(customerId, operatorId, "SETTLEMENT_PAYMENT_CREATE", key, "POST",
"/api/platform/finance/v1/settlements/" + settlementId + "/transactions", request);
if (replay != null) return getSettlement(replay);
if (!"PAYABLE".equals(text(settlement, "direction"))) throw error("FIN_409", "仅应付结算单可登记付款");
if (!List.of("ISSUED", "PARTIALLY_PAID").contains(text(settlement, "status"))) {
throw error("FIN_409", "结算单状态不允许付款");
@@ -209,9 +212,6 @@ public class FinanceSettlementService {
if (number(settlement, "version").intValue() != request.expectedVersion()) throw error("FIN_409", "结算单版本冲突");
BigDecimal outstanding = decimal(settlement, "outstanding_amount");
if (request.amount().compareTo(outstanding) > 0) throw error("FIN_409", "付款金额不得超过未付余额");
Long replay = reserve(customerId, operatorId, "SETTLEMENT_PAYMENT_CREATE", key, "POST",
"/api/platform/finance/v1/settlements/" + settlementId + "/transactions", request);
if (replay != null) return getSettlement(settlementId);
validateVoucherFiles(customerId, request.voucherFileIds());
insertPayment(customerId, settlementId, request, operatorId);
@@ -126,9 +126,11 @@ class FinanceServiceValidationTest {
service.createReceipt(9L, 2L, request, "receipt-key");
verify(jdbc).update(startsWith("UPDATE fin_settlement SET paid_amount="),
eq(new BigDecimal("40.00")), eq(new BigDecimal("40.00")), eq(new BigDecimal("40.00")),
eq(new BigDecimal("40.00")), eq(2L), eq(9L), eq(50L), eq(new BigDecimal("40.00")));
verify(jdbc).update(contains("payment_status=CASE WHEN outstanding_amount=0"),
eq(new BigDecimal("40.00")), eq(new BigDecimal("40.00")),
eq(2L), eq(9L), eq(50L), eq(new BigDecimal("40.00")));
verify(jdbc).update(contains("status=CASE WHEN outstanding_amount=0"),
eq(new BigDecimal("40.00")), eq(new BigDecimal("40.00")), eq(2L), eq(50L), eq(9L));
}
@Test
@@ -39,6 +39,7 @@ import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.startsWith;
import org.mockito.ArgumentCaptor;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.RETURNS_DEFAULTS;
@@ -276,6 +277,47 @@ class FinanceSettlementBehaviorTest {
assertThat(result.summary().outstandingAmount()).isZero();
}
@Test
void finalPaymentReplayReturnsFirstResultWithoutDuplicatePayment() {
JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class, invocation ->
"update".equals(invocation.getMethod().getName()) ? 1 : RETURNS_DEFAULTS.answer(invocation));
FinanceFileClient fileClient = org.mockito.Mockito.mock(FinanceFileClient.class);
Map<String, Object> before = settlementRow("PAYABLE", "PARTIALLY_PAID", new BigDecimal("40.00"),
new BigDecimal("60.00"), 1);
Map<String, Object> after = settlementRow("PAYABLE", "PAID", new BigDecimal("100.00"), BigDecimal.ZERO, 2);
when(jdbc.queryForList(startsWith("SELECT id,tenant_id,direction"), eq(88L)))
.thenReturn(List.of(before), List.of(after));
when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L),
eq("SETTLEMENT_PAYMENT_CREATE"), eq("final-replay-key"))).thenReturn(List.of());
stubSettlementDetail(jdbc, after);
when(fileClient.metadata("internal-token", 10L, 10L, 91L)).thenReturn(ApiResponse.success(
new FinanceFileClient.Metadata(91L, 10L, "FINANCE_VOUCHER", "10", "pay.png", "abc", "image/png", 10L)));
FinanceSettlementService service = service(jdbc, fileClient);
SettlementDetail first = service.createSettlementPayment(88L, 7L, "final-replay-key",
payment(new BigDecimal("60.00"), 1));
ArgumentCaptor<String> requestHash = ArgumentCaptor.forClass(String.class);
verify(jdbc).update(startsWith("INSERT INTO fin_idempotency_record"), eq(10L), eq(7L),
eq("SETTLEMENT_PAYMENT_CREATE"), eq("final-replay-key"), requestHash.capture());
when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L),
eq("SETTLEMENT_PAYMENT_CREATE"), eq("final-replay-key"))).thenReturn(List.of(Map.of(
"request_hash", requestHash.getValue(), "status", "SUCCEEDED", "result_resource_id", 88L,
"updated_at", LocalDateTime.now())));
SettlementDetail replay = service.createSettlementPayment(88L, 7L, "final-replay-key",
payment(new BigDecimal("60.00"), 1));
assertThat(first.summary().status()).isEqualTo("PAID");
assertThat(replay.summary().status()).isEqualTo("PAID");
assertThat(replay.summary().outstandingAmount()).isZero();
long paymentInserts = org.mockito.Mockito.mockingDetails(jdbc).getInvocations().stream()
.filter(invocation -> invocation.getMethod().getName().equals("update")
&& invocation.getArguments().length > 0
&& String.valueOf((Object) invocation.getArgument(0)).startsWith("INSERT INTO fin_settlement_payment"))
.count();
assertThat(paymentInserts).isEqualTo(1);
}
@Test
void receivableSettlementDetailIncludesAppliedReceiptHistory() {
JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class);
@@ -46,9 +46,11 @@ public class PlatformLoadingPdaController {
@PreAuthorize("hasAuthority('platform:firstleg:pda:view')")
public ApiResponse<Task> task(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String containerNo,
@RequestParam(required = false) String cabinetNo,
@RequestParam(required = false) Long loadingPlanId,
@RequestParam @Positive long warehouseId) {
FirstlegScopeGuard.platform(user);
return ApiResponse.success(service.task(containerNo, warehouseId));
return ApiResponse.success(service.task(containerNo, cabinetNo, loadingPlanId, warehouseId));
}
@GetMapping("/tasks/pending")
@@ -31,6 +31,16 @@ public interface LoadingPickTaskMapper extends BaseMapper<LoadingPickTaskEntity>
LoadingPickTaskEntity selectLoadingByContainerAndAccount(@Param("containerNo") String containerNo,
@Param("warehouseId") long warehouseId);
@Select("SELECT task.* FROM fl_loading_pick_task task JOIN fl_loading_plan plan ON plan.id=task.loading_plan_id "
+ "JOIN fl_container_booking booking ON booking.id=plan.container_booking_id "
+ "WHERE task.task_status='LOADING' AND booking.cabinet_no=#{cabinetNo} AND booking.warehouse_id=#{warehouseId} LIMIT 1")
LoadingPickTaskEntity selectLoadingByCabinet(@Param("cabinetNo") String cabinetNo, @Param("warehouseId") long warehouseId);
@Select("SELECT task.* FROM fl_loading_pick_task task JOIN fl_loading_plan plan ON plan.id=task.loading_plan_id "
+ "JOIN fl_container_booking booking ON booking.id=plan.container_booking_id "
+ "WHERE task.task_status='LOADING' AND task.loading_plan_id=#{loadingPlanId} AND booking.warehouse_id=#{warehouseId} LIMIT 1")
LoadingPickTaskEntity selectLoadingByPlan(@Param("loadingPlanId") long loadingPlanId, @Param("warehouseId") long warehouseId);
@Select("""
<script>
SELECT task.id AS taskId, task.loading_plan_id AS loadingPlanId,
@@ -67,6 +67,12 @@ public class LoadingPickRepository {
public LoadingPickTaskEntity taskByContainer(String containerNo, long warehouseId) {
return tasks.selectLoadingByContainerAndAccount(containerNo, warehouseId);
}
public LoadingPickTaskEntity taskByCabinet(String cabinetNo, long warehouseId) {
return tasks.selectLoadingByCabinet(cabinetNo, warehouseId);
}
public LoadingPickTaskEntity taskByPlan(long loadingPlanId, long warehouseId) {
return tasks.selectLoadingByPlan(loadingPlanId, warehouseId);
}
public List<PendingTaskRow> pendingTasks(long operatorId, String keyword) {
return tasks.selectPendingForOperator(operatorId, keyword);
@@ -125,6 +125,19 @@ public class LoadingPickService {
return view(task, warehouseId);
}
public Task task(String containerNo, String cabinetNo, Long loadingPlanId, long warehouseId) {
int selectors = (StringUtils.hasText(containerNo) ? 1 : 0) + (StringUtils.hasText(cabinetNo) ? 1 : 0)
+ (loadingPlanId != null ? 1 : 0);
if (selectors != 1) throw new BizException(ResultCode.BAD_REQUEST,
"containerNo、cabinetNo、loadingPlanId必须且只能提供一个");
LoadingPickTaskEntity task = StringUtils.hasText(cabinetNo)
? repository.taskByCabinet(cabinetNo.trim(), warehouseId)
: loadingPlanId != null ? repository.taskByPlan(loadingPlanId, warehouseId)
: repository.taskByContainer(containerNo.trim(), warehouseId);
if (task == null) throw new BizException(ResultCode.NOT_FOUND, "未找到装柜中的PDA拣货任务");
return view(task, warehouseId);
}
public Task task(long taskId, long warehouseId) {
return view(requiredTask(taskId), warehouseId);
}
@@ -252,6 +252,13 @@ paths:
responses:
'200': {description: 成功、草稿不存在或计划已进入预装流程}
'403': {description: 无智能装柜查询权限}
/api/platform/v1/first-leg/smart-loading/candidates:
get:
summary: Query same-channel smart-loading candidates
parameters:
- {name: bookingId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: warehouseId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
responses: {'200': {description: Candidates filtered by the server to the booking channel}}
/api/platform/v1/first-leg/smart-loading/plans/{planId}:
get:
summary: 查询智能装柜草稿详情
@@ -12,7 +12,7 @@ import static org.assertj.core.api.Assertions.assertThat;
class PlatformLoadingPdaControllerContractTest {
@Test
void exposesAssignmentWorkAndReviewWithDedicatedAuthorities() throws Exception {
authority("task", "platform:firstleg:pda:view", AuthenticatedUser.class, String.class, long.class);
authority("task", "platform:firstleg:pda:view", AuthenticatedUser.class, String.class, String.class, Long.class, long.class);
authority("pendingTasks", "platform:firstleg:pda:view", AuthenticatedUser.class, String.class);
authority("inventorySnapshot", "platform:firstleg:pda:view", AuthenticatedUser.class, long.class, long.class);
authority("claim", "platform:firstleg:pda:work", AuthenticatedUser.class, long.class, ClaimRequest.class);
@@ -10,7 +10,14 @@ import static org.assertj.core.api.Assertions.assertThat;
class PlatformSmartLoadingControllerContractTest {
@Test void draftQueriesUseListPermission() throws Exception {
authority("draftByBooking","platform:firstleg:smart-loading:list",AuthenticatedUser.class,long.class);
authority("detail","platform:firstleg:smart-loading:list",AuthenticatedUser.class,long.class);
authority("detail","platform:firstleg:smart-loading:list",AuthenticatedUser.class,long.class);
authority("candidates","platform:firstleg:smart-loading:list",AuthenticatedUser.class,long.class,long.class);
}
@Test void openApiRequiresBookingAndWarehouseForCandidates() throws Exception {
String yaml=java.nio.file.Files.readString(java.nio.file.Path.of("src/main/resources/openapi/firstleg-v1.yaml"));
assertThat(yaml).contains("/api/platform/v1/first-leg/smart-loading/candidates:")
.contains("name: bookingId, in: query, required: true")
.contains("name: warehouseId, in: query, required: true");
}
@Test void everyMutationDeclaresDedicatedPermission() throws Exception {
authority("assign","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,AssignRequest.class);
@@ -0,0 +1,21 @@
package com.baiyue.firstleg.repository;
import com.baiyue.firstleg.mapper.LoadingPickTaskMapper;
import org.apache.ibatis.annotations.Select;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class LoadingPickStableLookupContractTest {
@Test void stableLookupsUseCabinetOrPlanAndNotMutableContainerIdentity() throws Exception {
String cabinet = LoadingPickTaskMapper.class.getMethod("selectLoadingByCabinet", String.class, long.class)
.getAnnotation(Select.class).value()[0];
String plan = LoadingPickTaskMapper.class.getMethod("selectLoadingByPlan", long.class, long.class)
.getAnnotation(Select.class).value()[0];
String legacy = LoadingPickTaskMapper.class.getMethod("selectLoadingByContainerAndAccount", String.class, long.class)
.getAnnotation(Select.class).value()[0];
assertThat(cabinet).contains("booking.cabinet_no=#{cabinetNo}").doesNotContain("task.container_no");
assertThat(plan).contains("task.loading_plan_id=#{loadingPlanId}").doesNotContain("task.container_no");
assertThat(legacy).contains("booking.container_no = #{containerNo}").doesNotContain("task.container_no =");
}
}
@@ -36,6 +36,7 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.times;
import static org.assertj.core.api.Assertions.assertThat;
@SpringJUnitConfig(LoadingPickService.class)
class LoadingPickServiceTest {
@@ -52,6 +53,49 @@ class LoadingPickServiceTest {
@Resource
private LoadingPickService service;
@Test
void stableCabinetAndPlanQueriesReturnSameTaskWithCurrentContainerNumber() {
LoadingPickTaskEntity task = task();
task.setContainerBookingId(40L); task.setCabinetNo("CAB-001");
var booking = new com.baiyue.firstleg.entity.ContainerBookingEntity();
booking.setId(40L); booking.setContainerNo("MSCU-NEW");
when(repository.taskByCabinet("CAB-001", 101L)).thenReturn(task);
when(repository.taskByPlan(10L, 101L)).thenReturn(task);
when(smartLoadingRepository.booking(40L)).thenReturn(booking);
when(repository.items(20L, 101L)).thenReturn(List.of());
when(repository.cartonLabels(20L)).thenReturn(List.of());
var byCabinet = service.task(null, "CAB-001", null, 101L);
var byPlan = service.task(null, null, 10L, 101L);
assertThat(byCabinet.id()).isEqualTo(20L);
assertThat(byPlan.id()).isEqualTo(20L);
assertThat(byCabinet.currentContainerNo()).isEqualTo("MSCU-NEW");
}
@Test
void rejectsMissingOrConflictingCompatibilitySelectors() {
assertThatThrownBy(() -> service.task(null, null, null, 101L))
.isInstanceOf(BizException.class).hasMessageContaining("必须且只能提供一个");
assertThatThrownBy(() -> service.task("MSCU", "CAB", null, 101L))
.isInstanceOf(BizException.class).hasMessageContaining("必须且只能提供一个");
}
@Test
void legacyContainerLookupReturnsCurrentTransportDisplayWithoutChangingIdentity() {
LoadingPickTaskEntity task = task(); task.setContainerBookingId(40L); task.setCabinetNo("CAB-001");
var booking = new com.baiyue.firstleg.entity.ContainerBookingEntity();
booking.setId(40L); booking.setContainerNo("MSCU-CURRENT");
when(repository.taskByContainer("MSCU-CURRENT", 101L)).thenReturn(task);
when(smartLoadingRepository.booking(40L)).thenReturn(booking);
when(repository.items(20L, 101L)).thenReturn(List.of());
when(repository.cartonLabels(20L)).thenReturn(List.of());
var result = service.task("MSCU-CURRENT", null, null, 101L);
assertThat(result.id()).isEqualTo(20L);
assertThat(result.loadingPlanId()).isEqualTo(10L);
assertThat(result.currentContainerNo()).isEqualTo("MSCU-CURRENT");
}
@Test
void keepsPlanLoadingWhenItemsRemain() {
LoadingPickTaskEntity task = task();