Files
ERP/oa-backend/src/main/java/com/kaidi/oa/web/FinApReminderController.java
T
QiufengandClaude Opus 4.8 5e51dc3f56 SNAPSHOT W7 已部署稳定态 — 凯迪ERP+OA一体化平台 (MET 73.3%)
恢复点(restore point)。别人改崩后可 git reset --hard 回到此提交。

== 此快照内容 ==
- 后端 oa-backend: 734 控制器 / 711 实体 (Spring Boot 3.2.5 + SQLite, 端口8091)
- 前端 modern-ui/app: Vue3+Vite, 约700页 (构建产物已在 oa-backend/src/main/resources/static)
- 数据库 oa-backend/data/oa.db: 含全部演示数据 (强制入库, 6.6MB)
- 交接文档 go.md + go-code-reference/endpoints/entities/database.md
- 多代理建设脚本 .claude/wf-*.js

== 状态 ==
- 对 凯迪科技ERP_20260507.xlsx 合规 MET ~73.3% (PARTIAL 75: 34可建+6种子/bug+35外部硬天花板)
- 安全: 5轮红队+5轮复检, default-deny分级鉴权, 连续零可利用
- W3~W7 累计补完436缺口; W8末轮(40缺口)为半成品(源码树可编译但未集成)
- 运行: cd oa-backend; java -jar build/libs/oa-backend-0.1.0.jar --server.port=8091; admin/123456

== 排除(gitignore, 可再生) ==
node_modules / oa-backend/build / .jdks / *.log / Backup-ERP-* / 弃用的OFBiz核心(只保留modern-ui)
完整文件夹备份见同目录 Backup-ERP-20260615-191517/ (含上述全部, 仅缺 node_modules)

时间戳: 20260615-191517

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 19:19:15 +08:00

