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