瀏覽代碼

feat(akds): add month closing and duplicate check

malk 4 周之前
父節點
當前提交
4738dad6a7

+ 14 - 0
mjava-akdsbeisen/src/main/java/com/malk/controller/CustomerEnrichController.java

@@ -2,6 +2,7 @@ package com.malk.controller;
 
 
 import com.malk.server.common.McR;
 import com.malk.server.common.McR;
 import com.malk.server.customer.CustomerDetailRequest;
 import com.malk.server.customer.CustomerDetailRequest;
+import com.malk.server.customer.CustomerDuplicateCheckRequest;
 import com.malk.server.customer.CustomerEnrichRequest;
 import com.malk.server.customer.CustomerEnrichRequest;
 import com.malk.server.customer.CustomerEnrichResult;
 import com.malk.server.customer.CustomerEnrichResult;
 import com.malk.server.customer.CustomerSearchRequest;
 import com.malk.server.customer.CustomerSearchRequest;
@@ -25,6 +26,19 @@ public class CustomerEnrichController {
     @Autowired
     @Autowired
     private CustomerEnrichService enrichService;
     private CustomerEnrichService enrichService;
 
 
+    /**
+     * 按正式企业名称查询有效客户流程。
+     *
+     * @param request 查重请求
+     * @return 是否存在运行中或审批通过的同名客户流程
+     */
+    @PostMapping("/duplicate-check")
+    public McR<Boolean> duplicateCheck(
+            @Validated @RequestBody CustomerDuplicateCheckRequest request) {
+        return McR.success(enrichService.hasDuplicateCustomer(
+                request.getCompanyName(), request.getCurrentProcessInstanceId()));
+    }
+
     /**
     /**
      * 搜索企业候选,仅调用天眼查 816,不自动选择候选。
      * 搜索企业候选,仅调用天眼查 816,不自动选择候选。
      *
      *

+ 61 - 0
mjava-akdsbeisen/src/main/java/com/malk/controller/MonthClosingController.java

@@ -0,0 +1,61 @@
+package com.malk.controller;
+
+import com.malk.server.common.McR;
+import com.malk.server.workhours.MonthClosingCheckRequest;
+import com.malk.server.workhours.MonthClosingStatusResult;
+import com.malk.server.workhours.MonthClosingValidationResult;
+import com.malk.service.workhours.MonthClosingService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.servlet.http.HttpServletResponse;
+
+/**
+ * 月度封账状态和提交前校验接口。
+ */
+@RestController
+@RequestMapping("/month-closing")
+public class MonthClosingController {
+
+    @Autowired
+    private MonthClosingService monthClosingService;
+
+    /**
+     * 查询目标月份是否已封账。
+     *
+     * @param request 月份请求
+     * @param response HTTP 响应
+     * @return 封账状态
+     */
+    @PostMapping("/status")
+    public McR<MonthClosingStatusResult> status(
+            @RequestBody MonthClosingCheckRequest request,
+            HttpServletResponse response) {
+        disableCache(response);
+        return McR.success(monthClosingService.status(request));
+    }
+
+    /**
+     * 校验目标月份是否允许提交封账流程。
+     *
+     * @param request 月份请求
+     * @param response HTTP 响应
+     * @return 封账提交前校验结果
+     */
+    @PostMapping("/validate")
+    public McR<MonthClosingValidationResult> validate(
+            @RequestBody MonthClosingCheckRequest request,
+            HttpServletResponse response) {
+        disableCache(response);
+        return McR.success(monthClosingService.validate(request));
+    }
+
+    private static void disableCache(HttpServletResponse response) {
+        response.setHeader("Cache-Control", "no-store, no-cache, must-revalidate");
+        response.setHeader("Pragma", "no-cache");
+        response.setDateHeader("Expires", 0L);
+    }
+}

+ 22 - 0
mjava-akdsbeisen/src/main/java/com/malk/server/customer/CustomerConf.java

@@ -1,6 +1,7 @@
 package com.malk.server.customer;
 package com.malk.server.customer;
 
 
 import lombok.Data;
 import lombok.Data;
+import lombok.ToString;
 import org.springframework.boot.context.properties.ConfigurationProperties;
 import org.springframework.boot.context.properties.ConfigurationProperties;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.context.annotation.Configuration;
 
 
@@ -26,6 +27,27 @@ public class CustomerConf {
      */
      */
     private String yidaFormUuid;
     private String yidaFormUuid;
 
 
+    /**
+     * 客户流程查询使用的宜搭应用密钥,通过环境变量注入。
+     */
+    @ToString.Exclude
+    private String yidaSystemToken;
+
+    /**
+     * 客户工商信息流程表单,用于客户名称查重。
+     */
+    private String duplicateProcessFormUuid = "FORM-743EC9F3012146B3831594126EE6117F12U8";
+
+    /**
+     * 客户工商信息流程中的客户名称字段。
+     */
+    private String duplicateCustomerNameFieldId = "selectField_mrnnj1vh";
+
+    /**
+     * 客户流程历史数据中的客户名称文本字段。
+     */
+    private String duplicateLegacyCustomerNameFieldId = "textField_mjmje451";
+
     /**
     /**
      * 天眼查企业主页 URL 模板 (拼装 "工商信息网址" 字段)
      * 天眼查企业主页 URL 模板 (拼装 "工商信息网址" 字段)
      * 例: https://www.tianyancha.com/company/{id}
      * 例: https://www.tianyancha.com/company/{id}

+ 20 - 0
mjava-akdsbeisen/src/main/java/com/malk/server/customer/CustomerDuplicateCheckRequest.java

@@ -0,0 +1,20 @@
+package com.malk.server.customer;
+
+import lombok.Data;
+
+import javax.validation.constraints.NotBlank;
+
+/**
+ * 客户流程查重请求。
+ */
+@Data
+public class CustomerDuplicateCheckRequest {
+
+    @NotBlank(message = "companyName 不能为空")
+    private String companyName;
+
+    /**
+     * 编辑已有流程时传入,用于排除当前流程实例。
+     */
+    private String currentProcessInstanceId;
+}

+ 26 - 0
mjava-akdsbeisen/src/main/java/com/malk/server/workhours/MonthClosingCheckRequest.java

@@ -0,0 +1,26 @@
+package com.malk.server.workhours;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * 月度封账状态及提交前校验请求。
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class MonthClosingCheckRequest {
+
+    /**
+     * 目标月份,格式 yyyyMM。
+     */
+    private String monthText;
+
+    /**
+     * 本次流程动作:封账或启封。仅提交前校验接口必填。
+     */
+    private String closingStatus;
+}

+ 24 - 0
mjava-akdsbeisen/src/main/java/com/malk/server/workhours/MonthClosingConf.java

@@ -0,0 +1,24 @@
+package com.malk.server.workhours;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+/**
+ * 月度封账流程配置。
+ */
+@Data
+@Component
+@ConfigurationProperties(prefix = "month-closing")
+public class MonthClosingConf {
+
+    private String formUuid;
+
+    private String processCode;
+
+    private String monthDateField;
+
+    private String statusField;
+
+    private String descriptionField;
+}

+ 31 - 0
mjava-akdsbeisen/src/main/java/com/malk/server/workhours/MonthClosingStatusResult.java

@@ -0,0 +1,31 @@
+package com.malk.server.workhours;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * 月度封账状态。
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class MonthClosingStatusResult {
+
+    private String monthText;
+
+    private boolean closed;
+
+    /**
+     * 同月最后一条审批通过记录的状态:封账、启封或空。
+     */
+    private String currentStatus;
+
+    private int approvedClosingCount;
+
+    private int approvedReopeningCount;
+
+    private int runningClosingCount;
+}

+ 38 - 0
mjava-akdsbeisen/src/main/java/com/malk/server/workhours/MonthClosingValidationResult.java

@@ -0,0 +1,38 @@
+package com.malk.server.workhours;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * 月度封账提交前校验结果。
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class MonthClosingValidationResult {
+
+    private String monthText;
+
+    private boolean allowed;
+
+    private String requestedStatus;
+
+    private String currentStatus;
+
+    private boolean alreadyClosed;
+
+    private int closingRunningCount;
+
+    private int projectRunningCount;
+
+    private int otherRunningCount;
+
+    private int writeBackPendingCount;
+
+    private int summaryPendingCount;
+
+    private String message;
+}

+ 9 - 0
mjava-akdsbeisen/src/main/java/com/malk/service/customer/CustomerEnrichService.java

@@ -9,6 +9,15 @@ import com.malk.server.customer.CustomerSearchResult;
  */
  */
 public interface CustomerEnrichService {
 public interface CustomerEnrichService {
 
 
+    /**
+     * 查询是否存在同名的有效客户流程。
+     *
+     * @param companyName 正式企业名称
+     * @param currentProcessInstanceId 当前流程实例 ID,可空
+     * @return 是否存在其他运行中或审批通过的同名流程
+     */
+    boolean hasDuplicateCustomer(String companyName, String currentProcessInstanceId);
+
     /**
     /**
      * 搜索工商候选企业,不自动选择候选。
      * 搜索工商候选企业,不自动选择候选。
      *
      *

+ 101 - 0
mjava-akdsbeisen/src/main/java/com/malk/service/customer/impl/CustomerEnrichServiceImpl.java

@@ -1,5 +1,8 @@
 package com.malk.service.customer.impl;
 package com.malk.service.customer.impl;
 
 
+import com.alibaba.fastjson.JSON;
+import com.malk.server.aliwork.YDConf;
+import com.malk.server.aliwork.YDParam;
 import com.malk.server.common.McException;
 import com.malk.server.common.McException;
 import com.malk.server.customer.CustomerConf;
 import com.malk.server.customer.CustomerConf;
 import com.malk.server.customer.CustomerEnrichMeta;
 import com.malk.server.customer.CustomerEnrichMeta;
@@ -10,6 +13,8 @@ import com.malk.server.customer.CustomerSearchResult;
 import com.malk.server.tianyancha.TycCompany;
 import com.malk.server.tianyancha.TycCompany;
 import com.malk.server.tianyancha.TycSearchItem;
 import com.malk.server.tianyancha.TycSearchItem;
 import com.malk.server.tianyancha.TycSearchResponse;
 import com.malk.server.tianyancha.TycSearchResponse;
+import com.malk.server.dingtalk.DDR_New;
+import com.malk.service.aliwork.YDClient;
 import com.malk.service.customer.CustomerEnrichService;
 import com.malk.service.customer.CustomerEnrichService;
 import com.malk.service.tianyancha.TycClient;
 import com.malk.service.tianyancha.TycClient;
 import lombok.extern.slf4j.Slf4j;
 import lombok.extern.slf4j.Slf4j;
@@ -61,6 +66,102 @@ public class CustomerEnrichServiceImpl implements CustomerEnrichService {
     @Autowired
     @Autowired
     private CustomerConf conf;
     private CustomerConf conf;
 
 
+    @Autowired
+    private YDClient ydClient;
+
+    /**
+     * 查询是否存在其他有效的同名客户流程。
+     *
+     * @param companyName 正式企业名称
+     * @param currentProcessInstanceId 当前流程实例 ID,可空
+     * @return 是否存在运行中或审批通过的同名流程
+     */
+    @Override
+    public boolean hasDuplicateCustomer(String companyName, String currentProcessInstanceId) {
+        String normalizedName = StringUtils.trimToNull(companyName);
+        McException.assertParamException(normalizedName == null, "companyName 不能为空");
+        McException.assertAccessException(
+                StringUtils.isBlank(conf.getYidaSystemToken()), "客户流程查询凭据未配置");
+
+        if (hasDuplicateByField(conf.getDuplicateCustomerNameFieldId(), normalizedName,
+                currentProcessInstanceId)) {
+            return true;
+        }
+        String legacyFieldId = conf.getDuplicateLegacyCustomerNameFieldId();
+        return !StringUtils.equals(conf.getDuplicateCustomerNameFieldId(), legacyFieldId)
+                && hasDuplicateByField(legacyFieldId, normalizedName, currentProcessInstanceId);
+    }
+
+    /**
+     * 按一个客户名称字段查询运行中和审批通过的同名客户流程。
+     *
+     * @param fieldId 客户名称字段 ID
+     * @param companyName 正式企业名称
+     * @param currentProcessInstanceId 当前流程实例 ID,可空
+     * @return 是否存在其他有效流程
+     */
+    private boolean hasDuplicateByField(String fieldId, String companyName,
+                                        String currentProcessInstanceId) {
+        if (StringUtils.isBlank(fieldId)) {
+            return false;
+        }
+        Map<String, String> searchFields = Collections.singletonMap(fieldId, companyName);
+        return hasOtherProcess(searchFields, "RUNNING", null, currentProcessInstanceId)
+                || hasOtherProcess(searchFields, "COMPLETED", "agree", currentProcessInstanceId);
+    }
+
+    /**
+     * 查询指定状态的客户流程,并排除当前流程实例。
+     *
+     * @param searchFields 宜搭字段查询条件
+     * @param instanceStatus 流程状态
+     * @param approvedResult 审批结果,可空
+     * @param currentProcessInstanceId 当前流程实例 ID,可空
+     * @return 是否存在其他流程
+     */
+    @SuppressWarnings("unchecked")
+    private boolean hasOtherProcess(Map<String, String> searchFields, String instanceStatus,
+                                    String approvedResult, String currentProcessInstanceId) {
+        DDR_New response = ydClient.queryData(YDParam.builder()
+                .appType(conf.getYidaAppType())
+                .systemToken(conf.getYidaSystemToken())
+                .formUuid(conf.getDuplicateProcessFormUuid())
+                .searchFieldJson(JSON.toJSONString(searchFields))
+                .instanceStatus(instanceStatus)
+                .approvedResult(approvedResult)
+                .pageSize(10)
+                .build(), YDConf.FORM_QUERY.retrieve_search_process);
+        List<Map<String, Object>> rows = response == null
+                ? Collections.emptyList()
+                : (List<Map<String, Object>>) response.getData();
+        if (rows == null || rows.isEmpty()) {
+            return false;
+        }
+        for (Map<String, Object> row : rows) {
+            if (!isCurrentProcess(row, currentProcessInstanceId)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * 判断查询记录是否为当前流程实例。
+     *
+     * @param row 宜搭流程记录
+     * @param currentProcessInstanceId 当前流程实例 ID,可空
+     * @return 是否为当前流程
+     */
+    private boolean isCurrentProcess(Map<String, Object> row, String currentProcessInstanceId) {
+        if (StringUtils.isBlank(currentProcessInstanceId) || row == null) {
+            return false;
+        }
+        Object processId = row.get("processInstanceId");
+        if (processId == null) processId = row.get("processInstId");
+        if (processId == null) processId = row.get("procInsId");
+        return processId != null && currentProcessInstanceId.trim().equals(String.valueOf(processId));
+    }
+
     /**
     /**
      * 搜索工商候选企业,不自动选择候选。
      * 搜索工商候选企业,不自动选择候选。
      *
      *

+ 515 - 0
mjava-akdsbeisen/src/main/java/com/malk/service/workhours/MonthClosingService.java

@@ -0,0 +1,515 @@
+package com.malk.service.workhours;
+
+import com.alibaba.fastjson.JSON;
+import com.malk.server.aliwork.YDConf;
+import com.malk.server.aliwork.YDParam;
+import com.malk.server.common.McException;
+import com.malk.server.dingtalk.DDR_New;
+import com.malk.server.workhours.MonthClosingCheckRequest;
+import com.malk.server.workhours.MonthClosingConf;
+import com.malk.server.workhours.MonthClosingStatusResult;
+import com.malk.server.workhours.MonthClosingValidationResult;
+import com.malk.server.workhours.WHConf;
+import com.malk.service.aliwork.YDClient;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.time.YearMonth;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.time.format.ResolverStyle;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+/**
+ * 月度封账统一校验服务。
+ *
+ * <p>TimeCard 与封账流程提交前均调用本服务,避免页面各自维护不同口径。</p>
+ */
+@Service
+public class MonthClosingService {
+
+    private static final int PAGE_SIZE = 100;
+    private static final int MAX_PAGES = 1000;
+    private static final String APPROVAL_MONTH_FIELD = "textField_mmd2lv0y";
+    private static final String SUMMARY_MONTH_FIELD = "textField_mmbffvda";
+    private static final String SUMMARY_STATUS_IN_APPROVAL = "审批中";
+    private static final String WRITE_BACK_SUCCESS = "全部成功";
+    private static final String STATUS_CLOSED = "封账";
+    private static final String STATUS_REOPENED = "启封";
+    private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
+    private static final DateTimeFormatter MONTH_FORMATTER =
+            DateTimeFormatter.ofPattern("uuuuMM").withResolverStyle(ResolverStyle.STRICT);
+
+    @Autowired
+    private YDClient ydClient;
+
+    @Autowired
+    private WHConf whConf;
+
+    @Autowired
+    private MonthClosingConf closingConf;
+
+    /**
+     * 查询目标月份是否已封账。
+     *
+     * @param request 月份请求
+     * @return 已审批封账与进行中封账数量
+     */
+    public MonthClosingStatusResult status(MonthClosingCheckRequest request) {
+        String monthText = validateRequest(request);
+        return queryClosingStatus(monthText);
+    }
+
+    /**
+     * 校验目标月份是否允许发起封账。
+     *
+     * @param request 月份请求
+     * @return 封账许可、阻塞数量及提示
+     */
+    public MonthClosingValidationResult validate(MonthClosingCheckRequest request) {
+        String monthText = validateRequest(request);
+        String requestedStatus = validateClosingStatus(request.getClosingStatus());
+        MonthClosingStatusResult closingStatus = queryClosingStatus(monthText);
+
+        if (STATUS_REOPENED.equals(requestedStatus)) {
+            return validateReopening(monthText, closingStatus);
+        }
+        return validateClosing(monthText, closingStatus);
+    }
+
+    private MonthClosingValidationResult validateClosing(
+            String monthText,
+            MonthClosingStatusResult closingStatus) {
+        if (closingStatus.isClosed() || closingStatus.getRunningClosingCount() > 0) {
+            return buildValidationResult(monthText, STATUS_CLOSED, closingStatus, 0, 0, 0, 0);
+        }
+
+        List<Map> projectRunning = queryProcesses(
+                whConf.getFormUuidApproval(), APPROVAL_MONTH_FIELD, monthText, "RUNNING");
+        List<Map> otherRunning = queryProcesses(
+                whConf.getFormUuidOtherApproval(), APPROVAL_MONTH_FIELD, monthText, "RUNNING");
+        List<Map> projectCompleted = queryProcesses(
+                whConf.getFormUuidApproval(), APPROVAL_MONTH_FIELD, monthText, "COMPLETED");
+        List<Map> otherCompleted = queryProcesses(
+                whConf.getFormUuidOtherApproval(), APPROVAL_MONTH_FIELD, monthText, "COMPLETED");
+
+        int writeBackPendingCount = countWriteBackPending(
+                projectCompleted, whConf.getApprovalSyncStatusField())
+                + countWriteBackPending(
+                otherCompleted, whConf.getOtherApprovalSyncStatusField());
+        int summaryPendingCount = querySummaryPendingCount(monthText);
+        return buildValidationResult(
+                monthText,
+                STATUS_CLOSED,
+                closingStatus,
+                projectRunning.size(),
+                otherRunning.size(),
+                writeBackPendingCount,
+                summaryPendingCount);
+    }
+
+    private MonthClosingValidationResult validateReopening(
+            String monthText,
+            MonthClosingStatusResult closingStatus) {
+        return buildValidationResult(monthText, STATUS_REOPENED, closingStatus, 0, 0, 0, 0);
+    }
+
+    private static MonthClosingValidationResult buildValidationResult(
+            String monthText,
+            String requestedStatus,
+            MonthClosingStatusResult closingStatus,
+            int projectRunningCount,
+            int otherRunningCount,
+            int writeBackPendingCount,
+            int summaryPendingCount) {
+        boolean requestedClosing = STATUS_CLOSED.equals(requestedStatus);
+        boolean stateAllowed = requestedClosing ? !closingStatus.isClosed() : closingStatus.isClosed();
+        boolean allowed = stateAllowed
+                && closingStatus.getRunningClosingCount() == 0
+                && projectRunningCount == 0
+                && otherRunningCount == 0
+                && writeBackPendingCount == 0
+                && summaryPendingCount == 0;
+
+        return MonthClosingValidationResult.builder()
+                .monthText(monthText)
+                .allowed(allowed)
+                .requestedStatus(requestedStatus)
+                .currentStatus(closingStatus.getCurrentStatus())
+                .alreadyClosed(closingStatus.isClosed())
+                .closingRunningCount(closingStatus.getRunningClosingCount())
+                .projectRunningCount(projectRunningCount)
+                .otherRunningCount(otherRunningCount)
+                .writeBackPendingCount(writeBackPendingCount)
+                .summaryPendingCount(summaryPendingCount)
+                .message(buildMessage(
+                        monthText,
+                        requestedStatus,
+                        closingStatus,
+                        projectRunningCount,
+                        otherRunningCount,
+                        writeBackPendingCount,
+                        summaryPendingCount))
+                .build();
+    }
+
+    private MonthClosingStatusResult queryClosingStatus(String monthText) {
+        List<Map> completed = filterClosingMonth(
+                queryProcesses(closingConf.getFormUuid(), null, null, "COMPLETED"), monthText);
+        List<Map> running = filterClosingMonth(
+                queryProcesses(closingConf.getFormUuid(), null, null, "RUNNING"), monthText);
+        int approvedClosingCount = 0;
+        int approvedReopeningCount = 0;
+        String currentStatus = "";
+        Long latestTime = null;
+        for (Map item : completed) {
+            if (!isApproved(item)) {
+                continue;
+            }
+            String itemStatus = text(extractFormData(item).get(closingConf.getStatusField()));
+            if (STATUS_CLOSED.equals(itemStatus)) {
+                approvedClosingCount++;
+            } else if (STATUS_REOPENED.equals(itemStatus)) {
+                approvedReopeningCount++;
+            } else {
+                continue;
+            }
+            Long itemTime = processEventTime(item);
+            if (StringUtils.isBlank(currentStatus)
+                    || (latestTime == null && itemTime != null)
+                    || (latestTime != null && itemTime != null && itemTime > latestTime)) {
+                currentStatus = itemStatus;
+                latestTime = itemTime;
+            }
+        }
+        return MonthClosingStatusResult.builder()
+                .monthText(monthText)
+                .closed(STATUS_CLOSED.equals(currentStatus))
+                .currentStatus(currentStatus)
+                .approvedClosingCount(approvedClosingCount)
+                .approvedReopeningCount(approvedReopeningCount)
+                .runningClosingCount(running.size())
+                .build();
+    }
+
+    private List<Map> filterClosingMonth(List<Map> processes, String monthText) {
+        List<Map> result = new ArrayList<>();
+        for (Map item : processes) {
+            Object monthValue = extractFormData(item).get(closingConf.getMonthDateField());
+            if (monthText.equals(monthFromDateValue(monthValue))) {
+                result.add(item);
+            }
+        }
+        return result;
+    }
+
+    private int countWriteBackPending(List<Map> completed, String syncStatusField) {
+        int pending = 0;
+        for (Map item : completed) {
+            if (!isApproved(item)) {
+                continue;
+            }
+            Map formData = extractFormData(item);
+            String syncStatus = text(formData.get(syncStatusField));
+            if (!WRITE_BACK_SUCCESS.equals(syncStatus)) {
+                pending++;
+            }
+        }
+        return pending;
+    }
+
+    private int querySummaryPendingCount(String monthText) {
+        Map<String, Object> search = new HashMap<>();
+        search.put(SUMMARY_MONTH_FIELD, monthText);
+        search.put(whConf.getSummaryApprovalStatusField(), SUMMARY_STATUS_IN_APPROVAL);
+        List<Map> rows = queryForms(whConf.getFormUuidWorkHoursSummary(), search);
+        int count = 0;
+        for (Map item : rows) {
+            Map formData = extractFormData(item);
+            if (monthText.equals(text(formData.get(SUMMARY_MONTH_FIELD)))
+                    && SUMMARY_STATUS_IN_APPROVAL.equals(
+                    text(formData.get(whConf.getSummaryApprovalStatusField())))) {
+                count++;
+            }
+        }
+        return count;
+    }
+
+    private List<Map> queryProcesses(
+            String formUuid,
+            String monthField,
+            String monthText,
+            String instanceStatus) {
+        McException.assertAccessException(
+                StringUtils.isBlank(formUuid),
+                "月度封账流程查询配置不完整");
+        Map<String, Object> search = new HashMap<>();
+        if (StringUtils.isNotBlank(monthField) && StringUtils.isNotBlank(monthText)) {
+            search.put(monthField, monthText);
+        }
+        List<Map> result = new ArrayList<>();
+        int page = 1;
+        while (page <= MAX_PAGES) {
+            DDR_New response = ydClient.queryData(YDParam.builder()
+                    .appType(whConf.getYidaAppType())
+                    .systemToken(whConf.getYidaSystemToken())
+                    .formUuid(formUuid)
+                    .searchFieldJson(search.isEmpty() ? null : JSON.toJSONString(search))
+                    .instanceStatus(instanceStatus)
+                    .pageNumber(page)
+                    .pageSize(PAGE_SIZE)
+                    .build(), YDConf.FORM_QUERY.retrieve_search_process);
+            List<Map> data = responseData(response);
+            result.addAll(data);
+            if (!hasNextPage(page, response.getTotalCount(), data.size(), result.size())) {
+                break;
+            }
+            page++;
+        }
+        McException.assertAccessException(page > MAX_PAGES, "月度封账流程查询超过分页上限");
+        return result;
+    }
+
+    private List<Map> queryForms(String formUuid, Map<String, Object> search) {
+        McException.assertAccessException(StringUtils.isBlank(formUuid), "工时汇总表配置为空");
+        List<Map> result = new ArrayList<>();
+        int page = 1;
+        while (page <= MAX_PAGES) {
+            DDR_New response = ydClient.queryData(YDParam.builder()
+                    .appType(whConf.getYidaAppType())
+                    .systemToken(whConf.getYidaSystemToken())
+                    .formUuid(formUuid)
+                    .searchFieldJson(JSON.toJSONString(search))
+                    .currentPage(page)
+                    .pageSize(PAGE_SIZE)
+                    .build(), YDConf.FORM_QUERY.retrieve_search_form);
+            List<Map> data = responseData(response);
+            result.addAll(data);
+            if (!hasNextPage(page, response.getTotalCount(), data.size(), result.size())) {
+                break;
+            }
+            page++;
+        }
+        McException.assertAccessException(page > MAX_PAGES, "工时汇总查询超过分页上限");
+        return result;
+    }
+
+    private static boolean hasNextPage(
+            int page,
+            long totalCount,
+            int returnedSize,
+            int collectedSize) {
+        if (returnedSize < PAGE_SIZE) {
+            return false;
+        }
+        return totalCount <= 0 || collectedSize < totalCount || (long) page * PAGE_SIZE < totalCount;
+    }
+
+    @SuppressWarnings("unchecked")
+    private static List<Map> responseData(DDR_New response) {
+        McException.assertAccessException(response == null, "宜搭查询未返回结果");
+        Object data = response.getData();
+        if (data == null) {
+            return new ArrayList<>();
+        }
+        McException.assertAccessException(!(data instanceof List), "宜搭查询返回数据格式异常");
+        return (List<Map>) data;
+    }
+
+    @SuppressWarnings("unchecked")
+    static Map extractFormData(Map item) {
+        if (item == null) {
+            return new HashMap();
+        }
+        Object formData = item.get("formData");
+        if (formData instanceof Map) {
+            return (Map) formData;
+        }
+        Object data = item.get("data");
+        if (!(data instanceof Map)) {
+            return new HashMap();
+        }
+        Map dataMap = (Map) data;
+        Object nested = dataMap.get("formData");
+        return nested instanceof Map ? (Map) nested : dataMap;
+    }
+
+    static String validateMonthText(String monthText) {
+        String value = StringUtils.trimToEmpty(monthText);
+        McException.assertParamException(!value.matches("\\d{6}"), "月份必须为yyyyMM格式");
+        try {
+            YearMonth.parse(value, MONTH_FORMATTER);
+        } catch (DateTimeParseException ex) {
+            throw McException.builder()
+                    .code("validated_param")
+                    .message("月份必须为有效的yyyyMM格式")
+                    .build();
+        }
+        return value;
+    }
+
+    static String monthFromDateValue(Object value) {
+        if (value == null) {
+            return "";
+        }
+        if (value instanceof Number) {
+            return monthFromEpoch(((Number) value).longValue());
+        }
+        String raw = text(value);
+        if (raw.matches("\\d{6}")) {
+            try {
+                return validateMonthText(raw);
+            } catch (McException ignored) {
+                return "";
+            }
+        }
+        if (raw.matches("\\d{8}")) {
+            return normalizeYearMonth(raw.substring(0, 4), raw.substring(4, 6));
+        }
+        if (raw.matches("\\d{10}|\\d{13}")) {
+            try {
+                return monthFromEpoch(Long.parseLong(raw));
+            } catch (NumberFormatException ignored) {
+                return "";
+            }
+        }
+        try {
+            return YearMonth.from(Instant.parse(raw).atZone(BUSINESS_ZONE)).format(MONTH_FORMATTER);
+        } catch (DateTimeParseException ignored) {
+            // 普通日期字符串继续按 yyyy-MM 前缀处理。
+        }
+        if (raw.matches("^\\d{4}[-/]\\d{2}.*")) {
+            return normalizeYearMonth(raw.substring(0, 4), raw.substring(5, 7));
+        }
+        return "";
+    }
+
+    private static String monthFromEpoch(long rawTimestamp) {
+        String digits = String.valueOf(Math.abs(rawTimestamp));
+        if (digits.length() == 8) {
+            return normalizeYearMonth(digits.substring(0, 4), digits.substring(4, 6));
+        }
+        long milliseconds = digits.length() <= 10 ? rawTimestamp * 1000L : rawTimestamp;
+        try {
+            return YearMonth.from(Instant.ofEpochMilli(milliseconds).atZone(BUSINESS_ZONE))
+                    .format(MONTH_FORMATTER);
+        } catch (RuntimeException ignored) {
+            return "";
+        }
+    }
+
+    private static String normalizeYearMonth(String year, String month) {
+        try {
+            return YearMonth.of(Integer.parseInt(year), Integer.parseInt(month))
+                    .format(MONTH_FORMATTER);
+        } catch (RuntimeException ignored) {
+            return "";
+        }
+    }
+
+    private static Long processEventTime(Map item) {
+        if (item == null) {
+            return null;
+        }
+        String[] fields = {
+                "modifiedTimeGMT", "gmtModified", "finishTimeGMT", "finishTime",
+                "modifiedTime", "createTimeGMT", "createTime"
+        };
+        for (String field : fields) {
+            Long parsed = parseTimestamp(item.get(field));
+            if (parsed != null) {
+                return parsed;
+            }
+        }
+        return null;
+    }
+
+    private static Long parseTimestamp(Object value) {
+        if (value instanceof Number) {
+            long number = ((Number) value).longValue();
+            return String.valueOf(Math.abs(number)).length() <= 10 ? number * 1000L : number;
+        }
+        String raw = text(value);
+        if (raw.matches("\\d{10}|\\d{13}")) {
+            try {
+                long number = Long.parseLong(raw);
+                return raw.length() == 10 ? number * 1000L : number;
+            } catch (NumberFormatException ignored) {
+                return null;
+            }
+        }
+        try {
+            return Instant.parse(raw).toEpochMilli();
+        } catch (DateTimeParseException ignored) {
+            return null;
+        }
+    }
+
+    private static String validateClosingStatus(String closingStatus) {
+        String value = text(closingStatus);
+        McException.assertParamException(
+                !STATUS_CLOSED.equals(value) && !STATUS_REOPENED.equals(value),
+                "封账状态必须为封账或启封");
+        return value;
+    }
+
+    private String validateRequest(MonthClosingCheckRequest request) {
+        McException.assertParamException(request == null, "请求不能为空");
+        McException.assertAccessException(
+                StringUtils.isAnyBlank(
+                        whConf.getYidaAppType(),
+                        whConf.getYidaSystemToken(),
+                        closingConf.getFormUuid(),
+                        closingConf.getMonthDateField(),
+                        closingConf.getStatusField()),
+                "月度封账配置不完整");
+        return validateMonthText(request.getMonthText());
+    }
+
+    private static boolean isApproved(Map item) {
+        return "agree".equals(text(item.get("approvedResult")).toLowerCase(Locale.ROOT));
+    }
+
+    private static String buildMessage(
+            String monthText,
+            String requestedStatus,
+            MonthClosingStatusResult closingStatus,
+            int projectRunningCount,
+            int otherRunningCount,
+            int writeBackPendingCount,
+            int summaryPendingCount) {
+        if (closingStatus.getRunningClosingCount() > 0) {
+            return monthText + " 已有封账或启封流程审批中,不可重复提交";
+        }
+        if (STATUS_REOPENED.equals(requestedStatus)) {
+            return closingStatus.isClosed()
+                    ? monthText + " 当前已封账,可以提交启封"
+                    : monthText + " 当前未封账,不可重复启封";
+        }
+        if (closingStatus.isClosed()) {
+            return monthText + " 已完成封账,不可重复提交";
+        }
+        if (projectRunningCount > 0 || otherRunningCount > 0) {
+            return monthText + " 仍有工时审批单处理中:项目工时 "
+                    + projectRunningCount + " 张,其他工时 " + otherRunningCount + " 张";
+        }
+        if (writeBackPendingCount > 0) {
+            return monthText + " 有 " + writeBackPendingCount + " 张审批单尚未成功回写工时汇总";
+        }
+        if (summaryPendingCount > 0) {
+            return monthText + " 仍有 " + summaryPendingCount + " 条工时汇总处于审批中";
+        }
+        return monthText + " 工时审批已完成,可以提交封账";
+    }
+
+    private static String text(Object value) {
+        return value == null ? "" : String.valueOf(value).trim();
+    }
+}

+ 11 - 0
mjava-akdsbeisen/src/main/resources/application-dev.yml

@@ -100,6 +100,14 @@ workhours:
   approvalOriginatorDeptId: "${AKDS_APPROVAL_ORIGINATOR_DEPT_ID:}"
   approvalOriginatorDeptId: "${AKDS_APPROVAL_ORIGINATOR_DEPT_ID:}"
   summaryApprovalStatusField: "selectField_mre1xz9g"
   summaryApprovalStatusField: "selectField_mre1xz9g"
 
 
+# prd 月度封账流程:TimeCard 实时锁定 + 封账提交前审批完整性校验
+month-closing:
+  formUuid: "FORM-275BDAFE67FE4226B6BCF2BC6EEFFD4B4215"
+  processCode: "TPROC--7O866X6120J890A8J27MK5JE1SZX2NO8CO6TMD"
+  monthDateField: "dateField_mt6wzb17"
+  statusField: "radioField_mt6wzb18"
+  descriptionField: "textareaField_bo8w3wug9"
+
 # 天眼查开放平台 (客户档案·工商信息回填, 2026-07-10)
 # 天眼查开放平台 (客户档案·工商信息回填, 2026-07-10)
 tianyancha:
 tianyancha:
   baseUrl: "https://open.api.tianyancha.com"
   baseUrl: "https://open.api.tianyancha.com"
@@ -112,6 +120,9 @@ tianyancha:
 customer:
 customer:
   yidaAppType: "APP_W73TG2OPB9M2J21FUSFA"
   yidaAppType: "APP_W73TG2OPB9M2J21FUSFA"
   yidaFormUuid: "FORM-743EC9F3012146B3831594126EE6117F12U8"
   yidaFormUuid: "FORM-743EC9F3012146B3831594126EE6117F12U8"
+  duplicateProcessFormUuid: "FORM-743EC9F3012146B3831594126EE6117F12U8"
+  duplicateCustomerNameFieldId: "selectField_mrnnj1vh"
+  duplicateLegacyCustomerNameFieldId: "textField_mjmje451"
   tycCompanyUrl: "https://www.tianyancha.com/company/{id}"
   tycCompanyUrl: "https://www.tianyancha.com/company/{id}"
   fields:
   fields:
     customerName: "selectField_mrnnj1vh"
     customerName: "selectField_mrnnj1vh"

+ 11 - 0
mjava-akdsbeisen/src/main/resources/application-prod.yml

@@ -81,6 +81,14 @@ workhours:
   approvalOriginatorDeptId: "${AKDS_APPROVAL_ORIGINATOR_DEPT_ID:1054043453}"
   approvalOriginatorDeptId: "${AKDS_APPROVAL_ORIGINATOR_DEPT_ID:1054043453}"
   summaryApprovalStatusField: "selectField_mre1xz9g"
   summaryApprovalStatusField: "selectField_mre1xz9g"
 
 
+# prd 月度封账:TimeCard 实时锁定 + 封账/启封提交前校验
+month-closing:
+  formUuid: "FORM-275BDAFE67FE4226B6BCF2BC6EEFFD4B4215"
+  processCode: "TPROC--7O866X6120J890A8J27MK5JE1SZX2NO8CO6TMD"
+  monthDateField: "dateField_mt6wzb17"
+  statusField: "radioField_mt6wzb18"
+  descriptionField: "textareaField_bo8w3wug9"
+
 # 天眼查开放平台 (客户档案·工商信息回填, 2026-07-10)
 # 天眼查开放平台 (客户档案·工商信息回填, 2026-07-10)
 tianyancha:
 tianyancha:
   baseUrl: "https://open.api.tianyancha.com"
   baseUrl: "https://open.api.tianyancha.com"
@@ -91,6 +99,9 @@ tianyancha:
 customer:
 customer:
   yidaAppType: "APP_W73TG2OPB9M2J21FUSFA"
   yidaAppType: "APP_W73TG2OPB9M2J21FUSFA"
   yidaFormUuid: "FORM-743EC9F3012146B3831594126EE6117F12U8"
   yidaFormUuid: "FORM-743EC9F3012146B3831594126EE6117F12U8"
+  duplicateProcessFormUuid: "FORM-743EC9F3012146B3831594126EE6117F12U8"
+  duplicateCustomerNameFieldId: "selectField_mrnnj1vh"
+  duplicateLegacyCustomerNameFieldId: "textField_mjmje451"
   tycCompanyUrl: "https://www.tianyancha.com/company/{id}"
   tycCompanyUrl: "https://www.tianyancha.com/company/{id}"
   fields:
   fields:
     customerName: "selectField_mrnnj1vh"
     customerName: "selectField_mrnnj1vh"

+ 132 - 0
mjava-akdsbeisen/src/test/java/com/malk/service/customer/impl/CustomerEnrichServiceImplTest.java

@@ -1,18 +1,25 @@
 package com.malk.service.customer.impl;
 package com.malk.service.customer.impl;
 
 
+import com.malk.server.aliwork.YDConf;
+import com.malk.server.aliwork.YDParam;
 import com.malk.server.common.McException;
 import com.malk.server.common.McException;
 import com.malk.server.customer.CustomerConf;
 import com.malk.server.customer.CustomerConf;
 import com.malk.server.customer.CustomerEnrichRequest;
 import com.malk.server.customer.CustomerEnrichRequest;
 import com.malk.server.customer.CustomerEnrichResult;
 import com.malk.server.customer.CustomerEnrichResult;
 import com.malk.server.customer.CustomerSearchRequest;
 import com.malk.server.customer.CustomerSearchRequest;
+import com.malk.server.dingtalk.DDR_New;
 import com.malk.server.tianyancha.TycCompany;
 import com.malk.server.tianyancha.TycCompany;
 import com.malk.server.tianyancha.TycSearchResponse;
 import com.malk.server.tianyancha.TycSearchResponse;
+import com.malk.service.aliwork.YDClient;
 import com.malk.service.tianyancha.TycClient;
 import com.malk.service.tianyancha.TycClient;
 import org.junit.Before;
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.Test;
 import org.springframework.test.util.ReflectionTestUtils;
 import org.springframework.test.util.ReflectionTestUtils;
 
 
+import java.util.ArrayList;
+import java.util.Collections;
 import java.util.LinkedHashMap;
 import java.util.LinkedHashMap;
+import java.util.List;
 import java.util.Map;
 import java.util.Map;
 
 
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertEquals;
@@ -28,6 +35,7 @@ public class CustomerEnrichServiceImplTest {
 
 
     private CustomerEnrichServiceImpl service;
     private CustomerEnrichServiceImpl service;
     private StubTycClient client;
     private StubTycClient client;
+    private StubYdClient ydClient;
 
 
     /**
     /**
      * 初始化离线服务和字段映射。
      * 初始化离线服务和字段映射。
@@ -36,7 +44,13 @@ public class CustomerEnrichServiceImplTest {
     public void setUp() {
     public void setUp() {
         service = new CustomerEnrichServiceImpl();
         service = new CustomerEnrichServiceImpl();
         client = new StubTycClient();
         client = new StubTycClient();
+        ydClient = new StubYdClient();
         CustomerConf conf = new CustomerConf();
         CustomerConf conf = new CustomerConf();
+        conf.setYidaAppType("APP_TEST");
+        conf.setYidaSystemToken("test-token");
+        conf.setDuplicateProcessFormUuid("FORM_PROCESS_TEST");
+        conf.setDuplicateCustomerNameFieldId("customerNameField");
+        conf.setDuplicateLegacyCustomerNameFieldId("legacyCustomerNameField");
         conf.setTycCompanyUrl("https://www.tianyancha.com/company/{id}");
         conf.setTycCompanyUrl("https://www.tianyancha.com/company/{id}");
         Map<String, String> fields = new LinkedHashMap<>();
         Map<String, String> fields = new LinkedHashMap<>();
         fields.put("creditCode", "credit");
         fields.put("creditCode", "credit");
@@ -51,6 +65,7 @@ public class CustomerEnrichServiceImplTest {
         conf.setFields(fields);
         conf.setFields(fields);
         ReflectionTestUtils.setField(service, "tycClient", client);
         ReflectionTestUtils.setField(service, "tycClient", client);
         ReflectionTestUtils.setField(service, "conf", conf);
         ReflectionTestUtils.setField(service, "conf", conf);
+        ReflectionTestUtils.setField(service, "ydClient", ydClient);
     }
     }
 
 
     /**
     /**
@@ -153,6 +168,80 @@ public class CustomerEnrichServiceImplTest {
         assertFalse(request.isQueryPresent());
         assertFalse(request.isQueryPresent());
     }
     }
 
 
+    /**
+     * 验证运行中的同名客户流程直接判重。
+     */
+    @Test
+    public void shouldFindRunningDuplicateCustomer() {
+        ydClient.runningRows = Collections.singletonList(process("processInstanceId", "running-1"));
+
+        assertTrue(service.hasDuplicateCustomer(" 上海示例科技有限公司 ", null));
+        assertEquals(1, ydClient.params.size());
+        assertEquals("RUNNING", ydClient.params.get(0).getInstanceStatus());
+        assertEquals("{\"customerNameField\":\"上海示例科技有限公司\"}",
+                ydClient.params.get(0).getSearchFieldJson());
+    }
+
+    /**
+     * 验证当前流程被排除后,审批通过的其他同名流程仍会判重。
+     */
+    @Test
+    public void shouldFindApprovedDuplicateAfterExcludingCurrentProcess() {
+        ydClient.runningRows = Collections.singletonList(process("processInstId", "current-1"));
+        ydClient.completedRows = Collections.singletonList(process("processInstanceId", "approved-1"));
+
+        assertTrue(service.hasDuplicateCustomer("上海示例科技有限公司", "current-1"));
+        assertEquals(2, ydClient.params.size());
+        assertEquals("COMPLETED", ydClient.params.get(1).getInstanceStatus());
+        assertEquals("agree", ydClient.params.get(1).getApprovedResult());
+    }
+
+    /**
+     * 验证历史客户名称文本字段中的有效流程也会判重。
+     */
+    @Test
+    public void shouldFindDuplicateInLegacyCustomerNameField() {
+        ydClient.legacyRunningRows = Collections.singletonList(
+                process("processInstanceId", "legacy-running-1"));
+
+        assertTrue(service.hasDuplicateCustomer("上海历史客户有限公司", null));
+        assertEquals(3, ydClient.params.size());
+        assertEquals("{\"legacyCustomerNameField\":\"上海历史客户有限公司\"}",
+                ydClient.params.get(2).getSearchFieldJson());
+        assertEquals("RUNNING", ydClient.params.get(2).getInstanceStatus());
+    }
+
+    /**
+     * 验证只有当前流程时不判重,且不会查询拒绝或撤销状态。
+     */
+    @Test
+    public void shouldIgnoreCurrentProcessAndInvalidStatuses() {
+        ydClient.runningRows = Collections.singletonList(process("procInsId", "current-1"));
+        ydClient.completedRows = Collections.singletonList(process("processInstanceId", "current-1"));
+
+        assertFalse(service.hasDuplicateCustomer("上海示例科技有限公司", "current-1"));
+        assertEquals(4, ydClient.params.size());
+        assertEquals("RUNNING", ydClient.params.get(0).getInstanceStatus());
+        assertEquals("COMPLETED", ydClient.params.get(1).getInstanceStatus());
+        assertEquals("agree", ydClient.params.get(1).getApprovedResult());
+        assertEquals("RUNNING", ydClient.params.get(2).getInstanceStatus());
+        assertEquals("COMPLETED", ydClient.params.get(3).getInstanceStatus());
+        assertEquals("agree", ydClient.params.get(3).getApprovedResult());
+    }
+
+    /**
+     * 构造流程查询记录。
+     *
+     * @param idKey 流程实例 ID 字段名
+     * @param id 流程实例 ID
+     * @return 流程查询记录
+     */
+    private Map<String, Object> process(String idKey, String id) {
+        Map<String, Object> row = new LinkedHashMap<>();
+        row.put(idKey, id);
+        return row;
+    }
+
     private TycCompany company(String name) {
     private TycCompany company(String name) {
         TycCompany company = new TycCompany();
         TycCompany company = new TycCompany();
         company.setId(1L);
         company.setId(1L);
@@ -185,4 +274,47 @@ public class CustomerEnrichServiceImplTest {
             return company;
             return company;
         }
         }
     }
     }
+
+    private static final class StubYdClient implements YDClient {
+        private List<Map<String, Object>> runningRows = Collections.emptyList();
+        private List<Map<String, Object>> completedRows = Collections.emptyList();
+        private List<Map<String, Object>> legacyRunningRows = Collections.emptyList();
+        private List<Map<String, Object>> legacyCompletedRows = Collections.emptyList();
+        private final List<YDParam> params = new ArrayList<>();
+
+        @Override
+        public Object operateData(YDParam param, YDConf.FORM_OPERATION type) {
+            return null;
+        }
+
+        @Override
+        public DDR_New queryData(YDParam param, YDConf.FORM_QUERY type) {
+            params.add(param);
+            DDR_New response = new DDR_New();
+            boolean legacyField = param.getSearchFieldJson().contains("legacyCustomerNameField");
+            if (legacyField) {
+                response.setData("RUNNING".equals(param.getInstanceStatus())
+                        ? legacyRunningRows : legacyCompletedRows);
+            } else {
+                response.setData("RUNNING".equals(param.getInstanceStatus())
+                        ? runningRows : completedRows);
+            }
+            return response;
+        }
+
+        @Override
+        public String convertTemporaryUrl(String url, int timeout) {
+            return null;
+        }
+
+        @Override
+        public String convertTemporaryUrl(String url) {
+            return null;
+        }
+
+        @Override
+        public String convertTemporaryUrl_PN(String url) {
+            return null;
+        }
+    }
 }
 }

+ 274 - 0
mjava-akdsbeisen/src/test/java/com/malk/service/workhours/MonthClosingServiceTest.java

@@ -0,0 +1,274 @@
+package com.malk.service.workhours;
+
+import com.malk.server.aliwork.YDConf;
+import com.malk.server.aliwork.YDParam;
+import com.malk.server.common.McException;
+import com.malk.server.dingtalk.DDR_New;
+import com.malk.server.workhours.MonthClosingCheckRequest;
+import com.malk.server.workhours.MonthClosingConf;
+import com.malk.server.workhours.MonthClosingStatusResult;
+import com.malk.server.workhours.MonthClosingValidationResult;
+import com.malk.server.workhours.WHConf;
+import com.malk.service.aliwork.YDClient;
+import org.junit.jupiter.api.Test;
+import org.mockito.invocation.InvocationOnMock;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+class MonthClosingServiceTest {
+
+    private static final String CLOSING_FORM = "FORM-CLOSING";
+    private static final String PROJECT_FORM = "FORM-PROJECT-APPROVAL";
+    private static final String OTHER_FORM = "FORM-OTHER-APPROVAL";
+    private static final String SUMMARY_FORM = "FORM-SUMMARY";
+    private static final String CLOSING_MONTH_FIELD = "dateField_closing_month";
+    private static final String CLOSING_STATUS_FIELD = "radioField_closing_status";
+    private static final String SYNC_STATUS_FIELD = "selectField_sync_status";
+    private static final String SUMMARY_STATUS_FIELD = "selectField_summary_status";
+
+    @Test
+    void shouldRejectInvalidMonthBeforeQueryingYida() {
+        YDClient ydClient = mock(YDClient.class);
+        MonthClosingService service = service(ydClient);
+
+        assertThrows(
+                McException.class,
+                () -> service.status(request("202613")));
+        verifyNoInteractions(ydClient);
+    }
+
+    @Test
+    void shouldAllowClosingWhenNoApprovalIsPending() {
+        YDClient ydClient = mock(YDClient.class);
+        when(ydClient.queryData(any(YDParam.class), any(YDConf.FORM_QUERY.class)))
+                .thenReturn(response(Collections.emptyList()));
+        MonthClosingService service = service(ydClient);
+
+        MonthClosingValidationResult result = service.validate(request("202608"));
+
+        assertTrue(result.isAllowed());
+        assertFalse(result.isAlreadyClosed());
+        assertEquals(0, result.getProjectRunningCount());
+        assertEquals(0, result.getOtherRunningCount());
+        assertEquals(0, result.getWriteBackPendingCount());
+        assertEquals(0, result.getSummaryPendingCount());
+    }
+
+    @Test
+    void shouldBlockClosingWhenProjectApprovalIsRunning() {
+        YDClient ydClient = mock(YDClient.class);
+        when(ydClient.queryData(any(YDParam.class), any(YDConf.FORM_QUERY.class)))
+                .thenAnswer(this::queryResponse);
+        MonthClosingService service = service(ydClient);
+
+        MonthClosingValidationResult result = service.validate(request("202608"));
+
+        assertFalse(result.isAllowed());
+        assertEquals(1, result.getProjectRunningCount());
+        assertTrue(result.getMessage().contains("项目工时 1 张"));
+    }
+
+    @Test
+    void shouldTreatApprovedClosingProcessAsClosed() {
+        YDClient ydClient = mock(YDClient.class);
+        when(ydClient.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_process)))
+                .thenAnswer(invocation -> {
+                    YDParam param = invocation.getArgument(0);
+                    if (CLOSING_FORM.equals(param.getFormUuid())
+                            && "COMPLETED".equals(param.getInstanceStatus())) {
+                        Map<String, Object> formData = new HashMap<>();
+                        formData.put(CLOSING_MONTH_FIELD, "2026-08-01");
+                        formData.put(CLOSING_STATUS_FIELD, "封账");
+                        Map<String, Object> item = new HashMap<>();
+                        item.put("approvedResult", "agree");
+                        item.put("modifiedTimeGMT", 1_777_590_000_000L);
+                        item.put("formData", formData);
+                        return response(Collections.singletonList(item));
+                    }
+                    return response(Collections.emptyList());
+                });
+        MonthClosingService service = service(ydClient);
+
+        MonthClosingStatusResult result = service.status(request("202608"));
+
+        assertTrue(result.isClosed());
+        assertEquals(1, result.getApprovedClosingCount());
+        assertEquals("封账", result.getCurrentStatus());
+    }
+
+    @Test
+    void shouldTreatLatestApprovedReopeningAsOpen() {
+        YDClient ydClient = mock(YDClient.class);
+        when(ydClient.queryData(any(YDParam.class), eq(YDConf.FORM_QUERY.retrieve_search_process)))
+                .thenAnswer(invocation -> {
+                    YDParam param = invocation.getArgument(0);
+                    if (CLOSING_FORM.equals(param.getFormUuid())
+                            && "COMPLETED".equals(param.getInstanceStatus())) {
+                        return response(Arrays.asList(
+                                closingAction("启封", 1_777_676_400_000L),
+                                closingAction("封账", 1_777_590_000_000L)));
+                    }
+                    return response(Collections.emptyList());
+                });
+        MonthClosingService service = service(ydClient);
+
+        MonthClosingStatusResult result = service.status(request("202608"));
+
+        assertFalse(result.isClosed());
+        assertEquals("启封", result.getCurrentStatus());
+        assertEquals(1, result.getApprovedClosingCount());
+        assertEquals(1, result.getApprovedReopeningCount());
+    }
+
+    @Test
+    void shouldAllowReopeningOnlyWhenMonthIsClosed() {
+        YDClient ydClient = mock(YDClient.class);
+        when(ydClient.queryData(any(YDParam.class), any(YDConf.FORM_QUERY.class)))
+                .thenAnswer(invocation -> {
+                    YDParam param = invocation.getArgument(0);
+                    if (CLOSING_FORM.equals(param.getFormUuid())
+                            && "COMPLETED".equals(param.getInstanceStatus())) {
+                        return response(Collections.singletonList(
+                                closingAction("封账", 1_777_590_000_000L)));
+                    }
+                    return response(Collections.emptyList());
+                });
+        MonthClosingService service = service(ydClient);
+
+        MonthClosingValidationResult result = service.validate(request("202608", "启封"));
+
+        assertTrue(result.isAllowed());
+        assertEquals("启封", result.getRequestedStatus());
+        assertEquals("封账", result.getCurrentStatus());
+    }
+
+    @Test
+    void shouldBlockReopeningWhenMonthIsAlreadyOpen() {
+        YDClient ydClient = mock(YDClient.class);
+        when(ydClient.queryData(any(YDParam.class), any(YDConf.FORM_QUERY.class)))
+                .thenReturn(response(Collections.emptyList()));
+        MonthClosingService service = service(ydClient);
+
+        MonthClosingValidationResult result = service.validate(request("202608", "启封"));
+
+        assertFalse(result.isAllowed());
+        assertTrue(result.getMessage().contains("当前未封账"));
+    }
+
+    @Test
+    void shouldNormalizeDateFieldValuesToMonth() {
+        assertEquals("202608", MonthClosingService.monthFromDateValue("2026-08-01"));
+        assertEquals(
+                "202608",
+                MonthClosingService.monthFromDateValue(
+                        java.time.Instant.parse("2026-08-01T00:00:00Z").toEpochMilli()));
+    }
+
+    @Test
+    void shouldBlockCompletedApprovalWhoseWriteBackIsNotSuccessful() {
+        YDClient ydClient = mock(YDClient.class);
+        when(ydClient.queryData(any(YDParam.class), any(YDConf.FORM_QUERY.class)))
+                .thenAnswer(invocation -> {
+                    YDParam param = invocation.getArgument(0);
+                    YDConf.FORM_QUERY queryType = invocation.getArgument(1);
+                    if (queryType == YDConf.FORM_QUERY.retrieve_search_process
+                            && PROJECT_FORM.equals(param.getFormUuid())
+                            && "COMPLETED".equals(param.getInstanceStatus())) {
+                        Map<String, Object> formData = new HashMap<>();
+                        formData.put(SYNC_STATUS_FIELD, "部分失败");
+                        Map<String, Object> item = new HashMap<>();
+                        item.put("approvedResult", "agree");
+                        item.put("formData", formData);
+                        return response(Collections.singletonList(item));
+                    }
+                    return response(Collections.emptyList());
+                });
+        MonthClosingService service = service(ydClient);
+
+        MonthClosingValidationResult result = service.validate(request("202608"));
+
+        assertFalse(result.isAllowed());
+        assertEquals(1, result.getWriteBackPendingCount());
+    }
+
+    private DDR_New queryResponse(InvocationOnMock invocation) {
+        YDParam param = invocation.getArgument(0);
+        YDConf.FORM_QUERY queryType = invocation.getArgument(1);
+        if (queryType == YDConf.FORM_QUERY.retrieve_search_process
+                && PROJECT_FORM.equals(param.getFormUuid())
+                && "RUNNING".equals(param.getInstanceStatus())) {
+            Map<String, Object> item = new HashMap<>();
+            item.put("processInstanceId", "PROC-RUNNING");
+            return response(Collections.singletonList(item));
+        }
+        return response(Collections.emptyList());
+    }
+
+    private MonthClosingService service(YDClient ydClient) {
+        WHConf whConf = new WHConf();
+        whConf.setYidaAppType("APP-TEST");
+        whConf.setYidaSystemToken("TOKEN-TEST");
+        whConf.setFormUuidApproval(PROJECT_FORM);
+        whConf.setFormUuidOtherApproval(OTHER_FORM);
+        whConf.setFormUuidWorkHoursSummary(SUMMARY_FORM);
+        whConf.setApprovalSyncStatusField(SYNC_STATUS_FIELD);
+        whConf.setOtherApprovalSyncStatusField(SYNC_STATUS_FIELD);
+        whConf.setSummaryApprovalStatusField(SUMMARY_STATUS_FIELD);
+
+        MonthClosingConf closingConf = new MonthClosingConf();
+        closingConf.setFormUuid(CLOSING_FORM);
+        closingConf.setMonthDateField(CLOSING_MONTH_FIELD);
+        closingConf.setStatusField(CLOSING_STATUS_FIELD);
+
+        MonthClosingService service = new MonthClosingService();
+        ReflectionTestUtils.setField(service, "ydClient", ydClient);
+        ReflectionTestUtils.setField(service, "whConf", whConf);
+        ReflectionTestUtils.setField(service, "closingConf", closingConf);
+        return service;
+    }
+
+    private static MonthClosingCheckRequest request(String monthText) {
+        return request(monthText, "封账");
+    }
+
+    private static MonthClosingCheckRequest request(String monthText, String closingStatus) {
+        return MonthClosingCheckRequest.builder()
+                .monthText(monthText)
+                .closingStatus(closingStatus)
+                .build();
+    }
+
+    private static Map<String, Object> closingAction(String status, long modifiedTime) {
+        Map<String, Object> formData = new HashMap<>();
+        formData.put(CLOSING_MONTH_FIELD, "2026-08-01");
+        formData.put(CLOSING_STATUS_FIELD, status);
+        Map<String, Object> item = new HashMap<>();
+        item.put("approvedResult", "agree");
+        item.put("modifiedTimeGMT", modifiedTime);
+        item.put("formData", formData);
+        return item;
+    }
+
+    private static DDR_New response(List<Map> data) {
+        DDR_New response = new DDR_New();
+        response.setData(new ArrayList<>(data));
+        response.setTotalCount(data.size());
+        return response;
+    }
+}