ソースを参照

e签宝调整

fyz 2 週間 前
親
コミット
cbcd8d4e4a

+ 23 - 0
mjava-sdzl/src/main/java/com/malk/sdzl/controller/YqbOptionPushController.java

@@ -1,6 +1,8 @@
 package com.malk.sdzl.controller;
 
 import com.malk.sdzl.entity.dto.YqbApiResponse;
+import com.malk.sdzl.entity.dto.YqbAssessmentCostCenterPushRequest;
+import com.malk.sdzl.entity.dto.YqbAssessmentCostCenterPushResult;
 import com.malk.sdzl.entity.dto.YqbCategoryCreateRequest;
 import com.malk.sdzl.entity.dto.YqbInvoiceTitleImportResult;
 import com.malk.sdzl.entity.dto.YqbInvoiceTitleRequest;
@@ -82,6 +84,27 @@ public class YqbOptionPushController {
         return McR.success(result);
     }
 
+    /**
+     * 先新增 categoryCode=09 的成本中心,再维护 categoryCode=07 的考核主体。
+     * 成本中心新增成功或返回已存在时,查询考核主体并按是否存在执行新增或修改。
+     *
+     * POST /api/sdzl/yqb/option/assessment-subject/cost-center/push
+     *
+     * @param request 考核主体、成本中心及考核主体关联的辅助核算编码
+     * @return 考核主体与成本中心的新增结果
+     */
+    @PostMapping("/assessment-subject/cost-center/push")
+    public McR pushAssessmentSubjectAndCostCenter(
+            @RequestBody YqbAssessmentCostCenterPushRequest request) {
+        log.info("开始新增易企报成本中心及维护考核主体,assessmentSubjectCode={},costCenterCode={},auxiliaryCodes={}",
+                request == null ? null : request.getAssessmentSubjectCode(),
+                request == null ? null : request.getCostCenterCode(),
+                request == null ? null : request.getAuxiliaryCodes());
+        YqbAssessmentCostCenterPushResult result =
+                yqbOptionPushService.pushAssessmentSubjectAndCostCenter(request);
+        return McR.success(result);
+    }
+
     /**
      * Excel 批量推送。
      * POST /api/sdzl/yqb/option/upload,Content-Type: multipart/form-data,文件字段名:file。

+ 38 - 0
mjava-sdzl/src/main/java/com/malk/sdzl/entity/dto/EqbSignResultCallback.java

@@ -0,0 +1,38 @@
+package com.malk.sdzl.entity.dto;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import lombok.Data;
+
+@Data
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class EqbSignResultCallback {
+    /** 整个签署流程结束事件,包含完成、撤销、过期和拒签。 */
+    public static final String ACTION = "SIGN_FLOW_COMPLETE";
+    public static final String STATUS_COMPLETED = "2";
+    public static final String STATUS_REVOKED = "3";
+    public static final String STATUS_EXPIRED = "5";
+    public static final String STATUS_REJECTED = "7";
+
+    private String action;
+    /** 回调首次触发时间,重试时保持不变,单位为毫秒。 */
+    private Long timestamp;
+    private String signFlowId;
+    private String signFlowTitle;
+    /** 流程最终状态(字符串):2 已完成,3 已撤销,5 已过期,7 已拒签。 */
+    private String signFlowStatus;
+    /** 流程结束原因,非正常签署完成时用于说明原因。 */
+    private String statusDescription;
+    /** 流程创建时间,单位为毫秒。 */
+    private Long signFlowCreateTime;
+    /** 流程开启时间,单位为毫秒。 */
+    private Long signFlowStartTime;
+    /** 流程完结时间,单位为毫秒。 */
+    private Long signFlowFinishTime;
+
+    /** 仅状态 2 表示整单签署成功,其他结束状态均不属于成功。 */
+    @JsonIgnore
+    public boolean isCompleted() {
+        return STATUS_COMPLETED.equals(signFlowStatus);
+    }
+}

+ 13 - 0
mjava-sdzl/src/main/java/com/malk/sdzl/entity/dto/EqbSignedFile.java

@@ -0,0 +1,13 @@
+package com.malk.sdzl.entity.dto;
+
+import lombok.Data;
+
+/**
+ * e签宝签署流程完成后返回的签署文件下载信息。
+ */
+@Data
+public class EqbSignedFile {
+    private String fileId;
+    private String fileName;
+    private String downloadUrl;
+}

+ 15 - 0
mjava-sdzl/src/main/java/com/malk/sdzl/entity/dto/YidaAttachment.java

@@ -0,0 +1,15 @@
+package com.malk.sdzl.entity.dto;
+
+import lombok.Data;
+
+/**
+ * 宜搭附件组件可直接接收的附件结构。
+ */
+@Data
+public class YidaAttachment {
+    private String downloadUrl;
+    private String previewUrl;
+    private String url;
+    private String name;
+    private String ext;
+}

