云
5 小时以前 795bc269ba9acfae74620059c4ed64bd121aa288
feat(mdm): 打通智能体物料导入端到端链路

- Java 桥接:补录会话、附件绑定、服务令牌守卫,统一真实 HTTP 状态码与错误体
- 只读预检:分类/单位解析、编码存在性、CREATE/SKIP/BLOCKED 与确定性 preflight_hash
- 幂等提交:command_id + idempotency_key、PLAN_STALE 校验、逐行独立事务与回执查询
- 抽取物料导入解析供 Excel 导入与桥接复用,原导入行为保持不变
- 新增 3 张智能体导入表迁移及合并初始化脚本 config_export_all_20261022
- v1 只新建物料、不同步 MES,Java/Python 写开关默认关闭
- 补充前端联调方案与业务可视化、开发进度文档
已修改13个文件
已添加31个文件
12483 ■■■■■ 文件已修改
docs/agent_material_import_integration.md 146 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/data/business.json 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/data/flows.json 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/index.html 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/project-business/mermaid/11-ai-business-flow.mmd 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/sql/README.md 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/sql/config_export_all_20261022.sql 9881 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/mdm_agent_import_command_20261022.sql 56 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/system_agent_import_session_20261022.sql 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mdm/src/main/java/cn/iocoder/yudao/module/mdm/controller/admin/agent/MdmAgentImportBridgeController.java 57 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mdm/src/main/java/cn/iocoder/yudao/module/mdm/controller/admin/agent/vo/MdmAgentImportCommitReqVO.java 44 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mdm/src/main/java/cn/iocoder/yudao/module/mdm/controller/admin/agent/vo/MdmAgentImportPreflightReqVO.java 46 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mdm/src/main/java/cn/iocoder/yudao/module/mdm/controller/admin/agent/vo/MdmAgentImportPreflightRespVO.java 91 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mdm/src/main/java/cn/iocoder/yudao/module/mdm/controller/admin/agent/vo/MdmAgentImportReceiptRespVO.java 56 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mdm/src/main/java/cn/iocoder/yudao/module/mdm/dal/dataobject/agent/MdmAgentImportCommandDO.java 44 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mdm/src/main/java/cn/iocoder/yudao/module/mdm/dal/dataobject/agent/MdmAgentImportCommandRowDO.java 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mdm/src/main/java/cn/iocoder/yudao/module/mdm/dal/mysql/agent/MdmAgentImportCommandMapper.java 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mdm/src/main/java/cn/iocoder/yudao/module/mdm/dal/mysql/agent/MdmAgentImportCommandRowMapper.java 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mdm/src/main/java/cn/iocoder/yudao/module/mdm/dal/mysql/item/MdmItemMapper.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mdm/src/main/java/cn/iocoder/yudao/module/mdm/service/agent/MdmAgentImportCommandService.java 370 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mdm/src/main/java/cn/iocoder/yudao/module/mdm/service/agent/MdmAgentImportPolicy.java 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mdm/src/main/java/cn/iocoder/yudao/module/mdm/service/agent/MdmAgentImportPreflightService.java 257 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mdm/src/main/java/cn/iocoder/yudao/module/mdm/service/item/MdmItemImportContext.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mdm/src/main/java/cn/iocoder/yudao/module/mdm/service/item/MdmItemRowResolution.java 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mdm/src/main/java/cn/iocoder/yudao/module/mdm/service/item/MdmItemService.java 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mdm/src/main/java/cn/iocoder/yudao/module/mdm/service/item/MdmItemServiceImpl.java 174 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mdm/src/test/java/cn/iocoder/yudao/module/mdm/service/agent/MdmAgentImportCommandServiceTest.java 171 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mdm/src/test/java/cn/iocoder/yudao/module/mdm/service/agent/MdmAgentImportPreflightServiceTest.java 125 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mdm/src/test/java/cn/iocoder/yudao/module/mdm/service/item/MdmItemImportParsingTest.java 89 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/agent/SystemAgentImportBridgeController.java 112 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/storage/SystemStorageAttachmentController.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/storage/SystemAgentImportSessionDO.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/storage/SystemAgentImportSessionMapper.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/enums/storage/StorageRecordTypeEnum.java 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/framework/agent/AgentBridgeException.java 38 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/framework/agent/AgentBridgeExceptionHandler.java 106 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/framework/agent/AgentPrincipal.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/framework/agent/SystemAgentBridgeGuard.java 48 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/storage/SystemAgentAttachmentRef.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/storage/SystemAgentImportBridgeService.java 152 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/storage/SystemStorageAttachmentService.java 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/storage/SystemStorageAttachmentServiceImpl.java 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/test/java/cn/iocoder/yudao/module/system/service/storage/SystemAgentImportBridgeServiceTest.java 102 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-server/src/main/resources/application-local.yaml 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/agent_material_import_integration.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,146 @@
# æ™ºèƒ½ä½“物料导入 - å‰ç«¯è”调方案
## æ¶‰åŠé¡µé¢
- ç‰©æ–™ç®¡ç† - ç‰©æ–™åˆ—表(入口与结果确认)
- ç‰©æ–™ç®¡ç† - æ™ºèƒ½ä½“导入(上传、审核、提交、回执)
## ä¸šåŠ¡å®šä½
智能体导入是独立于 Excel æ‰¹é‡å¯¼å…¥çš„æ–°å…¥å£ï¼šç”¨æˆ·ä¸Šä¼ ç‰©æ–™æ¸…单,智能体解析并调用 Java ä¸»æ•°æ®é¢„检,用户核对后提交。**本阶段只新建物料**;编码已存在时跳过,不更新原物料,不触发 MES åŒæ­¥ã€‚
## ä¸šåŠ¡æµç¨‹ä¸Žæ•°æ®å¸¦å…¥
1. è¿›å…¥å¯¼å…¥é¡µ â†’ å‰ç«¯åˆ›å»ºè¡¥å½•会话,拿到 `sessionId`。
2. é€‰æ‹© Excel/CSV â†’ èµ°æ—¢æœ‰æ–‡ä»¶ä¸Šä¼ ï¼Œæ‹¿åˆ° `blobId`。
3. å°† `blobId` ç»‘定到会话 â†’ æ‹¿åˆ° `attachmentId`。附件归属、大小与格式由后端校验。
4. ç”¨ `attachmentId` åˆ›å»ºæ™ºèƒ½ä½“任务 â†’ è¿”回任务 ID、候选行、Java é¢„检结果(分类、单位、编码状态)与人审中断信息。
5. ç”¨æˆ·åœ¨å®¡æ ¸é¡µä¿®è®¢å­—段并重新检查 â†’ æ¯æ¬¡ä¿®è®¢åŽ `revision` åŠ  1,并重新预检。
6. ç”¨æˆ·ç¡®è®¤ â†’ æäº¤ï¼›æˆåŠŸåŽå±•ç¤ºé€è¡Œå›žæ‰§ã€‚
7. ç»“果页可再次查询回执;任务 ID å¯å›žæŸ¥ä»»åŠ¡çŠ¶æ€ã€‚
关键带入关系:
| æ¥æº | å¸¦å…¥ç›®æ ‡ | å­—段 |
|---|---|---|
| æ–‡ä»¶ä¸Šä¼  | ä¼šè¯ç»‘定 | `blobId` |
| ä¼šè¯ç»‘定 | åˆ›å»ºä»»åŠ¡ | `attachmentId`(不是 blobId) |
| åˆ›å»ºä»»åŠ¡å“åº” | å®¡æ ¸é¡µ | `task_id`、`state.revision`、`state.preflight`、`interrupts` |
| é¢„检结果 | ç¡®è®¤è¯·æ±‚ | `state.preflight_hash` |
| å®¡æ ¸ä¿®è®¢ | ä¸‹ä¸€æ¬¡é¢„检 | ä¿®è®¢åŽçš„字段值,`revision` è‡ªå¢ž |
| æäº¤å“åº” | å›žæ‰§é¡µ | `receipt.rows[].targetItemId`、`status` |
## API
### Java ç®¡ç†ç«¯ï¼ˆ`/admin-api`,携带用户 Bearer token)
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|---|---|---|
| POST | `/agent/bridge/import-sessions` | åˆ›å»ºè¡¥å½•会话,返回 `sessionId` |
| POST | `/agent/bridge/import-sessions/{sessionId}/attachments` | ç»‘定附件,返回 `attachmentId` |
**绑定请求参数:**
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|---|---|---|---|
| `blobId` | Long | æ˜¯ | æ—¢æœ‰ä¸Šä¼ æŽ¥å£è¿”回的 blob ID |
**限制:**
- åŒä¸€ä¼šè¯é‡å¤ç»‘定会替换旧附件,旧 `attachmentId` éšåŽä¸å¯ç”¨ã€‚
- æ–‡ä»¶ä¸Šé™ 8 MiB,仅允许 CSV/XLSX。
- ç”¨æˆ·éœ€å…·å¤‡ `mdm:item:import` æƒé™ï¼Œå¦åˆ™è¿”回 403 `FORBIDDEN`。
### Python æ™ºèƒ½ä½“服务(默认 `http://127.0.0.1:8091`,携带同一用户 Bearer token)
> Python æœåŠ¡ä¸åœ¨ `/admin-api` ä¸‹ï¼Œéœ€ç”±ç½‘关或前端代理转发;服务口令 `X-Agent-Service-Token` ç”± Python æœåŠ¡å†…éƒ¨ä½¿ç”¨ï¼Œ**前端不得携带或下发**。
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|---|---|---|
| POST | `/v1/material-imports` | åˆ›å»ºä»»åŠ¡å¹¶è§¦å‘é¢„æ£€ï¼Œè¿”å›žä»»åŠ¡ä¸Žå®¡æ ¸ä¸­æ–­ |
| GET | `/v1/material-imports/{task_id}` | è¯»å–任务当前状态 |
| POST | `/v1/material-imports/{task_id}/review` | ä¿®è®¢æˆ–确认 |
| GET | `/v1/material-imports/{task_id}/receipt` | æŸ¥è¯¢æäº¤å›žæ‰§ |
**创建任务请求参数:**
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|---|---|---|---|
| `attachment_id` | String | æ˜¯ | Java è¿”回的附件 ID |
| `filename` | String | å¦ | å…¼å®¹å­—段,解析以 Java è¿”回的文件名为准 |
**创建任务响应:**
| å­—段 | ç±»åž‹ | è¯´æ˜Ž |
|---|---|---|
| `task_id` | String | ä»»åŠ¡ ID,后续读取/审核/回执均使用 |
| `state.revision` | Integer | æ–¹æ¡ˆç‰ˆæœ¬ï¼Œç¡®è®¤æ—¶å¿…须回传当前值 |
| `state.candidates` | Array | è§£æžåŽçš„候选行及单元格来源 |
| `state.issues` | Array | å­—段缺失、重复编码等本地校验问题 |
| `state.preflight` | Object | Java é¢„检结果:`can_commit`、`summary`、`rows[]` ç­‰ |
| `state.preflight_hash` | String | é¢„检计划摘要 |
| `interrupts` | Array | äººå®¡ä¸­æ–­ä¿¡æ¯ï¼Œ`type=material_review` |
**审核请求参数:**
| å‚æ•° | ç±»åž‹ | å¿…å¡« | è¯´æ˜Ž |
|---|---|---|---|
| `expected_revision` | Integer | æ˜¯ | å½“前 `state.revision` |
| `action` | String | æ˜¯ | `revise` / `confirm` / `cancel` |
| `corrections` | Array | ä¿®è®¢æ—¶å¿…å¡« | æ¯é¡¹å« `candidate_id`、`field`、`value`、`reason` |
| `expected_preflight_hash` | String | ç¡®è®¤æ—¶å»ºè®®å¡«å†™ | å½“前 `state.preflight_hash` |
| `expected_proposal_hash` | String | å¦ | äºŒæ¬¡ç¡®è®¤æ—¶æ ¡éªŒæ–¹æ¡ˆæœªå˜åŒ– |
**回执响应关键字段:**
| å­—段 | è¯´æ˜Ž |
|---|---|
| `status` | `SUCCEEDED` / `PARTIAL_FAILED` / `FAILED` / `UNKNOWN` |
| `summary` | `total/success/failed/skipped/unknown` |
| `rows[].action` | `CREATE` / `SKIP` |
| `rows[].status` | `SUCCESS` / `SKIPPED` / `FAILED` / `UNKNOWN` |
| `rows[].targetItemId` | æ–°å»ºç‰©æ–™ ID;`SKIP` è¡Œä¸ºå·²æœ‰ç‰©æ–™ ID |
| `rows[].errorCode` / `errorMessage` | è¡Œçº§å¤±è´¥åŽŸå›  |
| `rows[].retryable` | æ˜¯å¦å»ºè®®é‡è¯• |
## å­—段展示规则
| å­—段 | å±•示位置 | è¯´æ˜Ž |
|---|---|---|
| ç‰©æ–™ç¼–码 | å®¡æ ¸åˆ—表、回执 | æ–‡ä»¶å†…重复时整批阻断 |
| ç‰©æ–™åç§° | å®¡æ ¸åˆ—表、回执 | ä¸ºç©ºæ—¶é˜»æ–­è¯¥è¡Œ |
| è§„格型号 | å®¡æ ¸åˆ—表 | å¯é€‰ |
| åˆ†ç±»ç¼–码 | å®¡æ ¸åˆ—表 | é¢„检解析为 `resolved.categoryName` åŽä¼˜å…ˆæ˜¾ç¤ºåç§° |
| è®¡é‡å•位编码 | å®¡æ ¸åˆ—表 | é¢„检解析为 `resolved.unitMeasureName` åŽä¼˜å…ˆæ˜¾ç¤ºåç§° |
| è¡ŒåŠ¨ä½œ | å®¡æ ¸åˆ—表 | `CREATE` æ–°å»ºã€`SKIP` å·²å­˜åœ¨å°†è·³è¿‡ã€`BLOCKED` é˜»æ–­ |
| é˜»æ–­/警告 | å®¡æ ¸åˆ—表行内 | å±•示 `blockers[]`、`warnings[]` çš„ `message`,并高亮该行 |
| é¢„检汇总 | å®¡æ ¸é¡µé¡¶éƒ¨ | æ€»æ•°ã€æ–°å»ºã€è·³è¿‡ã€é˜»æ–­ |
| æ˜¯å¦å¯æäº¤ | å®¡æ ¸é¡µåº•部 | ç”± `preflight.can_commit` ä¸Ž `state.issues` å…±åŒå†³å®š |
| ç›®æ ‡ç‰©æ–™ ID | å›žæ‰§é¡µ | `rows[].targetItemId`,可跳转物料详情 |
| å›žæ‰§æ±‡æ€» | å›žæ‰§é¡µé¡¶éƒ¨ | æˆåŠŸã€è·³è¿‡ã€å¤±è´¥ã€æœªçŸ¥æ•°é‡ |
## ä¸šåŠ¡è§„åˆ™è¯´æ˜Ž
| åœºæ™¯ | è§„则 |
|---|---|
| ç¼–码已存在 | é¢„检 `SKIP`,不阻断整批;提交时跳过,不更新原物料 |
| åˆ†ç±»/单位不存在 | è¯¥è¡Œ `BLOCKED`,整批不可提交;提示用户修正编码 |
| æ–‡ä»¶å†…编码重复 | æ‰€æœ‰é‡å¤è¡Œå‡ `BLOCKED` |
| è¯·æ±‚同步 MES | è¿”回阻断项 `MES_SYNC_DISABLED`;本阶段不支持 |
| è¶…过 5000 è¡Œ | æ•´æ‰¹é˜»æ–­ `ROW_LIMIT_EXCEEDED` |
| æ–¹æ¡ˆè¢«ä¿®è®¢ | `revision` è‡ªå¢žï¼Œå¿…须用新版本重新确认 |
| ç¡®è®¤åŽæ•°æ®å˜åŒ– | æäº¤æ—¶é‡æ–°é¢„检;计划变化返回 409 `PLAN_STALE`,需重新读取并确认 |
| é‡å¤ç‚¹å‡»æäº¤ | æœåŠ¡ç«¯å¹‚ç­‰ï¼Œè¿”å›žåŽŸå›žæ‰§ï¼Œä¸é‡å¤å»ºç‰©æ–™ |
| æäº¤è¶…æ—¶ | å‰ç«¯åº”查询回执,不要重新发起确认 |
| å†™å¼€å…³å…³é—­ | Python è¿”回 409 `COMMIT_DISABLED`;Java è¿”回 503 `WRITE_DISABLED` |
## æ³¨æ„äº‹é¡¹
- å‰ç«¯åªæºå¸¦ç”¨æˆ·ç™»å½• token;服务口令、附件下载接口仅限 Python æœåŠ¡å†…ç½‘è®¿é—®ã€‚
- `attachmentId` ä¸æ˜¯ `blobId`,绑定接口返回的才是任务入参。
- ç¡®è®¤å‰å¿…须重新读取任务,使用最新的 `revision` ä¸Ž `preflight_hash`;禁止缓存旧值直接确认。
- 409 `PLAN_STALE` ä¸Ž 409 `COMMIT_DISABLED` è¯­ä¹‰ä¸åŒï¼šå‰è€…需重新预检,后者需联系管理员打开写开关。
- `SKIP` è¡Œä¹Ÿä¼šå‡ºçŽ°åœ¨å›žæ‰§ä¸­ï¼Œå±•ç¤ºä¸ºâ€œå·²å­˜åœ¨ï¼Œå·²è·³è¿‡â€ï¼Œä¸æ˜¯å¤±è´¥ã€‚
- ä»»åŠ¡æŒ‰ç§Ÿæˆ· + ç”¨æˆ·éš”离,换账号或跨租户访问返回 404。
- æœ¬é˜¶æ®µä¸æä¾›è¿›åº¦æŽ¨é€ï¼›æäº¤ä¸ºåŒæ­¥è¯·æ±‚,前端需设置足够长的超时(建议 120 ç§’),超时后查询回执。
- ä¸æ”¯æŒæ›´æ–°å·²æœ‰ç‰©æ–™ï¼›å¦‚业务需要修改物料,仍走既有物料编辑流程。
docs/project-business/data/business.json
@@ -25,7 +25,7 @@
    "coreObjects": 48,
    "documentFlows": 6,
    "approvalListeners": 13,
    "aiCapabilities": 8,
    "aiCapabilities": 9,
    "wmBillTypes": 15
  },
  "businessAnswers": [
@@ -63,7 +63,7 @@
    ],
    [
      "AI åœ¨å“ªé‡Œå‚与",
      "发票/报价 OCR é¢„填、质检结果建议、缺料/时长/风险/交付四类预测;均为『业务组装 Prompt→模型→JSON→人工确认』模式"
      "发票/报价 OCR é¢„填、质检结果建议、缺料/时长/风险/交付四类预测;均为『业务组装 Prompt→模型→JSON→人工确认』模式。智能体物料导入另走规则+Java é¢„检链路,人工确认后幂等提交,只新建不同步 MES"
    ],
    [
      "模块间数据关系",
@@ -127,6 +127,16 @@
      "name": "RAG çŸ¥è¯†åº“",
      "flow": "文档 â†’ POI/Tika ææ–‡æœ¬ â†’ åˆ‡ç‰‡(800token) â†’ å‘量化入 Milvus â†’ æ£€ç´¢æŽ¥å£(topK3,阈值0.7)【当前无业务模块消费,预留能力】",
      "callsApis": []
    },
    {
      "name": "智能体物料导入",
      "flow": "创建补录会话→上传并绑定附件→Python è§£æž CSV/XLSX å€™é€‰â†’Java åªè¯»é¢„检分类·单位·编码存在性并生成确定性摘要→人工修订确认→commandId+幂等键逐行独立事务提交→逐行回执含目标物料ID;编码已存在跳过,不更新物料、不触发 MES",
      "callsApis": [
        "SystemAgentImportBridgeService",
        "MdmItemService",
        "MdmAgentImportPreflightService",
        "MdmAgentImportCommandService"
      ]
    }
  ],
  "pendingConfirm": [
docs/project-business/data/flows.json
@@ -55,9 +55,10 @@
       {"stage": "ç»´ä¿®", "action": "现场/返厂/远程维修,费用与结果记录,完成联动工单完结", "roles": ["维修工程师"]},
       {"stage": "退货", "action": "退货申请审核→自动建 MES é”€å”®é€€è´§å•(RQC)→质检/入库回调→退款回调→关闭", "roles": ["售后", "质检", "财务"]}]},
    {"id": "ai", "name": "AI ä¸šåŠ¡æµç¨‹", "mermaid": "mermaid/11-ai-business-flow.mmd", "color": "#56c8d8",
     "summary": "统一模式:业务侧收集上下文(必要时调 MDM/SRM/CRM API å–名称)→组装 Prompt→chat/chatWithImage→解析 JSON→前端预填或建议;无 Function Calling;RAG çŸ¥è¯†åº“已建成但暂无业务消费。",
     "summary": "统一模式:业务侧收集上下文(必要时调 MDM/SRM/CRM API å–名称)→组装 Prompt→chat/chatWithImage→解析 JSON→前端预填或建议;无 Function Calling;RAG çŸ¥è¯†åº“已建成但暂无业务消费。智能体物料导入为规则+Java é¢„检链路,经人工确认后幂等提交。",
     "steps": [
       {"stage": "录入提效", "action": "发票 OCR(图片多模态/PDF文本)、报价单 OCR â†’ ç»“构化预填", "roles": ["采购员", "销售"]},
       {"stage": "主数据导入", "action": "上传物料 CSV/XLSX â†’ Python è§£æžå€™é€‰ â†’ Java åªè¯»é¢„检分类/单位/重复编码 â†’ äººå·¥ä¿®è®¢ç¡®è®¤ â†’ å¹‚等提交 â†’ é€è¡Œå›žæ‰§ï¼›åªæ–°å»ºã€ä¸æ›´æ–°ã€ä¸åŒæ­¥ MES", "roles": ["物料管理员"]},
       {"stage": "辅助判定", "action": "质检单+指标+缺陷 â†’ å»ºè®®åˆæ ¼/特采/退货/报废+理由", "roles": ["质检员"]},
       {"stage": "决策预测", "action": "缺料/生产时长/生产风险/交付四类预测供计划排产参考", "roles": ["计划员"]},
       {"stage": "模板识别", "action": "上传既有检验报告文件(扫描件/照片、电子版 PDF、Word/Excel) â†’ åˆ†é€šé“抽取(有文本层走文本、扫描版逐页转图) â†’ å¤§æ¨¡åž‹æŒ‰å‰ç«¯ä¸Šä¼ çš„组件清单产出扁平组件草稿 â†’ äººå·¥åœ¨è®¾è®¡å™¨ç¡®è®¤åŽä¿å­˜ä¸ºæ¨¡æ¿ç‰ˆæœ¬", "roles": ["质检员", "质量工程师"]},
docs/project-business/index.html
@@ -205,7 +205,7 @@
    "coreObjects": 48,
    "documentFlows": 6,
    "approvalListeners": 13,
    "aiCapabilities": 8,
    "aiCapabilities": 9,
    "wmBillTypes": 15
  },
  "businessAnswers": [
@@ -243,7 +243,7 @@
    ],
    [
      "AI åœ¨å“ªé‡Œå‚与",
      "发票/报价 OCR é¢„填、质检结果建议、缺料/时长/风险/交付四类预测;均为『业务组装 Prompt→模型→JSON→人工确认』模式"
      "发票/报价 OCR é¢„填、质检结果建议、缺料/时长/风险/交付四类预测;均为『业务组装 Prompt→模型→JSON→人工确认』模式。智能体物料导入另走规则+Java é¢„检链路,人工确认后幂等提交,只新建不同步 MES"
    ],
    [
      "模块间数据关系",
@@ -307,6 +307,16 @@
      "name": "RAG çŸ¥è¯†åº“",
      "flow": "文档 â†’ POI/Tika ææ–‡æœ¬ â†’ åˆ‡ç‰‡(800token) â†’ å‘量化入 Milvus â†’ æ£€ç´¢æŽ¥å£(topK3,阈值0.7)【当前无业务模块消费,预留能力】",
      "callsApis": []
    },
    {
      "name": "智能体物料导入",
      "flow": "创建补录会话→上传并绑定附件→Python è§£æž CSV/XLSX å€™é€‰â†’Java åªè¯»é¢„检分类·单位·编码存在性并生成确定性摘要→人工修订确认→commandId+幂等键逐行独立事务提交→逐行回执含目标物料ID;编码已存在跳过,不更新物料、不触发 MES",
      "callsApis": [
        "SystemAgentImportBridgeService",
        "MdmItemService",
        "MdmAgentImportPreflightService",
        "MdmAgentImportCommandService"
      ]
    }
  ],
  "pendingConfirm": [
@@ -413,7 +423,7 @@
    {"key": "hrm", "name": "人力资源", "color": "#9db85a", "tag": "HRM", "flow": null,
     "positioning": "员工入转调离与考勤薪酬",
     "problem": "人事流程审批化、离职数据不丢(交接)、算薪自动化",
     "features": ["员工档案(入职自动建系统账号)", "请假/加班/调岗/离职 BPM å®¡æ‰¹", "离职交接映射 expandUserIds", "考勤(打卡·规则·排班·补卡审批·月度汇总)", "薪酬(应发-社保公积金-个税-请假扣款·1.5/2/3倍加班费·发放台账)", "培训证书(SQL先行待实现)"],
     "features": ["员工档案(入职自动建系统账号)", "请假/加班/调岗/离职 BPM å®¡æ‰¹", "离职交接映射 expandUserIds", "考勤(移动端我的考勤状态·打卡·规则·排班·补卡审批·月度汇总)", "薪酬(应发-社保公积金-个税-请假扣款·1.5/2/3倍加班费·发放台账)", "培训证书(SQL先行待实现)"],
     "objects": ["员工", "请假单", "离职申请", "交接映射", "考勤记录", "薪酬核算"],
     "roles": ["HR", "部门主管", "员工本人"],
     "upstream": [], "downstream": ["各模块(数据可见性扩展)", "system(账号)"],
@@ -550,9 +560,10 @@
       {"stage": "ç»´ä¿®", "action": "现场/返厂/远程维修,费用与结果记录,完成联动工单完结", "roles": ["维修工程师"]},
       {"stage": "退货", "action": "退货申请审核→自动建 MES é”€å”®é€€è´§å•(RQC)→质检/入库回调→退款回调→关闭", "roles": ["售后", "质检", "财务"]}]},
    {"id": "ai", "name": "AI ä¸šåŠ¡æµç¨‹", "mermaid": "mermaid/11-ai-business-flow.mmd", "color": "#56c8d8",
     "summary": "统一模式:业务侧收集上下文(必要时调 MDM/SRM/CRM API å–名称)→组装 Prompt→chat/chatWithImage→解析 JSON→前端预填或建议;无 Function Calling;RAG çŸ¥è¯†åº“已建成但暂无业务消费。",
     "summary": "统一模式:业务侧收集上下文(必要时调 MDM/SRM/CRM API å–名称)→组装 Prompt→chat/chatWithImage→解析 JSON→前端预填或建议;无 Function Calling;RAG çŸ¥è¯†åº“已建成但暂无业务消费。智能体物料导入为规则+Java é¢„检链路,经人工确认后幂等提交。",
     "steps": [
       {"stage": "录入提效", "action": "发票 OCR(图片多模态/PDF文本)、报价单 OCR â†’ ç»“构化预填", "roles": ["采购员", "销售"]},
       {"stage": "主数据导入", "action": "上传物料 CSV/XLSX â†’ Python è§£æžå€™é€‰ â†’ Java åªè¯»é¢„检分类/单位/重复编码 â†’ äººå·¥ä¿®è®¢ç¡®è®¤ â†’ å¹‚等提交 â†’ é€è¡Œå›žæ‰§ï¼›åªæ–°å»ºã€ä¸æ›´æ–°ã€ä¸åŒæ­¥ MES", "roles": ["物料管理员"]},
       {"stage": "辅助判定", "action": "质检单+指标+缺陷 â†’ å»ºè®®åˆæ ¼/特采/退货/报废+理由", "roles": ["质检员"]},
       {"stage": "决策预测", "action": "缺料/生产时长/生产风险/交付四类预测供计划排产参考", "roles": ["计划员"]},
       {"stage": "模板识别", "action": "上传既有检验报告文件(扫描件/照片、电子版 PDF、Word/Excel) â†’ åˆ†é€šé“抽取(有文本层走文本、扫描版逐页转图) â†’ å¤§æ¨¡åž‹æŒ‰å‰ç«¯ä¸Šä¼ çš„组件清单产出扁平组件草稿 â†’ äººå·¥åœ¨è®¾è®¡å™¨ç¡®è®¤åŽä¿å­˜ä¸ºæ¨¡æ¿ç‰ˆæœ¬", "roles": ["质检员", "质量工程师"]},
@@ -579,7 +590,7 @@
    {"role": "设备员/维修工", "modules": ["MES-DV"], "operations": ["点检保养", "维修工单", "工装领用"], "data": ["mes_dv_*", "mes_tm_*"], "scope": "chargeUserId å½’属"},
    {"role": "客服/售后工程师", "modules": ["售后"], "operations": ["工单受理派工", "ç»´ä¿®", "退货申请"], "data": ["after_sale_*"], "scope": "行级数据权限四视图:我负责/我参与/下属负责/全部(aftersales:view-all)"},
    {"role": "财务", "modules": ["ERP", "CRM"], "operations": ["收款单", "付款单", "开票审核", "结算账户"], "data": ["erp_finance_*", "crm_invoice", "crm_receivable"], "scope": "financeUserId å½’属"},
    {"role": "HR", "modules": ["HRM"], "operations": ["员工档案入转调离", "考勤规则排班", "薪酬核算发放", "离职交接"], "data": ["hrm_*"], "scope": "部门范围 dataScope"},
    {"role": "HR", "modules": ["HRM"], "operations": ["员工档案入转调离", "考勤规则排班(本人打卡全员可用,不设权限点)", "薪酬核算发放", "离职交接"], "data": ["hrm_*"], "scope": "部门范围 dataScope"},
    {"role": "管理层", "modules": ["BI"], "operations": ["查看采购销售/仓库/生产设备/质量/人力五大主题大屏"], "data": ["bi_dashboard"], "scope": "只读"}
  ]
}</script>
@@ -1087,6 +1098,14 @@
  KB4 -.-> KB5[暂未接入业务问答流程·待确认]
  MODE[模型层:通义千问为主·预留DeepSeek/OpenAI/Ollama未实现] --- O2
  NOTE[全项目无Function Calling·AI只输出结构化JSON由业务侧解析] --- J2
  A1[物料管理员上传物料CSV/XLSX→创建补录会话并绑定附件] --> A2[Java桥接授权:用户token+服务令牌+mdm:item:import]
  A2 --> A3[Python解析候选·识别公式/数字编码/缺失分类或单位]
  A3 --> A4[Java只读预检·分类单位编码存在性→CREATE/SKIP/BLOCKED+确定性摘要]
  A4 --> A5[人工审核修订·版本自增·重新预检]
  A5 --> A6[幂等提交·commandId+幂等键·逐行独立事务·提交前重查编码]
  A6 --> A7[逐行回执·SUCCESS/SKIPPED/FAILED+目标物料ID]
  A6 --> A8[超时或中断→查询命令回执对账·不盲目重试]
  A9[只新建不更新·syncMes=false·不触发MES同步] --- A6
