fix: 代码审查修复

- 会签:一人多组时取消其所在**每个组**的其余待办(原先只取消点击任务所在组,另一组永久 PENDING 卡单)
- 跨库:提交首节点审批人解析移出事务;协调单受理部门改为 prepare 事务外解析
- 回填:CONFIRM 节点支持 writableFields(ADR 0006 三类节点均可声明)
- 收据单:受理部门(财务部)快照到实例,列表/导出可见
- 明细导出:ENUM 列输出中文文案而非存储编码
- 价格表:历史版本包含未来生效的已归档版本
- 导出会签进度一次装配,去掉按节点重复查询
- 菜单种子:价格菜单在 superAdmin 授权之前,授权条件覆盖 approval:%
- doc_type 与 flow_code 常量分开;删除未使用的 findByDocType / ApprovalDetail.fieldChanges
- 补附件三档白名单用例(VOUCHER 通过、EXTERNAL 被拒)
This commit is contained in:
weicw
2026-10-31 08:22:04 +08:00
parent 25b40d83c6
commit 599d85b7da
28 changed files with 263 additions and 92 deletions
+15 -13
View File
@@ -65,19 +65,6 @@ WHERE NOT EXISTS (
SELECT 1 FROM sys_menu WHERE tenant_id = 10150 AND authority = 'approval:instance:export' AND menu_type = 1 AND deleted = 0
);
-- 授权 superAdmin(role_id = 586, tenant 10150)
INSERT INTO sys_role_menu (role_id, menu_id, tenant_id)
SELECT 586, m.menu_id, 10150
FROM sys_menu m
WHERE m.tenant_id = 10150
AND m.deleted = 0
AND (m.path IN ('/approval', '/approval/todo', '/approval/instance') OR m.authority LIKE 'approval:instance:%')
AND NOT EXISTS (
SELECT 1 FROM sys_role_menu rm WHERE rm.role_id = 586 AND rm.menu_id = m.menu_id AND rm.tenant_id = 10150
);
SET @instanceMenu = (SELECT menu_id FROM sys_menu WHERE tenant_id = 10150 AND path = '/approval/instance' AND menu_type = 0 AND deleted = 0 ORDER BY menu_id LIMIT 1);
-- 价格表查看(ADR 0008):受众全体员工,独立权限点,配 /approval/price 只读页
SET @parent = (SELECT menu_id FROM sys_menu WHERE tenant_id = 10150 AND path = '/approval' AND menu_type = 0 AND deleted = 0 ORDER BY menu_id LIMIT 1);
@@ -94,3 +81,18 @@ FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM sys_menu WHERE tenant_id = 10150 AND authority = 'approval:price:view' AND menu_type = 1 AND deleted = 0
);
-- 授权 superAdmin(role_id = 586, tenant 10150)
INSERT INTO sys_role_menu (role_id, menu_id, tenant_id)
SELECT 586, m.menu_id, 10150
FROM sys_menu m
WHERE m.tenant_id = 10150
AND m.deleted = 0
AND (m.path IN ('/approval', '/approval/todo', '/approval/instance', '/approval/price')
OR m.authority LIKE 'approval:%')
AND NOT EXISTS (
SELECT 1 FROM sys_role_menu rm WHERE rm.role_id = 586 AND rm.menu_id = m.menu_id AND rm.tenant_id = 10150
);
SET @instanceMenu = (SELECT menu_id FROM sys_menu WHERE tenant_id = 10150 AND path = '/approval/instance' AND menu_type = 0 AND deleted = 0 ORDER BY menu_id LIMIT 1);
-13
View File
@@ -90,18 +90,5 @@ CREATE TABLE IF NOT EXISTS approval_field_change (
KEY idx_field (tenant_id, field_key, new_value)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='审批字段改值历史';
-- 老数据回填:一期实例没有 title/search_text(幂等,只补空值)
UPDATE approval_instance i
JOIN approval_completion_form f ON f.instance_id = i.id
SET i.title = f.name,
i.search_text = CONCAT_WS(' ', f.name, f.implement_org, f.service_content)
WHERE i.title IS NULL;
UPDATE approval_instance i
JOIN approval_coordination_form c ON c.instance_id = i.id
SET i.title = c.title,
i.search_text = CONCAT_WS(' ', c.title, c.content, c.remark)
WHERE i.title IS NULL;
DROP PROCEDURE IF EXISTS approval_phase2_add_column;
DROP PROCEDURE IF EXISTS approval_phase2_add_index;
@@ -65,6 +65,19 @@ public final class ApprovalConstants {
public static final String FLOW_COUPON = "coupon";
public static final String FLOW_FACILITY = "facility";
/**
* 单据类型(doc_type,决定用哪张业务表单)。
*
* <p>与 flow_code 分开是 ADR 0007 的核心区分:工作协调单两个流程共用同一个 doc_type。
* 每单一张表单的流程取值与 flow_code 相同,但常量分开命名,避免把两个概念混用。</p>
*/
public static final String DOC_SETTLEMENT = "settlement";
public static final String DOC_RECEIPT = "receipt";
public static final String DOC_VEHICLE = "vehicle";
public static final String DOC_VENUE = "venue";
public static final String DOC_COUPON = "coupon";
public static final String DOC_FACILITY = "facility";
// 单号前缀
public static final String NO_PREFIX_COMPLETION = "WG";
public static final String NO_PREFIX_COORDINATION = "XT";
@@ -98,9 +98,11 @@ public class ApprovalTaskController extends BaseController {
@PreAuthorize("hasAuthority('approval:instance:approve')")
@ApiOperation("存档确认")
@PostMapping("/task/{id}/confirm")
public ApiResult<ApprovalInstanceVO> confirm(@PathVariable("id") Long id) {
public ApiResult<ApprovalInstanceVO> confirm(@PathVariable("id") Long id,
@RequestBody(required = false) ApprovalTaskActionParam param) {
User user = getLoginUser();
ApprovalTaskService.Result result = taskService.confirm(id, user);
ApprovalTaskService.Result result = taskService.confirm(id,
param == null ? null : param.getFields(), user);
return success(result.getWarning() == null ? "存档成功" : result.getWarning(),
instanceService.detail(result.getInstanceId(), user));
}
@@ -79,7 +79,8 @@ public class CompletionDocumentType extends AbstractApprovalDocumentType {
}
@Override
public Long persist(Long instanceId, Long bizId, ApprovalDocumentParam param, User user) {
public Long persist(Long instanceId, Long bizId, ApprovalDocumentParam param, User user,
Object prepared) {
ApprovalCompletionParam completion = cast(param);
ApprovalCompletionForm form = bizId == null ? null : completionFormMapper.selectById(bizId);
if (form == null) {
@@ -94,13 +94,27 @@ public class CoordinationDocumentType extends AbstractApprovalDocumentType {
optionalText("备注", coordination.getRemark(), 500);
}
/** 受理部门快照在事务外解析:persist 在事务内,@DS("server") 会失效。 */
@Override
public Long persist(Long instanceId, Long bizId, ApprovalDocumentParam param, User user) {
public Object prepare(ApprovalDocumentParam param, User user) {
ApprovalCoordinationParam coordination = cast(param);
ApprovalOrganizationVO accept = directoryService.getOrganization(coordination.getAcceptOrgId());
if (accept == null) {
throw new BusinessException("受理部门不存在或已停用");
}
return accept;
}
@Override
public Long persist(Long instanceId, Long bizId, ApprovalDocumentParam param, User user,
Object prepared) {
ApprovalCoordinationParam coordination = cast(param);
ApprovalOrganizationVO accept = prepared instanceof ApprovalOrganizationVO
? (ApprovalOrganizationVO) prepared
: directoryService.getOrganization(coordination.getAcceptOrgId());
if (accept == null) {
throw new BusinessException("受理部门不存在或已停用");
}
ApprovalCoordinationForm form = bizId == null ? null : coordinationFormMapper.selectById(bizId);
if (form == null) {
form = new ApprovalCoordinationForm();
@@ -88,7 +88,7 @@ public class CouponDocumentType extends AbstractApprovalDocumentType {
@Override
public String docType() {
return ApprovalConstants.FLOW_COUPON;
return ApprovalConstants.DOC_COUPON;
}
@Override
@@ -137,7 +137,8 @@ public class CouponDocumentType extends AbstractApprovalDocumentType {
* <p>调用方(ApprovalDocumentService)已在外层事务内调用,跨库解析不在这里做。</p>
*/
@Override
public Long persist(Long instanceId, Long bizId, ApprovalDocumentParam param, User user) {
public Long persist(Long instanceId, Long bizId, ApprovalDocumentParam param, User user,
Object prepared) {
ApprovalCouponParam coupon = cast(param);
List<Map<String, Object>> rows = ApprovalLineSupport.normalize(coupon.getLines(), LINE_COLUMNS);
validateRows(rows);
@@ -76,7 +76,7 @@ public class FacilityDocumentType extends AbstractApprovalDocumentType {
@Override
public String docType() {
return ApprovalConstants.FLOW_FACILITY;
return ApprovalConstants.DOC_FACILITY;
}
@Override
@@ -130,7 +130,8 @@ public class FacilityDocumentType extends AbstractApprovalDocumentType {
}
@Override
public Long persist(Long instanceId, Long bizId, ApprovalDocumentParam param, User user) {
public Long persist(Long instanceId, Long bizId, ApprovalDocumentParam param, User user,
Object prepared) {
ApprovalFacilityParam facility = cast(param);
LocalDate effectiveDate = requireEffectiveDate(facility.getEffectiveDate());
List<Map<String, Object>> rows = ApprovalLineSupport.normalize(facility.getLines(), lineColumns());
@@ -53,8 +53,9 @@ public class FacilityPriceQueryService implements ApprovalPriceProvider {
if (tenantId == null) {
return null;
}
// 当前生效版本:只看到期已生效的版本
List<FacilityPriceVersionRow> rows = priceMapper.selectArchivedVersions(
tenantId, LocalDate.now(), 0, 1);
tenantId, false, LocalDate.now(), 0, 1);
return rows.isEmpty() ? null : toVersion(tenantId, rows.get(0));
}
@@ -76,8 +77,9 @@ public class FacilityPriceQueryService implements ApprovalPriceProvider {
}
int size = limit <= 0 ? DEFAULT_HISTORY_LIMIT : limit;
int pageNo = page <= 0 ? 1 : page;
// 历史版本:包含未来生效的已归档版本(#58 验收要求历史可查)
List<FacilityPriceVersionRow> rows = priceMapper.selectArchivedVersions(
tenantId, LocalDate.now(), (pageNo - 1) * size, size);
tenantId, true, LocalDate.now(), (pageNo - 1) * size, size);
List<ApprovalPriceVersion> versions = new ArrayList<>(rows.size());
for (FacilityPriceVersionRow row : rows) {
ApprovalPriceVersion version = toVersion(tenantId, row);
@@ -81,7 +81,7 @@ public class ReceiptDocumentType extends AbstractApprovalDocumentType {
@Override
public String docType() {
return DOC_TYPE;
return ApprovalConstants.DOC_RECEIPT;
}
@Override
@@ -100,6 +100,12 @@ public class ReceiptDocumentType extends AbstractApprovalDocumentType {
}
/** 转账凭证至少一张:只要求整单有附件会让别的材料顶替掉凭证(ADR 0014)。 */
/** 受理部门固定为财务部(ADR 0014)。 */
@Override
public String acceptOrgCode() {
return "APPROVAL_FINANCE";
}
@Override
public String requiredAttachmentCategory() {
return ApprovalConstants.CATEGORY_VOUCHER;
@@ -125,7 +131,8 @@ public class ReceiptDocumentType extends AbstractApprovalDocumentType {
}
@Override
public Long persist(Long instanceId, Long bizId, ApprovalDocumentParam param, User user) {
public Long persist(Long instanceId, Long bizId, ApprovalDocumentParam param, User user,
Object prepared) {
ApprovalReceiptParam receipt = cast(param);
ApprovalReceiptForm form = bizId == null ? null : receiptFormMapper.selectById(bizId);
if (form == null) {
@@ -70,7 +70,7 @@ public class SettlementDocumentType extends AbstractApprovalDocumentType {
@Override
public String docType() {
return ApprovalConstants.FLOW_SETTLEMENT;
return ApprovalConstants.DOC_SETTLEMENT;
}
@Override
@@ -121,7 +121,8 @@ public class SettlementDocumentType extends AbstractApprovalDocumentType {
* <p>调用方(ApprovalDocumentService)已在外层事务内调用,跨库解析不在这里做。</p>
*/
@Override
public Long persist(Long instanceId, Long bizId, ApprovalDocumentParam param, User user) {
public Long persist(Long instanceId, Long bizId, ApprovalDocumentParam param, User user,
Object prepared) {
ApprovalSettlementParam settlement = cast(param);
List<Map<String, Object>> rows = ApprovalLineSupport.normalize(settlement.getLines(), LINE_COLUMNS);
@@ -111,7 +111,7 @@ public class VehicleDocumentType extends AbstractApprovalDocumentType {
@Override
public String docType() {
return ApprovalConstants.FLOW_VEHICLE;
return ApprovalConstants.DOC_VEHICLE;
}
@Override
@@ -158,7 +158,8 @@ public class VehicleDocumentType extends AbstractApprovalDocumentType {
* <p>调用方(ApprovalDocumentService)已在外层事务内调用,跨库解析不在这里做。</p>
*/
@Override
public Long persist(Long instanceId, Long bizId, ApprovalDocumentParam param, User user) {
public Long persist(Long instanceId, Long bizId, ApprovalDocumentParam param, User user,
Object prepared) {
ApprovalVehicleParam vehicle = cast(param);
List<Map<String, Object>> rows = ApprovalLineSupport.normalize(vehicle.getLines(), LINE_COLUMNS);
validateRows(rows);
@@ -59,7 +59,7 @@ public class VenueDocumentType extends AbstractApprovalDocumentType {
/** doc_type 与 flow_code 同值(本单只有一个流程);复用流程常量避免两处写字面量。 */
@Override
public String docType() {
return ApprovalConstants.FLOW_VENUE;
return ApprovalConstants.DOC_VENUE;
}
@Override
@@ -97,7 +97,8 @@ public class VenueDocumentType extends AbstractApprovalDocumentType {
}
@Override
public Long persist(Long instanceId, Long bizId, ApprovalDocumentParam param, User user) {
public Long persist(Long instanceId, Long bizId, ApprovalDocumentParam param, User user,
Object prepared) {
ApprovalVenueParam venue = cast(param);
ApprovalVenueForm form = bizId == null || bizId <= 0 ? null : venueFormMapper.selectById(bizId);
if (form == null) {
@@ -44,6 +44,12 @@ public abstract class AbstractApprovalDocumentType implements ApprovalDocumentTy
return new ArrayList<>();
}
/** 默认无预解析。 */
@Override
public Object prepare(ApprovalDocumentParam param, User user) {
return null;
}
/** 必填文本校验,返回去掉首尾空格的值。 */
protected String requireText(String label, String value, int maxLength) {
String trimmed = value == null ? "" : value.trim();
@@ -20,7 +20,6 @@ import java.util.Map;
public class ApprovalDocumentRegistry {
private final Map<String, ApprovalDocumentType> byFlowCode = new LinkedHashMap<>();
private final Map<String, ApprovalDocumentType> byDocType = new LinkedHashMap<>();
public ApprovalDocumentRegistry(List<ApprovalDocumentType> documentTypes) {
for (ApprovalDocumentType type : documentTypes) {
@@ -28,8 +27,6 @@ public class ApprovalDocumentRegistry {
if (previous != null) {
throw new IllegalStateException("审批流程编码重复注册: " + type.flowCode());
}
// doc_type 可被多个流程共用(内部/外部协调单),以第一个注册的为准
byDocType.putIfAbsent(type.docType(), type);
}
}
@@ -46,10 +43,6 @@ public class ApprovalDocumentRegistry {
return flowCode == null ? null : byFlowCode.get(flowCode);
}
public ApprovalDocumentType findByDocType(String docType) {
return docType == null ? null : byDocType.get(docType);
}
public List<ApprovalDocumentType> all() {
return Collections.unmodifiableList(new ArrayList<>(byFlowCode.values()));
}
@@ -51,6 +51,15 @@ public interface ApprovalDocumentType {
return Collections.emptyList();
}
/**
* 固定受理部门组织编码:单据的业务受理方写死在流程里(如收据单的财务部)。
*
* <p>返回非空时,提交会把该部门快照到实例的受理部门,列表与导出因此能显示。</p>
*/
default String acceptOrgCode() {
return null;
}
/** 提交预检要求的附件分类;null 沿用「整单至少一张附件」。 */
default String requiredAttachmentCategory() {
return null;
@@ -59,15 +68,33 @@ public interface ApprovalDocumentType {
/** 草稿参数类型。 */
Class<? extends ApprovalDocumentParam> paramClass();
/** 字段校验,失败抛 BusinessException。 */
/**
* 字段校验,失败抛 BusinessException。
*
* <p>在事务外执行,可以访问 core 库(组织、角色)。</p>
*/
void validate(ApprovalDocumentParam param, User user);
/**
* 写库前的预解析:把需要 core 库的数据(如受理部门快照)解析好交给 {@link #persist}。
*
* <p>{@code persist} 在事务内执行,不能调 {@code @DS("server")} 的服务;
* 需要跨库数据时必须在这里解析。默认无操作,返回 null。</p>
*/
default Object prepare(ApprovalDocumentParam param, User user) {
return null;
}
/**
* 写业务表:新建时 bizId 为 null,更新时传入已有 bizId。
*
* <p>由 {@code ApprovalDocumentService} 在事务内调用,因此实现不得访问 core 库;
* 需要的跨库数据由 {@link #prepare} 提前解析后经 {@code prepared} 传入。</p>
*
* @param prepared {@link #prepare} 的返回值
* @return 业务表主键
*/
Long persist(Long instanceId, Long bizId, ApprovalDocumentParam param, User user);
Long persist(Long instanceId, Long bizId, ApprovalDocumentParam param, User user, Object prepared);
/** 把业务字段与明细写入详情。 */
void load(ApprovalInstance instance, ApprovalDetail detail);
@@ -25,6 +25,7 @@ public interface ApprovalFacilityPriceMapper {
*/
@InterceptorIgnore(tenantLine = "true")
List<FacilityPriceVersionRow> selectArchivedVersions(@Param("tenantId") Integer tenantId,
@Param("includeFuture") boolean includeFuture,
@Param("today") LocalDate today,
@Param("offset") int offset,
@Param("limit") int limit);
@@ -2,7 +2,11 @@
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.gxwebsoft.approval.mapper.ApprovalFacilityPriceMapper">
<!-- 当前生效版本 = 已归档且生效日期不晚于今天里 effective_date 最大的那版(ADR 0012) -->
<!--
已归档价格表版本,按生效日期倒序。
includeFuture=true 时包含「未来生效」的已归档版本(历史版本列表要看得到它们);
当前生效版本只取 effective_date 不晚于今天的最新一版(ADR 0012)。
-->
<select id="selectArchivedVersions" resultType="com.gxwebsoft.approval.doc.facility.FacilityPriceVersionRow">
SELECT i.id AS instance_id,
i.biz_no AS biz_no,
@@ -14,7 +18,9 @@
WHERE i.tenant_id = #{tenantId}
AND i.flow_code = 'facility'
AND i.status = 'ARCHIVED'
<if test="!includeFuture">
AND f.effective_date &lt;= #{today}
</if>
ORDER BY f.effective_date DESC, i.archive_time DESC, i.id DESC
<if test="limit >= 0">
LIMIT #{limit} OFFSET #{offset}
@@ -210,7 +210,8 @@ public class ApprovalApproverResolver {
if (ApprovalConstants.RESOLVER_ORG_LEADER_BY_CODE.equals(resolver)
|| ApprovalConstants.RESOLVER_REPORTER_ORG_LEADER.equals(resolver)
|| ApprovalConstants.RESOLVER_ACCEPT_ORG_LEADER.equals(resolver)) {
return groupName + "负责人角色缺配";
// 组名本身已含部门名(如「财务部」);避免拼成「财务部负责人负责人角色缺配」
return groupName.endsWith("负责人") ? groupName + "角色缺配" : groupName + "负责人角色缺配";
}
return groupName + "审批人缺配";
}
@@ -63,17 +63,18 @@ public class ApprovalDocumentService {
throw new BusinessException("参数不能为空");
}
type.validate(param, user);
Object prepared = type.prepare(param, user);
Integer organizationId = user == null ? null : user.getOrganizationId();
ApprovalOrganizationVO organization = directoryService.getOrganization(organizationId);
String organizationName = organization == null ? null : organization.getOrganizationName();
if (param.getId() != null) {
return inTransaction(() ->
updateDraft(type, param, user, organizationId, organizationName));
updateDraft(type, param, user, organizationId, organizationName, prepared));
}
ApprovalFlowDefinition definition = flowService.getOrCreate(flowCode);
return inTransaction(() ->
createDraft(type, param, user, definition, organizationId, organizationName));
createDraft(type, param, user, definition, organizationId, organizationName, prepared));
}
/**
@@ -181,7 +182,7 @@ public class ApprovalDocumentService {
private Long createDraft(ApprovalDocumentType type, ApprovalDocumentParam param, User user,
ApprovalFlowDefinition definition, Integer organizationId,
String organizationName) {
String organizationName, Object prepared) {
ApprovalInstance instance = new ApprovalInstance();
instance.setFlowCode(type.flowCode());
instance.setDefinitionId(definition.getId());
@@ -196,7 +197,7 @@ public class ApprovalDocumentService {
instance.setReportOrgName(organizationName);
instanceMapper.insert(instance);
Long bizId = type.persist(instance.getId(), null, param, user);
Long bizId = type.persist(instance.getId(), null, param, user, prepared);
instance.setBizId(bizId == null ? 0L : bizId);
instance.setTitle(type.title(instance));
instance.setSearchText(truncate(type.searchText(instance)));
@@ -205,7 +206,7 @@ public class ApprovalDocumentService {
}
private Long updateDraft(ApprovalDocumentType type, ApprovalDocumentParam param, User user,
Integer organizationId, String organizationName) {
Integer organizationId, String organizationName, Object prepared) {
ApprovalInstance instance = instanceMapper.selectById(param.getId());
if (instance == null) {
throw new BusinessException("单据不存在");
@@ -219,7 +220,7 @@ public class ApprovalDocumentService {
if (!ApprovalConstants.EDITABLE_STATUSES.contains(instance.getStatus())) {
throw new BusinessException(ApprovalConstants.writeBlockedMessage(instance.getStatus()));
}
Long bizId = type.persist(instance.getId(), instance.getBizId(), param, user);
Long bizId = type.persist(instance.getId(), instance.getBizId(), param, user, prepared);
if (bizId != null) {
instance.setBizId(bizId);
}
@@ -154,6 +154,30 @@ public class ApprovalEngineService {
return taskMapper.update(null, wrapper);
}
/**
* 指定用户在节点本轮所属的会签组标识(去重,按出现顺序)。
*
* <p>一人同属多组时,一次通过同时签了他所在的所有组,因此这些组的其余成员待办
* 也要一并取消——否则那些组会永远停在 PENDING,节点无法推进。</p>
*/
public List<String> assigneeGroupKeys(Long instanceId, Integer round, String nodeKey,
Integer assigneeId) {
List<ApprovalTask> tasks = taskMapper.selectList(new LambdaQueryWrapper<ApprovalTask>()
.eq(ApprovalTask::getInstanceId, instanceId)
.eq(ApprovalTask::getRound, round)
.eq(ApprovalTask::getNodeKey, nodeKey)
.eq(ApprovalTask::getAssigneeId, assigneeId)
.orderByAsc(ApprovalTask::getGroupSeq)
.orderByAsc(ApprovalTask::getId));
Map<String, Boolean> ordered = new LinkedHashMap<>();
for (ApprovalTask task : tasks) {
if (task.getGroupKey() != null) {
ordered.putIfAbsent(task.getGroupKey(), Boolean.TRUE);
}
}
return new ArrayList<>(ordered.keySet());
}
/**
* 该节点该轮仍待处理的组标识集合(按 group_seq 顺序)。
*/
@@ -252,7 +252,7 @@ public class ApprovalExportService {
dataRow.createCell(col++).setCellValue(text(exportLine.instance.getReportOrgName()));
dataRow.createCell(col++).setCellValue(text(exportLine.instance.getReporterName()));
for (ApprovalLineSpec column : columns) {
dataRow.createCell(col++).setCellValue(lineValue(exportLine.line.get(column.getKey())));
dataRow.createCell(col++).setCellValue(lineValue(column, exportLine.line.get(column.getKey())));
}
}
}
@@ -268,14 +268,14 @@ public class ApprovalExportService {
if (nodes.stream().noneMatch(node -> node.getGroups() != null && !node.getGroups().isEmpty())) {
return "";
}
// 一次装配全部会签进度,避免按节点重复查询(导出批量时是 N 倍开销)
com.gxwebsoft.approval.vo.ApprovalDetail detail = new com.gxwebsoft.approval.vo.ApprovalDetail();
documentService.fillGroupProgress(instance, detail);
List<com.gxwebsoft.approval.vo.ApprovalGroupProgressVO> progress = detail.getGroupProgress();
for (ApprovalNode node : nodes) {
if (node.getGroups() == null || node.getGroups().isEmpty()) {
continue;
}
// 用详情装配同一套进度,避免导出与详情口径分叉
com.gxwebsoft.approval.vo.ApprovalDetail detail = new com.gxwebsoft.approval.vo.ApprovalDetail();
documentService.fillGroupProgress(instance, detail);
List<com.gxwebsoft.approval.vo.ApprovalGroupProgressVO> progress = detail.getGroupProgress();
int signedGroups = 0;
int totalGroups = 0;
for (com.gxwebsoft.approval.vo.ApprovalGroupProgressVO group : progress) {
@@ -321,10 +321,22 @@ public class ApprovalExportService {
};
}
private String lineValue(Object value) {
/**
* 明细列取值:ENUM 列输出中文文案而不是存储编码(导出给人看,不是给程序看)。
*/
private String lineValue(ApprovalLineSpec column, Object value) {
if (value == null) {
return "";
}
if (ApprovalLineSpec.TYPE_ENUM.equals(column.getType()) && column.getOptions() != null) {
String code = String.valueOf(value);
for (ApprovalLineSpec.Option option : column.getOptions()) {
if (code.equals(option.getValue())) {
return option.getLabel();
}
}
return code;
}
if (value instanceof BigDecimal) {
return ((BigDecimal) value).stripTrailingZeros().toPlainString();
}
@@ -133,16 +133,32 @@ public class ApprovalInstanceService {
}
// 协调单的受理部门在提交时快照到实例;外部流程的分管领导按受理部门解析
ApprovalOrganizationVO acceptOrganization = preflightAcceptOrganization(type, instance);
if (acceptOrganization == null && type.acceptOrgCode() != null) {
// 业务受理方写死在流程里的单据(如收据单的财务部):快照到实例供列表/导出显示
acceptOrganization = directoryService.getOrganizationByCode(type.acceptOrgCode());
if (acceptOrganization == null) {
throw new BusinessException("受理部门不存在或已停用");
}
}
// 预检要在内存里先带上受理部门快照:节点解析器(acceptOrgLeader/acceptOrgDeputy)读的是实例字段,
// 而快照在 doSubmit 才落库
if (acceptOrganization != null) {
instance.setAcceptOrgId(acceptOrganization.getOrganizationId());
instance.setAcceptOrgName(acceptOrganization.getOrganizationName());
}
// 附件分类与逐节点逐组审批人解析(ADR 0003 / 0005)
documentService.preflight(instance, flowService.parseNodes(instance.getDefinitionId()));
ApprovalOrganizationVO resolvedAcceptOrg = acceptOrganization;
List<ApprovalNode> nodes = flowService.parseNodes(instance.getDefinitionId());
// 附件分类与逐节点逐组审批人解析(ADR 0003 / 0005),必须在事务外完成
documentService.preflight(instance, nodes);
if (nodes.isEmpty()) {
throw new BusinessException("审批流程未配置节点");
}
// 首节点审批人同样在事务外解析:TransactionTemplate 内 @DS("server") 不生效
ApprovalApproverResolver.Resolution firstNode =
approverResolver.resolve(instance, nodes.get(0));
return inTransaction(() -> doSubmit(instanceId, user, organization, acceptOrganization, type));
return inTransaction(() ->
doSubmit(instanceId, user, organization, resolvedAcceptOrg, type, firstNode));
}
/**
@@ -183,7 +199,8 @@ public class ApprovalInstanceService {
}
private String doSubmit(Long instanceId, User user, ApprovalOrganizationVO organization,
ApprovalOrganizationVO acceptOrganization, ApprovalDocumentType type) {
ApprovalOrganizationVO acceptOrganization, ApprovalDocumentType type,
ApprovalApproverResolver.Resolution firstNode) {
ApprovalInstance locked = engineService.lockInstance(instanceId);
if (!locked.getReporterId().equals(user.getUserId())
|| !ApprovalConstants.EDITABLE_STATUSES.contains(locked.getStatus())) {
@@ -204,11 +221,7 @@ public class ApprovalInstanceService {
if (locked.getSubmitTime() == null) {
locked.setSubmitTime(LocalDateTime.now());
}
List<ApprovalNode> nodes = flowService.parseNodes(locked.getDefinitionId());
if (nodes.isEmpty()) {
throw new BusinessException("审批流程未配置节点");
}
List<ApprovalApprover> approvers = approverResolver.resolve(locked, nodes.get(0)).getApprovers();
List<ApprovalApprover> approvers = firstNode.getApprovers();
int tasks = engineService.startFlow(locked, approvers, user,
firstSubmit ? ApprovalConstants.ACTION_SUBMIT : ApprovalConstants.ACTION_RESUBMIT);
if (tasks == 0) {
@@ -112,6 +112,8 @@ public class ApprovalTaskService {
}
ensureTaskCurrent(task, instance);
ApprovalDocumentType documentType = documentRegistry.find(instance.getFlowCode());
// 字段校验在事务外(可能查询 core 库做唯一性判断),落库前在事务内重新确认任务仍可处理;
// 任务若已被处理则整体回滚,不会留下没有 field_change 的字段值
List<ApprovalFieldUpdate> changes = writeFields(instance, documentType, task, fields, false, user);
if (changes.isEmpty()) {
return new Result(instance.getId(), null);
@@ -130,7 +132,7 @@ public class ApprovalTaskService {
* 存档确认:CONFIRM 任务只能确认(无意见),同节点同轮全部完成后实例进入审批完成,
* 并激活归档节点;归档节点缺配时实例停在审批中并提示管理员补配置。
*/
public Result confirm(Long taskId, User user) {
public Result confirm(Long taskId, Map<String, String> fields, User user) {
ApprovalTask task = requireTask(taskId, user);
if (!ApprovalConstants.NODE_CONFIRM.equals(task.getNodeType())) {
throw new BusinessException("当前任务不支持存档确认");
@@ -140,12 +142,15 @@ public class ApprovalTaskService {
throw new BusinessException("单据不存在");
}
ensureNotArchived(instance);
// ADR 0006:CONFIRM 节点也可声明可写字段(如设施价格表的运营管理部存档人)
ApprovalDocumentType documentType = documentRegistry.find(instance.getFlowCode());
List<ApprovalFieldUpdate> changes = writeFields(instance, documentType, task, fields, true, user);
ApprovalNode next = engineService.nextNode(instance, task.getNodeKey());
ApprovalApproverResolver.Resolution resolution = next == null
? ApprovalApproverResolver.Resolution.resolved(Collections.emptyList())
: approverResolver.resolve(instance, next);
String warning = inTransaction(() -> doConfirm(taskId, task, user, next, resolution));
String warning = inTransaction(() -> doConfirm(taskId, task, user, next, resolution, changes));
return new Result(instance.getId(), warning);
}
@@ -298,12 +303,18 @@ public class ApprovalTaskService {
throw new BusinessException("任务已处理");
}
persistChanges(locked, task, changes, user);
// 一人同属多组:一次点击完成他在本节点本轮的全部待办
// 一人同属多组:一次点击完成他在本节点本轮的全部待办,并签掉他所在的**每个**组,
// 因此这些组的其余成员待办都要取消(只取消点击任务所在组会让另一组永远卡住)
List<String> signedGroups = engineService.assigneeGroupKeys(locked.getId(), locked.getRound(),
task.getNodeKey(), user.getUserId());
if (signedGroups.isEmpty() && task.getGroupKey() != null) {
signedGroups = java.util.Collections.singletonList(task.getGroupKey());
}
engineService.completeAssigneeTasks(locked.getId(), locked.getRound(), task.getNodeKey(),
user.getUserId(), taskId, opinion);
if (task.getGroupKey() != null) {
for (String groupKey : signedGroups) {
engineService.cancelPendingTasksInGroup(locked.getId(), locked.getRound(), task.getNodeKey(),
task.getGroupKey(), null);
groupKey, null);
}
engineService.addRecord(locked, taskId, task.getNodeKey(), user,
ApprovalConstants.ACTION_APPROVE, opinion);
@@ -331,13 +342,15 @@ public class ApprovalTaskService {
}
private String doConfirm(Long taskId, ApprovalTask task, User user,
ApprovalNode next, ApprovalApproverResolver.Resolution resolution) {
ApprovalNode next, ApprovalApproverResolver.Resolution resolution,
List<ApprovalFieldUpdate> changes) {
ApprovalInstance locked = engineService.lockInstance(task.getInstanceId());
ensureNotArchived(locked);
ensureTaskHandleable(task, locked);
if (updatePendingTask(taskId, ApprovalConstants.TASK_DONE, null) == 0) {
throw new BusinessException("任务已处理");
}
persistChanges(locked, task, changes, user);
engineService.addRecord(locked, taskId, task.getNodeKey(), user,
ApprovalConstants.ACTION_CONFIRM, null);
// 存档进度按「节点 + 轮次」聚合,同轮两个 CONFIRM 节点不会串进度(ADR 0003 修正)
@@ -33,9 +33,6 @@ public class ApprovalDetail implements Serializable {
/** 会签节点进度(当前轮),非会签单据为空。 */
private List<ApprovalGroupProgressVO> groupProgress = new ArrayList<>();
/** 字段改值历史(最新在后)。 */
private List<ApprovalFieldChangeVO> fieldChanges = new ArrayList<>();
public void addField(String key, String label, Object value) {
fields.add(new ApprovalFieldVO(key, label, value == null ? null : String.valueOf(value)));
}
@@ -93,6 +93,42 @@ class ApprovalAttachmentFileRoundTripIT {
assertThat(download.getResponse().getContentAsByteArray()).isEqualTo(payload);
}
/**
* 附件分类三档(#50):VOUCHER 上传成功、详情按三档显示名称、白名单外的值被拒。
*/
@Test
void voucherCategoryIsAcceptedAndUnknownCategoryRejected() throws Exception {
long instanceId = createCompletionDraft("附件分类三档");
MockMultipartFile file = new MockMultipartFile("file", "voucher.pdf", "application/pdf",
new byte[]{'%', 'P', 'D', 'F', '-', '1', '.', '4'});
String response = mockMvc.perform(multipart("/api/approval/attachment/upload")
.file(file)
.param("instanceId", String.valueOf(instanceId))
.param("category", "VOUCHER")
.header("tenantId", TENANT)
.with(authentication(TestAuthentication.user(reporter.userId, reporter.username,
organizationId, TENANT, SUBMIT))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.category").value("VOUCHER"))
.andExpect(jsonPath("$.data.categoryName").value("凭证"))
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
assertThat(objectMapper.readTree(response).path("data").path("id").asLong()).isPositive();
MockMultipartFile again = new MockMultipartFile("file", "sign.png", "image/png", pngBytes());
mockMvc.perform(multipart("/api/approval/attachment/upload")
.file(again)
.param("instanceId", String.valueOf(instanceId))
.param("category", "EXTERNAL")
.header("tenantId", TENANT)
.with(authentication(TestAuthentication.user(reporter.userId, reporter.username,
organizationId, TENANT, SUBMIT))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(1))
.andExpect(jsonPath("$.message").value("附件分类不正确"));
}
// ---------- helpers ----------
private long createCompletionDraft(String name) throws Exception {
@@ -135,24 +135,31 @@ class ApprovalCountersignEngineIT {
@Test
void onePersonInTwoGroupsCompletesBothWithSingleClick() throws Exception {
// 让第一组负责人同时挂上第二组的部门负责人角色:他会拿到两条会签任务
// 让第一组负责人同时挂上第二组负责人角色:他会拿到两条会签任务,
// 而第二组另有一名成员(groupLeaders.get(1))——该成员的待办必须随这次点击一并取消,
// 否则第二组永远有 PENDING,节点无法推进。
Integer secondRoleId = data.server().queryForObject(
"SELECT role_id FROM sys_role WHERE tenant_id = ? AND deleted = 0 AND role_code = ?",
Integer.class, TENANT, data.leaderRoleCode(groupOrgs.get(1)));
data.bind(groupLeaders.get(0).userId, secondRoleId);
// 第二组只留这一个成员,使总任务数仍是 4,其中该用户占 2 条
data.server().update("DELETE FROM sys_user_role WHERE user_id = ? AND role_id = ?",
groupLeaders.get(1).userId, secondRoleId);
long instanceId = createAndSubmit();
assertThat(countersignTaskCount(instanceId)).isEqualTo(4);
assertThat(countersignTaskCount(instanceId)).isEqualTo(5);
approve(taskFor(instanceId, groupLeaders.get(0))).andExpect(jsonPath("$.code").value(0));
// 本人两条任务都完成
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? "
+ "AND assignee_id = ? AND status = 'DONE'", instanceId, groupLeaders.get(0).userId)).isEqualTo(2);
assertThat(currentNodeKey(instanceId)).isEqualTo("countersign");
// 他所在两个组的其余成员待办被取消,只剩另外两组共 2 条待办
assertThat(countersignPendingCount(instanceId)).isEqualTo(2);
assertThat(currentNodeKey(instanceId)).isEqualTo("countersign");
// 另外两组签完后节点正常推进:证明一人双组没有把节点卡死
approve(taskFor(instanceId, groupLeaders.get(2))).andExpect(jsonPath("$.code").value(0));
approve(taskFor(instanceId, groupLeaders.get(3)))
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.currentNodeName").value("存档"));
}
@Test
@@ -114,7 +114,8 @@ public class CountersignProbeDocumentType extends AbstractApprovalDocumentType {
}
@Override
public Long persist(Long instanceId, Long bizId, ApprovalDocumentParam param, User user) {
public Long persist(Long instanceId, Long bizId, ApprovalDocumentParam param, User user,
Object prepared) {
return bizId == null ? instanceId : bizId;
}