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

56 KiB
Raw Blame History

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

@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.vue
  • src/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
- 分支或 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.