Selaa lähdekoodia

feat: 杂收发与订单审批改用花名册和通讯录,申请时间保留到秒

宜搭订单查询会带主表字段名,数量和型号接口同时接受该订单号。杂收发标题改为理由码说明加单号,仓管员按工号换成钉钉用户。订单审批不再把 ERP 工号和部门代号直接写入成员组件。

Co-authored-by: Cursor <cursoragent@cursor.com>
admin 2 päivää sitten
vanhempi
commit
c6715b3803

+ 4 - 4
DATA-DICTIONARY-API.md

@@ -44,8 +44,8 @@
 | `erp-item-view` | ERP 料件视图 | `ent` | `keyword` |
 | `erp-sales-order` | 销售订单开窗 | `t100id` | `keyword` |
 | `erp-sales-order-line` | 客户订单项次 | 无 | `keyword` |
-| `erp-order-qty` | 订单号查订单数量 | `ddh` | 无 |
-| `erp-order-product-model` | 订单号查产品型号 | `ddh` | 无 |
+| `erp-order-qty` | 订单号查订单数量 | `ddh` 或 `main.pmdi004` | 无 |
+| `erp-order-product-model` | 订单号查产品型号 | `ddh` 或 `main.pmdi004` | 无 |
 | `erp-production-order` | 生产令开窗 | `t100id`、`userId` | 无 |
 | `erp-supplier-disabled` | ERP 供应商禁用视图 | `t100id`、`ent` | `keyword` |
 | `erp-active-item` | T100 有效料号 | 无 | `keyword` |
@@ -136,7 +136,7 @@ GET /api/dictionary/erp-order-qty?ddh=SO-001
 ```
 
 - SQL 使用 `订单号查订单数量.sql`。表名加 `DSDATA`,公司 `86`、据点 `ML` 及原有筛选条件保持不变。
-- `ddh`:订单号,必填,精确匹配 `xmdcdocno`。
+- `ddh`:订单号,必填,精确匹配 `xmdcdocno`。宜搭若按主表字段传 `main.pmdi004`,同样作为订单号;两者都传时用 `ddh`。
 - 只汇总 `xmdc019 = '1'` 且料号以 `1` 开头的明细。
 - 返回字段:`xmdc007`。没有符合条件的明细时仍返回一行,数量为 `0`。
 
@@ -147,7 +147,7 @@ GET /api/dictionary/erp-order-product-model?ddh=SO-001
 ```
 
 - SQL 使用 `订单号查产品型号.sql`。表名加 `DSDATA`,公司 `86`、据点 `ML` 及原有筛选条件保持不变。
-- `ddh`:订单号,必填,精确匹配 `xmdcdocno`。
+- `ddh`:订单号,必填,精确匹配 `xmdcdocno`。宜搭若按主表字段传 `main.pmdi004`,同样作为订单号;两者都传时用 `ddh`。
 - 只取 `xmdc019 = '1'` 且料号以 `1` 开头的明细,返回这些明细规格 `imaal004` 的最大值。
 - 返回字段:`imaal004`。没有符合条件的明细时仍返回一行,型号为空。
 

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

