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.
- Add conditional update to mark an unsettled counter payment as
- 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.
- Add DDL for
- 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
redFlushwith 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