恢复点(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>
326 lines
15 KiB
Java
326 lines
15 KiB
Java
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));
|
||
}
|
||
}
|
||
}
|