326 lines
15 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package com.kaidi.oa.web;
import com.kaidi.oa.common.ApiResp;
import com.kaidi.oa.common.Money;
import com.kaidi.oa.domain.ArApItem;
import com.kaidi.oa.repository.ArApItemRepository;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 财务部·应付账款到期提醒与供应商对账单(模块3缺口补全)。
*
* 审计缺口:
* ① 到期付款提醒 / 供应商对账单自动生成未建专属端点。
* ② 发票状态(认证/未认证)字段与进项税抵扣联动逻辑未在应付模块显式体现。
*
* 本控制器提供:
* GET /due-reminders —— 到期应付款提醒(按 dueDate 临近程度分优先级)。
* GET /supplier-statement —— 供应商对账单(按供应商汇总:应付总额/已付/余额)。
* GET /invoice-cert-status —— 按发票认证状态汇总(认证/未认证/已抵扣金额)。
* GET /input-tax-deduction —— 进项税抵扣台账(已认证发票的可抵扣增值税汇总)。
*
* @Scheduled: 每日6点自动扫描到期应付款,预警记录写入 System.out(实际可接推送)。
*
* 读口:读敏感数据,需 ADMIN/APPROVER(由 AuthInterceptor FINANCE_PREFIXES 管控)。
*/
@RestController
@RequestMapping("/api/oa/fin-ap-reminder")
public class FinApReminderController {
private final ArApItemRepository arApRepo;
public FinApReminderController(ArApItemRepository arApRepo) {
this.arApRepo = arApRepo;
}
// ============================================================
// 1. 到期付款提醒
// ============================================================
/**
* 到期应付款提醒:按 dueDate 临近程度分三级:
* - 紧急:dueDate 在今日或已逾期(overdueDays >= 0
* - 预警:dueDate 在 7 天内
* - 提示:dueDate 在 30 天内
*/
@GetMapping("/due-reminders")
public ApiResp<Map<String, Object>> dueReminders(
@RequestParam(required = false, defaultValue = "30") int lookAheadDays) {
List<ArApItem> apItems = arApRepo.findByArApType(ArApItem.T_AP).stream()
.filter(i -> !ArApItem.S_SETTLED.equals(i.getStatus()))
.toList();
LocalDate today = LocalDate.now();
List<Map<String, Object>> urgent = new ArrayList<>();
List<Map<String, Object>> warn = new ArrayList<>();
List<Map<String, Object>> remind = new ArrayList<>();
BigDecimal urgentTotal = BigDecimal.ZERO;
BigDecimal warnTotal = BigDecimal.ZERO;
BigDecimal remindTotal = BigDecimal.ZERO;
for (ArApItem item : apItems) {
if (item.getDueDate() == null || item.getDueDate().isBlank()) continue;
LocalDate due;
try {
due = LocalDate.parse(item.getDueDate().substring(0, 10));
} catch (Exception e) {
continue;
}
long daysTodue = ChronoUnit.DAYS.between(today, due); // 负值=已逾期
if (daysTodue > lookAheadDays) continue;
Map<String, Object> row = new LinkedHashMap<>();
row.put("id", item.getId());
row.put("code", item.getCode());
row.put("partyName", item.getPartyName());
row.put("relatedRef", item.getRelatedRef());
row.put("unwrittenOff", Money.nz(item.getUnwrittenOff()));
row.put("dueDate", item.getDueDate());
row.put("daysTodue", daysTodue);
row.put("invoiceCertStatus", item.getRemark() != null && item.getRemark().contains("[CERT:")
? item.getRemark().replaceAll(".*\\[CERT:([^]]+)].*", "$1") : "未标注");
row.put("status", item.getStatus());
if (daysTodue <= 0) {
row.put("priority", "紧急");
urgent.add(row);
urgentTotal = urgentTotal.add(Money.nz(item.getUnwrittenOff()));
} else if (daysTodue <= 7) {
row.put("priority", "预警");
warn.add(row);
warnTotal = warnTotal.add(Money.nz(item.getUnwrittenOff()));
} else {
row.put("priority", "提示");
remind.add(row);
remindTotal = remindTotal.add(Money.nz(item.getUnwrittenOff()));
}
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("asOfDate", today.toString());
result.put("lookAheadDays", lookAheadDays);
result.put("urgentCount", urgent.size());
result.put("urgentAmount", urgentTotal.setScale(2, RoundingMode.HALF_UP));
result.put("warnCount", warn.size());
result.put("warnAmount", warnTotal.setScale(2, RoundingMode.HALF_UP));
result.put("remindCount", remind.size());
result.put("remindAmount", remindTotal.setScale(2, RoundingMode.HALF_UP));
result.put("urgentItems", urgent);
result.put("warnItems", warn);
result.put("remindItems", remind);
result.put("totalPayablePending",
urgentTotal.add(warnTotal).add(remindTotal).setScale(2, RoundingMode.HALF_UP));
return ApiResp.ok(result);
}
// ============================================================
// 2. 供应商对账单自动生成
// ============================================================
/**
* 按供应商(partyName)汇总应付单情况:
* 总应付、已付款(writtenOff 合计)、未付余额、逾期金额、发票张数(通过 relatedRef 计数)。
*/
@GetMapping("/supplier-statement")
public ApiResp<Map<String, Object>> supplierStatement(
@RequestParam(required = false) String supplierName) {
List<ArApItem> allAp = arApRepo.findByArApType(ArApItem.T_AP);
if (supplierName != null && !supplierName.isBlank()) {
allAp = allAp.stream()
.filter(i -> supplierName.equals(i.getPartyName()))
.toList();
}
// 按供应商汇总
Map<String, BigDecimal[]> bySupplier = new LinkedHashMap<>();
// [0]=总应付, [1]=已付, [2]=未付, [3]=逾期, [4]=发票张数
Map<String, Integer> invoiceCount = new LinkedHashMap<>();
LocalDate today = LocalDate.now();
for (ArApItem item : allAp) {
String name = item.getPartyName() != null ? item.getPartyName() : "未知供应商";
bySupplier.computeIfAbsent(name, k -> new BigDecimal[]{
BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO});
BigDecimal[] arr = bySupplier.get(name);
arr[0] = arr[0].add(Money.nz(item.getAmount()));
arr[1] = arr[1].add(Money.nz(item.getWrittenOff()));
arr[2] = arr[2].add(Money.nz(item.getUnwrittenOff()));
// 判断是否逾期
if (item.getDueDate() != null && !item.getDueDate().isBlank()) {
try {
LocalDate due = LocalDate.parse(item.getDueDate().substring(0, 10));
long daysOverdue = ChronoUnit.DAYS.between(due, today);
if (daysOverdue > 0 && !ArApItem.S_SETTLED.equals(item.getStatus())) {
arr[3] = arr[3].add(Money.nz(item.getUnwrittenOff()));
}
} catch (Exception ignored) { /* 跳过日期异常条目 */ }
}
invoiceCount.merge(name, 1, Integer::sum);
}
List<Map<String, Object>> rows = new ArrayList<>();
BigDecimal totalAp = BigDecimal.ZERO;
BigDecimal totalPaid = BigDecimal.ZERO;
BigDecimal totalPending = BigDecimal.ZERO;
BigDecimal totalOverdue = BigDecimal.ZERO;
for (Map.Entry<String, BigDecimal[]> en : bySupplier.entrySet()) {
BigDecimal[] arr = en.getValue();
Map<String, Object> row = new LinkedHashMap<>();
row.put("supplierName", en.getKey());
row.put("totalPayable", arr[0].setScale(2, RoundingMode.HALF_UP));
row.put("paidAmount", arr[1].setScale(2, RoundingMode.HALF_UP));
row.put("pendingAmount", arr[2].setScale(2, RoundingMode.HALF_UP));
row.put("overdueAmount", arr[3].setScale(2, RoundingMode.HALF_UP));
row.put("invoiceCount", invoiceCount.getOrDefault(en.getKey(), 0));
row.put("paymentRatio", arr[0].signum() > 0
? arr[1].divide(arr[0], 4, RoundingMode.HALF_UP)
.multiply(BigDecimal.valueOf(100))
.setScale(1, RoundingMode.HALF_UP).toString() + "%"
: "N/A");
rows.add(row);
totalAp = totalAp.add(arr[0]);
totalPaid = totalPaid.add(arr[1]);
totalPending = totalPending.add(arr[2]);
totalOverdue = totalOverdue.add(arr[3]);
}
// 按未付余额降序排列
rows.sort((a, b) -> ((BigDecimal) b.get("pendingAmount"))
.compareTo((BigDecimal) a.get("pendingAmount")));
Map<String, Object> result = new LinkedHashMap<>();
result.put("reportName", "供应商对账单");
result.put("asOfDate", today.toString());
result.put("supplierFilter", supplierName != null ? supplierName : "全部");
result.put("supplierCount", rows.size());
result.put("totalPayable", totalAp.setScale(2, RoundingMode.HALF_UP));
result.put("totalPaid", totalPaid.setScale(2, RoundingMode.HALF_UP));
result.put("totalPending", totalPending.setScale(2, RoundingMode.HALF_UP));
result.put("totalOverdue", totalOverdue.setScale(2, RoundingMode.HALF_UP));
result.put("rows", rows);
return ApiResp.ok(result);
}
// ============================================================
// 3. 发票认证状态汇总(进项税抵扣台账)
// ============================================================
/**
* 按发票认证状态汇总进项税情况。
* 认证状态从 remark 字段中的 [CERT:已认证/未认证/已抵扣] 标记提取。
* 可抵扣增值税 = 认证金额 × 13%(一般纳税人标准税率)。
*/
@GetMapping("/invoice-cert-status")
public ApiResp<Map<String, Object>> invoiceCertStatus() {
List<ArApItem> apItems = arApRepo.findByArApType(ArApItem.T_AP);
BigDecimal certifiedAmt = BigDecimal.ZERO;
BigDecimal uncertifiedAmt = BigDecimal.ZERO;
BigDecimal deductedAmt = BigDecimal.ZERO;
int certifiedCount = 0;
int uncertifiedCount = 0;
int deductedCount = 0;
int notTaggedCount = 0;
List<Map<String, Object>> certItems = new ArrayList<>();
for (ArApItem item : apItems) {
String remark = item.getRemark() != null ? item.getRemark() : "";
String certStatus = "未标注";
if (remark.contains("[CERT:已认证]")) {
certStatus = "已认证";
certifiedAmt = certifiedAmt.add(Money.nz(item.getAmount()));
certifiedCount++;
} else if (remark.contains("[CERT:已抵扣]")) {
certStatus = "已抵扣";
deductedAmt = deductedAmt.add(Money.nz(item.getAmount()));
deductedCount++;
} else if (remark.contains("[CERT:未认证]")) {
certStatus = "未认证";
uncertifiedAmt = uncertifiedAmt.add(Money.nz(item.getAmount()));
uncertifiedCount++;
} else {
notTaggedCount++;
}
if (!"未标注".equals(certStatus)) {
Map<String, Object> row = new LinkedHashMap<>();
row.put("id", item.getId());
row.put("code", item.getCode());
row.put("partyName", item.getPartyName());
row.put("relatedRef", item.getRelatedRef());
row.put("amount", Money.nz(item.getAmount()));
row.put("invoiceCertStatus", certStatus);
certItems.add(row);
}
}
// 可抵扣增值税估算(认证金额中的进项税,按13%标准税率)
BigDecimal taxRate = new BigDecimal("0.13");
BigDecimal deductibleInputTax = certifiedAmt.multiply(taxRate).divide(
BigDecimal.ONE.add(taxRate), 2, RoundingMode.HALF_UP);
Map<String, Object> result = new LinkedHashMap<>();
result.put("reportName", "进项发票认证状态汇总");
result.put("certifiedCount", certifiedCount);
result.put("certifiedAmount", certifiedAmt.setScale(2, RoundingMode.HALF_UP));
result.put("uncertifiedCount", uncertifiedCount);
result.put("uncertifiedAmount", uncertifiedAmt.setScale(2, RoundingMode.HALF_UP));
result.put("deductedCount", deductedCount);
result.put("deductedAmount", deductedAmt.setScale(2, RoundingMode.HALF_UP));
result.put("notTaggedCount", notTaggedCount);
result.put("deductibleInputTaxEstimate", deductibleInputTax);
result.put("taxRateUsed", "13%(一般纳税人标准税率估算)");
result.put("certifiedItems", certItems);
return ApiResp.ok(result);
}
// ============================================================
// 4. @Scheduled 自动到期扫描(每日 6:00)
// ============================================================
/**
* 每日6:00自动扫描到期/即将到期应付款,输出预警日志。
* 生产环境可对接钉钉/企微/邮件推送。
*/
@Scheduled(cron = "0 0 6 * * *")
@Transactional
public void scheduledApDueCheck() {
LocalDate today = LocalDate.now();
List<ArApItem> apItems = arApRepo.findByArApType(ArApItem.T_AP).stream()
.filter(i -> !ArApItem.S_SETTLED.equals(i.getStatus()))
.toList();
int urgentCount = 0;
BigDecimal urgentAmt = BigDecimal.ZERO;
for (ArApItem item : apItems) {
if (item.getDueDate() == null || item.getDueDate().isBlank()) continue;
try {
LocalDate due = LocalDate.parse(item.getDueDate().substring(0, 10));
long daysTodue = ChronoUnit.DAYS.between(today, due);
if (daysTodue <= 7) {
urgentCount++;
urgentAmt = urgentAmt.add(Money.nz(item.getUnwrittenOff()));
}
} catch (Exception ignored) { /* 日期格式异常跳过 */ }
}
if (urgentCount > 0) {
System.out.printf("[FinApReminder][%s] 到期应付款预警:%d 笔 / 合计 %s 元(7日内到期)%n",
today, urgentCount, urgentAmt.setScale(2, RoundingMode.HALF_UP));
}
}
}