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> dueReminders( @RequestParam(required = false, defaultValue = "30") int lookAheadDays) { List apItems = arApRepo.findByArApType(ArApItem.T_AP).stream() .filter(i -> !ArApItem.S_SETTLED.equals(i.getStatus())) .toList(); LocalDate today = LocalDate.now(); List> urgent = new ArrayList<>(); List> warn = new ArrayList<>(); List> 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 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 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> supplierStatement( @RequestParam(required = false) String supplierName) { List allAp = arApRepo.findByArApType(ArApItem.T_AP); if (supplierName != null && !supplierName.isBlank()) { allAp = allAp.stream() .filter(i -> supplierName.equals(i.getPartyName())) .toList(); } // 按供应商汇总 Map bySupplier = new LinkedHashMap<>(); // [0]=总应付, [1]=已付, [2]=未付, [3]=逾期, [4]=发票张数 Map 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> rows = new ArrayList<>(); BigDecimal totalAp = BigDecimal.ZERO; BigDecimal totalPaid = BigDecimal.ZERO; BigDecimal totalPending = BigDecimal.ZERO; BigDecimal totalOverdue = BigDecimal.ZERO; for (Map.Entry en : bySupplier.entrySet()) { BigDecimal[] arr = en.getValue(); Map 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 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> invoiceCertStatus() { List 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> 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 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 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 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)); } } }