fujian_water_biz_doc/docs/superpowers/plans/2026-07-15-rev003-charging-p0-remediation.md

994 lines
46 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# REV-003 Charging P0 Remediation Implementation Plan
## Execution status (2026-07-15)
- Backend Tasks 1-8 are implemented and committed; 106 focused business tests, 1 bank-reversal test, and the 29-module compile passed.
- Frontend Tasks 9-10 are implemented and committed; 38 Node contract tests and the full Vite build passed.
- Task 11 formal design, audit update, and verification evidence are complete. `vue-tsc` was not run.
- `REV004_IT_DB_URL` is unset in this environment, so real-database integration remains a release gate rather than a local completion result.
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the unsafe per-bill generic update flow with an atomic, idempotent counter-charge command and make amounts, settlement, top-up reversal, account concurrency, bank reversal, and callback replay financially consistent.
**Architecture:** Keep the existing one-`PaymentRecord`-per-charge compatibility model, but group all records from one counter operation with `requestId` and `paymentBatchNo`. A new `CounterChargeApplicationService` owns amount calculation, row locking, account mutations, payment capture, and charge projection inside one transaction. Settlement accepts explicit payment record IDs, and all balance/reversal paths use locked account rows plus append-only payment/account logs.
**Tech Stack:** Java 17, Spring Boot, MyBatis-Plus, PostgreSQL 16, JUnit 5, Mockito, Vue 3, TypeScript, Element Plus, Node.js `node:test`.
---
## File map
### Backend files to create
- `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/countercharge/CounterChargeAmountCalculator.java` — pure amount allocation rules.
- `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/countercharge/CounterChargeApplicationService.java` — command boundary.
- `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/countercharge/CounterChargeApplicationServiceImpl.java` — transaction orchestration.
- `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/controller/admin/charge/vo/CounterChargeSubmitReqVO.java` — dedicated command request.
- `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/controller/admin/charge/vo/CounterChargeSubmitRespVO.java` — payment batch result.
- `sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/countercharge/CounterChargeAmountCalculatorTest.java`.
- `sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/countercharge/CounterChargeApplicationServiceImplTest.java`.
- `sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/account/AccountServiceImplConcurrencyTest.java`.
- `sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/paymentrecord/payment-record-p0-schema.contract.test.mjs`.
- `sql/rev003/REV003_counter_charge_p0_ddl.sql` — additive nullable columns and unique indexes.
### Backend files to modify
- `PaymentRecordDO.java`, `PaymentRecordMapper.java`, `PaymentRecordService.java`, `PaymentRecordServiceImpl.java`.
- `AccountMapper.java`, `AccountServiceImpl.java`.
- `ChargeMapper.java`, `ChargeController.java`, `ChargeServiceImpl.java`, `ChargeServiceCounterPaymentTest.java`.
- `CounterTopupReqVO.java`, `CounterTopupRespVO.java`.
- `CounterSettleConfirmReqVO.java`, `CounterSettleApplicationServiceImpl.java`, `CounterSettleApplicationServiceImplTest.java`.
- `CounterUnsettledPageRespVO.java`, `CounterSettleDetailRespVO.java`, `PaymentQueryServiceImpl.java`, `PaymentQueryServiceTest.java`.
- `PrestorageBpmCallbackService.java`, `PrestorageFormalizationService.java`, `PrestorageBpmCallbackServiceTest.java`.
- `sw-business-bank/.../PayInvalidServiceImpl.java` plus a new focused test class.
### Frontend files to create
- `src/views/operatingCharges/counterCharging/counterChargeMath.mjs` — finite-number and receivable helpers used by the page and directly tested by Node.
- `types/counter-charge-math.d.ts` — TypeScript declarations for the `.mjs` helper.
- `tests/operatingCharges/counterChargingP0Flow.test.mjs` — API and page wiring contract.
- `tests/operatingCharges/counterCheckoutExplicitSelection.test.mjs` — exact-settlement contract.
### Frontend files to modify
- `src/api/operatingCharges/counterCharging/index.ts`.
- `src/views/operatingCharges/counterCharging/index.vue`.
- `src/api/business/charge/counterSettle.ts`.
- `src/views/operatingCharges/counterCheckout/components/CounterUnsettledPanel.vue`.
- `src/views/operatingCharges/counterCheckout/components/CounterSettleConfirmDialog.vue`.
- `src/views/operatingCharges/counterCheckout/components/CounterSettledDetailDialog.vue`.
### Documentation files to update
- `docs/evidence/rev003-charging/2026-07-15-p0-audit.md`.
- `docs/evidence/rev003-charging/2026-07-15-p0-verification.md`.
- `docs/design/02_Detailed_Design/12_REV_Detailed.md`.
- `docs/design/03_Technical_Design/03_Interface_Design.md`.
---
### Task 1: Add explicit payment amount and idempotency fields
**Files:**
- Create: `water-backend/sql/rev003/REV003_counter_charge_p0_ddl.sql`
- Create: `water-backend/sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/paymentrecord/payment-record-p0-schema.contract.test.mjs`
- Modify: `water-backend/sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/dal/dataobject/paymentrecord/PaymentRecordDO.java`
- Modify: `water-backend/sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/dal/mysql/paymentrecord/PaymentRecordMapper.java`
- [ ] **Step 1: Write the failing schema contract test**
```javascript
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
const paymentRecord = readFileSync(
'sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/dal/dataobject/paymentrecord/PaymentRecordDO.java',
'utf8'
)
const ddl = readFileSync('sql/rev003/REV003_counter_charge_p0_ddl.sql', 'utf8')
test('payment record exposes P0 amount split and idempotency fields', () => {
for (const field of ['requestId', 'paymentBatchNo', 'channelAmount', 'prepayAmount', 'overpayAmount']) {
assert.match(paymentRecord, new RegExp(`private .* ${field};`))
}
})
test('P0 DDL adds additive fields and unique idempotency indexes', () => {
assert.match(ddl, /ADD COLUMN IF NOT EXISTS request_id/)
assert.match(ddl, /ADD COLUMN IF NOT EXISTS channel_amount/)
assert.match(ddl, /uk_biz_payment_record_counter_request/)
assert.match(ddl, /uk_biz_payment_record_reverse_relation/)
})
```
- [ ] **Step 2: Run the contract and verify RED**
Run:
```bash
node --test sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/paymentrecord/payment-record-p0-schema.contract.test.mjs
```
Expected: FAIL because the DDL file and fields do not exist.
- [ ] **Step 3: Add the DO fields**
```java
private String requestId;
private String paymentBatchNo;
private BigDecimal channelAmount;
private BigDecimal prepayAmount;
private BigDecimal overpayAmount;
```
- [ ] **Step 4: Add additive PostgreSQL DDL**
```sql
ALTER TABLE biz_payment_record ADD COLUMN IF NOT EXISTS request_id VARCHAR(64);
ALTER TABLE biz_payment_record ADD COLUMN IF NOT EXISTS payment_batch_no VARCHAR(64);
ALTER TABLE biz_payment_record ADD COLUMN IF NOT EXISTS channel_amount NUMERIC(18, 2) NOT NULL DEFAULT 0;
ALTER TABLE biz_payment_record ADD COLUMN IF NOT EXISTS prepay_amount NUMERIC(18, 2) NOT NULL DEFAULT 0;
ALTER TABLE biz_payment_record ADD COLUMN IF NOT EXISTS overpay_amount NUMERIC(18, 2) NOT NULL DEFAULT 0;
CREATE INDEX IF NOT EXISTS idx_biz_payment_record_batch
ON biz_payment_record (tenant_id, payment_batch_no, deleted);
CREATE UNIQUE INDEX IF NOT EXISTS uk_biz_payment_record_counter_request
ON biz_payment_record (tenant_id, request_id, biz_scene, source_ref_id)
WHERE deleted = 0 AND request_id IS NOT NULL AND biz_scene = 'CHARGE_PAYMENT';
CREATE UNIQUE INDEX IF NOT EXISTS uk_biz_payment_record_topup_request
ON biz_payment_record (tenant_id, request_id, biz_scene)
WHERE deleted = 0 AND request_id IS NOT NULL AND biz_scene = 'DEPOSIT_TOPUP';
CREATE UNIQUE INDEX IF NOT EXISTS uk_biz_payment_record_reverse_relation
ON biz_payment_record (tenant_id, related_payment_record_id, biz_scene)
WHERE deleted = 0 AND related_payment_record_id IS NOT NULL;
```
- [ ] **Step 5: Add mapper queries used by idempotent commands**
```java
default List<PaymentRecordDO> selectByRequestId(String requestId, String bizScene) {
if (requestId == null || requestId.isBlank()) {
return List.of();
}
return selectList(new LambdaQueryWrapperX<PaymentRecordDO>()
.eq(PaymentRecordDO::getRequestId, requestId)
.eqIfPresent(PaymentRecordDO::getBizScene, bizScene)
.orderByAsc(PaymentRecordDO::getId));
}
default List<PaymentRecordDO> selectCounterUnsettledRecordsByIds(List<Long> ids) {
if (ids == null || ids.isEmpty()) {
return List.of();
}
return selectList(new LambdaQueryWrapperX<PaymentRecordDO>()
.in(PaymentRecordDO::getId, ids)
.eq(PaymentRecordDO::getSourceType, PaymentSourceTypeEnum.COUNTER_CHARGE.getValue())
.in(PaymentRecordDO::getBizScene, List.of("CHARGE_PAYMENT", "DEPOSIT_TOPUP"))
.eq(PaymentRecordDO::getPayInOut, "IN")
.eq(PaymentRecordDO::getSettleStatus, PaymentSettleStatusEnum.UNSETTLED.getCode())
.isNull(PaymentRecordDO::getSettleId)
.orderByAsc(PaymentRecordDO::getId));
}
```
- [ ] **Step 6: Verify GREEN**
Run the Step 2 command. Expected: 2 tests PASS.
- [ ] **Step 7: Commit**
```bash
git add sql/rev003 sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/dal/dataobject/paymentrecord sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/dal/mysql/paymentrecord sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/paymentrecord/payment-record-p0-schema.contract.test.mjs
git commit -m "feat: add counter payment idempotency fields"
```
### Task 2: Correct principal and late-fee semantics
**Files:**
- Modify: `water-backend/sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/paymentrecord/PaymentRecordServiceImplTest.java`
- Modify: `water-backend/sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/paymentrecord/PaymentRecordServiceImpl.java`
- Modify: `water-backend/sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/charge/ChargeServiceCounterPaymentTest.java`
- Modify: `water-backend/sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/charge/ChargeServiceImpl.java`
- [ ] **Step 1: Add a failing payment allocation test**
```java
@Test
void captureCounterPayment_shouldKeepPrincipalAndAddLateFeeToReceivable() {
ChargeDO charge = buildCharge(1001L, 9001L, new BigDecimal("100.00"), new BigDecimal("10.00"));
PaymentRecordDO result = paymentRecordService.captureChargePaymentFromCounter(
charge, LocalDateTime.of(2026, 7, 15, 10, 0), 1, 1, "1001", BigDecimal.ZERO);
ArgumentCaptor<PaymentRecordDO> recordCaptor = ArgumentCaptor.forClass(PaymentRecordDO.class);
verify(paymentRecordMapper).insert(recordCaptor.capture());
assertEquals(new BigDecimal("110.00"), recordCaptor.getValue().getPaymentAmount());
assertEquals(new BigDecimal("100.00"), recordCaptor.getValue().getBillAmount());
assertEquals(new BigDecimal("10.00"), recordCaptor.getValue().getLateFeeAmount());
ArgumentCaptor<PaymentRecordDetailDO> detailCaptor = ArgumentCaptor.forClass(PaymentRecordDetailDO.class);
verify(paymentRecordDetailMapper, times(2)).insert(detailCaptor.capture());
assertEquals(new BigDecimal("100.00"), detailCaptor.getAllValues().get(0).getPrincipalAmount());
assertEquals(new BigDecimal("10.00"), detailCaptor.getAllValues().get(1).getLateFeeAmount());
}
```
- [ ] **Step 2: Run the test and verify RED**
```bash
mvn -pl sw-business/sw-business-server -Dtest=PaymentRecordServiceImplTest#captureCounterPayment_shouldKeepPrincipalAndAddLateFeeToReceivable -Dsurefire.failIfNoSpecifiedTests=false test
```
Expected: FAIL because payment amount is `100.00` and principal is `90.00`.
- [ ] **Step 3: Implement canonical helpers**
```java
private BigDecimal principalAmount(ChargeDO charge) {
return nonNegative(charge == null ? null : charge.getExtendedAmount());
}
private BigDecimal lateFeeAmount(ChargeDO charge) {
return nonNegative(charge == null ? null : charge.getLateFee());
}
private BigDecimal receivableAmount(ChargeDO charge) {
return principalAmount(charge).add(lateFeeAmount(charge));
}
private BigDecimal nonNegative(BigDecimal amount) {
return amount == null || amount.compareTo(BigDecimal.ZERO) < 0 ? BigDecimal.ZERO : amount;
}
```
Use `receivableAmount(charge)` for `paymentAmount` and `allocatedAmount`; use `principalAmount(charge)` for `billAmount` and the principal detail. Never subtract late fee from `extendedAmount`.
- [ ] **Step 4: Add a failing counter-preview test**
For an overdue charge with principal `8.00`, late fee `3.00`, and a current principal `12.00`, assert:
```java
assertEquals(new BigDecimal("8.00"), result.getOverduePrincipal());
assertEquals(new BigDecimal("3.00"), result.getOverdueLateFee());
assertEquals(new BigDecimal("23.00"), result.getTotalReceivable());
assertEquals(new BigDecimal("13.00"), result.getRemainingPayable());
```
- [ ] **Step 5: Run the preview test and verify RED**
```bash
mvn -pl sw-business/sw-business-server -Dtest=ChargeServiceCounterPaymentTest#getCounterPreview_shouldApplyPrepaidInFixedPriorityOrder -Dsurefire.failIfNoSpecifiedTests=false test
```
Expected: FAIL because overdue principal is currently calculated as `extendedAmount - lateFee`.
- [ ] **Step 6: Correct preview calculation**
Use `extendedAmount` as principal and add `lateFee` to the total. Preserve priority: overdue late fee, overdue principal, current principal.
- [ ] **Step 7: Run both focused test classes**
```bash
mvn -pl sw-business/sw-business-server -Dtest=PaymentRecordServiceImplTest,ChargeServiceCounterPaymentTest -Dsurefire.failIfNoSpecifiedTests=false test
```
Expected: PASS.
- [ ] **Step 8: Commit**
```bash
git add sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/paymentrecord/PaymentRecordServiceImpl.java sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/charge/ChargeServiceImpl.java sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/paymentrecord/PaymentRecordServiceImplTest.java sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/charge/ChargeServiceCounterPaymentTest.java
git commit -m "fix: align counter charge principal and late fee amounts"
```
### Task 3: Lock accounts and make top-up idempotent
**Files:**
- Create: `water-backend/sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/account/AccountServiceImplConcurrencyTest.java`
- Modify: `AccountMapper.java`, `AccountServiceImpl.java`
- Modify: `CounterTopupReqVO.java`, `ChargeServiceCounterPaymentTest.java`, `ChargeServiceImpl.java`
- Modify: `PaymentRecordService.java`, `PaymentRecordServiceImpl.java`, `PaymentCommandApplicationService.java`, `PaymentCommandApplicationServiceImpl.java`
- [ ] **Step 1: Write failing account-lock tests**
```java
@Test
void increaseDeposit_shouldReadAccountForUpdateBeforeWriting() {
AccountDO account = AccountDO.builder().id(1L).custId(66L).deposit(new BigDecimal("5.00")).build();
when(accountMapper.selectByCustIdForUpdate(66L)).thenReturn(account);
AccountDO result = service.increaseDeposit(66L, new BigDecimal("2.00"), logContext());
assertEquals(new BigDecimal("7.00"), result.getDeposit());
verify(accountMapper).selectByCustIdForUpdate(66L);
verify(accountMapper).updateById(account);
}
@Test
void decreaseDeposit_shouldRejectInsufficientLockedBalanceWithoutUpdate() {
AccountDO account = AccountDO.builder().id(1L).custId(66L).deposit(new BigDecimal("5.00")).build();
when(accountMapper.selectByCustIdForUpdate(66L)).thenReturn(account);
assertThrows(ServiceException.class,
() -> service.decreaseDeposit(66L, new BigDecimal("6.00"), logContext()));
verify(accountMapper, never()).updateById(any());
}
```
- [ ] **Step 2: Verify RED**
Run the new test class. Expected: compilation failure because `selectByCustIdForUpdate` does not exist.
- [ ] **Step 3: Add the locking mapper method**
```java
default AccountDO selectByCustIdForUpdate(Long custId) {
if (custId == null) {
return null;
}
return selectOne(new LambdaQueryWrapperX<AccountDO>()
.eq(AccountDO::getCustId, custId)
.last("FOR UPDATE"));
}
```
Change all deposit increase/decrease implementations to use this method.
- [ ] **Step 4: Add a failing top-up replay test**
```java
@Test
void counterTopup_shouldReturnExistingPaymentForRepeatedRequestId() {
CounterTopupReqVO req = buildTopup("REQ-1");
PaymentRecordDO existing = PaymentRecordDO.builder()
.id(99L).paymentNo("TOP-99").requestId("REQ-1")
.paymentAmount(new BigDecimal("50.00")).build();
when(paymentRecordService.getByRequestId("REQ-1", "DEPOSIT_TOPUP")).thenReturn(List.of(existing));
when(accountService.getAccountByCustId(66L)).thenReturn(AccountDO.builder().deposit(new BigDecimal("80.00")).build());
CounterTopupRespVO result = chargeService.counterTopup(req);
assertEquals(99L, result.getPaymentRecordId());
verify(accountService, never()).increaseDeposit(anyLong(), any(), any());
}
```
- [ ] **Step 5: Add request ID and server-owned actor/time**
`CounterTopupReqVO`:
```java
@NotBlank(message = "请求号不能为空")
private String requestId;
```
Keep legacy `cashierId` and `payTime` nullable for deserialization compatibility, but resolve production values as:
```java
String cashierId = Optional.ofNullable(SecurityFrameworkUtils.getLoginUserId())
.map(String::valueOf)
.orElse(reqVO.getCashierId());
LocalDateTime payTime = LocalDateTime.now();
```
Extend payment capture with `requestId`, `paymentBatchNo`, `channelAmount`, and initialize `prepayAmount/overpayAmount` to zero. Before mutating balance, return an existing completed payment for the same request.
- [ ] **Step 6: Run focused tests**
```bash
mvn -pl sw-business/sw-business-server -Dtest=AccountServiceImplConcurrencyTest,ChargeServiceCounterPaymentTest,PaymentRecordServiceImplTest -Dsurefire.failIfNoSpecifiedTests=false test
```
Expected: PASS.
- [ ] **Step 7: Commit**
```bash
git add sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/dal/mysql/account sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/account sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/controller/admin/charge/vo/CounterTopupReqVO.java sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/paymentrecord sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/paymentapp sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/charge/ChargeServiceImpl.java sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/account sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/charge/ChargeServiceCounterPaymentTest.java
git commit -m "fix: lock deposit updates and deduplicate counter topups"
```
### Task 4: Implement the atomic multi-bill counter-charge command
**Files:**
- Create the counter-charge service, calculator, DTOs, and tests listed in the file map.
- Modify: `ChargeController.java`, `ChargeMapper.java`, payment service/application signatures.
- [ ] **Step 1: Write calculator tests first**
```java
@Test
void calculate_shouldAddLateFeeAndAllocatePrepayOldestFirst() {
List<BillInput> bills = List.of(
new BillInput(1L, 66L, 202605, bd("100.00"), bd("10.00")),
new BillInput(2L, 66L, 202606, bd("50.00"), bd("0.00")));
BatchAmounts result = calculator.calculate(bills, Map.of(66L, bd("120.00")), true, bd("40.00"));
assertEquals(bd("160.00"), result.totalReceivable());
assertEquals(bd("120.00"), result.totalPrepay());
assertEquals(bd("40.00"), result.channelAmount());
assertEquals(bd("0.00"), result.overpay());
assertEquals(bd("110.00"), result.items().get(0).prepayAmount());
assertEquals(bd("10.00"), result.items().get(1).prepayAmount());
}
@Test
void calculate_shouldRejectShortPayment() {
assertThrows(ServiceException.class, () -> calculator.calculate(
List.of(new BillInput(1L, 66L, 202605, bd("100.00"), bd("10.00"))),
Map.of(), false, bd("109.99")));
}
@Test
void calculate_shouldRejectOverpayAcrossMultipleMainAccounts() {
assertThrows(ServiceException.class, () -> calculator.calculate(
List.of(
new BillInput(1L, 66L, 202605, bd("10.00"), bd("0.00")),
new BillInput(2L, 77L, 202605, bd("10.00"), bd("0.00"))),
Map.of(), false, bd("21.00")));
}
```
- [ ] **Step 2: Verify calculator RED**
Run `CounterChargeAmountCalculatorTest`. Expected: compilation failure because the calculator does not exist.
- [ ] **Step 3: Implement the pure calculator**
Create records `BillInput`, `ItemAmounts`, and `BatchAmounts`. Sort by bill month then charge ID. Clamp only null values to zero; reject negative principal, late fee, channel amount, or balance. Allocate prepay per main account and enforce the batch equation exactly at scale 2.
- [ ] **Step 4: Write application-service tests**
Cover:
```java
@Test
void submit_shouldCaptureTwoBillsAndProjectBothInsideOneBatch() { /* assert same requestId/batchNo */ }
@Test
void submit_shouldReturnExistingBatchWithoutMutatingForReplay() { /* existing request records */ }
@Test
void submit_shouldRollbackContractWhenOneChargeIsNoLongerUnpaid() { /* conditional update count */ }
@Test
void submit_shouldCreateTopupInSameTransactionForSingleAccountOverpay() { /* overpay record + account log */ }
@Test
void submit_shouldRejectExpectedAmountMismatch() { /* stale page amount */ }
```
- [ ] **Step 5: Define the request and response DTOs**
```java
@Data
public class CounterChargeSubmitReqVO {
@NotBlank private String requestId;
@NotEmpty private List<@NotNull Long> chargeIds;
@NotNull @DecimalMin("0.01") private BigDecimal expectedReceivableAmount;
@NotNull @DecimalMin("0.00") private BigDecimal actualPayAmount;
@NotNull private Boolean usePrepay;
@NotNull private Integer chargeWay;
private String remark;
}
```
```java
@Data @Builder
public class CounterChargeSubmitRespVO {
private String requestId;
private String paymentBatchNo;
private List<Long> paymentRecordIds;
private BigDecimal totalReceivableAmount;
private BigDecimal channelAmount;
private BigDecimal prepayAmount;
private BigDecimal overpayTopupAmount;
private BigDecimal balanceAfter;
}
```
- [ ] **Step 6: Implement locking and conditional projection**
`ChargeMapper` must provide:
```java
default List<ChargeDO> selectByIdsForUpdate(List<Long> ids) {
return selectList(new LambdaQueryWrapperX<ChargeDO>()
.in(ChargeDO::getId, ids)
.orderByAsc(ChargeDO::getId)
.last("FOR UPDATE"));
}
default int markCounterPaid(Long id, LocalDateTime payTime, Integer chargeMethod,
Integer chargeWay, String cashierId) {
return update(null, new LambdaUpdateWrapper<ChargeDO>()
.set(ChargeDO::getPayState, PayStateEnum.PAID.getValue())
.set(ChargeDO::getPayDate, payTime)
.set(ChargeDO::getChargeMethod, chargeMethod)
.set(ChargeDO::getChargeWay, chargeWay)
.set(ChargeDO::getCashierId, cashierId)
.eq(ChargeDO::getId, id)
.eq(ChargeDO::getPayState, PayStateEnum.UNPAID.getValue()));
}
```
- [ ] **Step 7: Implement the transaction service**
The `@Transactional` method must:
1. Normalize and validate `requestId` and IDs.
2. Return an existing batch when request ID and charge set match; reject reuse with different IDs.
3. Allow only `chargeWay=1` until a confirmed channel adapter exists.
4. Lock charges and main accounts in ascending ID order.
5. Calculate amounts and compare server total with expected total.
6. Create per-charge PaymentRecords with shared batch/request IDs and explicit channel/prepay values.
7. Write prepay deductions using the payment record ID in `AccountLogContext`.
8. Conditionally mark every charge paid; any update count other than one throws and rolls back.
9. Create overpay top-up and balance increase in the same transaction.
10. Return the batch response.
- [ ] **Step 8: Add the controller endpoint**
```java
@PostMapping("/counter-charge/submit")
@PreAuthorize("@ss.hasPermission('business:charge:update')")
public CommonResult<CounterChargeSubmitRespVO> submitCounterCharge(
@Valid @RequestBody CounterChargeSubmitReqVO reqVO) {
return success(counterChargeApplicationService.submit(reqVO));
}
```
- [ ] **Step 9: Run focused tests**
```bash
mvn -pl sw-business/sw-business-server -Dtest=CounterChargeAmountCalculatorTest,CounterChargeApplicationServiceImplTest,ChargeControllerTest,PaymentRecordServiceImplTest -Dsurefire.failIfNoSpecifiedTests=false test
```
Expected: PASS.
- [ ] **Step 10: Commit**
```bash
git add sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/countercharge sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/controller/admin/charge sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/dal/mysql/charge sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/paymentrecord sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/paymentapp sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/countercharge sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/controller/admin/charge
git commit -m "feat: add atomic multi-bill counter charge command"
```
### Task 5: Close generic charge and account financial bypasses
**Files:**
- Modify: `ChargeServiceCounterPaymentTest.java`, `ChargeServiceImpl.java`.
- Modify: `AccountServiceImplConcurrencyTest.java`, `AccountServiceImpl.java`.
- [ ] **Step 1: Replace old generic-payment tests with rejection tests**
```java
@Test
void updateCharge_shouldRejectCounterPaymentTransition() {
ChargeDO before = buildCharge(1001L, PayStateEnum.UNPAID.getValue());
when(chargeMapper.selectById(1001L)).thenReturn(before);
ChargeSaveReqVO req = paidCounterUpdate(1001L);
ServiceException error = assertThrows(ServiceException.class, () -> chargeService.updateCharge(req));
assertEquals("柜台收费请使用专用收费接口", error.getMessage());
verify(chargeMapper, never()).updateById(any());
}
@Test
void deleteCharge_shouldRejectPaidOrSettledBill() { /* payState != UNPAID */ }
```
- [ ] **Step 2: Verify RED**
Run `ChargeServiceCounterPaymentTest`. Expected: old path still succeeds.
- [ ] **Step 3: Implement guards before generic updates/deletes**
Reject any `UNPAID -> PAID/SETTLED` transition in `updateCharge`; reject delete when `payState != UNPAID` or an active PaymentRecord exists.
- [ ] **Step 4: Guard generic account balance changes**
Add tests that `updateAccount` rejects a request whose deposit differs from the persisted account and `deleteAccount` rejects non-zero balances. Preserve non-financial account metadata updates.
- [ ] **Step 5: Run focused tests and commit**
```bash
mvn -pl sw-business/sw-business-server -Dtest=ChargeServiceCounterPaymentTest,AccountServiceImplConcurrencyTest -Dsurefire.failIfNoSpecifiedTests=false test
git add sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/charge/ChargeServiceImpl.java sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/account/AccountServiceImpl.java sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/charge/ChargeServiceCounterPaymentTest.java sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/account/AccountServiceImplConcurrencyTest.java
git commit -m "fix: block generic financial state mutations"
```
### Task 6: Settle an explicit payment-record set and use channel amounts
**Files:**
- Modify settlement VO/service/tests, PaymentRecord service/mapper, query VO/service/tests.
- [ ] **Step 1: Write failing exact-scope tests**
```java
@Test
void confirm_shouldSettleOnlyRequestedPaymentRecords() {
CounterSettleConfirmReqVO req = new CounterSettleConfirmReqVO();
req.setPaymentRecordIds(List.of(101L, 102L));
req.setSettleTime(SETTLE_TIME);
when(paymentRecordService.getCounterUnsettledRecordsByIds(List.of(101L, 102L)))
.thenReturn(List.of(record(101L, "1001", "20.00", "5.00"),
record(102L, "1001", "16.00", "0.00")));
CounterSettleRespVO result = service.confirm(req);
verify(paymentRecordService).markSettled(List.of(101L, 102L), result.getSettleId(), SETTLE_TIME);
assertEquals(new BigDecimal("36.00"), result.getTotalAmount());
}
@Test
void confirm_shouldRejectRequestedRecordOwnedByAnotherCashier() { /* login 1001, row 2002 */ }
@Test
void confirm_shouldRejectWhenAnyRequestedIdIsMissingOrAlreadySettled() { /* size mismatch */ }
```
- [ ] **Step 2: Verify RED**
Run `CounterSettleApplicationServiceImplTest`. Expected: compile failure because request IDs and service method do not exist.
- [ ] **Step 3: Change the request contract**
```java
@NotEmpty(message = "请选择待结账记录")
private List<@NotNull Long> paymentRecordIds;
// cashierId remains read-compatible but is ignored when an authenticated user exists
private String cashierId;
```
- [ ] **Step 4: Implement exact-scope settlement**
- Normalize/deduplicate request IDs.
- Load eligible rows only by IDs.
- Require loaded count to equal requested count.
- Resolve cashier from authenticated user and require every row to match.
- Calculate `settleAmount` from `channelAmount`, falling back to legacy `paymentAmount` only when the new field is null.
- Mark exactly those records settled and exactly their charge IDs projected as settled.
- [ ] **Step 5: Expose amount split in query responses**
Add `channelAmount` and `prepayAmount` to unsettled/detail response VOs. Update payment summary to aggregate channel money separately from prepay.
- [ ] **Step 6: Run focused tests and commit**
```bash
mvn -pl sw-business/sw-business-server -Dtest=CounterSettleApplicationServiceImplTest,PaymentQueryServiceTest,PaymentRecordServiceImplTest -Dsurefire.failIfNoSpecifiedTests=false test
git add sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/controller/admin/charge/vo sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/countersettle sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/paymentrecord sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/paymentquery sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/countersettle sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/paymentquery
git commit -m "fix: settle explicit counter payment records"
```
### Task 7: Repair top-up reversal and enforce reverse uniqueness
**Files:**
- Modify: `CounterSettleApplicationServiceImplTest.java`, `CounterSettleApplicationServiceImpl.java`, `PaymentRecordMapper.java`, `PaymentRecordServiceImplTest.java`, `PaymentRecordServiceImpl.java`.
- [ ] **Step 1: Correct the mock expectation first**
Change the settled top-up test to expect:
```java
when(paymentRecordMapper.markCounterSettleTopupReversed(501L, 701L)).thenReturn(1);
verify(paymentRecordMapper).markCounterSettleTopupReversed(501L, 701L);
verify(paymentRecordMapper, never()).markCounterSettleReversed(anyLong(), anyLong());
```
Add an unsettled top-up test expecting `markCounterUnsettledTopupReversed`.
- [ ] **Step 2: Verify RED**
Run the two top-up red-flush tests. Expected: FAIL because production calls the charge-payment mapper method or rejects the unsettled top-up.
- [ ] **Step 3: Route each state to the correct mapper method**
Use `markCounterSettleTopupReversed` for settled top-ups and `markCounterUnsettledTopupReversed` for unsettled top-ups. Do not require a settle detail for an unsettled top-up.
- [ ] **Step 4: Handle concurrent duplicate reverse insertion**
Keep the existing read-before-insert behavior for friendly replay, rely on the new unique index for races, and translate duplicate-key exceptions by reloading the existing reverse record.
- [ ] **Step 5: Run tests and commit**
```bash
mvn -pl sw-business/sw-business-server -Dtest=CounterSettleApplicationServiceImplTest,PaymentRecordServiceImplTest -Dsurefire.failIfNoSpecifiedTests=false test
git add sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/countersettle sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/paymentrecord sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/dal/mysql/paymentrecord sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/countersettle sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/paymentrecord
git commit -m "fix: reverse counter topups through correct state path"
```
### Task 8: Harden bank invalidation and prestorage callback replay
**Files:**
- Create: `water-backend/sw-business-bank/sw-business-bank-server/src/test/java/cn/com/emsoft/sw/bankbusiness/service/payinvalid/PayInvalidServiceImplTest.java`
- Modify: `PayInvalidServiceImpl.java`, `ChargeServiceImpl.java`.
- Modify: `PrestorageBpmCallbackServiceTest.java`, `PrestorageBpmCallbackService.java`, `PrestorageFormalizationService.java` and its mapper.
- [ ] **Step 1: Add a failing invalid-charge state test**
```java
@Test
void invalidCharge_shouldRejectWhenBusinessReverseDidNotRun() {
ChargeDO charge = charge(1L, PayStateEnum.SETTLED.getValue());
when(chargeMapper.selectById(1L)).thenReturn(charge);
ServiceException error = assertThrows(ServiceException.class, () -> service.invalidCharge(1L));
assertEquals("当前账单状态不允许银行冲正", error.getMessage());
verify(paymentCommandApplicationService, never()).reverseChargePayment(any());
}
```
- [ ] **Step 2: Implement explicit invalidation result**
`ChargeServiceImpl.invalidCharge` returns only after `reverseChargePayment` succeeds. Null charge returns null; every non-`PAID` state throws. `PayInvalidServiceImpl` treats any exception or null as failure and must not mark the original bank transaction reversed.
- [ ] **Step 3: Add failing callback replay tests**
```java
@Test
void handleApproved_shouldReturnWithoutMutationWhenAlreadyCompleted() {
PrestorageAdjustDO main = adjustment("COMPLETED");
when(prestorageFormalizationService.getViewForUpdate("REV004-PRF-1"))
.thenReturn(view(main));
service.handleApproved("REV004-PRF-1", "PROC-1");
verifyNoInteractions(accountService, paymentCommandApplicationService);
}
```
Add a transfer test that verifies both accounts are locked in ascending account ID order and changed through `AccountService` with AccountLog contexts.
- [ ] **Step 4: Implement locked callback execution**
- Add `getViewForUpdate(adjustmentNo)` using a mapper `FOR UPDATE` query.
- Return immediately when `businessStatus=COMPLETED`.
- Only `APPROVED_PENDING_EXECUTION`, or the initial approved callback before binding, may execute.
- Replace direct `AccountMapper.updateById` balance writes with locked `AccountService.decreaseDeposit/increaseDeposit` calls carrying adjustment source information.
- [ ] **Step 5: Run focused tests and commit**
```bash
mvn -pl sw-business/sw-business-server -Dtest=PrestorageBpmCallbackServiceTest -Dsurefire.failIfNoSpecifiedTests=false test
mvn -pl sw-business-bank/sw-business-bank-server -Dtest=PayInvalidServiceImplTest -Dsurefire.failIfNoSpecifiedTests=false test
git add sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/accountingadjust/prestorage sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/accountingadjust/prestorage sw-business-bank/sw-business-bank-server/src/main/java/cn/com/emsoft/sw/bankbusiness/service/payinvalid sw-business-bank/sw-business-bank-server/src/test/java/cn/com/emsoft/sw/bankbusiness/service/payinvalid sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/charge/ChargeServiceImpl.java
git commit -m "fix: prevent false bank reversal and callback replay"
```
### Task 9: Switch counter charging UI to the atomic command
**Files:**
- Create: `counterChargeMath.mjs`, declaration file, `counterChargingP0Flow.test.mjs`.
- Modify: counter-charging API and page.
- [ ] **Step 1: Write failing real math-helper tests**
```javascript
import test from 'node:test'
import assert from 'node:assert/strict'
import { billReceivable, parseMoney } from '../../src/views/operatingCharges/counterCharging/counterChargeMath.mjs'
test('bill receivable adds principal and late fee', () => {
assert.equal(billReceivable({ extendedAmount: 100, lateFee: 10 }), 110)
})
test('parseMoney rejects non finite values', () => {
assert.equal(parseMoney('abc'), null)
assert.equal(parseMoney('Infinity'), null)
assert.equal(parseMoney('10.20'), 10.2)
})
```
- [ ] **Step 2: Verify RED**
Run `node --test tests/operatingCharges/counterChargingP0Flow.test.mjs`. Expected: module-not-found.
- [ ] **Step 3: Implement the helper**
```javascript
export const toCents = (value) => Math.round(Number(value || 0) * 100)
export const fromCents = (value) => Number((value / 100).toFixed(2))
export const parseMoney = (value) => {
const parsed = Number(value)
return Number.isFinite(parsed) && parsed >= 0 ? fromCents(toCents(parsed)) : null
}
export const billReceivable = (bill) => fromCents(
toCents(bill?.extendedAmount ?? bill?.billAmount ?? 0) + toCents(bill?.lateFee ?? 0)
)
```
- [ ] **Step 4: Add the atomic API contract**
```ts
export interface CounterChargeSubmitPayload {
requestId: string
chargeIds: number[]
expectedReceivableAmount: number
actualPayAmount: number
usePrepay: boolean
chargeWay: number
remark?: string
}
export const submitCounterCharge = (data: CounterChargeSubmitPayload) =>
request.post<CounterChargeSubmitRespVO>({
url: '/business/charge/counter-charge/submit',
data
})
```
Update top-up payload to require `requestId`; stop sending client-owned cashier/time for the new path.
- [ ] **Step 5: Replace page-side allocation and sequential submission**
- Calculate display receivable with `billReceivable`.
- Validate `parseMoney(actualAmount)` before opening and before confirming.
- Keep a `pendingRequestId` across retry; clear it only when selection/amount changes or the request succeeds.
- Call `submitCounterCharge` exactly once with all selected IDs.
- Remove the `for ... submitCashCharge` loop and separate overpay request.
- Update success rendering from the batch response and then refresh customer, bills, balance, and payment summary.
- Only cash remains enabled for direct submission; configuration failure also falls back to cash only.
- Rename “删除” to “本次不收”.
- [ ] **Step 6: Expand confirmation details**
Display principal, late fee, total receivable, estimated prepay, channel actual, and overpay. Mark the prepay split as “以服务端提交结果为准”.
- [ ] **Step 7: Run focused frontend tests**
```bash
node --test tests/operatingCharges/counterChargingP0Flow.test.mjs src/views/operatingCharges/counterCharging/counterTopup.contract.test.mjs tests/operatingCharges/counterChargingZeroAmount.contract.test.mjs tests/revenue-bugs/counterChargeAndCheckoutDisplay.contract.test.mjs
```
Expected: new tests PASS; update obsolete regex assertions in existing files to the new accepted behavior, without weakening amount or atomicity assertions.
- [ ] **Step 8: Commit**
```bash
git add src/api/operatingCharges/counterCharging src/views/operatingCharges/counterCharging types/counter-charge-math.d.ts tests/operatingCharges/counterChargingP0Flow.test.mjs tests/operatingCharges/counterChargingZeroAmount.contract.test.mjs tests/revenue-bugs/counterChargeAndCheckoutDisplay.contract.test.mjs
git commit -m "fix: submit counter charges as one atomic batch"
```
### Task 10: Make counter settlement selection real
**Files:**
- Create: `tests/operatingCharges/counterCheckoutExplicitSelection.test.mjs`.
- Modify: counter-settle API and three counter-checkout components.
- [ ] **Step 1: Write the failing page contract**
```javascript
test('counter checkout submits selected payment record ids', () => {
assert.match(panel, /@selection-change="\(rows.*handleGroupSelection/)
assert.match(panel, /selectedPaymentRecordIds/)
assert.match(dialog, /paymentRecordIds: summary\.value\.paymentRecordIds/)
assert.match(api, /paymentRecordIds: number\[\]/)
})
```
- [ ] **Step 2: Verify RED**
Run the new test. Expected: FAIL because selection is currently decorative and the request only contains cashier ID.
- [ ] **Step 3: Implement group selection state**
Maintain `Map<string, CounterUnsettledRowVO[]>`, flatten unique selected rows, and derive amount/count from those rows only. The settle button is disabled until at least one eligible row is selected. Clear selections after data reload or successful settlement.
- [ ] **Step 4: Submit exact IDs**
Change `CounterSettleConfirmReqVO` to:
```ts
export interface CounterSettleConfirmReqVO {
paymentRecordIds: number[]
settleTime: string
remark?: string
}
```
Pass IDs through the dialog. Do not send `cashierId` as authority.
- [ ] **Step 5: Make reversal reason required**
```ts
export interface CounterRedFlushReqVO {
paymentRecordIds: number[]
reason: string
}
```
Use an input validator that rejects blank reasons in both unsettled and settled dialogs.
- [ ] **Step 6: Run focused tests and commit**
```bash
node --test tests/operatingCharges/counterCheckoutExplicitSelection.test.mjs tests/rev006/counterCheckoutOldPageInventory.test.mjs tests/revenue-bugs/counterChargeAndCheckoutDisplay.contract.test.mjs src/views/operatingCharges/counterCheckout/redFlushReason.contract.test.mjs
git add src/api/business/charge/counterSettle.ts src/views/operatingCharges/counterCheckout tests/operatingCharges/counterCheckoutExplicitSelection.test.mjs tests/rev006/counterCheckoutOldPageInventory.test.mjs tests/revenue-bugs/counterChargeAndCheckoutDisplay.contract.test.mjs
git commit -m "fix: settle only explicitly selected payment records"
```
### Task 11: Verification, formal documentation, and evidence
**Files:**
- Create: `water-docs/docs/evidence/rev003-charging/2026-07-15-p0-verification.md`.
- Modify formal detailed/interface design and the audit record.
- [ ] **Step 1: Run backend unit tests by focused class**
```bash
mvn -pl sw-business/sw-business-server -Dtest=CounterChargeAmountCalculatorTest,CounterChargeApplicationServiceImplTest,PaymentRecordServiceImplTest,ChargeServiceCounterPaymentTest,AccountServiceImplConcurrencyTest,CounterSettleApplicationServiceImplTest,PaymentQueryServiceTest,PrestorageBpmCallbackServiceTest -Dsurefire.failIfNoSpecifiedTests=false test
mvn -pl sw-business-bank/sw-business-bank-server -Dtest=PayInvalidServiceImplTest -Dsurefire.failIfNoSpecifiedTests=false test
```
- [ ] **Step 2: Run backend compile**
```bash
mvn -pl sw-business/sw-business-server,sw-business-bank/sw-business-bank-server -am -DskipTests compile
```
- [ ] **Step 3: Run frontend deterministic tests**
```bash
node --test \
tests/operatingCharges/counterChargingP0Flow.test.mjs \
tests/operatingCharges/counterCheckoutExplicitSelection.test.mjs \
src/views/operatingCharges/counterCharging/counterTopup.contract.test.mjs \
src/views/operatingCharges/counterCheckout/redFlushReason.contract.test.mjs \
tests/operatingCharges/counterChargingZeroAmount.contract.test.mjs \
tests/revenue-bugs/counterChargeAndCheckoutDisplay.contract.test.mjs \
tests/rev006/counterCheckoutOldPageInventory.test.mjs
```
- [ ] **Step 4: Run frontend build without `vue-tsc`**
```bash
pnpm build:dev
```
Do not run `pnpm ts:check` or any direct `vue-tsc` command.
- [ ] **Step 5: Run database integration tests when configured**
```bash
if [ -n "$REV004_IT_DB_URL" ]; then
mvn -pl sw-business/sw-business-server \
-Dtest=CounterChargeFullChainIntegrationTest,CounterSettleIntegrationTest \
-Dsurefire.failIfNoSpecifiedTests=false test
else
echo "BLOCKED: REV004_IT_DB_URL is not configured"
fi
```
- [ ] **Step 6: Update formal design**
Document the actual `/business/charge/counter-charge/submit` contract, request idempotency, explicit settlement IDs, amount formulas, channel-confirmation boundary, and additive database fields. Mark the old generic update payment path as compatibility-only and prohibited for new counter payments.
- [ ] **Step 7: Record verification evidence**
The evidence file must contain exact commands, exit codes, test counts, build result, integration-test `PASS/FAIL/BLOCKED`, backend/frontend commit SHAs, remaining risks, and an explicit statement that `vue-tsc` was not run.
- [ ] **Step 8: Commit documentation**
```bash
git add docs/evidence/rev003-charging docs/design/02_Detailed_Design/12_REV_Detailed.md docs/design/03_Technical_Design/03_Interface_Design.md docs/superpowers/plans/2026-07-15-rev003-charging-p0-remediation.md
git commit -m "docs: record REV-003 charging P0 implementation evidence"
```
## Plan self-review
- Spec coverage: all P0 findings in the approved design map to Tasks 110; bank outbox is explicitly excluded from the first delivery but false-success handling is included.
- Placeholder scan: no placeholder markers or unspecified test steps remain.
- Type consistency: request fields use `requestId`, `chargeIds`, `expectedReceivableAmount`, `actualPayAmount`, `usePrepay`, `chargeWay`; settlement consistently uses `paymentRecordIds`.
- Safety: all production changes have a failing test first; all financial mutations are transactional and use locked rows or conditional updates.
- User constraint: no plan step invokes `vue-tsc`.