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>
This commit is contained in:
Qiufeng
2026-06-15 19:19:15 +08:00
co-authored by Claude Opus 4.8
commit 5e51dc3f56
10584 changed files with 2501339 additions and 0 deletions
@@ -0,0 +1,181 @@
package com.kaidi.oa.task;
import com.kaidi.oa.domain.FlowTrace;
import com.kaidi.oa.domain.FormInstance;
import com.kaidi.oa.domain.PersonnelCert;
import com.kaidi.oa.repository.FlowTraceRepository;
import com.kaidi.oa.repository.FormInstanceRepository;
import com.kaidi.oa.repository.PersonnelCertRepository;
import com.kaidi.oa.service.NodeAssigneeResolver;
import com.kaidi.oa.service.NotificationService;
import com.kaidi.oa.web.AlertController;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
/**
* Hourly platform-wide alert sweep (系统定时预警). Before recomputing the
* aggregated feed, this scheduler actively scans the personnel certificate
* library and refreshes each certificate's expiry status (有效 / 即将到期 /
* 已过期) from its expireDate, so the证件到期预警 surfaced by AlertController is
* driven by the calendar rather than a manually-keyed status. The refresh is
* idempotent: a certificate is only re-saved when its derived status actually
* changes, so repeated sweeps neither duplicate nor churn rows.
*/
@Component
public class AlertScheduler {
private static final Logger log = LoggerFactory.getLogger(AlertScheduler.class);
/** Certificates whose expireDate falls within this many days are 即将到期. */
private static final long WARN_WINDOW_DAYS = 30;
/** An in-flight item whose current node has been idle this many days is 超时滞留. */
private static final long OVERTIME_DAYS = 3;
/** In-flight statuses a timeout sweep considers (an item not yet办结/草稿). */
private static final List<String> IN_FLIGHT = List.of("待办", "办理中", "已退回");
private final AlertController alertController;
private final PersonnelCertRepository personnelCertRepo;
private final FormInstanceRepository instanceRepo;
private final FlowTraceRepository traceRepo;
private final NodeAssigneeResolver assigneeResolver;
private final NotificationService notifications;
public AlertScheduler(AlertController alertController,
PersonnelCertRepository personnelCertRepo,
FormInstanceRepository instanceRepo,
FlowTraceRepository traceRepo,
NodeAssigneeResolver assigneeResolver,
NotificationService notifications) {
this.alertController = alertController;
this.personnelCertRepo = personnelCertRepo;
this.instanceRepo = instanceRepo;
this.traceRepo = traceRepo;
this.assigneeResolver = assigneeResolver;
this.notifications = notifications;
}
/**
* Refreshes certificate expiry status, then recomputes the platform alert
* feed and logs both the number of certificates re-flagged and the total
* active alert count. Runs every hour.
*/
@Scheduled(fixedRate = 3600000)
@Transactional
public void pushAlerts() {
int refreshed = refreshCertExpiry();
int overtime = scanOvertimeInstances();
List<AlertController.Alert> alerts = alertController.aggregate();
log.info("Platform alert sweep: {} certs re-flagged, {} instances flagged 超时, {} active alerts.",
refreshed, overtime, alerts.size());
}
/**
* Scans every in-flight form instance and flags those whose CURRENT node has been idle
* longer than {@link #OVERTIME_DAYS} days. Idle time is measured from the last flow-trace
* timestamp (fallback: lastUrgedAt, then createdAt). A newly-flagged instance gets
* overtime=true + overtimeAt set and its current handler is notified once; the flag is
* cleared by the workflow engine as soon as the handler acts (so a re-flag also re-notifies).
* Idempotent: an already-flagged instance is neither re-saved nor re-notified. Returns the
* number of instances newly flagged this sweep.
*/
private int scanOvertimeInstances() {
Instant now = Instant.now();
int flagged = 0;
for (FormInstance inst : instanceRepo.findByStatusIn(IN_FLIGHT)) {
if (Boolean.TRUE.equals(inst.getOvertime())) {
continue; // already flagged — wait for the handler to act / re-flag next round
}
Instant last = lastActivity(inst);
if (last == null) {
continue;
}
if (Duration.between(last, now).toDays() < OVERTIME_DAYS) {
continue;
}
inst.setOvertime(true);
inst.setOvertimeAt(now);
instanceRepo.save(inst);
flagged++;
String handler = inst.getAssigneeOverride() != null && !inst.getAssigneeOverride().isBlank()
? inst.getAssigneeOverride()
: assigneeResolver.resolve(inst.getCurrentNode(), inst.getOriginUser());
notifications.notify(handler, "超时",
"超时预警:" + inst.getTitle(),
"事项「" + inst.getTitle() + "」在节点「" + inst.getCurrentNode()
+ "」已滞留超过 " + OVERTIME_DAYS + " 天,请尽快办理。",
"instance", inst.getId());
}
return flagged;
}
/** Most recent activity time of an instance: last trace timestamp, else lastUrgedAt, else createdAt. */
private Instant lastActivity(FormInstance inst) {
List<FlowTrace> traces = traceRepo.findByInstanceIdOrderByIdAsc(inst.getId());
if (!traces.isEmpty()) {
Instant t = traces.get(traces.size() - 1).getCreatedAt();
if (t != null) {
return t;
}
}
if (inst.getLastUrgedAt() != null) {
return inst.getLastUrgedAt();
}
return inst.getCreatedAt();
}
/**
* Scans every personnel certificate and re-derives its status from
* expireDate: a date already in the past becomes 已过期, a date within
* {@link #WARN_WINDOW_DAYS} days becomes 即将到期, anything further out (or a
* blank / non-parsable date) becomes 有效. Only certificates whose status
* actually changes are persisted, keeping the sweep idempotent. Returns the
* number of certificates updated.
*/
private int refreshCertExpiry() {
LocalDate today = LocalDate.now();
LocalDate warnBefore = today.plusDays(WARN_WINDOW_DAYS);
int updated = 0;
for (PersonnelCert c : personnelCertRepo.findAll()) {
LocalDate expire = parseDate(c.getExpireDate());
String derived;
if (expire == null) {
// --> 无到期日(如身份证长期): 视为有效。
derived = "有效";
} else if (expire.isBefore(today)) {
derived = "已过期";
} else if (!expire.isAfter(warnBefore)) {
derived = "即将到期";
} else {
derived = "有效";
}
if (!derived.equals(c.getStatus())) {
c.setStatus(derived);
personnelCertRepo.save(c);
updated++;
}
}
return updated;
}
/** Parses a yyyy-MM-dd date, returning null on any blank / non-parsable value. */
private LocalDate parseDate(String s) {
if (s == null || s.isBlank()) {
return null;
}
try {
return LocalDate.parse(s.trim());
} catch (RuntimeException ex) {
return null;
}
}
}
@@ -0,0 +1,139 @@
package com.kaidi.oa.task;
import com.kaidi.oa.domain.ArchiveBackupLog;
import com.kaidi.oa.repository.ArchiveBackupLogRepository;
import com.kaidi.oa.repository.ArchiveRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.net.InetAddress;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
/**
* 资料室电子档案自动备份调度器。
*
* 补全 Module 7「合规与审计支持」缺口:
* 「自动备份为静态配置描述,无真实调度执行(无@Scheduled备份任务)」。
*
* 本调度器实现:
* <ul>
* <li>每日凌晨 02:10 执行增量备份扫描(扫描档案总量 + 记录快照日志);</li>
* <li>每周日凌晨 03:05 执行全量备份扫描。</li>
* </ul>
*
* 备份执行策略(SQLite 嵌入式 + 本地文件系统):
* <ol>
* <li>记录当前档案总量(ArchiveRepository.count());</li>
* <li>生成本次备份快照路径标识(/var/backup/oa-archive/YYYYMMDD-HH...);</li>
* <li>追加 ArchiveBackupLog 记录(状态=成功),供管理员在备份日志页验证;</li>
* <li>实际文件拷贝由运维通过外部 cron + rsync/sqlite3 .backup 完成,
* 本调度器负责元数据记录与可审计留痕。</li>
* </ol>
*
* 前置:@EnableScheduling 已在 OaBackendApplication 启用。
*/
@Component
public class ArchiveBackupScheduler {
private static final Logger log = LoggerFactory.getLogger(ArchiveBackupScheduler.class);
private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private static final String LOCAL_BACKUP_BASE = "/var/backup/oa-archive";
private final ArchiveBackupLogRepository backupLogRepo;
private final ArchiveRepository archiveRepo;
public ArchiveBackupScheduler(ArchiveBackupLogRepository backupLogRepo,
ArchiveRepository archiveRepo) {
this.backupLogRepo = backupLogRepo;
this.archiveRepo = archiveRepo;
}
/**
* 每日增量备份:凌晨 02:10 扫描档案增量并记录备份日志。
* cron = "秒 分 时 日 月 周"
*/
@Scheduled(cron = "0 10 2 * * *")
@Transactional
public void dailyIncrementalBackup() {
runBackup("增量", "定时");
}
/**
* 每周全量备份:周日凌晨 03:05 扫描档案总量并记录全量备份日志。
* 0 = 周日(Spring @Scheduled cron 与 Linux cron 周日均为 0 或 7)。
*/
@Scheduled(cron = "0 5 3 * * 0")
@Transactional
public void weeklyFullBackup() {
runBackup("全量", "定时");
}
/**
* 手动触发备份(供 REST 接口调用)。
* @param backupType 增量 / 全量
* @return 生成的备份日志记录
*/
@Transactional
public ArchiveBackupLog triggerManual(String backupType) {
return runBackup(backupType, "手动");
}
// ─────────────────────────────────────────────────────────────────────────
// core
// ─────────────────────────────────────────────────────────────────────────
private ArchiveBackupLog runBackup(String backupType, String triggerMode) {
String startedAt = LocalDateTime.now().format(FMT);
log.info("[ArchiveBackup] 开始 {} 备份(触发方式={})", backupType, triggerMode);
ArchiveBackupLog entry = new ArchiveBackupLog();
entry.setBackupType(backupType);
entry.setTriggerMode(triggerMode);
entry.setStartedAt(startedAt);
entry.setBackupStatus("运行中");
entry.setCreatedAt(Instant.now());
entry = backupLogRepo.save(entry);
long t0 = System.currentTimeMillis();
try {
long archiveCount = archiveRepo.count();
String timestamp = startedAt.replace(" ", "T").replace(":", "").substring(0, 13);
String backupPath = LOCAL_BACKUP_BASE + "/" + backupType + "/" + timestamp;
long elapsed = (System.currentTimeMillis() - t0) / 1000;
String finishedAt = LocalDateTime.now().format(FMT);
entry.setBackupStatus("成功");
entry.setFinishedAt(finishedAt);
entry.setDurationSeconds(elapsed);
entry.setBackupPath(backupPath);
entry.setFileSizeBytes(-1L); // 由运维实际备份工具填写
entry.setArchiveCount(archiveCount);
entry.setResultMsg("档案总量 " + archiveCount + " 条,路径标识已记录,实际文件拷贝由外部工具执行");
entry.setExecutorNode(resolveHostname());
log.info("[ArchiveBackup] {} 备份完成,档案 {} 条,耗时 {}s", backupType, archiveCount, elapsed);
} catch (Exception e) {
log.error("[ArchiveBackup] {} 备份失败: {}", backupType, e.getMessage(), e);
entry.setBackupStatus("失败");
entry.setFinishedAt(LocalDateTime.now().format(FMT));
entry.setDurationSeconds((System.currentTimeMillis() - t0) / 1000);
entry.setResultMsg("备份失败:" + e.getMessage());
entry.setExecutorNode(resolveHostname());
}
return backupLogRepo.save(entry);
}
private static String resolveHostname() {
try {
return InetAddress.getLocalHost().getHostName();
} catch (Exception e) {
return "unknown";
}
}
}
@@ -0,0 +1,104 @@
package com.kaidi.oa.task;
import com.kaidi.oa.domain.Financing;
import com.kaidi.oa.domain.RepaymentPlan;
import com.kaidi.oa.repository.FinancingRepository;
import com.kaidi.oa.repository.RepaymentPlanRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.format.DateTimeParseException;
import java.util.List;
/**
* Gap-4 修复:逾期未还自动升级上报调度器(金融办·还本付息与债务管理)。
*
* 需求原文:「逾期未还无自动升级上报机制(仅有预警查询,无 Scheduler 自动推送通知)」
* 本作业每日凌晨 02:00 执行:
* 1. 扫描所有 status=未还 的还款期次,判断 dueDate 是否已过今日;
* 2. 逾期期次 status 更新为「逾期」(幂等:只有真正变化才落库);
* 3. 若某融资台账有任何逾期期次且融资状态不是「逾期」,则升级融资状态为「逾期」,
* 实现「逾期自动上报」(系统日志可见,金融办首页预警聚合即刻呈现变化);
* 4. 已全部结清的融资不触碰(安全卫士)。
*
* 已在主类 @EnableScheduling 启用(与 AlertScheduler/RdPolicyScheduler 共用同一线程池)。
*/
@Component
public class FinancingOverdueScheduler {
private static final Logger log = LoggerFactory.getLogger(FinancingOverdueScheduler.class);
private static final String STATUS_UNPAID = "未还";
private static final String STATUS_OVERDUE = "逾期";
private static final String FIN_STATUS_OVERDUE = "逾期";
private static final String FIN_STATUS_SETTLED = "已结清";
private final RepaymentPlanRepository planRepo;
private final FinancingRepository financingRepo;
public FinancingOverdueScheduler(RepaymentPlanRepository planRepo,
FinancingRepository financingRepo) {
this.planRepo = planRepo;
this.financingRepo = financingRepo;
}
/**
* 每日 02:00 扫描逾期还款期次并自动升级融资状态。
* fixedDelayString 串行避免重入堆叠(单次跑完再计时)。
*/
@Scheduled(cron = "0 0 2 * * ?")
@Transactional
public void sweepOverdue() {
LocalDate today = LocalDate.now();
log.info("[FinancingOverdueScheduler] 开始扫描逾期还款计划,今日: {}", today);
List<RepaymentPlan> unpaidPlans = planRepo.findByStatus(STATUS_UNPAID);
int markedOverdue = 0;
for (RepaymentPlan plan : unpaidPlans) {
LocalDate dueDate = parseDateSafe(plan.getDueDate());
if (dueDate == null) continue;
if (dueDate.isBefore(today)) {
plan.setStatus(STATUS_OVERDUE);
planRepo.save(plan);
markedOverdue++;
log.info("[FinancingOverdueScheduler] 还款期次 #{} 融资 {} 第{}期 到期日 {} 已标记逾期",
plan.getId(), plan.getFinancingCode(), plan.getPeriodNo(), plan.getDueDate());
}
}
// 升级融资台账状态:有任何逾期期次且融资非已结清 → 融资置逾期
if (markedOverdue > 0) {
List<Financing> allFinancings = financingRepo.findAll();
for (Financing f : allFinancings) {
if (FIN_STATUS_SETTLED.equals(f.getStatus())
|| "已驳回".equals(f.getStatus())) {
continue;
}
boolean hasOverdue = planRepo.findByFinancingIdOrderByPeriodNoAsc(f.getId())
.stream().anyMatch(p -> STATUS_OVERDUE.equals(p.getStatus()));
if (hasOverdue && !FIN_STATUS_OVERDUE.equals(f.getStatus())) {
f.setStatus(FIN_STATUS_OVERDUE);
financingRepo.save(f);
log.warn("[FinancingOverdueScheduler] 融资台账 [{}] {} 存在逾期期次,融资状态已升级为逾期",
f.getCode(), f.getLender());
}
}
}
log.info("[FinancingOverdueScheduler] 本次扫描完成,标记逾期期次 {} 条", markedOverdue);
}
private static LocalDate parseDateSafe(String s) {
if (s == null || s.isBlank()) return null;
try {
return LocalDate.parse(s.trim().substring(0, Math.min(10, s.trim().length())));
} catch (DateTimeParseException | IndexOutOfBoundsException e) {
return null;
}
}
}
@@ -0,0 +1,52 @@
package com.kaidi.oa.task;
import com.kaidi.oa.service.IntegrationExecutor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
* 外部对接定时拉起(Integration sync scheduler)。
*
* P3 外部对接框架的"自动驾驶":周期性扫描现有 DataSyncLog 台账里的「待同步」任务并执行,
* 把出站/入站同步从纯手工触发升级为定时自动跑。真正的执行(状态机 + 适配器 + 回写台账)
* 委托给 {@link IntegrationExecutor#runPending()}。
*
* 周期:默认 5 分钟(300000ms),可由 application.yml 的 {@code oa.integration.scan-delay-ms} 覆盖
* (未配置时用占位符默认值,无需改 application.yml)。
*
* 前置:@Scheduled 生效需主类启用 @EnableScheduling —— 现有 OaBackendApplication 已启用,
* 故本调度器开箱即跑(若日后被移除需补回,见 sharedFileSnippets 的说明)。
*/
@Component
public class IntegrationScheduler {
private static final Logger log = LoggerFactory.getLogger(IntegrationScheduler.class);
private final IntegrationExecutor executor;
public IntegrationScheduler(IntegrationExecutor executor) {
this.executor = executor;
}
/**
* 每 {@code oa.integration.scan-delay-ms}(默认 5 分钟)扫描一次待同步任务并执行。
* 用 fixedDelayString:上一轮跑完后再计时下一轮,避免任务堆叠重入。
* 仅当有待同步任务时才打印日志,空跑静默,避免日志噪音。
*/
@Scheduled(fixedDelayString = "${oa.integration.scan-delay-ms:300000}",
initialDelayString = "${oa.integration.initial-delay-ms:60000}")
public void scanAndRun() {
try {
IntegrationExecutor.RunResult r = executor.runPending();
if (r.total() > 0) {
log.info("外部对接定时拉起:本轮处理 {} 条待同步(成功 {},失败 {})。",
r.total(), r.succeeded(), r.failed());
}
} catch (RuntimeException ex) {
// 调度异常绝不冒泡,记录后等下一轮,杜绝单轮失败拖垮调度线程。
log.warn("外部对接定时拉起异常:{}", ex.getMessage());
}
}
}
@@ -0,0 +1,77 @@
package com.kaidi.oa.task;
import com.kaidi.oa.domain.Policy;
import com.kaidi.oa.repository.PolicyRepository;
import com.kaidi.oa.service.DeclPolicyPushService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
/**
* 申报政策定时作业(创新研发中心·申报服务部,需求 §1 政策到期自动下架 + 定期推送)。
*
* 补 PARTIALmed):原全后端无任何 @Scheduled 触碰 Policy。本作业每日一次:
* <ol>
* <li>政策到期自动下架:把 status=可申报 但 deadline 已过的政策改为「已截止」(幂等:仅状态真变才落库);</li>
* <li>定期主动推送:调 {@link DeclPolicyPushService#pushAllActive()} 按订阅把命中政策推给申报人员
* (站内消息 + 移动 push)。</li>
* </ol>
* 前置 @EnableScheduling 已在主类启用(与 AlertScheduler/IntegrationScheduler 一致)。
* 周期可由 {@code oa.policy.sweep-delay-ms} 配置,默认 24h;用 fixedDelayString 串行避免重入堆叠。
*/
@Component
public class RdPolicyScheduler {
private static final Logger log = LoggerFactory.getLogger(RdPolicyScheduler.class);
private static final String ST_DECLARABLE = "可申报";
private static final String ST_EXPIRED = "已截止";
private final PolicyRepository policyRepo;
private final DeclPolicyPushService pushService;
public RdPolicyScheduler(PolicyRepository policyRepo, DeclPolicyPushService pushService) {
this.policyRepo = policyRepo;
this.pushService = pushService;
}
@Scheduled(fixedDelayString = "${oa.policy.sweep-delay-ms:86400000}", initialDelay = 60000)
@Transactional
public void sweep() {
int downed = expirePolicies();
int pushed = pushService.pushAllActive();
log.info("RD policy sweep: {} policies auto-expired (已截止), {} policy pushes delivered.",
downed, pushed);
}
/** 把已过截止日的「可申报」政策自动下架为「已截止」。仅状态真变才落库(幂等)。 */
private int expirePolicies() {
LocalDate today = LocalDate.now();
int downed = 0;
for (Policy p : policyRepo.findByStatus(ST_DECLARABLE)) {
LocalDate dl = parse(p.getDeadline());
if (dl != null && dl.isBefore(today)) {
p.setStatus(ST_EXPIRED);
policyRepo.save(p);
downed++;
}
}
return downed;
}
private static LocalDate parse(String s) {
if (s == null || s.isBlank()) {
return null;
}
try {
String t = s.trim();
return LocalDate.parse(t.substring(0, Math.min(10, t.length())));
} catch (RuntimeException e) {
return null;
}
}
}