Compare commits
31 Commits
9c78dcb6db
...
e4f5bf7530
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4f5bf7530 | ||
|
|
44ef533aa0 | ||
|
|
4c274804e0 | ||
|
|
b6035976ec | ||
|
|
62372abeb6 | ||
|
|
d864294dde | ||
|
|
ec293fa8b2 | ||
|
|
bc1352b918 | ||
|
|
8d195c2d51 | ||
|
|
0ddda5363e | ||
|
|
2e526be3fb | ||
|
|
3c75537f54 | ||
|
|
ec7742a39c | ||
|
|
f634e7cf07 | ||
|
|
e3ab3a094a | ||
|
|
356c8520ca | ||
|
|
2f0d978dbf | ||
|
|
7f2b296339 | ||
|
|
326cf77010 | ||
|
|
c3835fb28d | ||
|
|
f5e9029c46 | ||
|
|
8a61186cfd | ||
|
|
444c2ed1af | ||
|
|
7b926ff60a | ||
|
|
f34803e1a2 | ||
|
|
e853846070 | ||
|
|
0e3b1735a5 | ||
|
|
f3146af838 | ||
|
|
4960a88497 | ||
|
|
84e7f4d7d0 | ||
|
|
616a4a3b1d |
94
docs/api.md
94
docs/api.md
|
|
@ -127,7 +127,7 @@
|
|||
|
||||
### 本文档覆盖范围
|
||||
|
||||
本文档详述**物料、入库、出库、库存**四个资源域。以下分组已实现但未在本文档展开,需要时请直接查阅 `internal/handler/api.go` 的路由注册:
|
||||
本文档详述**物料、入库、出库、库存**四个资源域,物料批量导入(`/material-import`)已并入 §1。以下分组已实现但未在本文档展开,需要时请直接查阅 `internal/handler/api.go` 的路由注册:
|
||||
|
||||
| 分组 | 路径 | 权限 |
|
||||
|-------------------------|---------------------------------------------|--------|
|
||||
|
|
@ -143,6 +143,7 @@
|
|||
| 物料申请 | `/applications`、`/applications/my` | 需登录 |
|
||||
| 操作日志 | `/logs`、`/logs/materials/:materialId` | 需登录 |
|
||||
| 图片上传 | `POST /upload` | 需登录 |
|
||||
| 物料批量导入 | `GET /material-import/template`、`POST /material-import` | 需登录,见 §1 |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -254,7 +255,7 @@
|
|||
| spec | string | 否 | 规格 |
|
||||
| manufacturer_model| string | 否 | 厂家型号 |
|
||||
| base_unit_id | integer | 否 | 基本计量单位 ID |
|
||||
| unit | string | 否 | 单位名称(冗余展示字段) |
|
||||
| unit | string | 否 | 单位编码(冗余展示字段) |
|
||||
| material_image | string | 否 | 图片地址 |
|
||||
| organization_id | integer | 否 | 传入会被忽略,服务端取当前用户的组织 |
|
||||
|
||||
|
|
@ -305,7 +306,7 @@
|
|||
| spec | string | 否 | 规格 |
|
||||
| manufacturer_model| string | 否 | 厂家型号 |
|
||||
| base_unit_id | integer | 否 | 基本计量单位 ID |
|
||||
| unit | string | 否 | 单位名称 |
|
||||
| unit | string | 否 | 单位编码 |
|
||||
| material_image | string | 否 | 图片地址 |
|
||||
| status | string | 否 | 物料状态 |
|
||||
|
||||
|
|
@ -388,6 +389,93 @@
|
|||
|
||||
---
|
||||
|
||||
### GET /material-import/template
|
||||
|
||||
下载批量导入用的 xlsx 模板。模板随代码实时生成、不落盘,所以下载到的列定义永远与当前校验规则一致。
|
||||
|
||||
**请求:** 无参数。
|
||||
|
||||
**响应:** xlsx 文件流,`Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`,文件名为 `物料导入模板.xlsx`。
|
||||
|
||||
模板含两张工作表,按以下顺序排列,打开时默认显示「填写说明」:
|
||||
|
||||
| 工作表 | 内容 |
|
||||
|--------|------|
|
||||
| 填写说明 | 字段规则、填写与上传步骤、容量限制、整批校验、重复导入提醒和参考示例 |
|
||||
| 物料数据 | 第 1 行是冻结表头,从第 2 行填写;五列采用文本格式,数据页不放示例物料 |
|
||||
|
||||
`物料数据` 的表头是 5 列:`物料名称`、`规格`、`单位`、`大类`、`小类`。解析时**按列名定位,不按列位置**:
|
||||
|
||||
- 列顺序可以随意调整;表头允许带首尾空格和末尾的 `*` 必填标记;不认识的列忽略;
|
||||
- 必填列 `物料名称`、`大类`、`小类` 缺任何一列,判为模板不合法并返回「模板格式不正确,请重新下载最新模板」;
|
||||
- 选填列 `规格`、`单位` 可以整列删掉,取值按空处理。
|
||||
|
||||
分类**按名称**填写;名称之外也接受**编码**和界面上的「编码 名称」写法(界面一律显示成 `A 机械结构类`,用户整段照抄进模板是常事)。后端按「大类 + 小类」在本公司内组合匹配(小类名在一个公司内会重名,例如多个大类下都有「其他」,所以必须两级组合)。名称、编码、「编码 名称」三种写法同名或同码时以**名称**优先。单位按名称匹配,同时兼容按编码匹配(如 `kg` 与 `千克`)。
|
||||
|
||||
### POST /material-import
|
||||
|
||||
上传填好的 xlsx,**校验通过即在同一个请求内入库**——没有「预览确认」环节,也没有暂存任务 ID。
|
||||
|
||||
**请求:** `multipart/form-data`,文件字段名 `file`。仅支持 `.xlsx`,单文件不超过 1 MB,数据区不超过 10,000 行(不含表头)。
|
||||
|
||||
服务端在 multipart 解析前限制请求体为 1 MiB + 64 KiB(表单开销),文件本身仍限制为 1 MiB;超大请求在读到上限时提前拒绝。Excel 使用逐行迭代读取,发现第 10,001 条非空数据时立即停止,表头和空行不计数;较大的工作表 XML 使用临时文件,避免将整张表的所有行缓存到内存。
|
||||
|
||||
**逐行校验规则:**
|
||||
|
||||
| 列 | 规则 |
|
||||
|----|------|
|
||||
| 物料名称 | 必填,去首尾空格后非空,≤ 200 字 |
|
||||
| 规格 | 选填,≤ 200 字 |
|
||||
| 单位 | 选填;留空时默认「个」。非空时必须在当前公司已启用单位中按名称或编码匹配到 |
|
||||
| 大类 | 必填,必须是当前公司已启用的顶层分类,按名称、编码或「编码 名称」匹配 |
|
||||
| 小类 | 必填,必须是该大类下已启用的小类,同样认名称、编码或「编码 名称」 |
|
||||
| 编码规则 | 该小类必须存在启用的编码规则(`code_rules.status = 'active'`) |
|
||||
|
||||
物料编码由系统按小类编码规则生成,**不接受模板里的编码列**。单位落库统一写该单位的**编码**——模板里填名称(如「千克」)时也会转成编码,与单条新增保持一致。同一行可能产生多条原因,用中文分号连接。
|
||||
|
||||
**成功响应 `201 Created`:**
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "成功导入 200 条物料",
|
||||
"data": { "total_rows": 200, "success_rows": 200, "failed_rows": 0, "errors": [] }
|
||||
}
|
||||
```
|
||||
|
||||
**校验失败响应 `400 Bad Request`**(HTTP 状态是 400,但 `data` 里带完整明细):
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 400,
|
||||
"message": "有 3 行校验未通过,本次未导入任何数据",
|
||||
"data": {
|
||||
"total_rows": 200, "success_rows": 0, "failed_rows": 3,
|
||||
"errors": [
|
||||
{ "row": 3, "data": { "name": "", "spec": "M8x30", "unit": "个", "major": "机械结构类", "minor": "标准件" }, "reason": "物料名称不能为空" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`row` 是 **Excel 里的真实行号**(从 1 开始、含表头行),用户可以直接对着 Excel 定位;`data` 是该行的原始内容。
|
||||
|
||||
**状态码:**
|
||||
|
||||
- `201 Created` — 全部行通过并入库
|
||||
- `400 Bad Request` — 存在校验未通过的行(整批不入库);模板不合法(「模板格式不正确,请重新下载最新模板」)、行数超限(「导入行数超过 10000 行上限」)、未选文件(「请选择要导入的 xlsx 文件」)、文件超过 1 MB(「文件超过 1MB 上限」)
|
||||
- `500 Internal Server Error` — 入库失败,事务已回滚,整批未落库
|
||||
|
||||
**行为说明:**
|
||||
|
||||
- **任意一行校验失败,整批不入库**,响应中返回全部逐行错误;重新上传前请先修正后重试。
|
||||
- 整批在**一个事务**内完成:按小类锁编码规则 → 分配编码 → 分批插入物料(每批 500 行)→ 写审计日志;任一步失败整体回滚。
|
||||
- **仅记录成功导入**:事务提交后写一条 `material_import_logs` 成功汇总,包含操作人、公司、时间、文件名、总行数和成功数,失败数为 0、状态为 `success`、失败原因为空;失败导入不保存历史记录。**逐行错误明细只随响应返回,不落库。** 成功新增的每件物料仍写 CREATE 日志及完整 `new_value` JSON 快照;现有日志页「查看详情」显示「批量导入创建:物料名称(物料编码)」,下方按行展示导入时的编码、名称、规格、分类、单位和状态;该详情是物料操作日志,不展示导入文件名或批次汇总。
|
||||
- 首版只支持**新增**物料:不做更新,也不自动创建分类或单位。
|
||||
- **不做重复检测**:同一份文件重复上传会真的导入两遍(生成不同的编码)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 入库接口
|
||||
|
||||
入库是物料**进入库存视野**的唯一入口:物料的第一行库存记录和第一个预警阈值都在这里产生。入库成功后,服务端会通过 WebSocket 广播库存变更。
|
||||
|
|
|
|||
3198
docs/superpowers/plans/2026-10-10-material-bulk-import.md
Normal file
3198
docs/superpowers/plans/2026-10-10-material-bulk-import.md
Normal file
File diff suppressed because it is too large
Load Diff
|
|
@ -1,8 +1,8 @@
|
|||
# 物料管理系统回归测试用例
|
||||
|
||||
更新时间:2026-10-09
|
||||
更新时间:2026-10-12
|
||||
|
||||
本文用于版本发布前的人工回归检查。用例依据当前仓库已实现的入库、出库、库存、物料申请审批、物料主数据和组织隔离功能编写;权限功能暂不纳入本轮回归。测试时请使用隔离的测试环境和测试账号;涉及库存变更的用例应使用专用测试物料,执行前记录库存基线。
|
||||
本文用于版本发布前的人工回归检查。用例依据当前仓库已实现的入库、出库、库存、物料申请审批、物料主数据、物料批量导入和组织隔离功能编写;权限功能暂不纳入本轮回归。测试时请使用隔离的测试环境和测试账号;涉及库存变更的用例应使用专用测试物料,执行前记录库存基线。
|
||||
|
||||
## 通用测试准备
|
||||
|
||||
|
|
@ -192,11 +192,11 @@
|
|||
|
||||
**测试内容:** 验证物料分类、计量单位和编码规则能够配置,并被新增物料流程正确使用。
|
||||
|
||||
**前置数据:** 使用可维护物料主数据的测试账号;准备尚未使用的分类编码、单位编码和编码前缀。
|
||||
**前置数据:** 使用可维护物料主数据的测试账号;准备尚未使用的大类和小类编码、单位编码。
|
||||
|
||||
**操作步骤:** 在「物料主数据 → 基础配置」依次新增一个大类、其下的小类、一个计量单位,并为小类配置启用的编码规则(例如前缀 `RT`、流水号 4 位、下一流水号 1);进入「物料」新增物料,选择该大类、小类和单位,保存后再次新增一件同分类物料。
|
||||
**操作步骤:** 在「物料主数据 → 基础配置」依次新增一个大类、其下的小类、一个计量单位,并为小类配置启用的编码规则(前缀按约定取该小类编码,如小类编码为 `A01` 时前缀填 `A01`;流水号 4 位、下一流水号 1);进入「物料」新增物料,选择该大类、小类和单位,保存后再次新增一件同分类物料。
|
||||
|
||||
**判断指标:** 配置项保存并在列表显示正确;新增物料可选到对应分类和单位;系统按规则生成编码(例如 `RT0001`、`RT0002`),编码不重复,物料详情显示所选分类和单位。
|
||||
**判断指标:** 配置项保存并在列表显示正确;新增物料可选到对应分类和单位;系统按规则生成编码,为小类编码加 4 位流水号(如小类 `A01` 时为 `A010001`、`A010002`),编码不重复,物料详情显示所选分类和单位。
|
||||
|
||||
### TC-19 供应商和客户资料维护
|
||||
|
||||
|
|
@ -208,10 +208,84 @@
|
|||
|
||||
**判断指标:** 两类资料均保存成功且字段显示正确;供应商分类筛选结果匹配;编辑后联系人信息更新;供应商资料显示在供应商页面,客户资料显示在客户页面,页面间无错误混列。
|
||||
|
||||
## 物料批量导入专项回归(TC-20到TC-27)
|
||||
|
||||
### 测试准备
|
||||
|
||||
- 使用测试环境和可操作物料的账号,准备可切换的组织 A、B。
|
||||
- 在组织 A 准备启用的大类、小类、单位「个」及编码为 `kg` 的单位,为小类配置启用的编码规则;另准备停用分类、停用单位和未配置规则的小类。
|
||||
- 每个文件使用独立的测试物料名称,执行前记录物料列表和规则下一流水号。以下操作均通过页面和 Excel 手动完成,结果填写下方记录表。
|
||||
|
||||
### TC-20 下载导入模板
|
||||
|
||||
**测试内容:** 验证模板下载和填写说明。
|
||||
|
||||
**操作步骤:** 进入「物料」页面,点击「下载模板」,用 Excel 打开下载文件,查看工作表、表头和填写说明。
|
||||
|
||||
**判断指标:** 文件可正常打开,第一个工作表为「填写说明」且默认打开,第二个为「物料数据」。说明页包含字段规则、操作步骤、容量限制、整批失败和重复导入提醒及参考示例;数据页保持空白,第 1 行表头为「物料名称、规格、单位、大类、小类」,从第 2 行填写,滚动时表头保持可见。名称、大类、小类必填,规格和单位选填;单位留空默认「个」,物料编码由系统生成;五列按文本格式填写,前导零不会因数字格式被去掉。
|
||||
|
||||
### TC-21 合法数据导入及编码生成
|
||||
|
||||
**测试内容:** 验证物料新增、列表刷新及编码连续性。
|
||||
|
||||
**前置数据:** 专用小类编码规则前缀按约定取该小类编码(含大类字母,如大类 `A` 下的小类 `A01`),流水号 4 位、下一流水号为 1,且无已占用编码。
|
||||
|
||||
**操作步骤:** 在模板中填写两行不同名称的合法物料,保存后点击「批量导入」上传;查看提示、物料列表、详情和物料日志。刷新页面,再手动新增一件同小类物料,确认新编码与导入结果接续。
|
||||
|
||||
**判断指标:** 提示成功导入 2 条,列表刷新,名称、规格、分类和单位正确;两条编码均按该小类规则生成,由小类编码加 4 位补零流水号组成(如小类 `A01` 时为 `A010001`、`A010002`),日志详情首行为「批量导入创建:物料名称(物料编码)」,下方按行显示导入时的物料编码、名称、规格、分类、单位和状态,同时保存新增内容快照及正确操作人;后续修改物料不应改变这条历史日志;随后单条新增编码为同小类编码加 `0003`(如 `A010003`),与导入的两条连续。导入结果刷新后仍可查询。
|
||||
|
||||
### TC-22 分类写法与选填字段兼容
|
||||
|
||||
**测试内容:** 验证分类、单位的不同填写方式及空行处理。
|
||||
|
||||
**操作步骤:** 分别使用分类名称、分类编码和页面显示的「编码 名称」填写大类、小类,给分类文本加首尾空格;单位分别填名称、编码 `kg` 或留空,规格留空。在文件中插入全空行;另存一份删除规格和单位列、调整剩余列顺序的文件,分别上传。
|
||||
|
||||
**判断指标:** 合法写法均能导入并匹配正确分类和单位;规格可为空,单位留空或删列时默认「个」;列顺序变化不导致字段错位,全空行不计入导入数量。
|
||||
|
||||
### TC-23 错误数据校验与整批拒绝
|
||||
|
||||
**测试内容:** 验证字段错误提示以及合法行不会被部分导入。
|
||||
|
||||
**操作步骤:** 准备一份含合法行和错误行的文件,错误分别为名称/大类/小类为空、分类或单位不存在/停用、小类不属于所填大类、小类未配置启用规则、名称或规格超过 200 字。插入一个全空行后上传,检查错误明细,并按名称查询本次所有物料;对照编码规则的下一流水号。将超长字段改为 200 字且修正其余错误后重传。
|
||||
|
||||
**判断指标:** 首次提示整批未导入,错误原因可定位,行号与 Excel 实际行号一致,空行不会压缩行号;一行多个错误只计一条错误行,合法行也不新增。编码规则的下一流水号不变;全部修正后可成功导入,200 字的名称和规格可接受。
|
||||
|
||||
### TC-24 错误分页、导出与修正重传
|
||||
|
||||
**测试内容:** 验证错误明细查看、保存和再次上传。
|
||||
|
||||
**操作步骤:** 上传含 21 条错误行的文件,其中字段包含中文、逗号或双引号;切换错误表第二页,点击「导出错误明细」,用 Excel 打开 CSV。关闭弹窗,在原 `.xlsx` 中修正全部错误,再选择同一个文件上传。
|
||||
|
||||
**判断指标:** 第一页显示 20 条、第二页 1 条;CSV 包含全部 21 条错误及行号、原始字段、失败原因,中文无乱码、字段不串列。关闭后无旧错误残留,同一文件能重新选择并提交,修正后成功导入 21 条。
|
||||
|
||||
### TC-25 文件格式、模板及容量限制
|
||||
|
||||
**测试内容:** 验证无效文件拦截和大小、行数限制。
|
||||
|
||||
**操作步骤:** 分别尝试上传 `.xls`、`.csv`、超过 1 MiB(1024×1024 字节)的文件、损坏的 `.xlsx`、缺少「物料数据」工作表或必填表头的文件,并尝试取消文件选择。再分别上传 10000 行和 10001 行合法非空数据的 `.xlsx`,两份文件均不超过 1 MiB,记录耗时和结果。
|
||||
|
||||
**判断指标:** 不支持的格式、超限大小和错误模板不能新增物料,并有可理解的提示;取消选择无新增。10000 行在允许范围内,应完整导入;10001 行被整批拒绝,表头和空行不计入数量。若出现超时,记录实际提示并刷新查询是否已导入,确认结果后再决定是否重传,避免重复新增。
|
||||
|
||||
### TC-26 重复数据与重复上传
|
||||
|
||||
**测试内容:** 验证当前重复导入的实际行为。
|
||||
|
||||
**操作步骤:** 在同一文件中填写两条同名同规格的合法物料并上传;完成后再次选择同一个文件上传,查询物料及编码。
|
||||
|
||||
**判断指标:** 按当前功能,两次均成功,每次新增两条,合计四条独立物料,编码互不重复;原有物料未被覆盖。当前不做重复检测,重复上传会再次新增。
|
||||
|
||||
### TC-27 组织隔离与导入期间操作限制
|
||||
|
||||
**测试内容:** 验证主数据归属和导入期间的操作限制。
|
||||
|
||||
**操作步骤:** 在组织 A 上传使用 B 专属分类或单位的文件;随后在 A 上传较多行的合法文件,在导入请求尚未完成时尝试切换组织及再次点击「批量导入」。完成后切到 B 查询本次测试物料,再切回 A 查询。
|
||||
|
||||
**判断指标:** B 专属分类或单位在 A 被判不存在,整批拒绝;请求进行中组织切换被阻止,再次点击不发起重复导入。完成后可正常切换;新物料仅在 A 可查,B 的物料和编码规则不受影响,切回 A 后结果正确。
|
||||
|
||||
## 执行结果记录
|
||||
|
||||
| 用例编号 | 执行日期 | 执行人 | 结果(通过/失败/阻塞) | 缺陷单号或备注 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| ---- | ---- | --- | ------------ | ------- |
|
||||
| | | | | |
|
||||
|
||||
## 使用说明
|
||||
|
|
@ -219,4 +293,4 @@
|
|||
- 每次回归记录软件版本、测试环境、账号角色和执行结果;库存相关用例同时记录物料、组织及测试前后库存。
|
||||
- 失败时保留单据编号、申请编号、物料编码、操作时间和页面提示,便于定位。
|
||||
- 涉及修改或删除单据的用例,核对库存余额及库存流水与最终单据一致;库存流水不作为不可修改的审计日志。
|
||||
- 当前系统没有独立盘点、调拨、BOM或批量导入业务流程,本清单不将这些未接入功能作为现有功能验收项。
|
||||
- 当前系统没有独立盘点、调拨或 BOM 业务流程,本清单不将这些未接入功能作为现有功能验收项;物料批量导入已纳入 TC-20~TC-27(人工测试)。
|
||||
|
|
|
|||
|
|
@ -1,4 +1,16 @@
|
|||
import request from './index'
|
||||
import { getOrganizationId } from '../utils/organizationContext'
|
||||
|
||||
// 下载接口绕开 axios,得自己带令牌与当前公司,否则 401 / 400。
|
||||
// 令牌沿用拦截器同款读法(localStorage),避免在 API 模块里依赖 Pinia 激活时序。
|
||||
function downloadHeaders() {
|
||||
const token = localStorage.getItem('wmms_token')
|
||||
const organizationId = getOrganizationId()
|
||||
return {
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(organizationId ? { 'X-Organization-ID': String(organizationId) } : {})
|
||||
}
|
||||
}
|
||||
|
||||
export const materialsApi = {
|
||||
getAll(params = {}) {
|
||||
|
|
@ -23,5 +35,18 @@ export const materialsApi = {
|
|||
return request.post('/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
},
|
||||
importFile(formData) {
|
||||
// silent:校验失败的明细要自己弹窗展示,不让拦截器先弹一句笼统提示
|
||||
return request.post('/material-import', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
silent: true
|
||||
})
|
||||
},
|
||||
downloadImportTemplate() {
|
||||
// 下载走原生 fetch:响应是文件流,axios 拦截器只认 JSON 的 code 字段
|
||||
return fetch('/api/material-import/template', {
|
||||
headers: downloadHeaders()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,63 @@
|
|||
import { mount } from '@vue/test-utils'
|
||||
import ElementPlus from 'element-plus'
|
||||
import { nextTick } from 'vue'
|
||||
import { describe, expect, test, vi } from 'vitest'
|
||||
import MaterialImportResult from './MaterialImportResult.vue'
|
||||
|
||||
const failedResult = {
|
||||
total_rows: 3,
|
||||
success_rows: 0,
|
||||
failed_rows: 2,
|
||||
errors: [
|
||||
{ row: 2, data: { name: '', spec: 'M8x30', unit: '个', major: '机械结构类', minor: '标准件' }, reason: '物料名称不能为空' },
|
||||
{ row: 4, data: { name: '螺栓', spec: '', unit: '吨', major: '机械结构类', minor: '标准件' }, reason: '单位「吨」不存在或已停用' }
|
||||
]
|
||||
}
|
||||
|
||||
// el-table 分两轮渲染:先注册列,下一轮才把单元格填上。只等一个 tick 的话
|
||||
// 拿到的是空 <tr>,断言文本会莫名其妙地失败(实测过一次),所以统一等两轮。
|
||||
async function mountResult(result, options = {}) {
|
||||
const wrapper = mount(MaterialImportResult, {
|
||||
props: { result },
|
||||
global: { plugins: [ElementPlus], stubs: { teleport: true }, ...options }
|
||||
})
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
describe('MaterialImportResult', () => {
|
||||
test('失败时展示行数摘要与每行错误', async () => {
|
||||
const wrapper = await mountResult(failedResult)
|
||||
|
||||
expect(wrapper.text()).toContain('共 3 行')
|
||||
expect(wrapper.text()).toContain('2 行未通过')
|
||||
const rows = wrapper.findAll('tbody tr')
|
||||
expect(rows.length).toBe(2)
|
||||
expect(rows[0].text()).toContain('2')
|
||||
expect(rows[0].text()).toContain('物料名称不能为空')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
test('导出错误明细时把行号与原因写进 CSV', async () => {
|
||||
const download = vi.fn()
|
||||
const wrapper = await mountResult(failedResult, { provide: { downloadCsv: download } })
|
||||
|
||||
await wrapper.find('[data-test="export-errors"]').trigger('click')
|
||||
|
||||
expect(download).toHaveBeenCalledTimes(1)
|
||||
const [fileName, csv] = download.mock.calls[0]
|
||||
expect(fileName).toContain('导入错误明细')
|
||||
expect(csv).toContain('物料名称不能为空')
|
||||
expect(csv).toContain('行号')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
test('全部成功时只展示成功摘要,不显示错误表格', async () => {
|
||||
const wrapper = await mountResult({ total_rows: 2, success_rows: 2, failed_rows: 0, errors: [] })
|
||||
|
||||
expect(wrapper.text()).toContain('成功导入 2 条')
|
||||
expect(wrapper.find('[data-test="error-table"]').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
117
frontend/src/components/business/MaterialImportResult.vue
Normal file
117
frontend/src/components/business/MaterialImportResult.vue
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
<template>
|
||||
<div class="import-result">
|
||||
<el-alert
|
||||
v-if="result.failed_rows === 0"
|
||||
:title="`共 ${result.total_rows} 行,成功导入 ${result.success_rows} 条`"
|
||||
type="success"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
<el-alert
|
||||
v-else
|
||||
:title="`共 ${result.total_rows} 行,${result.failed_rows} 行未通过,本次未导入任何数据`"
|
||||
type="error"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
|
||||
<template v-if="result.failed_rows > 0">
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" plain data-test="export-errors" @click="exportErrors">导出错误明细</el-button>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
data-test="error-table"
|
||||
:data="pagedErrors"
|
||||
size="small"
|
||||
border
|
||||
max-height="320"
|
||||
>
|
||||
<el-table-column prop="row" label="行号" width="80" />
|
||||
<el-table-column label="物料名称" width="140">
|
||||
<template #default="{ row }">{{ row.data.name || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="规格" width="120">
|
||||
<template #default="{ row }">{{ row.data.spec || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="单位" width="90">
|
||||
<template #default="{ row }">{{ row.data.unit || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="大类" width="140">
|
||||
<template #default="{ row }">{{ row.data.major || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="小类" width="120">
|
||||
<template #default="{ row }">{{ row.data.minor || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="reason" label="失败原因" min-width="220" />
|
||||
</el-table>
|
||||
|
||||
<BasePagination
|
||||
:current-page="currentPage"
|
||||
:page-size="pageSize"
|
||||
:total="result.errors.length"
|
||||
@change="handlePageChange"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<div class="footer">
|
||||
<el-button @click="$emit('close')">关闭</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, inject, ref } from 'vue'
|
||||
import BasePagination from './BasePagination.vue'
|
||||
import { buildCsv, downloadCsv as defaultDownloadCsv } from '../../utils/exportCsv'
|
||||
|
||||
const props = defineProps({
|
||||
result: { type: Object, required: true }
|
||||
})
|
||||
defineEmits(['close'])
|
||||
|
||||
// 注入只用于测试替换下载动作,生产走默认实现。
|
||||
const downloadCsv = inject('downloadCsv', defaultDownloadCsv)
|
||||
|
||||
const pageSize = 20
|
||||
const currentPage = ref(1)
|
||||
|
||||
const pagedErrors = computed(() => {
|
||||
const start = (currentPage.value - 1) * pageSize
|
||||
return props.result.errors.slice(start, start + pageSize)
|
||||
})
|
||||
|
||||
function handlePageChange(page) {
|
||||
currentPage.value = page
|
||||
}
|
||||
|
||||
function exportErrors() {
|
||||
const rows = [['行号', '物料名称', '规格', '单位', '大类', '小类', '失败原因']]
|
||||
for (const item of props.result.errors) {
|
||||
rows.push([
|
||||
item.row,
|
||||
item.data?.name || '',
|
||||
item.data?.spec || '',
|
||||
item.data?.unit || '',
|
||||
item.data?.major || '',
|
||||
item.data?.minor || '',
|
||||
item.reason
|
||||
])
|
||||
}
|
||||
downloadCsv(`导入错误明细_${timestampSuffix()}.csv`, buildCsv(rows))
|
||||
}
|
||||
|
||||
// 文件名后缀用本地时间,不依赖 datetime.js 的展示格式
|
||||
function timestampSuffix() {
|
||||
const now = new Date()
|
||||
const pad = value => String(value).padStart(2, '0')
|
||||
return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`
|
||||
+ `-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.import-result { display: flex; flex-direction: column; gap: 12px; }
|
||||
.toolbar { display: flex; justify-content: flex-end; }
|
||||
.footer { display: flex; justify-content: flex-end; }
|
||||
</style>
|
||||
27
frontend/src/utils/exportCsv.js
Normal file
27
frontend/src/utils/exportCsv.js
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
// 生成 CSV 文本。带 UTF-8 BOM(写成转义而不是内联零宽字符,源码里才看得见),
|
||||
// Excel 打开中文才不会乱码;字段里的逗号、引号、换行按 RFC 4180 用双引号包裹并转义。
|
||||
export function buildCsv(rows) {
|
||||
const lines = rows.map(row => row.map(escapeCsvCell).join(','))
|
||||
return '\uFEFF' + lines.map(line => line + '\r\n').join('')
|
||||
}
|
||||
|
||||
function escapeCsvCell(value) {
|
||||
const text = value === null || value === undefined ? '' : String(value)
|
||||
if (/[",\r\n]/.test(text)) {
|
||||
return `"${text.replace(/"/g, '""')}"`
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// 触发浏览器下载。用 Blob + a[download],不依赖任何第三方库。
|
||||
export function downloadCsv(fileName, csv) {
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = fileName
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
21
frontend/src/utils/exportCsv.test.js
Normal file
21
frontend/src/utils/exportCsv.test.js
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { describe, expect, test } from 'vitest'
|
||||
import { buildCsv } from './exportCsv'
|
||||
|
||||
// BOM 用转义写,不内联那个零宽字符:内联的话看不见也改不动,
|
||||
// 一旦被编辑器或编码转换吃掉,测试就会以一个极难看懂的方式失败。
|
||||
const BOM = '\uFEFF'
|
||||
|
||||
describe('buildCsv', () => {
|
||||
test('用逗号分隔并在行尾换行', () => {
|
||||
const csv = buildCsv([
|
||||
['行号', '失败原因'],
|
||||
[3, '物料名称不能为空']
|
||||
])
|
||||
expect(csv).toBe(BOM + '行号,失败原因\r\n3,物料名称不能为空\r\n')
|
||||
})
|
||||
|
||||
test('包含逗号、引号、换行的字段用双引号包裹并转义', () => {
|
||||
const csv = buildCsv([['规格', '备注'], ['M8,30', '含"引号"'], ['两\n行', '']])
|
||||
expect(csv).toBe(BOM + '规格,备注\r\n"M8,30","含""引号"""\r\n"两\n行",\r\n')
|
||||
})
|
||||
})
|
||||
45
frontend/src/views/Logs.test.js
Normal file
45
frontend/src/views/Logs.test.js
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import ElementPlus from 'element-plus'
|
||||
import { describe, expect, test, vi } from 'vitest'
|
||||
import Logs from './Logs.vue'
|
||||
|
||||
vi.mock('../stores/logs', () => ({
|
||||
useLogsStore: () => ({
|
||||
logs: [], loading: false,
|
||||
page: { currentPage: 1, pageSize: 20, total: 0 },
|
||||
fetchLogs: vi.fn()
|
||||
})
|
||||
}))
|
||||
vi.mock('../stores/materials', () => ({
|
||||
useMaterialsStore: () => ({
|
||||
historicalMaterialOptions: [],
|
||||
fetchHistoricalMaterialOptions: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
describe('导入日志详情', () => {
|
||||
test('显示导入时的字段详情并保留换行', async () => {
|
||||
const description = '批量导入创建:螺栓(RTIMP0001)\n物料编码:RTIMP0001\n名称:螺栓\n规格:M8\n分类:机械结构类 / 标准件\n单位:个\n状态:启用'
|
||||
const wrapper = mount(Logs, {
|
||||
global: {
|
||||
plugins: [ElementPlus],
|
||||
stubs: { teleport: true, ElSelect: true, ElOption: true, ElDatePicker: true }
|
||||
}
|
||||
})
|
||||
try {
|
||||
wrapper.vm.$.setupState.showDetail({
|
||||
material_id: 31, operation_type: 'CREATE', operator: 'tester',
|
||||
created_at: '2026-10-12T10:00:00', description,
|
||||
old_value: '', new_value: '{"name":"螺栓","material_code":"RTIMP0001"}'
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
const detail = wrapper.get('.log-description')
|
||||
expect(detail.text()).toBe(description)
|
||||
expect(detail.element.style.whiteSpace).toBe('pre-wrap')
|
||||
expect(wrapper.find('.value-compare').text()).toContain('"material_code": "RTIMP0001"')
|
||||
} finally {
|
||||
wrapper.unmount()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -72,7 +72,9 @@
|
|||
<el-descriptions-item label="操作类型" :span="2"><StatusBadge :status="currentRow.operation_type" /></el-descriptions-item>
|
||||
<el-descriptions-item label="操作人">{{ currentRow.operator }}</el-descriptions-item>
|
||||
<el-descriptions-item label="时间">{{ formatDate(currentRow.created_at) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="详情" :span="2">{{ currentRow.description }}</el-descriptions-item>
|
||||
<el-descriptions-item label="详情" :span="2">
|
||||
<span class="log-description" style="white-space: pre-wrap; overflow-wrap: anywhere">{{ currentRow.description }}</span>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<div v-if="currentRow?.old_value || currentRow?.new_value" class="value-compare">
|
||||
<h4>变更对比</h4>
|
||||
|
|
|
|||
|
|
@ -20,6 +20,11 @@ const materialsStore = vi.hoisted(() => ({
|
|||
getCategoryDisplay: vi.fn(() => '-')
|
||||
}))
|
||||
|
||||
const materialsApi = vi.hoisted(() => ({
|
||||
importFile: vi.fn(),
|
||||
downloadImportTemplate: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../stores/materials', () => ({ useMaterialsStore: () => materialsStore }))
|
||||
vi.mock('../stores/auth', () => ({ useAuthStore: () => ({ isAdmin: true }) }))
|
||||
// 切换公司的未保存确认由组织 store 承载;这里只关心页面本身,替身掉守卫注册。
|
||||
|
|
@ -42,6 +47,7 @@ vi.mock('../api/masterData', () => ({
|
|||
unitsApi: { list: vi.fn() },
|
||||
codeRulesApi: { next: vi.fn() }
|
||||
}))
|
||||
vi.mock('../api/materials', () => ({ materialsApi }))
|
||||
|
||||
// 真实的 ElSelect 在 jsdom 下会因自身更新报 "Maximum recursive updates exceeded",
|
||||
// 这里用替身直接派发 change,模拟用户选中某个分类。
|
||||
|
|
@ -607,3 +613,118 @@ describe('物料图片上传的保存互斥', () => {
|
|||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('批量导入', () => {
|
||||
beforeEach(() => {
|
||||
materialCategoriesApi.list.mockResolvedValue({ data: categories })
|
||||
unitsApi.list.mockResolvedValue({ data: [] })
|
||||
materialsApi.importFile.mockReset()
|
||||
materialsApi.downloadImportTemplate.mockReset()
|
||||
materialsStore.fetchMaterials.mockReset()
|
||||
vi.spyOn(ElMessage, 'success').mockImplementation(() => {})
|
||||
vi.spyOn(ElMessage, 'error').mockImplementation(() => {})
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
test('批量导入成功后提示成功并刷新列表', async () => {
|
||||
materialsApi.importFile.mockResolvedValue({
|
||||
data: { total_rows: 2, success_rows: 2, failed_rows: 0, errors: [] }
|
||||
})
|
||||
|
||||
const wrapper = mountMaterials()
|
||||
await flushPromises()
|
||||
// 挂载时 onMounted 已经拉过一次列表,清掉才能断言“导入后又拉了一次”
|
||||
materialsStore.fetchMaterials.mockClear()
|
||||
|
||||
await setupState(wrapper).handleImportFile({
|
||||
target: { files: [new File(['x'], '物料.xlsx')], value: '物料.xlsx' }
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(materialsApi.importFile).toHaveBeenCalled()
|
||||
expect(materialsStore.fetchMaterials).toHaveBeenCalledWith(1, '', 'all')
|
||||
expect(ElMessage.success).toHaveBeenCalledWith('成功导入 2 条物料')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
test('批量导入失败时展示错误明细且不刷新列表', async () => {
|
||||
const error = new Error('校验未通过')
|
||||
error.response = {
|
||||
data: {
|
||||
code: 400,
|
||||
message: '有 1 行校验未通过,本次未导入任何数据',
|
||||
data: {
|
||||
total_rows: 1, success_rows: 0, failed_rows: 1,
|
||||
errors: [{ row: 2, data: { name: '', spec: '', unit: '', major: '', minor: '' }, reason: '物料名称不能为空' }]
|
||||
}
|
||||
}
|
||||
}
|
||||
materialsApi.importFile.mockRejectedValue(error)
|
||||
|
||||
const wrapper = mountMaterials()
|
||||
await flushPromises()
|
||||
materialsStore.fetchMaterials.mockClear()
|
||||
|
||||
await setupState(wrapper).handleImportFile({
|
||||
target: { files: [new File(['x'], '物料.xlsx')], value: '物料.xlsx' }
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(setupState(wrapper).importResult.failed_rows).toBe(1)
|
||||
expect(ElMessage.error).not.toHaveBeenCalled()
|
||||
expect(materialsStore.fetchMaterials).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
test.each([
|
||||
[400, '模板格式不正确,请重新下载最新模板', '模板格式不正确,请重新下载最新模板'],
|
||||
[400, '文件超过 1MB 上限', '文件超过 1MB 上限'],
|
||||
[500, '物料编码已存在', '物料编码已存在'],
|
||||
[500, 'Internal Server Error', '批量导入失败'],
|
||||
[500, undefined, '批量导入失败']
|
||||
])('HTTP %s 导入错误无逐行明细时提示:%s', async (status, message, expected) => {
|
||||
const error = new Error(`Request failed with status code ${status}`)
|
||||
error.response = { status, data: { code: status, message } }
|
||||
materialsApi.importFile.mockRejectedValue(error)
|
||||
|
||||
const wrapper = mountMaterials()
|
||||
try {
|
||||
await flushPromises()
|
||||
materialsStore.fetchMaterials.mockClear()
|
||||
|
||||
await setupState(wrapper).handleImportFile({
|
||||
target: { files: [new File(['x'], '物料.xlsx')], value: '物料.xlsx' }
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(ElMessage.error).toHaveBeenCalledTimes(1)
|
||||
expect(ElMessage.error).toHaveBeenCalledWith(expected)
|
||||
expect(setupState(wrapper).importDialogVisible).toBe(false)
|
||||
expect(setupState(wrapper).importing).toBe(false)
|
||||
expect(materialsStore.fetchMaterials).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
wrapper.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
test('网络中断时仍提示导入失败并解除上传状态', async () => {
|
||||
materialsApi.importFile.mockRejectedValue(new Error('Network Error'))
|
||||
const wrapper = mountMaterials()
|
||||
try {
|
||||
await flushPromises()
|
||||
await setupState(wrapper).handleImportFile({
|
||||
target: { files: [new File(['x'], '物料.xlsx')], value: '物料.xlsx' }
|
||||
})
|
||||
|
||||
expect(ElMessage.error).toHaveBeenCalledTimes(1)
|
||||
expect(ElMessage.error).toHaveBeenCalledWith('批量导入失败')
|
||||
expect(setupState(wrapper).importing).toBe(false)
|
||||
} finally {
|
||||
wrapper.unmount()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,7 +2,18 @@
|
|||
<div class="materials-page">
|
||||
<div class="page-header">
|
||||
<h2>物料主数据</h2>
|
||||
<div class="header-actions">
|
||||
<el-button @click="handleDownloadTemplate">下载模板</el-button>
|
||||
<el-button @click="triggerImport">批量导入</el-button>
|
||||
<el-button type="primary" @click="showAddModal">新增物料</el-button>
|
||||
<input
|
||||
ref="importInput"
|
||||
type="file"
|
||||
accept=".xlsx"
|
||||
style="display: none"
|
||||
@change="handleImportFile"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="search-bar">
|
||||
|
|
@ -105,7 +116,7 @@
|
|||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="单位">
|
||||
<el-select v-model="form.base_unit_id" style="width: 100%" @change="syncUnit">
|
||||
<el-select v-model="form.base_unit_id" placeholder="未选择单位时,默认使用“个”" style="width: 100%" @change="syncUnit">
|
||||
<el-option v-for="unit in units" :key="unit.id" :label="unit.code === unit.name ? unit.code : `${unit.code} - ${unit.name}`" :value="unit.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
|
@ -162,6 +173,10 @@
|
|||
:on-confirm="confirmDelete"
|
||||
/>
|
||||
|
||||
<el-dialog v-model="importDialogVisible" title="批量导入结果" width="900px" @closed="handleImportDialogClosed">
|
||||
<MaterialImportResult v-if="importResult" :result="importResult" @close="importDialogVisible = false" />
|
||||
</el-dialog>
|
||||
|
||||
<AlertTip :message="alertMessage" :type="alertType" />
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -175,9 +190,11 @@ import { useSavingGuard } from '../composables/useSavingGuard'
|
|||
import BaseTable from '../components/business/BaseTable.vue'
|
||||
import DeleteConfirm from '../components/business/DeleteConfirm.vue'
|
||||
import AlertTip from '../components/business/AlertTip.vue'
|
||||
import MaterialImportResult from '../components/business/MaterialImportResult.vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { createMaterialForm, toMaterialPayload } from './materialForm'
|
||||
import { codeRulesApi, materialCategoriesApi, unitsApi } from '../api/masterData'
|
||||
import { materialsApi } from '../api/materials'
|
||||
import { getMajorCategories, getMinorCategories, resolveCategoryPath } from '../utils/materialCategories'
|
||||
import { searchMaterialOptions } from '../utils/materialSearch'
|
||||
import { errorText } from '../utils/errorText'
|
||||
|
|
@ -212,6 +229,10 @@ const alertMessage = ref('')
|
|||
const alertType = ref('success')
|
||||
const imagePreview = ref('')
|
||||
const imageInput = ref(null)
|
||||
const importInput = ref(null)
|
||||
const importDialogVisible = ref(false)
|
||||
const importResult = ref(null)
|
||||
const importing = ref(false)
|
||||
const categories = ref([])
|
||||
const units = ref([])
|
||||
// 用户是否手改过编码:没改过时切分类要按新规则重算,改过就以用户的值为准。
|
||||
|
|
@ -253,6 +274,79 @@ function handleImageUpload(e) {
|
|||
}
|
||||
}
|
||||
|
||||
// 批量导入入口:上传期间再点不该重新弹选择框
|
||||
function triggerImport() {
|
||||
if (importing.value) return
|
||||
importInput.value?.click()
|
||||
}
|
||||
|
||||
async function handleImportFile(event) {
|
||||
const file = event.target.files?.[0]
|
||||
// 清空 input,否则连续选同一个文件不会再触发 change
|
||||
event.target.value = ''
|
||||
if (!file) return
|
||||
if (!file.name.toLowerCase().endsWith('.xlsx')) {
|
||||
ElMessage.warning('只支持 .xlsx 文件')
|
||||
return
|
||||
}
|
||||
if (file.size > 1 * 1024 * 1024) {
|
||||
ElMessage.warning('文件不能超过 1MB')
|
||||
return
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
importing.value = true
|
||||
try {
|
||||
// 导入是写请求:包进保存互斥,飞行期间禁止切换公司
|
||||
const res = await runWithSaving(() => materialsApi.importFile(formData))
|
||||
const result = res.data
|
||||
await materialsStore.fetchMaterials(1, '', 'all')
|
||||
searchKeyword.value = ''
|
||||
ElMessage.success(`成功导入 ${result.success_rows} 条物料`)
|
||||
} catch (e) {
|
||||
// 校验失败时响应里带逐行明细,弹窗展示;其余错误走兜底提示
|
||||
const detail = e?.response?.data?.data
|
||||
if (detail && Array.isArray(detail.errors)) {
|
||||
importResult.value = detail
|
||||
importDialogVisible.value = true
|
||||
} else {
|
||||
// 导入请求关闭了全局提示,HTTP 错误和网络错误都由页面兜底。
|
||||
console.error('批量导入失败:', e)
|
||||
ElMessage.error(errorText(e, '批量导入失败'))
|
||||
}
|
||||
} finally {
|
||||
importing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleImportDialogClosed() {
|
||||
importResult.value = null
|
||||
}
|
||||
|
||||
async function handleDownloadTemplate() {
|
||||
try {
|
||||
const res = await runWithSaving(() => materialsApi.downloadImportTemplate())
|
||||
if (!res.ok) {
|
||||
ElMessage.error('模板下载失败')
|
||||
return
|
||||
}
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = '物料导入模板.xlsx'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (e) {
|
||||
console.error('下载模板失败:', e)
|
||||
ElMessage.error('模板下载失败')
|
||||
}
|
||||
}
|
||||
|
||||
const form = reactive(createMaterialForm())
|
||||
|
||||
onMounted(async () => {
|
||||
|
|
@ -416,6 +510,14 @@ async function handleSave() {
|
|||
ElMessage.warning('请填写物料名称')
|
||||
return
|
||||
}
|
||||
if (Array.from(form.name).length > 200) {
|
||||
ElMessage.warning('物料名称过长,请小于200字')
|
||||
return
|
||||
}
|
||||
if (Array.from(form.spec || '').length > 200) {
|
||||
ElMessage.warning('物料规格过长,请小于200字')
|
||||
return
|
||||
}
|
||||
saveLoading.value = true
|
||||
try {
|
||||
const formData = toMaterialPayload(form)
|
||||
|
|
@ -507,6 +609,10 @@ useOrganizationSwitchGuard(() => hasUnsavedChanges.value)
|
|||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.search-bar {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ export function createMaterialForm(source = {}, categories = []) {
|
|||
name: source.name || '',
|
||||
spec: source.spec || '',
|
||||
category: source.category || '',
|
||||
unit: source.unit || 'pcs',
|
||||
unit: source.unit || '个',
|
||||
material_image: source.material_image || '',
|
||||
major_category_id: source.major_category_id ?? major?.id ?? null,
|
||||
category_id: source.category_id ?? null,
|
||||
|
|
@ -31,6 +31,6 @@ export function toMaterialPayload(source) {
|
|||
for (const key of materialKeys) {
|
||||
payload[key] = key.endsWith('_id') ? source[key] ?? null : source[key] || ''
|
||||
}
|
||||
if (!payload.unit) payload.unit = 'pcs'
|
||||
if (!payload.unit) payload.unit = '个'
|
||||
return payload
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ describe('material master-data form', () => {
|
|||
name: '',
|
||||
spec: '',
|
||||
category: '',
|
||||
unit: 'pcs',
|
||||
unit: '个',
|
||||
material_image: '',
|
||||
major_category_id: null,
|
||||
category_id: null,
|
||||
|
|
|
|||
21
go.mod
21
go.mod
|
|
@ -3,37 +3,42 @@ module github.com/wmms/wmms
|
|||
go 1.21
|
||||
|
||||
require (
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2
|
||||
github.com/gin-gonic/gin v1.9.1
|
||||
github.com/go-playground/validator/v10 v10.14.0
|
||||
github.com/go-sql-driver/mysql v1.7.1
|
||||
github.com/gorilla/websocket v1.5.0
|
||||
golang.org/x/crypto v0.9.0
|
||||
github.com/shopspring/decimal v1.4.0
|
||||
github.com/xuri/excelize/v2 v2.8.1
|
||||
golang.org/x/crypto v0.19.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2 // indirect
|
||||
github.com/bytedance/sonic v1.9.1 // indirect
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.14.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
|
||||
github.com/leodido/go-urn v1.2.4 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
|
||||
github.com/shopspring/decimal v1.4.0 // indirect
|
||||
github.com/richardlehane/mscfb v1.0.4 // indirect
|
||||
github.com/richardlehane/msoleps v1.0.3 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||
github.com/xuri/efp v0.0.0-20231025114914-d1ff6096ae53 // indirect
|
||||
github.com/xuri/nfp v0.0.0-20230919160717-d98342af3f05 // indirect
|
||||
golang.org/x/arch v0.3.0 // indirect
|
||||
golang.org/x/net v0.10.0 // indirect
|
||||
golang.org/x/sys v0.8.0 // indirect
|
||||
golang.org/x/text v0.9.0 // indirect
|
||||
golang.org/x/net v0.21.0 // indirect
|
||||
golang.org/x/sys v0.17.0 // indirect
|
||||
golang.org/x/text v0.14.0 // indirect
|
||||
google.golang.org/protobuf v1.30.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
|
|
|||
36
go.sum
36
go.sum
|
|
@ -27,8 +27,6 @@ github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrt
|
|||
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
|
|
@ -50,10 +48,17 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
|
|||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
|
||||
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
|
||||
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM=
|
||||
github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk=
|
||||
github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
|
||||
github.com/richardlehane/msoleps v1.0.3 h1:aznSZzrwYRl3rLKRT3gUk9am7T/mLNSnJINvN0AQoVM=
|
||||
github.com/richardlehane/msoleps v1.0.3/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
|
||||
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
|
|
@ -65,25 +70,34 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
|||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
|
||||
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
||||
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/xuri/efp v0.0.0-20231025114914-d1ff6096ae53 h1:Chd9DkqERQQuHpXjR/HSV1jLZA6uaoiwwH3vSuF3IW0=
|
||||
github.com/xuri/efp v0.0.0-20231025114914-d1ff6096ae53/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
|
||||
github.com/xuri/excelize/v2 v2.8.1 h1:pZLMEwK8ep+CLIUWpWmvW8IWE/yxqG0I1xcN6cVMGuQ=
|
||||
github.com/xuri/excelize/v2 v2.8.1/go.mod h1:oli1E4C3Pa5RXg1TBXn4ENCXDV5JUMlBluUhG7c+CEE=
|
||||
github.com/xuri/nfp v0.0.0-20230919160717-d98342af3f05 h1:qhbILQo1K3mphbwKh1vNm4oGezE1eF9fQWmNiIpSfI4=
|
||||
github.com/xuri/nfp v0.0.0-20230919160717-d98342af3f05/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
|
||||
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
|
||||
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
|
||||
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/image v0.14.0 h1:tNgSxAFe3jC4uYqvZdTr84SZoM1KfwdC9SKIFrLjFn4=
|
||||
golang.org/x/image v0.14.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
|
||||
golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ type Handler struct {
|
|||
masterDataSvc *service.MasterDataService
|
||||
partnerSvc *service.PartnerService
|
||||
warehouseSvc *service.WarehouseService
|
||||
materialImportSvc *service.MaterialImportService
|
||||
}
|
||||
|
||||
func userFacingServiceErrorMessage(err error) string {
|
||||
|
|
@ -49,6 +50,7 @@ func NewHandler() *Handler {
|
|||
masterDataSvc: service.NewMasterDataService(),
|
||||
partnerSvc: service.NewPartnerService(),
|
||||
warehouseSvc: service.NewWarehouseService(),
|
||||
materialImportSvc: service.NewMaterialImportService(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -93,6 +95,17 @@ func (h *Handler) RegisterRoutes(r *gin.Engine) {
|
|||
materials.POST("/:id/restore", h.RestoreMaterial)
|
||||
}
|
||||
|
||||
// 物料批量导入单独挂前缀。这不是 gin 的限制——实测 v1.9.1 允许静态段与
|
||||
// :id 同层共存,把它放进上面的 materials 组并不会冲突;独立成前缀是因为
|
||||
// 「导入」是含模板下载与上传入库的一条完整链路,两个端点归在一处,
|
||||
// 也避免 /materials/import 被读成某个物料的 id。
|
||||
materialImport := api.Group("/material-import")
|
||||
materialImport.Use(middleware.AuthMiddleware(), middleware.OrganizationMiddleware())
|
||||
{
|
||||
materialImport.GET("/template", h.DownloadMaterialImportTemplate)
|
||||
materialImport.POST("", h.ImportMaterials)
|
||||
}
|
||||
|
||||
inbound := api.Group("/inbound")
|
||||
inbound.Use(middleware.AuthMiddleware(), middleware.OrganizationMiddleware())
|
||||
{
|
||||
|
|
@ -319,7 +332,7 @@ func (h *Handler) CreateMaterial(c *gin.Context) {
|
|||
|
||||
material, err := h.materialSvc.Create(&req, operator)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrInvalidMaterialCategory) || errors.Is(err, service.ErrInvalidMaterialUnit) {
|
||||
if errors.Is(err, service.ErrInvalidMaterialCategory) || errors.Is(err, service.ErrInvalidMaterialUnit) || errors.Is(err, service.ErrMaterialNameTooLong) || errors.Is(err, service.ErrMaterialSpecTooLong) {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
|
@ -358,7 +371,7 @@ func (h *Handler) UpdateMaterial(c *gin.Context) {
|
|||
|
||||
material, err := h.materialSvc.Update(orgID, id, &req, operator)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrInvalidMaterialCategory) || errors.Is(err, service.ErrInvalidMaterialUnit) {
|
||||
if errors.Is(err, service.ErrInvalidMaterialCategory) || errors.Is(err, service.ErrInvalidMaterialUnit) || errors.Is(err, service.ErrMaterialNameTooLong) || errors.Is(err, service.ErrMaterialSpecTooLong) {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
|
|
|||
92
internal/handler/material_import.go
Normal file
92
internal/handler/material_import.go
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/wmms/wmms/internal/middleware"
|
||||
"github.com/wmms/wmms/internal/service"
|
||||
"github.com/wmms/wmms/pkg/response"
|
||||
)
|
||||
|
||||
// DownloadMaterialImportTemplate 返回物料导入模板文件。模板随代码生成,
|
||||
// 不落盘,保证下载到的永远和当前校验规则一致。
|
||||
func (h *Handler) DownloadMaterialImportTemplate(c *gin.Context) {
|
||||
data, err := service.BuildMaterialImportTemplate()
|
||||
if err != nil {
|
||||
response.InternalError(c, "模板生成失败")
|
||||
return
|
||||
}
|
||||
fileName := url.PathEscape("物料导入模板.xlsx")
|
||||
c.Header("Content-Disposition", `attachment; filename="material-import-template.xlsx"; filename*=UTF-8''`+fileName)
|
||||
c.Data(http.StatusOK, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", data)
|
||||
}
|
||||
|
||||
// ImportMaterials 接收上传的 xlsx,校验通过即整批入库。
|
||||
// 校验失败返回 400 且 data 里带逐行错误明细。
|
||||
func (h *Handler) ImportMaterials(c *gin.Context) {
|
||||
orgID, ok := organizationID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// 在 multipart 解析前限制读取;文件限额之外仅给表单头部预留少量空间。
|
||||
const maxRequestBytes = service.MaterialImportMaxBytes + (64 << 10)
|
||||
if c.Request.ContentLength > maxRequestBytes {
|
||||
response.BadRequest(c, "文件超过 1MB 上限或表单数据过大")
|
||||
return
|
||||
}
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxRequestBytes)
|
||||
defer c.Request.Body.Close()
|
||||
defer func() {
|
||||
if c.Request.MultipartForm != nil {
|
||||
_ = c.Request.MultipartForm.RemoveAll()
|
||||
}
|
||||
}()
|
||||
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
var sizeErr *http.MaxBytesError
|
||||
if errors.As(err, &sizeErr) {
|
||||
response.BadRequest(c, "文件超过 1MB 上限或表单数据过大")
|
||||
return
|
||||
}
|
||||
response.BadRequest(c, "请选择要导入的 xlsx 文件")
|
||||
return
|
||||
}
|
||||
if file.Size > service.MaterialImportMaxBytes {
|
||||
response.BadRequest(c, "文件超过 1MB 上限")
|
||||
return
|
||||
}
|
||||
|
||||
source, err := file.Open()
|
||||
if err != nil {
|
||||
response.BadRequest(c, "文件读取失败")
|
||||
return
|
||||
}
|
||||
defer source.Close()
|
||||
|
||||
operator := middleware.GetCurrentUsername(c)
|
||||
if operator == "" {
|
||||
operator = "系统"
|
||||
}
|
||||
|
||||
result, err := h.materialImportSvc.Import(orgID, operator, file.Filename, source)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrImportTemplateInvalid) || errors.Is(err, service.ErrImportTooManyRows) {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.InternalError(c, err.Error())
|
||||
return
|
||||
}
|
||||
if result.FailedRows > 0 {
|
||||
response.ErrorWithData(c, http.StatusBadRequest,
|
||||
"有 "+strconv.Itoa(result.FailedRows)+" 行校验未通过,本次未导入任何数据", result)
|
||||
return
|
||||
}
|
||||
response.Created(c, "成功导入 "+strconv.Itoa(result.SuccessRows)+" 条物料", result)
|
||||
}
|
||||
115
internal/handler/material_import_test.go
Normal file
115
internal/handler/material_import_test.go
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/wmms/wmms/internal/service"
|
||||
"github.com/wmms/wmms/pkg/response"
|
||||
)
|
||||
|
||||
type countedImportBody struct {
|
||||
io.Reader
|
||||
read int64
|
||||
}
|
||||
|
||||
func (body *countedImportBody) Read(p []byte) (int, error) {
|
||||
n, err := body.Reader.Read(p)
|
||||
body.read += int64(n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (*countedImportBody) Close() error { return nil }
|
||||
|
||||
func TestMaterialImportRejectsLargeBodyBeforeReadingItAll(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
var payload bytes.Buffer
|
||||
writer := multipart.NewWriter(&payload)
|
||||
part, err := writer.CreateFormFile("file", "物料.xlsx")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := part.Write(bytes.Repeat([]byte("x"), 8<<20)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, contentLength := range []int64{int64(payload.Len()), -1} {
|
||||
t.Run(fmt.Sprintf("content-length=%d", contentLength), func(t *testing.T) {
|
||||
body := &countedImportBody{Reader: bytes.NewReader(payload.Bytes())}
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Set("organization_id", int64(1))
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/material-import", nil)
|
||||
c.Request.Body = body
|
||||
c.Request.ContentLength = contentLength
|
||||
c.Request.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
(&Handler{}).ImportMaterials(c)
|
||||
if recorder.Code != http.StatusBadRequest || !strings.Contains(recorder.Body.String(), "1MB") {
|
||||
t.Fatalf("status=%d, body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if body.read > service.MaterialImportMaxBytes+(64<<10)+1 {
|
||||
t.Fatalf("超大请求已读取 %d 字节,应在解析前限制读取", body.read)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaterialImportFileSizeBoundaryAllowsMultipartOverhead(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
for _, size := range []int{service.MaterialImportMaxBytes, service.MaterialImportMaxBytes + 1} {
|
||||
t.Run(fmt.Sprintf("file-size=%d", size), func(t *testing.T) {
|
||||
var payload bytes.Buffer
|
||||
writer := multipart.NewWriter(&payload)
|
||||
part, err := writer.CreateFormFile("file", "物料.xlsx")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := part.Write(bytes.Repeat([]byte("x"), size)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Set("organization_id", int64(1))
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/material-import", bytes.NewReader(payload.Bytes()))
|
||||
c.Request.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
(&Handler{materialImportSvc: service.NewMaterialImportService()}).ImportMaterials(c)
|
||||
want := "模板格式不正确"
|
||||
if size > service.MaterialImportMaxBytes {
|
||||
want = "文件超过 1MB 上限"
|
||||
}
|
||||
if recorder.Code != http.StatusBadRequest || !strings.Contains(recorder.Body.String(), want) {
|
||||
t.Fatalf("status=%d, body=%s, want %s", recorder.Code, recorder.Body.String(), want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorWithDataKeepsPayload(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
|
||||
response.ErrorWithData(c, http.StatusBadRequest, "有 1 行校验未通过", map[string]interface{}{
|
||||
"total_rows": 1, "failed_rows": 1,
|
||||
})
|
||||
|
||||
if recorder.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", recorder.Code)
|
||||
}
|
||||
body := recorder.Body.String()
|
||||
if !strings.Contains(body, "有 1 行校验未通过") || !strings.Contains(body, `"failed_rows":1`) {
|
||||
t.Fatalf("body = %s", body)
|
||||
}
|
||||
}
|
||||
|
|
@ -150,12 +150,12 @@ func TestCreateMaterialUsesHeaderOrganizationInsteadOfBody(t *testing.T) {
|
|||
mock.ExpectQuery(lockCodeRulePattern).WithArgs(int64(1), int64(12)).
|
||||
WillReturnRows(materialRow([]string{"id", "organization_id", "category_id", "prefix", "sequence_length", "next_sequence"}))
|
||||
mock.ExpectExec(materialInsertPattern).
|
||||
WithArgs(int64(1), "HDR-001", "轴承", nil, "", nil, int64(12), nil, "pcs", "active", 1).
|
||||
WithArgs(int64(1), "HDR-001", "轴承", nil, "", nil, int64(12), nil, "个", "active", 1).
|
||||
WillReturnResult(sqlmock.NewResult(7, 1))
|
||||
mock.ExpectCommit()
|
||||
mock.ExpectQuery(materialGetByIDPattern).WithArgs(int64(1), int64(7)).
|
||||
WillReturnRows(materialRow(materialColumns(),
|
||||
int64(7), int64(1), "HDR-001", "轴承", nil, "", nil, int64(12), nil, "pcs", "active", 1, time.Now(), time.Now()))
|
||||
int64(7), int64(1), "HDR-001", "轴承", nil, "", nil, int64(12), nil, "个", "active", 1, time.Now(), time.Now()))
|
||||
mock.ExpectExec(materialLogInsertPattern).
|
||||
WithArgs("CREATE", int64(7), "", sqlmock.AnyArg(), "operator", sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ func TestPhaseOneAndLegacyRoutesAreRegistered(t *testing.T) {
|
|||
"POST /api/materials/:id/obsolete", "POST /api/materials/:id/restore",
|
||||
"PUT /api/stock/:materialId/warning", "GET /api/stock/:materialId/details",
|
||||
"POST /api/applications", "GET /api/logs",
|
||||
"GET /api/material-import/template", "POST /api/material-import",
|
||||
}
|
||||
for _, route := range want {
|
||||
if !registered[route] {
|
||||
|
|
|
|||
61
internal/model/material_import.go
Normal file
61
internal/model/material_import.go
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// MaterialImportRow 是解析后的一行原始数据。Row 保留 Excel 真实行号(从 1
|
||||
// 开始、含表头行),校验失败时用它定位,用户可直接对着 Excel 找。
|
||||
type MaterialImportRow struct {
|
||||
Row int
|
||||
Name string
|
||||
Spec string
|
||||
Unit string
|
||||
MajorName string
|
||||
MinorName string
|
||||
}
|
||||
|
||||
// Data 返回该行的原始内容,用于错误明细里的"原始数据"。
|
||||
func (r MaterialImportRow) Data() map[string]string {
|
||||
return map[string]string{
|
||||
"name": r.Name,
|
||||
"spec": r.Spec,
|
||||
"unit": r.Unit,
|
||||
"major": r.MajorName,
|
||||
"minor": r.MinorName,
|
||||
}
|
||||
}
|
||||
|
||||
// MaterialImportError 是一行的失败原因,Reason 可能包含多条,用中文分号连接。
|
||||
type MaterialImportError struct {
|
||||
Row int `json:"row"`
|
||||
Data map[string]string `json:"data"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// MaterialImportResult 是一次导入的返回值。校验失败时 SuccessRows 为 0、
|
||||
// Errors 非空;成功时 Errors 为空数组(不是 null)。
|
||||
type MaterialImportResult struct {
|
||||
TotalRows int `json:"total_rows"`
|
||||
SuccessRows int `json:"success_rows"`
|
||||
FailedRows int `json:"failed_rows"`
|
||||
Errors []MaterialImportError `json:"errors"`
|
||||
}
|
||||
|
||||
// MaterialImportLog 是一次成功导入的持久化汇总;失败不写记录。
|
||||
// 保留失败字段以兼容已有表结构和历史数据,新记录失败数为 0、失败原因为空。
|
||||
type MaterialImportLog struct {
|
||||
ID int64 `json:"id"`
|
||||
OrganizationID int64 `json:"organization_id"`
|
||||
Operator string `json:"operator"`
|
||||
FileName string `json:"file_name"`
|
||||
TotalRows int `json:"total_rows"`
|
||||
SuccessRows int `json:"success_rows"`
|
||||
FailedRows int `json:"failed_rows"`
|
||||
Status string `json:"status"`
|
||||
FailureReason string `json:"failure_reason"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
const (
|
||||
MaterialImportStatusSuccess = "success"
|
||||
MaterialImportStatusFailed = "failed"
|
||||
)
|
||||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/wmms/wmms/internal/model"
|
||||
|
|
@ -417,3 +418,180 @@ func (r *MaterialRepo) Restore(organizationID, id int64) error {
|
|||
func (r *MaterialRepo) RestoreTx(tx *sql.Tx, organizationID, id int64) error {
|
||||
return restoreMaterialTx(tx, organizationID, id)
|
||||
}
|
||||
|
||||
// materialImportBatchSize 是批量插入物料时的每批行数。编码已在内存里按小类
|
||||
// 顺序分配好,这里只负责把 INSERT 折叠成分批的多值语句,避免两万行逐条往返。
|
||||
const materialImportBatchSize = 500
|
||||
|
||||
// ImportTx 在调用方事务内为整批物料分配编码并插入,任意一行失败都由调用方回滚。
|
||||
// 编码按小类分组生成:组内先对 code_rules 行加锁(按 category_id 升序加锁,
|
||||
// 避免两个导入事务以不同顺序持锁而死锁),再跳过库中已占用的号段。
|
||||
func (r *MaterialRepo) ImportTx(tx *sql.Tx, organizationID int64, materials []*model.Material) error {
|
||||
if len(materials) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 1. 按 category_id 分组,并保证加锁顺序升序。
|
||||
type group struct {
|
||||
categoryID int64
|
||||
items []*model.Material
|
||||
}
|
||||
index := make(map[int64]int)
|
||||
groups := make([]*group, 0)
|
||||
for _, m := range materials {
|
||||
var categoryID int64
|
||||
if m.CategoryID != nil {
|
||||
categoryID = *m.CategoryID
|
||||
}
|
||||
position, ok := index[categoryID]
|
||||
if !ok {
|
||||
position = len(groups)
|
||||
index[categoryID] = position
|
||||
groups = append(groups, &group{categoryID: categoryID})
|
||||
}
|
||||
groups[position].items = append(groups[position].items, m)
|
||||
}
|
||||
sort.Slice(groups, func(i, j int) bool { return groups[i].categoryID < groups[j].categoryID })
|
||||
|
||||
// 2. 逐组锁规则、分配编码。
|
||||
for _, g := range groups {
|
||||
rule, err := lockCodeRule(tx, organizationID, g.categoryID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rule == nil {
|
||||
return fmt.Errorf("小类(ID %d)未配置启用的编码规则", g.categoryID)
|
||||
}
|
||||
|
||||
taken, err := loadTakenMaterialCodes(tx, organizationID, rule.Prefix)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sequence := rule.NextSequence
|
||||
for _, m := range g.items {
|
||||
code, next, err := nextAvailableMaterialCode(rule, sequence, taken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.MaterialCode = code
|
||||
taken[code] = struct{}{}
|
||||
sequence = next
|
||||
}
|
||||
if _, err := tx.Exec(`UPDATE code_rules SET next_sequence = ? WHERE id = ?`, sequence, rule.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 分批多值插入,每批插入后按编码回查自增 ID。
|
||||
// 多值 INSERT 只返回首个自增 ID 且不保证连续,拿不到逐行 ID,
|
||||
// 而后面写审计日志需要它;按批回查则把 IN 列表控制在 500 项以内。
|
||||
for start := 0; start < len(materials); start += materialImportBatchSize {
|
||||
end := start + materialImportBatchSize
|
||||
if end > len(materials) {
|
||||
end = len(materials)
|
||||
}
|
||||
batch := materials[start:end]
|
||||
query, args := buildMaterialInsertBatch(batch)
|
||||
if _, err := tx.Exec(query, args...); err != nil {
|
||||
if strings.Contains(err.Error(), "Duplicate entry") {
|
||||
return ErrDuplicateMaterialCode
|
||||
}
|
||||
return err
|
||||
}
|
||||
if err := fillMaterialIDs(tx, organizationID, batch); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadTakenMaterialCodes 取出本公司该前缀下已存在的全部物料编码,供内存判重。
|
||||
// 前缀来自公司自己的编码规则配置,若它含 % 或 _ 只会让 LIKE 多匹配到无关编码,
|
||||
// 而那些编码的格式与本规则生成的编码不同、永远不会被 FormatCode 产出,
|
||||
// 因此多匹配不会漏判,无需额外转义。
|
||||
func loadTakenMaterialCodes(tx *sql.Tx, organizationID int64, prefix string) (map[string]struct{}, error) {
|
||||
rows, err := tx.Query(
|
||||
`SELECT material_code FROM materials WHERE organization_id = ? AND material_code LIKE ?`,
|
||||
organizationID, prefix+"%")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
taken := make(map[string]struct{})
|
||||
for rows.Next() {
|
||||
var code string
|
||||
if err := rows.Scan(&code); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
taken[code] = struct{}{}
|
||||
}
|
||||
return taken, rows.Err()
|
||||
}
|
||||
|
||||
// nextAvailableMaterialCode 从 start 起找到第一个未占用的编码,返回编码与下一个
|
||||
// 待用序号。探测上限沿用单条新增的 probeLimit,防止规则被用尽时无限循环。
|
||||
func nextAvailableMaterialCode(rule *model.CodeRule, start int64, taken map[string]struct{}) (string, int64, error) {
|
||||
for sequence := start; sequence < start+probeLimit; sequence++ {
|
||||
code := rule.FormatCode(sequence)
|
||||
if _, occupied := taken[code]; !occupied {
|
||||
return code, sequence + 1, nil
|
||||
}
|
||||
}
|
||||
return "", 0, fmt.Errorf("编码规则 %s 的可用流水号已用尽", rule.Prefix)
|
||||
}
|
||||
|
||||
// buildMaterialInsertBatch 拼一条多值 INSERT,返回 SQL 与按顺序展开的参数。
|
||||
func buildMaterialInsertBatch(batch []*model.Material) (string, []interface{}) {
|
||||
var builder strings.Builder
|
||||
builder.WriteString(`INSERT INTO materials (organization_id, material_code, name, material_image, spec, manufacturer_model, category_id, base_unit_id, unit, status, version) VALUES `)
|
||||
args := make([]interface{}, 0, len(batch)*11)
|
||||
for i, m := range batch {
|
||||
if i > 0 {
|
||||
builder.WriteString(", ")
|
||||
}
|
||||
builder.WriteString("(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
|
||||
args = append(args, m.OrganizationID, m.MaterialCode, m.Name, m.MaterialImage, m.Spec,
|
||||
m.ManufacturerModel, m.CategoryID, m.BaseUnitID, m.Unit, m.Status, m.Version)
|
||||
}
|
||||
return builder.String(), args
|
||||
}
|
||||
|
||||
// fillMaterialIDs 按物料编码回查自增 ID 并回填。多值插入拿不到逐行 ID,
|
||||
// 而后续写 material_logs 需要它。
|
||||
func fillMaterialIDs(tx *sql.Tx, organizationID int64, materials []*model.Material) error {
|
||||
codes := make([]interface{}, 0, len(materials))
|
||||
for _, m := range materials {
|
||||
codes = append(codes, m.MaterialCode)
|
||||
}
|
||||
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(codes)), ",")
|
||||
query := `SELECT id, material_code FROM materials WHERE organization_id = ? AND material_code IN (` + placeholders + `)`
|
||||
args := append([]interface{}{organizationID}, codes...)
|
||||
|
||||
rows, err := tx.Query(query, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
ids := make(map[string]int64, len(materials))
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var code string
|
||||
if err := rows.Scan(&id, &code); err != nil {
|
||||
return err
|
||||
}
|
||||
ids[code] = id
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, m := range materials {
|
||||
id, ok := ids[m.MaterialCode]
|
||||
if !ok {
|
||||
return fmt.Errorf("物料 %s 插入后未能回查到 ID", m.MaterialCode)
|
||||
}
|
||||
m.ID = id
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
29
internal/repository/material_import.go
Normal file
29
internal/repository/material_import.go
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
package repository
|
||||
|
||||
import (
|
||||
"github.com/wmms/wmms/internal/model"
|
||||
"github.com/wmms/wmms/pkg/database"
|
||||
)
|
||||
|
||||
// MaterialImportLogRepo 负责 material_import_logs 的写入,表里存的是文件级批次汇总
|
||||
// (操作人、文件名、总行数、成功数)。
|
||||
//
|
||||
// 目前只写不读:没有查询接口也没有页面——逐条物料的溯源走日志页,成功导入的每件物料
|
||||
// 都有一条带操作人和「批量导入创建」详情的 CREATE 记录。这张表留作将来做「导入历史」
|
||||
// 入口时的数据源,暂时没有使用方。
|
||||
type MaterialImportLogRepo struct{}
|
||||
|
||||
func NewMaterialImportLogRepo() *MaterialImportLogRepo { return &MaterialImportLogRepo{} }
|
||||
|
||||
// Create 写一条成功导入汇总,由服务在事务提交成功后调用。
|
||||
// 失败导入不写记录,逐行错误明细只随接口响应返回,不落库。
|
||||
func (r *MaterialImportLogRepo) Create(log *model.MaterialImportLog) error {
|
||||
result, err := database.DB.Exec(
|
||||
`INSERT INTO material_import_logs (organization_id, operator, file_name, total_rows, success_rows, failed_rows, status, failure_reason) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
log.OrganizationID, log.Operator, log.FileName, log.TotalRows, log.SuccessRows, log.FailedRows, log.Status, log.FailureReason)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.ID, err = result.LastInsertId()
|
||||
return err
|
||||
}
|
||||
201
internal/repository/material_import_test.go
Normal file
201
internal/repository/material_import_test.go
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
package repository
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/wmms/wmms/internal/model"
|
||||
"github.com/wmms/wmms/pkg/database"
|
||||
)
|
||||
|
||||
const importLockRuleQuery = `SELECT id, organization_id, category_id, prefix, sequence_length, next_sequence FROM code_rules WHERE organization_id = ? AND category_id = ? AND status = 'active' FOR UPDATE`
|
||||
|
||||
const insertMaterialBatchStmt = `INSERT INTO materials (organization_id, material_code, name, material_image, spec, manufacturer_model, category_id, base_unit_id, unit, status, version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
|
||||
func newImportMock(t *testing.T) (sqlmock.Sqlmock, func()) {
|
||||
t.Helper()
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("open sql mock: %v", err)
|
||||
}
|
||||
previous := database.DB
|
||||
database.DB = db
|
||||
return mock, func() {
|
||||
database.DB = previous
|
||||
db.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func materialCategoryPtr(id int64) *int64 { return &id }
|
||||
|
||||
func TestMaterialRepoImportTxAllocatesCodesAndInsertsOneByOne(t *testing.T) {
|
||||
mock, cleanup := newImportMock(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery(regexp.QuoteMeta(importLockRuleQuery)).WithArgs(int64(1), int64(12)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "category_id", "prefix", "sequence_length", "next_sequence"}).
|
||||
AddRow(5, 1, 12, "A01", 4, 201))
|
||||
// 已占用编码探测:A010201 已被手工录入占用,应跳到 A010202
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT material_code FROM materials WHERE organization_id = ? AND material_code LIKE ?`)).
|
||||
WithArgs(int64(1), "A01%").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"material_code"}).AddRow("A010201"))
|
||||
mock.ExpectExec(regexp.QuoteMeta(`UPDATE code_rules SET next_sequence = ? WHERE id = ?`)).
|
||||
WithArgs(int64(204), int64(5)).WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
// 两行折叠成一条多值 INSERT
|
||||
mock.ExpectExec(regexp.QuoteMeta(insertMaterialBatchStmt)).
|
||||
WithArgs(
|
||||
int64(1), "A010202", "螺栓", nil, "", nil, int64(12), nil, "个", "active", 1,
|
||||
int64(1), "A010203", "螺母", nil, "", nil, int64(12), nil, "个", "active", 1,
|
||||
).
|
||||
WillReturnResult(sqlmock.NewResult(31, 2))
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT id, material_code FROM materials WHERE organization_id = ? AND material_code IN (?,?)`)).
|
||||
WithArgs(int64(1), "A010202", "A010203").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "material_code"}).
|
||||
AddRow(31, "A010202").AddRow(32, "A010203"))
|
||||
|
||||
tx, err := database.DB.Begin()
|
||||
if err != nil {
|
||||
t.Fatalf("begin: %v", err)
|
||||
}
|
||||
categoryID := int64(12)
|
||||
materials := []*model.Material{
|
||||
{OrganizationID: 1, Name: "螺栓", CategoryID: &categoryID, Unit: "个", Status: "active", Version: 1},
|
||||
{OrganizationID: 1, Name: "螺母", CategoryID: &categoryID, Unit: "个", Status: "active", Version: 1},
|
||||
}
|
||||
if err := NewMaterialRepo().ImportTx(tx, 1, materials); err != nil {
|
||||
t.Fatalf("import: %v", err)
|
||||
}
|
||||
if materials[0].MaterialCode != "A010202" || materials[0].ID != 31 {
|
||||
t.Fatalf("first = %+v", materials[0])
|
||||
}
|
||||
if materials[1].MaterialCode != "A010203" || materials[1].ID != 32 {
|
||||
t.Fatalf("second = %+v", materials[1])
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaterialRepoImportTxReportsMissingCodeRule(t *testing.T) {
|
||||
mock, cleanup := newImportMock(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery(regexp.QuoteMeta(importLockRuleQuery)).WithArgs(int64(1), int64(12)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "category_id", "prefix", "sequence_length", "next_sequence"}))
|
||||
|
||||
tx, err := database.DB.Begin()
|
||||
if err != nil {
|
||||
t.Fatalf("begin: %v", err)
|
||||
}
|
||||
materials := []*model.Material{
|
||||
{OrganizationID: 1, Name: "螺栓", CategoryID: materialCategoryPtr(12), Unit: "个", Status: "active", Version: 1},
|
||||
}
|
||||
err = NewMaterialRepo().ImportTx(tx, 1, materials)
|
||||
if err == nil || !strings.Contains(err.Error(), "编码规则") {
|
||||
t.Fatalf("err = %v, want 编码规则错误", err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaterialRepoImportTxBatchesInsertsOverFiveHundredRows(t *testing.T) {
|
||||
// 501 行 → 两批插入(500 + 1)。插入语句与回查参数很长,这里用子串松匹配,
|
||||
// 只断言"分了两批"以及编码序号按批连续。
|
||||
mock, cleanup := newImportMock(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery(regexp.QuoteMeta(importLockRuleQuery)).WithArgs(int64(1), int64(12)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "category_id", "prefix", "sequence_length", "next_sequence"}).
|
||||
AddRow(5, 1, 12, "A01", 4, 1))
|
||||
mock.ExpectQuery(`SELECT material_code FROM materials`).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"material_code"}))
|
||||
mock.ExpectExec(`UPDATE code_rules SET next_sequence`).WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(`INSERT INTO materials`).WillReturnResult(sqlmock.NewResult(1000, 500))
|
||||
mock.ExpectQuery(`SELECT id, material_code FROM materials`).WillReturnRows(idRowsFor(1, 500))
|
||||
mock.ExpectExec(`INSERT INTO materials`).WillReturnResult(sqlmock.NewResult(1500, 1))
|
||||
mock.ExpectQuery(`SELECT id, material_code FROM materials`).WillReturnRows(idRowsFor(501, 501))
|
||||
|
||||
tx, err := database.DB.Begin()
|
||||
if err != nil {
|
||||
t.Fatalf("begin: %v", err)
|
||||
}
|
||||
categoryID := int64(12)
|
||||
materials := make([]*model.Material, 0, 501)
|
||||
for i := 0; i < 501; i++ {
|
||||
materials = append(materials, &model.Material{
|
||||
OrganizationID: 1, Name: "物料", CategoryID: &categoryID, Unit: "个", Status: "active", Version: 1,
|
||||
})
|
||||
}
|
||||
if err := NewMaterialRepo().ImportTx(tx, 1, materials); err != nil {
|
||||
t.Fatalf("import: %v", err)
|
||||
}
|
||||
if materials[0].MaterialCode != "A010001" || materials[500].MaterialCode != "A010501" {
|
||||
t.Fatalf("codes = %s ... %s", materials[0].MaterialCode, materials[500].MaterialCode)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// idRowsFor 造出回查语句的返回行:序号 [from, to] 对应的物料 ID 与编码。
|
||||
func idRowsFor(from, to int) *sqlmock.Rows {
|
||||
rows := sqlmock.NewRows([]string{"id", "material_code"})
|
||||
for i := from; i <= to; i++ {
|
||||
rows.AddRow(int64(i), fmt.Sprintf("A01%04d", i))
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func TestMaterialLogRepoCreateBatchTx(t *testing.T) {
|
||||
mock, cleanup := newImportMock(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec(`INSERT INTO material_logs`).WillReturnResult(sqlmock.NewResult(1, 2))
|
||||
|
||||
tx, err := database.DB.Begin()
|
||||
if err != nil {
|
||||
t.Fatalf("begin: %v", err)
|
||||
}
|
||||
logs := []*model.MaterialLog{
|
||||
{OperationType: "CREATE", MaterialID: 31, Operator: "tester", Description: "批量导入"},
|
||||
{OperationType: "CREATE", MaterialID: 32, Operator: "tester", Description: "批量导入"},
|
||||
}
|
||||
if err := NewMaterialLogRepo().CreateBatchTx(tx, logs); err != nil {
|
||||
t.Fatalf("create batch: %v", err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaterialImportLogRepoCreate(t *testing.T) {
|
||||
mock, cleanup := newImportMock(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO material_import_logs (organization_id, operator, file_name, total_rows, success_rows, failed_rows, status, failure_reason) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)).
|
||||
WithArgs(int64(1), "tester", "物料.xlsx", 200, 0, 3, "failed", "共 3 行校验未通过").
|
||||
WillReturnResult(sqlmock.NewResult(9, 1))
|
||||
|
||||
log := &model.MaterialImportLog{
|
||||
OrganizationID: 1, Operator: "tester", FileName: "物料.xlsx",
|
||||
TotalRows: 200, SuccessRows: 0, FailedRows: 3,
|
||||
Status: "failed", FailureReason: "共 3 行校验未通过",
|
||||
}
|
||||
if err := NewMaterialImportLogRepo().Create(log); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if log.ID != 9 {
|
||||
t.Fatalf("id = %d, want 9", log.ID)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
|
@ -127,6 +127,26 @@ func (r *MaterialLogRepo) List(organizationID int64, operationType, materialId s
|
|||
return logs, total, nil
|
||||
}
|
||||
|
||||
// CreateBatchTx 在同一事务里批量写审计日志。导入场景一条物料一条 CREATE 日志,
|
||||
// 逐条往返太慢,这里折叠成多值 INSERT。
|
||||
func (r *MaterialLogRepo) CreateBatchTx(tx *sql.Tx, logs []*model.MaterialLog) error {
|
||||
if len(logs) == 0 {
|
||||
return nil
|
||||
}
|
||||
var builder strings.Builder
|
||||
builder.WriteString(`INSERT INTO material_logs (operation_type, material_id, old_value, new_value, operator, description) VALUES `)
|
||||
args := make([]interface{}, 0, len(logs)*6)
|
||||
for i, log := range logs {
|
||||
if i > 0 {
|
||||
builder.WriteString(", ")
|
||||
}
|
||||
builder.WriteString("(?, ?, ?, ?, ?, ?)")
|
||||
args = append(args, log.OperationType, log.MaterialID, log.OldValue, log.NewValue, log.Operator, log.Description)
|
||||
}
|
||||
_, err := tx.Exec(builder.String(), args...)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetByMaterialID 只返回属于当前公司的该物料日志;物料不属于本公司时结果为空。
|
||||
func (r *MaterialLogRepo) GetByMaterialID(organizationID, materialID int64) ([]model.MaterialLog, error) {
|
||||
query := `SELECT ml.id, ml.operation_type, ml.material_id, ml.old_value, ml.new_value, ml.operator, ml.description, ml.created_at
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"database/sql"
|
||||
"errors"
|
||||
"log"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/wmms/wmms/internal/model"
|
||||
"github.com/wmms/wmms/internal/repository"
|
||||
|
|
@ -11,6 +12,12 @@ import (
|
|||
|
||||
var ErrInvalidMaterialCategory = errors.New("请选择有效的小类类型")
|
||||
|
||||
var ErrMaterialNameTooLong = errors.New("物料名称过长,请小于200字")
|
||||
var ErrMaterialSpecTooLong = errors.New("物料规格过长,请小于200字")
|
||||
|
||||
const maxMaterialNameLength = 200
|
||||
const maxMaterialSpecLength = 200
|
||||
|
||||
// ErrInvalidMaterialUnit 表示物料引用的基本单位无效:要么不属于当前公司,
|
||||
// 要么已停用。创建以及"改选"单位时都要求是本公司启用中的单位。
|
||||
var ErrInvalidMaterialUnit = errors.New("请选择有效的单位")
|
||||
|
|
@ -65,6 +72,12 @@ func (s *MaterialService) Create(req *model.CreateMaterialRequest, operator stri
|
|||
if organizationID <= 0 {
|
||||
return nil, ErrInvalidOrganizationID
|
||||
}
|
||||
if utf8.RuneCountInString(req.Name) > maxMaterialNameLength {
|
||||
return nil, ErrMaterialNameTooLong
|
||||
}
|
||||
if utf8.RuneCountInString(req.Spec) > maxMaterialSpecLength {
|
||||
return nil, ErrMaterialSpecTooLong
|
||||
}
|
||||
if err := s.validateCategory(req.CategoryID, organizationID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -74,7 +87,7 @@ func (s *MaterialService) Create(req *model.CreateMaterialRequest, operator stri
|
|||
}
|
||||
unit := req.Unit
|
||||
if unit == "" {
|
||||
unit = "pcs"
|
||||
unit = "个"
|
||||
}
|
||||
|
||||
var materialImage *string
|
||||
|
|
@ -184,6 +197,12 @@ func (s *MaterialService) Update(organizationID, id int64, req *model.UpdateMate
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if utf8.RuneCountInString(req.Name) > maxMaterialNameLength {
|
||||
return nil, ErrMaterialNameTooLong
|
||||
}
|
||||
if utf8.RuneCountInString(req.Spec) > maxMaterialSpecLength {
|
||||
return nil, ErrMaterialSpecTooLong
|
||||
}
|
||||
if req.CategoryID == nil || *req.CategoryID <= 0 {
|
||||
return nil, ErrInvalidMaterialCategory
|
||||
}
|
||||
|
|
|
|||
320
internal/service/material_import.go
Normal file
320
internal/service/material_import.go
Normal file
|
|
@ -0,0 +1,320 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/wmms/wmms/internal/model"
|
||||
"github.com/wmms/wmms/internal/repository"
|
||||
)
|
||||
|
||||
// MaterialImportService 负责一次物料批量导入:解析、逐行校验、事务入库、写记录。
|
||||
// 首版只支持新增;校验阶段不生成编码,编码在入库事务里按小类规则分配。
|
||||
type MaterialImportService struct {
|
||||
materialRepo *repository.MaterialRepo
|
||||
masterRepo *repository.MasterDataRepository
|
||||
logRepo *repository.MaterialLogRepo
|
||||
importRepo *repository.MaterialImportLogRepo
|
||||
}
|
||||
|
||||
// materialImportItem 保留导入时匹配到的分类、单位名称,供历史日志展示。
|
||||
type materialImportItem struct {
|
||||
*model.Material
|
||||
categoryLabel string
|
||||
unitLabel string
|
||||
}
|
||||
|
||||
func NewMaterialImportService() *MaterialImportService {
|
||||
return &MaterialImportService{
|
||||
materialRepo: repository.NewMaterialRepo(),
|
||||
masterRepo: repository.NewMasterDataRepository(),
|
||||
logRepo: repository.NewMaterialLogRepo(),
|
||||
importRepo: repository.NewMaterialImportLogRepo(),
|
||||
}
|
||||
}
|
||||
|
||||
// Import 处理一次上传。返回值约定:
|
||||
// - 校验未通过:返回 result(含逐行错误)与 nil error,前端按明细展示;
|
||||
// - 模板或文件不合法:返回 nil 与错误,前端提示原因;
|
||||
// - 入库失败:返回 nil 与错误,事务已回滚,整批未落库。
|
||||
//
|
||||
// 仅在事务提交成功后写一条成功导入记录,失败不保存历史记录。
|
||||
func (s *MaterialImportService) Import(organizationID int64, operator, fileName string, r io.Reader) (*model.MaterialImportResult, error) {
|
||||
if organizationID <= 0 {
|
||||
return nil, ErrInvalidOrganizationID
|
||||
}
|
||||
|
||||
rows, err := ParseMaterialImportFile(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
materials, importErrors, err := s.validateRows(organizationID, rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(importErrors) > 0 {
|
||||
result := &model.MaterialImportResult{
|
||||
TotalRows: len(rows),
|
||||
SuccessRows: 0,
|
||||
FailedRows: len(importErrors),
|
||||
Errors: importErrors,
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
if err := s.persist(organizationID, operator, materials); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := &model.MaterialImportResult{
|
||||
TotalRows: len(rows),
|
||||
SuccessRows: len(materials),
|
||||
FailedRows: 0,
|
||||
Errors: []model.MaterialImportError{},
|
||||
}
|
||||
s.recordSuccessfulImport(organizationID, operator, fileName, result.TotalRows, result.SuccessRows)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// validateRows 逐行校验并组装待插入物料。返回的错误是查询主数据失败这类系统级
|
||||
// 错误;逐行的问题以 importErrors 返回,两者互斥。
|
||||
func (s *MaterialImportService) validateRows(organizationID int64, rows []model.MaterialImportRow) ([]*materialImportItem, []model.MaterialImportError, error) {
|
||||
categories, err := s.masterRepo.ListCategories(organizationID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
units, err := s.masterRepo.ListUnits(organizationID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
majors := make(map[string]*model.MaterialCategory)
|
||||
minors := make(map[string]*model.MaterialCategory) // key: 大类ID + "\x00" + 小类写法
|
||||
for i := range categories {
|
||||
category := &categories[i]
|
||||
if category.Status != "active" {
|
||||
continue
|
||||
}
|
||||
// 界面上分类一律显示成「编码 名称」(前端 categoryLabel 的拼法),用户整段
|
||||
// 照抄进模板是常事,所以名称之外再登记编码与「编码 名称」两种写法。名称排
|
||||
// 在最前、已有键不覆盖,与单位一样保证同名或同码时结果确定。
|
||||
aliases := []string{category.Name, category.Code}
|
||||
if category.Code != "" {
|
||||
aliases = append(aliases, category.Code+" "+category.Name)
|
||||
}
|
||||
if category.ParentID == nil {
|
||||
for _, alias := range aliases {
|
||||
registerCategoryAlias(majors, normalizeWhitespace(alias), category)
|
||||
}
|
||||
continue
|
||||
}
|
||||
for _, alias := range aliases {
|
||||
registerCategoryAlias(minors, minorCategoryKey(*category.ParentID, normalizeWhitespace(alias)), category)
|
||||
}
|
||||
}
|
||||
unitByName := make(map[string]*model.Unit)
|
||||
unitByCode := make(map[string]*model.Unit)
|
||||
for i := range units {
|
||||
unit := &units[i]
|
||||
if unit.Status != "active" {
|
||||
continue
|
||||
}
|
||||
// 名称和编码各自只认第一条,避免同名单位覆盖成不确定的结果
|
||||
if _, exists := unitByName[unit.Name]; !exists {
|
||||
unitByName[unit.Name] = unit
|
||||
}
|
||||
if _, exists := unitByCode[unit.Code]; !exists {
|
||||
unitByCode[unit.Code] = unit
|
||||
}
|
||||
}
|
||||
|
||||
materials := make([]*materialImportItem, 0, len(rows))
|
||||
importErrors := make([]model.MaterialImportError, 0)
|
||||
// 同一小类的规则只查一次:两万行通常只落在少数几个小类上,
|
||||
// 逐行去查会把一次导入放大成两万次往返。
|
||||
ruleAvailable := make(map[int64]bool)
|
||||
|
||||
for _, row := range rows {
|
||||
reasons := make([]string, 0, 3)
|
||||
|
||||
name := row.Name
|
||||
if name == "" {
|
||||
reasons = append(reasons, "物料名称不能为空")
|
||||
} else if len([]rune(name)) > 200 {
|
||||
reasons = append(reasons, "物料名称超过 200 字")
|
||||
}
|
||||
if len([]rune(row.Spec)) > 200 {
|
||||
reasons = append(reasons, "规格超过 200 字")
|
||||
}
|
||||
|
||||
majorName := normalizeWhitespace(row.MajorName)
|
||||
major := majors[majorName]
|
||||
if majorName == "" {
|
||||
reasons = append(reasons, "大类不能为空")
|
||||
} else if major == nil {
|
||||
reasons = append(reasons, fmt.Sprintf("大类「%s」不存在或已停用,请核对分类名称或编码", row.MajorName))
|
||||
}
|
||||
|
||||
minorName := normalizeWhitespace(row.MinorName)
|
||||
var minor *model.MaterialCategory
|
||||
if minorName == "" {
|
||||
reasons = append(reasons, "小类不能为空")
|
||||
} else if major == nil {
|
||||
// 大类已经报错,小类无法定位,不重复报
|
||||
} else {
|
||||
minor = minors[minorCategoryKey(major.ID, minorName)]
|
||||
if minor == nil {
|
||||
reasons = append(reasons, fmt.Sprintf("小类「%s」在「%s」下不存在或已停用,请核对分类名称或编码", row.MinorName, row.MajorName))
|
||||
}
|
||||
}
|
||||
|
||||
// 单位留空时按单条新增的口径默认「个」;填了则先按名称、再按编码匹配,
|
||||
// 因为像「kg」和「千克」这种编码与名称不一致的单位两种写法都合理。
|
||||
unitName := row.Unit
|
||||
if unitName == "" {
|
||||
unitName = "个"
|
||||
}
|
||||
unit := unitByName[unitName]
|
||||
if unit == nil {
|
||||
unit = unitByCode[unitName]
|
||||
}
|
||||
if unit == nil {
|
||||
if row.Unit == "" {
|
||||
reasons = append(reasons, "本单位缺少默认单位「个」,请在模板中填写单位")
|
||||
} else {
|
||||
reasons = append(reasons, fmt.Sprintf("单位「%s」不存在或已停用", row.Unit))
|
||||
}
|
||||
}
|
||||
|
||||
if minor != nil {
|
||||
available, checked := ruleAvailable[minor.ID]
|
||||
if !checked {
|
||||
rule, err := s.masterRepo.FindActiveCodeRule(organizationID, minor.ID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
available = rule != nil
|
||||
ruleAvailable[minor.ID] = available
|
||||
}
|
||||
if !available {
|
||||
reasons = append(reasons, fmt.Sprintf("小类「%s」未配置编码规则", row.MinorName))
|
||||
}
|
||||
}
|
||||
|
||||
if len(reasons) > 0 {
|
||||
importErrors = append(importErrors, model.MaterialImportError{
|
||||
Row: row.Row,
|
||||
Data: row.Data(),
|
||||
Reason: strings.Join(reasons, ";"),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
var baseUnitID *int64
|
||||
if unit != nil {
|
||||
id := unit.ID
|
||||
baseUnitID = &id
|
||||
}
|
||||
categoryID := minor.ID
|
||||
// 模板里填的是名称时也落编码:编码是单位表的唯一键,名称是可变的显示名,
|
||||
// 物料表的 unit 只该有一种写法(与单条新增一致),否则库存页直接显示该
|
||||
// 字段的地方会出现「千克」「kg」并存。
|
||||
materials = append(materials, &materialImportItem{Material: &model.Material{
|
||||
OrganizationID: organizationID,
|
||||
Name: name,
|
||||
Spec: row.Spec,
|
||||
CategoryID: &categoryID,
|
||||
BaseUnitID: baseUnitID,
|
||||
Unit: unit.Code,
|
||||
Status: "active",
|
||||
Version: 1,
|
||||
}, categoryLabel: major.Name + " / " + minor.Name, unitLabel: unit.Name})
|
||||
}
|
||||
|
||||
return materials, importErrors, nil
|
||||
}
|
||||
|
||||
// persist 在单个事务里插入物料并写审计日志,任一步失败整批回滚。
|
||||
func (s *MaterialImportService) persist(organizationID int64, operator string, materials []*materialImportItem) error {
|
||||
tx, err := repository.BeginTx()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
materialRows := make([]*model.Material, 0, len(materials))
|
||||
for _, item := range materials {
|
||||
materialRows = append(materialRows, item.Material)
|
||||
}
|
||||
if err := s.materialRepo.ImportTx(tx, organizationID, materialRows); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logs := make([]*model.MaterialLog, 0, len(materials))
|
||||
for _, material := range materials {
|
||||
newValue, err := json.Marshal(material.Material)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
spec := material.Spec
|
||||
if spec == "" {
|
||||
spec = "—"
|
||||
}
|
||||
logs = append(logs, &model.MaterialLog{
|
||||
OperationType: "CREATE",
|
||||
MaterialID: material.ID,
|
||||
NewValue: string(newValue),
|
||||
Operator: operator,
|
||||
Description: fmt.Sprintf("批量导入创建:%s(%s)\n物料编码:%s\n名称:%s\n规格:%s\n分类:%s\n单位:%s\n状态:%s",
|
||||
material.Name, material.MaterialCode, material.MaterialCode, material.Name, spec,
|
||||
material.categoryLabel, material.unitLabel, materialStatusLabel(material.Status)),
|
||||
})
|
||||
}
|
||||
if err := s.logRepo.CreateBatchTx(tx, logs); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// recordSuccessfulImport 仅写成功导入记录。记录失败只影响可追溯性,不应该让已经完成的导入
|
||||
// 变成失败,所以这里吞掉错误。
|
||||
func (s *MaterialImportService) recordSuccessfulImport(organizationID int64, operator, fileName string, total, success int) {
|
||||
_ = s.importRepo.Create(&model.MaterialImportLog{
|
||||
OrganizationID: organizationID,
|
||||
Operator: operator,
|
||||
FileName: fileName,
|
||||
TotalRows: total,
|
||||
SuccessRows: success,
|
||||
FailedRows: 0,
|
||||
Status: model.MaterialImportStatusSuccess,
|
||||
FailureReason: "",
|
||||
})
|
||||
}
|
||||
|
||||
func minorCategoryKey(majorID int64, minorName string) string {
|
||||
return fmt.Sprintf("%d\x00%s", majorID, minorName)
|
||||
}
|
||||
|
||||
// normalizeWhitespace 把文本里的各种空白(半角空格、全角空格 U+3000、不换行空格
|
||||
// U+00A0、制表符等)统一压成单个半角空格,并去掉首尾。
|
||||
//
|
||||
// 界面上的分类显示名是前端拼出来的「编码 + 半角空格 + 名称」,用户照抄进模板时,
|
||||
// 那个空格在中文输入法全角模式下会变成 U+3000、从网页复制偶尔带 U+00A0——两串肉眼
|
||||
// 一模一样、码点不同,不归一化就成了「浏览器复制的能导入、自己手写的报不存在」。
|
||||
func normalizeWhitespace(text string) string {
|
||||
return strings.Join(strings.Fields(text), " ")
|
||||
}
|
||||
|
||||
// registerCategoryAlias 把一个写法登记进分类索引。空写法忽略;键已被别的分类占用
|
||||
// 时不覆盖,保证结果不随分类的遍历顺序变化。
|
||||
func registerCategoryAlias(index map[string]*model.MaterialCategory, key string, category *model.MaterialCategory) {
|
||||
if key == "" {
|
||||
return
|
||||
}
|
||||
if _, exists := index[key]; !exists {
|
||||
index[key] = category
|
||||
}
|
||||
}
|
||||
315
internal/service/material_import_excel.go
Normal file
315
internal/service/material_import_excel.go
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/wmms/wmms/internal/model"
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
// MaterialImportSheetData 是填写数据的表名,「物料数据」。
|
||||
MaterialImportSheetData = "物料数据"
|
||||
// MaterialImportSheetGuide 是填写说明的表名。
|
||||
MaterialImportSheetGuide = "填写说明"
|
||||
// MaterialImportMaxRows 是数据区最大行数(不含表头)。
|
||||
MaterialImportMaxRows = 10000
|
||||
// MaterialImportMaxBytes 是上传文件大小上限。
|
||||
MaterialImportMaxBytes = 1 << 20
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrImportTemplateInvalid 表示上传的文件不是本功能的模板。
|
||||
ErrImportTemplateInvalid = errors.New("模板格式不正确,请重新下载最新模板")
|
||||
// ErrImportTooManyRows 表示数据行数超出上限。
|
||||
ErrImportTooManyRows = fmt.Errorf("导入行数超过 %d 行上限", MaterialImportMaxRows)
|
||||
)
|
||||
|
||||
// materialImportHeaders 是生成模板时的列顺序(含选填列)。
|
||||
var materialImportHeaders = []string{"物料名称", "规格", "单位", "大类", "小类"}
|
||||
|
||||
// materialImportRequiredHeaders 是表头里必须存在的列;缺任何一列都判定为
|
||||
// 模板不合法。规格和单位是选填,用户删掉这两个空列是合理操作,不拦。
|
||||
var materialImportRequiredHeaders = []string{"物料名称", "大类", "小类"}
|
||||
|
||||
// BuildMaterialImportTemplate 生成说明在前、空白数据表在后的导入模板。
|
||||
func BuildMaterialImportTemplate() ([]byte, error) {
|
||||
f := excelize.NewFile()
|
||||
defer f.Close()
|
||||
if err := f.SetSheetName("Sheet1", MaterialImportSheetGuide); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := f.NewSheet(MaterialImportSheetData); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f.SetActiveSheet(0)
|
||||
|
||||
rules := [][]string{
|
||||
{"物料名称", "必填", "内六角螺栓", "不超过 200 字,不可留空。"},
|
||||
{"规格", "选填", "M8×30", "不超过 200 字,可留空;含前导零的规格按文本填写。"},
|
||||
{"单位", "选填", "个 / kg", "填写当前组织已启用的单位名称或编码;留空默认「个」。"},
|
||||
{"大类", "必填", "机械结构类", "填写当前组织已启用的大类名称、编码或「编码 名称」。"},
|
||||
{"小类", "必填", "标准件", "填写所选大类下的小类名称、编码或「编码 名称」;小类须配置启用的编码规则。"},
|
||||
}
|
||||
sections := map[int]string{1: "物料批量导入 · 填写说明", 4: "一、字段填写规则", 12: "二、填写与上传步骤", 18: "三、导入注意事项", 25: "四、填写示例(仅供参考)"}
|
||||
notes := map[int]string{
|
||||
2: "先阅读本页,再切换到第二个工作表「物料数据」填写。",
|
||||
13: "1. 确认页面当前组织,核对该组织的分类、单位及小类编码规则。",
|
||||
14: "2. 在「物料数据」从第 2 行开始填写;第 1 行表头及工作表名称请保留。",
|
||||
15: "3. 保存为 .xlsx 文件,回到「物料」页面点击「批量导入」并选择文件。",
|
||||
16: "4. 有错误时查看行号和原因,可导出错误明细;修正原 .xlsx 后重新上传。",
|
||||
19: "文件限制:仅支持 .xlsx,大小不超过 1MB;非空数据最多 10000 行,表头和全空行不计数。",
|
||||
20: "整批校验:任意一行校验失败,本次所有物料均不导入;请全部修正后重传。",
|
||||
21: "编码与库存:物料编码由系统生成,无需填写;仅新增物料,不增加库存,也不更新已有物料。",
|
||||
22: "重复导入:同名同规格不会自动去重;成功后重复上传同一文件,会再次新增物料。",
|
||||
23: "默认单位:单位留空时使用「个」;当前组织未启用「个」时,请填写其他有效单位。",
|
||||
30: "示例只展示写法,请替换为当前组织实际启用的分类与单位;示例不在数据页,不会自动导入。",
|
||||
}
|
||||
for row, text := range sections {
|
||||
if err := f.MergeCell(MaterialImportSheetGuide, fmt.Sprintf("A%d", row), fmt.Sprintf("E%d", row)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := f.SetCellValue(MaterialImportSheetGuide, fmt.Sprintf("A%d", row), text); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for row, text := range notes {
|
||||
if err := f.MergeCell(MaterialImportSheetGuide, fmt.Sprintf("A%d", row), fmt.Sprintf("E%d", row)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := f.SetCellValue(MaterialImportSheetGuide, fmt.Sprintf("A%d", row), text); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
guideRows := append([][]string{{"字段", "是否必填", "示例", "填写规则"}}, rules...)
|
||||
for i, row := range guideRows {
|
||||
for j, text := range row {
|
||||
cell, _ := excelize.CoordinatesToCellName(j+1, i+5)
|
||||
if err := f.SetCellValue(MaterialImportSheetGuide, cell, text); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := f.MergeCell(MaterialImportSheetGuide, fmt.Sprintf("D%d", i+5), fmt.Sprintf("E%d", i+5)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
sampleRows := [][]string{materialImportHeaders, {"内六角螺栓", "M8×30", "个", "机械结构类", "标准件"}, {"六角螺母", "M8", "个", "机械结构类", "标准件"}}
|
||||
for i, row := range sampleRows {
|
||||
for j, text := range row {
|
||||
cell, _ := excelize.CoordinatesToCellName(j+1, i+26)
|
||||
if err := f.SetCellValue(MaterialImportSheetGuide, cell, text); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
for i, header := range materialImportHeaders {
|
||||
cell, _ := excelize.CoordinatesToCellName(i+1, 1)
|
||||
if err := f.SetCellValue(MaterialImportSheetData, cell, header); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := styleMaterialImportTemplate(f); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
buf, err := f.WriteToBuffer()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func styleMaterialImportTemplate(f *excelize.File) error {
|
||||
styles := []excelize.Style{
|
||||
{Font: &excelize.Font{Family: "Microsoft YaHei", Size: 11, Color: "334155"}, Alignment: &excelize.Alignment{Vertical: "center", WrapText: true}},
|
||||
{Font: &excelize.Font{Family: "Microsoft YaHei", Size: 20, Bold: true, Color: "FFFFFF"}, Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"17365D"}}, Alignment: &excelize.Alignment{Vertical: "center"}},
|
||||
{Font: &excelize.Font{Family: "Microsoft YaHei", Size: 12, Bold: true, Color: "17365D"}, Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"E8EFF7"}}, Alignment: &excelize.Alignment{Vertical: "center"}},
|
||||
{Font: &excelize.Font{Family: "Microsoft YaHei", Size: 11, Bold: true, Color: "FFFFFF"}, Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"345D87"}}, Alignment: &excelize.Alignment{Vertical: "center", Horizontal: "left", WrapText: true}},
|
||||
{Font: &excelize.Font{Family: "Microsoft YaHei", Size: 11, Color: "1D4ED8"}, NumFmt: 49, Alignment: &excelize.Alignment{Vertical: "center", Horizontal: "left", WrapText: true}},
|
||||
{Font: &excelize.Font{Family: "Microsoft YaHei", Size: 11, Bold: true, Italic: true, Color: "334155"}, Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"FFC000"}}, Alignment: &excelize.Alignment{Vertical: "center", WrapText: true}},
|
||||
{Font: &excelize.Font{Family: "Microsoft YaHei", Size: 11, Bold: true, Color: "334155"}, Alignment: &excelize.Alignment{Vertical: "center", WrapText: true}},
|
||||
{Font: &excelize.Font{Family: "Microsoft YaHei", Size: 11, Bold: true, Color: "FF0000"}, Alignment: &excelize.Alignment{Vertical: "center", WrapText: true}},
|
||||
}
|
||||
ids := make([]int, len(styles))
|
||||
for i := range styles {
|
||||
id, err := f.NewStyle(&styles[i])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ids[i] = id
|
||||
}
|
||||
if err := f.SetCellStyle(MaterialImportSheetGuide, "A1", "E30", ids[0]); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := f.SetCellStyle(MaterialImportSheetGuide, "A2", "E2", ids[5]); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := f.SetCellStyle(MaterialImportSheetGuide, "A1", "E1", ids[1]); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, row := range []int{4, 12, 18, 25} {
|
||||
if err := f.SetCellStyle(MaterialImportSheetGuide, fmt.Sprintf("A%d", row), fmt.Sprintf("E%d", row), ids[2]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, row := range []int{5, 26} {
|
||||
if err := f.SetCellStyle(MaterialImportSheetGuide, fmt.Sprintf("A%d", row), fmt.Sprintf("E%d", row), ids[3]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := f.SetCellStyle(MaterialImportSheetGuide, "A6", "A10", ids[6]); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, cell := range []string{"B6", "B9", "B10"} {
|
||||
if err := f.SetCellStyle(MaterialImportSheetGuide, cell, cell, ids[7]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for i, width := range []float64{20, 12, 24, 34} {
|
||||
col, _ := excelize.ColumnNumberToName(i + 1)
|
||||
if err := f.SetColWidth(MaterialImportSheetGuide, col, col, width); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for row := 1; row <= 30; row++ {
|
||||
height := 30.0
|
||||
if row == 1 {
|
||||
height = 42
|
||||
}
|
||||
if row >= 6 && row <= 10 {
|
||||
height = 54
|
||||
}
|
||||
if row >= 19 && row <= 23 || row == 30 {
|
||||
height = 42
|
||||
}
|
||||
if err := f.SetRowHeight(MaterialImportSheetGuide, row, height); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := f.SetColStyle(MaterialImportSheetData, "A:E", ids[4]); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := f.SetCellStyle(MaterialImportSheetData, "A1", "E1", ids[3]); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := f.SetRowHeight(MaterialImportSheetData, 1, 32); err != nil {
|
||||
return err
|
||||
}
|
||||
for i, width := range []float64{34, 28, 16, 26} {
|
||||
col, _ := excelize.ColumnNumberToName(i + 1)
|
||||
if err := f.SetColWidth(MaterialImportSheetData, col, col, width); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := f.SetColWidth(MaterialImportSheetData, "E", "E", 13); err != nil {
|
||||
return err
|
||||
}
|
||||
return f.SetPanes(MaterialImportSheetData, &excelize.Panes{Freeze: true, YSplit: 1, TopLeftCell: "A2", ActivePane: "bottomLeft"})
|
||||
}
|
||||
|
||||
// ParseMaterialImportFile 解析上传的 xlsx,返回带 Excel 行号的数据行。
|
||||
// 列按表头名定位:列顺序可调、选填列可整列删掉、多余列忽略;只有缺少必填列
|
||||
// 或缺少「物料数据」表时才判定模板不合法。
|
||||
// 整行为空的行会被跳过;只要任一列有内容就保留,交给后续校验报错。
|
||||
func ParseMaterialImportFile(r io.Reader) ([]model.MaterialImportRow, error) {
|
||||
// 较大的工作表 XML 使用临时文件,避免压缩后很小的工作簿整表驻留内存。
|
||||
f, err := excelize.OpenReader(r, excelize.Options{UnzipXMLSizeLimit: 1 << 20})
|
||||
if err != nil {
|
||||
if f != nil {
|
||||
_ = f.Close()
|
||||
}
|
||||
return nil, ErrImportTemplateInvalid
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
idx, err := f.GetSheetIndex(MaterialImportSheetData)
|
||||
if err != nil || idx < 0 {
|
||||
return nil, ErrImportTemplateInvalid
|
||||
}
|
||||
|
||||
iterator, err := f.Rows(MaterialImportSheetData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer iterator.Close()
|
||||
if !iterator.Next() {
|
||||
return nil, ErrImportTemplateInvalid
|
||||
}
|
||||
header, err := iterator.Columns()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
columns := materialImportColumnIndex(header)
|
||||
for _, required := range materialImportRequiredHeaders {
|
||||
if _, ok := columns[required]; !ok {
|
||||
return nil, ErrImportTemplateInvalid
|
||||
}
|
||||
}
|
||||
|
||||
rows := make([]model.MaterialImportRow, 0, 256)
|
||||
rowNumber := 1
|
||||
for iterator.Next() {
|
||||
rowNumber++
|
||||
raw, err := iterator.Columns()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row := model.MaterialImportRow{
|
||||
Row: rowNumber, // 包含被跳过的空行,保留真实 Excel 行号。
|
||||
Name: materialImportColumnValue(raw, columns, "物料名称"),
|
||||
Spec: materialImportColumnValue(raw, columns, "规格"),
|
||||
Unit: materialImportColumnValue(raw, columns, "单位"),
|
||||
MajorName: materialImportColumnValue(raw, columns, "大类"),
|
||||
MinorName: materialImportColumnValue(raw, columns, "小类"),
|
||||
}
|
||||
if row.Name == "" && row.Spec == "" && row.Unit == "" && row.MajorName == "" && row.MinorName == "" {
|
||||
continue
|
||||
}
|
||||
if len(rows) == MaterialImportMaxRows {
|
||||
return nil, ErrImportTooManyRows
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
|
||||
if err := iterator.Error(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := iterator.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// materialImportColumnIndex 把表头行转成「列名 → 列号」。允许用户在表头里加
|
||||
// 首尾空格、末尾加 * 标记必填;不认识的列忽略,重复列名取第一列。
|
||||
func materialImportColumnIndex(header []string) map[string]int {
|
||||
index := make(map[string]int, len(header))
|
||||
for i, cell := range header {
|
||||
name := strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(cell), "*"))
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := index[name]; !exists {
|
||||
index[name] = i
|
||||
}
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
// materialImportColumnValue 按列名取值;选填列被整列删掉时返回空串。
|
||||
func materialImportColumnValue(row []string, columns map[string]int, name string) string {
|
||||
index, ok := columns[name]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return materialImportCell(row, index)
|
||||
}
|
||||
|
||||
func materialImportCell(row []string, index int) string {
|
||||
if index >= len(row) {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(row[index])
|
||||
}
|
||||
275
internal/service/material_import_excel_test.go
Normal file
275
internal/service/material_import_excel_test.go
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
func TestParseMaterialImportFileStopsBeforeRowsBeyondLimit(t *testing.T) {
|
||||
dataRows := make([][]string, MaterialImportMaxRows+1)
|
||||
for i := range dataRows {
|
||||
dataRows[i] = []string{"物料", "", "", "机械结构类", "标准件"}
|
||||
}
|
||||
data := buildTestWorkbook(t, dataRows)
|
||||
reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var changed bytes.Buffer
|
||||
writer := zip.NewWriter(&changed)
|
||||
for _, entry := range reader.File {
|
||||
source, err := entry.Open()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content, err := io.ReadAll(source)
|
||||
source.Close()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if entry.Name == "xl/worksheets/sheet1.xml" {
|
||||
// 超限行之后故意放非法 XML,证明解析器不会再读取后续行。
|
||||
content = bytes.Replace(content, []byte("</sheetData>"),
|
||||
[]byte(`<row r="20003"><c></row></sheetData>`), 1)
|
||||
}
|
||||
target, err := writer.Create(entry.Name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := target.Write(content); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows, err := ParseMaterialImportFile(bytes.NewReader(changed.Bytes()))
|
||||
if !errors.Is(err, ErrImportTooManyRows) || rows != nil {
|
||||
t.Fatalf("rows=%d, err=%v, want immediate ErrImportTooManyRows", len(rows), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMaterialImportFileAcceptsRowLimit(t *testing.T) {
|
||||
dataRows := make([][]string, MaterialImportMaxRows)
|
||||
for i := range dataRows {
|
||||
dataRows[i] = []string{"物料", "", "", "机械结构类", "标准件"}
|
||||
}
|
||||
rows, err := ParseMaterialImportFile(bytes.NewReader(buildTestWorkbook(t, dataRows)))
|
||||
if err != nil || len(rows) != MaterialImportMaxRows {
|
||||
t.Fatalf("rows=%d, err=%v", len(rows), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMaterialImportFileReadsRowsWithExcelRowNumbers(t *testing.T) {
|
||||
data := buildTestWorkbook(t, [][]string{
|
||||
{"内六角螺栓", "M8x30", "个", "机械结构类", "标准件"},
|
||||
{"", "", "", "", ""}, // 整行空,应被跳过
|
||||
{"急停按钮", "XB2", "个", "电气元器件类", "成品件"},
|
||||
})
|
||||
|
||||
rows, err := ParseMaterialImportFile(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("rows = %d, want 2", len(rows))
|
||||
}
|
||||
if rows[0].Row != 2 || rows[0].Name != "内六角螺栓" || rows[0].MinorName != "标准件" {
|
||||
t.Fatalf("first row = %+v", rows[0])
|
||||
}
|
||||
// 第 3 行是空行被跳过,所以第二条数据是 Excel 第 4 行
|
||||
if rows[1].Row != 4 || rows[1].Name != "急停按钮" {
|
||||
t.Fatalf("second row = %+v", rows[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMaterialImportFileKeepsRowsWithAnyContent(t *testing.T) {
|
||||
// 名称填了、其余为空:不是空行,必须保留交给校验去报错
|
||||
data := buildTestWorkbook(t, [][]string{
|
||||
{"只有名称", "", "", "", ""},
|
||||
})
|
||||
rows, err := ParseMaterialImportFile(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if len(rows) != 1 || rows[0].Name != "只有名称" {
|
||||
t.Fatalf("rows = %+v", rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMaterialImportFileRejectsMissingRequiredColumn(t *testing.T) {
|
||||
// 只有「物料名称」和「规格」,缺必填的大类、小类
|
||||
data := buildTestWorkbookWithHeaders(t, []string{"物料名称", "规格"}, [][]string{
|
||||
{"内六角螺栓", "M8x30"},
|
||||
})
|
||||
|
||||
_, err := ParseMaterialImportFile(bytes.NewReader(data))
|
||||
if !errors.Is(err, ErrImportTemplateInvalid) {
|
||||
t.Fatalf("err = %v, want ErrImportTemplateInvalid", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMaterialImportFileAcceptsMissingOptionalColumnsAndReorderedColumns(t *testing.T) {
|
||||
// 删掉「规格」,并把「单位」挪到「物料名称」前面
|
||||
headers := []string{"单位", "物料名称", "大类", "小类"}
|
||||
data := buildTestWorkbookWithHeaders(t, headers, [][]string{
|
||||
{"个", "内六角螺栓", "机械结构类", "标准件"},
|
||||
})
|
||||
|
||||
rows, err := ParseMaterialImportFile(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("rows = %d, want 1", len(rows))
|
||||
}
|
||||
if rows[0].Name != "内六角螺栓" || rows[0].Unit != "个" || rows[0].MinorName != "标准件" {
|
||||
t.Fatalf("row = %+v", rows[0])
|
||||
}
|
||||
if rows[0].Spec != "" {
|
||||
t.Fatalf("缺失的规格列应解析为空,得到 %q", rows[0].Spec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMaterialImportFileIgnoresUnknownColumns(t *testing.T) {
|
||||
headers := []string{"物料名称", "备注", "大类", "小类"}
|
||||
data := buildTestWorkbookWithHeaders(t, headers, [][]string{
|
||||
{"内六角螺栓", "与导入无关", "机械结构类", "标准件"},
|
||||
})
|
||||
|
||||
rows, err := ParseMaterialImportFile(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if len(rows) != 1 || rows[0].Name != "内六角螺栓" {
|
||||
t.Fatalf("rows = %+v", rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMaterialImportFileRejectsTooManyRows(t *testing.T) {
|
||||
rows := make([][]string, 0, MaterialImportMaxRows+1)
|
||||
for i := 0; i < MaterialImportMaxRows+1; i++ {
|
||||
rows = append(rows, []string{"物料", "", "", "机械结构类", "标准件"})
|
||||
}
|
||||
data := buildTestWorkbook(t, rows)
|
||||
|
||||
_, err := ParseMaterialImportFile(bytes.NewReader(data))
|
||||
if !errors.Is(err, ErrImportTooManyRows) {
|
||||
t.Fatalf("err = %v, want ErrImportTooManyRows", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMaterialImportTemplateRoundTrips(t *testing.T) {
|
||||
data, err := BuildMaterialImportTemplate()
|
||||
if err != nil {
|
||||
t.Fatalf("build template: %v", err)
|
||||
}
|
||||
f, err := excelize.OpenReader(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
t.Fatalf("open template: %v", err)
|
||||
}
|
||||
defer f.Close()
|
||||
sheets := f.GetSheetList()
|
||||
if len(sheets) != 2 || sheets[0] != MaterialImportSheetGuide || sheets[1] != MaterialImportSheetData {
|
||||
t.Fatalf("工作表顺序 = %v,期望先说明后填表", sheets)
|
||||
}
|
||||
if f.GetActiveSheetIndex() != 0 {
|
||||
t.Fatal("打开模板时应先显示填写说明")
|
||||
}
|
||||
guide, err := f.GetRows(MaterialImportSheetGuide)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var guideText strings.Builder
|
||||
for _, row := range guide {
|
||||
guideText.WriteString(strings.Join(row, " "))
|
||||
}
|
||||
for _, rule := range []string{"编码 名称", "1MB", "10000", "整批", "重复", "库存", "第 2 行"} {
|
||||
if !strings.Contains(guideText.String(), rule) {
|
||||
t.Fatalf("填写说明缺少规则:%s", rule)
|
||||
}
|
||||
}
|
||||
styleAt := func(sheet, cell string) *excelize.Style {
|
||||
t.Helper()
|
||||
styleID, err := f.GetCellStyle(sheet, cell)
|
||||
if err != nil {
|
||||
t.Fatalf("读取 %s!%s 样式: %v", sheet, cell, err)
|
||||
}
|
||||
style, err := f.GetStyle(styleID)
|
||||
if err != nil {
|
||||
t.Fatalf("解析 %s!%s 样式: %v", sheet, cell, err)
|
||||
}
|
||||
return style
|
||||
}
|
||||
intro := styleAt(MaterialImportSheetGuide, "A2")
|
||||
if intro.Font == nil || !intro.Font.Bold || !intro.Font.Italic || intro.Fill.Color[0] != "FFC000" || intro.Alignment == nil || intro.Alignment.Horizontal != "" || intro.Alignment.Vertical != "center" {
|
||||
t.Fatalf("说明提示样式不匹配附件模板: %+v", intro)
|
||||
}
|
||||
field := styleAt(MaterialImportSheetGuide, "A6")
|
||||
if field.Font == nil || !field.Font.Bold {
|
||||
t.Fatalf("字段名应加粗: %+v", field)
|
||||
}
|
||||
required := styleAt(MaterialImportSheetGuide, "B6")
|
||||
if required.Font == nil || !required.Font.Bold || required.Font.Color != "FF0000" {
|
||||
t.Fatalf("必填标记应为红色粗体: %+v", required)
|
||||
}
|
||||
lastColumnWidth, err := f.GetColWidth(MaterialImportSheetData, "E")
|
||||
if err != nil || lastColumnWidth != 13 {
|
||||
t.Fatalf("物料数据 E 列宽 = %v, err = %v, want 13", lastColumnWidth, err)
|
||||
}
|
||||
|
||||
if idx, _ := f.GetSheetIndex(MaterialImportSheetData); idx < 0 {
|
||||
t.Fatal("模板缺少「物料数据」工作表")
|
||||
}
|
||||
if idx, _ := f.GetSheetIndex(MaterialImportSheetGuide); idx < 0 {
|
||||
t.Fatal("模板缺少「填写说明」工作表")
|
||||
}
|
||||
headers, err := f.GetRows(MaterialImportSheetData)
|
||||
if err != nil {
|
||||
t.Fatalf("read rows: %v", err)
|
||||
}
|
||||
if len(headers) == 0 || headers[0][0] != "物料名称" || headers[0][4] != "小类" {
|
||||
t.Fatalf("表头 = %v", headers)
|
||||
}
|
||||
// 模板必须能被自己的解析器接受(表头一致)
|
||||
if rows, err := ParseMaterialImportFile(bytes.NewReader(data)); err != nil {
|
||||
t.Fatalf("模板无法被解析器接受: %v", err)
|
||||
} else if len(rows) != 0 {
|
||||
t.Fatal("数据页不应包含会被实际导入的示例物料")
|
||||
}
|
||||
}
|
||||
|
||||
// buildTestWorkbook 用标准表头 + 给定数据行构造一个 xlsx 字节流。
|
||||
func buildTestWorkbook(t *testing.T, dataRows [][]string) []byte {
|
||||
t.Helper()
|
||||
return buildTestWorkbookWithHeaders(t, materialImportHeaders, dataRows)
|
||||
}
|
||||
|
||||
// buildTestWorkbookWithHeaders 允许指定表头,用来覆盖缺列、乱序、多余列的解析分支。
|
||||
func buildTestWorkbookWithHeaders(t *testing.T, headers []string, dataRows [][]string) []byte {
|
||||
t.Helper()
|
||||
f := excelize.NewFile()
|
||||
defer f.Close()
|
||||
f.SetSheetName("Sheet1", MaterialImportSheetData)
|
||||
for i, header := range headers {
|
||||
cell, _ := excelize.CoordinatesToCellName(i+1, 1)
|
||||
f.SetCellValue(MaterialImportSheetData, cell, header)
|
||||
}
|
||||
for r, row := range dataRows {
|
||||
for c, value := range row {
|
||||
cell, _ := excelize.CoordinatesToCellName(c+1, r+2)
|
||||
f.SetCellValue(MaterialImportSheetData, cell, value)
|
||||
}
|
||||
}
|
||||
buf, err := f.WriteToBuffer()
|
||||
if err != nil {
|
||||
t.Fatalf("write workbook: %v", err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
421
internal/service/material_import_test.go
Normal file
421
internal/service/material_import_test.go
Normal file
|
|
@ -0,0 +1,421 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/wmms/wmms/internal/model"
|
||||
"github.com/wmms/wmms/pkg/database"
|
||||
)
|
||||
|
||||
const importCategoryQuery = `SELECT id, organization_id, code, name, description, parent_id, status FROM material_categories WHERE organization_id = ? ORDER BY code`
|
||||
const importUnitQuery = `SELECT id, organization_id, code, name, precision_scale, status FROM units WHERE organization_id = ? ORDER BY code`
|
||||
const importRuleQuery = `SELECT id, organization_id, category_id, prefix, sequence_length, next_sequence, status FROM code_rules WHERE organization_id = ? AND category_id = ? AND status = 'active'`
|
||||
|
||||
func newImportServiceMock(t *testing.T) (sqlmock.Sqlmock, func()) {
|
||||
t.Helper()
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatalf("open sql mock: %v", err)
|
||||
}
|
||||
previous := database.DB
|
||||
database.DB = db
|
||||
return mock, func() {
|
||||
database.DB = previous
|
||||
db.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func expectImportMasterData(mock sqlmock.Sqlmock, withRule bool) {
|
||||
mock.ExpectQuery(regexp.QuoteMeta(importCategoryQuery)).WithArgs(int64(1)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "code", "name", "description", "parent_id", "status"}).
|
||||
AddRow(3, 1, "A", "机械结构类", "", nil, "active").
|
||||
AddRow(12, 1, "A01", "标准件", "", 3, "active"))
|
||||
mock.ExpectQuery(regexp.QuoteMeta(importUnitQuery)).WithArgs(int64(1)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "code", "name", "precision_scale", "status"}).
|
||||
AddRow(1, 1, "个", "个", 0, "active"))
|
||||
if withRule {
|
||||
mock.ExpectQuery(regexp.QuoteMeta(importRuleQuery)).WithArgs(int64(1), int64(12)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "category_id", "prefix", "sequence_length", "next_sequence", "status"}).
|
||||
AddRow(5, 1, 12, "A01", 4, 201, "active"))
|
||||
} else {
|
||||
mock.ExpectQuery(regexp.QuoteMeta(importRuleQuery)).WithArgs(int64(1), int64(12)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "category_id", "prefix", "sequence_length", "next_sequence"}))
|
||||
}
|
||||
}
|
||||
|
||||
// expectImportLog 校验成功导入的汇总记录。
|
||||
func expectImportLog(mock sqlmock.Sqlmock, fileName, status string, total, success, failed int, reason interface{}) {
|
||||
// 行数显式转 int64:database/sql 会把实参归一成 driver.Value(int→int64),
|
||||
// sqlmock 按类型比对,传 Go int 匹配不上。
|
||||
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO material_import_logs (organization_id, operator, file_name, total_rows, success_rows, failed_rows, status, failure_reason) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)).
|
||||
WithArgs(int64(1), "tester", fileName, int64(total), int64(success), int64(failed), status, reason).
|
||||
WillReturnResult(sqlmock.NewResult(9, 1))
|
||||
}
|
||||
|
||||
// 记录写入错误会被服务吞掉,单纯不设置 SQL 期望无法证明没有尝试写入。
|
||||
// 设一个最后的写入期望,并确认只有它未被消费,且前面的业务期望全部完成。
|
||||
func expectNoImportLog(t *testing.T, mock sqlmock.Sqlmock) func() {
|
||||
t.Helper()
|
||||
unexpected := mock.ExpectExec(`INSERT INTO material_import_logs`).WillReturnResult(sqlmock.NewResult(9, 1))
|
||||
return func() {
|
||||
t.Helper()
|
||||
err := mock.ExpectationsWereMet()
|
||||
if err == nil {
|
||||
t.Fatal("失败导入不应写入历史记录")
|
||||
}
|
||||
want := "there is a remaining expectation which was not matched: " + unexpected.String()
|
||||
if err.Error() != want {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaterialImportServiceDoesNotRecordMasterDataFailure(t *testing.T) {
|
||||
mock, cleanup := newImportServiceMock(t)
|
||||
defer cleanup()
|
||||
queryErr := errors.New("主数据查询失败")
|
||||
mock.ExpectQuery(regexp.QuoteMeta(importCategoryQuery)).WithArgs(int64(1)).WillReturnError(queryErr)
|
||||
defer expectNoImportLog(t, mock)()
|
||||
data := buildTestWorkbook(t, [][]string{
|
||||
{"螺栓", "", "个", "机械结构类", "标准件"},
|
||||
})
|
||||
result, err := NewMaterialImportService().Import(1, "tester", "物料.xlsx", bytes.NewReader(data))
|
||||
if !errors.Is(err, queryErr) || result != nil {
|
||||
t.Fatalf("result = %+v, err = %v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaterialImportServiceReportsRowErrorsWithoutWritingMaterials(t *testing.T) {
|
||||
mock, cleanup := newImportServiceMock(t)
|
||||
defer cleanup()
|
||||
|
||||
expectImportMasterData(mock, true)
|
||||
defer expectNoImportLog(t, mock)()
|
||||
|
||||
data := buildTestWorkbook(t, [][]string{
|
||||
{"", "", "", "机械结构类", "标准件"}, // 名称缺失
|
||||
{"螺栓", "", "吨", "不存在的类", "标准件"}, // 大类不存在
|
||||
})
|
||||
result, err := NewMaterialImportService().Import(1, "tester", "物料.xlsx", bytes.NewReader(data))
|
||||
if err != nil {
|
||||
t.Fatalf("import: %v", err)
|
||||
}
|
||||
if result.SuccessRows != 0 || result.FailedRows != 2 || len(result.Errors) != 2 {
|
||||
t.Fatalf("result = %+v", result)
|
||||
}
|
||||
if result.Errors[0].Row != 2 || !strings.Contains(result.Errors[0].Reason, "物料名称") {
|
||||
t.Fatalf("first error = %+v", result.Errors[0])
|
||||
}
|
||||
if result.Errors[1].Row != 3 || !strings.Contains(result.Errors[1].Reason, "大类") {
|
||||
t.Fatalf("second error = %+v", result.Errors[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaterialImportServiceReportsMissingCodeRule(t *testing.T) {
|
||||
mock, cleanup := newImportServiceMock(t)
|
||||
defer cleanup()
|
||||
|
||||
expectImportMasterData(mock, false)
|
||||
defer expectNoImportLog(t, mock)()
|
||||
|
||||
data := buildTestWorkbook(t, [][]string{
|
||||
{"螺栓", "", "个", "机械结构类", "标准件"},
|
||||
})
|
||||
result, err := NewMaterialImportService().Import(1, "tester", "物料.xlsx", bytes.NewReader(data))
|
||||
if err != nil {
|
||||
t.Fatalf("import: %v", err)
|
||||
}
|
||||
if result.FailedRows != 1 || !strings.Contains(result.Errors[0].Reason, "编码规则") {
|
||||
t.Fatalf("result = %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaterialImportServiceRejectsInvalidTemplate(t *testing.T) {
|
||||
mock, cleanup := newImportServiceMock(t)
|
||||
defer cleanup()
|
||||
|
||||
defer expectNoImportLog(t, mock)()
|
||||
|
||||
result, err := NewMaterialImportService().Import(1, "tester", "错的.xlsx", strings.NewReader("not an xlsx"))
|
||||
if err == nil {
|
||||
t.Fatal("期望返回模板错误")
|
||||
}
|
||||
if result != nil {
|
||||
t.Fatalf("result = %+v, want nil", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaterialImportServiceImportsWhenAllRowsValid(t *testing.T) {
|
||||
mock, cleanup := newImportServiceMock(t)
|
||||
defer cleanup()
|
||||
|
||||
expectImportMasterData(mock, true)
|
||||
// 事务内:锁规则 → 取已占用编码 → 推进序号 → 插入 → 回查 ID
|
||||
// ImportTx 先把整批编码分配完并推进 code_rules,再分批插入。
|
||||
expectImportPersist(mock, "A010201")
|
||||
expectImportLog(mock, "物料.xlsx", "success", 1, 1, 0, "")
|
||||
|
||||
data := buildTestWorkbook(t, [][]string{
|
||||
{"内六角螺栓", "M8x30", "个", "机械结构类", "标准件"},
|
||||
})
|
||||
result, err := NewMaterialImportService().Import(1, "tester", "物料.xlsx", bytes.NewReader(data))
|
||||
if err != nil {
|
||||
t.Fatalf("import: %v", err)
|
||||
}
|
||||
if result.SuccessRows != 1 || result.FailedRows != 0 || len(result.Errors) != 0 {
|
||||
t.Fatalf("result = %+v", result)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaterialImportServiceRollsBackWhenInsertFails(t *testing.T) {
|
||||
mock, cleanup := newImportServiceMock(t)
|
||||
defer cleanup()
|
||||
|
||||
expectImportMasterData(mock, true)
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery(`FOR UPDATE`).WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "category_id", "prefix", "sequence_length", "next_sequence"}).
|
||||
AddRow(5, 1, 12, "A01", 4, 201))
|
||||
mock.ExpectQuery(`SELECT material_code FROM materials`).WillReturnRows(sqlmock.NewRows([]string{"material_code"}))
|
||||
mock.ExpectExec(regexp.QuoteMeta(`UPDATE code_rules SET next_sequence = ? WHERE id = ?`)).WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(`INSERT INTO materials`).WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
// 插入后回查不到 ID(模拟插入未真正落库),persist 据此报错并回滚
|
||||
mock.ExpectQuery(`SELECT id, material_code FROM materials`).WillReturnRows(sqlmock.NewRows([]string{"id", "material_code"}))
|
||||
mock.ExpectRollback()
|
||||
defer expectNoImportLog(t, mock)()
|
||||
|
||||
data := buildTestWorkbook(t, [][]string{
|
||||
{"内六角螺栓", "M8x30", "个", "机械结构类", "标准件"},
|
||||
})
|
||||
result, err := NewMaterialImportService().Import(1, "tester", "物料.xlsx", bytes.NewReader(data))
|
||||
if err == nil {
|
||||
t.Fatal("期望入库失败")
|
||||
}
|
||||
if result != nil {
|
||||
t.Fatalf("result = %+v, want nil", result)
|
||||
}
|
||||
}
|
||||
|
||||
// expectImportPersist 描述一次成功入库的事务序列:锁规则 → 取已占用编码 → 推进序号
|
||||
// → 插入 → 回查 ID → 写审计日志 → 提交。code 是本次分配给物料的编码。
|
||||
type importSnapshot struct {
|
||||
code string
|
||||
}
|
||||
|
||||
func (expected importSnapshot) Match(value driver.Value) bool {
|
||||
text, ok := value.(string)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
var snapshot struct {
|
||||
ID int64 `json:"id"`
|
||||
OrganizationID int64 `json:"organization_id"`
|
||||
MaterialCode string `json:"material_code"`
|
||||
Name string `json:"name"`
|
||||
Spec string `json:"spec"`
|
||||
CategoryID int64 `json:"category_id"`
|
||||
BaseUnitID int64 `json:"base_unit_id"`
|
||||
Unit string `json:"unit"`
|
||||
Status string `json:"status"`
|
||||
Version int `json:"version"`
|
||||
}
|
||||
return json.Unmarshal([]byte(text), &snapshot) == nil &&
|
||||
snapshot.ID == 31 && snapshot.OrganizationID == 1 &&
|
||||
snapshot.MaterialCode == expected.code && snapshot.Name == "内六角螺栓" &&
|
||||
snapshot.Spec == "M8x30" && snapshot.CategoryID == 12 && snapshot.BaseUnitID == 1 &&
|
||||
snapshot.Unit == "个" && snapshot.Status == "active" && snapshot.Version == 1
|
||||
}
|
||||
|
||||
func expectImportPersist(mock sqlmock.Sqlmock, code string) {
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT id, organization_id, category_id, prefix, sequence_length, next_sequence FROM code_rules WHERE organization_id = ? AND category_id = ? AND status = 'active' FOR UPDATE`)).
|
||||
WithArgs(int64(1), int64(12)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "category_id", "prefix", "sequence_length", "next_sequence"}).
|
||||
AddRow(5, 1, 12, "A01", 4, 201))
|
||||
mock.ExpectQuery(`SELECT material_code FROM materials WHERE organization_id = \? AND material_code LIKE \?`).
|
||||
WithArgs(int64(1), "A01%").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"material_code"}))
|
||||
mock.ExpectExec(regexp.QuoteMeta(`UPDATE code_rules SET next_sequence = ? WHERE id = ?`)).
|
||||
WithArgs(int64(202), int64(5)).WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(`INSERT INTO materials`).WillReturnResult(sqlmock.NewResult(31, 1))
|
||||
mock.ExpectQuery(`SELECT id, material_code FROM materials WHERE organization_id = \? AND material_code IN \(\?\)`).
|
||||
WithArgs(int64(1), code).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "material_code"}).AddRow(31, code))
|
||||
description := "批量导入创建:内六角螺栓(" + code + ")\n物料编码:" + code +
|
||||
"\n名称:内六角螺栓\n规格:M8x30\n分类:机械结构类 / 标准件\n单位:个\n状态:启用"
|
||||
mock.ExpectExec(`INSERT INTO material_logs`).
|
||||
WithArgs("CREATE", int64(31), "", importSnapshot{code: code}, "tester", description).
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectCommit()
|
||||
}
|
||||
|
||||
// 界面上分类一律显示成「编码 名称」(Materials.vue 的 categoryLabel),用户整段照抄
|
||||
// 进模板是常事;导入校验得认这种写法,否则分类明明存在也会报「不存在或已停用」。
|
||||
func TestMaterialImportServiceAcceptsCategoryDisplayLabels(t *testing.T) {
|
||||
mock, cleanup := newImportServiceMock(t)
|
||||
defer cleanup()
|
||||
|
||||
expectImportMasterData(mock, true)
|
||||
expectImportPersist(mock, "A010201")
|
||||
expectImportLog(mock, "物料.xlsx", "success", 1, 1, 0, "")
|
||||
|
||||
data := buildTestWorkbook(t, [][]string{
|
||||
{"内六角螺栓", "M8x30", "个", "A 机械结构类", "A01 标准件"},
|
||||
})
|
||||
result, err := NewMaterialImportService().Import(1, "tester", "物料.xlsx", bytes.NewReader(data))
|
||||
if err != nil {
|
||||
t.Fatalf("import: %v", err)
|
||||
}
|
||||
if result.SuccessRows != 1 || result.FailedRows != 0 || len(result.Errors) != 0 {
|
||||
t.Fatalf("result = %+v", result)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// 只填编码也认:模板说明写的是名称,但用户对着物料分类页抄编码同样应该通过。
|
||||
func TestMaterialImportServiceAcceptsCategoryCodes(t *testing.T) {
|
||||
mock, cleanup := newImportServiceMock(t)
|
||||
defer cleanup()
|
||||
|
||||
expectImportMasterData(mock, true)
|
||||
expectImportPersist(mock, "A010201")
|
||||
expectImportLog(mock, "物料.xlsx", "success", 1, 1, 0, "")
|
||||
|
||||
data := buildTestWorkbook(t, [][]string{
|
||||
{"内六角螺栓", "M8x30", "个", "A", "A01"},
|
||||
})
|
||||
result, err := NewMaterialImportService().Import(1, "tester", "物料.xlsx", bytes.NewReader(data))
|
||||
if err != nil {
|
||||
t.Fatalf("import: %v", err)
|
||||
}
|
||||
if result.SuccessRows != 1 || result.FailedRows != 0 || len(result.Errors) != 0 {
|
||||
t.Fatalf("result = %+v", result)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// 放宽的只是写法,不是启用状态:停用分类用「编码 名称」引用同样要挡下来。
|
||||
func TestMaterialImportServiceRejectsDisabledCategoryAliases(t *testing.T) {
|
||||
mock, cleanup := newImportServiceMock(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectQuery(regexp.QuoteMeta(importCategoryQuery)).WithArgs(int64(1)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "code", "name", "description", "parent_id", "status"}).
|
||||
AddRow(3, 1, "A", "机械结构类", "", nil, "active").
|
||||
AddRow(12, 1, "A01", "标准件", "", 3, "active").
|
||||
AddRow(20, 1, "B", "旧大类", "", nil, "inactive"))
|
||||
mock.ExpectQuery(regexp.QuoteMeta(importUnitQuery)).WithArgs(int64(1)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "code", "name", "precision_scale", "status"}).
|
||||
AddRow(1, 1, "个", "个", 0, "active"))
|
||||
// 大类查不到,小类不再定位,也就不会去查编码规则
|
||||
|
||||
defer expectNoImportLog(t, mock)()
|
||||
|
||||
data := buildTestWorkbook(t, [][]string{
|
||||
{"内六角螺栓", "M8x30", "个", "B 旧大类", "标准件"},
|
||||
})
|
||||
result, err := NewMaterialImportService().Import(1, "tester", "物料.xlsx", bytes.NewReader(data))
|
||||
if err != nil {
|
||||
t.Fatalf("import: %v", err)
|
||||
}
|
||||
// 断言必须点到「大类本身不存在」:小类的报错里也带着「B 旧大类」这几个字,
|
||||
// 只判 Contains("大类") 会被这层巧合放过。
|
||||
if result.FailedRows != 1 || !strings.Contains(result.Errors[0].Reason, "大类「B 旧大类」") {
|
||||
t.Fatalf("result = %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
// 界面上的显示名是「编码 + 半角空格 + 名称」,而中文输入法全角模式下敲出来的空格是
|
||||
// U+3000、从网页复制有时带 U+00A0。两串肉眼完全一样、码点不同,不归一化就会变成
|
||||
// 「从浏览器复制的能导入、自己手写的报不存在」。
|
||||
func TestMaterialImportServiceAcceptsWhitespaceVariantsInCategoryNames(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
major string
|
||||
minor string
|
||||
}{
|
||||
{"全角空格 U+3000", "A 机械结构类", "A01 标准件"},
|
||||
{"不换行空格 U+00A0", "A 机械结构类", "A01 标准件"},
|
||||
{"连续半角空格", "A 机械结构类", "A01 标准件"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
mock, cleanup := newImportServiceMock(t)
|
||||
defer cleanup()
|
||||
|
||||
expectImportMasterData(mock, true)
|
||||
expectImportPersist(mock, "A010201")
|
||||
expectImportLog(mock, "物料.xlsx", "success", 1, 1, 0, "")
|
||||
|
||||
data := buildTestWorkbook(t, [][]string{
|
||||
{"内六角螺栓", "M8x30", "个", tc.major, tc.minor},
|
||||
})
|
||||
result, err := NewMaterialImportService().Import(1, "tester", "物料.xlsx", bytes.NewReader(data))
|
||||
if err != nil {
|
||||
t.Fatalf("import: %v", err)
|
||||
}
|
||||
if result.SuccessRows != 1 || result.FailedRows != 0 || len(result.Errors) != 0 {
|
||||
t.Fatalf("result = %+v", result)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 物料表的 unit 只存一种写法:单位编码。模板里填名称(界面显示的是「千克」这类
|
||||
// 显示名)时也得落成 kg,否则同一单位会按录入路径留下两种值——库存页直接显示该
|
||||
// 字段(Stock.vue 的 unit 列),会同时出现「千克」和「kg」。
|
||||
func TestMaterialImportServiceStoresUnitCodeWhenTemplateUsesUnitName(t *testing.T) {
|
||||
mock, cleanup := newImportServiceMock(t)
|
||||
defer cleanup()
|
||||
|
||||
mock.ExpectQuery(regexp.QuoteMeta(importCategoryQuery)).WithArgs(int64(1)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "code", "name", "description", "parent_id", "status"}).
|
||||
AddRow(3, 1, "A", "机械结构类", "", nil, "active").
|
||||
AddRow(12, 1, "A01", "标准件", "", 3, "active"))
|
||||
// 计量类单位:编码 kg、名称千克(计数类单位 code 与 name 相同,测不出差异)
|
||||
mock.ExpectQuery(regexp.QuoteMeta(importUnitQuery)).WithArgs(int64(1)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "code", "name", "precision_scale", "status"}).
|
||||
AddRow(7, 1, "kg", "千克", 3, "active"))
|
||||
mock.ExpectQuery(regexp.QuoteMeta(importRuleQuery)).WithArgs(int64(1), int64(12)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "category_id", "prefix", "sequence_length", "next_sequence", "status"}).
|
||||
AddRow(5, 1, 12, "A01", 4, 201, "active"))
|
||||
|
||||
rows := []model.MaterialImportRow{
|
||||
{Row: 2, Name: "内六角螺栓", Spec: "M8x30", Unit: "千克", MajorName: "机械结构类", MinorName: "标准件"},
|
||||
{Row: 3, Name: "垫圈", Spec: "M8", Unit: "kg", MajorName: "机械结构类", MinorName: "标准件"},
|
||||
}
|
||||
items, importErrors, err := NewMaterialImportService().validateRows(1, rows)
|
||||
if err != nil {
|
||||
t.Fatalf("validateRows: %v", err)
|
||||
}
|
||||
if len(importErrors) != 0 || len(items) != 2 {
|
||||
t.Fatalf("items = %d, importErrors = %+v", len(items), importErrors)
|
||||
}
|
||||
for i, item := range items {
|
||||
if item.Unit != "kg" {
|
||||
t.Fatalf("items[%d].Unit = %q, want 单位编码 %q", i, item.Unit, "kg")
|
||||
}
|
||||
}
|
||||
if items[0].BaseUnitID == nil || *items[0].BaseUnitID != 7 {
|
||||
t.Fatalf("baseUnitID = %v, want 7", items[0].BaseUnitID)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
|
@ -156,7 +156,7 @@ func expectCodeTaken(mock sqlmock.Sqlmock, code string, taken bool) {
|
|||
|
||||
func expectMaterialInsert(mock sqlmock.Sqlmock, code string) {
|
||||
mock.ExpectExec(regexp.QuoteMeta(insertMaterialStmt)).
|
||||
WithArgs(int64(1), code, "轴承", nil, "", nil, int64(12), nil, "pcs", "active", 1).
|
||||
WithArgs(int64(1), code, "轴承", nil, "", nil, int64(12), nil, "个", "active", 1).
|
||||
WillReturnResult(sqlmock.NewResult(7, 1))
|
||||
}
|
||||
|
||||
|
|
@ -164,7 +164,7 @@ func expectCreatedMaterial(mock sqlmock.Sqlmock, code string) {
|
|||
now := time.Date(2026, 9, 20, 9, 0, 0, 0, time.UTC)
|
||||
mock.ExpectQuery(regexp.QuoteMeta(getMaterialQuery)).WithArgs(int64(1), int64(7)).WillReturnRows(
|
||||
sqlmock.NewRows([]string{"id", "organization_id", "material_code", "name", "material_image", "spec", "manufacturer_model", "category_id", "base_unit_id", "unit", "status", "version", "created_at", "updated_at"}).
|
||||
AddRow(7, 1, code, "轴承", nil, "", nil, 12, nil, "pcs", "active", 1, now, now),
|
||||
AddRow(7, 1, code, "轴承", nil, "", nil, 12, nil, "个", "active", 1, now, now),
|
||||
)
|
||||
mock.ExpectExec(regexp.QuoteMeta(materialLogStmt)).
|
||||
WithArgs("CREATE", int64(7), "", sqlmock.AnyArg(), "tester", sqlmock.AnyArg()).
|
||||
|
|
@ -289,7 +289,7 @@ func TestMaterialServiceReportsDuplicateManuallyEditedCode(t *testing.T) {
|
|||
expectActiveSubcategory(mock)
|
||||
expectLockedCodeRule(mock, &next)
|
||||
mock.ExpectExec(regexp.QuoteMeta(insertMaterialStmt)).
|
||||
WithArgs(int64(1), "CUSTOM-001", "轴承", nil, "", nil, int64(12), nil, "pcs", "active", 1).
|
||||
WithArgs(int64(1), "CUSTOM-001", "轴承", nil, "", nil, int64(12), nil, "个", "active", 1).
|
||||
WillReturnError(errors.New("Error 1062: Duplicate entry 'CUSTOM-001' for key 'uk_materials_org_code'"))
|
||||
mock.ExpectRollback()
|
||||
|
||||
|
|
|
|||
|
|
@ -65,6 +65,16 @@ func Error(c *gin.Context, httpStatus int, message string) {
|
|||
})
|
||||
}
|
||||
|
||||
// ErrorWithData 返回带业务数据的错误响应。批量导入校验失败时需要把逐行错误
|
||||
// 一起带回去,不能只给一句话。
|
||||
func ErrorWithData(c *gin.Context, httpStatus int, message string, data interface{}) {
|
||||
c.JSON(httpStatus, Response{
|
||||
Code: httpStatus,
|
||||
Message: message,
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
|
||||
func BadRequest(c *gin.Context, message string) {
|
||||
Error(c, 400, message)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ CREATE TABLE material_categories (id BIGINT AUTO_INCREMENT PRIMARY KEY, organiza
|
|||
CREATE TABLE units (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL, code VARCHAR(50) NOT NULL, name VARCHAR(100) NOT NULL, precision_scale TINYINT NOT NULL DEFAULT 0, status VARCHAR(20) NOT NULL DEFAULT 'active', UNIQUE KEY uk_units_org_code(organization_id,code), FOREIGN KEY(organization_id) REFERENCES organizations(id) ON DELETE RESTRICT) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
CREATE TABLE materials (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL, material_code VARCHAR(50) NOT NULL, name VARCHAR(200) NOT NULL, material_image VARCHAR(500), spec VARCHAR(200), manufacturer_model VARCHAR(200), material_type_id BIGINT, category_id BIGINT NOT NULL, base_unit_id BIGINT, unit VARCHAR(20), status VARCHAR(20) NOT NULL DEFAULT 'active', version INT NOT NULL DEFAULT 1, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_materials_org_code(organization_id,material_code), KEY idx_materials_category(category_id), FOREIGN KEY(organization_id) REFERENCES organizations(id) ON DELETE RESTRICT, FOREIGN KEY(material_type_id) REFERENCES material_types(id) ON DELETE RESTRICT, FOREIGN KEY(category_id) REFERENCES material_categories(id) ON DELETE RESTRICT, FOREIGN KEY(base_unit_id) REFERENCES units(id) ON DELETE RESTRICT) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
CREATE TABLE code_rules (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL, category_id BIGINT NOT NULL, prefix VARCHAR(30) NOT NULL, sequence_length TINYINT NOT NULL, next_sequence BIGINT NOT NULL DEFAULT 1, status VARCHAR(20) NOT NULL DEFAULT 'active', UNIQUE KEY uk_code_rules_org_category(organization_id,category_id), FOREIGN KEY(organization_id) REFERENCES organizations(id), FOREIGN KEY(category_id) REFERENCES material_categories(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
CREATE TABLE material_import_logs (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL, operator VARCHAR(100) NOT NULL, file_name VARCHAR(255) NOT NULL, total_rows INT NOT NULL DEFAULT 0, success_rows INT NOT NULL DEFAULT 0, failed_rows INT NOT NULL DEFAULT 0, status VARCHAR(20) NOT NULL, failure_reason VARCHAR(1000) NOT NULL DEFAULT '', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_material_import_logs_org_created(organization_id,created_at), FOREIGN KEY(organization_id) REFERENCES organizations(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
CREATE TABLE material_unit_conversions (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL, material_id BIGINT NOT NULL, from_unit_id BIGINT NOT NULL, to_unit_id BIGINT NOT NULL, conversion_rate DECIMAL(20,6) NOT NULL, effective_from DATE NOT NULL, effective_to DATE NULL, KEY idx_material_unit_conversions_material(organization_id,material_id), FOREIGN KEY(organization_id) REFERENCES organizations(id), FOREIGN KEY(material_id) REFERENCES materials(id), FOREIGN KEY(from_unit_id) REFERENCES units(id), FOREIGN KEY(to_unit_id) REFERENCES units(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
CREATE TABLE tags (id BIGINT AUTO_INCREMENT PRIMARY KEY, material_id BIGINT NOT NULL, tag_code VARCHAR(100) NOT NULL UNIQUE, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_tags_material(material_id), FOREIGN KEY(material_id) REFERENCES materials(id) ON DELETE CASCADE) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
CREATE TABLE business_partners (id BIGINT AUTO_INCREMENT PRIMARY KEY, legal_name VARCHAR(200) NOT NULL, unified_credit_code VARCHAR(50) UNIQUE, contact_name VARCHAR(100), contact_phone VARCHAR(50), address VARCHAR(500), created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
|
@ -61,6 +62,38 @@ CREATE TABLE IF NOT EXISTS schema_migration_reconciliations (
|
|||
INSERT INTO schema_migrations(version,status,completed_at,details) VALUES('2026-10-08-company-master-data-isolation','completed',NOW(),JSON_OBJECT('version','2026-10-08-company-master-data-isolation','source','fresh-init'));
|
||||
|
||||
INSERT INTO organizations (id,code,name,status) VALUES (1,'DEFAULT','默认公司组织','active');
|
||||
-- 常用计量单位归属默认公司;计数、包装使用整数,重量、容量、长度、面积保留 3 位小数。
|
||||
INSERT INTO units (organization_id,code,name,precision_scale,status) VALUES
|
||||
(1,'个','个',0,'active'),
|
||||
(1,'件','件',0,'active'),
|
||||
(1,'只','只',0,'active'),
|
||||
(1,'支','支',0,'active'),
|
||||
(1,'根','根',0,'active'),
|
||||
(1,'条','条',0,'active'),
|
||||
(1,'片','片',0,'active'),
|
||||
(1,'张','张',0,'active'),
|
||||
(1,'套','套',0,'active'),
|
||||
(1,'台','台',0,'active'),
|
||||
(1,'对','对',0,'active'),
|
||||
(1,'双','双',0,'active'),
|
||||
(1,'包','包',0,'active'),
|
||||
(1,'袋','袋',0,'active'),
|
||||
(1,'盒','盒',0,'active'),
|
||||
(1,'箱','箱',0,'active'),
|
||||
(1,'瓶','瓶',0,'active'),
|
||||
(1,'罐','罐',0,'active'),
|
||||
(1,'桶','桶',0,'active'),
|
||||
(1,'卷','卷',0,'active'),
|
||||
(1,'g','克',3,'active'),
|
||||
(1,'kg','千克',3,'active'),
|
||||
(1,'t','吨',3,'active'),
|
||||
(1,'mL','毫升',3,'active'),
|
||||
(1,'L','升',3,'active'),
|
||||
(1,'m³','立方米',3,'active'),
|
||||
(1,'mm','毫米',3,'active'),
|
||||
(1,'cm','厘米',3,'active'),
|
||||
(1,'m','米',3,'active'),
|
||||
(1,'m²','平方米',3,'active');
|
||||
INSERT INTO roles (code,name) VALUES ('super_admin','超级管理员'),('warehouse_admin','仓库管理员'),('purchaser','采购人员'),('technician','技术人员'),('production','生产人员'),('sales','销售人员');
|
||||
INSERT INTO permissions (code,name) VALUES ('master.material.read','查看物料'),('master.material.write','维护物料'),('master.config.write','维护物料基础配置'),('organization.read','查看公司组织'),('organization.write','维护公司组织'),('organization.user.write','维护用户'),('organization.role.write','维护角色与权限'),('partner.supplier.read','查看供应商'),('partner.supplier.write','维护供应商'),('partner.customer.read','查看客户'),('partner.customer.write','维护客户'),('inventory.stock.read','查看库存'),('inventory.warning.write','维护库存预警'),('inventory.inbound.write','维护入库'),('inventory.outbound.write','维护出库');
|
||||
INSERT INTO role_permissions (role_id,permission_id) SELECT r.id,p.id FROM roles r CROSS JOIN permissions p WHERE r.code='super_admin';
|
||||
|
|
|
|||
4
scripts/upgrades/2026-10-10-material-bulk-import.sql
Normal file
4
scripts/upgrades/2026-10-10-material-bulk-import.sql
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
-- 物料批量导入:新增导入记录表。仅在已部署的库上执行一次;本地开发库走 init.sql 重建。
|
||||
SET NAMES utf8mb4;
|
||||
CREATE TABLE material_import_logs (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL, operator VARCHAR(100) NOT NULL, file_name VARCHAR(255) NOT NULL, total_rows INT NOT NULL DEFAULT 0, success_rows INT NOT NULL DEFAULT 0, failed_rows INT NOT NULL DEFAULT 0, status VARCHAR(20) NOT NULL, failure_reason VARCHAR(1000) NOT NULL DEFAULT '', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_material_import_logs_org_created(organization_id,created_at), FOREIGN KEY(organization_id) REFERENCES organizations(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
INSERT INTO schema_migrations(version,status,completed_at,details) VALUES('2026-10-10-material-bulk-import','completed',NOW(),JSON_OBJECT('version','2026-10-10-material-bulk-import','source','upgrade'));
|
||||
BIN
server.exe
BIN
server.exe
Binary file not shown.
14
启动项目.bat
14
启动项目.bat
|
|
@ -3,9 +3,19 @@ title Warehouse Management System Launcher
|
|||
|
||||
cd /d "%~dp0"
|
||||
|
||||
echo Building backend...
|
||||
rem Rebuild on every launch so the executable can never be a stale one.
|
||||
go build -o server.exe ./cmd/server
|
||||
if errorlevel 1 (
|
||||
echo.
|
||||
echo Backend build failed. Fix the errors above and run this launcher again.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo Starting backend (8082)...
|
||||
rem Run the current source so local testing never uses an outdated server.exe.
|
||||
start "Warehouse MMS - Backend" /D "%~dp0" cmd /k "go run ./cmd/server"
|
||||
rem Work dir must stay at the project root: frontend/dist and uploads are relative paths.
|
||||
start "Warehouse MMS - Backend" /D "%~dp0" cmd /k "server.exe"
|
||||
|
||||
echo Starting frontend (8083)...
|
||||
start "Warehouse MMS - Frontend" /D "%~dp0frontend" cmd /k "npm run dev"
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user