</pre>
<script type="module">
docs/project-business/mermaid/11-ai-business-flow.mmd
@@ -39,3 +39,11 @@
  KB4 -.-> KB5[暂未接入业务问答流程·待确认]
  MODE[模型层:通义千问为主·预留DeepSeek/OpenAI/Ollama未实现] --- O2
  NOTE[全项目无Function Calling·AI只输出结构化JSON由业务侧解析] --- J2
  A1[物料管理员上传物料CSV/XLSX→创建补录会话并绑定附件] --> A2[Java桥接授权:用户token+服务令牌+mdm:item:import]
  A2 --> A3[Python解析候选·识别公式/数字编码/缺失分类或单位]
  A3 --> A4[Java只读预检·分类单位编码存在性→CREATE/SKIP/BLOCKED+确定性摘要]
  A4 --> A5[人工审核修订·版本自增·重新预检]
  A5 --> A6[幂等提交·commandId+幂等键·逐行独立事务·提交前重查编码]
  A6 --> A7[逐行回执·SUCCESS/SKIPPED/FAILED+目标物料ID]
  A6 --> A8[超时或中断→查询命令回执对账·不盲目重试]
  A9[只新建不更新·syncMes=false·不触发MES同步] --- A6
docs/sql/README.md
@@ -3,3 +3,6 @@
本项目包含的 SQL è„šæœ¬æ±‡æ€»å¦‚ä¸‹ï¼š
- [mes_pro_task_assignee.sql](mes_pro_task_assignee.sql): å¢žåŠ ç”Ÿäº§ä»»åŠ¡æŠ¥å·¥äººå‘˜å…³è”è¡¨ï¼Œç”¨äºŽå¤šäººå‘˜æŠ¥å·¥åˆ†é…ã€‚
- [system_agent_import_session_20261022.sql](../../sql/mysql/system_agent_import_session_20261022.sql):创建智能体补录会话表,绑定租户和创建用户。
- [mdm_agent_import_command_20261022.sql](../../sql/mysql/mdm_agent_import_command_20261022.sql):创建智能体物料导入幂等命令表与逐行回执表。
- [config_export_all_20261022.sql](config_export_all_20261022.sql):全库合并初始化脚本(阶段一 419 å¼ è¡¨å«æ™ºèƒ½ä½“导入 3 å¼ è¡¨ + é…ç½®æ•°æ®ï¼Œé˜¶æ®µäºŒèº«ä»½/菜单补导,阶段三/四增量)。
docs/sql/config_export_all_20261022.sql
¶Ô±ÈÐÂÎļþ
ÎļþÌ«´ó
sql/mysql/mdm_agent_import_command_20261022.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,56 @@
-- æ™ºèƒ½ä½“物料导入幂等命令与逐行回执。执行前备份目标库,并在应用发布前完成迁移。
CREATE TABLE IF NOT EXISTS `mdm_agent_import_command` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
  `command_id` varchar(64) NOT NULL COMMENT '智能体提交命令 ID(幂等)',
  `idempotency_key` varchar(128) NOT NULL COMMENT '幂等键',
  `request_digest` char(64) NOT NULL COMMENT '请求摘要 SHA-256',
  `tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '租户 ID',
  `owner_user_id` bigint NOT NULL COMMENT '提交用户 ID',
  `session_id` bigint DEFAULT NULL COMMENT '补录会话 ID',
  `attachment_id` bigint NOT NULL COMMENT '附件 ID',
  `source_sha256` char(64) NOT NULL COMMENT '附件内容摘要',
  `preflight_hash` char(64) NOT NULL COMMENT '预检计划摘要',
  `proposal_hash` varchar(128) DEFAULT NULL COMMENT '智能体方案摘要',
  `sync_mes` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否同步 MES(本阶段固定关闭)',
  `status` varchar(20) NOT NULL DEFAULT 'RUNNING' COMMENT 'RUNNING/SUCCEEDED/PARTIAL_FAILED/FAILED/UNKNOWN',
  `total_count` int NOT NULL DEFAULT 0 COMMENT '总行数',
  `success_count` int NOT NULL DEFAULT 0 COMMENT '成功行数',
  `failed_count` int NOT NULL DEFAULT 0 COMMENT '失败行数',
  `skipped_count` int NOT NULL DEFAULT 0 COMMENT '跳过行数',
  `error_code` varchar(64) DEFAULT NULL COMMENT '命令级错误码',
  `error_message` varchar(512) DEFAULT NULL COMMENT '命令级错误信息',
  `lease_expires_at` datetime DEFAULT NULL COMMENT '执行租约到期时间',
  `creator` varchar(64) DEFAULT NULL,
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `updater` varchar(64) DEFAULT NULL,
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  `deleted` bit(1) NOT NULL DEFAULT b'0',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_agent_import_command` (`tenant_id`, `owner_user_id`, `command_id`),
  UNIQUE KEY `uk_agent_import_idempotency` (`tenant_id`, `owner_user_id`, `idempotency_key`),
  KEY `idx_agent_import_command_session` (`tenant_id`, `session_id`, `create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='智能体物料导入命令';
CREATE TABLE IF NOT EXISTS `mdm_agent_import_command_row` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
  `command_id` varchar(64) NOT NULL COMMENT '提交命令 ID',
  `candidate_id` varchar(64) NOT NULL COMMENT '导入行标识',
  `source_row` int DEFAULT NULL COMMENT '源文件行号',
  `code` varchar(64) DEFAULT NULL COMMENT '物料编码',
  `name` varchar(255) DEFAULT NULL COMMENT '物料名称',
  `action` varchar(16) NOT NULL COMMENT 'CREATE/SKIP',
  `status` varchar(16) NOT NULL COMMENT 'SUCCESS/FAILED/SKIPPED/UNKNOWN',
  `target_item_id` bigint DEFAULT NULL COMMENT '创建的物料 ID',
  `target_version` varchar(64) DEFAULT NULL COMMENT '目标版本(预留)',
  `error_code` varchar(64) DEFAULT NULL COMMENT '行级错误码',
  `error_message` varchar(512) DEFAULT NULL COMMENT '行级错误信息',
  `retryable` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否可重试',
  `creator` varchar(64) DEFAULT NULL,
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `updater` varchar(64) DEFAULT NULL,
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  `deleted` bit(1) NOT NULL DEFAULT b'0',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_agent_import_row` (`command_id`, `candidate_id`),
  KEY `idx_agent_import_row_command` (`command_id`, `source_row`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='智能体物料导入逐行回执';
sql/mysql/system_agent_import_session_20261022.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,14 @@
-- Java Bridge è¡¥å½•会话。执行前备份目标库,并在应用发布前完成迁移。
CREATE TABLE IF NOT EXISTS `system_agent_import_session` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '补录会话 ID',
  `owner_user_id` bigint NOT NULL COMMENT '创建用户 ID',
  `status` varchar(20) NOT NULL DEFAULT 'active' COMMENT '会话状态',
  `tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '租户 ID',
  `creator` varchar(64) DEFAULT NULL,
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `updater` varchar(64) DEFAULT NULL,
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  `deleted` bit(1) NOT NULL DEFAULT b'0',
  PRIMARY KEY (`id`),
  KEY `idx_agent_import_owner` (`tenant_id`, `owner_user_id`, `status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='智能体数据补录会话';
yudao-module-mdm/src/main/java/cn/iocoder/yudao/module/mdm/controller/admin/agent/MdmAgentImportBridgeController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,57 @@
package cn.iocoder.yudao.module.mdm.controller.admin.agent;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.module.mdm.controller.admin.agent.vo.MdmAgentImportCommitReqVO;
import cn.iocoder.yudao.module.mdm.controller.admin.agent.vo.MdmAgentImportPreflightReqVO;
import cn.iocoder.yudao.module.mdm.controller.admin.agent.vo.MdmAgentImportPreflightRespVO;
import cn.iocoder.yudao.module.mdm.controller.admin.agent.vo.MdmAgentImportReceiptRespVO;
import cn.iocoder.yudao.module.mdm.service.agent.MdmAgentImportCommandService;
import cn.iocoder.yudao.module.mdm.service.agent.MdmAgentImportPreflightService;
import cn.iocoder.yudao.module.system.framework.agent.AgentPrincipal;
import cn.iocoder.yudao.module.system.framework.agent.SystemAgentBridgeGuard;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
/** æ™ºèƒ½ä½“物料导入桥接:只读预检 + å¹‚等提交 + å›žæ‰§æŸ¥è¯¢ã€‚ */
@RestController
@RequestMapping("/agent/bridge/material-imports")
public class MdmAgentImportBridgeController {
    @Resource
    private MdmAgentImportPreflightService preflightService;
    @Resource
    private MdmAgentImportCommandService commandService;
    @Resource
    private SystemAgentBridgeGuard bridgeGuard;
    @PostMapping("/preflight")
    @PreAuthorize("@ss.hasPermission('mdm:item:import')")
    public CommonResult<MdmAgentImportPreflightRespVO> preflight(
            @RequestHeader(value = "X-Agent-Service-Token", required = false) String serviceToken,
            @Valid @RequestBody MdmAgentImportPreflightReqVO reqVO) {
        AgentPrincipal principal = bridgeGuard.require(serviceToken, SystemAgentBridgeGuard.PERMISSION_ITEM_IMPORT);
        return success(preflightService.preflight(reqVO, principal));
    }
    @PostMapping("/commit")
    @PreAuthorize("@ss.hasPermission('mdm:item:import')")
    public CommonResult<MdmAgentImportReceiptRespVO> commit(
            @RequestHeader(value = "X-Agent-Service-Token", required = false) String serviceToken,
            @Valid @RequestBody MdmAgentImportCommitReqVO reqVO) {
        AgentPrincipal principal = bridgeGuard.require(serviceToken, SystemAgentBridgeGuard.PERMISSION_ITEM_IMPORT);
        return success(commandService.commit(reqVO, principal));
    }
    @GetMapping("/commands/{commandId}")
    @PreAuthorize("@ss.hasPermission('mdm:item:import')")
    public CommonResult<MdmAgentImportReceiptRespVO> getCommand(
            @RequestHeader(value = "X-Agent-Service-Token", required = false) String serviceToken,
            @PathVariable String commandId) {
        AgentPrincipal principal = bridgeGuard.require(serviceToken, SystemAgentBridgeGuard.PERMISSION_ITEM_IMPORT);
        return success(commandService.getCommand(commandId, principal));
    }
}
yudao-module-mdm/src/main/java/cn/iocoder/yudao/module/mdm/controller/admin/agent/vo/MdmAgentImportCommitReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,44 @@
package cn.iocoder.yudao.module.mdm.controller.admin.agent.vo;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.util.List;
/**
 * æ™ºèƒ½ä½“物料导入提交请求。commandId ä¸Ž idempotencyKey å…±åŒä¿è¯é‡å¤æäº¤å¹‚等。
 */
@Data
public class MdmAgentImportCommitReqVO {
    @NotBlank(message = "命令标识不能为空")
    @Size(max = 64, message = "命令标识过长")
    private String commandId;
    @NotBlank(message = "幂等键不能为空")
    @Size(max = 128, message = "幂等键过长")
    private String idempotencyKey;
    @NotNull(message = "附件编号不能为空")
    private Long attachmentId;
    private Long sessionId;
    @NotBlank(message = "附件摘要不能为空")
    private String sourceSha256;
    @NotBlank(message = "预检摘要不能为空")
    private String preflightHash;
    private String proposalHash;
    private String proposalRevision;
    private Boolean syncMes;
    @NotEmpty(message = "导入行不能为空")
    @Valid
    private List<MdmAgentImportPreflightReqVO.Row> rows;
}
yudao-module-mdm/src/main/java/cn/iocoder/yudao/module/mdm/controller/admin/agent/vo/MdmAgentImportPreflightReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,46 @@
package cn.iocoder.yudao.module.mdm.controller.admin.agent.vo;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.util.List;
/**
 * æ™ºèƒ½ä½“物料导入预检请求。只读,不写业务表。
 */
@Data
public class MdmAgentImportPreflightReqVO {
    @NotNull(message = "附件编号不能为空")
    private Long attachmentId;
    private Long sessionId;
    @NotBlank(message = "附件摘要不能为空")
    private String sourceSha256;
    private String proposalRevision;
    private Boolean syncMes;
    @NotEmpty(message = "导入行不能为空")
    @Valid
    private List<Row> rows;
    @Data
    public static class Row {
        @NotBlank(message = "行标识不能为空")
        private String candidateId;
        private Integer rowNo;
        private String code;
        private String name;
        private String specification;
        private String categoryCode;
        private String unitMeasureCode;
    }
}
yudao-module-mdm/src/main/java/cn/iocoder/yudao/module/mdm/controller/admin/agent/vo/MdmAgentImportPreflightRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,91 @@
package cn.iocoder.yudao.module.mdm.controller.admin.agent.vo;
import lombok.Builder;
import lombok.Data;
import java.util.List;
/**
 * æ™ºèƒ½ä½“物料导入预检结果。canCommit=false æ—¶æäº¤å¿…然被拒绝。
 */
@Data
@Builder
public class MdmAgentImportPreflightRespVO {
    private String preflightHash;
    private Source source;
    private Authorization authorization;
    private Policy policy;
    private Summary summary;
    private Boolean canCommit;
    private List<String> sideEffects;
    private List<Row> rows;
    private List<Issue> blockingItems;
    @Data
    @Builder
    public static class Source {
        private Long attachmentId;
        private Long sessionId;
        private String sourceSha256;
        private String proposalRevision;
    }
    @Data
    @Builder
    public static class Authorization {
        private Long userId;
        private Long tenantId;
        private String permission;
        private String policyRevision;
    }
    @Data
    @Builder
    public static class Policy {
        private Boolean updateAllowed;
        private Boolean mesSyncAllowed;
        private Integer maxRows;
    }
    @Data
    @Builder
    public static class Summary {
        private Integer total;
        private Integer create;
        private Integer skip;
        private Integer blocked;
    }
    @Data
    @Builder
    public static class Row {
        private String candidateId;
        private Integer rowNo;
        private String code;
        private String name;
        private String action;
        private Resolved resolved;
        private List<Issue> blockers;
        private List<Issue> warnings;
        private Long targetItemId;
    }
    @Data
    @Builder
    public static class Resolved {
        private Long categoryId;
        private String categoryName;
        private Long unitMeasureId;
        private String unitMeasureName;
    }
    @Data
    @Builder
    public static class Issue {
        private String code;
        private String field;
        private String message;
        private Boolean retryable;
    }
}
yudao-module-mdm/src/main/java/cn/iocoder/yudao/module/mdm/controller/admin/agent/vo/MdmAgentImportReceiptRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,56 @@
package cn.iocoder.yudao.module.mdm.controller.admin.agent.vo;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
/**
 * æ™ºèƒ½ä½“物料导入回执。重复查询/重复提交返回同一结构。
 */
@Data
@Builder
public class MdmAgentImportReceiptRespVO {
    private String commandId;
    private String idempotencyKey;
    private String status;
    private Long attachmentId;
    private Long sessionId;
    private String sourceSha256;
    private String preflightHash;
    private String proposalHash;
    private Summary summary;
    private String errorCode;
    private String errorMessage;
    private List<Row> rows;
    private LocalDateTime createTime;
    private LocalDateTime completedTime;
    @Data
    @Builder
    public static class Summary {
        private Integer total;
        private Integer success;
        private Integer failed;
        private Integer skipped;
        private Integer unknown;
    }
    @Data
    @Builder
    public static class Row {
        private String candidateId;
        private Integer rowNo;
        private String code;
        private String name;
        private String action;
        private String status;
        private Long targetItemId;
        private String targetVersion;
        private String errorCode;
        private String errorMessage;
        private Boolean retryable;
    }
}
yudao-module-mdm/src/main/java/cn/iocoder/yudao/module/mdm/dal/dataobject/agent/MdmAgentImportCommandDO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,44 @@
package cn.iocoder.yudao.module.mdm.dal.dataobject.agent;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
import java.time.LocalDateTime;
/**
 * æ™ºèƒ½ä½“物料导入命令。同一 (tenant, owner, commandId/idempotencyKey) åªå…è®¸ä¸€æ¡ã€‚
 */
@TableName("mdm_agent_import_command")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class MdmAgentImportCommandDO extends BaseDO {
    @TableId
    private Long id;
    private String commandId;
    private String idempotencyKey;
    private String requestDigest;
    private Long tenantId;
    private Long ownerUserId;
    private Long sessionId;
    private Long attachmentId;
    private String sourceSha256;
    private String preflightHash;
    private String proposalHash;
    private Boolean syncMes;
    private String status;
    private Integer totalCount;
    private Integer successCount;
    private Integer failedCount;
    private Integer skippedCount;
    private String errorCode;
    private String errorMessage;
    private LocalDateTime leaseExpiresAt;
}
yudao-module-mdm/src/main/java/cn/iocoder/yudao/module/mdm/dal/dataobject/agent/MdmAgentImportCommandRowDO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,35 @@
package cn.iocoder.yudao.module.mdm.dal.dataobject.agent;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
/**
 * æ™ºèƒ½ä½“物料导入的逐行回执。
 */
@TableName("mdm_agent_import_command_row")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class MdmAgentImportCommandRowDO extends BaseDO {
    @TableId
    private Long id;
    private String commandId;
    private String candidateId;
    private Integer sourceRow;
    private String code;
    private String name;
    private String action;
    private String status;
    private Long targetItemId;
    private String targetVersion;
    private String errorCode;
    private String errorMessage;
    private Boolean retryable;
}
yudao-module-mdm/src/main/java/cn/iocoder/yudao/module/mdm/dal/mysql/agent/MdmAgentImportCommandMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,24 @@
package cn.iocoder.yudao.module.mdm.dal.mysql.agent;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.mdm.dal.dataobject.agent.MdmAgentImportCommandDO;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface MdmAgentImportCommandMapper extends BaseMapperX<MdmAgentImportCommandDO> {
    default MdmAgentImportCommandDO selectByCommandId(Long tenantId, Long ownerUserId, String commandId) {
        return selectOne(new LambdaQueryWrapperX<MdmAgentImportCommandDO>()
                .eq(MdmAgentImportCommandDO::getTenantId, tenantId)
                .eq(MdmAgentImportCommandDO::getOwnerUserId, ownerUserId)
                .eq(MdmAgentImportCommandDO::getCommandId, commandId));
    }
    default MdmAgentImportCommandDO selectByIdempotencyKey(Long tenantId, Long ownerUserId, String idempotencyKey) {
        return selectOne(new LambdaQueryWrapperX<MdmAgentImportCommandDO>()
                .eq(MdmAgentImportCommandDO::getTenantId, tenantId)
                .eq(MdmAgentImportCommandDO::getOwnerUserId, ownerUserId)
                .eq(MdmAgentImportCommandDO::getIdempotencyKey, idempotencyKey));
    }
}
yudao-module-mdm/src/main/java/cn/iocoder/yudao/module/mdm/dal/mysql/agent/MdmAgentImportCommandRowMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,19 @@
package cn.iocoder.yudao.module.mdm.dal.mysql.agent;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.mdm.dal.dataobject.agent.MdmAgentImportCommandRowDO;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
@Mapper
public interface MdmAgentImportCommandRowMapper extends BaseMapperX<MdmAgentImportCommandRowDO> {
    default List<MdmAgentImportCommandRowDO> selectListByCommandId(String commandId) {
        return selectList(new LambdaQueryWrapperX<MdmAgentImportCommandRowDO>()
                .eq(MdmAgentImportCommandRowDO::getCommandId, commandId)
                .orderByAsc(MdmAgentImportCommandRowDO::getSourceRow)
                .orderByAsc(MdmAgentImportCommandRowDO::getId));
    }
}
yudao-module-mdm/src/main/java/cn/iocoder/yudao/module/mdm/dal/mysql/item/MdmItemMapper.java
@@ -48,6 +48,10 @@
        return selectOne(MdmItemDO::getCode, code);
    }
    default List<MdmItemDO> selectListByCode(String code) {
        return selectList(MdmItemDO::getCode, code);
    }
    /**
     * æ ¹æ®ç¼–码和删除状态查询物料(用于解决软删除唯一键冲突)
     */
yudao-module-mdm/src/main/java/cn/iocoder/yudao/module/mdm/service/agent/MdmAgentImportCommandService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,370 @@
package cn.iocoder.yudao.module.mdm.service.agent;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.module.mdm.controller.admin.agent.vo.MdmAgentImportCommitReqVO;
import cn.iocoder.yudao.module.mdm.controller.admin.agent.vo.MdmAgentImportPreflightReqVO;
import cn.iocoder.yudao.module.mdm.controller.admin.agent.vo.MdmAgentImportPreflightRespVO;
import cn.iocoder.yudao.module.mdm.controller.admin.agent.vo.MdmAgentImportReceiptRespVO;
import cn.iocoder.yudao.module.mdm.controller.admin.item.vo.MdmItemImportExcelVO;
import cn.iocoder.yudao.module.mdm.controller.admin.item.vo.MdmItemSaveReqVO;
import cn.iocoder.yudao.module.mdm.dal.dataobject.agent.MdmAgentImportCommandDO;
import cn.iocoder.yudao.module.mdm.dal.dataobject.agent.MdmAgentImportCommandRowDO;
import cn.iocoder.yudao.module.mdm.dal.dataobject.item.MdmItemDO;
import cn.iocoder.yudao.module.mdm.dal.mysql.agent.MdmAgentImportCommandMapper;
import cn.iocoder.yudao.module.mdm.dal.mysql.agent.MdmAgentImportCommandRowMapper;
import cn.iocoder.yudao.module.mdm.service.item.MdmItemImportContext;
import cn.iocoder.yudao.module.mdm.service.item.MdmItemRowResolution;
import cn.iocoder.yudao.module.mdm.service.item.MdmItemService;
import cn.iocoder.yudao.module.system.framework.agent.AgentBridgeException;
import cn.iocoder.yudao.module.system.framework.agent.AgentPrincipal;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.validation.annotation.Validated;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.*;
import java.util.concurrent.atomic.AtomicReference;
/**
 * ç‰©æ–™å¯¼å…¥å¹‚等提交:同一 commandId/idempotencyKey é‡å¤æäº¤è¿”回原回执,绝不重复建物料。
 */
@Service
@Validated
public class MdmAgentImportCommandService {
    public static final String STATUS_RUNNING = "RUNNING";
    public static final String STATUS_SUCCEEDED = "SUCCEEDED";
    public static final String STATUS_PARTIAL_FAILED = "PARTIAL_FAILED";
    public static final String STATUS_FAILED = "FAILED";
    public static final String STATUS_UNKNOWN = "UNKNOWN";
    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
    private static final Duration LEASE_DURATION = Duration.ofMinutes(5);
    @Resource
    private MdmAgentImportPreflightService preflightService;
    @Resource
    private MdmItemService itemService;
    @Resource
    private MdmAgentImportPolicy policy;
    @Resource
    private MdmAgentImportCommandMapper commandMapper;
    @Resource
    private MdmAgentImportCommandRowMapper commandRowMapper;
    @Resource
    private PlatformTransactionManager transactionManager;
    public MdmAgentImportReceiptRespVO commit(MdmAgentImportCommitReqVO reqVO, AgentPrincipal principal) {
        if (!policy.isWriteEnabled()) {
            throw new AgentBridgeException(HttpStatus.SERVICE_UNAVAILABLE, "WRITE_DISABLED",
                    "智能体写入开关未打开,提交已拒绝");
        }
        String requestDigest = buildRequestDigest(reqVO);
        Long tenantId = principal.tenantId();
        Long ownerUserId = principal.userId();
        // 1. å¹‚等命中:命令 ID ä¼˜å…ˆï¼Œå…¶æ¬¡å¹‚等键
        MdmAgentImportCommandDO existing = commandMapper.selectByCommandId(tenantId, ownerUserId, reqVO.getCommandId());
        if (existing != null) {
            if (!existing.getRequestDigest().equals(requestDigest)) {
                throw new AgentBridgeException(HttpStatus.CONFLICT, "COMMAND_ID_CONFLICT",
                        "同一命令 ID æºå¸¦äº†ä¸åŒçš„请求内容,请检查任务状态后重试");
            }
            return toReceipt(existing);
        }
        existing = commandMapper.selectByIdempotencyKey(tenantId, ownerUserId, reqVO.getIdempotencyKey());
        if (existing != null) {
            if (!existing.getRequestDigest().equals(requestDigest)) {
                throw new AgentBridgeException(HttpStatus.CONFLICT, "IDEMPOTENCY_CONFLICT",
                        "同一幂等键携带了不同的请求内容,请检查任务状态后重试");
            }
            return toReceipt(existing);
        }
        // 2. é‡æ–°é¢„检并比对计划摘要,防止确认后数据已变化
        MdmAgentImportPreflightRespVO preflight = preflightService.preflight(toPreflightRequest(reqVO), principal);
        if (!reqVO.getPreflightHash().equalsIgnoreCase(preflight.getPreflightHash())) {
            throw new AgentBridgeException(HttpStatus.CONFLICT, "PLAN_STALE",
                    "预检计划已变化(编码状态或主数据被改动),请重新预检并确认", false,
                    Map.of("preflight", preflight));
        }
        if (!Boolean.TRUE.equals(preflight.getCanCommit())) {
            throw new AgentBridgeException(HttpStatus.UNPROCESSABLE_ENTITY, "PREFLIGHT_BLOCKED",
                    "预检存在阻断项,不能提交", false, Map.of("preflight", preflight));
        }
        // 3. ç™»è®°å‘½ä»¤ï¼ˆå”¯ä¸€é”®å…œä½å¹¶å‘重复提交)
        MdmAgentImportCommandDO command = MdmAgentImportCommandDO.builder()
                .commandId(reqVO.getCommandId())
                .idempotencyKey(reqVO.getIdempotencyKey())
                .requestDigest(requestDigest)
                .tenantId(tenantId)
                .ownerUserId(ownerUserId)
                .sessionId(reqVO.getSessionId())
                .attachmentId(reqVO.getAttachmentId())
                .sourceSha256(reqVO.getSourceSha256().toLowerCase(Locale.ROOT))
                .preflightHash(reqVO.getPreflightHash().toLowerCase(Locale.ROOT))
                .proposalHash(reqVO.getProposalHash())
                .syncMes(Boolean.TRUE.equals(reqVO.getSyncMes()))
                .status(STATUS_RUNNING)
                .totalCount(reqVO.getRows().size())
                .successCount(0).failedCount(0).skippedCount(0)
                .leaseExpiresAt(LocalDateTime.now().plus(LEASE_DURATION))
                .build();
        try {
            commandMapper.insert(command);
        } catch (DuplicateKeyException ex) {
            MdmAgentImportCommandDO raced = commandMapper.selectByCommandId(tenantId, ownerUserId, reqVO.getCommandId());
            if (raced == null) {
                raced = commandMapper.selectByIdempotencyKey(tenantId, ownerUserId, reqVO.getIdempotencyKey());
            }
            if (raced == null) {
                throw ex;
            }
            if (!raced.getRequestDigest().equals(requestDigest)) {
                throw new AgentBridgeException(HttpStatus.CONFLICT, "IDEMPOTENCY_CONFLICT",
                        "同一命令/幂等键携带了不同的请求内容,请检查任务状态后重试");
            }
            return toReceipt(raced);
        }
        // 4. é€è¡Œç‹¬ç«‹äº‹åŠ¡æ‰§è¡Œï¼›ä»»ä¸€è¡Œå¤±è´¥ä¸å½±å“å…¶ä»–è¡Œ
        int success = 0;
        int failed = 0;
        int skipped = 0;
        TransactionTemplate rowTxTemplate = new TransactionTemplate(transactionManager);
        List<MdmItemImportExcelVO> excelRows = reqVO.getRows().stream()
                .map(MdmAgentImportCommandService::toExcelVO).toList();
        MdmItemImportContext context = itemService.buildImportContext(excelRows);
        try {
            for (int index = 0; index < reqVO.getRows().size(); index++) {
                MdmAgentImportPreflightReqVO.Row row = reqVO.getRows().get(index);
                MdmAgentImportPreflightRespVO.Row planned = preflight.getRows().get(index);
                if ("SKIP".equals(planned.getAction())) {
                    commandRowMapper.insert(baseRow(command.getCommandId(), row)
                            .action("SKIP").status("SKIPPED")
                            .targetItemId(planned.getTargetItemId())
                            .retryable(false).build());
                    skipped++;
                    continue;
                }
                if (!"CREATE".equals(planned.getAction())) {
                    commandRowMapper.insert(baseRow(command.getCommandId(), row)
                            .action("CREATE").status("FAILED")
                            .errorCode("PREFLIGHT_BLOCKED").errorMessage("该行预检未通过").retryable(false).build());
                    failed++;
                    continue;
                }
                MdmItemRowResolution resolution = itemService.resolveImportRow(excelRows.get(index), context);
                if (!resolution.isSuccess()) {
                    commandRowMapper.insert(baseRow(command.getCommandId(), row)
                            .action("CREATE").status("FAILED")
                            .errorCode("ROW_INVALID").errorMessage(trim(resolution.failureMessage(), 512))
                            .retryable(false).build());
                    failed++;
                    continue;
                }
                AtomicReference<Throwable> rowError = new AtomicReference<>();
                boolean[] concurrentSkip = {false};
                try {
                    rowTxTemplate.executeWithoutResult(status -> {
                        try {
                            // æäº¤å‰é‡æŸ¥ï¼Œç¼©å°å¹¶å‘重复建物料的窗口
                            List<MdmItemDO> current = itemService.getItemListByCode(row.getCode().trim());
                            if (!current.isEmpty()) {
                                concurrentSkip[0] = true;
                                commandRowMapper.insert(baseRow(command.getCommandId(), row)
                                        .action("SKIP").status("SKIPPED")
                                        .targetItemId(current.get(0).getId()).retryable(false).build());
                                return;
                            }
                            MdmItemSaveReqVO saveReqVO = resolution.saveReqVO();
                            saveReqVO.setSyncMes(false); // æœ¬é˜¶æ®µå›ºå®šä¸åŒæ­¥ MES
                            Long itemId = itemService.createItem(saveReqVO);
                            commandRowMapper.insert(baseRow(command.getCommandId(), row)
                                    .action("CREATE").status("SUCCESS").targetItemId(itemId)
                                    .retryable(false).build());
                        } catch (Throwable t) {
                            rowError.set(t);
                            throw t;
                        }
                    });
                    if (concurrentSkip[0]) {
                        skipped++;
                    } else {
                        success++;
                    }
                } catch (Exception ex) {
                    Throwable cause = rowError.get() != null ? rowError.get() : ex;
                    String message = cause instanceof ServiceException
                            ? ((ServiceException) cause).getMessage() : cause.getMessage();
                    String errorCode = cause instanceof ServiceException ? "ITEM_CREATE_REJECTED" : "ITEM_CREATE_FAILED";
                    commandRowMapper.insert(baseRow(command.getCommandId(), row)
                            .action("CREATE").status("FAILED")
                            .errorCode(errorCode).errorMessage(trim(message, 512))
                            .retryable(!(cause instanceof ServiceException)).build());
                    failed++;
                }
            }
        } catch (Exception ex) {
            // åŸºç¡€è®¾æ–½å¼‚常中断:保留已完成行回执,命令标记 UNKNOWN ä¾›å¯¹è´¦
            command.setStatus(STATUS_UNKNOWN);
            command.setErrorCode("COMMIT_INTERRUPTED");
            command.setErrorMessage(trim(ex.getMessage(), 512));
            command.setSuccessCount(success);
            command.setFailedCount(failed);
            command.setSkippedCount(skipped);
            commandMapper.updateById(command);
            return toReceipt(command);
        }
        command.setSuccessCount(success);
        command.setFailedCount(failed);
        command.setSkippedCount(skipped);
        command.setStatus(failed == 0 ? STATUS_SUCCEEDED
                : (success + skipped == 0 ? STATUS_FAILED : STATUS_PARTIAL_FAILED));
        command.setLeaseExpiresAt(LocalDateTime.now());
        commandMapper.updateById(command);
        return toReceipt(command);
    }
    public MdmAgentImportReceiptRespVO getCommand(String commandId, AgentPrincipal principal) {
        MdmAgentImportCommandDO command = commandMapper.selectByCommandId(
                principal.tenantId(), principal.userId(), commandId);
        if (command == null) {
            throw new AgentBridgeException(HttpStatus.NOT_FOUND, "COMMAND_NOT_FOUND", "导入命令不存在");
        }
        return toReceipt(command);
    }
    private MdmAgentImportReceiptRespVO toReceipt(MdmAgentImportCommandDO command) {
        String status = command.getStatus();
        if (STATUS_RUNNING.equals(status) && command.getLeaseExpiresAt() != null
                && command.getLeaseExpiresAt().isBefore(LocalDateTime.now())) {
            // ç§Ÿçº¦è¿‡æœŸè¯´æ˜Žæ‰§è¡Œè¿›ç¨‹å¯èƒ½å·²ä¸­æ–­ï¼›ä¸æ”¹åº“,仅如实告知调用方结果未知
            status = STATUS_UNKNOWN;
        }
        List<MdmAgentImportCommandRowDO> rowDOs = commandRowMapper.selectListByCommandId(command.getCommandId());
        List<MdmAgentImportReceiptRespVO.Row> rows = rowDOs.stream()
                .map(row -> MdmAgentImportReceiptRespVO.Row.builder()
                        .candidateId(row.getCandidateId())
                        .rowNo(row.getSourceRow())
                        .code(row.getCode())
                        .name(row.getName())
                        .action(row.getAction())
                        .status(row.getStatus())
                        .targetItemId(row.getTargetItemId())
                        .targetVersion(row.getTargetVersion())
                        .errorCode(row.getErrorCode())
                        .errorMessage(row.getErrorMessage())
                        .retryable(row.getRetryable())
                        .build())
                .toList();
        int success = (int) rowDOs.stream().filter(row -> "SUCCESS".equals(row.getStatus())).count();
        int failed = (int) rowDOs.stream().filter(row -> "FAILED".equals(row.getStatus())).count();
        int skipped = (int) rowDOs.stream().filter(row -> "SKIPPED".equals(row.getStatus())).count();
        int unknown = (int) rowDOs.stream().filter(row -> "UNKNOWN".equals(row.getStatus())).count();
        return MdmAgentImportReceiptRespVO.builder()
                .commandId(command.getCommandId())
                .idempotencyKey(command.getIdempotencyKey())
                .status(status)
                .attachmentId(command.getAttachmentId())
                .sessionId(command.getSessionId())
                .sourceSha256(command.getSourceSha256())
                .preflightHash(command.getPreflightHash())
                .proposalHash(command.getProposalHash())
                .summary(MdmAgentImportReceiptRespVO.Summary.builder()
                        .total(command.getTotalCount())
                        .success(success).failed(failed).skipped(skipped).unknown(unknown)
                        .build())
                .errorCode(command.getErrorCode())
                .errorMessage(command.getErrorMessage())
                .rows(rows)
                .createTime(command.getCreateTime())
                .completedTime(STATUS_RUNNING.equals(command.getStatus()) ? null : command.getUpdateTime())
                .build();
    }
    private static MdmAgentImportCommandRowDO.MdmAgentImportCommandRowDOBuilder baseRow(
            String commandId, MdmAgentImportPreflightReqVO.Row row) {
        return MdmAgentImportCommandRowDO.builder()
                .commandId(commandId)
                .candidateId(row.getCandidateId())
                .sourceRow(row.getRowNo())
                .code(row.getCode())
                .name(row.getName());
    }
    private static MdmItemImportExcelVO toExcelVO(MdmAgentImportPreflightReqVO.Row row) {
        return MdmItemImportExcelVO.builder()
                .code(row.getCode())
                .name(row.getName())
                .specification(row.getSpecification())
                .categoryCode(row.getCategoryCode())
                .unitMeasureCode(row.getUnitMeasureCode())
                .build();
    }
    private static MdmAgentImportPreflightReqVO toPreflightRequest(MdmAgentImportCommitReqVO reqVO) {
        MdmAgentImportPreflightReqVO preflightReqVO = new MdmAgentImportPreflightReqVO();
        preflightReqVO.setAttachmentId(reqVO.getAttachmentId());
        preflightReqVO.setSessionId(reqVO.getSessionId());
        preflightReqVO.setSourceSha256(reqVO.getSourceSha256());
        preflightReqVO.setProposalRevision(reqVO.getProposalRevision());
        preflightReqVO.setSyncMes(reqVO.getSyncMes());
        preflightReqVO.setRows(reqVO.getRows());
        return preflightReqVO;
    }
    /**
     * è¯·æ±‚摘要只包含业务内容,不含 commandId/idempotencyKey,便于识别“同键不同内容”。
     */
    private static String buildRequestDigest(MdmAgentImportCommitReqVO reqVO) {
        Map<String, Object> canonical = new LinkedHashMap<>();
        canonical.put("attachment_id", reqVO.getAttachmentId());
        canonical.put("session_id", reqVO.getSessionId());
        canonical.put("source_sha256", StrUtil.nullToEmpty(reqVO.getSourceSha256()).toLowerCase(Locale.ROOT));
        canonical.put("preflight_hash", StrUtil.nullToEmpty(reqVO.getPreflightHash()).toLowerCase(Locale.ROOT));
        canonical.put("proposal_hash", StrUtil.nullToEmpty(reqVO.getProposalHash()));
        canonical.put("proposal_revision", StrUtil.nullToEmpty(reqVO.getProposalRevision()));
        canonical.put("sync_mes", Boolean.TRUE.equals(reqVO.getSyncMes()));
        List<Map<String, Object>> rows = new ArrayList<>();
        for (MdmAgentImportPreflightReqVO.Row row : reqVO.getRows()) {
            Map<String, Object> item = new LinkedHashMap<>();
            item.put("candidate_id", row.getCandidateId());
            item.put("row_no", row.getRowNo());
            item.put("code", StrUtil.nullToEmpty(row.getCode()));
            item.put("name", StrUtil.nullToEmpty(row.getName()));
            item.put("specification", StrUtil.nullToEmpty(row.getSpecification()));
            item.put("category_code", StrUtil.nullToEmpty(row.getCategoryCode()));
            item.put("unit_measure_code", StrUtil.nullToEmpty(row.getUnitMeasureCode()));
            rows.add(item);
        }
        canonical.put("rows", rows);
        try {
            byte[] payload = OBJECT_MAPPER.writeValueAsBytes(canonical);
            return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(payload));
        } catch (NoSuchAlgorithmException ex) {
            throw new IllegalStateException("SHA-256 ä¸å¯ç”¨", ex);
        } catch (JsonProcessingException ex) {
            throw new IllegalStateException("请求摘要生成失败", ex);
        }
    }
    private static String trim(String value, int maxLength) {
        if (value == null) {
            return null;
        }
        return value.length() <= maxLength ? value : value.substring(0, maxLength);
    }
}
yudao-module-mdm/src/main/java/cn/iocoder/yudao/module/mdm/service/agent/MdmAgentImportPolicy.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,34 @@
package cn.iocoder.yudao.module.mdm.service.agent;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
/**
 * æ™ºèƒ½ä½“物料导入的写入策略:默认全关,联调时显式打开。
 */