+ 27 - 0
mjava-sdzl/src/main/java/com/malk/sdzl/entity/dto/YqbAssessmentCostCenterPushRequest.java

@@ -0,0 +1,27 @@
+package com.malk.sdzl.entity.dto;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 易企报考核主体及成本中心新增请求。
+ *
+ * <p>接口会先新增成本中心,成功或已存在时再新增考核主体。</p>
+ */
+@Data
+public class YqbAssessmentCostCenterPushRequest implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    /** 考核主体编码,对应新增考核主体请求中的 itemCode。 */
+    private String assessmentSubjectCode;
+    /** 考核主体名称,对应新增考核主体请求中的 itemName。 */
+    private String assessmentSubject;
+    /** 考核主体关联的辅助核算编码,多个编码以英文逗号分隔。 */
+    private String auxiliaryCodes;
+    /** 成本中心编码,对应新增成本中心请求中的 itemCode。 */
+    private String costCenterCode;
+    /** 成本中心名称,对应新增成本中心请求中的 itemName。 */
+    private String costCenter;
+}

+ 25 - 0
mjava-sdzl/src/main/java/com/malk/sdzl/entity/dto/YqbAssessmentCostCenterPushResult.java

@@ -0,0 +1,25 @@
+package com.malk.sdzl.entity.dto;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+/** 易企报考核主体及成本中心新增结果。 */
+@Data
+public class YqbAssessmentCostCenterPushResult implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    /** 是否已经调用易企报新增成本中心接口。 */
+    private boolean costCenterPushed;
+    /** 易企报新增成本中心接口的原始响应。 */
+    private YqbApiResponse<Object> costCenterResult;
+    /** 查询考核主体是否存在的接口响应;未执行查询时为空。 */
+    private YqbApiResponse<Object> assessmentSubjectQueryResult;
+    /** 查询到相同 itemCode 的考核主体时为 true。 */
+    private boolean assessmentSubjectExisted;
+    /** 是否已经调用易企报新增或修改考核主体接口。 */
+    private boolean assessmentSubjectPushed;
+    /** 易企报新增或修改考核主体接口的原始响应;未调用时为空。 */
+    private YqbApiResponse<Object> assessmentSubjectResult;
+}

+ 287 - 0
mjava-sdzl/src/main/java/com/malk/sdzl/service/Impl/EqbCallbackService.java

