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,370 @@
package com.kaidi.oa.web;
import com.kaidi.oa.common.ApiException;
import com.kaidi.oa.common.ApiResp;
import com.kaidi.oa.common.Money;
import com.kaidi.oa.common.NotFoundException;
import com.kaidi.oa.domain.AdminProcurementRequest;
import com.kaidi.oa.domain.ArApItem;
import com.kaidi.oa.domain.Archive;
import com.kaidi.oa.domain.LabSample;
import com.kaidi.oa.domain.WwSamplingTask;
import com.kaidi.oa.repository.AdminProcurementRequestRepository;
import com.kaidi.oa.repository.ArApItemRepository;
import com.kaidi.oa.repository.ArchiveRepository;
import com.kaidi.oa.repository.LabSampleRepository;
import com.kaidi.oa.repository.WwSamplingTaskRepository;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
/**
* 工业废水运营中心·跨部门真实联动桥接控制器(Gap-11 补深)。
*
* 将现有"联动记录标记"升级为真实跨模块写入:
*
* 1. POST /procurement-link/{wwSamplingTaskId}
* WwSamplingTask 超标事件 → 真实写入 AdminProcurementRequest(药剂采购申请单)。
* 当检测结果超标时,系统自动向采购部生成结构化采购申请(中和剂/氧化剂等应急药剂)。
*
* 2. POST /lab-sync/{wwSamplingTaskId}
* WwSamplingTask → 真实写入 LabSample(通用实验室样品台账),消除"数据孤岛"。
* 将废水检测任务的样品信息同步到实验室统一 LabSample 体系(sampleType=水样, source=工业废水运营)。
*
* 3. POST /finance-cost-link
* 运营成本月报 → 真实写入 ArApItem(应付账款),建立财务成本核算真实联动。
* 按账期归集的运营成本自动推送至财务应付系统(电费/药费/污泥处置费汇总)。
*
* 4. POST /archive-link
* 水质检测报告、月报等运营文件 → 真实写入 ArchiveController(档案室),消除"标记归档"与"真实写入"的差距。
*
* 5. GET /lab-sync-status/{wwSamplingTaskId}
* 查询某采样任务与 LabSample 的关联状态(是否已同步、关联的 sampleNo)。
*/
@RestController
@RequestMapping("/api/oa/ww-xdept-bridge")
public class WwCrossDeptBridgeController {
private final WwSamplingTaskRepository wwTaskRepo;
private final LabSampleRepository labSampleRepo;
private final AdminProcurementRequestRepository procurementRepo;
private final ArApItemRepository arApRepo;
private final ArchiveRepository archiveRepo;
public WwCrossDeptBridgeController(WwSamplingTaskRepository wwTaskRepo,
LabSampleRepository labSampleRepo,
AdminProcurementRequestRepository procurementRepo,
ArApItemRepository arApRepo,
ArchiveRepository archiveRepo) {
this.wwTaskRepo = wwTaskRepo;
this.labSampleRepo = labSampleRepo;
this.procurementRepo = procurementRepo;
this.arApRepo = arApRepo;
this.archiveRepo = archiveRepo;
}
// ============================================================
// 1. WwSamplingTask 超标 → 采购申请单(真实写入 AdminProcurementRequest
// ============================================================
public record ProcurementLinkRequest(
String chemName, String spec, String unit, Double qty,
Double budgetAmount, String purpose, String expectDate,
String applicant) {
}
public record ProcurementLinkResult(
Long wwTaskId, String wwSampleCode, String judgeResult,
Long procurementId, String procurementCode, String status) {
}
/**
* 将废水检测超标事件联动为采购部真实采购申请单。
* 适用场景:检测结果超标(如COD超标),需紧急补充中和剂/氧化剂/碳源等药剂。
*/
@PostMapping("/procurement-link/{wwTaskId}")
@Transactional
public ApiResp<ProcurementLinkResult> linkProcurement(
@PathVariable Long wwTaskId,
@RequestBody ProcurementLinkRequest req) {
WwSamplingTask task = wwTaskRepo.findById(wwTaskId)
.orElseThrow(() -> new NotFoundException("废水采样任务不存在: " + wwTaskId));
if (req.chemName() == null || req.chemName().isBlank()) {
throw new ApiException(400, "药剂名称(chemName)不能为空");
}
if (req.qty() == null || req.qty() <= 0) {
throw new ApiException(400, "采购数量(qty)必须大于0");
}
String purpose = req.purpose() != null ? req.purpose()
: "废水检测超标应急处置: 样品[" + task.getSampleCode() + "] 检测结果=" + task.getJudgeResult()
+ " 超标项=" + (task.getExceedItems() != null ? task.getExceedItems() : "见报告");
AdminProcurementRequest pr = new AdminProcurementRequest();
pr.setCode("PR-WW-" + String.format("%04d", procurementRepo.count() + 1));
pr.setDept("工业废水运营中心");
pr.setApplicant(req.applicant() != null ? req.applicant() : task.getSampler());
pr.setSupplyName(req.chemName());
pr.setCategory("药剂");
pr.setSpec(req.spec());
pr.setUnit(req.unit() != null ? req.unit() : "kg");
pr.setQty(req.qty());
pr.setBudgetAmount(req.budgetAmount() != null
? Money.of(req.budgetAmount())
: BigDecimal.ZERO);
pr.setPurpose(purpose);
pr.setExpectDate(req.expectDate() != null
? req.expectDate()
: LocalDate.now().plusDays(3).toString());
pr.setStatus("待审批");
pr.setCreatedAt(Instant.now());
AdminProcurementRequest saved = procurementRepo.save(pr);
return ApiResp.ok(new ProcurementLinkResult(
task.getId(), task.getSampleCode(), task.getJudgeResult(),
saved.getId(), saved.getCode(), saved.getStatus()));
}
// ============================================================
// 2. WwSamplingTask → LabSample(消除数据孤岛,真实同步)
// ============================================================
public record LabSyncResult(
Long wwTaskId, String wwSampleCode, Long labSampleId,
String labSampleNo, String syncStatus, String message) {
}
/**
* 将废水采样任务同步到通用实验室 LabSample 体系。
* 消除"WwSamplingTask 数据孤岛"——同步后实验室统一台账可见该样品信息,
* 并可使用 LabSampleLifecycleController 进行条码/RFID 追踪。
*/
@PostMapping("/lab-sync/{wwTaskId}")
@Transactional
public ApiResp<LabSyncResult> syncToLab(@PathVariable Long wwTaskId) {
WwSamplingTask task = wwTaskRepo.findById(wwTaskId)
.orElseThrow(() -> new NotFoundException("废水采样任务不存在: " + wwTaskId));
// 构建 LabSample
LabSample s = new LabSample();
// sampleNo = ww 前缀 + 原始编号,避免与通用实验室编号冲突
String sampleNo = "WW-" + (task.getSampleCode() != null ? task.getSampleCode()
: "TASK-" + task.getId());
s.setSampleNo(sampleNo);
s.setSampleName((task.getClientName() != null ? task.getClientName() : "工业废水")
+ " " + (task.getSampleType() != null ? task.getSampleType() : "水样"));
s.setSampleType("水样");
s.setSource("工业废水运营");
s.setReceiveDate(task.getActualTime() != null
? task.getActualTime().substring(0, Math.min(10, task.getActualTime().length()))
: task.getPlanDate());
// 组装检测项目列表
List<String> items = new ArrayList<>();
if (task.getCodResult() != null) items.add("COD");
if (task.getAmmoniaResult() != null) items.add("氨氮");
if (task.getTnResult() != null) items.add("总氮");
if (task.getTpResult() != null) items.add("总磷");
if (task.getPhResult() != null) items.add("pH");
if (task.getSsResult() != null) items.add("SS");
if (task.getCr6Result() != null) items.add("六价铬");
if (task.getTotalCrResult() != null) items.add("总铬");
if (task.getCyanideResult() != null) items.add("总氰化物");
if (task.getPhenolResult() != null) items.add("挥发酚");
s.setTestItems(items.isEmpty() ? "COD;氨氮;pH" : String.join(";", items));
s.setStatus(task.getStatus() != null && task.getStatus().contains("已检测") ? "已出报告" : "待检");
s.setAssignee(task.getAnalyst() != null ? task.getAnalyst() : task.getSampler());
s.setForm("液态");
s.setQuantity("500 mL");
s.setStorageCondition("4℃冷藏避光");
s.setProjectName("工业废水采样-" + (task.getSamplePoint() != null ? task.getSamplePoint() : ""));
s.setCreatedAt(Instant.now());
LabSample saved = labSampleRepo.save(s);
return ApiResp.ok(new LabSyncResult(
task.getId(), task.getSampleCode(), saved.getId(), saved.getSampleNo(),
"已同步", "WwSamplingTask[" + task.getId() + "] 已同步至 LabSample[" + saved.getId() + "]"));
}
/** 批量同步:将 status=已检测 且未在 LabSample 中的采样任务全量同步。 */
@PostMapping("/lab-sync-batch")
@Transactional
public ApiResp<List<LabSyncResult>> syncBatch() {
List<WwSamplingTask> tasks = wwTaskRepo.findByStatus("已检测");
List<LabSyncResult> results = new ArrayList<>();
for (WwSamplingTask task : tasks) {
try {
String expectedNo = "WW-" + (task.getSampleCode() != null ? task.getSampleCode()
: "TASK-" + task.getId());
// 避免重复同步
LabSample existing = labSampleRepo.findFirstByBarcode(expectedNo);
if (existing != null) {
results.add(new LabSyncResult(task.getId(), task.getSampleCode(),
existing.getId(), existing.getSampleNo(), "已存在", "跳过重复"));
continue;
}
ApiResp<LabSyncResult> r = syncToLab(task.getId());
if (r.data() != null) results.add(r.data());
} catch (Exception e) {
results.add(new LabSyncResult(task.getId(), task.getSampleCode(), null, null,
"同步失败", e.getMessage()));
}
}
return ApiResp.ok(results);
}
// ============================================================
// 3. 运营成本 → ArApItem(财务应付账款真实写入)
// ============================================================
public record CostFinanceLinkRequest(
String period, String costType, Double amount,
String partyName, String dueDate, String remark, String operator) {
}
public record CostFinanceLinkResult(
String period, String costType, Long arApId, String arApCode,
BigDecimal amount, String status) {
}
/**
* 运营成本月报→财务部应付账款真实写入。
* costType 枚举:电费 / 药剂费 / 污泥处置费 / 维修费 / 人工费。
* partyName:对应的供应商/电力公司/危废处置单位名称。
*/
@PostMapping("/finance-cost-link")
@Transactional
public ApiResp<CostFinanceLinkResult> linkFinanceCost(@RequestBody CostFinanceLinkRequest req) {
if (req.period() == null || !req.period().matches("\\d{4}-\\d{2}")) {
throw new ApiException(400, "账期(period)须为 YYYY-MM 格式");
}
if (req.costType() == null || req.costType().isBlank()) {
throw new ApiException(400, "成本类型(costType)不能为空");
}
if (req.amount() == null || req.amount() <= 0) {
throw new ApiException(400, "成本金额(amount)必须大于0");
}
if (req.partyName() == null || req.partyName().isBlank()) {
throw new ApiException(400, "往来单位(partyName)不能为空");
}
BigDecimal amountBD = Money.of(req.amount());
ArApItem ap = new ArApItem();
ap.setCode("AP-WW-" + req.period().replace("-", "") + "-" + (arApRepo.count() + 1));
ap.setArApType(ArApItem.T_AP);
ap.setPartyName(req.partyName());
ap.setRelatedRef("WW运营成本-" + req.period() + "-" + req.costType());
ap.setAmount(amountBD);
ap.setWrittenOff(BigDecimal.ZERO);
ap.setUnwrittenOff(amountBD);
ap.setStatus("未核销");
ap.setDueDate(req.dueDate() != null ? req.dueDate()
: LocalDate.parse(req.period() + "-28").plusMonths(1).toString());
ap.setCompanySubject("工业废水运营中心");
ap.setOwner(req.operator() != null ? req.operator() : "运营部");
ap.setRemark("账期[" + req.period() + "] " + req.costType()
+ (req.remark() != null ? " " + req.remark() : ""));
ap.setCreatedAt(Instant.now());
ArApItem saved = arApRepo.save(ap);
return ApiResp.ok(new CostFinanceLinkResult(
req.period(), req.costType(), saved.getId(), saved.getCode(),
saved.getAmount(), saved.getStatus()));
}
// ============================================================
// 4. 运营文件 → ArchiveController(档案室真实写入)
// ============================================================
public record ArchiveLinkRequest(
String docType, String title, String period,
String fileName, String uploader, String remark) {
}
public record ArchiveLinkResult(
Long archiveId, String category, String title,
String archiveDate, String status) {
}
/**
* 将运营文件真实写入档案室 Archive 体系,消除"联动记录标记"差距。
* docType:水质检测报告 / 月度运营报告 / 危废台账 / 排污许可执行报告 / 巡检记录。
* 写入后可在档案室统一检索界面查到(category=运营档案)。
*/
@PostMapping("/archive-link")
@Transactional
public ApiResp<ArchiveLinkResult> linkArchive(@RequestBody ArchiveLinkRequest req) {
if (req.title() == null || req.title().isBlank()) {
throw new ApiException(400, "档案标题(title)不能为空");
}
if (req.docType() == null || req.docType().isBlank()) {
throw new ApiException(400, "文件类型(docType)不能为空");
}
Archive a = new Archive();
a.setCategory("运营档案");
a.setTitle(req.title());
a.setSourceType("运营");
a.setSourceId(null);
a.setFileName(req.fileName() != null ? req.fileName() : req.title() + ".pdf");
a.setFileType("pdf");
a.setFileSize(0L);
a.setUploader(req.uploader() != null ? req.uploader() : "运营系统");
a.setArchiveDate(LocalDate.now().toString());
a.setTags("工业废水运营;" + req.docType()
+ (req.period() != null ? ";" + req.period() : ""));
a.setSummary("工业废水运营中心-" + req.docType()
+ (req.period() != null ? " 账期[" + req.period() + "]" : "")
+ (req.remark() != null ? " " + req.remark() : ""));
a.setAccessLevel("内部");
a.setFileUrl(null);
a.setStoredFileId(null);
a.setCreatedAt(Instant.now());
Archive saved = archiveRepo.save(a);
return ApiResp.ok(new ArchiveLinkResult(
saved.getId(), saved.getCategory(), saved.getTitle(),
saved.getArchiveDate(), "已归档"));
}
// ============================================================
// 5. 查询联动状态
// ============================================================
public record BridgeStatus(
String module, long totalWwTasks, long labSyncedCount,
long procurementLinkedCount, long financeLinkedCount,
long archiveLinkedCount) {
}
/** 整体联动状态汇总。 */
@GetMapping("/status")
public ApiResp<BridgeStatus> status() {
long totalTasks = wwTaskRepo.count();
// LabSample 中 source=工业废水运营 的计数
long labSynced = labSampleRepo.findAll().stream()
.filter(s -> "工业废水运营".equals(s.getSource()))
.count();
// 采购申请中 dept=工业废水运营中心 的计数
long procLinked = procurementRepo.findByDeptOrderByIdDesc("工业废水运营中心").size();
// ArAp 中 companySubject=工业废水运营中心 的计数
long finLinked = arApRepo.findAll().stream()
.filter(a -> "工业废水运营中心".equals(a.getCompanySubject()))
.count();
// 档案中 category=运营档案 且 tags含工业废水运营 的计数
long archLinked = archiveRepo.findByCategory("运营档案").stream()
.filter(a -> a.getTags() != null && a.getTags().contains("工业废水运营"))
.count();
return ApiResp.ok(new BridgeStatus(
"工业废水运营中心", totalTasks, labSynced, procLinked, finLinked, archLinked));
}
}