@Component
public class MdmAgentImportPolicy {
    public static final String POLICY_REVISION = "mdm-item-import-v1";
    @Value("${yudao.agent.bridge.write-enabled:false}")
    private boolean writeEnabled;
    @Value("${yudao.agent.bridge.allow-mes-sync:false}")
    private boolean allowMesSync;
    @Value("${yudao.agent.bridge.max-rows:5000}")
    private int maxRows;
    public boolean isWriteEnabled() {
        return writeEnabled;
    }
    public boolean isAllowMesSync() {
        return allowMesSync;
    }
    public int getMaxRows() {
        return maxRows;
    }
}
yudao-module-mdm/src/main/java/cn/iocoder/yudao/module/mdm/service/agent/MdmAgentImportPreflightService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,257 @@
package cn.iocoder.yudao.module.mdm.service.agent;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.module.mdm.controller.admin.agent.vo.MdmAgentImportPreflightReqVO;
import cn.iocoder.yudao.module.mdm.controller.admin.agent.vo.MdmAgentImportPreflightRespVO;
import cn.iocoder.yudao.module.mdm.controller.admin.item.vo.MdmItemImportExcelVO;
import cn.iocoder.yudao.module.mdm.dal.dataobject.item.MdmItemDO;
import cn.iocoder.yudao.module.mdm.service.item.MdmItemImportContext;
import cn.iocoder.yudao.module.mdm.service.item.MdmItemRowResolution;
import cn.iocoder.yudao.module.mdm.service.item.MdmItemService;
import cn.iocoder.yudao.module.system.framework.agent.AgentBridgeException;
import cn.iocoder.yudao.module.system.framework.agent.AgentPrincipal;
import cn.iocoder.yudao.module.system.framework.agent.SystemAgentBridgeGuard;
import cn.iocoder.yudao.module.system.service.storage.SystemAgentAttachmentRef;
import cn.iocoder.yudao.module.system.service.storage.SystemAgentImportBridgeService;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.*;
/**
 * ç‰©æ–™å¯¼å…¥åªè¯»é¢„检:校验附件、解析分类/单位、判断编码是否已存在,不写任何业务表。
 */
