56 KiB
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:
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_TOPUPin counter unsettled records.
- Include
- 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
cashierIdwhen provided. - Add red-flush record page query.
- Support deposit-topup settlement rows without
- 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-pageendpoint and export endpoint.
- Add
- 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/billMonthis allowed in exported/list rows.
- Ensure null
- Modify
sw-business/sw-business-server/src/main/java/cn/com/emsoft/sw/business/service/charge/ChargeServiceImpl.java- Validate
prepayDeductAmountagainst account deposit.
- Validate
- 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.
- Retry
- 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:
@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:
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:
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:
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:
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:
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:
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:
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
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:
void confirm_shouldPreferAuthenticatedUserOverRequestCashierId()
to:
void confirm_shouldUseRequestCashierIdWhenProvided()
Change the mocked payment lookup and assertion to use request cashier:
when(paymentRecordService.getCounterUnsettledRecords("forged-cashier")).thenReturn(List.of(record));
and:
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:
@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:
import static org.mockito.Mockito.never;
- Step 3: Run counter settle tests to verify failures
Run:
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:
.eq(PaymentRecordDO::getBizScene, "CHARGE_PAYMENT")
with:
.in(PaymentRecordDO::getBizScene, List.of("CHARGE_PAYMENT", "DEPOSIT_TOPUP"))
Add import if missing:
import java.util.List;
- Step 5: Add helper to identify charge-payment rows
In CounterSettleApplicationServiceImpl.java, add:
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:
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:
ChargeDO charge = chargeMap.get(record.getSourceRefId());
and ensure these assignments remain nullable:
.chargeId(isChargePaymentRecord(record) ? record.getSourceRefId() : null)
.billMonth(charge != null ? charge.getBillMonth() : null)
- Step 8: Skip charge writeback when no charge IDs exist
Replace:
validateChargeSettledCount(chargeIds,
chargeMapper.markCounterSettled(chargeIds, settleRecord.getId(), reqVO.getSettleTime()));
with:
if (!chargeIds.isEmpty()) {
validateChargeSettledCount(chargeIds,
chargeMapper.markCounterSettled(chargeIds, settleRecord.getId(), reqVO.getSettleTime()));
}
- Step 9: Change cashier resolution rule
Replace resolveCashierId with:
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:
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
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:
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:
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:
import cn.com.emsoft.sw.business.controller.admin.charge.vo.CounterRedFlushRecordPageReqVO;
import cn.com.emsoft.sw.business.controller.admin.charge.vo.CounterRedFlushRecordRespVO;
Add methods:
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:
@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:
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:
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:
@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:
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:
@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:
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
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:
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:
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:
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:
import cn.com.emsoft.sw.business.dal.dataobject.account.AccountDO;
- Step 4: Run prepay tests
Run:
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
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:
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:
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:
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:
import { resolveAccountingAdjustSubmitMessage } from '../utils/statusMessage'
Replace:
await UnsoldAdjustmentApi.submitUnsoldSplit({
with:
const resp = await UnsoldAdjustmentApi.submitUnsoldSplit({
Replace:
message.success('提交成功')
with:
message.success(resolveAccountingAdjustSubmitMessage(resp))
- Step 5: Update BadDebtAdjustmentForm
In BadDebtAdjustmentForm.vue, add import:
import { resolveAccountingAdjustSubmitMessage } from '../utils/statusMessage'
Replace the red warning text:
<span class="text-red-500 text-14px">设置完成后,账单将无法按照欠费查询和统计。</span>
with:
<span class="text-red-500 text-14px">申请提交后进入审批,审批通过并回写后账单状态生效。</span>
Replace:
await UnsoldAdjustmentApi.submitBadDebtBatch({
with:
const resp = await UnsoldAdjustmentApi.submitBadDebtBatch({
Replace:
message.success('提交成功')
with:
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.vuesrc/views/accountProcess/unsoldAdjustment/components/PenaltyRemissionForm.vue
For each file:
import { resolveAccountingAdjustSubmitMessage } from '../utils/statusMessage'
Use:
const resp = await UnsoldAdjustmentApi.submitPriceDiffBatch({ items })
message.success(resolveAccountingAdjustSubmitMessage(resp))
and:
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:
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
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:
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:
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:
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:
} catch (err: any) {
message.error(err?.message || '调价失败,请稍后重试')
isAdjusting.value = false
} finally {
with:
} catch (err: any) {
message.error(err?.message || '调价失败,请稍后重试')
await reacquireAdjustmentLockAfterFailure()
} finally {
- Step 5: Run frontend contract test
Run:
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
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:
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:
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:
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:
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:
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:
const queryParams = reactive<any>({
pageNo: 1,
pageSize: 10,
cashierId: undefined,
custCode: undefined,
reversedTime: []
})
Add helper:
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:
const data = await getRedReversalPage(buildQueryParams())
In export, use:
const data = await exportRedReversalRecord(buildQueryParams())
- Step 6: Update red-flush record labels and columns
Change “处理人” input to:
<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:
<el-form-item label="红冲时间" prop="reversedTime" style="width: 90%">
Replace defaultColumns with:
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:
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
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:
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:
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:
const formatNullableBillField = (value?: string | number | null) => {
if (value === null || value === undefined || value === '') {
return '--'
}
return String(value)
}
Use it for bill fields:
<template #default="scope">{{ formatNullableBillField(scope.row.chargeNo) }}</template>
and:
<template #default="scope">{{ formatNullableBillField(scope.row.billMonth) }}</template>
- Step 4: Add zero prepay confirmation helper
In counterCharging/index.vue, add:
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:
if (!(await confirmZeroPrepayDeduction())) {
return
}
- Step 6: Run frontend contract test
Run:
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
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:
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:
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:
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:
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:
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:
# 营收明确缺陷第一批修复验证记录
日期:2026-06-08
## 修复范围
- #78 水价调整失败后重试闭环
- #39 柜台预存缴费进入结账
- #50 柜台结账收费员筛选
- #53 预存抵扣校验
- #58/#59 柜台红冲记录查询
- #69/#76 待审批账务调整文案
## 后端基线
- 仓库:water-backend
- 分支或 worktree:backend-revenue-bugfix-clear-scope
- 提交:记录 `cd /Volumes/Dpan/github/water-workspace/worktrees/backend-revenue-bugfix-clear-scope && git rev-parse HEAD` 的输出
## 前端基线
- 仓库:water-frontend
- 分支或 worktree:frontend-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:
#78covered by Task 1 and Task 6.#39covered by Task 2 and Task 8.#50covered by Task 2.#53covered by Task 4 and Task 8.#58/#59covered by Task 3 and Task 7.#69/#76covered 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
resolveAccountingAdjustSubmitMessageis used consistently. - Red-flush date params use
beginReversedTimeandendReversedTimeconsistently.