fujian_water_biz_doc/docs/superpowers/plans/2026-06-08-revenue-bugfix-clear-scope.md

1689 lines
56 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Revenue Bugfix Clear Scope 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:** Fix the first batch of clearly evidenced revenue bugs so retry, settlement, red-flush records, prepay deduction, and pending-approval UI states behave consistently.
**Architecture:** Keep the current backend application services and frontend pages. Add narrow backend query/validation behavior where data is missing, and adjust frontend API wiring and status messages where the UI currently misrepresents backend state. Each task is independently testable and should be committed separately.
**Tech Stack:** Java 17, Spring Boot, MyBatis Plus, JUnit 5, Mockito, Vue 3, TypeScript, Element Plus, node:test, Playwright.
---
## Source Spec
Approved design: `docs/superpowers/specs/2026-06-08-revenue-bugfix-clear-scope-design.md`
Implementation touches two sibling repositories:
- Backend: `../water-backend`
- Frontend: `../water-frontend`
Start execution from isolated worktrees created from `develop`:
```bash
cd /Volumes/Dpan/github/water-workspace
git -C water-backend worktree add ../worktrees/backend-revenue-bugfix-clear-scope develop
git -C water-frontend worktree add ../worktrees/frontend-revenue-bugfix-clear-scope develop
```
If either worktree already exists, reuse it after checking `git status --short`.
## File Structure
Backend files:
- Modify `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/dal/redis/pricetemplate/PriceTemplateAdjustmentLockRedisDAO.java`
- Replace lock refresh implementation with safe Redis/Redisson expiry extension.
- Modify `sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/pricetemplate/PriceTemplateServiceImplTest.java`
- Add tests for failed update releasing lock and re-entry refresh behavior.
- Modify `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/dal/mysql/paymentrecord/PaymentRecordMapper.java`
- Include `DEPOSIT_TOPUP` in counter unsettled records.
- Modify `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/countersettle/CounterSettleApplicationServiceImpl.java`
- Support deposit-topup settlement rows without `chargeId`.
- Use requested `cashierId` when provided.
- Add red-flush record page query.
- Modify `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/countersettle/CounterSettleApplicationService.java`
- Add red-flush record page/export methods.
- Modify `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/controller/admin/charge/ChargeController.java`
- Add `/counter-settle/red-flush-record-page` endpoint and export endpoint.
- Create `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/controller/admin/charge/vo/CounterRedFlushRecordPageReqVO.java`
- Query params for柜台红冲记录.
- Create `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/controller/admin/charge/vo/CounterRedFlushRecordRespVO.java`
- Response row for柜台红冲记录.
- Modify `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/controller/admin/charge/vo/CounterUnsettledPageRespVO.java`
- Ensure null `chargeId/billMonth` is allowed in exported/list rows.
- Modify `sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/charge/ChargeServiceImpl.java`
- Validate `prepayDeductAmount` against account deposit.
- Modify `sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/countersettle/CounterSettleApplicationServiceImplTest.java`
- Add settlement, cashier, and red-flush-record tests.
- Add or modify `sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/charge/ChargeServiceImplCounterPrepayTest.java`
- Test prepay deduction validation.
Frontend files:
- Modify `src/views/settings/price/priceTemplate/index.vue`
- Retry `startAdjustment()` after failed submit.
- Modify `src/views/accountProcess/unsoldAdjustment/components/SplitAdjustmentForm.vue`
- Show pending-approval message from response.
- Modify `src/views/accountProcess/unsoldAdjustment/components/BadDebtAdjustmentForm.vue`
- Show pending-approval message from response and reduce immediate-effect wording.
- Modify `src/views/accountProcess/unsoldAdjustment/components/PriceAdjustmentForm.vue`
- Show pending-approval message from response.
- Modify `src/views/accountProcess/unsoldAdjustment/components/PenaltyRemissionForm.vue`
- Show pending-approval message from response.
- Modify `src/views/operatingCharges/counterCheckout/components/CounterUnsettledPanel.vue`
- Display deposit-topup rows with missing bill fields gracefully.
- Modify `src/api/business/charge/counterSettle.ts`
- Add red-flush record API types and methods.
- Modify `src/api/operatingCharges/redReversalRecord/index.ts`
- Point red-flush record page to counter-settle red-flush endpoints.
- Modify `src/views/operatingCharges/redReversalRecord/index.vue`
- Change query fields and columns to柜台红冲语义.
- Modify `src/views/operatingCharges/counterCharging/index.vue`
- Add prepay deduction zero/overflow guard before submit.
- Add frontend contract tests under existing test style:
- `tests/revenue-bugs/revenueBugfixClearScope.contract.test.mjs`
Documentation:
- After implementation and verification, append evidence to a new file:
- `../water-docs/docs/evidence/revenue-bugfix-clear-scope-2026-06-08.md`
---
### Task 1: Backend Price Adjustment Lock Recovery
**Files:**
- Modify: `../water-backend/sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/dal/redis/pricetemplate/PriceTemplateAdjustmentLockRedisDAO.java`
- Modify: `../water-backend/sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/pricetemplate/PriceTemplateServiceImplTest.java`
- [ ] **Step 1: Write failing test for re-entering price adjustment after failed update**
Add this test to `PriceTemplateServiceImplTest.java`:
```java
@Test
void startPriceAdjustment_shouldRefreshCurrentUsersLockWhenReEnteringAfterFailure() {
try (MockedStatic<TenantContextHolder> tenantContext = org.mockito.Mockito.mockStatic(TenantContextHolder.class);
MockedStatic<SecurityFrameworkUtils> securityContext = org.mockito.Mockito.mockStatic(SecurityFrameworkUtils.class)) {
tenantContext.when(TenantContextHolder::getTenantId).thenReturn(1L);
securityContext.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(1001L);
when(priceTemplateAdjustmentLockRedisDAO.getLockUserInfo(1L)).thenReturn(1001L);
when(priceTemplateAdjustmentLockRedisDAO.refreshLock(
1L,
1001L,
PriceTemplateServiceImpl.PRICE_TEMPLATE_ADJUSTMENT_TIMEOUT_MILLIS
)).thenReturn(true);
priceTemplateService.startPriceAdjustment();
verify(priceTemplateAdjustmentLockRedisDAO).refreshLock(
1L,
1001L,
PriceTemplateServiceImpl.PRICE_TEMPLATE_ADJUSTMENT_TIMEOUT_MILLIS
);
}
}
```
Add imports:
```java
import cn.com.emsoft.sw.framework.security.core.util.SecurityFrameworkUtils;
import cn.com.emsoft.sw.framework.tenant.core.context.TenantContextHolder;
import org.mockito.MockedStatic;
import static org.mockito.Mockito.verify;
```
- [ ] **Step 2: Run test to verify current baseline**
Run:
```bash
cd /Volumes/Dpan/github/water-workspace/worktrees/backend-revenue-bugfix-clear-scope
mvn -pl sw-business/sw-business-server -Dtest=PriceTemplateServiceImplTest test
```
Expected: PASS. This locks current expected frontend recovery entry point.
- [ ] **Step 3: Add DAO unit test for refreshLock not calling forceUnlock**
Create `sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/dal/redis/pricetemplate/PriceTemplateAdjustmentLockRedisDAOTest.java`:
```java
package cn.com.emsoft.sw.business.dal.redis.pricetemplate;
import org.junit.jupiter.api.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class PriceTemplateAdjustmentLockRedisDAOTest {
@Test
void refreshLock_shouldNotReleaseLockBeforeRefreshingTtl() throws Exception {
String source = Files.readString(Path.of(
"src/main/java/cn/com/emsoft/sw/business/dal/redis/pricetemplate/PriceTemplateAdjustmentLockRedisDAO.java"));
int refreshStart = source.indexOf("public boolean refreshLock");
int nextMethod = source.indexOf("/**", refreshStart + 1);
String refreshMethod = source.substring(refreshStart, nextMethod);
assertFalse(refreshMethod.contains("forceUnlock()"));
assertTrue(refreshMethod.contains("remainTimeToLive()"));
assertTrue(refreshMethod.contains("expire("));
}
}
```
- [ ] **Step 4: Run DAO test to verify it fails**
Run:
```bash
cd /Volumes/Dpan/github/water-workspace/worktrees/backend-revenue-bugfix-clear-scope/sw-business/sw-business-server
mvn -Dtest=PriceTemplateAdjustmentLockRedisDAOTest test
```
Expected: FAIL because `refreshLock()` still contains `forceUnlock()`.
- [ ] **Step 5: Implement safe lock refresh**
Replace `refreshLock()` in `PriceTemplateAdjustmentLockRedisDAO.java` with:
```java
public boolean refreshLock(Long tenantId, Long userId, long timeoutMillis) {
String lockKey = formatKey(tenantId);
RLock lock = redissonClient.getLock(lockKey);
if (!lock.isLocked()) {
return false;
}
Long currentLockUserId = getLockUserInfo(tenantId);
if (!userId.equals(currentLockUserId)) {
log.warn("[调价锁] 刷新锁失败当前用户ID: {}, 锁持有者ID: {}, 租户ID: {}",
userId, currentLockUserId, tenantId);
throw exception(PRICE_TEMPLATE_ADJUSTMENT_LOCK_NOT_OWNED_BY_USER, currentLockUserId);
}
long currentTtl = lock.remainTimeToLive();
if (currentTtl <= 0) {
return false;
}
boolean refreshed = lock.expire(timeoutMillis, TimeUnit.MILLISECONDS);
if (refreshed) {
setLockUserInfo(tenantId, userId, timeoutMillis);
log.info("[调价锁] 成功刷新锁过期时间租户ID: {}, 用户ID: {}, 新超时: {}ms",
tenantId, userId, timeoutMillis);
}
return refreshed;
}
```
Replace `setLockUserInfo(Long tenantId, Long userId)` with:
```java
private void setLockUserInfo(Long tenantId, Long userId) {
setLockUserInfo(tenantId, userId, TimeUnit.MINUTES.toMillis(30));
}
private void setLockUserInfo(Long tenantId, Long userId, long timeoutMillis) {
String userKey = formatUserKey(tenantId);
stringRedisTemplate.opsForValue().set(userKey, userId.toString(), timeoutMillis, TimeUnit.MILLISECONDS);
}
```
- [ ] **Step 6: Run price lock tests**
Run:
```bash
cd /Volumes/Dpan/github/water-workspace/worktrees/backend-revenue-bugfix-clear-scope
mvn -pl sw-business/sw-business-server -Dtest=PriceTemplateServiceImplTest,PriceTemplateAdjustmentLockRedisDAOTest test
```
Expected: PASS.
- [ ] **Step 7: Commit backend price-lock fix**
```bash
git add sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/dal/redis/pricetemplate/PriceTemplateAdjustmentLockRedisDAO.java \
sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/pricetemplate/PriceTemplateServiceImplTest.java \
sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/dal/redis/pricetemplate/PriceTemplateAdjustmentLockRedisDAOTest.java
git commit -m "fix: make price adjustment lock refresh safe"
```
---
### Task 2: Backend Counter Settlement Includes Deposit Topups and Respects Cashier Query
**Files:**
- Modify: `../water-backend/sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/dal/mysql/paymentrecord/PaymentRecordMapper.java`
- Modify: `../water-backend/sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/countersettle/CounterSettleApplicationServiceImpl.java`
- Modify: `../water-backend/sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/countersettle/CounterSettleApplicationServiceImplTest.java`
- [ ] **Step 1: Rename outdated cashier test**
In `CounterSettleApplicationServiceImplTest.java`, rename:
```java
void confirm_shouldPreferAuthenticatedUserOverRequestCashierId()
```
to:
```java
void confirm_shouldUseRequestCashierIdWhenProvided()
```
Change the mocked payment lookup and assertion to use request cashier:
```java
when(paymentRecordService.getCounterUnsettledRecords("forged-cashier")).thenReturn(List.of(record));
```
and:
```java
verify(paymentRecordService).getCounterUnsettledRecords("forged-cashier");
assertEquals("forged-cashier", settleCaptor.getValue().getCashierId());
assertEquals("2002", detailCaptor.getValue().getProcPerson());
```
Keep the static login user mock at `2002L`; this proves `cashierId` and operator are separated.
- [ ] **Step 2: Add failing test for deposit topup settlement**
Add this test to `CounterSettleApplicationServiceImplTest.java`:
```java
@Test
void confirm_shouldSettleDepositTopupWithoutChargeWriteBack() {
LocalDateTime settleTime = LocalDateTime.of(2026, 6, 8, 10, 0);
PaymentRecordDO topup = PaymentRecordDO.builder()
.id(301L)
.sourceRefId(9001L)
.paymentNo("PAY-TOPUP-301")
.custId(9001L)
.custCode("CUST-TOPUP")
.custName("预存客户")
.paymentAmount(new BigDecimal("100.00"))
.build();
when(paymentRecordService.getCounterUnsettledRecords("1001")).thenReturn(List.of(topup));
doAnswer(invocation -> {
SettleRecordDO record = invocation.getArgument(0);
record.setId(8001L);
return 1;
}).when(settleRecordMapper).insert(any(SettleRecordDO.class));
CounterSettleConfirmReqVO reqVO = new CounterSettleConfirmReqVO();
reqVO.setCashierId("1001");
reqVO.setSettleTime(settleTime);
reqVO.setRemark("deposit topup settle");
var result = service.confirm(reqVO);
assertEquals(new BigDecimal("100.00"), result.getTotalAmount());
assertEquals(1, result.getTotalCount());
verify(chargeMapper, never()).markCounterSettled(any(), any(), any());
ArgumentCaptor<SettleRecordDetailDO> detailCaptor = ArgumentCaptor.forClass(SettleRecordDetailDO.class);
verify(settleRecordDetailMapper).insert(detailCaptor.capture());
assertNull(detailCaptor.getValue().getChargeId());
assertNull(detailCaptor.getValue().getBillMonth());
assertEquals(Long.valueOf(9001L), detailCaptor.getValue().getSourceCustId());
}
```
Add import if missing:
```java
import static org.mockito.Mockito.never;
```
- [ ] **Step 3: Run counter settle tests to verify failures**
Run:
```bash
cd /Volumes/Dpan/github/water-workspace/worktrees/backend-revenue-bugfix-clear-scope
mvn -pl sw-business/sw-business-server -Dtest=CounterSettleApplicationServiceImplTest test
```
Expected: FAIL on cashier behavior and deposit topup charge writeback.
- [ ] **Step 4: Include deposit topup records in unsettled query**
In `PaymentRecordMapper.selectCounterUnsettledRecords`, replace:
```java
.eq(PaymentRecordDO::getBizScene, "CHARGE_PAYMENT")
```
with:
```java
.in(PaymentRecordDO::getBizScene, List.of("CHARGE_PAYMENT", "DEPOSIT_TOPUP"))
```
Add import if missing:
```java
import java.util.List;
```
- [ ] **Step 5: Add helper to identify charge-payment rows**
In `CounterSettleApplicationServiceImpl.java`, add:
```java
private boolean isChargePaymentRecord(PaymentRecordDO record) {
return record != null && Objects.equals("CHARGE_PAYMENT", record.getBizScene());
}
```
- [ ] **Step 6: Filter charge IDs to real charge-payment rows**
In `confirm()`, replace the `chargeIds` calculation with:
```java
List<Long> chargeIds = records.stream()
.filter(this::isChargePaymentRecord)
.map(PaymentRecordDO::getSourceRefId)
.filter(Objects::nonNull)
.distinct()
.toList();
```
Keep `totalAmount` calculated from all records.
- [ ] **Step 7: Make detail insertion tolerate missing charge**
In the `for (PaymentRecordDO record : records)` loop, keep:
```java
ChargeDO charge = chargeMap.get(record.getSourceRefId());
```
and ensure these assignments remain nullable:
```java
.chargeId(isChargePaymentRecord(record) ? record.getSourceRefId() : null)
.billMonth(charge != null ? charge.getBillMonth() : null)
```
- [ ] **Step 8: Skip charge writeback when no charge IDs exist**
Replace:
```java
validateChargeSettledCount(chargeIds,
chargeMapper.markCounterSettled(chargeIds, settleRecord.getId(), reqVO.getSettleTime()));
```
with:
```java
if (!chargeIds.isEmpty()) {
validateChargeSettledCount(chargeIds,
chargeMapper.markCounterSettled(chargeIds, settleRecord.getId(), reqVO.getSettleTime()));
}
```
- [ ] **Step 9: Change cashier resolution rule**
Replace `resolveCashierId` with:
```java
private String resolveCashierId(String requestCashierId) {
if (requestCashierId != null && !requestCashierId.isBlank()) {
return requestCashierId;
}
return resolveAuthenticatedUserId();
}
```
Leave `resolveOperatorId` unchanged so the current login user remains the operator.
- [ ] **Step 10: Run counter settle tests**
Run:
```bash
cd /Volumes/Dpan/github/water-workspace/worktrees/backend-revenue-bugfix-clear-scope
mvn -pl sw-business/sw-business-server -Dtest=CounterSettleApplicationServiceImplTest test
```
Expected: PASS.
- [ ] **Step 11: Commit counter settlement fix**
```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/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: include counter topups in settlement"
```
---
### Task 3: Backend Counter Red-Flush Record Query
**Files:**
- Create: `../water-backend/sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/controller/admin/charge/vo/CounterRedFlushRecordPageReqVO.java`
- Create: `../water-backend/sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/controller/admin/charge/vo/CounterRedFlushRecordRespVO.java`
- Modify: `../water-backend/sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/countersettle/CounterSettleApplicationService.java`
- Modify: `../water-backend/sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/countersettle/CounterSettleApplicationServiceImpl.java`
- Modify: `../water-backend/sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/controller/admin/charge/ChargeController.java`
- Modify: `../water-backend/sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/countersettle/CounterSettleApplicationServiceImplTest.java`
- [ ] **Step 1: Create request VO**
Create `CounterRedFlushRecordPageReqVO.java`:
```java
package cn.com.emsoft.sw.business.controller.admin.charge.vo;
import cn.com.emsoft.sw.framework.common.pojo.PageParam;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDateTime;
import static cn.com.emsoft.sw.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND;
@Schema(description = "管理后台 - 柜台红冲记录分页 Request VO")
@Data
@EqualsAndHashCode(callSuper = true)
public class CounterRedFlushRecordPageReqVO extends PageParam {
@Schema(description = "收费员", example = "1001")
private String cashierId;
@Schema(description = "客户编号", example = "CUST001")
private String custCode;
@Schema(description = "开始红冲时间")
@DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
private LocalDateTime beginReversedTime;
@Schema(description = "结束红冲时间")
@DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
private LocalDateTime endReversedTime;
}
```
- [ ] **Step 2: Create response VO**
Create `CounterRedFlushRecordRespVO.java`:
```java
package cn.com.emsoft.sw.business.controller.admin.charge.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Schema(description = "管理后台 - 柜台红冲记录 Response VO")
@Data
@Builder
public class CounterRedFlushRecordRespVO {
@Schema(description = "结清记录ID")
private Long settleId;
@Schema(description = "结清单号")
private String settleNo;
@Schema(description = "支付主单ID")
private Long paymentRecordId;
@Schema(description = "支付单号")
private String paymentNo;
@Schema(description = "客户编号")
private String custCode;
@Schema(description = "客户名称")
private String custName;
@Schema(description = "收费员")
private String cashierId;
@Schema(description = "红冲金额")
private BigDecimal reversedAmount;
@Schema(description = "红冲时间")
private LocalDateTime reversedTime;
@Schema(description = "红冲原因")
private String reverseReason;
}
```
- [ ] **Step 3: Add service interface methods**
In `CounterSettleApplicationService.java`, add imports:
```java
import cn.com.emsoft.sw.business.controller.admin.charge.vo.CounterRedFlushRecordPageReqVO;
import cn.com.emsoft.sw.business.controller.admin.charge.vo.CounterRedFlushRecordRespVO;
```
Add methods:
```java
PageResult<CounterRedFlushRecordRespVO> getRedFlushRecordPage(CounterRedFlushRecordPageReqVO reqVO);
List<CounterRedFlushRecordRespVO> getRedFlushRecordExportRows(CounterRedFlushRecordPageReqVO reqVO);
```
- [ ] **Step 4: Add failing test for red-flush record query**
Add this test to `CounterSettleApplicationServiceImplTest.java`:
```java
@Test
void getRedFlushRecordPage_shouldFilterByReverseTimeAndReturnReversedDetails() {
LocalDateTime reverseTime = LocalDateTime.of(2026, 6, 8, 11, 30);
CounterRedFlushRecordPageReqVO reqVO = new CounterRedFlushRecordPageReqVO();
reqVO.setPageNo(1);
reqVO.setPageSize(10);
reqVO.setCashierId("1001");
reqVO.setBeginReversedTime(reverseTime.minusHours(1));
reqVO.setEndReversedTime(reverseTime.plusHours(1));
SettleRecordDO settleRecord = SettleRecordDO.builder()
.id(7001L)
.settleNo("SETTLE-7001")
.cashierId("1001")
.status("PARTIAL_REVERSED")
.build();
SettleRecordDetailDO detail = SettleRecordDetailDO.builder()
.settleId(7001L)
.paymentRecordId(101L)
.paymentNo("PAY-101")
.sourceCustCode("CUST-1")
.sourceCustName("客户一")
.reversedAmount(new BigDecimal("20.50"))
.reverseTime(reverseTime)
.procRemark("柜台红冲")
.build();
when(settleRecordMapper.selectList(any(com.baomidou.mybatisplus.core.conditions.Wrapper.class)))
.thenReturn(List.of(settleRecord));
when(settleRecordDetailMapper.selectBySettleId(7001L)).thenReturn(List.of(detail));
PageResult<CounterRedFlushRecordRespVO> result = service.getRedFlushRecordPage(reqVO);
assertEquals(1L, result.getTotal());
assertEquals("SETTLE-7001", result.getList().get(0).getSettleNo());
assertEquals(new BigDecimal("20.50"), result.getList().get(0).getReversedAmount());
assertEquals(reverseTime, result.getList().get(0).getReversedTime());
}
```
Add imports:
```java
import cn.com.emsoft.sw.business.controller.admin.charge.vo.CounterRedFlushRecordPageReqVO;
import cn.com.emsoft.sw.business.controller.admin.charge.vo.CounterRedFlushRecordRespVO;
```
- [ ] **Step 5: Run test to verify it fails**
Run:
```bash
cd /Volumes/Dpan/github/water-workspace/worktrees/backend-revenue-bugfix-clear-scope
mvn -pl sw-business/sw-business-server -Dtest=CounterSettleApplicationServiceImplTest#getRedFlushRecordPage_shouldFilterByReverseTimeAndReturnReversedDetails test
```
Expected: FAIL because the method does not exist.
- [ ] **Step 6: Implement red-flush record query**
Add this method to `CounterSettleApplicationServiceImpl.java`:
```java
@Override
public PageResult<CounterRedFlushRecordRespVO> getRedFlushRecordPage(CounterRedFlushRecordPageReqVO reqVO) {
List<CounterRedFlushRecordRespVO> rows = queryRedFlushRecordRows(reqVO);
return paginate(rows, reqVO.getPageNo(), reqVO.getPageSize());
}
@Override
public List<CounterRedFlushRecordRespVO> getRedFlushRecordExportRows(CounterRedFlushRecordPageReqVO reqVO) {
CounterRedFlushRecordPageReqVO exportReq = BeanUtils.toBean(reqVO, CounterRedFlushRecordPageReqVO.class);
exportReq.setPageNo(1);
exportReq.setPageSize(PageParam.PAGE_SIZE_NONE);
return queryRedFlushRecordRows(exportReq);
}
private List<CounterRedFlushRecordRespVO> queryRedFlushRecordRows(CounterRedFlushRecordPageReqVO reqVO) {
CounterRedFlushRecordPageReqVO effectiveReq = BeanUtils.toBean(reqVO, CounterRedFlushRecordPageReqVO.class);
effectiveReq.setCashierId(resolveCashierId(effectiveReq.getCashierId()));
List<SettleRecordDO> settleRecords = settleRecordMapper.selectList(new LambdaQueryWrapperX<SettleRecordDO>()
.eqIfPresent(SettleRecordDO::getCashierId, effectiveReq.getCashierId())
.in(SettleRecordDO::getStatus, List.of(
SettleRecordStatusEnum.PARTIAL_REVERSED.getCode(),
SettleRecordStatusEnum.FULL_REVERSED.getCode()))
.geIfPresent(SettleRecordDO::getReversedTime, effectiveReq.getBeginReversedTime())
.leIfPresent(SettleRecordDO::getReversedTime, effectiveReq.getEndReversedTime())
.orderByDesc(SettleRecordDO::getReversedTime, SettleRecordDO::getId));
List<CounterRedFlushRecordRespVO> rows = new ArrayList<>();
for (SettleRecordDO settleRecord : settleRecords) {
for (SettleRecordDetailDO detail : settleRecordDetailMapper.selectBySettleId(settleRecord.getId())) {
if (!Objects.equals(SettleRecordDetailStatusEnum.REVERSED.getCode(), detail.getDetailStatus())) {
continue;
}
if (StrUtil.isNotBlank(effectiveReq.getCustCode())
&& !Objects.equals(effectiveReq.getCustCode(), detail.getSourceCustCode())) {
continue;
}
rows.add(CounterRedFlushRecordRespVO.builder()
.settleId(settleRecord.getId())
.settleNo(settleRecord.getSettleNo())
.paymentRecordId(detail.getPaymentRecordId())
.paymentNo(detail.getPaymentNo())
.custCode(detail.getSourceCustCode())
.custName(detail.getSourceCustName())
.cashierId(settleRecord.getCashierId())
.reversedAmount(resolvePersistedReversedAmount(detail))
.reversedTime(detail.getReverseTime())
.reverseReason(detail.getProcRemark())
.build());
}
}
return rows;
}
```
Add imports:
```java
import cn.com.emsoft.sw.business.controller.admin.charge.vo.CounterRedFlushRecordPageReqVO;
import cn.com.emsoft.sw.business.controller.admin.charge.vo.CounterRedFlushRecordRespVO;
```
- [ ] **Step 7: Add controller endpoints**
In `ChargeController.java`, add:
```java
@GetMapping("/counter-settle/red-flush-record-page")
@Operation(summary = "柜台红冲记录分页")
@PreAuthorize("@ss.hasPermission('business:charge:query')")
public CommonResult<PageResult<CounterRedFlushRecordRespVO>> getCounterRedFlushRecordPage(
@Valid CounterRedFlushRecordPageReqVO reqVO) {
return success(counterSettleApplicationService.getRedFlushRecordPage(reqVO));
}
@GetMapping("/counter-settle/red-flush-record-export")
@Operation(summary = "导出柜台红冲记录 Excel")
@PreAuthorize("@ss.hasPermission('business:charge:export')")
@ApiAccessLog(operateType = EXPORT)
public void exportCounterRedFlushRecordExcel(@Valid CounterRedFlushRecordPageReqVO reqVO,
HttpServletResponse response) throws IOException {
List<CounterRedFlushRecordRespVO> list = counterSettleApplicationService.getRedFlushRecordExportRows(reqVO);
String fileName = ExcelFileNameUtils.generateFileName("柜台红冲记录");
ExcelUtils.write(response, fileName, "数据", CounterRedFlushRecordRespVO.class, list);
}
```
- [ ] **Step 8: Run red-flush query tests**
Run:
```bash
cd /Volumes/Dpan/github/water-workspace/worktrees/backend-revenue-bugfix-clear-scope
mvn -pl sw-business/sw-business-server -Dtest=CounterSettleApplicationServiceImplTest test
```
Expected: PASS.
- [ ] **Step 9: Commit red-flush record API**
```bash
git add sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/controller/admin/charge/vo/CounterRedFlushRecordPageReqVO.java \
sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/controller/admin/charge/vo/CounterRedFlushRecordRespVO.java \
sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/countersettle/CounterSettleApplicationService.java \
sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/countersettle/CounterSettleApplicationServiceImpl.java \
sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/controller/admin/charge/ChargeController.java \
sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/countersettle/CounterSettleApplicationServiceImplTest.java
git commit -m "feat: add counter red flush record query"
```
---
### Task 4: Backend Prepay Deduction Validation
**Files:**
- Modify: `../water-backend/sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/charge/ChargeServiceImpl.java`
- Create: `../water-backend/sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/charge/ChargeServiceImplCounterPrepayTest.java`
- [ ] **Step 1: Create failing tests**
Create `ChargeServiceImplCounterPrepayTest.java`:
```java
package cn.com.emsoft.sw.business.service.charge;
import cn.com.emsoft.sw.business.controller.admin.charge.vo.ChargeSaveReqVO;
import cn.com.emsoft.sw.business.dal.dataobject.account.AccountDO;
import cn.com.emsoft.sw.business.dal.dataobject.charge.ChargeDO;
import cn.com.emsoft.sw.business.dal.mysql.charge.ChargeMapper;
import cn.com.emsoft.sw.business.enums.charge.ChargeMethodEnum;
import cn.com.emsoft.sw.business.enums.charge.PayStateEnum;
import cn.com.emsoft.sw.business.service.account.AccountService;
import cn.com.emsoft.sw.business.service.paymentapp.PaymentCommandApplicationService;
import cn.com.emsoft.sw.framework.common.exception.ServiceException;
import cn.com.emsoft.sw.framework.test.core.ut.BaseMockitoUnitTest;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class ChargeServiceImplCounterPrepayTest extends BaseMockitoUnitTest {
@InjectMocks
private ChargeServiceImpl chargeService;
@Mock
private ChargeMapper chargeMapper;
@Mock
private AccountService accountService;
@Mock
private PaymentCommandApplicationService paymentCommandApplicationService;
@Test
void updateCharge_shouldRejectPrepayDeductAmountGreaterThanAccountDeposit() {
ChargeDO before = new ChargeDO();
before.setId(100L);
before.setCustId(9001L);
before.setPayState(PayStateEnum.UNPAID.getValue());
when(chargeMapper.selectById(100L)).thenReturn(before, paidCharge());
AccountDO account = new AccountDO();
account.setDeposit(new BigDecimal("5.00"));
when(accountService.getAccountByCustId(9001L)).thenReturn(account);
ChargeSaveReqVO reqVO = paidReq(new BigDecimal("8.00"));
assertThrows(ServiceException.class, () -> chargeService.updateCharge(reqVO));
verify(accountService, never()).decreaseDeposit(9001L, new BigDecimal("8.00"));
verify(paymentCommandApplicationService, never()).captureCounterChargePayment(
org.mockito.Mockito.any(), org.mockito.Mockito.any(), org.mockito.Mockito.any(),
org.mockito.Mockito.any(), org.mockito.Mockito.any(), org.mockito.Mockito.any());
}
private ChargeDO paidCharge() {
ChargeDO charge = new ChargeDO();
charge.setId(100L);
charge.setCustId(9001L);
charge.setExtendedAmount(new BigDecimal("20.00"));
charge.setPayState(PayStateEnum.PAID.getValue());
return charge;
}
private ChargeSaveReqVO paidReq(BigDecimal prepayDeductAmount) {
ChargeSaveReqVO reqVO = new ChargeSaveReqVO();
reqVO.setId(100L);
reqVO.setCustId(9001L);
reqVO.setPayState(PayStateEnum.PAID.getValue());
reqVO.setChargeMethod(ChargeMethodEnum.COUNTER.getValue());
reqVO.setChargeWay(1);
reqVO.setCashierId("1001");
reqVO.setPayDate(LocalDateTime.of(2026, 6, 8, 10, 0));
reqVO.setPrepayDeductAmount(prepayDeductAmount);
return reqVO;
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
cd /Volumes/Dpan/github/water-workspace/worktrees/backend-revenue-bugfix-clear-scope
mvn -pl sw-business/sw-business-server -Dtest=ChargeServiceImplCounterPrepayTest test
```
Expected: FAIL because `normalizeCounterPrepayDeductAmount` does not check account balance.
- [ ] **Step 3: Implement account balance validation**
In `ChargeServiceImpl.normalizeCounterPrepayDeductAmount`, after the total amount check, add:
```java
if (prepayDeductAmount.compareTo(BigDecimal.ZERO) > 0) {
AccountDO account = accountService.getAccountByCustId(latest.getCustId());
BigDecimal deposit = account == null || account.getDeposit() == null ? BigDecimal.ZERO : account.getDeposit();
if (prepayDeductAmount.compareTo(deposit) > 0) {
throw invalidParamException("预存抵扣金额不能大于账户预存余额");
}
}
```
Ensure `AccountDO` is imported:
```java
import cn.com.emsoft.sw.business.dal.dataobject.account.AccountDO;
```
- [ ] **Step 4: Run prepay tests**
Run:
```bash
cd /Volumes/Dpan/github/water-workspace/worktrees/backend-revenue-bugfix-clear-scope
mvn -pl sw-business/sw-business-server -Dtest=ChargeServiceImplCounterPrepayTest test
```
Expected: PASS.
- [ ] **Step 5: Commit prepay validation**
```bash
git add sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/charge/ChargeServiceImpl.java \
sw-business/sw-business-server/src/test/java/cn/com/emsoft/sw/business/service/charge/ChargeServiceImplCounterPrepayTest.java
git commit -m "fix: validate counter prepay deduction balance"
```
---
### Task 5: Frontend Shared Accounting Adjustment Status Messages
**Files:**
- Create: `../water-frontend/src/views/accountProcess/unsoldAdjustment/utils/statusMessage.ts`
- Modify: `../water-frontend/src/views/accountProcess/unsoldAdjustment/components/SplitAdjustmentForm.vue`
- Modify: `../water-frontend/src/views/accountProcess/unsoldAdjustment/components/BadDebtAdjustmentForm.vue`
- Modify: `../water-frontend/src/views/accountProcess/unsoldAdjustment/components/PriceAdjustmentForm.vue`
- Modify: `../water-frontend/src/views/accountProcess/unsoldAdjustment/components/PenaltyRemissionForm.vue`
- Add: `../water-frontend/tests/revenue-bugs/revenueBugfixClearScope.contract.test.mjs`
- [ ] **Step 1: Create failing frontend contract test**
Create `tests/revenue-bugs/revenueBugfixClearScope.contract.test.mjs`:
```javascript
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import path from 'node:path'
const root = process.cwd()
test('unsold adjustment forms use runtime status message helper', async () => {
const files = [
'src/views/accountProcess/unsoldAdjustment/components/SplitAdjustmentForm.vue',
'src/views/accountProcess/unsoldAdjustment/components/BadDebtAdjustmentForm.vue',
'src/views/accountProcess/unsoldAdjustment/components/PriceAdjustmentForm.vue',
'src/views/accountProcess/unsoldAdjustment/components/PenaltyRemissionForm.vue'
]
for (const file of files) {
const source = await readFile(path.join(root, file), 'utf8')
assert.match(source, /resolveAccountingAdjustSubmitMessage/)
assert.doesNotMatch(source, /message\.success\('提交成功'\)/)
}
})
test('status message helper maps pending approval to user-facing pending text', async () => {
const source = await readFile(
path.join(root, 'src/views/accountProcess/unsoldAdjustment/utils/statusMessage.ts'),
'utf8'
)
assert.match(source, /申请已提交,待审批/)
assert.match(source, /处理完成/)
assert.match(source, /待回写/)
})
```
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
cd /Volumes/Dpan/github/water-workspace/worktrees/frontend-revenue-bugfix-clear-scope
node --test tests/revenue-bugs/revenueBugfixClearScope.contract.test.mjs
```
Expected: FAIL because helper does not exist and forms still use `提交成功`.
- [ ] **Step 3: Create status message helper**
Create `src/views/accountProcess/unsoldAdjustment/utils/statusMessage.ts`:
```typescript
export interface AccountingAdjustRuntimeStatus {
approvalRequired?: boolean
resultStatus?: string
approvalStatus?: string
writeBackStatus?: string
message?: string
msg?: string
}
export const resolveAccountingAdjustSubmitMessage = (resp?: AccountingAdjustRuntimeStatus) => {
if (!resp) {
return '申请已提交'
}
if (resp.approvalRequired || resp.resultStatus === 'PENDING_APPROVAL' || resp.approvalStatus === 'PENDING_APPROVAL') {
return '申请已提交,待审批'
}
if (resp.writeBackStatus === 'PENDING') {
return '申请已提交,待回写'
}
if (resp.resultStatus === 'SUCCESS' && resp.writeBackStatus === 'UPDATED') {
return '处理完成'
}
return resp.message || resp.msg || '申请已提交'
}
```
- [ ] **Step 4: Update SplitAdjustmentForm**
In `SplitAdjustmentForm.vue`, add import:
```typescript
import { resolveAccountingAdjustSubmitMessage } from '../utils/statusMessage'
```
Replace:
```typescript
await UnsoldAdjustmentApi.submitUnsoldSplit({
```
with:
```typescript
const resp = await UnsoldAdjustmentApi.submitUnsoldSplit({
```
Replace:
```typescript
message.success('提交成功')
```
with:
```typescript
message.success(resolveAccountingAdjustSubmitMessage(resp))
```
- [ ] **Step 5: Update BadDebtAdjustmentForm**
In `BadDebtAdjustmentForm.vue`, add import:
```typescript
import { resolveAccountingAdjustSubmitMessage } from '../utils/statusMessage'
```
Replace the red warning text:
```vue
<span class="text-red-500 text-14px">设置完成后账单将无法按照欠费查询和统计</span>
```
with:
```vue
<span class="text-red-500 text-14px">申请提交后进入审批审批通过并回写后账单状态生效</span>
```
Replace:
```typescript
await UnsoldAdjustmentApi.submitBadDebtBatch({
```
with:
```typescript
const resp = await UnsoldAdjustmentApi.submitBadDebtBatch({
```
Replace:
```typescript
message.success('提交成功')
```
with:
```typescript
message.success(resolveAccountingAdjustSubmitMessage(resp))
```
- [ ] **Step 6: Update PriceAdjustmentForm and PenaltyRemissionForm**
Apply the same import and response capture pattern in:
- `src/views/accountProcess/unsoldAdjustment/components/PriceAdjustmentForm.vue`
- `src/views/accountProcess/unsoldAdjustment/components/PenaltyRemissionForm.vue`
For each file:
```typescript
import { resolveAccountingAdjustSubmitMessage } from '../utils/statusMessage'
```
Use:
```typescript
const resp = await UnsoldAdjustmentApi.submitPriceDiffBatch({ items })
message.success(resolveAccountingAdjustSubmitMessage(resp))
```
and:
```typescript
const resp = await UnsoldAdjustmentApi.submitLateFeeReduceBatch({
lateFeeType: adjustmentType.value === 'amount' ? '1' : '2',
applicant: formData.value.applicant || undefined,
contactMobile: formData.value.contactMobile || undefined,
applyReason: formData.value.applyReason || undefined,
remark: formData.value.remark || undefined,
attachmentRefs: formData.value.files?.length ? formData.value.files : undefined,
items
})
message.success(resolveAccountingAdjustSubmitMessage(resp))
```
- [ ] **Step 7: Run frontend contract test**
Run:
```bash
cd /Volumes/Dpan/github/water-workspace/worktrees/frontend-revenue-bugfix-clear-scope
node --test tests/revenue-bugs/revenueBugfixClearScope.contract.test.mjs
```
Expected: PASS.
- [ ] **Step 8: Commit frontend pending-status messages**
```bash
git add src/views/accountProcess/unsoldAdjustment/utils/statusMessage.ts \
src/views/accountProcess/unsoldAdjustment/components/SplitAdjustmentForm.vue \
src/views/accountProcess/unsoldAdjustment/components/BadDebtAdjustmentForm.vue \
src/views/accountProcess/unsoldAdjustment/components/PriceAdjustmentForm.vue \
src/views/accountProcess/unsoldAdjustment/components/PenaltyRemissionForm.vue \
tests/revenue-bugs/revenueBugfixClearScope.contract.test.mjs
git commit -m "fix: show pending status for accounting adjustments"
```
---
### Task 6: Frontend Price Adjustment Retry Flow
**Files:**
- Modify: `../water-frontend/src/views/settings/price/priceTemplate/index.vue`
- Modify: `../water-frontend/tests/revenue-bugs/revenueBugfixClearScope.contract.test.mjs`
- [ ] **Step 1: Add failing contract assertion**
Append this test to `tests/revenue-bugs/revenueBugfixClearScope.contract.test.mjs`:
```javascript
test('price template submit failure attempts to reacquire adjustment lock', async () => {
const source = await readFile(
path.join(root, 'src/views/settings/price/priceTemplate/index.vue'),
'utf8'
)
assert.match(source, /const reacquireAdjustmentLockAfterFailure = async/)
assert.match(source, /await reacquireAdjustmentLockAfterFailure\(\)/)
assert.doesNotMatch(source, /catch \(err: any\) \{\s*message\.error\(err\?\.message \|\| '调价失败,请稍后重试'\)\s*isAdjusting\.value = false\s*\}/)
})
```
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
cd /Volumes/Dpan/github/water-workspace/worktrees/frontend-revenue-bugfix-clear-scope
node --test tests/revenue-bugs/revenueBugfixClearScope.contract.test.mjs
```
Expected: FAIL.
- [ ] **Step 3: Add reacquire helper**
In `src/views/settings/price/priceTemplate/index.vue`, near `startAdjustment`, add:
```typescript
const reacquireAdjustmentLockAfterFailure = async () => {
try {
await PriceTemplateApi.startAdjustment()
isAdjusting.value = true
message.warning('调价失败,本次调价锁已重新获取,请修正数据后再次提交')
} catch (lockErr: any) {
isAdjusting.value = false
message.error(lockErr?.message || '调价失败,重新获取调价锁失败,请稍后重试')
}
}
```
- [ ] **Step 4: Use helper in submit failure path**
In `submitForm`, replace the catch block:
```typescript
} catch (err: any) {
message.error(err?.message || '调价失败,请稍后重试')
isAdjusting.value = false
} finally {
```
with:
```typescript
} catch (err: any) {
message.error(err?.message || '调价失败,请稍后重试')
await reacquireAdjustmentLockAfterFailure()
} finally {
```
- [ ] **Step 5: Run frontend contract test**
Run:
```bash
cd /Volumes/Dpan/github/water-workspace/worktrees/frontend-revenue-bugfix-clear-scope
node --test tests/revenue-bugs/revenueBugfixClearScope.contract.test.mjs
```
Expected: PASS.
- [ ] **Step 6: Commit price adjustment retry flow**
```bash
git add src/views/settings/price/priceTemplate/index.vue tests/revenue-bugs/revenueBugfixClearScope.contract.test.mjs
git commit -m "fix: reacquire price adjustment lock after failure"
```
---
### Task 7: Frontend Counter Red-Flush Record Page Wiring
**Files:**
- Modify: `../water-frontend/src/api/business/charge/counterSettle.ts`
- Modify: `../water-frontend/src/api/operatingCharges/redReversalRecord/index.ts`
- Modify: `../water-frontend/src/views/operatingCharges/redReversalRecord/index.vue`
- Modify: `../water-frontend/tests/revenue-bugs/revenueBugfixClearScope.contract.test.mjs`
- [ ] **Step 1: Add failing contract assertion**
Append this test:
```javascript
test('red reversal record page uses counter red flush record endpoint and reverse time filters', async () => {
const apiSource = await readFile(
path.join(root, 'src/api/operatingCharges/redReversalRecord/index.ts'),
'utf8'
)
const viewSource = await readFile(
path.join(root, 'src/views/operatingCharges/redReversalRecord/index.vue'),
'utf8'
)
assert.match(apiSource, /counter-settle\/red-flush-record-page/)
assert.match(apiSource, /counter-settle\/red-flush-record-export/)
assert.doesNotMatch(apiSource, /accounting-adjust\/log-page/)
assert.match(viewSource, /红冲时间/)
assert.match(viewSource, /beginReversedTime/)
assert.match(viewSource, /endReversedTime/)
})
```
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
cd /Volumes/Dpan/github/water-workspace/worktrees/frontend-revenue-bugfix-clear-scope
node --test tests/revenue-bugs/revenueBugfixClearScope.contract.test.mjs
```
Expected: FAIL.
- [ ] **Step 3: Add counter red-flush API methods**
In `src/api/business/charge/counterSettle.ts`, add:
```typescript
export interface CounterRedFlushRecordPageReqVO extends PageParam {
cashierId?: string
custCode?: string
beginReversedTime?: string
endReversedTime?: string
}
export interface CounterRedFlushRecordRespVO {
settleId: number
settleNo?: string
paymentRecordId?: number
paymentNo?: string
custCode?: string
custName?: string
cashierId?: string
reversedAmount?: number
reversedTime?: string
reverseReason?: string
}
```
Add methods inside `CounterSettleApi`:
```typescript
getCounterRedFlushRecordPage: (params: CounterRedFlushRecordPageReqVO) =>
request.get<PageResult<CounterRedFlushRecordRespVO[]>>({
url: '/business/charge/counter-settle/red-flush-record-page',
params
}),
exportCounterRedFlushRecordPage: (params: CounterRedFlushRecordPageReqVO) =>
request.download({ url: '/business/charge/counter-settle/red-flush-record-export', params }),
```
- [ ] **Step 4: Rewire redReversalRecord API wrapper**
Replace `src/api/operatingCharges/redReversalRecord/index.ts` with:
```typescript
import { CounterSettleApi, type CounterRedFlushRecordPageReqVO } from '@/api/business/charge/counterSettle'
export const getRedReversalPage = async (params: CounterRedFlushRecordPageReqVO) => {
return await CounterSettleApi.getCounterRedFlushRecordPage(params)
}
export const exportRedReversalRecord = (params: CounterRedFlushRecordPageReqVO) => {
return CounterSettleApi.exportCounterRedFlushRecordPage(params)
}
```
- [ ] **Step 5: Update red-flush record page query fields**
In `src/views/operatingCharges/redReversalRecord/index.vue`, replace query params:
```typescript
const queryParams = reactive<any>({
pageNo: 1,
pageSize: 10,
cashierId: undefined,
custCode: undefined,
reversedTime: []
})
```
Add helper:
```typescript
const buildQueryParams = () => ({
pageNo: queryParams.pageNo,
pageSize: queryParams.pageSize,
cashierId: queryParams.cashierId,
custCode: queryParams.custCode,
beginReversedTime: queryParams.reversedTime?.[0],
endReversedTime: queryParams.reversedTime?.[1]
})
```
In `getList`, use:
```typescript
const data = await getRedReversalPage(buildQueryParams())
```
In export, use:
```typescript
const data = await exportRedReversalRecord(buildQueryParams())
```
- [ ] **Step 6: Update red-flush record labels and columns**
Change “处理人” input to:
```vue
<el-form-item label="收费员" prop="cashierId" style="width: 90%">
<el-input v-model="queryParams.cashierId" placeholder="请输入收费员" clearable />
</el-form-item>
```
Change date picker label to:
```vue
<el-form-item label="红冲时间" prop="reversedTime" style="width: 90%">
```
Replace `defaultColumns` with:
```typescript
const defaultColumns = [
{ field: 'settleNo', label: '结账单号', width: 180 },
{ field: 'paymentNo', label: '收费单号', width: 180 },
{ field: 'custCode', label: '客户编号', width: 160 },
{ field: 'custName', label: '客户名称', width: 180 },
{ field: 'cashierId', label: '收费员', width: 140 },
{ field: 'reversedAmount', label: '红冲金额', width: 140 },
{ field: 'reversedTime', label: '红冲时间', width: 180 },
{ field: 'reverseReason', label: '红冲原因', width: 220 }
]
```
- [ ] **Step 7: Run frontend contract test**
Run:
```bash
cd /Volumes/Dpan/github/water-workspace/worktrees/frontend-revenue-bugfix-clear-scope
node --test tests/revenue-bugs/revenueBugfixClearScope.contract.test.mjs
```
Expected: PASS.
- [ ] **Step 8: Commit red-flush record frontend wiring**
```bash
git add src/api/business/charge/counterSettle.ts \
src/api/operatingCharges/redReversalRecord/index.ts \
src/views/operatingCharges/redReversalRecord/index.vue \
tests/revenue-bugs/revenueBugfixClearScope.contract.test.mjs
git commit -m "fix: wire red flush record page to counter records"
```
---
### Task 8: Frontend Counter Settlement and Prepay Guards
**Files:**
- Modify: `../water-frontend/src/views/operatingCharges/counterCheckout/components/CounterUnsettledPanel.vue`
- Modify: `../water-frontend/src/views/operatingCharges/counterCharging/index.vue`
- Modify: `../water-frontend/tests/revenue-bugs/revenueBugfixClearScope.contract.test.mjs`
- [ ] **Step 1: Add failing contract assertion**
Append:
```javascript
test('counter checkout and charging guard deposit-topup and zero prepay states', async () => {
const checkoutSource = await readFile(
path.join(root, 'src/views/operatingCharges/counterCheckout/components/CounterUnsettledPanel.vue'),
'utf8'
)
const chargingSource = await readFile(
path.join(root, 'src/views/operatingCharges/counterCharging/index.vue'),
'utf8'
)
assert.match(checkoutSource, /formatNullableBillField/)
assert.match(chargingSource, /confirmZeroPrepayDeduction/)
assert.match(chargingSource, /预存抵扣金额为0/)
})
```
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
cd /Volumes/Dpan/github/water-workspace/worktrees/frontend-revenue-bugfix-clear-scope
node --test tests/revenue-bugs/revenueBugfixClearScope.contract.test.mjs
```
Expected: FAIL.
- [ ] **Step 3: Add nullable bill formatter**
In `CounterUnsettledPanel.vue`, add:
```typescript
const formatNullableBillField = (value?: string | number | null) => {
if (value === null || value === undefined || value === '') {
return '--'
}
return String(value)
}
```
Use it for bill fields:
```vue
<template #default="scope">{{ formatNullableBillField(scope.row.chargeNo) }}</template>
```
and:
```vue
<template #default="scope">{{ formatNullableBillField(scope.row.billMonth) }}</template>
```
- [ ] **Step 4: Add zero prepay confirmation helper**
In `counterCharging/index.vue`, add:
```typescript
const confirmZeroPrepayDeduction = async () => {
if (!preDeduct.value || preDeductAmount.value > 0) {
return true
}
try {
await message.confirm('预存抵扣金额为0将按现金/其他方式全额收费,是否继续?')
return true
} catch {
return false
}
}
```
- [ ] **Step 5: Invoke zero prepay confirmation before submit**
At the start of the submit handler, after existing selected-row guard and before `submitting.value = true`, add:
```typescript
if (!(await confirmZeroPrepayDeduction())) {
return
}
```
- [ ] **Step 6: Run frontend contract test**
Run:
```bash
cd /Volumes/Dpan/github/water-workspace/worktrees/frontend-revenue-bugfix-clear-scope
node --test tests/revenue-bugs/revenueBugfixClearScope.contract.test.mjs
```
Expected: PASS.
- [ ] **Step 7: Commit counter frontend guards**
```bash
git add src/views/operatingCharges/counterCheckout/components/CounterUnsettledPanel.vue \
src/views/operatingCharges/counterCharging/index.vue \
tests/revenue-bugs/revenueBugfixClearScope.contract.test.mjs
git commit -m "fix: guard counter topup and prepay display states"
```
---
### Task 9: Cross-Repository Verification
**Files:**
- Backend verification only.
- Frontend verification only.
- Create evidence: `../water-docs/docs/evidence/revenue-bugfix-clear-scope-2026-06-08.md`
- [ ] **Step 1: Run backend targeted tests**
Run:
```bash
cd /Volumes/Dpan/github/water-workspace/worktrees/backend-revenue-bugfix-clear-scope
mvn -pl sw-business/sw-business-server -Dtest=PriceTemplateServiceImplTest,PriceTemplateAdjustmentLockRedisDAOTest,CounterSettleApplicationServiceImplTest,ChargeServiceImplCounterPrepayTest test
```
Expected: BUILD SUCCESS.
- [ ] **Step 2: Run backend module compile**
Run:
```bash
cd /Volumes/Dpan/github/water-workspace/worktrees/backend-revenue-bugfix-clear-scope
mvn -pl sw-business/sw-business-server -DskipTests compile
```
Expected: BUILD SUCCESS.
- [ ] **Step 3: Run frontend contract test**
Run:
```bash
cd /Volumes/Dpan/github/water-workspace/worktrees/frontend-revenue-bugfix-clear-scope
node --test tests/revenue-bugs/revenueBugfixClearScope.contract.test.mjs
```
Expected: all tests PASS.
- [ ] **Step 4: Run frontend type check**
Run:
```bash
cd /Volumes/Dpan/github/water-workspace/worktrees/frontend-revenue-bugfix-clear-scope
pnpm ts:check
```
Expected: no TypeScript errors.
- [ ] **Step 5: Run focused frontend build if type check passes**
Run:
```bash
cd /Volumes/Dpan/github/water-workspace/worktrees/frontend-revenue-bugfix-clear-scope
pnpm build:dev
```
Expected: build completes successfully.
- [ ] **Step 6: Write evidence document**
Create `../water-docs/docs/evidence/revenue-bugfix-clear-scope-2026-06-08.md`:
```markdown
# 营收明确缺陷第一批修复验证记录
日期2026-06-08
## 修复范围
- #78 水价调整失败后重试闭环
- #39 柜台预存缴费进入结账
- #50 柜台结账收费员筛选
- #53 预存抵扣校验
- #58/#59 柜台红冲记录查询
- #69/#76 待审批账务调整文案
## 后端基线
- 仓库water-backend
- 分支或 worktreebackend-revenue-bugfix-clear-scope
- 提交:记录 `cd /Volumes/Dpan/github/water-workspace/worktrees/backend-revenue-bugfix-clear-scope && git rev-parse HEAD` 的输出
## 前端基线
- 仓库water-frontend
- 分支或 worktreefrontend-revenue-bugfix-clear-scope
- 提交:记录 `cd /Volumes/Dpan/github/water-workspace/worktrees/frontend-revenue-bugfix-clear-scope && git rev-parse HEAD` 的输出
## 验证命令
```bash
mvn -pl sw-business/sw-business-server -Dtest=PriceTemplateServiceImplTest,PriceTemplateAdjustmentLockRedisDAOTest,CounterSettleApplicationServiceImplTest,ChargeServiceImplCounterPrepayTest test
mvn -pl sw-business/sw-business-server -DskipTests compile
node --test tests/revenue-bugs/revenueBugfixClearScope.contract.test.mjs
pnpm ts:check
pnpm build:dev
```
## 验证结果
- 后端 targeted tests通过
- 后端 compile通过
- 前端 contract test通过
- 前端 type check通过
- 前端 build通过
## 备注
本轮未处理 #70#9#70 需抓包确认提交字段;#9 需产品确认抄表状态规则。
```
- [ ] **Step 7: Commit evidence**
```bash
cd /Volumes/Dpan/github/water-workspace/water-docs
git add docs/evidence/revenue-bugfix-clear-scope-2026-06-08.md
git commit -m "docs: add revenue bugfix verification evidence"
```
---
## Self-Review
Spec coverage:
- `#78` covered by Task 1 and Task 6.
- `#39` covered by Task 2 and Task 8.
- `#50` covered by Task 2.
- `#53` covered by Task 4 and Task 8.
- `#58/#59` covered by Task 3 and Task 7.
- `#69/#76` covered by Task 5.
- Verification and docs evidence covered by Task 9.
Placeholder scan:
- No `TBD`, `TODO`, or open-ended “handle later” steps are present.
- Each task has exact files, code snippets, commands, and expected results.
Type consistency:
- Backend VO names: `CounterRedFlushRecordPageReqVO`, `CounterRedFlushRecordRespVO`.
- Frontend API type names match backend VO names.
- Frontend helper name `resolveAccountingAdjustSubmitMessage` is used consistently.
- Red-flush date params use `beginReversedTime` and `endReversedTime` consistently.