@Service
@Validated
public class MdmAgentImportPreflightService {
    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
    @Resource
    private SystemAgentImportBridgeService bridgeService;
    @Resource
    private MdmItemService itemService;
    @Resource
    private MdmAgentImportPolicy policy;
    public MdmAgentImportPreflightRespVO preflight(MdmAgentImportPreflightReqVO reqVO, AgentPrincipal principal) {
        if (reqVO.getRows().size() > policy.getMaxRows()) {
            throw new AgentBridgeException(HttpStatus.PAYLOAD_TOO_LARGE, "ROW_LIMIT_EXCEEDED",
                    "导入行数超过上限:" + policy.getMaxRows());
        }
        // 1. é™„件与摘要必须与本次会话绑定的一致
        SystemAgentAttachmentRef attachment = bridgeService.requireOwnedAttachmentRef(reqVO.getAttachmentId());
        if (reqVO.getSessionId() != null && !reqVO.getSessionId().equals(attachment.sessionId())) {
            throw new AgentBridgeException(HttpStatus.CONFLICT, "SESSION_MISMATCH",
                    "附件不属于指定补录会话,请重新上传后再试", true, null);
        }
        String sourceSha256 = bridgeService.sha256OfAttachment(reqVO.getAttachmentId());
        if (!sourceSha256.equalsIgnoreCase(reqVO.getSourceSha256().trim())) {
            throw new AgentBridgeException(HttpStatus.CONFLICT, "SOURCE_DIGEST_MISMATCH",
                    "附件内容已变化(摘要不一致),请重新上传后再试", true, null);
        }
        // 2. candidate_id å¿…须唯一,否则回执无法逐行对应
        Set<String> candidateIds = new HashSet<>();
        for (MdmAgentImportPreflightReqVO.Row row : reqVO.getRows()) {
            if (!candidateIds.add(row.getCandidateId())) {
                throw new AgentBridgeException(HttpStatus.BAD_REQUEST, "CANDIDATE_ID_DUPLICATED",
                        "导入行标识重复:" + row.getCandidateId());
            }
        }
        // 3. é¢„热分类/单位映射
        List<MdmItemImportExcelVO> excelRows = reqVO.getRows().stream()
                .map(MdmAgentImportPreflightService::toExcelVO).toList();
        MdmItemImportContext context = itemService.buildImportContext(excelRows);
        boolean syncMesRequested = Boolean.TRUE.equals(reqVO.getSyncMes());
        List<MdmAgentImportPreflightRespVO.Issue> blockingItems = new ArrayList<>();
        if (syncMesRequested) {
            blockingItems.add(issue("MES_SYNC_DISABLED", "syncMes",
                    "本阶段不开放同步 MES,请关闭 syncMes åŽé‡è¯•", false));
        }
        // 4. æ–‡ä»¶å†…重复编码:所有重复行一起阻断,避免“首行建、次行跳”的误导
        Map<String, Long> codeCounts = reqVO.getRows().stream()
                .map(MdmAgentImportPreflightReqVO.Row::getCode).filter(StrUtil::isNotBlank)
                .map(String::trim).collect(java.util.stream.Collectors.groupingBy(code -> code,
                        java.util.stream.Collectors.counting()));
        Set<String> duplicateCodes = codeCounts.entrySet().stream()
                .filter(entry -> entry.getValue() > 1).map(Map.Entry::getKey)
                .collect(java.util.stream.Collectors.toSet());
        // 5. é€è¡Œåˆ¤å®š CREATE / SKIP / BLOCKED
        List<MdmAgentImportPreflightRespVO.Row> rows = new ArrayList<>();
        int createCount = 0;
        int skipCount = 0;
        int blockedCount = 0;
        for (int index = 0; index < reqVO.getRows().size(); index++) {
            MdmAgentImportPreflightReqVO.Row row = reqVO.getRows().get(index);
            MdmItemImportExcelVO excelVO = excelRows.get(index);
            List<MdmAgentImportPreflightRespVO.Issue> blockers = new ArrayList<>();
            List<MdmAgentImportPreflightRespVO.Issue> warnings = new ArrayList<>();
            if (StrUtil.isBlank(row.getCode())) {
                blockers.add(issue("ROW_CODE_REQUIRED", "code", "物料编码不能为空", false));
            } else if (duplicateCodes.contains(row.getCode().trim())) {
                blockers.add(issue("DUPLICATE_CODE_IN_FILE", "code",
                        "文件内物料编码重复:" + row.getCode().trim(), false));
            }
            if (StrUtil.isBlank(row.getName())) {
                blockers.add(issue("ROW_NAME_REQUIRED", "name", "物料名称不能为空", false));
            }
            if (StrUtil.isNotBlank(row.getCategoryCode())
                    && !context.categoryCodeIdMap().containsKey(row.getCategoryCode())) {
                blockers.add(issue("CATEGORY_NOT_FOUND", "categoryCode",
                        "物料分类编码不存在:" + row.getCategoryCode(), false));
            }
            if (StrUtil.isNotBlank(row.getUnitMeasureCode())
                    && !context.unitCodeIdMap().containsKey(row.getUnitMeasureCode())) {
                blockers.add(issue("UNIT_NOT_FOUND", "unitMeasureCode",
                        "计量单位编码不存在:" + row.getUnitMeasureCode(), false));
            }
            MdmItemRowResolution resolution = itemService.resolveImportRow(excelVO, context);
            if (!resolution.isSuccess() && blockers.isEmpty()) {
                // ç†è®ºä¸Šä¸Šé¢çš„逐项校验已覆盖;兜底保持与 Excel å¯¼å…¥ä¸€è‡´çš„æ–‡æ¡ˆ
                blockers.add(issue("ROW_INVALID", null, resolution.failureMessage(), false));
            }
            String action;
            Long targetItemId = null;
            if (!blockers.isEmpty()) {
                action = "BLOCKED";
                blockedCount++;
            } else {
                List<MdmItemDO> existing = itemService.getItemListByCode(row.getCode().trim());
                if (!existing.isEmpty()) {
                    action = "SKIP";
                    targetItemId = existing.get(0).getId();
                    skipCount++;
                    warnings.add(issue("ITEM_CODE_EXISTS", "code", "物料编码已存在,本阶段只新建不更新,将跳过该行", false));
                } else {
                    action = "CREATE";
                    createCount++;
                }
            }
            rows.add(MdmAgentImportPreflightRespVO.Row.builder()
                    .candidateId(row.getCandidateId())
                    .rowNo(row.getRowNo())
                    .code(row.getCode())
                    .name(row.getName())
                    .action(action)
                    .resolved(resolve(row, context))
                    .blockers(blockers)
                    .warnings(warnings)
                    .targetItemId(targetItemId)
                    .build());
        }
        boolean canCommit = blockingItems.isEmpty() && blockedCount == 0;
        String preflightHash = buildPreflightHash(reqVO, principal, attachment, sourceSha256,
                syncMesRequested, rows);
        return MdmAgentImportPreflightRespVO.builder()
                .preflightHash(preflightHash)
                .source(MdmAgentImportPreflightRespVO.Source.builder()
                        .attachmentId(attachment.attachmentId())
                        .sessionId(attachment.sessionId())
                        .sourceSha256(sourceSha256)
                        .proposalRevision(reqVO.getProposalRevision())
                        .build())
                .authorization(MdmAgentImportPreflightRespVO.Authorization.builder()
                        .userId(principal.userId())
                        .tenantId(principal.tenantId())
                        .permission(SystemAgentBridgeGuard.PERMISSION_ITEM_IMPORT)
                        .policyRevision(MdmAgentImportPolicy.POLICY_REVISION)
                        .build())
                .policy(MdmAgentImportPreflightRespVO.Policy.builder()
                        .updateAllowed(false)
                        .mesSyncAllowed(policy.isAllowMesSync())
                        .maxRows(policy.getMaxRows())
                        .build())
                .summary(MdmAgentImportPreflightRespVO.Summary.builder()
                        .total(rows.size())
                        .create(createCount)
                        .skip(skipCount)
                        .blocked(blockedCount)
                        .build())
                .canCommit(canCommit)
                .sideEffects(canCommit ? List.of("mdm_item.create:" + createCount) : List.of())
                .rows(rows)
                .blockingItems(blockingItems)
                .build();
    }
    private static MdmItemImportExcelVO toExcelVO(MdmAgentImportPreflightReqVO.Row row) {
        return MdmItemImportExcelVO.builder()
                .code(row.getCode())
                .name(row.getName())
                .specification(row.getSpecification())
                .categoryCode(row.getCategoryCode())
                .unitMeasureCode(row.getUnitMeasureCode())
                .build();
    }
    private static MdmAgentImportPreflightRespVO.Resolved resolve(MdmAgentImportPreflightReqVO.Row row,
                                                                 MdmItemImportContext context) {
        Long categoryId = StrUtil.isBlank(row.getCategoryCode()) ? null
                : context.categoryCodeIdMap().get(row.getCategoryCode());
        String categoryName = StrUtil.isBlank(row.getCategoryCode()) ? null
                : context.categoryCodeNameMap().get(row.getCategoryCode());
        Long unitMeasureId = StrUtil.isBlank(row.getUnitMeasureCode()) ? null
                : context.unitCodeIdMap().get(row.getUnitMeasureCode());
        String unitMeasureName = StrUtil.isBlank(row.getUnitMeasureCode()) ? null
                : context.unitCodeNameMap().get(row.getUnitMeasureCode());
        return MdmAgentImportPreflightRespVO.Resolved.builder()
                .categoryId(categoryId).categoryName(categoryName)
                .unitMeasureId(unitMeasureId).unitMeasureName(unitMeasureName)
                .build();
    }
    private static MdmAgentImportPreflightRespVO.Issue issue(String code, String field, String message,
                                                             boolean retryable) {
        return MdmAgentImportPreflightRespVO.Issue.builder()
                .code(code).field(field).message(message).retryable(retryable).build();
    }
    /**
     * æŒ‰å›ºå®šå­—段顺序拼装规范 JSON åŽå– SHA-256:同一份计划必须得到同一个 hash。
     */
    private static String buildPreflightHash(MdmAgentImportPreflightReqVO reqVO, AgentPrincipal principal,
                                             SystemAgentAttachmentRef attachment, String sourceSha256,
                                             boolean syncMesRequested, List<MdmAgentImportPreflightRespVO.Row> rows) {
        Map<String, Object> canonical = new LinkedHashMap<>();
        canonical.put("policy_revision", MdmAgentImportPolicy.POLICY_REVISION);
        canonical.put("user_id", principal.userId());
        canonical.put("tenant_id", principal.tenantId());
        canonical.put("attachment_id", attachment.attachmentId());
        canonical.put("session_id", attachment.sessionId());
        canonical.put("source_sha256", sourceSha256.toLowerCase(Locale.ROOT));
        canonical.put("proposal_revision", StrUtil.blankToDefault(reqVO.getProposalRevision(), ""));
        canonical.put("sync_mes", syncMesRequested);
        List<Map<String, Object>> canonicalRows = new ArrayList<>();
        for (MdmAgentImportPreflightRespVO.Row row : rows) {
            Map<String, Object> item = new LinkedHashMap<>();
            item.put("candidate_id", row.getCandidateId());
            item.put("row_no", row.getRowNo());
            item.put("code", row.getCode());
            item.put("name", row.getName());
            canonicalRows.add(item);
        }
        canonical.put("rows", canonicalRows);
        try {
            byte[] payload = OBJECT_MAPPER.writeValueAsBytes(canonical);
            return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(payload));
        } catch (NoSuchAlgorithmException ex) {
            throw new IllegalStateException("SHA-256 ä¸å¯ç”¨", ex);
        } catch (com.fasterxml.jackson.core.JsonProcessingException ex) {
            throw new IllegalStateException("预检摘要生成失败", ex);
        }
    }
}
yudao-module-mdm/src/main/java/cn/iocoder/yudao/module/mdm/service/item/MdmItemImportContext.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,12 @@
package cn.iocoder.yudao.module.mdm.service.item;
import java.util.Map;
/**
 * ç‰©æ–™å¯¼å…¥çš„预加载上下文:分类/单位编码到 ID、名称的映射,避免逐行查库。
 */
