Files
ERP/oa-backend/src/main/java/com/kaidi/oa/web/FinRdTimesheetController.java
T
QiufengandClaude Opus 4.8 5e51dc3f56 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>
2026-06-15 19:19:15 +08:00

295 lines
14 KiB
Java

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);
}
}