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:
@@ -0,0 +1,294 @@
|
||||
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.FinRdPersonnel;
|
||||
import com.kaidi.oa.domain.FinRdTimesheet;
|
||||
import com.kaidi.oa.repository.FinRdPersonnelRepository;
|
||||
import com.kaidi.oa.repository.FinRdTimesheetRepository;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
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.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
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.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 财务部·研发工时填报管理(缺口 #4)。
|
||||
*
|
||||
* 审计缺口:无独立工时管理模块(日/周工时填报→项目负责人审核→自动关联薪资分摊)。
|
||||
*
|
||||
* 端点:
|
||||
* GET / —— 列表(按 status/projectCode/employeeId 过滤)
|
||||
* GET /{id} —— 单条明细
|
||||
* POST / —— 新建工时填报(草稿)
|
||||
* PUT /{id} —— 编辑(草稿状态)
|
||||
* DELETE /{id} —— 删除(草稿状态)
|
||||
* POST /{id}/submit —— 提交审核:草稿 → 待审核
|
||||
* POST /{id}/approve —— 审核通过:待审核 → 已审核,自动计算分摊人工成本
|
||||
* POST /{id}/reject —— 驳回:待审核 → 已驳回
|
||||
* GET /project-hours-summary —— 按项目工时汇总(研发费用归集依据)
|
||||
* GET /employee-hours-check —— 员工工时校验(超过法定工作日预警)
|
||||
*
|
||||
* 写口:FINANCE_PREFIXES(/api/oa/fin-rd-timesheets) 限 ADMIN/APPROVER。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/oa/fin-rd-timesheets")
|
||||
public class FinRdTimesheetController {
|
||||
|
||||
/** 法定日工作时长(小时/天)用于校验:月报 176 小时(22 天 * 8 小时)。 */
|
||||
private static final double LEGAL_MONTHLY_HOURS = 176.0;
|
||||
/** 法定日工时上限:日报不超过 8 小时/天(单次填报,实务中可加班但需标注)。 */
|
||||
private static final double LEGAL_DAILY_HOURS = 10.0;
|
||||
|
||||
private final FinRdTimesheetRepository sheetRepo;
|
||||
private final FinRdPersonnelRepository personnelRepo;
|
||||
|
||||
public FinRdTimesheetController(FinRdTimesheetRepository sheetRepo,
|
||||
FinRdPersonnelRepository personnelRepo) {
|
||||
this.sheetRepo = sheetRepo;
|
||||
this.personnelRepo = personnelRepo;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ApiResp<List<FinRdTimesheet>> list(
|
||||
@RequestParam(required = false) String status,
|
||||
@RequestParam(required = false) String projectCode,
|
||||
@RequestParam(required = false) String employeeId) {
|
||||
if (projectCode != null && status != null)
|
||||
return ApiResp.ok(sheetRepo.findByProjectCodeAndStatus(projectCode, status));
|
||||
if (status != null) return ApiResp.ok(sheetRepo.findByStatus(status));
|
||||
if (projectCode != null) return ApiResp.ok(sheetRepo.findByProjectCode(projectCode));
|
||||
if (employeeId != null) return ApiResp.ok(sheetRepo.findByEmployeeId(employeeId));
|
||||
return ApiResp.ok(sheetRepo.findAll());
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ApiResp<FinRdTimesheet> get(@PathVariable Long id) {
|
||||
return ApiResp.ok(sheetRepo.findById(id)
|
||||
.orElseThrow(() -> new NotFoundException("工时填报记录不存在: " + id)));
|
||||
}
|
||||
|
||||
public record SheetRequest(
|
||||
String employeeId, String employeeName, String projectCode, String projectName,
|
||||
String fillType, String fillDate, Double reportedHours,
|
||||
Double totalHoursInPeriod, String salaryRef, String remark) {}
|
||||
|
||||
@PostMapping
|
||||
public ApiResp<FinRdTimesheet> create(@RequestBody SheetRequest req) {
|
||||
if (req.employeeId() == null || req.employeeId().isBlank())
|
||||
throw new ApiException(400, "员工工号(employeeId)不能为空");
|
||||
if (req.projectCode() == null || req.projectCode().isBlank())
|
||||
throw new ApiException(400, "项目编码(projectCode)不能为空");
|
||||
if (req.reportedHours() == null || req.reportedHours() <= 0)
|
||||
throw new ApiException(400, "填报工时(reportedHours)必须大于 0");
|
||||
if (req.reportedHours() > LEGAL_DAILY_HOURS && FinRdTimesheet.FILL_DAILY.equals(req.fillType()))
|
||||
throw new ApiException(400, "日报填报工时不得超过 " + LEGAL_DAILY_HOURS + " 小时/天");
|
||||
|
||||
FinRdTimesheet s = new FinRdTimesheet();
|
||||
s.setSheetCode("TS-" + System.currentTimeMillis() % 1000000);
|
||||
s.setEmployeeId(req.employeeId());
|
||||
s.setEmployeeName(req.employeeName());
|
||||
s.setProjectCode(req.projectCode());
|
||||
s.setProjectName(req.projectName());
|
||||
s.setFillType(req.fillType() == null ? FinRdTimesheet.FILL_DAILY : req.fillType());
|
||||
s.setFillDate(req.fillDate() != null ? req.fillDate() : LocalDate.now().toString());
|
||||
s.setReportedHours(req.reportedHours());
|
||||
s.setTotalHoursInPeriod(req.totalHoursInPeriod());
|
||||
// 自动校验:若当期总工时超过法定月工时标准则标记
|
||||
boolean exceeds = req.totalHoursInPeriod() != null
|
||||
&& req.totalHoursInPeriod() > LEGAL_MONTHLY_HOURS;
|
||||
s.setExceedsLegalLimit(exceeds);
|
||||
s.setSalaryRef(req.salaryRef());
|
||||
s.setAllocatedLaborCost(BigDecimal.ZERO);
|
||||
s.setStatus(FinRdTimesheet.S_DRAFT);
|
||||
s.setRemark(req.remark());
|
||||
s.setCreatedAt(Instant.now());
|
||||
s.setUpdatedAt(Instant.now());
|
||||
return ApiResp.ok(sheetRepo.save(s));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ApiResp<FinRdTimesheet> update(@PathVariable Long id, @RequestBody SheetRequest req) {
|
||||
FinRdTimesheet s = sheetRepo.findById(id)
|
||||
.orElseThrow(() -> new NotFoundException("工时填报记录不存在: " + id));
|
||||
if (!FinRdTimesheet.S_DRAFT.equals(s.getStatus())
|
||||
&& !FinRdTimesheet.S_REJECTED.equals(s.getStatus()))
|
||||
throw new ApiException(409, "仅草稿/已驳回状态可编辑,当前状态:" + s.getStatus());
|
||||
if (req.reportedHours() != null) s.setReportedHours(req.reportedHours());
|
||||
if (req.projectCode() != null) s.setProjectCode(req.projectCode());
|
||||
if (req.projectName() != null) s.setProjectName(req.projectName());
|
||||
if (req.fillDate() != null) s.setFillDate(req.fillDate());
|
||||
if (req.totalHoursInPeriod() != null) s.setTotalHoursInPeriod(req.totalHoursInPeriod());
|
||||
if (req.salaryRef() != null) s.setSalaryRef(req.salaryRef());
|
||||
if (req.remark() != null) s.setRemark(req.remark());
|
||||
s.setUpdatedAt(Instant.now());
|
||||
return ApiResp.ok(sheetRepo.save(s));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ApiResp<Void> delete(@PathVariable Long id) {
|
||||
FinRdTimesheet s = sheetRepo.findById(id)
|
||||
.orElseThrow(() -> new NotFoundException("工时填报记录不存在: " + id));
|
||||
if (!FinRdTimesheet.S_DRAFT.equals(s.getStatus()))
|
||||
throw new ApiException(409, "仅草稿状态可删除");
|
||||
sheetRepo.deleteById(id);
|
||||
return ApiResp.ok(null);
|
||||
}
|
||||
|
||||
// ===================== 状态机 =====================
|
||||
|
||||
@PostMapping("/{id}/submit")
|
||||
@Transactional
|
||||
public ApiResp<FinRdTimesheet> submit(@PathVariable Long id) {
|
||||
FinRdTimesheet s = sheetRepo.findById(id)
|
||||
.orElseThrow(() -> new NotFoundException("工时填报记录不存在: " + id));
|
||||
if (!FinRdTimesheet.S_DRAFT.equals(s.getStatus())
|
||||
&& !FinRdTimesheet.S_REJECTED.equals(s.getStatus()))
|
||||
throw new ApiException(409, "仅草稿/已驳回状态可提交审核");
|
||||
s.setStatus(FinRdTimesheet.S_PENDING);
|
||||
s.setUpdatedAt(Instant.now());
|
||||
return ApiResp.ok(sheetRepo.save(s));
|
||||
}
|
||||
|
||||
public record ReviewRequest(String reviewer, Double confirmedHours) {}
|
||||
|
||||
/**
|
||||
* 审核通过:待审核 → 已审核,自动按工时占比计算分摊人工成本。
|
||||
* 分摊逻辑:从 FinRdPersonnel 取月标准人工成本,按 confirmedHours/monthlyStdHours 比例分摊。
|
||||
*/
|
||||
@PostMapping("/{id}/approve")
|
||||
@Transactional
|
||||
public ApiResp<FinRdTimesheet> approve(@PathVariable Long id,
|
||||
@RequestBody(required = false) ReviewRequest req) {
|
||||
FinRdTimesheet s = sheetRepo.findById(id)
|
||||
.orElseThrow(() -> new NotFoundException("工时填报记录不存在: " + id));
|
||||
if (!FinRdTimesheet.S_PENDING.equals(s.getStatus()))
|
||||
throw new ApiException(409, "仅待审核状态可审核,当前状态:" + s.getStatus());
|
||||
|
||||
double confirmed = req != null && req.confirmedHours() != null
|
||||
? req.confirmedHours() : (s.getReportedHours() != null ? s.getReportedHours() : 0.0);
|
||||
s.setConfirmedHours(confirmed);
|
||||
if (req != null && req.reviewer() != null) s.setReviewer(req.reviewer());
|
||||
|
||||
// 自动分摊人工成本
|
||||
if (s.getEmployeeId() != null) {
|
||||
List<FinRdPersonnel> personnels = personnelRepo.findByStatus(FinRdPersonnel.S_ACTIVE);
|
||||
personnels.stream()
|
||||
.filter(p -> s.getEmployeeId().equals(p.getEmployeeId()))
|
||||
.findFirst()
|
||||
.ifPresent(p -> {
|
||||
double stdHours = p.getMonthlyStdHours() != null ? p.getMonthlyStdHours() : 176.0;
|
||||
BigDecimal monthlyCost = Money.nz(p.getMonthlyLaborCost());
|
||||
if (stdHours > 0 && monthlyCost.signum() > 0) {
|
||||
BigDecimal alloc = monthlyCost
|
||||
.multiply(BigDecimal.valueOf(confirmed))
|
||||
.divide(BigDecimal.valueOf(stdHours), 2, RoundingMode.HALF_UP);
|
||||
s.setAllocatedLaborCost(alloc);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
s.setStatus(FinRdTimesheet.S_APPROVED);
|
||||
s.setUpdatedAt(Instant.now());
|
||||
return ApiResp.ok(sheetRepo.save(s));
|
||||
}
|
||||
|
||||
public record RejectRequest(String reviewer, String rejectReason) {}
|
||||
|
||||
@PostMapping("/{id}/reject")
|
||||
@Transactional
|
||||
public ApiResp<FinRdTimesheet> reject(@PathVariable Long id, @RequestBody RejectRequest req) {
|
||||
FinRdTimesheet s = sheetRepo.findById(id)
|
||||
.orElseThrow(() -> new NotFoundException("工时填报记录不存在: " + id));
|
||||
if (!FinRdTimesheet.S_PENDING.equals(s.getStatus()))
|
||||
throw new ApiException(409, "仅待审核状态可驳回");
|
||||
if (req.rejectReason() == null || req.rejectReason().isBlank())
|
||||
throw new ApiException(400, "驳回须填写原因(rejectReason)");
|
||||
s.setStatus(FinRdTimesheet.S_REJECTED);
|
||||
s.setReviewer(req.reviewer());
|
||||
s.setRejectReason(req.rejectReason());
|
||||
s.setUpdatedAt(Instant.now());
|
||||
return ApiResp.ok(sheetRepo.save(s));
|
||||
}
|
||||
|
||||
// ===================== 聚合端点 =====================
|
||||
|
||||
/**
|
||||
* 按项目汇总工时与分摊人工成本(研发费用归集依据)。
|
||||
*/
|
||||
@GetMapping("/project-hours-summary")
|
||||
public ApiResp<List<Map<String, Object>>> projectHoursSummary(
|
||||
@RequestParam(required = false) String projectCode) {
|
||||
List<FinRdTimesheet> approved = sheetRepo.findByStatus(FinRdTimesheet.S_APPROVED);
|
||||
if (projectCode != null)
|
||||
approved = approved.stream().filter(s -> projectCode.equals(s.getProjectCode())).toList();
|
||||
|
||||
Map<String, double[]> byProject = new LinkedHashMap<>();
|
||||
for (FinRdTimesheet s : approved) {
|
||||
String pc = s.getProjectCode() != null ? s.getProjectCode() : "未知";
|
||||
byProject.computeIfAbsent(pc, k -> new double[]{0.0, 0.0});
|
||||
double[] arr = byProject.get(pc);
|
||||
arr[0] += (s.getConfirmedHours() != null ? s.getConfirmedHours() : 0.0);
|
||||
arr[1] += (s.getAllocatedLaborCost() != null ? s.getAllocatedLaborCost().doubleValue() : 0.0);
|
||||
}
|
||||
|
||||
List<Map<String, Object>> result = new ArrayList<>();
|
||||
for (Map.Entry<String, double[]> en : byProject.entrySet()) {
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("projectCode", en.getKey());
|
||||
row.put("totalConfirmedHours", en.getValue()[0]);
|
||||
row.put("totalAllocatedLaborCost", BigDecimal.valueOf(en.getValue()[1])
|
||||
.setScale(2, RoundingMode.HALF_UP));
|
||||
result.add(row);
|
||||
}
|
||||
return ApiResp.ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 员工工时校验:检查当期工时汇总是否超过法定标准。
|
||||
*/
|
||||
@GetMapping("/employee-hours-check")
|
||||
public ApiResp<List<Map<String, Object>>> employeeHoursCheck(
|
||||
@RequestParam(required = false) String fillDate) {
|
||||
List<FinRdTimesheet> all = sheetRepo.findAll();
|
||||
if (fillDate != null)
|
||||
all = all.stream().filter(s -> fillDate.equals(s.getFillDate())).toList();
|
||||
|
||||
Map<String, double[]> byEmp = new LinkedHashMap<>();
|
||||
for (FinRdTimesheet s : all) {
|
||||
String eid = s.getEmployeeId() != null ? s.getEmployeeId() : "未知";
|
||||
byEmp.computeIfAbsent(eid, k -> new double[]{0.0});
|
||||
byEmp.get(eid)[0] += (s.getReportedHours() != null ? s.getReportedHours() : 0.0);
|
||||
}
|
||||
|
||||
List<Map<String, Object>> result = new ArrayList<>();
|
||||
for (Map.Entry<String, double[]> en : byEmp.entrySet()) {
|
||||
double totalHours = en.getValue()[0];
|
||||
boolean exceeds = totalHours > LEGAL_MONTHLY_HOURS;
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("employeeId", en.getKey());
|
||||
row.put("totalReportedHours", totalHours);
|
||||
row.put("legalLimit", LEGAL_MONTHLY_HOURS);
|
||||
row.put("exceedsLimit", exceeds);
|
||||
row.put("overHours", exceeds ? totalHours - LEGAL_MONTHLY_HOURS : 0.0);
|
||||
result.add(row);
|
||||
}
|
||||
return ApiResp.ok(result);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user