public record MdmItemImportContext(Map<String, Long> categoryCodeIdMap,
                                   Map<String, String> categoryCodeNameMap,
                                   Map<String, Long> unitCodeIdMap,
                                   Map<String, String> unitCodeNameMap) {
}
yudao-module-mdm/src/main/java/cn/iocoder/yudao/module/mdm/service/item/MdmItemRowResolution.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,21 @@
package cn.iocoder.yudao.module.mdm.service.item;
import cn.iocoder.yudao.module.mdm.controller.admin.item.vo.MdmItemSaveReqVO;
/**
 * å•行导入的解析结果:失败时只带文案,成功时带组装好的 SaveReqVO。
 */
public record MdmItemRowResolution(String failureMessage, MdmItemSaveReqVO saveReqVO) {
    public boolean isSuccess() {
        return failureMessage == null;
    }
    public static MdmItemRowResolution failure(String failureMessage) {
        return new MdmItemRowResolution(failureMessage, null);
    }
    public static MdmItemRowResolution success(MdmItemSaveReqVO saveReqVO) {
        return new MdmItemRowResolution(null, saveReqVO);
    }
}
yudao-module-mdm/src/main/java/cn/iocoder/yudao/module/mdm/service/item/MdmItemService.java
@@ -164,4 +164,29 @@
     */
    MdmItemImportRespVO importItemList(List<MdmItemImportExcelVO> importList, Boolean isUpdateSupport);
    /**
     * é¢„热导入上下文(分类、计量单位编码映射)
     *
     * @param importList å¯¼å…¥åˆ—表
     * @return å¯¼å…¥ä¸Šä¸‹æ–‡
     */
    MdmItemImportContext buildImportContext(List<MdmItemImportExcelVO> importList);
    /**
     * è§£æžå•行导入数据(校验必填项、解析分类与单位、组装 SaveReqVO)
     *
     * @param importVO å¯¼å…¥è¡Œ
     * @param context  å¯¼å…¥ä¸Šä¸‹æ–‡
     * @return è§£æžç»“æžœ
     */
    MdmItemRowResolution resolveImportRow(MdmItemImportExcelVO importVO, MdmItemImportContext context);
    /**
     * æŒ‰ç¼–码查询物料列表(编码无唯一索引,可能返回多条)
     *
     * @param code ç‰©æ–™ç¼–码
     * @return ç‰©æ–™åˆ—表
     */
    List<MdmItemDO> getItemListByCode(String code);
}
yudao-module-mdm/src/main/java/cn/iocoder/yudao/module/mdm/service/item/MdmItemServiceImpl.java
@@ -451,88 +451,18 @@
                .failureList(new LinkedHashMap<>()).build();
        // 1. é¢„热:分类编码 -> ID(MES ç‰©æ–™åˆ†ç±»ï¼Œä¸Žç‰©æ–™è¡¨å•下拉数据源一致)、单位编码 -> ID
        Map<String, Long> categoryCodeIdMap = new HashMap<>();
        for (String categoryCode : importList.stream()
                .map(MdmItemImportExcelVO::getCategoryCode).filter(StrUtil::isNotBlank).distinct().toList()) {
            CommonResult<MesMdItemTypeRespDTO> categoryResult = mesMdItemTypeApi.getItemTypeByCode(categoryCode);
            if (categoryResult != null && categoryResult.getData() != null) {
                categoryCodeIdMap.put(categoryCode, categoryResult.getData().getId());
            }
        }
        Map<String, Long> unitCodeIdMap = new HashMap<>();
        for (MdmUnitMeasureDO unit : unitMeasureMapper.selectList()) {
            unitCodeIdMap.put(unit.getCode(), unit.getId());
        }
        MdmItemImportContext context = buildImportContext(importList);
        // 2. éåŽ†å¯¼å…¥ï¼ˆæ¯è¡Œä¸€ä¸ªç‹¬ç«‹äº‹åŠ¡ï¼šå•è¡Œå¤±è´¥åªå›žæ»šè¯¥è¡Œï¼Œä¸å½±å“å…¶ä½™è¡Œï¼‰
        TransactionTemplate importTxTemplate = new TransactionTemplate(transactionManager);
        for (MdmItemImportExcelVO importVO : importList) {
            // 2.1 åŸºç¡€æ ¡éªŒ
            if (StrUtil.isBlank(importVO.getCode()) || StrUtil.isBlank(importVO.getName())) {
                respVO.getFailureList().put(buildFailKey(importVO), "物料编码和物料名称不能为空");
            // 2.1~2.6 æ ¡éªŒå¿…填项、解析分类与单位、组装 SaveReqVO
            MdmItemRowResolution resolution = resolveImportRow(importVO, context);
            if (!resolution.isSuccess()) {
                respVO.getFailureList().put(buildFailKey(importVO), resolution.failureMessage());
                continue;
            }
            // 2.2 è§£æžåˆ†ç±»
            Long categoryId = null;
            if (StrUtil.isNotBlank(importVO.getCategoryCode())) {
                categoryId = categoryCodeIdMap.get(importVO.getCategoryCode());
                if (categoryId == null) {
                    respVO.getFailureList().put(buildFailKey(importVO), "物料分类编码不存在:" + importVO.getCategoryCode());
                    continue;
                }
            }
            // 2.3 è§£æžä¸»å•位
            Long unitMeasureId = null;
            if (StrUtil.isNotBlank(importVO.getUnitMeasureCode())) {
                unitMeasureId = unitCodeIdMap.get(importVO.getUnitMeasureCode());
                if (unitMeasureId == null) {
                    respVO.getFailureList().put(buildFailKey(importVO), "计量单位编码不存在:" + importVO.getUnitMeasureCode());
                    continue;
                }
            }
            // 2.4 è§£æžè¾…单位1
            Long unitMeasureId2 = null;
            if (StrUtil.isNotBlank(importVO.getUnitMeasureCode2())) {
                unitMeasureId2 = unitCodeIdMap.get(importVO.getUnitMeasureCode2());
                if (unitMeasureId2 == null) {
                    respVO.getFailureList().put(buildFailKey(importVO), "辅单位1编码不存在:" + importVO.getUnitMeasureCode2());
                    continue;
                }
            }
            // 2.5 è§£æžè¾…单位2
            Long unitMeasureId3 = null;
            if (StrUtil.isNotBlank(importVO.getUnitMeasureCode3())) {
                unitMeasureId3 = unitCodeIdMap.get(importVO.getUnitMeasureCode3());
                if (unitMeasureId3 == null) {
                    respVO.getFailureList().put(buildFailKey(importVO), "辅单位2编码不存在:" + importVO.getUnitMeasureCode3());
                    continue;
                }
            }
            // 2.6 ç»„装 SaveReqVO(复用 create/update é€»è¾‘)
            MdmItemSaveReqVO saveReqVO = new MdmItemSaveReqVO();
            saveReqVO.setCode(importVO.getCode());
            saveReqVO.setName(importVO.getName());
            saveReqVO.setBarCode(importVO.getBarCode());
            saveReqVO.setSpecification(importVO.getSpecification());
            saveReqVO.setCategoryId(categoryId);
            saveReqVO.setUnitMeasureId(unitMeasureId);
            saveReqVO.setUnitMeasureId2(unitMeasureId2);
            saveReqVO.setUnitMeasureRate1(importVO.getUnitMeasureRate1());
            saveReqVO.setUnitMeasureId3(unitMeasureId3);
            saveReqVO.setUnitMeasureRate2(importVO.getUnitMeasureRate2());
            saveReqVO.setPurchasePrice(importVO.getPurchasePrice());
            saveReqVO.setSalesPrice(importVO.getSalesPrice());
            saveReqVO.setCostPrice(importVO.getCostPrice());
            saveReqVO.setSafetyStock(importVO.getSafetyStock());
            saveReqVO.setMinStock(importVO.getMinStock());
            saveReqVO.setMaxStock(importVO.getMaxStock());
            saveReqVO.setIsBatchManaged(importVO.getIsBatchManaged());
            saveReqVO.setExpiryDay(importVO.getExpiryDay());
            saveReqVO.setStatus(importVO.getStatus() != null ? importVO.getStatus() : cn.iocoder.yudao.framework.common.enums.CommonStatusEnum.ENABLE.getStatus());
            saveReqVO.setSyncMes(importVO.getSyncMes() != null ? importVO.getSyncMes() : false);
            saveReqVO.setRemark(importVO.getRemark());
            saveReqVO.setItemType(1); // é»˜è®¤ç‰©æ–™ç±»åž‹ï¼šç‰©æ–™
            MdmItemSaveReqVO saveReqVO = resolution.saveReqVO();
            // 2.7 å·²å­˜åœ¨ï¼ˆæŒ‰ç¼–码判断)
            MdmItemDO existByCode = itemMapper.selectByCode(importVO.getCode());
@@ -574,6 +504,98 @@
        return respVO;
    }
    @Override
    public MdmItemImportContext buildImportContext(List<MdmItemImportExcelVO> importList) {
        Map<String, Long> categoryCodeIdMap = new HashMap<>();
        Map<String, String> categoryCodeNameMap = new HashMap<>();
        for (String categoryCode : importList.stream()
                .map(MdmItemImportExcelVO::getCategoryCode).filter(StrUtil::isNotBlank).distinct().toList()) {
            CommonResult<MesMdItemTypeRespDTO> categoryResult = mesMdItemTypeApi.getItemTypeByCode(categoryCode);
            if (categoryResult != null && categoryResult.getData() != null) {
                categoryCodeIdMap.put(categoryCode, categoryResult.getData().getId());
                categoryCodeNameMap.put(categoryCode, categoryResult.getData().getName());
            }
        }
        Map<String, Long> unitCodeIdMap = new HashMap<>();
        Map<String, String> unitCodeNameMap = new HashMap<>();
        for (MdmUnitMeasureDO unit : unitMeasureMapper.selectList()) {
            unitCodeIdMap.put(unit.getCode(), unit.getId());
            unitCodeNameMap.put(unit.getCode(), unit.getName());
        }
        return new MdmItemImportContext(categoryCodeIdMap, categoryCodeNameMap, unitCodeIdMap, unitCodeNameMap);
    }
    @Override
    public MdmItemRowResolution resolveImportRow(MdmItemImportExcelVO importVO, MdmItemImportContext context) {
        // åŸºç¡€æ ¡éªŒ
        if (StrUtil.isBlank(importVO.getCode()) || StrUtil.isBlank(importVO.getName())) {
            return MdmItemRowResolution.failure("物料编码和物料名称不能为空");
        }
        // è§£æžåˆ†ç±»
        Long categoryId = null;
        if (StrUtil.isNotBlank(importVO.getCategoryCode())) {
            categoryId = context.categoryCodeIdMap().get(importVO.getCategoryCode());
            if (categoryId == null) {
                return MdmItemRowResolution.failure("物料分类编码不存在:" + importVO.getCategoryCode());
            }
        }
        // è§£æžä¸»å•位
        Long unitMeasureId = null;
        if (StrUtil.isNotBlank(importVO.getUnitMeasureCode())) {
            unitMeasureId = context.unitCodeIdMap().get(importVO.getUnitMeasureCode());
            if (unitMeasureId == null) {
                return MdmItemRowResolution.failure("计量单位编码不存在:" + importVO.getUnitMeasureCode());
            }
        }
        // è§£æžè¾…单位1
        Long unitMeasureId2 = null;
        if (StrUtil.isNotBlank(importVO.getUnitMeasureCode2())) {
            unitMeasureId2 = context.unitCodeIdMap().get(importVO.getUnitMeasureCode2());
            if (unitMeasureId2 == null) {
                return MdmItemRowResolution.failure("辅单位1编码不存在:" + importVO.getUnitMeasureCode2());
            }
        }
        // è§£æžè¾…单位2
        Long unitMeasureId3 = null;
        if (StrUtil.isNotBlank(importVO.getUnitMeasureCode3())) {
            unitMeasureId3 = context.unitCodeIdMap().get(importVO.getUnitMeasureCode3());
            if (unitMeasureId3 == null) {
                return MdmItemRowResolution.failure("辅单位2编码不存在:" + importVO.getUnitMeasureCode3());
            }
        }
        // ç»„装 SaveReqVO(复用 create/update é€»è¾‘)
        MdmItemSaveReqVO saveReqVO = new MdmItemSaveReqVO();
        saveReqVO.setCode(importVO.getCode());
        saveReqVO.setName(importVO.getName());
        saveReqVO.setBarCode(importVO.getBarCode());
        saveReqVO.setSpecification(importVO.getSpecification());
        saveReqVO.setCategoryId(categoryId);
        saveReqVO.setUnitMeasureId(unitMeasureId);
        saveReqVO.setUnitMeasureId2(unitMeasureId2);
        saveReqVO.setUnitMeasureRate1(importVO.getUnitMeasureRate1());
        saveReqVO.setUnitMeasureId3(unitMeasureId3);
        saveReqVO.setUnitMeasureRate2(importVO.getUnitMeasureRate2());
        saveReqVO.setPurchasePrice(importVO.getPurchasePrice());
        saveReqVO.setSalesPrice(importVO.getSalesPrice());
        saveReqVO.setCostPrice(importVO.getCostPrice());
        saveReqVO.setSafetyStock(importVO.getSafetyStock());
        saveReqVO.setMinStock(importVO.getMinStock());
        saveReqVO.setMaxStock(importVO.getMaxStock());
        saveReqVO.setIsBatchManaged(importVO.getIsBatchManaged());
        saveReqVO.setExpiryDay(importVO.getExpiryDay());
        saveReqVO.setStatus(importVO.getStatus() != null ? importVO.getStatus() : cn.iocoder.yudao.framework.common.enums.CommonStatusEnum.ENABLE.getStatus());
        saveReqVO.setSyncMes(importVO.getSyncMes() != null ? importVO.getSyncMes() : false);
        saveReqVO.setRemark(importVO.getRemark());
        saveReqVO.setItemType(1); // é»˜è®¤ç‰©æ–™ç±»åž‹ï¼šç‰©æ–™
        return MdmItemRowResolution.success(saveReqVO);
    }
    @Override
    public List<MdmItemDO> getItemListByCode(String code) {
        return itemMapper.selectListByCode(code);
    }
    /**
     * å¤±è´¥æ¸…单的 key:用「编码 åç§°ã€ï¼Œè€Œä¸æ˜¯å•用名称。
     * ç‰©æ–™å…è®¸åŒåä¸åŒç¼–码后,只按名称做 key ä¼šè®©åŒåè¡Œäº’相覆盖、少报失败行;
yudao-module-mdm/src/test/java/cn/iocoder/yudao/module/mdm/service/agent/MdmAgentImportCommandServiceTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,171 @@
package cn.iocoder.yudao.module.mdm.service.agent;
import cn.iocoder.yudao.module.mdm.controller.admin.agent.vo.MdmAgentImportCommitReqVO;
import cn.iocoder.yudao.module.mdm.controller.admin.agent.vo.MdmAgentImportPreflightReqVO;
import cn.iocoder.yudao.module.mdm.controller.admin.agent.vo.MdmAgentImportPreflightRespVO;
import cn.iocoder.yudao.module.mdm.controller.admin.agent.vo.MdmAgentImportReceiptRespVO;
import cn.iocoder.yudao.module.mdm.controller.admin.item.vo.MdmItemImportExcelVO;
import cn.iocoder.yudao.module.mdm.controller.admin.item.vo.MdmItemSaveReqVO;
import cn.iocoder.yudao.module.mdm.dal.dataobject.agent.MdmAgentImportCommandDO;
import cn.iocoder.yudao.module.mdm.dal.mysql.agent.MdmAgentImportCommandMapper;
import cn.iocoder.yudao.module.mdm.dal.mysql.agent.MdmAgentImportCommandRowMapper;
import cn.iocoder.yudao.module.mdm.service.item.MdmItemImportContext;
import cn.iocoder.yudao.module.mdm.service.item.MdmItemRowResolution;
import cn.iocoder.yudao.module.mdm.service.item.MdmItemService;
import cn.iocoder.yudao.module.system.framework.agent.AgentBridgeException;
import cn.iocoder.yudao.module.system.framework.agent.AgentPrincipal;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.TransactionStatus;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class MdmAgentImportCommandServiceTest {
    private static final String PREFLIGHT_HASH = "b".repeat(64);
    private static final String SOURCE_SHA = "a".repeat(64);
    @Mock private MdmAgentImportPreflightService preflightService;
    @Mock private MdmItemService itemService;
    @Mock private MdmAgentImportPolicy policy;
    @Mock private MdmAgentImportCommandMapper commandMapper;
    @Mock private MdmAgentImportCommandRowMapper commandRowMapper;
    @Mock private PlatformTransactionManager transactionManager;
    @InjectMocks private MdmAgentImportCommandService service;
    private final AgentPrincipal principal = new AgentPrincipal(7L, 0L, "live");
    @BeforeEach
    void setUp() {
        when(policy.isWriteEnabled()).thenReturn(true);
        when(transactionManager.getTransaction(any(TransactionDefinition.class)))
                .thenReturn(mock(TransactionStatus.class));
        when(itemService.buildImportContext(any())).thenReturn(new MdmItemImportContext(
                Map.of("RAW", 11L), Map.of("RAW", "原料"),
                Map.of("PCS", 22L), Map.of("PCS", "个")));
        when(itemService.resolveImportRow(any(MdmItemImportExcelVO.class), any()))
                .thenAnswer(invocation -> MdmItemRowResolution.success(new MdmItemSaveReqVO()));
        when(itemService.getItemListByCode(any())).thenReturn(List.of());
        when(itemService.createItem(any())).thenReturn(77L);
        when(commandRowMapper.selectListByCommandId(any())).thenReturn(List.of());
    }
    @Test
    void writeDisabledRejects() {
        when(policy.isWriteEnabled()).thenReturn(false);
        AgentBridgeException ex = assertThrows(AgentBridgeException.class,
                () -> service.commit(request("物料A"), principal));
        assertEquals("WRITE_DISABLED", ex.getErrorCode());
        verify(itemService, never()).createItem(any());
    }
    @Test
    void repeatedCommitCreatesItemOnceAndReplaysReceipt() {
        stubPreflight(PREFLIGHT_HASH);
        AtomicReference<MdmAgentImportCommandDO> inserted = captureInsertedCommand();
        MdmAgentImportReceiptRespVO first = service.commit(request("物料A"), principal);
        when(commandMapper.selectByCommandId(0L, 7L, "cmd-1")).thenReturn(inserted.get());
        MdmAgentImportReceiptRespVO second = service.commit(request("物料A"), principal);
        assertEquals("SUCCEEDED", first.getStatus());
        assertEquals("SUCCEEDED", second.getStatus());
        assertEquals(first.getPreflightHash(), second.getPreflightHash());
        verify(itemService, times(1)).createItem(any());
    }
    @Test
    void sameCommandWithDifferentContentConflicts() {
        when(preflightService.preflight(any(), any())).thenReturn(preflight(PREFLIGHT_HASH));
        AtomicReference<MdmAgentImportCommandDO> inserted = captureInsertedCommand();
        service.commit(request("物料A"), principal);
        when(commandMapper.selectByCommandId(0L, 7L, "cmd-1")).thenReturn(inserted.get());
        when(commandMapper.selectByIdempotencyKey(0L, 7L, "idem-1")).thenReturn(null);
        AgentBridgeException ex = assertThrows(AgentBridgeException.class,
                () -> service.commit(request("物料B"), principal));
        assertEquals("COMMAND_ID_CONFLICT", ex.getErrorCode());
    }
    @Test
    void stalePreflightIsRejected() {
        stubPreflight("c".repeat(64));
        AgentBridgeException ex = assertThrows(AgentBridgeException.class,
                () -> service.commit(request("物料A"), principal));
        assertEquals("PLAN_STALE", ex.getErrorCode());
        verify(commandMapper, never()).insert(any(MdmAgentImportCommandDO.class));
    }
    @Test
    void missingCommandReturnsNotFound() {
        when(commandMapper.selectByCommandId(0L, 7L, "cmd-x")).thenReturn(null);
        AgentBridgeException ex = assertThrows(AgentBridgeException.class,
                () -> service.getCommand("cmd-x", principal));
        assertEquals("COMMAND_NOT_FOUND", ex.getErrorCode());
    }
    private void stubPreflight(String preflightHash) {
        when(preflightService.preflight(any(), any())).thenReturn(preflight(preflightHash));
    }
    private static MdmAgentImportPreflightRespVO preflight(String hash) {
        return MdmAgentImportPreflightRespVO.builder()
                .preflightHash(hash)
                .canCommit(true)
                .rows(List.of(MdmAgentImportPreflightRespVO.Row.builder()
                        .candidateId("c-0").action("CREATE").build()))
                .summary(MdmAgentImportPreflightRespVO.Summary.builder()
                        .total(1).create(1).skip(0).blocked(0).build())
                .build();
    }
    private AtomicReference<MdmAgentImportCommandDO> captureInsertedCommand() {
        AtomicReference<MdmAgentImportCommandDO> holder = new AtomicReference<>();
        doAnswer(invocation -> {
            MdmAgentImportCommandDO command = invocation.getArgument(0);
            command.setId(1L);
            holder.set(command);
            return 1;
        }).when(commandMapper).insert(any(MdmAgentImportCommandDO.class));
        return holder;
    }
    private static MdmAgentImportCommitReqVO request(String name) {
        MdmAgentImportPreflightReqVO.Row row = new MdmAgentImportPreflightReqVO.Row();
        row.setCandidateId("c-0");
        row.setRowNo(1);
        row.setCode("AGENT-E2E-1");
        row.setName(name);
        row.setCategoryCode("RAW");
        row.setUnitMeasureCode("PCS");
        MdmAgentImportCommitReqVO reqVO = new MdmAgentImportCommitReqVO();
        reqVO.setCommandId("cmd-1");
        reqVO.setIdempotencyKey("idem-1");
        reqVO.setAttachmentId(5L);
        reqVO.setSessionId(21L);
        reqVO.setSourceSha256(SOURCE_SHA);
        reqVO.setPreflightHash(PREFLIGHT_HASH);
        reqVO.setProposalRevision("rev-1");
        reqVO.setRows(List.of(row));
        return reqVO;
    }
}
yudao-module-mdm/src/test/java/cn/iocoder/yudao/module/mdm/service/agent/MdmAgentImportPreflightServiceTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,125 @@
package cn.iocoder.yudao.module.mdm.service.agent;
import cn.iocoder.yudao.module.mdm.controller.admin.agent.vo.MdmAgentImportPreflightReqVO;
import cn.iocoder.yudao.module.mdm.controller.admin.agent.vo.MdmAgentImportPreflightRespVO;
import cn.iocoder.yudao.module.mdm.controller.admin.item.vo.MdmItemImportExcelVO;
import cn.iocoder.yudao.module.mdm.controller.admin.item.vo.MdmItemSaveReqVO;
import cn.iocoder.yudao.module.mdm.dal.dataobject.item.MdmItemDO;
import cn.iocoder.yudao.module.mdm.service.item.MdmItemImportContext;
import cn.iocoder.yudao.module.mdm.service.item.MdmItemRowResolution;
import cn.iocoder.yudao.module.mdm.service.item.MdmItemService;
import cn.iocoder.yudao.module.system.framework.agent.AgentPrincipal;
import cn.iocoder.yudao.module.system.service.storage.SystemAgentAttachmentRef;
import cn.iocoder.yudao.module.system.service.storage.SystemAgentImportBridgeService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class MdmAgentImportPreflightServiceTest {
    private static final String SHA = "a".repeat(64);
    @Mock private SystemAgentImportBridgeService bridgeService;
    @Mock private MdmItemService itemService;
    @Mock private MdmAgentImportPolicy policy;
    @InjectMocks private MdmAgentImportPreflightService service;
    private final AgentPrincipal principal = new AgentPrincipal(7L, 0L, "live");
    @Test
    void classifiesCreateSkipAndBlockedRows() {
        stubAttachmentAndPolicy();
        MdmItemDO existing = new MdmItemDO();
        existing.setId(99L);
        when(itemService.getItemListByCode("A")).thenReturn(List.of());
        when(itemService.getItemListByCode("B")).thenReturn(List.of(existing));
        MdmAgentImportPreflightRespVO result = service.preflight(
                request("A", "B", "C"), principal);
        assertEquals(3, result.getSummary().getTotal());
        assertEquals(1, result.getSummary().getCreate());
        assertEquals(1, result.getSummary().getSkip());
        assertEquals(1, result.getSummary().getBlocked());
        assertEquals("CREATE", result.getRows().get(0).getAction());
        assertEquals("SKIP", result.getRows().get(1).getAction());
        assertEquals(99L, result.getRows().get(1).getTargetItemId());
        assertFalse(result.getRows().get(1).getWarnings().isEmpty());
        assertEquals("BLOCKED", result.getRows().get(2).getAction());
        assertEquals("CATEGORY_NOT_FOUND", result.getRows().get(2).getBlockers().get(0).getCode());
        assertFalse(result.getCanCommit());
    }
    @Test
    void duplicateCodeInFileIsBlocked() {
        stubAttachmentAndPolicy();
        when(itemService.getItemListByCode(anyString())).thenReturn(List.of());
        MdmAgentImportPreflightRespVO result = service.preflight(
                request("A", "A"), principal);
        assertEquals(2, result.getSummary().getBlocked());
        assertEquals("DUPLICATE_CODE_IN_FILE", result.getRows().get(1).getBlockers().get(0).getCode());
    }
    @Test
    void hashIsStableAcrossIdenticalRuns() {
        stubAttachmentAndPolicy();
        when(itemService.getItemListByCode("A")).thenReturn(List.of());
        MdmAgentImportPreflightRespVO first = service.preflight(request("A"), principal);
        MdmAgentImportPreflightRespVO second = service.preflight(request("A"), principal);
        assertTrue(first.getCanCommit());
        assertEquals(first.getPreflightHash(), second.getPreflightHash());
    }
    private void stubAttachmentAndPolicy() {
        when(bridgeService.requireOwnedAttachmentRef(5L))
                .thenReturn(new SystemAgentAttachmentRef(5L, 21L, "materials.csv", 100L, Path.of("x")));
        when(bridgeService.sha256OfAttachment(5L)).thenReturn(SHA);
        when(policy.getMaxRows()).thenReturn(5000);
        when(policy.isAllowMesSync()).thenReturn(false);
        MdmItemImportContext context = new MdmItemImportContext(
                Map.of("RAW", 11L), Map.of("RAW", "原料"),
                Map.of("PCS", 22L), Map.of("PCS", "个"));
        when(itemService.buildImportContext(any())).thenReturn(context);
        when(itemService.resolveImportRow(any(MdmItemImportExcelVO.class), any()))
                .thenAnswer(invocation -> MdmItemRowResolution.success(new MdmItemSaveReqVO()));
    }
    private static MdmAgentImportPreflightReqVO request(String... codes) {
        MdmAgentImportPreflightReqVO reqVO = new MdmAgentImportPreflightReqVO();
        reqVO.setAttachmentId(5L);
        reqVO.setSessionId(21L);
        reqVO.setSourceSha256(SHA);
        reqVO.setProposalRevision("rev-1");
        reqVO.setRows(java.util.stream.IntStream.range(0, codes.length).mapToObj(index -> {
            String code = codes[index];
            MdmAgentImportPreflightReqVO.Row row = new MdmAgentImportPreflightReqVO.Row();
            row.setCandidateId("c-" + index);
            row.setRowNo(index + 1);
            row.setCode(code);
            row.setName("物料" + code);
            row.setCategoryCode("C".equals(code) ? "NOPE" : "RAW");
            row.setUnitMeasureCode("PCS");
            return row;
        }).toList());
        return reqVO;
    }
}
yudao-module-mdm/src/test/java/cn/iocoder/yudao/module/mdm/service/item/MdmItemImportParsingTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,89 @@
package cn.iocoder.yudao.module.mdm.service.item;
import cn.iocoder.yudao.module.mdm.controller.admin.item.vo.MdmItemImportExcelVO;
import cn.iocoder.yudao.module.mdm.controller.admin.item.vo.MdmItemImportRespVO;
import cn.iocoder.yudao.module.mdm.controller.admin.item.vo.MdmItemSaveReqVO;
import cn.iocoder.yudao.module.mdm.dal.dataobject.item.MdmItemDO;
import cn.iocoder.yudao.module.mdm.dal.dataobject.unit.MdmUnitMeasureDO;
import cn.iocoder.yudao.module.mdm.dal.mysql.item.MdmItemMapper;
import cn.iocoder.yudao.module.mdm.dal.mysql.unit.MdmUnitMeasureMapper;
import cn.iocoder.yudao.module.mes.api.item.MesMdItemTypeApi;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class MdmItemImportParsingTest {
    @Mock private MdmItemMapper itemMapper;
    @Mock private MdmUnitMeasureMapper unitMeasureMapper;
    @Mock private MesMdItemTypeApi mesMdItemTypeApi;
    @InjectMocks private MdmItemServiceImpl service;
    @Test
    void emptyCodeOrNameFails() {
        MdmItemRowResolution resolution = service.resolveImportRow(
                MdmItemImportExcelVO.builder().name("物料A").build(), emptyContext());
        assertFalse(resolution.isSuccess());
        assertEquals("物料编码和物料名称不能为空", resolution.failureMessage());
    }
    @Test
    void missingCategoryFails() {
        MdmItemRowResolution resolution = service.resolveImportRow(
                MdmItemImportExcelVO.builder().code("A1").name("物料A").categoryCode("NOPE").build(),
                emptyContext());
        assertFalse(resolution.isSuccess());
        assertEquals("物料分类编码不存在:NOPE", resolution.failureMessage());
    }
    @Test
    void missingUnitFails() {
        MdmItemRowResolution resolution = service.resolveImportRow(
                MdmItemImportExcelVO.builder().code("A1").name("物料A").unitMeasureCode("NOPE").build(),
                emptyContext());
        assertFalse(resolution.isSuccess());
        assertEquals("计量单位编码不存在:NOPE", resolution.failureMessage());
    }
    @Test
    void successBuildsSaveReqVO() {
        MdmItemImportContext context = new MdmItemImportContext(
                Map.of("RAW", 11L), Map.of("RAW", "原料"),
                Map.of("PCS", 22L), Map.of("PCS", "个"));
        MdmItemRowResolution resolution = service.resolveImportRow(
                MdmItemImportExcelVO.builder().code("A1").name("物料A")
                        .categoryCode("RAW").unitMeasureCode("PCS").build(), context);
        assertTrue(resolution.isSuccess());
        MdmItemSaveReqVO reqVO = resolution.saveReqVO();
        assertEquals("A1", reqVO.getCode());
        assertEquals(11L, reqVO.getCategoryId());
        assertEquals(22L, reqVO.getUnitMeasureId());
        assertEquals(1, reqVO.getItemType());
        assertFalse(reqVO.getSyncMes());
    }
    @Test
    void existingCodeStillReportsSameMessage() {
        when(unitMeasureMapper.selectList()).thenReturn(List.of());
        when(itemMapper.selectByCode("A1")).thenReturn(new MdmItemDO());
        MdmItemImportRespVO respVO = service.importItemList(List.of(
                MdmItemImportExcelVO.builder().code("A1").name("物料A").build()), false);
        assertEquals(Map.of("A1 ç‰©æ–™A", "物料编码已存在"), respVO.getFailureList());
        assertTrue(respVO.getCreateList().isEmpty());
    }
    private static MdmItemImportContext emptyContext() {
        return new MdmItemImportContext(Map.of(), Map.of(), Map.of(), Map.of());
    }
}
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/agent/SystemAgentImportBridgeController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,112 @@
package cn.iocoder.yudao.module.system.controller.admin.agent;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.module.system.framework.agent.AgentBridgeException;
import cn.iocoder.yudao.module.system.framework.agent.AgentPrincipal;
import cn.iocoder.yudao.module.system.framework.agent.SystemAgentBridgeGuard;
import cn.iocoder.yudao.module.system.service.storage.SystemAgentAttachmentRef;
import cn.iocoder.yudao.module.system.service.storage.SystemAgentImportBridgeService;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import org.springframework.core.io.FileSystemResource;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.Map;
import java.util.Set;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
/** Read-only bridge for material onboarding; business writes live in the MDM module. */
@RestController
@RequestMapping("/agent/bridge")
public class SystemAgentImportBridgeController {
    private static final Set<String> ACTIONS = Set.of("material_import.prepare", "material_import.read",
            "material_import.review", "material_import.preflight", "material_import.commit",
            "material_import.read_receipt");
    @Resource
    private SystemAgentImportBridgeService bridgeService;
    @Resource
    private SystemAgentBridgeGuard bridgeGuard;
    @PostMapping("/import-sessions")
    @PreAuthorize("@ss.hasPermission('mdm:item:import')")
    public CommonResult<Long> createSession() {
        return success(bridgeService.createSession());
    }
    @PostMapping("/import-sessions/{sessionId}/attachments")
    @PreAuthorize("@ss.hasPermission('mdm:item:import')")
    public CommonResult<Long> bindAttachment(@PathVariable Long sessionId, @Valid @RequestBody BindRequest request) {
        return success(bridgeService.bindAttachment(sessionId, request.getBlobId()));
    }
    @PostMapping("/authorize")
    @PreAuthorize("@ss.hasPermission('mdm:item:import')")
    public CommonResult<Map<String, String>> authorize(
            @RequestHeader(value = "X-Agent-Service-Token", required = false) String serviceToken,
            @Valid @RequestBody AuthorizeRequest request) {
        AgentPrincipal principal = bridgeGuard.require(serviceToken, SystemAgentBridgeGuard.PERMISSION_ITEM_IMPORT);
        if (!ACTIONS.contains(request.getAction())) {
            throw new AgentBridgeException(HttpStatus.FORBIDDEN, "ACTION_FORBIDDEN", "不支持的智能体操作");
        }
        if (requiresAttachmentOwnership(request.getAction())) {
            bridgeService.requireOwnedAttachmentRef(parseId(request.getResource()));
        }
        // read/review/read_receipt çš„任务 UUID æ‰€æœ‰æƒç”± Python checkpoint ä½œç”¨åŸŸæ ¸éªŒï¼›æœ¬ç«¯é‡æŸ¥å½“前按钮权限。
        return success(Map.of("user_id", String.valueOf(principal.userId()),
                "tenant_id", String.valueOf(principal.tenantId()),
                "policy_revision", principal.policyRevision()));
    }
    @GetMapping("/attachments/{attachmentId}")
    @PreAuthorize("@ss.hasPermission('mdm:item:import')")
    public ResponseEntity<FileSystemResource> downloadAttachment(
            @RequestHeader(value = "X-Agent-Service-Token", required = false) String serviceToken,
            @PathVariable Long attachmentId) {
        bridgeGuard.require(serviceToken, SystemAgentBridgeGuard.PERMISSION_ITEM_IMPORT);
        SystemAgentAttachmentRef ref = bridgeService.requireOwnedAttachmentRef(attachmentId);
        return ResponseEntity.ok().contentType(MediaType.APPLICATION_OCTET_STREAM)
                .contentLength(ref.byteSize())
                .header("X-Agent-Filename-B64", Base64.getEncoder().encodeToString(
                        ref.originalFilename().getBytes(StandardCharsets.UTF_8)))
                .body(new FileSystemResource(ref.path()));
    }
    private static boolean requiresAttachmentOwnership(String action) {
        return "material_import.prepare".equals(action)
                || "material_import.preflight".equals(action)
                || "material_import.commit".equals(action);
    }
    private Long parseId(String value) {
        try {
            return Long.valueOf(value);
        } catch (NumberFormatException ex) {
            throw new AgentBridgeException(HttpStatus.FORBIDDEN, "ATTACHMENT_FORBIDDEN", "附件不可访问");
        }
    }
    @Data
    public static class BindRequest {
        @NotNull
        private Long blobId;
    }
    @Data
    public static class AuthorizeRequest {
        @NotNull
        private String action;
        @NotNull
        private String resource;
    }
}
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/storage/SystemStorageAttachmentController.java
@@ -36,6 +36,10 @@
    @PreAuthorize("@ss.hasPermission('system:storage-attachment:query')")
    public CommonResult<List<SystemStorageBlobRespVO>> listAttachments(
            @Valid SystemStorageAttachmentListReqVO reqVO) {
        if ("agent_import_session".equals(reqVO.getRecordType())) {
            throw new org.springframework.web.server.ResponseStatusException(
                    org.springframework.http.HttpStatus.FORBIDDEN, "智能体补录附件请使用补录会话接口查询");
        }
        return success(storageAttachmentService.listAttachments(reqVO));
    }
@@ -44,6 +48,10 @@
    @PreAuthorize("@ss.hasPermission('system:storage-attachment:create')")
    public CommonResult<Boolean> bindAttachments(
            @Valid @RequestBody SystemStorageAttachmentSaveReqVO reqVO) {
        if ("agent_import_session".equals(reqVO.getRecordType())) {
            throw new org.springframework.web.server.ResponseStatusException(
                    org.springframework.http.HttpStatus.FORBIDDEN, "智能体补录附件请使用补录会话绑定接口");
        }
        storageAttachmentService.bindAttachments(reqVO);
        return success(true);
    }
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/dataobject/storage/SystemAgentImportSessionDO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,17 @@
package cn.iocoder.yudao.module.system.dal.dataobject.storage;
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("system_agent_import_session")
public class SystemAgentImportSessionDO extends TenantBaseDO {
    @TableId
    private Long id;
    private Long ownerUserId;
    private String status;
}
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/dal/mysql/storage/SystemAgentImportSessionMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,9 @@
package cn.iocoder.yudao.module.system.dal.mysql.storage;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.module.system.dal.dataobject.storage.SystemAgentImportSessionDO;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SystemAgentImportSessionMapper extends BaseMapperX<SystemAgentImportSessionDO> {
}
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/enums/storage/StorageRecordTypeEnum.java
@@ -16,6 +16,7 @@
    // ===== é€šç”¨ =====
    COMMON_FILE("common_file", "通用文件"),
    AGENT_IMPORT_SESSION("agent_import_session", "智能体补录会话"),
    // ===== ç”Ÿäº§ =====
    PRODUCTION_ORDER("production_order", "生产工单"),
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/framework/agent/AgentBridgeException.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,38 @@
package cn.iocoder.yudao.module.system.framework.agent;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import java.util.Map;
/** æ¡¥æŽ¥æŽ¥å£çš„业务异常,携带真实 HTTP çŠ¶æ€ã€ç¨³å®šé”™è¯¯ç ä¸Žå¯å±•ç¤ºæ˜Žç»†ã€‚ */
public class AgentBridgeException extends ResponseStatusException {
    private final String errorCode;
    private final boolean retryable;
    private final Map<String, Object> details;
    public AgentBridgeException(HttpStatus status, String errorCode, String message) {
        this(status, errorCode, message, false, null);
    }
    public AgentBridgeException(HttpStatus status, String errorCode, String message, boolean retryable,
                                Map<String, Object> details) {
        super(status, message);
        this.errorCode = errorCode;
        this.retryable = retryable;
        this.details = details;
    }
    public String getErrorCode() {
        return errorCode;
    }
    public boolean isRetryable() {
        return retryable;
    }
    public Map<String, Object> getDetails() {
        return details;
    }
}
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/framework/agent/AgentBridgeExceptionHandler.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,106 @@
package cn.iocoder.yudao.module.system.framework.agent;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.server.ResponseStatusException;
import java.util.LinkedHashMap;
import java.util.Map;
/**
 * æ™ºèƒ½ä½“桥接专用异常映射:状态码必须真实,Python æ‰èƒ½åŒºåˆ† 401/403/409/422。
 * ä»…作用于 agent åŒ…,避免影响其他管理端接口的既有响应格式。
 */
@Slf4j
@Order(Ordered.HIGHEST_PRECEDENCE)
@RestControllerAdvice(basePackages = {
        "cn.iocoder.yudao.module.system.controller.admin.agent",
        "cn.iocoder.yudao.module.mdm.controller.admin.agent"
})
public class AgentBridgeExceptionHandler {
    @ExceptionHandler(AgentBridgeException.class)
    public ResponseEntity<CommonResult<Map<String, Object>>> handleAgentBridge(AgentBridgeException ex) {
        return build(ex.getStatusCode().value(), ex.getReason(), ex.getErrorCode(),
                ex.isRetryable(), ex.getDetails());
    }
    @ExceptionHandler(ResponseStatusException.class)
    public ResponseEntity<CommonResult<Map<String, Object>>> handleResponseStatus(ResponseStatusException ex) {
        int status = ex.getStatusCode().value();
        return build(status, ex.getReason(), defaultCode(status), false, null);
    }
    @ExceptionHandler(AccessDeniedException.class)
    public ResponseEntity<CommonResult<Map<String, Object>>> handleAccessDenied(AccessDeniedException ex) {
        return build(HttpStatus.FORBIDDEN.value(), "当前身份缺少物料导入权限(mdm:item:import)",
                "FORBIDDEN", false, null);
    }
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<CommonResult<Map<String, Object>>> handleInvalidArgument(MethodArgumentNotValidException ex) {
        FieldError fieldError = ex.getBindingResult().getFieldError();
        String message = fieldError == null ? "请求参数不合法"
                : fieldError.getField() + ":" + fieldError.getDefaultMessage();
        return build(HttpStatus.BAD_REQUEST.value(), message, "BAD_REQUEST", false, null);
    }
    @ExceptionHandler(HttpMessageNotReadableException.class)
    public ResponseEntity<CommonResult<Map<String, Object>>> handleUnreadable(HttpMessageNotReadableException ex) {
        return build(HttpStatus.BAD_REQUEST.value(), "请求体格式不正确", "BAD_REQUEST", false, null);
    }
    @ExceptionHandler(Exception.class)
    public ResponseEntity<CommonResult<Map<String, Object>>> handleUnexpected(Exception ex) {
        log.error("[handleUnexpected][智能体桥接未预期异常]", ex);
        return build(HttpStatus.INTERNAL_SERVER_ERROR.value(), "智能体桥接处理失败", "INTERNAL_ERROR", true, null);
    }
    private ResponseEntity<CommonResult<Map<String, Object>>> build(
            int status, String message, String errorCode, boolean retryable, Map<String, Object> details) {
        Map<String, Object> data = new LinkedHashMap<>();
        data.put("error_code", errorCode);
        data.put("retryable", retryable);
        data.put("details", details == null ? Map.of() : details);
        CommonResult<Map<String, Object>> result = CommonResult.error(status,
                message == null ? defaultMessage(status) : message);
        result.setData(data);
        return ResponseEntity.status(status).body(result);
    }
    private static String defaultCode(int status) {
        return switch (status) {
            case 401 -> "UNAUTHORIZED";
            case 403 -> "FORBIDDEN";
            case 404 -> "NOT_FOUND";
            case 409 -> "CONFLICT";
            case 413 -> "FILE_TOO_LARGE";
            case 422 -> "VALIDATION_FAILED";
            case 503 -> "SERVICE_UNAVAILABLE";
            default -> status >= 500 ? "INTERNAL_ERROR" : "BAD_REQUEST";
        };
    }
    private static String defaultMessage(int status) {
        return switch (status) {
            case 401 -> "未登录或登录已过期";
            case 403 -> "当前身份无权访问";
            case 404 -> "资源不存在";
            case 409 -> "请求冲突";
            case 413 -> "文件超过大小限制";
            case 422 -> "请求数据未通过校验";
            case 503 -> "服务暂不可用";
            default -> status >= 500 ? "服务处理失败" : "请求不合法";
        };
    }
}
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/framework/agent/AgentPrincipal.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,5 @@
package cn.iocoder.yudao.module.system.framework.agent;
/** æ™ºèƒ½ä½“调用经 Java çŽ°æŸ¥åŽçš„èº«ä»½ä¸Šä¸‹æ–‡ï¼Œä¸æ¥è‡ªå®¢æˆ·ç«¯è‡ªæŠ¥ã€‚ */
public record AgentPrincipal(Long userId, Long tenantId, String policyRevision) {
}
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/framework/agent/SystemAgentBridgeGuard.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,48 @@
package cn.iocoder.yudao.module.system.framework.agent;
import cn.iocoder.yudao.framework.security.core.service.SecurityFrameworkService;
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
/** æ¡¥æŽ¥ç»Ÿä¸€é‰´æƒï¼šæœåŠ¡ä»¤ç‰Œ + å½“前登录用户 + ä¸šåŠ¡æƒé™ï¼Œç¼ºä¸€ä¸å¯ã€‚ */
@Component
public class SystemAgentBridgeGuard {
    public static final String PERMISSION_ITEM_IMPORT = "mdm:item:import";
    @Value("${yudao.agent.bridge-service-token:}")
    private String bridgeServiceToken;
    @Resource
    private SecurityFrameworkService securityFrameworkService;
    public void verifyServiceToken(String provided) {
        if (bridgeServiceToken == null || bridgeServiceToken.isBlank() || provided == null
                || !MessageDigest.isEqual(bridgeServiceToken.getBytes(StandardCharsets.UTF_8),
                provided.getBytes(StandardCharsets.UTF_8))) {
            throw new AgentBridgeException(HttpStatus.FORBIDDEN, "SERVICE_TOKEN_INVALID",
                    "智能体服务身份无效,请检查服务密钥配置");
        }
    }
    public AgentPrincipal require(String providedServiceToken, String permission) {
        verifyServiceToken(providedServiceToken);
        Long userId = SecurityFrameworkUtils.getLoginUserId();
        if (userId == null) {
            throw new AgentBridgeException(HttpStatus.UNAUTHORIZED, "UNAUTHORIZED", "未登录或登录已过期");
        }
        if (!securityFrameworkService.hasPermission(permission)) {
            throw new AgentBridgeException(HttpStatus.FORBIDDEN, "FORBIDDEN",
                    "当前用户缺少物料导入权限(" + permission + ")");
        }
        Long tenantId = TenantContextHolder.getTenantId();
        return new AgentPrincipal(userId, tenantId == null ? 0L : tenantId, "live");
    }
}
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/storage/SystemAgentAttachmentRef.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,8 @@
package cn.iocoder.yudao.module.system.service.storage;
import java.nio.file.Path;
/** å·²é€šè¿‡æ‰€æœ‰æƒæ ¡éªŒçš„补录附件引用;path ä»…供桥接读取,不对外返回。 */
public record SystemAgentAttachmentRef(Long attachmentId, Long sessionId, String originalFilename,
                                       long byteSize, Path path) {
}
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/storage/SystemAgentImportBridgeService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,152 @@
package cn.iocoder.yudao.module.system.service.storage;
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
import cn.iocoder.yudao.module.system.controller.admin.storage.vo.SystemStorageAttachmentSaveReqVO;
import cn.iocoder.yudao.module.system.dal.dataobject.storage.SystemAgentImportSessionDO;
import cn.iocoder.yudao.module.system.dal.dataobject.storage.SystemStorageAttachmentDO;
import cn.iocoder.yudao.module.system.dal.dataobject.storage.SystemStorageBlobDO;
import cn.iocoder.yudao.module.system.dal.mysql.storage.SystemAgentImportSessionMapper;
import cn.iocoder.yudao.module.system.dal.mysql.storage.SystemStorageAttachmentMapper;
import cn.iocoder.yudao.module.system.framework.agent.AgentBridgeException;
import cn.iocoder.yudao.module.system.framework.storage.config.StorageProperties;
import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.List;
import java.util.Objects;
@Service
public class SystemAgentImportBridgeService {
    public static final long MAX_FILE_BYTES = 8L * 1024 * 1024;
    public static final String RECORD_TYPE = "agent_import_session";
    @Resource
    private SystemAgentImportSessionMapper sessionMapper;
    @Resource
    private SystemStorageAttachmentMapper attachmentMapper;
    @Resource
    private SystemStorageBlobService blobService;
    @Resource
    private SystemStorageAttachmentService attachmentService;
    @Resource
    private StorageProperties storageProperties;
    @Transactional(rollbackFor = Exception.class)
    public Long createSession() {
        Long userId = SecurityFrameworkUtils.getLoginUserId();
        if (userId == null) {
            throw new AgentBridgeException(HttpStatus.UNAUTHORIZED, "UNAUTHORIZED", "未登录或登录已过期");
        }
        SystemAgentImportSessionDO session = new SystemAgentImportSessionDO();
        session.setOwnerUserId(userId);
        session.setStatus("active");
        sessionMapper.insert(session);
        return session.getId();
    }
    @Transactional(rollbackFor = Exception.class)
    public Long bindAttachment(Long sessionId, Long blobId) {
        requireOwnedSession(sessionId);
        SystemStorageBlobDO blob = blobService.getStorageBlob(blobId);
        requireOwnedBlob(blob);
        SystemStorageAttachmentSaveReqVO req = new SystemStorageAttachmentSaveReqVO();
        req.setApplication("file");
        req.setRecordType(RECORD_TYPE);
        req.setRecordId(sessionId);
        SystemStorageAttachmentSaveReqVO.BlobItem item = new SystemStorageAttachmentSaveReqVO.BlobItem();
        item.setBlobId(blobId);
        req.setBlobItems(List.of(item));
        attachmentService.bindAttachments(req);
        // é€šç”¨ bind å¯¹åŒä¸€ä¼šè¯æ˜¯æ›¿æ¢è¯­ä¹‰ï¼šé‡å¤ç»‘定时旧附件行会被删除。
        return attachmentMapper.selectListByApplicationAndRecordTypeAndRecordId("file", RECORD_TYPE, sessionId)
                .stream().filter(row -> blobId.equals(row.getStorageBlobId()))
                .map(SystemStorageAttachmentDO::getId).findFirst()
                .orElseThrow(() -> new AgentBridgeException(HttpStatus.CONFLICT, "ATTACHMENT_BIND_FAILED",
                        "附件绑定失败,请重新上传后再试", true, null));
    }
    public SystemAgentAttachmentRef requireOwnedAttachmentRef(Long attachmentId) {
        SystemStorageAttachmentDO attachment = attachmentMapper.selectById(attachmentId);
        if (attachment == null) {
            throw new AgentBridgeException(HttpStatus.CONFLICT, "ATTACHMENT_REPLACED",
                    "附件已失效(可能被重新上传替换),请重新上传后再试", true, null);
        }
        if (!RECORD_TYPE.equals(attachment.getRecordType())) {
            throw new AgentBridgeException(HttpStatus.FORBIDDEN, "ATTACHMENT_FORBIDDEN", "附件不可访问");
        }
        requireOwnedSession(attachment.getRecordId());
        SystemStorageBlobDO blob = blobService.getStorageBlob(attachment.getStorageBlobId());
        requireOwnedBlob(blob);
        Path file = resolveBlobFile(blob);
        String filename = blob.getOriginalFilename();
        if (filename == null || filename.isBlank()) {
            filename = "attachment";
        }
        return new SystemAgentAttachmentRef(attachmentId, attachment.getRecordId(), filename,
                blob.getByteSize(), file);
    }
    public String sha256OfAttachment(Long attachmentId) {
        SystemAgentAttachmentRef ref = requireOwnedAttachmentRef(attachmentId);
        try (InputStream in = Files.newInputStream(ref.path())) {
            MessageDigest digest = MessageDigest.getInstance("SHA-256");
            byte[] buffer = new byte[8192];
            long total = 0;
            int read;
            while ((read = in.read(buffer)) != -1) {
                total += read;
                if (total > MAX_FILE_BYTES) {
                    throw new AgentBridgeException(HttpStatus.FORBIDDEN, "FILE_TOO_LARGE", "附件超过 8 MiB");
                }
                digest.update(buffer, 0, read);
            }
            return HexFormat.of().formatHex(digest.digest());
        } catch (IOException ex) {
            throw new AgentBridgeException(HttpStatus.FORBIDDEN, "ATTACHMENT_UNREADABLE", "附件不可访问");
        } catch (NoSuchAlgorithmException ex) {
            throw new IllegalStateException("SHA-256 ä¸å¯ç”¨", ex);
        }
    }
    private Path resolveBlobFile(SystemStorageBlobDO blob) {
        try {
            Path root = Path.of(storageProperties.getPath()).toRealPath();
            Path file = root.resolve(blob.getPath() == null ? "" : blob.getPath())
                    .resolve(blob.getUidFilename()).toRealPath();
            if (!file.startsWith(root) || !Files.isRegularFile(file)
                    || Files.size(file) > MAX_FILE_BYTES) {
                throw new AgentBridgeException(HttpStatus.FORBIDDEN, "ATTACHMENT_UNREADABLE", "附件不可访问");
            }
            return file;
        } catch (IOException ex) {
            throw new AgentBridgeException(HttpStatus.FORBIDDEN, "ATTACHMENT_UNREADABLE", "附件不可访问");
        }
    }
    private void requireOwnedSession(Long sessionId) {
        SystemAgentImportSessionDO session = sessionMapper.selectById(sessionId);
        Long userId = SecurityFrameworkUtils.getLoginUserId();
        if (session == null || userId == null || !Objects.equals(session.getOwnerUserId(), userId)
                || !"active".equals(session.getStatus())) {
            throw new AgentBridgeException(HttpStatus.FORBIDDEN, "SESSION_FORBIDDEN", "补录会话不可访问");
        }
    }
    private void requireOwnedBlob(SystemStorageBlobDO blob) {
        Long userId = SecurityFrameworkUtils.getLoginUserId();
        if (blob == null || userId == null || !String.valueOf(userId).equals(blob.getCreator())
                || blob.getByteSize() == null || blob.getByteSize() > MAX_FILE_BYTES) {
            throw new AgentBridgeException(HttpStatus.FORBIDDEN, "BLOB_FORBIDDEN", "文件不可访问或超过 8 MiB");
        }
    }
}
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/storage/SystemStorageAttachmentService.java
@@ -33,6 +33,7 @@
     */
    void deleteAttachments(List<Long> ids);
    /**
     * åˆ é™¤æŒ‡å®šç”¨é€”、指定业务记录下的附件关系
     */
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/storage/SystemStorageAttachmentServiceImpl.java
@@ -6,6 +6,7 @@
import cn.iocoder.yudao.module.system.enums.storage.StorageApplicationTypeEnum;
import cn.iocoder.yudao.module.system.enums.storage.StorageRecordTypeEnum;
import cn.iocoder.yudao.module.system.util.storage.StorageFileUtil;
import cn.iocoder.yudao.module.system.dal.mysql.storage.SystemStorageAttachmentMapper;
import jakarta.annotation.Resource;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@@ -23,6 +24,7 @@
public class SystemStorageAttachmentServiceImpl implements SystemStorageAttachmentService {
    private final StorageFileUtil fileUtil;
    private final SystemStorageAttachmentMapper attachmentMapper;
    @Override
    @Transactional(rollbackFor = Exception.class)
@@ -47,9 +49,21 @@
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void deleteAttachments(List<Long> ids) {
        assertNoAgentImportAttachments(ids);
        fileUtil.deleteStorageAttachmentsByStorageAttachmentIds(ids);
    }
    private void assertNoAgentImportAttachments(List<Long> ids) {
        if (ids == null || ids.isEmpty()) {
            return;
        }
        if (attachmentMapper.selectBatchIds(ids).stream()
                .anyMatch(item -> "agent_import_session".equals(item.getRecordType()))) {
            throw new org.springframework.web.server.ResponseStatusException(
                    org.springframework.http.HttpStatus.FORBIDDEN, "智能体补录附件请使用补录会话接口管理");
        }
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void deleteAttachmentsByApplicationAndRecordTypeAndRecordId(String application, String recordType, Long recordId) {
yudao-module-system/src/test/java/cn/iocoder/yudao/module/system/service/storage/SystemAgentImportBridgeServiceTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,102 @@
package cn.iocoder.yudao.module.system.service.storage;
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
import cn.iocoder.yudao.module.system.dal.dataobject.storage.SystemAgentImportSessionDO;
import cn.iocoder.yudao.module.system.dal.dataobject.storage.SystemStorageAttachmentDO;
import cn.iocoder.yudao.module.system.dal.dataobject.storage.SystemStorageBlobDO;
import cn.iocoder.yudao.module.system.dal.mysql.storage.SystemAgentImportSessionMapper;
import cn.iocoder.yudao.module.system.dal.mysql.storage.SystemStorageAttachmentMapper;
import cn.iocoder.yudao.module.system.framework.agent.AgentBridgeException;
import cn.iocoder.yudao.module.system.framework.storage.config.StorageProperties;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.junit.jupiter.MockitoExtension;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class SystemAgentImportBridgeServiceTest {
    @Mock private SystemAgentImportSessionMapper sessionMapper;
    @Mock private SystemStorageAttachmentMapper attachmentMapper;
    @Mock private SystemStorageBlobService blobService;
    @Mock private SystemStorageAttachmentService attachmentService;
    @Mock private StorageProperties storageProperties;
    @InjectMocks private SystemAgentImportBridgeService service;
    @Test
    void foreignSessionCannotReadAttachment() {
        SystemStorageAttachmentDO attachment = new SystemStorageAttachmentDO();
        attachment.setRecordType("agent_import_session");
        attachment.setRecordId(21L);
        attachment.setStorageBlobId(90L);
        when(attachmentMapper.selectById(5L)).thenReturn(attachment);
        SystemAgentImportSessionDO session = new SystemAgentImportSessionDO();
        session.setOwnerUserId(8L);
        session.setStatus("active");
        when(sessionMapper.selectById(21L)).thenReturn(session);
        try (MockedStatic<SecurityFrameworkUtils> auth = mockStatic(SecurityFrameworkUtils.class)) {
            auth.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(7L);
            assertThrows(AgentBridgeException.class, () -> service.requireOwnedAttachmentRef(5L));
        }
        verifyNoInteractions(blobService);
    }
    @Test
    void ownSessionCannotBindForeignUpload() {
        SystemAgentImportSessionDO session = new SystemAgentImportSessionDO();
        session.setOwnerUserId(7L);
        session.setStatus("active");
        when(sessionMapper.selectById(21L)).thenReturn(session);
        SystemStorageBlobDO blob = new SystemStorageBlobDO();
        blob.setCreator("8");
        blob.setByteSize(100L);
        when(blobService.getStorageBlob(90L)).thenReturn(blob);
        try (MockedStatic<SecurityFrameworkUtils> auth = mockStatic(SecurityFrameworkUtils.class)) {
            auth.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(7L);
            assertThrows(AgentBridgeException.class, () -> service.bindAttachment(21L, 90L));
        }
        verifyNoInteractions(attachmentService);
    }
    @Test
    void ownerReadsBoundAttachment(@TempDir Path tempDir) throws Exception {
        Path blobDir = Files.createDirectories(tempDir.resolve("2026/1022"));
        byte[] content = "code,name\nA,物料A\n".getBytes(java.nio.charset.StandardCharsets.UTF_8);
        Files.write(blobDir.resolve("uuid-materials.csv"), content);
        when(storageProperties.getPath()).thenReturn(tempDir.toString());
        SystemStorageAttachmentDO attachment = new SystemStorageAttachmentDO();
        attachment.setRecordType("agent_import_session");
        attachment.setRecordId(21L);
        attachment.setStorageBlobId(90L);
        when(attachmentMapper.selectById(5L)).thenReturn(attachment);
        SystemAgentImportSessionDO session = new SystemAgentImportSessionDO();
        session.setOwnerUserId(7L);
        session.setStatus("active");
        when(sessionMapper.selectById(21L)).thenReturn(session);
        SystemStorageBlobDO blob = new SystemStorageBlobDO();
        blob.setCreator("7");
        blob.setByteSize((long) content.length);
        blob.setOriginalFilename("materials.csv");
        blob.setPath("2026/1022");
        blob.setUidFilename("uuid-materials.csv");
        when(blobService.getStorageBlob(90L)).thenReturn(blob);
        try (MockedStatic<SecurityFrameworkUtils> auth = mockStatic(SecurityFrameworkUtils.class)) {
            auth.when(SecurityFrameworkUtils::getLoginUserId).thenReturn(7L);
            SystemAgentAttachmentRef ref = service.requireOwnedAttachmentRef(5L);
            assertEquals("materials.csv", ref.originalFilename());
            assertEquals(21L, ref.sessionId());
            assertEquals(64, service.sha256OfAttachment(5L).length());
        }
    }
}
yudao-server/src/main/resources/application-local.yaml
@@ -3,6 +3,9 @@
--- #################### æ•°æ®åº“相关配置 ####################
spring:
  config:
    # æœ¬åœ°æ¡¥æŽ¥å¯†é’¥æ”¾åœ¨ä»“库根目录的 Git å¿½ç•¥æ–‡ä»¶ä¸­ï¼Œä¸æ‰“è¿› Java åŒ…。
    import: optional:file:${MOM_AGENT_JAVA_CONFIG:./application-my.yaml}
  autoconfigure:
    # noinspection SpringBootApplicationYaml
    exclude:
@@ -65,7 +68,7 @@
      primary: master
      datasource:
        master:
          url: jdbc:mysql://localhost:3306/mom-hm?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true&rewriteBatchedStatements=true # MySQL Connector/J 8.X è¿žæŽ¥çš„示例
          url: jdbc:mysql://localhost:3306/ruoyi-vue-pro?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true&rewriteBatchedStatements=true # æœ¬åœ°è”调测试库
          username: root
          password: 123456
@@ -186,6 +189,11 @@
    enable: false # æœ¬åœ°çŽ¯å¢ƒï¼Œæš‚æ—¶å…³é—­å›¾ç‰‡éªŒè¯ç ï¼Œæ–¹ä¾¿ç™»å½•ç­‰æŽ¥å£çš„æµ‹è¯•ï¼›
  security:
    mock-enable: true
  agent:
    bridge:
      write-enabled: false # æ™ºèƒ½ä½“物料导入写开关;仅联调验收时临时打开
      allow-mes-sync: false
      max-rows: 5000
  access-log: # è®¿é—®æ—¥å¿—的配置项
    enable: false
  demo: false # å…³é—­æ¼”示模式