Browse Source

接口开发10.20

ht 2 weeks ago
parent
commit
43aff05850

File diff suppressed because it is too large
+ 67 - 15
README.md


+ 5 - 0
pom.xml

@@ -79,6 +79,11 @@
             <artifactId>hutool-all</artifactId>
             <version>5.8.25</version>
         </dependency>
+        <dependency>
+            <groupId>org.bouncycastle</groupId>
+            <artifactId>bcprov-jdk15on</artifactId>
+            <version>1.70</version>
+        </dependency>
         <!-- mjava UtilToken.buildKey 依赖;未打入包会导致 NoClassDefFoundError: StringUtils -->
         <dependency>
             <groupId>org.apache.commons</groupId>

+ 12 - 10
src/main/java/com/malk/minglei/controller/MingleiPortController.java

@@ -1,7 +1,7 @@
 package com.malk.minglei.controller;
 
-import com.malk.minglei.dto.MingleiPortRequest;
-import com.malk.minglei.service.port.MingleiPortService;
+import com.malk.minglei.dto.SupplierPriceApprovalProcessRequest;
+import com.malk.minglei.service.port.SupplierPriceApprovalProcessService;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.http.HttpStatus;
@@ -29,26 +29,28 @@ public class MingleiPortController {
 
     private static final Logger LOGGER = LoggerFactory.getLogger(MingleiPortController.class);
 
-    private final MingleiPortService mingleiPortService;
+    private final SupplierPriceApprovalProcessService supplierPriceApprovalProcessService;
 
     /**
      * 创建明磊外部单据接口控制器。
      *
-     * @param mingleiPortService 明磊外部单据接口服务
+     * @param supplierPriceApprovalProcessService 核价流程读取与 T100 推送服务
      */
-    public MingleiPortController(MingleiPortService mingleiPortService) {
-        this.mingleiPortService = mingleiPortService;
+    public MingleiPortController(SupplierPriceApprovalProcessService supplierPriceApprovalProcessService) {
+        this.supplierPriceApprovalProcessService = supplierPriceApprovalProcessService;
     }
 
     /**
-     * 接收本系统的供应商价格审批单请求,并调用外部 T100 接口。
+     * 接收宜搭表单和流程实例 ID,按表单字段映射读取数据并调用外部 T100 接口。
      *
-     * @param request 仅包含 head 和 detail 的供应商价格审批单请求体
+     * @param request 包含表单 ID 和流程实例 ID 的请求体
      * @return 外部 T100 接口响应内容
      */
     @PostMapping("/supplier-price-approvals")
-    public ResponseEntity<Map<String, Object>> submitSupplierPriceApproval(@Valid @RequestBody MingleiPortRequest request) {
-        return ResponseEntity.ok(mingleiPortService.submitSupplierPriceApproval(request));
+    public ResponseEntity<Map<String, Object>> submitSupplierPriceApproval(
+            @Valid @RequestBody SupplierPriceApprovalProcessRequest request) {
+        return ResponseEntity.ok(supplierPriceApprovalProcessService
+                .submitByProcessInstanceId(request.getFormId(), request.getProcessInstanceId()));
     }
 
     /**

+ 195 - 4
src/main/java/com/malk/minglei/service/impl/dictionary/DictionaryQueryServiceImpl.java

@@ -53,7 +53,7 @@ public class DictionaryQueryServiceImpl implements DictionaryQueryService {
     private static final String ERP_PACKAGING_MATERIAL_COUNT_SQL = "select count(1) from DSDATA.pmaauc_t " +
             "where pmaaucent = :ent and pmaauc016 is not null";
 
-    // 料件视图直接返回视图完整字段,可按中文品名模糊筛选。
+    // 料件视图直接返回视图完整字段,可按料号或中文品名模糊筛选。
     private static final String ERP_ITEM_VIEW_DICTIONARY_CODE = "erp-item-view";
     private static final String ERP_ITEM_VIEW_DATA_SQL = "select * from DSDATA.erp_apmt440_imaauc_v " +
             "where imaalent = :ent";
@@ -116,6 +116,83 @@ public class DictionaryQueryServiceImpl implements DictionaryQueryService {
                     "and xmda003 <> 'ML03020200' and xmdastus = 'Y' and xmdaownid in (:qx)";
     private static final String ERP_PRODUCTION_ORDER_COUNT_SQL = "select count(1) from (" + ERP_PRODUCTION_ORDER_DATA_SQL + ")";
 
+    // 供应商禁用视图按据点和公司返回当前不可用的供应商记录,并支持按供应商名称筛选。
+    private static final String ERP_SUPPLIER_DISABLED_DICTIONARY_CODE = "erp-supplier-disabled";
+    private static final String ERP_SUPPLIER_DISABLED_DATA_SQL = "select * from DSDATA.ERP_APMT440_PMAAUC_N " +
+            "where pmabsite = :t100id and pmaaent = :ent";
+    private static final String ERP_SUPPLIER_DISABLED_COUNT_SQL = "select count(1) from DSDATA.ERP_APMT440_PMAAUC_N " +
+            "where pmabsite = :t100id and pmaaent = :ent";
+
+    // T100 有效料号固定查询明磊公司 ML 据点,排除停用料号,并支持按料号或中文品名筛选。
+    private static final String ERP_ACTIVE_ITEM_DICTIONARY_CODE = "erp-active-item";
+    private static final String ERP_ACTIVE_ITEM_DATA_SQL = "select imaa001, imaal003, imaal004 " +
+            "from DSDATA.imaa_t " +
+            "inner join DSDATA.imaf_t on imaaent = imafent and imaa001 = imaf001 and imafsite = 'ML' " +
+            "inner join DSDATA.imaal_t on imaaent = imaalent and imaa001 = imaal001 and imaal002 = 'zh_CN' " +
+            "where imaaent = 86 and imaa010 <> 'TY'";
+    private static final String ERP_ACTIVE_ITEM_COUNT_SQL = "select count(1) " +
+            "from DSDATA.imaa_t " +
+            "inner join DSDATA.imaf_t on imaaent = imafent and imaa001 = imaf001 and imafsite = 'ML' " +
+            "inner join DSDATA.imaal_t on imaaent = imaalent and imaa001 = imaal001 and imaal002 = 'zh_CN' " +
+            "where imaaent = 86 and imaa010 <> 'TY'";
+
+    // 范本料号开窗返回明磊公司所有未停用料号及其中文品名、规格,并支持按料号或中文品名筛选。
+    private static final String ERP_TEMPLATE_ITEM_DICTIONARY_CODE = "erp-template-item";
+    private static final String ERP_TEMPLATE_ITEM_DATA_SQL = "select imaa001, imaal003, imaal004 " +
+            "from DSDATA.imaa_t " +
+            "left join DSDATA.imaal_t on imaaent = imaalent and imaa001 = imaal001 and imaal002 = 'zh_CN' " +
+            "where imaaent = 86 and imaa010 <> 'TY'";
+    private static final String ERP_TEMPLATE_ITEM_COUNT_SQL = "select count(1) from DSDATA.imaa_t " +
+            "left join DSDATA.imaal_t on imaaent = imaalent and imaa001 = imaal001 and imaal002 = 'zh_CN' " +
+            "where imaaent = 86 and imaa010 <> 'TY'";
+
+    // 范本料号订单开窗按料号前缀返回 ML 据点中未作废的销售订单与料件明细,并支持按订单号筛选。
+    private static final String ERP_TEMPLATE_ORDERS_DICTIONARY_CODE = "erp-template-orders";
+    private static final String ERP_TEMPLATE_ORDERS_DATA_SQL =
+            "select case xmdastus when 'C' then '已结案' when 'N' then '未审核' when 'Y' then '已审核' " +
+                    "when 'H' then '留置' else '其他' end xmdastus, " +
+                    "xmdadocno, xmdadocdt, xmda002, " +
+                    "(select ooag011 from DSDATA.ooag_t where ooagent = xmdaent and ooag001 = xmda002) xmda002_desc, " +
+                    "xmda003, " +
+                    "(select ooefl003 from DSDATA.ooefl_t where ooeflent = xmdaent and ooefl001 = xmda003 " +
+                    "and ooefl002 = 'zh_CN') xmda003_desc, " +
+                    "xmda004, " +
+                    "(select pmaal003 from DSDATA.pmaal_t where pmaalent = xmdaent and pmaal001 = xmda004 " +
+                    "and pmaal002 = 'zh_CN') xmda004_desc, " +
+                    "xmdc001, xmdcud010, " +
+                    "(select imaal003 from DSDATA.imaal_t where imaalent = xmdcent and imaal001 = xmdc001 " +
+                    "and imaal002 = 'zh_CN') xmdc001_desc, " +
+                    "(select imaal004 from DSDATA.imaal_t where imaalent = xmdcent and imaal001 = xmdc001 " +
+                    "and imaal002 = 'zh_CN') xmdc001_spec, " +
+                    "xmdc002, xmdc007 " +
+                    "from DSDATA.xmda_t " +
+                    "inner join DSDATA.xmdc_t on xmdaent = xmdcent and xmdasite = xmdcsite and xmdadocno = xmdcdocno " +
+                    "where xmdastus <> 'X' and xmdcent = 86 and xmdcsite = 'ML' and xmdc001 like :bgfblh";
+    private static final String ERP_TEMPLATE_ORDERS_COUNT_SQL = "select count(1) from (" + ERP_TEMPLATE_ORDERS_DATA_SQL + ")";
+
+    // 料号上次价格按有效采购价格单的最新确认时间返回对应价格。
+    private static final String ERP_LAST_PRICE_DICTIONARY_CODE = "erp-last-price";
+    private static final String MAIN_PMDI004_FILTER = "main.pmdi004";
+    private static final String DETAIL_1_LJBH_FILTER = "detail_1.ljbh";
+    private static final String ERP_LAST_PRICE_DATA_SQL = "select distinct pmdj011 from (" +
+            "select to_char(pmdicnfdt, 'yyyy-mm-dd HH24:mi:ss') pmdicnfdt, pmdi015, " +
+                    "case when pmdi016 is null then date '2025-12-31' else pmdi016 end pmdi016, pmdi004, pmdj002, pmdj011 " +
+                    "from DSDATA.pmdi_t " +
+                    "inner join DSDATA.pmdj_t on pmdient = pmdjent and pmdisite = pmdjsite and pmdidocno = pmdjdocno " +
+                    "where pmdient = 86 and pmdisite = 'ML' and pmdistus = 'Y' and (pmdiud001 = 'N' or pmdiud001 is null) " +
+                    "and sysdate >= pmdi015 and sysdate <= case when pmdi016 is null then date '2025-12-31' else pmdi016 end" +
+            ") t1 where t1.pmdicnfdt = (" +
+            "select max(t2.pmdicnfdt) from (" +
+                    "select to_char(pmdicnfdt, 'yyyy-mm-dd HH24:mi:ss') pmdicnfdt, pmdi015, " +
+                    "case when pmdi016 is null then date '2025-12-31' else pmdi016 end pmdi016, pmdi004, pmdj002, pmdj011 " +
+                    "from DSDATA.pmdi_t " +
+                    "inner join DSDATA.pmdj_t on pmdient = pmdjent and pmdisite = pmdjsite and pmdidocno = pmdjdocno " +
+                    "where pmdient = 86 and pmdisite = 'ML' and pmdistus = 'Y' " +
+                    "and sysdate >= pmdi015 and sysdate <= case when pmdi016 is null then date '2025-12-31' else pmdi016 end" +
+            ") t2 where t1.pmdi004 = t2.pmdi004 and t1.pmdj002 = t2.pmdj002) " +
+            "and t1.pmdi004 = :mainPmdi004 and t1.pmdj002 = :detail1Ljbh";
+    private static final String ERP_LAST_PRICE_COUNT_SQL = "select count(1) from (" + ERP_LAST_PRICE_DATA_SQL + ")";
+
     // 委外采购单开窗固定查询明磊公司与 ML 据点,采购单号来自宜搭表单字段。
     private static final String ERP_OUTSOURCED_PURCHASE_ORDER_WINDOW_DICTIONARY_CODE =
             "erp-outsourced-purchase-order-window";
@@ -202,6 +279,21 @@ public class DictionaryQueryServiceImpl implements DictionaryQueryService {
         if (ERP_PRODUCTION_ORDER_DICTIONARY_CODE.equals(dictionaryCode)) {
             return queryErpProductionOrder(safeFilters);
         }
+        if (ERP_SUPPLIER_DISABLED_DICTIONARY_CODE.equals(dictionaryCode)) {
+            return queryErpSupplierDisabled(safeFilters);
+        }
+        if (ERP_ACTIVE_ITEM_DICTIONARY_CODE.equals(dictionaryCode)) {
+            return queryErpActiveItem(safeFilters);
+        }
+        if (ERP_TEMPLATE_ITEM_DICTIONARY_CODE.equals(dictionaryCode)) {
+            return queryErpTemplateItem(safeFilters);
+        }
+        if (ERP_TEMPLATE_ORDERS_DICTIONARY_CODE.equals(dictionaryCode)) {
+            return queryErpTemplateOrders(safeFilters);
+        }
+        if (ERP_LAST_PRICE_DICTIONARY_CODE.equals(dictionaryCode)) {
+            return queryErpLastPrice(safeFilters);
+        }
         if (ERP_OUTSOURCED_PURCHASE_ORDER_WINDOW_DICTIONARY_CODE.equals(dictionaryCode)) {
             return queryErpOutsourcedPurchaseOrderWindow(safeFilters);
         }
@@ -279,7 +371,7 @@ public class DictionaryQueryServiceImpl implements DictionaryQueryService {
     }
 
     /**
-     * 查询 ERP 料件视图的全部字段,并可按中文品名模糊筛选。
+     * 查询 ERP 料件视图的全部字段,并可按料号或中文品名模糊筛选。
      *
      * @param filters 调用方传入的筛选条件
      * @return ERP 料件视图分页记录
@@ -291,8 +383,9 @@ public class DictionaryQueryServiceImpl implements DictionaryQueryService {
         String countSql = ERP_ITEM_VIEW_COUNT_SQL;
         String keyword = optionalTextFilter(filters, "keyword");
         if (keyword != null) {
-            dataSql += " and imaal003 like :keyword";
-            countSql += " and imaal003 like :keyword";
+            String keywordCondition = " and (imaa001 like :keyword or imaal003 like :keyword)";
+            dataSql += keywordCondition;
+            countSql += keywordCondition;
             parameters.addValue("keyword", createLikeValue(keyword));
         }
         return queryPage(dataSql, countSql, parameters, createPageRequest());
@@ -325,6 +418,104 @@ public class DictionaryQueryServiceImpl implements DictionaryQueryService {
         return queryPage(ERP_PRODUCTION_ORDER_DATA_SQL, ERP_PRODUCTION_ORDER_COUNT_SQL, parameters, createPageRequest());
     }
 
+    /**
+     * 查询指定据点和公司的禁用供应商视图,并可按供应商名称模糊筛选。
+     *
+     * @param filters 调用方传入的筛选条件
+     * @return 禁用供应商分页记录
+     */
+    private Map<String, Object> queryErpSupplierDisabled(Map<String, String> filters) {
+        validateFilters(filters, ERP_SUPPLIER_DISABLED_DICTIONARY_CODE, "keyword");
+        MapSqlParameterSource parameters = createBaseParameters(filters);
+        String dataSql = ERP_SUPPLIER_DISABLED_DATA_SQL;
+        String countSql = ERP_SUPPLIER_DISABLED_COUNT_SQL;
+        String keyword = optionalTextFilter(filters, "keyword");
+        if (keyword != null) {
+            dataSql += " and pmaal004 like :keyword";
+            countSql += " and pmaal004 like :keyword";
+            parameters.addValue("keyword", createLikeValue(keyword));
+        }
+        return queryPage(dataSql, countSql, parameters, createPageRequest());
+    }
+
+    /**
+     * 查询固定范围内的 T100 有效料号,并可按料号或中文品名模糊筛选。
+     *
+     * @param filters 调用方传入的筛选条件
+     * @return 有效料号分页记录
+     */
+    private Map<String, Object> queryErpActiveItem(Map<String, String> filters) {
+        validateFilters(filters, ERP_ACTIVE_ITEM_DICTIONARY_CODE, "keyword");
+        MapSqlParameterSource parameters = new MapSqlParameterSource();
+        String dataSql = ERP_ACTIVE_ITEM_DATA_SQL;
+        String countSql = ERP_ACTIVE_ITEM_COUNT_SQL;
+        String keyword = optionalTextFilter(filters, "keyword");
+        if (keyword != null) {
+            String keywordCondition = " and (imaa001 like :keyword or imaal003 like :keyword)";
+            dataSql += keywordCondition;
+            countSql += keywordCondition;
+            parameters.addValue("keyword", createLikeValue(keyword));
+        }
+        return queryPage(dataSql, countSql, parameters, createPageRequest());
+    }
+
+    /**
+     * 查询固定范围内的范本料号,并可按料号或中文品名模糊筛选。
+     *
+     * @param filters 调用方传入的筛选条件
+     * @return 范本料号分页记录
+     */
+    private Map<String, Object> queryErpTemplateItem(Map<String, String> filters) {
+        validateFilters(filters, ERP_TEMPLATE_ITEM_DICTIONARY_CODE, "keyword");
+        MapSqlParameterSource parameters = new MapSqlParameterSource();
+        String dataSql = ERP_TEMPLATE_ITEM_DATA_SQL;
+        String countSql = ERP_TEMPLATE_ITEM_COUNT_SQL;
+        String keyword = optionalTextFilter(filters, "keyword");
+        if (keyword != null) {
+            String keywordCondition = " and (imaa001 like :keyword or imaal003 like :keyword)";
+            dataSql += keywordCondition;
+            countSql += keywordCondition;
+            parameters.addValue("keyword", createLikeValue(keyword));
+        }
+        return queryPage(dataSql, countSql, parameters, createPageRequest());
+    }
+
+    /**
+     * 按范本料号前缀查询 ML 据点内未作废的关联订单,并可按订单号模糊筛选。
+     *
+     * @param filters 调用方传入的筛选条件
+     * @return 范本料号关联订单分页记录
+     */
+    private Map<String, Object> queryErpTemplateOrders(Map<String, String> filters) {
+        validateFilters(filters, ERP_TEMPLATE_ORDERS_DICTIONARY_CODE, "bgfblh", "keyword");
+        MapSqlParameterSource parameters = new MapSqlParameterSource();
+        parameters.addValue("bgfblh", requireTextFilter(filters, "bgfblh") + "%");
+        String dataSql = ERP_TEMPLATE_ORDERS_DATA_SQL;
+        String countSql = ERP_TEMPLATE_ORDERS_COUNT_SQL;
+        String keyword = optionalTextFilter(filters, "keyword");
+        if (keyword != null) {
+            String keywordCondition = " and xmdadocno like :keyword";
+            dataSql += keywordCondition;
+            countSql = "select count(1) from (" + dataSql + ")";
+            parameters.addValue("keyword", createLikeValue(keyword));
+        }
+        return queryPage(dataSql, countSql, parameters, createPageRequest());
+    }
+
+    /**
+     * 按宜搭主表料号和子表料件编号查询有效采购价格单中的上次价格。
+     *
+     * @param filters 调用方传入的筛选条件
+     * @return 上次价格分页记录
+     */
+    private Map<String, Object> queryErpLastPrice(Map<String, String> filters) {
+        validateFilters(filters, ERP_LAST_PRICE_DICTIONARY_CODE, MAIN_PMDI004_FILTER, DETAIL_1_LJBH_FILTER);
+        MapSqlParameterSource parameters = new MapSqlParameterSource();
+        parameters.addValue("mainPmdi004", requireTextFilter(filters, MAIN_PMDI004_FILTER));
+        parameters.addValue("detail1Ljbh", requireTextFilter(filters, DETAIL_1_LJBH_FILTER));
+        return queryPage(ERP_LAST_PRICE_DATA_SQL, ERP_LAST_PRICE_COUNT_SQL, parameters, createPageRequest());
+    }
+
     /**
      * 查询指定委外采购单的明细料件信息。
      *

+ 12 - 0
src/main/java/com/malk/minglei/service/impl/employee/EmployeeRosterRecord.java

@@ -14,6 +14,10 @@ public class EmployeeRosterRecord {
     private String jobGradeId;
     private String department;
     private String firstDepartment;
+    /**
+     * 数据库人员状态:1 表示在职,0 表示离职。
+     */
+    private int isResigned = 1;
 
     public String getDingtalkId() {
         return dingtalkId;
@@ -86,4 +90,12 @@ public class EmployeeRosterRecord {
     public void setFirstDepartment(String firstDepartment) {
         this.firstDepartment = firstDepartment;
     }
+
+    public int getIsResigned() {
+        return isResigned;
+    }
+
+    public void setIsResigned(int isResigned) {
+        this.isResigned = isResigned;
+    }
 }

+ 28 - 5
src/main/java/com/malk/minglei/service/impl/employee/EmployeeRosterRepository.java

@@ -10,7 +10,9 @@ import org.springframework.stereotype.Repository;
 import java.sql.PreparedStatement;
 import java.sql.ResultSet;
 import java.sql.SQLException;
+import java.util.LinkedHashMap;
 import java.util.List;
+import java.util.Map;
 
 /**
  * 员工花名册本地数据库访问组件。
@@ -75,12 +77,31 @@ public class EmployeeRosterRepository {
     private EmployeeRosterInfo findOne(String columnName, String value) {
         JdbcTemplate jdbcTemplate = requireJdbcTemplate();
         String sql = "SELECT id, dingtalk_id, employee_no, username, job_title, job_title_id, job_grade, job_grade_id, department, first_department " +
-                "FROM " + resolveTableName() + " WHERE " + columnName + " = ? LIMIT 1";
+                "FROM " + resolveTableName() + " WHERE " + columnName + " = ? AND is_resigned = 1 LIMIT 1";
         List<EmployeeRosterInfo> rows = jdbcTemplate.query(sql, preparedStatement -> preparedStatement.setString(1, value),
                 (resultSet, rowNum) -> mapRow(resultSet));
         return rows.isEmpty() ? null : rows.get(0);
     }
 
+    /**
+     * 查询所有在职重名人员及其人数,用于同步后通知人事核对。
+     *
+     * @return 姓名到同名在职人数的映射,按姓名升序排列
+     */
+    public Map<String, Integer> findActiveDuplicateUsernameCounts() {
+        JdbcTemplate jdbcTemplate = requireJdbcTemplate();
+        String sql = "SELECT username, COUNT(*) AS duplicate_count FROM " + resolveTableName() +
+                " WHERE is_resigned = 1 AND username IS NOT NULL AND TRIM(username) <> '' " +
+                "GROUP BY username HAVING COUNT(*) > 1 ORDER BY username";
+        return jdbcTemplate.query(sql, resultSet -> {
+            Map<String, Integer> duplicateCounts = new LinkedHashMap<String, Integer>();
+            while (resultSet.next()) {
+                duplicateCounts.put(resultSet.getString("username"), Integer.valueOf(resultSet.getInt("duplicate_count")));
+            }
+            return duplicateCounts;
+        });
+    }
+
     private EmployeeRosterInfo mapRow(ResultSet resultSet) throws SQLException {
         EmployeeRosterInfo info = new EmployeeRosterInfo();
         info.setId(Integer.valueOf(resultSet.getInt("id")));
@@ -106,7 +127,8 @@ public class EmployeeRosterRepository {
         statement.setString(index++, record.getJobGrade());
         statement.setString(index++, record.getJobGradeId());
         statement.setString(index++, record.getDepartment());
-        statement.setString(index, record.getFirstDepartment());
+        statement.setString(index++, record.getFirstDepartment());
+        statement.setInt(index, record.getIsResigned());
     }
 
     private JdbcTemplate requireJdbcTemplate() {
@@ -119,8 +141,8 @@ public class EmployeeRosterRepository {
 
     private String createMergeSql(String tableName) {
         return "INSERT INTO " + tableName + " " +
-                "(dingtalk_id, employee_no, username, job_title, job_title_id, job_grade, job_grade_id, department, first_department) " +
-                "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) " +
+                "(dingtalk_id, employee_no, username, job_title, job_title_id, job_grade, job_grade_id, department, first_department, is_resigned) " +
+                "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " +
                 "ON DUPLICATE KEY UPDATE " +
                 "dingtalk_id = VALUES(dingtalk_id), " +
                 "employee_no = VALUES(employee_no), " +
@@ -130,7 +152,8 @@ public class EmployeeRosterRepository {
                 "job_grade = VALUES(job_grade), " +
                 "job_grade_id = VALUES(job_grade_id), " +
                 "department = VALUES(department), " +
-                "first_department = VALUES(first_department)";
+                "first_department = VALUES(first_department), " +
+                "is_resigned = VALUES(is_resigned)";
     }
 
     private String resolveTableName() {

+ 27 - 1
src/main/java/com/malk/minglei/service/impl/employee/EmployeeRosterServiceImpl.java

@@ -36,6 +36,7 @@ public class EmployeeRosterServiceImpl implements EmployeeRosterService {
     private final DingTalkEmployeeRosterClient rosterClient;
     private final EmployeeRosterRepository repository;
     private final EmployeeRosterProperties properties;
+    private final EmployeeRosterDuplicateNameNotifier duplicateNameNotifier;
 
     /**
      * 创建员工花名册服务实现。
@@ -52,13 +53,15 @@ public class EmployeeRosterServiceImpl implements EmployeeRosterService {
                                      DDConf dingTalkConfiguration,
                                      DingTalkEmployeeRosterClient rosterClient,
                                      EmployeeRosterRepository repository,
-                                     EmployeeRosterProperties properties) {
+                                     EmployeeRosterProperties properties,
+                                     EmployeeRosterDuplicateNameNotifier duplicateNameNotifier) {
         this.dingTalkClient = dingTalkClient;
         this.contactsClient = contactsClient;
         this.dingTalkConfiguration = dingTalkConfiguration;
         this.rosterClient = rosterClient;
         this.repository = repository;
         this.properties = properties;
+        this.duplicateNameNotifier = duplicateNameNotifier;
     }
 
     /**
@@ -101,6 +104,7 @@ public class EmployeeRosterServiceImpl implements EmployeeRosterService {
         }
 
         int syncedCount = repository.upsertAll(records);
+        duplicateNameNotifier.notifyDuplicateUsernames(repository.findActiveDuplicateUsernameCounts());
         return new EmployeeRosterSyncResult(departmentId, type == INCLUDE_CHILD_DEPARTMENTS, records.size(), fieldCodes.size(), syncedCount);
     }
 
@@ -243,9 +247,31 @@ public class EmployeeRosterServiceImpl implements EmployeeRosterService {
         record.setJobGradeId(findRosterValue(rosterValues, "职等", "ad410990-cee4-4e19-b70f-189a8ab87527", "job_grade"));
         record.setDepartment(findRosterValue(rosterValues, "部门", "所在部门", "部门名称", "department"));
         record.setFirstDepartment(findRosterValue(rosterValues, "一级部门", "一级部门名称", "first_department"));
+        record.setIsResigned(isActive(user) ? 1 : 0);
         return record;
     }
 
+    /**
+     * 钉钉通讯录明确返回非激活状态时,按离职处理;缺失状态字段时保持原有在职兼容行为。
+     *
+     * @param user 钉钉通讯录用户详情
+     * @return 是否在职
+     */
+    private boolean isActive(Map user) {
+        Object active = firstNotNull(user.get("active"), user.get("is_active"), user.get("isActive"));
+        if (active == null) {
+            return true;
+        }
+        if (active instanceof Boolean) {
+            return ((Boolean) active).booleanValue();
+        }
+        if (active instanceof Number) {
+            return ((Number) active).intValue() != 0;
+        }
+        String value = String.valueOf(active).trim();
+        return !"false".equalsIgnoreCase(value) && !"0".equals(value);
+    }
+
     private List<Map> extractUserList(Map result) {
         if (result == null) {
             return Collections.emptyList();

+ 4 - 2
src/main/java/com/malk/minglei/service/impl/port/MingleiPortServiceImpl.java

@@ -15,6 +15,8 @@ import org.springframework.http.ResponseEntity;
 import org.springframework.stereotype.Service;
 import org.springframework.web.client.RestTemplate;
 
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
 import java.util.Collections;
 import java.util.LinkedHashMap;
 import java.util.Map;
@@ -31,12 +33,12 @@ public class MingleiPortServiceImpl implements MingleiPortService {
     private static final String HOST_IP = "10.0.2.60";
     private static final String HOST_LANGUAGE = "zh_CN";
     private static final String HOST_ACCOUNT = "tiptop";
-    private static final String HOST_TIMESTAMP = "20260807164700004";
     private static final String SERVICE_PRODUCT = "T100";
     private static final String SERVICE_NAME = "ProcurementApprovalPrice";
     private static final String SERVICE_ID = "toptst";
     private static final String ENTERPRISE_ID = "87";
     private static final String COMPANY_ID = "ML";
+    private static final DateTimeFormatter TIMESTAMP_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmssSSS");
 
     private final RestTemplate restTemplate;
     private final MingleiPortProperties properties;
@@ -84,7 +86,7 @@ public class MingleiPortServiceImpl implements MingleiPortService {
         host.put("ip", HOST_IP);
         host.put("lang", HOST_LANGUAGE);
         host.put("acct", HOST_ACCOUNT);
-        host.put("timestamp", HOST_TIMESTAMP);
+        host.put("timestamp", LocalDateTime.now().format(TIMESTAMP_FORMATTER));
 
         Map<String, Object> service = new LinkedHashMap<String, Object>();
         service.put("prod", SERVICE_PRODUCT);

+ 2 - 0
src/main/java/com/malk/minglei/service/impl/yida/MiscReceiptIssueFormPushHandler.java

@@ -48,6 +48,7 @@ public class MiscReceiptIssueFormPushHandler implements YidaFormPushHandler {
     private static final String REASON_CODE_FIELD_ID = "textField_mt80nq6t";
     private static final String REASON_DESC_FIELD_ID = "textField_mt80nq6v";
     private static final String REMARK_FIELD_ID = "textField_mt7zi4fz";
+    private static final String DOCUMENT_NO_FIELD_ID = "textField_mvc41oom";
 
     private static final String DETAIL_TABLE_FIELD_ID = "tableField_msh186sv";
     private static final String DETAIL_SEQ_FIELD_ID = "textField_mt7zi4g1";
@@ -163,6 +164,7 @@ public class MiscReceiptIssueFormPushHandler implements YidaFormPushHandler {
         putIfNotBlank(formData, REASON_CODE_FIELD_ID, value(main, "inba007"));
         putIfNotBlank(formData, REASON_DESC_FIELD_ID, value(main, "oocql004"));
         putIfNotBlank(formData, REMARK_FIELD_ID, value(main, "inba008"));
+        putIfNotBlank(formData, DOCUMENT_NO_FIELD_ID, docNo);
 
         List<Map<String, Object>> rows = toDetailRows(detailRows);
         if (!rows.isEmpty()) {

+ 19 - 0
src/main/resources/application.yml

@@ -1,5 +1,10 @@
 server:
   port: ${SERVER_PORT:80}
+  servlet:
+    encoding:
+      charset: UTF-8
+      enabled: true
+      force: true
   tomcat:
     accesslog:
       enabled: true
@@ -21,6 +26,20 @@ dingtalk:
   app-secret: ${DINGTALK_APP_SECRET:z1YxVWLauwNdns9cyIJEg58Lz0S2XLdxMmbOkk-xISmeHoKdmusyt2xB4N8vpIFj}
 
 minglei:
+  banking:
+    base-url: ${MINGLEI_BANKING_BASE_URL:https://sandbox.nexbankapi.com}
+    payment-encryption-enabled: ${MINGLEI_BANKING_PAYMENT_ENCRYPTION_ENABLED:true}
+    app-id: ${MINGLEI_BANKING_APP_ID:f0241b326afe45fb852340120cf84302}
+    app-secret: ${MINGLEI_BANKING_APP_SECRET:D0cpE1DKtoV6O0X5cQOQO4quqo1wlOR6DMICXDXuV7zv}
+    customer-code: ${MINGLEI_BANKING_CUSTOMER_CODE:CUSTD74467BFC9EC8396}
+    default-from-account-no: ${MINGLEI_BANKING_DEFAULT_FROM_ACCOUNT_NO:123321}
+    default-from-account-name: ${MINGLEI_BANKING_DEFAULT_FROM_ACCOUNT_NAME:浙江明磊锂能源科技有限公司}
+    intra-bank-name: ${MINGLEI_BANKING_INTRA_BANK_NAME:交通银行}
+    # 支付国密模式使用 16 进制 SM2 公钥/私钥;请通过环境变量注入,禁止提交真实密钥。
+    xencio-public-key: ${MINGLEI_BANKING_XENCIO_PUBLIC_KEY:MFkwEwYHKoZIzj0CAQYIKoEcz1UBgi0DQgAEetJMdNQheTFUCY84eZIER4A6QuCLSiFxOEQn/Ywv9mBa2J8DEzohO4rKLcL37Y/kO7rDSuE1jpkZBpEdBnJwRw==}
+    enterprise-private-key: ${MINGLEI_BANKING_ENTERPRISE_PRIVATE_KEY:MIGTAgEAMBMGByqGSM49AgEGCCqBHM9VAYItBHkwdwIBAQQgt7HYxYc4GUHXsOyDcvBk1/Q5SOMbZwXsjT9wmdm1/fKgCgYIKoEcz1UBgi2hRANCAAQnLMyPqsMFqYCDq/2/s9b0+2ZkiAAUfrEMDMyKZ9c8QyqULyuUAU+MehdpuEIIrKrAmvz0k5RzOVc9Kqp3ZvXG}
+    connect-timeout-millis: ${MINGLEI_BANKING_CONNECT_TIMEOUT_MILLIS:10000}
+    read-timeout-millis: ${MINGLEI_BANKING_READ_TIMEOUT_MILLIS:30000}
   port:
     supplier-price-approval-url: ${MINGLEI_SUPPLIER_PRICE_APPROVAL_URL:http://10.0.2.60/wtoptst/ws/r/awsp920}
     supplier-price-approval-key: ${MINGLEI_SUPPLIER_PRICE_APPROVAL_KEY:f5458f5c0f9022db743a7c0710145903}

+ 1 - 0
src/main/resources/db/employee_roster.sql

@@ -9,6 +9,7 @@ CREATE TABLE employee_roster (
   job_grade_id VARCHAR(64) DEFAULT NULL COMMENT '职等ID',
   department VARCHAR(128) DEFAULT NULL COMMENT '部门',
   first_department VARCHAR(128) DEFAULT NULL COMMENT '一级部门',
+  is_resigned TINYINT NOT NULL DEFAULT 1 COMMENT '人员状态:1在职,0离职',
   PRIMARY KEY (id),
   UNIQUE KEY uk_dingtalk_id (dingtalk_id),
   UNIQUE KEY uk_employee_no (employee_no),

+ 20 - 4
src/test/java/com/malk/minglei/service/impl/employee/EmployeeRosterServiceImplTest.java

@@ -41,7 +41,8 @@ class EmployeeRosterServiceImplTest {
                 null,
                 null,
                 repository,
-                new EmployeeRosterProperties());
+                new EmployeeRosterProperties(),
+                mock(EmployeeRosterDuplicateNameNotifier.class));
 
         assertSame(employee, service.findByEmployeeNo(" 1001 "));
         verify(repository).findByEmployeeNo("1001");
@@ -54,6 +55,7 @@ class EmployeeRosterServiceImplTest {
         DDClient_Contacts contactsClient = mock(DDClient_Contacts.class);
         DingTalkEmployeeRosterClient rosterClient = mock(DingTalkEmployeeRosterClient.class);
         EmployeeRosterRepository repository = mock(EmployeeRosterRepository.class);
+        EmployeeRosterDuplicateNameNotifier duplicateNameNotifier = mock(EmployeeRosterDuplicateNameNotifier.class);
         DDConf dingTalkConfiguration = new DDConf();
         dingTalkConfiguration.setAgentId(123456L);
 
@@ -78,6 +80,8 @@ class EmployeeRosterServiceImplTest {
                         roster("user-2", "工程师", "P5", "技术部", "研发中心"),
                         roster("user-3", "专员", "P4", "行政部", "职能中心")));
         given(repository.upsertAll(anyList())).willReturn(3);
+        Map<String, Integer> duplicateUsernameCounts = Collections.singletonMap("Alice", Integer.valueOf(2));
+        given(repository.findActiveDuplicateUsernameCounts()).willReturn(duplicateUsernameCounts);
 
         EmployeeRosterServiceImpl service = new EmployeeRosterServiceImpl(
                 dingTalkClient,
@@ -85,7 +89,8 @@ class EmployeeRosterServiceImplTest {
                 dingTalkConfiguration,
                 rosterClient,
                 repository,
-                new EmployeeRosterProperties());
+                new EmployeeRosterProperties(),
+                duplicateNameNotifier);
 
         EmployeeRosterSyncResult result = service.sync(1L, 0);
 
@@ -107,6 +112,8 @@ class EmployeeRosterServiceImplTest {
         assertEquals("10010", records.get(0).getJobGradeId());
         assertEquals("技术部", records.get(0).getDepartment());
         assertEquals("研发中心", records.get(0).getFirstDepartment());
+        assertEquals(1, records.get(0).getIsResigned());
+        verify(duplicateNameNotifier).notifyDuplicateUsernames(duplicateUsernameCounts);
     }
 
     @Test
@@ -116,12 +123,13 @@ class EmployeeRosterServiceImplTest {
         DDClient_Contacts contactsClient = mock(DDClient_Contacts.class);
         DingTalkEmployeeRosterClient rosterClient = mock(DingTalkEmployeeRosterClient.class);
         EmployeeRosterRepository repository = mock(EmployeeRosterRepository.class);
+        EmployeeRosterDuplicateNameNotifier duplicateNameNotifier = mock(EmployeeRosterDuplicateNameNotifier.class);
         DDConf dingTalkConfiguration = new DDConf();
         dingTalkConfiguration.setAgentId(123456L);
 
         given(dingTalkClient.getAccessToken()).willReturn("access-token");
         given(contactsClient.listDeptUserDetail_v2(eq("access-token"), eq(1L), anyMap())).willReturn(mapOf(
-                "list", Collections.singletonList(userWithTitle("user-1", "Alice", "1001", "Fallback Title")),
+                "list", Collections.singletonList(resignedUser("user-1", "Alice", "1001", "Fallback Title")),
                 "has_more", false));
         given(rosterClient.listRosterFieldGroups("access-token", 123456L)).willReturn(Collections.singletonList(mapOf(
                 "field_list", Arrays.asList(
@@ -140,7 +148,8 @@ class EmployeeRosterServiceImplTest {
                 dingTalkConfiguration,
                 rosterClient,
                 repository,
-                new EmployeeRosterProperties());
+                new EmployeeRosterProperties(),
+                duplicateNameNotifier);
 
         service.sync(1L, 1);
 
@@ -151,6 +160,7 @@ class EmployeeRosterServiceImplTest {
         assertNull(records.get(0).getJobTitleId());
         assertNull(records.get(0).getJobGrade());
         assertNull(records.get(0).getJobGradeId());
+        assertEquals(0, records.get(0).getIsResigned());
     }
 
     private static Map<String, Object> user(String userId, String name, String jobNumber) {
@@ -168,6 +178,12 @@ class EmployeeRosterServiceImplTest {
         return user;
     }
 
+    private static Map<String, Object> resignedUser(String userId, String name, String jobNumber, String title) {
+        Map<String, Object> user = userWithTitle(userId, name, jobNumber, title);
+        user.put("active", false);
+        return user;
+    }
+
     private static Map<String, Object> mapOf(String key, Object value) {
         Map<String, Object> map = new LinkedHashMap<String, Object>();
         map.put(key, value);

+ 11 - 12
src/test/java/com/malk/minglei/service/impl/port/MingleiPortServiceImplTest.java

@@ -11,9 +11,11 @@ import org.springframework.web.client.RestTemplate;
 
 import java.util.Map;
 
+import static org.hamcrest.Matchers.matchesPattern;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.springframework.test.web.client.match.MockRestRequestMatchers.content;
 import static org.springframework.test.web.client.match.MockRestRequestMatchers.header;
+import static org.springframework.test.web.client.match.MockRestRequestMatchers.jsonPath;
 import static org.springframework.test.web.client.match.MockRestRequestMatchers.method;
 import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
 import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
@@ -40,7 +42,15 @@ class MingleiPortServiceImplTest {
         server.expect(requestTo("http://external.example/wtoptst/ws/r/awsp920"))
                 .andExpect(method(org.springframework.http.HttpMethod.POST))
                 .andExpect(header("Content-Type", MediaType.APPLICATION_JSON_VALUE))
-                .andExpect(content().json(externalRequestJson()))
+                .andExpect(jsonPath("$.key").value("key-1"))
+                .andExpect(jsonPath("$.type").value("sync"))
+                .andExpect(jsonPath("$.host.timestamp").value(matchesPattern("\\d{17}")))
+                .andExpect(jsonPath("$.service.name").value("ProcurementApprovalPrice"))
+                .andExpect(jsonPath("$.datakey.EntId").value("87"))
+                .andExpect(jsonPath("$.datakey.CompanyId").value("ML"))
+                .andExpect(jsonPath("$.payload.std_data.parameter.head.pmdidocno").value("CH3"))
+                .andExpect(jsonPath("$.payload.std_data.parameter.detail[0].pmdj011").value("0.5000"))
+                .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
                 .andRespond(withSuccess("{\"srvver\":\"1.0\",\"srvcode\":\"100\",\"datakey\":null,\"payload\":{\"std_data\":{\"execution\":{\"code\":\"0\",\"sql_code\":null,\"description\":\"SUCCESS\"},\"parameter\":{\"pmdidocno\":\"CH3\",\"tran_time\":\"2026-08-25 10:00:00\"}}}}", MediaType.APPLICATION_JSON));
 
         Map<String, Object> response = mingleiPortService.submitSupplierPriceApproval(readRequest());
@@ -60,15 +70,4 @@ class MingleiPortServiceImplTest {
                 + "}";
     }
 
-    private String externalRequestJson() {
-        return "{"
-                + "\"key\":\"key-1\",\"type\":\"sync\","
-                + "\"host\":{\"prod\":\"APP\",\"ip\":\"10.0.2.60\",\"lang\":\"zh_CN\",\"acct\":\"tiptop\",\"timestamp\":\"20260807164700004\"},"
-                + "\"service\":{\"prod\":\"T100\",\"name\":\"ProcurementApprovalPrice\",\"ip\":\"10.0.2.60\",\"id\":\"toptst\"},"
-                + "\"datakey\":{\"EntId\":\"87\",\"CompanyId\":\"ML\"},"
-                + "\"payload\":{\"std_data\":{\"parameter\":{"
-                + "\"head\":{\"pmdidocno\":\"CH3\",\"pmdi001\":\"N\",\"pmdi034\":\"605188\",\"pmdidocdt\":\"2026-08-06\",\"pmdi004\":\"517022\",\"pmdi015\":\"2026-08-06\",\"pmdi016\":\"2027-06-01\",\"pmdi003\":\"A043\",\"pmdi002\":\"210721017\",\"pmdiud001\":\"N\",\"pmdistus\":\"I\"},"
-                + "\"detail\":[{\"pmdj011\":\"0.5000\",\"pmdj002\":\"524000059517022000000\",\"pmdj030\":\"\"}]"
-                + "}}}}";
-    }
 }

+ 1 - 0
src/test/java/com/malk/minglei/service/impl/yida/MiscReceiptIssueFormPushHandlerTest.java

@@ -110,6 +110,7 @@ class MiscReceiptIssueFormPushHandlerTest {
         assertEquals("RSN-001", formData.getString("textField_mt80nq6t"));
         assertEquals("reason-desc-main", formData.getString("textField_mt80nq6v"));
         assertEquals("remark-001", formData.getString("textField_mt7zi4fz"));
+        assertEquals("MS-001", formData.getString("textField_mvc41oom"));
 
         JSONArray detailRows = formData.getJSONArray("tableField_msh186sv");
         assertEquals(1, detailRows.size());