# Counter Unsettled Red Flush Implementation Plan > **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:** Add formal red flush support for unsettled counter charge records without letting flushed records participate in later counter settlement. **Architecture:** Keep the existing `counter-settle/red-flush` API and split service behavior by payment settlement state. Settled red flush continues through `biz_settle_record_detail`; unsettled red flush writes a new projection table after creating the reverse payment record and marking the original payment record as `REVERSED`. Red flush record query/export merges settled-detail rows and unsettled-projection rows. Frontend reuses the same red flush API from the counter checkout page, adding a single-row action in the unsettled list and keeping the settled-detail action. **Tech Stack:** Java 17, Spring Boot, MyBatis Plus, Lombok, Kingbase/PostgreSQL SQL migration, JUnit 5, Mockito, Vue 3, Element Plus, TypeScript. --- ## File Structure - Create: `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/dal/dataobject/counterredflush/CounterUnsettledRedFlushRecordDO.java` - DO for the new unsettled red flush projection table. - Create: `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/dal/mysql/counterredflush/CounterUnsettledRedFlushRecordMapper.java` - Mapper for insert, duplicate checks, and red flush record query filtering. - Modify: `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/dal/mysql/paymentrecord/PaymentRecordMapper.java` - Add conditional update to mark an unsettled counter payment as `REVERSED`. - Modify: `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/dal/mysql/charge/ChargeMapper.java` - Add conditional update to restore a paid, unsettled charge to unpaid. - Modify: `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/countersettle/CounterSettleApplicationServiceImpl.java` - Split settled and unsettled red flush flows; merge red flush record rows from both sources. - Modify: `sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/countersettle/CounterSettleApplicationServiceImplTest.java` - Add tests for unsettled success, record query, mixed batch rejection, duplicate/concurrent rejection, and access control. - Modify: `sql/rev005/REV005_counter_settle_tables.sql` - Add DDL for `biz_counter_unsettled_red_flush_record`. - Modify: `water-web/src/views/operatingCharges/counterCheckout/components/CounterUnsettledPanel.vue` - Add single-row unsettled red flush action with required reason prompt. - Modify: `water-web/src/views/operatingCharges/counterCheckout/index.vue` - Add parent API call and refresh chain for unsettled red flush. - Modify: `water-web/src/views/operatingCharges/counterCheckout/components/CounterSettledDetailDialog.vue` - Align settled red flush reason with backend required validation. - Modify: `water-web/src/api/business/charge/counterSettle.ts` - Mark red flush reason as required and keep response row typing aligned. - Modify: `../water-docs/docs/design/02_Detailed_Design/12_REV_Detailed.md` - Document current formal unsettled red flush behavior. - Modify: `../water-docs/docs/design/03_Technical_Design/01_Database_Design.md` - Document the projection table. - Modify: `../water-docs/docs/design/03_Technical_Design/03_Interface_Design.md` - Document API constraints for settled/unsettled split and mixed-batch rejection. - Create: `../water-docs/docs/evidence/bugfix/counter-unsettled-red-flush-2026-06-30.md` - Evidence record with commands and outcomes. ## Task 1: Add Unsettled Red Flush Projection DO and Mapper **Files:** - Create: `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/dal/dataobject/counterredflush/CounterUnsettledRedFlushRecordDO.java` - Create: `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/dal/mysql/counterredflush/CounterUnsettledRedFlushRecordMapper.java` - Test: `sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/countersettle/CounterSettleApplicationServiceImplTest.java` - [ ] **Step 1: Add a failing compile reference in the service test** Add these imports and mock field to `CounterSettleApplicationServiceImplTest`: ```java import cn.com.emsoft.sw.business.dal.dataobject.counterredflush.CounterUnsettledRedFlushRecordDO; import cn.com.emsoft.sw.business.dal.mysql.counterredflush.CounterUnsettledRedFlushRecordMapper; ``` ```java @Mock private CounterUnsettledRedFlushRecordMapper counterUnsettledRedFlushRecordMapper; ``` - [ ] **Step 2: Run compile to verify the missing types fail** Run: ```bash mvn -pl sw-business/sw-business-server -Dtest=CounterSettleApplicationServiceImplTest test ``` Expected: FAIL with errors containing `package ...counterredflush does not exist` or `cannot find symbol CounterUnsettledRedFlushRecordMapper`. - [ ] **Step 3: Create `CounterUnsettledRedFlushRecordDO`** Create `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/dal/dataobject/counterredflush/CounterUnsettledRedFlushRecordDO.java`: ```java package cn.com.emsoft.sw.business.dal.dataobject.counterredflush; import cn.com.emsoft.sw.framework.tenant.core.db.TenantBaseDO; import com.baomidou.mybatisplus.annotation.KeySequence; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; import lombok.ToString; import java.math.BigDecimal; import java.time.LocalDateTime; @TableName("biz_counter_unsettled_red_flush_record") @KeySequence("biz_counter_unsettled_red_flush_record_seq") @Data @EqualsAndHashCode(callSuper = true) @ToString(callSuper = true) @Builder @NoArgsConstructor @AllArgsConstructor public class CounterUnsettledRedFlushRecordDO extends TenantBaseDO { @TableId private Long id; private Long paymentRecordId; private String paymentNo; private Long reversePaymentRecordId; private String reversePaymentNo; private Long chargeId; private Long sourceCustId; private String sourceCustCode; private String sourceCustName; private String cashierId; private BigDecimal reversedAmount; private LocalDateTime reversedTime; private String reverseReason; private String procPerson; private String procType; } ``` - [ ] **Step 4: Create `CounterUnsettledRedFlushRecordMapper`** Create `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/dal/mysql/counterredflush/CounterUnsettledRedFlushRecordMapper.java`: ```java package cn.com.emsoft.sw.business.dal.mysql.counterredflush; import cn.com.emsoft.sw.business.dal.dataobject.counterredflush.CounterUnsettledRedFlushRecordDO; import cn.com.emsoft.sw.framework.mybatis.core.mapper.BaseMapperX; import cn.com.emsoft.sw.framework.mybatis.core.query.LambdaQueryWrapperX; import org.apache.ibatis.annotations.Mapper; import java.time.LocalDateTime; import java.util.Collections; import java.util.List; @Mapper public interface CounterUnsettledRedFlushRecordMapper extends BaseMapperX { default List selectByPaymentRecordIds(List paymentRecordIds) { if (paymentRecordIds == null || paymentRecordIds.isEmpty()) { return Collections.emptyList(); } return selectList(new LambdaQueryWrapperX() .in(CounterUnsettledRedFlushRecordDO::getPaymentRecordId, paymentRecordIds) .orderByAsc(CounterUnsettledRedFlushRecordDO::getId)); } default List selectRecordRows(String cashierId, String custCode, LocalDateTime beginReversedTime, LocalDateTime endReversedTime) { return selectList(new LambdaQueryWrapperX() .eq(CounterUnsettledRedFlushRecordDO::getCashierId, cashierId) .eqIfPresent(CounterUnsettledRedFlushRecordDO::getSourceCustCode, custCode) .geIfPresent(CounterUnsettledRedFlushRecordDO::getReversedTime, beginReversedTime) .leIfPresent(CounterUnsettledRedFlushRecordDO::getReversedTime, endReversedTime) .orderByDesc(CounterUnsettledRedFlushRecordDO::getReversedTime, CounterUnsettledRedFlushRecordDO::getId)); } } ``` - [ ] **Step 5: Run compile to verify new types are available** Run: ```bash mvn -pl sw-business/sw-business-server -Dtest=CounterSettleApplicationServiceImplTest test ``` Expected: compile progresses past missing type errors. Existing tests may fail later because the new mock is not injected until the service is updated. - [ ] **Step 6: Commit** ```bash git add sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/dal/dataobject/counterredflush/CounterUnsettledRedFlushRecordDO.java \ sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/dal/mysql/counterredflush/CounterUnsettledRedFlushRecordMapper.java \ sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/countersettle/CounterSettleApplicationServiceImplTest.java git commit -m "feat(counter-settle): add unsettled red flush projection mapper" ``` ## Task 2: Add Conditional Mapper Updates for Unsettled Red Flush **Files:** - Modify: `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/dal/mysql/paymentrecord/PaymentRecordMapper.java` - Modify: `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/dal/mysql/charge/ChargeMapper.java` - Test: `sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/countersettle/CounterSettleApplicationServiceImplTest.java` - [ ] **Step 1: Add a mapper contract compile check** Add this test to `CounterSettleApplicationServiceImplTest`: ```java @Test void redFlush_mapperContracts_shouldExposeUnsettledStateUpdates() { when(paymentRecordMapper.markCounterUnsettledReversed(101L)).thenReturn(1); when(chargeMapper.clearCounterPaid(1001L)).thenReturn(1); assertEquals(1, paymentRecordMapper.markCounterUnsettledReversed(101L)); assertEquals(1, chargeMapper.clearCounterPaid(1001L)); } ``` Before adding the mapper methods, this test will not compile. This is the intended failure. - [ ] **Step 2: Add `markCounterUnsettledReversed`** Append this method to `PaymentRecordMapper` before the closing brace: ```java default int markCounterUnsettledReversed(Long paymentRecordId) { if (paymentRecordId == null) { return 0; } return update(null, new LambdaUpdateWrapper() .set(PaymentRecordDO::getSettleStatus, PaymentSettleStatusEnum.REVERSED.getCode()) .eq(PaymentRecordDO::getId, paymentRecordId) .eq(PaymentRecordDO::getSourceType, PaymentSourceTypeEnum.COUNTER_CHARGE.getValue()) .eq(PaymentRecordDO::getBizScene, "CHARGE_PAYMENT") .eq(PaymentRecordDO::getPayInOut, "IN") .eq(PaymentRecordDO::getSettleStatus, PaymentSettleStatusEnum.UNSETTLED.getCode()) .isNull(PaymentRecordDO::getSettleId)); } ``` - [ ] **Step 3: Add `clearCounterPaid`** Append this method to `ChargeMapper` before the closing brace: ```java default int clearCounterPaid(Long chargeId) { if (chargeId == null) { return 0; } return update(null, new LambdaUpdateWrapper() .set(ChargeDO::getPayDate, null) .set(ChargeDO::getCashierId, null) .set(ChargeDO::getChargeMethod, null) .set(ChargeDO::getChargeWay, null) .set(ChargeDO::getPayState, PayStateEnum.UNPAID.getValue()) .eq(ChargeDO::getId, chargeId) .eq(ChargeDO::getPayState, PayStateEnum.PAID.getValue()) .isNull(ChargeDO::getSettleId)); } ``` - [ ] **Step 4: Run compile** Run: ```bash mvn -pl sw-business/sw-business-server -Dtest=CounterSettleApplicationServiceImplTest test ``` Expected: compile succeeds and `redFlush_mapperContracts_shouldExposeUnsettledStateUpdates` passes. Other tests should continue to pass because only mapper default methods were added. - [ ] **Step 5: Commit** ```bash git add sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/dal/mysql/paymentrecord/PaymentRecordMapper.java \ sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/dal/mysql/charge/ChargeMapper.java \ sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/countersettle/CounterSettleApplicationServiceImplTest.java git commit -m "feat(counter-settle): add unsettled red flush state updates" ``` ## Task 3: Add Database DDL for the Projection Table **Files:** - Modify: `sql/rev005/REV005_counter_settle_tables.sql` - [ ] **Step 1: Append sequence and table DDL** Append this block after the existing `biz_settle_record_detail` indexes: ```sql DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.relkind = 'S' AND c.relname = 'biz_counter_unsettled_red_flush_record_seq' AND n.nspname = current_schema() ) THEN CREATE SEQUENCE biz_counter_unsettled_red_flush_record_seq START 1; END IF; END $$; CREATE TABLE IF NOT EXISTS biz_counter_unsettled_red_flush_record ( id BIGINT NOT NULL DEFAULT nextval('biz_counter_unsettled_red_flush_record_seq'), payment_record_id BIGINT NOT NULL, payment_no VARCHAR(64) NULL, reverse_payment_record_id BIGINT NULL, reverse_payment_no VARCHAR(64) NULL, charge_id BIGINT NULL, source_cust_id BIGINT NULL, source_cust_code VARCHAR(64) NULL, source_cust_name VARCHAR(128) NULL, cashier_id VARCHAR(64) NULL, reversed_amount NUMERIC(18, 2) NULL, reversed_time TIMESTAMP NOT NULL, reverse_reason VARCHAR(500) NULL, proc_person VARCHAR(64) NULL, proc_type VARCHAR(64) NULL, creator VARCHAR(64) NULL DEFAULT '', create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updater VARCHAR(64) NULL DEFAULT '', update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, deleted SMALLINT NOT NULL DEFAULT 0, tenant_id BIGINT NOT NULL DEFAULT 0, CONSTRAINT pk_biz_counter_unsettled_red_flush_record PRIMARY KEY (id) ); CREATE UNIQUE INDEX IF NOT EXISTS uk_counter_unsettled_red_flush_payment ON biz_counter_unsettled_red_flush_record (payment_record_id, tenant_id); CREATE INDEX IF NOT EXISTS idx_counter_unsettled_red_flush_cashier_time ON biz_counter_unsettled_red_flush_record (cashier_id, reversed_time, tenant_id); CREATE INDEX IF NOT EXISTS idx_counter_unsettled_red_flush_cust_time ON biz_counter_unsettled_red_flush_record (source_cust_code, reversed_time, tenant_id); ``` - [ ] **Step 2: Verify no duplicate DDL identifiers** Run: ```bash rg -n "biz_counter_unsettled_red_flush_record" sql/rev005/REV005_counter_settle_tables.sql ``` Expected: output shows the sequence, table, and three indexes exactly once each. - [ ] **Step 3: Commit** ```bash git add sql/rev005/REV005_counter_settle_tables.sql git commit -m "db(counter-settle): add unsettled red flush projection table" ``` ## Task 4: Implement Unsettled Red Flush Service Flow **Files:** - Modify: `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/countersettle/CounterSettleApplicationServiceImpl.java` - Test: `sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/countersettle/CounterSettleApplicationServiceImplTest.java` - [ ] **Step 1: Write failing unsettled red flush success test** Add this test to `CounterSettleApplicationServiceImplTest`: ```java @Test void redFlush_shouldReverseUnsettledCounterChargeAndWriteProjection() { PaymentRecordDO paymentRecord = PaymentRecordDO.builder() .id(101L) .paymentNo("PAY-101") .sourceType("COUNTER_CHARGE") .sourceRefId(1001L) .bizScene("CHARGE_PAYMENT") .payInOut("IN") .paymentAmount(new BigDecimal("20.50")) .settleStatus("UNSETTLED") .settleId(null) .chargeWay(11) .cashierId("1001") .custId(9001L) .custCode("CUST-1") .custName("客户一") .extJson("{\"prepayDeductAmount\":\"8.20\"}") .build(); ChargeDO charge = buildCharge(1001L, 58L, 9001L, "CUST-1", "客户一"); charge.setPayState(PayStateEnum.PAID.getValue()); PaymentRecordDO reverseRecord = PaymentRecordDO.builder() .id(201L) .paymentNo("PAY-201") .relatedPaymentRecordId(101L) .build(); when(settleRecordDetailMapper.selectByPaymentRecordIds(List.of(101L))).thenReturn(List.of()); when(paymentRecordMapper.selectList(any(LambdaQueryWrapperX.class))).thenReturn(List.of(paymentRecord)); when(counterUnsettledRedFlushRecordMapper.selectByPaymentRecordIds(List.of(101L))).thenReturn(List.of()); when(chargeMapper.selectByIds(List.of(1001L))).thenReturn(List.of(charge)); when(paymentRecordMapper.markCounterUnsettledReversed(101L)).thenReturn(1); when(chargeMapper.clearCounterPaid(1001L)).thenReturn(1); when(paymentCommandApplicationService.reverseChargePayment(charge)).thenReturn(reverseRecord); CounterRedFlushReqVO reqVO = new CounterRedFlushReqVO(); reqVO.setPaymentRecordIds(List.of(101L)); reqVO.setOperatorId("1001"); reqVO.setReason("未结账红冲"); var result = service.redFlush(reqVO); assertEquals(1, result.getRedFlushedCount()); assertEquals(new BigDecimal("12.30"), result.getRefundCashAmount()); assertEquals(new BigDecimal("8.20"), result.getRefundPrepayAmount()); verify(paymentRecordMapper).markCounterUnsettledReversed(101L); verify(chargeMapper).clearCounterPaid(1001L); ArgumentCaptor captor = ArgumentCaptor.forClass(CounterUnsettledRedFlushRecordDO.class); verify(counterUnsettledRedFlushRecordMapper).insert(captor.capture()); CounterUnsettledRedFlushRecordDO record = captor.getValue(); assertEquals(Long.valueOf(101L), record.getPaymentRecordId()); assertEquals("PAY-101", record.getPaymentNo()); assertEquals(Long.valueOf(201L), record.getReversePaymentRecordId()); assertEquals("PAY-201", record.getReversePaymentNo()); assertEquals(Long.valueOf(1001L), record.getChargeId()); assertEquals("CUST-1", record.getSourceCustCode()); assertEquals("1001", record.getCashierId()); assertEquals(new BigDecimal("20.50"), record.getReversedAmount()); assertEquals("未结账红冲", record.getReverseReason()); assertEquals("1001", record.getProcPerson()); assertEquals("COUNTER_UNSETTLED_RED_FLUSH", record.getProcType()); } ``` - [ ] **Step 2: Run the failing test** Run: ```bash mvn -pl sw-business/sw-business-server -Dtest=CounterSettleApplicationServiceImplTest#redFlush_shouldReverseUnsettledCounterChargeAndWriteProjection test ``` Expected: FAIL because `redFlush` still rejects records that do not have `SettleRecordDetailDO`. - [ ] **Step 3: Add service imports and mapper resource** Add imports to `CounterSettleApplicationServiceImpl`: ```java import cn.com.emsoft.sw.business.dal.dataobject.counterredflush.CounterUnsettledRedFlushRecordDO; import cn.com.emsoft.sw.business.dal.mysql.counterredflush.CounterUnsettledRedFlushRecordMapper; import cn.com.emsoft.sw.business.enums.payment.PaymentBizSceneEnum; import cn.com.emsoft.sw.business.enums.payment.PaymentInOutEnum; import cn.com.emsoft.sw.business.enums.payment.PaymentSourceTypeEnum; ``` Add constant and mapper field near existing constants/resources: ```java private static final String COUNTER_UNSETTLED_RED_FLUSH_PROC_TYPE = "COUNTER_UNSETTLED_RED_FLUSH"; ``` ```java @Resource private CounterUnsettledRedFlushRecordMapper counterUnsettledRedFlushRecordMapper; ``` - [ ] **Step 4: Replace `redFlush` with status dispatch** Replace the current `redFlush` method body with: ```java @Override @Transactional(rollbackFor = Exception.class) public CounterRedFlushRespVO redFlush(CounterRedFlushReqVO reqVO) { String operatorId = resolveOperatorId(reqVO.getOperatorId()); List paymentRecordIds = normalizePaymentRecordIds(reqVO.getPaymentRecordIds()); if (paymentRecordIds.isEmpty()) { throw invalidParamException("支付主单ID列表不能为空"); } Map paymentRecordMap = buildPaymentRecordMap(paymentRecordIds); if (paymentRecordMap.size() != paymentRecordIds.size()) { throw invalidParamException("支付主单不存在,无法执行柜台红冲"); } Map detailMap = settleRecordDetailMapper.selectByPaymentRecordIds(paymentRecordIds).stream() .collect(Collectors.toMap(SettleRecordDetailDO::getPaymentRecordId, Function.identity(), (left, right) -> left, LinkedHashMap::new)); boolean allSettled = detailMap.size() == paymentRecordIds.size(); boolean allUnsettled = detailMap.isEmpty() && paymentRecordIds.stream() .map(paymentRecordMap::get) .allMatch(this::isUnsettledCounterRedFlushCandidate); if (allSettled) { return redFlushSettledRecords(reqVO, operatorId, paymentRecordIds, paymentRecordMap, detailMap); } if (allUnsettled) { return redFlushUnsettledRecords(reqVO, operatorId, paymentRecordIds, paymentRecordMap); } throw invalidParamException("已结账与未结账收费记录请分批红冲"); } ``` - [ ] **Step 5: Add helper methods for unsettled flow** Add these methods below `redFlush`: ```java private List normalizePaymentRecordIds(List paymentRecordIds) { return paymentRecordIds == null ? List.of() : paymentRecordIds.stream() .filter(Objects::nonNull) .distinct() .toList(); } private CounterRedFlushRespVO redFlushSettledRecords(CounterRedFlushReqVO reqVO, String operatorId, List paymentRecordIds, Map paymentRecordMap, Map detailMap) { validateAuthenticatedCashierRedFlushAccess(paymentRecordIds, paymentRecordMap, detailMap); List chargeIds = paymentRecordIds.stream() .map(paymentRecordId -> { SettleRecordDetailDO detail = detailMap.get(paymentRecordId); PaymentRecordDO paymentRecord = paymentRecordMap.get(paymentRecordId); return detail != null && detail.getChargeId() != null ? detail.getChargeId() : (paymentRecord == null ? null : paymentRecord.getSourceRefId()); }) .filter(Objects::nonNull) .distinct() .toList(); Map chargeMap = buildChargeMapByIds(chargeIds); LocalDateTime reverseTime = LocalDateTime.now(); BigDecimal refundCashAmount = BigDecimal.ZERO; BigDecimal refundPrepayAmount = BigDecimal.ZERO; Set touchedSettleIds = new LinkedHashSet<>(); for (Long paymentRecordId : paymentRecordIds) { PaymentRecordDO paymentRecord = paymentRecordMap.get(paymentRecordId); SettleRecordDetailDO detail = detailMap.get(paymentRecordId); validateRedFlushCandidate(paymentRecord, detail); Long chargeId = detail.getChargeId() != null ? detail.getChargeId() : paymentRecord.getSourceRefId(); ChargeDO charge = chargeMap.get(chargeId); if (charge == null) { throw invalidParamException("营业账不存在,无法执行柜台红冲: {}", chargeId); } BigDecimal detailReversedAmount = defaultZero(detail.getSettleAmount()); BigDecimal detailRefundPrepayAmount = resolveRefundPrepayAmount(detail, paymentRecord); BigDecimal detailRefundCashAmount = detailReversedAmount.subtract(detailRefundPrepayAmount).max(BigDecimal.ZERO); int detailUpdated = settleRecordDetailMapper.markCounterRedFlushed( paymentRecordId, detail.getSettleId(), detailReversedAmount, reverseTime, operatorId, reqVO.getReason(), COUNTER_SETTLE_RED_FLUSH_PROC_TYPE); if (detailUpdated != 1) { throw invalidParamException("支付主单已红冲,禁止重复红冲: {}", paymentRecordId); } int paymentUpdated = paymentRecordMapper.markCounterSettleReversed(paymentRecordId, detail.getSettleId()); if (paymentUpdated != 1) { throw invalidParamException("支付主单已红冲或结清状态已变更,禁止重复红冲: {}", paymentRecordId); } PaymentRecordDO reverseRecord; try { reverseRecord = paymentCommandApplicationService.reverseChargePayment(buildReverseEligibleCharge(charge, detail)); } catch (IllegalStateException ex) { throw invalidParamException(ex.getMessage()); } if (reverseRecord == null || !Objects.equals(reverseRecord.getRelatedPaymentRecordId(), paymentRecordId)) { throw invalidParamException("仅支持冲回该账单最新未冲回的收费流水: {}", paymentRecordId); } int clearedChargeCount = chargeMapper.clearCounterSettled(chargeId, detail.getSettleId()); if (clearedChargeCount != 1) { throw invalidParamException("营业账结清状态已变更,无法执行柜台红冲: {}", chargeId); } refundCashAmount = refundCashAmount.add(detailRefundCashAmount); refundPrepayAmount = refundPrepayAmount.add(detailRefundPrepayAmount); touchedSettleIds.add(detail.getSettleId()); } touchedSettleIds.forEach(settleId -> refreshSettleRecordAfterRedFlush(settleId, reqVO.getReason(), reverseTime)); return CounterRedFlushRespVO.builder() .redFlushedCount(paymentRecordIds.size()) .refundCashAmount(refundCashAmount) .refundPrepayAmount(refundPrepayAmount) .build(); } private CounterRedFlushRespVO redFlushUnsettledRecords(CounterRedFlushReqVO reqVO, String operatorId, List paymentRecordIds, Map paymentRecordMap) { validateAuthenticatedCashierUnsettledRedFlushAccess(paymentRecordMap.values()); List existingRecords = counterUnsettledRedFlushRecordMapper.selectByPaymentRecordIds(paymentRecordIds); if (existingRecords != null && !existingRecords.isEmpty()) { throw invalidParamException("支付主单已红冲,禁止重复红冲: {}", existingRecords.get(0).getPaymentRecordId()); } Map chargeMap = buildChargeMapByPaymentRecords(new ArrayList<>(paymentRecordMap.values())); LocalDateTime reverseTime = LocalDateTime.now(); BigDecimal refundCashAmount = BigDecimal.ZERO; BigDecimal refundPrepayAmount = BigDecimal.ZERO; for (Long paymentRecordId : paymentRecordIds) { PaymentRecordDO paymentRecord = paymentRecordMap.get(paymentRecordId); validateUnsettledRedFlushCandidate(paymentRecord); ChargeDO charge = chargeMap.get(paymentRecord.getSourceRefId()); if (charge == null) { throw invalidParamException("营业账不存在,无法执行柜台红冲: {}", paymentRecord.getSourceRefId()); } PaymentRecordDO reverseRecord; try { reverseRecord = paymentCommandApplicationService.reverseChargePayment(charge); } catch (IllegalStateException ex) { throw invalidParamException(ex.getMessage()); } if (reverseRecord == null || !Objects.equals(reverseRecord.getRelatedPaymentRecordId(), paymentRecordId)) { throw invalidParamException("仅支持冲回该账单最新未冲回的收费流水: {}", paymentRecordId); } int paymentUpdated = paymentRecordMapper.markCounterUnsettledReversed(paymentRecordId); if (paymentUpdated != 1) { throw invalidParamException("支付主单已红冲或结清状态已变更,禁止重复红冲: {}", paymentRecordId); } int chargeUpdated = chargeMapper.clearCounterPaid(charge.getId()); if (chargeUpdated != 1) { throw invalidParamException("营业账收费状态已变更,无法执行柜台红冲: {}", charge.getId()); } BigDecimal reversedAmount = defaultZero(paymentRecord.getPaymentAmount()); BigDecimal refundPrepay = resolveRefundPrepayAmount(reversedAmount, paymentRecord); BigDecimal refundCash = reversedAmount.subtract(refundPrepay).max(BigDecimal.ZERO); counterUnsettledRedFlushRecordMapper.insert(CounterUnsettledRedFlushRecordDO.builder() .paymentRecordId(paymentRecord.getId()) .paymentNo(paymentRecord.getPaymentNo()) .reversePaymentRecordId(reverseRecord.getId()) .reversePaymentNo(reverseRecord.getPaymentNo()) .chargeId(charge.getId()) .sourceCustId(paymentRecord.getCustId()) .sourceCustCode(paymentRecord.getCustCode()) .sourceCustName(resolveUnsettledRedFlushCustName(paymentRecord, charge)) .cashierId(paymentRecord.getCashierId()) .reversedAmount(reversedAmount) .reversedTime(reverseTime) .reverseReason(reqVO.getReason()) .procPerson(operatorId) .procType(COUNTER_UNSETTLED_RED_FLUSH_PROC_TYPE) .build()); refundCashAmount = refundCashAmount.add(refundCash); refundPrepayAmount = refundPrepayAmount.add(refundPrepay); } return CounterRedFlushRespVO.builder() .redFlushedCount(paymentRecordIds.size()) .refundCashAmount(refundCashAmount) .refundPrepayAmount(refundPrepayAmount) .build(); } ``` - [ ] **Step 6: Add validation helpers** Add these methods near existing validation helpers: ```java private boolean isUnsettledCounterRedFlushCandidate(PaymentRecordDO paymentRecord) { return paymentRecord != null && Objects.equals(PaymentSourceTypeEnum.COUNTER_CHARGE.getValue(), paymentRecord.getSourceType()) && Objects.equals(PaymentBizSceneEnum.CHARGE_PAYMENT.getValue(), paymentRecord.getBizScene()) && Objects.equals(PaymentInOutEnum.IN.getValue(), paymentRecord.getPayInOut()) && Objects.equals(PaymentSettleStatusEnum.UNSETTLED.getCode(), paymentRecord.getSettleStatus()) && paymentRecord.getSettleId() == null; } private void validateUnsettledRedFlushCandidate(PaymentRecordDO paymentRecord) { if (!isUnsettledCounterRedFlushCandidate(paymentRecord)) { throw invalidParamException("仅允许红冲未结账的柜台收费记录: {}", paymentRecord == null ? null : paymentRecord.getId()); } } private void validateAuthenticatedCashierUnsettledRedFlushAccess(Collection paymentRecords) { String authenticatedUserId = resolveAuthenticatedUserId(); if (authenticatedUserId == null || authenticatedUserId.isBlank()) { return; } boolean hasOtherCashierRecord = paymentRecords.stream() .filter(Objects::nonNull) .anyMatch(record -> !Objects.equals(authenticatedUserId, record.getCashierId())); if (hasOtherCashierRecord) { throw invalidParamException("无权红冲其他收费员的收费记录"); } } private BigDecimal resolveRefundPrepayAmount(BigDecimal reversedAmount, PaymentRecordDO paymentRecord) { BigDecimal snapshotPrepayAmount = extractPrepayDeductAmount(paymentRecord); if (snapshotPrepayAmount.compareTo(BigDecimal.ZERO) <= 0 && paymentRecord != null && Objects.equals(CHARGE_WAY_PREPAY_DEDUCT, paymentRecord.getChargeWay())) { snapshotPrepayAmount = defaultZero(paymentRecord.getPaymentAmount()); } if (snapshotPrepayAmount.compareTo(BigDecimal.ZERO) <= 0) { return BigDecimal.ZERO; } return snapshotPrepayAmount.min(defaultZero(reversedAmount)).max(BigDecimal.ZERO); } private String resolveUnsettledRedFlushCustName(PaymentRecordDO paymentRecord, ChargeDO charge) { if (paymentRecord != null && paymentRecord.getCustName() != null && !paymentRecord.getCustName().isBlank()) { return paymentRecord.getCustName(); } return charge == null ? null : charge.getCustName(); } ``` - [ ] **Step 7: Run success test** Run: ```bash mvn -pl sw-business/sw-business-server -Dtest=CounterSettleApplicationServiceImplTest#redFlush_shouldReverseUnsettledCounterChargeAndWriteProjection test ``` Expected: PASS. - [ ] **Step 8: Run settled red flush regression test** Run: ```bash mvn -pl sw-business/sw-business-server -Dtest=CounterSettleApplicationServiceImplTest#redFlush_shouldPersistDetailAndHeaderReversedAmountAndSplitRefunds test ``` Expected: PASS. - [ ] **Step 9: Commit** ```bash git add sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/countersettle/CounterSettleApplicationServiceImpl.java \ sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/countersettle/CounterSettleApplicationServiceImplTest.java git commit -m "feat(counter-settle): support unsettled red flush" ``` ## Task 5: Add Unsettled Red Flush Guard Tests **Files:** - Modify: `sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/countersettle/CounterSettleApplicationServiceImplTest.java` - [ ] **Step 1: Add mixed-batch rejection test** Add: ```java @Test void redFlush_shouldRejectMixedSettledAndUnsettledRecords() { PaymentRecordDO settled = PaymentRecordDO.builder() .id(101L) .sourceType("COUNTER_CHARGE") .sourceRefId(1001L) .bizScene("CHARGE_PAYMENT") .payInOut("IN") .settleStatus("SETTLED") .settleId(7001L) .cashierId("1001") .build(); PaymentRecordDO unsettled = PaymentRecordDO.builder() .id(102L) .sourceType("COUNTER_CHARGE") .sourceRefId(1002L) .bizScene("CHARGE_PAYMENT") .payInOut("IN") .settleStatus("UNSETTLED") .settleId(null) .cashierId("1001") .build(); SettleRecordDetailDO detail = SettleRecordDetailDO.builder() .settleId(7001L) .paymentRecordId(101L) .detailStatus("NORMAL") .build(); when(paymentRecordMapper.selectList(any(LambdaQueryWrapperX.class))).thenReturn(List.of(settled, unsettled)); when(settleRecordDetailMapper.selectByPaymentRecordIds(List.of(101L, 102L))).thenReturn(List.of(detail)); CounterRedFlushReqVO reqVO = new CounterRedFlushReqVO(); reqVO.setPaymentRecordIds(List.of(101L, 102L)); reqVO.setOperatorId("1001"); reqVO.setReason("混批"); ServiceException ex = assertThrows(ServiceException.class, () -> service.redFlush(reqVO)); assertTrue(ex.getMessage().contains("已结账与未结账收费记录请分批红冲")); verify(counterUnsettledRedFlushRecordMapper, never()).insert(any()); verify(paymentCommandApplicationService, never()).reverseChargePayment(any()); } ``` - [ ] **Step 2: Add duplicate projection rejection test** Add: ```java @Test void redFlush_shouldRejectDuplicateUnsettledRedFlush() { PaymentRecordDO paymentRecord = PaymentRecordDO.builder() .id(101L) .sourceType("COUNTER_CHARGE") .sourceRefId(1001L) .bizScene("CHARGE_PAYMENT") .payInOut("IN") .settleStatus("UNSETTLED") .settleId(null) .cashierId("1001") .build(); when(settleRecordDetailMapper.selectByPaymentRecordIds(List.of(101L))).thenReturn(List.of()); when(paymentRecordMapper.selectList(any(LambdaQueryWrapperX.class))).thenReturn(List.of(paymentRecord)); when(counterUnsettledRedFlushRecordMapper.selectByPaymentRecordIds(List.of(101L))) .thenReturn(List.of(CounterUnsettledRedFlushRecordDO.builder() .paymentRecordId(101L) .build())); CounterRedFlushReqVO reqVO = new CounterRedFlushReqVO(); reqVO.setPaymentRecordIds(List.of(101L)); reqVO.setOperatorId("1001"); reqVO.setReason("重复"); ServiceException ex = assertThrows(ServiceException.class, () -> service.redFlush(reqVO)); assertTrue(ex.getMessage().contains("支付主单已红冲")); verify(paymentCommandApplicationService, never()).reverseChargePayment(any()); } ``` - [ ] **Step 3: Add concurrent settlement rejection test** Add: ```java @Test void redFlush_shouldRejectUnsettledRecordWhenStateChangedDuringUpdate() { PaymentRecordDO paymentRecord = PaymentRecordDO.builder() .id(101L) .paymentNo("PAY-101") .sourceType("COUNTER_CHARGE") .sourceRefId(1001L) .bizScene("CHARGE_PAYMENT") .payInOut("IN") .paymentAmount(new BigDecimal("20.50")) .settleStatus("UNSETTLED") .settleId(null) .cashierId("1001") .build(); ChargeDO charge = buildCharge(1001L, 58L, 9001L, "CUST-1", "客户一"); PaymentRecordDO reverseRecord = PaymentRecordDO.builder() .id(201L) .relatedPaymentRecordId(101L) .build(); when(settleRecordDetailMapper.selectByPaymentRecordIds(List.of(101L))).thenReturn(List.of()); when(paymentRecordMapper.selectList(any(LambdaQueryWrapperX.class))).thenReturn(List.of(paymentRecord)); when(counterUnsettledRedFlushRecordMapper.selectByPaymentRecordIds(List.of(101L))).thenReturn(List.of()); when(chargeMapper.selectByIds(List.of(1001L))).thenReturn(List.of(charge)); when(paymentCommandApplicationService.reverseChargePayment(charge)).thenReturn(reverseRecord); when(paymentRecordMapper.markCounterUnsettledReversed(101L)).thenReturn(0); CounterRedFlushReqVO reqVO = new CounterRedFlushReqVO(); reqVO.setPaymentRecordIds(List.of(101L)); reqVO.setOperatorId("1001"); reqVO.setReason("并发"); ServiceException ex = assertThrows(ServiceException.class, () -> service.redFlush(reqVO)); assertTrue(ex.getMessage().contains("结清状态已变更")); verify(counterUnsettledRedFlushRecordMapper, never()).insert(any()); } ``` - [ ] **Step 4: Add access-control test** Add: ```java @Test void redFlush_shouldRejectAuthenticatedCashierForAnotherCashiersUnsettledRecord() { PaymentRecordDO paymentRecord = PaymentRecordDO.builder() .id(101L) .sourceType("COUNTER_CHARGE") .sourceRefId(1001L) .bizScene("CHARGE_PAYMENT") .payInOut("IN") .settleStatus("UNSETTLED") .settleId(null) .cashierId("2002") .build(); when(settleRecordDetailMapper.selectByPaymentRecordIds(List.of(101L))).thenReturn(List.of()); when(paymentRecordMapper.selectList(any(LambdaQueryWrapperX.class))).thenReturn(List.of(paymentRecord)); CounterRedFlushReqVO reqVO = new CounterRedFlushReqVO(); reqVO.setPaymentRecordIds(List.of(101L)); reqVO.setOperatorId("2002"); reqVO.setReason("越权"); try (MockedStatic mockedSecurity = org.mockito.Mockito.mockStatic(SecurityFrameworkUtils.class)) { mockedSecurity.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(1001L); ServiceException ex = assertThrows(ServiceException.class, () -> service.redFlush(reqVO)); assertTrue(ex.getMessage().contains("无权红冲其他收费员的收费记录")); } verify(paymentCommandApplicationService, never()).reverseChargePayment(any()); } ``` - [ ] **Step 5: Run guard tests** Run: ```bash mvn -pl sw-business/sw-business-server -Dtest='CounterSettleApplicationServiceImplTest#redFlush_shouldRejectMixedSettledAndUnsettledRecords+redFlush_shouldRejectDuplicateUnsettledRedFlush+redFlush_shouldRejectUnsettledRecordWhenStateChangedDuringUpdate+redFlush_shouldRejectAuthenticatedCashierForAnotherCashiersUnsettledRecord' test ``` Expected: PASS. - [ ] **Step 6: Commit** ```bash git add sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/countersettle/CounterSettleApplicationServiceImplTest.java git commit -m "test(counter-settle): cover unsettled red flush guards" ``` ## Task 6: Merge Unsettled Projection into Red Flush Record Query **Files:** - Modify: `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/countersettle/CounterSettleApplicationServiceImpl.java` - Test: `sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/countersettle/CounterSettleApplicationServiceImplTest.java` - [ ] **Step 1: Add failing query test** Add: ```java @Test void redFlushRecordPage_shouldIncludeUnsettledProjectionRows() { LocalDateTime reversedTime = LocalDateTime.of(2026, 6, 30, 9, 30); CounterUnsettledRedFlushRecordDO projection = CounterUnsettledRedFlushRecordDO.builder() .paymentRecordId(101L) .paymentNo("PAY-101") .sourceCustCode("CUST-1") .sourceCustName("客户一") .cashierId("1001") .reversedAmount(new BigDecimal("20.50")) .reversedTime(reversedTime) .reverseReason("未结账红冲") .build(); when(settleRecordMapper.selectList(any(LambdaQueryWrapperX.class))).thenReturn(List.of()); when(counterUnsettledRedFlushRecordMapper.selectRecordRows("1001", null, null, null)).thenReturn(List.of(projection)); CounterRedFlushRecordPageReqVO reqVO = new CounterRedFlushRecordPageReqVO(); reqVO.setCashierId("1001"); PageResult result = service.getRedFlushRecordPage(reqVO); assertEquals(1L, result.getTotal()); Object row = result.getList().get(0); assertTrue(row instanceof cn.com.emsoft.sw.business.controller.admin.charge.vo.CounterRedFlushRecordRespVO); var resp = (cn.com.emsoft.sw.business.controller.admin.charge.vo.CounterRedFlushRecordRespVO) row; assertNull(resp.getSettleId()); assertNull(resp.getSettleNo()); assertEquals(Long.valueOf(101L), resp.getPaymentRecordId()); assertEquals("PAY-101", resp.getPaymentNo()); assertEquals("CUST-1", resp.getCustCode()); assertEquals("客户一", resp.getCustName()); assertEquals("1001", resp.getCashierId()); assertEquals(new BigDecimal("20.50"), resp.getReversedAmount()); assertEquals(reversedTime, resp.getReversedTime()); assertEquals("未结账红冲", resp.getReverseReason()); } ``` - [ ] **Step 2: Run failing query test** Run: ```bash mvn -pl sw-business/sw-business-server -Dtest=CounterSettleApplicationServiceImplTest#redFlushRecordPage_shouldIncludeUnsettledProjectionRows test ``` Expected: FAIL because `queryRedFlushRecordRows` currently returns early when no settled red flush records exist. - [ ] **Step 3: Refactor query to merge both sources** Replace `queryRedFlushRecordRows` with: ```java private List queryRedFlushRecordRows(CounterRedFlushRecordPageReqVO reqVO) { String cashierId = requireCashierId(resolveCashierId(reqVO.getCashierId())); List rows = new ArrayList<>(); rows.addAll(querySettledRedFlushRecordRows(reqVO, cashierId)); rows.addAll(queryUnsettledRedFlushRecordRows(reqVO, cashierId)); return rows.stream() .sorted(redFlushRecordRowComparator()) .toList(); } ``` Add: ```java private List querySettledRedFlushRecordRows(CounterRedFlushRecordPageReqVO reqVO, String cashierId) { LambdaQueryWrapperX queryWrapper = new LambdaQueryWrapperX() .eq(SettleRecordDO::getCashierId, cashierId); queryWrapper.in(SettleRecordDO::getStatus, SettleRecordStatusEnum.PARTIAL_REVERSED.getCode(), SettleRecordStatusEnum.FULL_REVERSED.getCode()); queryWrapper.orderByDesc(SettleRecordDO::getReversedTime, SettleRecordDO::getId); List settleRecords = settleRecordMapper.selectList(queryWrapper); if (settleRecords == null || settleRecords.isEmpty()) { return Collections.emptyList(); } List settleIds = settleRecords.stream() .map(SettleRecordDO::getId) .filter(Objects::nonNull) .distinct() .toList(); List details = settleRecordDetailMapper.selectBySettleIds(settleIds); Map> detailMap = (details == null ? Collections.emptyList() : details).stream() .collect(Collectors.groupingBy(SettleRecordDetailDO::getSettleId, LinkedHashMap::new, Collectors.toList())); Map paymentRecordMap = buildPaymentRecordMap((details == null ? Collections.emptyList() : details).stream() .map(SettleRecordDetailDO::getPaymentRecordId) .filter(Objects::nonNull) .distinct() .toList()); Map chargeMap = buildChargeMapByIds((details == null ? Collections.emptyList() : details).stream() .map(SettleRecordDetailDO::getChargeId) .filter(Objects::nonNull) .distinct() .toList()); Map custNameMap = buildCustNameMapByCodes((details == null ? Collections.emptyList() : details).stream() .map(SettleRecordDetailDO::getSourceCustCode) .filter(code -> code != null && !code.isBlank()) .distinct() .toList()); return settleRecords.stream() .flatMap(settleRecord -> detailMap.getOrDefault(settleRecord.getId(), Collections.emptyList()).stream() .filter(detail -> Objects.equals(SettleRecordDetailStatusEnum.REVERSED.getCode(), detail.getDetailStatus())) .filter(detail -> isDetailReverseTimeInRange(detail, reqVO)) .filter(detail -> reqVO.getCustCode() == null || reqVO.getCustCode().isBlank() || Objects.equals(reqVO.getCustCode(), detail.getSourceCustCode())) .map(detail -> toRedFlushRecordResp( settleRecord, detail, paymentRecordMap.get(detail.getPaymentRecordId()), chargeMap.get(detail.getChargeId()), custNameMap.get(detail.getSourceCustCode())))) .toList(); } private List queryUnsettledRedFlushRecordRows(CounterRedFlushRecordPageReqVO reqVO, String cashierId) { List records = counterUnsettledRedFlushRecordMapper.selectRecordRows( cashierId, reqVO.getCustCode(), reqVO.getBeginReversedTime(), reqVO.getEndReversedTime()); return (records == null ? Collections.emptyList() : records).stream() .map(this::toRedFlushRecordResp) .toList(); } private CounterRedFlushRecordRespVO toRedFlushRecordResp(CounterUnsettledRedFlushRecordDO record) { return CounterRedFlushRecordRespVO.builder() .settleId(null) .settleNo(null) .paymentRecordId(record.getPaymentRecordId()) .paymentNo(record.getPaymentNo()) .custCode(record.getSourceCustCode()) .custName(record.getSourceCustName()) .cashierId(record.getCashierId()) .reversedAmount(record.getReversedAmount()) .reversedTime(record.getReversedTime()) .reverseReason(record.getReverseReason()) .build(); } ``` - [ ] **Step 4: Run query test and settled record regression** Run: ```bash mvn -pl sw-business/sw-business-server -Dtest='CounterSettleApplicationServiceImplTest#redFlushRecordPage_shouldIncludeUnsettledProjectionRows+getRedFlushRecordPage_shouldFilterByReverseTimeAndReturnReversedDetails' test ``` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/countersettle/CounterSettleApplicationServiceImpl.java \ sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/countersettle/CounterSettleApplicationServiceImplTest.java git commit -m "feat(counter-settle): include unsettled red flush records" ``` ## Task 7: Run Focused Backend Verification **Files:** - No source edits unless verification reveals a failure. - [ ] **Step 1: Run counter-settle unit tests** Run: ```bash mvn -pl sw-business/sw-business-server -Dtest=CounterSettleApplicationServiceImplTest test ``` Expected: PASS, with all tests in `CounterSettleApplicationServiceImplTest` passing. - [ ] **Step 2: Run payment record reverse tests** Run: ```bash mvn -pl sw-business/sw-business-server -Dtest=PaymentRecordServiceImplTest test ``` Expected: PASS, proving the reused `reverseChargePayment` behavior was not broken. - [ ] **Step 3: Run compile for business server module** Run: ```bash mvn -pl sw-business/sw-business-server -DskipTests compile ``` Expected: PASS. - [ ] **Step 4: Commit only if verification fixes were needed** If a verification failure required a code change in the counter-settle service or tests: ```bash git add sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/countersettle/CounterSettleApplicationServiceImpl.java \ sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/countersettle/CounterSettleApplicationServiceImplTest.java git commit -m "fix(counter-settle): stabilize unsettled red flush verification" ``` If a verification failure required a mapper or DDL fix, add the exact changed mapper or SQL file alongside the service/test paths above before committing. If no code changed, do not create a commit. ## Task 8: Add Frontend Unsettled Red Flush Entry **Files:** - Modify: `src/views/operatingCharges/counterCheckout/components/CounterUnsettledPanel.vue` - Modify: `src/views/operatingCharges/counterCheckout/index.vue` - Modify: `src/views/operatingCharges/counterCheckout/components/CounterSettledDetailDialog.vue` - Modify: `src/api/business/charge/counterSettle.ts` - [ ] **Step 1: Add operation column in unsettled table** Add a fixed right-side `红冲` button for rows that have `paymentRecordId`, `chargeId`, and `bizScene` empty or `CHARGE_PAYMENT`. - [ ] **Step 2: Add required reason confirmation** Use the existing `useMessage().confirm` and `useMessage().prompt` pattern. Do not submit when the trimmed reason is empty because the backend request VO requires `reason`. - [ ] **Step 3: Wire parent API call** Call `CounterSettleApi.reverseCounterSettlePayment({ paymentRecordIds: [row.paymentRecordId], reason })`, then refresh unsettled rows, payment summary, and settled list. - [ ] **Step 4: Align settled red flush reason typing** Make `CounterRedFlushReqVO.reason` required in TypeScript and prevent the settled-detail red flush action from submitting blank reasons. - [ ] **Step 5: Frontend verification** Run: ```bash NODE_OPTIONS=--max-old-space-size=8192 pnpm ts:check ``` Expected for the current frontend baseline: full `vue-tsc` may still fail on existing auto-import/global type errors in unrelated files. Filter the output for the touched counter checkout files and ensure there are no new errors in `CounterUnsettledPanel.vue`, `CounterSettledDetailDialog.vue`, `index.vue`, or `counterSettle.ts`. ## Task 9: Update Formal Docs and Evidence **Files:** - Modify: `../water-docs/docs/design/02_Detailed_Design/12_REV_Detailed.md` - Modify: `../water-docs/docs/design/03_Technical_Design/01_Database_Design.md` - Modify: `../water-docs/docs/design/03_Technical_Design/03_Interface_Design.md` - Create: `../water-docs/docs/evidence/bugfix/counter-unsettled-red-flush-2026-06-30.md` - [ ] **Step 1: Update detailed design** In `../water-docs/docs/design/02_Detailed_Design/12_REV_Detailed.md`, update the `#### 柜台结账` section to include: ```markdown - 柜台红冲分为未结账红冲与已结账红冲:未结账红冲直接针对仍处于 `UNSETTLED` 且未关联结清单的柜台收费主单生成正式反向流水,原收费主单改为 `REVERSED` 后不再进入后续结账汇总;已结账红冲继续基于结清明细回写结清主单与明细的红冲金额、原因和时间。 - 未结账红冲不生成普通结账单,红冲痕迹通过独立投影记录承接,并与已结账红冲记录在查询和导出口径合并展示。 ``` - [ ] **Step 2: Update database design** In `../water-docs/docs/design/03_Technical_Design/01_Database_Design.md`, add a concise table row or paragraph near the REV/payment table section: ```markdown | `biz_counter_unsettled_red_flush_record` | 柜台未结账红冲投影 | `payment_record_id`、`reverse_payment_record_id`、`charge_id`、`source_cust_code`、`cashier_id`、`reversed_amount`、`reversed_time`、`reverse_reason`、`proc_person`、`proc_type` | 承接未结账正式红冲的查询与导出痕迹,不作为结账单据,不参与柜台结账汇总 | ``` - [ ] **Step 3: Update interface design** In `../water-docs/docs/design/03_Technical_Design/03_Interface_Design.md`, add the counter red flush behavior under the payment/counter settlement API area: ```markdown - 柜台结清红冲接口同时承接已结账红冲和未结账正式红冲。已结账记录必须已关联结清明细;未结账记录必须满足 `settleStatus=UNSETTLED` 且 `settleId=null`。 - 未结账红冲成功后,原支付主单更新为 `REVERSED`,生成 `CHARGE_REVERSE` 反向流水,并写入未结账红冲投影;该记录不再出现在待结清列表,也不参与后续柜台结账确认。 - 同一请求不得混合已结账与未结账记录;混合请求应拒绝并提示分批红冲。 ``` - [ ] **Step 4: Create evidence file** Create `../water-docs/docs/evidence/bugfix/counter-unsettled-red-flush-2026-06-30.md`: ```markdown # 柜台未结账正式红冲验证记录 ## 背景 柜台结账历史口径要求未结账和已结账记录均可红冲。当前后端原实现仅允许已结账记录红冲,本次补齐未结账正式红冲。 ## 实现范围 - 新增 `biz_counter_unsettled_red_flush_record` 未结账红冲投影。 - `POST /admin-api/business/charge/counter-settle/red-flush` 支持 `UNSETTLED + settleId=null` 的柜台收费主单。 - 未结账红冲生成 `CHARGE_REVERSE` 反向流水,原支付主单更新为 `REVERSED`,并从待结清列表和后续结账汇总中排除。 - 红冲记录查询/导出合并已结账红冲和未结账红冲。 ## 验证命令 | 命令 | 结果 | 说明 | | --- | --- | --- | | `mvn -pl sw-business/sw-business-server -Dtest=CounterSettleApplicationServiceImplTest test` | 通过 | 柜台结账与红冲单测 | | `mvn -pl sw-business/sw-business-server -Dtest=PaymentRecordServiceImplTest test` | 通过 | 支付反向流水回归 | | `mvn -pl sw-business/sw-business-server -DskipTests compile` | 通过 | 业务服务编译 | ## 结论 柜台未结账正式红冲已完成后端最小闭环验证,原收费主单红冲后不再进入待结清列表和后续结账汇总,红冲记录查询与导出可追溯。 ``` If any command fails during execution, update the table row from `通过` to `未通过` and add a short failure note before committing the evidence file. - [ ] **Step 5: Commit docs** ```bash git -C ../water-docs add docs/design/02_Detailed_Design/12_REV_Detailed.md \ docs/design/03_Technical_Design/01_Database_Design.md \ docs/design/03_Technical_Design/03_Interface_Design.md \ docs/evidence/bugfix/counter-unsettled-red-flush-2026-06-30.md git -C ../water-docs commit -m "docs: record counter unsettled red flush implementation" ``` ## Task 10: Final Regression and Status Check **Files:** - No planned source edits. - [ ] **Step 1: Check backend worktree** Run: ```bash git status --short ``` Expected: no unstaged implementation files except intentional uncommitted work. - [ ] **Step 2: Check docs worktree** Run: ```bash git -C ../water-docs status --short ``` Expected: no unstaged documentation files except intentional uncommitted work. - [ ] **Step 3: Run final focused verification** Run: ```bash mvn -pl sw-business/sw-business-server -Dtest=CounterSettleApplicationServiceImplTest,PaymentRecordServiceImplTest test ``` Expected: PASS. - [ ] **Step 4: Summarize implementation** Prepare the final handoff with: ```text Implemented: - Unsettled counter red flush via formal reverse payment record. - Original unsettled payment records marked REVERSED and excluded from later settlement. - New projection table included in red flush record query/export. - Formal docs and evidence updated. Verification: - mvn -pl sw-business/sw-business-server -Dtest=CounterSettleApplicationServiceImplTest test: PASS - mvn -pl sw-business/sw-business-server -Dtest=PaymentRecordServiceImplTest test: PASS - mvn -pl sw-business/sw-business-server -DskipTests compile: PASS ```