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( @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 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 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 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 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 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 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 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 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>> projectHoursSummary( @RequestParam(required = false) String projectCode) { List approved = sheetRepo.findByStatus(FinRdTimesheet.S_APPROVED); if (projectCode != null) approved = approved.stream().filter(s -> projectCode.equals(s.getProjectCode())).toList(); Map 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> result = new ArrayList<>(); for (Map.Entry en : byProject.entrySet()) { Map 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>> employeeHoursCheck( @RequestParam(required = false) String fillDate) { List all = sheetRepo.findAll(); if (fillDate != null) all = all.stream().filter(s -> fillDate.equals(s.getFillDate())).toList(); Map 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> result = new ArrayList<>(); for (Map.Entry en : byEmp.entrySet()) { double totalHours = en.getValue()[0]; boolean exceeds = totalHours > LEGAL_MONTHLY_HOURS; Map 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); } }