@@ -0,0 +1,287 @@
+package com.malk.sdzl.service.Impl;
+
+import com.alibaba.fastjson.JSON;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.malk.sdzl.entity.dto.EqbSignResultCallback;
+import com.malk.sdzl.entity.dto.EqbSignedFile;
+import com.malk.sdzl.entity.dto.YidaAttachment;
+import com.malk.sdzl.service.EqbService;
+import com.malk.server.aliwork.YDConf;
+import com.malk.server.aliwork.YDParam;
+import com.malk.service.aliwork.YDClient;
+import com.malk.service.aliwork.YDService;
+import com.malk.utils.UtilMap;
+import org.apache.commons.codec.DecoderException;
+import org.apache.commons.codec.binary.Hex;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.HttpHeaders;
+import org.springframework.stereotype.Service;
+import org.springframework.util.MultiValueMap;
+import org.springframework.util.StringUtils;
+
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.security.GeneralSecurityException;
+import java.security.MessageDigest;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.TreeMap;
+
+@Service
+public class EqbCallbackService {
+    private static final Logger LOG = LoggerFactory.getLogger(EqbCallbackService.class);
+    private static final ObjectMapper MAPPER = new ObjectMapper();
+    private static final int SIGN_FLOW_LOCK_COUNT = 64;
+    private static final Object[] SIGN_FLOW_LOCKS = createSignFlowLocks();
+    private static final String SIGN_FLOW_TRACKING_FORM = "FORM-3C73F8BB960244DE841E2DFD6B0A2818V7H5";
+
+    // 未单独配置回调凭据时,沿用 eqb.appId 和 eqb.appKey。
+    @Value("${eqb.callback.appId:}")
+    private String callbackAppId;
+    @Value("${eqb.callback.appKey:}")
+    private String callbackAppKey;
+    @Value("${eqb.appId}")
+    private String defaultAppId;
+    @Value("${eqb.appKey}")
+    private String defaultAppKey;
+
+    public void receive(byte[] body, HttpHeaders headers, MultiValueMap<String, String> query) {
+        verifySignature(body, headers, query);
+        JsonNode payload;
+        try {
+            payload = MAPPER.reader().with(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_TRAILING_TOKENS)
+                    .readTree(body);
+        } catch (IOException e) {
+            throw new IllegalArgumentException("Body must be a JSON object");
+        }
+        if (payload == null || !payload.isObject() || !payload.path("action").isTextual()
+                || !StringUtils.hasText(payload.path("action").asText())) {
+            throw new IllegalArgumentException("action is required");
+        }
+        if (!EqbSignResultCallback.ACTION.equals(payload.path("action").asText())) {
+            // 兼容平台新增事件:当前无需处理的事件直接确认接收成功。
+            LOG.debug("Ignored eSign callback action={}", payload.path("action").asText());
+            return;
+        }
+        requireText(payload, "signFlowId");
+        requireText(payload, "signFlowTitle");
+        requireLong(payload, "timestamp");
+        requireLong(payload, "signFlowCreateTime");
+        requireLong(payload, "signFlowStartTime");
+        requireLong(payload, "signFlowFinishTime");
+        requireText(payload, "signFlowStatus");
+        String status = payload.path("signFlowStatus").textValue();
+        if (!EqbSignResultCallback.STATUS_COMPLETED.equals(status)
+                && !EqbSignResultCallback.STATUS_REVOKED.equals(status)
+                && !EqbSignResultCallback.STATUS_EXPIRED.equals(status)
+                && !EqbSignResultCallback.STATUS_REJECTED.equals(status)) {
+            throw new IllegalArgumentException("signFlowStatus must be 2, 3, 5 or 7");
+        }
+        // 描述字段允许为空字符串,流程是否成功只根据 signFlowStatus 判断。
+        if (!payload.path("statusDescription").isTextual()) {
+            throw new IllegalArgumentException("statusDescription must be a string");
+        }
+        EqbSignResultCallback resultCallback;
+        try {
+            resultCallback = MAPPER.treeToValue(payload, EqbSignResultCallback.class);
+        } catch (IOException e) {
+            throw new IllegalArgumentException("Invalid sign flow result fields");
+        }
+        // 业务处理异常继续向外抛出,由控制器返回失败状态,供平台重试。
+        handleSignFlowComplete(resultCallback);
+    }
+
+    private void verifySignature(byte[] body, HttpHeaders headers, MultiValueMap<String, String> query) {
+        String appId = StringUtils.hasText(callbackAppId) ? callbackAppId : defaultAppId;
+        String appKey = StringUtils.hasText(callbackAppKey) ? callbackAppKey : defaultAppKey;
+        if (StringUtils.hasText(callbackAppId) != StringUtils.hasText(callbackAppKey)) {
+            throw new IllegalStateException("Configure both eqb.callback.appId and eqb.callback.appKey");
+        }
+        String timestamp = headers.getFirst("X-Tsign-Open-TIMESTAMP");
+        String signature = headers.getFirst("X-Tsign-Open-SIGNATURE");
+        String algorithm = headers.getFirst("X-Tsign-Open-SIGNATURE-ALGORITHM");
+        if (!appId.equals(headers.getFirst("X-Tsign-Open-App-Id"))
+                || timestamp == null || !timestamp.matches("[0-9]{1,19}")
+                || signature == null || !signature.matches("[0-9a-fA-F]{64}")
+                || (algorithm != null && !"hmac-sha256".equalsIgnoreCase(algorithm))) {
+            throw new SecurityException("Invalid callback headers");
+        }
+        if (body == null || body.length == 0) {
+            throw new IllegalArgumentException("Body is required");
+        }
+        StringBuilder prefix = new StringBuilder(timestamp);
+        for (Map.Entry<String, List<String>> entry : new TreeMap<>(query).entrySet()) {
+            if (entry.getValue() == null || entry.getValue().size() != 1 || entry.getValue().get(0) == null) {
+                throw new IllegalArgumentException("Repeated query parameters are not supported");
+            }
+            prefix.append(entry.getValue().get(0));
+        }
+        try {
+            Mac mac = Mac.getInstance("HmacSHA256");
+            mac.init(new SecretKeySpec(appKey.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
+            mac.update(prefix.toString().getBytes(StandardCharsets.UTF_8));
+            if (!MessageDigest.isEqual(mac.doFinal(body), Hex.decodeHex(signature.toCharArray()))) {
+                throw new SecurityException("Signature mismatch");
+            }
+        } catch (GeneralSecurityException | DecoderException e) {
+            throw new IllegalStateException("Cannot verify callback signature", e);
+        }
+    }
+    @Value("${aliwork.appType}")
+    private String appType;
+    @Value("${aliwork.systemToken}")
+    private String systemToken;
+    @Autowired
+    private YDService ydService;
+    @Autowired
+    private YDClient ydClient;
+    @Autowired
+    private EqbService eqbService;
+
+    /**
+     * 签署成功后将 e签宝返回的文件地址封装为宜搭附件,并回写到业务表。
+     */
+    public void handleSignFlowComplete(EqbSignResultCallback callback) {
+        LOG.info("e签宝签署流程结束: signFlowId={}, signFlowStatus={}, completed={}, signFlowFinishTime={}, timestamp={}",
+                callback.getSignFlowId(), callback.getSignFlowStatus(), callback.isCompleted(),
+                callback.getSignFlowFinishTime(), callback.getTimestamp());
+        if (!callback.isCompleted()) {
+            return;
+        }
+
+        synchronized (signFlowLock(callback.getSignFlowId())) {
+            List<Map> trackingRows = queryPendingTrackingRows(callback.getSignFlowId());
+            if (trackingRows == null || trackingRows.isEmpty()) {
+                LOG.info("e签宝签署流程已处理,忽略重复通知: signFlowId={}", callback.getSignFlowId());
+                return;
+            }
+
+            List<EqbSignedFile> signedFiles = getSignedFileDownloadUrls(callback.getSignFlowId());
+            List<YidaAttachment> attachments = toYidaAttachments(signedFiles);
+
+            for (Map trackingRow : trackingRows) {
+                String businessInstanceId = UtilMap.getString(trackingRow, "textField_mte6b6fs");
+                if (!StringUtils.hasText(businessInstanceId)) {
+                    throw new IllegalStateException("签署跟踪记录缺少业务表单实例ID");
+                }
+                Map<String, Object> businessUpdate = new LinkedHashMap<>();
+                businessUpdate.put("attachmentField_msu4s4kw", attachments);
+                businessUpdate.put("radioField_mu9983xg", "已完成");
+                businessUpdate.put("dateField_mse1k70h", callback.getSignFlowFinishTime());
+                updateYidaForm(businessInstanceId, businessUpdate);
+            }
+
+            // 业务表和附件都更新成功后再置为已处理;失败时保留“否”,让平台回调可以重试。
+            for (Map trackingRow : trackingRows) {
+                String trackingInstanceId = UtilMap.getString(trackingRow, "formInstanceId");
+                if (!StringUtils.hasText(trackingInstanceId)) {
+                    throw new IllegalStateException("签署跟踪记录缺少formInstanceId");
+                }
+                Map<String, Object> trackingUpdate = new LinkedHashMap<>();
+                trackingUpdate.put("radioField_mte6b6fw", "是");
+                trackingUpdate.put("radioField_mu9983xg", "已完成");
+                updateYidaForm(trackingInstanceId, trackingUpdate);
+            }
+        }
+    }
+
+    protected List<EqbSignedFile> getSignedFileDownloadUrls(String signFlowId) {
+        Map response = eqbService.getSignedFileDownloadUrls(signFlowId);
+        Object filesValue = response == null ? null : response.get("files");
+        List<EqbSignedFile> files = JSON.parseArray(JSON.toJSONString(filesValue), EqbSignedFile.class);
+        if (files == null || files.isEmpty()) {
+            throw new IllegalStateException("e签宝未返回已签署文件下载地址");
+        }
+        for (EqbSignedFile file : files) {
+            if (file == null || !StringUtils.hasText(file.getFileId())
+                    || !StringUtils.hasText(file.getFileName())
+                    || !StringUtils.hasText(file.getDownloadUrl())) {
+                throw new IllegalStateException("e签宝返回的已签署文件信息不完整");
+            }
+        }
+        return files;
+    }
+
+    static List<YidaAttachment> toYidaAttachments(List<EqbSignedFile> signedFiles) {
+        if (signedFiles == null || signedFiles.isEmpty()) {
+            throw new IllegalArgumentException("已签署文件不能为空");
+        }
+        List<YidaAttachment> attachments = new ArrayList<>(signedFiles.size());
+        for (EqbSignedFile signedFile : signedFiles) {
+            if (signedFile == null || !StringUtils.hasText(signedFile.getFileName())
+                    || !StringUtils.hasText(signedFile.getDownloadUrl())) {
+                throw new IllegalArgumentException("已签署文件信息不完整");
+            }
+            String fileName = signedFile.getFileName().trim();
+            int extensionSeparator = fileName.lastIndexOf('.');
+            if (extensionSeparator < 0 || extensionSeparator == fileName.length() - 1) {
+                throw new IllegalArgumentException("已签署文件缺少扩展名: " + fileName);
+            }
+            String downloadUrl = signedFile.getDownloadUrl().trim();
+            YidaAttachment attachment = new YidaAttachment();
+            attachment.setDownloadUrl(downloadUrl);
+            attachment.setPreviewUrl(downloadUrl);
+            attachment.setUrl(downloadUrl);
+            attachment.setName(fileName);
+            attachment.setExt(fileName.substring(extensionSeparator + 1).toLowerCase(Locale.ROOT));
+            attachments.add(attachment);
+        }
+        return attachments;
+    }
+
+    private List<Map> queryPendingTrackingRows(String signFlowId) {
+        Map<String, Object> searchFields = new LinkedHashMap<>();
+        searchFields.put("textField_mte3s2rf", signFlowId);
+        searchFields.put("radioField_mte6b6fw", "否");
+        return ydService.queryFormData_all(YDParam.builder()
+                .appType(appType)
+                .systemToken(systemToken)
+                .formUuid(SIGN_FLOW_TRACKING_FORM)
+                .searchFieldJson(JSON.toJSONString(searchFields))
+                .build());
+    }
+
+    private void updateYidaForm(String formInstanceId, Map<String, Object> updateFields) {
+        ydClient.operateData(YDParam.builder()
+                .appType(appType)
+                .systemToken(systemToken)
+                .formInstanceId(formInstanceId)
+                .useLatestVersion(true)
+                .updateFormDataJson(JSON.toJSONString(updateFields))
+                .build(), YDConf.FORM_OPERATION.update);
+    }
+
+    private static Object signFlowLock(String signFlowId) {
+        return SIGN_FLOW_LOCKS[Math.floorMod(signFlowId.hashCode(), SIGN_FLOW_LOCK_COUNT)];
+    }
+
+    private static Object[] createSignFlowLocks() {
+        Object[] locks = new Object[SIGN_FLOW_LOCK_COUNT];
+        for (int index = 0; index < locks.length; index++) {
+            locks[index] = new Object();
+        }
+        return locks;
+    }
+
+    private void requireText(JsonNode payload, String field) {
+        if (!payload.path(field).isTextual() || !StringUtils.hasText(payload.path(field).asText())) {
+            throw new IllegalArgumentException(field + " is required");
+        }
+    }
+
+    private void requireLong(JsonNode payload, String field) {
+        if (!payload.path(field).isIntegralNumber() || !payload.path(field).canConvertToLong()) {
+            throw new IllegalArgumentException(field + " must be an int64");
+        }
+    }
+
+}

+ 53 - 8
mjava-sdzl/src/main/java/com/malk/sdzl/service/Impl/EqbServiceImpl.java

@@ -60,7 +60,18 @@ public class EqbServiceImpl implements EqbService {
     private static final String OUR_PARTY = "乙方";
     private static final String PARTY_A_SIGN_KEYWORD = "甲方/甲方授权代表电子签名";
     private static final String PARTY_B_SIGN_KEYWORD = "乙方电子签名";
-    private static final String OUR_PARTY_SEAL_ID = "25b69e0c-4d74-4c8d-9164-eb10891a284d";
+    /** 未配置主体或印章类型时使用的默认印章。 */
+    private static final String DEFAULT_OUR_PARTY_SEAL_ID = "25b69e0c-4d74-4c8d-9164-eb10891a284d";
+    /** 宜搭表单中的考核主体字段。 */
+    private static final String ASSESSMENT_SUBJECT_FIELD_ID = "textField_asr5cxt";
+    /** 宜搭表单中的核算印章类型字段。 */
+    private static final String ACCOUNTING_SEAL_TYPE_FIELD_ID = "textField_3mf3de6";
+    /**
+     * 考核主体与印章类型对应的 e 签宝印章 ID。
+     * 后续新增主体时,在该映射中增加一个主体及其印章类型配置即可。
+     */
+    private static final Map<String, Map<String, String>> ASSESSMENT_SUBJECT_SEAL_IDS =
+            createAssessmentSubjectSealIds();
     private static final String INTERNAL_SIGN_KEYWORD = "_signKeyword";
     private static final Set<String> UPLOAD_FILE_EXTENSIONS = new HashSet<>(Arrays.asList(
             "pdf", "docx", "doc", "rtf", "xlsx", "xls", "pptx", "ppt", "wps", "et", "dps",
@@ -119,6 +130,18 @@ public class EqbServiceImpl implements EqbService {
 
     private volatile List<Map> MATE;
 
+    private static Map<String, Map<String, String>> createAssessmentSubjectSealIds() {
+        Map<String, String> tianJiSealIds = new LinkedHashMap<>();
+        tianJiSealIds.put("公章", "4b8cd76c-d0e4-4874-badb-0df0a974abf1");
+        tianJiSealIds.put("合同专用章", "7a51d006-bb92-4b5b-86c6-4f639ee94a40");
+        tianJiSealIds.put("合同章", "7a51d006-bb92-4b5b-86c6-4f639ee94a40");
+
+        Map<String, Map<String, String>> subjectSealIds = new LinkedHashMap<>();
+        subjectSealIds.put("上海添玑好房网络服务有限公司",
+                Collections.unmodifiableMap(tianJiSealIds));
+        return Collections.unmodifiableMap(subjectSealIds);
+    }
+
     /**
      * 加载json
      */
@@ -220,7 +243,8 @@ public class EqbServiceImpl implements EqbService {
                 FormAttachment attachment = attachments.get(i);
                 stage = "下载附件:" + attachment.fileName;
                 String downloadUrl = attachment.downloadUrl;
-                if (DIRECT_URL_ATTACHMENT_FIELD.equals(attachment.fieldId)) {
+
+                if (!DIRECT_URL_ATTACHMENT_FIELD.equals(attachment.fieldId)) {
                     // 宜搭原生附件需先换取临时免登地址,第二个组件直接使用其下载地址。
                     downloadUrl = ydClient.convertTemporaryUrl(downloadUrl, appType, systemToken);
                     if (!hasText(downloadUrl)) {
@@ -366,6 +390,7 @@ public class EqbServiceImpl implements EqbService {
     protected JSONArray buildFormSigners(String formUuid, Map formData) {
 
         JSONArray signers = new JSONArray();
+        String assignedSealId = resolveAssignedSealId(formData);
         String jName = UtilMap.getString(formData,"textField_e6og3c9");
         String jPhone = UtilMap.getString(formData,"textField_ismg45u");
         String yName = UtilMap.getString(formData,"textField_mtwp487i");
@@ -427,26 +452,46 @@ public class EqbServiceImpl implements EqbService {
                     signer.put("orgSignerInfo", orgSignerInfo);
                 }
             }
-            signer.put("signFields", Collections.singletonList(createSignField(partyName)));
+            signer.put("signFields", Collections.singletonList(
+                    createSignField(partyName, assignedSealId)));
             signers.add(signer);
         }
         return signers;
     }
 
-    private JSONObject createSignField(String partyName) {
+    /** 根据考核主体和核算印章类型选择印章;未配置的组合统一使用默认印章。 */
+    private String resolveAssignedSealId(Map formData) {
+        if (formData == null) {
+            return DEFAULT_OUR_PARTY_SEAL_ID;
+        }
+        String assessmentSubject = trimToEmpty(
+                UtilMap.getString(formData, ASSESSMENT_SUBJECT_FIELD_ID));
+        String sealType = trimToEmpty(
+                UtilMap.getString(formData, ACCOUNTING_SEAL_TYPE_FIELD_ID));
+        Map<String, String> sealIdsByType = ASSESSMENT_SUBJECT_SEAL_IDS.get(assessmentSubject);
+        if (sealIdsByType == null) {
+            return DEFAULT_OUR_PARTY_SEAL_ID;
+        }
+        String assignedSealId = sealIdsByType.get(sealType);
+        return hasText(assignedSealId) ? assignedSealId : DEFAULT_OUR_PARTY_SEAL_ID;
+    }
+
+    private String trimToEmpty(String value) {
+        return value == null ? "" : value.trim();
+    }
+
+    private JSONObject createSignField(String partyName, String assignedSealId) {
         boolean isOurParty = OUR_PARTY.equals(partyName);
         JSONObject fieldConfig = new JSONObject();
         fieldConfig.put("autoSign", isOurParty);
         fieldConfig.put("freeMode", false);
         fieldConfig.put("signFieldStyle", 1);
         if (isOurParty) {
-            fieldConfig.put("assignedSealId", OUR_PARTY_SEAL_ID);
+            fieldConfig.put("assignedSealId", hasText(assignedSealId)
+                    ? assignedSealId : DEFAULT_OUR_PARTY_SEAL_ID);
         }
         JSONObject field = new JSONObject();
         field.put("normalSignFieldConfig", fieldConfig);
-        if (isOurParty){
-
-        }
         field.put(INTERNAL_SIGN_KEYWORD, getSignKeyword(partyName));
         return field;
     }

+ 145 - 0
mjava-sdzl/src/main/java/com/malk/sdzl/service/Impl/YqbOptionPushServiceImpl.java

@@ -1,8 +1,13 @@
 package com.malk.sdzl.service.Impl;
 
 import com.alibaba.excel.EasyExcel;
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
 import com.malk.sdzl.common.YqbApiClient;
 import com.malk.sdzl.entity.dto.YqbApiResponse;
+import com.malk.sdzl.entity.dto.YqbAssessmentCostCenterPushRequest;
+import com.malk.sdzl.entity.dto.YqbAssessmentCostCenterPushResult;
 import com.malk.sdzl.entity.dto.YqbCategoryCreateRequest;
 import com.malk.sdzl.entity.dto.YqbOptionExcelRow;
 import com.malk.sdzl.entity.dto.YqbOptionPushRequest;
@@ -33,10 +38,16 @@ public class YqbOptionPushServiceImpl implements YqbOptionPushService {
 
     private static final String ADD_CATEGORY_PATH = "/reimburse/auxiliary/v2/category/add.rjson";
     private static final String ADD_OPTION_PATH = "/reimburse/auxiliary/v2/item/add.rjson";
+    /** 易企报“获取辅助核算列表”接口。 */
+    private static final String QUERY_OPTION_LIST_PATH = "/reimburse/auxiliary/getAuxiliaryList.rjson";
     /** 易企报“修改辅助选项”接口。 */
     private static final String UPDATE_OPTION_PATH = "/reimburse/auxiliary/v2/item/update.rjson";
     /** 供应商辅助类别编码。 */
     private static final String SUPPLIER_CATEGORY_CODE = "02";
+    /** 考核主体辅助类别编码。 */
+    private static final String ASSESSMENT_SUBJECT_CATEGORY_CODE = "07";
+    /** 成本中心辅助类别编码。 */
+    private static final String COST_CENTER_CATEGORY_CODE = "09";
     /** 查询本地税号及对应的易企报发票抬头 ID。 */
     private static final String SELECT_TAX_PAYER_NO_AND_INVOICE_ID_SQL =
             "SELECT taxPayerNo, invoiceId FROM sdzlInvoice "
@@ -76,6 +87,99 @@ public class YqbOptionPushServiceImpl implements YqbOptionPushService {
         return yqbApiClient.post(ADD_OPTION_PATH, request.toRequestBody(), "新增选项");
     }
 
+    /**
+     * 依次新增成本中心和考核主体。
+     *
+     * <p>成本中心的 categoryCode 固定为 09,且不传 parentCode 和 auxiliaryCodes。
+     * 随后查询 categoryCode=07 的考核主体:itemCode 不存在时新增,存在时修改;
+     * 新增和修改考核主体均传入调用方提供的 auxiliaryCodes。</p>
+     */
+    @Override
+    public YqbAssessmentCostCenterPushResult pushAssessmentSubjectAndCostCenter(
+            YqbAssessmentCostCenterPushRequest request) {
+        validateAssessmentCostCenterRequest(request);
+
+        // 第一步:新增成本中心,类别固定为 09,不传 parentCode 和 auxiliaryCodes。
+        YqbOptionPushRequest costCenterRequest = new YqbOptionPushRequest();
+        costCenterRequest.setCategoryCode(COST_CENTER_CATEGORY_CODE);
+        costCenterRequest.setItemCode(request.getCostCenterCode());
+        costCenterRequest.setItemName(request.getCostCenter());
+
+        YqbAssessmentCostCenterPushResult result = new YqbAssessmentCostCenterPushResult();
+        result.setCostCenterPushed(true);
+        YqbApiResponse<Object> costCenterResult = pushOne(costCenterRequest);
+        result.setCostCenterResult(costCenterResult);
+
+        // 成本中心新增成功或已经存在时,才继续维护考核主体。
+        if (!isSuccessOrAlreadyExists(costCenterResult)) {
+            log.warn("成本中心新增失败,停止维护考核主体,itemCode={},result={},message={}",
+                    request.getCostCenterCode(), costCenterResult.getResult(),
+                    costCenterResult.getMessage());
+            return result;
+        }
+
+        // 第二步:查询 07 类别下是否已有相同 itemCode 的考核主体。
+        YqbApiResponse<Object> queryResult = queryAssessmentSubjects();
+        result.setAssessmentSubjectQueryResult(queryResult);
+        if (!queryResult.isSuccess()) {
+            log.warn("查询考核主体失败,停止新增或修改,itemCode={},result={},message={}",
+                    request.getAssessmentSubjectCode(), queryResult.getResult(), queryResult.getMessage());
+            return result;
+        }
+
+        boolean assessmentSubjectExisted = containsItemCode(
+                queryResult.getData(), request.getAssessmentSubjectCode());
+        result.setAssessmentSubjectExisted(assessmentSubjectExisted);
+
+        // 第三步:不存在则新增,存在则以相同的新旧 itemCode 修改考核主体。
+        YqbOptionPushRequest assessmentSubjectRequest = new YqbOptionPushRequest();
+        assessmentSubjectRequest.setCategoryCode(ASSESSMENT_SUBJECT_CATEGORY_CODE);
+        assessmentSubjectRequest.setItemCode(request.getAssessmentSubjectCode());
+        assessmentSubjectRequest.setItemName(request.getAssessmentSubject());
+        assessmentSubjectRequest.setAuxiliaryCodes(request.getAuxiliaryCodes());
+
+        result.setAssessmentSubjectPushed(true);
+        if (assessmentSubjectExisted) {
+            assessmentSubjectRequest.setOldItemCode(request.getAssessmentSubjectCode());
+            result.setAssessmentSubjectResult(yqbApiClient.post(
+                    UPDATE_OPTION_PATH, assessmentSubjectRequest.toRequestBody(), "修改考核主体"));
+        } else {
+            result.setAssessmentSubjectResult(pushOne(assessmentSubjectRequest));
+        }
+        return result;
+    }
+
+    /** 查询 07 类别下的全部考核主体。 */
+    private YqbApiResponse<Object> queryAssessmentSubjects() {
+        Map<String, Object> requestBody = new HashMap<>();
+        requestBody.put("categoryCode", ASSESSMENT_SUBJECT_CATEGORY_CODE);
+        return yqbApiClient.post(QUERY_OPTION_LIST_PATH, requestBody, "查询考核主体");
+    }
+
+    /** 按接口文档中的 data.items[].itemCode 判断考核主体是否存在。 */
+    private boolean containsItemCode(Object data, String itemCode) {
+        if (data == null) {
+            return false;
+        }
+        JSONObject dataObject = data instanceof JSONObject
+                ? (JSONObject) data
+                : JSON.parseObject(JSON.toJSONString(data));
+        if (dataObject == null) {
+            throw new IllegalStateException("查询考核主体接口返回的data格式异常");
+        }
+        JSONArray items = dataObject.getJSONArray("items");
+        if (items == null) {
+            return false;
+        }
+        for (int i = 0; i < items.size(); i++) {
+            JSONObject item = items.getJSONObject(i);
+            if (item != null && itemCode.equals(item.getString("itemCode"))) {
+                return true;
+            }
+        }
+        return false;
+    }
+
     @Override
     public YqbOptionPushResult pushByExcel(MultipartFile file) {
         if (file == null || file.isEmpty()) {
@@ -458,6 +562,47 @@ public class YqbOptionPushServiceImpl implements YqbOptionPushService {
         }
     }
 
+    private void validateAssessmentCostCenterRequest(YqbAssessmentCostCenterPushRequest request) {
+        if (request == null) {
+            throw new IllegalArgumentException("请求参数不能为空");
+        }
+        if (isBlank(request.getAssessmentSubjectCode())) {
+            throw new IllegalArgumentException("考核主体编码不能为空");
+        }
+        if (isBlank(request.getAssessmentSubject())) {
+            throw new IllegalArgumentException("考核主体不能为空");
+        }
+        if (isBlank(request.getAuxiliaryCodes())) {
+            throw new IllegalArgumentException("auxiliaryCodes不能为空");
+        }
+        if (isBlank(request.getCostCenterCode())) {
+            throw new IllegalArgumentException("成本中心编码不能为空");
+        }
+        if (isBlank(request.getCostCenter())) {
+            throw new IllegalArgumentException("成本中心不能为空");
+        }
+    }
+
+    /** 判断当前选项是否新增成功或已经存在。 */
+    private boolean isSuccessOrAlreadyExists(YqbApiResponse<Object> response) {
+        if (response == null) {
+            return false;
+        }
+        if (response.isSuccess()) {
+            return true;
+        }
+        String message = response.getMessage();
+        if (message == null) {
+            return false;
+        }
+        String normalizedMessage = message.trim().toLowerCase();
+        return normalizedMessage.contains("已存在")
+                || normalizedMessage.contains("已经存在")
+                || normalizedMessage.contains("重复")
+                || normalizedMessage.contains("already exist")
+                || normalizedMessage.contains("duplicate");
+    }
+
     private boolean isBlank(String value) {
         return value == null || value.trim().isEmpty();
     }

+ 12 - 0
mjava-sdzl/src/main/java/com/malk/sdzl/service/YqbOptionPushService.java

@@ -1,6 +1,8 @@
 package com.malk.sdzl.service;
 
 import com.malk.sdzl.entity.dto.YqbApiResponse;
+import com.malk.sdzl.entity.dto.YqbAssessmentCostCenterPushRequest;
+import com.malk.sdzl.entity.dto.YqbAssessmentCostCenterPushResult;
 import com.malk.sdzl.entity.dto.YqbCategoryCreateRequest;
 import com.malk.sdzl.entity.dto.YqbOptionPushRequest;
 import com.malk.sdzl.entity.dto.YqbOptionPushResult;
@@ -15,6 +17,16 @@ public interface YqbOptionPushService {
     /** 推送一条辅助选项。 */
     YqbApiResponse<Object> pushOne(YqbOptionPushRequest request);
 
+    /**
+     * 依次新增成本中心和考核主体。
+     * 成本中心新增成功或返回已存在时,才继续新增考核主体。
+     *
+     * @param request 考核主体、成本中心及考核主体关联的辅助核算编码
+     * @return 两次易企报调用结果;未调用考核主体时,其响应为空
+     */
+    YqbAssessmentCostCenterPushResult pushAssessmentSubjectAndCostCenter(
+            YqbAssessmentCostCenterPushRequest request);
+
     /** 上传 Excel 后按行循环推送辅助选项。 */
     YqbOptionPushResult pushByExcel(MultipartFile file);