fujian_water_biz_doc/docs/superpowers/plans/2026-06-30-counter-unsettled-red-flush.md

60 KiB

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:

import cn.com.emsoft.sw.business.dal.dataobject.counterredflush.CounterUnsettledRedFlushRecordDO;
import cn.com.emsoft.sw.business.dal.mysql.counterredflush.CounterUnsettledRedFlushRecordMapper;
    @Mock
    private CounterUnsettledRedFlushRecordMapper counterUnsettledRedFlushRecordMapper;
  • Step 2: Run compile to verify the missing types fail

Run:

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:

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:

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<CounterUnsettledRedFlushRecordDO> {

    default List<CounterUnsettledRedFlushRecordDO> selectByPaymentRecordIds(List<Long> paymentRecordIds) {
        if (paymentRecordIds == null || paymentRecordIds.isEmpty()) {
            return Collections.emptyList();
        }
        return selectList(new LambdaQueryWrapperX<CounterUnsettledRedFlushRecordDO>()
                .in(CounterUnsettledRedFlushRecordDO::getPaymentRecordId, paymentRecordIds)
                .orderByAsc(CounterUnsettledRedFlushRecordDO::getId));
    }

    default List<CounterUnsettledRedFlushRecordDO> selectRecordRows(String cashierId,
                                                                   String custCode,
                                                                   LocalDateTime beginReversedTime,
                                                                   LocalDateTime endReversedTime) {
        return selectList(new LambdaQueryWrapperX<CounterUnsettledRedFlushRecordDO>()
                .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:

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
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:

    @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:

    default int markCounterUnsettledReversed(Long paymentRecordId) {
        if (paymentRecordId == null) {
            return 0;
        }
        return update(null, new LambdaUpdateWrapper<PaymentRecordDO>()
                .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:

    default int clearCounterPaid(Long chargeId) {
        if (chargeId == null) {
            return 0;
        }
        return update(null, new LambdaUpdateWrapper<ChargeDO>()
                .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:

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
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:

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:

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
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:

    @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<CounterUnsettledRedFlushRecordDO> 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:

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:

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:

    private static final String COUNTER_UNSETTLED_RED_FLUSH_PROC_TYPE = "COUNTER_UNSETTLED_RED_FLUSH";
    @Resource
    private CounterUnsettledRedFlushRecordMapper counterUnsettledRedFlushRecordMapper;
  • Step 4: Replace redFlush with status dispatch

Replace the current redFlush method body with:

    @Override
    @Transactional(rollbackFor = Exception.class)
    public CounterRedFlushRespVO redFlush(CounterRedFlushReqVO reqVO) {
        String operatorId = resolveOperatorId(reqVO.getOperatorId());
        List<Long> paymentRecordIds = normalizePaymentRecordIds(reqVO.getPaymentRecordIds());
        if (paymentRecordIds.isEmpty()) {
            throw invalidParamException("支付主单ID列表不能为空");
        }

        Map<Long, PaymentRecordDO> paymentRecordMap = buildPaymentRecordMap(paymentRecordIds);
        if (paymentRecordMap.size() != paymentRecordIds.size()) {
            throw invalidParamException("支付主单不存在,无法执行柜台红冲");
        }

        Map<Long, SettleRecordDetailDO> 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:

    private List<Long> normalizePaymentRecordIds(List<Long> paymentRecordIds) {
        return paymentRecordIds == null ? List.of() : paymentRecordIds.stream()
                .filter(Objects::nonNull)
                .distinct()
                .toList();
    }

    private CounterRedFlushRespVO redFlushSettledRecords(CounterRedFlushReqVO reqVO,
                                                         String operatorId,
                                                         List<Long> paymentRecordIds,
                                                         Map<Long, PaymentRecordDO> paymentRecordMap,
                                                         Map<Long, SettleRecordDetailDO> detailMap) {
        validateAuthenticatedCashierRedFlushAccess(paymentRecordIds, paymentRecordMap, detailMap);

        List<Long> 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<Long, ChargeDO> chargeMap = buildChargeMapByIds(chargeIds);

        LocalDateTime reverseTime = LocalDateTime.now();
        BigDecimal refundCashAmount = BigDecimal.ZERO;
        BigDecimal refundPrepayAmount = BigDecimal.ZERO;
        Set<Long> 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<Long> paymentRecordIds,
                                                           Map<Long, PaymentRecordDO> paymentRecordMap) {
        validateAuthenticatedCashierUnsettledRedFlushAccess(paymentRecordMap.values());
        List<CounterUnsettledRedFlushRecordDO> existingRecords =
                counterUnsettledRedFlushRecordMapper.selectByPaymentRecordIds(paymentRecordIds);
        if (existingRecords != null && !existingRecords.isEmpty()) {
            throw invalidParamException("支付主单已红冲,禁止重复红冲: {}", existingRecords.get(0).getPaymentRecordId());
        }

        Map<Long, ChargeDO> 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:

    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<PaymentRecordDO> 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:

mvn -pl sw-business/sw-business-server -Dtest=CounterSettleApplicationServiceImplTest#redFlush_shouldReverseUnsettledCounterChargeAndWriteProjection test

Expected: PASS.

  • Step 8: Run settled red flush regression test

Run:

mvn -pl sw-business/sw-business-server -Dtest=CounterSettleApplicationServiceImplTest#redFlush_shouldPersistDetailAndHeaderReversedAmountAndSplitRefunds test

Expected: PASS.

  • Step 9: Commit
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:

    @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:

    @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:

    @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:

    @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<SecurityFrameworkUtils> 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:

mvn -pl sw-business/sw-business-server -Dtest='CounterSettleApplicationServiceImplTest#redFlush_shouldRejectMixedSettledAndUnsettledRecords+redFlush_shouldRejectDuplicateUnsettledRedFlush+redFlush_shouldRejectUnsettledRecordWhenStateChangedDuringUpdate+redFlush_shouldRejectAuthenticatedCashierForAnotherCashiersUnsettledRecord' test

Expected: PASS.

  • Step 6: Commit
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:

    @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:

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:

    private List<CounterRedFlushRecordRespVO> queryRedFlushRecordRows(CounterRedFlushRecordPageReqVO reqVO) {
        String cashierId = requireCashierId(resolveCashierId(reqVO.getCashierId()));
        List<CounterRedFlushRecordRespVO> rows = new ArrayList<>();
        rows.addAll(querySettledRedFlushRecordRows(reqVO, cashierId));
        rows.addAll(queryUnsettledRedFlushRecordRows(reqVO, cashierId));
        return rows.stream()
                .sorted(redFlushRecordRowComparator())
                .toList();
    }

Add:

    private List<CounterRedFlushRecordRespVO> querySettledRedFlushRecordRows(CounterRedFlushRecordPageReqVO reqVO, String cashierId) {
        LambdaQueryWrapperX<SettleRecordDO> queryWrapper = new LambdaQueryWrapperX<SettleRecordDO>()
                .eq(SettleRecordDO::getCashierId, cashierId);
        queryWrapper.in(SettleRecordDO::getStatus,
                SettleRecordStatusEnum.PARTIAL_REVERSED.getCode(),
                SettleRecordStatusEnum.FULL_REVERSED.getCode());
        queryWrapper.orderByDesc(SettleRecordDO::getReversedTime, SettleRecordDO::getId);
        List<SettleRecordDO> settleRecords = settleRecordMapper.selectList(queryWrapper);
        if (settleRecords == null || settleRecords.isEmpty()) {
            return Collections.emptyList();
        }
        List<Long> settleIds = settleRecords.stream()
                .map(SettleRecordDO::getId)
                .filter(Objects::nonNull)
                .distinct()
                .toList();
        List<SettleRecordDetailDO> details = settleRecordDetailMapper.selectBySettleIds(settleIds);
        Map<Long, List<SettleRecordDetailDO>> detailMap = (details == null ? Collections.<SettleRecordDetailDO>emptyList() : details).stream()
                .collect(Collectors.groupingBy(SettleRecordDetailDO::getSettleId, LinkedHashMap::new, Collectors.toList()));
        Map<Long, PaymentRecordDO> paymentRecordMap = buildPaymentRecordMap((details == null ? Collections.<SettleRecordDetailDO>emptyList() : details).stream()
                .map(SettleRecordDetailDO::getPaymentRecordId)
                .filter(Objects::nonNull)
                .distinct()
                .toList());
        Map<Long, ChargeDO> chargeMap = buildChargeMapByIds((details == null ? Collections.<SettleRecordDetailDO>emptyList() : details).stream()
                .map(SettleRecordDetailDO::getChargeId)
                .filter(Objects::nonNull)
                .distinct()
                .toList());
        Map<String, String> custNameMap = buildCustNameMapByCodes((details == null ? Collections.<SettleRecordDetailDO>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<CounterRedFlushRecordRespVO> queryUnsettledRedFlushRecordRows(CounterRedFlushRecordPageReqVO reqVO, String cashierId) {
        List<CounterUnsettledRedFlushRecordDO> records = counterUnsettledRedFlushRecordMapper.selectRecordRows(
                cashierId,
                reqVO.getCustCode(),
                reqVO.getBeginReversedTime(),
                reqVO.getEndReversedTime());
        return (records == null ? Collections.<CounterUnsettledRedFlushRecordDO>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:

mvn -pl sw-business/sw-business-server -Dtest='CounterSettleApplicationServiceImplTest#redFlushRecordPage_shouldIncludeUnsettledProjectionRows+getRedFlushRecordPage_shouldFilterByReverseTimeAndReturnReversedDetails' test

Expected: PASS.

  • Step 5: Commit
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:

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:

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:

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:

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:

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:

- 柜台红冲分为未结账红冲与已结账红冲:未结账红冲直接针对仍处于 `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:

| `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:

- 柜台结清红冲接口同时承接已结账红冲和未结账正式红冲。已结账记录必须已关联结清明细;未结账记录必须满足 `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:

# 柜台未结账正式红冲验证记录

## 背景

柜台结账历史口径要求未结账和已结账记录均可红冲。当前后端原实现仅允许已结账记录红冲,本次补齐未结账正式红冲。

## 实现范围

- 新增 `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
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:

git status --short

Expected: no unstaged implementation files except intentional uncommitted work.

  • Step 2: Check docs worktree

Run:

git -C ../water-docs status --short

Expected: no unstaged documentation files except intentional uncommitted work.

  • Step 3: Run final focused verification

Run:

mvn -pl sw-business/sw-business-server -Dtest=CounterSettleApplicationServiceImplTest,PaymentRecordServiceImplTest test

Expected: PASS.

  • Step 4: Summarize implementation

Prepare the final handoff with:

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