From 7168f141931f34e92cb0726cdc5403c20e54bbea Mon Sep 17 00:00:00 2001 From: tangweijie <877588133@qq.com> Date: Tue, 30 Jun 2026 16:29:02 +0800 Subject: [PATCH] docs: plan counter unsettled red flush --- .../2026-06-30-counter-unsettled-red-flush.md | 1272 +++++++++++++++++ 1 file changed, 1272 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-30-counter-unsettled-red-flush.md diff --git a/docs/superpowers/plans/2026-06-30-counter-unsettled-red-flush.md b/docs/superpowers/plans/2026-06-30-counter-unsettled-red-flush.md new file mode 100644 index 0000000..b808080 --- /dev/null +++ b/docs/superpowers/plans/2026-06-30-counter-unsettled-red-flush.md @@ -0,0 +1,1272 @@ +# 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. + +**Tech Stack:** Java 17, Spring Boot, MyBatis Plus, Lombok, Kingbase/PostgreSQL SQL migration, JUnit 5, Mockito. + +--- + +## 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-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: 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 9: 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 +```