This commit is contained in:
haomingming
2026-05-29 10:38:34 +08:00
parent edffa25ccd
commit db10401b13
111 changed files with 38710 additions and 1643 deletions
@@ -33,13 +33,23 @@ public class AuditController {
@RequestParam(value = "meetingId", required = false) Long meetingId, @RequestParam(value = "meetingId", required = false) Long meetingId,
@RequestParam(value = "pageNo", required = false) Integer pageNo, @RequestParam(value = "pageNo", required = false) Integer pageNo,
@RequestParam(value = "pageSize", required = false) Integer pageSize, @RequestParam(value = "pageSize", required = false) Integer pageSize,
@RequestParam(value = "reviewFocus", required = false) String reviewFocus,
@RequestParam(value = "sortBy", required = false) String sortBy, @RequestParam(value = "sortBy", required = false) String sortBy,
@RequestParam(value = "order", required = false) String order @RequestParam(value = "order", required = false) String order
) { ) {
if (meetingId == null && pageNo == null && pageSize == null && sortBy == null && order == null) { if (meetingId == null && pageNo == null && pageSize == null && reviewFocus == null && sortBy == null && order == null) {
return ApiResponse.success(auditService.listTasks(mine, scope)); return ApiResponse.success(auditService.listTasks(mine, scope));
} }
return ApiResponse.success(auditService.listTasks(mine, scope, meetingId, pageNo, pageSize, sortBy, order)); return ApiResponse.success(auditService.listTasks(mine, scope, meetingId, pageNo, pageSize, reviewFocus, sortBy, order));
}
@GetMapping("/tasks/review-stat")
public ApiResponse<Map<String, Object>> reviewStat(
@RequestParam(value = "mine", required = false, defaultValue = "false") boolean mine,
@RequestParam(value = "scope", required = false) String scope,
@RequestParam(value = "reviewFocus", required = false) String reviewFocus
) {
return ApiResponse.success(auditService.reviewStat(mine, scope, reviewFocus));
} }
@PostMapping("/tasks/{id}/approve") @PostMapping("/tasks/{id}/approve")
@@ -76,6 +86,12 @@ public class AuditController {
return ApiResponse.success(auditService.readTaskMaterial(id, moduleCode)); return ApiResponse.success(auditService.readTaskMaterial(id, moduleCode));
} }
@GetMapping("/tasks/{id}")
@RequirePermission(value = "audit.material.read", dataScope = DataScopeType.MEETING, auditAction = "AUDIT_TASK_DETAIL_READ")
public ApiResponse<Map<String, Object>> taskDetail(@PathVariable("id") Long id) {
return ApiResponse.success(auditService.readTaskDetail(id));
}
@PostMapping("/tasks/{id}/material/approve-module") @PostMapping("/tasks/{id}/material/approve-module")
@RequirePermission(value = "audit.approve", dataScope = DataScopeType.MEETING_MODULE, auditAction = "AUDIT_MATERIAL_APPROVE_MODULE") @RequirePermission(value = "audit.approve", dataScope = DataScopeType.MEETING_MODULE, auditAction = "AUDIT_MATERIAL_APPROVE_MODULE")
public ApiResponse<Map<String, Object>> approveMaterialModule(@PathVariable("id") Long id, public ApiResponse<Map<String, Object>> approveMaterialModule(@PathVariable("id") Long id,
@@ -90,6 +106,13 @@ public class AuditController {
return ApiResponse.success(auditService.rejectMaterialItem(id, request)); return ApiResponse.success(auditService.rejectMaterialItem(id, request));
} }
@PostMapping("/tasks/{id}/issues/{issueId}/resolve")
@RequirePermission(value = "audit.approve", dataScope = DataScopeType.MEETING_MODULE, auditAction = "AUDIT_ISSUE_RESOLVE")
public ApiResponse<Map<String, Object>> confirmResolvedIssue(@PathVariable("id") Long id,
@PathVariable("issueId") Long issueId) {
return ApiResponse.success(auditService.confirmResolvedIssue(id, issueId));
}
@PostMapping("/tasks/{id}/transfer") @PostMapping("/tasks/{id}/transfer")
@RequirePermission(value = "audit.transfer", dataScope = DataScopeType.MEETING, auditAction = "AUDIT_TRANSFER") @RequirePermission(value = "audit.transfer", dataScope = DataScopeType.MEETING, auditAction = "AUDIT_TRANSFER")
public ApiResponse<Map<String, Object>> transfer(@PathVariable("id") Long id, public ApiResponse<Map<String, Object>> transfer(@PathVariable("id") Long id,
@@ -1,12 +1,13 @@
package com.writeoff.module.audit.dto; package com.writeoff.module.audit.dto;
import javax.validation.constraints.NotBlank; import javax.validation.constraints.NotBlank;
import java.util.List;
public class AuditActionRequest { public class AuditActionRequest {
@NotBlank(message = "幂等键不能为空") @NotBlank(message = "幂等键不能为空")
private String idempotencyKey; private String idempotencyKey;
@NotBlank(message = "审核意见不能为空")
private String opinion; private String opinion;
private List<AuditIssueRequest> issues;
public String getIdempotencyKey() { public String getIdempotencyKey() {
return idempotencyKey; return idempotencyKey;
@@ -23,4 +24,12 @@ public class AuditActionRequest {
public void setOpinion(String opinion) { public void setOpinion(String opinion) {
this.opinion = opinion; this.opinion = opinion;
} }
public List<AuditIssueRequest> getIssues() {
return issues;
}
public void setIssues(List<AuditIssueRequest> issues) {
this.issues = issues;
}
} }
@@ -0,0 +1,46 @@
package com.writeoff.module.audit.dto;
import javax.validation.constraints.NotBlank;
public class AuditIssueRequest {
@NotBlank(message = "模块编码不能为空")
private String moduleCode;
@NotBlank(message = "问题定位不能为空")
private String targetPath;
@NotBlank(message = "问题标签不能为空")
private String targetLabel;
@NotBlank(message = "问题原因不能为空")
private String reason;
public String getModuleCode() {
return moduleCode;
}
public void setModuleCode(String moduleCode) {
this.moduleCode = moduleCode;
}
public String getTargetPath() {
return targetPath;
}
public void setTargetPath(String targetPath) {
this.targetPath = targetPath;
}
public String getTargetLabel() {
return targetLabel;
}
public void setTargetLabel(String targetLabel) {
this.targetLabel = targetLabel;
}
public String getReason() {
return reason;
}
public void setReason(String reason) {
this.reason = reason;
}
}
@@ -0,0 +1,125 @@
package com.writeoff.module.audit.model;
public class AuditIssue {
private Long id;
private Long taskId;
private Long meetingId;
private Long submissionVersionId;
private String reviewNode;
private String moduleCode;
private String targetPath;
private String targetLabel;
private String reason;
private String status;
private String responseText;
private Long createdBy;
private String createdAt;
private Long respondedBy;
private String respondedAt;
private Long resolvedBy;
private String resolvedAt;
public AuditIssue(Long id,
Long taskId,
Long meetingId,
Long submissionVersionId,
String reviewNode,
String moduleCode,
String targetPath,
String targetLabel,
String reason,
String status,
String responseText,
Long createdBy,
String createdAt,
Long respondedBy,
String respondedAt,
Long resolvedBy,
String resolvedAt) {
this.id = id;
this.taskId = taskId;
this.meetingId = meetingId;
this.submissionVersionId = submissionVersionId;
this.reviewNode = reviewNode;
this.moduleCode = moduleCode;
this.targetPath = targetPath;
this.targetLabel = targetLabel;
this.reason = reason;
this.status = status;
this.responseText = responseText;
this.createdBy = createdBy;
this.createdAt = createdAt;
this.respondedBy = respondedBy;
this.respondedAt = respondedAt;
this.resolvedBy = resolvedBy;
this.resolvedAt = resolvedAt;
}
public Long getId() {
return id;
}
public Long getTaskId() {
return taskId;
}
public Long getMeetingId() {
return meetingId;
}
public Long getSubmissionVersionId() {
return submissionVersionId;
}
public String getReviewNode() {
return reviewNode;
}
public String getModuleCode() {
return moduleCode;
}
public String getTargetPath() {
return targetPath;
}
public String getTargetLabel() {
return targetLabel;
}
public String getReason() {
return reason;
}
public String getStatus() {
return status;
}
public String getResponseText() {
return responseText;
}
public Long getCreatedBy() {
return createdBy;
}
public String getCreatedAt() {
return createdAt;
}
public Long getRespondedBy() {
return respondedBy;
}
public String getRespondedAt() {
return respondedAt;
}
public Long getResolvedBy() {
return resolvedBy;
}
public String getResolvedAt() {
return resolvedAt;
}
}
@@ -5,6 +5,7 @@ import java.util.List;
public class AuditTask { public class AuditTask {
private Long id; private Long id;
private Long meetingId; private Long meetingId;
private Long submissionVersionId;
private AuditNode node; private AuditNode node;
private Long assigneeUserId; private Long assigneeUserId;
private String assigneeUserName; private String assigneeUserName;
@@ -20,7 +21,14 @@ public class AuditTask {
private Integer rejectCount; private Integer rejectCount;
private String lastRejectReason; private String lastRejectReason;
private String lastActionAt; private String lastActionAt;
private String submittedAt;
private List<AuditFlowNodeInfo> flowNodes; private List<AuditFlowNodeInfo> flowNodes;
private Boolean resubmitted;
private Integer materialChangedCount;
private Integer unresolvedIssueCount;
private Integer resolvedIssueCount;
private Integer extraChangeCount;
private Integer riskScore;
public AuditTask(Long id, Long meetingId, AuditNode node, Long assigneeUserId, AuditTaskStatus status, String opinion) { public AuditTask(Long id, Long meetingId, AuditNode node, Long assigneeUserId, AuditTaskStatus status, String opinion) {
this(id, meetingId, node, assigneeUserId, status, opinion, null, 0, 0, false, null, null, null, 0, null, null); this(id, meetingId, node, assigneeUserId, status, opinion, null, 0, 0, false, null, null, null, 0, null, null);
@@ -72,6 +80,10 @@ public class AuditTask {
return meetingId; return meetingId;
} }
public Long getSubmissionVersionId() {
return submissionVersionId;
}
public AuditNode getNode() { public AuditNode getNode() {
return node; return node;
} }
@@ -132,14 +144,46 @@ public class AuditTask {
return lastActionAt; return lastActionAt;
} }
public String getSubmittedAt() {
return submittedAt;
}
public List<AuditFlowNodeInfo> getFlowNodes() { public List<AuditFlowNodeInfo> getFlowNodes() {
return flowNodes; return flowNodes;
} }
public Boolean getResubmitted() {
return resubmitted;
}
public Integer getMaterialChangedCount() {
return materialChangedCount;
}
public Integer getUnresolvedIssueCount() {
return unresolvedIssueCount;
}
public Integer getResolvedIssueCount() {
return resolvedIssueCount;
}
public Integer getExtraChangeCount() {
return extraChangeCount;
}
public Integer getRiskScore() {
return riskScore;
}
public void setStatus(AuditTaskStatus status) { public void setStatus(AuditTaskStatus status) {
this.status = status; this.status = status;
} }
public void setSubmissionVersionId(Long submissionVersionId) {
this.submissionVersionId = submissionVersionId;
}
public void setOpinion(String opinion) { public void setOpinion(String opinion) {
this.opinion = opinion; this.opinion = opinion;
} }
@@ -192,7 +236,35 @@ public class AuditTask {
this.lastActionAt = lastActionAt; this.lastActionAt = lastActionAt;
} }
public void setSubmittedAt(String submittedAt) {
this.submittedAt = submittedAt;
}
public void setFlowNodes(List<AuditFlowNodeInfo> flowNodes) { public void setFlowNodes(List<AuditFlowNodeInfo> flowNodes) {
this.flowNodes = flowNodes; this.flowNodes = flowNodes;
} }
public void setResubmitted(Boolean resubmitted) {
this.resubmitted = resubmitted;
}
public void setMaterialChangedCount(Integer materialChangedCount) {
this.materialChangedCount = materialChangedCount;
}
public void setUnresolvedIssueCount(Integer unresolvedIssueCount) {
this.unresolvedIssueCount = unresolvedIssueCount;
}
public void setResolvedIssueCount(Integer resolvedIssueCount) {
this.resolvedIssueCount = resolvedIssueCount;
}
public void setExtraChangeCount(Integer extraChangeCount) {
this.extraChangeCount = extraChangeCount;
}
public void setRiskScore(Integer riskScore) {
this.riskScore = riskScore;
}
} }
@@ -13,6 +13,8 @@ public interface AuditTaskRepository {
List<AuditTask> findAll(); List<AuditTask> findAll();
List<AuditTask> findMineTasks(Long assigneeUserId, boolean pendingOnly);
Optional<AuditTask> findLatestByMeetingId(Long meetingId); Optional<AuditTask> findLatestByMeetingId(Long meetingId);
int withdrawPendingByMeetingId(Long meetingId, String reason, Long operatorUserId); int withdrawPendingByMeetingId(Long meetingId, String reason, Long operatorUserId);
@@ -30,6 +30,7 @@ public class InMemoryAuditTaskRepository implements AuditTaskRepository {
task.getStatus(), task.getStatus(),
task.getOpinion() task.getOpinion()
); );
newTask.setSubmissionVersionId(task.getSubmissionVersionId());
store.put(newTask.getId(), newTask); store.put(newTask.getId(), newTask);
return newTask; return newTask;
} }
@@ -47,6 +48,20 @@ public class InMemoryAuditTaskRepository implements AuditTaskRepository {
return new ArrayList<>(store.values()); return new ArrayList<>(store.values());
} }
@Override
public List<AuditTask> findMineTasks(Long assigneeUserId, boolean pendingOnly) {
if (assigneeUserId == null) {
return new ArrayList<>();
}
return store.values().stream()
.filter(task -> assigneeUserId.equals(task.getAssigneeUserId()))
.filter(task -> pendingOnly
? "PENDING".equals(task.getStatus().name())
: !"PENDING".equals(task.getStatus().name()))
.sorted(Comparator.comparingLong(AuditTask::getId).reversed())
.collect(java.util.stream.Collectors.toList());
}
@Override @Override
public Optional<AuditTask> findLatestByMeetingId(Long meetingId) { public Optional<AuditTask> findLatestByMeetingId(Long meetingId) {
return store.values().stream() return store.values().stream()
@@ -22,7 +22,8 @@ import java.util.Optional;
@ConditionalOnProperty(prefix = "app.repository", name = "mode", havingValue = "jdbc", matchIfMissing = true) @ConditionalOnProperty(prefix = "app.repository", name = "mode", havingValue = "jdbc", matchIfMissing = true)
public class JdbcAuditTaskRepository implements AuditTaskRepository { public class JdbcAuditTaskRepository implements AuditTaskRepository {
private final JdbcTemplate jdbcTemplate; private final JdbcTemplate jdbcTemplate;
private static final RowMapper<AuditTask> ROW_MAPPER = (rs, n) -> new AuditTask( private static final RowMapper<AuditTask> ROW_MAPPER = (rs, n) -> {
AuditTask task = new AuditTask(
rs.getLong("id"), rs.getLong("id"),
rs.getLong("meeting_id"), rs.getLong("meeting_id"),
AuditNode.valueOf(rs.getString("audit_node")), AuditNode.valueOf(rs.getString("audit_node")),
@@ -40,6 +41,10 @@ public class JdbcAuditTaskRepository implements AuditTaskRepository {
rs.getString("last_reject_reason"), rs.getString("last_reject_reason"),
rs.getString("last_action_at") rs.getString("last_action_at")
); );
task.setSubmissionVersionId(rs.getObject("submission_version_id") == null ? null : rs.getLong("submission_version_id"));
task.setSubmittedAt(rs.getString("submitted_at"));
return task;
};
public JdbcAuditTaskRepository(JdbcTemplate jdbcTemplate) { public JdbcAuditTaskRepository(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate; this.jdbcTemplate = jdbcTemplate;
@@ -51,26 +56,30 @@ public class JdbcAuditTaskRepository implements AuditTaskRepository {
KeyHolder keyHolder = new GeneratedKeyHolder(); KeyHolder keyHolder = new GeneratedKeyHolder();
jdbcTemplate.update(connection -> { jdbcTemplate.update(connection -> {
PreparedStatement ps = connection.prepareStatement( PreparedStatement ps = connection.prepareStatement(
"INSERT INTO audit_task (tenant_id, meeting_id, audit_node, assignee_user_id, status, opinion, sla_deadline_at, timeout_level, overtime_hours, is_overtime, " + "INSERT INTO audit_task (tenant_id, meeting_id, submission_version_id, audit_node, assignee_user_id, status, opinion, sla_deadline_at, timeout_level, overtime_hours, is_overtime, " +
"transfer_from_user_id, transfer_reason, return_reason, reject_count, last_reject_reason, last_action_at, created_by, updated_by) " + "transfer_from_user_id, transfer_reason, return_reason, reject_count, last_reject_reason, last_action_at, created_by, updated_by) " +
"VALUES (?, ?, ?, ?, ?, ?, DATE_ADD(NOW(), INTERVAL 24 HOUR), 0, 0, 0, NULL, NULL, NULL, 0, NULL, NOW(), 0, 0)", "VALUES (?, ?, ?, ?, ?, ?, ?, DATE_ADD(NOW(), INTERVAL 24 HOUR), 0, 0, 0, NULL, NULL, NULL, 0, NULL, NOW(), 0, 0)",
Statement.RETURN_GENERATED_KEYS Statement.RETURN_GENERATED_KEYS
); );
ps.setLong(1, tenantId()); ps.setLong(1, tenantId());
ps.setLong(2, task.getMeetingId()); ps.setLong(2, task.getMeetingId());
ps.setString(3, task.getNode().name()); ps.setObject(3, task.getSubmissionVersionId());
ps.setObject(4, task.getAssigneeUserId()); ps.setString(4, task.getNode().name());
ps.setString(5, task.getStatus().name()); ps.setObject(5, task.getAssigneeUserId());
ps.setString(6, task.getOpinion()); ps.setString(6, task.getStatus().name());
ps.setString(7, task.getOpinion());
return ps; return ps;
}, keyHolder); }, keyHolder);
Number key = keyHolder.getKey(); Number key = keyHolder.getKey();
Long id = key == null ? null : key.longValue(); Long id = key == null ? null : key.longValue();
return new AuditTask(id, task.getMeetingId(), task.getNode(), task.getAssigneeUserId(), task.getStatus(), task.getOpinion()); AuditTask saved = new AuditTask(id, task.getMeetingId(), task.getNode(), task.getAssigneeUserId(), task.getStatus(), task.getOpinion());
saved.setSubmissionVersionId(task.getSubmissionVersionId());
return saved;
} }
jdbcTemplate.update( jdbcTemplate.update(
"UPDATE audit_task SET status=?, opinion=?, assignee_user_id=?, transfer_from_user_id=?, transfer_reason=?, return_reason=?, reject_count=?, " + "UPDATE audit_task SET submission_version_id=?, status=?, opinion=?, assignee_user_id=?, transfer_from_user_id=?, transfer_reason=?, return_reason=?, reject_count=?, " +
"last_reject_reason=?, last_action_at=NOW(), updated_by=0 WHERE tenant_id=? AND id=?", "last_reject_reason=?, last_action_at=NOW(), updated_by=0 WHERE tenant_id=? AND id=?",
task.getSubmissionVersionId(),
task.getStatus().name(), task.getStatus().name(),
task.getOpinion(), task.getOpinion(),
task.getAssigneeUserId(), task.getAssigneeUserId(),
@@ -88,11 +97,13 @@ public class JdbcAuditTaskRepository implements AuditTaskRepository {
@Override @Override
public Optional<AuditTask> findById(Long id) { public Optional<AuditTask> findById(Long id) {
List<AuditTask> list = jdbcTemplate.query( List<AuditTask> list = jdbcTemplate.query(
"SELECT id, meeting_id, audit_node, assignee_user_id, status, opinion, " + "SELECT id, meeting_id, submission_version_id, audit_node, assignee_user_id, status, opinion, " +
"DATE_FORMAT(sla_deadline_at, '%Y-%m-%d %H:%i:%s') AS sla_deadline_at, IFNULL(timeout_level, 0) AS timeout_level, " + "DATE_FORMAT(sla_deadline_at, '%Y-%m-%d %H:%i:%s') AS sla_deadline_at, IFNULL(timeout_level, 0) AS timeout_level, " +
"IFNULL(overtime_hours, 0) AS overtime_hours, IFNULL(is_overtime, 0) AS is_overtime, " + "IFNULL(overtime_hours, 0) AS overtime_hours, IFNULL(is_overtime, 0) AS is_overtime, " +
"transfer_from_user_id, transfer_reason, return_reason, IFNULL(reject_count, 0) AS reject_count, last_reject_reason, " + "transfer_from_user_id, transfer_reason, return_reason, IFNULL(reject_count, 0) AS reject_count, last_reject_reason, " +
"DATE_FORMAT(last_action_at, '%Y-%m-%d %H:%i:%s') AS last_action_at " + "DATE_FORMAT(last_action_at, '%Y-%m-%d %H:%i:%s') AS last_action_at, " +
"COALESCE((SELECT DATE_FORMAT(msv.created_at, '%Y-%m-%d %H:%i:%s') FROM meeting_submission_version msv " +
"WHERE msv.tenant_id=audit_task.tenant_id AND msv.id=audit_task.submission_version_id), DATE_FORMAT(audit_task.created_at, '%Y-%m-%d %H:%i:%s')) AS submitted_at " +
"FROM audit_task WHERE tenant_id=? AND id=? AND is_deleted=0", "FROM audit_task WHERE tenant_id=? AND id=? AND is_deleted=0",
ROW_MAPPER, tenantId(), id ROW_MAPPER, tenantId(), id
); );
@@ -103,24 +114,45 @@ public class JdbcAuditTaskRepository implements AuditTaskRepository {
public List<AuditTask> findAll() { public List<AuditTask> findAll() {
refreshTimeoutLevels(); refreshTimeoutLevels();
return jdbcTemplate.query( return jdbcTemplate.query(
"SELECT id, meeting_id, audit_node, assignee_user_id, status, opinion, " + "SELECT id, meeting_id, submission_version_id, audit_node, assignee_user_id, status, opinion, " +
"DATE_FORMAT(sla_deadline_at, '%Y-%m-%d %H:%i:%s') AS sla_deadline_at, IFNULL(timeout_level, 0) AS timeout_level, " + "DATE_FORMAT(sla_deadline_at, '%Y-%m-%d %H:%i:%s') AS sla_deadline_at, IFNULL(timeout_level, 0) AS timeout_level, " +
"IFNULL(overtime_hours, 0) AS overtime_hours, IFNULL(is_overtime, 0) AS is_overtime, " + "IFNULL(overtime_hours, 0) AS overtime_hours, IFNULL(is_overtime, 0) AS is_overtime, " +
"transfer_from_user_id, transfer_reason, return_reason, IFNULL(reject_count, 0) AS reject_count, last_reject_reason, " + "transfer_from_user_id, transfer_reason, return_reason, IFNULL(reject_count, 0) AS reject_count, last_reject_reason, " +
"DATE_FORMAT(last_action_at, '%Y-%m-%d %H:%i:%s') AS last_action_at " + "DATE_FORMAT(last_action_at, '%Y-%m-%d %H:%i:%s') AS last_action_at, " +
"COALESCE((SELECT DATE_FORMAT(msv.created_at, '%Y-%m-%d %H:%i:%s') FROM meeting_submission_version msv " +
"WHERE msv.tenant_id=audit_task.tenant_id AND msv.id=audit_task.submission_version_id), DATE_FORMAT(audit_task.created_at, '%Y-%m-%d %H:%i:%s')) AS submitted_at " +
"FROM audit_task WHERE tenant_id=? AND is_deleted=0 ORDER BY id DESC", "FROM audit_task WHERE tenant_id=? AND is_deleted=0 ORDER BY id DESC",
ROW_MAPPER, tenantId() ROW_MAPPER, tenantId()
); );
} }
@Override @Override
public Optional<AuditTask> findLatestByMeetingId(Long meetingId) { public List<AuditTask> findMineTasks(Long assigneeUserId, boolean pendingOnly) {
List<AuditTask> list = jdbcTemplate.query( refreshTimeoutLevels();
"SELECT id, meeting_id, audit_node, assignee_user_id, status, opinion, " + String statusSql = pendingOnly ? "status='PENDING'" : "status<>'PENDING'";
return jdbcTemplate.query(
"SELECT id, meeting_id, submission_version_id, audit_node, assignee_user_id, status, opinion, " +
"DATE_FORMAT(sla_deadline_at, '%Y-%m-%d %H:%i:%s') AS sla_deadline_at, IFNULL(timeout_level, 0) AS timeout_level, " + "DATE_FORMAT(sla_deadline_at, '%Y-%m-%d %H:%i:%s') AS sla_deadline_at, IFNULL(timeout_level, 0) AS timeout_level, " +
"IFNULL(overtime_hours, 0) AS overtime_hours, IFNULL(is_overtime, 0) AS is_overtime, " + "IFNULL(overtime_hours, 0) AS overtime_hours, IFNULL(is_overtime, 0) AS is_overtime, " +
"transfer_from_user_id, transfer_reason, return_reason, IFNULL(reject_count, 0) AS reject_count, last_reject_reason, " + "transfer_from_user_id, transfer_reason, return_reason, IFNULL(reject_count, 0) AS reject_count, last_reject_reason, " +
"DATE_FORMAT(last_action_at, '%Y-%m-%d %H:%i:%s') AS last_action_at " + "DATE_FORMAT(last_action_at, '%Y-%m-%d %H:%i:%s') AS last_action_at, " +
"COALESCE((SELECT DATE_FORMAT(msv.created_at, '%Y-%m-%d %H:%i:%s') FROM meeting_submission_version msv " +
"WHERE msv.tenant_id=audit_task.tenant_id AND msv.id=audit_task.submission_version_id), DATE_FORMAT(audit_task.created_at, '%Y-%m-%d %H:%i:%s')) AS submitted_at " +
"FROM audit_task WHERE tenant_id=? AND assignee_user_id=? AND " + statusSql + " AND is_deleted=0 ORDER BY id DESC",
ROW_MAPPER, tenantId(), assigneeUserId
);
}
@Override
public Optional<AuditTask> findLatestByMeetingId(Long meetingId) {
List<AuditTask> list = jdbcTemplate.query(
"SELECT id, meeting_id, submission_version_id, audit_node, assignee_user_id, status, opinion, " +
"DATE_FORMAT(sla_deadline_at, '%Y-%m-%d %H:%i:%s') AS sla_deadline_at, IFNULL(timeout_level, 0) AS timeout_level, " +
"IFNULL(overtime_hours, 0) AS overtime_hours, IFNULL(is_overtime, 0) AS is_overtime, " +
"transfer_from_user_id, transfer_reason, return_reason, IFNULL(reject_count, 0) AS reject_count, last_reject_reason, " +
"DATE_FORMAT(last_action_at, '%Y-%m-%d %H:%i:%s') AS last_action_at, " +
"COALESCE((SELECT DATE_FORMAT(msv.created_at, '%Y-%m-%d %H:%i:%s') FROM meeting_submission_version msv " +
"WHERE msv.tenant_id=audit_task.tenant_id AND msv.id=audit_task.submission_version_id), DATE_FORMAT(audit_task.created_at, '%Y-%m-%d %H:%i:%s')) AS submitted_at " +
"FROM audit_task WHERE tenant_id=? AND meeting_id=? AND is_deleted=0 ORDER BY id DESC LIMIT 1", "FROM audit_task WHERE tenant_id=? AND meeting_id=? AND is_deleted=0 ORDER BY id DESC LIMIT 1",
ROW_MAPPER, tenantId(), meetingId ROW_MAPPER, tenantId(), meetingId
); );
@@ -0,0 +1,248 @@
package com.writeoff.module.audit.service;
import com.writeoff.common.exception.BusinessException;
import com.writeoff.module.audit.dto.AuditIssueRequest;
import com.writeoff.module.audit.model.AuditIssue;
import com.writeoff.module.audit.model.AuditTask;
import com.writeoff.security.AuthContext;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
@Service
public class AuditIssueService {
private static final RowMapper<AuditIssue> ROW_MAPPER = (rs, n) -> new AuditIssue(
rs.getLong("id"),
rs.getLong("task_id"),
rs.getLong("meeting_id"),
rs.getObject("submission_version_id") == null ? null : rs.getLong("submission_version_id"),
rs.getString("review_node"),
rs.getString("module_code"),
rs.getString("target_path"),
rs.getString("target_label"),
rs.getString("reason"),
rs.getString("status"),
rs.getString("response_text"),
rs.getObject("created_by") == null ? null : rs.getLong("created_by"),
rs.getString("created_at"),
rs.getObject("responded_by") == null ? null : rs.getLong("responded_by"),
rs.getString("responded_at"),
rs.getObject("resolved_by") == null ? null : rs.getLong("resolved_by"),
rs.getString("resolved_at")
);
private final JdbcTemplate jdbcTemplate;
public AuditIssueService(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
public List<AuditIssue> listByTaskId(Long taskId) {
if (taskId == null || taskId <= 0L) {
return new ArrayList<>();
}
return jdbcTemplate.query(
"SELECT ai.id, ai.task_id, ai.meeting_id, ai.submission_version_id, ai.review_node, ai.module_code, ai.target_path, ai.target_label, ai.reason, ai.status, ai.response_text, " +
"ai.created_by, " +
"(SELECT ir.responded_by FROM issue_response ir WHERE ir.tenant_id=ai.tenant_id AND ir.issue_id=ai.id ORDER BY ir.id DESC LIMIT 1) AS responded_by, " +
"CASE WHEN ai.status='RESOLVED' THEN ai.updated_by ELSE NULL END AS resolved_by, " +
"DATE_FORMAT(ai.created_at, '%Y-%m-%d %H:%i:%s') AS created_at, " +
"DATE_FORMAT(ai.responded_at, '%Y-%m-%d %H:%i:%s') AS responded_at, " +
"CASE WHEN ai.status='RESOLVED' THEN DATE_FORMAT(ai.updated_at, '%Y-%m-%d %H:%i:%s') ELSE NULL END AS resolved_at " +
"FROM audit_issue ai WHERE ai.tenant_id=? AND ai.task_id=? ORDER BY ai.id ASC",
ROW_MAPPER,
tenantId(),
taskId
);
}
public List<Map<String, Object>> listRowsByTaskId(Long taskId) {
List<AuditIssue> issues = listByTaskId(taskId);
Map<Long, String> userNameMap = mapUserNames(collectUserIds(issues));
List<Map<String, Object>> rows = new ArrayList<>();
for (AuditIssue issue : issues) {
Map<String, Object> row = new LinkedHashMap<>();
row.put("id", issue.getId());
row.put("taskId", issue.getTaskId());
row.put("meetingId", issue.getMeetingId());
row.put("submissionVersionId", issue.getSubmissionVersionId());
row.put("reviewNode", issue.getReviewNode());
row.put("moduleCode", issue.getModuleCode());
row.put("targetPath", issue.getTargetPath());
row.put("targetLabel", issue.getTargetLabel());
row.put("reason", issue.getReason());
row.put("status", issue.getStatus());
row.put("responseText", issue.getResponseText());
row.put("createdBy", issue.getCreatedBy());
row.put("createdByName", userNameMap.get(issue.getCreatedBy()));
row.put("createdAt", issue.getCreatedAt());
row.put("respondedBy", issue.getRespondedBy());
row.put("respondedByName", userNameMap.get(issue.getRespondedBy()));
row.put("respondedAt", issue.getRespondedAt());
row.put("resolvedBy", issue.getResolvedBy());
row.put("resolvedByName", userNameMap.get(issue.getResolvedBy()));
row.put("resolvedAt", issue.getResolvedAt());
rows.add(row);
}
return rows;
}
@Transactional
public int replaceTaskIssues(AuditTask task, List<AuditIssueRequest> issues) {
if (task == null || task.getId() == null) {
throw new BusinessException(10001, "审核任务不存在");
}
jdbcTemplate.update("DELETE FROM audit_issue WHERE tenant_id=? AND task_id=? AND status='OPEN'", tenantId(), task.getId());
if (issues == null || issues.isEmpty()) {
return 0;
}
int count = 0;
for (AuditIssueRequest issue : issues) {
if (issue == null) {
continue;
}
String moduleCode = str(issue.getModuleCode());
String targetPath = str(issue.getTargetPath());
String targetLabel = str(issue.getTargetLabel());
String reason = str(issue.getReason());
if (moduleCode.isEmpty() || targetPath.isEmpty() || targetLabel.isEmpty() || reason.isEmpty()) {
throw new BusinessException(10001, "结构化驳回问题缺少必填字段");
}
count += jdbcTemplate.update(
"INSERT INTO audit_issue (tenant_id, task_id, meeting_id, submission_version_id, review_node, module_code, target_path, target_label, reason, status, created_by, updated_by) " +
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'OPEN', ?, ?)",
tenantId(),
task.getId(),
task.getMeetingId(),
task.getSubmissionVersionId(),
task.getNode() == null ? null : task.getNode().name(),
moduleCode,
targetPath,
targetLabel,
reason,
safeUserId(),
safeUserId()
);
}
return count;
}
@Transactional
public Map<String, Object> confirmResolved(Long taskId, Long issueId) {
if (taskId == null || taskId <= 0L || issueId == null || issueId <= 0L) {
throw new BusinessException(10001, "问题确认参数不能为空");
}
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT ai.id, ai.status, ai.target_label, ai.target_path, ai.task_id " +
"FROM audit_issue ai " +
"WHERE ai.tenant_id=? AND ai.id=? AND ai.status IN ('OPEN', 'PENDING_CONFIRM', 'RESOLVED') AND (" +
"ai.task_id=? OR EXISTS (" +
"SELECT 1 FROM audit_task at WHERE at.tenant_id=ai.tenant_id AND at.id=? AND at.meeting_id=ai.meeting_id AND at.submission_version_id=ai.submission_version_id" +
")" +
") LIMIT 1",
tenantId(),
issueId,
taskId,
taskId
);
if (rows.isEmpty()) {
throw new BusinessException(10003, "审核问题不存在");
}
Map<String, Object> current = rows.get(0);
String status = str(String.valueOf(current.get("status")));
if ("RESOLVED".equalsIgnoreCase(status)) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("issueId", issueId);
result.put("status", "RESOLVED");
result.put("statusText", "已确认解决");
return result;
}
Number ownerTaskIdNum = (Number) current.get("task_id");
Long ownerTaskId = ownerTaskIdNum == null ? null : ownerTaskIdNum.longValue();
jdbcTemplate.update(
"UPDATE audit_issue SET status='RESOLVED', updated_by=?, updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND id=?",
safeUserId(),
tenantId(),
issueId
);
Map<String, Object> result = new LinkedHashMap<>();
result.put("issueId", issueId);
result.put("taskId", ownerTaskId);
result.put("status", "RESOLVED");
result.put("statusText", "已确认解决");
result.put("targetLabel", str(String.valueOf(firstNonNull(current.get("target_label"), current.get("target_path")))));
return result;
}
private Object firstNonNull(Object first, Object second) {
return first != null ? first : second;
}
private Set<Long> collectUserIds(List<AuditIssue> issues) {
Set<Long> userIds = new HashSet<>();
if (issues == null) {
return userIds;
}
for (AuditIssue issue : issues) {
addUserId(userIds, issue.getCreatedBy());
addUserId(userIds, issue.getRespondedBy());
addUserId(userIds, issue.getResolvedBy());
}
return userIds;
}
private void addUserId(Set<Long> userIds, Long userId) {
if (userId != null && userId > 0L) {
userIds.add(userId);
}
}
private Map<Long, String> mapUserNames(Set<Long> userIds) {
Map<Long, String> result = new HashMap<>();
if (userIds == null || userIds.isEmpty()) {
return result;
}
String placeholders = userIds.stream().map(id -> "?").collect(Collectors.joining(","));
List<Object> args = new ArrayList<>();
args.add(tenantId());
args.addAll(userIds);
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT id, user_name FROM sys_user WHERE tenant_id=? AND is_deleted=0 AND id IN (" + placeholders + ")",
args.toArray()
);
for (Map<String, Object> row : rows) {
Number idNum = (Number) row.get("id");
if (idNum == null) {
continue;
}
String userName = str(String.valueOf(row.get("user_name")));
if (!userName.isEmpty()) {
result.put(idNum.longValue(), userName);
}
}
return result;
}
private String str(String value) {
return value == null ? "" : value.trim();
}
private Long tenantId() {
return AuthContext.requireTenantId();
}
private Long safeUserId() {
Long userId = AuthContext.userId();
return userId == null ? 0L : userId;
}
}
File diff suppressed because it is too large Load Diff
@@ -117,7 +117,7 @@ public class ExpertService {
List<Object> countArgs = new ArrayList<>(); List<Object> countArgs = new ArrayList<>();
if (keyword != null && !keyword.trim().isEmpty()) { if (keyword != null && !keyword.trim().isEmpty()) {
String kw = keyword.trim().replace("'", "''"); String kw = keyword.trim().replace("'", "''");
whereClause.append(" AND (e.expert_name LIKE '%").append(kw).append("%' OR e.id_no LIKE '%").append(kw).append("%' OR e.phone LIKE '%").append(kw).append("%')"); whereClause.append(" AND (e.expert_name LIKE '%").append(kw).append("%' OR e.id_no LIKE '%").append(kw).append("%')");
} }
Integer total = jdbcTemplate.queryForObject( Integer total = jdbcTemplate.queryForObject(
@@ -109,11 +109,14 @@ public class PlatformExpertService {
"LEFT JOIN platform_dictionary_item dh ON dh.dict_type='EXPERT_HOSPITAL' AND dh.dict_code=e.hospital_code AND dh.is_deleted=0 " + "LEFT JOIN platform_dictionary_item dh ON dh.dict_type='EXPERT_HOSPITAL' AND dh.dict_code=e.hospital_code AND dh.is_deleted=0 " +
"WHERE e.tenant_id=" + PLATFORM_TENANT_ID + " AND e.is_deleted=0" "WHERE e.tenant_id=" + PLATFORM_TENANT_ID + " AND e.is_deleted=0"
); );
String idNoKeyword = normalizeIdNoKeyword(keyword);
List<Object> params = new ArrayList<Object>(); List<Object> params = new ArrayList<Object>();
if (idNoKeyword != null) { String normalizedKeyword = keyword == null ? "" : keyword.trim();
sql.append(" AND e.id_no = ?"); if (!normalizedKeyword.isEmpty()) {
params.add(idNoKeyword); String likeKeyword = "%" + normalizedKeyword + "%";
sql.append(" AND (e.expert_name LIKE ? OR e.id_no LIKE ? OR e.phone LIKE ?)");
params.add(likeKeyword);
params.add(likeKeyword);
params.add(likeKeyword);
} }
sql.append(" ORDER BY e.id DESC LIMIT 200"); sql.append(" ORDER BY e.id DESC LIMIT 200");
List<ExpertInfo> list = params.isEmpty() List<ExpertInfo> list = params.isEmpty()
@@ -126,6 +129,38 @@ public class PlatformExpertService {
return new PageResult<ExpertInfo>(maskedList, maskedList.size(), 1, 200); return new PageResult<ExpertInfo>(maskedList, maskedList.size(), 1, 200);
} }
public PageResult<ExpertInfo> listForMeetingBinding(String keyword) {
String normalizedKeyword = keyword == null ? "" : keyword.trim();
if (normalizedKeyword.isEmpty()) {
return new PageResult<ExpertInfo>(new ArrayList<ExpertInfo>(), 0, 1, 200);
}
StringBuilder sql = new StringBuilder(
"SELECT e.id, e.expert_name, e.gender, e.birthday, e.id_no, e.id_card_valid_until, e.id_card_front_oss_key, e.id_card_back_oss_key, e.phone, " +
"e.title_code, IFNULL(dt.dict_name, e.title) AS title_name, " +
"e.hospital_code, IFNULL(dh.dict_name, e.organization) AS hospital_name, " +
"e.status, e.status_reason, e.status_changed_at, e.export_restricted " +
"FROM expert e " +
"LEFT JOIN platform_dictionary_item dt ON dt.dict_type='EXPERT_TITLE' AND dt.dict_code=e.title_code AND dt.is_deleted=0 " +
"LEFT JOIN platform_dictionary_item dh ON dh.dict_type='EXPERT_HOSPITAL' AND dh.dict_code=e.hospital_code AND dh.is_deleted=0 " +
"WHERE e.tenant_id=" + PLATFORM_TENANT_ID + " AND e.is_deleted=0 AND (e.expert_name = ?"
);
List<Object> params = new ArrayList<Object>();
params.add(normalizedKeyword);
if (ID_NO_PATTERN.matcher(normalizedKeyword).matches()) {
sql.append(" OR e.id_no = ?");
params.add(normalizedKeyword.toUpperCase());
}
sql.append(") ORDER BY e.id DESC LIMIT 200");
List<ExpertInfo> list = jdbcTemplate.query(sql.toString(), EXPERT_ROW_MAPPER, params.toArray());
List<ExpertInfo> maskedList = new ArrayList<ExpertInfo>(list.size());
for (ExpertInfo item : list) {
maskedList.add(maskSensitiveFields(item));
}
return new PageResult<ExpertInfo>(maskedList, maskedList.size(), 1, 200);
}
public ExpertInfo get(Long id) { public ExpertInfo get(Long id) {
return findById(id); return findById(id);
} }
@@ -16,6 +16,7 @@ import com.writeoff.module.meeting.dto.MeetingInvoiceConfigRequest;
import com.writeoff.module.meeting.dto.MeetingLaborAgreementExtractApplyRequest; import com.writeoff.module.meeting.dto.MeetingLaborAgreementExtractApplyRequest;
import com.writeoff.module.meeting.dto.MeetingLaborAgreementExtractQueryRequest; import com.writeoff.module.meeting.dto.MeetingLaborAgreementExtractQueryRequest;
import com.writeoff.module.meeting.dto.MeetingLaborAgreementExtractSubmitRequest; import com.writeoff.module.meeting.dto.MeetingLaborAgreementExtractSubmitRequest;
import com.writeoff.module.meeting.dto.MeetingMaterialResubmitPreviewRequest;
import com.writeoff.module.meeting.dto.SaveMeetingMaterialRequest; import com.writeoff.module.meeting.dto.SaveMeetingMaterialRequest;
import com.writeoff.module.meeting.dto.SubmitMeetingRequest; import com.writeoff.module.meeting.dto.SubmitMeetingRequest;
import com.writeoff.module.meeting.dto.SubmitMeetingMaterialRequest; import com.writeoff.module.meeting.dto.SubmitMeetingMaterialRequest;
@@ -79,7 +80,7 @@ public class MeetingController {
@GetMapping("/tenant-experts") @GetMapping("/tenant-experts")
@RequirePermission(value = "meeting.material.read", dataScope = DataScopeType.TENANT, auditAction = "MEETING_TENANT_EXPERT_LIST") @RequirePermission(value = "meeting.material.read", dataScope = DataScopeType.TENANT, auditAction = "MEETING_TENANT_EXPERT_LIST")
public ApiResponse<PageResult<ExpertInfo>> tenantExperts(@RequestParam(value = "keyword", required = false) String keyword) { public ApiResponse<PageResult<ExpertInfo>> tenantExperts(@RequestParam(value = "keyword", required = false) String keyword) {
return ApiResponse.success(platformExpertService.list(keyword)); return ApiResponse.success(platformExpertService.listForMeetingBinding(keyword));
} }
@PostMapping("/tenant-experts") @PostMapping("/tenant-experts")
@@ -115,22 +116,29 @@ public class MeetingController {
return ApiResponse.success(meetingExpertBindingService.unbindOne(id, expertId)); return ApiResponse.success(meetingExpertBindingService.unbindOne(id, expertId));
} }
@PostMapping("/{id}/labor-agreement-extract/upload-sign")
@RequirePermission(value = "meeting.labor-agreement.extract", dataScope = DataScopeType.MEETING, auditAction = "MEETING_LABOR_AGREEMENT_UPLOAD_SIGN")
public ApiResponse<Map<String, Object>> laborAgreementUploadSign(@PathVariable("id") Long id,
@RequestBody @Valid MeetingMaterialUploadSignRequest request) {
return ApiResponse.success(meetingMaterialService.presignMaterialUpload(id, "EXPERT_LIST", request.getFileName(), request.getContentType()));
}
@PostMapping("/{id}/labor-agreement-extract/task") @PostMapping("/{id}/labor-agreement-extract/task")
@RequirePermission(value = "meeting.material.save", dataScope = DataScopeType.MEETING, auditAction = "MEETING_LABOR_AGREEMENT_EXTRACT_SUBMIT") @RequirePermission(value = "meeting.labor-agreement.extract", dataScope = DataScopeType.MEETING, auditAction = "MEETING_LABOR_AGREEMENT_EXTRACT_SUBMIT")
public ApiResponse<DocumentExtractTaskSubmitResponse> submitLaborAgreementExtractTask(@PathVariable("id") Long id, public ApiResponse<DocumentExtractTaskSubmitResponse> submitLaborAgreementExtractTask(@PathVariable("id") Long id,
@RequestBody @Valid MeetingLaborAgreementExtractSubmitRequest request) { @RequestBody @Valid MeetingLaborAgreementExtractSubmitRequest request) {
return ApiResponse.success(meetingLaborAgreementExtractService.submit(id, request)); return ApiResponse.success(meetingLaborAgreementExtractService.submit(id, request));
} }
@PostMapping("/{id}/labor-agreement-extract/query") @PostMapping("/{id}/labor-agreement-extract/query")
@RequirePermission(value = "meeting.material.save", dataScope = DataScopeType.MEETING, auditAction = "MEETING_LABOR_AGREEMENT_EXTRACT_QUERY") @RequirePermission(value = "meeting.labor-agreement.extract", dataScope = DataScopeType.MEETING, auditAction = "MEETING_LABOR_AGREEMENT_EXTRACT_QUERY")
public ApiResponse<MeetingLaborAgreementExtractResult> queryLaborAgreementExtract(@PathVariable("id") Long id, public ApiResponse<MeetingLaborAgreementExtractResult> queryLaborAgreementExtract(@PathVariable("id") Long id,
@RequestBody @Valid MeetingLaborAgreementExtractQueryRequest request) { @RequestBody @Valid MeetingLaborAgreementExtractQueryRequest request) {
return ApiResponse.success(meetingLaborAgreementExtractService.query(id, request.getTaskId())); return ApiResponse.success(meetingLaborAgreementExtractService.query(id, request.getTaskId()));
} }
@PostMapping("/{id}/labor-agreement-extract/apply") @PostMapping("/{id}/labor-agreement-extract/apply")
@RequirePermission(value = "meeting.material.save", dataScope = DataScopeType.MEETING, auditAction = "MEETING_LABOR_AGREEMENT_EXTRACT_APPLY") @RequirePermission(value = "meeting.labor-agreement.extract", dataScope = DataScopeType.MEETING, auditAction = "MEETING_LABOR_AGREEMENT_EXTRACT_APPLY")
public ApiResponse<Map<String, Object>> applyLaborAgreementExtract(@PathVariable("id") Long id, public ApiResponse<Map<String, Object>> applyLaborAgreementExtract(@PathVariable("id") Long id,
@RequestBody @Valid MeetingLaborAgreementExtractApplyRequest request) { @RequestBody @Valid MeetingLaborAgreementExtractApplyRequest request) {
return ApiResponse.success(meetingLaborAgreementExtractService.apply(id, request)); return ApiResponse.success(meetingLaborAgreementExtractService.apply(id, request));
@@ -175,6 +183,12 @@ public class MeetingController {
return ApiResponse.success(meetingService.submit(id, request)); return ApiResponse.success(meetingService.submit(id, request));
} }
@GetMapping("/{id}/pending-issues")
@RequirePermission(value = "meeting.read", dataScope = DataScopeType.MEETING, auditAction = "MEETING_PENDING_ISSUE_LIST")
public ApiResponse<List<Map<String, Object>>> pendingIssues(@PathVariable("id") Long id) {
return ApiResponse.success(meetingService.listPendingIssues(id));
}
@PostMapping("/{id}/withdraw") @PostMapping("/{id}/withdraw")
@RequirePermission(value = "meeting.withdraw", dataScope = DataScopeType.MEETING, auditAction = "MEETING_WITHDRAW") @RequirePermission(value = "meeting.withdraw", dataScope = DataScopeType.MEETING, auditAction = "MEETING_WITHDRAW")
public ApiResponse<Map<String, Object>> withdraw(@PathVariable("id") Long id, public ApiResponse<Map<String, Object>> withdraw(@PathVariable("id") Long id,
@@ -241,6 +255,15 @@ public class MeetingController {
return ApiResponse.success(meetingMaterialService.history(id, moduleCode)); return ApiResponse.success(meetingMaterialService.history(id, moduleCode));
} }
@PostMapping("/{id}/materials/{moduleCode}/resubmit-preview")
@RequirePermission(value = "meeting.material.read", dataScope = DataScopeType.MEETING_MODULE, auditAction = "MEETING_MATERIAL_CURRENT")
public ApiResponse<Map<String, Object>> materialResubmitPreview(@PathVariable("id") Long id,
@PathVariable("moduleCode") String moduleCode,
@RequestBody(required = false) MeetingMaterialResubmitPreviewRequest request) {
String contentJson = request == null ? null : request.getContentJson();
return ApiResponse.success(meetingMaterialService.previewResubmitSummary(id, moduleCode, contentJson));
}
@GetMapping("/{id}/matched-templates") @GetMapping("/{id}/matched-templates")
@RequirePermission(value = "meeting.material.read", dataScope = DataScopeType.MEETING, auditAction = "MEETING_MATCHED_TEMPLATES") @RequirePermission(value = "meeting.material.read", dataScope = DataScopeType.MEETING, auditAction = "MEETING_MATCHED_TEMPLATES")
public ApiResponse<List<TemplateInfo>> matchedTemplates(@PathVariable("id") Long id) { public ApiResponse<List<TemplateInfo>> matchedTemplates(@PathVariable("id") Long id) {
@@ -0,0 +1,27 @@
package com.writeoff.module.meeting.dto;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
public class MeetingIssueResponseRequest {
@NotNull(message = "问题ID不能为空")
private Long issueId;
@NotBlank(message = "问题处理说明不能为空")
private String responseText;
public Long getIssueId() {
return issueId;
}
public void setIssueId(Long issueId) {
this.issueId = issueId;
}
public String getResponseText() {
return responseText;
}
public void setResponseText(String responseText) {
this.responseText = responseText;
}
}
@@ -1,4 +1,4 @@
package com.writeoff.module.meeting.dto; package com.writeoff.module.meeting.dto;
import javax.validation.constraints.NotBlank; import javax.validation.constraints.NotBlank;
@@ -0,0 +1,22 @@
package com.writeoff.module.meeting.dto;
public class MeetingMaterialResubmitPreviewRequest {
private String contentJson;
private String remark;
public String getContentJson() {
return contentJson;
}
public void setContentJson(String contentJson) {
this.contentJson = contentJson;
}
public String getRemark() {
return remark;
}
public void setRemark(String remark) {
this.remark = remark;
}
}
@@ -2,33 +2,50 @@ package com.writeoff.module.meeting.dto;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
@Schema(description = "会议列表筛选参数") @Schema(description = "Meeting list query request")
public class MeetingQueryRequest { public class MeetingQueryRequest {
@Schema(description = "项目ID") @Schema(description = "Project ID")
private Long projectId; private Long projectId;
@Schema(description = "项目名称(模糊匹配)")
@Schema(description = "Project name, fuzzy match")
private String projectName; private String projectName;
@Schema(description = "会议主题(模糊匹配)")
@Schema(description = "Meeting topic, fuzzy match")
private String topic; private String topic;
@Schema(description = "会议状态(NOT_STARTED/IN_PROGRESS/COMPLETED/CANCELED/DELAYED/FROZEN")
@Schema(description = "Meeting status")
private String meetingStatus; private String meetingStatus;
@Schema(description = "会议审核状态(PENDING/IN_REVIEW/APPROVED/REJECTED")
@Schema(description = "Meeting audit status")
private String auditStatus; private String auditStatus;
@Schema(description = "当前审核节点")
@Schema(description = "Current audit node")
private String currentAuditNode; private String currentAuditNode;
@Schema(description = "当前审核人用户ID")
@Schema(description = "Current auditor user ID")
private Long currentAuditorUserId; private Long currentAuditorUserId;
@Schema(description = "会议开始时间范围-起,格式:yyyy-MM-dd HH:mm:ss")
@Schema(description = "Meeting start time from, format yyyy-MM-dd HH:mm:ss")
private String meetingStartFrom; private String meetingStartFrom;
@Schema(description = "会议开始时间范围-止,格式:yyyy-MM-dd HH:mm:ss")
@Schema(description = "Meeting start time to, format yyyy-MM-dd HH:mm:ss")
private String meetingStartTo; private String meetingStartTo;
@Schema(description = "最后提交时间范围-起,格式:yyyy-MM-ddTHH:mm:ss")
@Schema(description = "Last submit time from, format yyyy-MM-ddTHH:mm:ss")
private String lastSubmitFrom; private String lastSubmitFrom;
@Schema(description = "最后提交时间范围-止,格式:yyyy-MM-ddTHH:mm:ss")
@Schema(description = "Last submit time to, format yyyy-MM-ddTHH:mm:ss")
private String lastSubmitTo; private String lastSubmitTo;
@Schema(description = "是否包含已删除会议")
@Schema(description = "Whether to include deleted meetings")
private Boolean includeDeleted; private Boolean includeDeleted;
@Schema(description = "Page number, starts from 1")
private Integer pageNo;
@Schema(description = "Page size")
private Integer pageSize;
public Long getProjectId() { public Long getProjectId() {
return projectId; return projectId;
} }
@@ -124,4 +141,20 @@ public class MeetingQueryRequest {
public void setIncludeDeleted(Boolean includeDeleted) { public void setIncludeDeleted(Boolean includeDeleted) {
this.includeDeleted = includeDeleted; this.includeDeleted = includeDeleted;
} }
public Integer getPageNo() {
return pageNo;
}
public void setPageNo(Integer pageNo) {
this.pageNo = pageNo;
}
public Integer getPageSize() {
return pageSize;
}
public void setPageSize(Integer pageSize) {
this.pageSize = pageSize;
}
} }
@@ -1,11 +1,13 @@
package com.writeoff.module.meeting.dto; package com.writeoff.module.meeting.dto;
import javax.validation.constraints.NotBlank; import javax.validation.constraints.NotBlank;
import java.util.List;
public class SubmitMeetingMaterialRequest { public class SubmitMeetingMaterialRequest {
@NotBlank(message = "资料内容不能为空") @NotBlank(message = "资料内容不能为空")
private String contentJson; private String contentJson;
private String remark; private String remark;
private List<MeetingIssueResponseRequest> issueResponses;
public String getContentJson() { public String getContentJson() {
return contentJson; return contentJson;
@@ -22,4 +24,12 @@ public class SubmitMeetingMaterialRequest {
public void setRemark(String remark) { public void setRemark(String remark) {
this.remark = remark; this.remark = remark;
} }
public List<MeetingIssueResponseRequest> getIssueResponses() {
return issueResponses;
}
public void setIssueResponses(List<MeetingIssueResponseRequest> issueResponses) {
this.issueResponses = issueResponses;
}
} }
@@ -1,11 +1,13 @@
package com.writeoff.module.meeting.dto; package com.writeoff.module.meeting.dto;
import javax.validation.constraints.NotBlank; import javax.validation.constraints.NotBlank;
import java.util.List;
public class SubmitMeetingRequest { public class SubmitMeetingRequest {
@NotBlank(message = "幂等键不能为空") @NotBlank(message = "幂等键不能为空")
private String idempotencyKey; private String idempotencyKey;
private String remark; private String remark;
private List<MeetingIssueResponseRequest> issueResponses;
public String getIdempotencyKey() { public String getIdempotencyKey() {
return idempotencyKey; return idempotencyKey;
@@ -22,4 +24,12 @@ public class SubmitMeetingRequest {
public void setRemark(String remark) { public void setRemark(String remark) {
this.remark = remark; this.remark = remark;
} }
public List<MeetingIssueResponseRequest> getIssueResponses() {
return issueResponses;
}
public void setIssueResponses(List<MeetingIssueResponseRequest> issueResponses) {
this.issueResponses = issueResponses;
}
} }
@@ -10,6 +10,7 @@ public class Meeting {
private Long projectId; private Long projectId;
@Schema(description = "项目名称(展示字段)") @Schema(description = "项目名称(展示字段)")
private String projectName; private String projectName;
private int laborAgreementSignType = 1;
@Schema(description = "会议主题") @Schema(description = "会议主题")
private String topic; private String topic;
@Schema(description = "会议类别") @Schema(description = "会议类别")
@@ -203,6 +204,10 @@ public class Meeting {
return projectName; return projectName;
} }
public int getLaborAgreementSignType() {
return laborAgreementSignType;
}
public String getTopic() { public String getTopic() {
return topic; return topic;
} }
@@ -347,6 +352,10 @@ public class Meeting {
this.projectName = projectName; this.projectName = projectName;
} }
public void setLaborAgreementSignType(int laborAgreementSignType) {
this.laborAgreementSignType = laborAgreementSignType;
}
public void setCurrentAuditorUserId(Long currentAuditorUserId) { public void setCurrentAuditorUserId(Long currentAuditorUserId) {
this.currentAuditorUserId = currentAuditorUserId; this.currentAuditorUserId = currentAuditorUserId;
} }
@@ -116,6 +116,7 @@ public class MeetingLaborAgreementExtractResult {
private String phone; private String phone;
private String laborFeeText; private String laborFeeText;
private Long laborFeeCent; private Long laborFeeCent;
private Long laborFeePreTaxCent;
private String bankName; private String bankName;
private String bankCardNo; private String bankCardNo;
private String accountName; private String accountName;
@@ -162,6 +163,14 @@ public class MeetingLaborAgreementExtractResult {
this.laborFeeCent = laborFeeCent; this.laborFeeCent = laborFeeCent;
} }
public Long getLaborFeePreTaxCent() {
return laborFeePreTaxCent;
}
public void setLaborFeePreTaxCent(Long laborFeePreTaxCent) {
this.laborFeePreTaxCent = laborFeePreTaxCent;
}
public String getBankName() { public String getBankName() {
return bankName; return bankName;
} }
@@ -15,6 +15,8 @@ public class MeetingMaterial {
private Integer versionNo; private Integer versionNo;
private Boolean latestVersion; private Boolean latestVersion;
private String updatedAt; private String updatedAt;
private String draftContentJson;
private String draftRemark;
public MeetingMaterial(Long id, public MeetingMaterial(Long id,
Long meetingId, Long meetingId,
@@ -30,6 +32,26 @@ public class MeetingMaterial {
Integer versionNo, Integer versionNo,
Boolean latestVersion, Boolean latestVersion,
String updatedAt) { String updatedAt) {
this(id, meetingId, moduleCode, contentJson, status, auditNodeStatus, auditAggregateStatus, submitRemark,
rejectCount, lastRejectReason, resubmitAt, versionNo, latestVersion, updatedAt, null, null);
}
public MeetingMaterial(Long id,
Long meetingId,
String moduleCode,
String contentJson,
String status,
String auditNodeStatus,
String auditAggregateStatus,
String submitRemark,
Integer rejectCount,
String lastRejectReason,
String resubmitAt,
Integer versionNo,
Boolean latestVersion,
String updatedAt,
String draftContentJson,
String draftRemark) {
this.id = id; this.id = id;
this.meetingId = meetingId; this.meetingId = meetingId;
this.moduleCode = moduleCode; this.moduleCode = moduleCode;
@@ -44,6 +66,8 @@ public class MeetingMaterial {
this.versionNo = versionNo; this.versionNo = versionNo;
this.latestVersion = latestVersion; this.latestVersion = latestVersion;
this.updatedAt = updatedAt; this.updatedAt = updatedAt;
this.draftContentJson = draftContentJson;
this.draftRemark = draftRemark;
} }
public Long getId() { public Long getId() {
@@ -101,4 +125,12 @@ public class MeetingMaterial {
public String getUpdatedAt() { public String getUpdatedAt() {
return updatedAt; return updatedAt;
} }
public String getDraftContentJson() {
return draftContentJson;
}
public String getDraftRemark() {
return draftRemark;
}
} }
@@ -0,0 +1,62 @@
package com.writeoff.module.meeting.model;
public class MeetingSubmissionVersion {
private Long id;
private Long meetingId;
private Integer versionNo;
private String remark;
private String snapshotJson;
private Long createdBy;
private String createdByName;
private String createdAt;
public MeetingSubmissionVersion(Long id,
Long meetingId,
Integer versionNo,
String remark,
String snapshotJson,
Long createdBy,
String createdByName,
String createdAt) {
this.id = id;
this.meetingId = meetingId;
this.versionNo = versionNo;
this.remark = remark;
this.snapshotJson = snapshotJson;
this.createdBy = createdBy;
this.createdByName = createdByName;
this.createdAt = createdAt;
}
public Long getId() {
return id;
}
public Long getMeetingId() {
return meetingId;
}
public Integer getVersionNo() {
return versionNo;
}
public String getRemark() {
return remark;
}
public String getSnapshotJson() {
return snapshotJson;
}
public Long getCreatedBy() {
return createdBy;
}
public String getCreatedByName() {
return createdByName;
}
public String getCreatedAt() {
return createdAt;
}
}
@@ -57,6 +57,7 @@ public class JdbcMeetingRepository implements MeetingRepository {
rs.getString("invoice_config_json") rs.getString("invoice_config_json")
); );
meeting.setProjectName(rs.getString("project_name")); meeting.setProjectName(rs.getString("project_name"));
meeting.setLaborAgreementSignType(rs.getObject("labor_agreement_sign_type") == null ? 1 : rs.getInt("labor_agreement_sign_type"));
meeting.setDeleted(rs.getInt("is_deleted") == 1); meeting.setDeleted(rs.getInt("is_deleted") == 1);
return meeting; return meeting;
}; };
@@ -198,7 +199,7 @@ public class JdbcMeetingRepository implements MeetingRepository {
@Override @Override
public Optional<Meeting> findById(Long id) { public Optional<Meeting> findById(Long id) {
List<Meeting> list = jdbcTemplate.query( List<Meeting> list = jdbcTemplate.query(
"SELECT m.id, m.project_id, p.project_name, m.topic, m.meeting_category, m.meeting_form, m.location, " + "SELECT m.id, m.project_id, p.project_name, p.labor_agreement_sign_type, m.topic, m.meeting_category, m.meeting_form, m.location, " +
"DATE_FORMAT(m.start_time, '%Y-%m-%d %H:%i:%s') AS start_time, DATE_FORMAT(m.end_time, '%Y-%m-%d %H:%i:%s') AS end_time, " + "DATE_FORMAT(m.start_time, '%Y-%m-%d %H:%i:%s') AS start_time, DATE_FORMAT(m.end_time, '%Y-%m-%d %H:%i:%s') AS end_time, " +
"m.budget_cent, m.labor_ratio, m.catering_ratio, m.meeting_status, m.audit_status, m.current_audit_node, " + "m.budget_cent, m.labor_ratio, m.catering_ratio, m.meeting_status, m.audit_status, m.current_audit_node, " +
"DATE_FORMAT(m.last_submit_at, '%Y-%m-%dT%H:%i:%s') AS last_submit_at, m.last_reject_reason, m.overdue_days, m.risk_flags_json, m.is_frozen, m.freeze_reason, " + "DATE_FORMAT(m.last_submit_at, '%Y-%m-%dT%H:%i:%s') AS last_submit_at, m.last_reject_reason, m.overdue_days, m.risk_flags_json, m.is_frozen, m.freeze_reason, " +
@@ -217,7 +218,7 @@ public class JdbcMeetingRepository implements MeetingRepository {
public List<Meeting> findAll(boolean includeDeleted) { public List<Meeting> findAll(boolean includeDeleted) {
String whereSql = includeDeleted ? "WHERE m.tenant_id=? " : "WHERE m.tenant_id=? AND m.is_deleted=0 "; String whereSql = includeDeleted ? "WHERE m.tenant_id=? " : "WHERE m.tenant_id=? AND m.is_deleted=0 ";
return jdbcTemplate.query( return jdbcTemplate.query(
"SELECT m.id, m.project_id, p.project_name, m.topic, m.meeting_category, m.meeting_form, m.location, " + "SELECT m.id, m.project_id, p.project_name, p.labor_agreement_sign_type, m.topic, m.meeting_category, m.meeting_form, m.location, " +
"DATE_FORMAT(m.start_time, '%Y-%m-%d %H:%i:%s') AS start_time, DATE_FORMAT(m.end_time, '%Y-%m-%d %H:%i:%s') AS end_time, " + "DATE_FORMAT(m.start_time, '%Y-%m-%d %H:%i:%s') AS start_time, DATE_FORMAT(m.end_time, '%Y-%m-%d %H:%i:%s') AS end_time, " +
"m.budget_cent, m.labor_ratio, m.catering_ratio, m.meeting_status, m.audit_status, m.current_audit_node, " + "m.budget_cent, m.labor_ratio, m.catering_ratio, m.meeting_status, m.audit_status, m.current_audit_node, " +
"DATE_FORMAT(m.last_submit_at, '%Y-%m-%dT%H:%i:%s') AS last_submit_at, m.last_reject_reason, m.overdue_days, m.risk_flags_json, m.is_frozen, m.freeze_reason, " + "DATE_FORMAT(m.last_submit_at, '%Y-%m-%dT%H:%i:%s') AS last_submit_at, m.last_reject_reason, m.overdue_days, m.risk_flags_json, m.is_frozen, m.freeze_reason, " +
@@ -16,6 +16,7 @@ import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collection; import java.util.Collection;
import java.util.HashSet; import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -64,7 +65,7 @@ public class MeetingExpertBindingService {
meetingService.getById(meetingId); meetingService.getById(meetingId);
List<MeetingExpertBinding> beforeBindings = listByMeetingId(meetingId); List<MeetingExpertBinding> beforeBindings = listByMeetingId(meetingId);
List<Long> rawIds = request.getExpertIds() == null ? new ArrayList<Long>() : request.getExpertIds(); List<Long> rawIds = request.getExpertIds() == null ? new ArrayList<Long>() : request.getExpertIds();
Set<Long> idSet = new HashSet<Long>(); Set<Long> idSet = new LinkedHashSet<Long>();
for (Long id : rawIds) { for (Long id : rawIds) {
if (id != null && id > 0) { if (id != null && id > 0) {
idSet.add(id); idSet.add(id);
@@ -0,0 +1,210 @@
package com.writeoff.module.meeting.service;
import com.writeoff.module.meeting.dto.MeetingIssueResponseRequest;
import com.writeoff.security.AuthContext;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
@Service
public class MeetingIssueResponseService {
private final JdbcTemplate jdbcTemplate;
public MeetingIssueResponseService(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
public List<Map<String, Object>> listOpenIssuesByMeetingId(Long meetingId) {
return listIssuesByMeetingId(meetingId, false);
}
public List<Map<String, Object>> listIssuesByMeetingId(Long meetingId) {
return listIssuesByMeetingId(meetingId, true);
}
public List<Map<String, Object>> listIssuesByMeetingAndModule(Long meetingId, String moduleCode) {
return filterIssuesByModule(listIssuesByMeetingId(meetingId), moduleCode);
}
public List<Map<String, Object>> listOpenIssuesByMeetingAndModule(Long meetingId, String moduleCode) {
return filterIssuesByModule(listOpenIssuesByMeetingId(meetingId), moduleCode);
}
private List<Map<String, Object>> listIssuesByMeetingId(Long meetingId, boolean includeResolved) {
if (meetingId == null || meetingId <= 0L) {
return new ArrayList<>();
}
String statusSql = includeResolved
? "ai.status IN ('OPEN', 'PENDING_CONFIRM', 'RESOLVED')"
: "ai.status IN ('OPEN', 'PENDING_CONFIRM')";
return jdbcTemplate.query(
"SELECT ai.id, ai.task_id, ai.meeting_id, ai.submission_version_id, ai.review_node, ai.module_code, ai.target_path, ai.target_label, ai.reason, ai.status, " +
"DATE_FORMAT(ai.created_at, '%Y-%m-%d %H:%i:%s') AS created_at, " +
"(SELECT ir.response_text FROM issue_response ir WHERE ir.tenant_id=ai.tenant_id AND ir.issue_id=ai.id ORDER BY ir.id DESC LIMIT 1) AS latest_response_text, " +
"(SELECT DATE_FORMAT(ir.responded_at, '%Y-%m-%d %H:%i:%s') FROM issue_response ir WHERE ir.tenant_id=ai.tenant_id AND ir.issue_id=ai.id ORDER BY ir.id DESC LIMIT 1) AS latest_responded_at " +
"FROM audit_issue ai " +
"WHERE ai.tenant_id=? AND ai.meeting_id=? AND " + statusSql + " AND ai.task_id=(" +
"SELECT at.id FROM audit_task at WHERE at.tenant_id=ai.tenant_id AND at.meeting_id=ai.meeting_id AND at.status='REJECTED' AND at.is_deleted=0 ORDER BY at.id DESC LIMIT 1" +
") ORDER BY ai.id ASC",
(rs, n) -> {
Map<String, Object> row = new LinkedHashMap<>();
row.put("id", rs.getLong("id"));
row.put("taskId", rs.getLong("task_id"));
row.put("meetingId", rs.getLong("meeting_id"));
row.put("submissionVersionId", rs.getObject("submission_version_id") == null ? null : rs.getLong("submission_version_id"));
row.put("reviewNode", rs.getString("review_node"));
row.put("moduleCode", rs.getString("module_code"));
row.put("targetPath", rs.getString("target_path"));
row.put("targetLabel", rs.getString("target_label"));
row.put("reason", rs.getString("reason"));
row.put("status", rs.getString("status"));
row.put("createdAt", rs.getString("created_at"));
row.put("latestResponseText", rs.getString("latest_response_text"));
row.put("latestRespondedAt", rs.getString("latest_responded_at"));
return row;
},
tenantId(),
meetingId
);
}
private List<Map<String, Object>> filterIssuesByModule(List<Map<String, Object>> all, String moduleCode) {
if (moduleCode == null || moduleCode.trim().isEmpty()) {
return all;
}
String normalized = moduleCode.trim().toUpperCase(Locale.ROOT);
List<Map<String, Object>> filtered = new ArrayList<>();
for (Map<String, Object> issue : all) {
String currentModuleCode = String.valueOf(issue.get("moduleCode") == null ? "" : issue.get("moduleCode")).trim().toUpperCase(Locale.ROOT);
if (normalized.equals(currentModuleCode)) {
filtered.add(issue);
}
}
return filtered;
}
@Transactional
public int saveMeetingResponses(Long meetingId, Long submissionVersionId, List<MeetingIssueResponseRequest> responses) {
List<Map<String, Object>> openIssues = listOpenIssuesByMeetingId(meetingId);
return saveResponses(openIssues, submissionVersionId, responses);
}
@Transactional
public int markMeetingIssuesResolved(Long meetingId) {
if (meetingId == null || meetingId <= 0L) {
return 0;
}
return jdbcTemplate.update(
"UPDATE audit_issue SET status='RESOLVED', updated_by=?, updated_at=CURRENT_TIMESTAMP " +
"WHERE tenant_id=? AND meeting_id=? AND status IN ('OPEN', 'PENDING_CONFIRM')",
safeUserId(),
tenantId(),
meetingId
);
}
public void validateResponsesRequired(Long meetingId, List<MeetingIssueResponseRequest> responses) {
// Response text is optional. Re-submit is judged by actual content changes.
}
public void validateResponsesRequired(Long meetingId, String moduleCode, List<MeetingIssueResponseRequest> responses) {
// Response text is optional. Re-submit is judged by actual content changes.
}
public int saveMeetingResponses(Long meetingId, String moduleCode, Long submissionVersionId, List<MeetingIssueResponseRequest> responses) {
List<Map<String, Object>> openIssues = listOpenIssuesByMeetingAndModule(meetingId, moduleCode);
return saveResponses(openIssues, submissionVersionId, responses);
}
@Transactional
public int closeIssuesForRejectedTask(Long taskId, String closeStatus) {
if (taskId == null || taskId <= 0L) {
return 0;
}
String normalizedStatus = str(closeStatus).toUpperCase();
if (normalizedStatus.isEmpty()) {
normalizedStatus = "RESOLVED";
}
return jdbcTemplate.update(
"UPDATE audit_issue SET status=?, updated_by=?, updated_at=CURRENT_TIMESTAMP " +
"WHERE tenant_id=? AND task_id=? AND status IN ('OPEN', 'PENDING_CONFIRM')",
normalizedStatus,
safeUserId(),
tenantId(),
taskId
);
}
private int saveResponses(List<Map<String, Object>> openIssues, Long submissionVersionId, List<MeetingIssueResponseRequest> responses) {
if (openIssues.isEmpty() || responses == null || responses.isEmpty()) {
return 0;
}
Map<Long, Map<String, Object>> issueMap = new LinkedHashMap<>();
for (Map<String, Object> issue : openIssues) {
Number idNum = (Number) issue.get("id");
if (idNum != null) {
issueMap.put(idNum.longValue(), issue);
}
}
Map<Long, String> responseTextMap = new LinkedHashMap<>();
for (MeetingIssueResponseRequest response : responses) {
if (response == null || response.getIssueId() == null) {
continue;
}
String text = str(response.getResponseText());
if (!text.isEmpty()) {
responseTextMap.put(response.getIssueId(), text);
}
}
int count = 0;
for (Map.Entry<Long, String> entry : responseTextMap.entrySet()) {
Map<String, Object> issue = issueMap.get(entry.getKey());
if (issue == null) {
continue;
}
String responseText = str(entry.getValue());
if (responseText.isEmpty()) {
continue;
}
jdbcTemplate.update(
"INSERT INTO issue_response (tenant_id, issue_id, submission_version_id, response_text, response_status, responded_by, created_by, updated_by) " +
"VALUES (?, ?, ?, ?, 'PENDING_CONFIRM', ?, ?, ?)",
tenantId(),
entry.getKey(),
submissionVersionId,
responseText,
safeUserId(),
safeUserId(),
safeUserId()
);
jdbcTemplate.update(
"UPDATE audit_issue SET status='PENDING_CONFIRM', response_text=?, responded_at=CURRENT_TIMESTAMP, updated_by=?, updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND id=?",
responseText,
safeUserId(),
tenantId(),
entry.getKey()
);
count++;
}
return count;
}
private Long tenantId() {
return AuthContext.requireTenantId();
}
private Long safeUserId() {
Long userId = AuthContext.userId();
return userId == null ? 0L : userId;
}
private String str(String value) {
return value == null ? "" : value.trim();
}
}
@@ -1,4 +1,4 @@
package com.writeoff.module.meeting.service; package com.writeoff.module.meeting.service;
import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
@@ -21,6 +21,8 @@ import com.writeoff.module.ocr.service.BaiduDocumentExtractService;
import com.writeoff.module.system.model.PlatformDictionaryItem; import com.writeoff.module.system.model.PlatformDictionaryItem;
import com.writeoff.module.system.service.PlatformDictionaryService; import com.writeoff.module.system.service.PlatformDictionaryService;
import com.writeoff.security.AuthContext; import com.writeoff.security.AuthContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -36,16 +38,17 @@ import java.util.Set;
@Service @Service
public class MeetingLaborAgreementExtractService { public class MeetingLaborAgreementExtractService {
private static final Logger log = LoggerFactory.getLogger(MeetingLaborAgreementExtractService.class);
private static final String MODULE_CODE = "EXPERT_LIST"; private static final String MODULE_CODE = "EXPERT_LIST";
private static final String HOSPITAL_DICT_TYPE = "EXPERT_HOSPITAL"; private static final String HOSPITAL_DICT_TYPE = "EXPERT_HOSPITAL";
private static final List<String> OCR_KEYS_NAME = asList("涔欐柟"); private static final List<String> OCR_KEYS_NAME = asList("乙方");
private static final List<String> OCR_KEYS_HOSPITAL = asList("宸ヤ綔鍗曚綅"); private static final List<String> OCR_KEYS_HOSPITAL = asList("工作单位");
private static final List<String> OCR_KEYS_PHONE = asList("鑱旂郴鐢佃瘽"); private static final List<String> OCR_KEYS_PHONE = asList("联系电话");
private static final List<String> OCR_KEYS_FEE = asList("鍔冲姟璐?); private static final List<String> OCR_KEYS_FEE = asList("劳务费");
private static final List<String> OCR_KEYS_BANK_NAME = asList("鎴烽摱琛?); private static final List<String> OCR_KEYS_BANK_NAME = asList("开户银行");
private static final List<String> OCR_KEYS_BANK_CARD = asList("寮€鎴峰笎鍙?, "鎴疯处鍙?); private static final List<String> OCR_KEYS_BANK_CARD = asList("开户帐号", "开户账号");
private static final List<String> OCR_KEYS_ID_NO = asList("韬唤璇佸彿鐮?, "唤璇佸彿"); private static final List<String> OCR_KEYS_ID_NO = asList("身份证号码");
private static final List<String> OCR_KEYS_ACCOUNT_NAME = asList("埛鍚?); private static final List<String> OCR_KEYS_ACCOUNT_NAME = asList("账户名", "帐户名");
private final MeetingService meetingService; private final MeetingService meetingService;
private final BaiduDocumentExtractService documentExtractService; private final BaiduDocumentExtractService documentExtractService;
@@ -88,20 +91,48 @@ public class MeetingLaborAgreementExtractService {
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public Map<String, Object> apply(Long meetingId, MeetingLaborAgreementExtractApplyRequest request) { public Map<String, Object> apply(Long meetingId, MeetingLaborAgreementExtractApplyRequest request) {
assertMeetingEditable(meetingId); assertMeetingEditable(meetingId);
log.info("meeting labor agreement apply start, meetingId={}, taskId={}, requestedExpertId={}, updateExisting={}, objectKey={}, fileName={}",
meetingId,
trimToEmpty(request.getTaskId()),
request.getExistingExpertId(),
request.getUpdateExistingExpert(),
trimToEmpty(request.getObjectKey()),
trimToEmpty(request.getFileName()));
MeetingLaborAgreementExtractResult result = buildResult(documentExtractService.queryTask(request.getTaskId())); MeetingLaborAgreementExtractResult result = buildResult(documentExtractService.queryTask(request.getTaskId()));
log.info("meeting labor agreement apply query result, meetingId={}, taskId={}, status={}, reason={}",
meetingId,
trimToEmpty(request.getTaskId()),
trimToEmpty(result.getStatus()),
trimToEmpty(result.getReason()));
if (!"Success".equalsIgnoreCase(trimToEmpty(result.getStatus()))) { if (!"Success".equalsIgnoreCase(trimToEmpty(result.getStatus()))) {
throw new BusinessException(ErrorCodes.INVALID_STATE, "OCR浠诲姟鏈畬鎴愶紝涓嶈兘搴旂敤"); throw new BusinessException(ErrorCodes.INVALID_STATE, "OCR任务未完成,不能应用");
} }
MeetingLaborAgreementExtractResult.ParsedExpert parsed = result.getParsedExpert(); MeetingLaborAgreementExtractResult.ParsedExpert parsed = result.getParsedExpert();
if (parsed == null) { if (parsed == null) {
throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "鏈В鏋愬埌涓撳淇℃伅"); throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "未解析到专家信息");
} }
log.info("meeting labor agreement parsed expert, meetingId={}, taskId={}, expertName={}, idNo={}, phone={}, laborFeeCent={}, bankName={}, bankCardNo={}, accountName={}, nameMismatchFlag={}",
meetingId,
trimToEmpty(request.getTaskId()),
trimToEmpty(parsed.getExpertName()),
trimToEmpty(parsed.getIdNo()),
trimToEmpty(parsed.getPhone()),
parsed.getLaborFeeCent(),
trimToEmpty(parsed.getBankName()),
trimToEmpty(parsed.getBankCardNo()),
trimToEmpty(parsed.getAccountName()),
parsed.getNameMismatchFlag());
String idNo = trimToNull(parsed.getIdNo()); String idNo = trimToNull(parsed.getIdNo());
if (idNo == null) { if (idNo == null) {
throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "鏈瘑鍒埌韬唤璇佸彿鐮?); throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "未识别到身份证号码");
} }
ExpertInfo existing = platformExpertService.findByExactIdNo(idNo); ExpertInfo existing = platformExpertService.findByExactIdNo(idNo);
log.info("meeting labor agreement existing expert lookup, meetingId={}, taskId={}, matchedExistingExpertId={}, matchedExistingExpertName={}",
meetingId,
trimToEmpty(request.getTaskId()),
existing == null ? null : existing.getId(),
existing == null ? "" : trimToEmpty(existing.getExpertName()));
Long requestedExpertId = request.getExistingExpertId(); Long requestedExpertId = request.getExistingExpertId();
boolean updateExisting = Boolean.TRUE.equals(request.getUpdateExistingExpert()); boolean updateExisting = Boolean.TRUE.equals(request.getUpdateExistingExpert());
ExpertInfo targetExpert; ExpertInfo targetExpert;
@@ -110,10 +141,10 @@ public class MeetingLaborAgreementExtractService {
if (existing != null) { if (existing != null) {
if (!updateExisting) { if (!updateExisting) {
throw new BusinessException(ErrorCodes.INVALID_STATE, "宸插瓨鍦ㄥ悓韬唤璇佷笓瀹讹紝璇风璁ゆ槸鍚鐢ㄥ苟鏇存柊"); throw new BusinessException(ErrorCodes.INVALID_STATE, "已存在同身份证专家,请确认是否复用并更新");
} }
if (requestedExpertId == null || !existing.getId().equals(requestedExpertId)) { if (requestedExpertId == null || !existing.getId().equals(requestedExpertId)) {
throw new BusinessException(ErrorCodes.INVALID_STATE, "涓撳涓庣郴缁熷尮閰嶇粨鏋滀笉涓?); throw new BusinessException(ErrorCodes.INVALID_STATE, "确认专家与系统匹配结果不一致");
} }
targetExpert = updateExistingExpert(existing, parsed); targetExpert = updateExistingExpert(existing, parsed);
updated = true; updated = true;
@@ -125,6 +156,14 @@ public class MeetingLaborAgreementExtractService {
upsertBankCard(targetExpert.getId(), parsed); upsertBankCard(targetExpert.getId(), parsed);
bindExpertToMeeting(meetingId, targetExpert.getId()); bindExpertToMeeting(meetingId, targetExpert.getId());
saveLaborToMeeting(meetingId, targetExpert, parsed, request.getObjectKey(), request.getFileName()); saveLaborToMeeting(meetingId, targetExpert, parsed, request.getObjectKey(), request.getFileName());
log.info("meeting labor agreement apply success, meetingId={}, taskId={}, expertId={}, createdExpert={}, updatedExpert={}, protocolObjectKey={}, protocolFileName={}",
meetingId,
trimToEmpty(request.getTaskId()),
targetExpert.getId(),
created,
updated,
trimToEmpty(request.getObjectKey()),
trimToEmpty(request.getFileName()));
Map<String, Object> data = new LinkedHashMap<String, Object>(); Map<String, Object> data = new LinkedHashMap<String, Object>();
data.put("taskId", request.getTaskId()); data.put("taskId", request.getTaskId());
@@ -184,6 +223,7 @@ public class MeetingLaborAgreementExtractService {
String laborFeeText = firstWord(singleKey, OCR_KEYS_FEE); String laborFeeText = firstWord(singleKey, OCR_KEYS_FEE);
expert.setLaborFeeText(laborFeeText); expert.setLaborFeeText(laborFeeText);
expert.setLaborFeeCent(parseAmountCent(laborFeeText)); expert.setLaborFeeCent(parseAmountCent(laborFeeText));
expert.setLaborFeePreTaxCent(calculateByAfterTaxCent(expert.getLaborFeeCent()));
expert.setBankName(firstWord(singleKey, OCR_KEYS_BANK_NAME)); expert.setBankName(firstWord(singleKey, OCR_KEYS_BANK_NAME));
expert.setBankCardNo(normalizeBankCardNo(firstWord(singleKey, OCR_KEYS_BANK_CARD))); expert.setBankCardNo(normalizeBankCardNo(firstWord(singleKey, OCR_KEYS_BANK_CARD)));
expert.setAccountName(firstWord(singleKey, OCR_KEYS_ACCOUNT_NAME)); expert.setAccountName(firstWord(singleKey, OCR_KEYS_ACCOUNT_NAME));
@@ -238,7 +278,9 @@ public class MeetingLaborAgreementExtractService {
request.setIsDefault(Boolean.TRUE); request.setIsDefault(Boolean.TRUE);
boolean mismatch = Boolean.TRUE.equals(parsed.getNameMismatchFlag()); boolean mismatch = Boolean.TRUE.equals(parsed.getNameMismatchFlag());
request.setInconsistentNameApproved(mismatch ? Boolean.TRUE : Boolean.FALSE); request.setInconsistentNameApproved(mismatch ? Boolean.TRUE : Boolean.FALSE);
request.setChangeReason(mismatch ? "鍔冲姟鍗忚OCR璇嗗埆鍒拌处鎴峰悕涓庝箼鏂逛笉涓€鑷达紝宸叉墦鏍囦繚瀛? : "鍔冲姟鍗忚OCR鑷姩瀵煎叆"); request.setChangeReason(mismatch
? "劳务协议OCR识别到账户名与乙方不一致,已打标保存"
: "劳务协议OCR自动导入");
platformExpertService.addOrUpdateDefaultCard(expertId, request); platformExpertService.addOrUpdateDefaultCard(expertId, request);
} }
@@ -274,6 +316,11 @@ public class MeetingLaborAgreementExtractService {
if (!(invoiceDetail.get("invoices") instanceof List)) { if (!(invoiceDetail.get("invoices") instanceof List)) {
invoiceDetail.put("invoices", new ArrayList<Object>()); invoiceDetail.put("invoices", new ArrayList<Object>());
} }
log.info("meeting labor agreement save to material start, meetingId={}, expertId={}, currentContentLength={}, currentDetailCount={}",
meetingId,
expert.getId(),
contentJson == null ? 0 : contentJson.length(),
details.size());
long expertId = expert.getId() == null ? 0L : expert.getId(); long expertId = expert.getId() == null ? 0L : expert.getId();
Map<String, Object> targetRow = null; Map<String, Object> targetRow = null;
@@ -309,12 +356,26 @@ public class MeetingLaborAgreementExtractService {
if (!(targetRow.get("invoiceFiles") instanceof List)) { if (!(targetRow.get("invoiceFiles") instanceof List)) {
targetRow.put("invoiceFiles", new ArrayList<Object>()); targetRow.put("invoiceFiles", new ArrayList<Object>());
} }
targetRow.put("amountCent", parsed.getLaborFeeCent() == null ? 0L : parsed.getLaborFeeCent()); long afterTaxAmountCent = parsed.getLaborFeeCent() == null ? 0L : parsed.getLaborFeeCent();
long preTaxAmountCent = parsed.getLaborFeePreTaxCent() == null ? calculateByAfterTaxCent(afterTaxAmountCent) : parsed.getLaborFeePreTaxCent();
targetRow.put("amountCent", preTaxAmountCent);
targetRow.put("preTaxAmountCent", preTaxAmountCent);
targetRow.put("afterTaxAmountCent", afterTaxAmountCent);
targetRow.put("preTaxAmountSource", "LABOR_AGREEMENT_OCR");
targetRow.put("afterTaxAmountSource", "LABOR_AGREEMENT_OCR");
log.info("meeting labor agreement material row prepared, meetingId={}, expertId={}, detailCount={}, protocolOssKey={}, protocolFileName={}, amountCent={}, invoiceFileCount={}",
meetingId,
expertId,
details.size(),
trimToEmpty(protocolFile.get("ossKey")),
trimToEmpty(protocolFile.get("fileName")),
targetRow.get("afterTaxAmountCent"),
((List<?>) targetRow.get("invoiceFiles")).size());
String remark = Boolean.TRUE.equals(parsed.getNameMismatchFlag()) String remark = Boolean.TRUE.equals(parsed.getNameMismatchFlag())
? "OCR璇嗗埆鎻愮ず锛氫箼鏂逛笌璐埛鍚嶄笉涓鑷达紝璇蜂汉宸ュ? ? "OCR识别提示:乙方与账户名不一致,请人工复核"
: ""; : "";
targetRow.put("remark", remark); targetRow.put("remark", remark);
meetingMaterialService.saveRawContent(meetingId, MODULE_CODE, toJson(root), "鍔冲姟鍗忚OCR鑷姩瀵煎叆"); meetingMaterialService.saveRawContent(meetingId, MODULE_CODE, toJson(root), "劳务协议OCR自动导入");
} }
private PlatformDictionaryItem ensureHospitalDictionary(String hospitalName) { private PlatformDictionaryItem ensureHospitalDictionary(String hospitalName) {
@@ -326,27 +387,27 @@ public class MeetingLaborAgreementExtractService {
if (existing != null) { if (existing != null) {
return existing; return existing;
} }
return platformDictionaryService.createEnabledItem(HOSPITAL_DICT_TYPE, name, "AUTO_HOSPITAL", "鍔冲姟鍗忚OCR鑷姩鍒涘缓"); return platformDictionaryService.createEnabledItem(HOSPITAL_DICT_TYPE, name, "AUTO_HOSPITAL", "劳务协议OCR自动创建");
} }
private void assertMeetingEditable(Long meetingId) { private void assertMeetingEditable(Long meetingId) {
Meeting meeting = meetingService.getById(meetingId); Meeting meeting = meetingService.getById(meetingId);
MeetingAuditStatus auditStatus = meeting.getAuditStatus(); MeetingAuditStatus auditStatus = meeting.getAuditStatus();
if (auditStatus == MeetingAuditStatus.IN_REVIEW || auditStatus == MeetingAuditStatus.APPROVED) { if (auditStatus == MeetingAuditStatus.IN_REVIEW || auditStatus == MeetingAuditStatus.APPROVED) {
throw new BusinessException(ErrorCodes.INVALID_STATE, "璇ヤ細璁祫鏂欏鏍镐腑鎴栧凡瀹℃牳閫氳繃锛屼笉鍏佽鍐嶄慨鏀?); throw new BusinessException(ErrorCodes.INVALID_STATE, "该会议资料审核中或已审核通过,不允许再修改");
} }
} }
private List<DocumentExtractTaskSubmitRequest.ManifestField> buildManifest() { private List<DocumentExtractTaskSubmitRequest.ManifestField> buildManifest() {
List<DocumentExtractTaskSubmitRequest.ManifestField> list = new ArrayList<DocumentExtractTaskSubmitRequest.ManifestField>(); List<DocumentExtractTaskSubmitRequest.ManifestField> list = new ArrayList<DocumentExtractTaskSubmitRequest.ManifestField>();
list.add(manifestField("涔欐柟")); list.add(manifestField("乙方"));
list.add(manifestField("宸ヤ綔鍗曚綅")); list.add(manifestField("工作单位"));
list.add(manifestField("鑱旂郴鐢佃瘽")); list.add(manifestField("联系电话"));
list.add(manifestField("鍔冲姟璐?)); list.add(manifestField("劳务费"));
list.add(manifestField("寮€鎴烽摱琛?)); list.add(manifestField("开户银行"));
list.add(manifestField("鎴峰笎鍙?)); list.add(manifestField("开户帐号"));
list.add(manifestField("璐︽埛鍚?)); list.add(manifestField("账户名"));
list.add(manifestField("唤璇佸彿鐮?)); list.add(manifestField("身份证号码"));
return list; return list;
} }
@@ -368,6 +429,25 @@ public class MeetingLaborAgreementExtractService {
return list; return list;
} }
private long calculateByAfterTaxCent(Long afterTaxCent) {
long normalized = afterTaxCent == null ? 0L : Math.max(0L, afterTaxCent);
if (normalized <= 0L) {
return 0L;
}
double afterTax = normalized / 100D;
double preTax;
if (afterTax <= 3360D) {
preTax = (afterTax - 160D) / 0.8D;
} else if (afterTax <= 21000D) {
preTax = afterTax / 0.84D;
} else if (afterTax <= 49500D) {
preTax = (afterTax - 2000D) / 0.76D;
} else {
preTax = (afterTax - 7000D) / 0.68D;
}
return Math.max(0L, Math.round(Math.round(preTax * 100D) / 100D * 100D));
}
private String firstWord(Map<String, Object> singleKey, List<String> keys) { private String firstWord(Map<String, Object> singleKey, List<String> keys) {
for (String key : keys) { for (String key : keys) {
Object rowsObj = singleKey.get(key); Object rowsObj = singleKey.get(key);
@@ -392,7 +472,11 @@ public class MeetingLaborAgreementExtractService {
if (raw == null) { if (raw == null) {
return 0L; return 0L;
} }
String normalized = raw.replace(",", "").replace("锛?, "").replace("?, "").replace("浜烘皯甯?, "").trim(); String normalized = raw.replace(",", "")
.replace("", "")
.replace("", "")
.replace("人民币", "")
.trim();
if (normalized.isEmpty()) { if (normalized.isEmpty()) {
return 0L; return 0L;
} }
@@ -433,7 +517,7 @@ public class MeetingLaborAgreementExtractService {
try { try {
return objectMapper.writeValueAsString(value); return objectMapper.writeValueAsString(value);
} catch (Exception ex) { } catch (Exception ex) {
throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "JSON搴忓垪鍖栧け璐?); throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "JSON序列化失败");
} }
} }
@@ -441,10 +525,8 @@ public class MeetingLaborAgreementExtractService {
Map<String, Object> value = asMap(parent.get(key)); Map<String, Object> value = asMap(parent.get(key));
if (value.isEmpty() && !(parent.get(key) instanceof Map)) { if (value.isEmpty() && !(parent.get(key) instanceof Map)) {
value = new LinkedHashMap<String, Object>(); value = new LinkedHashMap<String, Object>();
parent.put(key, value);
} else if (!(parent.get(key) instanceof Map)) {
parent.put(key, value);
} }
parent.put(key, value);
return value; return value;
} }
@@ -537,5 +619,3 @@ public class MeetingLaborAgreementExtractService {
return text.isEmpty() ? null : text; return text.isEmpty() ? null : text;
} }
} }
@@ -0,0 +1,718 @@
package com.writeoff.module.meeting.service;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.writeoff.common.exception.BusinessException;
import com.writeoff.common.exception.ErrorCodes;
import org.apache.poi.ss.usermodel.BorderStyle;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellStyle;
import org.apache.poi.ss.usermodel.DataFormat;
import org.apache.poi.ss.usermodel.FillPatternType;
import org.apache.poi.ss.usermodel.Font;
import org.apache.poi.ss.usermodel.HorizontalAlignment;
import org.apache.poi.ss.usermodel.IndexedColors;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.VerticalAlignment;
import org.apache.poi.ss.util.CellRangeAddress;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
@Service
public class MeetingLaborSummaryExportService {
private static final long PLATFORM_TENANT_ID = 0L;
private static final String BASIC_INFO_MODULE_CODE = "BASIC_INFO";
private static final String EXPERT_LIST_MODULE_CODE = "EXPERT_LIST";
private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
private static final List<String> HEADERS = Arrays.asList(
"序号",
"银行(开户行)",
"账号所在省份",
"账号所在地市",
"卡号",
"姓名",
"实发",
"备注",
"个税",
"应发",
"单位",
"电话",
"身份证号",
"科室",
"任务"
);
private final JdbcTemplate jdbcTemplate;
private final ObjectMapper objectMapper = new ObjectMapper();
public MeetingLaborSummaryExportService(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
public byte[] buildWorkbook(Long tenantId, Long meetingId) {
Map<String, Object> meeting = findMeeting(tenantId, meetingId);
Map<String, String> materialJsonByCode = queryMeetingMaterialJsonByCode(tenantId, meetingId);
Map<String, Object> expertList = parseJsonObject(materialJsonByCode.get(EXPERT_LIST_MODULE_CODE));
Map<String, Object> basicInfo = parseJsonObject(materialJsonByCode.get(BASIC_INFO_MODULE_CODE));
List<LaborSummaryRow> rows = buildRows(tenantId, meetingId, meeting, expertList, basicInfo);
try (XSSFWorkbook workbook = new XSSFWorkbook();
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
Sheet sheet = workbook.createSheet("劳务汇总表");
configureSheet(sheet);
Styles styles = createStyles(workbook);
int rowIndex = 0;
Row titleRow = sheet.createRow(rowIndex++);
titleRow.setHeightInPoints(28F);
createTextCell(titleRow, 0, buildTitle(meeting), styles.title);
for (int i = 1; i < HEADERS.size(); i++) {
createTextCell(titleRow, i, "", styles.title);
}
sheet.addMergedRegion(new CellRangeAddress(0, 0, 0, HEADERS.size() - 1));
Row headerRow = sheet.createRow(rowIndex++);
headerRow.setHeightInPoints(24F);
for (int i = 0; i < HEADERS.size(); i++) {
createTextCell(headerRow, i, HEADERS.get(i), styles.header);
}
int dataStartExcelRow = rowIndex + 1;
for (LaborSummaryRow item : rows) {
Row row = sheet.createRow(rowIndex++);
int col = 0;
createTextCell(row, col++, String.valueOf(item.sequenceNo), styles.text);
createTextCell(row, col++, item.bankName, styles.text);
createTextCell(row, col++, item.bankProvince, styles.text);
createTextCell(row, col++, item.bankCity, styles.text);
createTextCell(row, col++, item.bankCardNo, styles.text);
createTextCell(row, col++, item.name, styles.text);
createNumberCell(row, col++, item.netAmountYuan, styles.amount);
createTextCell(row, col++, item.remark, styles.text);
createNumberCell(row, col++, item.taxAmountYuan, styles.amount);
createNumberCell(row, col++, item.grossAmountYuan, styles.amount);
createTextCell(row, col++, item.organization, styles.text);
createTextCell(row, col++, item.phone, styles.text);
createTextCell(row, col++, item.idNo, styles.text);
createTextCell(row, col++, item.department, styles.text);
createTextCell(row, col, item.task, styles.text);
}
Row totalRow = sheet.createRow(rowIndex);
createTextCell(totalRow, 0, "", styles.total);
createTextCell(totalRow, 1, "共计", styles.total);
for (int i = 2; i <= 5; i++) {
createTextCell(totalRow, i, "", styles.total);
}
sheet.addMergedRegion(new CellRangeAddress(rowIndex, rowIndex, 1, 5));
if (rows.isEmpty()) {
createNumberCell(totalRow, 6, 0D, styles.totalAmount);
createTextCell(totalRow, 7, "", styles.total);
createNumberCell(totalRow, 8, 0D, styles.totalAmount);
createNumberCell(totalRow, 9, 0D, styles.totalAmount);
} else {
int dataEndExcelRow = rowIndex;
createFormulaCell(totalRow, 6, "SUM(G" + dataStartExcelRow + ":G" + dataEndExcelRow + ")", styles.totalAmount);
createTextCell(totalRow, 7, "", styles.total);
createFormulaCell(totalRow, 8, "SUM(I" + dataStartExcelRow + ":I" + dataEndExcelRow + ")", styles.totalAmount);
createFormulaCell(totalRow, 9, "SUM(J" + dataStartExcelRow + ":J" + dataEndExcelRow + ")", styles.totalAmount);
}
for (int i = 10; i < HEADERS.size(); i++) {
createTextCell(totalRow, i, "", styles.total);
}
workbook.write(outputStream);
return outputStream.toByteArray();
} catch (IOException ex) {
throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "劳务汇总表生成失败");
}
}
private Map<String, Object> findMeeting(Long tenantId, Long meetingId) {
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT id, topic, location, DATE_FORMAT(start_time, '%Y-%m-%d %H:%i:%s') AS start_time " +
"FROM meeting WHERE tenant_id=? AND id=? AND is_deleted=0 LIMIT 1",
tenantId,
meetingId
);
if (rows.isEmpty()) {
throw new BusinessException(ErrorCodes.RESOURCE_NOT_FOUND, "会议不存在");
}
return rows.get(0);
}
private Map<String, String> queryMeetingMaterialJsonByCode(Long tenantId, Long meetingId) {
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT module_code, content_json FROM meeting_material WHERE tenant_id=? AND meeting_id=? AND is_deleted=0",
tenantId,
meetingId
);
Map<String, String> result = new LinkedHashMap<String, String>();
for (Map<String, Object> row : rows) {
String moduleCode = stringValue(row.get("module_code"));
if (!moduleCode.isEmpty()) {
result.put(moduleCode, stringValue(row.get("content_json")));
}
}
return result;
}
private List<LaborSummaryRow> buildRows(Long tenantId,
Long meetingId,
Map<String, Object> meeting,
Map<String, Object> expertList,
Map<String, Object> basicInfo) {
Map<String, Object> laborProtocol = mapValue(expertList.get("laborProtocol"));
List<Map<String, Object>> details = listOfMap(laborProtocol.get("details"));
if (details.isEmpty()) {
return Collections.emptyList();
}
List<Long> expertIds = extractExpertIds(details);
Map<Long, MeetingExpertSnapshot> meetingExperts = queryMeetingExpertSnapshots(tenantId, meetingId);
Map<Long, PlatformExpertSnapshot> platformExperts = queryPlatformExpertSnapshots(expertIds);
Map<Long, BankCardSnapshot> bankCards = queryDefaultBankCards(expertIds);
Map<Long, String> taskMap = buildTaskMap(basicInfo);
String remark = buildRemark(meeting);
List<LaborSummaryRow> rows = new ArrayList<LaborSummaryRow>();
int sequenceNo = 1;
for (Map<String, Object> detail : details) {
Long expertId = longValue(detail.get("expertId"));
MeetingExpertSnapshot meetingExpert = expertId == null ? null : meetingExperts.get(expertId);
PlatformExpertSnapshot platformExpert = expertId == null ? null : platformExperts.get(expertId);
BankCardSnapshot bankCard = expertId == null ? null : bankCards.get(expertId);
long netAmountCent = firstPositiveLong(detail.get("afterTaxAmountCent"));
long grossAmountCent = firstPositiveLong(detail.get("preTaxAmountCent"), detail.get("amountCent"), detail.get("afterTaxAmountCent"));
long taxAmountCent = Math.max(0L, grossAmountCent - netAmountCent);
LaborSummaryRow row = new LaborSummaryRow();
row.sequenceNo = sequenceNo++;
row.bankName = buildBankName(bankCard);
row.bankProvince = bankCard == null ? "" : bankCard.bankProvince;
row.bankCity = bankCard == null ? "" : bankCard.bankCity;
row.bankCardNo = bankCard == null ? "" : bankCard.bankCardNo;
row.name = firstNonEmpty(
bankCard == null ? "" : bankCard.accountName,
stringValue(detail.get("expertName")),
meetingExpert == null ? "" : meetingExpert.expertName,
platformExpert == null ? "" : platformExpert.expertName
);
row.netAmountYuan = centToYuan(netAmountCent);
row.remark = remark;
row.taxAmountYuan = centToYuan(taxAmountCent);
row.grossAmountYuan = centToYuan(grossAmountCent);
row.organization = firstNonEmpty(
platformExpert == null ? "" : platformExpert.organization,
meetingExpert == null ? "" : meetingExpert.organization
);
row.phone = firstNonEmpty(
platformExpert == null ? "" : platformExpert.phone,
meetingExpert == null ? "" : meetingExpert.phone
);
row.idNo = platformExpert == null ? "" : platformExpert.idNo;
row.department = "";
row.task = expertId == null ? "" : stringValue(taskMap.get(expertId));
rows.add(row);
}
return rows;
}
private Map<Long, MeetingExpertSnapshot> queryMeetingExpertSnapshots(Long tenantId, Long meetingId) {
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT expert_id, expert_name, phone, organization FROM meeting_expert_binding " +
"WHERE tenant_id=? AND meeting_id=? ORDER BY id ASC",
tenantId,
meetingId
);
Map<Long, MeetingExpertSnapshot> result = new LinkedHashMap<Long, MeetingExpertSnapshot>();
for (Map<String, Object> row : rows) {
Long expertId = longValue(row.get("expert_id"));
if (expertId == null || expertId <= 0L || result.containsKey(expertId)) {
continue;
}
result.put(expertId, new MeetingExpertSnapshot(
expertId,
stringValue(row.get("expert_name")),
stringValue(row.get("phone")),
stringValue(row.get("organization"))
));
}
return result;
}
private Map<Long, PlatformExpertSnapshot> queryPlatformExpertSnapshots(List<Long> expertIds) {
if (expertIds.isEmpty()) {
return Collections.emptyMap();
}
List<Object> args = new ArrayList<Object>();
args.add(PLATFORM_TENANT_ID);
args.addAll(expertIds);
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT e.id, e.expert_name, e.phone, e.id_no, IFNULL(dh.dict_name, e.organization) AS hospital_name " +
"FROM expert e " +
"LEFT JOIN platform_dictionary_item dh ON dh.dict_type='EXPERT_HOSPITAL' AND dh.dict_code=e.hospital_code AND dh.is_deleted=0 " +
"WHERE e.tenant_id=? AND e.is_deleted=0 AND e.id IN (" + placeholders(expertIds.size()) + ")",
args.toArray()
);
Map<Long, PlatformExpertSnapshot> result = new LinkedHashMap<Long, PlatformExpertSnapshot>();
for (Map<String, Object> row : rows) {
Long expertId = longValue(row.get("id"));
if (expertId == null || expertId <= 0L) {
continue;
}
result.put(expertId, new PlatformExpertSnapshot(
expertId,
stringValue(row.get("expert_name")),
stringValue(row.get("phone")),
stringValue(row.get("id_no")),
stringValue(row.get("hospital_name"))
));
}
return result;
}
private Map<Long, BankCardSnapshot> queryDefaultBankCards(List<Long> expertIds) {
if (expertIds.isEmpty()) {
return Collections.emptyMap();
}
List<Object> args = new ArrayList<Object>();
args.add(PLATFORM_TENANT_ID);
args.addAll(expertIds);
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT expert_id, bank_name, bank_province, bank_city, bank_branch_name, bank_card_no, account_name, is_default " +
"FROM expert_bank_card " +
"WHERE tenant_id=? AND is_deleted=0 AND expert_id IN (" + placeholders(expertIds.size()) + ") " +
"ORDER BY expert_id ASC, CASE WHEN is_default='Y' THEN 0 ELSE 1 END ASC, id DESC",
args.toArray()
);
Map<Long, BankCardSnapshot> result = new LinkedHashMap<Long, BankCardSnapshot>();
for (Map<String, Object> row : rows) {
Long expertId = longValue(row.get("expert_id"));
if (expertId == null || expertId <= 0L || result.containsKey(expertId)) {
continue;
}
result.put(expertId, new BankCardSnapshot(
expertId,
stringValue(row.get("bank_name")),
stringValue(row.get("bank_province")),
stringValue(row.get("bank_city")),
stringValue(row.get("bank_branch_name")),
stringValue(row.get("bank_card_no")),
stringValue(row.get("account_name"))
));
}
return result;
}
private Map<Long, String> buildTaskMap(Map<String, Object> basicInfo) {
Map<Long, LinkedHashSet<String>> taskSets = new LinkedHashMap<Long, LinkedHashSet<String>>();
mergeTask(taskSets, basicInfo.get("chairmanExpertIds"), "主席");
mergeTask(taskSets, basicInfo.get("speakerExpertIds"), "讲者");
mergeTask(taskSets, basicInfo.get("hostExpertIds"), "主持");
mergeTask(taskSets, basicInfo.get("discussionGuestExpertIds"), "讨论嘉宾");
Map<Long, String> result = new LinkedHashMap<Long, String>();
for (Map.Entry<Long, LinkedHashSet<String>> entry : taskSets.entrySet()) {
result.put(entry.getKey(), String.join("", entry.getValue()));
}
return result;
}
private void mergeTask(Map<Long, LinkedHashSet<String>> taskSets, Object rawIds, String label) {
for (Long expertId : parseIdList(rawIds)) {
if (expertId == null || expertId <= 0L) {
continue;
}
LinkedHashSet<String> labels = taskSets.get(expertId);
if (labels == null) {
labels = new LinkedHashSet<String>();
taskSets.put(expertId, labels);
}
labels.add(label);
}
}
private List<Long> parseIdList(Object raw) {
if (raw == null) {
return Collections.emptyList();
}
List<Long> result = new ArrayList<Long>();
if (raw instanceof Collection) {
for (Object item : (Collection<?>) raw) {
Long value = longValue(item);
if (value != null) {
result.add(value);
}
}
return result;
}
String text = stringValue(raw);
if (text.isEmpty()) {
return result;
}
for (String item : text.split(",")) {
Long value = longValue(item);
if (value != null) {
result.add(value);
}
}
return result;
}
private List<Long> extractExpertIds(List<Map<String, Object>> details) {
LinkedHashSet<Long> ids = new LinkedHashSet<Long>();
for (Map<String, Object> detail : details) {
Long expertId = longValue(detail.get("expertId"));
if (expertId != null && expertId > 0L) {
ids.add(expertId);
}
}
return new ArrayList<Long>(ids);
}
private String buildTitle(Map<String, Object> meeting) {
String topic = stringValue(meeting.get("topic"));
if (topic.isEmpty()) {
topic = "会议";
}
return "" + topic + "” 劳务费信息表";
}
private String buildRemark(Map<String, Object> meeting) {
String monthDay = formatMonthDay(stringValue(meeting.get("start_time")));
String topic = stringValue(meeting.get("topic"));
String location = stringValue(meeting.get("location"));
StringBuilder builder = new StringBuilder();
if (!monthDay.isEmpty()) {
builder.append(monthDay);
}
if (!topic.isEmpty()) {
builder.append(topic);
}
if (builder.length() > 0 && !location.isEmpty()) {
builder.append("-").append(location);
} else if (builder.length() == 0) {
builder.append(location);
}
return builder.toString();
}
private String formatMonthDay(String startTime) {
if (startTime == null || startTime.trim().isEmpty()) {
return "";
}
try {
LocalDateTime dateTime = LocalDateTime.parse(startTime.trim(), DATE_TIME_FORMATTER);
return dateTime.getMonthValue() + "." + dateTime.getDayOfMonth();
} catch (Exception ignored) {
}
try {
LocalDate date = LocalDate.parse(startTime.trim(), DATE_FORMATTER);
return date.getMonthValue() + "." + date.getDayOfMonth();
} catch (Exception ignored) {
return "";
}
}
private void configureSheet(Sheet sheet) {
int[] widths = new int[] {8, 24, 14, 14, 20, 12, 12, 32, 12, 12, 28, 16, 22, 14, 14, 18, 14, 14};
for (int i = 0; i < widths.length; i++) {
sheet.setColumnWidth(i, widths[i] * 256);
}
sheet.createFreezePane(0, 2);
sheet.setDefaultRowHeightInPoints(22F);
}
private Styles createStyles(XSSFWorkbook workbook) {
short greyFill = IndexedColors.GREY_25_PERCENT.getIndex();
DataFormat dataFormat = workbook.createDataFormat();
short amountFormat = dataFormat.getFormat("0.00");
Font titleFont = workbook.createFont();
titleFont.setBold(true);
titleFont.setFontHeightInPoints((short) 14);
Font boldFont = workbook.createFont();
boldFont.setBold(true);
CellStyle title = workbook.createCellStyle();
applyBorder(title);
title.setAlignment(HorizontalAlignment.CENTER);
title.setVerticalAlignment(VerticalAlignment.CENTER);
title.setFont(titleFont);
CellStyle header = workbook.createCellStyle();
applyBorder(header);
header.setAlignment(HorizontalAlignment.CENTER);
header.setVerticalAlignment(VerticalAlignment.CENTER);
header.setFillForegroundColor(greyFill);
header.setFillPattern(FillPatternType.SOLID_FOREGROUND);
header.setFont(boldFont);
CellStyle text = workbook.createCellStyle();
applyBorder(text);
text.setVerticalAlignment(VerticalAlignment.CENTER);
CellStyle amount = workbook.createCellStyle();
amount.cloneStyleFrom(text);
amount.setAlignment(HorizontalAlignment.RIGHT);
amount.setDataFormat(amountFormat);
CellStyle total = workbook.createCellStyle();
applyBorder(total);
total.setVerticalAlignment(VerticalAlignment.CENTER);
total.setFillForegroundColor(greyFill);
total.setFillPattern(FillPatternType.SOLID_FOREGROUND);
total.setFont(boldFont);
CellStyle totalAmount = workbook.createCellStyle();
totalAmount.cloneStyleFrom(total);
totalAmount.setAlignment(HorizontalAlignment.RIGHT);
totalAmount.setDataFormat(amountFormat);
return new Styles(title, header, text, amount, total, totalAmount);
}
private void applyBorder(CellStyle style) {
style.setBorderTop(BorderStyle.THIN);
style.setBorderBottom(BorderStyle.THIN);
style.setBorderLeft(BorderStyle.THIN);
style.setBorderRight(BorderStyle.THIN);
}
private void createTextCell(Row row, int columnIndex, String value, CellStyle style) {
Cell cell = row.createCell(columnIndex);
cell.setCellValue(value == null ? "" : value);
cell.setCellStyle(style);
}
private void createNumberCell(Row row, int columnIndex, double value, CellStyle style) {
Cell cell = row.createCell(columnIndex);
cell.setCellValue(value);
cell.setCellStyle(style);
}
private void createFormulaCell(Row row, int columnIndex, String formula, CellStyle style) {
Cell cell = row.createCell(columnIndex);
cell.setCellFormula(formula);
cell.setCellStyle(style);
}
private String buildBankName(BankCardSnapshot bankCard) {
if (bankCard == null) {
return "";
}
String bankName = stringValue(bankCard.bankName);
String branchName = stringValue(bankCard.bankBranchName);
if (branchName.isEmpty()) {
return bankName;
}
if (bankName.isEmpty()) {
return branchName;
}
if (branchName.replace(" ", "").contains(bankName.replace(" ", ""))) {
return branchName;
}
return bankName + branchName;
}
private double centToYuan(long cent) {
return Math.max(0L, cent) / 100D;
}
private long firstPositiveLong(Object... values) {
if (values == null) {
return 0L;
}
for (Object value : values) {
Long parsed = longValue(value);
if (parsed != null && parsed > 0L) {
return parsed;
}
}
return 0L;
}
private String firstNonEmpty(String... values) {
if (values == null) {
return "";
}
for (String value : values) {
String text = stringValue(value);
if (!text.isEmpty()) {
return text;
}
}
return "";
}
private String placeholders(int count) {
return String.join(",", Collections.nCopies(count, "?"));
}
private Map<String, Object> parseJsonObject(String json) {
if (json == null || json.trim().isEmpty()) {
return new LinkedHashMap<String, Object>();
}
try {
return objectMapper.readValue(json, new TypeReference<Map<String, Object>>() {});
} catch (Exception ex) {
return new LinkedHashMap<String, Object>();
}
}
private List<Map<String, Object>> listOfMap(Object value) {
if (!(value instanceof List)) {
return Collections.emptyList();
}
List<Map<String, Object>> result = new ArrayList<Map<String, Object>>();
for (Object item : (List<?>) value) {
if (item instanceof Map) {
result.add((Map<String, Object>) item);
}
}
return result;
}
private Map<String, Object> mapValue(Object value) {
if (value instanceof Map) {
return (Map<String, Object>) value;
}
return Collections.emptyMap();
}
private String stringValue(Object value) {
return value == null ? "" : String.valueOf(value).trim();
}
private Long longValue(Object value) {
if (value instanceof Number) {
return ((Number) value).longValue();
}
try {
String text = stringValue(value);
return text.isEmpty() ? null : Long.valueOf(text);
} catch (Exception ex) {
return null;
}
}
private static class MeetingExpertSnapshot {
private final Long expertId;
private final String expertName;
private final String phone;
private final String organization;
private MeetingExpertSnapshot(Long expertId, String expertName, String phone, String organization) {
this.expertId = expertId;
this.expertName = expertName;
this.phone = phone;
this.organization = organization;
}
}
private static class PlatformExpertSnapshot {
private final Long expertId;
private final String expertName;
private final String phone;
private final String idNo;
private final String organization;
private PlatformExpertSnapshot(Long expertId,
String expertName,
String phone,
String idNo,
String organization) {
this.expertId = expertId;
this.expertName = expertName;
this.phone = phone;
this.idNo = idNo;
this.organization = organization;
}
}
private static class BankCardSnapshot {
private final Long expertId;
private final String bankName;
private final String bankProvince;
private final String bankCity;
private final String bankBranchName;
private final String bankCardNo;
private final String accountName;
private BankCardSnapshot(Long expertId,
String bankName,
String bankProvince,
String bankCity,
String bankBranchName,
String bankCardNo,
String accountName) {
this.expertId = expertId;
this.bankName = bankName;
this.bankProvince = bankProvince;
this.bankCity = bankCity;
this.bankBranchName = bankBranchName;
this.bankCardNo = bankCardNo;
this.accountName = accountName;
}
}
private static class LaborSummaryRow {
private int sequenceNo;
private String bankName = "";
private String bankProvince = "";
private String bankCity = "";
private String bankCardNo = "";
private String name = "";
private double netAmountYuan;
private String remark = "";
private double taxAmountYuan;
private double grossAmountYuan;
private String organization = "";
private String phone = "";
private String idNo = "";
private String department = "";
private String task = "";
}
private static class Styles {
private final CellStyle title;
private final CellStyle header;
private final CellStyle text;
private final CellStyle amount;
private final CellStyle total;
private final CellStyle totalAmount;
private Styles(CellStyle title,
CellStyle header,
CellStyle text,
CellStyle amount,
CellStyle total,
CellStyle totalAmount) {
this.title = title;
this.header = header;
this.text = text;
this.amount = amount;
this.total = total;
this.totalAmount = totalAmount;
}
}
}
@@ -51,11 +51,15 @@ public class MeetingMaterialExportService {
private final JdbcTemplate jdbcTemplate; private final JdbcTemplate jdbcTemplate;
private final OssService ossService; private final OssService ossService;
private final MeetingLaborSummaryExportService meetingLaborSummaryExportService;
private final ObjectMapper objectMapper = new ObjectMapper(); private final ObjectMapper objectMapper = new ObjectMapper();
public MeetingMaterialExportService(JdbcTemplate jdbcTemplate, OssService ossService) { public MeetingMaterialExportService(JdbcTemplate jdbcTemplate,
OssService ossService,
MeetingLaborSummaryExportService meetingLaborSummaryExportService) {
this.jdbcTemplate = jdbcTemplate; this.jdbcTemplate = jdbcTemplate;
this.ossService = ossService; this.ossService = ossService;
this.meetingLaborSummaryExportService = meetingLaborSummaryExportService;
} }
public byte[] buildZip(Long tenantId, Long meetingId) { public byte[] buildZip(Long tenantId, Long meetingId) {
@@ -104,6 +108,24 @@ public class MeetingMaterialExportService {
List<ExportAttachment> attachments = extractAttachments(moduleSpec.moduleCode, parsedJson); List<ExportAttachment> attachments = extractAttachments(moduleSpec.moduleCode, parsedJson);
moduleManifest.put("jsonEntryPath", jsonEntryPath); moduleManifest.put("jsonEntryPath", jsonEntryPath);
moduleManifest.put("attachmentCount", attachments.size()); moduleManifest.put("attachmentCount", attachments.size());
int generatedFileCount = 0;
if ("EXPERT_LIST".equalsIgnoreCase(moduleSpec.moduleCode)) {
String laborSummaryFileName = "劳务汇总表.xlsx";
String laborSummaryEntryPath = uniqueEntryPath(moduleFolder, laborSummaryFileName, usedEntries);
writeBinaryEntry(zipOutputStream, laborSummaryEntryPath, meetingLaborSummaryExportService.buildWorkbook(tenantId, meetingId));
manifestFiles.add(buildManifestFile(
moduleSpec.moduleCode,
moduleSpec.folderName.substring(3),
"生成文件",
laborSummaryFileName,
null,
laborSummaryEntryPath,
"SUCCESS",
null
));
generatedFileCount++;
}
for (ExportAttachment attachment : attachments) { for (ExportAttachment attachment : attachments) {
attemptedAttachmentCount++; attemptedAttachmentCount++;
@@ -123,6 +145,9 @@ public class MeetingMaterialExportService {
failedAttachmentCount++; failedAttachmentCount++;
} }
} }
if (generatedFileCount > 0) {
moduleManifest.put("generatedFileCount", generatedFileCount);
}
moduleManifest.put("successAttachmentCount", successAttachmentCount); moduleManifest.put("successAttachmentCount", successAttachmentCount);
moduleManifest.put("failedAttachmentCount", failedAttachmentCount); moduleManifest.put("failedAttachmentCount", failedAttachmentCount);
manifestModules.add(moduleManifest); manifestModules.add(moduleManifest);
File diff suppressed because it is too large Load Diff
@@ -17,8 +17,11 @@ import com.writeoff.module.meeting.dto.SubmitMeetingRequest;
import com.writeoff.module.meeting.dto.WithdrawMeetingRequest; import com.writeoff.module.meeting.dto.WithdrawMeetingRequest;
import com.writeoff.module.meeting.model.Meeting; import com.writeoff.module.meeting.model.Meeting;
import com.writeoff.module.meeting.model.MeetingAuditStatus; import com.writeoff.module.meeting.model.MeetingAuditStatus;
import com.writeoff.module.meeting.model.MeetingSubmissionVersion;
import com.writeoff.module.meeting.model.MeetingStatus; import com.writeoff.module.meeting.model.MeetingStatus;
import com.writeoff.module.meeting.repository.MeetingRepository; import com.writeoff.module.meeting.repository.MeetingRepository;
import com.writeoff.module.notification.dto.DispatchNotificationRequest;
import com.writeoff.module.notification.service.NotificationDispatchService;
import com.writeoff.module.project.model.Project; import com.writeoff.module.project.model.Project;
import com.writeoff.module.project.service.ProjectService; import com.writeoff.module.project.service.ProjectService;
import com.writeoff.module.scheduler.service.AsyncJobService; import com.writeoff.module.scheduler.service.AsyncJobService;
@@ -26,10 +29,15 @@ import com.writeoff.module.system.model.BizChangeLogInfo;
import com.writeoff.module.system.service.BizChangeLogService; import com.writeoff.module.system.service.BizChangeLogService;
import com.writeoff.module.system.service.DataPermissionService; import com.writeoff.module.system.service.DataPermissionService;
import com.writeoff.security.AuthContext; import com.writeoff.security.AuthContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.Arrays;
import java.util.List; import java.util.List;
import java.util.HashSet; import java.util.HashSet;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
@@ -38,12 +46,16 @@ import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException; import java.time.format.DateTimeParseException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections;
import java.util.Set; import java.util.Set;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.math.BigDecimal;
import java.math.RoundingMode;
@Service @Service
public class MeetingService { public class MeetingService {
private static final Logger log = LoggerFactory.getLogger(MeetingService.class);
private static final DateTimeFormatter SQL_ISO_SECOND_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss"); private static final DateTimeFormatter SQL_ISO_SECOND_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss");
private static final Set<String> LOCATION_OPTIONS = new HashSet<String>(); private static final Set<String> LOCATION_OPTIONS = new HashSet<String>();
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
@@ -59,12 +71,17 @@ public class MeetingService {
private final AuditFlowConfigService auditFlowConfigService; private final AuditFlowConfigService auditFlowConfigService;
private final DataPermissionService dataPermissionService; private final DataPermissionService dataPermissionService;
private final ExpertSnapshotService expertSnapshotService; private final ExpertSnapshotService expertSnapshotService;
private final NotificationDispatchService notificationDispatchService;
private final BizChangeLogService bizChangeLogService; private final BizChangeLogService bizChangeLogService;
private final MeetingMaterialService meetingMaterialService;
private final MeetingSubmissionVersionService meetingSubmissionVersionService;
private final MeetingIssueResponseService meetingIssueResponseService;
private final MeetingVersionChangeService meetingVersionChangeService;
private final Map<String, Long> submitIdempotency = new ConcurrentHashMap<>(); private final Map<String, Long> submitIdempotency = new ConcurrentHashMap<>();
private final Map<String, Long> withdrawIdempotency = new ConcurrentHashMap<>(); private final Map<String, Long> withdrawIdempotency = new ConcurrentHashMap<>();
@Autowired @Autowired
public MeetingService(MeetingRepository meetingRepository, ProjectService projectService, AuditTaskRepository auditTaskRepository, AsyncJobService asyncJobService, AuditFlowConfigService auditFlowConfigService, DataPermissionService dataPermissionService, ExpertSnapshotService expertSnapshotService, BizChangeLogService bizChangeLogService) { public MeetingService(MeetingRepository meetingRepository, ProjectService projectService, AuditTaskRepository auditTaskRepository, AsyncJobService asyncJobService, AuditFlowConfigService auditFlowConfigService, DataPermissionService dataPermissionService, ExpertSnapshotService expertSnapshotService, NotificationDispatchService notificationDispatchService, BizChangeLogService bizChangeLogService, @Lazy MeetingMaterialService meetingMaterialService, @Lazy MeetingSubmissionVersionService meetingSubmissionVersionService, @Lazy MeetingIssueResponseService meetingIssueResponseService, @Lazy MeetingVersionChangeService meetingVersionChangeService) {
this.meetingRepository = meetingRepository; this.meetingRepository = meetingRepository;
this.projectService = projectService; this.projectService = projectService;
this.auditTaskRepository = auditTaskRepository; this.auditTaskRepository = auditTaskRepository;
@@ -72,11 +89,16 @@ public class MeetingService {
this.auditFlowConfigService = auditFlowConfigService; this.auditFlowConfigService = auditFlowConfigService;
this.dataPermissionService = dataPermissionService; this.dataPermissionService = dataPermissionService;
this.expertSnapshotService = expertSnapshotService; this.expertSnapshotService = expertSnapshotService;
this.notificationDispatchService = notificationDispatchService;
this.bizChangeLogService = bizChangeLogService; this.bizChangeLogService = bizChangeLogService;
this.meetingMaterialService = meetingMaterialService;
this.meetingSubmissionVersionService = meetingSubmissionVersionService;
this.meetingIssueResponseService = meetingIssueResponseService;
this.meetingVersionChangeService = meetingVersionChangeService;
} }
public MeetingService(MeetingRepository meetingRepository, ProjectService projectService, AuditTaskRepository auditTaskRepository, AsyncJobService asyncJobService) { public MeetingService(MeetingRepository meetingRepository, ProjectService projectService, AuditTaskRepository auditTaskRepository, AsyncJobService asyncJobService) {
this(meetingRepository, projectService, auditTaskRepository, asyncJobService, null, null, null, null); this(meetingRepository, projectService, auditTaskRepository, asyncJobService, null, null, null, null, null, null, null, null, null);
} }
public PageResult<Meeting> list(MeetingQueryRequest query) { public PageResult<Meeting> list(MeetingQueryRequest query) {
@@ -100,7 +122,15 @@ public class MeetingService {
} }
list.forEach(this::applyEffectiveStatus); list.forEach(this::applyEffectiveStatus);
list = applyFilters(list, query); list = applyFilters(list, query);
return new PageResult<>(list, list.size(), 1, 20); int safePageNo = normalizePageNo(query == null ? null : query.getPageNo());
int safePageSize = normalizePageSize(query == null ? null : query.getPageSize());
int total = list.size();
int from = (safePageNo - 1) * safePageSize;
if (from >= total) {
return new PageResult<>(Collections.emptyList(), total, safePageNo, safePageSize);
}
int to = Math.min(from + safePageSize, total);
return new PageResult<>(list.subList(from, to), total, safePageNo, safePageSize);
} }
private List<Meeting> applyFilters(List<Meeting> source, MeetingQueryRequest query) { private List<Meeting> applyFilters(List<Meeting> source, MeetingQueryRequest query) {
@@ -176,15 +206,29 @@ public class MeetingService {
return null; return null;
} }
private int normalizePageNo(Integer pageNo) {
return pageNo == null || pageNo < 1 ? 1 : pageNo;
}
private int normalizePageSize(Integer pageSize) {
if (pageSize == null || pageSize < 1) {
return 20;
}
return Math.min(pageSize, 200);
}
public Meeting create(CreateMeetingRequest request) { public Meeting create(CreateMeetingRequest request) {
Project project = projectService.getById(request.getProjectId()); Project project = projectService.getById(request.getProjectId());
validateProjectForMeetingCreate(project); validateProjectForMeetingCreate(project, true);
validateMeetingTimeInProjectCycle(project, request); validateMeetingTimeInProjectCycle(project, request);
int existingMeetingCount = countMeetingsByProjectId(project.getId()); int existingMeetingCount = countMeetingsByProjectId(project.getId());
if (existingMeetingCount >= project.getMeetingTotal()) { if (existingMeetingCount >= project.getMeetingTotal()) {
throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "项目可创建的会议数量已达上限"); throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "项目可创建的会议数量已达上限");
} }
validateLocation(request.getLocation()); validateLocation(request.getLocation());
double laborRatio = request.getLaborRatio() == null ? project.getLaborFeeRatio() : normalizeRatio(request.getLaborRatio(), "劳务占比");
double cateringRatio = request.getCateringRatio() == null ? project.getCateringFeeRatio() : normalizeRatio(request.getCateringRatio(), "餐费占比");
assertMeetingRatiosWithinProject(project, laborRatio, cateringRatio);
long defaultBudgetCent = calculateDefaultMeetingBudgetCent(project); long defaultBudgetCent = calculateDefaultMeetingBudgetCent(project);
if (defaultBudgetCent <= 0L) { if (defaultBudgetCent <= 0L) {
throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "默认会议预算必须大于 0"); throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "默认会议预算必须大于 0");
@@ -200,8 +244,8 @@ public class MeetingService {
request.getStartTime(), request.getStartTime(),
request.getEndTime(), request.getEndTime(),
defaultBudgetCent, defaultBudgetCent,
request.getLaborRatio() == null ? 0d : request.getLaborRatio(), laborRatio,
request.getCateringRatio() == null ? 0d : request.getCateringRatio(), cateringRatio,
MeetingStatus.NOT_STARTED, MeetingStatus.NOT_STARTED,
MeetingAuditStatus.PENDING, MeetingAuditStatus.PENDING,
null, null,
@@ -231,7 +275,7 @@ public class MeetingService {
return saved; return saved;
} }
private void validateProjectForMeetingCreate(Project project) { private void validateProjectForMeetingCreate(Project project, boolean forCreate) {
if (project.getMeetingTotal() <= 0) { if (project.getMeetingTotal() <= 0) {
throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "创建会议前请先配置项目会议场次"); throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "创建会议前请先配置项目会议场次");
} }
@@ -240,6 +284,9 @@ public class MeetingService {
if (startDate == null || endDate == null || endDate.isBefore(startDate)) { if (startDate == null || endDate == null || endDate.isBefore(startDate)) {
throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "创建会议前请先配置有效的项目起止日期"); throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "创建会议前请先配置有效的项目起止日期");
} }
if (forCreate && projectService.hasChildren(project.getId())) {
throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "存在子项目的项目下不可创建会议");
}
} }
private int countMeetingsByProjectId(Long projectId) { private int countMeetingsByProjectId(Long projectId) {
@@ -249,6 +296,25 @@ public class MeetingService {
.count(); .count();
} }
private double normalizeRatio(Double value, String fieldName) {
double normalized = value == null ? 0d : value;
if (Double.isNaN(normalized) || Double.isInfinite(normalized) || normalized < 0d || normalized > 1d) {
throw new BusinessException(ErrorCodes.VALIDATION_ERROR, fieldName + "只能在0~1之间");
}
return BigDecimal.valueOf(normalized).setScale(6, RoundingMode.HALF_UP).doubleValue();
}
private void assertMeetingRatiosWithinProject(Project project, double laborRatio, double cateringRatio) {
double projectLaborRatio = normalizeRatio(project.getLaborFeeRatio(), "项目劳务费用占比");
double projectCateringRatio = normalizeRatio(project.getCateringFeeRatio(), "项目餐费占比");
if (laborRatio > projectLaborRatio) {
throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "会议劳务占比不能高于项目劳务费用占比");
}
if (cateringRatio > projectCateringRatio) {
throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "会议餐费占比不能高于项目餐费占比");
}
}
private long calculateDefaultMeetingBudgetCent(Project project) { private long calculateDefaultMeetingBudgetCent(Project project) {
long projectBudgetCent = Math.max(0L, project.getBudgetCent()); long projectBudgetCent = Math.max(0L, project.getBudgetCent());
ProjectFeeSummary feeSummary = parseProjectFeeSummary(project.getProjectFeeJson()); ProjectFeeSummary feeSummary = parseProjectFeeSummary(project.getProjectFeeJson());
@@ -321,9 +387,12 @@ public class MeetingService {
throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "编辑会议时不能修改所属项目"); throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "编辑会议时不能修改所属项目");
} }
Project project = projectService.getById(existing.getProjectId()); Project project = projectService.getById(existing.getProjectId());
validateProjectForMeetingCreate(project); validateProjectForMeetingCreate(project, false);
validateMeetingTimeInProjectCycle(project, request); validateMeetingTimeInProjectCycle(project, request);
validateLocation(request.getLocation()); validateLocation(request.getLocation());
double laborRatio = request.getLaborRatio() == null ? existing.getLaborRatio() : normalizeRatio(request.getLaborRatio(), "劳务占比");
double cateringRatio = request.getCateringRatio() == null ? existing.getCateringRatio() : normalizeRatio(request.getCateringRatio(), "餐费占比");
assertMeetingRatiosWithinProject(project, laborRatio, cateringRatio);
Meeting updated = new Meeting( Meeting updated = new Meeting(
existing.getId(), existing.getId(),
existing.getProjectId(), existing.getProjectId(),
@@ -334,8 +403,8 @@ public class MeetingService {
request.getStartTime(), request.getStartTime(),
request.getEndTime(), request.getEndTime(),
request.getBudgetCent(), request.getBudgetCent(),
request.getLaborRatio() == null ? 0d : request.getLaborRatio(), laborRatio,
request.getCateringRatio() == null ? 0d : request.getCateringRatio(), cateringRatio,
existing.getStatus(), existing.getStatus(),
existing.getAuditStatus(), existing.getAuditStatus(),
existing.getCurrentAuditNode(), existing.getCurrentAuditNode(),
@@ -397,6 +466,7 @@ public class MeetingService {
} }
} }
@Transactional
public Map<String, Object> submit(Long meetingId, SubmitMeetingRequest request) { public Map<String, Object> submit(Long meetingId, SubmitMeetingRequest request) {
Meeting meeting = meetingRepository.findById(meetingId) Meeting meeting = meetingRepository.findById(meetingId)
.orElseThrow(() -> new BusinessException(ErrorCodes.RESOURCE_NOT_FOUND, "会议不存在")); .orElseThrow(() -> new BusinessException(ErrorCodes.RESOURCE_NOT_FOUND, "会议不存在"));
@@ -405,6 +475,14 @@ public class MeetingService {
throw new BusinessException(ErrorCodes.IDEMPOTENCY_CONFLICT, "请求重复,请勿重复提交"); throw new BusinessException(ErrorCodes.IDEMPOTENCY_CONFLICT, "请求重复,请勿重复提交");
} }
submitIdempotency.put(request.getIdempotencyKey(), meetingId); submitIdempotency.put(request.getIdempotencyKey(), meetingId);
log.info(
"meeting submit review start, tenantId={}, meetingId={}, operatorUserId={}, remark={}, issueResponseCount={}",
tenantId(),
meetingId,
safeUserId(),
request == null ? null : request.getRemark(),
request == null || request.getIssueResponses() == null ? 0 : request.getIssueResponses().size()
);
MeetingStatus effectiveStatus = resolveEffectiveStatus(meeting); MeetingStatus effectiveStatus = resolveEffectiveStatus(meeting);
if (effectiveStatus != MeetingStatus.COMPLETED) { if (effectiveStatus != MeetingStatus.COMPLETED) {
@@ -417,9 +495,44 @@ public class MeetingService {
if (meeting.getAuditStatus() == MeetingAuditStatus.IN_REVIEW) { if (meeting.getAuditStatus() == MeetingAuditStatus.IN_REVIEW) {
throw new BusinessException(ErrorCodes.INVALID_STATE, "会议正在审核中"); throw new BusinessException(ErrorCodes.INVALID_STATE, "会议正在审核中");
} }
if (meetingIssueResponseService != null) {
meetingIssueResponseService.validateResponsesRequired(meetingId, request.getIssueResponses());
}
if (expertSnapshotService != null) { if (expertSnapshotService != null) {
expertSnapshotService.snapshotOnMeetingSubmit(meetingId); expertSnapshotService.snapshotOnMeetingSubmit(meetingId);
} }
if (meetingMaterialService != null) {
meetingMaterialService.markAllMaterialsSubmitted(meetingId, request.getRemark());
}
Long previousSubmissionVersionId = meetingSubmissionVersionService == null
? null
: meetingSubmissionVersionService.findLatestVersionIdBeforeCreate(meetingId);
Integer previousVersionNo = meetingSubmissionVersionService == null
? null
: meetingSubmissionVersionService.findLatestVersionNo(meetingId);
MeetingSubmissionVersion submissionVersion = meetingSubmissionVersionService == null
? null
: meetingSubmissionVersionService.create(meetingId, request.getRemark());
if (submissionVersion != null && meetingVersionChangeService != null && meetingMaterialService != null) {
Map<String, String> currentModuleContentMap = new LinkedHashMap<>();
for (String moduleCode : Arrays.asList("BASIC_INFO", "WRITE_OFF_DOCS", "EXPERT_PROFILE", "EXPERT_LIST", "MEETING_INVOICE")) {
currentModuleContentMap.put(moduleCode, meetingMaterialService.currentContentJsonOrEmpty(meetingId, moduleCode));
}
meetingVersionChangeService.buildAndSaveChangeSet(
meetingId,
previousSubmissionVersionId,
previousVersionNo,
submissionVersion.getId(),
currentModuleContentMap
);
}
if (meetingIssueResponseService != null) {
meetingIssueResponseService.saveMeetingResponses(
meetingId,
submissionVersion == null ? null : submissionVersion.getId(),
request.getIssueResponses()
);
}
meeting.setAuditStatus(MeetingAuditStatus.IN_REVIEW); meeting.setAuditStatus(MeetingAuditStatus.IN_REVIEW);
meetingRepository.save(meeting); meetingRepository.save(meeting);
@@ -434,7 +547,8 @@ public class MeetingService {
if (bizChangeLogService != null) { if (bizChangeLogService != null) {
bizChangeLogService.logAction("MEETING", meetingId, "MEETING_SUBMIT", request.getRemark()); bizChangeLogService.logAction("MEETING", meetingId, "MEETING_SUBMIT", request.getRemark());
} }
auditTaskRepository.save(new AuditTask( AuditTask previousTask = auditTaskRepository.findLatestByMeetingId(meetingId).orElse(null);
AuditTask createdTask = auditTaskRepository.save(new AuditTask(
null, null,
meetingId, meetingId,
firstNode, firstNode,
@@ -442,19 +556,65 @@ public class MeetingService {
AuditTaskStatus.PENDING, AuditTaskStatus.PENDING,
request.getRemark() request.getRemark()
)); ));
createdTask.setSubmissionVersionId(submissionVersion == null ? null : submissionVersion.getId());
createdTask = auditTaskRepository.save(createdTask);
if (meetingMaterialService != null && previousTask != null && previousTask.getId() != null && previousTask.getId() > 0L) {
try {
meetingMaterialService.inheritApprovedItemReviews(
meetingId,
previousTask.getId(),
createdTask.getId(),
previousTask.getNode() == null ? firstNode.name() : previousTask.getNode().name(),
firstNode.name(),
previousTask.getSubmissionVersionId(),
createdTask.getSubmissionVersionId()
);
} catch (Exception ex) {
log.warn(
"inherit approved material reviews on meeting submit failed, tenantId={}, meetingId={}, previousTaskId={}, createdTaskId={}",
tenantId(),
meetingId,
previousTask.getId(),
createdTask.getId(),
ex
);
}
}
asyncJobService.enqueue( asyncJobService.enqueue(
"AUDIT_REMIND", "AUDIT_REMIND",
"meetingId=" + meetingId, "meetingId=" + meetingId,
"job-audit-remind-" + meetingId + "-" + request.getIdempotencyKey() "job-audit-remind-" + meetingId + "-" + request.getIdempotencyKey()
); );
triggerAuditTaskAssignedNotification(meeting, createdTask);
Map<String, Object> result = new LinkedHashMap<>(); Map<String, Object> result = new LinkedHashMap<>();
result.put("meetingId", meetingId); result.put("meetingId", meetingId);
result.put("auditStatus", meeting.getAuditStatus().name()); result.put("auditStatus", meeting.getAuditStatus().name());
result.put("currentNode", firstNode.name()); result.put("currentNode", firstNode.name());
result.put("submissionVersionId", submissionVersion == null ? null : submissionVersion.getId());
log.info(
"meeting submit review success, tenantId={}, meetingId={}, operatorUserId={}, submissionVersionId={}, previousSubmissionVersionId={}, previousVersionNo={}, createdTaskId={}, firstNode={}, assigneeUserId={}",
tenantId(),
meetingId,
safeUserId(),
submissionVersion == null ? null : submissionVersion.getId(),
previousSubmissionVersionId,
previousVersionNo,
createdTask == null ? null : createdTask.getId(),
firstNode,
assigneeUserId
);
return result; return result;
} }
public List<Map<String, Object>> listPendingIssues(Long meetingId) {
getById(meetingId);
if (meetingMaterialService == null) {
return Collections.emptyList();
}
return meetingMaterialService.listMeetingPendingResubmitIssues(meetingId);
}
public Map<String, Object> withdraw(Long meetingId, WithdrawMeetingRequest request) { public Map<String, Object> withdraw(Long meetingId, WithdrawMeetingRequest request) {
Meeting meeting = meetingRepository.findById(meetingId) Meeting meeting = meetingRepository.findById(meetingId)
.orElseThrow(() -> new BusinessException(ErrorCodes.RESOURCE_NOT_FOUND, "会议不存在")); .orElseThrow(() -> new BusinessException(ErrorCodes.RESOURCE_NOT_FOUND, "会议不存在"));
@@ -492,7 +652,7 @@ public class MeetingService {
.orElseThrow(() -> new BusinessException(ErrorCodes.RESOURCE_NOT_FOUND, "会议不存在")); .orElseThrow(() -> new BusinessException(ErrorCodes.RESOURCE_NOT_FOUND, "会议不存在"));
MeetingStatus effectiveStatus = resolveEffectiveStatus(meeting); MeetingStatus effectiveStatus = resolveEffectiveStatus(meeting);
// 婵炲濮撮幊搴g礊鐎n兘鍋撻崗澶婂⒉闁绘濞婇幃鈺呮嚋绾版ê浜惧ù锝囨焿缁€?PENDING闂佹寧绋戦悧鍡楃暤閸℃顩烽幖娣灪瀵捇鏌熺紒妯哄婵﹫闄勫濠氬炊妞嬪海顦繛鎴炴尰濮婄懓锕㈤鐘冲仏妞ゆ劑鍨归弸娆戠磽娴h灏版俊鐐插€垮畷妤佹媴缁涘鏅犻梺鍛婂笧婵炩偓婵? // 只有待审核且从未提交过的草稿会议才允许删除。
if (meeting.getAuditStatus() != MeetingAuditStatus.PENDING) { if (meeting.getAuditStatus() != MeetingAuditStatus.PENDING) {
throw new BusinessException(ErrorCodes.INVALID_STATE, "只有待审核的草稿会议才可删除"); throw new BusinessException(ErrorCodes.INVALID_STATE, "只有待审核的草稿会议才可删除");
} }
@@ -592,6 +752,49 @@ public class MeetingService {
meetingRepository.save(meeting); meetingRepository.save(meeting);
} }
public void triggerAuditTaskAssignedNotification(AuditTask task) {
if (task == null || task.getMeetingId() == null || task.getMeetingId() <= 0L) {
return;
}
Meeting meeting = meetingRepository.findById(task.getMeetingId()).orElse(null);
if (meeting == null) {
return;
}
triggerAuditTaskAssignedNotification(meeting, task);
}
private void triggerAuditTaskAssignedNotification(Meeting meeting, AuditTask task) {
if (notificationDispatchService == null || meeting == null || task == null) {
return;
}
Long assigneeUserId = task.getAssigneeUserId();
if (assigneeUserId == null || assigneeUserId <= 0L) {
return;
}
try {
Map<String, Object> vars = new LinkedHashMap<String, Object>();
vars.put("meetingId", meeting.getId());
vars.put("meetingTopic", meeting.getTopic() == null ? "" : meeting.getTopic());
vars.put("auditNode", task.getNode() == null ? "" : task.getNode().name());
vars.put("auditTaskId", task.getId() == null ? 0L : task.getId());
vars.put("assigneeUserId", assigneeUserId);
DispatchNotificationRequest dispatchRequest = new DispatchNotificationRequest();
dispatchRequest.setIdempotencyKey("audit-auto-notify-AUDIT_TASK_ASSIGNED-" + (task.getId() == null ? 0L : task.getId()));
dispatchRequest.setEventCode("AUDIT_TASK_ASSIGNED");
dispatchRequest.setBizType("MEETING");
dispatchRequest.setBizId("meeting-" + meeting.getId());
dispatchRequest.setVariablesJson(OBJECT_MAPPER.writeValueAsString(vars));
notificationDispatchService.dispatch(dispatchRequest);
} catch (BusinessException ex) {
if (ex.getCode() != ErrorCodes.RESOURCE_NOT_FOUND) {
log.warn("自动触发审核任务分配通知失败, taskId={}, code={}, msg={}", task.getId(), ex.getCode(), ex.getMessage());
}
} catch (Exception ex) {
log.warn("自动触发审核任务分配通知异常, taskId={}", task.getId(), ex);
}
}
public Meeting updateInvoiceConfig(Long meetingId, com.writeoff.module.meeting.dto.MeetingInvoiceConfigRequest request) { public Meeting updateInvoiceConfig(Long meetingId, com.writeoff.module.meeting.dto.MeetingInvoiceConfigRequest request) {
Meeting meeting = getById(meetingId); Meeting meeting = getById(meetingId);
String beforeConfigJson = meeting.getInvoiceConfigJson(); String beforeConfigJson = meeting.getInvoiceConfigJson();
@@ -0,0 +1,249 @@
package com.writeoff.module.meeting.service;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.writeoff.common.exception.BusinessException;
import com.writeoff.common.exception.ErrorCodes;
import com.writeoff.module.meeting.model.Meeting;
import com.writeoff.module.meeting.model.MeetingSubmissionVersion;
import com.writeoff.module.meeting.repository.MeetingRepository;
import com.writeoff.security.AuthContext;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.sql.PreparedStatement;
import java.sql.Statement;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@Service
public class MeetingSubmissionVersionService {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private static final RowMapper<MeetingSubmissionVersion> ROW_MAPPER = (rs, n) -> new MeetingSubmissionVersion(
rs.getLong("id"),
rs.getLong("meeting_id"),
rs.getInt("version_no"),
rs.getString("remark"),
rs.getString("snapshot_json"),
rs.getObject("created_by") == null ? null : rs.getLong("created_by"),
rs.getString("created_by_name"),
rs.getString("created_at")
);
private final JdbcTemplate jdbcTemplate;
private final MeetingRepository meetingRepository;
public MeetingSubmissionVersionService(JdbcTemplate jdbcTemplate,
MeetingRepository meetingRepository) {
this.jdbcTemplate = jdbcTemplate;
this.meetingRepository = meetingRepository;
}
@Transactional
public MeetingSubmissionVersion create(Long meetingId, String remark) {
Meeting meeting = meetingRepository.findById(meetingId)
.orElseThrow(() -> new BusinessException(ErrorCodes.RESOURCE_NOT_FOUND, "会议不存在"));
int nextVersionNo = nextVersionNo(meetingId);
String snapshotJson = buildSnapshotJson(meetingId, meeting, remark, nextVersionNo);
KeyHolder keyHolder = new GeneratedKeyHolder();
jdbcTemplate.update(connection -> {
PreparedStatement ps = connection.prepareStatement(
"INSERT INTO meeting_submission_version (tenant_id, meeting_id, version_no, remark, snapshot_json, created_by, updated_by) " +
"VALUES (?, ?, ?, ?, ?, ?, ?)",
Statement.RETURN_GENERATED_KEYS
);
Long operator = safeUserId();
ps.setLong(1, tenantId());
ps.setLong(2, meetingId);
ps.setInt(3, nextVersionNo);
ps.setString(4, remark);
ps.setString(5, snapshotJson);
ps.setLong(6, operator);
ps.setLong(7, operator);
return ps;
}, keyHolder);
Number key = keyHolder.getKey();
Long id = key == null ? null : key.longValue();
return getById(id).orElseThrow(() -> new BusinessException(10001, "提交版本创建失败"));
}
public Optional<MeetingSubmissionVersion> getById(Long id) {
if (id == null || id <= 0L) {
return Optional.empty();
}
List<MeetingSubmissionVersion> rows = jdbcTemplate.query(
"SELECT msv.id, msv.meeting_id, msv.version_no, msv.remark, msv.snapshot_json, msv.created_by, su.user_name AS created_by_name, " +
"DATE_FORMAT(msv.created_at, '%Y-%m-%d %H:%i:%s') AS created_at " +
"FROM meeting_submission_version msv " +
"LEFT JOIN sys_user su ON su.tenant_id=msv.tenant_id AND su.id=msv.created_by AND su.is_deleted=0 " +
"WHERE msv.tenant_id=? AND msv.id=?",
ROW_MAPPER,
tenantId(),
id
);
return rows.stream().findFirst();
}
public Optional<MeetingSubmissionVersion> findByMeetingIdAndVersionNo(Long meetingId, Integer versionNo) {
if (meetingId == null || meetingId <= 0L || versionNo == null || versionNo <= 0) {
return Optional.empty();
}
List<MeetingSubmissionVersion> rows = jdbcTemplate.query(
"SELECT msv.id, msv.meeting_id, msv.version_no, msv.remark, msv.snapshot_json, msv.created_by, su.user_name AS created_by_name, " +
"DATE_FORMAT(msv.created_at, '%Y-%m-%d %H:%i:%s') AS created_at " +
"FROM meeting_submission_version msv " +
"LEFT JOIN sys_user su ON su.tenant_id=msv.tenant_id AND su.id=msv.created_by AND su.is_deleted=0 " +
"WHERE msv.tenant_id=? AND msv.meeting_id=? AND msv.version_no=? LIMIT 1",
ROW_MAPPER,
tenantId(),
meetingId,
versionNo
);
return rows.stream().findFirst();
}
public List<MeetingSubmissionVersion> listByMeetingId(Long meetingId) {
if (meetingId == null || meetingId <= 0L) {
return new java.util.ArrayList<>();
}
return jdbcTemplate.query(
"SELECT msv.id, msv.meeting_id, msv.version_no, msv.remark, msv.snapshot_json, msv.created_by, su.user_name AS created_by_name, " +
"DATE_FORMAT(msv.created_at, '%Y-%m-%d %H:%i:%s') AS created_at " +
"FROM meeting_submission_version msv " +
"LEFT JOIN sys_user su ON su.tenant_id=msv.tenant_id AND su.id=msv.created_by AND su.is_deleted=0 " +
"WHERE msv.tenant_id=? AND msv.meeting_id=? " +
"ORDER BY msv.version_no ASC, msv.id ASC",
ROW_MAPPER,
tenantId(),
meetingId
);
}
public Long findLatestVersionIdBeforeCreate(Long meetingId) {
if (meetingId == null || meetingId <= 0L) {
return null;
}
List<Long> rows = jdbcTemplate.query(
"SELECT id FROM meeting_submission_version WHERE tenant_id=? AND meeting_id=? ORDER BY version_no DESC LIMIT 1",
(rs, n) -> rs.getLong("id"),
tenantId(),
meetingId
);
return rows.isEmpty() ? null : rows.get(0);
}
public Integer findLatestVersionNo(Long meetingId) {
if (meetingId == null || meetingId <= 0L) {
return null;
}
List<Integer> rows = jdbcTemplate.query(
"SELECT version_no FROM meeting_submission_version WHERE tenant_id=? AND meeting_id=? ORDER BY version_no DESC LIMIT 1",
(rs, n) -> rs.getInt("version_no"),
tenantId(),
meetingId
);
return rows.isEmpty() ? null : rows.get(0);
}
private int nextVersionNo(Long meetingId) {
Integer latest = jdbcTemplate.query(
"SELECT MAX(version_no) FROM meeting_submission_version WHERE tenant_id=? AND meeting_id=?",
rs -> rs.next() ? rs.getInt(1) : 0,
tenantId(),
meetingId
);
return (latest == null ? 0 : latest) + 1;
}
private String buildSnapshotJson(Long meetingId, Meeting meeting, String remark, int versionNo) {
Map<String, Object> root = new LinkedHashMap<>();
root.put("meetingId", meetingId);
root.put("submissionVersionNo", versionNo);
root.put("remark", remark == null ? "" : remark);
Map<String, Object> meetingInfo = new LinkedHashMap<>();
meetingInfo.put("projectId", meeting.getProjectId());
meetingInfo.put("projectName", meeting.getProjectName());
meetingInfo.put("topic", meeting.getTopic());
meetingInfo.put("meetingCategory", meeting.getMeetingCategory());
meetingInfo.put("meetingForm", meeting.getMeetingForm());
meetingInfo.put("location", meeting.getLocation());
meetingInfo.put("startTime", meeting.getStartTime());
meetingInfo.put("endTime", meeting.getEndTime());
meetingInfo.put("budgetCent", meeting.getBudgetCent());
meetingInfo.put("laborRatio", meeting.getLaborRatio());
meetingInfo.put("cateringRatio", meeting.getCateringRatio());
root.put("meeting", meetingInfo);
Map<String, Object> materials = new LinkedHashMap<>();
for (String moduleCode : new String[] {"BASIC_INFO", "WRITE_OFF_DOCS", "EXPERT_LIST", "MEETING_INVOICE"}) {
materials.put(moduleCode, safeParseJson(loadCurrentMaterialContentJson(meetingId, moduleCode)));
}
materials.put("EXPERT_PROFILE", safeParseJson(extractExpertProfileJson(loadCurrentMaterialContentJson(meetingId, "WRITE_OFF_DOCS"))));
root.put("materials", materials);
try {
return OBJECT_MAPPER.writeValueAsString(root);
} catch (JsonProcessingException e) {
throw new BusinessException(10001, "提交版本快照序列化失败");
}
}
private Object safeParseJson(String json) {
if (json == null || json.trim().isEmpty()) {
return null;
}
try {
return OBJECT_MAPPER.readValue(json, Object.class);
} catch (Exception ex) {
return json;
}
}
private String extractExpertProfileJson(String contentJson) {
if (contentJson == null || contentJson.trim().isEmpty()) {
return contentJson;
}
try {
Object parsed = OBJECT_MAPPER.readValue(contentJson, Object.class);
if (!(parsed instanceof Map)) {
return contentJson;
}
Map<?, ?> root = (Map<?, ?>) parsed;
Object profileFile = root.get("profileFile");
if (profileFile == null && root.containsKey("ossKey")) {
return contentJson;
}
return OBJECT_MAPPER.writeValueAsString(profileFile == null ? root : profileFile);
} catch (Exception ex) {
return contentJson;
}
}
private String loadCurrentMaterialContentJson(Long meetingId, String moduleCode) {
List<String> rows = jdbcTemplate.query(
"SELECT COALESCE(NULLIF(draft_content_json, ''), content_json) AS effective_content_json " +
"FROM meeting_material WHERE tenant_id=? AND meeting_id=? AND module_code=? AND is_deleted=0 LIMIT 1",
(rs, n) -> rs.getString("effective_content_json"),
tenantId(),
meetingId,
moduleCode
);
return rows.isEmpty() ? "" : String.valueOf(rows.get(0) == null ? "" : rows.get(0));
}
private Long tenantId() {
return AuthContext.requireTenantId();
}
private Long safeUserId() {
Long userId = AuthContext.userId();
return userId == null ? 0L : userId;
}
}
@@ -0,0 +1,397 @@
package com.writeoff.module.meeting.service;
import com.writeoff.module.meeting.model.MeetingSubmissionVersion;
import com.writeoff.security.AuthContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.sql.PreparedStatement;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@Service
public class MeetingVersionChangeService {
private static final Logger log = LoggerFactory.getLogger(MeetingVersionChangeService.class);
private final JdbcTemplate jdbcTemplate;
private final MeetingMaterialService meetingMaterialService;
private final MeetingSubmissionVersionService meetingSubmissionVersionService;
public MeetingVersionChangeService(JdbcTemplate jdbcTemplate,
MeetingMaterialService meetingMaterialService,
MeetingSubmissionVersionService meetingSubmissionVersionService) {
this.jdbcTemplate = jdbcTemplate;
this.meetingMaterialService = meetingMaterialService;
this.meetingSubmissionVersionService = meetingSubmissionVersionService;
}
@Transactional
public Map<String, Object> buildAndSaveChangeSet(Long meetingId,
Long fromSubmissionVersionId,
Integer fromVersionNo,
Long toSubmissionVersionId,
Map<String, String> currentModuleContentMap) {
if (meetingId == null || meetingId <= 0L || toSubmissionVersionId == null || toSubmissionVersionId <= 0L) {
return Collections.emptyMap();
}
List<Map<String, Object>> allChanges = new ArrayList<>();
int changedModuleCount = 0;
int issueRelatedCount = 0;
int extraChangeCount = 0;
MeetingSubmissionVersion previousVersion = fromSubmissionVersionId == null || fromSubmissionVersionId <= 0L || meetingSubmissionVersionService == null
? null
: meetingSubmissionVersionService.getById(fromSubmissionVersionId).orElse(null);
Map<String, String> previousModuleContentMap = extractModuleContentMapFromVersion(previousVersion);
for (String moduleCode : supportedModules()) {
String currentContentJson = stringValue(currentModuleContentMap.get(moduleCode));
String previousContentJson = stringValue(previousModuleContentMap.get(moduleCode));
Map<String, Object> summary = meetingMaterialService.buildResubmitSummaryFromPreviousContent(
meetingId,
moduleCode,
currentContentJson,
resolveCurrentVersionNo(fromVersionNo),
previousContentJson,
fromVersionNo,
previousVersion == null ? "" : previousVersion.getCreatedAt(),
previousVersion == null ? "" : previousVersion.getRemark()
);
List<Map<String, Object>> changes = castRows(summary.get("changes"));
if (!changes.isEmpty()) {
changedModuleCount++;
}
for (Map<String, Object> change : changes) {
Map<String, Object> row = new LinkedHashMap<>(change);
row.put("moduleCode", moduleCode);
if (Boolean.TRUE.equals(change.get("relatedIssue"))) {
issueRelatedCount++;
} else {
extraChangeCount++;
}
allChanges.add(row);
}
}
final int finalChangedModuleCount = changedModuleCount;
final int finalChangedItemCount = allChanges.size();
final int finalIssueRelatedCount = issueRelatedCount;
final int finalExtraChangeCount = extraChangeCount;
KeyHolder keyHolder = new GeneratedKeyHolder();
jdbcTemplate.update(connection -> {
PreparedStatement ps = connection.prepareStatement(
"INSERT INTO version_change_set (tenant_id, meeting_id, from_submission_version_id, to_submission_version_id, changed_module_count, changed_item_count, issue_related_count, extra_change_count, created_by, updated_by) " +
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
Statement.RETURN_GENERATED_KEYS
);
ps.setLong(1, tenantId());
ps.setLong(2, meetingId);
if (fromSubmissionVersionId == null || fromSubmissionVersionId <= 0L) {
ps.setObject(3, null);
} else {
ps.setLong(3, fromSubmissionVersionId);
}
ps.setLong(4, toSubmissionVersionId);
ps.setInt(5, finalChangedModuleCount);
ps.setInt(6, finalChangedItemCount);
ps.setInt(7, finalIssueRelatedCount);
ps.setInt(8, finalExtraChangeCount);
ps.setLong(9, safeUserId());
ps.setLong(10, safeUserId());
return ps;
}, keyHolder);
Number key = keyHolder.getKey();
Long changeSetId = key == null ? null : key.longValue();
if (changeSetId != null && changeSetId > 0L) {
saveChangeItems(changeSetId, meetingId, allChanges);
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("changeSetId", changeSetId);
result.put("changedModuleCount", changedModuleCount);
result.put("changedItemCount", allChanges.size());
result.put("issueRelatedCount", issueRelatedCount);
result.put("extraChangeCount", extraChangeCount);
result.put("changes", allChanges);
log.info(
"meeting resubmit changeSet built, tenantId={}, meetingId={}, fromSubmissionVersionId={}, fromVersionNo={}, toSubmissionVersionId={}, changeSetId={}, changedModuleCount={}, changedItemCount={}, issueRelatedCount={}, extraChangeCount={}",
tenantId(),
meetingId,
fromSubmissionVersionId,
fromVersionNo,
toSubmissionVersionId,
changeSetId,
changedModuleCount,
allChanges.size(),
issueRelatedCount,
extraChangeCount
);
return result;
}
public Map<String, Object> findLatestBySubmissionVersionId(Long submissionVersionId) {
return findLatestBySubmissionVersionId(submissionVersionId, false);
}
@Transactional
public Map<String, Object> findLatestBySubmissionVersionId(Long submissionVersionId, boolean autoBackfill) {
if (submissionVersionId == null || submissionVersionId <= 0L) {
return Collections.emptyMap();
}
List<Map<String, Object>> rows = queryLatestChangeSetRows(submissionVersionId);
if (rows.isEmpty() && autoBackfill) {
Optional<MeetingSubmissionVersion> currentVersionOptional = meetingSubmissionVersionService.getById(submissionVersionId);
if (currentVersionOptional.isPresent()) {
MeetingSubmissionVersion currentVersion = currentVersionOptional.get();
MeetingSubmissionVersion previousVersion = resolvePreviousSubmissionVersion(
currentVersion.getMeetingId(),
currentVersion.getVersionNo()
);
if (currentVersion.getMeetingId() != null && currentVersion.getMeetingId() > 0L && currentVersion.getVersionNo() != null && currentVersion.getVersionNo() > 1) {
buildAndSaveChangeSetFromVersions(
currentVersion.getMeetingId(),
previousVersion == null ? null : previousVersion.getId(),
previousVersion == null ? null : previousVersion.getVersionNo(),
currentVersion
);
rows = queryLatestChangeSetRows(submissionVersionId);
}
}
}
if (rows.isEmpty()) {
return Collections.emptyMap();
}
Map<String, Object> set = new LinkedHashMap<>(rows.get(0));
Number changeSetIdNum = (Number) set.get("id");
Long changeSetId = changeSetIdNum == null ? null : changeSetIdNum.longValue();
if (changeSetId == null || changeSetId <= 0L) {
set.put("items", Collections.emptyList());
return set;
}
List<Map<String, Object>> items = jdbcTemplate.queryForList(
"SELECT id, module_code, target_path, target_label, target_kind, target_row_key, attachment_identity, attachment_hash, change_type, old_value, new_value, related_issue_id, is_extra_change, " +
"DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s') AS created_at " +
"FROM version_change_item WHERE tenant_id=? AND change_set_id=? ORDER BY id ASC",
tenantId(),
changeSetId
);
for (Map<String, Object> item : items) {
item.put("itemKey", stringValue(item.get("target_path")));
item.put("itemLabel", stringValue(item.get("target_label")));
item.put("targetKind", stringValue(item.get("target_kind")));
item.put("targetRowKey", stringValue(item.get("target_row_key")));
item.put("attachmentIdentity", stringValue(item.get("attachment_identity")));
item.put("attachmentHash", stringValue(item.get("attachment_hash")));
}
set.put("items", items);
return set;
}
@Transactional
public Map<String, Object> buildAndSaveChangeSetFromVersions(Long meetingId,
Long fromSubmissionVersionId,
Integer fromVersionNo,
MeetingSubmissionVersion toVersion) {
if (meetingId == null || meetingId <= 0L || toVersion == null || toVersion.getId() == null || toVersion.getId() <= 0L) {
return Collections.emptyMap();
}
List<Map<String, Object>> existing = queryLatestChangeSetRows(toVersion.getId());
if (!existing.isEmpty()) {
return findLatestBySubmissionVersionId(toVersion.getId(), false);
}
Map<String, String> currentModuleContentMap = extractModuleContentMapFromVersion(toVersion);
return buildAndSaveChangeSet(
meetingId,
fromSubmissionVersionId,
fromVersionNo,
toVersion.getId(),
currentModuleContentMap
);
}
public Map<String, Object> backfillMissingChangeSetsByMeetingId(Long meetingId) {
List<MeetingSubmissionVersion> versions = meetingSubmissionVersionService.listByMeetingId(meetingId);
Map<Integer, MeetingSubmissionVersion> versionNoMap = new HashMap<>();
for (MeetingSubmissionVersion version : versions) {
if (version != null && version.getVersionNo() != null) {
versionNoMap.put(version.getVersionNo(), version);
}
}
int createdCount = 0;
int skippedCount = 0;
List<Long> createdVersionIds = new ArrayList<>();
for (MeetingSubmissionVersion version : versions) {
if (version == null || version.getId() == null || version.getId() <= 0L) {
continue;
}
if (!queryLatestChangeSetRows(version.getId()).isEmpty()) {
skippedCount++;
continue;
}
Integer currentVersionNo = version.getVersionNo();
if (currentVersionNo == null || currentVersionNo <= 1) {
skippedCount++;
continue;
}
MeetingSubmissionVersion previousVersion = versionNoMap.get(currentVersionNo - 1);
buildAndSaveChangeSetFromVersions(
meetingId,
previousVersion == null ? null : previousVersion.getId(),
previousVersion == null ? null : previousVersion.getVersionNo(),
version
);
createdCount++;
createdVersionIds.add(version.getId());
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("meetingId", meetingId);
result.put("createdCount", createdCount);
result.put("skippedCount", skippedCount);
result.put("createdVersionIds", createdVersionIds);
return result;
}
private List<Map<String, Object>> queryLatestChangeSetRows(Long submissionVersionId) {
return jdbcTemplate.queryForList(
"SELECT id, meeting_id, from_submission_version_id, to_submission_version_id, changed_module_count, changed_item_count, issue_related_count, extra_change_count, " +
"DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s') AS created_at " +
"FROM version_change_set WHERE tenant_id=? AND to_submission_version_id=? ORDER BY id DESC LIMIT 1",
tenantId(),
submissionVersionId
);
}
private MeetingSubmissionVersion resolvePreviousSubmissionVersion(Long meetingId, Integer currentVersionNo) {
if (meetingId == null || meetingId <= 0L || currentVersionNo == null || currentVersionNo <= 1) {
return null;
}
return meetingSubmissionVersionService.findByMeetingIdAndVersionNo(meetingId, currentVersionNo - 1).orElse(null);
}
@SuppressWarnings("unchecked")
private Map<String, String> extractModuleContentMapFromVersion(MeetingSubmissionVersion version) {
Map<String, String> moduleContentMap = new LinkedHashMap<>();
for (String moduleCode : supportedModules()) {
moduleContentMap.put(moduleCode, "");
}
if (version == null) {
return moduleContentMap;
}
try {
Map<String, Object> root = meetingMaterialService.parseObjectMap(version.getSnapshotJson());
Object materialsObj = root.get("materials");
if (!(materialsObj instanceof Map)) {
return moduleContentMap;
}
Map<String, Object> materials = (Map<String, Object>) materialsObj;
for (String moduleCode : supportedModules()) {
Object moduleValue = materials.get(moduleCode);
moduleContentMap.put(moduleCode, meetingMaterialService.writeJson(moduleValue));
}
return moduleContentMap;
} catch (Exception ex) {
return moduleContentMap;
}
}
private void saveChangeItems(Long changeSetId, Long meetingId, List<Map<String, Object>> changes) {
if (changeSetId == null || changeSetId <= 0L || changes == null || changes.isEmpty()) {
return;
}
for (Map<String, Object> change : changes) {
jdbcTemplate.update(
"INSERT INTO version_change_item (tenant_id, change_set_id, meeting_id, module_code, target_path, target_label, target_kind, target_row_key, attachment_identity, attachment_hash, change_type, old_value, new_value, related_issue_id, is_extra_change, created_by, updated_by) " +
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
tenantId(),
changeSetId,
meetingId,
stringValue(change.get("moduleCode")),
stringValue(change.get("itemKey")),
stringValue(change.get("itemLabel")),
stringValue(change.get("targetKind")),
nullableString(change.get("targetRowKey")),
nullableString(change.get("attachmentIdentity")),
nullableString(change.get("attachmentHash")),
stringValue(change.get("changeType")),
nullableString(change.get("previousValue")),
nullableString(change.get("currentValue")),
toLong(change.get("relatedIssueId")),
Boolean.TRUE.equals(change.get("isExtraChange")) ? 1 : 0,
safeUserId(),
safeUserId()
);
}
}
private List<Map<String, Object>> castRows(Object raw) {
if (!(raw instanceof List)) {
return Collections.emptyList();
}
List<Map<String, Object>> result = new ArrayList<>();
for (Object row : (List<?>) raw) {
if (row instanceof Map) {
result.add(new LinkedHashMap<>((Map<String, Object>) row));
}
}
return result;
}
private List<String> supportedModules() {
List<String> list = new ArrayList<>();
list.add("BASIC_INFO");
list.add("WRITE_OFF_DOCS");
list.add("EXPERT_PROFILE");
list.add("EXPERT_LIST");
list.add("MEETING_INVOICE");
return list;
}
private Integer resolveCurrentVersionNo(Integer fromVersionNo) {
int base = fromVersionNo == null ? 0 : fromVersionNo;
return base + 1;
}
private Long toLong(Object value) {
if (value == null) {
return null;
}
if (value instanceof Number) {
long longValue = ((Number) value).longValue();
return longValue <= 0L ? null : longValue;
}
try {
String text = String.valueOf(value).trim();
if (text.isEmpty()) {
return null;
}
long parsed = Long.parseLong(text);
return parsed <= 0L ? null : parsed;
} catch (Exception ex) {
return null;
}
}
private String stringValue(Object value) {
return value == null ? "" : String.valueOf(value).trim();
}
private String nullableString(Object value) {
String text = stringValue(value);
return text.isEmpty() ? null : text;
}
private Long tenantId() {
return AuthContext.requireTenantId();
}
private Long safeUserId() {
Long userId = AuthContext.userId();
return userId == null ? 0L : userId;
}
}
@@ -22,8 +22,17 @@ public class InAppNotificationController {
@GetMapping @GetMapping
@RequirePermission(value = "notification.inapp.read", dataScope = DataScopeType.TENANT, auditAction = "IN_APP_NOTIFICATION_LIST") @RequirePermission(value = "notification.inapp.read", dataScope = DataScopeType.TENANT, auditAction = "IN_APP_NOTIFICATION_LIST")
public ApiResponse<PageResult<InAppNotificationInfo>> listMine() { public ApiResponse<PageResult<InAppNotificationInfo>> listMine(
return ApiResponse.success(inAppNotificationService.listMine()); @RequestParam(value = "pageNo", defaultValue = "1") int pageNo,
@RequestParam(value = "pageSize", defaultValue = "200") int pageSize,
@RequestParam(value = "onlyUnread", defaultValue = "false") boolean onlyUnread) {
return ApiResponse.success(inAppNotificationService.listMine(pageNo, pageSize, onlyUnread));
}
@GetMapping("/summary")
@RequirePermission(value = "notification.inapp.read", dataScope = DataScopeType.TENANT, auditAction = "IN_APP_NOTIFICATION_SUMMARY")
public ApiResponse<Map<String, Object>> summaryMine() {
return ApiResponse.success(inAppNotificationService.summaryMine());
} }
@PostMapping("/{id}/read") @PostMapping("/{id}/read")
@@ -7,7 +7,6 @@ import com.writeoff.module.notification.model.PlatformNotifyGatewayInfo;
import com.writeoff.module.notification.service.PlatformNotifyGatewayService; import com.writeoff.module.notification.service.PlatformNotifyGatewayService;
import com.writeoff.module.notification.service.PlatformNotifyGatewayTestService; import com.writeoff.module.notification.service.PlatformNotifyGatewayTestService;
import com.writeoff.security.DataScopeType; import com.writeoff.security.DataScopeType;
import com.writeoff.security.PermissionDomain;
import com.writeoff.security.RequirePermission; import com.writeoff.security.RequirePermission;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
@@ -22,7 +21,7 @@ import java.util.List;
import java.util.Map; import java.util.Map;
@RestController @RestController
@RequestMapping("/api/platform/notify-gateways") @RequestMapping("/api/notify-gateways")
public class PlatformNotifyGatewayController { public class PlatformNotifyGatewayController {
private final PlatformNotifyGatewayService gatewayService; private final PlatformNotifyGatewayService gatewayService;
private final PlatformNotifyGatewayTestService testService; private final PlatformNotifyGatewayTestService testService;
@@ -34,20 +33,20 @@ public class PlatformNotifyGatewayController {
} }
@GetMapping @GetMapping
@RequirePermission(value = "platform.notify-gateway.read", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_NOTIFY_GATEWAY_LIST") @RequirePermission(value = "notification.notify-gateway.read", dataScope = DataScopeType.TENANT, auditAction = "NOTIFY_GATEWAY_LIST")
public ApiResponse<List<PlatformNotifyGatewayInfo>> list() { public ApiResponse<List<PlatformNotifyGatewayInfo>> list() {
return ApiResponse.success(gatewayService.list()); return ApiResponse.success(gatewayService.list());
} }
@PutMapping("/{channelCode}") @PutMapping("/{channelCode}")
@RequirePermission(value = "platform.notify-gateway.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_NOTIFY_GATEWAY_SAVE") @RequirePermission(value = "notification.notify-gateway.manage", dataScope = DataScopeType.TENANT, auditAction = "NOTIFY_GATEWAY_SAVE")
public ApiResponse<PlatformNotifyGatewayInfo> save(@PathVariable("channelCode") String channelCode, public ApiResponse<PlatformNotifyGatewayInfo> save(@PathVariable("channelCode") String channelCode,
@RequestBody SavePlatformNotifyGatewayRequest request) { @RequestBody SavePlatformNotifyGatewayRequest request) {
return ApiResponse.success(gatewayService.save(channelCode, request)); return ApiResponse.success(gatewayService.save(channelCode, request));
} }
@PostMapping("/{channelCode}/test") @PostMapping("/{channelCode}/test")
@RequirePermission(value = "platform.notify-gateway.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_NOTIFY_GATEWAY_TEST") @RequirePermission(value = "notification.notify-gateway.manage", dataScope = DataScopeType.TENANT, auditAction = "NOTIFY_GATEWAY_TEST")
public ApiResponse<Map<String, Object>> test(@PathVariable("channelCode") String channelCode, public ApiResponse<Map<String, Object>> test(@PathVariable("channelCode") String channelCode,
@RequestBody @Valid TestPlatformNotifyGatewayRequest request) { @RequestBody @Valid TestPlatformNotifyGatewayRequest request) {
return ApiResponse.success(testService.test(channelCode, request)); return ApiResponse.success(testService.test(channelCode, request));
@@ -14,6 +14,7 @@ public class CreateNotificationPolicyRequest {
private String receiverType; private String receiverType;
@NotNull(message = "文案模板ID不能为空") @NotNull(message = "文案模板ID不能为空")
private Long templateId; private Long templateId;
private String smsTemplateCode;
private String variablesJson; private String variablesJson;
private String status; private String status;
@@ -57,6 +58,14 @@ public class CreateNotificationPolicyRequest {
this.templateId = templateId; this.templateId = templateId;
} }
public String getSmsTemplateCode() {
return smsTemplateCode;
}
public void setSmsTemplateCode(String smsTemplateCode) {
this.smsTemplateCode = smsTemplateCode;
}
public String getVariablesJson() { public String getVariablesJson() {
return variablesJson; return variablesJson;
} }
@@ -7,6 +7,7 @@ public class TestPlatformNotifyGatewayRequest {
private String receiverRef; private String receiverRef;
private String subject; private String subject;
private String content; private String content;
private String smsTemplateCode;
public String getReceiverRef() { public String getReceiverRef() {
return receiverRef; return receiverRef;
@@ -31,4 +32,12 @@ public class TestPlatformNotifyGatewayRequest {
public void setContent(String content) { public void setContent(String content) {
this.content = content; this.content = content;
} }
public String getSmsTemplateCode() {
return smsTemplateCode;
}
public void setSmsTemplateCode(String smsTemplateCode) {
this.smsTemplateCode = smsTemplateCode;
}
} }
@@ -7,16 +7,18 @@ public class NotificationPolicyInfo {
private String channel; private String channel;
private String receiverType; private String receiverType;
private Long templateId; private Long templateId;
private String smsTemplateCode;
private String variablesJson; private String variablesJson;
private String status; private String status;
public NotificationPolicyInfo(Long id, String policyName, String eventCode, String channel, String receiverType, Long templateId, String variablesJson, String status) { public NotificationPolicyInfo(Long id, String policyName, String eventCode, String channel, String receiverType, Long templateId, String smsTemplateCode, String variablesJson, String status) {
this.id = id; this.id = id;
this.policyName = policyName; this.policyName = policyName;
this.eventCode = eventCode; this.eventCode = eventCode;
this.channel = channel; this.channel = channel;
this.receiverType = receiverType; this.receiverType = receiverType;
this.templateId = templateId; this.templateId = templateId;
this.smsTemplateCode = smsTemplateCode;
this.variablesJson = variablesJson; this.variablesJson = variablesJson;
this.status = status; this.status = status;
} }
@@ -45,6 +47,10 @@ public class NotificationPolicyInfo {
return templateId; return templateId;
} }
public String getSmsTemplateCode() {
return smsTemplateCode;
}
public String getVariablesJson() { public String getVariablesJson() {
return variablesJson; return variablesJson;
} }
@@ -4,6 +4,7 @@ import java.util.Map;
public class PlatformNotifyGatewayInfo { public class PlatformNotifyGatewayInfo {
private final Long id; private final Long id;
private final Long tenantId;
private final String channelCode; private final String channelCode;
private final String gatewayName; private final String gatewayName;
private final String providerCode; private final String providerCode;
@@ -14,6 +15,7 @@ public class PlatformNotifyGatewayInfo {
private final Map<String, Object> config; private final Map<String, Object> config;
public PlatformNotifyGatewayInfo(Long id, public PlatformNotifyGatewayInfo(Long id,
Long tenantId,
String channelCode, String channelCode,
String gatewayName, String gatewayName,
String providerCode, String providerCode,
@@ -23,6 +25,7 @@ public class PlatformNotifyGatewayInfo {
String updatedAt, String updatedAt,
Map<String, Object> config) { Map<String, Object> config) {
this.id = id; this.id = id;
this.tenantId = tenantId;
this.channelCode = channelCode; this.channelCode = channelCode;
this.gatewayName = gatewayName; this.gatewayName = gatewayName;
this.providerCode = providerCode; this.providerCode = providerCode;
@@ -37,6 +40,10 @@ public class PlatformNotifyGatewayInfo {
return id; return id;
} }
public Long getTenantId() {
return tenantId;
}
public String getChannelCode() { public String getChannelCode() {
return channelCode; return channelCode;
} }
@@ -4,6 +4,7 @@ import java.util.Map;
public class PlatformNotifyGatewayResolvedConfig { public class PlatformNotifyGatewayResolvedConfig {
private final Long id; private final Long id;
private final Long tenantId;
private final String channelCode; private final String channelCode;
private final String gatewayName; private final String gatewayName;
private final String providerCode; private final String providerCode;
@@ -12,6 +13,7 @@ public class PlatformNotifyGatewayResolvedConfig {
private final Map<String, Object> config; private final Map<String, Object> config;
public PlatformNotifyGatewayResolvedConfig(Long id, public PlatformNotifyGatewayResolvedConfig(Long id,
Long tenantId,
String channelCode, String channelCode,
String gatewayName, String gatewayName,
String providerCode, String providerCode,
@@ -19,6 +21,7 @@ public class PlatformNotifyGatewayResolvedConfig {
String remark, String remark,
Map<String, Object> config) { Map<String, Object> config) {
this.id = id; this.id = id;
this.tenantId = tenantId;
this.channelCode = channelCode; this.channelCode = channelCode;
this.gatewayName = gatewayName; this.gatewayName = gatewayName;
this.providerCode = providerCode; this.providerCode = providerCode;
@@ -31,6 +34,10 @@ public class PlatformNotifyGatewayResolvedConfig {
return id; return id;
} }
public Long getTenantId() {
return tenantId;
}
public String getChannelCode() { public String getChannelCode() {
return channelCode; return channelCode;
} }
@@ -4,27 +4,33 @@ import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.writeoff.module.notification.model.PlatformNotifyGatewayResolvedConfig; import com.writeoff.module.notification.model.PlatformNotifyGatewayResolvedConfig;
import com.writeoff.module.notification.service.PlatformNotifyGatewayService; import com.writeoff.module.notification.service.PlatformNotifyGatewayService;
import com.writeoff.security.AuthContext;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSenderImpl; import org.springframework.mail.javamail.JavaMailSenderImpl;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import javax.mail.internet.InternetAddress;
import javax.mail.internet.MimeMessage;
import java.nio.charset.StandardCharsets;
import java.util.Map; import java.util.Map;
import java.util.Properties; import java.util.Properties;
@Component @Component
public class EmailNotificationProvider implements NotificationChannelProvider { public class EmailNotificationProvider implements NotificationChannelProvider {
private static final Logger log = LoggerFactory.getLogger(EmailNotificationProvider.class); private static final Logger log = LoggerFactory.getLogger(EmailNotificationProvider.class);
private static final String CONTEXT_TENANT_ID = "tenantId";
private final PlatformNotifyGatewayService gatewayService; private final PlatformNotifyGatewayService gatewayService;
private final ObjectMapper objectMapper = new ObjectMapper(); private final ObjectMapper objectMapper = new ObjectMapper();
private final String defaultSubject; private final String defaultSubject;
public EmailNotificationProvider(PlatformNotifyGatewayService gatewayService, public EmailNotificationProvider(PlatformNotifyGatewayService gatewayService,
@Value("${app.notification.mail.default-subject:绯荤粺閫氱煡}") String defaultSubject) { @Value("${app.notification.mail.default-subject:系统通知}") String defaultSubject) {
this.gatewayService = gatewayService; this.gatewayService = gatewayService;
this.defaultSubject = defaultSubject == null ? "绯荤粺閫氱煡" : defaultSubject.trim(); this.defaultSubject = defaultSubject == null ? "系统通知" : defaultSubject.trim();
} }
@Override @Override
@@ -35,11 +41,12 @@ public class EmailNotificationProvider implements NotificationChannelProvider {
@Override @Override
public NotificationSendResult send(String receiverRef, String payloadJson, Map<String, Object> context) { public NotificationSendResult send(String receiverRef, String payloadJson, Map<String, Object> context) {
if (receiverRef == null || receiverRef.trim().isEmpty()) { if (receiverRef == null || receiverRef.trim().isEmpty()) {
return new NotificationSendResult(false, null, "INVALID_RECEIVER", "閭鍦板潃涓嶈兘涓虹┖"); return new NotificationSendResult(false, null, "INVALID_RECEIVER", "邮件接收人不能为空");
} }
long start = System.currentTimeMillis(); long start = System.currentTimeMillis();
try { try {
PlatformNotifyGatewayResolvedConfig gatewayConfig = gatewayService.resolveChannelConfig("EMAIL", true); Long tenantId = resolveTenantId(context);
PlatformNotifyGatewayResolvedConfig gatewayConfig = gatewayService.resolveChannelConfig(tenantId, "EMAIL", true);
JavaMailSenderImpl sender = resolveMailSender(gatewayConfig); JavaMailSenderImpl sender = resolveMailSender(gatewayConfig);
Map<String, Object> runtimeConfig = gatewayConfig.getConfig(); Map<String, Object> runtimeConfig = gatewayConfig.getConfig();
String subject = textOr(runtimeConfig.get("defaultSubject"), defaultSubject); String subject = textOr(runtimeConfig.get("defaultSubject"), defaultSubject);
@@ -55,19 +62,19 @@ public class EmailNotificationProvider implements NotificationChannelProvider {
content = String.valueOf(body); content = String.valueOf(body);
} }
} }
SimpleMailMessage message = new SimpleMailMessage();
String runtimeFrom = text(runtimeConfig.get("fromAddress")); String runtimeFrom = text(runtimeConfig.get("fromAddress"));
if (!runtimeFrom.isEmpty()) { String runtimeFromName = text(runtimeConfig.get("fromName"));
message.setFrom(runtimeFrom); MimeMessage message = sender.createMimeMessage();
} MimeMessageHelper helper = new MimeMessageHelper(message, false, StandardCharsets.UTF_8.name());
message.setTo(receiverRef.trim()); applyFrom(helper, runtimeFrom, runtimeFromName);
message.setSubject(subject); helper.setTo(receiverRef.trim());
message.setText(content == null ? "" : content); helper.setSubject(subject);
log.info("email sending start, to={}, subject={}", receiverRef.trim(), subject); helper.setText(content == null ? "" : content, false);
//log.info("email sending start, tenantId={}, to={}, subject={}", tenantId, receiverRef.trim(), subject);
sender.send(message); sender.send(message);
String id = "EMAIL-" + System.currentTimeMillis(); String id = "EMAIL-" + System.currentTimeMillis();
log.info("email sending success, to={}, messageId={}, elapsedMs={}", receiverRef.trim(), id, System.currentTimeMillis() - start); //log.info("email sending success, tenantId={}, to={}, messageId={}, elapsedMs={}", tenantId, receiverRef.trim(), id, System.currentTimeMillis() - start);
return new NotificationSendResult(true, id, "SENT", "閭欢鍙戦€佹垚鍔?"); return new NotificationSendResult(true, id, "SENT", "邮件发送成功");
} catch (Exception ex) { } catch (Exception ex) {
log.error("email sending failed, to={}, elapsedMs={}, reason={}", receiverRef.trim(), System.currentTimeMillis() - start, ex.getMessage(), ex); log.error("email sending failed, to={}, elapsedMs={}, reason={}", receiverRef.trim(), System.currentTimeMillis() - start, ex.getMessage(), ex);
return new NotificationSendResult(false, null, "SEND_FAILED", ex.getMessage()); return new NotificationSendResult(false, null, "SEND_FAILED", ex.getMessage());
@@ -102,6 +109,36 @@ public class EmailNotificationProvider implements NotificationChannelProvider {
return sender; return sender;
} }
private void applyFrom(MimeMessageHelper helper, String fromAddress, String fromName) throws Exception {
if (fromName.isEmpty()) {
helper.setFrom(fromAddress);
return;
}
helper.setFrom(new InternetAddress(fromAddress, fromName, StandardCharsets.UTF_8.name()));
}
private Long resolveTenantId(Map<String, Object> context) {
Long tenantId = toLong(context == null ? null : context.get(CONTEXT_TENANT_ID));
if (tenantId != null && tenantId > 0) {
return tenantId;
}
return AuthContext.requireTenantId();
}
private Long toLong(Object value) {
if (value == null) {
return null;
}
if (value instanceof Number) {
return ((Number) value).longValue();
}
try {
return Long.valueOf(String.valueOf(value).trim());
} catch (Exception ex) {
return null;
}
}
private String text(Object value) { private String text(Object value) {
return value == null ? "" : String.valueOf(value).trim(); return value == null ? "" : String.valueOf(value).trim();
} }
@@ -4,6 +4,7 @@ import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.writeoff.module.notification.model.PlatformNotifyGatewayResolvedConfig; import com.writeoff.module.notification.model.PlatformNotifyGatewayResolvedConfig;
import com.writeoff.module.notification.service.PlatformNotifyGatewayService; import com.writeoff.module.notification.service.PlatformNotifyGatewayService;
import com.writeoff.security.AuthContext;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -31,6 +32,8 @@ import java.util.UUID;
@Component @Component
public class SmsNotificationProvider implements NotificationChannelProvider { public class SmsNotificationProvider implements NotificationChannelProvider {
private static final Logger log = LoggerFactory.getLogger(SmsNotificationProvider.class); private static final Logger log = LoggerFactory.getLogger(SmsNotificationProvider.class);
private static final String CONTEXT_TENANT_ID = "tenantId";
private final PlatformNotifyGatewayService gatewayService; private final PlatformNotifyGatewayService gatewayService;
private final ObjectMapper objectMapper = new ObjectMapper(); private final ObjectMapper objectMapper = new ObjectMapper();
@@ -48,10 +51,11 @@ public class SmsNotificationProvider implements NotificationChannelProvider {
if (receiverRef == null || receiverRef.trim().isEmpty()) { if (receiverRef == null || receiverRef.trim().isEmpty()) {
return new NotificationSendResult(false, null, "INVALID_RECEIVER", "短信接收人不能为空"); return new NotificationSendResult(false, null, "INVALID_RECEIVER", "短信接收人不能为空");
} }
PlatformNotifyGatewayResolvedConfig gatewayConfig = gatewayService.resolveChannelConfig("SMS", true); Long tenantId = resolveTenantId(context);
PlatformNotifyGatewayResolvedConfig gatewayConfig = gatewayService.resolveChannelConfig(tenantId, "SMS", true);
if (gatewayConfig == null) { if (gatewayConfig == null) {
String legacyId = "SMS-" + System.currentTimeMillis(); String legacyId = "SMS-" + System.currentTimeMillis();
log.info("sms gateway config not enabled, fallback to legacy mock accept, receiver={}", receiverRef.trim()); log.info("sms gateway config not enabled, fallback to legacy mock accept, tenantId={}, receiver={}", tenantId, receiverRef.trim());
return new NotificationSendResult(true, legacyId, "LEGACY_ACCEPTED", "短信通道已按兼容模式受理"); return new NotificationSendResult(true, legacyId, "LEGACY_ACCEPTED", "短信通道已按兼容模式受理");
} }
if ("ALIYUN_SMS".equalsIgnoreCase(gatewayConfig.getProviderCode())) { if ("ALIYUN_SMS".equalsIgnoreCase(gatewayConfig.getProviderCode())) {
@@ -60,7 +64,7 @@ public class SmsNotificationProvider implements NotificationChannelProvider {
boolean mockEnabled = boolValue(gatewayConfig.getConfig().get("mockEnabled"), "MOCK".equalsIgnoreCase(gatewayConfig.getProviderCode())); boolean mockEnabled = boolValue(gatewayConfig.getConfig().get("mockEnabled"), "MOCK".equalsIgnoreCase(gatewayConfig.getProviderCode()));
String providerCode = gatewayConfig.getProviderCode() == null ? "SMS" : gatewayConfig.getProviderCode().trim().toUpperCase(); String providerCode = gatewayConfig.getProviderCode() == null ? "SMS" : gatewayConfig.getProviderCode().trim().toUpperCase();
String id = providerCode + "-" + System.currentTimeMillis(); String id = providerCode + "-" + System.currentTimeMillis();
log.info("sms sending accepted, provider={}, receiver={}, mockEnabled={}", providerCode, receiverRef.trim(), mockEnabled); log.info("sms sending accepted, tenantId={}, provider={}, receiver={}, mockEnabled={}", tenantId, providerCode, receiverRef.trim(), mockEnabled);
return new NotificationSendResult(true, id, mockEnabled ? "MOCK_ACCEPTED" : "ACCEPTED", mockEnabled ? "短信网关已模拟受理" : "短信网关已受理"); return new NotificationSendResult(true, id, mockEnabled ? "MOCK_ACCEPTED" : "ACCEPTED", mockEnabled ? "短信网关已模拟受理" : "短信网关已受理");
} }
@@ -83,7 +87,7 @@ public class SmsNotificationProvider implements NotificationChannelProvider {
String accessKeyId = text(config.get("accessKeyId")); String accessKeyId = text(config.get("accessKeyId"));
String accessKeySecret = text(config.get("accessKeySecret")); String accessKeySecret = text(config.get("accessKeySecret"));
String signName = text(config.get("signName")); String signName = text(config.get("signName"));
String templateCode = text(config.get("templateCode")); String templateCode = resolveTemplateCode(config, payloadJson, context);
String regionId = textOr(config.get("regionId"), "cn-hangzhou"); String regionId = textOr(config.get("regionId"), "cn-hangzhou");
if (accessKeyId.isEmpty() || accessKeySecret.isEmpty()) { if (accessKeyId.isEmpty() || accessKeySecret.isEmpty()) {
return new NotificationSendResult(false, null, "CONFIG_ERROR", "阿里云短信 AccessKey 配置不完整"); return new NotificationSendResult(false, null, "CONFIG_ERROR", "阿里云短信 AccessKey 配置不完整");
@@ -129,6 +133,25 @@ public class SmsNotificationProvider implements NotificationChannelProvider {
} }
} }
private String resolveTemplateCode(Map<String, Object> config, String payloadJson, Map<String, Object> context) {
String templateCode = text(config.get("templateCode"));
try {
Map<String, Object> payload = parsePayload(payloadJson);
String payloadTemplateCode = text(payload.get("smsTemplateCode"));
if (!payloadTemplateCode.isEmpty()) {
return payloadTemplateCode;
}
} catch (Exception ignored) {
}
if (context != null) {
String contextTemplateCode = text(context.get("smsTemplateCode"));
if (!contextTemplateCode.isEmpty()) {
return contextTemplateCode;
}
}
return templateCode;
}
private Map<String, Object> parsePayload(String payloadJson) throws Exception { private Map<String, Object> parsePayload(String payloadJson) throws Exception {
if (payloadJson == null || payloadJson.trim().isEmpty() || !payloadJson.trim().startsWith("{")) { if (payloadJson == null || payloadJson.trim().isEmpty() || !payloadJson.trim().startsWith("{")) {
return new LinkedHashMap<String, Object>(); return new LinkedHashMap<String, Object>();
@@ -138,6 +161,18 @@ public class SmsNotificationProvider implements NotificationChannelProvider {
} }
private Map<String, Object> buildAliyunTemplateParams(Map<String, Object> payload, Map<String, Object> context) { private Map<String, Object> buildAliyunTemplateParams(Map<String, Object> payload, Map<String, Object> context) {
Object explicitParams = payload == null ? null : payload.get("smsTemplateParams");
if (explicitParams instanceof Map) {
Map<String, Object> params = new LinkedHashMap<String, Object>();
params.putAll((Map<String, Object>) explicitParams);
if (context != null) {
Object taskId = context.get("taskId");
if (taskId != null && !params.containsKey("taskId")) {
params.put("taskId", String.valueOf(taskId));
}
}
return params;
}
Map<String, Object> params = new LinkedHashMap<String, Object>(); Map<String, Object> params = new LinkedHashMap<String, Object>();
if (payload != null) { if (payload != null) {
params.putAll(payload); params.putAll(payload);
@@ -239,6 +274,28 @@ public class SmsNotificationProvider implements NotificationChannelProvider {
.replace("%7E", "~"); .replace("%7E", "~");
} }
private Long resolveTenantId(Map<String, Object> context) {
Long tenantId = toLong(context == null ? null : context.get(CONTEXT_TENANT_ID));
if (tenantId != null && tenantId > 0) {
return tenantId;
}
return AuthContext.requireTenantId();
}
private Long toLong(Object value) {
if (value == null) {
return null;
}
if (value instanceof Number) {
return ((Number) value).longValue();
}
try {
return Long.valueOf(String.valueOf(value).trim());
} catch (Exception ex) {
return null;
}
}
private boolean boolValue(Object value, boolean fallback) { private boolean boolValue(Object value, boolean fallback) {
if (value == null) { if (value == null) {
return fallback; return fallback;
@@ -9,7 +9,9 @@ import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map;
@Service @Service
public class InAppNotificationService { public class InAppNotificationService {
@@ -28,22 +30,61 @@ public class InAppNotificationService {
this.jdbcTemplate = jdbcTemplate; this.jdbcTemplate = jdbcTemplate;
} }
public PageResult<InAppNotificationInfo> listMine() { public PageResult<InAppNotificationInfo> listMine(int pageNo, int pageSize, boolean onlyUnread) {
Long userId = AuthContext.userId(); Long userId = AuthContext.userId();
String userRef = userId == null ? "" : ("user-" + userId); String userRef = userId == null ? "" : ("user-" + userId);
List<InAppNotificationInfo> list = jdbcTemplate.query( int safePage = Math.max(pageNo, 1);
"SELECT id, title, content, status, " + int safeSize = Math.min(Math.max(pageSize, 1), 200);
"DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s') AS created_at, " + int offset = (safePage - 1) * safeSize;
"DATE_FORMAT(read_at, '%Y-%m-%d %H:%i:%s') AS read_at " + String unreadClause = onlyUnread ? " AND status='UNREAD'" : "";
String baseSql =
" FROM in_app_notification " + " FROM in_app_notification " +
"WHERE tenant_id=? AND is_deleted=0 AND (receiver_ref='ALL' OR receiver_ref=? OR receiver_user_id=?)" + "WHERE tenant_id=? AND is_deleted=0 AND (receiver_ref='ALL' OR receiver_ref=? OR receiver_user_id=?)" +
"ORDER BY id DESC LIMIT 200", unreadClause;
ROW_MAPPER, Integer total = jdbcTemplate.queryForObject(
"SELECT COUNT(1)" + baseSql,
Integer.class,
tenantId(), tenantId(),
userRef, userRef,
userId userId
); );
return new PageResult<InAppNotificationInfo>(list, list.size(), 1, 200); long totalCount = total == null ? 0 : total;
List<InAppNotificationInfo> list = jdbcTemplate.query(
"SELECT id, title, content, status, " +
"DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s') AS created_at, " +
"DATE_FORMAT(read_at, '%Y-%m-%d %H:%i:%s') AS read_at " +
baseSql +
" ORDER BY id DESC LIMIT ? OFFSET ?",
ROW_MAPPER,
tenantId(),
userRef,
userId,
safeSize,
offset
);
return new PageResult<InAppNotificationInfo>(list, totalCount, safePage, safeSize);
}
public Map<String, Object> summaryMine() {
Long userId = AuthContext.userId();
String userRef = userId == null ? "" : ("user-" + userId);
Object[] args = new Object[]{tenantId(), userRef, userId};
Integer total = jdbcTemplate.queryForObject(
"SELECT COUNT(1) FROM in_app_notification " +
"WHERE tenant_id=? AND is_deleted=0 AND (receiver_ref='ALL' OR receiver_ref=? OR receiver_user_id=?)",
Integer.class,
args
);
Integer unread = jdbcTemplate.queryForObject(
"SELECT COUNT(1) FROM in_app_notification " +
"WHERE tenant_id=? AND is_deleted=0 AND status='UNREAD' AND (receiver_ref='ALL' OR receiver_ref=? OR receiver_user_id=?)",
Integer.class,
args
);
Map<String, Object> data = new LinkedHashMap<String, Object>();
data.put("total", total == null ? 0 : total);
data.put("unread", unread == null ? 0 : unread);
return data;
} }
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
@@ -1,6 +1,7 @@
package com.writeoff.module.notification.service; package com.writeoff.module.notification.service;
import com.writeoff.module.notification.model.PlatformNotifyGatewayResolvedConfig; import com.writeoff.module.notification.model.PlatformNotifyGatewayResolvedConfig;
import com.writeoff.security.AuthContext;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -24,18 +25,23 @@ public class NotificationDeliveryProtectionService {
} }
public GuardDecision checkBeforeSend(String channelCode, String receiverRef) { public GuardDecision checkBeforeSend(String channelCode, String receiverRef) {
return checkBeforeSend(tenantId(), channelCode, receiverRef);
}
public GuardDecision checkBeforeSend(Long tenantId, String channelCode, String receiverRef) {
Long resolvedTenantId = requireTenantId(tenantId);
String channel = normalizeChannel(channelCode); String channel = normalizeChannel(channelCode);
if (!isProtectedChannel(channel)) { if (!isProtectedChannel(channel)) {
return GuardDecision.allow(); return GuardDecision.allow();
} }
BreakerState breakerState = loadBreakerState(channel); BreakerState breakerState = loadBreakerState(resolvedTenantId, channel);
if (breakerState.breakerUntil != null && LocalDateTime.now().isBefore(breakerState.breakerUntil)) { if (breakerState.breakerUntil != null && LocalDateTime.now().isBefore(breakerState.breakerUntil)) {
String untilText = breakerState.breakerUntil.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); String untilText = breakerState.breakerUntil.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
return GuardDecision.block("BREAKER_OPEN", "渠道熔断中,请在 " + untilText + " 后重试"); return GuardDecision.block("BREAKER_OPEN", "渠道熔断中,请在 " + untilText + " 后重试");
} }
if ("SMS".equals(channel)) { if ("SMS".equals(channel)) {
SmsGuardConfig config = resolveSmsConfig(channel); SmsGuardConfig config = resolveSmsConfig(resolvedTenantId, channel);
GuardRecord record = loadGuardRecord(channel, normalizeReceiver(receiverRef)); GuardRecord record = loadGuardRecord(resolvedTenantId, channel, normalizeReceiver(receiverRef));
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
if (record.lastSentAt != null && config.quietPeriodSeconds > 0 && now.isBefore(record.lastSentAt.plusSeconds(config.quietPeriodSeconds))) { if (record.lastSentAt != null && config.quietPeriodSeconds > 0 && now.isBefore(record.lastSentAt.plusSeconds(config.quietPeriodSeconds))) {
return GuardDecision.block("SMS_RATE_LIMIT_WINDOW", "同一手机号发送过于频繁,请稍后再试"); return GuardDecision.block("SMS_RATE_LIMIT_WINDOW", "同一手机号发送过于频繁,请稍后再试");
@@ -48,6 +54,11 @@ public class NotificationDeliveryProtectionService {
} }
public void recordSuccess(String channelCode, String receiverRef) { public void recordSuccess(String channelCode, String receiverRef) {
recordSuccess(tenantId(), channelCode, receiverRef);
}
public void recordSuccess(Long tenantId, String channelCode, String receiverRef) {
Long resolvedTenantId = requireTenantId(tenantId);
String channel = normalizeChannel(channelCode); String channel = normalizeChannel(channelCode);
if (!isProtectedChannel(channel)) { if (!isProtectedChannel(channel)) {
return; return;
@@ -55,43 +66,54 @@ public class NotificationDeliveryProtectionService {
if ("SMS".equals(channel)) { if ("SMS".equals(channel)) {
String normalizedReceiver = normalizeReceiver(receiverRef); String normalizedReceiver = normalizeReceiver(receiverRef);
jdbcTemplate.update( jdbcTemplate.update(
"INSERT INTO platform_notify_delivery_guard (channel_code, receiver_ref, stat_date, daily_count, last_sent_at) " + "INSERT INTO tenant_notify_delivery_guard (tenant_id, channel_code, receiver_ref, stat_date, daily_count, last_sent_at) " +
"VALUES (?, ?, CURRENT_DATE(), 1, CURRENT_TIMESTAMP) " + "VALUES (?, ?, ?, CURRENT_DATE(), 1, CURRENT_TIMESTAMP) " +
"ON DUPLICATE KEY UPDATE daily_count=daily_count+1, last_sent_at=VALUES(last_sent_at), updated_at=CURRENT_TIMESTAMP", "ON DUPLICATE KEY UPDATE daily_count=daily_count+1, last_sent_at=VALUES(last_sent_at), updated_at=CURRENT_TIMESTAMP",
resolvedTenantId,
channel, channel,
normalizedReceiver normalizedReceiver
); );
} }
jdbcTemplate.update( jdbcTemplate.update(
"UPDATE platform_notify_circuit_breaker SET consecutive_failures=0, breaker_until=NULL, last_failure_message=NULL, updated_at=CURRENT_TIMESTAMP WHERE channel_code=?", "UPDATE tenant_notify_circuit_breaker SET consecutive_failures=0, breaker_until=NULL, last_failure_message=NULL, updated_at=CURRENT_TIMESTAMP " +
"WHERE tenant_id=? AND channel_code=?",
resolvedTenantId,
channel channel
); );
} }
public void recordFailure(String channelCode, String failureMessage) { public void recordFailure(String channelCode, String failureMessage) {
recordFailure(tenantId(), channelCode, failureMessage);
}
public void recordFailure(Long tenantId, String channelCode, String failureMessage) {
Long resolvedTenantId = requireTenantId(tenantId);
String channel = normalizeChannel(channelCode); String channel = normalizeChannel(channelCode);
if (!isProtectedChannel(channel)) { if (!isProtectedChannel(channel)) {
return; return;
} }
BreakerState state = loadBreakerState(channel); BreakerState state = loadBreakerState(resolvedTenantId, channel);
int threshold = resolveFailureThreshold(channel); int threshold = resolveFailureThreshold(resolvedTenantId, channel);
int cooldownSeconds = resolveBreakerCooldownSeconds(channel); int cooldownSeconds = resolveBreakerCooldownSeconds(resolvedTenantId, channel);
int nextFailures = state.consecutiveFailures + 1; int nextFailures = state.consecutiveFailures + 1;
LocalDateTime breakerUntil = nextFailures >= threshold LocalDateTime breakerUntil = nextFailures >= threshold
? LocalDateTime.now().plusSeconds(Math.max(cooldownSeconds, 1)) ? LocalDateTime.now().plusSeconds(Math.max(cooldownSeconds, 1))
: null; : null;
if (state.exists) { if (state.exists) {
jdbcTemplate.update( jdbcTemplate.update(
"UPDATE platform_notify_circuit_breaker SET consecutive_failures=?, breaker_until=?, last_failure_message=?, updated_at=CURRENT_TIMESTAMP WHERE channel_code=?", "UPDATE tenant_notify_circuit_breaker SET consecutive_failures=?, breaker_until=?, last_failure_message=?, updated_at=CURRENT_TIMESTAMP " +
"WHERE tenant_id=? AND channel_code=?",
nextFailures, nextFailures,
toTimestamp(breakerUntil), toTimestamp(breakerUntil),
trimMessage(failureMessage), trimMessage(failureMessage),
resolvedTenantId,
channel channel
); );
return; return;
} }
jdbcTemplate.update( jdbcTemplate.update(
"INSERT INTO platform_notify_circuit_breaker (channel_code, consecutive_failures, breaker_until, last_failure_message) VALUES (?, ?, ?, ?)", "INSERT INTO tenant_notify_circuit_breaker (tenant_id, channel_code, consecutive_failures, breaker_until, last_failure_message) VALUES (?, ?, ?, ?, ?)",
resolvedTenantId,
channel, channel,
nextFailures, nextFailures,
toTimestamp(breakerUntil), toTimestamp(breakerUntil),
@@ -99,9 +121,10 @@ public class NotificationDeliveryProtectionService {
); );
} }
private GuardRecord loadGuardRecord(String channelCode, String receiverRef) { private GuardRecord loadGuardRecord(Long tenantId, String channelCode, String receiverRef) {
List<Map<String, Object>> rows = jdbcTemplate.queryForList( List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT daily_count, last_sent_at FROM platform_notify_delivery_guard WHERE channel_code=? AND receiver_ref=? AND stat_date=CURRENT_DATE() LIMIT 1", "SELECT daily_count, last_sent_at FROM tenant_notify_delivery_guard WHERE tenant_id=? AND channel_code=? AND receiver_ref=? AND stat_date=CURRENT_DATE() LIMIT 1",
tenantId,
channelCode, channelCode,
receiverRef receiverRef
); );
@@ -112,9 +135,10 @@ public class NotificationDeliveryProtectionService {
return new GuardRecord(intValue(row.get("daily_count"), 0), toLocalDateTime(row.get("last_sent_at"))); return new GuardRecord(intValue(row.get("daily_count"), 0), toLocalDateTime(row.get("last_sent_at")));
} }
private BreakerState loadBreakerState(String channelCode) { private BreakerState loadBreakerState(Long tenantId, String channelCode) {
List<Map<String, Object>> rows = jdbcTemplate.queryForList( List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT consecutive_failures, breaker_until FROM platform_notify_circuit_breaker WHERE channel_code=? LIMIT 1", "SELECT consecutive_failures, breaker_until FROM tenant_notify_circuit_breaker WHERE tenant_id=? AND channel_code=? LIMIT 1",
tenantId,
channelCode channelCode
); );
if (rows.isEmpty()) { if (rows.isEmpty()) {
@@ -124,8 +148,8 @@ public class NotificationDeliveryProtectionService {
return new BreakerState(true, intValue(row.get("consecutive_failures"), 0), toLocalDateTime(row.get("breaker_until"))); return new BreakerState(true, intValue(row.get("consecutive_failures"), 0), toLocalDateTime(row.get("breaker_until")));
} }
private SmsGuardConfig resolveSmsConfig(String channelCode) { private SmsGuardConfig resolveSmsConfig(Long tenantId, String channelCode) {
PlatformNotifyGatewayResolvedConfig resolved = gatewayService.resolveChannelConfig(channelCode, false); PlatformNotifyGatewayResolvedConfig resolved = gatewayService.resolveChannelConfig(tenantId, channelCode, false);
Map<String, Object> config = resolved == null ? null : resolved.getConfig(); Map<String, Object> config = resolved == null ? null : resolved.getConfig();
return new SmsGuardConfig( return new SmsGuardConfig(
intValue(config == null ? null : config.get("quietPeriodSeconds"), 30), intValue(config == null ? null : config.get("quietPeriodSeconds"), 30),
@@ -133,14 +157,14 @@ public class NotificationDeliveryProtectionService {
); );
} }
private int resolveFailureThreshold(String channelCode) { private int resolveFailureThreshold(Long tenantId, String channelCode) {
PlatformNotifyGatewayResolvedConfig resolved = gatewayService.resolveChannelConfig(channelCode, false); PlatformNotifyGatewayResolvedConfig resolved = gatewayService.resolveChannelConfig(tenantId, channelCode, false);
Map<String, Object> config = resolved == null ? null : resolved.getConfig(); Map<String, Object> config = resolved == null ? null : resolved.getConfig();
return Math.max(intValue(config == null ? null : config.get("failureThreshold"), 3), 1); return Math.max(intValue(config == null ? null : config.get("failureThreshold"), 3), 1);
} }
private int resolveBreakerCooldownSeconds(String channelCode) { private int resolveBreakerCooldownSeconds(Long tenantId, String channelCode) {
PlatformNotifyGatewayResolvedConfig resolved = gatewayService.resolveChannelConfig(channelCode, false); PlatformNotifyGatewayResolvedConfig resolved = gatewayService.resolveChannelConfig(tenantId, channelCode, false);
Map<String, Object> config = resolved == null ? null : resolved.getConfig(); Map<String, Object> config = resolved == null ? null : resolved.getConfig();
return Math.max(intValue(config == null ? null : config.get("breakerCooldownSeconds"), 300), 1); return Math.max(intValue(config == null ? null : config.get("breakerCooldownSeconds"), 300), 1);
} }
@@ -200,6 +224,17 @@ public class NotificationDeliveryProtectionService {
} }
} }
private Long tenantId() {
return AuthContext.requireTenantId();
}
private Long requireTenantId(Long tenantId) {
if (tenantId == null || tenantId <= 0) {
throw new IllegalStateException("tenant context required");
}
return tenantId;
}
public static final class GuardDecision { public static final class GuardDecision {
private final boolean allowed; private final boolean allowed;
private final String code; private final String code;
@@ -113,6 +113,7 @@ public class NotificationDispatchService {
if (filterPolicyId != null && filterPolicyId > 0) { if (filterPolicyId != null && filterPolicyId > 0) {
policies = jdbcTemplate.queryForList( policies = jdbcTemplate.queryForList(
"SELECT p.id, p.channel, p.receiver_type, p.template_id, p.variables_json, p.policy_name, " + "SELECT p.id, p.channel, p.receiver_type, p.template_id, p.variables_json, p.policy_name, " +
"p.sms_template_code, " +
"tt.template_name, tt.subject_template, tt.title_template, tt.content_template " + "tt.template_name, tt.subject_template, tt.title_template, tt.content_template " +
"FROM notification_policy p " + "FROM notification_policy p " +
"JOIN notification_text_template tt ON tt.tenant_id=p.tenant_id AND tt.id=p.template_id AND tt.is_deleted=0 AND tt.status='ENABLED' " + "JOIN notification_text_template tt ON tt.tenant_id=p.tenant_id AND tt.id=p.template_id AND tt.is_deleted=0 AND tt.status='ENABLED' " +
@@ -124,6 +125,7 @@ public class NotificationDispatchService {
} else { } else {
policies = jdbcTemplate.queryForList( policies = jdbcTemplate.queryForList(
"SELECT p.id, p.channel, p.receiver_type, p.template_id, p.variables_json, p.policy_name, " + "SELECT p.id, p.channel, p.receiver_type, p.template_id, p.variables_json, p.policy_name, " +
"p.sms_template_code, " +
"tt.template_name, tt.subject_template, tt.title_template, tt.content_template " + "tt.template_name, tt.subject_template, tt.title_template, tt.content_template " +
"FROM notification_policy p " + "FROM notification_policy p " +
"JOIN notification_text_template tt ON tt.tenant_id=p.tenant_id AND tt.id=p.template_id AND tt.is_deleted=0 AND tt.status='ENABLED' " + "JOIN notification_text_template tt ON tt.tenant_id=p.tenant_id AND tt.id=p.template_id AND tt.is_deleted=0 AND tt.status='ENABLED' " +
@@ -475,6 +477,7 @@ public class NotificationDispatchService {
merged.put("policyId", policy.get("id")); merged.put("policyId", policy.get("id"));
merged.put("policyName", policy.get("policy_name")); merged.put("policyName", policy.get("policy_name"));
merged.put("templateId", policy.get("template_id")); merged.put("templateId", policy.get("template_id"));
merged.put("smsTemplateCode", policy.get("sms_template_code"));
merged.put("templateName", policy.get("template_name")); merged.put("templateName", policy.get("template_name"));
String policyName = policy.get("policy_name") == null ? "系统通知" : String.valueOf(policy.get("policy_name")); String policyName = policy.get("policy_name") == null ? "系统通知" : String.valueOf(policy.get("policy_name"));
@@ -512,6 +515,10 @@ public class NotificationDispatchService {
payload.put("title", finalTitle); payload.put("title", finalTitle);
payload.put("content", finalContent); payload.put("content", finalContent);
payload.put("message", finalContent); payload.put("message", finalContent);
Map<String, Object> smsTemplateParams = resolveSmsTemplateParams(policyVars.get("smsTemplateParams"), payload);
if (!smsTemplateParams.isEmpty()) {
payload.put("smsTemplateParams", smsTemplateParams);
}
try { try {
return objectMapper.writeValueAsString(payload); return objectMapper.writeValueAsString(payload);
} catch (Exception ex) { } catch (Exception ex) {
@@ -519,6 +526,29 @@ public class NotificationDispatchService {
} }
} }
private Map<String, Object> resolveSmsTemplateParams(Object rawConfig, Map<String, Object> vars) {
if (!(rawConfig instanceof Map)) {
return new LinkedHashMap<String, Object>();
}
Map<?, ?> rawMap = (Map<?, ?>) rawConfig;
Map<String, Object> resolved = new LinkedHashMap<String, Object>();
for (Map.Entry<?, ?> entry : rawMap.entrySet()) {
String key = entry.getKey() == null ? "" : String.valueOf(entry.getKey()).trim();
if (key.isEmpty()) {
continue;
}
resolved.put(key, resolveSmsTemplateParamValue(entry.getValue(), vars));
}
return resolved;
}
private Object resolveSmsTemplateParamValue(Object value, Map<String, Object> vars) {
if (value instanceof String) {
return resolvePlaceholders((String) value, vars);
}
return value;
}
private Map<String, Object> parseVariables(String variablesJson) { private Map<String, Object> parseVariables(String variablesJson) {
if (variablesJson == null || variablesJson.trim().isEmpty()) { if (variablesJson == null || variablesJson.trim().isEmpty()) {
return new LinkedHashMap<String, Object>(); return new LinkedHashMap<String, Object>();
@@ -24,6 +24,7 @@ public class NotificationPolicyService {
rs.getString("channel"), rs.getString("channel"),
rs.getString("receiver_type"), rs.getString("receiver_type"),
rs.getLong("template_id"), rs.getLong("template_id"),
rs.getString("sms_template_code"),
rs.getString("variables_json"), rs.getString("variables_json"),
rs.getString("status") rs.getString("status")
); );
@@ -58,15 +59,17 @@ public class NotificationPolicyService {
public NotificationPolicyInfo create(CreateNotificationPolicyRequest request) { public NotificationPolicyInfo create(CreateNotificationPolicyRequest request) {
validateTextTemplateExists(request.getTemplateId()); validateTextTemplateExists(request.getTemplateId());
String status = normalizeStatus(request.getStatus()); String status = normalizeStatus(request.getStatus());
String smsTemplateCode = normalizeSmsTemplateCode(request.getChannel(), request.getSmsTemplateCode());
jdbcTemplate.update( jdbcTemplate.update(
"INSERT INTO notification_policy (tenant_id, policy_name, event_code, channel, receiver_type, template_id, variables_json, status, created_by, updated_by) " + "INSERT INTO notification_policy (tenant_id, policy_name, event_code, channel, receiver_type, template_id, sms_template_code, variables_json, status, created_by, updated_by) " +
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
tenantId(), tenantId(),
request.getPolicyName(), request.getPolicyName(),
request.getEventCode(), request.getEventCode(),
request.getChannel(), request.getChannel(),
request.getReceiverType(), request.getReceiverType(),
request.getTemplateId(), request.getTemplateId(),
smsTemplateCode,
request.getVariablesJson(), request.getVariablesJson(),
status, status,
safeUserId(), safeUserId(),
@@ -83,14 +86,16 @@ public class NotificationPolicyService {
assertExists(id); assertExists(id);
validateTextTemplateExists(request.getTemplateId()); validateTextTemplateExists(request.getTemplateId());
String status = normalizeStatus(request.getStatus()); String status = normalizeStatus(request.getStatus());
String smsTemplateCode = normalizeSmsTemplateCode(request.getChannel(), request.getSmsTemplateCode());
jdbcTemplate.update( jdbcTemplate.update(
"UPDATE notification_policy SET policy_name=?, event_code=?, channel=?, receiver_type=?, template_id=?, variables_json=?, status=?, updated_at=CURRENT_TIMESTAMP, updated_by=? " + "UPDATE notification_policy SET policy_name=?, event_code=?, channel=?, receiver_type=?, template_id=?, sms_template_code=?, variables_json=?, status=?, updated_at=CURRENT_TIMESTAMP, updated_by=? " +
"WHERE tenant_id=? AND id=?", "WHERE tenant_id=? AND id=?",
request.getPolicyName(), request.getPolicyName(),
request.getEventCode(), request.getEventCode(),
request.getChannel(), request.getChannel(),
request.getReceiverType(), request.getReceiverType(),
request.getTemplateId(), request.getTemplateId(),
smsTemplateCode,
request.getVariablesJson(), request.getVariablesJson(),
status, status,
safeUserId(), safeUserId(),
@@ -191,6 +196,17 @@ public class NotificationPolicyService {
return val; return val;
} }
private String normalizeSmsTemplateCode(String channel, String smsTemplateCode) {
if (!"SMS".equalsIgnoreCase(channel == null ? "" : channel.trim())) {
return null;
}
String normalized = smsTemplateCode == null ? "" : smsTemplateCode.trim();
if (normalized.isEmpty()) {
throw new BusinessException(10001, "短信模板编码不能为空");
}
return normalized;
}
private void validateTextTemplateExists(Long templateId) { private void validateTextTemplateExists(Long templateId) {
Integer count = jdbcTemplate.queryForObject( Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(1) FROM notification_text_template WHERE tenant_id=? AND id=? AND is_deleted=0", "SELECT COUNT(1) FROM notification_text_template WHERE tenant_id=? AND id=? AND is_deleted=0",
@@ -28,10 +28,16 @@ public class PlatformNotifyGatewayService {
} }
public List<PlatformNotifyGatewayInfo> list() { public List<PlatformNotifyGatewayInfo> list() {
return list(tenantId());
}
public List<PlatformNotifyGatewayInfo> list(Long tenantId) {
Long resolvedTenantId = requireTenantId(tenantId);
List<Map<String, Object>> rows = jdbcTemplate.queryForList( List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT id, channel_code, gateway_name, provider_code, status, config_json, secret_config_cipher, remark, " + "SELECT id, tenant_id, channel_code, gateway_name, provider_code, status, config_json, secret_config_cipher, remark, " +
"DATE_FORMAT(updated_at, '%Y-%m-%d %H:%i:%s') AS updated_at " + "DATE_FORMAT(updated_at, '%Y-%m-%d %H:%i:%s') AS updated_at " +
"FROM platform_notify_gateway WHERE is_deleted=0 ORDER BY id ASC" "FROM tenant_notify_gateway WHERE tenant_id=? AND is_deleted=0 ORDER BY id ASC",
resolvedTenantId
); );
List<PlatformNotifyGatewayInfo> list = new ArrayList<PlatformNotifyGatewayInfo>(); List<PlatformNotifyGatewayInfo> list = new ArrayList<PlatformNotifyGatewayInfo>();
for (Map<String, Object> row : rows) { for (Map<String, Object> row : rows) {
@@ -41,8 +47,13 @@ public class PlatformNotifyGatewayService {
} }
public PlatformNotifyGatewayInfo save(String channelCode, SavePlatformNotifyGatewayRequest request) { public PlatformNotifyGatewayInfo save(String channelCode, SavePlatformNotifyGatewayRequest request) {
return save(tenantId(), channelCode, request);
}
public PlatformNotifyGatewayInfo save(Long tenantId, String channelCode, SavePlatformNotifyGatewayRequest request) {
Long resolvedTenantId = requireTenantId(tenantId);
String normalizedChannel = normalizeChannel(channelCode); String normalizedChannel = normalizeChannel(channelCode);
Map<String, Object> existing = findRow(normalizedChannel); Map<String, Object> existing = findRow(resolvedTenantId, normalizedChannel);
if (existing.isEmpty()) { if (existing.isEmpty()) {
throw new BusinessException(10003, "通知网关不存在"); throw new BusinessException(10003, "通知网关不存在");
} }
@@ -54,12 +65,12 @@ public class PlatformNotifyGatewayService {
); );
String gatewayName = normalizeText( String gatewayName = normalizeText(
request == null ? null : request.getGatewayName(), request == null ? null : request.getGatewayName(),
normalizedChannel.equals("EMAIL") ? "邮件网关" : "短信网关" "EMAIL".equals(normalizedChannel) ? "邮件网关" : "短信网关"
); );
String remark = request == null ? null : normalizeNullableText(request.getRemark()); String remark = request == null ? null : normalizeNullableText(request.getRemark());
jdbcTemplate.update( jdbcTemplate.update(
"UPDATE platform_notify_gateway SET gateway_name=?, provider_code=?, status=?, config_json=?, secret_config_cipher=?, remark=?, updated_by=?, updated_at=CURRENT_TIMESTAMP " + "UPDATE tenant_notify_gateway SET gateway_name=?, provider_code=?, status=?, config_json=?, secret_config_cipher=?, remark=?, updated_by=?, updated_at=CURRENT_TIMESTAMP " +
"WHERE channel_code=? AND is_deleted=0", "WHERE tenant_id=? AND channel_code=? AND is_deleted=0",
gatewayName, gatewayName,
bundle.providerCode, bundle.providerCode,
bundle.status, bundle.status,
@@ -67,14 +78,20 @@ public class PlatformNotifyGatewayService {
cryptoService.encrypt(toJson(bundle.secretConfig)), cryptoService.encrypt(toJson(bundle.secretConfig)),
remark, remark,
safeUserId(), safeUserId(),
resolvedTenantId,
normalizedChannel normalizedChannel
); );
return toInfo(findRow(normalizedChannel)); return toInfo(findRow(resolvedTenantId, normalizedChannel));
} }
public PlatformNotifyGatewayResolvedConfig resolveChannelConfig(String channelCode, boolean requireEnabled) { public PlatformNotifyGatewayResolvedConfig resolveChannelConfig(String channelCode, boolean requireEnabled) {
return resolveChannelConfig(tenantId(), channelCode, requireEnabled);
}
public PlatformNotifyGatewayResolvedConfig resolveChannelConfig(Long tenantId, String channelCode, boolean requireEnabled) {
Long resolvedTenantId = requireTenantId(tenantId);
String normalizedChannel = normalizeChannel(channelCode); String normalizedChannel = normalizeChannel(channelCode);
Map<String, Object> row = findRow(normalizedChannel); Map<String, Object> row = findRow(resolvedTenantId, normalizedChannel);
if (row.isEmpty()) { if (row.isEmpty()) {
return null; return null;
} }
@@ -85,6 +102,7 @@ public class PlatformNotifyGatewayService {
Map<String, Object> mergedConfig = mergeConfig(row); Map<String, Object> mergedConfig = mergeConfig(row);
return new PlatformNotifyGatewayResolvedConfig( return new PlatformNotifyGatewayResolvedConfig(
toLong(row.get("id")), toLong(row.get("id")),
toLong(row.get("tenant_id")),
normalizedChannel, normalizedChannel,
String.valueOf(row.get("gateway_name")), String.valueOf(row.get("gateway_name")),
String.valueOf(row.get("provider_code")), String.valueOf(row.get("provider_code")),
@@ -94,10 +112,40 @@ public class PlatformNotifyGatewayService {
); );
} }
public void ensureTenantDefaults(Long tenantId) {
Long resolvedTenantId = requireTenantId(tenantId);
ensureTenantDefaultGateway(resolvedTenantId, "EMAIL", "邮件网关", "SMTP", "当前租户邮件网关配置");
ensureTenantDefaultGateway(resolvedTenantId, "SMS", "短信网关", "MOCK", "当前租户短信网关配置");
}
private void ensureTenantDefaultGateway(Long tenantId, String channelCode, String gatewayName, String providerCode, String remark) {
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(1) FROM tenant_notify_gateway WHERE tenant_id=? AND channel_code=? AND is_deleted=0",
Integer.class,
tenantId,
channelCode
);
if (count != null && count > 0) {
return;
}
jdbcTemplate.update(
"INSERT INTO tenant_notify_gateway (tenant_id, channel_code, gateway_name, provider_code, status, config_json, secret_config_cipher, remark, is_deleted, created_by, updated_by) " +
"VALUES (?, ?, ?, ?, 'DISABLED', '{}', '', ?, 0, ?, ?)",
tenantId,
channelCode,
gatewayName,
providerCode,
remark,
safeUserId(),
safeUserId()
);
}
private PlatformNotifyGatewayInfo toInfo(Map<String, Object> row) { private PlatformNotifyGatewayInfo toInfo(Map<String, Object> row) {
Map<String, Object> mergedConfig = mergeConfig(row); Map<String, Object> mergedConfig = mergeConfig(row);
return new PlatformNotifyGatewayInfo( return new PlatformNotifyGatewayInfo(
toLong(row.get("id")), toLong(row.get("id")),
toLong(row.get("tenant_id")),
String.valueOf(row.get("channel_code")), String.valueOf(row.get("channel_code")),
String.valueOf(row.get("gateway_name")), String.valueOf(row.get("gateway_name")),
String.valueOf(row.get("provider_code")), String.valueOf(row.get("provider_code")),
@@ -120,6 +168,7 @@ public class PlatformNotifyGatewayService {
publicConfig.put("port", normalizeInt(safeInput.get("port"), 587)); publicConfig.put("port", normalizeInt(safeInput.get("port"), 587));
publicConfig.put("protocol", normalizeText(safeInput.get("protocol"), "smtp")); publicConfig.put("protocol", normalizeText(safeInput.get("protocol"), "smtp"));
publicConfig.put("fromAddress", normalizeNullableText(safeInput.get("fromAddress"))); publicConfig.put("fromAddress", normalizeNullableText(safeInput.get("fromAddress")));
publicConfig.put("fromName", normalizeNullableText(safeInput.get("fromName")));
publicConfig.put("defaultSubject", normalizeText(safeInput.get("defaultSubject"), "系统通知")); publicConfig.put("defaultSubject", normalizeText(safeInput.get("defaultSubject"), "系统通知"));
publicConfig.put("smtpAuth", normalizeBoolean(safeInput.get("smtpAuth"), true)); publicConfig.put("smtpAuth", normalizeBoolean(safeInput.get("smtpAuth"), true));
publicConfig.put("starttlsEnable", normalizeBoolean(safeInput.get("starttlsEnable"), true)); publicConfig.put("starttlsEnable", normalizeBoolean(safeInput.get("starttlsEnable"), true));
@@ -150,7 +199,6 @@ public class PlatformNotifyGatewayService {
boolean mockEnabled = normalizeBoolean(safeInput.get("mockEnabled"), "MOCK".equals(providerCode)); boolean mockEnabled = normalizeBoolean(safeInput.get("mockEnabled"), "MOCK".equals(providerCode));
publicConfig.put("endpoint", normalizeNullableText(safeInput.get("endpoint"))); publicConfig.put("endpoint", normalizeNullableText(safeInput.get("endpoint")));
publicConfig.put("signName", normalizeNullableText(safeInput.get("signName"))); publicConfig.put("signName", normalizeNullableText(safeInput.get("signName")));
publicConfig.put("templateCode", normalizeNullableText(safeInput.get("templateCode")));
publicConfig.put("regionId", normalizeText(safeInput.get("regionId"), "cn-hangzhou")); publicConfig.put("regionId", normalizeText(safeInput.get("regionId"), "cn-hangzhou"));
publicConfig.put("mockEnabled", mockEnabled); publicConfig.put("mockEnabled", mockEnabled);
publicConfig.put("quietPeriodSeconds", normalizeInt(safeInput.get("quietPeriodSeconds"), 30)); publicConfig.put("quietPeriodSeconds", normalizeInt(safeInput.get("quietPeriodSeconds"), 30));
@@ -179,11 +227,12 @@ public class PlatformNotifyGatewayService {
return merged; return merged;
} }
private Map<String, Object> findRow(String channelCode) { private Map<String, Object> findRow(Long tenantId, String channelCode) {
List<Map<String, Object>> rows = jdbcTemplate.queryForList( List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT id, channel_code, gateway_name, provider_code, status, config_json, secret_config_cipher, remark, " + "SELECT id, tenant_id, channel_code, gateway_name, provider_code, status, config_json, secret_config_cipher, remark, " +
"DATE_FORMAT(updated_at, '%Y-%m-%d %H:%i:%s') AS updated_at " + "DATE_FORMAT(updated_at, '%Y-%m-%d %H:%i:%s') AS updated_at " +
"FROM platform_notify_gateway WHERE channel_code=? AND is_deleted=0 LIMIT 1", "FROM tenant_notify_gateway WHERE tenant_id=? AND channel_code=? AND is_deleted=0 LIMIT 1",
tenantId,
channelCode channelCode
); );
return rows.isEmpty() ? new LinkedHashMap<String, Object>() : rows.get(0); return rows.isEmpty() ? new LinkedHashMap<String, Object>() : rows.get(0);
@@ -298,6 +347,17 @@ public class PlatformNotifyGatewayService {
return userId == null ? 0L : userId; return userId == null ? 0L : userId;
} }
private Long tenantId() {
return AuthContext.requireTenantId();
}
private Long requireTenantId(Long tenantId) {
if (tenantId == null || tenantId <= 0) {
throw new IllegalStateException("tenant context required");
}
return tenantId;
}
private static final class SaveConfigBundle { private static final class SaveConfigBundle {
private final String providerCode; private final String providerCode;
private final String status; private final String status;
@@ -4,12 +4,15 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.writeoff.common.exception.BusinessException; import com.writeoff.common.exception.BusinessException;
import com.writeoff.module.notification.dto.TestPlatformNotifyGatewayRequest; import com.writeoff.module.notification.dto.TestPlatformNotifyGatewayRequest;
import com.writeoff.module.notification.model.PlatformNotifyGatewayResolvedConfig; import com.writeoff.module.notification.model.PlatformNotifyGatewayResolvedConfig;
import com.writeoff.module.notification.provider.SmsNotificationProvider;
import com.writeoff.module.notification.provider.NotificationSendResult; import com.writeoff.module.notification.provider.NotificationSendResult;
import org.springframework.mail.SimpleMailMessage; import com.writeoff.module.notification.provider.SmsNotificationProvider;
import org.springframework.mail.javamail.JavaMailSenderImpl; import org.springframework.mail.javamail.JavaMailSenderImpl;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import javax.mail.internet.InternetAddress;
import javax.mail.internet.MimeMessage;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.Map; import java.util.Map;
import java.util.Properties; import java.util.Properties;
@@ -44,6 +47,7 @@ public class PlatformNotifyGatewayTestService {
Map<String, Object> config = gateway.getConfig(); Map<String, Object> config = gateway.getConfig();
String host = text(config.get("host")); String host = text(config.get("host"));
String fromAddress = text(config.get("fromAddress")); String fromAddress = text(config.get("fromAddress"));
String fromName = text(config.get("fromName"));
boolean smtpAuth = boolValue(config.get("smtpAuth"), true); boolean smtpAuth = boolValue(config.get("smtpAuth"), true);
String username = text(config.get("username")); String username = text(config.get("username"));
String password = text(config.get("password")); String password = text(config.get("password"));
@@ -77,14 +81,16 @@ public class PlatformNotifyGatewayTestService {
props.put("mail.smtp.timeout", String.valueOf(intValue(config.get("timeoutMs"), 5000))); props.put("mail.smtp.timeout", String.valueOf(intValue(config.get("timeoutMs"), 5000)));
props.put("mail.smtp.writetimeout", String.valueOf(intValue(config.get("writeTimeoutMs"), 5000))); props.put("mail.smtp.writetimeout", String.valueOf(intValue(config.get("writeTimeoutMs"), 5000)));
SimpleMailMessage message = new SimpleMailMessage(); MimeMessage message = sender.createMimeMessage();
message.setFrom(fromAddress); MimeMessageHelper helper = new MimeMessageHelper(message, false, StandardCharsets.UTF_8.name());
message.setTo(request.getReceiverRef().trim()); applyFrom(helper, fromAddress, fromName);
message.setSubject(textOr(request.getSubject(), "通知网关测试")); helper.setTo(request.getReceiverRef().trim());
message.setText(textOr(request.getContent(), "这是一封来自平台通知网关配置中心的测试邮件。")); helper.setSubject(textOr(request.getSubject(), "通知网关测试"));
helper.setText(textOr(request.getContent(), "这是一封来自当前租户通知网关配置中心的测试邮件。"), false);
sender.send(message); sender.send(message);
Map<String, Object> data = new LinkedHashMap<String, Object>(); Map<String, Object> data = new LinkedHashMap<String, Object>();
data.put("tenantId", gateway.getTenantId());
data.put("channelCode", gateway.getChannelCode()); data.put("channelCode", gateway.getChannelCode());
data.put("providerCode", gateway.getProviderCode()); data.put("providerCode", gateway.getProviderCode());
data.put("receiverRef", request.getReceiverRef().trim()); data.put("receiverRef", request.getReceiverRef().trim());
@@ -112,18 +118,22 @@ public class PlatformNotifyGatewayTestService {
if (text(config.get("accessKeySecret")).isEmpty()) { if (text(config.get("accessKeySecret")).isEmpty()) {
throw new BusinessException(10001, "短信 AccessKeySecret 不能为空"); throw new BusinessException(10001, "短信 AccessKeySecret 不能为空");
} }
if ("ALIYUN_SMS".equalsIgnoreCase(gateway.getProviderCode()) && text(request.getSmsTemplateCode()).isEmpty()) {
throw new BusinessException(10001, "测试短信模板编码不能为空");
}
} }
if (!isPhoneLike(request.getReceiverRef())) { if (!isPhoneLike(request.getReceiverRef())) {
throw new BusinessException(10001, "短信测试接收目标必须为手机号"); throw new BusinessException(10001, "短信测试接收目标必须为手机号");
} }
Map<String, Object> payload = new LinkedHashMap<String, Object>(); Map<String, Object> payload = new LinkedHashMap<String, Object>();
payload.put("subject", textOr(request.getSubject(), "通知网关测试")); payload.put("subject", textOr(request.getSubject(), "通知网关测试"));
payload.put("content", textOr(request.getContent(), "这是一条来自平台通知网关配置中心的测试短信。")); payload.put("content", textOr(request.getContent(), "这是一条来自当前租户通知网关配置中心的测试短信。"));
payload.put("signName", text(config.get("signName"))); payload.put("signName", text(config.get("signName")));
payload.put("templateCode", text(config.get("templateCode"))); payload.put("smsTemplateCode", textOr(request.getSmsTemplateCode(), text(config.get("templateCode"))));
try { try {
if ("ALIYUN_SMS".equalsIgnoreCase(gateway.getProviderCode()) && !mockEnabled) { if ("ALIYUN_SMS".equalsIgnoreCase(gateway.getProviderCode()) && !mockEnabled) {
Map<String, Object> sendContext = new LinkedHashMap<String, Object>(); Map<String, Object> sendContext = new LinkedHashMap<String, Object>();
sendContext.put("tenantId", gateway.getTenantId());
sendContext.put("outId", "test-" + System.currentTimeMillis()); sendContext.put("outId", "test-" + System.currentTimeMillis());
NotificationSendResult result = smsNotificationProvider.send( NotificationSendResult result = smsNotificationProvider.send(
request.getReceiverRef().trim(), request.getReceiverRef().trim(),
@@ -134,6 +144,7 @@ public class PlatformNotifyGatewayTestService {
throw new BusinessException(10001, result == null ? "测试短信发送失败" : result.getProviderMessage()); throw new BusinessException(10001, result == null ? "测试短信发送失败" : result.getProviderMessage());
} }
Map<String, Object> data = new LinkedHashMap<String, Object>(); Map<String, Object> data = new LinkedHashMap<String, Object>();
data.put("tenantId", gateway.getTenantId());
data.put("channelCode", gateway.getChannelCode()); data.put("channelCode", gateway.getChannelCode());
data.put("providerCode", gateway.getProviderCode()); data.put("providerCode", gateway.getProviderCode());
data.put("receiverRef", request.getReceiverRef().trim()); data.put("receiverRef", request.getReceiverRef().trim());
@@ -144,6 +155,7 @@ public class PlatformNotifyGatewayTestService {
return data; return data;
} }
Map<String, Object> data = new LinkedHashMap<String, Object>(); Map<String, Object> data = new LinkedHashMap<String, Object>();
data.put("tenantId", gateway.getTenantId());
data.put("channelCode", gateway.getChannelCode()); data.put("channelCode", gateway.getChannelCode());
data.put("providerCode", gateway.getProviderCode()); data.put("providerCode", gateway.getProviderCode());
data.put("receiverRef", request.getReceiverRef().trim()); data.put("receiverRef", request.getReceiverRef().trim());
@@ -152,6 +164,8 @@ public class PlatformNotifyGatewayTestService {
data.put("payloadJson", objectMapper.writeValueAsString(payload)); data.put("payloadJson", objectMapper.writeValueAsString(payload));
data.put("message", mockEnabled ? "测试短信已模拟受理" : "测试短信参数校验通过,当前版本按模拟模式返回受理"); data.put("message", mockEnabled ? "测试短信已模拟受理" : "测试短信参数校验通过,当前版本按模拟模式返回受理");
return data; return data;
} catch (BusinessException ex) {
throw ex;
} catch (Exception ex) { } catch (Exception ex) {
throw new BusinessException(10001, "测试短信构造失败"); throw new BusinessException(10001, "测试短信构造失败");
} }
@@ -173,6 +187,14 @@ public class PlatformNotifyGatewayTestService {
return true; return true;
} }
private void applyFrom(MimeMessageHelper helper, String fromAddress, String fromName) throws Exception {
if (fromName.isEmpty()) {
helper.setFrom(fromAddress);
return;
}
helper.setFrom(new InternetAddress(fromAddress, fromName, StandardCharsets.UTF_8.name()));
}
private String text(Object value) { private String text(Object value) {
return value == null ? "" : String.valueOf(value).trim(); return value == null ? "" : String.valueOf(value).trim();
} }
@@ -3,6 +3,7 @@ package com.writeoff.module.project.dto;
import javax.validation.constraints.Min; import javax.validation.constraints.Min;
import javax.validation.constraints.NotBlank; import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull; import javax.validation.constraints.NotNull;
import javax.validation.constraints.Max;
import java.time.LocalDate; import java.time.LocalDate;
public class CreateProjectRequest { public class CreateProjectRequest {
@@ -23,6 +24,10 @@ public class CreateProjectRequest {
private String overBudgetApprovalChainJson; private String overBudgetApprovalChainJson;
private Double laborFeeRatio; private Double laborFeeRatio;
private Double cateringFeeRatio;
@Min(value = 1, message = "劳务费协议签署类型不合法")
@Max(value = 2, message = "劳务费协议签署类型不合法")
private Integer laborAgreementSignType;
private Boolean allowProjectOverBudget; private Boolean allowProjectOverBudget;
private String invoiceInfo; private String invoiceInfo;
private String expenseRatioJson; private String expenseRatioJson;
@@ -118,6 +123,22 @@ public class CreateProjectRequest {
this.laborFeeRatio = laborFeeRatio; this.laborFeeRatio = laborFeeRatio;
} }
public Double getCateringFeeRatio() {
return cateringFeeRatio;
}
public void setCateringFeeRatio(Double cateringFeeRatio) {
this.cateringFeeRatio = cateringFeeRatio;
}
public Integer getLaborAgreementSignType() {
return laborAgreementSignType;
}
public void setLaborAgreementSignType(Integer laborAgreementSignType) {
this.laborAgreementSignType = laborAgreementSignType;
}
public Boolean getAllowProjectOverBudget() { public Boolean getAllowProjectOverBudget() {
return allowProjectOverBudget; return allowProjectOverBudget;
} }
@@ -61,6 +61,8 @@ public class Project {
private int writeOffCompletedCount; private int writeOffCompletedCount;
/** 劳务费用占比 */ /** 劳务费用占比 */
private double laborFeeRatio; private double laborFeeRatio;
private double cateringFeeRatio;
private int laborAgreementSignType = 1;
/** 是否允许超过项目总费用 */ /** 是否允许超过项目总费用 */
private boolean allowProjectOverBudget; private boolean allowProjectOverBudget;
/** 发票信息快照(便于一键复制) */ /** 发票信息快照(便于一键复制) */
@@ -308,6 +310,14 @@ public class Project {
return laborFeeRatio; return laborFeeRatio;
} }
public double getCateringFeeRatio() {
return cateringFeeRatio;
}
public int getLaborAgreementSignType() {
return laborAgreementSignType;
}
public boolean isAllowProjectOverBudget() { public boolean isAllowProjectOverBudget() {
return allowProjectOverBudget; return allowProjectOverBudget;
} }
@@ -424,6 +434,14 @@ public class Project {
this.projectFeeJson = projectFeeJson; this.projectFeeJson = projectFeeJson;
} }
public void setCateringFeeRatio(double cateringFeeRatio) {
this.cateringFeeRatio = cateringFeeRatio;
}
public void setLaborAgreementSignType(int laborAgreementSignType) {
this.laborAgreementSignType = laborAgreementSignType;
}
public boolean isDeleted() { public boolean isDeleted() {
return deleted; return deleted;
} }
@@ -67,6 +67,8 @@ public class InMemoryProjectRepository implements ProjectRepository {
newProject.setHostExecutorUsers(project.getHostExecutorUsers()); newProject.setHostExecutorUsers(project.getHostExecutorUsers());
newProject.setPartnerOwnerUsers(project.getPartnerOwnerUsers()); newProject.setPartnerOwnerUsers(project.getPartnerOwnerUsers());
newProject.setPartnerExecutorUsers(project.getPartnerExecutorUsers()); newProject.setPartnerExecutorUsers(project.getPartnerExecutorUsers());
newProject.setLaborAgreementSignType(project.getLaborAgreementSignType());
newProject.setCateringFeeRatio(project.getCateringFeeRatio());
newProject.setProjectFeeJson(project.getProjectFeeJson()); newProject.setProjectFeeJson(project.getProjectFeeJson());
store.put(newProject.getId(), newProject); store.put(newProject.getId(), newProject);
return newProject; return newProject;
@@ -67,6 +67,8 @@ public class JdbcProjectRepository implements ProjectRepository {
p.setHostExecutorUsers(rs.getString("host_executor_users")); p.setHostExecutorUsers(rs.getString("host_executor_users"));
p.setPartnerOwnerUsers(rs.getString("partner_owner_users")); p.setPartnerOwnerUsers(rs.getString("partner_owner_users"));
p.setPartnerExecutorUsers(rs.getString("partner_executor_users")); p.setPartnerExecutorUsers(rs.getString("partner_executor_users"));
p.setLaborAgreementSignType(rs.getObject("labor_agreement_sign_type") == null ? 1 : rs.getInt("labor_agreement_sign_type"));
p.setCateringFeeRatio(rs.getBigDecimal("catering_fee_ratio") == null ? 0d : rs.getBigDecimal("catering_fee_ratio").doubleValue());
p.setProjectFeeJson(rs.getString("project_fee_json")); p.setProjectFeeJson(rs.getString("project_fee_json"));
p.setDeleted(rs.getInt("is_deleted") == 1); p.setDeleted(rs.getInt("is_deleted") == 1);
return p; return p;
@@ -82,8 +84,8 @@ public class JdbcProjectRepository implements ProjectRepository {
KeyHolder keyHolder = new GeneratedKeyHolder(); KeyHolder keyHolder = new GeneratedKeyHolder();
jdbcTemplate.update(connection -> { jdbcTemplate.update(connection -> {
PreparedStatement ps = connection.prepareStatement( PreparedStatement ps = connection.prepareStatement(
"INSERT INTO project (tenant_id, project_name, parent_project_id, start_date, end_date, host_enterprise_name, partner_enterprise_id, budget_cent, meeting_total, meeting_completed_count, allow_meeting_over_budget, over_budget_threshold_ratio, over_budget_approval_chain_json, budget_execution_ratio, risk_flags_json, write_off_not_started_count, write_off_in_progress_count, write_off_completed_count, labor_fee_ratio, allow_project_over_budget, invoice_info, expense_ratio_json, project_fee_json, terminated_reason, freeze_reason, archived_at, key_change_log_json, status, created_by, updated_by) " + "INSERT INTO project (tenant_id, project_name, parent_project_id, start_date, end_date, host_enterprise_name, partner_enterprise_id, budget_cent, meeting_total, meeting_completed_count, allow_meeting_over_budget, over_budget_threshold_ratio, over_budget_approval_chain_json, budget_execution_ratio, risk_flags_json, write_off_not_started_count, write_off_in_progress_count, write_off_completed_count, labor_fee_ratio, catering_fee_ratio, labor_agreement_sign_type, allow_project_over_budget, invoice_info, expense_ratio_json, project_fee_json, terminated_reason, freeze_reason, archived_at, key_change_log_json, status, created_by, updated_by) " +
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
Statement.RETURN_GENERATED_KEYS Statement.RETURN_GENERATED_KEYS
); );
Long operator = safeUserId(); Long operator = safeUserId();
@@ -114,21 +116,23 @@ public class JdbcProjectRepository implements ProjectRepository {
ps.setInt(17, project.getWriteOffInProgressCount()); ps.setInt(17, project.getWriteOffInProgressCount());
ps.setInt(18, project.getWriteOffCompletedCount()); ps.setInt(18, project.getWriteOffCompletedCount());
ps.setBigDecimal(19, java.math.BigDecimal.valueOf(project.getLaborFeeRatio())); ps.setBigDecimal(19, java.math.BigDecimal.valueOf(project.getLaborFeeRatio()));
ps.setInt(20, project.isAllowProjectOverBudget() ? 1 : 0); ps.setBigDecimal(20, java.math.BigDecimal.valueOf(project.getCateringFeeRatio()));
ps.setString(21, project.getInvoiceInfo()); ps.setInt(21, project.getLaborAgreementSignType());
ps.setString(22, project.getExpenseRatioJson()); ps.setInt(22, project.isAllowProjectOverBudget() ? 1 : 0);
ps.setString(23, project.getProjectFeeJson()); ps.setString(23, project.getInvoiceInfo());
ps.setString(24, project.getTerminatedReason()); ps.setString(24, project.getExpenseRatioJson());
ps.setString(25, project.getFreezeReason()); ps.setString(25, project.getProjectFeeJson());
ps.setString(26, project.getTerminatedReason());
ps.setString(27, project.getFreezeReason());
if (project.getArchivedAt() == null) { if (project.getArchivedAt() == null) {
ps.setNull(26, java.sql.Types.TIMESTAMP); ps.setNull(28, java.sql.Types.TIMESTAMP);
} else { } else {
ps.setTimestamp(26, java.sql.Timestamp.valueOf(project.getArchivedAt())); ps.setTimestamp(28, java.sql.Timestamp.valueOf(project.getArchivedAt()));
} }
ps.setString(27, project.getKeyChangeLogJson()); ps.setString(29, project.getKeyChangeLogJson());
ps.setString(28, project.getStatus().name()); ps.setString(30, project.getStatus().name());
ps.setLong(29, operator); ps.setLong(31, operator);
ps.setLong(30, operator); ps.setLong(32, operator);
return ps; return ps;
}, keyHolder); }, keyHolder);
Number key = keyHolder.getKey(); Number key = keyHolder.getKey();
@@ -176,12 +180,14 @@ public class JdbcProjectRepository implements ProjectRepository {
created.setSubProjectCount(project.getSubProjectCount()); created.setSubProjectCount(project.getSubProjectCount());
created.setParentProjectId(project.getParentProjectId()); created.setParentProjectId(project.getParentProjectId());
created.setHostEnterpriseName(project.getHostEnterpriseName()); created.setHostEnterpriseName(project.getHostEnterpriseName());
created.setLaborAgreementSignType(project.getLaborAgreementSignType());
created.setCateringFeeRatio(project.getCateringFeeRatio());
created.setProjectFeeJson(project.getProjectFeeJson()); created.setProjectFeeJson(project.getProjectFeeJson());
return created; return created;
} }
jdbcTemplate.update( jdbcTemplate.update(
"UPDATE project SET project_name=?, parent_project_id=?, start_date=?, end_date=?, host_enterprise_name=?, partner_enterprise_id=?, budget_cent=?, meeting_total=?, meeting_completed_count=?, allow_meeting_over_budget=?, " + "UPDATE project SET project_name=?, parent_project_id=?, start_date=?, end_date=?, host_enterprise_name=?, partner_enterprise_id=?, budget_cent=?, meeting_total=?, meeting_completed_count=?, allow_meeting_over_budget=?, " +
"over_budget_threshold_ratio=?, over_budget_approval_chain_json=?, budget_execution_ratio=?, risk_flags_json=?, write_off_not_started_count=?, write_off_in_progress_count=?, write_off_completed_count=?, labor_fee_ratio=?, allow_project_over_budget=?, invoice_info=?, expense_ratio_json=?, project_fee_json=?, terminated_reason=?, freeze_reason=?, archived_at=?, key_change_log_json=?, status=?, updated_by=? WHERE tenant_id=? AND id=?", "over_budget_threshold_ratio=?, over_budget_approval_chain_json=?, budget_execution_ratio=?, risk_flags_json=?, write_off_not_started_count=?, write_off_in_progress_count=?, write_off_completed_count=?, labor_fee_ratio=?, catering_fee_ratio=?, labor_agreement_sign_type=?, allow_project_over_budget=?, invoice_info=?, expense_ratio_json=?, project_fee_json=?, terminated_reason=?, freeze_reason=?, archived_at=?, key_change_log_json=?, status=?, updated_by=? WHERE tenant_id=? AND id=?",
project.getName(), project.getName(),
project.getParentProjectId(), project.getParentProjectId(),
project.getStartDate() == null ? null : java.sql.Date.valueOf(project.getStartDate()), project.getStartDate() == null ? null : java.sql.Date.valueOf(project.getStartDate()),
@@ -201,6 +207,8 @@ public class JdbcProjectRepository implements ProjectRepository {
project.getWriteOffInProgressCount(), project.getWriteOffInProgressCount(),
project.getWriteOffCompletedCount(), project.getWriteOffCompletedCount(),
java.math.BigDecimal.valueOf(project.getLaborFeeRatio()), java.math.BigDecimal.valueOf(project.getLaborFeeRatio()),
java.math.BigDecimal.valueOf(project.getCateringFeeRatio()),
project.getLaborAgreementSignType(),
project.isAllowProjectOverBudget() ? 1 : 0, project.isAllowProjectOverBudget() ? 1 : 0,
project.getInvoiceInfo(), project.getInvoiceInfo(),
project.getExpenseRatioJson(), project.getExpenseRatioJson(),
@@ -223,7 +231,7 @@ public class JdbcProjectRepository implements ProjectRepository {
"SELECT p.id, p.project_name, p.parent_project_id, " + "SELECT p.id, p.project_name, p.parent_project_id, " +
"(SELECT COUNT(1) FROM project c WHERE c.tenant_id=p.tenant_id AND c.parent_project_id=p.id AND c.is_deleted=0) AS sub_project_count, " + "(SELECT COUNT(1) FROM project c WHERE c.tenant_id=p.tenant_id AND c.parent_project_id=p.id AND c.is_deleted=0) AS sub_project_count, " +
"p.start_date, p.end_date, p.partner_enterprise_id AS enterprise_id, e.enterprise_name, p.host_enterprise_name, p.partner_enterprise_id, p.budget_cent, p.meeting_total, p.meeting_completed_count, " + "p.start_date, p.end_date, p.partner_enterprise_id AS enterprise_id, e.enterprise_name, p.host_enterprise_name, p.partner_enterprise_id, p.budget_cent, p.meeting_total, p.meeting_completed_count, " +
"p.allow_meeting_over_budget, p.over_budget_threshold_ratio, p.over_budget_approval_chain_json, p.budget_execution_ratio, p.risk_flags_json, p.write_off_not_started_count, p.write_off_in_progress_count, p.write_off_completed_count, p.labor_fee_ratio, p.allow_project_over_budget, p.invoice_info, p.expense_ratio_json, p.project_fee_json, p.terminated_reason, p.freeze_reason, p.archived_at, p.key_change_log_json, p.status, " + "p.allow_meeting_over_budget, p.over_budget_threshold_ratio, p.over_budget_approval_chain_json, p.budget_execution_ratio, p.risk_flags_json, p.write_off_not_started_count, p.write_off_in_progress_count, p.write_off_completed_count, p.labor_fee_ratio, p.catering_fee_ratio, p.labor_agreement_sign_type, p.allow_project_over_budget, p.invoice_info, p.expense_ratio_json, p.project_fee_json, p.terminated_reason, p.freeze_reason, p.archived_at, p.key_change_log_json, p.status, " +
"p.is_deleted, " + "p.is_deleted, " +
"(SELECT GROUP_CONCAT(DISTINCT su.user_name SEPARATOR '、') FROM sys_user su JOIN user_role ur ON su.tenant_id=ur.tenant_id AND su.id=ur.user_id JOIN role r ON ur.tenant_id=r.tenant_id AND ur.role_id=r.id WHERE su.tenant_id=p.tenant_id AND su.is_deleted=0 AND r.role_code='TENANT_ADMIN') AS host_owner_users, " + "(SELECT GROUP_CONCAT(DISTINCT su.user_name SEPARATOR '、') FROM sys_user su JOIN user_role ur ON su.tenant_id=ur.tenant_id AND su.id=ur.user_id JOIN role r ON ur.tenant_id=r.tenant_id AND ur.role_id=r.id WHERE su.tenant_id=p.tenant_id AND su.is_deleted=0 AND r.role_code='TENANT_ADMIN') AS host_owner_users, " +
"(SELECT GROUP_CONCAT(DISTINCT su.user_name SEPARATOR '、') FROM project_user_binding b JOIN sys_user su ON b.tenant_id=su.tenant_id AND b.user_id=su.id WHERE b.tenant_id=p.tenant_id AND b.project_id=p.id AND b.bind_role_code='PROJECT_OWNER' AND b.is_deleted=0 AND su.is_deleted=0) AS host_executor_users, " + "(SELECT GROUP_CONCAT(DISTINCT su.user_name SEPARATOR '、') FROM project_user_binding b JOIN sys_user su ON b.tenant_id=su.tenant_id AND b.user_id=su.id WHERE b.tenant_id=p.tenant_id AND b.project_id=p.id AND b.bind_role_code='PROJECT_OWNER' AND b.is_deleted=0 AND su.is_deleted=0) AS host_executor_users, " +
@@ -245,7 +253,7 @@ public class JdbcProjectRepository implements ProjectRepository {
"SELECT p.id, p.project_name, p.parent_project_id, " + "SELECT p.id, p.project_name, p.parent_project_id, " +
"(SELECT COUNT(1) FROM project c WHERE c.tenant_id=p.tenant_id AND c.parent_project_id=p.id AND c.is_deleted=0) AS sub_project_count, " + "(SELECT COUNT(1) FROM project c WHERE c.tenant_id=p.tenant_id AND c.parent_project_id=p.id AND c.is_deleted=0) AS sub_project_count, " +
"p.start_date, p.end_date, p.partner_enterprise_id AS enterprise_id, e.enterprise_name, p.host_enterprise_name, p.partner_enterprise_id, p.budget_cent, p.meeting_total, p.meeting_completed_count, " + "p.start_date, p.end_date, p.partner_enterprise_id AS enterprise_id, e.enterprise_name, p.host_enterprise_name, p.partner_enterprise_id, p.budget_cent, p.meeting_total, p.meeting_completed_count, " +
"p.allow_meeting_over_budget, p.over_budget_threshold_ratio, p.over_budget_approval_chain_json, p.budget_execution_ratio, p.risk_flags_json, p.write_off_not_started_count, p.write_off_in_progress_count, p.write_off_completed_count, p.labor_fee_ratio, p.allow_project_over_budget, p.invoice_info, p.expense_ratio_json, p.project_fee_json, p.terminated_reason, p.freeze_reason, p.archived_at, p.key_change_log_json, p.status, " + "p.allow_meeting_over_budget, p.over_budget_threshold_ratio, p.over_budget_approval_chain_json, p.budget_execution_ratio, p.risk_flags_json, p.write_off_not_started_count, p.write_off_in_progress_count, p.write_off_completed_count, p.labor_fee_ratio, p.catering_fee_ratio, p.labor_agreement_sign_type, p.allow_project_over_budget, p.invoice_info, p.expense_ratio_json, p.project_fee_json, p.terminated_reason, p.freeze_reason, p.archived_at, p.key_change_log_json, p.status, " +
"p.is_deleted, " + "p.is_deleted, " +
"(SELECT GROUP_CONCAT(DISTINCT su.user_name SEPARATOR '、') FROM sys_user su JOIN user_role ur ON su.tenant_id=ur.tenant_id AND su.id=ur.user_id JOIN role r ON ur.tenant_id=r.tenant_id AND ur.role_id=r.id WHERE su.tenant_id=p.tenant_id AND su.is_deleted=0 AND r.role_code='TENANT_ADMIN') AS host_owner_users, " + "(SELECT GROUP_CONCAT(DISTINCT su.user_name SEPARATOR '、') FROM sys_user su JOIN user_role ur ON su.tenant_id=ur.tenant_id AND su.id=ur.user_id JOIN role r ON ur.tenant_id=r.tenant_id AND ur.role_id=r.id WHERE su.tenant_id=p.tenant_id AND su.is_deleted=0 AND r.role_code='TENANT_ADMIN') AS host_owner_users, " +
"(SELECT GROUP_CONCAT(DISTINCT su.user_name SEPARATOR '、') FROM project_user_binding b JOIN sys_user su ON b.tenant_id=su.tenant_id AND b.user_id=su.id WHERE b.tenant_id=p.tenant_id AND b.project_id=p.id AND b.bind_role_code='PROJECT_OWNER' AND b.is_deleted=0 AND su.is_deleted=0) AS host_executor_users, " + "(SELECT GROUP_CONCAT(DISTINCT su.user_name SEPARATOR '、') FROM project_user_binding b JOIN sys_user su ON b.tenant_id=su.tenant_id AND b.user_id=su.id WHERE b.tenant_id=p.tenant_id AND b.project_id=p.id AND b.bind_role_code='PROJECT_OWNER' AND b.is_deleted=0 AND su.is_deleted=0) AS host_executor_users, " +
@@ -268,7 +276,7 @@ public class JdbcProjectRepository implements ProjectRepository {
"SELECT p.id, p.project_name, p.parent_project_id, " + "SELECT p.id, p.project_name, p.parent_project_id, " +
"(SELECT COUNT(1) FROM project c WHERE c.tenant_id=p.tenant_id AND c.parent_project_id=p.id AND c.is_deleted=0) AS sub_project_count, " + "(SELECT COUNT(1) FROM project c WHERE c.tenant_id=p.tenant_id AND c.parent_project_id=p.id AND c.is_deleted=0) AS sub_project_count, " +
"p.start_date, p.end_date, p.partner_enterprise_id AS enterprise_id, e.enterprise_name, p.host_enterprise_name, p.partner_enterprise_id, p.budget_cent, p.meeting_total, p.meeting_completed_count, " + "p.start_date, p.end_date, p.partner_enterprise_id AS enterprise_id, e.enterprise_name, p.host_enterprise_name, p.partner_enterprise_id, p.budget_cent, p.meeting_total, p.meeting_completed_count, " +
"p.allow_meeting_over_budget, p.over_budget_threshold_ratio, p.over_budget_approval_chain_json, p.budget_execution_ratio, p.risk_flags_json, p.write_off_not_started_count, p.write_off_in_progress_count, p.write_off_completed_count, p.labor_fee_ratio, p.allow_project_over_budget, p.invoice_info, p.expense_ratio_json, p.project_fee_json, p.terminated_reason, p.freeze_reason, p.archived_at, p.key_change_log_json, p.status, " + "p.allow_meeting_over_budget, p.over_budget_threshold_ratio, p.over_budget_approval_chain_json, p.budget_execution_ratio, p.risk_flags_json, p.write_off_not_started_count, p.write_off_in_progress_count, p.write_off_completed_count, p.labor_fee_ratio, p.catering_fee_ratio, p.labor_agreement_sign_type, p.allow_project_over_budget, p.invoice_info, p.expense_ratio_json, p.project_fee_json, p.terminated_reason, p.freeze_reason, p.archived_at, p.key_change_log_json, p.status, " +
"p.is_deleted, " + "p.is_deleted, " +
"(SELECT GROUP_CONCAT(DISTINCT su.user_name SEPARATOR '、') FROM sys_user su JOIN user_role ur ON su.tenant_id=ur.tenant_id AND su.id=ur.user_id JOIN role r ON ur.tenant_id=r.tenant_id AND ur.role_id=r.id WHERE su.tenant_id=p.tenant_id AND su.is_deleted=0 AND r.role_code='TENANT_ADMIN') AS host_owner_users, " + "(SELECT GROUP_CONCAT(DISTINCT su.user_name SEPARATOR '、') FROM sys_user su JOIN user_role ur ON su.tenant_id=ur.tenant_id AND su.id=ur.user_id JOIN role r ON ur.tenant_id=r.tenant_id AND ur.role_id=r.id WHERE su.tenant_id=p.tenant_id AND su.is_deleted=0 AND r.role_code='TENANT_ADMIN') AS host_owner_users, " +
"(SELECT GROUP_CONCAT(DISTINCT su.user_name SEPARATOR '、') FROM project_user_binding b JOIN sys_user su ON b.tenant_id=su.tenant_id AND b.user_id=su.id WHERE b.tenant_id=p.tenant_id AND b.project_id=p.id AND b.bind_role_code='PROJECT_OWNER' AND b.is_deleted=0 AND su.is_deleted=0) AS host_executor_users, " + "(SELECT GROUP_CONCAT(DISTINCT su.user_name SEPARATOR '、') FROM project_user_binding b JOIN sys_user su ON b.tenant_id=su.tenant_id AND b.user_id=su.id WHERE b.tenant_id=p.tenant_id AND b.project_id=p.id AND b.bind_role_code='PROJECT_OWNER' AND b.is_deleted=0 AND su.is_deleted=0) AS host_executor_users, " +
@@ -20,6 +20,7 @@ import com.writeoff.security.AuthContext;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
@@ -31,6 +32,8 @@ import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.math.BigDecimal;
import java.math.RoundingMode;
@Service @Service
public class ProjectService { public class ProjectService {
@@ -110,6 +113,10 @@ public class ProjectService {
return children; return children;
} }
public boolean hasChildren(Long projectId) {
return !projectRepository.findByParentProjectId(projectId, false).isEmpty();
}
public Project create(CreateProjectRequest request) { public Project create(CreateProjectRequest request) {
if (enterpriseService != null) { if (enterpriseService != null) {
enterpriseService.assertEnabled(request.getPartnerEnterpriseId()); enterpriseService.assertEnabled(request.getPartnerEnterpriseId());
@@ -121,6 +128,7 @@ public class ProjectService {
} }
if (request.getParentProjectId() != null) { if (request.getParentProjectId() != null) {
getById(request.getParentProjectId()); getById(request.getParentProjectId());
assertParentBudgetCanCoverChildren(request.getParentProjectId(), null, request.getBudgetCent());
} }
ProjectFeeSummary projectFee = buildProjectFeeSummary(request.getProjectFeeJson()); ProjectFeeSummary projectFee = buildProjectFeeSummary(request.getProjectFeeJson());
double budgetExecutionRatio = calculateBudgetExecutionRatio(request.getBudgetCent(), projectFee.totalCent); double budgetExecutionRatio = calculateBudgetExecutionRatio(request.getBudgetCent(), projectFee.totalCent);
@@ -157,7 +165,7 @@ public class ProjectService {
request.getMeetingTotal(), request.getMeetingTotal(),
0, 0,
0, 0,
request.getLaborFeeRatio() == null ? 0d : request.getLaborFeeRatio(), normalizeRatio(request.getLaborFeeRatio(), "鍔冲姟璐圭敤鍗犳瘮"),
request.getAllowProjectOverBudget() != null && request.getAllowProjectOverBudget(), request.getAllowProjectOverBudget() != null && request.getAllowProjectOverBudget(),
request.getInvoiceInfo(), request.getInvoiceInfo(),
request.getExpenseRatioJson(), request.getExpenseRatioJson(),
@@ -171,6 +179,8 @@ public class ProjectService {
null, null,
ProjectStatus.WAITING ProjectStatus.WAITING
); );
project.setCateringFeeRatio(normalizeRatio(request.getCateringFeeRatio(), "餐费占比"));
project.setLaborAgreementSignType(normalizeLaborAgreementSignType(request.getLaborAgreementSignType(), 1));
project.setParentProjectId(request.getParentProjectId()); project.setParentProjectId(request.getParentProjectId());
project.setHostEnterpriseName(resolveCurrentTenantName()); project.setHostEnterpriseName(resolveCurrentTenantName());
project.setProjectFeeJson(projectFee.normalizedJson); project.setProjectFeeJson(projectFee.normalizedJson);
@@ -194,6 +204,15 @@ public class ProjectService {
: requestProjectFeeJson; : requestProjectFeeJson;
ProjectFeeSummary projectFee = buildProjectFeeSummary(sourceProjectFeeJson); ProjectFeeSummary projectFee = buildProjectFeeSummary(sourceProjectFeeJson);
double budgetExecutionRatio = calculateBudgetExecutionRatio(request.getBudgetCent(), projectFee.totalCent); double budgetExecutionRatio = calculateBudgetExecutionRatio(request.getBudgetCent(), projectFee.totalCent);
Long targetParentProjectId = request.getParentProjectId() == null ? existing.getParentProjectId() : request.getParentProjectId();
if (targetParentProjectId != null) {
if (targetParentProjectId.equals(projectId)) {
throw new BusinessException(10001, "项目不能将自己设置为父项目");
}
getById(targetParentProjectId);
assertParentBudgetCanCoverChildren(targetParentProjectId, projectId, request.getBudgetCent());
}
assertCurrentBudgetCanCoverChildren(projectId, request.getBudgetCent());
assertProjectBudgetConstraint( assertProjectBudgetConstraint(
request.getAllowProjectOverBudget() != null && request.getAllowProjectOverBudget(), request.getAllowProjectOverBudget() != null && request.getAllowProjectOverBudget(),
request.getOverBudgetThresholdRatio() == null ? 0.1d : request.getOverBudgetThresholdRatio(), request.getOverBudgetThresholdRatio() == null ? 0.1d : request.getOverBudgetThresholdRatio(),
@@ -227,7 +246,7 @@ public class ProjectService {
existing.getWriteOffNotStartedCount(), existing.getWriteOffNotStartedCount(),
existing.getWriteOffInProgressCount(), existing.getWriteOffInProgressCount(),
existing.getWriteOffCompletedCount(), existing.getWriteOffCompletedCount(),
request.getLaborFeeRatio() == null ? existing.getLaborFeeRatio() : request.getLaborFeeRatio(), request.getLaborFeeRatio() == null ? existing.getLaborFeeRatio() : normalizeRatio(request.getLaborFeeRatio(), "鍔冲姟璐圭敤鍗犳瘮"),
request.getAllowProjectOverBudget() != null && request.getAllowProjectOverBudget(), request.getAllowProjectOverBudget() != null && request.getAllowProjectOverBudget(),
request.getInvoiceInfo(), request.getInvoiceInfo(),
request.getExpenseRatioJson(), request.getExpenseRatioJson(),
@@ -241,7 +260,13 @@ public class ProjectService {
existing.getKeyChangeLogJson(), existing.getKeyChangeLogJson(),
existing.getStatus() existing.getStatus()
); );
project.setParentProjectId(request.getParentProjectId() == null ? existing.getParentProjectId() : request.getParentProjectId()); project.setCateringFeeRatio(
request.getCateringFeeRatio() == null
? existing.getCateringFeeRatio()
: normalizeRatio(request.getCateringFeeRatio(), "餐费占比")
);
project.setLaborAgreementSignType(normalizeLaborAgreementSignType(request.getLaborAgreementSignType(), existing.getLaborAgreementSignType()));
project.setParentProjectId(targetParentProjectId);
project.setHostEnterpriseName(resolveCurrentTenantName()); project.setHostEnterpriseName(resolveCurrentTenantName());
project.setProjectFeeJson(projectFee.normalizedJson); project.setProjectFeeJson(projectFee.normalizedJson);
Project saved = projectRepository.save(project); Project saved = projectRepository.save(project);
@@ -330,13 +355,11 @@ public class ProjectService {
return result; return result;
} }
@Transactional
public void saveBindings(Long projectId, SaveProjectBindingsRequest request) { public void saveBindings(Long projectId, SaveProjectBindingsRequest request) {
ensureJdbcEnabled(); ensureJdbcEnabled();
getById(projectId); getById(projectId);
boolean projectExecutorMode = isCurrentUserProjectExecutor(); boolean projectExecutorMode = isCurrentUserProjectExecutor();
List<Map<String, Object>> beforeOwnerUsers = listProjectBoundUsers(projectId, "PROJECT_OWNER");
List<Map<String, Object>> beforeExecutorUsers = listProjectBoundUsers(projectId, "PROJECT_EXECUTOR");
List<Map<String, Object>> beforeLegacyExecutorUsers = listProjectBoundUsers(projectId, "EXECUTOR");
List<Long> ownerUserIds = request.getOwnerUserIds() == null ? new ArrayList<Long>() : request.getOwnerUserIds(); List<Long> ownerUserIds = request.getOwnerUserIds() == null ? new ArrayList<Long>() : request.getOwnerUserIds();
List<Long> executorUserIds = request.getExecutorUserIds() == null ? new ArrayList<Long>() : request.getExecutorUserIds(); List<Long> executorUserIds = request.getExecutorUserIds() == null ? new ArrayList<Long>() : request.getExecutorUserIds();
List<Long> legacyExecutorUserIds = request.getLegacyExecutorUserIds() == null ? new ArrayList<Long>() : request.getLegacyExecutorUserIds(); List<Long> legacyExecutorUserIds = request.getLegacyExecutorUserIds() == null ? new ArrayList<Long>() : request.getLegacyExecutorUserIds();
@@ -361,21 +384,7 @@ public class ProjectService {
} else { } else {
legacyExecutorUserIds = listProjectBoundUserIds(projectId, "EXECUTOR"); legacyExecutorUserIds = listProjectBoundUserIds(projectId, "EXECUTOR");
} }
jdbcTemplate.update( applyBindingsToProjectTree(projectId, ownerUserIds, executorUserIds, legacyExecutorUserIds);
"DELETE FROM project_user_binding WHERE tenant_id=? AND project_id=?",
tenantId(),
projectId
);
for (Long userId : ownerUserIds) {
insertBinding(projectId, userId, "PROJECT_OWNER");
}
for (Long userId : executorUserIds) {
insertBinding(projectId, userId, "PROJECT_EXECUTOR");
}
for (Long userId : legacyExecutorUserIds) {
insertBinding(projectId, userId, "EXECUTOR");
}
logProjectBindingChanges(projectId, beforeOwnerUsers, beforeExecutorUsers, beforeLegacyExecutorUsers, ownerUserIds, executorUserIds, legacyExecutorUserIds);
} }
public List<Map<String, Object>> listKeyChangeLogs(Long projectId) { public List<Map<String, Object>> listKeyChangeLogs(Long projectId) {
@@ -467,6 +476,44 @@ public class ProjectService {
); );
} }
private void applyBindingsToProjectTree(Long projectId,
List<Long> ownerUserIds,
List<Long> executorUserIds,
List<Long> legacyExecutorUserIds) {
applyBindingsToSingleProject(projectId, ownerUserIds, executorUserIds, legacyExecutorUserIds);
List<Project> children = projectRepository.findByParentProjectId(projectId, false);
for (Project child : children) {
if (child == null || child.getId() == null) {
continue;
}
applyBindingsToProjectTree(child.getId(), ownerUserIds, executorUserIds, legacyExecutorUserIds);
}
}
private void applyBindingsToSingleProject(Long projectId,
List<Long> ownerUserIds,
List<Long> executorUserIds,
List<Long> legacyExecutorUserIds) {
List<Map<String, Object>> beforeOwnerUsers = listProjectBoundUsers(projectId, "PROJECT_OWNER");
List<Map<String, Object>> beforeExecutorUsers = listProjectBoundUsers(projectId, "PROJECT_EXECUTOR");
List<Map<String, Object>> beforeLegacyExecutorUsers = listProjectBoundUsers(projectId, "EXECUTOR");
jdbcTemplate.update(
"DELETE FROM project_user_binding WHERE tenant_id=? AND project_id=?",
tenantId(),
projectId
);
for (Long userId : ownerUserIds) {
insertBinding(projectId, userId, "PROJECT_OWNER");
}
for (Long userId : executorUserIds) {
insertBinding(projectId, userId, "PROJECT_EXECUTOR");
}
for (Long userId : legacyExecutorUserIds) {
insertBinding(projectId, userId, "EXECUTOR");
}
logProjectBindingChanges(projectId, beforeOwnerUsers, beforeExecutorUsers, beforeLegacyExecutorUsers, ownerUserIds, executorUserIds, legacyExecutorUserIds);
}
private void ensureJdbcEnabled() { private void ensureJdbcEnabled() {
if (jdbcTemplate == null) { if (jdbcTemplate == null) {
throw new BusinessException(10001, "当前仓储模式不支持项目人员绑定"); throw new BusinessException(10001, "当前仓储模式不支持项目人员绑定");
@@ -590,6 +637,68 @@ public class ProjectService {
return (double) feeTotalCent / budget; return (double) feeTotalCent / budget;
} }
private void assertParentBudgetCanCoverChildren(Long parentProjectId, Long currentProjectId, Long currentBudgetCent) {
if (parentProjectId == null) {
return;
}
Project parentProject = getById(parentProjectId);
long siblingBudgetTotalCent = projectRepository.findByParentProjectId(parentProjectId, false).stream()
.filter(project -> currentProjectId == null || !currentProjectId.equals(project.getId()))
.mapToLong(Project::getBudgetCent)
.sum();
long nextChildrenBudgetTotalCent = siblingBudgetTotalCent + normalizeBudgetCent(currentBudgetCent);
if (nextChildrenBudgetTotalCent > parentProject.getBudgetCent()) {
throw new BusinessException(
10001,
String.format(
Locale.ROOT,
"子项目预算合计不能超过父项目预算:父项目预算%.2f元,子项目预算合计%.2f元",
toYuanAmount(parentProject.getBudgetCent()),
toYuanAmount(nextChildrenBudgetTotalCent)
)
);
}
}
private void assertCurrentBudgetCanCoverChildren(Long projectId, Long currentBudgetCent) {
long childrenBudgetTotalCent = projectRepository.findByParentProjectId(projectId, false).stream()
.mapToLong(Project::getBudgetCent)
.sum();
long nextBudgetCent = normalizeBudgetCent(currentBudgetCent);
if (childrenBudgetTotalCent > nextBudgetCent) {
throw new BusinessException(
10001,
String.format(
Locale.ROOT,
"当前项目预算不能小于子项目预算合计:项目预算%.2f元,子项目预算合计%.2f元",
toYuanAmount(nextBudgetCent),
toYuanAmount(childrenBudgetTotalCent)
)
);
}
}
private long normalizeBudgetCent(Long budgetCent) {
return budgetCent == null ? 0L : Math.max(0L, budgetCent);
}
private double toYuanAmount(long cent) {
return cent / 100d;
}
private int normalizeLaborAgreementSignType(Integer signType, int defaultValue) {
int resolved = signType == null ? defaultValue : signType;
return resolved == 2 ? 2 : 1;
}
private double normalizeRatio(Double value, String fieldName) {
double normalized = value == null ? 0d : value;
if (Double.isNaN(normalized) || Double.isInfinite(normalized) || normalized < 0d || normalized > 1d) {
throw new BusinessException(10001, fieldName + "鍙兘鍦?0~1涔嬮棿");
}
return BigDecimal.valueOf(normalized).setScale(6, RoundingMode.HALF_UP).doubleValue();
}
private void assertProjectBudgetConstraint(boolean allowProjectOverBudget, private void assertProjectBudgetConstraint(boolean allowProjectOverBudget,
double thresholdRatio, double thresholdRatio,
double budgetExecutionRatio, double budgetExecutionRatio,
@@ -627,6 +736,8 @@ public class ProjectService {
logProjectFieldChange(project.getId(), "PROJECT_CREATE", "budgetCent", "项目预算(分)", null, project.getBudgetCent(), null); logProjectFieldChange(project.getId(), "PROJECT_CREATE", "budgetCent", "项目预算(分)", null, project.getBudgetCent(), null);
logProjectFieldChange(project.getId(), "PROJECT_CREATE", "meetingTotal", "会议总期数", null, project.getMeetingTotal(), null); logProjectFieldChange(project.getId(), "PROJECT_CREATE", "meetingTotal", "会议总期数", null, project.getMeetingTotal(), null);
logProjectFieldChange(project.getId(), "PROJECT_CREATE", "laborFeeRatio", "劳务费用占比", null, project.getLaborFeeRatio(), null); logProjectFieldChange(project.getId(), "PROJECT_CREATE", "laborFeeRatio", "劳务费用占比", null, project.getLaborFeeRatio(), null);
logProjectFieldChange(project.getId(), "PROJECT_CREATE", "cateringFeeRatio", "餐费占比", null, project.getCateringFeeRatio(), null);
logProjectFieldChange(project.getId(), "PROJECT_CREATE", "laborAgreementSignType", "劳务费协议签署类型", null, project.getLaborAgreementSignType(), null);
logProjectFieldChange(project.getId(), "PROJECT_CREATE", "invoiceInfo", "发票信息", null, project.getInvoiceInfo(), null); logProjectFieldChange(project.getId(), "PROJECT_CREATE", "invoiceInfo", "发票信息", null, project.getInvoiceInfo(), null);
logProjectFieldChange(project.getId(), "PROJECT_CREATE", "expenseRatioJson", "费用占比配置", null, project.getExpenseRatioJson(), null); logProjectFieldChange(project.getId(), "PROJECT_CREATE", "expenseRatioJson", "费用占比配置", null, project.getExpenseRatioJson(), null);
logProjectFieldChange(project.getId(), "PROJECT_CREATE", "projectFeeJson", "项目费用配置", null, project.getProjectFeeJson(), null); logProjectFieldChange(project.getId(), "PROJECT_CREATE", "projectFeeJson", "项目费用配置", null, project.getProjectFeeJson(), null);
@@ -646,6 +757,8 @@ public class ProjectService {
logProjectFieldChange(after.getId(), "PROJECT_UPDATE", "budgetCent", "项目预算(分)", before.getBudgetCent(), after.getBudgetCent(), batchId); logProjectFieldChange(after.getId(), "PROJECT_UPDATE", "budgetCent", "项目预算(分)", before.getBudgetCent(), after.getBudgetCent(), batchId);
logProjectFieldChange(after.getId(), "PROJECT_UPDATE", "meetingTotal", "会议总期数", before.getMeetingTotal(), after.getMeetingTotal(), batchId); logProjectFieldChange(after.getId(), "PROJECT_UPDATE", "meetingTotal", "会议总期数", before.getMeetingTotal(), after.getMeetingTotal(), batchId);
logProjectFieldChange(after.getId(), "PROJECT_UPDATE", "laborFeeRatio", "劳务费用占比", before.getLaborFeeRatio(), after.getLaborFeeRatio(), batchId); logProjectFieldChange(after.getId(), "PROJECT_UPDATE", "laborFeeRatio", "劳务费用占比", before.getLaborFeeRatio(), after.getLaborFeeRatio(), batchId);
logProjectFieldChange(after.getId(), "PROJECT_UPDATE", "cateringFeeRatio", "餐费占比", before.getCateringFeeRatio(), after.getCateringFeeRatio(), batchId);
logProjectFieldChange(after.getId(), "PROJECT_UPDATE", "laborAgreementSignType", "劳务费协议签署类型", before.getLaborAgreementSignType(), after.getLaborAgreementSignType(), batchId);
logProjectFieldChange(after.getId(), "PROJECT_UPDATE", "invoiceInfo", "发票信息", before.getInvoiceInfo(), after.getInvoiceInfo(), batchId); logProjectFieldChange(after.getId(), "PROJECT_UPDATE", "invoiceInfo", "发票信息", before.getInvoiceInfo(), after.getInvoiceInfo(), batchId);
logProjectFieldChange(after.getId(), "PROJECT_UPDATE", "expenseRatioJson", "费用占比配置", before.getExpenseRatioJson(), after.getExpenseRatioJson(), batchId); logProjectFieldChange(after.getId(), "PROJECT_UPDATE", "expenseRatioJson", "费用占比配置", before.getExpenseRatioJson(), after.getExpenseRatioJson(), batchId);
logProjectFieldChange(after.getId(), "PROJECT_UPDATE", "projectFeeJson", "项目费用配置", before.getProjectFeeJson(), after.getProjectFeeJson(), batchId); logProjectFieldChange(after.getId(), "PROJECT_UPDATE", "projectFeeJson", "项目费用配置", before.getProjectFeeJson(), after.getProjectFeeJson(), batchId);
@@ -79,4 +79,10 @@ public class DataPermissionController {
public ApiResponse<Map<String, Object>> currentScope() { public ApiResponse<Map<String, Object>> currentScope() {
return ApiResponse.success(dataPermissionService.currentScopeSummary()); return ApiResponse.success(dataPermissionService.currentScopeSummary());
} }
@GetMapping("/match")
@RequirePermission(value = "data.permission.read", dataScope = DataScopeType.TENANT, auditAction = "DATA_PERMISSION_MATCH_SCOPE")
public ApiResponse<Map<String, Object>> matchScope(@RequestParam("account") String account) {
return ApiResponse.success(dataPermissionService.matchScopeSummary(account));
}
} }
@@ -166,23 +166,41 @@ public class DataPermissionService {
public Map<String, Object> currentScopeSummary() { public Map<String, Object> currentScopeSummary() {
Long userId = AuthContext.userId(); Long userId = AuthContext.userId();
DataScope scope = resolveCurrentUserScope(); DataScope scope = resolveCurrentUserScope();
Map<String, Object> data = new LinkedHashMap<>(); return buildScopeSummaryMap(userId, null, null, scope, userId == null ? new ArrayList<Long>() : listMatchedPolicyIds(userId), canExportCurrentUser());
data.put("userId", userId); }
data.put("projectAll", scope.isProjectAll());
data.put("projectIds", new ArrayList<>(scope.getProjectIds())); public Map<String, Object> matchScopeSummary(String account) {
data.put("projectOwnerOnly", scope.isProjectOwnerOnly()); String normalizedAccount = account == null ? "" : account.trim();
data.put("meetingAll", scope.isMeetingAll()); if (normalizedAccount.isEmpty()) {
data.put("meetingIds", new ArrayList<>(scope.getMeetingIds())); throw new BusinessException(10001, "请输入账号");
data.put("meetingOwnerOnly", scope.isMeetingOwnerOnly()); }
data.put("userAll", scope.isUserAll()); List<Map<String, Object>> users = jdbcTemplate.queryForList(
data.put("userIds", new ArrayList<>(scope.getUserIds())); "SELECT id, user_name, phone, status, " +
data.put("userOwnerOnly", scope.isUserOwnerOnly()); "DATE_FORMAT(valid_from, '%Y-%m-%d %H:%i:%s') AS valid_from, " +
data.put("expertAll", scope.isExpertAll()); "DATE_FORMAT(valid_to, '%Y-%m-%d %H:%i:%s') AS valid_to " +
data.put("expertIds", new ArrayList<>(scope.getExpertIds())); "FROM sys_user WHERE tenant_id=? AND is_deleted=0 AND phone=? ORDER BY id DESC LIMIT 2",
data.put("expertOwnerOnly", scope.isExpertOwnerOnly()); tenantId(),
data.put("matchedPolicyIds", userId == null ? new ArrayList<Long>() : listMatchedPolicyIds(userId)); normalizedAccount
data.put("exportAllowed", canExportCurrentUser()); );
return data; if (users.isEmpty()) {
throw new BusinessException(10003, "未找到该账号对应的用户");
}
if (users.size() > 1) {
throw new BusinessException(10001, "该账号匹配到多个用户,请检查租户内账号数据");
}
Map<String, Object> user = users.get(0);
Long userId = ((Number) user.get("id")).longValue();
DataScope scope = resolveUserScope(userId);
List<Long> matchedPolicyIds = listMatchedPolicyIds(userId);
boolean exportAllowed = canExportUser(userId);
return buildScopeSummaryMap(
userId,
user.get("user_name") == null ? "" : String.valueOf(user.get("user_name")),
user.get("phone") == null ? "" : String.valueOf(user.get("phone")),
scope,
matchedPolicyIds,
exportAllowed
);
} }
public List<Long> listPolicyRoleIds(Long policyId) { public List<Long> listPolicyRoleIds(Long policyId) {
@@ -314,6 +332,13 @@ public class DataPermissionService {
public boolean canExportCurrentUser() { public boolean canExportCurrentUser() {
Long userId = AuthContext.userId(); Long userId = AuthContext.userId();
if (userId == null) {
return false;
}
return canExportUser(userId);
}
public boolean canExportUser(Long userId) {
if (userId == null) { if (userId == null) {
return false; return false;
} }
@@ -371,6 +396,33 @@ public class DataPermissionService {
); );
} }
private Map<String, Object> buildScopeSummaryMap(Long userId,
String userName,
String phone,
DataScope scope,
List<Long> matchedPolicyIds,
boolean exportAllowed) {
Map<String, Object> data = new LinkedHashMap<>();
data.put("userId", userId);
data.put("userName", userName);
data.put("phone", phone);
data.put("projectAll", scope.isProjectAll());
data.put("projectIds", new ArrayList<>(scope.getProjectIds()));
data.put("projectOwnerOnly", scope.isProjectOwnerOnly());
data.put("meetingAll", scope.isMeetingAll());
data.put("meetingIds", new ArrayList<>(scope.getMeetingIds()));
data.put("meetingOwnerOnly", scope.isMeetingOwnerOnly());
data.put("userAll", scope.isUserAll());
data.put("userIds", new ArrayList<>(scope.getUserIds()));
data.put("userOwnerOnly", scope.isUserOwnerOnly());
data.put("expertAll", scope.isExpertAll());
data.put("expertIds", new ArrayList<>(scope.getExpertIds()));
data.put("expertOwnerOnly", scope.isExpertOwnerOnly());
data.put("matchedPolicyIds", matchedPolicyIds == null ? new ArrayList<Long>() : matchedPolicyIds);
data.put("exportAllowed", exportAllowed);
return data;
}
public Map<Long, Long> listProjectCreators(Collection<Long> projectIds) { public Map<Long, Long> listProjectCreators(Collection<Long> projectIds) {
Map<Long, Long> result = new LinkedHashMap<>(); Map<Long, Long> result = new LinkedHashMap<>();
if (projectIds == null || projectIds.isEmpty()) { if (projectIds == null || projectIds.isEmpty()) {
@@ -3,6 +3,7 @@ package com.writeoff.module.system.service;
import com.writeoff.common.api.PageResult; import com.writeoff.common.api.PageResult;
import com.writeoff.common.exception.BusinessException; import com.writeoff.common.exception.BusinessException;
import com.writeoff.module.file.service.OssService; import com.writeoff.module.file.service.OssService;
import com.writeoff.module.notification.service.PlatformNotifyGatewayService;
import com.writeoff.module.system.dto.CreateTenantAdminRequest; import com.writeoff.module.system.dto.CreateTenantAdminRequest;
import com.writeoff.module.system.dto.CreateTenantRequest; import com.writeoff.module.system.dto.CreateTenantRequest;
import com.writeoff.module.system.model.TenantInfo; import com.writeoff.module.system.model.TenantInfo;
@@ -29,6 +30,7 @@ public class TenantService {
private final JdbcTemplate jdbcTemplate; private final JdbcTemplate jdbcTemplate;
private final OssService ossService; private final OssService ossService;
private final Map<String, NotificationChannelProvider> providerMap; private final Map<String, NotificationChannelProvider> providerMap;
private final PlatformNotifyGatewayService notifyGatewayService;
private final PasswordPolicyService passwordPolicyService; private final PasswordPolicyService passwordPolicyService;
private final PasswordCodecService passwordCodecService; private final PasswordCodecService passwordCodecService;
@Autowired @Autowired
@@ -48,6 +50,7 @@ public class TenantService {
public TenantService(JdbcTemplate jdbcTemplate, public TenantService(JdbcTemplate jdbcTemplate,
OssService ossService, OssService ossService,
List<NotificationChannelProvider> providers, List<NotificationChannelProvider> providers,
PlatformNotifyGatewayService notifyGatewayService,
PasswordPolicyService passwordPolicyService, PasswordPolicyService passwordPolicyService,
PasswordCodecService passwordCodecService, PasswordCodecService passwordCodecService,
@Value("${app.notification.tenant-admin-mail-subject-template:租户管理员账号通知}") String tenantAdminMailSubjectTemplate, @Value("${app.notification.tenant-admin-mail-subject-template:租户管理员账号通知}") String tenantAdminMailSubjectTemplate,
@@ -55,6 +58,7 @@ public class TenantService {
this.jdbcTemplate = jdbcTemplate; this.jdbcTemplate = jdbcTemplate;
this.ossService = ossService; this.ossService = ossService;
this.providerMap = new HashMap<String, NotificationChannelProvider>(); this.providerMap = new HashMap<String, NotificationChannelProvider>();
this.notifyGatewayService = notifyGatewayService;
this.passwordPolicyService = passwordPolicyService; this.passwordPolicyService = passwordPolicyService;
this.passwordCodecService = passwordCodecService; this.passwordCodecService = passwordCodecService;
this.tenantAdminMailSubjectTemplate = tenantAdminMailSubjectTemplate; this.tenantAdminMailSubjectTemplate = tenantAdminMailSubjectTemplate;
@@ -100,6 +104,7 @@ public class TenantService {
Long id = jdbcTemplate.queryForObject("SELECT IFNULL(MAX(id), 0) FROM tenant", Long.class); Long id = jdbcTemplate.queryForObject("SELECT IFNULL(MAX(id), 0) FROM tenant", Long.class);
long tenantId = id == null ? 0L : id; long tenantId = id == null ? 0L : id;
initTenantBaseline(tenantId); initTenantBaseline(tenantId);
notifyGatewayService.ensureTenantDefaults(tenantId);
return findById(tenantId); return findById(tenantId);
} }
@@ -118,6 +123,7 @@ public class TenantService {
rolePermCount += ensureRolePermissionsFromTemplate(tenantId, targetRoleId, roleCode); rolePermCount += ensureRolePermissionsFromTemplate(tenantId, targetRoleId, roleCode);
roleMenuCount += ensureRoleMenusFromTemplate(tenantId, targetRoleId, roleCode); roleMenuCount += ensureRoleMenusFromTemplate(tenantId, targetRoleId, roleCode);
} }
notifyGatewayService.ensureTenantDefaults(tenantId);
java.util.Map<String, Object> data = new java.util.LinkedHashMap<String, Object>(); java.util.Map<String, Object> data = new java.util.LinkedHashMap<String, Object>();
data.put("tenantId", tenantId); data.put("tenantId", tenantId);
data.put("menuInitialized", menuCount); data.put("menuInitialized", menuCount);
@@ -547,7 +553,9 @@ public class TenantService {
String payload = "{\"subject\":\"" + jsonEscape(subject) + "\",\"content\":\"" + jsonEscape(content) + "\",\"action\":\"" + jsonEscape(action) String payload = "{\"subject\":\"" + jsonEscape(subject) + "\",\"content\":\"" + jsonEscape(content) + "\",\"action\":\"" + jsonEscape(action)
+ "\",\"tenantCode\":\"" + jsonEscape(tenantCode) + "\",\"tenantName\":\"" + jsonEscape(tenantName) + "\",\"loginPath\":\"" + "\",\"tenantCode\":\"" + jsonEscape(tenantCode) + "\",\"tenantName\":\"" + jsonEscape(tenantName) + "\",\"loginPath\":\""
+ jsonEscape(loginPath) + "\",\"phone\":\"" + jsonEscape(request.getPhone().trim()) + "\",\"setupLink\":\"" + jsonEscape(setupLink) + "\"}"; + jsonEscape(loginPath) + "\",\"phone\":\"" + jsonEscape(request.getPhone().trim()) + "\",\"setupLink\":\"" + jsonEscape(setupLink) + "\"}";
NotificationSendResult result = provider.send(request.getEmail().trim(), payload, null); Map<String, Object> context = new LinkedHashMap<String, Object>();
context.put("tenantId", tenantId);
NotificationSendResult result = provider.send(request.getEmail().trim(), payload, context);
if (result == null || !result.isAccepted()) { if (result == null || !result.isAccepted()) {
throw new BusinessException(10001, "管理员账号邮件发送失败"); throw new BusinessException(10001, "管理员账号邮件发送失败");
} }
@@ -0,0 +1,29 @@
SET @next_permission_id := (SELECT IFNULL(MAX(id), 0) + 1 FROM permission);
INSERT INTO permission (id, permission_code, permission_name, module)
SELECT @next_permission_id, 'meeting.labor-agreement.extract', '上传并抽取劳务费协议', 'meeting'
FROM dual
WHERE NOT EXISTS (SELECT 1 FROM permission WHERE permission_code = 'meeting.labor-agreement.extract');
UPDATE permission
SET permission_name = '上传并抽取劳务费协议',
module = 'meeting'
WHERE permission_code = 'meeting.labor-agreement.extract';
SET @next_role_permission_id := (SELECT IFNULL(MAX(id), 0) FROM role_permission);
INSERT INTO role_permission (id, tenant_id, role_id, permission_id)
SELECT
(@next_role_permission_id := @next_role_permission_id + 1) AS id,
r.tenant_id,
r.id AS role_id,
p.id AS permission_id
FROM role r
JOIN permission p ON p.permission_code = 'meeting.labor-agreement.extract'
WHERE r.role_code = 'TENANT_ADMIN'
AND r.is_deleted = 0
AND NOT EXISTS (
SELECT 1
FROM role_permission rp
WHERE rp.tenant_id = r.tenant_id
AND rp.role_id = r.id
AND rp.permission_id = p.id
);
@@ -0,0 +1,17 @@
SET @c := (
SELECT COUNT(1)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'project'
AND COLUMN_NAME = 'labor_agreement_sign_type'
);
SET @sql := IF(
@c = 0,
'ALTER TABLE project ADD COLUMN labor_agreement_sign_type TINYINT NOT NULL DEFAULT 1 COMMENT ''劳务费协议签署类型:1-放心签,2-线下签'' AFTER labor_fee_ratio',
'SELECT 1'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
UPDATE project
SET labor_agreement_sign_type = 1
WHERE labor_agreement_sign_type IS NULL OR labor_agreement_sign_type NOT IN (1, 2);
@@ -0,0 +1,10 @@
ALTER TABLE notification_policy
ADD COLUMN sms_template_code VARCHAR(128) DEFAULT NULL AFTER template_id;
UPDATE notification_policy p
JOIN platform_notify_gateway g
ON g.channel_code = 'SMS'
AND g.is_deleted = 0
SET p.sms_template_code = NULLIF(JSON_UNQUOTE(JSON_EXTRACT(g.config_json, '$.templateCode')), '')
WHERE p.channel = 'SMS'
AND (p.sms_template_code IS NULL OR p.sms_template_code = '');
@@ -0,0 +1,46 @@
CREATE TABLE IF NOT EXISTS meeting_submission_version (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
meeting_id BIGINT UNSIGNED NOT NULL,
version_no INT NOT NULL,
remark VARCHAR(500) DEFAULT NULL,
snapshot_json LONGTEXT NOT NULL,
created_by BIGINT UNSIGNED NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_tenant_meeting_version (tenant_id, meeting_id, version_no),
KEY idx_tenant_meeting_created (tenant_id, meeting_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
SET @c := (
SELECT COUNT(1)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'audit_task'
AND COLUMN_NAME = 'submission_version_id'
);
SET @sql := IF(
@c = 0,
'ALTER TABLE audit_task ADD COLUMN submission_version_id BIGINT UNSIGNED NULL AFTER meeting_id',
'SELECT 1'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @idx := (
SELECT COUNT(1)
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'audit_task'
AND INDEX_NAME = 'idx_tenant_submission_version'
);
SET @sql := IF(
@idx = 0,
'ALTER TABLE audit_task ADD INDEX idx_tenant_submission_version (tenant_id, submission_version_id)',
'SELECT 1'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
@@ -0,0 +1,39 @@
CREATE TABLE IF NOT EXISTS audit_issue (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
task_id BIGINT UNSIGNED NOT NULL,
meeting_id BIGINT UNSIGNED NOT NULL,
submission_version_id BIGINT UNSIGNED DEFAULT NULL,
review_node VARCHAR(32) NOT NULL,
module_code VARCHAR(32) NOT NULL,
target_path VARCHAR(255) NOT NULL,
target_label VARCHAR(255) NOT NULL,
reason VARCHAR(500) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'OPEN',
response_text VARCHAR(1000) DEFAULT NULL,
responded_at DATETIME DEFAULT NULL,
created_by BIGINT UNSIGNED NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_audit_issue_task (tenant_id, task_id),
KEY idx_audit_issue_meeting (tenant_id, meeting_id),
KEY idx_audit_issue_version (tenant_id, submission_version_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS issue_response (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
issue_id BIGINT UNSIGNED NOT NULL,
submission_version_id BIGINT UNSIGNED DEFAULT NULL,
response_text VARCHAR(1000) NOT NULL,
response_status VARCHAR(16) NOT NULL DEFAULT 'PENDING_CONFIRM',
responded_by BIGINT UNSIGNED NOT NULL DEFAULT 0,
responded_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by BIGINT UNSIGNED NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_issue_response_issue (tenant_id, issue_id),
KEY idx_issue_response_version (tenant_id, submission_version_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@@ -0,0 +1,94 @@
CREATE TABLE IF NOT EXISTS version_change_set (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
meeting_id BIGINT NOT NULL,
from_submission_version_id BIGINT NULL,
to_submission_version_id BIGINT NULL,
changed_module_count INT NOT NULL DEFAULT 0,
changed_item_count INT NOT NULL DEFAULT 0,
issue_related_count INT NOT NULL DEFAULT 0,
extra_change_count INT NOT NULL DEFAULT 0,
created_by BIGINT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_by BIGINT NOT NULL DEFAULT 0,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_vcs_meeting (tenant_id, meeting_id),
KEY idx_vcs_to_submission (tenant_id, to_submission_version_id)
);
CREATE TABLE IF NOT EXISTS version_change_item (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
change_set_id BIGINT NOT NULL,
meeting_id BIGINT NOT NULL,
module_code VARCHAR(64) NOT NULL,
target_path VARCHAR(255) NOT NULL,
target_label VARCHAR(255) NOT NULL,
change_type VARCHAR(32) NOT NULL,
old_value TEXT NULL,
new_value TEXT NULL,
related_issue_id BIGINT NULL,
is_extra_change TINYINT NOT NULL DEFAULT 0,
created_by BIGINT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_by BIGINT NOT NULL DEFAULT 0,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_vci_change_set (tenant_id, change_set_id),
KEY idx_vci_meeting_module (tenant_id, meeting_id, module_code)
);
SET @c := (
SELECT COUNT(1)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'version_change_item'
AND COLUMN_NAME = 'target_kind'
);
SET @sql := IF(
@c = 0,
'ALTER TABLE version_change_item ADD COLUMN target_kind VARCHAR(32) NOT NULL DEFAULT ''FIELD'' AFTER target_label',
'SELECT 1'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @c := (
SELECT COUNT(1)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'version_change_item'
AND COLUMN_NAME = 'target_row_key'
);
SET @sql := IF(
@c = 0,
'ALTER TABLE version_change_item ADD COLUMN target_row_key VARCHAR(255) NULL AFTER target_kind',
'SELECT 1'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @c := (
SELECT COUNT(1)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'version_change_item'
AND COLUMN_NAME = 'attachment_identity'
);
SET @sql := IF(
@c = 0,
'ALTER TABLE version_change_item ADD COLUMN attachment_identity VARCHAR(255) NULL AFTER target_row_key',
'SELECT 1'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @c := (
SELECT COUNT(1)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'version_change_item'
AND COLUMN_NAME = 'attachment_hash'
);
SET @sql := IF(
@c = 0,
'ALTER TABLE version_change_item ADD COLUMN attachment_hash VARCHAR(128) NULL AFTER attachment_identity',
'SELECT 1'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
@@ -0,0 +1,38 @@
SET @c := (
SELECT COUNT(1)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'meeting_material'
AND COLUMN_NAME = 'draft_content_json'
);
SET @sql := IF(
@c = 0,
'ALTER TABLE meeting_material ADD COLUMN draft_content_json TEXT NULL AFTER content_json',
'SELECT 1'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @c := (
SELECT COUNT(1)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'meeting_material'
AND COLUMN_NAME = 'draft_remark'
);
SET @sql := IF(
@c = 0,
'ALTER TABLE meeting_material ADD COLUMN draft_remark VARCHAR(500) NULL AFTER submit_remark',
'SELECT 1'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
UPDATE meeting_material
SET draft_content_json = CASE
WHEN draft_content_json IS NULL OR draft_content_json = '' THEN content_json
ELSE draft_content_json
END,
draft_remark = CASE
WHEN draft_remark IS NULL OR draft_remark = '' THEN submit_remark
ELSE draft_remark
END
WHERE tenant_id IS NOT NULL;
@@ -0,0 +1,195 @@
CREATE TABLE IF NOT EXISTS tenant_notify_gateway (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
channel_code VARCHAR(32) NOT NULL,
gateway_name VARCHAR(64) NOT NULL,
provider_code VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'DISABLED',
config_json TEXT DEFAULT NULL,
secret_config_cipher TEXT DEFAULT NULL,
remark VARCHAR(255) DEFAULT NULL,
is_deleted TINYINT(1) NOT NULL DEFAULT 0,
created_by BIGINT UNSIGNED NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_tenant_notify_gateway_channel (tenant_id, channel_code),
KEY idx_tenant_notify_gateway_tenant (tenant_id, is_deleted)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT INTO tenant_notify_gateway (
tenant_id,
channel_code,
gateway_name,
provider_code,
status,
config_json,
secret_config_cipher,
remark,
is_deleted,
created_by,
updated_by
)
SELECT
t.id,
'EMAIL',
'邮件网关',
COALESCE(NULLIF(pg.provider_code, ''), 'SMTP'),
COALESCE(NULLIF(pg.status, ''), 'DISABLED'),
COALESCE(pg.config_json, '{}'),
COALESCE(pg.secret_config_cipher, ''),
COALESCE(NULLIF(pg.remark, ''), '当前租户邮件网关配置'),
0,
0,
0
FROM tenant t
LEFT JOIN platform_notify_gateway pg
ON pg.channel_code = 'EMAIL'
AND pg.is_deleted = 0
WHERE t.is_deleted = 0
AND NOT EXISTS (
SELECT 1
FROM tenant_notify_gateway tg
WHERE tg.tenant_id = t.id
AND tg.channel_code = 'EMAIL'
AND tg.is_deleted = 0
);
INSERT INTO tenant_notify_gateway (
tenant_id,
channel_code,
gateway_name,
provider_code,
status,
config_json,
secret_config_cipher,
remark,
is_deleted,
created_by,
updated_by
)
SELECT
t.id,
'SMS',
'短信网关',
COALESCE(NULLIF(pg.provider_code, ''), 'MOCK'),
COALESCE(NULLIF(pg.status, ''), 'DISABLED'),
COALESCE(pg.config_json, '{}'),
COALESCE(pg.secret_config_cipher, ''),
COALESCE(NULLIF(pg.remark, ''), '当前租户短信网关配置'),
0,
0,
0
FROM tenant t
LEFT JOIN platform_notify_gateway pg
ON pg.channel_code = 'SMS'
AND pg.is_deleted = 0
WHERE t.is_deleted = 0
AND NOT EXISTS (
SELECT 1
FROM tenant_notify_gateway tg
WHERE tg.tenant_id = t.id
AND tg.channel_code = 'SMS'
AND tg.is_deleted = 0
);
SET @next_permission_id := (SELECT IFNULL(MAX(id), 0) + 1 FROM permission);
INSERT INTO permission (id, permission_code, permission_name, module)
SELECT @next_permission_id, 'notification.notify-gateway.read', '查看通知网关配置', 'notification'
FROM dual
WHERE NOT EXISTS (
SELECT 1
FROM permission
WHERE permission_code = 'notification.notify-gateway.read'
);
SET @next_permission_id := (SELECT IFNULL(MAX(id), 0) + 1 FROM permission);
INSERT INTO permission (id, permission_code, permission_name, module)
SELECT @next_permission_id, 'notification.notify-gateway.manage', '管理通知网关配置', 'notification'
FROM dual
WHERE NOT EXISTS (
SELECT 1
FROM permission
WHERE permission_code = 'notification.notify-gateway.manage'
);
UPDATE permission
SET permission_name = '查看通知网关配置',
module = 'notification'
WHERE permission_code = 'notification.notify-gateway.read';
UPDATE permission
SET permission_name = '管理通知网关配置',
module = 'notification'
WHERE permission_code = 'notification.notify-gateway.manage';
INSERT INTO menu (tenant_id, menu_code, menu_name, route_path, permission_code, sort_no, status, is_deleted, created_by, updated_by)
VALUES
(1, 'notification_notify_gateway', '通知网关配置', '/notify-gateways', 'notification.notify-gateway.read', 178, 'ENABLED', 0, 0, 0)
ON DUPLICATE KEY UPDATE
menu_name = VALUES(menu_name),
route_path = VALUES(route_path),
permission_code = VALUES(permission_code),
sort_no = VALUES(sort_no),
status = VALUES(status),
is_deleted = VALUES(is_deleted),
updated_at = CURRENT_TIMESTAMP;
SET @next_role_permission_id := (SELECT IFNULL(MAX(id), 0) FROM role_permission);
INSERT INTO role_permission (id, tenant_id, role_id, permission_id)
SELECT
(@next_role_permission_id := @next_role_permission_id + 1) AS id,
r.tenant_id,
r.id,
p.id
FROM role r
JOIN permission p ON p.permission_code = 'notification.notify-gateway.read'
WHERE r.role_code = 'TENANT_ADMIN'
AND r.is_deleted = 0
AND NOT EXISTS (
SELECT 1
FROM role_permission rp
WHERE rp.tenant_id = r.tenant_id
AND rp.role_id = r.id
AND rp.permission_id = p.id
);
INSERT INTO role_permission (id, tenant_id, role_id, permission_id)
SELECT
(@next_role_permission_id := @next_role_permission_id + 1) AS id,
r.tenant_id,
r.id,
p.id
FROM role r
JOIN permission p ON p.permission_code = 'notification.notify-gateway.manage'
WHERE r.role_code = 'TENANT_ADMIN'
AND r.is_deleted = 0
AND NOT EXISTS (
SELECT 1
FROM role_permission rp
WHERE rp.tenant_id = r.tenant_id
AND rp.role_id = r.id
AND rp.permission_id = p.id
);
SET @next_role_menu_id = (SELECT IFNULL(MAX(id), 0) FROM role_menu);
INSERT INTO role_menu (id, tenant_id, role_id, menu_id)
SELECT
(@next_role_menu_id := @next_role_menu_id + 1) AS id,
r.tenant_id,
r.id,
m.id
FROM role r
JOIN menu m
ON m.tenant_id = r.tenant_id
AND m.menu_code = 'notification_notify_gateway'
AND m.is_deleted = 0
WHERE r.role_code = 'TENANT_ADMIN'
AND r.is_deleted = 0
AND NOT EXISTS (
SELECT 1
FROM role_menu rm
WHERE rm.tenant_id = r.tenant_id
AND rm.role_id = r.id
AND rm.menu_id = m.id
);
@@ -0,0 +1,48 @@
CREATE TABLE IF NOT EXISTS tenant_notify_delivery_guard (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
channel_code VARCHAR(32) NOT NULL,
receiver_ref VARCHAR(128) NOT NULL,
stat_date DATE NOT NULL,
daily_count INT NOT NULL DEFAULT 0,
last_sent_at DATETIME DEFAULT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_tenant_notify_delivery_guard (tenant_id, channel_code, receiver_ref, stat_date),
KEY idx_tenant_notify_delivery_guard_date (tenant_id, stat_date, channel_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS tenant_notify_circuit_breaker (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
channel_code VARCHAR(32) NOT NULL,
consecutive_failures INT NOT NULL DEFAULT 0,
breaker_until DATETIME DEFAULT NULL,
last_failure_message VARCHAR(500) DEFAULT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_tenant_notify_circuit_breaker (tenant_id, channel_code),
KEY idx_tenant_notify_circuit_breaker_tenant (tenant_id, channel_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT INTO tenant_notify_circuit_breaker (tenant_id, channel_code, consecutive_failures, breaker_until, last_failure_message)
SELECT t.id, 'EMAIL', 0, NULL, NULL
FROM tenant t
WHERE t.is_deleted = 0
AND NOT EXISTS (
SELECT 1
FROM tenant_notify_circuit_breaker cb
WHERE cb.tenant_id = t.id
AND cb.channel_code = 'EMAIL'
);
INSERT INTO tenant_notify_circuit_breaker (tenant_id, channel_code, consecutive_failures, breaker_until, last_failure_message)
SELECT t.id, 'SMS', 0, NULL, NULL
FROM tenant t
WHERE t.is_deleted = 0
AND NOT EXISTS (
SELECT 1
FROM tenant_notify_circuit_breaker cb
WHERE cb.tenant_id = t.id
AND cb.channel_code = 'SMS'
);
@@ -0,0 +1,8 @@
UPDATE notification_policy p
JOIN tenant_notify_gateway g
ON g.tenant_id = p.tenant_id
AND g.channel_code = 'SMS'
AND g.is_deleted = 0
SET p.sms_template_code = NULLIF(JSON_UNQUOTE(JSON_EXTRACT(g.config_json, '$.templateCode')), '')
WHERE p.channel = 'SMS'
AND (p.sms_template_code IS NULL OR p.sms_template_code = '');
@@ -0,0 +1,18 @@
SET @c := (
SELECT COUNT(1)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'project'
AND COLUMN_NAME = 'catering_fee_ratio'
);
SET @sql := IF(
@c = 0,
'ALTER TABLE project ADD COLUMN catering_fee_ratio DECIMAL(8,6) NOT NULL DEFAULT 0.000000 AFTER labor_fee_ratio',
'SELECT 1'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
ALTER TABLE project
MODIFY COLUMN catering_fee_ratio DECIMAL(8,6) NOT NULL DEFAULT 0.000000 COMMENT '餐费占比';
@@ -0,0 +1,85 @@
package com.writeoff.module.audit.service;
import com.writeoff.module.audit.dto.AuditActionRequest;
import com.writeoff.module.audit.model.AuditNode;
import com.writeoff.module.audit.model.AuditTask;
import com.writeoff.module.audit.model.AuditTaskStatus;
import com.writeoff.module.audit.repository.InMemoryAuditTaskRepository;
import com.writeoff.module.meeting.service.MeetingService;
import com.writeoff.module.scheduler.repository.InMemoryAsyncJobRepository;
import com.writeoff.module.scheduler.service.AsyncJobService;
import com.writeoff.security.AuthContext;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class AuditServiceNotificationTest {
@AfterEach
void clearAuthContext() {
AuthContext.clear();
}
@Test
void approveShouldTriggerAssignedNotificationForNextNode() {
AuthContext.set(2001L, 1L);
InMemoryAuditTaskRepository repository = new InMemoryAuditTaskRepository();
AsyncJobService asyncJobService = new AsyncJobService(new InMemoryAsyncJobRepository());
MeetingService meetingService = mock(MeetingService.class);
AuditFlowConfigService auditFlowConfigService = mock(AuditFlowConfigService.class);
AuditTask pendingTask = repository.save(new AuditTask(
null,
9001L,
AuditNode.INIT_REVIEW,
2001L,
AuditTaskStatus.PENDING,
""
));
when(auditFlowConfigService.nextNode(1L, AuditNode.INIT_REVIEW)).thenReturn(AuditNode.RE_REVIEW);
when(auditFlowConfigService.resolveAssigneeUserId(1L, AuditNode.RE_REVIEW)).thenReturn(3001L);
AuditService auditService = new AuditService(
repository,
meetingService,
null,
asyncJobService,
auditFlowConfigService,
null,
null,
null,
null,
null,
null,
null,
null,
null
);
AuditActionRequest request = new AuditActionRequest();
request.setIdempotencyKey("approve-next-node-notify");
request.setOpinion("初审通过");
auditService.approve(pendingTask.getId(), request);
ArgumentCaptor<AuditTask> taskCaptor = ArgumentCaptor.forClass(AuditTask.class);
verify(meetingService, times(1)).updateCurrentAuditNode(9001L, AuditNode.RE_REVIEW.name(), 3001L);
verify(meetingService, times(1)).triggerAuditTaskAssignedNotification(taskCaptor.capture());
AuditTask nextTask = taskCaptor.getValue();
assertNotNull(nextTask);
assertEquals(9001L, nextTask.getMeetingId());
assertEquals(AuditNode.RE_REVIEW, nextTask.getNode());
assertEquals(Long.valueOf(3001L), nextTask.getAssigneeUserId());
assertEquals(AuditTaskStatus.PENDING, nextTask.getStatus());
}
}
@@ -7,6 +7,7 @@ import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.mockito.ArgumentMatcher; import org.mockito.ArgumentMatcher;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.PlatformTransactionManager;
import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
@@ -25,11 +26,14 @@ class SystemUserServicePasswordTest {
void shouldAcceptLegacyPlaintextOldPasswordAndUpgradeToHashWhenChangingPassword() { void shouldAcceptLegacyPlaintextOldPasswordAndUpgradeToHashWhenChangingPassword() {
JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class); JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class);
PasswordCodecService passwordCodecService = new PasswordCodecService(); PasswordCodecService passwordCodecService = new PasswordCodecService();
PlatformTransactionManager transactionManager = mock(PlatformTransactionManager.class);
SystemUserService systemUserService = new SystemUserService( SystemUserService systemUserService = new SystemUserService(
jdbcTemplate, jdbcTemplate,
null, null,
null,
new PasswordPolicyService(), new PasswordPolicyService(),
passwordCodecService passwordCodecService,
transactionManager
); );
AuthContext.set(1001L, 2001L); AuthContext.set(1001L, 2001L);
when(jdbcTemplate.queryForObject( when(jdbcTemplate.queryForObject(
+281
View File
@@ -0,0 +1,281 @@
优先级 ToDoList
P0(先做,不做后面的方案立不住)
- [ ] 审核任务改为绑定“提交版本”而不是 `meetingId/current material`
状态:已完成
说明:`audit_task` 已绑定 `submission_version_id`;审核详情、材料抽屉、差异摘要、问题闭环等新任务读取链路均优先消费绑定的提交版本快照,不再读取申请人当前草稿,旧任务仅在缺少版本绑定时走兼容兜底。
- [ ] 提交后版本不可变,申请人只允许编辑草稿,重新提交生成新版本
状态:已完成
说明:已为 `meeting_material` 增加草稿字段,申请端模块保存/模块提交只更新草稿;整单提交时才把草稿固化为新的提交版本并同步 `meeting_submission_version``meeting_material_history`,已提交版本不再被后续编辑覆盖。
- [ ] 驳回改为结构化问题数组,禁止仅提交自由文本驳回意见
状态:已完成
说明:单任务驳回现已强制要求提交非空 `issues[]`,且每条结构化问题必须包含模块、定位、标签、原因等必填字段;自由文本 `opinion` 仅保留为可选补充说明,不能再单独作为驳回依据。批量驳回仍明确禁用,避免绕过结构化问题闭环。
- [ ] 建立 `audit_issue / issue_response / version_change_set / version_change_item` 核心表结构
状态:部分完成
说明:已落 `audit_issue / issue_response` 基础表并接入问题写入/回应闭环;同时新增 `version_change_set / version_change_item` 表结构与会议提交后的差异持久化链路,但差异粒度仍以当前模块级 `itemKey` 为主,尚未升级到文档目标里的统一字段/行/附件模型。
- [ ] 审核详情聚合接口一次返回版本、问题、回应、差异、完整快照
状态:已完成
说明:整单级审核详情接口已可一次返回审核任务、绑定提交版本、完整快照、问题列表、回应结果、差异摘要与模块详情;新任务统一读取绑定版本快照,旧任务仅在历史数据缺版本绑定时做兼容回退。
P1(核心流程闭环)
- [ ] 申请端支持逐条填写问题处理说明,并落库到 `issue_response`
状态:已完成
说明:会议级与模块级重新提交均支持逐条填写问题处理说明,申请端“去修改”跳转与字段高亮链路已接入;提交后会把回应按提交版本写入 `issue_response`,并回写问题当前处理说明。
- [ ] 重新提交时强制校验未关闭问题都已响应,否则不允许提交
状态:已完成
说明:会议级与模块级重新提交均已在前后端双重校验未关闭问题必须逐条响应;待处理问题读取范围已统一覆盖 `OPEN / PENDING_CONFIRM`,避免遗漏“已回复待确认”的问题。
- [ ] 审核端问题闭环支持三态:已修改待确认、未修改、已确认解决
状态:已完成
说明:审核端复审摘要已稳定区分“已修改待确认 / 未修改 / 已确认解决”三态;审核人确认解决时会按问题真实归属记录更新状态,不再受当前待审任务与原驳回任务 ID 不一致影响。
- [ ] 审核通过时自动关闭当前任务及关联未关闭问题;再次驳回时挂到新版本
状态:已完成
说明:终审通过时会自动关闭会议下未关闭问题;再次驳回前会先收口旧版本未关闭问题,再按当前提交版本重新生成新的结构化问题,确保问题生命周期按版本闭环流转。
- [ ] 审核处理逻辑校验“只能处理分配给自己的任务”
状态:已完成
说明:后端审核主入口和资料审核入口已统一增加处理人硬校验;除未分配任务外,非当前处理人无法执行通过、驳回、退回、转审及资料项审核动作。
P2(差异引擎与规则完善)
- [ ] 差异结果从“模块 itemKey 对比”升级为“字段/行/附件”统一 ChangeSet
状态:已完成
说明:`version_change_set / version_change_item` 已升级为统一的字段 / 行 / 附件差异模型,并持久化 `target_kind / target_row_key / attachment_identity / attachment_hash`;会议重新提交后会沉淀跨版本差异,审核详情聚合接口、审核抽屉“本次修改”、申请端复审预览均已优先消费持久化 `changeSet`
- [ ] 实现问题命中规则:精确命中、父子路径命中、模块内额外修改识别
状态:已完成
说明:已支持精确命中、父子路径命中和模块内额外修改识别;审核复审摘要、持久化 `version_change_item`、审核抽屉高亮定位与申请端“去修改”链路均会优先关联命中的驳回问题,并兼容 `agenda / invitation / profileFile / meeting_invoice:OTHER / invoice:*` 等历史别名。
- [ ] 附件比对改为 `asset_id/hash` 维度,避免仅按文件名或当前结构判断
状态:已完成
说明:当前已按工程化附件身份实现稳定比对:优先使用 `ossKey/objectKey` 作为附件 identity,缺失时回退到 `fileName|size|contentType` 的短 hash,并持久化 `attachment_identity / attachment_hash`;尚未引入独立 `attachment_asset` 实体,但已不再依赖纯文件名或当前数组结构判断。
- [ ] 明细数组建立稳定行主键,避免只靠数组下标做差异
状态:已完成
说明:明细数组已按业务稳定键建模差异与审核项:专家资料优先用 `expertId`,专家发票优先用 `invoiceNo`,会议发票按 `sectionCode + fieldKey`,文档附件优先用 `ossKey`,仅在历史或异常数据缺少稳定键时才回退到顺序索引,避免常态场景只靠数组下标做差异。
P3(审核端页面)
- [ ] 审核列表完整显示:重新提交、本次修改数、驳回项处理进度、额外修改数
状态:已完成
说明:审核列表已完整展示“重新提交 / 本次修改数 / 驳回项处理进度 / 额外修改数 / 未解决项 / 高风险复审”等核心标签;同时补齐复审快速筛选、后端级 `reviewFocus` 查询过滤,以及待我处理列表按 `riskScore` 的服务端风险优先排序,避免列表结果继续依赖前端二次筛选。
- [ ] 审核详情页固定为四块:本次摘要、驳回问题闭环、仅看修改、完整资料
状态:已完成
说明:审核详情抽屉已改为整单级四区固定布局:`本次摘要 / 驳回问题闭环 / 仅看修改 / 完整资料`;顶部摘要统一消费 `fetchAuditTaskDetail` 聚合结果,完整资料保留模块 tabs 作为第四区内部导航,不再以模块级抽屉替代整单视图。
- [ ] 完整资料页支持变更高亮,并可从字段跳回对应 ChangeItem
状态:已完成
说明:完整资料区已覆盖基础信息、核销材料、专家简介、专家资料、会议发票等模块的变更高亮;支持从整单“仅看修改”点击后自动切换到目标模块并滚动定位到对应字段/材料卡片,也支持从高亮字段反向回跳到对应 ChangeItem。
- [ ] 审核端优先展示“驳回相关修改”,其次展示“额外修改”
状态:已完成
说明:整单“仅看修改”区已固定拆分为“驳回相关修改 / 额外修改”两个分组,并默认先展示驳回相关修改;同时补齐整单级 ChangeItem 与完整资料字段之间的双向跳转和跨模块切换。
P4(申请端页面)
- [ ] 驳回后进入“修改并重新提交”模式,顶部固定展示上次驳回问题清单
状态:已完成
说明:驳回后进入资料页会明确进入“修改并重新提交模式”;侧栏持续展示上次驳回问题与本次修改摘要,主内容顶部提供统一 sticky 复审头部,集中展示问题总数、未解决数、驳回相关修改、额外修改及待处理模块入口,形成稳定的整页复审模式。
- [ ] 每条问题增加“去修改”跳转,直达对应模块/字段
状态:已完成
说明:会议级待回应弹窗、模块级提交复审预览、资料页顶部/侧栏问题清单都已支持“去修改”;点击后可直接切到对应模块,并结合字段高亮、滚动定位、专家子模块切换与历史别名兼容,已覆盖基础信息、核销材料、专家简介、专家现场照片/劳务协议、会议发票及 `agenda / invitation / profileFile / meeting_invoice:OTHER / invoice:*` 等关键路径。
- [ ] 提交前展示标准化变更预览:驳回项相关修改 / 额外修改 / 未处理问题
状态:已完成
说明:模块提交前的“提交复审预览”已标准化拆分为“上次驳回项 / 未处理问题 / 驳回项相关修改 / 额外修改”四块,并新增逐条提交状态、处理说明缺失提示与表格高亮;未完成修改或未填写处理说明的问题会在预览中显式暴露,并继续阻断提交。
P5(审计与治理)
- [ ] 关键动作全链路留痕:谁提交了哪个版本、谁创建了问题、谁回应、谁确认解决
状态:已完成
说明:会议提交版本、结构化问题创建、申请人回应、审核确认解决、审核通过/驳回/退回/转审动作均已纳入统一留痕链路;审核详情现可直接返回版本链、问题闭环留痕、审核动作时间线与操作者信息,形成端到端审计追溯视图。
- [ ] 任意审核结论都能追溯到具体版本和具体差异
状态:已完成
说明:审核详情已固定绑定 `submission_version_id`,并优先返回该版本对应的持久化 `version_change_set / version_change_item`;对历史旧任务,读取详情时会按版本链自动补建缺失的 ChangeSet,使审核结论可继续追溯到具体版本与具体差异,而不再仅依赖当前草稿或人工推断。
- [ ] 补充迁移与回填方案,兼容当前 `meeting_material_history``audit_material_item_review`
状态:已完成
说明:已明确并落地兼容策略:新数据继续写入 `meeting_submission_version / version_change_set / version_change_item`;旧任务读取详情时,若缺少持久化差异,则基于 `meeting_material_history` 的提交快照与 `audit_material_item_review` 的历史审核项自动回填 ChangeSet,并在追溯信息中标记“读取时自动回填/兼容旧任务”,避免历史记录断层。
最终方案
结论先定下来:把审核对象从“当前表单”改成“提交版本”,把驳回原因从“自由文本”改成“结构化问题”,把重新提交后的复审入口从“整单重看”改成“问题闭环 + 自动差异审核”。这样审核人打开后先看“这次改了什么、是否改到了上次驳回点”,而不是重新读全量资料。
一、整体架构
Application:申请单主实体,只承载申请编号、申请人、当前草稿、最新状态。
ApplicationVersion:每次“提交审核”生成一份不可变版本,审核永远针对 version_id,不针对可变草稿。
AuditTask:某个版本对应的一次审核任务。
AuditIssue:审核不通过时产生的问题项,必须结构化记录到模块/字段/明细行/附件。
IssueResponse:申请人对每条问题项的处理说明。
VersionChangeSet:重新提交时,系统自动生成的版本差异汇总。
VersionChangeItem:字段级/行级/附件级差异明细。
AttachmentAsset:附件资源独立存储,用 asset_id/hash 做稳定比对。
二、核心业务规则
所有审核都基于版本,版本一旦提交不可修改。
申请人编辑的是草稿,重新提交时生成新版本,不覆盖旧版本。
驳回时不能只写一句“请完善资料”,必须至少创建 1 条 AuditIssue。
AuditIssue 必须挂到具体位置,至少精确到模块,理想情况精确到字段或明细行。
重新提交时,申请人必须对每条未关闭问题填写处理说明。
审核人再次审核时,默认只看“本次修改”和“问题闭环”,完整资料作为第二层查看。
如果申请人除了驳回项外还改了别的内容,系统单独标红提示“额外修改”。
三、标准流程
申请人填写草稿,点击提交,系统生成 V1 快照并创建审核任务。
审核人审核 V1,若不通过,创建若干 AuditIssue,例如:
basic.meetingTime:会议时间与通知不一致
budget.items[itemId=3].amount:预算金额计算错误
attachments.agenda:缺少议程附件
申请人查看驳回问题,修改草稿,并逐条填写“本次如何处理”。
申请人重新提交,系统生成 V2,并自动计算 V1 -> V2 的 ChangeSet。
审核人打开 V2 时,页面顶部先展示:
本次共修改 8 处,涉及 3 个模块
上次驳回 5 条,已命中修改 4 条,未修改 1 条
另有 2 处非驳回项修改
审核人先处理“问题闭环”,确认每条问题是否已改到位,再决定通过或再次驳回。
若再次驳回,生成新的问题项并挂到 V2;若通过,关闭当前审核任务和所有未关闭问题项。
四、数据模型
application
id, code, applicant_id, current_draft_json, latest_version_id, status
application_version
id, application_id, version_no, snapshot_json, snapshot_hash, submit_note, submitted_by, submitted_at, base_version_id
audit_task
id, application_version_id, status, auditor_id, started_at, decided_at, decision_comment
audit_issue
id, audit_task_id, issue_no, module_code, target_path, target_label, target_row_key, reason, severity, status
issue_response
id, issue_id, application_version_id, response_text, response_status, responded_at
version_change_set
id, from_version_id, to_version_id, changed_module_count, changed_item_count, issue_related_count, extra_change_count
version_change_item
id, change_set_id, module_code, target_path, target_label, target_row_key, change_type, old_value, new_value, related_issue_id, is_extra_change
attachment_asset
id, file_name, file_hash, storage_key, mime_type, file_size
version_attachment_ref
id, version_id, target_path, asset_id
五、字段路径规范
普通字段:basic.meetingName
嵌套对象:schedule.startTime
明细行:budget.items[itemId=3].amount
参会人:participants[userId=1001].title
附件:attachments.noticeFile
路径必须稳定,不能靠前端展示文案比对;明细表必须有稳定行主键,不能靠数组下标。
六、差异引擎规则
标量字段:直接比较前后值。
对象字段:递归比较。
明细数组:按稳定主键判断 新增/删除/修改。
附件:按 asset_id 或 file_hash 判断变化,不能只看文件名。
忽略字段:更新时间、操作人、系统流水号等非业务字段。
值展示规则:长文本截断展示,支持展开;金额保留格式化前后值;附件显示文件名和预览入口。
问题命中规则:
路径完全相同:直接命中
当前字段是问题字段的子路径或父路径:视为相关命中
同模块但不同字段:视为“模块内额外修改”
输出结果分三类:
已按驳回项修改
未命中驳回项
额外修改
七、审核端页面设计
审核列表页增加字段:
重新提交 标记
本次修改 x 处
驳回项已处理 y/z
额外修改 n 处
审核详情页固定四个区域:
本次摘要
驳回问题闭环
仅看修改
完整资料
本次摘要
显示版本链:V1 驳回 -> V2 待审
显示修改统计和风险提示
驳回问题闭环
每条问题展示:问题内容、定位字段、上次值、本次值、申请人处理说明、审核结论
状态只有:已修改待确认、未修改、已确认解决
仅看修改
按模块分组展示所有 ChangeItem
默认先显示“驳回相关修改”,再显示“额外修改”
支持前后值并排对比
完整资料
仍展示整单
所有变更字段高亮
点击字段可跳回对应 ChangeItem
八、申请端页面设计
驳回后进入“修改并重新提交”模式。
页面顶部固定显示“上次驳回问题清单”。
每条问题旁边有“去修改”按钮,直接跳转到对应字段/模块。
每条问题必须填写“处理说明”,例如“已按会议通知修改时间为 2026-05-22 09:00”。
提交前显示“本次变更预览”,让申请人确认:
驳回项相关修改
额外修改
未处理问题
若存在未响应的问题,不允许提交。
九、接口设计
POST /applications
创建申请草稿
PUT /applications/{id}/draft
保存草稿
POST /applications/{id}/submit
生成新版本并提交审核
GET /audit-tasks/{taskId}
返回审核详情聚合数据:版本信息、问题项、问题回应、变更集、完整快照
POST /audit-tasks/{taskId}/reject
入参为结构化问题项数组和总评
POST /audit-tasks/{taskId}/approve
审核通过
GET /versions/{versionId}/diff?baseVersionId=xxx
获取版本差异
POST /issues/{issueId}/response
申请人填写问题处理说明
十、审核入参与出参约束
驳回入参必须包含:
target_path
target_label
module_code
reason
重新提交时必须包含:
base_version_id
submit_note
issue_responses[]
审核详情聚合接口一次返回前端所需全部信息,前端不要自己拼多接口。
十一、权限与审计
申请人只能编辑草稿,不能改已提交版本。
审核人只能审核分配给自己的 AuditTask。
所有关键动作留痕:
谁在何时提交了哪个版本
谁创建了哪条驳回问题
谁对问题做了什么回应
谁最终确认问题已解决
任意审核结论都能追溯到具体版本和具体差异。
十二、必须落地的两个强约束
审核针对版本,不针对当前表
驳回必须结构化,不允许只有自由文本
如果这两个不做,后面的“只看修改、不重看整单”就做不稳。
十三、最终效果
审核人再次打开时,先看到的是“改了什么”和“是否改到了上次问题”,不是整页资料。
申请人无法模糊处理驳回意见,必须逐条回应。
系统能明确区分“问题修复”和“额外修改”,减少复审成本和漏审风险。
整个流程天然可审计、可追溯、可统计。
+160
View File
@@ -0,0 +1,160 @@
# 通知事件触发时机备忘录
本文用于记录当前项目内通知事件码的实际触发时机,便于后续配置“通知策略中心”、排查通知链路和补齐模板。
## 1. 已实现事件
| 事件码 | 业务含义 | 触发时机 | 触发位置 | 业务对象 | 主要变量 | 默认接收人建议 | 备注 |
| --- | --- | --- | --- | --- | --- | --- | --- |
| `AUDIT_TASK_ASSIGNED` | 审核任务已分配 | 会议提交后创建首个审核任务时触发 | `backend/src/main/java/com/writeoff/module/meeting/service/MeetingService.java:477` | `MEETING` | `meetingId` `meetingTopic` `auditNode` `auditTaskId` `assigneeUserId` | `AUDITOR` | 仅在存在明确审核人时触发,实际派发封装在 `MeetingService.triggerAuditTaskAssignedNotification()` |
| `AUDIT_TASK_ASSIGNED` | 审核任务已分配 | 某一审核节点通过后,创建下一审核节点任务时触发 | `backend/src/main/java/com/writeoff/module/audit/service/AuditService.java:294` | `MEETING` | `meetingId` `meetingTopic` `auditNode` `auditTaskId` `assigneeUserId` | `AUDITOR` | 适用于初审通过进入复审、复审通过进入终审 |
| `AUDIT_TASK_ASSIGNED` | 审核任务已分配 | 复审或终审拒绝后,流程回到首节点并重新创建首节点任务时触发 | `backend/src/main/java/com/writeoff/module/audit/service/AuditService.java:336` | `MEETING` | `meetingId` `meetingTopic` `auditNode` `auditTaskId` `assigneeUserId` | `AUDITOR` | 这里是“重新发起首审任务”的提醒,不是给提交人的结果通知 |
| `AUDIT_APPROVED_FINAL` | 终审通过 | 审核通过且不存在下一审核节点时触发 | `backend/src/main/java/com/writeoff/module/audit/service/AuditService.java:288` | `MEETING` | `meetingId` `meetingTopic` `auditNode` `auditTaskId` `result` `opinion` | `SUBMITTER` | 当前用于“最终审核通过”通知提交人;`result` 固定解析为“通过” |
| `AUDIT_REJECTED` | 审核拒绝 | 初审节点执行拒绝,且本轮审核直接结束时触发 | `backend/src/main/java/com/writeoff/module/audit/service/AuditService.java:312` | `MEETING` | `meetingId` `meetingTopic` `auditNode` `auditTaskId` `result` `opinion` | `SUBMITTER` | 当前只在初审拒绝时触发;`result` 固定解析为“不通过” |
| `AUDIT_RETURNED` | 审核退回 | 审核人员执行“退回修改”动作时触发 | `backend/src/main/java/com/writeoff/module/audit/service/AuditService.java:350` | `MEETING` | `meetingId` `meetingTopic` `auditNode` `auditTaskId` `result` `opinion` | `SUBMITTER` | 与“拒绝”分开建码;`result` 固定解析为“退回” |
| `USER_CREATED` | 用户创建成功 | 新用户创建完成后自动触发 | `backend/src/main/java/com/writeoff/module/system/service/SystemUserService.java:778` | `USER` | `email` `validFrom` `validTo` `tenantCode` `tenantName` `loginPath` | `TARGET_USER` | 与会议审核无关,但已是现网实际触发事件 |
## 2. 兼容保留事件
| 事件码 | 当前状态 | 说明 | 备注 |
| --- | --- | --- | --- |
| `AUDIT_APPROVED` | 兼容保留,不再作为主触发事件 | 前端通知策略页和状态文案仍保留该选项,用于兼容历史策略或历史数据 | 后端审核主流程已改为触发 `AUDIT_APPROVED_FINAL``AuditService.resolveAuditResultText()` 仍兼容识别该旧码 |
## 3. 预留未接入事件
| 事件码 | 当前状态 | 说明 | 建议 |
| --- | --- | --- | --- |
| `FINANCE_CONFIRMED` | 前端已展示,后端未发现实际触发点 | 当前代码扫描只发现状态文案和通知策略下拉选项,未发现自动派发逻辑 | 后续确定“财务确认”的真实业务动作后,再补后端触发点和变量定义 |
## 4. 审核事件变量说明
### 4.1 审核任务分配类
`AUDIT_TASK_ASSIGNED` 当前由 `MeetingService.triggerAuditTaskAssignedNotification()` 统一派发,变量如下:
| 变量名 | 含义 |
| --- | --- |
| `meetingId` | 会议 ID |
| `meetingTopic` | 会议主题 |
| `auditNode` | 当前审核节点,如 `INIT_REVIEW``RE_REVIEW``FINAL_REVIEW` |
| `auditTaskId` | 审核任务 ID |
| `assigneeUserId` | 被分配审核人的用户 ID |
### 4.2 审核结果类
`AUDIT_APPROVED_FINAL``AUDIT_REJECTED``AUDIT_RETURNED` 当前由 `AuditService.triggerAuditNotification()` 派发,变量如下:
| 变量名 | 含义 |
| --- | --- |
| `meetingId` | 会议 ID |
| `meetingTopic` | 会议主题 |
| `auditNode` | 触发该动作的审核节点 |
| `auditTaskId` | 审核任务 ID |
| `result` | 结果文案,当前可能为“通过”“不通过”“退回” |
| `opinion` | 审核意见 |
## 5. 当前落地口径
1. “提醒审核人员”统一使用 `AUDIT_TASK_ASSIGNED`,由通知策略决定站内信、邮件等发送方式。
2. “审核结果提醒提交人”当前拆分为三个结果事件:`AUDIT_APPROVED_FINAL``AUDIT_REJECTED``AUDIT_RETURNED`
3. 复审通过本身不会直接通知提交人,因为流程仍未结束;它会触发下一节点的 `AUDIT_TASK_ASSIGNED`,通知下一位审核人。
4. 复审或终审拒绝时,当前实现是重置到首审并重新派发首审任务,因此会触发 `AUDIT_TASK_ASSIGNED`,但不会额外触发 `AUDIT_REJECTED`
5. 前端当前默认接收人规则已对齐:
- `AUDIT_TASK_ASSIGNED` -> `AUDITOR`
- `AUDIT_APPROVED_FINAL` -> `SUBMITTER`
- `AUDIT_REJECTED` -> `SUBMITTER`
- `AUDIT_RETURNED` -> `SUBMITTER`
- `USER_CREATED` -> `TARGET_USER`
## 6. 后续建议
1. 在“通知策略中心”中为上述事件分别配置默认模板,避免事件已触发但无可用策略。
2. 若业务上希望“复审拒绝/终审拒绝”同时通知提交人,需要补充一个新的结果事件,或调整现有拒绝分支逻辑。
3. 若后续启用 `FINANCE_CONFIRMED`,应先明确触发动作、接收人、变量字段,再补后端派发。
## 7. 建议通知文案模板
### 7.1 使用说明
1. 系统当前通知模板占位符语法为 `${变量名}`
2. 以下模板均只使用当前后端已实际传递的变量,可以直接用于现有通知模板配置。
3. `auditNode` 当前是英文枚举值,如 `INIT_REVIEW``RE_REVIEW``FINAL_REVIEW`。如果希望直接展示“初审 / 复审 / 终审”中文名称,建议后续补充 `auditNodeName` 变量。
4. 以下内容更适合站内信和邮件;如果后续要接短信,建议再单独准备精简版。
### 7.2 `AUDIT_TASK_ASSIGNED`
- 模板名称:会议审核任务提醒
- 适用场景:会议提交审核后、审核通过流转到下一节点后、复审/终审拒绝后重新回到初审时
- `subjectTemplate``会议审核任务待处理`
- `titleTemplate``会议《${meetingTopic}》待您审核`
- `contentTemplate`
```text
您有一条新的会议审核任务待处理。
会议主题:${meetingTopic}
会议ID${meetingId}
当前审核节点:${auditNode}
审核任务ID${auditTaskId}
请尽快登录系统完成审核处理。
```
### 7.3 `AUDIT_REJECTED`
- 模板名称:会议初审拒绝通知
- 适用场景:初审拒绝时触发
- `subjectTemplate``会议初审未通过通知`
- `titleTemplate``您提交的会议《${meetingTopic}》初审未通过`
- `contentTemplate`
```text
您提交的会议审核未通过。
会议主题:${meetingTopic}
会议ID${meetingId}
处理节点:${auditNode}
审核结果:${result}
审核意见:${opinion}
请根据审核意见修改后重新提交。
```
### 7.4 `AUDIT_RETURNED`
- 模板名称:会议退回修改通知
- 适用场景:退回修改时触发
- `subjectTemplate``会议已退回修改`
- `titleTemplate``您提交的会议《${meetingTopic}》已退回修改`
- `contentTemplate`
```text
您提交的会议已被退回修改。
会议主题:${meetingTopic}
会议ID${meetingId}
处理节点:${auditNode}
审核结果:${result}
审核意见:${opinion}
请根据审核意见完成修改后再次提交审核。
```
### 7.5 `AUDIT_APPROVED_FINAL`
- 模板名称:会议终审通过通知
- 适用场景:最后一个审核节点通过时触发
- `subjectTemplate``会议终审通过通知`
- `titleTemplate``您提交的会议《${meetingTopic}》已审核通过`
- `contentTemplate`
```text
您提交的会议已完成审核并通过。
会议主题:${meetingTopic}
会议ID${meetingId}
通过节点:${auditNode}
审核结果:${result}
审核意见:${opinion}
您可以继续后续业务处理。
```
### 7.6 模板命名建议
1. `AUDIT_TASK_ASSIGNED`:会议审核任务提醒
2. `AUDIT_REJECTED`:会议初审拒绝通知
3. `AUDIT_RETURNED`:会议退回修改通知
4. `AUDIT_APPROVED_FINAL`:会议终审通过通知
+10 -5
View File
@@ -3,6 +3,7 @@ import { ElMessage } from "element-plus";
import { pinia } from "../stores"; import { pinia } from "../stores";
import { useAuthStore } from "../stores/auth"; import { useAuthStore } from "../stores/auth";
import { resolveLoginPath } from "../utils/authNavigation"; import { resolveLoginPath } from "../utils/authNavigation";
import { markRequestErrorNotified } from "../utils/requestError";
const http = axios.create({ const http = axios.create({
baseURL: "/api", baseURL: "/api",
@@ -12,6 +13,7 @@ const http = axios.create({
const FORCE_LOGOUT_CODES = new Set([11001, 11003, 11004, 11005, 11006, 11007]); const FORCE_LOGOUT_CODES = new Set([11001, 11003, 11004, 11005, 11006, 11007]);
let refreshPromise: Promise<string> | null = null; let refreshPromise: Promise<string> | null = null;
let pendingForceLogout = false; let pendingForceLogout = false;
const getAuthStore = () => useAuthStore(pinia); const getAuthStore = () => useAuthStore(pinia);
const isAuthSessionEndpoint = (url: string): boolean => { const isAuthSessionEndpoint = (url: string): boolean => {
@@ -85,6 +87,7 @@ http.interceptors.response.use((resp) => resp.data, (error) => {
const businessCode = Number(error?.response?.data?.code || 0); const businessCode = Number(error?.response?.data?.code || 0);
const isAuthRequest = isAuthSessionEndpoint(requestUrl); const isAuthRequest = isAuthSessionEndpoint(requestUrl);
const originalRequest = error?.config as any; const originalRequest = error?.config as any;
if (!isAuthRequest && businessCode === 11002 && !originalRequest?._retry) { if (!isAuthRequest && businessCode === 11002 && !originalRequest?._retry) {
originalRequest._retry = true; originalRequest._retry = true;
return ensureRefreshedToken() return ensureRefreshedToken()
@@ -95,13 +98,12 @@ http.interceptors.response.use((resp) => resp.data, (error) => {
}) })
.catch((refreshError) => { .catch((refreshError) => {
forceLogoutAndRedirect("会话已过期,请重新登录"); forceLogoutAndRedirect("会话已过期,请重新登录");
markRequestErrorNotified(refreshError);
return Promise.reject(refreshError); return Promise.reject(refreshError);
}); });
} }
if (
!isAuthRequest && if (!isAuthRequest && FORCE_LOGOUT_CODES.has(businessCode)) {
FORCE_LOGOUT_CODES.has(businessCode)
) {
const backendMessage = error?.response?.data?.message || error?.response?.data?.msg || error?.response?.data?.error; const backendMessage = error?.response?.data?.message || error?.response?.data?.msg || error?.response?.data?.error;
const fallbackMessage = backendMessage || getForceLogoutMessage(businessCode); const fallbackMessage = backendMessage || getForceLogoutMessage(businessCode);
if (!error?.response) { if (!error?.response) {
@@ -119,8 +121,10 @@ http.interceptors.response.use((resp) => resp.data, (error) => {
}; };
} }
forceLogoutAndRedirect(fallbackMessage); forceLogoutAndRedirect(fallbackMessage);
markRequestErrorNotified(error);
return Promise.reject(error); return Promise.reject(error);
} }
const backendMessage = error?.response?.data?.message || error?.response?.data?.msg || error?.response?.data?.error; const backendMessage = error?.response?.data?.message || error?.response?.data?.msg || error?.response?.data?.error;
let errorMessage = backendMessage; let errorMessage = backendMessage;
if (!errorMessage) { if (!errorMessage) {
@@ -134,13 +138,14 @@ http.interceptors.response.use((resp) => resp.data, (error) => {
errorMessage = "请求失败,请稍后重试"; errorMessage = "请求失败,请稍后重试";
} }
} }
// 从响应头或响应体中解析 requestId,附加到错误提示
const requestId = error?.response?.headers?.["x-request-id"] const requestId = error?.response?.headers?.["x-request-id"]
|| error?.response?.data?.requestId || error?.response?.data?.requestId
|| ""; || "";
if (requestId) { if (requestId) {
errorMessage = `${errorMessage}RequestId: ${requestId}`; errorMessage = `${errorMessage}RequestId: ${requestId}`;
} }
markRequestErrorNotified(error);
ElMessage.error(errorMessage); ElMessage.error(errorMessage);
return Promise.reject(error); return Promise.reject(error);
}); });
+78 -14
View File
@@ -40,6 +40,8 @@ export const createProject = (payload: {
paymentStatus?: string; paymentStatus?: string;
writeOffStatus?: string; writeOffStatus?: string;
laborFeeRatio?: number; laborFeeRatio?: number;
cateringFeeRatio?: number;
laborAgreementSignType?: 1 | 2;
allowProjectOverBudget?: boolean; allowProjectOverBudget?: boolean;
invoiceInfo?: string; invoiceInfo?: string;
expenseRatioJson?: string; expenseRatioJson?: string;
@@ -62,6 +64,8 @@ export const updateProject = (
paymentStatus?: string; paymentStatus?: string;
writeOffStatus?: string; writeOffStatus?: string;
laborFeeRatio?: number; laborFeeRatio?: number;
cateringFeeRatio?: number;
laborAgreementSignType?: 1 | 2;
allowProjectOverBudget?: boolean; allowProjectOverBudget?: boolean;
invoiceInfo?: string; invoiceInfo?: string;
expenseRatioJson?: string; expenseRatioJson?: string;
@@ -96,6 +100,8 @@ export const fetchMeetings = (params?: {
lastSubmitFrom?: string; lastSubmitFrom?: string;
lastSubmitTo?: string; lastSubmitTo?: string;
includeDeleted?: boolean; includeDeleted?: boolean;
pageNo?: number;
pageSize?: number;
}) => http.get("/meetings", { params }); }) => http.get("/meetings", { params });
export const fetchMeetingPlatformExperts = (params?: { keyword?: string }) => http.get("/meetings/tenant-experts", { params }); export const fetchMeetingPlatformExperts = (params?: { keyword?: string }) => http.get("/meetings/tenant-experts", { params });
export const createMeetingPlatformExpert = (payload: { export const createMeetingPlatformExpert = (payload: {
@@ -127,6 +133,10 @@ export const submitMeetingLaborAgreementExtractTask = (
meetingId: number, meetingId: number,
payload: { objectKey: string; fileName: string }, payload: { objectKey: string; fileName: string },
) => http.post(`/meetings/${meetingId}/labor-agreement-extract/task`, payload); ) => http.post(`/meetings/${meetingId}/labor-agreement-extract/task`, payload);
export const fetchMeetingLaborAgreementUploadSign = (
meetingId: number,
payload: { fileName: string; contentType?: string },
) => http.post(`/meetings/${meetingId}/labor-agreement-extract/upload-sign`, payload);
export const queryMeetingLaborAgreementExtract = ( export const queryMeetingLaborAgreementExtract = (
meetingId: number, meetingId: number,
payload: { taskId: string }, payload: { taskId: string },
@@ -175,8 +185,17 @@ export const updateMeeting = (
cateringRatio?: number; cateringRatio?: number;
}, },
) => http.put(`/meetings/${id}`, payload); ) => http.put(`/meetings/${id}`, payload);
export const submitMeeting = (id: number, payload: { idempotencyKey: string; remark: string }) => export const submitMeeting = (id: number, payload: {
idempotencyKey: string;
remark: string;
issueResponses?: Array<{
issueId: number;
responseText: string;
}>;
}) =>
http.post(`/meetings/${id}/submit`, payload); http.post(`/meetings/${id}/submit`, payload);
export const fetchMeetingPendingIssues = (id: number) =>
http.get(`/meetings/${id}/pending-issues`);
export const withdrawMeeting = (id: number, payload: { idempotencyKey: string; reason: string }) => export const withdrawMeeting = (id: number, payload: { idempotencyKey: string; reason: string }) =>
http.post(`/meetings/${id}/withdraw`, payload); http.post(`/meetings/${id}/withdraw`, payload);
export const deleteMeeting = (id: number) => export const deleteMeeting = (id: number) =>
@@ -189,27 +208,39 @@ export const fetchMeetingMaterials = (meetingId: number) =>
http.get(`/meetings/${meetingId}/materials`); http.get(`/meetings/${meetingId}/materials`);
export const fetchMeetingMaterialCurrent = ( export const fetchMeetingMaterialCurrent = (
meetingId: number, meetingId: number,
moduleCode: "BASIC_INFO" | "WRITE_OFF_DOCS" | "EXPERT_LIST" | "MEETING_INVOICE", moduleCode: "BASIC_INFO" | "WRITE_OFF_DOCS" | "EXPERT_PROFILE" | "EXPERT_LIST" | "MEETING_INVOICE",
) => http.get(`/meetings/${meetingId}/materials/${moduleCode}/current`); ) => http.get(`/meetings/${meetingId}/materials/${moduleCode}/current`);
export const saveMeetingMaterial = ( export const saveMeetingMaterial = (
meetingId: number, meetingId: number,
moduleCode: "BASIC_INFO" | "WRITE_OFF_DOCS" | "EXPERT_LIST" | "MEETING_INVOICE", moduleCode: "BASIC_INFO" | "WRITE_OFF_DOCS" | "EXPERT_PROFILE" | "EXPERT_LIST" | "MEETING_INVOICE",
payload: { contentJson: string; remark?: string }, payload: { contentJson: string; remark?: string },
) => http.post(`/meetings/${meetingId}/materials/${moduleCode}/save`, payload); ) => http.post(`/meetings/${meetingId}/materials/${moduleCode}/save`, payload);
export const submitMeetingMaterial = ( export const submitMeetingMaterial = (
meetingId: number, meetingId: number,
moduleCode: "BASIC_INFO" | "WRITE_OFF_DOCS" | "EXPERT_LIST" | "MEETING_INVOICE", moduleCode: "BASIC_INFO" | "WRITE_OFF_DOCS" | "EXPERT_PROFILE" | "EXPERT_LIST" | "MEETING_INVOICE",
payload: { contentJson: string; remark?: string }, payload: {
contentJson: string;
remark?: string;
issueResponses?: Array<{
issueId: number;
responseText: string;
}>;
},
) => http.post(`/meetings/${meetingId}/materials/${moduleCode}/submit`, payload); ) => http.post(`/meetings/${meetingId}/materials/${moduleCode}/submit`, payload);
export const fetchMeetingMaterialUploadSign = ( export const fetchMeetingMaterialUploadSign = (
meetingId: number, meetingId: number,
moduleCode: "BASIC_INFO" | "WRITE_OFF_DOCS" | "EXPERT_LIST" | "MEETING_INVOICE", moduleCode: "BASIC_INFO" | "WRITE_OFF_DOCS" | "EXPERT_PROFILE" | "EXPERT_LIST" | "MEETING_INVOICE",
payload: { fileName: string; contentType?: string }, payload: { fileName: string; contentType?: string },
) => http.post(`/meetings/${meetingId}/materials/${moduleCode}/upload-sign`, payload); ) => http.post(`/meetings/${meetingId}/materials/${moduleCode}/upload-sign`, payload);
export const fetchMeetingMaterialHistory = ( export const fetchMeetingMaterialHistory = (
meetingId: number, meetingId: number,
moduleCode: "BASIC_INFO" | "WRITE_OFF_DOCS" | "EXPERT_LIST" | "MEETING_INVOICE", moduleCode: "BASIC_INFO" | "WRITE_OFF_DOCS" | "EXPERT_PROFILE" | "EXPERT_LIST" | "MEETING_INVOICE",
) => http.get(`/meetings/${meetingId}/materials/${moduleCode}/history`); ) => http.get(`/meetings/${meetingId}/materials/${moduleCode}/history`);
export const fetchMeetingMaterialResubmitPreview = (
meetingId: number,
moduleCode: "BASIC_INFO" | "WRITE_OFF_DOCS" | "EXPERT_PROFILE" | "EXPERT_LIST" | "MEETING_INVOICE",
payload: { contentJson: string; remark?: string },
) => http.post(`/meetings/${meetingId}/materials/${moduleCode}/resubmit-preview`, payload);
export const fetchFilePresignDownload = (params: { objectKey: string }) => export const fetchFilePresignDownload = (params: { objectKey: string }) =>
http.get("/files/presign-download", { params }); http.get("/files/presign-download", { params });
@@ -269,6 +300,7 @@ export const fetchAuditTasks = (params?: boolean | {
meetingId?: number; meetingId?: number;
pageNo?: number; pageNo?: number;
pageSize?: number; pageSize?: number;
reviewFocus?: string;
sortBy?: string; sortBy?: string;
order?: "asc" | "desc"; order?: "asc" | "desc";
}) => { }) => {
@@ -282,12 +314,15 @@ export const fetchAuditTasks = (params?: boolean | {
meetingId: params?.meetingId, meetingId: params?.meetingId,
pageNo: params?.pageNo, pageNo: params?.pageNo,
pageSize: params?.pageSize, pageSize: params?.pageSize,
reviewFocus: params?.reviewFocus,
sortBy: params?.sortBy, sortBy: params?.sortBy,
order: params?.order, order: params?.order,
}, },
}); });
}; };
export const exportAuditOpinions = () => http.get("/audits/export-opinions"); export const exportAuditOpinions = () => http.get("/audits/export-opinions");
export const fetchAuditTaskDetail = (taskId: number) =>
http.get(`/audits/tasks/${taskId}`);
export const readAuditTaskMaterial = ( export const readAuditTaskMaterial = (
taskId: number, taskId: number,
moduleCode: "BASIC_INFO" | "WRITE_OFF_DOCS" | "EXPERT_LIST" | "MEETING_INVOICE" | "EXPERT_PROFILE", moduleCode: "BASIC_INFO" | "WRITE_OFF_DOCS" | "EXPERT_LIST" | "MEETING_INVOICE" | "EXPERT_PROFILE",
@@ -310,9 +345,20 @@ export const rejectAuditMaterialItem = (
reason: string; reason: string;
}, },
) => http.post(`/audits/tasks/${taskId}/material/reject-item`, payload); ) => http.post(`/audits/tasks/${taskId}/material/reject-item`, payload);
export const confirmAuditIssueResolved = (taskId: number, issueId: number) =>
http.post(`/audits/tasks/${taskId}/issues/${issueId}/resolve`);
export const approveAuditTask = (id: number, payload: { idempotencyKey: string; opinion: string }) => export const approveAuditTask = (id: number, payload: { idempotencyKey: string; opinion: string }) =>
http.post(`/audits/tasks/${id}/approve`, payload); http.post(`/audits/tasks/${id}/approve`, payload);
export const rejectAuditTask = (id: number, payload: { idempotencyKey: string; opinion: string }) => export const rejectAuditTask = (id: number, payload: {
idempotencyKey: string;
opinion: string;
issues: Array<{
moduleCode: "BASIC_INFO" | "WRITE_OFF_DOCS" | "EXPERT_PROFILE" | "EXPERT_LIST" | "MEETING_INVOICE";
targetPath: string;
targetLabel: string;
reason: string;
}>;
}) =>
http.post(`/audits/tasks/${id}/reject`, payload); http.post(`/audits/tasks/${id}/reject`, payload);
export const returnAuditTask = (id: number, payload: { idempotencyKey: string; opinion: string }) => export const returnAuditTask = (id: number, payload: { idempotencyKey: string; opinion: string }) =>
http.post(`/audits/tasks/${id}/return`, payload); http.post(`/audits/tasks/${id}/return`, payload);
@@ -327,6 +373,17 @@ export const batchApproveAuditTasks = (payload: { idempotencyKey: string; taskId
export const batchRejectAuditTasks = (payload: { idempotencyKey: string; taskIds: number[]; opinion: string }) => export const batchRejectAuditTasks = (payload: { idempotencyKey: string; taskIds: number[]; opinion: string }) =>
http.post("/audits/tasks/batch-reject", payload); http.post("/audits/tasks/batch-reject", payload);
export const fetchAuditSlaStat = () => http.get("/audits/tasks/sla-stat"); export const fetchAuditSlaStat = () => http.get("/audits/tasks/sla-stat");
export const fetchAuditReviewStat = (params?: {
mine?: boolean;
scope?: string;
reviewFocus?: string;
}) => http.get("/audits/tasks/review-stat", {
params: {
mine: !!params?.mine,
scope: params?.scope,
reviewFocus: params?.reviewFocus,
},
});
export const fetchAuditFlows = (params?: { pageNo?: number; pageSize?: number }) => export const fetchAuditFlows = (params?: { pageNo?: number; pageSize?: number }) =>
http.get("/audit-flows", { params }); http.get("/audit-flows", { params });
@@ -542,6 +599,7 @@ export const enableDataPermission = (id: number) => http.post(`/data-permissions
export const disableDataPermission = (id: number) => http.post(`/data-permissions/${id}/disable`); export const disableDataPermission = (id: number) => http.post(`/data-permissions/${id}/disable`);
export const fetchDataPermissionRoles = (id: number) => http.get(`/data-permissions/${id}/roles`); export const fetchDataPermissionRoles = (id: number) => http.get(`/data-permissions/${id}/roles`);
export const fetchCurrentDataScope = () => http.get("/data-permissions/current-scope"); export const fetchCurrentDataScope = () => http.get("/data-permissions/current-scope");
export const fetchMatchedDataScope = (params: { account: string }) => http.get("/data-permissions/match", { params });
export const fetchAuditLogs = (params?: { userId?: number; actionCode?: string; pageNo?: number; pageSize?: number }) => export const fetchAuditLogs = (params?: { userId?: number; actionCode?: string; pageNo?: number; pageSize?: number }) =>
http.get("/audit-logs", { params }); http.get("/audit-logs", { params });
export const fetchPlatformAuditLogs = (params?: { export const fetchPlatformAuditLogs = (params?: {
@@ -894,8 +952,8 @@ export const revokePlatformPrincipalSessions = (payload: {
scope: "TENANT" | "PLATFORM"; scope: "TENANT" | "PLATFORM";
tenantId?: number; tenantId?: number;
}) => http.post("/platform/auth-sessions/revoke-principal", payload); }) => http.post("/platform/auth-sessions/revoke-principal", payload);
export const fetchPlatformNotifyGateways = () => http.get("/platform/notify-gateways"); export const fetchNotifyGateways = () => http.get("/notify-gateways");
export const savePlatformNotifyGateway = ( export const saveNotifyGateway = (
channelCode: string, channelCode: string,
payload: { payload: {
gatewayName: string; gatewayName: string;
@@ -904,15 +962,16 @@ export const savePlatformNotifyGateway = (
remark?: string; remark?: string;
config: Record<string, unknown>; config: Record<string, unknown>;
}, },
) => http.put(`/platform/notify-gateways/${channelCode}`, payload); ) => http.put(`/notify-gateways/${channelCode}`, payload);
export const testPlatformNotifyGateway = ( export const testNotifyGateway = (
channelCode: string, channelCode: string,
payload: { payload: {
receiverRef: string; receiverRef: string;
subject?: string; subject?: string;
content?: string; content?: string;
smsTemplateCode?: string;
}, },
) => http.post(`/platform/notify-gateways/${channelCode}/test`, payload); ) => http.post(`/notify-gateways/${channelCode}/test`, payload);
export const fetchNotificationPolicies = (params?: { pageNo?: number; pageSize?: number }) => export const fetchNotificationPolicies = (params?: { pageNo?: number; pageSize?: number }) =>
http.get("/notification-policies", { params }); http.get("/notification-policies", { params });
@@ -943,6 +1002,7 @@ export const createNotificationPolicy = (payload: {
channel: string; channel: string;
receiverType: string; receiverType: string;
templateId: number; templateId: number;
smsTemplateCode?: string;
variablesJson?: string; variablesJson?: string;
status?: "ENABLED" | "DISABLED"; status?: "ENABLED" | "DISABLED";
}) => http.post("/notification-policies", payload); }) => http.post("/notification-policies", payload);
@@ -954,6 +1014,7 @@ export const updateNotificationPolicy = (
channel: string; channel: string;
receiverType: string; receiverType: string;
templateId: number; templateId: number;
smsTemplateCode?: string;
variablesJson?: string; variablesJson?: string;
status?: "ENABLED" | "DISABLED"; status?: "ENABLED" | "DISABLED";
}, },
@@ -979,7 +1040,10 @@ export const ingestNotificationReceipt = (payload: {
receiptMessage?: string; receiptMessage?: string;
delivered?: boolean; delivered?: boolean;
}) => http.post("/notifications/receipts", payload); }) => http.post("/notifications/receipts", payload);
export const fetchInAppNotifications = (params?: { ts?: number }) => http.get("/in-app-notifications", { params }); export const fetchInAppNotifications = (params?: { ts?: number; pageNo?: number; pageSize?: number; onlyUnread?: boolean }) =>
http.get("/in-app-notifications", { params });
export const fetchInAppNotificationSummary = (params?: { ts?: number }) =>
http.get("/in-app-notifications/summary", { params });
export const markInAppNotificationRead = (id: number) => http.post(`/in-app-notifications/${id}/read`); export const markInAppNotificationRead = (id: number) => http.post(`/in-app-notifications/${id}/read`);
export const markAllInAppNotificationsRead = () => http.post("/in-app-notifications/read-all"); export const markAllInAppNotificationsRead = () => http.post("/in-app-notifications/read-all");
export const fetchExportTasks = () => http.get("/export-tasks"); export const fetchExportTasks = () => http.get("/export-tasks");
@@ -1,12 +1,12 @@
<template> <template>
<button class="search-trigger" :class="{ 'search-trigger--light': light }" @click="openDialog"> <!-- <button class="search-trigger" :class="{ 'search-trigger--light': light }" @click="openDialog">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="7" /> <circle cx="11" cy="11" r="7" />
<path d="m21 21-4.35-4.35" /> <path d="m21 21-4.35-4.35" />
</svg> </svg>
<span class="search-trigger__label">全局搜索</span> <span class="search-trigger__label">全局搜索</span>
<span class="search-trigger__shortcut">Ctrl/Cmd + K</span> <span class="search-trigger__shortcut">Ctrl/Cmd + K</span>
</button> </button> -->
<el-dialog <el-dialog
v-model="visible" v-model="visible"
@@ -0,0 +1,113 @@
<script setup lang="ts">
import { computed } from "vue";
import { DIALOG_WIDTH } from "../constants/ui";
import { toZhStatus } from "../utils/status";
const visible = defineModel<boolean>({ required: true });
const props = withDefaults(
defineProps<{
notification: Record<string, any> | null;
canMarkRead?: boolean;
}>(),
{
canMarkRead: false,
},
);
const emit = defineEmits<{
"mark-read": [id: number];
}>();
const showMarkReadAction = computed(
() => props.canMarkRead && props.notification && String(props.notification.status || "") !== "READ",
);
const statusTagType = computed(() => (String(props.notification?.status || "") === "READ" ? "info" : "danger"));
const handleMarkRead = () => {
const id = Number(props.notification?.id || 0);
if (!Number.isFinite(id) || id <= 0) {
return;
}
emit("mark-read", id);
};
</script>
<template>
<el-dialog v-model="visible" title="通知详情" :width="DIALOG_WIDTH.md" destroy-on-close append-to-body>
<template v-if="notification">
<div class="notif-detail-header">
<div class="notif-detail-title-row">
<div class="notif-detail-title">{{ notification.title || "-" }}</div>
<el-tag :type="statusTagType">{{ toZhStatus(notification.status) }}</el-tag>
</div>
<div class="notif-detail-meta">
<span>创建时间{{ notification.createdAt || "-" }}</span>
<span>已读时间{{ notification.readAt || "-" }}</span>
</div>
</div>
<el-divider />
<div class="notif-detail-content">
{{ notification.content || "暂无通知内容" }}
</div>
</template>
<template #footer>
<div class="notif-detail-footer">
<el-button @click="visible = false">关闭</el-button>
<el-button v-if="showMarkReadAction" type="primary" @click="handleMarkRead">标记已读</el-button>
</div>
</template>
</el-dialog>
</template>
<style scoped>
.notif-detail-header {
display: flex;
flex-direction: column;
gap: 10px;
}
.notif-detail-title-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.notif-detail-title {
font-size: 18px;
font-weight: 700;
line-height: 1.5;
color: var(--el-text-color-primary);
}
.notif-detail-meta {
display: flex;
flex-wrap: wrap;
gap: 12px 20px;
font-size: 13px;
color: var(--el-text-color-secondary);
}
.notif-detail-content {
max-height: 420px;
overflow: auto;
padding: 14px 16px;
border-radius: 12px;
background: var(--el-fill-color-light);
white-space: pre-wrap;
word-break: break-word;
line-height: 1.8;
color: var(--el-text-color-primary);
}
.notif-detail-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
</style>
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -20,6 +20,7 @@ export const PERMS = {
materialSubmit: "meeting.material.submit", materialSubmit: "meeting.material.submit",
materialHistoryRead: "meeting.material.history.read", materialHistoryRead: "meeting.material.history.read",
materialExport: "meeting.material.export", materialExport: "meeting.material.export",
laborAgreementExtract: "meeting.labor-agreement.extract",
invoiceConfig: "meeting.invoice.config", invoiceConfig: "meeting.invoice.config",
changeLogRead: "meeting.change-log.read", changeLogRead: "meeting.change-log.read",
}, },
@@ -102,6 +103,8 @@ export const PERMS = {
notification: { notification: {
policyRead: "notification.policy.read", policyRead: "notification.policy.read",
policyManage: "notification.policy.manage", policyManage: "notification.policy.manage",
notifyGatewayRead: "notification.notify-gateway.read",
notifyGatewayManage: "notification.notify-gateway.manage",
textTemplateRead: "notification.text-template.read", textTemplateRead: "notification.text-template.read",
textTemplateManage: "notification.text-template.manage", textTemplateManage: "notification.text-template.manage",
dispatch: "notification.dispatch", dispatch: "notification.dispatch",
@@ -149,7 +152,5 @@ export const PERMS = {
dictionaryManage: "platform.dictionary.manage", dictionaryManage: "platform.dictionary.manage",
sessionRead: "platform.session.read", sessionRead: "platform.session.read",
sessionManage: "platform.session.manage", sessionManage: "platform.session.manage",
notifyGatewayRead: "platform.notify-gateway.read",
notifyGatewayManage: "platform.notify-gateway.manage",
}, },
} as const; } as const;
+2
View File
@@ -47,4 +47,6 @@ export const LABEL_WIDTH = {
md: "90px", md: "90px",
/** 宽标签表单 */ /** 宽标签表单 */
lg: "110px", lg: "110px",
/** 超宽标签表单 */
pro: "140px",
} as const; } as const;
+25 -1
View File
@@ -23,6 +23,30 @@ let idleTimer: number | null = null;
let lastActivityAt = Date.now(); let lastActivityAt = Date.now();
let idleLogoutPending = false; let idleLogoutPending = false;
let previousAuthLoggedIn = false; let previousAuthLoggedIn = false;
let lastErrorMessageText = "";
let lastErrorMessageAt = 0;
const ERROR_MESSAGE_DEDUPE_WINDOW_MS = 400;
const rawElMessageError = ElMessage.error.bind(ElMessage);
const normalizeErrorMessageText = (messageText: string) => {
return messageText.replace(/[(\uFF08]\s*RequestId:\s*[^)\uFF09]+[)\uFF09]\s*$/i, "").trim();
};
ElMessage.error = ((options: Parameters<typeof ElMessage.error>[0], appContext?: Parameters<typeof ElMessage.error>[1]) => {
const rawMessageText = typeof options === "string"
? options
: typeof options === "object" && options !== null && "message" in options
? String((options as { message?: unknown }).message ?? "").trim()
: "";
const messageText = normalizeErrorMessageText(rawMessageText);
const now = Date.now();
if (messageText && messageText === lastErrorMessageText && now - lastErrorMessageAt <= ERROR_MESSAGE_DEDUPE_WINDOW_MS) {
return {} as ReturnType<typeof ElMessage.error>;
}
lastErrorMessageText = messageText;
lastErrorMessageAt = now;
return rawElMessageError(options as any, appContext);
}) as typeof ElMessage.error;
const stopIdleTimer = () => { const stopIdleTimer = () => {
if (idleTimer !== null) { if (idleTimer !== null) {
@@ -98,7 +122,7 @@ const isPublicEntryPath = (path: string) => PUBLIC_ENTRY_PATH_PATTERNS.some((pat
// 全局错误边界:捕获未处理的组件异常,防止白屏 // 全局错误边界:捕获未处理的组件异常,防止白屏
app.config.errorHandler = (err, _instance, info) => { app.config.errorHandler = (err, _instance, info) => {
console.error("[全局错误边界]", info, err); console.error("[全局错误边界]", info, err);
ElMessage.error("页面发生异常,请尝试刷新页面"); // ElMessage.error("页面发生异常,请尝试刷新页面");
}; };
app.use(router).use(ElementPlus).mount("#app"); app.use(router).use(ElementPlus).mount("#app");
+1 -1
View File
@@ -83,6 +83,7 @@ const router = createRouter({
{ path: "/invoice-profiles", component: InvoiceProfilePage, meta: { title: "发票管理" } }, { path: "/invoice-profiles", component: InvoiceProfilePage, meta: { title: "发票管理" } },
{ path: "/export-tasks", component: ExportTaskPage, meta: { title: "导出任务中心" } }, { path: "/export-tasks", component: ExportTaskPage, meta: { title: "导出任务中心" } },
{ path: "/notification-policies", component: NotificationPolicyPage, meta: { title: "通知策略中心" } }, { path: "/notification-policies", component: NotificationPolicyPage, meta: { title: "通知策略中心" } },
{ path: "/notify-gateways", component: PlatformNotifyGatewayPage, meta: { title: "通知网关配置" } },
{ path: "/notification-text-templates", component: NotificationTextTemplatePage, meta: { title: "通知文案模板" } }, { path: "/notification-text-templates", component: NotificationTextTemplatePage, meta: { title: "通知文案模板" } },
{ path: "/in-app-notifications", component: InAppNotificationPage, meta: { title: "站内通知中心" } }, { path: "/in-app-notifications", component: InAppNotificationPage, meta: { title: "站内通知中心" } },
{ path: "/observability", component: ObservabilityPage, meta: { title: "可观测性与告警" } }, { path: "/observability", component: ObservabilityPage, meta: { title: "可观测性与告警" } },
@@ -94,7 +95,6 @@ const router = createRouter({
{ path: "/platform/roles", component: PlatformRolePage, meta: { title: "平台角色管理" } }, { path: "/platform/roles", component: PlatformRolePage, meta: { title: "平台角色管理" } },
{ path: "/platform/dictionaries", component: PlatformDictionaryPage, meta: { title: "平台字典管理" } }, { path: "/platform/dictionaries", component: PlatformDictionaryPage, meta: { title: "平台字典管理" } },
{ path: "/platform/auth-sessions", component: PlatformSessionPage, meta: { title: "平台会话管理" } }, { path: "/platform/auth-sessions", component: PlatformSessionPage, meta: { title: "平台会话管理" } },
{ path: "/platform/notify-gateways", component: PlatformNotifyGatewayPage, meta: { title: "通知网关配置" } },
{ path: "/platform/permissions", redirect: { path: "/platform/menus", query: { tab: "permissions" } } }, { path: "/platform/permissions", redirect: { path: "/platform/menus", query: { tab: "permissions" } } },
{ path: "/platform/experts", component: ExpertPage, meta: { title: "平台专家管理" } }, { path: "/platform/experts", component: ExpertPage, meta: { title: "平台专家管理" } },
{ path: "/:pathMatch(.*)*", component: NotFoundPage, meta: { title: "页面未找到" } }, { path: "/:pathMatch(.*)*", component: NotFoundPage, meta: { title: "页面未找到" } },
+2 -3
View File
@@ -23,11 +23,10 @@ export type TenantOption = {
const fallbackMenus: MenuItem[] = []; const fallbackMenus: MenuItem[] = [];
const platformMenus: MenuItem[] = [ const platformMenus: MenuItem[] = [
{ menuName: "租户管理", routePath: "/platform/tenants" }, { menuName: "平台租户管理", routePath: "/platform/tenants" },
{ menuName: "平台用户管理", routePath: "/platform/users" }, { menuName: "平台用户管理", routePath: "/platform/users" },
{ menuName: "平台角色管理", routePath: "/platform/roles" }, { menuName: "平台角色管理", routePath: "/platform/roles" },
{ menuName: "通知网关配置", routePath: "/platform/notify-gateways" }, { menuName: "平台专家管理", routePath: "/platform/experts" },
{ menuName: "专家管理", routePath: "/platform/experts" },
{ menuName: "平台字典管理", routePath: "/platform/dictionaries" }, { menuName: "平台字典管理", routePath: "/platform/dictionaries" },
{ menuName: "平台会话管理", routePath: "/platform/auth-sessions" }, { menuName: "平台会话管理", routePath: "/platform/auth-sessions" },
{ menuName: "平台审计日志", routePath: "/platform/audit-logs" }, { menuName: "平台审计日志", routePath: "/platform/audit-logs" },
+10 -4
View File
@@ -1,6 +1,7 @@
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { import {
fetchInAppNotifications, fetchInAppNotifications,
fetchInAppNotificationSummary,
markAllInAppNotificationsRead, markAllInAppNotificationsRead,
markInAppNotificationRead, markInAppNotificationRead,
} from "../api/modules"; } from "../api/modules";
@@ -34,6 +35,7 @@ export const useNotificationStore = defineStore("notification", {
state: () => ({ state: () => ({
unreadCount: 0, unreadCount: 0,
notifRows: [] as any[], notifRows: [] as any[],
notifTotal: 0,
polling: false, polling: false,
websocketConnected: false, websocketConnected: false,
}), }),
@@ -133,6 +135,7 @@ export const useNotificationStore = defineStore("notification", {
if (!canReadInApp()) { if (!canReadInApp()) {
this.stopRealtime(); this.stopRealtime();
this.notifRows = []; this.notifRows = [];
this.notifTotal = 0;
this.setUnreadCount(0); this.setUnreadCount(0);
return; return;
} }
@@ -164,6 +167,7 @@ export const useNotificationStore = defineStore("notification", {
this.stopRealtime(); this.stopRealtime();
this.unreadCount = 0; this.unreadCount = 0;
this.notifRows = []; this.notifRows = [];
this.notifTotal = 0;
emitUnreadChanged(0); emitUnreadChanged(0);
}, },
setUnreadCount(nextCount: number) { setUnreadCount(nextCount: number) {
@@ -176,9 +180,8 @@ export const useNotificationStore = defineStore("notification", {
return; return;
} }
try { try {
const resp = await fetchInAppNotifications({ ts: Date.now() }); const resp = await fetchInAppNotificationSummary({ ts: Date.now() });
const list = resp?.data?.list || []; this.setUnreadCount(Number(resp?.data?.unread || 0));
this.setUnreadCount(list.filter((item: any) => String(item?.status || "") === "UNREAD").length);
} catch (_e) { } catch (_e) {
this.setUnreadCount(0); this.setUnreadCount(0);
} }
@@ -186,6 +189,7 @@ export const useNotificationStore = defineStore("notification", {
async loadRows() { async loadRows() {
if (!canReadInApp()) { if (!canReadInApp()) {
this.notifRows = []; this.notifRows = [];
this.notifTotal = 0;
this.setUnreadCount(0); this.setUnreadCount(0);
return; return;
} }
@@ -193,9 +197,11 @@ export const useNotificationStore = defineStore("notification", {
const resp = await fetchInAppNotifications({ ts: Date.now() }); const resp = await fetchInAppNotifications({ ts: Date.now() });
const list = Array.isArray(resp?.data?.list) ? resp.data.list : []; const list = Array.isArray(resp?.data?.list) ? resp.data.list : [];
this.notifRows = list; this.notifRows = list;
this.setUnreadCount(list.filter((item: any) => String(item?.status || "") === "UNREAD").length); this.notifTotal = Number(resp?.data?.total || 0);
await this.loadUnreadCount();
} catch (_e) { } catch (_e) {
this.notifRows = []; this.notifRows = [];
this.notifTotal = 0;
this.setUnreadCount(0); this.setUnreadCount(0);
} }
}, },
-10
View File
@@ -81,16 +81,6 @@
flex-direction: column; flex-direction: column;
} }
/* ---- 预览盒(通知策略等处使用) ---- */
.preview-box {
width: 100%;
background: var(--wo-bg-light);
border: 1px solid var(--wo-border-light);
border-radius: var(--wo-radius-md);
padding: 10px 12px;
line-height: var(--wo-line-height-loose);
}
/* ---- 图片缩略图 ---- */ /* ---- 图片缩略图 ---- */
.thumbnail-sm { .thumbnail-sm {
width: 56px; width: 56px;
+46
View File
@@ -0,0 +1,46 @@
import { ElMessage } from "element-plus";
const REQUEST_ERROR_NOTIFIED = "__requestErrorNotified";
const getBackendMessage = (error: any): string => {
const message = error?.response?.data?.message || error?.response?.data?.msg || error?.response?.data?.error;
return typeof message === "string" ? message.trim() : "";
};
const isIgnoredAction = (error: unknown): boolean => {
const action = String(error ?? "").trim().toLowerCase();
const message = String((error as any)?.message || "").trim().toLowerCase();
return action === "cancel" || action === "close" || message === "cancel" || message === "close";
};
export const markRequestErrorNotified = <T>(error: T): T => {
if (error && typeof error === "object") {
(error as Record<string, unknown>)[REQUEST_ERROR_NOTIFIED] = true;
}
return error;
};
export const isRequestErrorNotified = (error: unknown): boolean => {
return Boolean(
error
&& typeof error === "object"
&& (error as Record<string, unknown>)[REQUEST_ERROR_NOTIFIED],
);
};
export const showRequestError = (
error: unknown,
fallbackMessage: string,
level: "error" | "warning" = "error",
) => {
if (isIgnoredAction(error) || isRequestErrorNotified(error)) {
return;
}
const resolvedMessage = getBackendMessage(error) || String((error as any)?.message || "").trim() || fallbackMessage;
if (level === "warning") {
ElMessage.warning(resolvedMessage);
} else {
ElMessage.error(resolvedMessage);
}
markRequestErrorNotified(error);
};
+3
View File
@@ -58,8 +58,11 @@ const STATUS_TEXT_MAP: Record<string, string> = {
AUDITOR: "审核人", AUDITOR: "审核人",
FINANCE_ROLE: "财务角色", FINANCE_ROLE: "财务角色",
TARGET_USER: "目标用户", TARGET_USER: "目标用户",
AUDIT_TASK_ASSIGNED: "审核任务分配",
AUDIT_APPROVED: "审核通过", AUDIT_APPROVED: "审核通过",
AUDIT_APPROVED_FINAL: "终审通过",
AUDIT_REJECTED: "审核拒绝", AUDIT_REJECTED: "审核拒绝",
AUDIT_RETURNED: "审核退回",
FINANCE_CONFIRMED: "财务已确认", FINANCE_CONFIRMED: "财务已确认",
USER_CREATED: "用户创建", USER_CREATED: "用户创建",
DELIVERED: "已送达", DELIVERED: "已送达",
+144 -30
View File
@@ -201,8 +201,8 @@
> >
<div class="notif-toolbar"> <div class="notif-toolbar">
<div class="notif-tags"> <div class="notif-tags">
<el-tag type="info" size="small">总数 {{ notifRows.length }}</el-tag> <el-tag type="info" size="small">总数 {{ notifTotal }}</el-tag>
<el-tag type="danger" size="small">未读 {{ notifUnreadCount }}</el-tag> <el-tag type="danger" size="small">未读 {{ unreadInAppCount }}</el-tag>
</div> </div>
<div class="notif-actions"> <div class="notif-actions">
<el-switch <el-switch
@@ -227,40 +227,61 @@
</div> </div>
</div> </div>
<el-table :data="notifDisplayRows" empty-text="暂无站内通知" max-height="420" stripe> <el-table :data="notifRows" empty-text="暂无站内通知" max-height="480" stripe>
<el-table-column prop="title" label="标题" min-width="180" /> <el-table-column prop="title" label="标题" min-width="180" />
<el-table-column prop="content" label="内容" min-width="280" show-overflow-tooltip /> <el-table-column prop="content" label="内容" min-width="280" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="90" :formatter="notifStatusFormatter" /> <el-table-column prop="status" label="状态" width="90" :formatter="notifStatusFormatter" />
<el-table-column prop="createdAt" label="创建时间" width="170" /> <el-table-column prop="createdAt" label="创建时间" width="170" />
<el-table-column label="操作" width="100"> <el-table-column label="操作" width="190">
<template #default="{ row }"> <template #default="{ row }">
<el-space wrap>
<el-button size="small" @click="openNotifDetail(row)">查看详情</el-button>
<el-button <el-button
v-if="canMarkReadInApp && row.status !== 'READ'" v-if="canMarkReadInApp && row.status !== 'READ'"
type="primary" type="primary"
size="small" size="small"
link
class="notif-mark-read-btn" class="notif-mark-read-btn"
@click="handleNotifMarkRead(row.id)" @click="handleNotifMarkRead(row.id)"
> >
标记已读 标记已读
</el-button> </el-button>
<span v-else class="notif-read-tag">已读</span> </el-space>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<div class="flex-end mt-md">
<el-pagination
:current-page="notifPageNo"
:page-size="notifPageSize"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
:total="notifTotal"
small
@current-change="handleNotifPageChange"
@size-change="handleNotifPageSizeChange"
/>
</div>
</el-dialog> </el-dialog>
<InAppNotificationDetailDialog
v-model="notifDetailVisible"
:notification="currentNotifDetail"
:can-mark-read="canMarkReadInApp"
@mark-read="handleNotifMarkRead"
/>
</el-container> </el-container>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from "vue"; import { computed, onMounted, onUnmounted, ref, watch } from "vue";
import { storeToRefs } from "pinia"; import { storeToRefs } from "pinia";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import type { LocationQueryRaw, RouteLocationRaw } from "vue-router"; import type { LocationQueryRaw, RouteLocationRaw } from "vue-router";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import BreadcrumbNav from "../../components/BreadcrumbNav.vue"; import BreadcrumbNav from "../../components/BreadcrumbNav.vue";
import GlobalSearchLauncher from "../../components/GlobalSearchLauncher.vue"; import GlobalSearchLauncher from "../../components/GlobalSearchLauncher.vue";
import { logoutAllAuth, switchTenant } from "../../api/modules"; import InAppNotificationDetailDialog from "../../components/InAppNotificationDetailDialog.vue";
import { fetchInAppNotifications, markAllInAppNotificationsRead, markInAppNotificationRead, logoutAllAuth, switchTenant } from "../../api/modules";
import { PERMS } from "../../constants/permissions"; import { PERMS } from "../../constants/permissions";
import { useAppearanceStore } from "../../stores/appearance"; import { useAppearanceStore } from "../../stores/appearance";
import { useAuthStore } from "../../stores/auth"; import { useAuthStore } from "../../stores/auth";
@@ -276,12 +297,18 @@ const menuStore = useMenuStore();
const notificationStore = useNotificationStore(); const notificationStore = useNotificationStore();
const { scope: authScope, tenantId, userName, tenantName, tenantCode, tenantContextVersion } = storeToRefs(authStore); const { scope: authScope, tenantId, userName, tenantName, tenantCode, tenantContextVersion } = storeToRefs(authStore);
const { menus, tenantLogoDisplay, switchableTenants, tenantSwitching, useTopMenuLayout } = storeToRefs(menuStore); const { menus, tenantLogoDisplay, switchableTenants, tenantSwitching, useTopMenuLayout } = storeToRefs(menuStore);
const { unreadCount: unreadInAppCount, notifRows } = storeToRefs(notificationStore); const { unreadCount: unreadInAppCount } = storeToRefs(notificationStore);
const activePath = computed(() => route.path); const activePath = computed(() => route.path);
const selectedTenantSwitchId = computed(() => tenantId.value); const selectedTenantSwitchId = computed(() => tenantId.value);
const notifDialogVisible = ref(false); const notifDialogVisible = ref(false);
const notifOnlyUnread = ref(false); const notifOnlyUnread = ref(false);
const notifRows = ref<Record<string, any>[]>([]);
const notifTotal = ref(0);
const notifPageNo = ref(1);
const notifPageSize = ref(10);
const notifDetailVisible = ref(false);
const currentNotifDetail = ref<Record<string, any> | null>(null);
let suppressNextAuthRefresh = false; let suppressNextAuthRefresh = false;
@@ -331,11 +358,20 @@ const watermarkStyle = computed(() => ({
backgroundImage: createWatermarkImage([userDisplay.value, `${watermarkScopeLabel.value} ${watermarkDate}`.trim()]), backgroundImage: createWatermarkImage([userDisplay.value, `${watermarkScopeLabel.value} ${watermarkDate}`.trim()]),
})); }));
const notifUnreadCount = computed(() => notifRows.value.filter((item) => String(item?.status || "") === "UNREAD").length); const notifUnreadCount = computed(() => Number(unreadInAppCount.value || 0));
const notifDisplayRows = computed(() =>
notifOnlyUnread.value ? notifRows.value.filter((item) => String(item?.status || "") === "UNREAD") : notifRows.value,
);
const notifStatusFormatter = (_row: unknown, _column: unknown, value: unknown) => toZhStatus(value); const notifStatusFormatter = (_row: unknown, _column: unknown, value: unknown) => toZhStatus(value);
const syncCurrentNotifDetail = (targetId?: number) => {
const id = Number(targetId || currentNotifDetail.value?.id || 0);
if (!Number.isFinite(id) || id <= 0) {
return;
}
const nextRow = notifRows.value.find((item) => Number(item?.id || 0) === id) || null;
if (nextRow) {
currentNotifDetail.value = nextRow;
}
};
const dashboardRoute = computed(() => (authScope.value === "PLATFORM" ? "/platform/tenants" : "/dashboard")); const dashboardRoute = computed(() => (authScope.value === "PLATFORM" ? "/platform/tenants" : "/dashboard"));
const profileRoute = computed(() => "/profile"); const profileRoute = computed(() => "/profile");
const routeViewKey = computed(() => { const routeViewKey = computed(() => {
@@ -402,7 +438,30 @@ const resolveTenantSwitchTargetRoute = (
}; };
const loadNotifRows = async () => { const loadNotifRows = async () => {
await notificationStore.loadRows(); let resp = await fetchInAppNotifications({
ts: Date.now(),
pageNo: notifPageNo.value,
pageSize: notifPageSize.value,
onlyUnread: notifOnlyUnread.value,
});
notifRows.value = Array.isArray(resp?.data?.list) ? resp.data.list : [];
notifTotal.value = Number(resp?.data?.total || 0);
notifPageNo.value = Number(resp?.data?.pageNo || notifPageNo.value || 1);
notifPageSize.value = Number(resp?.data?.pageSize || notifPageSize.value || 10);
const maxPage = notifTotal.value > 0 ? Math.max(1, Math.ceil(notifTotal.value / notifPageSize.value)) : 1;
if (notifPageNo.value > maxPage) {
notifPageNo.value = maxPage;
resp = await fetchInAppNotifications({
ts: Date.now(),
pageNo: notifPageNo.value,
pageSize: notifPageSize.value,
onlyUnread: notifOnlyUnread.value,
});
notifRows.value = Array.isArray(resp?.data?.list) ? resp.data.list : [];
notifTotal.value = Number(resp?.data?.total || 0);
notifPageNo.value = Number(resp?.data?.pageNo || notifPageNo.value || 1);
notifPageSize.value = Number(resp?.data?.pageSize || notifPageSize.value || 10);
}
}; };
const showNotifDialog = async () => { const showNotifDialog = async () => {
@@ -411,15 +470,41 @@ const showNotifDialog = async () => {
}; };
const handleNotifMarkRead = async (id: number) => { const handleNotifMarkRead = async (id: number) => {
await notificationStore.markRead(id); await markInAppNotificationRead(id);
await notificationStore.loadUnreadCount();
await loadNotifRows();
syncCurrentNotifDetail(id);
ElMessage.success("已标记为已读"); ElMessage.success("已标记为已读");
}; };
const handleNotifMarkAllRead = async () => { const handleNotifMarkAllRead = async () => {
const affected = await notificationStore.markAllRead(); const resp = await markAllInAppNotificationsRead();
const affected = Number(resp?.data?.affected || 0);
await notificationStore.loadUnreadCount();
if (notifOnlyUnread.value && affected > 0) {
notifPageNo.value = 1;
}
await loadNotifRows();
syncCurrentNotifDetail();
ElMessage.success(affected > 0 ? `已标记 ${affected} 条通知为已读` : "没有未读通知"); ElMessage.success(affected > 0 ? `已标记 ${affected} 条通知为已读` : "没有未读通知");
}; };
const openNotifDetail = (row: Record<string, any>) => {
currentNotifDetail.value = row;
notifDetailVisible.value = true;
};
const handleNotifPageChange = async (nextPage: number) => {
notifPageNo.value = Number(nextPage || 1);
await loadNotifRows();
};
const handleNotifPageSizeChange = async (nextPageSize: number) => {
notifPageSize.value = Number(nextPageSize || 10);
notifPageNo.value = 1;
await loadNotifRows();
};
const refreshLayoutState = async () => { const refreshLayoutState = async () => {
authStore.syncFromStorage(); authStore.syncFromStorage();
await menuStore.refreshContext(); await menuStore.refreshContext();
@@ -439,7 +524,7 @@ const logoutAll = async () => {
}); });
menuStore.reset(); menuStore.reset();
notificationStore.reset(); notificationStore.reset();
ElMessage.success("已退出全部设备"); ElMessage.success("已退出");
}; };
const handleUserCommand = async (command: string) => { const handleUserCommand = async (command: string) => {
@@ -502,6 +587,21 @@ const handleAuthStorageChanged = (event: StorageEvent) => {
window.location.reload(); window.location.reload();
}; };
watch(notifRows, () => {
if (!notifDetailVisible.value || !currentNotifDetail.value) {
return;
}
syncCurrentNotifDetail();
});
watch(notifOnlyUnread, async () => {
if (!notifDialogVisible.value) {
return;
}
notifPageNo.value = 1;
await loadNotifRows();
});
onMounted(async () => { onMounted(async () => {
window.addEventListener("auth:token-updated", handleAuthTokenUpdated as EventListener); window.addEventListener("auth:token-updated", handleAuthTokenUpdated as EventListener);
window.addEventListener("storage", handleAuthStorageChanged); window.addEventListener("storage", handleAuthStorageChanged);
@@ -788,7 +888,8 @@ onUnmounted(() => {
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 12px;
margin-bottom: 16px; margin-bottom: 10px;
margin-top: 10px;
flex-wrap: wrap; flex-wrap: wrap;
} }
@@ -800,23 +901,36 @@ onUnmounted(() => {
} }
:deep(.notif-mark-all-read-btn.el-button--primary.is-plain) { :deep(.notif-mark-all-read-btn.el-button--primary.is-plain) {
background: rgba(var(--wo-theme-rgb), 0.08) !important;
border-color: var(--wo-brand-primary) !important;
color: var(--wo-brand-primary-dark-2) !important;
}
:deep(.notif-mark-all-read-btn.el-button--primary.is-plain:hover) {
background: rgba(var(--wo-theme-rgb), 0.14) !important; background: rgba(var(--wo-theme-rgb), 0.14) !important;
color: var(--wo-brand-primary-dark-2) !important; border: 1px solid rgba(var(--wo-theme-rgb), 0.3) !important;
}
:deep(.notif-mark-read-btn.el-button.is-link) {
color: var(--wo-brand-primary-dark-2) !important; color: var(--wo-brand-primary-dark-2) !important;
font-weight: 600; font-weight: 600;
} }
:deep(.notif-mark-read-btn.el-button.is-link:hover) { :deep(.notif-mark-all-read-btn.el-button--primary.is-plain:hover:not(.is-disabled)) {
color: var(--wo-brand-primary) !important; background: rgba(var(--wo-theme-rgb), 0.22) !important;
border-color: var(--wo-brand-primary-dark-2) !important;
color: var(--wo-brand-primary-dark-2) !important;
box-shadow: 0 2px 10px rgba(var(--wo-theme-rgb), 0.18);
}
:deep(.notif-mark-read-btn.el-button--primary) {
background: var(--wo-brand-primary-dark-2) !important;
border: 1px solid var(--wo-brand-primary-dark-2) !important;
color: #fff !important;
font-weight: 600;
box-shadow: 0 2px 8px rgba(var(--wo-theme-rgb), 0.2);
}
:deep(.notif-mark-read-btn.el-button--primary:hover:not(.is-disabled)) {
background: var(--wo-brand-gradient) !important;
border-color: transparent !important;
color: #fff !important;
}
:deep(.notif-mark-all-read-btn.is-disabled),
:deep(.notif-mark-read-btn.is-disabled) {
box-shadow: none !important;
} }
.notif-read-tag { .notif-read-tag {
+588 -80
View File
@@ -2,10 +2,12 @@
<PageContainer title="审核管理"> <PageContainer title="审核管理">
<AuditQueryToolbar <AuditQueryToolbar
v-model:active-tab="activeTab" v-model:active-tab="activeTab"
v-model:review-focus="reviewFocus"
:can-batch-remind="canBatchRemind" :can-batch-remind="canBatchRemind"
:can-export="canExport" :can-export="canExport"
:can-sla-read="canSlaRead" :can-sla-read="canSlaRead"
:sla-stat="slaStat" :sla-stat="slaStat"
:review-stat="reviewStat"
@load="load" @load="load"
@batch-remind="handleBatchRemind" @batch-remind="handleBatchRemind"
@export-opinions="handleExport" @export-opinions="handleExport"
@@ -29,11 +31,23 @@
@open-audit-progress="openAuditProgress" @open-audit-progress="openAuditProgress"
@open-material="openMaterial" @open-material="openMaterial"
/> />
<div class="flex-end mt-md">
<el-pagination
:current-page="listPageNo"
:page-size="listPageSize"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
:total="listTotal"
@current-change="handleListPageChange"
@size-change="handleListPageSizeChange"
/>
</div>
<AuditMaterialDrawer <AuditMaterialDrawer
v-model:material-dialog-visible="materialDialogVisible" v-model:material-dialog-visible="materialDialogVisible"
v-model:material-module="materialModule" v-model:material-module="materialModule"
v-model:expert-review-sub-module="expertReviewSubModule" v-model:expert-review-sub-module="expertReviewSubModule"
v-model:selected-audit-expert-id="selectedAuditExpertId" v-model:selected-audit-expert-id="selectedAuditExpertId"
:resubmit-summary="resubmitSummary"
:material-audit-node="materialAuditNode" :material-audit-node="materialAuditNode"
:material-budget-view="materialBudgetView" :material-budget-view="materialBudgetView"
:format-yuan="formatYuan" :format-yuan="formatYuan"
@@ -92,7 +106,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, onMounted } from "vue"; import { computed, ref, onMounted, watch } from "vue";
import PageContainer from "../../components/PageContainer.vue"; import PageContainer from "../../components/PageContainer.vue";
import { ElMessage, ElMessageBox } from "element-plus"; import { ElMessage, ElMessageBox } from "element-plus";
import { import {
@@ -100,6 +114,8 @@ import {
approveAuditTask, approveAuditTask,
batchRemindAuditTasks, batchRemindAuditTasks,
exportAuditOpinions, exportAuditOpinions,
fetchAuditTaskDetail,
fetchAuditReviewStat,
fetchFilePresignDownload, fetchFilePresignDownload,
fetchMeetingDetail, fetchMeetingDetail,
fetchMeetings, fetchMeetings,
@@ -123,6 +139,10 @@ import MeetingDocPreviewDialog from "./meeting-page/MeetingDocPreviewDialog.vue"
const authStore = useAuthStore(); const authStore = useAuthStore();
const rows = ref<any[]>([]); const rows = ref<any[]>([]);
const activeTab = ref<"pendingMine" | "handledMine">("pendingMine"); const activeTab = ref<"pendingMine" | "handledMine">("pendingMine");
const reviewFocus = ref<"all" | "resubmit" | "unresolved" | "extraChange">("all");
const listPageNo = ref(1);
const listPageSize = ref(20);
const listTotal = ref(0);
const canApprove = computed(() => authStore.hasPermission(PERMS.audit.approve)); const canApprove = computed(() => authStore.hasPermission(PERMS.audit.approve));
const canReject = computed(() => authStore.hasPermission(PERMS.audit.reject)); const canReject = computed(() => authStore.hasPermission(PERMS.audit.reject));
const canReturn = computed(() => authStore.hasPermission(PERMS.audit.back)); const canReturn = computed(() => authStore.hasPermission(PERMS.audit.back));
@@ -142,6 +162,17 @@ const slaStat = ref({
timeout12h: 0, timeout12h: 0,
timeout24h: 0, timeout24h: 0,
}); });
const toSafeInt = (value: unknown) => Math.max(0, Number(value || 0));
const isResubmitTaskRow = (row: any) => !!row?.resubmitted || toSafeInt(row?.materialChangedCount) > 0;
const reviewStat = computed(() => {
return reviewStatState.value;
});
const reviewStatState = ref({
resubmitCount: 0,
unresolvedCount: 0,
extraChangeCount: 0,
highRiskCount: 0,
});
const materialDialogVisible = ref(false); const materialDialogVisible = ref(false);
const materialTaskId = ref<number | null>(null); const materialTaskId = ref<number | null>(null);
const materialMeetingId = ref<number | null>(null); const materialMeetingId = ref<number | null>(null);
@@ -313,6 +344,55 @@ const materialModule = ref<
const expertReviewSubModule = ref<ExpertSubModuleCode>("ONSITE_PHOTO"); const expertReviewSubModule = ref<ExpertSubModuleCode>("ONSITE_PHOTO");
const selectedAuditExpertId = ref<number | null>(null); const selectedAuditExpertId = ref<number | null>(null);
const materialItemReviewMap = ref<Record<string, { reviewResult?: string; reviewReason?: string; updatedAt?: string }>>({}); const materialItemReviewMap = ref<Record<string, { reviewResult?: string; reviewReason?: string; updatedAt?: string }>>({});
const auditTaskDetailCache = ref<Record<string, any> | null>(null);
const createEmptyResubmitSummary = () => ({
isResubmitted: false,
currentVersionNo: null as number | null,
previousVersionNo: null as number | null,
currentSubmittedAt: "",
currentSubmittedByName: "",
previousSubmittedAt: "",
previousSubmittedByName: "",
previousRemark: "",
changedCount: 0,
changedModuleCount: 0,
issueCount: 0,
resolvedCount: 0,
unresolvedCount: 0,
pendingConfirmCount: 0,
issueRelatedCount: 0,
extraChangeCount: 0,
changes: [] as any[],
issues: [] as any[],
auditTraceTimeline: [] as any[],
auditTraceVersionChain: [] as any[],
auditTraceMigration: {} as Record<string, any>,
});
const resubmitSummary = ref<{
isResubmitted?: boolean;
currentVersionNo?: number | null;
previousVersionNo?: number | null;
currentSubmittedAt?: string;
currentSubmittedByName?: string;
previousSubmittedAt?: string;
previousSubmittedByName?: string;
previousRemark?: string;
changedCount?: number;
changedModuleCount?: number;
issueCount?: number;
resolvedCount?: number;
unresolvedCount?: number;
pendingConfirmCount?: number;
changedIssueCount?: number;
unchangedIssueCount?: number;
issueRelatedCount?: number;
extraChangeCount?: number;
changes?: any[];
issues?: any[];
auditTraceTimeline?: any[];
auditTraceVersionChain?: any[];
auditTraceMigration?: Record<string, any>;
}>(createEmptyResubmitSummary());
const materialContent = ref(""); const materialContent = ref("");
const basicView = ref<any>({ const basicView = ref<any>({
chairmanExpertIds: [] as number[], chairmanExpertIds: [] as number[],
@@ -337,7 +417,7 @@ const docView = ref<any>({
signInOssKey: "", signInOssKey: "",
themePhotoName: "", themePhotoName: "",
themePhotoOssKey: "", themePhotoOssKey: "",
invitations: [], invitations: [] as Array<{ name: string; ossKey: string; itemKey: string }>,
}); });
const photoView = ref<any>({ const photoView = ref<any>({
photos: [], photos: [],
@@ -372,7 +452,7 @@ const meetingInvoiceView = ref<any>({
}>, }>,
}); });
const currentMaterialMeetingForm = ref(""); const currentMaterialMeetingForm = ref("");
const materialBudgetView = ref({ const createEmptyMaterialBudgetView = () => ({
budgetCent: 0, budgetCent: 0,
laborTotalCent: 0, laborTotalCent: 0,
invoiceTotalCent: 0, invoiceTotalCent: 0,
@@ -382,6 +462,7 @@ const materialBudgetView = ref({
overCent: 0, overCent: 0,
ready: false, ready: false,
}); });
const materialBudgetView = ref(createEmptyMaterialBudgetView());
const expertReviewRows = computed(() => { const expertReviewRows = computed(() => {
const map = new Map<number, string>(); const map = new Map<number, string>();
const collect = (expertId: unknown, expertName: unknown) => { const collect = (expertId: unknown, expertName: unknown) => {
@@ -575,6 +656,7 @@ const isHigherReviewNode = (value: unknown) => {
const node = normalizeNodeKey(value); const node = normalizeNodeKey(value);
return node === "RE_REVIEW" || node === "FINAL_REVIEW"; return node === "RE_REVIEW" || node === "FINAL_REVIEW";
}; };
const requiresStructuredRejectIssues = (value: unknown) => !isHigherReviewNode(value);
const matchesAuditNode = (node: any, currentNode: unknown) => { const matchesAuditNode = (node: any, currentNode: unknown) => {
const current = normalizeNodeKey(currentNode); const current = normalizeNodeKey(currentNode);
if (!current) { if (!current) {
@@ -606,7 +688,7 @@ const flowNodeTagType = (node: any, row: any): "success" | "warning" | "info" =>
const loadNameMaps = async () => { const loadNameMaps = async () => {
try { try {
const meetingResp = await fetchMeetings(); const meetingResp = await fetchMeetings({ pageNo: 1, pageSize: 200 });
const meetings = meetingResp?.data?.list || []; const meetings = meetingResp?.data?.list || [];
const meetingMap: Record<number, string> = {}; const meetingMap: Record<number, string> = {};
const budgetMap: Record<number, number> = {}; const budgetMap: Record<number, number> = {};
@@ -631,30 +713,46 @@ const load = async () => {
const currentUserId = getCurrentUserId(); const currentUserId = getCurrentUserId();
if (!currentUserId) { if (!currentUserId) {
rows.value = []; rows.value = [];
listTotal.value = 0;
reviewStatState.value = {
resubmitCount: 0,
unresolvedCount: 0,
extraChangeCount: 0,
highRiskCount: 0,
};
slaStat.value = {
pendingTotal: 0,
timeout4h: 0,
timeout12h: 0,
timeout24h: 0,
};
return; return;
} }
if (activeTab.value === "pendingMine") {
const resp = await fetchAuditTasks({ const resp = await fetchAuditTasks({
mine: true, mine: true,
scope: "PENDING_MINE", scope: activeTab.value === "pendingMine" ? "PENDING_MINE" : "HANDLED_MINE",
pageNo: 1, pageNo: listPageNo.value,
pageSize: 100, pageSize: listPageSize.value,
sortBy: "lastActionAt", reviewFocus: reviewFocus.value,
sortBy: "submittedAt",
order: "desc", order: "desc",
}); });
rows.value = resp?.data?.list || []; rows.value = Array.isArray(resp?.data?.list) ? resp.data.list : [];
} else { listTotal.value = Number(resp?.data?.total || 0);
const resp = await fetchAuditTasks({ listPageNo.value = Number(resp?.data?.pageNo || listPageNo.value || 1);
listPageSize.value = Number(resp?.data?.pageSize || listPageSize.value || 20);
const reviewStatResp = await fetchAuditReviewStat({
mine: true, mine: true,
scope: "HANDLED_MINE", scope: activeTab.value === "pendingMine" ? "PENDING_MINE" : "HANDLED_MINE",
pageNo: 1, reviewFocus: reviewFocus.value,
pageSize: 100,
sortBy: "lastActionAt",
order: "desc",
}); });
rows.value = resp?.data?.list || []; reviewStatState.value = {
} resubmitCount: Number(reviewStatResp?.data?.resubmitCount || 0),
unresolvedCount: Number(reviewStatResp?.data?.unresolvedCount || 0),
extraChangeCount: Number(reviewStatResp?.data?.extraChangeCount || 0),
highRiskCount: Number(reviewStatResp?.data?.highRiskCount || 0),
};
if (canSlaRead.value && activeTab.value === "pendingMine") { if (canSlaRead.value && activeTab.value === "pendingMine") {
const statResp = await fetchAuditSlaStat(); const statResp = await fetchAuditSlaStat();
@@ -664,14 +762,95 @@ const load = async () => {
timeout12h: Number(statResp?.data?.timeout12h || 0), timeout12h: Number(statResp?.data?.timeout12h || 0),
timeout24h: Number(statResp?.data?.timeout24h || 0), timeout24h: Number(statResp?.data?.timeout24h || 0),
}; };
} else {
slaStat.value = {
pendingTotal: 0,
timeout4h: 0,
timeout12h: 0,
timeout24h: 0,
};
} }
}; };
const handleTabChange = () => { const handleTabChange = () => {
listPageNo.value = 1;
load(); load();
}; };
const handleListPageChange = async (pageNo: number) => {
listPageNo.value = Number(pageNo || 1);
await load();
};
const handleListPageSizeChange = async (pageSize: number) => {
listPageSize.value = Number(pageSize || 20);
listPageNo.value = 1;
await load();
};
type AuditRejectIssue = {
moduleCode: BackendMaterialModuleCode;
targetPath: string;
targetLabel: string;
reason: string;
};
const REJECT_ISSUE_REQUIRED_MESSAGE = "请先逐项标记至少 1 条不通过问题,再提交整单驳回";
const collectRejectedIssues = async (taskId: number): Promise<AuditRejectIssue[]> => {
const moduleCodes: BackendMaterialModuleCode[] = ["BASIC_INFO", "WRITE_OFF_DOCS", "EXPERT_PROFILE", "EXPERT_LIST", "MEETING_INVOICE"];
let detailData = Number(materialTaskId.value || 0) === taskId ? auditTaskDetailCache.value : null;
if (!detailData) {
try {
const resp = await fetchAuditTaskDetail(taskId);
detailData = resp?.data || null;
if (Number(materialTaskId.value || 0) === taskId) {
auditTaskDetailCache.value = detailData;
}
} catch (_e) {
detailData = null;
}
}
if (detailData?.modules) {
const issues: AuditRejectIssue[] = [];
moduleCodes.forEach((moduleCode) => {
const reviews = Array.isArray(detailData?.modules?.[moduleCode]?.itemReviews)
? detailData.modules[moduleCode].itemReviews
: [];
reviews.forEach((review: any) => {
if (String(review?.reviewResult || "").toUpperCase() !== "REJECTED") {
return;
}
const targetPath = String(review?.itemKey || "").trim();
const targetLabel = String(review?.itemLabel || review?.itemKey || "未知条目").trim();
const reason = String(review?.reviewReason || "").trim();
if (!targetPath || !targetLabel || !reason) {
return;
}
issues.push({
moduleCode,
targetPath,
targetLabel,
reason,
});
});
});
return issues;
}
return [];
};
const collectRejectedIssuesOrWarn = async (taskId: number, node?: unknown) => {
const issues = await collectRejectedIssues(taskId);
if (issues.length <= 0 && requiresStructuredRejectIssues(node)) {
ElMessage.warning(REJECT_ISSUE_REQUIRED_MESSAGE);
return null;
}
return issues;
};
const handleAction = async (action: "approve" | "reject" | "return", taskId: number) => { const handleAction = async (action: "approve" | "reject" | "return", taskId: number) => {
const currentTask = rows.value.find((item: any) => Number(item?.id || 0) === taskId);
const dialog = await ElMessageBox.prompt("请输入审核意见", "审核操作", { const dialog = await ElMessageBox.prompt("请输入审核意见", "审核操作", {
confirmButtonText: "确定", confirmButtonText: "确定",
cancelButtonText: "取消", cancelButtonText: "取消",
@@ -687,7 +866,11 @@ const handleAction = async (action: "approve" | "reject" | "return", taskId: num
await approveAuditTask(taskId, payload); await approveAuditTask(taskId, payload);
ElMessage.success("审核通过"); ElMessage.success("审核通过");
} else if (action === "reject") { } else if (action === "reject") {
await rejectAuditTask(taskId, payload); const issues = await collectRejectedIssuesOrWarn(taskId, currentTask?.node);
if (!issues) {
return;
}
await rejectAuditTask(taskId, { ...payload, issues });
ElMessage.success("审核拒绝"); ElMessage.success("审核拒绝");
} else { } else {
await returnAuditTask(taskId, payload); await returnAuditTask(taskId, payload);
@@ -706,6 +889,7 @@ const handleSubmitAudit = async (taskId: number) => {
const higherReview = isHigherReviewNode(currentTask?.node); const higherReview = isHigherReviewNode(currentTask?.node);
if (higherReview) { if (higherReview) {
let action: "approve" | "reject"; let action: "approve" | "reject";
let rejectedIssues: AuditRejectIssue[] | null = null;
try { try {
await ElMessageBox.confirm( await ElMessageBox.confirm(
"请选择本次审核结果", "请选择本次审核结果",
@@ -734,6 +918,12 @@ const handleSubmitAudit = async (taskId: number) => {
ElMessage.warning("\u60a8\u6ca1\u6709\u5ba1\u6838\u62d2\u7edd\u6743\u9650"); ElMessage.warning("\u60a8\u6ca1\u6709\u5ba1\u6838\u62d2\u7edd\u6743\u9650");
return; return;
} }
if (action === "reject") {
rejectedIssues = await collectRejectedIssuesOrWarn(taskId, currentTask?.node);
if (!rejectedIssues) {
return;
}
}
const opinionDialog = await ElMessageBox.prompt( const opinionDialog = await ElMessageBox.prompt(
action === "approve" ? "\u8bf7\u8f93\u5165\u901a\u8fc7\u610f\u89c1" : "\u8bf7\u8f93\u5165\u62d2\u7edd\u539f\u56e0", action === "approve" ? "\u8bf7\u8f93\u5165\u901a\u8fc7\u610f\u89c1" : "\u8bf7\u8f93\u5165\u62d2\u7edd\u539f\u56e0",
@@ -759,33 +949,18 @@ const handleSubmitAudit = async (taskId: number) => {
await approveAuditTask(taskId, payload); await approveAuditTask(taskId, payload);
ElMessage.success("\u5ba1\u6838\u901a\u8fc7"); ElMessage.success("\u5ba1\u6838\u901a\u8fc7");
} else { } else {
await rejectAuditTask(taskId, payload); const issues = rejectedIssues || await collectRejectedIssues(taskId);
await rejectAuditTask(taskId, { ...payload, issues });
ElMessage.success("\u5ba1\u6838\u62d2\u7edd"); ElMessage.success("\u5ba1\u6838\u62d2\u7edd");
} }
await load(); await load();
return; return;
} }
const rejectedIssues = await collectRejectedIssues(taskId);
const moduleCodes: BackendMaterialModuleCode[] = ["BASIC_INFO", "WRITE_OFF_DOCS", "EXPERT_PROFILE", "EXPERT_LIST", "MEETING_INVOICE"]; const rejectedItems = rejectedIssues.map((item) => ({
const results = await Promise.allSettled( itemLabel: item.targetLabel,
moduleCodes.map((code) => readAuditTaskMaterial(taskId, code)), reason: item.reason,
); }));
const rejectedItems: Array<{ itemLabel: string; reason: string }> = [];
results.forEach((result) => {
if (result.status !== "fulfilled") {
return;
}
const reviews = Array.isArray(result.value?.data?.itemReviews) ? result.value.data.itemReviews : [];
reviews.forEach((review: any) => {
if (String(review?.reviewResult || "").toUpperCase() === "REJECTED") {
rejectedItems.push({
itemLabel: String(review?.itemLabel || review?.itemKey || "\u672a\u77e5\u6761\u76ee"),
reason: String(review?.reviewReason || ""),
});
}
});
});
let htmlMessage: string; let htmlMessage: string;
let dialogTitle: string; let dialogTitle: string;
@@ -844,7 +1019,11 @@ const handleSubmitAudit = async (taskId: number) => {
await approveAuditTask(taskId, payload); await approveAuditTask(taskId, payload);
ElMessage.success("\u5ba1\u6838\u901a\u8fc7"); ElMessage.success("\u5ba1\u6838\u901a\u8fc7");
} else { } else {
await rejectAuditTask(taskId, payload); if (rejectedIssues.length <= 0) {
ElMessage.warning("请先逐项标记至少 1 条不通过问题,再提交整单驳回");
return;
}
await rejectAuditTask(taskId, { ...payload, issues: rejectedIssues });
ElMessage.success("\u5ba1\u6838\u62d2\u7edd"); ElMessage.success("\u5ba1\u6838\u62d2\u7edd");
} }
await load(); await load();
@@ -909,7 +1088,7 @@ const extractExpertBudgetUsage = (expertContentJson: string) => {
const parsed = safeParse(expertContentJson || "{}"); const parsed = safeParse(expertContentJson || "{}");
const laborRows = Array.isArray(parsed?.laborProtocol?.details) ? parsed.laborProtocol.details : []; const laborRows = Array.isArray(parsed?.laborProtocol?.details) ? parsed.laborProtocol.details : [];
const invoiceRows = Array.isArray(parsed?.invoiceDetail?.invoices) ? parsed.invoiceDetail.invoices : []; const invoiceRows = Array.isArray(parsed?.invoiceDetail?.invoices) ? parsed.invoiceDetail.invoices : [];
const laborTotalCent = laborRows.reduce((sum: number, row: any) => sum + Math.max(0, Number(row?.amountCent || 0)), 0); const laborTotalCent = laborRows.reduce((sum: number, row: any) => sum + Math.max(0, Number(row?.preTaxAmountCent ?? row?.amountCent ?? 0)), 0);
const invoiceTotalCent = invoiceRows.reduce((sum: number, row: any) => { const invoiceTotalCent = invoiceRows.reduce((sum: number, row: any) => {
const amount = Number(row?.invoiceAmountCent ?? row?.amountCent ?? 0); const amount = Number(row?.invoiceAmountCent ?? row?.amountCent ?? 0);
return sum + Math.max(0, amount); return sum + Math.max(0, amount);
@@ -954,16 +1133,21 @@ const refreshMaterialBudgetView = async () => {
}; };
return; return;
} }
const cachedModules = auditTaskDetailCache.value?.modules || {};
const cachedExpertContentJson = String(cachedModules?.EXPERT_LIST?.material?.contentJson || "");
const cachedMeetingInvoiceContentJson = String(cachedModules?.MEETING_INVOICE?.material?.contentJson || "");
const cachedSnapshotMeeting = auditTaskDetailCache.value?.snapshot?.meeting || {};
const hasCachedMeetingMeta = Object.keys(cachedSnapshotMeeting || {}).length > 0;
const [expertResp, meetingInvoiceResp, meetingDetailResp] = await Promise.allSettled([ const [expertResp, meetingInvoiceResp, meetingDetailResp] = await Promise.allSettled([
readAuditTaskMaterial(taskId, "EXPERT_LIST"), cachedExpertContentJson ? Promise.resolve({ data: { material: { contentJson: cachedExpertContentJson } } }) : readAuditTaskMaterial(taskId, "EXPERT_LIST"),
readAuditTaskMaterial(taskId, "MEETING_INVOICE"), cachedMeetingInvoiceContentJson ? Promise.resolve({ data: { material: { contentJson: cachedMeetingInvoiceContentJson } } }) : readAuditTaskMaterial(taskId, "MEETING_INVOICE"),
fetchMeetingDetail(meetingId), hasCachedMeetingMeta ? Promise.resolve({ data: cachedSnapshotMeeting }) : fetchMeetingDetail(meetingId),
]); ]);
const expertContentJson = expertResp.status === "fulfilled" const expertContentJson = expertResp.status === "fulfilled"
? String(expertResp.value?.data?.material?.contentJson || "") ? String((expertResp.value as any)?.data?.material?.contentJson || "")
: ""; : "";
const meetingInvoiceContentJson = meetingInvoiceResp.status === "fulfilled" const meetingInvoiceContentJson = meetingInvoiceResp.status === "fulfilled"
? String(meetingInvoiceResp.value?.data?.material?.contentJson || "") ? String((meetingInvoiceResp.value as any)?.data?.material?.contentJson || "")
: ""; : "";
const meetingForm = meetingDetailResp.status === "fulfilled" const meetingForm = meetingDetailResp.status === "fulfilled"
? String(meetingDetailResp.value?.data?.meetingForm || "").trim() ? String(meetingDetailResp.value?.data?.meetingForm || "").trim()
@@ -972,6 +1156,12 @@ const refreshMaterialBudgetView = async () => {
const budgetCent = meetingDetailResp.status === "fulfilled" const budgetCent = meetingDetailResp.status === "fulfilled"
? Math.max(0, Number(meetingDetailResp.value?.data?.budgetCent || 0)) ? Math.max(0, Number(meetingDetailResp.value?.data?.budgetCent || 0))
: Math.max(0, Number(meetingBudgetMap.value[meetingId] || 0)); : Math.max(0, Number(meetingBudgetMap.value[meetingId] || 0));
if (hasCachedMeetingMeta) {
meetingBudgetMap.value = {
...meetingBudgetMap.value,
[meetingId]: budgetCent,
};
}
const { laborTotalCent, invoiceTotalCent } = extractExpertBudgetUsage(expertContentJson); const { laborTotalCent, invoiceTotalCent } = extractExpertBudgetUsage(expertContentJson);
const meetingInvoiceTotalCent = extractMeetingInvoiceBudgetUsage(meetingInvoiceContentJson, meetingForm); const meetingInvoiceTotalCent = extractMeetingInvoiceBudgetUsage(meetingInvoiceContentJson, meetingForm);
const usedTotalCent = laborTotalCent + invoiceTotalCent + meetingInvoiceTotalCent; const usedTotalCent = laborTotalCent + invoiceTotalCent + meetingInvoiceTotalCent;
@@ -989,15 +1179,58 @@ const refreshMaterialBudgetView = async () => {
}; };
}; };
const refreshAuditTaskDetailCache = async () => {
if (!materialTaskId.value) {
auditTaskDetailCache.value = null;
resubmitSummary.value = createEmptyResubmitSummary();
return;
}
try {
console.info("[audit-review] fetch audit task detail start", {
taskId: materialTaskId.value,
meetingId: materialMeetingId.value,
materialAuditNode: materialAuditNode.value,
});
const detailResp = await fetchAuditTaskDetail(materialTaskId.value);
auditTaskDetailCache.value = detailResp?.data || null;
resubmitSummary.value = resolveTaskResubmitSummary();
console.info("[audit-review] fetch audit task detail success", {
taskId: materialTaskId.value,
meetingId: materialMeetingId.value,
submissionVersion: auditTaskDetailCache.value?.submissionVersion,
issueCount: Array.isArray(auditTaskDetailCache.value?.issues) ? auditTaskDetailCache.value.issues.length : 0,
changeSetId: auditTaskDetailCache.value?.changeSet?.id,
changeSetItemCount: Array.isArray(auditTaskDetailCache.value?.changeSet?.items) ? auditTaskDetailCache.value.changeSet.items.length : 0,
moduleSummaryCount: Array.isArray(auditTaskDetailCache.value?.moduleSummaries) ? auditTaskDetailCache.value.moduleSummaries.length : 0,
resubmitSummary: resubmitSummary.value,
});
} catch (_e) {
auditTaskDetailCache.value = null;
resubmitSummary.value = createEmptyResubmitSummary();
console.info("[audit-review] fetch audit task detail failed", {
taskId: materialTaskId.value,
meetingId: materialMeetingId.value,
});
}
};
const openMaterial = async (row: any) => { const openMaterial = async (row: any) => {
materialTaskId.value = Number(row?.id || 0) || null; materialTaskId.value = Number(row?.id || 0) || null;
materialMeetingId.value = Number(row?.meetingId || 0) || null; materialMeetingId.value = Number(row?.meetingId || 0) || null;
currentMaterialMeetingForm.value = String(row?.meetingForm || "").trim(); currentMaterialMeetingForm.value = String(row?.meetingForm || "").trim();
materialAuditNode.value = String(row?.node || "").trim(); materialAuditNode.value = String(row?.node || "").trim();
auditTaskDetailCache.value = null;
materialModule.value = "BASIC_INFO"; materialModule.value = "BASIC_INFO";
expertReviewSubModule.value = "ONSITE_PHOTO"; expertReviewSubModule.value = "ONSITE_PHOTO";
selectedAuditExpertId.value = null; selectedAuditExpertId.value = null;
materialDialogVisible.value = true; materialDialogVisible.value = true;
console.info("[audit-review] open material drawer", {
taskId: materialTaskId.value,
meetingId: materialMeetingId.value,
materialAuditNode: materialAuditNode.value,
row,
});
await refreshAuditTaskDetailCache();
await refreshMaterialBudgetView(); await refreshMaterialBudgetView();
await loadMaterial(); await loadMaterial();
}; };
@@ -1035,15 +1268,240 @@ const resetMaterialViews = () => {
expertProfileView.value = { fileName: "", ossKey: "" }; expertProfileView.value = { fileName: "", ossKey: "" };
meetingInvoiceView.value = { sections: [] }; meetingInvoiceView.value = { sections: [] };
selectedAuditExpertId.value = null; selectedAuditExpertId.value = null;
materialBudgetView.value = { materialBudgetView.value = createEmptyMaterialBudgetView();
budgetCent: 0, };
laborTotalCent: 0,
invoiceTotalCent: 0, const hasPersistedTaskChangeSet = () => {
meetingInvoiceTotalCent: 0, const changeSet = auditTaskDetailCache.value?.changeSet;
usedTotalCent: 0, return !!changeSet && (Number(changeSet?.id || 0) > 0 || Array.isArray(changeSet?.items));
remainCent: 0, };
overCent: 0,
ready: false, const resolveChangeSetModuleCode = (row: Record<string, any>) =>
String(row?.moduleCode || row?.module_code || "").trim().toUpperCase();
const formatChangeSummaryValue = (value: unknown) => {
if (value == null) {
return "";
}
if (typeof value === "string") {
return value;
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
try {
return JSON.stringify(value);
} catch (_e) {
return String(value);
}
};
const isIssueRelatedResubmitChange = (row: Record<string, any>) => {
if (row?.relatedIssue === true) {
return true;
}
if (row?.relatedIssue === false) {
return false;
}
const relatedIssueId = Number(row?.relatedIssueId || row?.related_issue_id || 0);
if (relatedIssueId > 0) {
return true;
}
const rawIsExtraChange = row?.isExtraChange ?? row?.is_extra_change;
return !(rawIsExtraChange === true || rawIsExtraChange === 1 || rawIsExtraChange === "1");
};
const normalizeResubmitChangeRow = (row: Record<string, any>, fallbackModuleCode = "") => {
const relatedIssueId = Number(row?.relatedIssueId || row?.related_issue_id || 0);
const relatedIssue = isIssueRelatedResubmitChange(row);
return {
...row,
moduleCode: String(row?.moduleCode || row?.module_code || fallbackModuleCode || "").trim().toUpperCase(),
relatedIssueId: relatedIssueId > 0 ? relatedIssueId : undefined,
relatedIssue,
isExtraChange: !relatedIssue,
};
};
const resolvePersistedModuleChanges = (moduleCode: BackendMaterialModuleCode) => {
if (!hasPersistedTaskChangeSet()) {
return null;
}
const items = Array.isArray(auditTaskDetailCache.value?.changeSet?.items)
? auditTaskDetailCache.value.changeSet.items
: [];
return items
.filter((row: Record<string, any>) => resolveChangeSetModuleCode(row) === moduleCode)
.map((row: Record<string, any>) => {
const normalized = normalizeResubmitChangeRow(row, moduleCode);
return {
id: Number(row?.id || 0),
itemKey: String(row?.targetPath || row?.target_path || "").trim(),
matchKeys: Array.isArray(row?.matchKeys) ? row.matchKeys : undefined,
itemLabel: String(
row?.targetLabel
|| row?.target_label
|| row?.targetPath
|| row?.target_path
|| "",
).trim(),
previousValue: formatChangeSummaryValue(row?.oldValue ?? row?.old_value),
currentValue: formatChangeSummaryValue(row?.newValue ?? row?.new_value),
changeType: String(row?.changeType || row?.change_type || "").trim(),
targetKind: String(row?.targetKind || row?.target_kind || "").trim(),
targetRowKey: String(row?.targetRowKey || row?.target_row_key || "").trim(),
attachmentIdentity: String(row?.attachmentIdentity || row?.attachment_identity || "").trim(),
attachmentHash: String(row?.attachmentHash || row?.attachment_hash || "").trim(),
moduleCode: normalized.moduleCode,
relatedIssueId: normalized.relatedIssueId,
relatedIssue: normalized.relatedIssue,
isExtraChange: normalized.isExtraChange,
createdAt: String(row?.createdAt || row?.created_at || "").trim(),
};
});
};
const resolvePersistedTaskChanges = () => {
if (!hasPersistedTaskChangeSet()) {
return null;
}
const items = Array.isArray(auditTaskDetailCache.value?.changeSet?.items)
? auditTaskDetailCache.value?.changeSet?.items
: [];
if (items.length <= 0) {
return null;
}
return items.map((row: Record<string, any>) => {
const normalized = normalizeResubmitChangeRow(row);
return {
id: Number(row?.id || 0),
itemKey: String(row?.targetPath || row?.target_path || "").trim(),
matchKeys: Array.isArray(row?.matchKeys) ? row.matchKeys : undefined,
itemLabel: String(
row?.targetLabel
|| row?.target_label
|| row?.targetPath
|| row?.target_path
|| "",
).trim(),
previousValue: formatChangeSummaryValue(row?.oldValue ?? row?.old_value),
currentValue: formatChangeSummaryValue(row?.newValue ?? row?.new_value),
changeType: String(row?.changeType || row?.change_type || "").trim(),
targetKind: String(row?.targetKind || row?.target_kind || "").trim(),
targetRowKey: String(row?.targetRowKey || row?.target_row_key || "").trim(),
attachmentIdentity: String(row?.attachmentIdentity || row?.attachment_identity || "").trim(),
attachmentHash: String(row?.attachmentHash || row?.attachment_hash || "").trim(),
moduleCode: normalized.moduleCode,
relatedIssueId: normalized.relatedIssueId,
relatedIssue: normalized.relatedIssue,
isExtraChange: normalized.isExtraChange,
createdAt: String(row?.createdAt || row?.created_at || "").trim(),
};
});
};
const countIssueSummaryByStatus = (issues: any[]) => {
let resolvedCount = 0;
let unresolvedCount = 0;
let pendingConfirmCount = 0;
issues.forEach((issue) => {
const status = String(issue?.status || "").trim().toUpperCase();
if (status === "RESOLVED") {
resolvedCount += 1;
return;
}
if (status === "PENDING_CONFIRM") {
pendingConfirmCount += 1;
}
unresolvedCount += 1;
});
return { resolvedCount, pendingConfirmCount, unresolvedCount };
};
const resolveTaskResubmitSummary = () => {
const empty = createEmptyResubmitSummary();
const detail = auditTaskDetailCache.value;
if (!detail) {
return empty;
}
const moduleSummaries = Array.isArray(detail?.moduleSummaries) ? detail.moduleSummaries : [];
const moduleSummaryRows = moduleSummaries.map((row: any) => ({
moduleCode: String(row?.moduleCode || "").trim().toUpperCase(),
summary: row?.resubmitSummary || {},
}));
const primarySummary = moduleSummaryRows
.map((row) => row.summary)
.find((row: any) =>
!!row?.isResubmitted
|| Number(row?.changedCount || 0) > 0
|| Number(row?.issueCount || 0) > 0,
) || {};
const moduleSummaryChanges = moduleSummaryRows.reduce((list: any[], row) => {
const changes = Array.isArray(row.summary?.changes) ? row.summary.changes : [];
changes.forEach((change: Record<string, any>) => {
list.push(normalizeResubmitChangeRow(change, row.moduleCode));
});
return list;
}, []);
const issues = moduleSummaryRows.reduce((list: any[], row) => {
const summaryIssues = Array.isArray(row.summary?.issues) ? row.summary.issues : [];
summaryIssues.forEach((issue: Record<string, any>) => {
list.push({
...issue,
moduleCode: row.moduleCode,
});
});
return list;
}, []);
const persistedChanges = resolvePersistedTaskChanges();
const summaryChanges = persistedChanges && persistedChanges.length > 0
? persistedChanges
: moduleSummaryChanges;
const changedModuleCodes = new Set(
summaryChanges
.map((row: any) => String(row?.moduleCode || "").trim())
.filter((code: string) => !!code),
);
const issueStatusSummary = countIssueSummaryByStatus(issues);
const issueRelatedCount = summaryChanges.length > 0
? summaryChanges.filter((row: any) => !!row?.relatedIssue).length
: moduleSummaryRows.reduce((count, row) => count + toSafeInt(row.summary?.issueRelatedCount), 0);
const extraChangeCount = summaryChanges.length > 0
? summaryChanges.filter((row: any) => !row?.relatedIssue).length
: moduleSummaryRows.reduce((count, row) => count + toSafeInt(row.summary?.extraChangeCount), 0);
const changedModuleCount = changedModuleCodes.size > 0
? changedModuleCodes.size
: moduleSummaryRows.filter((row) => toSafeInt(row.summary?.changedCount) > 0).length;
const submissionVersion = detail?.submissionVersion || {};
const auditTrace = detail?.auditTrace || {};
return {
isResubmitted: moduleSummaryRows.some((row) => !!row.summary?.isResubmitted) || summaryChanges.length > 0 || issues.length > 0,
currentVersionNo: submissionVersion?.versionNo ?? primarySummary?.currentVersionNo ?? null,
previousVersionNo: primarySummary?.previousVersionNo ?? null,
currentSubmittedAt: String(submissionVersion?.createdAt || primarySummary?.currentSubmittedAt || ""),
currentSubmittedByName: String(submissionVersion?.createdByName || primarySummary?.currentSubmittedByName || ""),
previousSubmittedAt: String(primarySummary?.previousSubmittedAt || ""),
previousSubmittedByName: String(primarySummary?.previousSubmittedByName || ""),
previousRemark: String(
submissionVersion?.remark
|| primarySummary?.previousRemark
|| "",
),
changedCount: summaryChanges.length,
changedModuleCount,
issueCount: issues.length,
resolvedCount: issueStatusSummary.resolvedCount,
unresolvedCount: issueStatusSummary.unresolvedCount,
pendingConfirmCount: issueStatusSummary.pendingConfirmCount,
changedIssueCount: moduleSummaryRows.reduce((count, row) => count + toSafeInt(row.summary?.changedIssueCount), 0),
unchangedIssueCount: moduleSummaryRows.reduce((count, row) => count + toSafeInt(row.summary?.unchangedIssueCount), 0),
issueRelatedCount,
extraChangeCount,
changes: summaryChanges,
issues,
auditTraceTimeline: Array.isArray(auditTrace?.timeline) ? auditTrace.timeline : [],
auditTraceVersionChain: Array.isArray(auditTrace?.versionChain) ? auditTrace.versionChain : [],
auditTraceMigration: auditTrace?.migration || {},
}; };
}; };
@@ -1103,7 +1561,14 @@ const toReviewTagType = (itemKey: string | string[]): "success" | "danger" | "in
} }
return "info"; return "info";
}; };
const buildPhotoItemKey = (row: any, idx: number) => `photo:${row?.ossKey || row?.originIndex || idx + 1}`; const sanitizeStableItemKeyToken = (value: unknown) =>
String(value ?? "")
.trim()
.replace(/\\/g, "/")
.replace(/[^0-9A-Za-z._:/-]/g, "_");
const buildPhotoItemKey = (row: any, idx: number) => `photo:${sanitizeStableItemKeyToken(row?.ossKey) || row?.originIndex || idx + 1}`;
const buildAgendaItemKey = (row: any, idx: number) => `agenda:${sanitizeStableItemKeyToken(row?.ossKey) || idx + 1}`;
const buildInvitationItemKey = (row: any, idx: number) => `invitation:${sanitizeStableItemKeyToken(row?.ossKey) || idx + 1}`;
type AuditLaborRoleKey = "chairman" | "speaker" | "host" | "discussionGuest"; type AuditLaborRoleKey = "chairman" | "speaker" | "host" | "discussionGuest";
@@ -1124,23 +1589,14 @@ const normalizeAuditLaborRoleKey = (raw: unknown): AuditLaborRoleKey | null => {
/** 与会议端一致:有 role 时为 labor:expertId:role,否则 labor:expertId(兼容旧数据) */ /** 与会议端一致:有 role 时为 labor:expertId:role,否则 labor:expertId(兼容旧数据) */
const buildLaborItemKey = (row: any, idx: number) => { const buildLaborItemKey = (row: any, idx: number) => {
const id = Number(row?.expertId || 0) || idx + 1; const id = Number(row?.expertId || 0) || idx + 1;
const role = normalizeAuditLaborRoleKey(row?.role);
if (role) {
return `labor:${id}:${role}`;
}
return `labor:${id}`; return `labor:${id}`;
}; };
/** 审核状态解析:优先带角色的 key,回退到仅 expertId(历史审核记录) */ /** 审核状态解析:优先带角色的 key,回退到仅 expertId(历史审核记录) */
const buildLaborReviewItemKeys = (row: any, idx: number): string | string[] => { const buildLaborReviewItemKeys = (row: any, idx: number): string | string[] => {
const id = Number(row?.expertId || 0) || idx + 1; return buildLaborItemKey(row, idx);
const role = normalizeAuditLaborRoleKey(row?.role);
if (role) {
return [`labor:${id}:${role}`, `labor:${id}`];
}
return `labor:${id}`;
}; };
const buildInvoiceItemKey = (row: any, idx: number) => `invoice:${row?.invoiceNo || idx + 1}`; const buildInvoiceItemKey = (row: any, idx: number) => `invoice:${sanitizeStableItemKeyToken(row?.invoiceNo) || idx + 1}`;
const buildMeetingInvoiceFieldItemKey = (sectionCode: MeetingInvoiceSectionCode, fieldKey: MeetingInvoiceUploadFieldKey) => const buildMeetingInvoiceFieldItemKey = (sectionCode: MeetingInvoiceSectionCode, fieldKey: MeetingInvoiceUploadFieldKey) =>
`meeting_invoice:${sectionCode}:${fieldKey}`; `meeting_invoice:${sectionCode}:${fieldKey}`;
const buildMeetingInvoiceAmountItemKey = (sectionCode: MeetingInvoiceSectionCode) => `meeting_invoice:${sectionCode}:amount`; const buildMeetingInvoiceAmountItemKey = (sectionCode: MeetingInvoiceSectionCode) => `meeting_invoice:${sectionCode}:amount`;
@@ -1385,6 +1841,7 @@ const handleApproveCurrentModule = async () => {
} else { } else {
ElMessage.success(`本模块审核通过(${savedCount}/${itemCount}`); ElMessage.success(`本模块审核通过(${savedCount}/${itemCount}`);
} }
await refreshAuditTaskDetailCache();
await loadMaterial(); await loadMaterial();
} finally { } finally {
approvingCurrentModule.value = false; approvingCurrentModule.value = false;
@@ -1413,6 +1870,7 @@ const handleRejectMaterialItem = async (itemKey: string, itemLabel: string) => {
reason: dialog.value || "资料条目不符合要求", reason: dialog.value || "资料条目不符合要求",
}); });
ElMessage.success("已记录条目不通过"); ElMessage.success("已记录条目不通过");
await refreshAuditTaskDetailCache();
await loadMaterial(); await loadMaterial();
}; };
@@ -1422,15 +1880,32 @@ const loadMaterial = async () => {
} }
const backendModuleCode = resolveBackendMaterialModuleCode(materialModule.value); const backendModuleCode = resolveBackendMaterialModuleCode(materialModule.value);
try { try {
const resp = await readAuditTaskMaterial(materialTaskId.value, backendModuleCode); const cachedModules = auditTaskDetailCache.value?.modules || {};
let basicInfoResp: Awaited<ReturnType<typeof readAuditTaskMaterial>> | null = null; let resp: any = null;
if (cachedModules?.[backendModuleCode]) {
resp = { data: cachedModules[backendModuleCode] };
} else {
resp = await readAuditTaskMaterial(materialTaskId.value, backendModuleCode);
}
let basicInfoResp: any = null;
if (materialModule.value === "EXPERT_LIST") { if (materialModule.value === "EXPERT_LIST") {
if (cachedModules?.BASIC_INFO) {
basicInfoResp = { data: cachedModules.BASIC_INFO };
} else {
try { try {
basicInfoResp = await readAuditTaskMaterial(materialTaskId.value, "BASIC_INFO"); basicInfoResp = await readAuditTaskMaterial(materialTaskId.value, "BASIC_INFO");
} catch (_e) { } catch (_e) {
basicInfoResp = null; basicInfoResp = null;
} }
} }
}
console.info("[audit-review] load task material start", {
taskId: materialTaskId.value,
meetingId: materialMeetingId.value,
materialModule: materialModule.value,
backendModuleCode,
hasCachedModule: !!cachedModules?.[backendModuleCode],
});
const raw = resp?.data?.material?.contentJson || "{}"; const raw = resp?.data?.material?.contentJson || "{}";
const reviewRows = Array.isArray(resp?.data?.itemReviews) ? resp.data.itemReviews : []; const reviewRows = Array.isArray(resp?.data?.itemReviews) ? resp.data.itemReviews : [];
const reviewMap: Record<string, { reviewResult?: string; reviewReason?: string; updatedAt?: string }> = {}; const reviewMap: Record<string, { reviewResult?: string; reviewReason?: string; updatedAt?: string }> = {};
@@ -1444,12 +1919,32 @@ const loadMaterial = async () => {
reviewReason: String(row?.reviewReason || ""), reviewReason: String(row?.reviewReason || ""),
updatedAt: String(row?.updatedAt || ""), updatedAt: String(row?.updatedAt || ""),
}; };
if (key.startsWith("agenda:")) {
if (!reviewMap.agenda) {
reviewMap.agenda = reviewMap[key];
}
}
if (key.startsWith("invitation:")) {
if (!reviewMap.invitation) {
reviewMap.invitation = reviewMap[key];
}
}
}); });
materialItemReviewMap.value = reviewMap;
materialContent.value = raw;
const parsed = safeParse(raw);
resetMaterialViews(); resetMaterialViews();
materialItemReviewMap.value = reviewMap;
fileUrlMap.value = {}; fileUrlMap.value = {};
materialContent.value = raw;
console.info("[audit-review] load task material success", {
taskId: materialTaskId.value,
meetingId: materialMeetingId.value,
materialModule: materialModule.value,
backendModuleCode,
contentLength: String(raw || "").length,
itemReviewCount: reviewRows.length,
summary: resp?.data?.resubmitSummary,
taskIssueCount: Array.isArray(resp?.data?.issues) ? resp.data.issues.length : 0,
});
const parsed = safeParse(raw);
if (materialModule.value === "BASIC_INFO") { if (materialModule.value === "BASIC_INFO") {
const bio = resp?.data?.basicInfoExperts; const bio = resp?.data?.basicInfoExperts;
basicView.value = { basicView.value = {
@@ -1477,7 +1972,7 @@ const loadMaterial = async () => {
.map((x: any, i: number) => ({ .map((x: any, i: number) => ({
name: String(x?.name || "").trim(), name: String(x?.name || "").trim(),
ossKey: String(x?.ossKey || "").trim(), ossKey: String(x?.ossKey || "").trim(),
itemKey: `agenda:${i + 1}`, itemKey: buildAgendaItemKey(x, i),
})) }))
.filter((x: { ossKey: string }) => !!x.ossKey); .filter((x: { ossKey: string }) => !!x.ossKey);
} else if (agendaRaw && typeof agendaRaw === "object" && String((agendaRaw as { ossKey?: string }).ossKey || "").trim()) { } else if (agendaRaw && typeof agendaRaw === "object" && String((agendaRaw as { ossKey?: string }).ossKey || "").trim()) {
@@ -1485,7 +1980,7 @@ const loadMaterial = async () => {
agendas = [{ agendas = [{
name: String(a.name || "").trim(), name: String(a.name || "").trim(),
ossKey: String(a.ossKey || "").trim(), ossKey: String(a.ossKey || "").trim(),
itemKey: "agenda", itemKey: buildAgendaItemKey(a, 0),
}]; }];
} }
docView.value = { docView.value = {
@@ -1494,7 +1989,13 @@ const loadMaterial = async () => {
signInOssKey: parsed.signInSheet?.ossKey || "", signInOssKey: parsed.signInSheet?.ossKey || "",
themePhotoName: parsed.themePhoto?.name || parsed.themePhoto?.fileName || "", themePhotoName: parsed.themePhoto?.name || parsed.themePhoto?.fileName || "",
themePhotoOssKey: parsed.themePhoto?.ossKey || "", themePhotoOssKey: parsed.themePhoto?.ossKey || "",
invitations: Array.isArray(parsed.invitation) ? parsed.invitation : [], invitations: (Array.isArray(parsed.invitation) ? parsed.invitation : [])
.map((x: any, i: number) => ({
name: String(x?.name || x?.fileName || "").trim(),
ossKey: String(x?.ossKey || "").trim(),
itemKey: buildInvitationItemKey(x, i),
}))
.filter((x: { ossKey: string }) => !!x.ossKey),
}; };
expertProfileView.value = { expertProfileView.value = {
fileName: String(parsed?.profileFile?.name || parsed?.fileName || "").trim(), fileName: String(parsed?.profileFile?.name || parsed?.fileName || "").trim(),
@@ -1557,7 +2058,6 @@ const loadMaterial = async () => {
return { return {
expertId: Number(x?.expertId || 0), expertId: Number(x?.expertId || 0),
expertName: x?.expertName || (x?.expertId ? `专家#${x.expertId}` : "-"), expertName: x?.expertName || (x?.expertId ? `专家#${x.expertId}` : "-"),
role: normalizeAuditLaborRoleKey(x?.role) ?? "",
protocolName: x?.protocolFile?.name || "", protocolName: x?.protocolFile?.name || "",
protocolOssKey: x?.protocolFile?.ossKey || "", protocolOssKey: x?.protocolFile?.ossKey || "",
invoiceFiles: invoiceFiles.map((file: any) => ({ invoiceFiles: invoiceFiles.map((file: any) => ({
@@ -1566,7 +2066,9 @@ const loadMaterial = async () => {
})), })),
invoiceFileName: primaryInvoiceFile?.name || primaryInvoiceFile?.fileName || "", invoiceFileName: primaryInvoiceFile?.name || primaryInvoiceFile?.fileName || "",
invoiceOssKey: primaryInvoiceFile?.ossKey || "", invoiceOssKey: primaryInvoiceFile?.ossKey || "",
amountCent: Number(x?.amountCent || 0), amountCent: Number(x?.preTaxAmountCent ?? x?.amountCent ?? 0),
preTaxAmountCent: Number(x?.preTaxAmountCent ?? x?.amountCent ?? 0),
afterTaxAmountCent: Number(x?.afterTaxAmountCent || 0),
remark: x?.remark || "", remark: x?.remark || "",
}; };
}), }),
@@ -1613,6 +2115,7 @@ const loadMaterial = async () => {
} catch (_e) { } catch (_e) {
materialContent.value = "{}"; materialContent.value = "{}";
materialItemReviewMap.value = {}; materialItemReviewMap.value = {};
resubmitSummary.value = { isResubmitted: false, changes: [], issues: [] };
resetMaterialViews(); resetMaterialViews();
fileUrlMap.value = {}; fileUrlMap.value = {};
} }
@@ -1629,4 +2132,9 @@ const safeParse = (val: string) => {
onMounted(async () => { onMounted(async () => {
await Promise.all([loadNameMaps(), load()]); await Promise.all([loadNameMaps(), load()]);
}); });
watch(reviewFocus, async () => {
listPageNo.value = 1;
await load();
});
</script> </script>
@@ -1,6 +1,16 @@
<template> <template>
<PageContainer title="数据权限管理"> <PageContainer title="数据权限管理">
<QueryToolbar> <QueryToolbar>
<el-input
v-if="canRead"
v-model="matchAccount"
placeholder="输入账号检测命中策略"
clearable
class="w-input-md"
@keyup.enter="handleMatchAccount"
/>
<el-button v-if="canRead" @click="handleMatchAccount">检测命中策略</el-button>
<el-button v-if="canRead" @click="handleResetMatch">清空检测</el-button>
<el-button v-if="canManage" type="primary" @click="openCreateDrawer">新增策略</el-button> <el-button v-if="canManage" type="primary" @click="openCreateDrawer">新增策略</el-button>
<el-button v-if="canRead" @click="load">刷新</el-button> <el-button v-if="canRead" @click="load">刷新</el-button>
</QueryToolbar> </QueryToolbar>
@@ -9,19 +19,34 @@
<template #header>当前用户生效范围</template> <template #header>当前用户生效范围</template>
<div class="lh-loose"> <div class="lh-loose">
<div>用户ID{{ currentScope.userId ?? "-" }}</div> <div>用户ID{{ currentScope.userId ?? "-" }}</div>
<div>项目范围{{ currentScope.projectAll ? "全部" : (currentScope.projectIds || []).join(",") || "-" }}</div> <div>项目范围{{ formatScopeText(currentScope.projectAll, currentScope.projectOwnerOnly, currentScope.projectIds) }}</div>
<div>会议范围{{ currentScope.meetingAll ? "全部" : (currentScope.meetingIds || []).join(",") || "-" }}</div> <div>会议范围{{ formatScopeText(currentScope.meetingAll, currentScope.meetingOwnerOnly, currentScope.meetingIds) }}</div>
<div>用户范围{{ currentScope.userAll ? "全部" : (currentScope.userIds || []).join(",") || "-" }}</div> <div>用户范围{{ formatScopeText(currentScope.userAll, currentScope.userOwnerOnly, currentScope.userIds) }}</div>
<div>专家范围{{ currentScope.expertAll ? "全部" : (currentScope.expertIds || []).join(",") || "-" }}</div> <div>专家范围{{ formatScopeText(currentScope.expertAll, currentScope.expertOwnerOnly, currentScope.expertIds) }}</div>
<div>导出权限{{ currentScope.exportAllowed ? "允许" : "禁止" }}</div> <div>导出权限{{ currentScope.exportAllowed ? "允许" : "禁止" }}</div>
</div> </div>
</el-card> </el-card>
<el-card v-if="matchedScope" shadow="never" class="mb-md">
<template #header>账号检测结果</template>
<div class="lh-loose">
<div>账号{{ matchedScope.phone || matchAccount }}</div>
<div>用户名称{{ matchedScope.userName || "-" }}</div>
<div>用户ID{{ matchedScope.userId ?? "-" }}</div>
<div>命中策略{{ matchedPolicyNames }}</div>
<div>项目范围{{ formatScopeText(matchedScope.projectAll, matchedScope.projectOwnerOnly, matchedScope.projectIds) }}</div>
<div>会议范围{{ formatScopeText(matchedScope.meetingAll, matchedScope.meetingOwnerOnly, matchedScope.meetingIds) }}</div>
<div>用户范围{{ formatScopeText(matchedScope.userAll, matchedScope.userOwnerOnly, matchedScope.userIds) }}</div>
<div>专家范围{{ formatScopeText(matchedScope.expertAll, matchedScope.expertOwnerOnly, matchedScope.expertIds) }}</div>
<div>导出权限{{ matchedScope.exportAllowed ? "允许" : "禁止" }}</div>
</div>
</el-card>
<el-table :data="rows" class="mt-md"> <el-table :data="rows" class="mt-md">
<el-table-column prop="policyName" label="策略名称" width="180" /> <el-table-column prop="policyName" label="策略名称" width="180" />
<el-table-column label="当前账号命中" width="120"> <el-table-column :label="matchedScope ? '检测账号命中' : '当前账号命中'" width="120">
<template #default="{ row }"> <template #default="{ row }">
<el-tag v-if="(currentScope.matchedPolicyIds || []).includes(row.id)" type="success">命中</el-tag> <el-tag v-if="matchedPolicyIds.includes(row.id)" type="success">命中</el-tag>
<el-tag v-else type="info">未命中</el-tag> <el-tag v-else type="info">未命中</el-tag>
</template> </template>
</el-table-column> </el-table-column>
@@ -34,7 +59,7 @@
<el-table-column prop="expertScope" label="专家范围" width="100" :formatter="statusFormatter" /> <el-table-column prop="expertScope" label="专家范围" width="100" :formatter="statusFormatter" />
<el-table-column prop="expertIdsCsv" label="专家ID集" /> <el-table-column prop="expertIdsCsv" label="专家ID集" />
<el-table-column prop="status" label="状态" width="100" :formatter="statusFormatter" /> <el-table-column prop="status" label="状态" width="100" :formatter="statusFormatter" />
<el-table-column label="操作" width="420"> <el-table-column label="操作" width="300">
<template #default="{ row }"> <template #default="{ row }">
<el-button v-if="canManage" size="small" @click="openEdit(row)">编辑</el-button> <el-button v-if="canManage" size="small" @click="openEdit(row)">编辑</el-button>
<el-button v-if="canManage" size="small" @click="openAssignRoles(row)">分配角色</el-button> <el-button v-if="canManage" size="small" @click="openAssignRoles(row)">分配角色</el-button>
@@ -187,6 +212,7 @@ import {
fetchDataPermissionRoles, fetchDataPermissionRoles,
fetchCurrentDataScope, fetchCurrentDataScope,
fetchDataPermissions, fetchDataPermissions,
fetchMatchedDataScope,
fetchRoles, fetchRoles,
updateDataPermission, updateDataPermission,
} from "../../api/modules"; } from "../../api/modules";
@@ -221,6 +247,8 @@ const selectedPolicyId = ref<number | null>(null);
const selectedRoleIds = ref<number[]>([]); const selectedRoleIds = ref<number[]>([]);
const assignMode = ref<"APPEND" | "REPLACE">("APPEND"); const assignMode = ref<"APPEND" | "REPLACE">("APPEND");
const currentScope = ref<any>({}); const currentScope = ref<any>({});
const matchAccount = ref("");
const matchedScope = ref<any | null>(null);
const formRef = ref<FormInstance>(); const formRef = ref<FormInstance>();
const editFormRef = ref<FormInstance>(); const editFormRef = ref<FormInstance>();
@@ -230,6 +258,32 @@ const formRules = ref<FormRules>({
}); });
const statusFormatter = (_row: any, _column: any, value: unknown) => toZhStatus(value); const statusFormatter = (_row: any, _column: any, value: unknown) => toZhStatus(value);
const matchedPolicyIds = computed<number[]>(() => {
const scope = matchedScope.value || currentScope.value;
return Array.isArray(scope?.matchedPolicyIds) ? scope.matchedPolicyIds : [];
});
const matchedPolicyNames = computed(() => {
if (!matchedScope.value) {
return "-";
}
const names = rows.value
.filter((item) => matchedPolicyIds.value.includes(item.id))
.map((item) => item.policyName);
return names.length ? names.join("、") : "未命中";
});
const formatScopeText = (all: boolean, ownerOnly: boolean, ids?: unknown[]) => {
if (all) {
return "全部";
}
if (ownerOnly) {
return "负责人";
}
if (Array.isArray(ids) && ids.length) {
return ids.join(",");
}
return "-";
};
const openCreateDrawer = () => { const openCreateDrawer = () => {
form.value = { form.value = {
@@ -251,6 +305,7 @@ const openCreateDrawer = () => {
const load = async () => { const load = async () => {
if (!canRead.value) { if (!canRead.value) {
rows.value = []; rows.value = [];
matchedScope.value = null;
return; return;
} }
const [resp, scopeResp] = await Promise.all([fetchDataPermissions(), fetchCurrentDataScope()]); const [resp, scopeResp] = await Promise.all([fetchDataPermissions(), fetchCurrentDataScope()]);
@@ -263,6 +318,22 @@ const loadRoles = async () => {
roles.value = roleResp?.data?.list || []; roles.value = roleResp?.data?.list || [];
}; };
const handleMatchAccount = async () => {
const account = matchAccount.value.trim();
if (!account) {
ElMessage.warning("请输入账号");
return;
}
const resp = await fetchMatchedDataScope({ account });
matchedScope.value = resp?.data || null;
ElMessage.success("账号命中策略检测完成");
};
const handleResetMatch = () => {
matchAccount.value = "";
matchedScope.value = null;
};
const handleCreate = async () => { const handleCreate = async () => {
if (!formRef.value) return; if (!formRef.value) return;
const valid = await formRef.value.validate().catch(() => false); const valid = await formRef.value.validate().catch(() => false);
+2 -1
View File
@@ -1,7 +1,7 @@
<template> <template>
<PageContainer title="专家管理"> <PageContainer title="专家管理">
<QueryToolbar> <QueryToolbar>
<el-input v-model="keyword" placeholder="姓名/身份证/手机号" style="width: 220px" /> <el-input v-model="keyword" :placeholder="keywordPlaceholder" style="width: 220px" />
<el-button v-if="canRead" @click="load">查询</el-button> <el-button v-if="canRead" @click="load">查询</el-button>
<el-button v-if="canImport" @click="openImportDialog">批量导入</el-button> <el-button v-if="canImport" @click="openImportDialog">批量导入</el-button>
<el-button v-if="canImport" @click="downloadExpertTemplate">下载模板</el-button> <el-button v-if="canImport" @click="downloadExpertTemplate">下载模板</el-button>
@@ -309,6 +309,7 @@ const canImport = computed(() => (isPlatform.value ? authStore.hasPermission(PER
const canExport = computed(() => (isPlatform.value ? authStore.hasPermission(PERMS.platform.expertRead) : authStore.hasPermission(PERMS.expert.export))); const canExport = computed(() => (isPlatform.value ? authStore.hasPermission(PERMS.platform.expertRead) : authStore.hasPermission(PERMS.expert.export)));
const canCardManage = computed(() => (isPlatform.value ? authStore.hasPermission(PERMS.platform.expertManage) : authStore.hasPermission(PERMS.expert.cardManage))); const canCardManage = computed(() => (isPlatform.value ? authStore.hasPermission(PERMS.platform.expertManage) : authStore.hasPermission(PERMS.expert.cardManage)));
const canBankCardOcr = computed(() => (isPlatform.value ? authStore.hasPermission(PERMS.platform.bankCardOcr) : authStore.hasPermission(PERMS.expert.bankCardOcr))); const canBankCardOcr = computed(() => (isPlatform.value ? authStore.hasPermission(PERMS.platform.bankCardOcr) : authStore.hasPermission(PERMS.expert.bankCardOcr)));
const keywordPlaceholder = computed(() => (isPlatform.value ? "姓名/身份证号/手机号" : "姓名/身份证号"));
const keyword = ref(""); const keyword = ref("");
const rows = ref<any[]>([]); const rows = ref<any[]>([]);
@@ -3,7 +3,7 @@
<template #header> <template #header>
<div class="header-row"> <div class="header-row">
<el-space> <el-space>
<el-tag type="info">总数 {{ rows.length }}</el-tag> <el-tag type="info">总数 {{ total }}</el-tag>
<el-tag type="danger">未读 {{ unreadCount }}</el-tag> <el-tag type="danger">未读 {{ unreadCount }}</el-tag>
<el-switch <el-switch
v-if="canRead" v-if="canRead"
@@ -16,6 +16,7 @@
v-if="canRead && canMarkRead" v-if="canRead && canMarkRead"
type="primary" type="primary"
plain plain
class="notif-mark-all-read-btn"
:disabled="unreadCount <= 0" :disabled="unreadCount <= 0"
@click="handleMarkAllRead" @click="handleMarkAllRead"
> >
@@ -35,34 +36,57 @@
class="mb-md" class="mb-md"
/> />
<el-table v-else :data="displayRows" class="mt-md" empty-text="暂无站内通知"> <el-table v-else :data="rows" class="mt-md" empty-text="暂无站内通知">
<el-table-column prop="title" label="标题" min-width="220" /> <el-table-column prop="title" label="标题" min-width="220" />
<el-table-column prop="content" label="内容" min-width="360" show-overflow-tooltip /> <el-table-column prop="content" label="内容" min-width="360" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="100" :formatter="statusFormatter" /> <el-table-column prop="status" label="状态" width="100" :formatter="statusFormatter" />
<el-table-column prop="createdAt" label="创建时间" width="180" /> <el-table-column prop="createdAt" label="创建时间" width="180" />
<el-table-column prop="readAt" label="已读时间" width="180" /> <el-table-column prop="readAt" label="已读时间" width="180" />
<el-table-column label="操作" width="120"> <el-table-column label="操作" width="190">
<template #default="{ row }"> <template #default="{ row }">
<el-space wrap>
<el-button size="small" @click="openDetail(row)">查看详情</el-button>
<el-button <el-button
v-if="canMarkRead && row.status !== 'READ'" v-if="canMarkRead && row.status !== 'READ'"
type="primary" type="primary"
size="small" size="small"
class="notif-mark-read-btn"
@click="handleMarkRead(row.id)" @click="handleMarkRead(row.id)"
> >
标记已读 标记已读
</el-button> </el-button>
<span v-else>-</span> </el-space>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<div v-if="canRead" class="flex-end mt-md">
<el-pagination
:current-page="pageNo"
:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
:total="total"
@current-change="handlePageChange"
@size-change="handlePageSizeChange"
/>
</div>
<InAppNotificationDetailDialog
v-model="detailVisible"
:notification="currentNotification"
:can-mark-read="canMarkRead"
@mark-read="handleMarkRead"
/>
</PageContainer> </PageContainer>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from "vue"; import { computed, onMounted, ref, watch } from "vue";
import { storeToRefs } from "pinia";
import PageContainer from "../../components/PageContainer.vue";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import { fetchInAppNotifications, markAllInAppNotificationsRead, markInAppNotificationRead } from "../../api/modules";
import InAppNotificationDetailDialog from "../../components/InAppNotificationDetailDialog.vue";
import PageContainer from "../../components/PageContainer.vue";
import { PERMS } from "../../constants/permissions"; import { PERMS } from "../../constants/permissions";
import { useAuthStore } from "../../stores/auth"; import { useAuthStore } from "../../stores/auth";
import { useNotificationStore } from "../../stores/notification"; import { useNotificationStore } from "../../stores/notification";
@@ -70,47 +94,166 @@ import { toZhStatus } from "../../utils/status";
const authStore = useAuthStore(); const authStore = useAuthStore();
const notificationStore = useNotificationStore(); const notificationStore = useNotificationStore();
const { notifRows: rows } = storeToRefs(notificationStore);
const canRead = computed(() => authStore.scope === "TENANT" && authStore.hasPermission(PERMS.notification.inAppRead)); const canRead = computed(() => authStore.scope === "TENANT" && authStore.hasPermission(PERMS.notification.inAppRead));
const canMarkRead = computed( const canMarkRead = computed(
() => authStore.scope === "TENANT" && authStore.hasPermission(PERMS.notification.inAppMarkRead), () => authStore.scope === "TENANT" && authStore.hasPermission(PERMS.notification.inAppMarkRead),
); );
const onlyUnread = ref(false); const onlyUnread = ref(false);
const unreadCount = computed(() => rows.value.filter((item) => String(item?.status || "") === "UNREAD").length); const pageNo = ref(1);
const displayRows = computed(() => const pageSize = ref(20);
onlyUnread.value ? rows.value.filter((item) => String(item?.status || "") === "UNREAD") : rows.value, const total = ref(0);
); const unreadCount = ref(0);
const rows = ref<Record<string, any>[]>([]);
const detailVisible = ref(false);
const currentNotification = ref<Record<string, any> | null>(null);
const statusFormatter = (_row: unknown, _column: unknown, value: unknown) => toZhStatus(value); const statusFormatter = (_row: unknown, _column: unknown, value: unknown) => toZhStatus(value);
const load = async () => { const syncCurrentNotification = (targetId?: number) => {
if (!canRead.value) { const id = Number(targetId || currentNotification.value?.id || 0);
notificationStore.reset(); if (!Number.isFinite(id) || id <= 0) {
return; return;
} }
await notificationStore.loadRows(); const nextRow = rows.value.find((item) => Number(item?.id || 0) === id) || null;
if (nextRow) {
currentNotification.value = nextRow;
}
};
const load = async () => {
if (!canRead.value) {
rows.value = [];
total.value = 0;
unreadCount.value = 0;
return;
}
let resp = await fetchInAppNotifications({
ts: Date.now(),
pageNo: pageNo.value,
pageSize: pageSize.value,
onlyUnread: onlyUnread.value,
});
rows.value = Array.isArray(resp?.data?.list) ? resp.data.list : [];
total.value = Number(resp?.data?.total || 0);
pageNo.value = Number(resp?.data?.pageNo || pageNo.value || 1);
pageSize.value = Number(resp?.data?.pageSize || pageSize.value || 20);
const maxPage = total.value > 0 ? Math.max(1, Math.ceil(total.value / pageSize.value)) : 1;
if (pageNo.value > maxPage) {
pageNo.value = maxPage;
resp = await fetchInAppNotifications({
ts: Date.now(),
pageNo: pageNo.value,
pageSize: pageSize.value,
onlyUnread: onlyUnread.value,
});
rows.value = Array.isArray(resp?.data?.list) ? resp.data.list : [];
total.value = Number(resp?.data?.total || 0);
pageNo.value = Number(resp?.data?.pageNo || pageNo.value || 1);
pageSize.value = Number(resp?.data?.pageSize || pageSize.value || 20);
}
unreadCount.value = Number(notificationStore.unreadCount || 0);
}; };
const handleMarkRead = async (id: number) => { const handleMarkRead = async (id: number) => {
await notificationStore.markRead(id); await markInAppNotificationRead(id);
await notificationStore.loadUnreadCount();
unreadCount.value = Number(notificationStore.unreadCount || 0);
await load();
syncCurrentNotification(id);
ElMessage.success("已标记为已读"); ElMessage.success("已标记为已读");
}; };
const handleMarkAllRead = async () => { const handleMarkAllRead = async () => {
const affected = await notificationStore.markAllRead(); const resp = await markAllInAppNotificationsRead();
const affected = Number(resp?.data?.affected || 0);
await notificationStore.loadUnreadCount();
unreadCount.value = Number(notificationStore.unreadCount || 0);
if (onlyUnread.value && affected > 0) {
pageNo.value = 1;
}
await load();
syncCurrentNotification();
ElMessage.success(affected > 0 ? `已标记 ${affected} 条通知为已读` : "没有未读通知"); ElMessage.success(affected > 0 ? `已标记 ${affected} 条通知为已读` : "没有未读通知");
}; };
const openDetail = (row: Record<string, any>) => {
currentNotification.value = row;
detailVisible.value = true;
};
const handlePageChange = async (nextPage: number) => {
pageNo.value = Number(nextPage || 1);
await load();
};
const handlePageSizeChange = async (nextPageSize: number) => {
pageSize.value = Number(nextPageSize || 20);
pageNo.value = 1;
await load();
};
watch(rows, () => {
if (!detailVisible.value || !currentNotification.value) {
return;
}
syncCurrentNotification();
});
watch(onlyUnread, async () => {
if (!canRead.value) {
return;
}
pageNo.value = 1;
await load();
});
onMounted(async () => { onMounted(async () => {
await notificationStore.loadUnreadCount();
unreadCount.value = Number(notificationStore.unreadCount || 0);
await load(); await load();
}); });
</script> </script>
<style scoped> <style scoped>
.header-row { .header-row {
margin-top: 10px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
} }
:deep(.notif-mark-all-read-btn.el-button--primary.is-plain) {
background: rgba(var(--wo-theme-rgb), 0.14) !important;
border: 1px solid rgba(var(--wo-theme-rgb), 0.3) !important;
color: var(--wo-brand-primary-dark-2) !important;
font-weight: 600;
}
:deep(.notif-mark-all-read-btn.el-button--primary.is-plain:hover:not(.is-disabled)) {
background: rgba(var(--wo-theme-rgb), 0.22) !important;
border-color: var(--wo-brand-primary-dark-2) !important;
color: var(--wo-brand-primary-dark-2) !important;
box-shadow: 0 2px 10px rgba(var(--wo-theme-rgb), 0.18);
}
:deep(.notif-mark-read-btn.el-button--primary) {
background: var(--wo-brand-primary-dark-2) !important;
border: 1px solid var(--wo-brand-primary-dark-2) !important;
color: #fff !important;
font-weight: 600;
box-shadow: 0 2px 8px rgba(var(--wo-theme-rgb), 0.2);
}
:deep(.notif-mark-read-btn.el-button--primary:hover:not(.is-disabled)) {
background: var(--wo-brand-gradient) !important;
border-color: transparent !important;
color: #fff !important;
}
:deep(.notif-mark-all-read-btn.is-disabled),
:deep(.notif-mark-read-btn.is-disabled) {
box-shadow: none !important;
}
</style> </style>
File diff suppressed because it is too large Load Diff
@@ -12,6 +12,11 @@
<el-table-column prop="channel" label="渠道" width="100" :formatter="statusFormatter" /> <el-table-column prop="channel" label="渠道" width="100" :formatter="statusFormatter" />
<el-table-column prop="receiverType" label="对象" width="120" :formatter="statusFormatter" /> <el-table-column prop="receiverType" label="对象" width="120" :formatter="statusFormatter" />
<el-table-column prop="templateId" label="文案模板" min-width="160" :formatter="templateNameFormatter" /> <el-table-column prop="templateId" label="文案模板" min-width="160" :formatter="templateNameFormatter" />
<el-table-column prop="smsTemplateCode" label="短信模板" min-width="180">
<template #default="{ row }">
{{ row.channel === "SMS" ? row.smsTemplateCode || "-" : "-" }}
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100" :formatter="statusFormatter" /> <el-table-column prop="status" label="状态" width="100" :formatter="statusFormatter" />
<el-table-column label="操作" width="410"> <el-table-column label="操作" width="410">
<template #default="{ row }"> <template #default="{ row }">
@@ -83,10 +88,13 @@
<el-form-item label="策略名称" prop="policyName"><el-input v-model="form.policyName" /></el-form-item> <el-form-item label="策略名称" prop="policyName"><el-input v-model="form.policyName" /></el-form-item>
<el-form-item label="事件" prop="eventCode"> <el-form-item label="事件" prop="eventCode">
<el-select v-model="form.eventCode"> <el-select v-model="form.eventCode">
<el-option label="审核通过" value="AUDIT_APPROVED" /> <el-option label="审核任务分配" value="AUDIT_TASK_ASSIGNED" />
<el-option label="终审通过" value="AUDIT_APPROVED_FINAL" />
<el-option label="审核拒绝" value="AUDIT_REJECTED" /> <el-option label="审核拒绝" value="AUDIT_REJECTED" />
<el-option label="审核退回" value="AUDIT_RETURNED" />
<el-option label="财务已确认" value="FINANCE_CONFIRMED" /> <el-option label="财务已确认" value="FINANCE_CONFIRMED" />
<el-option label="用户创建" value="USER_CREATED" /> <el-option label="用户创建" value="USER_CREATED" />
<el-option label="审核通过(旧)" value="AUDIT_APPROVED" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="渠道" prop="channel"> <el-form-item label="渠道" prop="channel">
@@ -109,40 +117,24 @@
<el-option v-for="item in templateOptions" :key="item.id" :label="item.templateName" :value="item.id" /> <el-option v-for="item in templateOptions" :key="item.id" :label="item.templateName" :value="item.id" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="邮件主题"><el-input v-model="createComposer.subject" placeholder="例如:审核通知-${meetingTopic}" /></el-form-item> <el-form-item v-if="form.channel === 'SMS'" label="短信模板" prop="smsTemplateCode">
<el-form-item label="站内标题"><el-input v-model="createComposer.title" placeholder="例如:审核结果通知" /></el-form-item> <el-input v-model="form.smsTemplateCode" placeholder="例如 SMS_0000001" />
<el-form-item label="正文模板">
<el-input
v-model="createComposer.content"
type="textarea"
:rows="3"
placeholder="支持占位符,如:会议《${meetingTopic}》状态:${result}"
/>
</el-form-item> </el-form-item>
<el-form-item label="变量键值"> <el-form-item v-if="form.channel === 'SMS'" label="鐭俊鍙橀噺">
<div class="w-full"> <div class="w-full">
<div class="text-hint mt-xs mb-xs">短信厂商只接收模板变量可将完整通知内容映射给厂商变量例如 `content -> ${content}`</div>
<el-space <el-space
v-for="(item, idx) in createComposer.variables" v-for="(item, idx) in smsTemplateParamComposer.variables"
:key="`create-var-${idx}`" :key="`create-sms-var-${idx}`"
class="flex mb-sm" class="flex mb-sm"
> >
<el-input v-model="item.key" placeholder="变量名,如 meetingTopic" class="w-input-md" /> <el-input v-model="item.key" placeholder="厂商变量名,如 content" class="w-input-md" />
<el-input v-model="item.value" placeholder="变量值,如 学术会议A" class="w-input-lg" /> <el-input v-model="item.value" placeholder="变量值或表达式,如 ${content}" class="w-input-lg" />
<el-button type="danger" plain @click="removeVariable(createComposer, idx)">删除</el-button> <el-button type="danger" plain @click="removeVariable(smsTemplateParamComposer, idx)">删除</el-button>
</el-space> </el-space>
<el-button plain @click="addVariable(createComposer)">新增变量</el-button> <el-button plain @click="addVariable(smsTemplateParamComposer)">新增变量</el-button>
</div> </div>
</el-form-item> </el-form-item>
<el-form-item label="预览">
<div class="preview-box">
<div><b>主题</b>{{ buildPreview(createComposer).subject || "-" }}</div>
<div><b>标题</b>{{ buildPreview(createComposer).title || "-" }}</div>
<div><b>正文</b>{{ buildPreview(createComposer).content || "-" }}</div>
</div>
</el-form-item>
<el-form-item label="变量JSON">
<el-input :model-value="buildVariablesJson(createComposer)" type="textarea" :rows="4" readonly />
</el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="createVisible = false">取消</el-button> <el-button @click="createVisible = false">取消</el-button>
@@ -150,15 +142,18 @@
</template> </template>
</el-drawer> </el-drawer>
<el-dialog v-model="editVisible" title="编辑通知策略" :width="DIALOG_WIDTH.md"> <el-dialog v-model="editVisible" title="编辑通知策略" :width="DIALOG_WIDTH.lg">
<el-form ref="editFormRef" :model="editForm" label-position="left" :label-width="LABEL_WIDTH.md" :rules="formRules"> <el-form ref="editFormRef" :model="editForm" label-position="left" :label-width="LABEL_WIDTH.md" :rules="formRules">
<el-form-item label="策略名称" prop="policyName"><el-input v-model="editForm.policyName" /></el-form-item> <el-form-item label="策略名称" prop="policyName"><el-input v-model="editForm.policyName" /></el-form-item>
<el-form-item label="事件" prop="eventCode"> <el-form-item label="事件" prop="eventCode">
<el-select v-model="editForm.eventCode"> <el-select v-model="editForm.eventCode">
<el-option label="审核通过" value="AUDIT_APPROVED" /> <el-option label="审核任务分配" value="AUDIT_TASK_ASSIGNED" />
<el-option label="终审通过" value="AUDIT_APPROVED_FINAL" />
<el-option label="审核拒绝" value="AUDIT_REJECTED" /> <el-option label="审核拒绝" value="AUDIT_REJECTED" />
<el-option label="审核退回" value="AUDIT_RETURNED" />
<el-option label="财务已确认" value="FINANCE_CONFIRMED" /> <el-option label="财务已确认" value="FINANCE_CONFIRMED" />
<el-option label="用户创建" value="USER_CREATED" /> <el-option label="用户创建" value="USER_CREATED" />
<el-option label="审核通过(旧)" value="AUDIT_APPROVED" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="渠道" prop="channel"> <el-form-item label="渠道" prop="channel">
@@ -181,39 +176,8 @@
<el-option v-for="item in templateOptions" :key="item.id" :label="item.templateName" :value="item.id" /> <el-option v-for="item in templateOptions" :key="item.id" :label="item.templateName" :value="item.id" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="邮件主题"><el-input v-model="editComposer.subject" placeholder="例如:审核通知-${meetingTopic}" /></el-form-item> <el-form-item v-if="editForm.channel === 'SMS'" label="短信模板" prop="smsTemplateCode">
<el-form-item label="站内标题"><el-input v-model="editComposer.title" placeholder="例如:审核结果通知" /></el-form-item> <el-input v-model="editForm.smsTemplateCode" placeholder="例如 SMS_0000001" />
<el-form-item label="正文模板">
<el-input
v-model="editComposer.content"
type="textarea"
:rows="3"
placeholder="支持占位符,如:会议《${meetingTopic}》状态:${result}"
/>
</el-form-item>
<el-form-item label="变量键值">
<div class="w-full">
<el-space
v-for="(item, idx) in editComposer.variables"
:key="`edit-var-${idx}`"
class="flex mb-sm"
>
<el-input v-model="item.key" placeholder="变量名,如 meetingTopic" class="w-input-md" />
<el-input v-model="item.value" placeholder="变量值,如 学术会议A" class="w-input-lg" />
<el-button type="danger" plain @click="removeVariable(editComposer, idx)">删除</el-button>
</el-space>
<el-button plain @click="addVariable(editComposer)">新增变量</el-button>
</div>
</el-form-item>
<el-form-item label="预览">
<div class="preview-box">
<div><b>主题</b>{{ buildPreview(editComposer).subject || "-" }}</div>
<div><b>标题</b>{{ buildPreview(editComposer).title || "-" }}</div>
<div><b>正文</b>{{ buildPreview(editComposer).content || "-" }}</div>
</div>
</el-form-item>
<el-form-item label="变量JSON">
<el-input :model-value="buildVariablesJson(editComposer)" type="textarea" :rows="4" readonly />
</el-form-item> </el-form-item>
<el-form-item label="状态"> <el-form-item label="状态">
<el-select v-model="editForm.status"> <el-select v-model="editForm.status">
@@ -221,6 +185,21 @@
<el-option label="停用" value="DISABLED" /> <el-option label="停用" value="DISABLED" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item v-if="editForm.channel === 'SMS'" label="模板变量">
<div class="w-full">
<div class="text-hint mt-xs mb-xs">短信厂商只接收模板变量可将完整通知内容映射给厂商变量例如 `content -> ${content}`</div>
<el-space
v-for="(item, idx) in editSmsTemplateParamComposer.variables"
:key="`edit-sms-var-${idx}`"
class="flex mb-sm"
>
<el-input v-model="item.key" placeholder="厂商变量名,如 content" class="w-input-md" />
<el-input v-model="item.value" placeholder="变量值或表达式,如 ${content}" class="w-input-lg" />
<el-button type="danger" plain @click="removeVariable(editSmsTemplateParamComposer, idx)">删除</el-button>
</el-space>
<el-button plain @click="addVariable(editSmsTemplateParamComposer)">新增变量</el-button>
</div>
</el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="editVisible = false">取消</el-button> <el-button @click="editVisible = false">取消</el-button>
@@ -232,10 +211,13 @@
<el-form ref="dispatchFormRef" :model="dispatchForm" label-position="left" :label-width="LABEL_WIDTH.lg" :rules="dispatchFormRules"> <el-form ref="dispatchFormRef" :model="dispatchForm" label-position="left" :label-width="LABEL_WIDTH.lg" :rules="dispatchFormRules">
<el-form-item label="事件" prop="eventCode"> <el-form-item label="事件" prop="eventCode">
<el-select v-model="dispatchForm.eventCode" class="w-full"> <el-select v-model="dispatchForm.eventCode" class="w-full">
<el-option label="审核通过" value="AUDIT_APPROVED" /> <el-option label="审核任务分配" value="AUDIT_TASK_ASSIGNED" />
<el-option label="终审通过" value="AUDIT_APPROVED_FINAL" />
<el-option label="审核拒绝" value="AUDIT_REJECTED" /> <el-option label="审核拒绝" value="AUDIT_REJECTED" />
<el-option label="审核退回" value="AUDIT_RETURNED" />
<el-option label="财务已确认" value="FINANCE_CONFIRMED" /> <el-option label="财务已确认" value="FINANCE_CONFIRMED" />
<el-option label="用户创建" value="USER_CREATED" /> <el-option label="用户创建" value="USER_CREATED" />
<el-option label="审核通过(旧)" value="AUDIT_APPROVED" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="业务类型"> <el-form-item label="业务类型">
@@ -258,10 +240,7 @@
<el-input v-model="item.value" placeholder="变量值,如 学术会议A" class="w-input-lg" /> <el-input v-model="item.value" placeholder="变量值,如 学术会议A" class="w-input-lg" />
<el-button type="danger" plain @click="removeVariable(dispatchComposer, idx)">删除</el-button> <el-button type="danger" plain @click="removeVariable(dispatchComposer, idx)">删除</el-button>
</el-space> </el-space>
<el-space>
<el-button plain @click="addVariable(dispatchComposer)">新增变量</el-button> <el-button plain @click="addVariable(dispatchComposer)">新增变量</el-button>
<el-button plain @click="applyCurrentPolicyVariables">带入当前策略变量示例</el-button>
</el-space>
</div> </div>
</el-form-item> </el-form-item>
<el-form-item label="发送JSON"> <el-form-item label="发送JSON">
@@ -319,33 +298,24 @@ const taskQuery = ref<{ pageNo: number; pageSize: number }>({
const templateNameMap = ref<Record<number, string>>({}); const templateNameMap = ref<Record<number, string>>({});
const templateOptions = ref<Array<{ id: number; templateName: string }>>([]); const templateOptions = ref<Array<{ id: number; templateName: string }>>([]);
type VariableItem = { key: string; value: string }; type VariableItem = { key: string; value: string };
type VariableComposer = { subject: string; title: string; content: string; variables: VariableItem[] }; type VariableDraft = { variables: VariableItem[] };
type PolicyVariablesPayload = { smsTemplateParams?: Record<string, string> };
const createVisible = ref(false); const createVisible = ref(false);
const form = ref({ const form = ref({
policyName: "审核通过通知", policyName: "审核任务分配通知",
eventCode: "AUDIT_APPROVED", eventCode: "AUDIT_TASK_ASSIGNED",
channel: "IN_APP", channel: "IN_APP",
receiverType: "SUBMITTER", receiverType: "AUDITOR",
templateId: 1, templateId: 1,
variablesJson: "{\"meetingTopic\":\"${meetingTopic}\"}", smsTemplateCode: "",
status: "ENABLED" as "ENABLED" | "DISABLED", status: "ENABLED" as "ENABLED" | "DISABLED",
}); });
const createComposer = ref<VariableComposer>({ const smsTemplateParamComposer = ref<VariableDraft>({ variables: [] });
subject: "",
title: "审核通过通知",
content: "会议《${meetingTopic}》已审核通过",
variables: [{ key: "meetingTopic", value: "示例会议" }],
});
const editVisible = ref(false); const editVisible = ref(false);
const editId = ref<number | null>(null); const editId = ref<number | null>(null);
const editForm = ref<any>({}); const editForm = ref<any>({});
const editComposer = ref<VariableComposer>({ const editSmsTemplateParamComposer = ref<VariableDraft>({ variables: [] });
subject: "",
title: "",
content: "",
variables: [],
});
const formRef = ref<FormInstance>(); const formRef = ref<FormInstance>();
const editFormRef = ref<FormInstance>(); const editFormRef = ref<FormInstance>();
const dispatchFormRef = ref<FormInstance>(); const dispatchFormRef = ref<FormInstance>();
@@ -356,18 +326,46 @@ const formRules = ref<FormRules>({
channel: [{ required: true, message: "请选择渠道", trigger: "change" }], channel: [{ required: true, message: "请选择渠道", trigger: "change" }],
receiverType: [{ required: true, message: "请选择对象", trigger: "change" }], receiverType: [{ required: true, message: "请选择对象", trigger: "change" }],
templateId: [{ required: true, message: "请选择文案模板", trigger: "change" }], templateId: [{ required: true, message: "请选择文案模板", trigger: "change" }],
smsTemplateCode: [
{
validator: (_rule, value, callback) => {
if (form.value.channel === "SMS" && !String(value || "").trim()) {
callback(new Error("请输入短信模板编码"));
return;
}
if (editVisible.value && editForm.value?.channel === "SMS" && !String(value || "").trim()) {
callback(new Error("请输入短信模板编码"));
return;
}
callback();
},
trigger: "blur",
},
],
}); });
const dispatchFormRules = ref<FormRules>({ const dispatchFormRules = ref<FormRules>({
eventCode: [{ required: true, message: "请选择事件", trigger: "change" }], eventCode: [{ required: true, message: "请选择事件", trigger: "change" }],
}); });
const statusFormatter = (_row: any, _column: any, value: unknown) => toZhStatus(value); const statusFormatter = (_row: any, _column: any, value: unknown) => toZhStatus(value);
const normalizeUpper = (value: unknown) => String(value || "").trim().toUpperCase();
const isUserCreatedEvent = (value: unknown) => String(value || "").trim().toUpperCase() === "USER_CREATED"; const isUserCreatedEvent = (value: unknown) => String(value || "").trim().toUpperCase() === "USER_CREATED";
const resolveReceiverTypeByEvent = (eventCode: unknown, currentReceiverType: unknown) => { const resolveReceiverTypeByEvent = (eventCode: unknown, currentReceiverType: unknown) => {
const normalizedEventCode = normalizeUpper(eventCode);
if (isUserCreatedEvent(eventCode)) { if (isUserCreatedEvent(eventCode)) {
return "TARGET_USER"; return "TARGET_USER";
} }
return String(currentReceiverType || "").trim().toUpperCase() === "TARGET_USER" if (normalizedEventCode === "AUDIT_TASK_ASSIGNED") {
return "AUDITOR";
}
if (
normalizedEventCode === "AUDIT_APPROVED_FINAL" ||
normalizedEventCode === "AUDIT_REJECTED" ||
normalizedEventCode === "AUDIT_RETURNED"
) {
return "SUBMITTER";
}
return normalizeUpper(currentReceiverType) === "TARGET_USER"
? "SUBMITTER" ? "SUBMITTER"
: String(currentReceiverType || "SUBMITTER"); : String(currentReceiverType || "SUBMITTER");
}; };
@@ -404,17 +402,12 @@ const receiverResolveSourceFormatter = (_row: any, _column: any, value: unknown)
const dispatchVisible = ref(false); const dispatchVisible = ref(false);
const dispatchDialogTitle = ref("触发通知发送"); const dispatchDialogTitle = ref("触发通知发送");
const dispatchForm = ref({ const dispatchForm = ref({
eventCode: "AUDIT_APPROVED", eventCode: "AUDIT_TASK_ASSIGNED",
bizType: "MEETING", bizType: "MEETING",
bizId: "", bizId: "",
policyId: 0, policyId: 0,
}); });
const dispatchComposer = ref<VariableComposer>({ const dispatchComposer = ref<VariableDraft>({ variables: [] });
subject: "",
title: "",
content: "",
variables: [],
});
const templateNameFormatter = (_row: any, _column: any, value: unknown) => { const templateNameFormatter = (_row: any, _column: any, value: unknown) => {
const templateId = Number(value || 0); const templateId = Number(value || 0);
@@ -459,18 +452,63 @@ const loadTemplateMap = async () => {
const openCreateDrawer = () => { const openCreateDrawer = () => {
form.value = { form.value = {
policyName: "审核通过通知", policyName: "审核任务分配通知",
eventCode: "AUDIT_APPROVED", eventCode: "AUDIT_TASK_ASSIGNED",
channel: "IN_APP", channel: "IN_APP",
receiverType: "SUBMITTER", receiverType: "AUDITOR",
templateId: 1, templateId: 1,
variablesJson: "{\"meetingTopic\":\"${meetingTopic}\"}", smsTemplateCode: "",
status: "ENABLED", status: "ENABLED",
}; };
createComposer.value = parseVariablesJson(form.value.variablesJson); smsTemplateParamComposer.value = { variables: [{ key: "content", value: "${content}" }] };
createVisible.value = true; createVisible.value = true;
}; };
const parsePolicyVariables = (variablesJson?: string): PolicyVariablesPayload => {
const raw = String(variablesJson || "").trim();
if (!raw) {
return {};
}
try {
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return {};
}
return parsed as PolicyVariablesPayload;
} catch (_e) {
return {};
}
};
const buildPolicyVariableDraft = (variablesJson?: string): VariableDraft => {
const payload = parsePolicyVariables(variablesJson);
const params = payload.smsTemplateParams;
if (!params || typeof params !== "object") {
return { variables: [] };
}
return {
variables: Object.entries(params).map(([key, value]) => ({
key,
value: String(value ?? ""),
})),
};
};
const buildPolicyVariablesJson = (composer: VariableDraft): string => {
const smsTemplateParams: Record<string, string> = {};
composer.variables.forEach((item) => {
const key = item.key.trim();
if (!key) {
return;
}
smsTemplateParams[key] = item.value;
});
if (Object.keys(smsTemplateParams).length === 0) {
return "";
}
return JSON.stringify({ smsTemplateParams });
};
watch( watch(
() => form.value.eventCode, () => form.value.eventCode,
(eventCode) => { (eventCode) => {
@@ -539,7 +577,8 @@ const handleCreate = async () => {
} }
await createNotificationPolicy({ await createNotificationPolicy({
...form.value, ...form.value,
variablesJson: buildVariablesJson(createComposer.value), smsTemplateCode: form.value.channel === "SMS" ? form.value.smsTemplateCode.trim() : undefined,
variablesJson: form.value.channel === "SMS" ? buildPolicyVariablesJson(smsTemplateParamComposer.value) : "",
}); });
ElMessage.success("通知策略创建成功"); ElMessage.success("通知策略创建成功");
createVisible.value = false; createVisible.value = false;
@@ -548,12 +587,8 @@ const handleCreate = async () => {
const openDispatchDialog = (row?: any) => { const openDispatchDialog = (row?: any) => {
const sourcePolicy = row || null; const sourcePolicy = row || null;
let sourceComposer: VariableComposer = createComposer.value;
if (sourcePolicy && sourcePolicy.variablesJson) {
sourceComposer = parseVariablesJson(String(sourcePolicy.variablesJson || ""));
}
dispatchForm.value = { dispatchForm.value = {
eventCode: sourcePolicy?.eventCode || form.value.eventCode || "AUDIT_APPROVED", eventCode: sourcePolicy?.eventCode || form.value.eventCode || "AUDIT_TASK_ASSIGNED",
bizType: resolveBizTypeByEvent(sourcePolicy?.eventCode || form.value.eventCode, sourcePolicy?.bizType), bizType: resolveBizTypeByEvent(sourcePolicy?.eventCode || form.value.eventCode, sourcePolicy?.bizType),
bizId: "", bizId: "",
policyId: sourcePolicy?.id ? Number(sourcePolicy.id) : 0, policyId: sourcePolicy?.id ? Number(sourcePolicy.id) : 0,
@@ -561,12 +596,7 @@ const openDispatchDialog = (row?: any) => {
dispatchDialogTitle.value = sourcePolicy?.policyName dispatchDialogTitle.value = sourcePolicy?.policyName
? `触发通知发送 - ${String(sourcePolicy.policyName)}` ? `触发通知发送 - ${String(sourcePolicy.policyName)}`
: "触发通知发送"; : "触发通知发送";
dispatchComposer.value = { dispatchComposer.value = { variables: [] };
subject: "",
title: "",
content: "",
variables: sourceComposer.variables.map((x) => ({ key: x.key, value: x.value })),
};
dispatchVisible.value = true; dispatchVisible.value = true;
}; };
@@ -609,10 +639,13 @@ const openEdit = (row: any) => {
channel: row.channel, channel: row.channel,
receiverType: row.receiverType, receiverType: row.receiverType,
templateId: row.templateId, templateId: row.templateId,
variablesJson: row.variablesJson, smsTemplateCode: row.smsTemplateCode || "",
status: row.status, status: row.status,
}; };
editComposer.value = parseVariablesJson(row.variablesJson); editSmsTemplateParamComposer.value = buildPolicyVariableDraft(row.variablesJson);
if (row.channel === "SMS" && editSmsTemplateParamComposer.value.variables.length === 0) {
editSmsTemplateParamComposer.value = { variables: [{ key: "content", value: "${content}" }] };
}
editVisible.value = true; editVisible.value = true;
}; };
@@ -629,7 +662,8 @@ const handleSaveEdit = async () => {
} }
await updateNotificationPolicy(editId.value, { await updateNotificationPolicy(editId.value, {
...editForm.value, ...editForm.value,
variablesJson: buildVariablesJson(editComposer.value), smsTemplateCode: editForm.value.channel === "SMS" ? String(editForm.value.smsTemplateCode || "").trim() : undefined,
variablesJson: editForm.value.channel === "SMS" ? buildPolicyVariablesJson(editSmsTemplateParamComposer.value) : "",
}); });
ElMessage.success("通知策略更新成功"); ElMessage.success("通知策略更新成功");
editVisible.value = false; editVisible.value = false;
@@ -665,64 +699,14 @@ const handleDeletePolicy = async (id: number, name: string) => {
await load(); await load();
}; };
const addVariable = (composer: VariableComposer) => { const addVariable = (composer: VariableDraft) => {
composer.variables.push({ key: "", value: "" }); composer.variables.push({ key: "", value: "" });
}; };
const removeVariable = (composer: VariableComposer, index: number) => { const removeVariable = (composer: VariableDraft, index: number) => {
composer.variables.splice(index, 1); composer.variables.splice(index, 1);
}; };
const parseVariablesJson = (raw?: string): VariableComposer => {
const fallback: VariableComposer = { subject: "", title: "", content: "", variables: [] };
if (!raw || !raw.trim()) {
return fallback;
}
try {
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return fallback;
}
const data = parsed as Record<string, unknown>;
const composer: VariableComposer = {
subject: String(data.subject || ""),
title: String(data.title || ""),
content: String(data.content || data.message || ""),
variables: [],
};
Object.entries(data).forEach(([key, value]) => {
if (["subject", "title", "content", "message"].includes(key)) {
return;
}
composer.variables.push({ key, value: value == null ? "" : String(value) });
});
return composer;
} catch (_e) {
return fallback;
}
};
const buildVariablesJson = (composer: VariableComposer): string => {
const result: Record<string, string> = {};
if (composer.subject.trim()) {
result.subject = composer.subject.trim();
}
if (composer.title.trim()) {
result.title = composer.title.trim();
}
if (composer.content.trim()) {
result.content = composer.content.trim();
}
composer.variables.forEach((item) => {
const key = item.key.trim();
if (!key) {
return;
}
result[key] = item.value;
});
return JSON.stringify(result);
};
const buildDispatchVariablesJson = (): string => { const buildDispatchVariablesJson = (): string => {
const result: Record<string, string> = {}; const result: Record<string, string> = {};
dispatchComposer.value.variables.forEach((item) => { dispatchComposer.value.variables.forEach((item) => {
@@ -735,39 +719,6 @@ const buildDispatchVariablesJson = (): string => {
return JSON.stringify(result); return JSON.stringify(result);
}; };
const applyCurrentPolicyVariables = () => {
dispatchComposer.value.variables = createComposer.value.variables.map((x) => ({ key: x.key, value: x.value }));
};
const renderTemplate = (template: string, vars: Record<string, string>): string => {
if (!template) {
return "";
}
return template.replace(/\$\{([^}]+)\}/g, (_m, key: string) => {
const k = String(key || "").trim();
return vars[k] ?? "";
});
};
const buildPreview = (composer: VariableComposer): { subject: string; title: string; content: string } => {
const vars: Record<string, string> = {};
composer.variables.forEach((item) => {
const key = item.key.trim();
if (!key) {
return;
}
vars[key] = item.value;
});
const subjectTpl = composer.subject.trim() || composer.title.trim();
const titleTpl = composer.title.trim() || composer.subject.trim();
const contentTpl = composer.content.trim();
return {
subject: renderTemplate(subjectTpl, vars),
title: renderTemplate(titleTpl, vars),
content: renderTemplate(contentTpl, vars),
};
};
onMounted(async () => { onMounted(async () => {
await Promise.all([loadTemplateMap(), load()]); await Promise.all([loadTemplateMap(), load()]);
}); });
@@ -78,6 +78,9 @@
<el-form-item label="发件邮箱"> <el-form-item label="发件邮箱">
<el-input v-model="emailForm.fromAddress" placeholder="例如 no-reply@company.com" /> <el-input v-model="emailForm.fromAddress" placeholder="例如 no-reply@company.com" />
</el-form-item> </el-form-item>
<el-form-item label="发件人名称">
<el-input v-model="emailForm.fromName" placeholder="例如 核销系统" />
</el-form-item>
<el-form-item label="默认主题"> <el-form-item label="默认主题">
<el-input v-model="emailForm.defaultSubject" placeholder="系统通知" /> <el-input v-model="emailForm.defaultSubject" placeholder="系统通知" />
</el-form-item> </el-form-item>
@@ -143,7 +146,7 @@
<div class="card-header"> <div class="card-header">
<div> <div>
<div class="card-title">短信网关配置</div> <div class="card-title">短信网关配置</div>
<div class="card-subtitle">当前版本支持平台统一保存短信参数并提供参数校验/模拟受理测试</div> <div class="card-subtitle">当前版本统一保存短信网关连接参数短信模板编码由通知策略逐条维护测试时可临时指定</div>
</div> </div>
<el-tag :type="smsForm.status === 'ENABLED' ? 'success' : 'info'">{{ smsForm.status === "ENABLED" ? "已启用" : "未启用" }}</el-tag> <el-tag :type="smsForm.status === 'ENABLED' ? 'success' : 'info'">{{ smsForm.status === "ENABLED" ? "已启用" : "未启用" }}</el-tag>
</div> </div>
@@ -213,9 +216,6 @@
<el-form-item label="短信签名"> <el-form-item label="短信签名">
<el-input v-model="smsForm.signName" placeholder="例如 会议核销系统" /> <el-input v-model="smsForm.signName" placeholder="例如 会议核销系统" />
</el-form-item> </el-form-item>
<el-form-item label="模板编码">
<el-input v-model="smsForm.templateCode" placeholder="例如 SMS_0000001" />
</el-form-item>
<el-form-item label="静默窗口(s)"> <el-form-item label="静默窗口(s)">
<el-input-number v-model="smsForm.quietPeriodSeconds" :min="0" :step="5" /> <el-input-number v-model="smsForm.quietPeriodSeconds" :min="0" :step="5" />
</el-form-item> </el-form-item>
@@ -251,6 +251,9 @@
<el-form-item label="测试内容"> <el-form-item label="测试内容">
<el-input v-model="smsForm.testContent" type="textarea" :rows="3" /> <el-input v-model="smsForm.testContent" type="textarea" :rows="3" />
</el-form-item> </el-form-item>
<el-form-item label="测试模板编码">
<el-input v-model="smsForm.testSmsTemplateCode" placeholder="例如 SMS_0000001" />
</el-form-item>
</el-form> </el-form>
<el-button v-if="canManage" @click="handleTestSms">发送测试短信</el-button> <el-button v-if="canManage" @click="handleTestSms">发送测试短信</el-button>
</div> </div>
@@ -266,7 +269,7 @@ import { ElMessage } from "element-plus";
import PageContainer from "../../components/PageContainer.vue"; import PageContainer from "../../components/PageContainer.vue";
import QueryToolbar from "../../components/QueryToolbar.vue"; import QueryToolbar from "../../components/QueryToolbar.vue";
import { LABEL_WIDTH } from "../../constants/ui"; import { LABEL_WIDTH } from "../../constants/ui";
import { fetchPlatformNotifyGateways, savePlatformNotifyGateway, testPlatformNotifyGateway } from "../../api/modules"; import { fetchNotifyGateways, saveNotifyGateway, testNotifyGateway } from "../../api/modules";
import { PERMS } from "../../constants/permissions"; import { PERMS } from "../../constants/permissions";
import { useAuthStore } from "../../stores/auth"; import { useAuthStore } from "../../stores/auth";
@@ -280,8 +283,8 @@ type GatewayRow = {
}; };
const authStore = useAuthStore(); const authStore = useAuthStore();
const canRead = computed(() => authStore.hasPermission(PERMS.platform.notifyGatewayRead)); const canRead = computed(() => authStore.hasPermission(PERMS.notification.notifyGatewayRead));
const canManage = computed(() => authStore.hasPermission(PERMS.platform.notifyGatewayManage)); const canManage = computed(() => authStore.hasPermission(PERMS.notification.notifyGatewayManage));
const activeTab = ref<"EMAIL" | "SMS">("EMAIL"); const activeTab = ref<"EMAIL" | "SMS">("EMAIL");
const emailCredentialReadonly = ref(true); const emailCredentialReadonly = ref(true);
const smsCredentialReadonly = ref(true); const smsCredentialReadonly = ref(true);
@@ -304,6 +307,7 @@ const emailForm = ref({
password: "", password: "",
protocol: "smtp", protocol: "smtp",
fromAddress: "", fromAddress: "",
fromName: "",
defaultSubject: "系统通知", defaultSubject: "系统通知",
smtpAuth: true, smtpAuth: true,
starttlsEnable: true, starttlsEnable: true,
@@ -328,7 +332,6 @@ const smsForm = ref({
accessKeyId: "", accessKeyId: "",
accessKeySecret: "", accessKeySecret: "",
signName: "", signName: "",
templateCode: "",
regionId: "cn-hangzhou", regionId: "cn-hangzhou",
mockEnabled: true, mockEnabled: true,
quietPeriodSeconds: 30, quietPeriodSeconds: 30,
@@ -339,6 +342,7 @@ const smsForm = ref({
testReceiverRef: "", testReceiverRef: "",
testSubject: "通知网关测试", testSubject: "通知网关测试",
testContent: "这是一条来自平台通知网关配置中心的测试短信。", testContent: "这是一条来自平台通知网关配置中心的测试短信。",
testSmsTemplateCode: "",
}); });
const applyGatewayRows = (rows: GatewayRow[]) => { const applyGatewayRows = (rows: GatewayRow[]) => {
@@ -356,6 +360,7 @@ const applyGatewayRows = (rows: GatewayRow[]) => {
password: String(email.config?.password || ""), password: String(email.config?.password || ""),
protocol: String(email.config?.protocol || "smtp"), protocol: String(email.config?.protocol || "smtp"),
fromAddress: String(email.config?.fromAddress || ""), fromAddress: String(email.config?.fromAddress || ""),
fromName: String(email.config?.fromName || ""),
defaultSubject: String(email.config?.defaultSubject || "系统通知"), defaultSubject: String(email.config?.defaultSubject || "系统通知"),
smtpAuth: !!email.config?.smtpAuth, smtpAuth: !!email.config?.smtpAuth,
starttlsEnable: email.config?.starttlsEnable !== false, starttlsEnable: email.config?.starttlsEnable !== false,
@@ -379,7 +384,6 @@ const applyGatewayRows = (rows: GatewayRow[]) => {
accessKeyId: String(sms.config?.accessKeyId || ""), accessKeyId: String(sms.config?.accessKeyId || ""),
accessKeySecret: String(sms.config?.accessKeySecret || ""), accessKeySecret: String(sms.config?.accessKeySecret || ""),
signName: String(sms.config?.signName || ""), signName: String(sms.config?.signName || ""),
templateCode: String(sms.config?.templateCode || ""),
regionId: String(sms.config?.regionId || "cn-hangzhou"), regionId: String(sms.config?.regionId || "cn-hangzhou"),
mockEnabled: sms.config?.mockEnabled !== false, mockEnabled: sms.config?.mockEnabled !== false,
quietPeriodSeconds: Number(sms.config?.quietPeriodSeconds || 30), quietPeriodSeconds: Number(sms.config?.quietPeriodSeconds || 30),
@@ -395,14 +399,14 @@ const load = async () => {
if (!canRead.value) { if (!canRead.value) {
return; return;
} }
const resp = await fetchPlatformNotifyGateways(); const resp = await fetchNotifyGateways();
applyGatewayRows(Array.isArray(resp?.data) ? resp.data : []); applyGatewayRows(Array.isArray(resp?.data) ? resp.data : []);
emailCredentialReadonly.value = true; emailCredentialReadonly.value = true;
smsCredentialReadonly.value = true; smsCredentialReadonly.value = true;
}; };
const handleSaveEmail = async () => { const handleSaveEmail = async () => {
await savePlatformNotifyGateway("EMAIL", { await saveNotifyGateway("EMAIL", {
gatewayName: emailForm.value.gatewayName.trim(), gatewayName: emailForm.value.gatewayName.trim(),
providerCode: emailForm.value.providerCode.trim() || "SMTP", providerCode: emailForm.value.providerCode.trim() || "SMTP",
status: emailForm.value.status, status: emailForm.value.status,
@@ -414,6 +418,7 @@ const handleSaveEmail = async () => {
password: emailForm.value.password, password: emailForm.value.password,
protocol: emailForm.value.protocol.trim() || "smtp", protocol: emailForm.value.protocol.trim() || "smtp",
fromAddress: emailForm.value.fromAddress.trim(), fromAddress: emailForm.value.fromAddress.trim(),
fromName: emailForm.value.fromName.trim(),
defaultSubject: emailForm.value.defaultSubject.trim() || "系统通知", defaultSubject: emailForm.value.defaultSubject.trim() || "系统通知",
smtpAuth: emailForm.value.smtpAuth, smtpAuth: emailForm.value.smtpAuth,
starttlsEnable: emailForm.value.starttlsEnable, starttlsEnable: emailForm.value.starttlsEnable,
@@ -431,7 +436,7 @@ const handleSaveEmail = async () => {
}; };
const handleSaveSms = async () => { const handleSaveSms = async () => {
await savePlatformNotifyGateway("SMS", { await saveNotifyGateway("SMS", {
gatewayName: smsForm.value.gatewayName.trim(), gatewayName: smsForm.value.gatewayName.trim(),
providerCode: smsForm.value.providerCode.trim() || "MOCK", providerCode: smsForm.value.providerCode.trim() || "MOCK",
status: smsForm.value.status, status: smsForm.value.status,
@@ -441,7 +446,6 @@ const handleSaveSms = async () => {
accessKeyId: smsForm.value.accessKeyId.trim(), accessKeyId: smsForm.value.accessKeyId.trim(),
accessKeySecret: smsForm.value.accessKeySecret, accessKeySecret: smsForm.value.accessKeySecret,
signName: smsForm.value.signName.trim(), signName: smsForm.value.signName.trim(),
templateCode: smsForm.value.templateCode.trim(),
regionId: smsForm.value.regionId.trim() || "cn-hangzhou", regionId: smsForm.value.regionId.trim() || "cn-hangzhou",
mockEnabled: smsForm.value.mockEnabled, mockEnabled: smsForm.value.mockEnabled,
quietPeriodSeconds: smsForm.value.quietPeriodSeconds, quietPeriodSeconds: smsForm.value.quietPeriodSeconds,
@@ -455,7 +459,7 @@ const handleSaveSms = async () => {
}; };
const handleTestEmail = async () => { const handleTestEmail = async () => {
await testPlatformNotifyGateway("EMAIL", { await testNotifyGateway("EMAIL", {
receiverRef: emailForm.value.testReceiverRef.trim(), receiverRef: emailForm.value.testReceiverRef.trim(),
subject: emailForm.value.testSubject.trim(), subject: emailForm.value.testSubject.trim(),
content: emailForm.value.testContent.trim(), content: emailForm.value.testContent.trim(),
@@ -464,10 +468,11 @@ const handleTestEmail = async () => {
}; };
const handleTestSms = async () => { const handleTestSms = async () => {
const resp = await testPlatformNotifyGateway("SMS", { const resp = await testNotifyGateway("SMS", {
receiverRef: smsForm.value.testReceiverRef.trim(), receiverRef: smsForm.value.testReceiverRef.trim(),
subject: smsForm.value.testSubject.trim(), subject: smsForm.value.testSubject.trim(),
content: smsForm.value.testContent.trim(), content: smsForm.value.testContent.trim(),
smsTemplateCode: smsForm.value.testSmsTemplateCode.trim() || undefined,
}); });
ElMessage.success(String(resp?.data?.message || "测试短信已受理")); ElMessage.success(String(resp?.data?.message || "测试短信已受理"));
}; };
+1 -1
View File
@@ -548,7 +548,7 @@ onMounted(() => {
.theme-scheme-grid { .theme-scheme-grid {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(9, minmax(0, 1fr));
gap: 12px; gap: 12px;
} }
+127 -6
View File
@@ -1,4 +1,4 @@
<template> <template>
<PageContainer title="项目管理"> <PageContainer title="项目管理">
<QueryToolbar> <QueryToolbar>
<el-form :inline="true" @submit.prevent class="project-page-toolbar"> <el-form :inline="true" @submit.prevent class="project-page-toolbar">
@@ -42,16 +42,16 @@
</el-table-column> </el-table-column>
<el-table-column prop="status" label="状态" :formatter="statusFormatter" width="100"/> <el-table-column prop="status" label="状态" :formatter="statusFormatter" width="100"/>
<el-table-column label="操作" width="400"> <el-table-column label="操作" width="200">
<template #default="{ row }"> <template #default="{ row }">
<el-button v-if="canCreate" size="small" type="primary" @click="openCreateDrawer(row)">添加子项目</el-button> <el-button v-if="canCreate" size="small" type="primary" @click="openCreateDrawer(row)">添加子项目</el-button>
<el-button size="small" @click="openDetailDrawer(row)">详情</el-button> <el-button size="small" @click="openDetailDrawer(row)">详情</el-button>
<el-button v-if="canCreate" size="small" type="primary" @click="openEditDrawer(row)">编辑</el-button> <el-button v-if="canCreate" size="small" type="primary" @click="openEditDrawer(row)">编辑</el-button>
<el-button v-if="canCreateMeeting" size="small" type="primary" @click="handleCreateMeeting(row)">新建会议</el-button> <el-button v-if="canCreateMeeting && !row.hasChildren" size="small" type="primary" @click="handleCreateMeeting(row)">新建会议</el-button>
<el-button v-if="canFreeze && row.status !== 'FROZEN' && row.status !== 'ARCHIVED'" size="small" type="warning" @click="handleFreeze(row.id)">冻结</el-button> <el-button v-if="canFreeze && row.status !== 'FROZEN' && row.status !== 'ARCHIVED'" size="small" type="warning" @click="handleFreeze(row.id)">冻结</el-button>
<el-button v-if="canUnfreeze && row.status === 'FROZEN'" size="small" type="success" @click="handleUnfreeze(row.id)">解冻</el-button> <el-button v-if="canUnfreeze && row.status === 'FROZEN'" size="small" type="success" @click="handleUnfreeze(row.id)">解冻</el-button>
<el-button v-if="canArchive && (row.status === 'COMPLETED' || row.status === 'FROZEN')" size="small" type="info" @click="handleArchive(row.id)">归档</el-button> <el-button v-if="canArchive && (row.status === 'COMPLETED' || row.status === 'FROZEN')" size="small" type="info" @click="handleArchive(row.id)">归档</el-button>
<el-button v-if="canBindUser" size="small" @click="openBindingDialog(row.id)">绑定人员</el-button> <el-button v-if="canBindUser && !row.parentProjectId" size="small" @click="openBindingDialog(row.id)">绑定人员</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -64,6 +64,18 @@
@saved="load" @saved="load"
/> />
<MeetingEditDrawer
v-model="meetingCreateDrawerVisible"
v-model:meeting-form="meetingCreateForm"
v-model:meeting-form-time-range="meetingCreateFormTimeRange"
:meeting-category-options="meetingCategoryOptions"
:meeting-location-options="meetingLocationOptions"
:max-labor-ratio="currentCreateMeetingProject?.laborFeeRatio ?? null"
:max-catering-ratio="currentCreateMeetingProject?.cateringFeeRatio ?? null"
title="新建会议"
@save="handleSaveCreatedMeeting"
/>
<el-drawer v-model="detailVisible" title="项目详情" size="65%"> <el-drawer v-model="detailVisible" title="项目详情" size="65%">
<el-descriptions :column="2" border v-if="currentProject"> <el-descriptions :column="2" border v-if="currentProject">
<el-descriptions-item label="项目名称">{{ currentProject.name || "-" }}</el-descriptions-item> <el-descriptions-item label="项目名称">{{ currentProject.name || "-" }}</el-descriptions-item>
@@ -76,6 +88,7 @@
<el-descriptions-item label="合作企业项目执行人">{{ currentProject.partnerExecutorUsers || "-" }}</el-descriptions-item> <el-descriptions-item label="合作企业项目执行人">{{ currentProject.partnerExecutorUsers || "-" }}</el-descriptions-item>
<el-descriptions-item label="项目周期">{{ currentProject.startDate || "-" }} ~ {{ currentProject.endDate || "-" }}</el-descriptions-item> <el-descriptions-item label="项目周期">{{ currentProject.startDate || "-" }} ~ {{ currentProject.endDate || "-" }}</el-descriptions-item>
<el-descriptions-item label="项目预算(元)">{{ toYuan(currentProject.budgetCent) }}</el-descriptions-item> <el-descriptions-item label="项目预算(元)">{{ toYuan(currentProject.budgetCent) }}</el-descriptions-item>
<el-descriptions-item label="劳务费协议签署类型">{{ formatLaborAgreementSignType(currentProject.laborAgreementSignType) }}</el-descriptions-item>
<el-descriptions-item label="管理费(元)">{{ toYuan(currentProjectFee.managementFeeCent) }}</el-descriptions-item> <el-descriptions-item label="管理费(元)">{{ toYuan(currentProjectFee.managementFeeCent) }}</el-descriptions-item>
<el-descriptions-item label="税费(元)">{{ toYuan(currentProjectFee.taxFeeCent) }}</el-descriptions-item> <el-descriptions-item label="税费(元)">{{ toYuan(currentProjectFee.taxFeeCent) }}</el-descriptions-item>
<el-descriptions-item label="到款金额(元)">{{ toYuan(currentProjectFee.paidAmountCent) }}</el-descriptions-item> <el-descriptions-item label="到款金额(元)">{{ toYuan(currentProjectFee.paidAmountCent) }}</el-descriptions-item>
@@ -157,13 +170,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from "vue"; import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import PageContainer from "../../components/PageContainer.vue"; import PageContainer from "../../components/PageContainer.vue";
import QueryToolbar from "../../components/QueryToolbar.vue"; import QueryToolbar from "../../components/QueryToolbar.vue";
import { DIALOG_WIDTH, LABEL_WIDTH } from "../../constants/ui"; import { DIALOG_WIDTH, LABEL_WIDTH } from "../../constants/ui";
import { ElMessage, ElMessageBox } from "element-plus"; import { ElMessage, ElMessageBox } from "element-plus";
import ProjectEditDrawer from "./project-page/ProjectEditDrawer.vue"; import ProjectEditDrawer from "./project-page/ProjectEditDrawer.vue";
import MeetingEditDrawer from "./meeting-page/MeetingEditDrawer.vue";
import { import {
createMeeting, createMeeting,
fetchDictionaries,
fetchMeetings, fetchMeetings,
fetchProjectBindingCandidates, fetchProjectBindingCandidates,
fetchProjectChildren, fetchProjectChildren,
@@ -186,10 +202,14 @@ const projectDrawerVisible = ref(false);
const projectDrawerMode = ref<"create" | "edit">("create"); const projectDrawerMode = ref<"create" | "edit">("create");
const projectDrawerSourceRow = ref<any | null>(null); const projectDrawerSourceRow = ref<any | null>(null);
const projectDrawerEditId = ref<number | null>(null); const projectDrawerEditId = ref<number | null>(null);
const meetingCreateDrawerVisible = ref(false);
const detailVisible = ref(false); const detailVisible = ref(false);
const bindingVisible = ref(false); const bindingVisible = ref(false);
const currentProject = ref<any | null>(null); const currentProject = ref<any | null>(null);
const currentCreateMeetingProject = ref<any | null>(null);
const keyChangeLogs = ref<any[]>([]); const keyChangeLogs = ref<any[]>([]);
const meetingCategoryOptions = ref<any[]>([]);
const meetingLocationOptions = ref<any[]>([]);
const ownerCandidates = ref<any[]>([]); const ownerCandidates = ref<any[]>([]);
const executorCandidates = ref<any[]>([]); const executorCandidates = ref<any[]>([]);
const legacyExecutorCandidates = ref<any[]>([]); const legacyExecutorCandidates = ref<any[]>([]);
@@ -202,6 +222,7 @@ const legacyExecutorUserIds = ref<number[]>([]);
const currentBindingProjectId = ref<number | null>(null); const currentBindingProjectId = ref<number | null>(null);
const childrenCache = new Map<number, any[]>(); const childrenCache = new Map<number, any[]>();
const authStore = useAuthStore(); const authStore = useAuthStore();
const router = useRouter();
const canCreate = computed(() => authStore.hasPermission(PERMS.project.create)); const canCreate = computed(() => authStore.hasPermission(PERMS.project.create));
const canCreateMeeting = computed(() => authStore.hasPermission(PERMS.meeting.create)); const canCreateMeeting = computed(() => authStore.hasPermission(PERMS.meeting.create));
@@ -262,6 +283,7 @@ const executorEditableUserIds = buildEditableBindingUserIdsModel(executorUserIds
const legacyExecutorEditableUserIds = buildEditableBindingUserIdsModel(legacyExecutorUserIds, legacyExecutorReadonlyBoundUsers); const legacyExecutorEditableUserIds = buildEditableBindingUserIdsModel(legacyExecutorUserIds, legacyExecutorReadonlyBoundUsers);
const statusFormatter = (_row: any, _column: any, value: unknown) => toZhStatus(value); const statusFormatter = (_row: any, _column: any, value: unknown) => toZhStatus(value);
const toYuan = (cent: unknown) => Number(((Number(cent) || 0) / 100).toFixed(2)); const toYuan = (cent: unknown) => Number(((Number(cent) || 0) / 100).toFixed(2));
const formatLaborAgreementSignType = (value: unknown) => Number(value) === 2 ? "线下签" : "放心签";
const formatExecutionRatio = (ratio: unknown) => `${((Number(ratio) || 0) * 100).toFixed(2)}%`; const formatExecutionRatio = (ratio: unknown) => `${((Number(ratio) || 0) * 100).toFixed(2)}%`;
const budgetFormatter = (_row: any, _column: any, value: unknown) => toYuan(value); const budgetFormatter = (_row: any, _column: any, value: unknown) => toYuan(value);
const executionRatioFormatter = (_row: any, _column: any, value: unknown) => formatExecutionRatio(value); const executionRatioFormatter = (_row: any, _column: any, value: unknown) => formatExecutionRatio(value);
@@ -331,6 +353,43 @@ const formatDateOnly = (value: Date) => {
return `${y}-${m}-${d}`; return `${y}-${m}-${d}`;
}; };
const toCent = (yuan?: number) => Math.round(Number(yuan || 0) * 100);
const defaultMeetingCreateForm = () => ({
projectId: 0,
projectName: "",
projectStartDate: "",
projectEndDate: "",
topic: "",
meetingCategory: "学术会",
meetingForm: "线下",
location: "",
startTime: "",
endTime: "",
budgetYuan: 0,
laborRatio: 0,
cateringRatio: 0,
});
const meetingCreateForm = ref(defaultMeetingCreateForm());
const meetingCreateFormTimeRange = computed<string[]>({
get() {
if (meetingCreateForm.value.startTime && meetingCreateForm.value.endTime) {
return [meetingCreateForm.value.startTime, meetingCreateForm.value.endTime];
}
return [];
},
set(value) {
if (Array.isArray(value) && value.length === 2) {
meetingCreateForm.value.startTime = value[0] || "";
meetingCreateForm.value.endTime = value[1] || "";
return;
}
meetingCreateForm.value.startTime = "";
meetingCreateForm.value.endTime = "";
},
});
const load = async () => { const load = async () => {
const resp = await fetchProjects({ parentOnly: true }); const resp = await fetchProjects({ parentOnly: true });
rows.value = resp?.data?.list || []; rows.value = resp?.data?.list || [];
@@ -378,7 +437,21 @@ const openDetailDrawer = async (row: any) => {
keyChangeLogs.value = resp?.data || []; keyChangeLogs.value = resp?.data || [];
}; };
const loadMeetingCategoryOptions = async () => {
const resp = await fetchDictionaries({ dictType: "MEETING_CATEGORY", enabledOnly: true });
meetingCategoryOptions.value = resp?.data || [];
};
const loadMeetingLocationOptions = async () => {
const resp = await fetchDictionaries({ dictType: "MEETING_LOCATION", enabledOnly: true });
meetingLocationOptions.value = resp?.data || [];
};
const handleCreateMeeting = async (row: any) => { const handleCreateMeeting = async (row: any) => {
if (Number(row?.subProjectCount || 0) > 0 || !!row?.hasChildren) {
ElMessage.warning("该项目存在子项目,不能在该项目下创建会议");
return;
}
const meetingTotal = Math.floor(Number(row?.meetingTotal) || 0); const meetingTotal = Math.floor(Number(row?.meetingTotal) || 0);
if (meetingTotal <= 0) { if (meetingTotal <= 0) {
ElMessage.warning("请先设置项目会议期数后再新建会议"); ElMessage.warning("请先设置项目会议期数后再新建会议");
@@ -391,7 +464,7 @@ const handleCreateMeeting = async (row: any) => {
return; return;
} }
const meetingResp = await fetchMeetings({ projectId: row.id }); const meetingResp = await fetchMeetings({ projectId: row.id, pageNo: 1, pageSize: 200 });
const existingMeetingCount = (meetingResp?.data?.list || []).length; const existingMeetingCount = (meetingResp?.data?.list || []).length;
if (existingMeetingCount >= meetingTotal) { if (existingMeetingCount >= meetingTotal) {
ElMessage.warning(`该项目会议已达到总期数(${meetingTotal}期),无法继续新建`); ElMessage.warning(`该项目会议已达到总期数(${meetingTotal}期),无法继续新建`);
@@ -411,6 +484,24 @@ const handleCreateMeeting = async (row: any) => {
const meetingDate = now >= projectStart && now <= projectEnd ? now : projectStart; const meetingDate = now >= projectStart && now <= projectEnd ? now : projectStart;
const meetingDateText = formatDateOnly(meetingDate); const meetingDateText = formatDateOnly(meetingDate);
const currentPeriod = existingMeetingCount + 1; const currentPeriod = existingMeetingCount + 1;
currentCreateMeetingProject.value = row;
meetingCreateForm.value = {
projectId: Number(row?.id || 0),
projectName: String(row?.name || ""),
projectStartDate: String(row?.startDate || ""),
projectEndDate: String(row?.endDate || ""),
topic: `${row.name || "会议"}-第${currentPeriod}`,
meetingCategory: "学术会",
meetingForm: "线下",
location: "",
startTime: `${meetingDateText} 09:00:00`,
endTime: `${meetingDateText} 18:00:00`,
budgetYuan: toYuan(defaultMeetingBudgetCent),
laborRatio: Number(row?.laborFeeRatio) || 0,
cateringRatio: Number(row?.cateringFeeRatio) || 0,
};
meetingCreateDrawerVisible.value = true;
return;
await createMeeting({ await createMeeting({
projectId: row.id, projectId: row.id,
topic: `${row.name || "会议"}-第${currentPeriod}`, topic: `${row.name || "会议"}-第${currentPeriod}`,
@@ -425,6 +516,36 @@ const handleCreateMeeting = async (row: any) => {
ElMessage.success("会议创建成功"); ElMessage.success("会议创建成功");
}; };
const handleSaveCreatedMeeting = async () => {
if (!currentCreateMeetingProject.value) {
return;
}
if (Number(meetingCreateForm.value.laborRatio || 0) > Number(currentCreateMeetingProject.value?.laborFeeRatio || 0)) {
ElMessage.warning("会议劳务占比不能高于项目劳务费用占比");
return;
}
if (Number(meetingCreateForm.value.cateringRatio || 0) > Number(currentCreateMeetingProject.value?.cateringFeeRatio || 0)) {
ElMessage.warning("会议餐费占比不能高于项目餐费占比");
return;
}
await createMeeting({
projectId: meetingCreateForm.value.projectId,
topic: meetingCreateForm.value.topic,
budgetCent: toCent(meetingCreateForm.value.budgetYuan),
laborRatio: Number(meetingCreateForm.value.laborRatio) || 0,
cateringRatio: Number(meetingCreateForm.value.cateringRatio) || 0,
meetingCategory: meetingCreateForm.value.meetingCategory,
meetingForm: meetingCreateForm.value.meetingForm,
location: meetingCreateForm.value.location,
startTime: meetingCreateForm.value.startTime,
endTime: meetingCreateForm.value.endTime,
});
ElMessage.success("会议创建成功");
meetingCreateDrawerVisible.value = false;
currentCreateMeetingProject.value = null;
await router.push("/meetings");
};
const handleFreeze = async (id: number) => { const handleFreeze = async (id: number) => {
const reason = await ElMessageBox.prompt("请输入冻结原因", "冻结项目", { confirmButtonText: "确定", cancelButtonText: "取消" }).catch(() => null); const reason = await ElMessageBox.prompt("请输入冻结原因", "冻结项目", { confirmButtonText: "确定", cancelButtonText: "取消" }).catch(() => null);
if (!reason) return; if (!reason) return;
@@ -485,7 +606,7 @@ const handleSaveBindings = async () => {
}; };
onMounted(async () => { onMounted(async () => {
await load(); await Promise.all([load(), loadMeetingCategoryOptions(), loadMeetingLocationOptions()]);
}); });
</script> </script>
@@ -63,7 +63,7 @@
<template #header> <template #header>
<div class="card-header"> <div class="card-header">
<span>待办审批</span> <span>待办审批</span>
<el-button type="primary" link @click="router.push('/audits')">查看全部</el-button> <el-button type="primary" @click="router.push('/audits')">查看全部</el-button>
</div> </div>
</template> </template>
+1 -1
View File
@@ -21,7 +21,7 @@
</el-table-column> </el-table-column>
<el-table-column prop="status" label="状态" width="120" :formatter="statusFormatter" /> <el-table-column prop="status" label="状态" width="120" :formatter="statusFormatter" />
<el-table-column prop="createdAt" label="创建时间" width="190" /> <el-table-column prop="createdAt" label="创建时间" width="190" />
<el-table-column label="操作" width="520"> <el-table-column label="操作" width="420">
<template #default="{ row }"> <template #default="{ row }">
<el-button v-if="canManage" size="small" @click="openEdit(row)">编辑</el-button> <el-button v-if="canManage" size="small" @click="openEdit(row)">编辑</el-button>
<el-button <el-button
@@ -1,6 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from "vue"; import { computed } from "vue";
type BasicExpertRow = { type BasicExpertRow = {
expertId: number; expertId: number;
expertName?: string | null; expertName?: string | null;
@@ -20,10 +19,14 @@ const props = defineProps<{
basicView: Record<string, any>; basicView: Record<string, any>;
canReject: boolean; canReject: boolean;
isHigherReview: boolean; isHigherReview: boolean;
focusedChangeItemKey?: string;
toReviewTagType: ( toReviewTagType: (
itemKey: string | string[], itemKey: string | string[],
) => "success" | "danger" | "info"; ) => "success" | "danger" | "info";
toReviewResultText: (itemKey: string | string[]) => string; toReviewResultText: (itemKey: string | string[]) => string;
getChangeAnchorAttrs: (itemKey: unknown) => Record<string, string | undefined>;
hasMatchedChangeKey: (itemKey: unknown) => boolean;
handleChangeAnchorClick: (itemKey: unknown, event?: Event) => void | Promise<void>;
handleRejectMaterialItem: (itemKey: string, itemLabel: string) => void; handleRejectMaterialItem: (itemKey: string, itemLabel: string) => void;
}>(); }>();
@@ -176,9 +179,7 @@ const sections = computed<
<header class="audit-section-card__header"> <header class="audit-section-card__header">
<div> <div>
<div class="audit-section-card__title">{{ section.title }}</div> <div class="audit-section-card__title">{{ section.title }}</div>
<div class="audit-section-card__subtitle"> <div class="audit-section-card__subtitle">{{ section.description }}</div>
{{ section.description }}
</div>
</div> </div>
</header> </header>
@@ -187,6 +188,12 @@ const sections = computed<
v-for="row in section.rows" v-for="row in section.rows"
:key="row.rejectKey" :key="row.rejectKey"
class="audit-basic-row" class="audit-basic-row"
:class="{
'is-change-focused': focusedChangeItemKey && getChangeAnchorAttrs(row.reviewKey)['data-change-focused'] === 'true',
'is-change-clickable': hasMatchedChangeKey(row.reviewKey),
}"
v-bind="getChangeAnchorAttrs(row.reviewKey)"
@click="handleChangeAnchorClick(row.reviewKey, $event)"
> >
<div class="audit-basic-row__label">{{ row.label }}</div> <div class="audit-basic-row__label">{{ row.label }}</div>
<div <div
@@ -208,9 +215,7 @@ const sections = computed<
size="small" size="small"
type="danger" type="danger"
plain plain
@click=" @click.stop="handleRejectMaterialItem(row.rejectKey, row.rejectLabel)"
handleRejectMaterialItem(row.rejectKey, row.rejectLabel)
"
> >
不通过 不通过
</el-button> </el-button>
@@ -267,12 +272,26 @@ const sections = computed<
align-items: flex-start; align-items: flex-start;
padding: 18px 24px; padding: 18px 24px;
border-top: 1px solid #f8fafc; border-top: 1px solid #f8fafc;
transition: background 0.2s ease, box-shadow 0.2s ease;
} }
.audit-basic-row:first-child { .audit-basic-row:first-child {
border-top: none; border-top: none;
} }
.audit-basic-row.is-change-clickable {
cursor: pointer;
}
.audit-basic-row[data-change-modified="true"] {
background: linear-gradient(180deg, rgba(255, 251, 235, 0.7), rgba(255, 255, 255, 0.96));
}
.audit-basic-row.is-change-focused {
box-shadow: inset 0 0 0 2px rgba(245, 158, 11, 0.45);
background: linear-gradient(180deg, rgba(255, 247, 237, 0.95), rgba(255, 255, 255, 0.98));
}
.audit-basic-row__label { .audit-basic-row__label {
font-size: 13px; font-size: 13px;
line-height: 22px; line-height: 22px;
@@ -6,10 +6,14 @@ const props = defineProps<{
expertProfileView: Record<string, any>; expertProfileView: Record<string, any>;
canReject: boolean; canReject: boolean;
isHigherReview: boolean; isHigherReview: boolean;
focusedChangeItemKey?: string;
toReviewTagType: ( toReviewTagType: (
itemKey: string | string[], itemKey: string | string[],
) => "success" | "danger" | "info"; ) => "success" | "danger" | "info";
toReviewResultText: (itemKey: string | string[]) => string; toReviewResultText: (itemKey: string | string[]) => string;
getChangeAnchorAttrs: (itemKey: unknown) => Record<string, string | undefined>;
hasMatchedChangeKey: (itemKey: unknown) => boolean;
handleChangeAnchorClick: (itemKey: unknown, event?: Event) => void | Promise<void>;
getFileUrl: (ossKey: unknown) => string; getFileUrl: (ossKey: unknown) => string;
isImageAttachment: (name: unknown, ossKey: unknown) => boolean; isImageAttachment: (name: unknown, ossKey: unknown) => boolean;
isPdfAttachment: (name: unknown, ossKey: unknown) => boolean; isPdfAttachment: (name: unknown, ossKey: unknown) => boolean;
@@ -21,6 +25,8 @@ const props = defineProps<{
handleRejectMaterialItem: (itemKey: string, itemLabel: string) => void; handleRejectMaterialItem: (itemKey: string, itemLabel: string) => void;
}>(); }>();
const reviewKey = ["expert_profile_file", "profileFile"];
const fileKey = computed(() => const fileKey = computed(() =>
String(props.expertProfileView?.ossKey ?? "").trim(), String(props.expertProfileView?.ossKey ?? "").trim(),
); );
@@ -48,7 +54,15 @@ const previewProfileFile = () => {
</script> </script>
<template> <template>
<section class="audit-section-card"> <section
class="audit-section-card"
:class="{
'is-change-focused': focusedChangeItemKey && getChangeAnchorAttrs(reviewKey)['data-change-focused'] === 'true',
'is-change-clickable': hasMatchedChangeKey(reviewKey),
}"
v-bind="getChangeAnchorAttrs(reviewKey)"
@click="handleChangeAnchorClick(reviewKey, $event)"
>
<header class="audit-section-card__header"> <header class="audit-section-card__header">
<div> <div>
<div class="audit-section-card__title">专家简介 / 串场文件</div> <div class="audit-section-card__title">专家简介 / 串场文件</div>
@@ -60,21 +74,16 @@ const previewProfileFile = () => {
<el-tag <el-tag
v-if="!isHigherReview" v-if="!isHigherReview"
size="small" size="small"
:type="toReviewTagType(['expert_profile_file', 'profileFile'])" :type="toReviewTagType(reviewKey)"
> >
{{ toReviewResultText(["expert_profile_file", "profileFile"]) }} {{ toReviewResultText(reviewKey) }}
</el-tag> </el-tag>
<el-button <el-button
v-if="canReject && !isHigherReview" v-if="canReject && !isHigherReview"
size="small" size="small"
type="danger" type="danger"
plain plain
@click=" @click.stop="handleRejectMaterialItem('expert_profile_file', '专家简介/串场文件')"
handleRejectMaterialItem(
'expert_profile_file',
'专家简介/串场文件',
)
"
> >
不通过 不通过
</el-button> </el-button>
@@ -100,7 +109,7 @@ const previewProfileFile = () => {
class="audit-profile-pdf" class="audit-profile-pdf"
type="button" type="button"
:title="fileName" :title="fileName"
@click="previewProfileFile" @click.stop="previewProfileFile"
> >
<el-icon class="audit-profile-pdf__icon"><Document /></el-icon> <el-icon class="audit-profile-pdf__icon"><Document /></el-icon>
<span class="audit-profile-pdf__label">PDF</span> <span class="audit-profile-pdf__label">PDF</span>
@@ -120,7 +129,7 @@ const previewProfileFile = () => {
size="small" size="small"
type="primary" type="primary"
:disabled="!fileKey" :disabled="!fileKey"
@click="previewProfileFile" @click.stop="previewProfileFile"
> >
查看文件 查看文件
</el-button> </el-button>
@@ -137,6 +146,20 @@ const previewProfileFile = () => {
background: #fff; background: #fff;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.05); box-shadow: 0 1px 3px rgba(15, 23, 42, 0.05);
overflow: hidden; overflow: hidden;
transition: background 0.2s ease, box-shadow 0.2s ease;
}
.audit-section-card.is-change-clickable {
cursor: pointer;
}
.audit-section-card[data-change-modified="true"] {
background: linear-gradient(180deg, rgba(255, 251, 235, 0.7), rgba(255, 255, 255, 0.96));
}
.audit-section-card.is-change-focused {
box-shadow: inset 0 0 0 2px rgba(245, 158, 11, 0.45);
background: linear-gradient(180deg, rgba(255, 247, 237, 0.95), rgba(255, 255, 255, 0.98));
} }
.audit-section-card__header { .audit-section-card__header {
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from "vue"; import { computed } from "vue";
import { Document } from "@element-plus/icons-vue"; import { Document } from "@element-plus/icons-vue";
type ExpertReviewSubModuleCode = "ONSITE_PHOTO" | "LABOR_PROTOCOL"; type ExpertReviewSubModuleCode = "ONSITE_PHOTO" | "LABOR_PROTOCOL";
@@ -22,11 +22,15 @@ const props = defineProps<{
selectedAuditExpertName: string; selectedAuditExpertName: string;
canReject: boolean; canReject: boolean;
isHigherReview: boolean; isHigherReview: boolean;
focusedChangeItemKey?: string;
formatYuan: (cent: unknown) => number; formatYuan: (cent: unknown) => number;
toReviewTagType: ( toReviewTagType: (
itemKey: string | string[], itemKey: string | string[],
) => "success" | "danger" | "info"; ) => "success" | "danger" | "info";
toReviewResultText: (itemKey: string | string[]) => string; toReviewResultText: (itemKey: string | string[]) => string;
getChangeAnchorAttrs: (itemKey: unknown) => Record<string, string | undefined>;
hasMatchedChangeKey: (itemKey: unknown) => boolean;
handleChangeAnchorClick: (itemKey: unknown, event?: Event) => void | Promise<void>;
getFileUrl: (ossKey: unknown) => string; getFileUrl: (ossKey: unknown) => string;
isImageAttachment: (name: unknown, ossKey: unknown) => boolean; isImageAttachment: (name: unknown, ossKey: unknown) => boolean;
isPdfAttachment: (name: unknown, ossKey: unknown) => boolean; isPdfAttachment: (name: unknown, ossKey: unknown) => boolean;
@@ -43,9 +47,7 @@ const props = defineProps<{
}>(); }>();
const formatLaborRoleLabel = (role: unknown) => { const formatLaborRoleLabel = (role: unknown) => {
const r = String(role || "") const r = String(role || "").trim().toLowerCase();
.trim()
.toLowerCase();
if (r === "chairman") { if (r === "chairman") {
return "大会主席"; return "大会主席";
} }
@@ -55,11 +57,7 @@ const formatLaborRoleLabel = (role: unknown) => {
if (r === "host") { if (r === "host") {
return "会议主持"; return "会议主持";
} }
if ( if (r === "discussionguest" || r === "discussion_guest" || r === "discussion-guest") {
r === "discussionguest" ||
r === "discussion_guest" ||
r === "discussion-guest"
) {
return "讨论嘉宾"; return "讨论嘉宾";
} }
return ""; return "";
@@ -69,90 +67,62 @@ const expertRoleTagItems = (expertId: number) => {
const id = Number(expertId || 0); const id = Number(expertId || 0);
const tags: Array<{ key: string; label: string; className: string }> = []; const tags: Array<{ key: string; label: string; className: string }> = [];
if ((props.basicView?.chairmanExpertIds || []).includes(id)) { if ((props.basicView?.chairmanExpertIds || []).includes(id)) {
tags.push({ tags.push({ key: "chairman", label: "大会主席", className: "role-tag role-tag-primary" });
key: "chairman",
label: "大会主席",
className: "role-tag role-tag-primary",
});
} }
if ((props.basicView?.speakerExpertIds || []).includes(id)) { if ((props.basicView?.speakerExpertIds || []).includes(id)) {
tags.push({ tags.push({ key: "speaker", label: "会议讲者", className: "role-tag role-tag-success" });
key: "speaker",
label: "会议讲者",
className: "role-tag role-tag-success",
});
} }
if ((props.basicView?.hostExpertIds || []).includes(id)) { if ((props.basicView?.hostExpertIds || []).includes(id)) {
tags.push({ tags.push({ key: "host", label: "会议主持", className: "role-tag role-tag-warning" });
key: "host",
label: "会议主持",
className: "role-tag role-tag-warning",
});
} }
if ((props.basicView?.discussionGuestExpertIds || []).includes(id)) { if ((props.basicView?.discussionGuestExpertIds || []).includes(id)) {
tags.push({ tags.push({ key: "discussionGuest", label: "讨论嘉宾", className: "role-tag role-tag-info" });
key: "discussionGuest",
label: "讨论嘉宾",
className: "role-tag role-tag-info",
});
} }
if (tags.length === 0) { if (tags.length === 0) {
tags.push({ tags.push({ key: "unassigned", label: "未分配", className: "role-tag role-tag-default" });
key: "unassigned",
label: "未分配",
className: "role-tag role-tag-default",
});
} }
return tags; return tags;
}; };
const currentSummaryReviewKey = computed(() => {
const expertId = Number(selectedAuditExpertId.value || 0);
return expertId > 0 ? [`onsite_summary:${expertId}`, "onsite_summary"] : "onsite_summary";
});
const onsiteSummaryText = computed(() => { const onsiteSummaryText = computed(() => {
const summaries = Array.isArray(props.photoView?.expertSummaries) ? props.photoView.expertSummaries : [];
const currentId = Number(selectedAuditExpertId.value || 0);
if (currentId > 0) {
const hit = summaries.find((row: any) => Number(row?.expertId || 0) === currentId);
const text = String(hit?.summary || hit?.content || "").trim();
if (text) {
return text;
}
}
const text = String(props.photoView?.summary || "").trim(); const text = String(props.photoView?.summary || "").trim();
return text || "-"; return text || "-";
}); });
const normalizedLaborRows = computed(() =>
(props.selectedAuditLaborDetails || []).map((row: any, index: number) => ({
...row,
__auditIndex: index,
__auditKey: `${row?.role || "unknown"}:${index}`,
__roleLabel: formatLaborRoleLabel(row?.role) || "未分配",
})),
);
const selectedLaborKey = ref<string | null>(null);
watch(
normalizedLaborRows,
(rows) => {
if (!rows.length) {
selectedLaborKey.value = null;
return;
}
const valid = rows.some((row) => row.__auditKey === selectedLaborKey.value);
if (!valid) {
selectedLaborKey.value = rows[0].__auditKey;
}
},
{ immediate: true },
);
watch(
() => selectedAuditExpertId.value,
() => {
const rows = normalizedLaborRows.value;
selectedLaborKey.value = rows.length ? rows[0].__auditKey : null;
},
);
const currentLaborRow = computed(() => { const currentLaborRow = computed(() => {
const rows = normalizedLaborRows.value; const rows = Array.isArray(props.selectedAuditLaborDetails) ? props.selectedAuditLaborDetails : [];
if (!rows.length) { if (!rows.length) {
return null; return null;
} }
return rows.find((row) => row.__auditKey === selectedLaborKey.value) || rows[0]; return {
...rows[0],
__auditIndex: 0,
__roleLabel: formatLaborRoleLabel(rows[0]?.role) || "未分配",
};
}); });
const currentLaborPreTaxAmountCent = computed(() =>
Number(currentLaborRow.value?.preTaxAmountCent ?? currentLaborRow.value?.amountCent ?? 0),
);
const currentLaborAfterTaxAmountCent = computed(() =>
Number(currentLaborRow.value?.afterTaxAmountCent ?? 0),
);
const currentLaborInvoiceFiles = computed(() => { const currentLaborInvoiceFiles = computed(() => {
const row = currentLaborRow.value; const row = currentLaborRow.value;
if (!row) { if (!row) {
@@ -171,32 +141,28 @@ const currentLaborInvoiceFiles = computed(() => {
if (!fallbackKey) { if (!fallbackKey) {
return []; return [];
} }
return [ return [{
{
fileName: String(row.invoiceFileName || "").trim(), fileName: String(row.invoiceFileName || "").trim(),
ossKey: fallbackKey, ossKey: fallbackKey,
}, }];
];
}); });
const laborProtocolFileLabel = (row: Record<string, unknown>) => { const laborProtocolFileLabel = (row: Record<string, unknown>) => {
const fn = String(row?.protocolName ?? "").trim(); const fileName = String(row?.protocolName ?? "").trim();
if (fn) { if (fileName) {
return fn; return fileName;
} }
const key = String(row?.protocolOssKey ?? "").trim(); const key = String(row?.protocolOssKey ?? "").trim();
const tail = key.split("/").pop() || key; return key.split("/").pop() || key || "PDF";
return tail || "PDF";
}; };
const laborInvoiceFileLabel = (row: Record<string, unknown>) => { const laborInvoiceFileLabel = (row: Record<string, unknown>) => {
const fn = String(row?.fileName ?? row?.invoiceFileName ?? "").trim(); const fileName = String(row?.fileName ?? row?.invoiceFileName ?? "").trim();
if (fn) { if (fileName) {
return fn; return fileName;
} }
const key = String(row?.ossKey ?? row?.invoiceOssKey ?? "").trim(); const key = String(row?.ossKey ?? row?.invoiceOssKey ?? "").trim();
const tail = key.split("/").pop() || key; return key.split("/").pop() || key || "PDF";
return tail || "PDF";
}; };
const previewLaborProtocol = (row: Record<string, unknown>) => { const previewLaborProtocol = (row: Record<string, unknown>) => {
@@ -212,10 +178,7 @@ const previewLaborInvoice = (row: Record<string, unknown>) => {
if (!key) { if (!key) {
return; return;
} }
void props.previewOssDocument( void props.previewOssDocument(key, String(row?.fileName ?? row?.invoiceFileName ?? ""));
key,
String(row?.fileName ?? row?.invoiceFileName ?? ""),
);
}; };
</script> </script>
@@ -224,9 +187,7 @@ const previewLaborInvoice = (row: Record<string, unknown>) => {
<div class="audit-expert-sidebar"> <div class="audit-expert-sidebar">
<div class="audit-expert-sidebar__header"> <div class="audit-expert-sidebar__header">
<div class="audit-expert-sidebar__title">专家资料列表</div> <div class="audit-expert-sidebar__title">专家资料列表</div>
<div class="audit-expert-sidebar__desc"> <div class="audit-expert-sidebar__desc">选择专家后查看现场照片与劳务资料</div>
选择专家后查看现场照片与劳务资料
</div>
</div> </div>
<div class="audit-expert-sidebar__list"> <div class="audit-expert-sidebar__list">
<div <div
@@ -255,10 +216,7 @@ const previewLaborInvoice = (row: Record<string, unknown>) => {
</div> </div>
</div> </div>
<div <div class="audit-expert-main" :class="{ 'audit-expert-main--empty': !selectedAuditExpertId }">
class="audit-expert-main"
:class="{ 'audit-expert-main--empty': !selectedAuditExpertId }"
>
<div v-if="!selectedAuditExpertId" class="audit-expert-empty"> <div v-if="!selectedAuditExpertId" class="audit-expert-empty">
<div class="audit-expert-empty__icon"> <div class="audit-expert-empty__icon">
<svg <svg
@@ -308,23 +266,31 @@ const previewLaborInvoice = (row: Record<string, unknown>) => {
<div class="audit-expert-content"> <div class="audit-expert-content">
<template v-if="expertReviewSubModule === 'ONSITE_PHOTO'"> <template v-if="expertReviewSubModule === 'ONSITE_PHOTO'">
<div class="audit-section-card"> <div
class="audit-section-card"
:class="{
'is-change-focused': focusedChangeItemKey && getChangeAnchorAttrs(currentSummaryReviewKey)['data-change-focused'] === 'true',
'is-change-clickable': hasMatchedChangeKey(currentSummaryReviewKey),
}"
v-bind="getChangeAnchorAttrs(currentSummaryReviewKey)"
@click="handleChangeAnchorClick(currentSummaryReviewKey, $event)"
>
<div class="audit-section-card__header"> <div class="audit-section-card__header">
<div class="audit-section-card__title">现场说明</div> <div class="audit-section-card__title">现场说明</div>
<div class="audit-section-card__actions"> <div class="audit-section-card__actions">
<el-tag <el-tag
v-if="!isHigherReview" v-if="!isHigherReview"
size="small" size="small"
:type="toReviewTagType('onsite_summary')" :type="toReviewTagType(currentSummaryReviewKey)"
> >
{{ toReviewResultText("onsite_summary") }} {{ toReviewResultText(currentSummaryReviewKey) }}
</el-tag> </el-tag>
<el-button <el-button
v-if="canReject && !isHigherReview" v-if="canReject && !isHigherReview"
size="small" size="small"
type="danger" type="danger"
link link
@click="handleRejectMaterialItem('onsite_summary', '现场说明')" @click.stop="handleRejectMaterialItem(`onsite_summary:${selectedAuditExpertId}`, '现场说明')"
> >
不通过 不通过
</el-button> </el-button>
@@ -347,6 +313,12 @@ const previewLaborInvoice = (row: Record<string, unknown>) => {
v-for="(row, index) in selectedAuditPhotos" v-for="(row, index) in selectedAuditPhotos"
:key="`${row.ossKey || row.originIndex || index}`" :key="`${row.ossKey || row.originIndex || index}`"
class="audit-photo-card" class="audit-photo-card"
:class="{
'is-change-focused': focusedChangeItemKey && getChangeAnchorAttrs(buildPhotoItemKey(row, index))['data-change-focused'] === 'true',
'is-change-clickable': hasMatchedChangeKey(buildPhotoItemKey(row, index)),
}"
v-bind="getChangeAnchorAttrs(buildPhotoItemKey(row, index))"
@click="handleChangeAnchorClick(buildPhotoItemKey(row, index), $event)"
> >
<div class="audit-photo-card__media"> <div class="audit-photo-card__media">
<el-image <el-image
@@ -371,10 +343,7 @@ const previewLaborInvoice = (row: Record<string, unknown>) => {
</div> </div>
</div> </div>
<div class="audit-photo-card__footer"> <div class="audit-photo-card__footer">
<div <div class="audit-photo-card__name" :title="row.name || selectedAuditExpertName">
class="audit-photo-card__name"
:title="row.name || selectedAuditExpertName"
>
{{ row.name || selectedAuditExpertName || "-" }} {{ row.name || selectedAuditExpertName || "-" }}
</div> </div>
<div class="audit-photo-card__actions"> <div class="audit-photo-card__actions">
@@ -383,7 +352,7 @@ const previewLaborInvoice = (row: Record<string, unknown>) => {
type="primary" type="primary"
plain plain
:disabled="!getFileUrl(row.ossKey)" :disabled="!getFileUrl(row.ossKey)"
@click="openFileUrl(row.ossKey)" @click.stop="openFileUrl(row.ossKey)"
> >
查看 查看
</el-button> </el-button>
@@ -392,12 +361,7 @@ const previewLaborInvoice = (row: Record<string, unknown>) => {
size="small" size="small"
type="danger" type="danger"
plain plain
@click=" @click.stop="handleRejectMaterialItem(buildPhotoItemKey(row, index), `现场照片-${selectedAuditExpertName || ''}`)"
handleRejectMaterialItem(
buildPhotoItemKey(row, index),
`现场照片-${selectedAuditExpertName || ''}`,
)
"
> >
不通过 不通过
</el-button> </el-button>
@@ -410,24 +374,19 @@ const previewLaborInvoice = (row: Record<string, unknown>) => {
</template> </template>
<template v-else-if="expertReviewSubModule === 'LABOR_PROTOCOL'"> <template v-else-if="expertReviewSubModule === 'LABOR_PROTOCOL'">
<div v-if="normalizedLaborRows.length" class="audit-role-pills">
<div <div
v-for="row in normalizedLaborRows" v-if="currentLaborRow"
:key="row.__auditKey" class="audit-labor-card"
class="audit-role-pill" :class="{
:class="{ 'is-active': selectedLaborKey === row.__auditKey }" 'is-change-focused': focusedChangeItemKey && getChangeAnchorAttrs(buildLaborReviewItemKeys(currentLaborRow, currentLaborRow.__auditIndex))['data-change-focused'] === 'true',
@click="selectedLaborKey = row.__auditKey" 'is-change-clickable': hasMatchedChangeKey(buildLaborReviewItemKeys(currentLaborRow, currentLaborRow.__auditIndex)),
}"
v-bind="getChangeAnchorAttrs(buildLaborReviewItemKeys(currentLaborRow, currentLaborRow.__auditIndex))"
@click="handleChangeAnchorClick(buildLaborReviewItemKeys(currentLaborRow, currentLaborRow.__auditIndex), $event)"
> >
{{ row.__roleLabel }}
</div>
</div>
<div v-if="currentLaborRow" class="audit-labor-card">
<div class="audit-labor-card__header"> <div class="audit-labor-card__header">
<div> <div>
<div class="audit-labor-card__title"> <div class="audit-labor-card__title">劳务信息</div>
{{ currentLaborRow.__roleLabel }}
</div>
<div class="audit-labor-card__subtitle"> <div class="audit-labor-card__subtitle">
{{ selectedAuditExpertName || currentLaborRow.expertName || "-" }} {{ selectedAuditExpertName || currentLaborRow.expertName || "-" }}
</div> </div>
@@ -436,38 +395,16 @@ const previewLaborInvoice = (row: Record<string, unknown>) => {
<el-tag <el-tag
v-if="!isHigherReview" v-if="!isHigherReview"
size="small" size="small"
:type=" :type="toReviewTagType(buildLaborReviewItemKeys(currentLaborRow, currentLaborRow.__auditIndex))"
toReviewTagType(
buildLaborReviewItemKeys(
currentLaborRow,
currentLaborRow.__auditIndex,
),
)
"
> >
{{ {{ toReviewResultText(buildLaborReviewItemKeys(currentLaborRow, currentLaborRow.__auditIndex)) }}
toReviewResultText(
buildLaborReviewItemKeys(
currentLaborRow,
currentLaborRow.__auditIndex,
),
)
}}
</el-tag> </el-tag>
<el-button <el-button
v-if="canReject && !isHigherReview" v-if="canReject && !isHigherReview"
size="small" size="small"
type="danger" type="danger"
link link
@click=" @click.stop="handleRejectMaterialItem(buildLaborItemKey(currentLaborRow, currentLaborRow.__auditIndex), `劳务协议-${selectedAuditExpertName || ''}${currentLaborRow.__roleLabel ? `-${currentLaborRow.__roleLabel}` : ''}`)"
handleRejectMaterialItem(
buildLaborItemKey(
currentLaborRow,
currentLaborRow.__auditIndex,
),
`劳务协议-${selectedAuditExpertName || ''}${currentLaborRow.__roleLabel ? `-${currentLaborRow.__roleLabel}` : ''}`,
)
"
> >
不通过 不通过
</el-button> </el-button>
@@ -481,10 +418,8 @@ const previewLaborInvoice = (row: Record<string, unknown>) => {
<template v-if="currentLaborRow.protocolOssKey"> <template v-if="currentLaborRow.protocolOssKey">
<el-image <el-image
v-if=" v-if="
isImageAttachment( isImageAttachment(currentLaborRow.protocolName, currentLaborRow.protocolOssKey) &&
currentLaborRow.protocolName, getFileUrl(currentLaborRow.protocolOssKey)
currentLaborRow.protocolOssKey,
) && getFileUrl(currentLaborRow.protocolOssKey)
" "
:src="getFileUrl(currentLaborRow.protocolOssKey)" :src="getFileUrl(currentLaborRow.protocolOssKey)"
fit="cover" fit="cover"
@@ -493,33 +428,25 @@ const previewLaborInvoice = (row: Record<string, unknown>) => {
class="audit-preview-image" class="audit-preview-image"
/> />
<div <div
v-else-if=" v-else-if="isPdfAttachment(currentLaborRow.protocolName, currentLaborRow.protocolOssKey)"
isPdfAttachment(
currentLaborRow.protocolName,
currentLaborRow.protocolOssKey,
)
"
class="audit-preview-pdf" class="audit-preview-pdf"
:title="laborProtocolFileLabel(currentLaborRow)" :title="laborProtocolFileLabel(currentLaborRow)"
role="button" role="button"
tabindex="0" tabindex="0"
@click="previewLaborProtocol(currentLaborRow)" @click.stop="previewLaborProtocol(currentLaborRow)"
@keydown.enter.prevent="previewLaborProtocol(currentLaborRow)" @keydown.enter.prevent="previewLaborProtocol(currentLaborRow)"
> >
<el-icon class="audit-preview-pdf__icon"><Document /></el-icon> <el-icon class="audit-preview-pdf__icon"><Document /></el-icon>
<span class="audit-preview-pdf__label">PDF</span> <span class="audit-preview-pdf__label">PDF</span>
</div> </div>
<div <div class="audit-attachment-panel__name" :title="laborProtocolFileLabel(currentLaborRow)">
class="audit-attachment-panel__name"
:title="laborProtocolFileLabel(currentLaborRow)"
>
{{ laborProtocolFileLabel(currentLaborRow) }} {{ laborProtocolFileLabel(currentLaborRow) }}
</div> </div>
<el-button <el-button
size="small" size="small"
type="primary" type="primary"
plain plain
@click="previewLaborProtocol(currentLaborRow)" @click.stop="previewLaborProtocol(currentLaborRow)"
> >
预览协议 预览协议
</el-button> </el-button>
@@ -532,7 +459,7 @@ const previewLaborInvoice = (row: Record<string, unknown>) => {
<div class="audit-attachment-panel__head">劳务金额</div> <div class="audit-attachment-panel__head">劳务金额</div>
<div class="audit-attachment-panel__body audit-amount-panel"> <div class="audit-attachment-panel__body audit-amount-panel">
<div class="audit-amount-panel__value"> <div class="audit-amount-panel__value">
{{ formatYuan(currentLaborRow.amountCent) }} {{ formatYuan(currentLaborPreTaxAmountCent) }} / {{ formatYuan(currentLaborAfterTaxAmountCent) }}
</div> </div>
<div class="audit-amount-panel__unit"></div> <div class="audit-amount-panel__unit"></div>
<div class="audit-amount-panel__remark"> <div class="audit-amount-panel__remark">
@@ -544,10 +471,7 @@ const previewLaborInvoice = (row: Record<string, unknown>) => {
<div class="audit-attachment-panel"> <div class="audit-attachment-panel">
<div class="audit-attachment-panel__head">发票附件</div> <div class="audit-attachment-panel__head">发票附件</div>
<div class="audit-attachment-panel__body"> <div class="audit-attachment-panel__body">
<div <div v-if="currentLaborInvoiceFiles.length" class="audit-file-list">
v-if="currentLaborInvoiceFiles.length"
class="audit-file-list"
>
<div <div
v-for="file in currentLaborInvoiceFiles" v-for="file in currentLaborInvoiceFiles"
:key="file.ossKey" :key="file.ossKey"
@@ -555,10 +479,7 @@ const previewLaborInvoice = (row: Record<string, unknown>) => {
> >
<div class="audit-file-item__preview"> <div class="audit-file-item__preview">
<el-image <el-image
v-if=" v-if="isImageAttachment(file.fileName, file.ossKey) && getFileUrl(file.ossKey)"
isImageAttachment(file.fileName, file.ossKey) &&
getFileUrl(file.ossKey)
"
:src="getFileUrl(file.ossKey)" :src="getFileUrl(file.ossKey)"
fit="cover" fit="cover"
:preview-src-list="[getFileUrl(file.ossKey)]" :preview-src-list="[getFileUrl(file.ossKey)]"
@@ -571,7 +492,7 @@ const previewLaborInvoice = (row: Record<string, unknown>) => {
:title="laborInvoiceFileLabel(file)" :title="laborInvoiceFileLabel(file)"
role="button" role="button"
tabindex="0" tabindex="0"
@click="previewLaborInvoice(file)" @click.stop="previewLaborInvoice(file)"
@keydown.enter.prevent="previewLaborInvoice(file)" @keydown.enter.prevent="previewLaborInvoice(file)"
> >
<el-icon class="audit-preview-pdf__icon"><Document /></el-icon> <el-icon class="audit-preview-pdf__icon"><Document /></el-icon>
@@ -580,17 +501,14 @@ const previewLaborInvoice = (row: Record<string, unknown>) => {
<div v-else class="audit-file-item__empty">文件</div> <div v-else class="audit-file-item__empty">文件</div>
</div> </div>
<div class="audit-file-item__meta"> <div class="audit-file-item__meta">
<div <div class="audit-file-item__name" :title="laborInvoiceFileLabel(file)">
class="audit-file-item__name"
:title="laborInvoiceFileLabel(file)"
>
{{ laborInvoiceFileLabel(file) }} {{ laborInvoiceFileLabel(file) }}
</div> </div>
<el-button <el-button
size="small" size="small"
type="primary" type="primary"
plain plain
@click="previewLaborInvoice(file)" @click.stop="previewLaborInvoice(file)"
> >
预览发票 预览发票
</el-button> </el-button>
@@ -698,10 +616,7 @@ const previewLaborInvoice = (row: Record<string, unknown>) => {
border: 1px solid #e2e8f0; border: 1px solid #e2e8f0;
background: #fff; background: #fff;
cursor: pointer; cursor: pointer;
transition: transition: border-color 0.2s, box-shadow 0.2s, transform 0.2s;
border-color 0.2s,
box-shadow 0.2s,
transform 0.2s;
} }
.audit-expert-card:hover { .audit-expert-card:hover {
@@ -858,6 +773,26 @@ const previewLaborInvoice = (row: Record<string, unknown>) => {
border-radius: 12px; border-radius: 12px;
padding: 20px 24px; padding: 20px 24px;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.05); box-shadow: 0 1px 3px rgba(15, 23, 42, 0.05);
transition: background 0.2s ease, box-shadow 0.2s ease;
}
.audit-section-card.is-change-clickable,
.audit-labor-card.is-change-clickable,
.audit-photo-card.is-change-clickable {
cursor: pointer;
}
.audit-section-card[data-change-modified="true"],
.audit-labor-card[data-change-modified="true"],
.audit-photo-card[data-change-modified="true"] {
background: linear-gradient(180deg, rgba(255, 251, 235, 0.7), rgba(255, 255, 255, 0.96));
}
.audit-section-card.is-change-focused,
.audit-labor-card.is-change-focused,
.audit-photo-card.is-change-focused {
box-shadow: inset 0 0 0 2px rgba(245, 158, 11, 0.45);
background: linear-gradient(180deg, rgba(255, 247, 237, 0.95), rgba(255, 255, 255, 0.98));
} }
.audit-section-card__header, .audit-section-card__header,
@@ -915,6 +850,7 @@ const previewLaborInvoice = (row: Record<string, unknown>) => {
border-radius: 12px; border-radius: 12px;
overflow: hidden; overflow: hidden;
background: #fff; background: #fff;
transition: background 0.2s ease, box-shadow 0.2s ease;
} }
.audit-photo-card__media { .audit-photo-card__media {
@@ -987,33 +923,6 @@ const previewLaborInvoice = (row: Record<string, unknown>) => {
flex-wrap: wrap; flex-wrap: wrap;
} }
.audit-role-pills {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.audit-role-pill {
padding: 6px 16px;
border-radius: 999px;
background: #f1f5f9;
color: #475569;
font-size: 13px;
border: 1px solid transparent;
cursor: pointer;
transition: all 0.2s;
}
.audit-role-pill:hover {
background: #e2e8f0;
}
.audit-role-pill.is-active {
background: #4f46e5;
color: #fff;
border-color: #4338ca;
}
.audit-labor-grid { .audit-labor-grid {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));

Some files were not shown because too many files have changed in this diff Show More