@@ -600,9 +600,9 @@ public class DictionaryQueryServiceImpl implements DictionaryQueryService {
      * @return 订单数量,字段 xmdc007
      */
     private Map<String, Object> queryErpOrderQty(Map<String, String> filters) {
-        validateFilters(filters, ERP_ORDER_QTY_DICTIONARY_CODE, "ddh");
+        validateFilters(filters, ERP_ORDER_QTY_DICTIONARY_CODE, "ddh", MAIN_PMDI004_FILTER);
         MapSqlParameterSource parameters = new MapSqlParameterSource();
-        parameters.addValue("ddh", requireTextFilter(filters, "ddh"));
+        parameters.addValue("ddh", requireOrderNumber(filters));
         return queryPage(ERP_ORDER_QTY_DATA_SQL, ERP_ORDER_QTY_COUNT_SQL, parameters, createPageRequest());
     }
 
@@ -613,13 +613,30 @@ public class DictionaryQueryServiceImpl implements DictionaryQueryService {
      * @return 产品型号,字段 imaal004
      */
     private Map<String, Object> queryErpOrderProductModel(Map<String, String> filters) {
-        validateFilters(filters, ERP_ORDER_PRODUCT_MODEL_DICTIONARY_CODE, "ddh");
+        validateFilters(filters, ERP_ORDER_PRODUCT_MODEL_DICTIONARY_CODE, "ddh", MAIN_PMDI004_FILTER);
         MapSqlParameterSource parameters = new MapSqlParameterSource();
-        parameters.addValue("ddh", requireTextFilter(filters, "ddh"));
+        parameters.addValue("ddh", requireOrderNumber(filters));
         return queryPage(ERP_ORDER_PRODUCT_MODEL_DATA_SQL, ERP_ORDER_PRODUCT_MODEL_COUNT_SQL,
                 parameters, createPageRequest());
     }
 
+    /**
+     * 订单号优先取 ddh。宜搭按主表字段名传参时使用 main.pmdi004。
+     *
+     * @param filters 调用方传入的筛选条件
+     * @return 非空订单号
+     */
+    private String requireOrderNumber(Map<String, String> filters) {
+        String orderNumber = optionalTextFilter(filters, "ddh");
+        if (orderNumber == null) {
+            orderNumber = optionalTextFilter(filters, MAIN_PMDI004_FILTER);
+        }
+        if (orderNumber == null) {
+            throw new IllegalArgumentException("ddh must not be blank");
+        }
+        return orderNumber;
+    }
+
     /**
      * 查询指定据点及指定用户所属部门范围内的生产令开窗数据。
      * 调用方传入钉钉 userId,服务先转换为 ERP 使用的员工工号后再执行 Oracle 查询。

+ 106 - 5
src/main/java/com/malk/minglei/service/impl/yida/ApproveOrderFormPushHandler.java

@@ -2,8 +2,11 @@ package com.malk.minglei.service.impl.yida;
 
 import com.alibaba.fastjson.JSON;
 import com.malk.minglei.config.YidaApplicationsProperties;
+import com.malk.minglei.dto.EmployeeRosterInfo;
 import com.malk.minglei.dto.ErpYidaPushMainData;
 import com.malk.minglei.dto.ErpYidaPushRequest;
+import com.malk.minglei.service.employee.EmployeeRosterService;
+import com.malk.minglei.service.yida.MingleiDocumentService;
 import com.malk.minglei.service.yida.YidaFormPushHandler;
 import com.malk.minglei.service.yida.YidaProcessOpenApiClient;
 import com.malk.server.common.McException;
@@ -15,7 +18,9 @@ import org.springframework.stereotype.Service;
 
 import java.math.BigDecimal;
 import java.time.LocalDate;
+import java.time.LocalDateTime;
 import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.LinkedHashMap;
@@ -35,6 +40,7 @@ public class ApproveOrderFormPushHandler implements YidaFormPushHandler {
     private static final String FORM_UUID = "FORM-8454FA17732746058A2D90B1C559CC19G5SR";
     private static final String PROCESS_CODE = "TPROC--9VD66QA18AJ8317KK3NXM54W1HIT30C7N28TM7";
     private static final String DEFAULT_ORIGINATOR_USER_ID = "41603502201288023";
+    private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
 
     private static final String TITLE_FIELD_ID = "textField_msh27unu";
     private static final String APPLICANT_FIELD_ID = "employeeField_msh186rr";
@@ -86,6 +92,8 @@ public class ApproveOrderFormPushHandler implements YidaFormPushHandler {
     private final YidaProcessOpenApiClient yidaProcessOpenApiClient;
     private final DDConf dingTalkConfiguration;
     private final YidaApplicationsProperties applications;
+    private final EmployeeRosterService employeeRosterService;
+    private final MingleiDocumentService mingleiDocumentService;
 
     /**
      * 创建销售订单审批推送处理器。
@@ -93,13 +101,19 @@ public class ApproveOrderFormPushHandler implements YidaFormPushHandler {
      * @param yidaProcessOpenApiClient 宜搭流程 OpenAPI 客户端
      * @param dingTalkConfiguration 钉钉应用配置
      * @param applications 宜搭应用配置
+     * @param employeeRosterService 员工花名册,用于把业务人员工号换成钉钉用户 ID
+     * @param mingleiDocumentService 部门配置查询,用于按业务人员取钉钉部门
      */
     public ApproveOrderFormPushHandler(YidaProcessOpenApiClient yidaProcessOpenApiClient,
                                        DDConf dingTalkConfiguration,
-                                       YidaApplicationsProperties applications) {
+                                       YidaApplicationsProperties applications,
+                                       EmployeeRosterService employeeRosterService,
+                                       MingleiDocumentService mingleiDocumentService) {
         this.yidaProcessOpenApiClient = yidaProcessOpenApiClient;
         this.dingTalkConfiguration = dingTalkConfiguration;
         this.applications = applications;
+        this.employeeRosterService = employeeRosterService;
+        this.mingleiDocumentService = mingleiDocumentService;
     }
 
     /**
@@ -180,15 +194,14 @@ public class ApproveOrderFormPushHandler implements YidaFormPushHandler {
         putIfNotBlank(formData, APPLICANT_FIELD_ID, toStringList(operatorUserId, null));
         putIfNotBlank(formData, COMPANY_FIELD_ID, firstNonBlank(value(main, "gs", "applyCompany", "company"), value(main, "xmdaent")));
         putIfNotBlank(formData, DEPARTMENT_FIELD_ID, toStringList(value(main, "bm", "applyDepartment", "department", "deptId"), null));
-        putIfNotBlank(formData, APPLY_DATE_FIELD_ID, toDateMillis(firstNonBlank(value(main, "rq", "applyDate"), value(main, "xmdadocdt"))));
+        putIfNotBlank(formData, APPLY_DATE_FIELD_ID, toDateTimeMillis(mostPreciseDate(
+                value(main, "rq"), value(main, "applyDate"), value(main, "xmdadocdt"))));
         putIfNotBlank(formData, FORM_TITLE_FIELD_ID, title);
         putIfNotBlank(formData, URGENCY_FIELD_ID, firstNonBlank(value(main, "urgency"), "正常"));
         putIfNotBlank(formData, ENTERPRISE_NO_FIELD_ID, value(main, "xmdaent"));
         putIfNotBlank(formData, ORDER_NO_FIELD_ID, value(main, "xmdadocno"));
         putIfNotBlank(formData, ORDER_DATE_FIELD_ID, toDateMillis(value(main, "xmdadocdt")));
         putIfNotBlank(formData, VERSION_FIELD_ID, toDecimal(value(main, "xmda001")));
-        putIfNotBlank(formData, SALES_PERSON_FIELD_ID, toStringList(value(main, "xmda002"), null));
-        putIfNotBlank(formData, SALES_DEPARTMENT_FIELD_ID, toStringList(value(main, "xmda003"), null));
         putIfNotBlank(formData, CUSTOMER_CODE_FIELD_ID, value(main, "xmda004"));
         putIfNotBlank(formData, CUSTOMER_SHORT_NAME_FIELD_ID, value(main, "pmaal004"));
         putIfNotBlank(formData, ORDER_TYPE_FIELD_ID, value(main, "xmda005"));
@@ -208,6 +221,9 @@ public class ApproveOrderFormPushHandler implements YidaFormPushHandler {
             formData.put(DETAIL_TABLE_FIELD_ID, orderRows);
         }
         copyYidaFieldValues(main, formData);
+        String salesUserId = resolveSalesUserId(value(main, "xmda002"));
+        putIfNotBlank(formData, SALES_PERSON_FIELD_ID, toStringList(salesUserId, null));
+        putIfNotBlank(formData, SALES_DEPARTMENT_FIELD_ID, toStringList(resolveSalesDepartmentId(salesUserId), null));
         return formData;
     }
 
@@ -310,14 +326,99 @@ public class ApproveOrderFormPushHandler implements YidaFormPushHandler {
         return new BigDecimal(String.valueOf(value).trim());
     }
 
+    /**
+     * 业务人员工号 xmda002 查花名册,得到钉钉用户 ID。
+     */
+    private String resolveSalesUserId(Object employeeNoValue) {
+        String employeeNo = toText(employeeNoValue);
+        if (employeeNo == null) {
+            return null;
+        }
+        if (employeeRosterService == null) {
+            throw new IllegalStateException("Employee roster service is not configured");
+        }
+        EmployeeRosterInfo employee = employeeRosterService.findByEmployeeNo(employeeNo);
+        if (employee == null) {
+            throw new IllegalArgumentException("axmt500 sales person not found: " + employeeNo);
+        }
+        String dingtalkId = toText(employee.getDingtalkId());
+        if (dingtalkId == null) {
+            throw new IllegalArgumentException("axmt500 sales person has no DingTalk userId: " + employeeNo);
+        }
+        return dingtalkId;
+    }
+
+    /**
+     * 业务部门取业务人员在钉钉的主部门。xmda003 是 T100 部门代号,部门组件无法直接使用。
+     */
+    private String resolveSalesDepartmentId(String salesUserId) {
+        if (salesUserId == null) {
+            return null;
+        }
+        if (mingleiDocumentService == null) {
+            throw new IllegalStateException("Department service is not configured");
+        }
+        Map<String, Object> configuration = mingleiDocumentService.queryApplicantDepartmentConfiguration(salesUserId);
+        String departmentId = configuration == null ? null : toText(configuration.get("departmentId"));
+        if (departmentId == null) {
+            throw new IllegalArgumentException("axmt500 sales department not found for user: " + salesUserId);
+        }
+        return departmentId;
+    }
+
+    /**
+     * 在日期候选里优先使用带时分秒的值。
+     */
+    private Object mostPreciseDate(Object first, Object second, Object third) {
+        Object selected = null;
+        for (Object candidate : new Object[]{first, second, third}) {
+            if (isBlank(candidate)) {
+                continue;
+            }
+            if (selected == null) {
+                selected = candidate;
+            }
+            String text = String.valueOf(candidate);
+            if (text.indexOf(':') >= 0 || text.indexOf('T') >= 0) {
+                return candidate;
+            }
+        }
+        return selected;
+    }
+
     private Long toDateMillis(Object value) {
+        return toDateTimeMillis(value);
+    }
+
+    /**
+     * 带时分秒的文本保留到秒,只有日期时仍取当天 0 点。
+     */
+    private Long toDateTimeMillis(Object value) {
         if (isBlank(value)) {
             return null;
         }
-        String text = String.valueOf(value).trim();
+        String text = String.valueOf(value).trim().replace('/', '-');
         if (text.matches("\\d+")) {
             return Long.valueOf(text);
         }
+        if (text.indexOf(':') >= 0 || text.indexOf('T') >= 0) {
+            String normalized = text.replace('T', ' ');
+            int zoneIndex = normalized.indexOf('+');
+            if (zoneIndex < 0 && normalized.lastIndexOf('-') > 10) {
+                zoneIndex = normalized.lastIndexOf('-');
+            }
+            if (zoneIndex > 10) {
+                normalized = normalized.substring(0, zoneIndex);
+            }
+            if (normalized.endsWith("Z")) {
+                normalized = normalized.substring(0, normalized.length() - 1);
+            }
+            if (normalized.length() > 19) {
+                normalized = normalized.substring(0, 19);
+            }
+            return LocalDateTime.parse(normalized, DATE_TIME_FORMATTER)
+                    .atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
+        }
         return LocalDate.parse(text).atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli();
     }
 

+ 53 - 4
src/main/java/com/malk/minglei/service/impl/yida/MiscReceiptIssueFormPushHandler.java

@@ -2,8 +2,10 @@ package com.malk.minglei.service.impl.yida;
 
 import com.alibaba.fastjson.JSON;
 import com.malk.minglei.config.YidaApplicationsProperties;
+import com.malk.minglei.dto.EmployeeRosterInfo;
 import com.malk.minglei.dto.ErpYidaPushMainData;
 import com.malk.minglei.dto.ErpYidaPushRequest;
+import com.malk.minglei.service.employee.EmployeeRosterService;
 import com.malk.minglei.service.yida.YidaFormPushHandler;
 import com.malk.minglei.service.yida.YidaProcessOpenApiClient;
 import com.malk.server.common.McException;
@@ -11,6 +13,7 @@ import com.malk.server.dingtalk.DDR_New;
 import com.malk.server.dingtalk.DDConf;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 
 import java.math.BigDecimal;
@@ -70,11 +73,13 @@ public class MiscReceiptIssueFormPushHandler implements YidaFormPushHandler {
     private static final String DETAIL_PROJECT_NO_FIELD_ID = "textField_mt6ur4w8";
     private static final String DETAIL_PROJECT_DESC_FIELD_ID = "textField_mt80nq7a";
     private static final String DETAIL_SOURCE_DOC_NO_FIELD_ID = "textField_mt80nq7b";
+    private static final String DETAIL_KEEPER_FIELD_ID = "employeeField_mt80nq7d";
 
     private final YidaProcessOpenApiClient yidaProcessOpenApiClient;
     private final DDConf dingTalkConfiguration;
     private final YidaApplicationsProperties applications;
     private final LocalFormDepartmentEnricher departmentEnricher;
+    private final EmployeeRosterService employeeRosterService;
 
     /**
      * 创建杂收发料单推送处理器。
@@ -83,15 +88,34 @@ public class MiscReceiptIssueFormPushHandler implements YidaFormPushHandler {
      * @param dingTalkConfiguration 钉钉应用配置
      * @param applications 宜搭应用配置
      * @param departmentEnricher 部门公司和负责人填充组件
+     * @param employeeRosterService 员工花名册,用于把明细仓管员工号换成钉钉用户 ID
      */
+    @Autowired
     public MiscReceiptIssueFormPushHandler(YidaProcessOpenApiClient yidaProcessOpenApiClient,
                                            DDConf dingTalkConfiguration,
                                            YidaApplicationsProperties applications,
-                                           LocalFormDepartmentEnricher departmentEnricher) {
+                                           LocalFormDepartmentEnricher departmentEnricher,
+                                           EmployeeRosterService employeeRosterService) {
         this.yidaProcessOpenApiClient = yidaProcessOpenApiClient;
         this.dingTalkConfiguration = dingTalkConfiguration;
         this.applications = applications;
         this.departmentEnricher = departmentEnricher;
+        this.employeeRosterService = employeeRosterService;
+    }
+
+    /**
+     * 创建不查花名册的杂收发料单推送处理器。明细带仓管员工号时会因花名册未配置而失败。
+     *
+     * @param yidaProcessOpenApiClient 宜搭流程 OpenAPI 客户端
+     * @param dingTalkConfiguration 钉钉应用配置
+     * @param applications 宜搭应用配置
+     * @param departmentEnricher 部门公司和负责人填充组件
+     */
+    public MiscReceiptIssueFormPushHandler(YidaProcessOpenApiClient yidaProcessOpenApiClient,
+                                           DDConf dingTalkConfiguration,
+                                           YidaApplicationsProperties applications,
+                                           LocalFormDepartmentEnricher departmentEnricher) {
+        this(yidaProcessOpenApiClient, dingTalkConfiguration, applications, departmentEnricher, null);
     }
 
     /**
@@ -119,7 +143,8 @@ public class MiscReceiptIssueFormPushHandler implements YidaFormPushHandler {
         String systemToken = requireText(applications.getCoordinationOffice().getSystemToken(), "YIDA_COORDINATION_OFFICE_SYSTEM_TOKEN is not configured");
         Map<String, Object> formData = toYidaFormData(request, operatorUserId);
         departmentEnricher.fill(FORM_UUID, operatorUserId, formData);
-        String title = requireText(toText(formData.get(FORM_TITLE_FIELD_ID)), "Yida process title is not configured");
+        String title = requireText(toText(firstNonBlank(formData.get(TITLE_FIELD_ID), formData.get(FORM_TITLE_FIELD_ID))),
+                "Yida process title is not configured");
         Map<String, Object> body = new LinkedHashMap<String, Object>();
         body.put("appType", appType);
         body.put("systemToken", systemToken);
@@ -234,6 +259,7 @@ public class MiscReceiptIssueFormPushHandler implements YidaFormPushHandler {
             putIfNotBlank(row, DETAIL_PROJECT_DESC_FIELD_ID, value(source, "pjbal003"));
             putIfNotBlank(row, DETAIL_SOURCE_DOC_NO_FIELD_ID, value(source, "inbb017"));
             copyYidaFieldValues(source, row);
+            putWarehouseKeeper(row, source);
             if (!row.isEmpty()) {
                 rows.add(row);
             }
@@ -241,6 +267,29 @@ public class MiscReceiptIssueFormPushHandler implements YidaFormPushHandler {
         return rows;
     }
 
+    /**
+     * 明细仓管员传入的是工号。按花名册换成钉钉用户 ID 后再写入员工组件。
+     */
+    private void putWarehouseKeeper(Map<String, Object> row, Map<String, Object> source) {
+        String employeeNo = toText(firstItem(value(source, DETAIL_KEEPER_FIELD_ID, "仓管员", "仓管员工号")));
+        if (employeeNo == null) {
+            row.remove(DETAIL_KEEPER_FIELD_ID);
+            return;
+        }
+        if (employeeRosterService == null) {
+            throw new IllegalStateException("Employee roster service is not configured");
+        }
+        EmployeeRosterInfo employee = employeeRosterService.findByEmployeeNo(employeeNo);
+        if (employee == null) {
+            throw new IllegalArgumentException("misc receipt issue warehouse keeper employee not found: " + employeeNo);
+        }
+        String dingtalkId = toText(employee.getDingtalkId());
+        if (dingtalkId == null) {
+            throw new IllegalArgumentException("misc receipt issue warehouse keeper employee has no DingTalk userId: " + employeeNo);
+        }
+        row.put(DETAIL_KEEPER_FIELD_ID, Collections.singletonList(dingtalkId));
+    }
+
     private List<Map<String, Object>> toMapRows(Object value) {
         List<Map<String, Object>> rows = new ArrayList<Map<String, Object>>();
         for (Object item : toList(value)) {
@@ -326,7 +375,7 @@ public class MiscReceiptIssueFormPushHandler implements YidaFormPushHandler {
     }
 
     /**
-     * 原因说明与杂收发单号直接拼接。任一侧为空时只保留另一侧。
+     * 原因说明与杂收发单号用横线拼接。任一侧为空时只保留另一侧。
      */
     private String joinText(String first, String second) {
         if (first == null) {
@@ -335,7 +384,7 @@ public class MiscReceiptIssueFormPushHandler implements YidaFormPushHandler {
         if (second == null) {
             return first;
         }
-        return first + second;
+        return first + "-" + second;
     }
 
     private void putIfNotBlank(Map<String, Object> target, String fieldId, Object value) {

+ 8 - 0
src/test/java/com/malk/minglei/service/impl/dictionary/DictionaryQueryServiceImplTest.java

@@ -376,6 +376,14 @@ class DictionaryQueryServiceImplTest {
         IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
                 () -> service.query("erp-order-qty", missingOrderNumber));
         assertEquals("ddh must not be blank", exception.getMessage());
+
+        Map<String, String> formFieldFilters = new LinkedHashMap<String, String>();
+        formFieldFilters.put("main.pmdi004", "SO-002");
+        service.query("erp-order-qty", formFieldFilters);
+        ArgumentCaptor<SqlParameterSource> formFieldParameters = ArgumentCaptor.forClass(SqlParameterSource.class);
+        verify(jdbcTemplate, org.mockito.Mockito.atLeastOnce())
+                .queryForObject(anyString(), formFieldParameters.capture(), eq(Long.class));
+        assertEquals("SO-002", formFieldParameters.getValue().getValue("ddh"));
     }
 
     /**

+ 91 - 4
src/test/java/com/malk/minglei/service/impl/yida/ApproveOrderFormPushHandlerTest.java

@@ -3,8 +3,11 @@ package com.malk.minglei.service.impl.yida;
 import com.alibaba.fastjson.JSONArray;
 import com.alibaba.fastjson.JSONObject;
 import com.malk.minglei.config.YidaApplicationsProperties;
+import com.malk.minglei.dto.EmployeeRosterInfo;
 import com.malk.minglei.dto.ErpYidaPushMainData;
 import com.malk.minglei.dto.ErpYidaPushRequest;
+import com.malk.minglei.service.employee.EmployeeRosterService;
+import com.malk.minglei.service.yida.MingleiDocumentService;
 import com.malk.minglei.service.yida.YidaProcessOpenApiClient;
 import com.malk.server.dingtalk.DDR_New;
 import com.malk.server.dingtalk.DDConf;
@@ -16,6 +19,7 @@ import java.util.LinkedHashMap;
 import java.util.Map;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
@@ -41,6 +45,7 @@ class ApproveOrderFormPushHandlerTest {
         ErpYidaPushMainData mainData = new ErpYidaPushMainData();
         mainData.setExtraField("xmdaent", "87");
         mainData.setExtraField("xmdadocno", "SO-001");
+        mainData.setRq("2026-09-15 13:45:30");
         mainData.setExtraField("xmdadocdt", "2026-09-15");
         mainData.setExtraField("xmda001", "2");
         mainData.setExtraField("xmda002", "sales-user-001");
@@ -97,7 +102,17 @@ class ApproveOrderFormPushHandlerTest {
         request.setMainData(mainData);
         request.setDetailData(detailData);
 
-        ApproveOrderFormPushHandler handler = new ApproveOrderFormPushHandler(yidaClient, ddConf, applications);
+        EmployeeRosterService roster = mock(EmployeeRosterService.class);
+        EmployeeRosterInfo salesPerson = new EmployeeRosterInfo();
+        salesPerson.setEmployeeNo("sales-user-001");
+        salesPerson.setDingtalkId("sales-dingtalk-001");
+        when(roster.findByEmployeeNo("sales-user-001")).thenReturn(salesPerson);
+        MingleiDocumentService documentService = mock(MingleiDocumentService.class);
+        Map<String, Object> departmentConfiguration = new LinkedHashMap<String, Object>();
+        departmentConfiguration.put("departmentId", "sales-dept-88");
+        when(documentService.queryApplicantDepartmentConfiguration("sales-dingtalk-001")).thenReturn(departmentConfiguration);
+        ApproveOrderFormPushHandler handler = new ApproveOrderFormPushHandler(yidaClient, ddConf, applications,
+                roster, documentService);
 
         assertEquals("process-instance-1", handler.push(request));
 
@@ -118,14 +133,14 @@ class ApproveOrderFormPushHandlerTest {
         assertEquals("41603502201288023", formData.getJSONArray("employeeField_msh186rr").getString(0));
         assertEquals("ML", formData.getString("selectField_mtv87n9f"));
         assertEquals("dept-001", formData.getJSONArray("departmentSelectField_msh186ry").getString(0));
-        assertEquals(Long.valueOf(1789401600000L), formData.getLong("dateField_msh186rs"));
+        assertEquals(Long.valueOf(1789451130000L), formData.getLong("dateField_msh186rs"));
         assertEquals("正常", formData.getString("textField_mt7zi4fy"));
         assertEquals("87", formData.getString("textField_mt82p09i"));
         assertEquals("SO-001", formData.getString("textField_mt82p09d"));
         assertEquals(Long.valueOf(1789401600000L), formData.getLong("dateField_mt82p09f"));
         assertEquals("2", formData.getString("numberField_mt82p090"));
-        assertEquals("sales-user-001", formData.getJSONArray("employeeField_mt82p08t").getString(0));
-        assertEquals("dept-001", formData.getJSONArray("departmentSelectField_mt82p08u").getString(0));
+        assertEquals("sales-dingtalk-001", formData.getJSONArray("employeeField_mt82p08t").getString(0));
+        assertEquals("sales-dept-88", formData.getJSONArray("departmentSelectField_mt82p08u").getString(0));
         assertEquals("CUS-001", formData.getString("textField_mt80nq6v"));
         assertEquals("customer-short-name", formData.getString("textField_mt82p08x"));
         assertEquals("normal-order", formData.getString("textField_mt82p091"));
@@ -162,4 +177,76 @@ class ApproveOrderFormPushHandlerTest {
         assertEquals("200", orderRow.getString("textField_8yabl1tlp"));
         assertEquals(Long.valueOf(1790784000000L), orderRow.getLong("dateField_8yabm8rmr"));
     }
+
+    @Test
+    void pushFailsWhenSalesPersonIsMissingFromRoster() {
+        EmployeeRosterService roster = mock(EmployeeRosterService.class);
+        when(roster.findByEmployeeNo("sales-user-001")).thenReturn(null);
+        ApproveOrderFormPushHandler handler = handler(mock(YidaProcessOpenApiClient.class), roster, mock(MingleiDocumentService.class));
+
+        IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
+                () -> handler.push(requestWithSalesPerson("sales-user-001")));
+
+        assertEquals("axmt500 sales person not found: sales-user-001", exception.getMessage());
+    }
+
+    @Test
+    void pushFailsWhenSalesPersonHasNoDingTalkUserId() {
+        EmployeeRosterService roster = mock(EmployeeRosterService.class);
+        EmployeeRosterInfo salesPerson = new EmployeeRosterInfo();
+        salesPerson.setEmployeeNo("sales-user-001");
+        when(roster.findByEmployeeNo("sales-user-001")).thenReturn(salesPerson);
+        ApproveOrderFormPushHandler handler = handler(mock(YidaProcessOpenApiClient.class), roster, mock(MingleiDocumentService.class));
+
+        IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
+                () -> handler.push(requestWithSalesPerson("sales-user-001")));
+
+        assertEquals("axmt500 sales person has no DingTalk userId: sales-user-001", exception.getMessage());
+    }
+
+    @Test
+    @SuppressWarnings({"rawtypes", "unchecked"})
+    void pushKeepsApplyDateAtMidnightWhenSourceHasNoTime() {
+        YidaProcessOpenApiClient yidaClient = mock(YidaProcessOpenApiClient.class);
+        DDR_New response = new DDR_New();
+        response.setResult("process-instance-date");
+        when(yidaClient.startProcess(org.mockito.ArgumentMatchers.anyMap())).thenReturn(response);
+
+        ErpYidaPushMainData mainData = new ErpYidaPushMainData();
+        mainData.setExtraField("xmdadocno", "SO-002");
+        mainData.setExtraField("xmdadocdt", "2026-09-15");
+        ErpYidaPushRequest request = new ErpYidaPushRequest();
+        request.setType(ApproveOrderFormPushHandler.TYPE);
+        request.setMainData(mainData);
+
+        ApproveOrderFormPushHandler handler = handler(yidaClient, mock(EmployeeRosterService.class), mock(MingleiDocumentService.class));
+        assertEquals("process-instance-date", handler.push(request));
+
+        ArgumentCaptor<Map> bodyCaptor = ArgumentCaptor.forClass(Map.class);
+        verify(yidaClient).startProcess(bodyCaptor.capture());
+        JSONObject formData = JSONObject.parseObject(String.valueOf(bodyCaptor.getValue().get("formDataJson")));
+        assertEquals(Long.valueOf(1789401600000L), formData.getLong("dateField_msh186rs"));
+    }
+
+    private ApproveOrderFormPushHandler handler(YidaProcessOpenApiClient yidaClient,
+                                                EmployeeRosterService roster,
+                                                MingleiDocumentService documentService) {
+        DDConf ddConf = new DDConf();
+        ddConf.setAppKey("app-key");
+        ddConf.setAppSecret("app-secret");
+        YidaApplicationsProperties applications = new YidaApplicationsProperties();
+        applications.getCoordinationOffice().setAppType("APP_RJJL69QUIZVQSV7YC8TL");
+        applications.getCoordinationOffice().setSystemToken("system-token");
+        return new ApproveOrderFormPushHandler(yidaClient, ddConf, applications, roster, documentService);
+    }
+
+    private ErpYidaPushRequest requestWithSalesPerson(String employeeNo) {
+        ErpYidaPushMainData mainData = new ErpYidaPushMainData();
+        mainData.setExtraField("xmdadocno", "SO-001");
+        mainData.setExtraField("xmda002", employeeNo);
+        ErpYidaPushRequest request = new ErpYidaPushRequest();
+        request.setType(ApproveOrderFormPushHandler.TYPE);
+        request.setMainData(mainData);
+        return request;
+    }
 }

+ 74 - 3
src/test/java/com/malk/minglei/service/impl/yida/MiscReceiptIssueFormPushHandlerTest.java

@@ -3,8 +3,10 @@ package com.malk.minglei.service.impl.yida;
 import com.alibaba.fastjson.JSONArray;
 import com.alibaba.fastjson.JSONObject;
 import com.malk.minglei.config.YidaApplicationsProperties;
+import com.malk.minglei.dto.EmployeeRosterInfo;
 import com.malk.minglei.dto.ErpYidaPushMainData;
 import com.malk.minglei.dto.ErpYidaPushRequest;
+import com.malk.minglei.service.employee.EmployeeRosterService;
 import com.malk.minglei.service.yida.MingleiDocumentService;
 import com.malk.minglei.service.yida.YidaProcessOpenApiClient;
 import com.malk.server.dingtalk.DDR_New;
@@ -18,6 +20,7 @@ import java.util.LinkedHashMap;
 import java.util.Map;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
@@ -98,11 +101,11 @@ class MiscReceiptIssueFormPushHandlerTest {
         assertEquals("41603502201288023", body.get("userId"));
         assertEquals("TPROC--MQD66371C1N84FPSJZO4W79IN4BW2AE7I08TM0", body.get("processCode"));
         assertEquals("FORM-4B08ADABCDAA45788C043729A855E03ED76G", body.get("formUuid"));
-        assertEquals("miscReceiptIssue-MS-001", body.get("title"));
-        assertEquals("miscReceiptIssue-MS-001", body.get("dataTitle"));
+        assertEquals("reason-desc-main-MS-001", body.get("title"));
+        assertEquals("reason-desc-main-MS-001", body.get("dataTitle"));
 
         JSONObject formData = JSONObject.parseObject(String.valueOf(body.get("formDataJson")));
-        assertEquals("reason-desc-mainMS-001", formData.getString("textField_msh27unu"));
+        assertEquals("reason-desc-main-MS-001", formData.getString("textField_msh27unu"));
         assertEquals("miscReceiptIssue-MS-001", formData.getString("textField_mspcpz92"));
         assertEquals("41603502201288023", formData.getJSONArray("employeeField_msh186rr").getString(0));
         assertEquals("Configured Company", formData.getString("selectField_mso1em48"));
@@ -141,5 +144,73 @@ class MiscReceiptIssueFormPushHandlerTest {
         assertEquals("PROJ-001", row.getString("textField_mt6ur4w8"));
         assertEquals("project-desc", row.getString("textField_mt80nq7a"));
         assertEquals("SRC-001", row.getString("textField_mt80nq7b"));
+        assertEquals(null, row.get("employeeField_mt80nq7d"));
+    }
+
+    @Test
+    @SuppressWarnings({"rawtypes", "unchecked"})
+    void pushConvertsDetailKeeperEmployeeNoToDingTalkUserId() {
+        YidaProcessOpenApiClient yidaClient = mock(YidaProcessOpenApiClient.class);
+        DDR_New response = new DDR_New();
+        response.setResult("process-instance-keeper");
+        when(yidaClient.startProcess(org.mockito.ArgumentMatchers.anyMap())).thenReturn(response);
+
+        EmployeeRosterService rosterService = mock(EmployeeRosterService.class);
+        EmployeeRosterInfo keeper = new EmployeeRosterInfo();
+        keeper.setEmployeeNo("10086001");
+        keeper.setDingtalkId("keeper-dingtalk-id");
+        when(rosterService.findByEmployeeNo("10086001")).thenReturn(keeper);
+
+        MiscReceiptIssueFormPushHandler handler = handler(yidaClient, rosterService);
+        ErpYidaPushRequest request = requestWithKeeper("10086001");
+
+        assertEquals("process-instance-keeper", handler.push(request));
+
+        ArgumentCaptor<Map> bodyCaptor = ArgumentCaptor.forClass(Map.class);
+        verify(yidaClient).startProcess(bodyCaptor.capture());
+        JSONObject formData = JSONObject.parseObject(String.valueOf(bodyCaptor.getValue().get("formDataJson")));
+        JSONObject row = formData.getJSONArray("tableField_msh186sv").getJSONObject(0);
+        assertEquals("keeper-dingtalk-id", row.getJSONArray("employeeField_mt80nq7d").getString(0));
+        assertEquals(1, row.getJSONArray("employeeField_mt80nq7d").size());
+    }
+
+    @Test
+    void pushFailsWhenDetailKeeperEmployeeIsMissingFromRoster() {
+        EmployeeRosterService rosterService = mock(EmployeeRosterService.class);
+        when(rosterService.findByEmployeeNo("10086001")).thenReturn(null);
+        MiscReceiptIssueFormPushHandler handler = handler(mock(YidaProcessOpenApiClient.class), rosterService);
+
+        IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
+                () -> handler.push(requestWithKeeper("10086001")));
+
+        assertEquals("misc receipt issue warehouse keeper employee not found: 10086001", exception.getMessage());
+    }
+
+    private MiscReceiptIssueFormPushHandler handler(YidaProcessOpenApiClient yidaClient, EmployeeRosterService rosterService) {
+        DDConf ddConf = new DDConf();
+        ddConf.setAppKey("app-key");
+        ddConf.setAppSecret("app-secret");
+        YidaApplicationsProperties applications = new YidaApplicationsProperties();
+        applications.getCoordinationOffice().setAppType("APP_RJJL69QUIZVQSV7YC8TL");
+        applications.getCoordinationOffice().setSystemToken("system-token");
+        MingleiDocumentService documentService = FormDepartmentTestSupport.configuredDocumentService("41603502201288023");
+        return new MiscReceiptIssueFormPushHandler(yidaClient, ddConf, applications,
+                new LocalFormDepartmentEnricher(documentService), rosterService);
+    }
+
+    private ErpYidaPushRequest requestWithKeeper(String employeeNo) {
+        ErpYidaPushMainData mainData = new ErpYidaPushMainData();
+        mainData.setExtraField("inbadocno", "MS-001");
+        mainData.setExtraField("oocql004", "reason-desc-main");
+        Map<String, Object> detailRow = new LinkedHashMap<String, Object>();
+        detailRow.put("inbb001", "ITEM-001");
+        detailRow.put("employeeField_mt80nq7d", employeeNo);
+        Map<String, Object> detailData = new LinkedHashMap<String, Object>();
+        detailData.put("detailData1", Collections.singletonList(detailRow));
+        ErpYidaPushRequest request = new ErpYidaPushRequest();
+        request.setType(MiscReceiptIssueFormPushHandler.TYPE);
+        request.setMainData(mainData);
+        request.setDetailData(detailData);
+        return request;
     }
 }