From e818f5991109bb5bf081b584aa3f88d0100812ff Mon Sep 17 00:00:00 2001 From: my_local_kimi_dev2 Date: Thu, 5 Nov 2026 18:36:06 +0800 Subject: [PATCH] =?UTF-8?q?feat(mcp):=20=E6=96=B0=E5=A2=9E=E4=BB=93?= =?UTF-8?q?=E5=BA=93=E5=87=BA=E5=85=A5=E5=BA=93=E6=98=8E=E7=BB=86=E4=B8=8E?= =?UTF-8?q?=E5=9C=A8=E5=BA=93=E7=89=A9=E5=93=81=E6=9F=A5=E8=AF=A2=E4=B8=89?= =?UTF-8?q?=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - warehouse.out.details.list.query / warehouse.in.details.list.query / warehouse.hardstock.list.query - 出库明细硬过滤 query_flag=0 + order_state=3,入库明细 order_state=3,hardstock 固定 stock_state=0 - dateType 三态 + 缺省最近180天、跨度上限180天;固定排序不提供排序参数 - PermissionService 新增 details 级行权限变体与 HardSockLife 变体(对齐 getWarehouseAuth) - REQ-20261105-0020 --- xiaoqu-agent-service/AGENTS.md | 2 +- .../doc/agent-mcp-tools-reference.md | 49 ++ .../doc/warehouse-details-stock-mcp-design.md | 96 ++++ xiaoqu-agent-service/sql/seed-dev.sql | 2 +- .../me/iiv/agent/entity/m0/HardSockLife.java | 63 +++ .../agent/entity/m2/WarehouseInDetails.java | 42 +- .../agent/entity/m2/WarehouseOutDetails.java | 60 ++- .../agent/mapper/m0/HardSockLifeMapper.java | 7 + .../mcp/tool/WarehouseDetailsStatisTools.java | 484 ++++++++++++++++++ .../agent/permission/PermissionService.java | 98 ++++ .../tool/WarehouseDetailsStatisToolsTest.java | 318 ++++++++++++ ...nServiceWarehouseDetailsRowFilterTest.java | 164 ++++++ 12 files changed, 1368 insertions(+), 17 deletions(-) create mode 100644 xiaoqu-agent-service/doc/warehouse-details-stock-mcp-design.md create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m0/HardSockLife.java create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m0/HardSockLifeMapper.java create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/WarehouseDetailsStatisTools.java create mode 100644 xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/WarehouseDetailsStatisToolsTest.java create mode 100644 xiaoqu-agent-service/src/test/java/me/iiv/agent/permission/PermissionServiceWarehouseDetailsRowFilterTest.java diff --git a/xiaoqu-agent-service/AGENTS.md b/xiaoqu-agent-service/AGENTS.md index 2d77e8b..293e79c 100644 --- a/xiaoqu-agent-service/AGENTS.md +++ b/xiaoqu-agent-service/AGENTS.md @@ -3,7 +3,7 @@ ## ai-proj 关联信息 - **ai-proj 项目 ID:224**(项目名 xiaoqu_agent_service)。本仓所有需求/任务创建后应挂到该项目,关联任务前确保任务也在项目 224。 -- 近期需求示例:REQ-20261012-0004(id 5977,空感 toilet.airquality list/detail/metrics/statelog 4 工具,已部署生产+冒烟通过)→ 任务 23488;REQ-20261013-0006(id 5988,代理角色用户查询工具 agent.user.list.query,approved)→ 任务 23566;REQ-20261012-0002(id 5975,客户换纸记录工具 district.paper.trace.list.query,released)→ 任务 23486;REQ-20260924-0009(id 5806,人流量统计工具+统计类限一个月,implementation 完成)→ 任务 22405;REQ-20260924-0003(id 5800,设备生命周期查询工具,implementation 完成)→ 任务 22391;REQ-20260921-0007(id 5778,设备电量消息查询工具,released)→ 任务 22306;REQ-20260919-0003(id 5747,出入库工具复查修复,released)→ 任务 22123。 +- 近期需求示例:REQ-20261105-0020(id 6390,仓库出入库明细+在库物品 warehouse.out/in.details.list.query、warehouse.hardstock.list.query 3 工具,approved)→ 任务 26101;REQ-20261104-0015(id 6366,清机记录 hardware.cleanlog.list.query,已上线生产+冒烟通过)→ 任务 25998;REQ-20261012-0004(id 5977,空感 toilet.airquality list/detail/metrics/statelog 4 工具,已部署生产+冒烟通过)→ 任务 23488;REQ-20261013-0006(id 5988,代理角色用户查询工具 agent.user.list.query,approved)→ 任务 23566;REQ-20261012-0002(id 5975,客户换纸记录工具 district.paper.trace.list.query,released)→ 任务 23486;REQ-20260924-0009(id 5806,人流量统计工具+统计类限一个月,implementation 完成)→ 任务 22405;REQ-20260924-0003(id 5800,设备生命周期查询工具,implementation 完成)→ 任务 22391;REQ-20260921-0007(id 5778,设备电量消息查询工具,released)→ 任务 22306;REQ-20260919-0003(id 5747,出入库工具复查修复,released)→ 任务 22123。 ## ai-proj MCP 已知限制与真实契约 diff --git a/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md b/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md index 885877a..e21b3e6 100644 --- a/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md +++ b/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md @@ -1105,6 +1105,55 @@ --- +## 46. warehouse.out.details.list.query + +出库明细分页查询(m2 `warehouse_out_details`,纯 MySQL),对应老 `/warehouseOutStatis/listByPage`。**只查已完成订单**:硬过滤 `soft_delete=0` + `query_flag=0` + `order_state=3`(王恒 2026-11-05 拍板,不下发 orderState 参数)。固定 `out_date DESC, id DESC`。 + +- 分类:warehouse | 类型:读 +- 入参(全部可选):`dateType`(0=出库日期默认 1=创建时间 2=确认出库时间)+ `startDate`/`endDate` + (yyyy-MM-dd,缺省最近 180 天,跨度上限 180 天);`warehouseId`/`warehouseTypeId`/`outOrderNo`/ + `allocationOrderNo`/`mallOrderNo`/`materialNo`/`pickupOrderNo`/`agentId`/`useCustomerType`/`useCustomerId` + (精确);`snCode`/`materielFullName`/`specifications`/`useCustomerName`/`deliveryOrderNo`/`installOrderNo` + (模糊);`materielIndexIds`(IN);`page`/`rows` 默认 1/20 上限 100(超出截断 + notice)。 +- 返回 `total/page/rows/[notice]/records`,record 含 id/outOrderNo/warehouseOutId/agentId+agentName/ + warehouseId+warehouseName/warehouseTypeId+warehouseTypeName/物料四件套/snCode/outCount/price/allMoney/ + outDate/orderState+orderStateText/applyUserName/outConfirmTime+outConfirmUserName/ + useCustomerType+useCustomerTypeText/useCustomerName/allocationOrderNo/mallOrderNo/deliveryOrderNo/ + installOrderNo/pickupOrderNo/createTime。不返回 schedulePeople(拍板)。 +- 行级权限:`applyWarehouseOutDetailsRowFilter`——总部/服务主体全量;代理身份 = 代理范围明细行 OR + 可见仓库(`warehouse.agent_id/manager_agent_id` 命中)涉及行 OR 本人参与审批(`warehouse_out_audit_process`)主单下行。 + +## 47. warehouse.in.details.list.query + +入库明细分页查询(m2 `warehouse_in_details`,纯 MySQL),对应老 `/warehouseInStatis/listByPage`。**只查已完成订单**:硬过滤 `soft_delete=0` + `order_state=3`(入库明细无 query_flag)。固定 `in_date DESC, id DESC`。 + +- 分类:warehouse | 类型:读 +- 入参与出库明细对称:`dateType`(0=入库日期默认 1=创建时间 2=确认入库时间)+ 180 天口径; + `warehouseId`/`warehouseTypeId`/`inOrderNo`/`allocationOrderNo`/`materialNo`/`agentId`/`installOrderNo`/ + `pickupOrderNo`/`withdrawOrderNo`(精确);`snCode`/`materielFullName`/`specifications`/`mallOrderNo`/ + `outOrderNo`/`lineMemo`(模糊);`materielIndexIds`(IN)。 +- record 含 id/inOrderNo/warehouseInId/agentId+agentName/warehouseId+warehouseName/warehouseTypeId+warehouseTypeName/ + 物料四件套/snCode/inCount/inDate/orderState+orderStateText/goodInState+goodInStateText(0入库中 1已入库 2已出库 3已撤销)/ + applyUserName/inConfirmTime+inConfirmUserName/allocationOrderNo/mallOrderNo/outOrderNo/deliveryOrderNo/ + installOrderNo/pickupOrderNo/withdrawOrderNo/memo/lineMemo/createTime。 +- 行级权限:`applyWarehouseInDetailsRowFilter`,与出库明细同口径(audit 表换 `warehouse_in_audit_process`)。 + +## 48. warehouse.hardstock.list.query + +库存物品详情分页查询(m0 视图 `hard_sock_life`,纯 MySQL),对应老 `/warehouseHardStock/listByPage`。**固定只查在库**:硬过滤 `soft_delete=0` + `stock_state=0`(王恒拍板,不留参数)。固定 `id DESC`。 + +- 分类:warehouse | 类型:读 +- 入参(全部可选):`agentId`/`assetOwnerId`(精确);`warehouseIds`/`materielIndexIds`(IN); + `materialNo`/`materielFullName`/`specifications`/`snCode`(模糊);`page`/`rows` 默认 1/20 上限 100。 +- record 含 id/snCode/lifeSnCode(组装后设备编号)/agentId+agentName/warehouseId+warehouseName/ + warehouseOwnerId+warehouseOwnerName/assetOwnerId+assetOwnerName/物料四件套/stockState+stockStateText/ + currentInventory/assembleState+assembleStateText/chargeFlag+chargeFlagText/replaceState+replaceStateText/ + useCustomerType+useCustomerTypeText/useCustomerName/outInOrderNo/outInDate/createTime。 +- 行级权限:`applyHardSockLifeRowFilter`,1:1 对齐老 `WrapperUtils.getWarehouseAuth` + (`agent_id`/`warehouse_owner_id` IN 自己+授权代管,与 warehouse.stock.list.query 同语义)。 + +三个工具详细设计见 [warehouse-details-stock-mcp-design](warehouse-details-stock-mcp-design.md)。 + --- ## 附录:在线查看工具清单 diff --git a/xiaoqu-agent-service/doc/warehouse-details-stock-mcp-design.md b/xiaoqu-agent-service/doc/warehouse-details-stock-mcp-design.md new file mode 100644 index 0000000..64f4a12 --- /dev/null +++ b/xiaoqu-agent-service/doc/warehouse-details-stock-mcp-design.md @@ -0,0 +1,96 @@ +# 仓库出入库明细 + 库存物品详情 MCP 工具设计 + +- 需求来源:王恒 2026-11-05(先设计,评审后开发) +- 对应老接口(xiaoqu-franchisee): + - `/warehouseOutStatis/listByPage` 出库明细(WarehouseOutStatisController.java:74-227) + - `/warehouseInStatis/listByPage` 入库明细(WarehouseInStatisController.java:77-222) + - `/warehouseHardStock/listByPage` 库存物品详情(WarehouseHardStockController.java:337-362) + +## 1. 现状盘点 + +agent-service 已有仓库域工具:`warehouse.stock.list.query`(/warehouseStatis 库存汇总)、`warehouse.in/out.list.query`+`detail`(出入库主单)、`warehouse.todo.list.query`(待办)。本次三个是**明细级/设备级**新工具,不与现有工具重复。 + +| 老接口 | 数据源 | 新工具 | +|---|---|---| +| /warehouseOutStatis/listByPage | m2 `warehouse_out_details` | `warehouse.out.details.list.query` | +| /warehouseInStatis/listByPage | m2 `warehouse_in_details` | `warehouse.in.details.list.query` | +| /warehouseHardStock/listByPage | m0 VIEW `hard_sock_life`(b0/s0 链路) | `warehouse.hardstock.list.query` | + +纯 MySQL,无 ES。均只做列表,老系统无对应 detail 接口。 + +## 2. 行级权限映射(关键决策) + +老系统权限分两套模型: + +- **出/入库明细**:`agent.channel_no` Z(总部)看全部;F(分公司)→ `agent_id = 自己` OR `warehouse_id IN fCompanyWarehouseList(user)`(warehouse.agent_id/manager_agent_id = 自己,WarehousePublicService.java:1460)OR 本人审核记录(audit_process)。 +- **库存物品详情**:`WrapperUtils.getWarehouseAuth`(WrapperUtils.java:848):isagent 用户 → `agent_id IN (自己 + warehouse_agent_auth 代管)` OR `warehouse_owner_id IN 同集合`;非代理不过滤。 + +MCP 侧不复刻 channelNo 模型,统一映射到现有 `PermissionService.visibleAgentIds`(自己 + 直属下级 + warehouse_agent_auth 代管)体系,与已上线 `warehouse.in/out.list.query` 口径一致: + +- 新增 `applyWarehouseOutDetailsRowFilter(LambdaQueryWrapper, User)`:总部/服务主体不过滤;代理身份 → `agent_id IN ids` OR 仓库可见(EXISTS warehouse w:`w.agent_id IN ids OR w.manager_agent_id IN ids`)OR 本人参与审批(EXISTS warehouse_out_audit_process user_id=本人)。 +- 新增 `applyWarehouseInDetailsRowFilter(...)`:同上,audit 表换 warehouse_in_audit_process。 +- 新增 `applyHardStockLifeRowFilter(LambdaQueryWrapper, User)`:1:1 对齐 getWarehouseAuth,`agent_id IN ids OR warehouse_owner_id IN ids`(与 applyWarehouseStockRowFilter 同语义、不同实体,参照 HardwareCleanLog 的变体模式)。 + +## 3. 参数契约 + +### warehouse.out.details.list.query(出库明细) + +- 日期:`dateType`(0=出库日期 out_date,默认;1=创建时间 create_time;2=确认出库时间 out_confirm_time)+ `startDate`/`endDate`(yyyy-MM-dd)。**缺省最近 180 天,跨度上限 180 天**(保老口径,这两个是明细流水不是统计工具,不适用 31 天限制)。 +- 过滤:`warehouseId`、`warehouseTypeId`、`outOrderNo`、`allocationOrderNo`、`mallOrderNo`、`materialNo`(等值);`snCode`、`materielFullName`、`specifications`、`useCustomerName`、`deliveryOrderNo`、`installOrderNo`(模糊);`materielIndexIds`(IN);`agentId`、`useCustomerType`、`useCustomerId`(等值);`pickupOrderNo`(等值)。**不提供 `orderState` 参数**(固定只查已完成,见硬过滤)。 +- 硬过滤:`soft_delete=0` + `query_flag=0`(有子子订单的父行不查,老口径 :183)+ **`order_state=3`(只查已完成订单,王恒 2026-11-05 拍板;明细行自带 order_state 列,语义与主单一致)**。 +- 分页:page 默认 1,rows 默认 20、上限 100(超限截断+notice)。 +- 排序:固定 `out_date DESC, id DESC`。 + +### warehouse.in.details.list.query(入库明细) + +- 与出库对称:`dateType`(0=入库日期 in_date 默认;1=create_time;2=确认入库时间 in_confirm_time)。 +- 过滤:`warehouseId`、`warehouseTypeId`、`inOrderNo`、`allocationOrderNo`、`materialNo`、`agentId`、`installOrderNo`、`pickupOrderNo`、`withdrawOrderNo`(等值);`snCode`、`materielFullName`、`specifications`、`mallOrderNo`、`outOrderNo`、`lineMemo`(模糊);`materielIndexIds`(IN)。**不提供 `orderState` 参数**。 +- 硬过滤:`soft_delete=0` + **`order_state=3`(只查已完成订单,同出库口径)**。 +- 排序:固定 `in_date DESC, id DESC`。 + +### warehouse.hardstock.list.query(库存物品详情) + +- **固定 `stock_state=0` 只查在库**(王恒口径)。不提供 stockState 参数;如未来要看出库历史再加。 +- 过滤:`agentId`、`assetOwnerId`(等值);`warehouseIds`、`materielIndexIds`(IN);`materialNo`、`materielFullName`、`specifications`、`snCode`(模糊)。 +- 硬过滤:`soft_delete=0`。无日期过滤(老接口也没有)。 +- 排序:固定 `id DESC`。 + +## 4. 返回契约 + +统一 `{total, page, rows, records[], notice?}`,records 为扁平 camelCase 行。枚举配 `*Text` 中文文案: + +- 出库明细行:id、outOrderNo、warehouseOutId、agentId/agentName、warehouseId/warehouseName、warehouseTypeId/warehouseTypeName、物料四件套(materialNo、materielFullName、materielSort、specifications)、snCode、outCount、price、allMoney、outDate、orderState+orderStateText(0待审核/1被驳回/2待出库/3已完成/4已撤销/5作废)、applyUserName、outConfirmTime/outConfirmUserName、useCustomerType+Text/useCustomerName、allocationOrderNo、mallOrderNo、deliveryOrderNo、installOrderNo、pickupOrderNo、createTime。 +- 入库明细行:对称字段(inOrderNo、warehouseInId、inCount、inDate、goodInState+Text(0入库中/1已入库/2已出库/3已撤销)、inConfirmTime/inConfirmUserName、withdrawOrderNo、memo、lineMemo)。 +- 库存物品行:id、snCode、lifeSnCode(组装后设备编号)、agentId/agentName、warehouseId/warehouseName、warehouseOwnerId/warehouseOwnerName、assetOwnerId/assetOwnerName、物料四件套、stockState+Text、currentInventory、assembleState+Text、chargeFlag+Text、replaceState+Text、useCustomerType+Text/useCustomerName、outInOrderNo、outInDate、createTime。 + +## 5. 与老系统的差异(不照搬的坑) + +| # | 老系统行为 | MCP 决策 | +|---|---|---| +| 1 | 动态排序 sidx/sord 直接拼列名(humpToLine,非白名单,有 SQL 注入面) | 固定排序,不提供排序参数 | +| 2 | 出/入库明细返回 schedulePeople(当前审核节点处理人,逐行查 audit_process 富化) | **不返回 schedulePeople**(王恒 2026-11-05 拍板:不要) | +| 3 | 出/入库明细权限用 channelNo Z/F 模型 | 映射到 visibleAgentIds 体系(与已上线 warehouse.in/out 主单工具同口径),语义等价:F 的 fCompanyWarehouseList = warehouse.agent_id/manager_agent_id,MCP 用 EXISTS 同列实现 | +| 4 | webId 字段裁剪机制 | 不搬,返回全字段 | +| 5 | hardstock 用 beanToWrapper 自动映射入参(eq/like 语义不透明) | 显式参数定义,匹配语义写进工具描述 | +| 6 | statisticsQuery/导出接口 | 本期不做(老系统另有 /warehouseInOutStatis 汇总,未在本次范围) | + +## 6. 变更文件清单 + +- `entity/m2/WarehouseOutDetails.java`、`WarehouseInDetails.java`:**本地拷贝补齐全列**(现有拷贝只有 EXISTS 子查询用的十几个字段,PR #31 同类教训,一次补全)。 +- `entity/m0/HardSockLife.java` + `mapper/m0/HardSockLifeMapper.java`:新建(视图在 m0 权限库 schema,老系统走 s0 链路)。 +- `permission/PermissionService.java`:+3 个行权限方法(§2)。 +- `mcp/tool/WarehouseDetailsStatisTools.java`:新建,1 个工具类 3 个工具方法(明细三件套同域同类,契约各自独立)。 +- 单测:参数校验(dateType 枚举、180 天超限)、行权限三态(总部/代理/代管)、query_flag=0、stock_state=0、order_state=3 硬过滤、枚举文案、空数据、超限截断。 +- `sql/seed-dev.sql`:dev scope 绑定;`doc/agent-mcp-tools-reference.md` 登记;README 工具计数。 + +## 7. scope 与上线 + +- 按 AGENTS.md 部署要点:**工单/仓库系列只绑 prod scope 1**(与 warehouse.stock/in/out 既有工具一致)。 +- 生产冒烟:王恒(812)ops-query Token,出库/入库各查一次真实数据 + 超 180 天报错 + 无权限场景区分;hardstock 查在库 SN。 + +## 8. 拍板结论(王恒 2026-11-05) + +1. hardstock 固定只查在库(stock_state=0),不提供参数放开历史出库记录。 +2. 出/入库明细保留老口径:缺省最近 180 天、跨度上限 180 天。 +3. 不返回 schedulePeople(审核中处理人),老系统的逐行 audit_process 富化不搬。 +4. 出/入库明细**只查订单状态已完成**:硬过滤 `order_state=3`,不下发 `orderState` 参数(老接口的 orderState 过滤参数取消;明细行自带 order_state 列,与主单状态同步)。已完成单据才产生有效库存流水,待审核/驳回/撤销行对下游 agent 无业务意义。 diff --git a/xiaoqu-agent-service/sql/seed-dev.sql b/xiaoqu-agent-service/sql/seed-dev.sql index 890d4bb..27ae13e 100644 --- a/xiaoqu-agent-service/sql/seed-dev.sql +++ b/xiaoqu-agent-service/sql/seed-dev.sql @@ -56,7 +56,7 @@ WHERE NOT EXISTS (SELECT 1 FROM agent_scope WHERE code = 'ops-query'); INSERT INTO scope_tool (scope_id, tool_id, create_id) SELECT s.id, t.id, 0 FROM agent_scope s JOIN tool t -WHERE s.code = 'ops-query' AND t.name IN ('sys.ping', 'integration.context.get', 'agent.count', 'agent.list.query', 'agent.detail.query', 'property.list.query', 'property.detail.query', 'district.list.query', 'district.detail.get', 'district.paper.statistics.query', 'district.paper.position.detail.query', 'hardware.list.query', 'hardware.detail.query', 'hardware.cleanlog.list.query', 'contract.list.query', 'contract.detail.query', 'mall.order.list.query', 'mall.order.detail.query', 'mall.order.logistics.query', 'install.order.list.query', 'install.order.detail.query', 'delivery.order.list.query', 'delivery.order.detail.query', 'pickup.order.list.query', 'pickup.order.detail.query', 'relocation.order.list.query', 'relocation.order.detail.query', 'withdraw.order.list.query', 'withdraw.order.detail.query', 'warehouse.in.list.query', 'warehouse.in.detail.query', 'warehouse.out.list.query', 'warehouse.out.detail.query', 'warehouse.stock.list.query') +WHERE s.code = 'ops-query' AND t.name IN ('sys.ping', 'integration.context.get', 'agent.count', 'agent.list.query', 'agent.detail.query', 'property.list.query', 'property.detail.query', 'district.list.query', 'district.detail.get', 'district.paper.statistics.query', 'district.paper.position.detail.query', 'hardware.list.query', 'hardware.detail.query', 'hardware.cleanlog.list.query', 'contract.list.query', 'contract.detail.query', 'mall.order.list.query', 'mall.order.detail.query', 'mall.order.logistics.query', 'install.order.list.query', 'install.order.detail.query', 'delivery.order.list.query', 'delivery.order.detail.query', 'pickup.order.list.query', 'pickup.order.detail.query', 'relocation.order.list.query', 'relocation.order.detail.query', 'withdraw.order.list.query', 'withdraw.order.detail.query', 'warehouse.in.list.query', 'warehouse.in.detail.query', 'warehouse.out.list.query', 'warehouse.out.detail.query', 'warehouse.stock.list.query', 'warehouse.out.details.list.query', 'warehouse.in.details.list.query', 'warehouse.hardstock.list.query') AND NOT EXISTS (SELECT 1 FROM scope_tool st WHERE st.scope_id = s.id AND st.tool_id = t.id); -- 人均用纸统计仅开放给用户委托 Token:绑定全部非服务身份作用域,不进入 sync/ops-query。 diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m0/HardSockLife.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m0/HardSockLife.java new file mode 100644 index 0000000..be15a6f --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m0/HardSockLife.java @@ -0,0 +1,63 @@ +package me.iiv.agent.entity.m0; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +import java.io.Serializable; +import java.util.Date; + +/** 库存物品生命周期视图(m0.hard_sock_life,VIEW),全列拷贝自 b0 参考实体。 */ +@Getter +@Setter +@ToString +@TableName("hard_sock_life") +public class HardSockLife implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId("id") + private Long id; + private Long createId; + private Date createTime; + private Long modifyId; + private Date modifyTime; + private Integer softDelete; + /** 仓库管理公司ID */ + private Long agentId; + private String agentName; + private Long warehouseId; + private String warehouseName; + /** 出入库单号 */ + private String outInOrderNo; + private Long materielDefinitionId; + private String materialNo; + private String materielFullName; + private String materielSort; + private Long materielIndexId; + private String specifications; + private String snCode; + /** 出入库时间 */ + private Date outInDate; + /** 库存状态(0:在库;1:已出库) */ + private Integer stockState; + private Integer currentInventory; + /** 使用客户类型(0:内部人员;1:渠道商;2:直营客户) */ + private Integer useCustomerType; + private Long useCustomerId; + private String useCustomerName; + /** 组装状态(0:未匹配;1:已匹配) */ + private Integer assembleState; + /** 组装后设备编号 */ + private String lifeSnCode; + private Long assetOwnerId; + private String assetOwnerName; + private Long warehouseOwnerId; + private String warehouseOwnerName; + /** 费用化标识(0:费用化;1:非费用化) */ + private Integer chargeFlag; + /** 替换状态(0:未替换;1:已替换) */ + private Integer replaceState; +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseInDetails.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseInDetails.java index 03b6ff2..9ef119d 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseInDetails.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseInDetails.java @@ -11,7 +11,7 @@ import java.io.Serializable; import java.time.LocalDate; import java.util.Date; -/** 入库单明细(m2.warehouse_in_details),生命周期详情的入库/重工事件来源。 */ +/** 入库单明细(m2.warehouse_in_details),全列拷贝自 b2 参考实体。 */ @Getter @Setter @ToString @@ -22,17 +22,49 @@ public class WarehouseInDetails implements Serializable { @TableId(value = "id", type = IdType.AUTO) private Long id; + private Long createId; private Date createTime; + private Long modifyId; + private Date modifyTime; private Integer softDelete; - private String snCode; + private Long agentId; + private String agentName; private String inOrderNo; + private String allocationOrderNo; + private Long warehouseInId; + private Long materielDefinitionId; + private String materialNo; + private String materielFullName; + private String materielSort; + private Long materielIndexId; + private String specifications; + private String snCode; + private Integer inCount; + private Integer currentInventory; + private LocalDate inDate; private Long warehouseId; private String warehouseName; + private Long warehouseTypeId; private String warehouseTypeName; - /** 单据状态,生命周期只取 3=已确认 */ + /** 入库单状态(0:待审核;1:被驳回;2:待入库;3:已完成;4:已撤销;5:作废) */ private Integer orderState; + private Long applyUserId; private String applyUserName; - private String inConfirmUserName; + /** 商品库存状态(0:入库中;1:已入库;2:已出库;3:已撤销) */ + private Integer goodInState; private Date inConfirmTime; - private LocalDate inDate; + private String inConfirmUserName; + private String outOrderNo; + private String mallOrderNo; + private Long jindieInternalId; + private Long jindiePurchaseInternalId; + private Integer assetDisposalType; + private Integer synchronizeState; + private Long outDetailsId; + private String deliveryOrderNo; + private String installOrderNo; + private String pickupOrderNo; + private String withdrawOrderNo; + private String memo; + private String lineMemo; } diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseOutDetails.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseOutDetails.java index 81bb932..b6e2392 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseOutDetails.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseOutDetails.java @@ -8,10 +8,11 @@ import lombok.Setter; import lombok.ToString; import java.io.Serializable; +import java.math.BigDecimal; import java.time.LocalDate; import java.util.Date; -/** 出库单明细(m2.warehouse_out_details),生命周期详情的出库/出厂事件来源。 */ +/** 出库单明细(m2.warehouse_out_details),全列拷贝自 b2 参考实体。 */ @Getter @Setter @ToString @@ -22,19 +23,58 @@ public class WarehouseOutDetails implements Serializable { @TableId(value = "id", type = IdType.AUTO) private Long id; + private Long createId; private Date createTime; + private Long modifyId; + private Date modifyTime; private Integer softDelete; - private String snCode; - private String outOrderNo; - private String mallOrderNo; + private Long agentId; + private String agentName; private Long warehouseId; private String warehouseName; - private String warehouseTypeName; - /** 单据状态,生命周期只取 3=已确认 */ - private Integer orderState; - private String applyUserName; - private String outConfirmUserName; - private Date outConfirmTime; + private Integer outCount; + private String outOrderNo; + private String allocationOrderNo; + private String mallOrderNo; + private Long warehouseOutId; + private Long materielDefinitionId; + private String materialNo; + private String materielFullName; + private String materielSort; + private Long materielIndexId; + private String specifications; + private String snCode; + private Integer currentInventory; private LocalDate outDate; + private Long warehouseTypeId; + private String warehouseTypeName; + /** 出库单状态(0:待审核;1:被驳回;2:待出库;3:已完成;4:已撤销;5:作废) */ + private Integer orderState; + /** 查询标志0:查询;1:不查询 如果有子子订单就不查该条记录 */ + private Integer queryFlag; + private Long parentId; + private Long applyUserId; + private String applyUserName; + private Date outConfirmTime; + private String outConfirmUserName; + /** 使用客户类型(0:内部人员;1:渠道商;2:直营客户) */ + private Integer useCustomerType; + private Long useCustomerId; private String useCustomerName; + private Long mallOrderDetailId; + private Integer outOfStock; + private BigDecimal price; + private BigDecimal allMoney; + private String threeAttribute; + private Long jindieInternalId; + private String assetDisposalType; + private Integer synchronizeState; + private String jindieMaterialNo; + private Integer assetSynchronizeState; + private String jindieWarehouseTypeNo; + private Integer assembleFlag; + private String batteryMaterialNo; + private String deliveryOrderNo; + private String installOrderNo; + private String pickupOrderNo; } diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m0/HardSockLifeMapper.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m0/HardSockLifeMapper.java new file mode 100644 index 0000000..c974110 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m0/HardSockLifeMapper.java @@ -0,0 +1,7 @@ +package me.iiv.agent.mapper.m0; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import me.iiv.agent.entity.m0.HardSockLife; + +public interface HardSockLifeMapper extends BaseMapper { +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/WarehouseDetailsStatisTools.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/WarehouseDetailsStatisTools.java new file mode 100644 index 0000000..048c4d4 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/WarehouseDetailsStatisTools.java @@ -0,0 +1,484 @@ +package me.iiv.agent.mcp.tool; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import me.iiv.agent.entity.m0.HardSockLife; +import me.iiv.agent.entity.m0.User; +import me.iiv.agent.entity.m2.WarehouseInDetails; +import me.iiv.agent.entity.m2.WarehouseOutDetails; +import me.iiv.agent.mapper.m0.HardSockLifeMapper; +import me.iiv.agent.mapper.m2.WarehouseInDetailsMapper; +import me.iiv.agent.mapper.m2.WarehouseOutDetailsMapper; +import me.iiv.agent.mcp.ToolObject; +import me.iiv.agent.mcp.XqTool; +import me.iiv.agent.permission.PermissionService; +import me.iiv.agent.security.AuthContextHolder; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.time.format.ResolverStyle; +import java.time.temporal.ChronoUnit; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +/** + * 仓库出入库明细与库存物品详情查询工具,参考老系统 + * /warehouseOutStatis/listByPage、/warehouseInStatis/listByPage、/warehouseHardStock/listByPage。 + * REQ-20261105-0020。拍板口径:hardstock 固定只查在库;出/入库明细缺省最近180天、跨度上限180天、 + * 只查已完成订单(order_state=3);不返回 schedulePeople;固定排序不提供排序参数。 + */ +@Component +public class WarehouseDetailsStatisTools implements ToolObject { + + private static final int DEFAULT_ROWS = 20; + private static final int MAX_ROWS = 100; + private static final int MAX_SPAN_DAYS = 180; + private static final int ORDER_STATE_DONE = 3; + private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai"); + private static final Pattern DATE_PATTERN = Pattern.compile("^\\d{4}-\\d{2}-\\d{2}$"); + private static final DateTimeFormatter DATE_FORMATTER = + DateTimeFormatter.ISO_LOCAL_DATE.withResolverStyle(ResolverStyle.STRICT); + private static final DateTimeFormatter TIME_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(BUSINESS_ZONE); + + private static final Map OUT_ORDER_STATE_TEXT = Map.of( + 0, "待审核", 1, "被驳回", 2, "待出库", 3, "已完成", 4, "已撤销", 5, "作废"); + private static final Map IN_ORDER_STATE_TEXT = Map.of( + 0, "待审核", 1, "被驳回", 2, "待入库", 3, "已完成", 4, "已撤销", 5, "作废"); + private static final Map GOOD_IN_STATE_TEXT = Map.of( + 0, "入库中", 1, "已入库", 2, "已出库", 3, "已撤销"); + private static final Map CUSTOMER_TYPE_TEXT = Map.of( + 0, "内部人员", 1, "渠道商", 2, "直营客户"); + private static final Map STOCK_STATE_TEXT = Map.of(0, "在库", 1, "已出库"); + private static final Map ASSEMBLE_STATE_TEXT = Map.of(0, "未匹配", 1, "已匹配"); + private static final Map CHARGE_FLAG_TEXT = Map.of(0, "费用化", 1, "非费用化"); + private static final Map REPLACE_STATE_TEXT = Map.of(0, "未替换", 1, "已替换"); + + final WarehouseOutDetailsMapper outDetailsMapper; + final WarehouseInDetailsMapper inDetailsMapper; + final HardSockLifeMapper hardSockLifeMapper; + final PermissionService permissionService; + + public WarehouseDetailsStatisTools(WarehouseOutDetailsMapper outDetailsMapper, + WarehouseInDetailsMapper inDetailsMapper, + HardSockLifeMapper hardSockLifeMapper, + PermissionService permissionService) { + this.outDetailsMapper = outDetailsMapper; + this.inDetailsMapper = inDetailsMapper; + this.hardSockLifeMapper = hardSockLifeMapper; + this.permissionService = permissionService; + } + + @XqTool(category = "warehouse", kind = "read") + @Tool(name = "warehouse.out.details.list.query", description = "分页查询出库明细,参考老系统 /warehouseOutStatis/listByPage。" + + "只查已完成订单(order_state=3)且 query_flag=0 的明细行。总部可看全部;代理身份可看所属代理范围、" + + "可见仓库涉及、或本人参与审批的出库单明细。dateType:0出库日期(默认)、1创建时间、2确认出库时间;" + + "缺省最近180天,跨度上限180天。分页 page 默认1,rows 默认20、上限100。固定按出库日期、id 倒序。") + public Map warehouseOutDetailsListQuery( + @ToolParam(required = false, description = "日期类型:0出库日期(默认) 1创建时间 2确认出库时间") Integer dateType, + @ToolParam(required = false, description = "开始日期,yyyy-MM-dd,缺省最近180天") String startDate, + @ToolParam(required = false, description = "结束日期,yyyy-MM-dd,含当天,缺省今天") String endDate, + @ToolParam(required = false, description = "仓库 id") Long warehouseId, + @ToolParam(required = false, description = "出库类型 id") Long warehouseTypeId, + @ToolParam(required = false, description = "出库单号") String outOrderNo, + @ToolParam(required = false, description = "调拨单号") String allocationOrderNo, + @ToolParam(required = false, description = "商城订单号") String mallOrderNo, + @ToolParam(required = false, description = "物品编号") String materialNo, + @ToolParam(required = false, description = "物品SN,模糊匹配") String snCode, + @ToolParam(required = false, description = "物品名称,模糊匹配") String materielFullName, + @ToolParam(required = false, description = "规格型号,模糊匹配") String specifications, + @ToolParam(required = false, description = "使用客户名称,模糊匹配") String useCustomerName, + @ToolParam(required = false, description = "送货单号,模糊匹配") String deliveryOrderNo, + @ToolParam(required = false, description = "装机单号,模糊匹配") String installOrderNo, + @ToolParam(required = false, description = "取货工单号") String pickupOrderNo, + @ToolParam(required = false, description = "物品分类 id 集合") List materielIndexIds, + @ToolParam(required = false, description = "所属代理商 id") Long agentId, + @ToolParam(required = false, description = "使用客户类型:0内部人员 1渠道商 2直营客户") Integer useCustomerType, + @ToolParam(required = false, description = "使用客户 id") Long useCustomerId, + @ToolParam(required = false, description = "页码,从1开始,默认1") Integer page, + @ToolParam(required = false, description = "每页条数,默认20,上限100") Integer rows) { + int dateTypeValue = validateDateType(dateType); + LocalDate start = parseLocalDate(startDate, "startDate"); + LocalDate end = parseLocalDate(endDate, "endDate"); + LocalDate[] range = resolveRange(start, end); + + int pageNo = page == null || page < 1 ? 1 : page; + boolean truncated = rows != null && rows > MAX_ROWS; + int pageSize = rows == null || rows < 1 ? DEFAULT_ROWS : Math.min(rows, MAX_ROWS); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(WarehouseOutDetails::getSoftDelete, 0) + .eq(WarehouseOutDetails::getQueryFlag, 0) + .eq(WarehouseOutDetails::getOrderState, ORDER_STATE_DONE) + .eq(warehouseId != null, WarehouseOutDetails::getWarehouseId, warehouseId) + .eq(warehouseTypeId != null, WarehouseOutDetails::getWarehouseTypeId, warehouseTypeId) + .eq(agentId != null, WarehouseOutDetails::getAgentId, agentId) + .eq(useCustomerType != null, WarehouseOutDetails::getUseCustomerType, useCustomerType) + .eq(useCustomerId != null, WarehouseOutDetails::getUseCustomerId, useCustomerId) + .in(materielIndexIds != null && !materielIndexIds.isEmpty(), + WarehouseOutDetails::getMaterielIndexId, materielIndexIds); + eqText(wrapper, WarehouseOutDetails::getOutOrderNo, outOrderNo); + eqText(wrapper, WarehouseOutDetails::getAllocationOrderNo, allocationOrderNo); + eqText(wrapper, WarehouseOutDetails::getMallOrderNo, mallOrderNo); + eqText(wrapper, WarehouseOutDetails::getMaterialNo, materialNo); + eqText(wrapper, WarehouseOutDetails::getPickupOrderNo, pickupOrderNo); + likeText(wrapper, WarehouseOutDetails::getSnCode, snCode); + likeText(wrapper, WarehouseOutDetails::getMaterielFullName, materielFullName); + likeText(wrapper, WarehouseOutDetails::getSpecifications, specifications); + likeText(wrapper, WarehouseOutDetails::getUseCustomerName, useCustomerName); + likeText(wrapper, WarehouseOutDetails::getDeliveryOrderNo, deliveryOrderNo); + likeText(wrapper, WarehouseOutDetails::getInstallOrderNo, installOrderNo); + applyOutDateRange(wrapper, dateTypeValue, range[0], range[1]); + + User user = AuthContextHolder.get().getUser(); + permissionService.applyWarehouseOutDetailsRowFilter(wrapper, user); + Long total = outDetailsMapper.selectCount(wrapper); + wrapper.orderByDesc(WarehouseOutDetails::getOutDate) + .orderByDesc(WarehouseOutDetails::getId) + .last("LIMIT " + (long) (pageNo - 1) * pageSize + "," + pageSize); + List> records = outDetailsMapper.selectList(wrapper).stream() + .map(this::outRecord).collect(Collectors.toList()); + return pageResult(total, pageNo, pageSize, truncated, records); + } + + @XqTool(category = "warehouse", kind = "read") + @Tool(name = "warehouse.in.details.list.query", description = "分页查询入库明细,参考老系统 /warehouseInStatis/listByPage。" + + "只查已完成订单(order_state=3)的明细行。总部可看全部;代理身份可看所属代理范围、" + + "可见仓库涉及、或本人参与审批的入库单明细。dateType:0入库日期(默认)、1创建时间、2确认入库时间;" + + "缺省最近180天,跨度上限180天。分页 page 默认1,rows 默认20、上限100。固定按入库日期、id 倒序。") + public Map warehouseInDetailsListQuery( + @ToolParam(required = false, description = "日期类型:0入库日期(默认) 1创建时间 2确认入库时间") Integer dateType, + @ToolParam(required = false, description = "开始日期,yyyy-MM-dd,缺省最近180天") String startDate, + @ToolParam(required = false, description = "结束日期,yyyy-MM-dd,含当天,缺省今天") String endDate, + @ToolParam(required = false, description = "仓库 id") Long warehouseId, + @ToolParam(required = false, description = "入库类型 id") Long warehouseTypeId, + @ToolParam(required = false, description = "入库单号") String inOrderNo, + @ToolParam(required = false, description = "调拨单号") String allocationOrderNo, + @ToolParam(required = false, description = "物品编号") String materialNo, + @ToolParam(required = false, description = "所属代理商 id") Long agentId, + @ToolParam(required = false, description = "装机单号") String installOrderNo, + @ToolParam(required = false, description = "取货工单号") String pickupOrderNo, + @ToolParam(required = false, description = "撤回工单号") String withdrawOrderNo, + @ToolParam(required = false, description = "物品SN,模糊匹配") String snCode, + @ToolParam(required = false, description = "物品名称,模糊匹配") String materielFullName, + @ToolParam(required = false, description = "规格型号,模糊匹配") String specifications, + @ToolParam(required = false, description = "商城订单号,模糊匹配") String mallOrderNo, + @ToolParam(required = false, description = "出库单号,模糊匹配") String outOrderNo, + @ToolParam(required = false, description = "行备注,模糊匹配") String lineMemo, + @ToolParam(required = false, description = "物品分类 id 集合") List materielIndexIds, + @ToolParam(required = false, description = "页码,从1开始,默认1") Integer page, + @ToolParam(required = false, description = "每页条数,默认20,上限100") Integer rows) { + int dateTypeValue = validateDateType(dateType); + LocalDate start = parseLocalDate(startDate, "startDate"); + LocalDate end = parseLocalDate(endDate, "endDate"); + LocalDate[] range = resolveRange(start, end); + + int pageNo = page == null || page < 1 ? 1 : page; + boolean truncated = rows != null && rows > MAX_ROWS; + int pageSize = rows == null || rows < 1 ? DEFAULT_ROWS : Math.min(rows, MAX_ROWS); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(WarehouseInDetails::getSoftDelete, 0) + .eq(WarehouseInDetails::getOrderState, ORDER_STATE_DONE) + .eq(warehouseId != null, WarehouseInDetails::getWarehouseId, warehouseId) + .eq(warehouseTypeId != null, WarehouseInDetails::getWarehouseTypeId, warehouseTypeId) + .eq(agentId != null, WarehouseInDetails::getAgentId, agentId) + .in(materielIndexIds != null && !materielIndexIds.isEmpty(), + WarehouseInDetails::getMaterielIndexId, materielIndexIds); + eqText(wrapper, WarehouseInDetails::getInOrderNo, inOrderNo); + eqText(wrapper, WarehouseInDetails::getAllocationOrderNo, allocationOrderNo); + eqText(wrapper, WarehouseInDetails::getMaterialNo, materialNo); + eqText(wrapper, WarehouseInDetails::getInstallOrderNo, installOrderNo); + eqText(wrapper, WarehouseInDetails::getPickupOrderNo, pickupOrderNo); + eqText(wrapper, WarehouseInDetails::getWithdrawOrderNo, withdrawOrderNo); + likeText(wrapper, WarehouseInDetails::getSnCode, snCode); + likeText(wrapper, WarehouseInDetails::getMaterielFullName, materielFullName); + likeText(wrapper, WarehouseInDetails::getSpecifications, specifications); + likeText(wrapper, WarehouseInDetails::getMallOrderNo, mallOrderNo); + likeText(wrapper, WarehouseInDetails::getOutOrderNo, outOrderNo); + likeText(wrapper, WarehouseInDetails::getLineMemo, lineMemo); + applyInDateRange(wrapper, dateTypeValue, range[0], range[1]); + + User user = AuthContextHolder.get().getUser(); + permissionService.applyWarehouseInDetailsRowFilter(wrapper, user); + Long total = inDetailsMapper.selectCount(wrapper); + wrapper.orderByDesc(WarehouseInDetails::getInDate) + .orderByDesc(WarehouseInDetails::getId) + .last("LIMIT " + (long) (pageNo - 1) * pageSize + "," + pageSize); + List> records = inDetailsMapper.selectList(wrapper).stream() + .map(this::inRecord).collect(Collectors.toList()); + return pageResult(total, pageNo, pageSize, truncated, records); + } + + @XqTool(category = "warehouse", kind = "read") + @Tool(name = "warehouse.hardstock.list.query", description = "分页查询库存物品详情(在库物品),参考老系统 /warehouseHardStock/listByPage。" + + "固定只查在库(stock_state=0),数据来自 m0 视图 hard_sock_life。总部可看全部;代理仅可看本公司或 " + + "warehouse_agent_auth 授权代管公司作为管理公司/所属公司的物品。分页 page 默认1,rows 默认20、上限100。" + + "固定按 id 倒序。") + public Map warehouseHardStockListQuery( + @ToolParam(required = false, description = "仓库管理公司 id") Long agentId, + @ToolParam(required = false, description = "资产所属公司 id") Long assetOwnerId, + @ToolParam(required = false, description = "仓库 id 集合") List warehouseIds, + @ToolParam(required = false, description = "物品分类 id 集合") List materielIndexIds, + @ToolParam(required = false, description = "物品编号,模糊匹配") String materialNo, + @ToolParam(required = false, description = "物品名称,模糊匹配") String materielFullName, + @ToolParam(required = false, description = "规格型号,模糊匹配") String specifications, + @ToolParam(required = false, description = "物品SN,模糊匹配") String snCode, + @ToolParam(required = false, description = "页码,从1开始,默认1") Integer page, + @ToolParam(required = false, description = "每页条数,默认20,上限100") Integer rows) { + int pageNo = page == null || page < 1 ? 1 : page; + boolean truncated = rows != null && rows > MAX_ROWS; + int pageSize = rows == null || rows < 1 ? DEFAULT_ROWS : Math.min(rows, MAX_ROWS); + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(HardSockLife::getSoftDelete, 0) + .eq(HardSockLife::getStockState, 0) + .eq(agentId != null, HardSockLife::getAgentId, agentId) + .eq(assetOwnerId != null, HardSockLife::getAssetOwnerId, assetOwnerId) + .in(warehouseIds != null && !warehouseIds.isEmpty(), HardSockLife::getWarehouseId, warehouseIds) + .in(materielIndexIds != null && !materielIndexIds.isEmpty(), + HardSockLife::getMaterielIndexId, materielIndexIds); + likeText(wrapper, HardSockLife::getMaterialNo, materialNo); + likeText(wrapper, HardSockLife::getMaterielFullName, materielFullName); + likeText(wrapper, HardSockLife::getSpecifications, specifications); + likeText(wrapper, HardSockLife::getSnCode, snCode); + + User user = AuthContextHolder.get().getUser(); + permissionService.applyHardSockLifeRowFilter(wrapper, user); + Long total = hardSockLifeMapper.selectCount(wrapper); + wrapper.orderByDesc(HardSockLife::getId) + .last("LIMIT " + (long) (pageNo - 1) * pageSize + "," + pageSize); + List> records = hardSockLifeMapper.selectList(wrapper).stream() + .map(this::hardStockRecord).collect(Collectors.toList()); + return pageResult(total, pageNo, pageSize, truncated, records); + } + + private void applyOutDateRange(LambdaQueryWrapper wrapper, int dateType, + LocalDate start, LocalDate end) { + if (dateType == 0) { + wrapper.ge(WarehouseOutDetails::getOutDate, start).le(WarehouseOutDetails::getOutDate, end); + } else if (dateType == 1) { + wrapper.ge(WarehouseOutDetails::getCreateTime, startOfDay(start)) + .lt(WarehouseOutDetails::getCreateTime, nextDayStart(end)); + } else { + wrapper.ge(WarehouseOutDetails::getOutConfirmTime, startOfDay(start)) + .lt(WarehouseOutDetails::getOutConfirmTime, nextDayStart(end)); + } + } + + private void applyInDateRange(LambdaQueryWrapper wrapper, int dateType, + LocalDate start, LocalDate end) { + if (dateType == 0) { + wrapper.ge(WarehouseInDetails::getInDate, start).le(WarehouseInDetails::getInDate, end); + } else if (dateType == 1) { + wrapper.ge(WarehouseInDetails::getCreateTime, startOfDay(start)) + .lt(WarehouseInDetails::getCreateTime, nextDayStart(end)); + } else { + wrapper.ge(WarehouseInDetails::getInConfirmTime, startOfDay(start)) + .lt(WarehouseInDetails::getInConfirmTime, nextDayStart(end)); + } + } + + private Map outRecord(WarehouseOutDetails detail) { + Map row = new LinkedHashMap<>(); + row.put("id", detail.getId()); + row.put("outOrderNo", detail.getOutOrderNo()); + row.put("warehouseOutId", detail.getWarehouseOutId()); + row.put("agentId", detail.getAgentId()); + row.put("agentName", detail.getAgentName()); + row.put("warehouseId", detail.getWarehouseId()); + row.put("warehouseName", detail.getWarehouseName()); + row.put("warehouseTypeId", detail.getWarehouseTypeId()); + row.put("warehouseTypeName", detail.getWarehouseTypeName()); + row.put("materialNo", detail.getMaterialNo()); + row.put("materielFullName", detail.getMaterielFullName()); + row.put("materielSort", detail.getMaterielSort()); + row.put("specifications", detail.getSpecifications()); + row.put("snCode", detail.getSnCode()); + row.put("outCount", detail.getOutCount()); + row.put("price", detail.getPrice()); + row.put("allMoney", detail.getAllMoney()); + row.put("outDate", detail.getOutDate() == null ? null : detail.getOutDate().toString()); + row.put("orderState", detail.getOrderState()); + row.put("orderStateText", text(OUT_ORDER_STATE_TEXT, detail.getOrderState())); + row.put("applyUserName", detail.getApplyUserName()); + row.put("outConfirmTime", formatTime(detail.getOutConfirmTime())); + row.put("outConfirmUserName", detail.getOutConfirmUserName()); + row.put("useCustomerType", detail.getUseCustomerType()); + row.put("useCustomerTypeText", text(CUSTOMER_TYPE_TEXT, detail.getUseCustomerType())); + row.put("useCustomerName", detail.getUseCustomerName()); + row.put("allocationOrderNo", detail.getAllocationOrderNo()); + row.put("mallOrderNo", detail.getMallOrderNo()); + row.put("deliveryOrderNo", detail.getDeliveryOrderNo()); + row.put("installOrderNo", detail.getInstallOrderNo()); + row.put("pickupOrderNo", detail.getPickupOrderNo()); + row.put("createTime", formatTime(detail.getCreateTime())); + return row; + } + + private Map inRecord(WarehouseInDetails detail) { + Map row = new LinkedHashMap<>(); + row.put("id", detail.getId()); + row.put("inOrderNo", detail.getInOrderNo()); + row.put("warehouseInId", detail.getWarehouseInId()); + row.put("agentId", detail.getAgentId()); + row.put("agentName", detail.getAgentName()); + row.put("warehouseId", detail.getWarehouseId()); + row.put("warehouseName", detail.getWarehouseName()); + row.put("warehouseTypeId", detail.getWarehouseTypeId()); + row.put("warehouseTypeName", detail.getWarehouseTypeName()); + row.put("materialNo", detail.getMaterialNo()); + row.put("materielFullName", detail.getMaterielFullName()); + row.put("materielSort", detail.getMaterielSort()); + row.put("specifications", detail.getSpecifications()); + row.put("snCode", detail.getSnCode()); + row.put("inCount", detail.getInCount()); + row.put("inDate", detail.getInDate() == null ? null : detail.getInDate().toString()); + row.put("orderState", detail.getOrderState()); + row.put("orderStateText", text(IN_ORDER_STATE_TEXT, detail.getOrderState())); + row.put("goodInState", detail.getGoodInState()); + row.put("goodInStateText", text(GOOD_IN_STATE_TEXT, detail.getGoodInState())); + row.put("applyUserName", detail.getApplyUserName()); + row.put("inConfirmTime", formatTime(detail.getInConfirmTime())); + row.put("inConfirmUserName", detail.getInConfirmUserName()); + row.put("allocationOrderNo", detail.getAllocationOrderNo()); + row.put("mallOrderNo", detail.getMallOrderNo()); + row.put("outOrderNo", detail.getOutOrderNo()); + row.put("deliveryOrderNo", detail.getDeliveryOrderNo()); + row.put("installOrderNo", detail.getInstallOrderNo()); + row.put("pickupOrderNo", detail.getPickupOrderNo()); + row.put("withdrawOrderNo", detail.getWithdrawOrderNo()); + row.put("memo", detail.getMemo()); + row.put("lineMemo", detail.getLineMemo()); + row.put("createTime", formatTime(detail.getCreateTime())); + return row; + } + + private Map hardStockRecord(HardSockLife item) { + Map row = new LinkedHashMap<>(); + row.put("id", item.getId()); + row.put("snCode", item.getSnCode()); + row.put("lifeSnCode", item.getLifeSnCode()); + row.put("agentId", item.getAgentId()); + row.put("agentName", item.getAgentName()); + row.put("warehouseId", item.getWarehouseId()); + row.put("warehouseName", item.getWarehouseName()); + row.put("warehouseOwnerId", item.getWarehouseOwnerId()); + row.put("warehouseOwnerName", item.getWarehouseOwnerName()); + row.put("assetOwnerId", item.getAssetOwnerId()); + row.put("assetOwnerName", item.getAssetOwnerName()); + row.put("materialNo", item.getMaterialNo()); + row.put("materielFullName", item.getMaterielFullName()); + row.put("materielSort", item.getMaterielSort()); + row.put("specifications", item.getSpecifications()); + row.put("stockState", item.getStockState()); + row.put("stockStateText", text(STOCK_STATE_TEXT, item.getStockState())); + row.put("currentInventory", item.getCurrentInventory()); + row.put("assembleState", item.getAssembleState()); + row.put("assembleStateText", text(ASSEMBLE_STATE_TEXT, item.getAssembleState())); + row.put("chargeFlag", item.getChargeFlag()); + row.put("chargeFlagText", text(CHARGE_FLAG_TEXT, item.getChargeFlag())); + row.put("replaceState", item.getReplaceState()); + row.put("replaceStateText", text(REPLACE_STATE_TEXT, item.getReplaceState())); + row.put("useCustomerType", item.getUseCustomerType()); + row.put("useCustomerTypeText", text(CUSTOMER_TYPE_TEXT, item.getUseCustomerType())); + row.put("useCustomerName", item.getUseCustomerName()); + row.put("outInOrderNo", item.getOutInOrderNo()); + row.put("outInDate", formatTime(item.getOutInDate())); + row.put("createTime", formatTime(item.getCreateTime())); + return row; + } + + private static Map pageResult(Long total, int pageNo, int pageSize, + boolean truncated, List> records) { + Map result = new LinkedHashMap<>(); + result.put("total", total); + result.put("page", pageNo); + result.put("rows", pageSize); + if (truncated) { + result.put("notice", "rows 超过上限 100,已截断为 100"); + } + result.put("records", records); + return result; + } + + private static int validateDateType(Integer dateType) { + if (dateType == null) { + return 0; + } + if (dateType < 0 || dateType > 2) { + throw new IllegalArgumentException("dateType 只支持 0(业务日期)、1(创建时间)、2(确认时间)"); + } + return dateType; + } + + private static LocalDate[] resolveRange(LocalDate start, LocalDate end) { + LocalDate today = LocalDate.now(BUSINESS_ZONE); + LocalDate resolvedEnd = end == null ? today : end; + LocalDate resolvedStart = start == null ? resolvedEnd.minusDays(MAX_SPAN_DAYS) : start; + if (resolvedStart.isAfter(resolvedEnd)) { + throw new IllegalArgumentException("startDate 不能晚于 endDate"); + } + // 老口径 DateUtil.betweenDay(start, end) > 180 才拒绝:恰好 180 天放行 + if (ChronoUnit.DAYS.between(resolvedStart, resolvedEnd) > MAX_SPAN_DAYS) { + throw new IllegalArgumentException("日期跨度不能超过 " + MAX_SPAN_DAYS + " 天"); + } + return new LocalDate[]{resolvedStart, resolvedEnd}; + } + + private static LocalDate parseLocalDate(String value, String name) { + if (!StringUtils.hasText(value)) { + return null; + } + String trimmed = value.trim(); + if (!DATE_PATTERN.matcher(trimmed).matches()) { + throw new IllegalArgumentException(name + " 格式必须为 yyyy-MM-dd"); + } + try { + return LocalDate.parse(trimmed, DATE_FORMATTER); + } catch (DateTimeParseException e) { + throw new IllegalArgumentException(name + " 格式必须为 yyyy-MM-dd"); + } + } + + private static Date startOfDay(LocalDate date) { + return Date.from(date.atStartOfDay(BUSINESS_ZONE).toInstant()); + } + + private static Date nextDayStart(LocalDate date) { + return Date.from(date.plusDays(1).atStartOfDay(BUSINESS_ZONE).toInstant()); + } + + private static void eqText(LambdaQueryWrapper wrapper, + com.baomidou.mybatisplus.core.toolkit.support.SFunction column, + String value) { + if (StringUtils.hasText(value)) { + wrapper.eq(column, value.trim()); + } + } + + private static void likeText(LambdaQueryWrapper wrapper, + com.baomidou.mybatisplus.core.toolkit.support.SFunction column, + String value) { + if (StringUtils.hasText(value)) { + wrapper.like(column, value.trim()); + } + } + + private static String text(Map texts, Integer code) { + return code == null ? null : texts.getOrDefault(code, "未知"); + } + + private static String formatTime(Date date) { + return date == null ? null : TIME_FORMATTER.format(date.toInstant()); + } +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/permission/PermissionService.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/permission/PermissionService.java index c35601c..2388eee 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/permission/PermissionService.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/permission/PermissionService.java @@ -9,6 +9,7 @@ import me.iiv.agent.entity.m0.DeviceLifeCycle; import me.iiv.agent.entity.m0.DistrictDistrictStrategy; import me.iiv.agent.entity.m0.HardwareCleanLog; import me.iiv.agent.entity.m0.HardwareView; +import me.iiv.agent.entity.m0.HardSockLife; import me.iiv.agent.entity.m0.Properyt; import me.iiv.agent.entity.m0.ToiletAirquality; import me.iiv.agent.entity.m0.User; @@ -23,7 +24,9 @@ import me.iiv.agent.entity.m2.RelocationOrder; import me.iiv.agent.entity.m2.WarehouseAgentAuth; import me.iiv.agent.entity.m2.WithdrawOrder; import me.iiv.agent.entity.m2.WarehouseIn; +import me.iiv.agent.entity.m2.WarehouseInDetails; import me.iiv.agent.entity.m2.WarehouseOut; +import me.iiv.agent.entity.m2.WarehouseOutDetails; import me.iiv.agent.entity.m2.WarehouseStockManage; import me.iiv.agent.entity.svc.AgentApiKey; import me.iiv.agent.mapper.m0.AgentManageDistrictAuthMapper; @@ -255,6 +258,101 @@ public class PermissionService { .or().apply(participantExists, user.getId())); } + /** + * 出库明细权限(warehouse.out.details.list.query 专用,REQ-20261105-0020): + * 主单同口径的明细级变体——代理范围(visibleAgentIds)的明细行、 + * 可见仓库(warehouse.agent_id/manager_agent_id 命中代理范围)涉及的明细行, + * 以及本人参与审批的主单下的明细行,三者取并集。明细行自带 warehouse_id/warehouse_out_id, + * EXISTS 直接落在明细表上,不回Join主单。 + */ + public void applyWarehouseOutDetailsRowFilter(LambdaQueryWrapper wrapper, User user) { + List agentIds = visibleAgentIds(user); + if (agentIds == null) { + return; + } + if (agentIds.isEmpty()) { + wrapper.eq(WarehouseOutDetails::getId, 0L); + return; + } + + String firstIds = placeholders(0, agentIds.size()); + String secondIds = placeholders(agentIds.size(), agentIds.size()); + Object[] warehouseArgs = new Object[agentIds.size() * 2]; + for (int i = 0; i < agentIds.size(); i++) { + warehouseArgs[i] = agentIds.get(i); + warehouseArgs[i + agentIds.size()] = agentIds.get(i); + } + String visibleWarehouseExists = "EXISTS (SELECT 1 FROM warehouse w " + + "WHERE w.id = warehouse_out_details.warehouse_id AND w.soft_delete = 0 " + + "AND (w.agent_id IN (" + firstIds + ") OR w.manager_agent_id IN (" + secondIds + ")))"; + String participantExists = "EXISTS (SELECT 1 FROM warehouse_out_audit_process woap " + + "WHERE woap.warehouse_out_id = warehouse_out_details.warehouse_out_id " + + "AND woap.soft_delete = 0 AND woap.user_id = {0})"; + + wrapper.and(w -> w.in(WarehouseOutDetails::getAgentId, agentIds) + .or().apply(visibleWarehouseExists, warehouseArgs) + .or().apply(participantExists, user.getId())); + } + + /** + * 入库明细权限(warehouse.in.details.list.query 专用,REQ-20261105-0020): + * 与出库明细同口径,audit 表换 warehouse_in_audit_process。 + */ + public void applyWarehouseInDetailsRowFilter(LambdaQueryWrapper wrapper, User user) { + List agentIds = visibleAgentIds(user); + if (agentIds == null) { + return; + } + if (agentIds.isEmpty()) { + wrapper.eq(WarehouseInDetails::getId, 0L); + return; + } + + String firstIds = placeholders(0, agentIds.size()); + String secondIds = placeholders(agentIds.size(), agentIds.size()); + Object[] warehouseArgs = new Object[agentIds.size() * 2]; + for (int i = 0; i < agentIds.size(); i++) { + warehouseArgs[i] = agentIds.get(i); + warehouseArgs[i + agentIds.size()] = agentIds.get(i); + } + String visibleWarehouseExists = "EXISTS (SELECT 1 FROM warehouse w " + + "WHERE w.id = warehouse_in_details.warehouse_id AND w.soft_delete = 0 " + + "AND (w.agent_id IN (" + firstIds + ") OR w.manager_agent_id IN (" + secondIds + ")))"; + String participantExists = "EXISTS (SELECT 1 FROM warehouse_in_audit_process wiap " + + "WHERE wiap.warehouse_in_id = warehouse_in_details.warehouse_in_id " + + "AND wiap.soft_delete = 0 AND wiap.user_id = {0})"; + + wrapper.and(w -> w.in(WarehouseInDetails::getAgentId, agentIds) + .or().apply(visibleWarehouseExists, warehouseArgs) + .or().apply(participantExists, user.getId())); + } + + /** + * 库存物品详情(m0 视图 hard_sock_life)行权限,1:1 对齐老系统 + * WrapperUtils.getWarehouseAuth(/warehouseHardStock/listByPage):总部/服务主体不加过滤; + * 代理可看本公司及 warehouse_agent_auth 授权代管公司作为管理公司(agent_id) + * 或所属公司(warehouse_owner_id)的物品。与 applyWarehouseStockRowFilter 同语义、 + * 不同实体(HardwareCleanLog 变体模式)。 + */ + public void applyHardSockLifeRowFilter(LambdaQueryWrapper wrapper, User user) { + if (servicePrincipalNoFilter(user)) { + return; + } + if (!isAgentAuthority(user.getAuthorityId())) { + return; + } + if (user.getAgentId() == null) { + throw new IllegalStateException("代理商用户未关联代理商,拒绝访问业务数据"); + } + Set ids = new LinkedHashSet<>(); + ids.add(user.getAgentId()); + ids.addAll(managedAuths(user.getId()).stream() + .map(WarehouseAgentAuth::getManagerAgentId) + .collect(Collectors.toList())); + wrapper.and(w -> w.in(HardSockLife::getAgentId, ids) + .or().in(HardSockLife::getWarehouseOwnerId, ids)); + } + /** * 送货工单权限:1:1 复刻老系统 getWorkOrderAuth(type=1)。总部看全部;其余代理身份 * 可看申请代理、所属代理(本代理+授权代管,不含直属下级)、授权代管物业范围, diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/WarehouseDetailsStatisToolsTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/WarehouseDetailsStatisToolsTest.java new file mode 100644 index 0000000..a900fe4 --- /dev/null +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/WarehouseDetailsStatisToolsTest.java @@ -0,0 +1,318 @@ +package me.iiv.agent.mcp.tool; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import me.iiv.agent.entity.m0.HardSockLife; +import me.iiv.agent.entity.m0.User; +import me.iiv.agent.entity.m2.WarehouseInDetails; +import me.iiv.agent.entity.m2.WarehouseOutDetails; +import me.iiv.agent.mapper.m0.HardSockLifeMapper; +import me.iiv.agent.mapper.m2.WarehouseInDetailsMapper; +import me.iiv.agent.mapper.m2.WarehouseOutDetailsMapper; +import me.iiv.agent.permission.PermissionService; +import me.iiv.agent.security.AuthContext; +import me.iiv.agent.security.AuthContextHolder; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.Arrays; +import java.util.Date; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class WarehouseDetailsStatisToolsTest { + + @Mock WarehouseOutDetailsMapper outDetailsMapper; + @Mock WarehouseInDetailsMapper inDetailsMapper; + @Mock HardSockLifeMapper hardSockLifeMapper; + @Mock PermissionService permissionService; + + WarehouseDetailsStatisTools tools; + + @BeforeEach + void setUp() { + MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), ""); + TableInfoHelper.initTableInfo(assistant, WarehouseOutDetails.class); + TableInfoHelper.initTableInfo(assistant, WarehouseInDetails.class); + TableInfoHelper.initTableInfo(assistant, HardSockLife.class); + tools = new WarehouseDetailsStatisTools(outDetailsMapper, inDetailsMapper, + hardSockLifeMapper, permissionService); + User user = new User(); + user.setId(7L); + AuthContextHolder.set(new AuthContext(null, user)); + } + + @AfterEach + void tearDown() { + AuthContextHolder.clear(); + } + + @Test + void outDetailsAppliesHardFiltersPermissionSortAndMapsRecord() { + WarehouseOutDetails detail = new WarehouseOutDetails(); + detail.setId(11L); + detail.setOutOrderNo("OUT-1"); + detail.setWarehouseOutId(5L); + detail.setAgentId(3L); + detail.setAgentName("代理A"); + detail.setWarehouseId(8L); + detail.setWarehouseName("一号仓"); + detail.setMaterialNo("MAT-1"); + detail.setMaterielFullName("擦手纸"); + detail.setSpecifications("A型"); + detail.setSnCode("SN001"); + detail.setOutCount(2); + detail.setPrice(new BigDecimal("1.50")); + detail.setAllMoney(new BigDecimal("3.00")); + detail.setOutDate(LocalDate.of(2026, 10, 1)); + detail.setOrderState(3); + detail.setUseCustomerType(1); + detail.setUseCustomerName("渠道商甲"); + detail.setOutConfirmTime(new Date(0)); + detail.setCreateTime(new Date(0)); + when(outDetailsMapper.selectCount(any())).thenReturn(1L); + when(outDetailsMapper.selectList(any())).thenReturn(List.of(detail)); + + Map result = tools.warehouseOutDetailsListQuery( + 0, "2026-10-01", "2026-10-31", 8L, 2L, " OUT-1 ", "AL-1", "MO-1", "MAT-1", + " SN ", " 擦手 ", " A型 ", " 渠道 ", "DEL-1", "INS-1", "PK-1", + List.of(3L), 3L, 1, 9L, 2, 500); + + assertEquals(1L, result.get("total")); + assertEquals(2, result.get("page")); + assertEquals(100, result.get("rows")); + assertEquals("rows 超过上限 100,已截断为 100", result.get("notice")); + @SuppressWarnings("unchecked") + Map record = ((List>) result.get("records")).get(0); + assertEquals("OUT-1", record.get("outOrderNo")); + assertEquals("已完成", record.get("orderStateText")); + assertEquals("渠道商", record.get("useCustomerTypeText")); + assertEquals("2026-10-01", record.get("outDate")); + assertEquals("1970-01-01 08:00:00", record.get("outConfirmTime")); + verify(permissionService).applyWarehouseOutDetailsRowFilter(any(), any()); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = + ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(outDetailsMapper).selectList(captor.capture()); + String sql = captor.getValue().getSqlSegment(); + assertTrue(sql.contains("query_flag =")); + assertTrue(sql.contains("order_state =")); + assertTrue(sql.contains("soft_delete =")); + assertTrue(sql.contains("warehouse_id =")); + assertTrue(sql.contains("out_order_no =")); + assertTrue(sql.contains("sn_code LIKE")); + assertTrue(sql.contains("materiel_full_name LIKE")); + assertTrue(sql.contains("delivery_order_no LIKE")); + assertTrue(sql.contains("materiel_index_id IN")); + assertTrue(sql.contains("out_date >=")); + assertTrue(sql.contains("out_date <=")); + assertTrue(sql.contains("out_date DESC")); + assertTrue(sql.contains("id DESC")); + assertTrue(sql.contains("LIMIT 100,100")); + } + + @Test + void outDetailsDefaultsToRecent180DaysAndSupportsCreateTime() { + when(outDetailsMapper.selectCount(any())).thenReturn(0L); + when(outDetailsMapper.selectList(any())).thenReturn(List.of()); + + tools.warehouseOutDetailsListQuery(1, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, null); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = + ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(outDetailsMapper).selectList(captor.capture()); + String sql = captor.getValue().getSqlSegment(); + assertTrue(sql.contains("create_time >=")); + assertTrue(sql.contains("create_time <")); + } + + @Test + void outDetailsRejectsBadDateTypeAndOverlongSpan() { + assertThrows(IllegalArgumentException.class, () -> tools.warehouseOutDetailsListQuery( + 3, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, null)); + assertThrows(IllegalArgumentException.class, () -> tools.warehouseOutDetailsListQuery( + 0, "2026-01-01", "2026-07-01", null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, null)); + assertThrows(IllegalArgumentException.class, () -> tools.warehouseOutDetailsListQuery( + 0, "2026/01/01", null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, null)); + } + + @Test + void inDetailsAppliesHardFiltersPermissionSortAndMapsRecord() { + WarehouseInDetails detail = new WarehouseInDetails(); + detail.setId(21L); + detail.setInOrderNo("IN-1"); + detail.setWarehouseInId(6L); + detail.setAgentId(3L); + detail.setAgentName("代理A"); + detail.setWarehouseId(8L); + detail.setWarehouseName("一号仓"); + detail.setMaterialNo("MAT-2"); + detail.setMaterielFullName("洗手液"); + detail.setSnCode("SN002"); + detail.setInCount(5); + detail.setInDate(LocalDate.of(2026, 10, 2)); + detail.setOrderState(3); + detail.setGoodInState(1); + detail.setInConfirmTime(new Date(0)); + detail.setLineMemo("行备注"); + detail.setCreateTime(new Date(0)); + when(inDetailsMapper.selectCount(any())).thenReturn(1L); + when(inDetailsMapper.selectList(any())).thenReturn(List.of(detail)); + + Map result = tools.warehouseInDetailsListQuery( + 0, "2026-10-01", "2026-10-31", 8L, 2L, " IN-1 ", "AL-1", "MAT-2", 3L, + "INS-1", "PK-1", "WD-1", " SN ", " 洗手 ", " B型 ", "MO-1", "OUT-1", " 备注 ", + List.of(3L), 1, 20); + + assertEquals(1L, result.get("total")); + @SuppressWarnings("unchecked") + Map record = ((List>) result.get("records")).get(0); + assertEquals("IN-1", record.get("inOrderNo")); + assertEquals("已完成", record.get("orderStateText")); + assertEquals("已入库", record.get("goodInStateText")); + assertEquals("行备注", record.get("lineMemo")); + verify(permissionService).applyWarehouseInDetailsRowFilter(any(), any()); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = + ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(inDetailsMapper).selectList(captor.capture()); + String sql = captor.getValue().getSqlSegment(); + assertTrue(sql.contains("order_state =")); + assertTrue(sql.contains("in_order_no =")); + assertTrue(sql.contains("withdraw_order_no =")); + assertTrue(sql.contains("mall_order_no LIKE")); + assertTrue(sql.contains("line_memo LIKE")); + assertTrue(sql.contains("in_date >=")); + assertTrue(sql.contains("in_date DESC")); + assertTrue(sql.contains("id DESC")); + assertTrue(sql.contains("LIMIT 0,20")); + } + + @Test + void inDetailsSupportsConfirmTimeDateType() { + when(inDetailsMapper.selectCount(any())).thenReturn(0L); + when(inDetailsMapper.selectList(any())).thenReturn(List.of()); + + tools.warehouseInDetailsListQuery(2, "2026-10-01", "2026-10-31", null, null, null, null, + null, null, null, null, null, null, null, null, null, null, null, null, null, null); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = + ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(inDetailsMapper).selectList(captor.capture()); + String sql = captor.getValue().getSqlSegment(); + assertTrue(sql.contains("in_confirm_time >=")); + assertTrue(sql.contains("in_confirm_time <")); + } + + @Test + void hardStockFixedInStockAppliesPermissionAndMapsRecord() { + HardSockLife item = new HardSockLife(); + item.setId(31L); + item.setSnCode("SN100"); + item.setLifeSnCode("DEV100"); + item.setAgentId(3L); + item.setAgentName("代理A"); + item.setWarehouseId(8L); + item.setWarehouseName("一号仓"); + item.setWarehouseOwnerId(4L); + item.setWarehouseOwnerName("所属B"); + item.setAssetOwnerId(5L); + item.setAssetOwnerName("资产C"); + item.setMaterialNo("MAT-3"); + item.setMaterielFullName("大卷纸"); + item.setStockState(0); + item.setCurrentInventory(1); + item.setAssembleState(1); + item.setChargeFlag(0); + item.setReplaceState(0); + item.setUseCustomerType(2); + item.setOutInOrderNo("IN-9"); + item.setOutInDate(new Date(0)); + item.setCreateTime(new Date(0)); + when(hardSockLifeMapper.selectCount(any())).thenReturn(1L); + when(hardSockLifeMapper.selectList(any())).thenReturn(List.of(item)); + + Map result = tools.warehouseHardStockListQuery( + 3L, 5L, List.of(8L), List.of(3L), " MAT ", " 大卷 ", " C型 ", " SN1 ", 1, 20); + + assertEquals(1L, result.get("total")); + @SuppressWarnings("unchecked") + Map record = ((List>) result.get("records")).get(0); + assertEquals("SN100", record.get("snCode")); + assertEquals("DEV100", record.get("lifeSnCode")); + assertEquals("在库", record.get("stockStateText")); + assertEquals("已匹配", record.get("assembleStateText")); + assertEquals("费用化", record.get("chargeFlagText")); + assertEquals("未替换", record.get("replaceStateText")); + assertEquals("直营客户", record.get("useCustomerTypeText")); + verify(permissionService).applyHardSockLifeRowFilter(any(), any()); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = + ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(hardSockLifeMapper).selectList(captor.capture()); + String sql = captor.getValue().getSqlSegment(); + assertTrue(sql.contains("stock_state =")); + assertTrue(sql.contains("soft_delete =")); + assertTrue(sql.contains("agent_id =")); + assertTrue(sql.contains("asset_owner_id =")); + assertTrue(sql.contains("warehouse_id IN")); + assertTrue(sql.contains("material_no LIKE")); + assertTrue(sql.contains("sn_code LIKE")); + assertTrue(sql.contains("id DESC")); + assertTrue(sql.contains("LIMIT 0,20")); + } + + @Test + void emptyResultReturnsZeroTotal() { + when(hardSockLifeMapper.selectCount(any())).thenReturn(0L); + when(hardSockLifeMapper.selectList(any())).thenReturn(List.of()); + + Map result = tools.warehouseHardStockListQuery( + null, null, null, null, null, null, null, null, null, null); + + assertEquals(0L, result.get("total")); + assertTrue(((List) result.get("records")).isEmpty()); + } + + @Test + void springAiPublishesThreeToolSchemas() { + var callbacks = org.springframework.ai.tool.method.MethodToolCallbackProvider.builder() + .toolObjects(tools).build().getToolCallbacks(); + Map schemas = Arrays.stream(callbacks).collect(java.util.stream.Collectors.toMap( + callback -> callback.getToolDefinition().name(), + callback -> callback.getToolDefinition().inputSchema())); + + assertEquals(3, schemas.size()); + assertTrue(schemas.get("warehouse.out.details.list.query").contains("dateType")); + assertTrue(schemas.get("warehouse.in.details.list.query").contains("withdrawOrderNo")); + String hardStockSchema = schemas.get("warehouse.hardstock.list.query"); + assertTrue(hardStockSchema.contains("assetOwnerId")); + assertTrue(!hardStockSchema.contains("stockState")); + } +} diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/permission/PermissionServiceWarehouseDetailsRowFilterTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/permission/PermissionServiceWarehouseDetailsRowFilterTest.java new file mode 100644 index 0000000..236e8aa --- /dev/null +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/permission/PermissionServiceWarehouseDetailsRowFilterTest.java @@ -0,0 +1,164 @@ +package me.iiv.agent.permission; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import me.iiv.agent.entity.m0.Agent; +import me.iiv.agent.entity.m0.Authority; +import me.iiv.agent.entity.m0.HardSockLife; +import me.iiv.agent.entity.m0.User; +import me.iiv.agent.entity.m2.WarehouseAgentAuth; +import me.iiv.agent.entity.m2.WarehouseInDetails; +import me.iiv.agent.entity.m2.WarehouseOutDetails; +import me.iiv.agent.mapper.m0.AgentManageDistrictAuthMapper; +import me.iiv.agent.mapper.m0.AgentMapper; +import me.iiv.agent.mapper.m0.AuthorityMapper; +import me.iiv.agent.mapper.m0.ProperytMapper; +import me.iiv.agent.mapper.m2.WarehouseAgentAuthMapper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.StringRedisTemplate; + +import java.util.Collection; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PermissionServiceWarehouseDetailsRowFilterTest { + + @Mock AuthorityMapper authorityMapper; + @Mock AgentMapper agentMapper; + @Mock WarehouseAgentAuthMapper warehouseAgentAuthMapper; + @Mock AgentManageDistrictAuthMapper agentManageDistrictAuthMapper; + @Mock ProperytMapper properytMapper; + @Mock StringRedisTemplate redisTemplate; + + PermissionService service; + + @BeforeEach + void setUp() { + MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), ""); + TableInfoHelper.initTableInfo(assistant, WarehouseOutDetails.class); + TableInfoHelper.initTableInfo(assistant, WarehouseInDetails.class); + TableInfoHelper.initTableInfo(assistant, HardSockLife.class); + service = new PermissionService(authorityMapper, agentMapper, warehouseAgentAuthMapper, + agentManageDistrictAuthMapper, properytMapper, redisTemplate); + } + + @Test + void outDetailsHeadquartersAddsNoFilter() { + User user = user(7L, 9L, null); + when(authorityMapper.selectById(9L)).thenReturn(authority(9L, 0)); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + + service.applyWarehouseOutDetailsRowFilter(wrapper, user); + + assertTrue(wrapper.getSqlSegment().isBlank()); + } + + @Test + void outDetailsAgentCombinesAgentWarehouseAndParticipantVisibility() { + User user = user(7L, 9L, 10L); + when(authorityMapper.selectById(9L)).thenReturn(authority(9L, 1)); + Agent child = new Agent(); + child.setId(11L); + when(agentMapper.selectList(any())).thenReturn(List.of(child)); + WarehouseAgentAuth managed = new WarehouseAgentAuth(); + managed.setManagerAgentId(12L); + when(warehouseAgentAuthMapper.selectList(any())).thenReturn(List.of(managed)); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + + service.applyWarehouseOutDetailsRowFilter(wrapper, user); + + String sql = wrapper.getSqlSegment(); + Collection values = wrapper.getParamNameValuePairs().values(); + assertTrue(sql.contains("agent_id IN")); + assertTrue(sql.contains("EXISTS")); + assertTrue(sql.contains("warehouse w")); + assertTrue(sql.contains("warehouse_out_audit_process")); + assertTrue(values.containsAll(List.of(7L, 10L, 11L, 12L))); + } + + @Test + void inDetailsAgentUsesInAuditProcess() { + User user = user(7L, 9L, 10L); + when(authorityMapper.selectById(9L)).thenReturn(authority(9L, 1)); + when(agentMapper.selectList(any())).thenReturn(List.of()); + when(warehouseAgentAuthMapper.selectList(any())).thenReturn(List.of()); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + + service.applyWarehouseInDetailsRowFilter(wrapper, user); + + String sql = wrapper.getSqlSegment(); + assertTrue(sql.contains("agent_id IN")); + assertTrue(sql.contains("warehouse_in_audit_process")); + assertTrue(wrapper.getParamNameValuePairs().values().contains(7L)); + } + + @Test + void hardSockLifeAgentFiltersAgentOrWarehouseOwner() { + User user = user(7L, 9L, 10L); + when(authorityMapper.selectById(9L)).thenReturn(authority(9L, 1)); + WarehouseAgentAuth managed = new WarehouseAgentAuth(); + managed.setManagerAgentId(12L); + when(warehouseAgentAuthMapper.selectList(any())).thenReturn(List.of(managed)); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + + service.applyHardSockLifeRowFilter(wrapper, user); + + String sql = wrapper.getSqlSegment(); + Collection values = wrapper.getParamNameValuePairs().values(); + assertTrue(sql.contains("agent_id IN")); + assertTrue(sql.contains("warehouse_owner_id IN")); + assertTrue(sql.contains("OR")); + assertTrue(values.containsAll(List.of(10L, 12L))); + } + + @Test + void hardSockLifeHeadquartersAddsNoFilter() { + User user = user(7L, 9L, null); + when(authorityMapper.selectById(9L)).thenReturn(authority(9L, 0)); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + + service.applyHardSockLifeRowFilter(wrapper, user); + + assertTrue(wrapper.getSqlSegment().isBlank()); + } + + @Test + void agentWithoutAgentIdFailsClosed() { + User user = user(7L, 9L, null); + when(authorityMapper.selectById(9L)).thenReturn(authority(9L, 1)); + + assertThrows(IllegalStateException.class, + () -> service.applyWarehouseOutDetailsRowFilter(new LambdaQueryWrapper<>(), user)); + assertThrows(IllegalStateException.class, + () -> service.applyWarehouseInDetailsRowFilter(new LambdaQueryWrapper<>(), user)); + assertThrows(IllegalStateException.class, + () -> service.applyHardSockLifeRowFilter(new LambdaQueryWrapper<>(), user)); + } + + private static User user(Long id, Long authorityId, Long agentId) { + User user = new User(); + user.setId(id); + user.setAuthorityId(authorityId); + user.setAgentId(agentId); + return user; + } + + private static Authority authority(Long id, int isAgent) { + Authority authority = new Authority(); + authority.setId(id); + authority.setIsagent(isAgent); + authority.setSoftDelete(0); + return authority; + } +}