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