Merge remote-tracking branch 'origin/feature/material-bulk-import' into dev
This commit is contained in:
commit
9c9ea5c62e
|
|
@ -1,31 +1,43 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"strings"
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/wmms/wmms/internal/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFrontendRoutesServeBuiltAssetsAtRootPath(t *testing.T) {
|
func TestFrontendRoutesServeBuiltAssetsAtRootPath(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
distDir := t.TempDir()
|
dist, err := web.DistFS()
|
||||||
assetsDir := filepath.Join(distDir, "assets")
|
if err != nil {
|
||||||
if err := os.Mkdir(assetsDir, 0o755); err != nil {
|
t.Fatal(err)
|
||||||
t.Fatalf("create assets directory: %v", err)
|
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(filepath.Join(assetsDir, "app.js"), []byte("console.log('ok')"), 0o600); err != nil {
|
assetPath := ""
|
||||||
t.Fatalf("write asset: %v", err)
|
if err := fs.WalkDir(dist, ".", func(path string, entry fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !entry.IsDir() && strings.HasPrefix(path, "assets/") {
|
||||||
|
assetPath = path
|
||||||
|
return fs.SkipAll
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if assetPath == "" {
|
||||||
|
t.Fatal("embedded frontend has no assets")
|
||||||
}
|
}
|
||||||
|
|
||||||
router := gin.New()
|
router := gin.New()
|
||||||
registerFrontendRoutes(router, distDir)
|
registerFrontendRoutes(router)
|
||||||
|
|
||||||
response := httptest.NewRecorder()
|
response := httptest.NewRecorder()
|
||||||
router.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/assets/app.js", nil))
|
router.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/"+assetPath, nil))
|
||||||
|
|
||||||
if response.Code != http.StatusOK {
|
if response.Code != http.StatusOK {
|
||||||
t.Fatalf("asset status = %d, want %d", response.Code, http.StatusOK)
|
t.Fatalf("asset status = %d, want %d", response.Code, http.StatusOK)
|
||||||
|
|
@ -34,12 +46,8 @@ func TestFrontendRoutesServeBuiltAssetsAtRootPath(t *testing.T) {
|
||||||
|
|
||||||
func TestFrontendRefreshFallsBackWithoutMaskingAPIOrAssets(t *testing.T) {
|
func TestFrontendRefreshFallsBackWithoutMaskingAPIOrAssets(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
distDir := t.TempDir()
|
|
||||||
if err := os.WriteFile(filepath.Join(distDir, "index.html"), []byte("<html>company app</html>"), 0o600); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
registerFrontendRoutes(r, distDir)
|
registerFrontendRoutes(r)
|
||||||
for _, path := range []string{"/stock", "/login", "/master-data/settings", "/api/missing", "/ws", "/uploads/missing", "/assets/missing.js"} {
|
for _, path := range []string{"/stock", "/login", "/master-data/settings", "/api/missing", "/ws", "/uploads/missing", "/assets/missing.js"} {
|
||||||
t.Run(path, func(t *testing.T) {
|
t.Run(path, func(t *testing.T) {
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
|
||||||
41
docs/api.md
41
docs/api.md
|
|
@ -182,6 +182,10 @@
|
||||||
"material_image": "/uploads/bolt.png",
|
"material_image": "/uploads/bolt.png",
|
||||||
"spec": "M8x30",
|
"spec": "M8x30",
|
||||||
"manufacturer_model": "GB/T 70.1",
|
"manufacturer_model": "GB/T 70.1",
|
||||||
|
"supplier_id": 21,
|
||||||
|
"manufacturer_id": 22,
|
||||||
|
"supplier_name": "示例供应商",
|
||||||
|
"manufacturer_name": "示例制造商",
|
||||||
"category_id": 12,
|
"category_id": 12,
|
||||||
"base_unit_id": 3,
|
"base_unit_id": 3,
|
||||||
"unit": "个",
|
"unit": "个",
|
||||||
|
|
@ -199,7 +203,7 @@
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`material_image`、`spec`、`manufacturer_model`、`category_id`、`base_unit_id`、`unit` 为空时**不会出现在响应里**(后端 `omitempty`),前端取值时需要做空值兜底。
|
`material_image`、`spec`、`manufacturer_model`、`supplier_id`、`manufacturer_id`、`category_id`、`base_unit_id`、`unit` 为空时**不会出现在响应里**(后端 `omitempty`),前端取值时需要做空值兜底。`manufacturer_model` 是制造商为产品指定的型号或订货识别型号;`spec` 表示物料规格,`manufacturer_id` 表示制造商伙伴关联。`supplier_name` 和 `manufacturer_name` 是伙伴显示名称。
|
||||||
|
|
||||||
**状态码:**
|
**状态码:**
|
||||||
|
|
||||||
|
|
@ -243,7 +247,9 @@
|
||||||
"category_id": 12,
|
"category_id": 12,
|
||||||
"base_unit_id": 3,
|
"base_unit_id": 3,
|
||||||
"unit": "个",
|
"unit": "个",
|
||||||
"material_image": "/uploads/bolt.png"
|
"material_image": "/uploads/bolt.png",
|
||||||
|
"supplier_id": 21,
|
||||||
|
"manufacturer_id": 22
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -253,10 +259,13 @@
|
||||||
| name | string | 是 | 物料名称 |
|
| name | string | 是 | 物料名称 |
|
||||||
| category_id | integer | 是 | 小类分类 ID,必须大于 0 且真实存在 |
|
| category_id | integer | 是 | 小类分类 ID,必须大于 0 且真实存在 |
|
||||||
| spec | string | 否 | 规格 |
|
| spec | string | 否 | 规格 |
|
||||||
| manufacturer_model| string | 否 | 厂家型号 |
|
| manufacturer_model| string | 否 | 制造商为产品指定的型号或订货识别型号 |
|
||||||
| base_unit_id | integer | 否 | 基本计量单位 ID |
|
| base_unit_id | integer | 否 | 基本计量单位 ID |
|
||||||
| unit | string | 否 | 单位编码(冗余展示字段) |
|
| unit | string | 否 | 单位编码(冗余展示字段) |
|
||||||
| material_image | string | 否 | 图片地址 |
|
| material_image | string | 否 | 图片地址 |
|
||||||
|
| supplier_id | integer | 否 | 当前公司内具有供应商身份的伙伴 ID |
|
||||||
|
| manufacturer_id | integer | 否 | 当前公司内具有制造商身份的伙伴 ID |
|
||||||
|
| allow_duplicate | boolean | 否 | 重复提示后用户确认继续时传 `true` |
|
||||||
| organization_id | integer | 否 | 传入会被忽略,服务端取当前用户的组织 |
|
| organization_id | integer | 否 | 传入会被忽略,服务端取当前用户的组织 |
|
||||||
|
|
||||||
**响应:** `201 Created`,`data` 为创建后的物料对象。
|
**响应:** `201 Created`,`data` 为创建后的物料对象。
|
||||||
|
|
@ -274,6 +283,14 @@
|
||||||
- `201 Created` — 创建成功
|
- `201 Created` — 创建成功
|
||||||
- `400 Bad Request` — 请求体非法、物料编码已存在,或 `category_id` 不是有效的小类类型
|
- `400 Bad Request` — 请求体非法、物料编码已存在,或 `category_id` 不是有效的小类类型
|
||||||
|
|
||||||
|
### GET /materials/partners?role=supplier|manufacturer
|
||||||
|
|
||||||
|
返回当前公司内可选的伙伴编号和名称,供物料表单选择使用;支持 `limit`(最大 100)和 `offset` 分页。
|
||||||
|
|
||||||
|
### POST /materials/duplicate-check
|
||||||
|
|
||||||
|
提交 `name`、`spec`、`unit`、`base_unit_id`、`supplier_id`、`manufacturer_id` 检查本公司已有物料。仅上述五个物料属性全部相同的记录会返回;`exclude_id` 可在编辑检查时排除自身。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### PUT /materials/:id
|
### PUT /materials/:id
|
||||||
|
|
@ -304,10 +321,12 @@
|
||||||
| material_code | string | 否 | 物料编码 |
|
| material_code | string | 否 | 物料编码 |
|
||||||
| name | string | 否 | 物料名称 |
|
| name | string | 否 | 物料名称 |
|
||||||
| spec | string | 否 | 规格 |
|
| spec | string | 否 | 规格 |
|
||||||
| manufacturer_model| string | 否 | 厂家型号 |
|
| manufacturer_model| string | 否 | 制造商为产品指定的型号或订货识别型号 |
|
||||||
| base_unit_id | integer | 否 | 基本计量单位 ID |
|
| base_unit_id | integer | 否 | 基本计量单位 ID |
|
||||||
| unit | string | 否 | 单位编码 |
|
| unit | string | 否 | 单位编码 |
|
||||||
| material_image | string | 否 | 图片地址 |
|
| material_image | string | 否 | 图片地址 |
|
||||||
|
| supplier_id | integer | 否 | 当前公司内供应商伙伴 ID;传 0 清空 |
|
||||||
|
| manufacturer_id | integer | 否 | 当前公司内制造商伙伴 ID;传 0 清空 |
|
||||||
| status | string | 否 | 物料状态 |
|
| status | string | 否 | 物料状态 |
|
||||||
|
|
||||||
> `category_id` 在更新接口里同样是必填项。只改名称而不传分类会返回 `400`。
|
> `category_id` 在更新接口里同样是必填项。只改名称而不传分类会返回 `400`。
|
||||||
|
|
@ -401,14 +420,14 @@
|
||||||
|
|
||||||
| 工作表 | 内容 |
|
| 工作表 | 内容 |
|
||||||
|--------|------|
|
|--------|------|
|
||||||
| 填写说明 | 字段规则、填写与上传步骤、容量限制、整批校验、重复导入提醒和参考示例 |
|
| 填写说明 | 字段规则、填写与上传步骤、容量限制、整批校验、重复检测规则和参考示例 |
|
||||||
| 物料数据 | 第 1 行是冻结表头,从第 2 行填写;五列采用文本格式,数据页不放示例物料 |
|
| 物料数据 | 第 1 行是冻结表头,从第 2 行填写;七列采用文本格式,数据页不放示例物料 |
|
||||||
|
|
||||||
`物料数据` 的表头是 5 列:`物料名称`、`规格`、`单位`、`大类`、`小类`。解析时**按列名定位,不按列位置**:
|
`物料数据` 的表头是 7 列:`物料名称`、`规格`、`单位`、`供应商`、`制造商`、`大类`、`小类`。解析时**按列名定位,不按列位置**:
|
||||||
|
|
||||||
- 列顺序可以随意调整;表头允许带首尾空格和末尾的 `*` 必填标记;不认识的列忽略;
|
- 列顺序可以随意调整;表头允许带首尾空格和末尾的 `*` 必填标记;不认识的列忽略;
|
||||||
- 必填列 `物料名称`、`大类`、`小类` 缺任何一列,判为模板不合法并返回「模板格式不正确,请重新下载最新模板」;
|
- 必填列 `物料名称`、`大类`、`小类` 缺任何一列,判为模板不合法并返回「模板格式不正确,请重新下载最新模板」;
|
||||||
- 选填列 `规格`、`单位` 可以整列删掉,取值按空处理。
|
- 选填列 `规格`、`单位`、`供应商`、`制造商` 可以整列删掉,取值按空处理。
|
||||||
|
|
||||||
分类**按名称**填写;名称之外也接受**编码**和界面上的「编码 名称」写法(界面一律显示成 `A 机械结构类`,用户整段照抄进模板是常事)。后端按「大类 + 小类」在本公司内组合匹配(小类名在一个公司内会重名,例如多个大类下都有「其他」,所以必须两级组合)。名称、编码、「编码 名称」三种写法同名或同码时以**名称**优先。单位按名称匹配,同时兼容按编码匹配(如 `kg` 与 `千克`)。
|
分类**按名称**填写;名称之外也接受**编码**和界面上的「编码 名称」写法(界面一律显示成 `A 机械结构类`,用户整段照抄进模板是常事)。后端按「大类 + 小类」在本公司内组合匹配(小类名在一个公司内会重名,例如多个大类下都有「其他」,所以必须两级组合)。名称、编码、「编码 名称」三种写法同名或同码时以**名称**优先。单位按名称匹配,同时兼容按编码匹配(如 `kg` 与 `千克`)。
|
||||||
|
|
||||||
|
|
@ -427,12 +446,16 @@
|
||||||
| 物料名称 | 必填,去首尾空格后非空,≤ 200 字 |
|
| 物料名称 | 必填,去首尾空格后非空,≤ 200 字 |
|
||||||
| 规格 | 选填,≤ 200 字 |
|
| 规格 | 选填,≤ 200 字 |
|
||||||
| 单位 | 选填;留空时默认「个」。非空时必须在当前公司已启用单位中按名称或编码匹配到 |
|
| 单位 | 选填;留空时默认「个」。非空时必须在当前公司已启用单位中按名称或编码匹配到 |
|
||||||
|
| 供应商 | 选填;按当前公司供应商名称或伙伴编码匹配,留空表示不指定 |
|
||||||
|
| 制造商 | 选填;按当前公司制造商名称或伙伴编码匹配,留空表示不指定 |
|
||||||
| 大类 | 必填,必须是当前公司已启用的顶层分类,按名称、编码或「编码 名称」匹配 |
|
| 大类 | 必填,必须是当前公司已启用的顶层分类,按名称、编码或「编码 名称」匹配 |
|
||||||
| 小类 | 必填,必须是该大类下已启用的小类,同样认名称、编码或「编码 名称」 |
|
| 小类 | 必填,必须是该大类下已启用的小类,同样认名称、编码或「编码 名称」 |
|
||||||
| 编码规则 | 该小类必须存在启用的编码规则(`code_rules.status = 'active'`) |
|
| 编码规则 | 该小类必须存在启用的编码规则(`code_rules.status = 'active'`) |
|
||||||
|
|
||||||
物料编码由系统按小类编码规则生成,**不接受模板里的编码列**。单位落库统一写该单位的**编码**——模板里填名称(如「千克」)时也会转成编码,与单条新增保持一致。同一行可能产生多条原因,用中文分号连接。
|
物料编码由系统按小类编码规则生成,**不接受模板里的编码列**。单位落库统一写该单位的**编码**——模板里填名称(如「千克」)时也会转成编码,与单条新增保持一致。同一行可能产生多条原因,用中文分号连接。
|
||||||
|
|
||||||
|
单条新增会检查名称、规格、单位、供应商和制造商是否与现有物料全部相同;发现匹配项会提示并允许用户取消或继续创建。
|
||||||
|
|
||||||
**成功响应 `201 Created`:**
|
**成功响应 `201 Created`:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|
@ -472,7 +495,7 @@
|
||||||
- 整批在**一个事务**内完成:按小类锁编码规则 → 分配编码 → 分批插入物料(每批 500 行)→ 写审计日志;任一步失败整体回滚。
|
- 整批在**一个事务**内完成:按小类锁编码规则 → 分配编码 → 分批插入物料(每批 500 行)→ 写审计日志;任一步失败整体回滚。
|
||||||
- **仅记录成功导入**:事务提交后写一条 `material_import_logs` 成功汇总,包含操作人、公司、时间、文件名、总行数和成功数,失败数为 0、状态为 `success`、失败原因为空;失败导入不保存历史记录。**逐行错误明细只随响应返回,不落库。** 成功新增的每件物料仍写 CREATE 日志及完整 `new_value` JSON 快照;现有日志页「查看详情」显示「批量导入创建:物料名称(物料编码)」,下方按行展示导入时的编码、名称、规格、分类、单位和状态;该详情是物料操作日志,不展示导入文件名或批次汇总。
|
- **仅记录成功导入**:事务提交后写一条 `material_import_logs` 成功汇总,包含操作人、公司、时间、文件名、总行数和成功数,失败数为 0、状态为 `success`、失败原因为空;失败导入不保存历史记录。**逐行错误明细只随响应返回,不落库。** 成功新增的每件物料仍写 CREATE 日志及完整 `new_value` JSON 快照;现有日志页「查看详情」显示「批量导入创建:物料名称(物料编码)」,下方按行展示导入时的编码、名称、规格、分类、单位和状态;该详情是物料操作日志,不展示导入文件名或批次汇总。
|
||||||
- 首版只支持**新增**物料:不做更新,也不自动创建分类或单位。
|
- 首版只支持**新增**物料:不做更新,也不自动创建分类或单位。
|
||||||
- **不做重复检测**:同一份文件重复上传会真的导入两遍(生成不同的编码)。
|
- **重复检测**:物料名称、规格、单位、供应商和制造商全部相同才判为重复;批量导入也会检查已有物料和本文件前序行,发现重复行时整批不写入,并返回行号和原因。供应商、制造商可留空;填写时使用当前公司的伙伴名称或伙伴编码。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
# 物料供应商、制造商与重复检查实施计划
|
||||||
|
|
||||||
|
> **执行说明:** 按任务逐项实施;本次在当前会话中直接执行。
|
||||||
|
|
||||||
|
**目标:** 为物料新增/编辑及 Excel 导入增加可选供应商和制造商,并检查物料名称、规格、单位、供应商、制造商五项完全相同的记录。
|
||||||
|
|
||||||
|
**实现方式:** 复用 `materials.supplier_id` 和 `materials.manufacturer_id` 关联当前公司的伙伴记录。扩展物料请求、读取和保存流程,并在服务端校验伙伴所属公司和角色。单条新增发现重复时由界面提示确认;批量导入发现数据库或同一文件中的重复行时,按现有整批校验规则返回行号并取消整批写入。
|
||||||
|
|
||||||
|
**技术栈:** Go、MySQL、Vue 3、Element Plus、Excelize。
|
||||||
|
|
||||||
|
**需求依据:** 2026-10-13 会话中用户确认的规则和字段范围。
|
||||||
|
|
||||||
|
## 全局约束
|
||||||
|
|
||||||
|
- 供应商和制造商均为选填。
|
||||||
|
- 仅当名称、规格、单位、供应商、制造商全部相同时,判定为重复。
|
||||||
|
- 伙伴必须属于当前公司,并具有对应的供应商或制造商身份。
|
||||||
|
- 保留工作区中已有的未提交修改,不覆盖无关内容。
|
||||||
|
- 按本会话要求,不新增或运行测试。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 任务一:保存并返回物料伙伴关联
|
||||||
|
|
||||||
|
**文件:**
|
||||||
|
- 修改:`internal/model/model.go`
|
||||||
|
- 修改:`internal/service/material.go`
|
||||||
|
- 修改:`internal/repository/material.go`
|
||||||
|
- 修改:`internal/handler/api.go`
|
||||||
|
|
||||||
|
**接口约定:**
|
||||||
|
- 物料 JSON 使用可空的 `supplier_id` 和 `manufacturer_id` 伙伴 ID。
|
||||||
|
- 新增与更新时,服务端校验伙伴属于当前公司且具备相应身份。
|
||||||
|
|
||||||
|
- [ ] 在物料实体和新增/更新请求中加入两个可选 ID。
|
||||||
|
- [ ] 在新增、查询、列表、更新 SQL 中读写数据库已有的两个字段。
|
||||||
|
- [ ] 增加公司范围和伙伴身份校验,并将无效选择作为请求错误返回。
|
||||||
|
|
||||||
|
### 任务二:完善单条物料表单和重复提示
|
||||||
|
|
||||||
|
**文件:**
|
||||||
|
- 修改:`frontend/src/views/materialForm.js`
|
||||||
|
- 修改:`frontend/src/views/Materials.vue`
|
||||||
|
- 修改:`frontend/src/api/materials.js`
|
||||||
|
- 修改:`frontend/src/api/partners.js`
|
||||||
|
- 修改:`internal/repository/material.go`
|
||||||
|
- 修改:`internal/service/material.go`
|
||||||
|
- 修改:`internal/handler/api.go`
|
||||||
|
|
||||||
|
**接口约定:**
|
||||||
|
- 判重请求传入名称、规格、单位及供应商/制造商 ID,返回当前公司的匹配物料。
|
||||||
|
- 新增表单显示匹配记录,并允许用户取消或确认继续新增。
|
||||||
|
|
||||||
|
- [ ] 增加供应商、制造商选择框,从当前公司按伙伴身份加载选项。
|
||||||
|
- [ ] 增加重复检查接口,并按确认的五项字段匹配。
|
||||||
|
- [ ] 新增物料前检查重复项;命中时展示确认提示。
|
||||||
|
|
||||||
|
### 任务三:完善 Excel 模板和批量重复校验
|
||||||
|
|
||||||
|
**文件:**
|
||||||
|
- 修改:`internal/model/material_import.go`
|
||||||
|
- 修改:`internal/service/material_import_excel.go`
|
||||||
|
- 修改:`internal/service/material_import.go`
|
||||||
|
- 修改:`internal/repository/material.go`
|
||||||
|
- 修改:`frontend/src/components/business/MaterialImportResult.vue`
|
||||||
|
- 修改:`docs/api.md`
|
||||||
|
|
||||||
|
**接口约定:**
|
||||||
|
- 模板中的 `供应商`、`制造商` 列均为选填;按当前公司伙伴名称或编码解析。
|
||||||
|
- 与已有物料或本文件较早行完全重复时,返回对应行号;任一行出错则整批不写入。
|
||||||
|
|
||||||
|
- [ ] 更新模板表头、填写说明、示例行、解析器和错误数据。
|
||||||
|
- [ ] 将伙伴名称或编码解析为当前公司的伙伴 ID,并报告无效或身份不符的输入。
|
||||||
|
- [ ] 批量读取已有物料判重,并检查本文件内前序行。
|
||||||
|
- [ ] 更新错误明细页面和 API 文档。
|
||||||
|
|
||||||
|
## 完成检查
|
||||||
|
|
||||||
|
- [ ] 检查差异,确认工作区既有的无关修改仍保留。
|
||||||
|
- [ ] 不运行测试或构建,遵循本会话要求。
|
||||||
|
|
@ -196,24 +196,24 @@
|
||||||
|
|
||||||
**操作步骤:** 在「物料主数据 → 基础配置」依次新增一个大类、其下的小类、一个计量单位,并为小类配置启用的编码规则(前缀按约定取该小类编码,如小类编码为 `A01` 时前缀填 `A01`;流水号 4 位、下一流水号 1);进入「物料」新增物料,选择该大类、小类和单位,保存后再次新增一件同分类物料。
|
**操作步骤:** 在「物料主数据 → 基础配置」依次新增一个大类、其下的小类、一个计量单位,并为小类配置启用的编码规则(前缀按约定取该小类编码,如小类编码为 `A01` 时前缀填 `A01`;流水号 4 位、下一流水号 1);进入「物料」新增物料,选择该大类、小类和单位,保存后再次新增一件同分类物料。
|
||||||
|
|
||||||
**判断指标:** 配置项保存并在列表显示正确;新增物料可选到对应分类和单位;系统按规则生成编码,为小类编码加 4 位流水号(如小类 `A01` 时为 `A010001`、`A010002`),编码不重复,物料详情显示所选分类和单位。
|
**判断指标:** 配置项保存并在列表显示正确;新增物料可选到对应分类、单位、供应商和制造商;供应商和制造商可以留空。系统按规则生成编码,为小类编码加 4 位流水号(如小类 `A01` 时为 `A010001`、`A010002`),编码不重复,物料列表和详情显示所选分类、单位、供应商和制造商。
|
||||||
|
|
||||||
### TC-19 供应商和客户资料维护
|
### TC-19 供应商和客户资料维护
|
||||||
|
|
||||||
**测试内容:** 验证业务伙伴新增、编辑、分类筛选及供应商/客户身份页面数据正确。
|
**测试内容:** 验证业务伙伴新增、编辑、分类筛选及供应商/制造商/客户身份页面数据正确,并可供物料选用。
|
||||||
|
|
||||||
**前置数据:** 准备唯一伙伴编码及未使用的测试企业名称;供应商与客户页面均可访问。
|
**前置数据:** 准备唯一伙伴编码及未使用的测试企业名称;供应商、制造商与客户页面均可访问。
|
||||||
|
|
||||||
**操作步骤:** 在「业务伙伴 → 供应商」新增一条供应商资料,填写伙伴编码、企业名称、联系人和电话,并选择一个业务分类;保存后按分类筛选并打开详情;编辑联系人信息并保存。再到「客户」页面新增一条客户资料,检查列表和详情。
|
**操作步骤:** 在「业务伙伴 → 供应商」新增一条供应商资料,填写伙伴编码、企业名称、联系人和电话,并选择一个业务分类;保存后按分类筛选并打开详情;编辑联系人信息并保存。再到「制造商」页面新增一条制造商资料,到「客户」页面新增一条客户资料,分别检查列表和详情。进入物料新增表单,确认供应商和制造商下拉项分别只显示具备对应身份的本组织伙伴。
|
||||||
|
|
||||||
**判断指标:** 两类资料均保存成功且字段显示正确;供应商分类筛选结果匹配;编辑后联系人信息更新;供应商资料显示在供应商页面,客户资料显示在客户页面,页面间无错误混列。
|
**判断指标:** 三类资料均保存成功且字段显示正确;供应商分类筛选结果匹配;编辑后联系人信息更新;供应商、制造商和客户资料分别显示在对应页面,页面间无错误混列;物料表单可分别选择本组织供应商和制造商。
|
||||||
|
|
||||||
## 物料批量导入专项回归(TC-20到TC-27)
|
## 物料批量导入专项回归(TC-20到TC-27)
|
||||||
|
|
||||||
### 测试准备
|
### 测试准备
|
||||||
|
|
||||||
- 使用测试环境和可操作物料的账号,准备可切换的组织 A、B。
|
- 使用测试环境和可操作物料的账号,准备可切换的组织 A、B。
|
||||||
- 在组织 A 准备启用的大类、小类、单位「个」及编码为 `kg` 的单位,为小类配置启用的编码规则;另准备停用分类、停用单位和未配置规则的小类。
|
- 在组织 A 准备启用的大类、小类、单位「个」及编码为 `kg` 的单位,为小类配置启用的编码规则;另准备停用分类、停用单位和未配置规则的小类。准备至少一个仅具供应商身份的伙伴和一个仅具制造商身份的伙伴,并记录其名称及伙伴编码;另准备组织 B 专属伙伴。
|
||||||
- 每个文件使用独立的测试物料名称,执行前记录物料列表和规则下一流水号。以下操作均通过页面和 Excel 手动完成,结果填写下方记录表。
|
- 每个文件使用独立的测试物料名称,执行前记录物料列表和规则下一流水号。以下操作均通过页面和 Excel 手动完成,结果填写下方记录表。
|
||||||
|
|
||||||
### TC-20 下载导入模板
|
### TC-20 下载导入模板
|
||||||
|
|
@ -222,7 +222,7 @@
|
||||||
|
|
||||||
**操作步骤:** 进入「物料」页面,点击「下载模板」,用 Excel 打开下载文件,查看工作表、表头和填写说明。
|
**操作步骤:** 进入「物料」页面,点击「下载模板」,用 Excel 打开下载文件,查看工作表、表头和填写说明。
|
||||||
|
|
||||||
**判断指标:** 文件可正常打开,第一个工作表为「填写说明」且默认打开,第二个为「物料数据」。说明页包含字段规则、操作步骤、容量限制、整批失败和重复导入提醒及参考示例;数据页保持空白,第 1 行表头为「物料名称、规格、单位、大类、小类」,从第 2 行填写,滚动时表头保持可见。名称、大类、小类必填,规格和单位选填;单位留空默认「个」,物料编码由系统生成;五列按文本格式填写,前导零不会因数字格式被去掉。
|
**判断指标:** 文件可正常打开,第一个工作表为「填写说明」且默认打开,第二个为「物料数据」。说明页包含字段规则、操作步骤、容量限制、整批失败和重复检查提醒及参考示例;数据页保持空白,第 1 行表头为「物料名称、规格、单位、供应商、制造商、大类、小类」,从第 2 行填写,滚动时表头保持可见。名称、大类、小类必填;规格、单位、供应商和制造商选填;单位留空默认「个」,供应商和制造商可填写本组织伙伴名称或编码,物料编码由系统生成;各列按文本格式填写,前导零不会因数字格式被去掉。
|
||||||
|
|
||||||
### TC-21 合法数据导入及编码生成
|
### TC-21 合法数据导入及编码生成
|
||||||
|
|
||||||
|
|
@ -232,23 +232,23 @@
|
||||||
|
|
||||||
**操作步骤:** 在模板中填写两行不同名称的合法物料,保存后点击「批量导入」上传;查看提示、物料列表、详情和物料日志。刷新页面,再手动新增一件同小类物料,确认新编码与导入结果接续。
|
**操作步骤:** 在模板中填写两行不同名称的合法物料,保存后点击「批量导入」上传;查看提示、物料列表、详情和物料日志。刷新页面,再手动新增一件同小类物料,确认新编码与导入结果接续。
|
||||||
|
|
||||||
**判断指标:** 提示成功导入 2 条,列表刷新,名称、规格、分类和单位正确;两条编码均按该小类规则生成,由小类编码加 4 位补零流水号组成(如小类 `A01` 时为 `A010001`、`A010002`),日志详情首行为「批量导入创建:物料名称(物料编码)」,下方按行显示导入时的物料编码、名称、规格、分类、单位和状态,同时保存新增内容快照及正确操作人;后续修改物料不应改变这条历史日志;随后单条新增编码为同小类编码加 `0003`(如 `A010003`),与导入的两条连续。导入结果刷新后仍可查询。
|
**判断指标:** 提示成功导入 2 条,列表刷新,名称、规格、分类、单位、供应商和制造商正确;两条编码均按该小类规则生成,由小类编码加 4 位补零流水号组成(如小类 `A01` 时为 `A010001`、`A010002`),日志详情首行为「批量导入创建:物料名称(物料编码)」,下方按行显示导入时的物料编码、名称、规格、分类、单位和状态,同时保存新增内容快照及正确操作人;后续修改物料不应改变这条历史日志;随后单条新增编码为同小类编码加 `0003`(如 `A010003`),与导入的两条连续。导入结果刷新后仍可查询。
|
||||||
|
|
||||||
### TC-22 分类写法与选填字段兼容
|
### TC-22 分类写法与选填字段兼容
|
||||||
|
|
||||||
**测试内容:** 验证分类、单位的不同填写方式及空行处理。
|
**测试内容:** 验证分类、单位的不同填写方式及空行处理。
|
||||||
|
|
||||||
**操作步骤:** 分别使用分类名称、分类编码和页面显示的「编码 名称」填写大类、小类,给分类文本加首尾空格;单位分别填名称、编码 `kg` 或留空,规格留空。在文件中插入全空行;另存一份删除规格和单位列、调整剩余列顺序的文件,分别上传。
|
**操作步骤:** 分别使用分类名称、分类编码和页面显示的「编码 名称」填写大类、小类,给分类文本加首尾空格;单位分别填名称、编码 `kg` 或留空,规格留空。供应商和制造商分别使用本组织伙伴名称、伙伴编码填写,并另测两列均留空。在文件中插入全空行;另存一份删除规格、单位、供应商和制造商列并调整剩余列顺序的文件,分别上传。
|
||||||
|
|
||||||
**判断指标:** 合法写法均能导入并匹配正确分类和单位;规格可为空,单位留空或删列时默认「个」;列顺序变化不导致字段错位,全空行不计入导入数量。
|
**判断指标:** 合法写法均能导入并匹配正确分类、单位、供应商和制造商;供应商和制造商留空或删列均可导入,单位留空或删列时默认「个」;列顺序变化不导致字段错位,全空行不计入导入数量。
|
||||||
|
|
||||||
### TC-23 错误数据校验与整批拒绝
|
### TC-23 错误数据校验与整批拒绝
|
||||||
|
|
||||||
**测试内容:** 验证字段错误提示以及合法行不会被部分导入。
|
**测试内容:** 验证字段错误提示以及合法行不会被部分导入。
|
||||||
|
|
||||||
**操作步骤:** 准备一份含合法行和错误行的文件,错误分别为名称/大类/小类为空、分类或单位不存在/停用、小类不属于所填大类、小类未配置启用规则、名称或规格超过 200 字。插入一个全空行后上传,检查错误明细,并按名称查询本次所有物料;对照编码规则的下一流水号。将超长字段改为 200 字且修正其余错误后重传。
|
**操作步骤:** 准备一份含合法行和错误行的文件,错误分别为名称/大类/小类为空、分类或单位不存在/停用、小类不属于所填大类、小类未配置启用规则、名称或规格超过 200 字、供应商或制造商在本组织不存在/不具备对应身份;再准备跨组织伙伴作为错误值。插入一个全空行后上传,检查错误明细,并按名称查询本次所有物料;对照编码规则的下一流水号。将超长字段改为 200 字、修正其余错误并重传。
|
||||||
|
|
||||||
**判断指标:** 首次提示整批未导入,错误原因可定位,行号与 Excel 实际行号一致,空行不会压缩行号;一行多个错误只计一条错误行,合法行也不新增。编码规则的下一流水号不变;全部修正后可成功导入,200 字的名称和规格可接受。
|
**判断指标:** 首次提示整批未导入,错误原因可定位(包含供应商/制造商身份错误),行号与 Excel 实际行号一致,空行不会压缩行号;一行多个错误只计一条错误行,合法行也不新增。编码规则的下一流水号不变;全部修正后可成功导入,200 字的名称和规格可接受。
|
||||||
|
|
||||||
### TC-24 错误分页、导出与修正重传
|
### TC-24 错误分页、导出与修正重传
|
||||||
|
|
||||||
|
|
@ -256,7 +256,7 @@
|
||||||
|
|
||||||
**操作步骤:** 上传含 21 条错误行的文件,其中字段包含中文、逗号或双引号;切换错误表第二页,点击「导出错误明细」,用 Excel 打开 CSV。关闭弹窗,在原 `.xlsx` 中修正全部错误,再选择同一个文件上传。
|
**操作步骤:** 上传含 21 条错误行的文件,其中字段包含中文、逗号或双引号;切换错误表第二页,点击「导出错误明细」,用 Excel 打开 CSV。关闭弹窗,在原 `.xlsx` 中修正全部错误,再选择同一个文件上传。
|
||||||
|
|
||||||
**判断指标:** 第一页显示 20 条、第二页 1 条;CSV 包含全部 21 条错误及行号、原始字段、失败原因,中文无乱码、字段不串列。关闭后无旧错误残留,同一文件能重新选择并提交,修正后成功导入 21 条。
|
**判断指标:** 第一页显示 20 条、第二页 1 条;CSV 包含全部 21 条错误及行号、名称、规格、单位、供应商、制造商、大类、小类和失败原因,中文无乱码、字段不串列。关闭后无旧错误残留,同一文件能重新选择并提交,修正后成功导入 21 条。
|
||||||
|
|
||||||
### TC-25 文件格式、模板及容量限制
|
### TC-25 文件格式、模板及容量限制
|
||||||
|
|
||||||
|
|
@ -266,21 +266,33 @@
|
||||||
|
|
||||||
**判断指标:** 不支持的格式、超限大小和错误模板不能新增物料,并有可理解的提示;取消选择无新增。10000 行在允许范围内,应完整导入;10001 行被整批拒绝,表头和空行不计入数量。若出现超时,记录实际提示并刷新查询是否已导入,确认结果后再决定是否重传,避免重复新增。
|
**判断指标:** 不支持的格式、超限大小和错误模板不能新增物料,并有可理解的提示;取消选择无新增。10000 行在允许范围内,应完整导入;10001 行被整批拒绝,表头和空行不计入数量。若出现超时,记录实际提示并刷新查询是否已导入,确认结果后再决定是否重传,避免重复新增。
|
||||||
|
|
||||||
### TC-26 重复数据与重复上传
|
### TC-26 单条新增及批量导入重复检查
|
||||||
|
|
||||||
**测试内容:** 验证当前重复导入的实际行为。
|
**测试内容:** 验证单条新增按名称、规格、单位、供应商和制造商五项完全相同提示确认;批量导入检查已有物料及同一文件内的重复行。
|
||||||
|
|
||||||
**操作步骤:** 在同一文件中填写两条同名同规格的合法物料并上传;完成后再次选择同一个文件上传,查询物料及编码。
|
**测试数据:**
|
||||||
|
|
||||||
**判断指标:** 按当前功能,两次均成功,每次新增两条,合计四条独立物料,编码互不重复;原有物料未被覆盖。当前不做重复检测,重复上传会再次新增。
|
| 物料名称 | 规格 | 单位 | 供应商 | 制造商 | 大类 | 小类 |
|
||||||
|
| ----- | ---- | --- | ------------- | ------------- | ----- | --- |
|
||||||
|
| 测试轴承 | 6205 | 件 | DEMO-BOTH-001 | DEMO-BOTH-002 | 机械结构类 | 标准件 |
|
||||||
|
| 测试轴承 | 6205 | 件 | DEMO-BOTH-001 | DEMO-BOTH-002 | 机械结构类 | 标准件 |
|
||||||
|
| 测试轴承座 | 6205 | 件 | DEMO-BOTH-001 | DEMO-BOTH-002 | 机械结构类 | 标准件 |
|
||||||
|
| 测试轴承 | 6206 | 件 | DEMO-BOTH-001 | DEMO-BOTH-002 | 机械结构类 | 标准件 |
|
||||||
|
| 测试轴承 | 6205 | 个 | DEMO-BOTH-001 | DEMO-BOTH-002 | 机械结构类 | 标准件 |
|
||||||
|
| 测试轴承 | 6205 | 件 | DEMO-BOTH-002 | DEMO-BOTH-002 | 机械结构类 | 标准件 |
|
||||||
|
| 测试轴承 | 6205 | 件 | DEMO-BOTH-001 | DEMO-BOTH-001 | 机械结构类 | 标准件 |
|
||||||
|
|
||||||
|
**操作步骤:** 先新增一件物料,填写规格、单位、供应商和制造商。再次新增五项完全相同的物料,分别选择取消和确认;再逐项改变规格、单位、供应商或制造商,每次保存名称相同但其他字段不同的物料。随后准备批量导入文件:一行与现有物料五项完全相同,一行与前一行五项完全相同;另准备一份含有效且仅一项不同的合法物料文件。上传并查看错误明细、列表和编码规则流水号;修正重复值后重传。
|
||||||
|
|
||||||
|
**判断指标:** 单条新增五项完全相同会列出已有物料并提示确认;取消后不新增,确认后允许新增。名称相同但规格、单位、供应商或制造商任一项不同均不应误报重复。批量文件与已有物料重复或文件内重复时返回相应行号和原因,整批不新增且编码流水号不推进;有效且仅一项不同的行可成功导入,修正重复行后可重传成功。重复上传已成功导入的相同文件时,按当前记录仍重复的物料五项均相同,应提示重复并阻止整批再次导入。
|
||||||
|
|
||||||
### TC-27 组织隔离与导入期间操作限制
|
### TC-27 组织隔离与导入期间操作限制
|
||||||
|
|
||||||
**测试内容:** 验证主数据归属和导入期间的操作限制。
|
**测试内容:** 验证主数据归属和导入期间的操作限制。
|
||||||
|
|
||||||
**操作步骤:** 在组织 A 上传使用 B 专属分类或单位的文件;随后在 A 上传较多行的合法文件,在导入请求尚未完成时尝试切换组织及再次点击「批量导入」。完成后切到 B 查询本次测试物料,再切回 A 查询。
|
**操作步骤:** 在组织 A 上传使用 B 专属分类、单位、供应商或制造商的文件;随后在 A 上传较多行的合法文件,在导入请求尚未完成时尝试切换组织及再次点击「批量导入」。完成后切到 B 查询本次测试物料,再切回 A 查询。
|
||||||
|
|
||||||
**判断指标:** B 专属分类或单位在 A 被判不存在,整批拒绝;请求进行中组织切换被阻止,再次点击不发起重复导入。完成后可正常切换;新物料仅在 A 可查,B 的物料和编码规则不受影响,切回 A 后结果正确。
|
**判断指标:** B 专属分类、单位或伙伴在 A 被判不存在或不具备对应身份,整批拒绝;请求进行中组织切换被阻止,再次点击不发起重复导入。完成后可正常切换;新物料仅在 A 可查,B 的物料和编码规则不受影响,切回 A 后结果正确。
|
||||||
|
|
||||||
## 执行结果记录
|
## 执行结果记录
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# 物料管理系统数据库表说明
|
# 物料管理系统数据库表说明
|
||||||
|
|
||||||
更新时间:2026-10-09。本文前半部分保留已有库的结构说明;第 11 节说明公司隔离升级后的运行结构。表数量随升级增加审计、迁移版本及对账表,以目标库 `information_schema` 查询为准。
|
更新时间:2026-10-12。本文前半部分保留已有库的结构说明;第 11 节说明公司隔离升级后的运行结构。表数量随升级增加审计、迁移版本及对账表,以目标库 `information_schema` 查询为准。
|
||||||
|
|
||||||
本文按照“表、保存什么、主要字段”介绍每张表的作用,后面单独列出表之间的关系。公司隔离发布使用 `scripts/init.sql`(空库)或 `scripts/upgrades/2026-10-08-company-master-data-isolation.sql`(已有库),不以旧库快照代替升级后的结构。
|
本文按照“表、保存什么、主要字段”介绍每张表的作用,后面单独列出表之间的关系。公司隔离发布使用 `scripts/init.sql`(空库)或 `scripts/upgrades/2026-10-08-company-master-data-isolation.sql`(已有库),不以旧库快照代替升级后的结构。
|
||||||
|
|
||||||
|
|
@ -24,7 +24,7 @@
|
||||||
|
|
||||||
| 表 | 保存什么 | 主要字段 |
|
| 表 | 保存什么 | 主要字段 |
|
||||||
| --------------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| --------------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `materials` | 物料基础资料,用于识别和描述物料;库存余额另存。 | 物料ID(`id`)、所属组织(`organization_id`)、物料编码(`material_code`)、名称(`name`)、图片(`material_image`)、规格(`spec`)、厂商型号(`manufacturer_model`)、分类ID(`category_id`)、基本单位ID(`base_unit_id`)、单位文本(`unit`)、状态(`status`)、资料版本(`version`)、`created_at`、`updated_at` |
|
| `materials` | 物料基础资料,用于识别和描述物料;库存余额另存。 | 物料ID(`id`)、所属组织(`organization_id`)、物料编码(`material_code`)、名称(`name`)、图片(`material_image`)、规格(`spec`)、厂家型号(`manufacturer_model`)、默认供应商关联ID(`supplier_id`)、制造商关联ID(`manufacturer_id`)、分类ID(`category_id`)、基本单位ID(`base_unit_id`)、单位文本(`unit`)、状态(`status`)、资料版本(`version`)、`created_at`、`updated_at` |
|
||||||
| `material_categories` | 物料大类、子类及分类定义,形成分类树。 | 分类ID(`id`)、所属组织(`organization_id`)、编码(`code`)、名称(`name`)、分类说明(`description`)、父分类ID(`parent_id`)、状态(`status`) |
|
| `material_categories` | 物料大类、子类及分类定义,形成分类树。 | 分类ID(`id`)、所属组织(`organization_id`)、编码(`code`)、名称(`name`)、分类说明(`description`)、父分类ID(`parent_id`)、状态(`status`) |
|
||||||
| `units` | 个、瓶、包、升等计量单位字典及数量精度。 | 单位ID(`id`)、所属组织(`organization_id`)、单位编码(`code`)、单位名称(`name`)、小数位数(`precision_scale`)、状态(`status`) |
|
| `units` | 个、瓶、包、升等计量单位字典及数量精度。 | 单位ID(`id`)、所属组织(`organization_id`)、单位编码(`code`)、单位名称(`name`)、小数位数(`precision_scale`)、状态(`status`) |
|
||||||
| `code_rules` | 按组织和物料分类配置的编码生成规则。 | 规则ID(`id`)、所属组织(`organization_id`)、分类ID(`category_id`)、编码前缀(`prefix`)、流水号长度(`sequence_length`)、下一个流水号(`next_sequence`)、状态(`status`) |
|
| `code_rules` | 按组织和物料分类配置的编码生成规则。 | 规则ID(`id`)、所属组织(`organization_id`)、分类ID(`category_id`)、编码前缀(`prefix`)、流水号长度(`sequence_length`)、下一个流水号(`next_sequence`)、状态(`status`) |
|
||||||
|
|
@ -36,7 +36,7 @@
|
||||||
| 表 | 保存什么 | 主要字段 |
|
| 表 | 保存什么 | 主要字段 |
|
||||||
| ----------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
| ----------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| `business_partners` | 企业基本资料,多个本方组织可以共用。 | 企业ID(`id`)、企业名称(`legal_name`)、统一社会信用代码(`unified_credit_code`)、联系人(`contact_name`)、电话(`contact_phone`)、地址(`address`)、`created_at`、`updated_at` |
|
| `business_partners` | 企业基本资料,多个本方组织可以共用。 | 企业ID(`id`)、企业名称(`legal_name`)、统一社会信用代码(`unified_credit_code`)、联系人(`contact_name`)、电话(`contact_phone`)、地址(`address`)、`created_at`、`updated_at` |
|
||||||
| `organization_partners` | 当前组织如何与这家企业合作。 | 关系ID(`id`)、所属组织(`organization_id`)、企业ID(`business_partner_id`)、伙伴编码(`partner_code`)、供应商身份(`is_supplier`)、客户身份(`is_customer`)、供应商状态(`supplier_status`)、客户状态(`customer_status`)、供应商分类(`supplier_categories`)、客户分类(`customer_categories`)、结算币种(`settlement_currency`)、付款条款(`payment_terms`)、`created_at`、`updated_at` |
|
| `organization_partners` | 当前组织如何与这家企业合作。 | 关系ID(`id`)、所属组织(`organization_id`)、企业ID(`business_partner_id`)、伙伴编码(`partner_code`)、供应商身份(`is_supplier`)、制造商身份(`is_manufacturer`)、客户身份(`is_customer`)、供应商状态(`supplier_status`)、客户状态(`customer_status`)、供应商分类(`supplier_categories`)、客户分类(`customer_categories`)、结算币种(`settlement_currency`)、付款条款(`payment_terms`)、`created_at`、`updated_at` |
|
||||||
| `material_suppliers` | 某个物料由哪个供应商供货,以及报价和供货条件。 | 记录ID(`id`)、所属组织(`organization_id`)、物料ID(`material_id`)、组织伙伴关系ID(`organization_partner_id`)、价格(`price`)、币种(`currency`)、最小订购量(`minimum_order_quantity`)、供货周期天数(`lead_time_days`)、首选标记(`is_preferred`)、生效日期(`effective_from`)、失效日期(`effective_to`)、状态(`status`) |
|
| `material_suppliers` | 某个物料由哪个供应商供货,以及报价和供货条件。 | 记录ID(`id`)、所属组织(`organization_id`)、物料ID(`material_id`)、组织伙伴关系ID(`organization_partner_id`)、价格(`price`)、币种(`currency`)、最小订购量(`minimum_order_quantity`)、供货周期天数(`lead_time_days`)、首选标记(`is_preferred`)、生效日期(`effective_from`)、失效日期(`effective_to`)、状态(`status`) |
|
||||||
|
|
||||||
同一外部企业可以与北京、武汉分别建立合作关系。企业名称、联系人、地址等基础资料共用;客户/供应商身份、状态和分类按组织分别保存。同一关系可同时具有客户和供应商身份。
|
同一外部企业可以与北京、武汉分别建立合作关系。企业名称、联系人、地址等基础资料共用;客户/供应商身份、状态和分类按组织分别保存。同一关系可同时具有客户和供应商身份。
|
||||||
|
|
@ -243,3 +243,13 @@
|
||||||
升级发现多个仓库/库位、多个默认公司、跨组织引用、未确认授权或无法确定流水来源时先阻断,禁止自动删除、合并或猜测归属。授权清单经业务确认后显式回填,不自动给普通用户授权所有公司。新公司分类/单位/规则默认为空,独立配置。
|
升级发现多个仓库/库位、多个默认公司、跨组织引用、未确认授权或无法确定流水来源时先阻断,禁止自动删除、合并或猜测归属。授权清单经业务确认后显式回填,不自动给普通用户授权所有公司。新公司分类/单位/规则默认为空,独立配置。
|
||||||
|
|
||||||
武汉目前没有历史数据(2026-10-09 用户确认),本次没有武汉历史副本检查或授权回填对象;空库初始化及合成历史夹具升级分别验证。部署、验收证据及回退限制见 [公司隔离验收与交付](company-master-data-isolation-acceptance.md)。
|
武汉目前没有历史数据(2026-10-09 用户确认),本次没有武汉历史副本检查或授权回填对象;空库初始化及合成历史夹具升级分别验证。部署、验收证据及回退限制见 [公司隔离验收与交付](company-master-data-isolation-acceptance.md)。
|
||||||
|
|
||||||
|
## 12. 物料供应商与制造商(2026-10-12)
|
||||||
|
|
||||||
|
企业资料继续共用 `business_partners`,供应商、制造商、客户身份保存在 `organization_partners`。新增 `is_manufacturer BOOLEAN NOT NULL DEFAULT FALSE`,三个身份可同时存在;旧数据不会自动变成制造商。
|
||||||
|
|
||||||
|
`materials.supplier_id` 表示默认供应商,`materials.manufacturer_id` 表示制造商,两个字段都是可空 BIGINT,引用 `organization_partners.id`,不是 `business_partners.id`。`manufacturer_model` 表示厂家型号,即制造商为其产品指定的型号或订货识别型号;它是物料上的文本信息,不是制造商关联 ID。它与 `spec`(物料规格)含义不同,例如规格可填写“M8×30”,厂家型号可填写制造商提供的产品型号或编码。
|
||||||
|
|
||||||
|
组织关联表增加唯一键 `(organization_id,id)`;物料通过 `(organization_id,supplier_id)` 和 `(organization_id,manufacturer_id)` 复合外键保证引用属于同一组织,删除或修改被引用的关联键会被拒绝。角色匹配需要由业务接口校验,外键本身不验证 `is_supplier` / `is_manufacturer`。
|
||||||
|
|
||||||
|
现有 `material_suppliers` 继续保存多个供应商的价格与供货条件;物料的 `supplier_id` 只表示默认选择。已有库使用 `scripts/upgrades/2026-10-12-material-partner-roles.sql` 升级,空库使用 `scripts/init.sql`。
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,9 @@ export const materialsApi = {
|
||||||
getById(id) {
|
getById(id) {
|
||||||
return request.get(`/materials/${id}`)
|
return request.get(`/materials/${id}`)
|
||||||
},
|
},
|
||||||
|
checkDuplicates(data) {
|
||||||
|
return request.post('/materials/duplicate-check', data)
|
||||||
|
},
|
||||||
create(data) {
|
create(data) {
|
||||||
return request.post('/materials', data)
|
return request.post('/materials', data)
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,9 @@ export const partnersApi = {
|
||||||
list(role, params = {}) {
|
list(role, params = {}) {
|
||||||
return request.get('/partners', { params: { role, ...params } })
|
return request.get('/partners', { params: { role, ...params } })
|
||||||
},
|
},
|
||||||
|
listForMaterial(role, params = {}) {
|
||||||
|
return request.get('/materials/partners', { params: { role, ...params } })
|
||||||
|
},
|
||||||
create(data) {
|
create(data) {
|
||||||
return request.post('/partners', data)
|
return request.post('/partners', data)
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,12 @@
|
||||||
<el-table-column label="单位" width="90">
|
<el-table-column label="单位" width="90">
|
||||||
<template #default="{ row }">{{ row.data.unit || '—' }}</template>
|
<template #default="{ row }">{{ row.data.unit || '—' }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column label="供应商" width="140">
|
||||||
|
<template #default="{ row }">{{ row.data.supplier || '—' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="制造商" width="140">
|
||||||
|
<template #default="{ row }">{{ row.data.manufacturer || '—' }}</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="大类" width="140">
|
<el-table-column label="大类" width="140">
|
||||||
<template #default="{ row }">{{ row.data.major || '—' }}</template>
|
<template #default="{ row }">{{ row.data.major || '—' }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|
@ -86,13 +92,15 @@ function handlePageChange(page) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function exportErrors() {
|
function exportErrors() {
|
||||||
const rows = [['行号', '物料名称', '规格', '单位', '大类', '小类', '失败原因']]
|
const rows = [['行号', '物料名称', '规格', '单位', '供应商', '制造商', '大类', '小类', '失败原因']]
|
||||||
for (const item of props.result.errors) {
|
for (const item of props.result.errors) {
|
||||||
rows.push([
|
rows.push([
|
||||||
item.row,
|
item.row,
|
||||||
item.data?.name || '',
|
item.data?.name || '',
|
||||||
item.data?.spec || '',
|
item.data?.spec || '',
|
||||||
item.data?.unit || '',
|
item.data?.unit || '',
|
||||||
|
item.data?.supplier || '',
|
||||||
|
item.data?.manufacturer || '',
|
||||||
item.data?.major || '',
|
item.data?.major || '',
|
||||||
item.data?.minor || '',
|
item.data?.minor || '',
|
||||||
item.reason
|
item.reason
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ const sections = [
|
||||||
adminOnly: true,
|
adminOnly: true,
|
||||||
items: [
|
items: [
|
||||||
{ path: '/partners/suppliers', label: '供应商', adminOnly: true },
|
{ path: '/partners/suppliers', label: '供应商', adminOnly: true },
|
||||||
|
{ path: '/partners/manufacturers', label: '制造商', adminOnly: true },
|
||||||
{ path: '/partners/customers', label: '客户', adminOnly: true }
|
{ path: '/partners/customers', label: '客户', adminOnly: true }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -11,13 +11,13 @@ describe('getNavigationMenu', () => {
|
||||||
])).toEqual([
|
])).toEqual([
|
||||||
['物料主数据', ['物料', '基础配置']],
|
['物料主数据', ['物料', '基础配置']],
|
||||||
['公司组织', ['组织管理', '用户管理', '角色与权限']],
|
['公司组织', ['组织管理', '用户管理', '角色与权限']],
|
||||||
['业务伙伴', ['供应商', '客户']],
|
['业务伙伴', ['供应商', '制造商', '客户']],
|
||||||
['库存管理', ['库存查询', '入库管理', '出库管理']]
|
['库存管理', ['库存查询', '入库管理', '出库管理']]
|
||||||
])
|
])
|
||||||
expect(menu.sections.flatMap(section => section.items).map(item => item.path)).toEqual([
|
expect(menu.sections.flatMap(section => section.items).map(item => item.path)).toEqual([
|
||||||
'/materials', '/master-data/settings',
|
'/materials', '/master-data/settings',
|
||||||
'/organizations', '/users', '/roles',
|
'/organizations', '/users', '/roles',
|
||||||
'/partners/suppliers', '/partners/customers',
|
'/partners/suppliers', '/partners/manufacturers', '/partners/customers',
|
||||||
'/stock', '/inbound', '/outbound'
|
'/stock', '/inbound', '/outbound'
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,12 @@ export const routes = [
|
||||||
component: () => import('../views/partners/Suppliers.vue'),
|
component: () => import('../views/partners/Suppliers.vue'),
|
||||||
meta: { requiresAuth: true, adminOnly: true, organizationScoped: true }
|
meta: { requiresAuth: true, adminOnly: true, organizationScoped: true }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/partners/manufacturers',
|
||||||
|
name: 'Manufacturers',
|
||||||
|
component: () => import('../views/partners/Manufacturers.vue'),
|
||||||
|
meta: { requiresAuth: true, adminOnly: true, organizationScoped: true }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/partners/customers',
|
path: '/partners/customers',
|
||||||
name: 'Customers',
|
name: 'Customers',
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ describe('phase-one routes', () => {
|
||||||
'/organizations',
|
'/organizations',
|
||||||
'/roles',
|
'/roles',
|
||||||
'/partners/suppliers',
|
'/partners/suppliers',
|
||||||
|
'/partners/manufacturers',
|
||||||
'/partners/customers'
|
'/partners/customers'
|
||||||
]))
|
]))
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,10 @@ const materialsStore = vi.hoisted(() => ({
|
||||||
|
|
||||||
const materialsApi = vi.hoisted(() => ({
|
const materialsApi = vi.hoisted(() => ({
|
||||||
importFile: vi.fn(),
|
importFile: vi.fn(),
|
||||||
downloadImportTemplate: vi.fn()
|
downloadImportTemplate: vi.fn(),
|
||||||
|
checkDuplicates: vi.fn()
|
||||||
}))
|
}))
|
||||||
|
const partnersApi = vi.hoisted(() => ({ listForMaterial: vi.fn() }))
|
||||||
|
|
||||||
vi.mock('../stores/materials', () => ({ useMaterialsStore: () => materialsStore }))
|
vi.mock('../stores/materials', () => ({ useMaterialsStore: () => materialsStore }))
|
||||||
vi.mock('../stores/auth', () => ({ useAuthStore: () => ({ isAdmin: true }) }))
|
vi.mock('../stores/auth', () => ({ useAuthStore: () => ({ isAdmin: true }) }))
|
||||||
|
|
@ -41,6 +43,8 @@ vi.mock('../composables/useSavingGuard', () => ({
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// restoreAllMocks 会清掉 vi.fn 的实现,这里每个用例重新装上直通实现。
|
// restoreAllMocks 会清掉 vi.fn 的实现,这里每个用例重新装上直通实现。
|
||||||
savingGuard.runWithSaving = vi.fn(task => task())
|
savingGuard.runWithSaving = vi.fn(task => task())
|
||||||
|
partnersApi.listForMaterial.mockResolvedValue({ data: { items: [], total: 0 } })
|
||||||
|
materialsApi.checkDuplicates.mockResolvedValue({ data: [] })
|
||||||
})
|
})
|
||||||
vi.mock('../api/masterData', () => ({
|
vi.mock('../api/masterData', () => ({
|
||||||
materialCategoriesApi: { list: vi.fn() },
|
materialCategoriesApi: { list: vi.fn() },
|
||||||
|
|
@ -48,6 +52,7 @@ vi.mock('../api/masterData', () => ({
|
||||||
codeRulesApi: { next: vi.fn() }
|
codeRulesApi: { next: vi.fn() }
|
||||||
}))
|
}))
|
||||||
vi.mock('../api/materials', () => ({ materialsApi }))
|
vi.mock('../api/materials', () => ({ materialsApi }))
|
||||||
|
vi.mock('../api/partners', () => ({ partnersApi }))
|
||||||
|
|
||||||
// 真实的 ElSelect 在 jsdom 下会因自身更新报 "Maximum recursive updates exceeded",
|
// 真实的 ElSelect 在 jsdom 下会因自身更新报 "Maximum recursive updates exceeded",
|
||||||
// 这里用替身直接派发 change,模拟用户选中某个分类。
|
// 这里用替身直接派发 change,模拟用户选中某个分类。
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,16 @@
|
||||||
{{ getUnitDisplay(row) }}
|
{{ getUnitDisplay(row) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column label="供应商" min-width="140">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.supplier_name || '-' }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="制造商" min-width="140">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.manufacturer_name || '-' }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column
|
<el-table-column
|
||||||
label="操作"
|
label="操作"
|
||||||
width="240"
|
width="240"
|
||||||
|
|
@ -120,6 +130,16 @@
|
||||||
<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-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-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item label="供应商">
|
||||||
|
<el-select v-model="form.supplier_id" clearable filterable placeholder="选填" style="width: 100%">
|
||||||
|
<el-option v-for="partner in suppliers" :key="partner.id" :label="`${partner.partner_code} - ${partner.legal_name}`" :value="partner.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="制造商">
|
||||||
|
<el-select v-model="form.manufacturer_id" clearable filterable placeholder="选填" style="width: 100%">
|
||||||
|
<el-option v-for="partner in manufacturers" :key="partner.id" :label="`${partner.partner_code} - ${partner.legal_name}`" :value="partner.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
<el-form-item label="图片URL">
|
<el-form-item label="图片URL">
|
||||||
<el-input v-model="form.material_image" placeholder="图片URL" />
|
<el-input v-model="form.material_image" placeholder="图片URL" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
@ -136,6 +156,8 @@
|
||||||
<el-descriptions-item label="大类类型">{{ getMajorCategoryDisplay(detailRow) }}</el-descriptions-item>
|
<el-descriptions-item label="大类类型">{{ getMajorCategoryDisplay(detailRow) }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="小类类型">{{ getMinorCategoryDisplay(detailRow) }}</el-descriptions-item>
|
<el-descriptions-item label="小类类型">{{ getMinorCategoryDisplay(detailRow) }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="单位">{{ getUnitDisplay(detailRow) }}</el-descriptions-item>
|
<el-descriptions-item label="单位">{{ getUnitDisplay(detailRow) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="供应商">{{ detailRow.supplier_name || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="制造商">{{ detailRow.manufacturer_name || '-' }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="创建时间">{{ formatDate(detailRow.created_at, MATERIAL_DATE_OPTIONS) }}</el-descriptions-item>
|
<el-descriptions-item label="创建时间">{{ formatDate(detailRow.created_at, MATERIAL_DATE_OPTIONS) }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="更新时间" :span="2">{{ formatDate(detailRow.updated_at, MATERIAL_DATE_OPTIONS) }}</el-descriptions-item>
|
<el-descriptions-item label="更新时间" :span="2">{{ formatDate(detailRow.updated_at, MATERIAL_DATE_OPTIONS) }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="物料图片" :span="2">
|
<el-descriptions-item label="物料图片" :span="2">
|
||||||
|
|
@ -195,6 +217,7 @@ import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { createMaterialForm, toMaterialPayload } from './materialForm'
|
import { createMaterialForm, toMaterialPayload } from './materialForm'
|
||||||
import { codeRulesApi, materialCategoriesApi, unitsApi } from '../api/masterData'
|
import { codeRulesApi, materialCategoriesApi, unitsApi } from '../api/masterData'
|
||||||
import { materialsApi } from '../api/materials'
|
import { materialsApi } from '../api/materials'
|
||||||
|
import { partnersApi } from '../api/partners'
|
||||||
import { getMajorCategories, getMinorCategories, resolveCategoryPath } from '../utils/materialCategories'
|
import { getMajorCategories, getMinorCategories, resolveCategoryPath } from '../utils/materialCategories'
|
||||||
import { searchMaterialOptions } from '../utils/materialSearch'
|
import { searchMaterialOptions } from '../utils/materialSearch'
|
||||||
import { errorText } from '../utils/errorText'
|
import { errorText } from '../utils/errorText'
|
||||||
|
|
@ -235,6 +258,8 @@ const importResult = ref(null)
|
||||||
const importing = ref(false)
|
const importing = ref(false)
|
||||||
const categories = ref([])
|
const categories = ref([])
|
||||||
const units = ref([])
|
const units = ref([])
|
||||||
|
const suppliers = ref([])
|
||||||
|
const manufacturers = ref([])
|
||||||
// 用户是否手改过编码:没改过时切分类要按新规则重算,改过就以用户的值为准。
|
// 用户是否手改过编码:没改过时切分类要按新规则重算,改过就以用户的值为准。
|
||||||
const codeEdited = ref(false)
|
const codeEdited = ref(false)
|
||||||
const isCreating = computed(() => dialogMode.value === 'create')
|
const isCreating = computed(() => dialogMode.value === 'create')
|
||||||
|
|
@ -355,11 +380,49 @@ onMounted(async () => {
|
||||||
// 检索框却只给有效物料的话,作废的那些就搜不出来(而这一页正是恢复它们的入口)。
|
// 检索框却只给有效物料的话,作废的那些就搜不出来(而这一页正是恢复它们的入口)。
|
||||||
// 业务单据页(入库/出库/申请)仍用只有有效物料的 materialOptions。
|
// 业务单据页(入库/出库/申请)仍用只有有效物料的 materialOptions。
|
||||||
materialsStore.fetchHistoricalMaterialOptions()
|
materialsStore.fetchHistoricalMaterialOptions()
|
||||||
const [categoryResponse, unitResponse] = await Promise.all([materialCategoriesApi.list(), unitsApi.list()])
|
const [categoryResult, unitResult, supplierResult, manufacturerResult] = await Promise.allSettled([
|
||||||
categories.value = categoryResponse.data || []
|
materialCategoriesApi.list(),
|
||||||
units.value = (unitResponse.data || []).filter(item => item.status === 'active')
|
unitsApi.list(),
|
||||||
|
loadPartners('supplier'),
|
||||||
|
loadPartners('manufacturer')
|
||||||
|
])
|
||||||
|
if (categoryResult.status === 'fulfilled') {
|
||||||
|
categories.value = categoryResult.value.data || []
|
||||||
|
} else {
|
||||||
|
console.error('加载物料分类失败:', categoryResult.reason)
|
||||||
|
}
|
||||||
|
if (unitResult.status === 'fulfilled') {
|
||||||
|
units.value = (unitResult.value.data || []).filter(item => item.status === 'active')
|
||||||
|
} else {
|
||||||
|
console.error('加载计量单位失败:', unitResult.reason)
|
||||||
|
}
|
||||||
|
if (supplierResult.status === 'fulfilled') {
|
||||||
|
suppliers.value = supplierResult.value
|
||||||
|
} else {
|
||||||
|
console.error('加载供应商失败:', supplierResult.reason)
|
||||||
|
}
|
||||||
|
if (manufacturerResult.status === 'fulfilled') {
|
||||||
|
manufacturers.value = manufacturerResult.value
|
||||||
|
} else {
|
||||||
|
console.error('加载制造商失败:', manufacturerResult.reason)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
async function loadPartners(role) {
|
||||||
|
const items = []
|
||||||
|
const limit = 100
|
||||||
|
let offset = 0
|
||||||
|
let total = 0
|
||||||
|
do {
|
||||||
|
const response = await partnersApi.listForMaterial(role, { limit, offset })
|
||||||
|
const pageItems = response.data?.items || []
|
||||||
|
items.push(...pageItems)
|
||||||
|
total = response.data?.total ?? items.length
|
||||||
|
offset += pageItems.length
|
||||||
|
} while (offset < total)
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
function syncCategory(id) {
|
function syncCategory(id) {
|
||||||
form.category = categories.value.find(item => item.id === id)?.code || ''
|
form.category = categories.value.find(item => item.id === id)?.code || ''
|
||||||
// 手改过的编码保留用户的输入,没改过才跟着新分类的规则重算
|
// 手改过的编码保留用户的输入,没改过才跟着新分类的规则重算
|
||||||
|
|
@ -521,6 +584,24 @@ async function handleSave() {
|
||||||
saveLoading.value = true
|
saveLoading.value = true
|
||||||
try {
|
try {
|
||||||
const formData = toMaterialPayload(form)
|
const formData = toMaterialPayload(form)
|
||||||
|
if (isCreating.value) {
|
||||||
|
const duplicateResponse = await materialsApi.checkDuplicates(formData)
|
||||||
|
const matches = duplicateResponse.data || []
|
||||||
|
formData.allow_duplicate = false
|
||||||
|
if (matches.length > 0) {
|
||||||
|
const details = matches.map(item => `${item.material_code} · ${item.name}`).join('、')
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`发现名称、规格、单位、供应商和制造商均相同的物料:${details}。仍要创建吗?`, '发现重复物料', {
|
||||||
|
confirmButtonText: '仍然创建',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
})
|
||||||
|
formData.allow_duplicate = true
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
const file = imageInput.value?.files[0]
|
const file = imageInput.value?.files[0]
|
||||||
if (file) {
|
if (file) {
|
||||||
const uploadForm = new FormData()
|
const uploadForm = new FormData()
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { createPinia, setActivePinia } from 'pinia'
|
||||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
|
||||||
import Materials from './Materials.vue'
|
import Materials from './Materials.vue'
|
||||||
import { materialsApi } from '../api/materials'
|
import { materialsApi } from '../api/materials'
|
||||||
|
import { partnersApi } from '../api/partners'
|
||||||
import { materialCategoriesApi, unitsApi } from '../api/masterData'
|
import { materialCategoriesApi, unitsApi } from '../api/masterData'
|
||||||
import { authApi } from '../api/auth'
|
import { authApi } from '../api/auth'
|
||||||
import { useOrganizationStore } from '../stores/organization'
|
import { useOrganizationStore } from '../stores/organization'
|
||||||
|
|
@ -22,9 +23,11 @@ vi.mock('../api/materials', () => ({
|
||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
obsolete: vi.fn(),
|
obsolete: vi.fn(),
|
||||||
restore: vi.fn()
|
restore: vi.fn(),
|
||||||
|
checkDuplicates: vi.fn()
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
vi.mock('../api/partners', () => ({ partnersApi: { listForMaterial: vi.fn() } }))
|
||||||
vi.mock('../api/masterData', () => ({
|
vi.mock('../api/masterData', () => ({
|
||||||
materialCategoriesApi: { list: vi.fn() },
|
materialCategoriesApi: { list: vi.fn() },
|
||||||
unitsApi: { list: vi.fn() },
|
unitsApi: { list: vi.fn() },
|
||||||
|
|
@ -78,6 +81,8 @@ describe('物料页在公司切换下的生命周期', () => {
|
||||||
setActivePinia(createPinia())
|
setActivePinia(createPinia())
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
materialsApi.getAll.mockResolvedValue({ data: { items: [], total: 0 } })
|
materialsApi.getAll.mockResolvedValue({ data: { items: [], total: 0 } })
|
||||||
|
materialsApi.checkDuplicates.mockResolvedValue({ data: [] })
|
||||||
|
partnersApi.listForMaterial.mockResolvedValue({ data: { items: [], total: 0 } })
|
||||||
materialCategoriesApi.list.mockResolvedValue({ data: [] })
|
materialCategoriesApi.list.mockResolvedValue({ data: [] })
|
||||||
unitsApi.list.mockResolvedValue({ data: [] })
|
unitsApi.list.mockResolvedValue({ data: [] })
|
||||||
authApi.listOrganizations.mockReset()
|
authApi.listOrganizations.mockReset()
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,9 @@ const materialKeys = [
|
||||||
'category_id',
|
'category_id',
|
||||||
'base_unit_id',
|
'base_unit_id',
|
||||||
'unit',
|
'unit',
|
||||||
'material_image'
|
'material_image',
|
||||||
|
'supplier_id',
|
||||||
|
'manufacturer_id'
|
||||||
]
|
]
|
||||||
|
|
||||||
export function createMaterialForm(source = {}, categories = []) {
|
export function createMaterialForm(source = {}, categories = []) {
|
||||||
|
|
@ -22,14 +24,18 @@ export function createMaterialForm(source = {}, categories = []) {
|
||||||
material_image: source.material_image || '',
|
material_image: source.material_image || '',
|
||||||
major_category_id: source.major_category_id ?? major?.id ?? null,
|
major_category_id: source.major_category_id ?? major?.id ?? null,
|
||||||
category_id: source.category_id ?? null,
|
category_id: source.category_id ?? null,
|
||||||
base_unit_id: source.base_unit_id ?? null
|
base_unit_id: source.base_unit_id ?? null,
|
||||||
|
supplier_id: source.supplier_id ?? null,
|
||||||
|
manufacturer_id: source.manufacturer_id ?? null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toMaterialPayload(source) {
|
export function toMaterialPayload(source) {
|
||||||
const payload = {}
|
const payload = {}
|
||||||
for (const key of materialKeys) {
|
for (const key of materialKeys) {
|
||||||
payload[key] = key.endsWith('_id') ? source[key] ?? null : source[key] || ''
|
payload[key] = key === 'supplier_id' || key === 'manufacturer_id'
|
||||||
|
? (source[key] == null || source[key] === '' ? 0 : source[key])
|
||||||
|
: key.endsWith('_id') ? source[key] ?? null : source[key] || ''
|
||||||
}
|
}
|
||||||
if (!payload.unit) payload.unit = '个'
|
if (!payload.unit) payload.unit = '个'
|
||||||
return payload
|
return payload
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,9 @@ describe('material master-data form', () => {
|
||||||
material_image: '',
|
material_image: '',
|
||||||
major_category_id: null,
|
major_category_id: null,
|
||||||
category_id: null,
|
category_id: null,
|
||||||
base_unit_id: null
|
base_unit_id: null,
|
||||||
|
supplier_id: null,
|
||||||
|
manufacturer_id: null
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -29,6 +31,8 @@ describe('material master-data form', () => {
|
||||||
category: 'A01-01',
|
category: 'A01-01',
|
||||||
category_id: 12,
|
category_id: 12,
|
||||||
base_unit_id: 3,
|
base_unit_id: 3,
|
||||||
|
supplier_id: 7,
|
||||||
|
manufacturer_id: 8,
|
||||||
unit: '件',
|
unit: '件',
|
||||||
material_image: '',
|
material_image: '',
|
||||||
total_on_hand: 99,
|
total_on_hand: 99,
|
||||||
|
|
@ -45,7 +49,9 @@ describe('material master-data form', () => {
|
||||||
category_id: 12,
|
category_id: 12,
|
||||||
base_unit_id: 3,
|
base_unit_id: 3,
|
||||||
unit: '件',
|
unit: '件',
|
||||||
material_image: ''
|
material_image: '',
|
||||||
|
supplier_id: 7,
|
||||||
|
manufacturer_id: 8
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
2
frontend/src/views/partners/Manufacturers.vue
Normal file
2
frontend/src/views/partners/Manufacturers.vue
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
<template><PartnerList role="manufacturer" title="制造商" /></template>
|
||||||
|
<script setup>import PartnerList from './PartnerList.vue'</script>
|
||||||
|
|
@ -90,6 +90,37 @@ describe('伙伴客户和供应商身份多选', () => {
|
||||||
})
|
})
|
||||||
afterEach(() => { vi.restoreAllMocks() })
|
afterEach(() => { vi.restoreAllMocks() })
|
||||||
|
|
||||||
|
test('制造商页默认制造商身份并支持独立保存', async () => {
|
||||||
|
const wrapper = mountPartner('manufacturer', '制造商')
|
||||||
|
await flushPromises()
|
||||||
|
expect(partnersApi.list).toHaveBeenCalledWith('manufacturer', { limit: 20, offset: 0 })
|
||||||
|
await openCreateDialog(wrapper, '制造商')
|
||||||
|
const checkboxes = businessRoleCheckboxes(wrapper)
|
||||||
|
expect(checkboxes).toHaveLength(3)
|
||||||
|
expect(checkboxes[2].element.checked).toBe(true)
|
||||||
|
expect(checkboxes[1].element.checked).toBe(false)
|
||||||
|
await inputByLabel(wrapper, '伙伴编码').setValue('MFG-001')
|
||||||
|
await inputByLabel(wrapper, '企业名称').setValue('制造企业')
|
||||||
|
await clickSave(wrapper)
|
||||||
|
expect(partnersApi.create).toHaveBeenCalledWith(expect.objectContaining({ is_manufacturer: true, is_supplier: false, is_customer: false }))
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('供应商编辑回显制造商身份,保存时保留三个角色及其他合作资料', async () => {
|
||||||
|
const row = { id: 7, partner_code: 'ALL-001', legal_name: '多角色企业', is_customer: true, is_supplier: true, is_manufacturer: true,
|
||||||
|
customer_status: 'suspended', supplier_status: 'active', customer_categories: ['material'], supplier_categories: ['finished_product'] }
|
||||||
|
partnersApi.list.mockResolvedValue({ data: { items: [row], total: 1 } })
|
||||||
|
const wrapper = mountPartner()
|
||||||
|
await flushPromises()
|
||||||
|
await findButton(wrapper, '编辑').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(businessRoleCheckboxes(wrapper).every(checkbox => checkbox.element.checked)).toBe(true)
|
||||||
|
await clickSave(wrapper)
|
||||||
|
expect(partnersApi.update).toHaveBeenCalledWith(7, expect.objectContaining({ is_supplier: true, is_customer: true, is_manufacturer: true,
|
||||||
|
customer_status: 'suspended', customer_categories: ['material'], supplier_categories: ['finished_product'] }))
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
test.each([['supplier', '供应商', false, true], ['customer', '客户', true, false]])(
|
test.each([['supplier', '供应商', false, true], ['customer', '客户', true, false]])(
|
||||||
'%s 新增默认当前身份,可双选并提交两个身份', async (role, title, customer, supplier) => {
|
'%s 新增默认当前身份,可双选并提交两个身份', async (role, title, customer, supplier) => {
|
||||||
const wrapper = mountPartner(role, title)
|
const wrapper = mountPartner(role, title)
|
||||||
|
|
@ -117,7 +148,8 @@ describe('伙伴客户和供应商身份多选', () => {
|
||||||
await findButton(wrapper, '编辑').trigger('click')
|
await findButton(wrapper, '编辑').trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
const checkboxes = businessRoleCheckboxes(wrapper)
|
const checkboxes = businessRoleCheckboxes(wrapper)
|
||||||
expect(checkboxes.every(checkbox => checkbox.element.checked)).toBe(true)
|
expect(checkboxes.slice(0, 2).every(checkbox => checkbox.element.checked)).toBe(true)
|
||||||
|
expect(checkboxes[2].element.checked).toBe(false)
|
||||||
await checkboxes[0].setValue(customer)
|
await checkboxes[0].setValue(customer)
|
||||||
await checkboxes[1].setValue(supplier)
|
await checkboxes[1].setValue(supplier)
|
||||||
expect(row.is_customer).toBe(true)
|
expect(row.is_customer).toBe(true)
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
<div><h2>{{ title }}</h2><p>业务伙伴独立于本公司的组织数据,可按合作组织配置{{ title }}身份和状态。</p></div>
|
<div><h2>{{ title }}</h2><p>业务伙伴独立于本公司的组织数据,可按合作组织配置{{ title }}身份和状态。</p></div>
|
||||||
<el-button type="primary" @click="openCreate">新增{{ title }}</el-button>
|
<el-button type="primary" @click="openCreate">新增{{ title }}</el-button>
|
||||||
</div>
|
</div>
|
||||||
<div class="filters">
|
<div v-if="hasCooperationFields" class="filters">
|
||||||
<el-select v-model="categoryFilter" placeholder="全部合作范围" clearable @change="load(1)">
|
<el-select v-model="categoryFilter" placeholder="全部合作范围" clearable @change="load(1)">
|
||||||
<el-option v-for="(label, value) in CATEGORY_LABELS" :key="value" :label="label" :value="value" />
|
<el-option v-for="(label, value) in CATEGORY_LABELS" :key="value" :label="label" :value="value" />
|
||||||
<el-option label="未分类" value="unclassified" />
|
<el-option label="未分类" value="unclassified" />
|
||||||
|
|
@ -21,7 +21,12 @@
|
||||||
>
|
>
|
||||||
<el-table-column prop="partner_code" label="伙伴编码" width="140" />
|
<el-table-column prop="partner_code" label="伙伴编码" width="140" />
|
||||||
<el-table-column prop="legal_name" label="企业名称" min-width="180" />
|
<el-table-column prop="legal_name" label="企业名称" min-width="180" />
|
||||||
<el-table-column label="合作范围" min-width="220">
|
<el-table-column label="业务角色" min-width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-for="(label, role) in ROLE_LABELS" v-show="row[`is_${role}`]" :key="role" class="category-tag">{{ label }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column v-if="hasCooperationFields" label="合作范围" min-width="220">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<template v-if="row[categoriesField]?.length">
|
<template v-if="row[categoriesField]?.length">
|
||||||
<el-tag v-for="category in row[categoriesField]" :key="category" class="category-tag">{{ CATEGORY_LABELS[category] || category }}</el-tag>
|
<el-tag v-for="category in row[categoriesField]" :key="category" class="category-tag">{{ CATEGORY_LABELS[category] || category }}</el-tag>
|
||||||
|
|
@ -32,7 +37,7 @@
|
||||||
<el-table-column prop="unified_credit_code" label="统一社会信用代码" min-width="180" />
|
<el-table-column prop="unified_credit_code" label="统一社会信用代码" min-width="180" />
|
||||||
<el-table-column prop="contact_name" label="联系人" width="110" />
|
<el-table-column prop="contact_name" label="联系人" width="110" />
|
||||||
<el-table-column prop="contact_phone" label="联系电话" width="140" />
|
<el-table-column prop="contact_phone" label="联系电话" width="140" />
|
||||||
<el-table-column :prop="statusField" label="合作状态" width="110" :formatter="statusFormatter" />
|
<el-table-column v-if="hasCooperationFields" :prop="statusField" label="合作状态" width="110" :formatter="statusFormatter" />
|
||||||
<el-table-column label="操作" width="100"><template #default="{ row }"><el-button link type="primary" @click="openEdit(row)">编辑</el-button></template></el-table-column>
|
<el-table-column label="操作" width="100"><template #default="{ row }"><el-button link type="primary" @click="openEdit(row)">编辑</el-button></template></el-table-column>
|
||||||
</BaseTable>
|
</BaseTable>
|
||||||
|
|
||||||
|
|
@ -44,9 +49,10 @@
|
||||||
<el-checkbox-group v-model="businessRoles">
|
<el-checkbox-group v-model="businessRoles">
|
||||||
<el-checkbox value="customer">客户</el-checkbox>
|
<el-checkbox value="customer">客户</el-checkbox>
|
||||||
<el-checkbox value="supplier">供应商</el-checkbox>
|
<el-checkbox value="supplier">供应商</el-checkbox>
|
||||||
|
<el-checkbox value="manufacturer">制造商</el-checkbox>
|
||||||
</el-checkbox-group>
|
</el-checkbox-group>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="合作范围">
|
<el-form-item v-if="hasCooperationFields" label="合作范围">
|
||||||
<el-checkbox-group v-model="form[categoriesField]">
|
<el-checkbox-group v-model="form[categoriesField]">
|
||||||
<el-checkbox v-for="(label, value) in CATEGORY_LABELS" :key="value" :value="value">{{ label }}</el-checkbox>
|
<el-checkbox v-for="(label, value) in CATEGORY_LABELS" :key="value" :value="value">{{ label }}</el-checkbox>
|
||||||
</el-checkbox-group>
|
</el-checkbox-group>
|
||||||
|
|
@ -55,7 +61,7 @@
|
||||||
<el-form-item label="联系人"><el-input v-model="form.contact_name" /></el-form-item>
|
<el-form-item label="联系人"><el-input v-model="form.contact_name" /></el-form-item>
|
||||||
<el-form-item label="联系电话"><el-input v-model="form.contact_phone" /></el-form-item>
|
<el-form-item label="联系电话"><el-input v-model="form.contact_phone" /></el-form-item>
|
||||||
<el-form-item label="地址"><el-input v-model="form.address" /></el-form-item>
|
<el-form-item label="地址"><el-input v-model="form.address" /></el-form-item>
|
||||||
<el-form-item label="合作状态">
|
<el-form-item v-if="hasCooperationFields" label="合作状态">
|
||||||
<el-select v-model="form.status" style="width: 100%">
|
<el-select v-model="form.status" style="width: 100%">
|
||||||
<el-option v-for="(label, value) in STATUS_LABELS" :key="value" :label="label" :value="value" />
|
<el-option v-for="(label, value) in STATUS_LABELS" :key="value" :label="label" :value="value" />
|
||||||
</el-select>
|
</el-select>
|
||||||
|
|
@ -91,6 +97,8 @@ const { runWithSaving } = useSavingGuard()
|
||||||
const pagination = reactive({ currentPage: 1, pageSize: 20, total: 0 })
|
const pagination = reactive({ currentPage: 1, pageSize: 20, total: 0 })
|
||||||
const statusField = computed(() => `${props.role}_status`)
|
const statusField = computed(() => `${props.role}_status`)
|
||||||
const categoriesField = computed(() => `${props.role}_categories`)
|
const categoriesField = computed(() => `${props.role}_categories`)
|
||||||
|
const hasCooperationFields = computed(() => props.role !== 'manufacturer')
|
||||||
|
const ROLE_LABELS = { customer: '客户', supplier: '供应商', manufacturer: '制造商' }
|
||||||
const CATEGORY_LABELS = { material: '物料级(生产)', finished_product: '成品级(经营买卖)' }
|
const CATEGORY_LABELS = { material: '物料级(生产)', finished_product: '成品级(经营买卖)' }
|
||||||
const categoryFilter = ref('')
|
const categoryFilter = ref('')
|
||||||
// 合作状态英文枚举到中文的对照。列表列与编辑弹窗共用这一份,避免两处各写一遍后失步。
|
// 合作状态英文枚举到中文的对照。列表列与编辑弹窗共用这一份,避免两处各写一遍后失步。
|
||||||
|
|
@ -99,7 +107,7 @@ const STATUS_LABELS = { active: '启用', suspended: '暂停', eliminated: '淘
|
||||||
function statusFormatter(row, column, cellValue) { return STATUS_LABELS[cellValue] || cellValue }
|
function statusFormatter(row, column, cellValue) { return STATUS_LABELS[cellValue] || cellValue }
|
||||||
const form = reactive(emptyForm())
|
const form = reactive(emptyForm())
|
||||||
|
|
||||||
function emptyForm() { return { partner_code: '', legal_name: '', unified_credit_code: '', contact_name: '', contact_phone: '', address: '', status: 'active', is_supplier: false, is_customer: false, supplier_categories: [], customer_categories: [] } }
|
function emptyForm() { return { partner_code: '', legal_name: '', unified_credit_code: '', contact_name: '', contact_phone: '', address: '', status: 'active', is_supplier: false, is_manufacturer: false, is_customer: false, supplier_categories: [], customer_categories: [] } }
|
||||||
async function load(currentPage = pagination.currentPage) {
|
async function load(currentPage = pagination.currentPage) {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
|
|
@ -126,7 +134,7 @@ function openCreate() {
|
||||||
}
|
}
|
||||||
function openEdit(row) {
|
function openEdit(row) {
|
||||||
editingId.value = row.id
|
editingId.value = row.id
|
||||||
businessRoles.value = ['customer', 'supplier'].filter(role => row[`is_${role}`])
|
businessRoles.value = Object.keys(ROLE_LABELS).filter(role => row[`is_${role}`])
|
||||||
replaceAllKeys(form, { ...emptyForm(), ...row, status: row[statusField.value] || 'active',
|
replaceAllKeys(form, { ...emptyForm(), ...row, status: row[statusField.value] || 'active',
|
||||||
supplier_categories: [...(row.supplier_categories || [])], customer_categories: [...(row.customer_categories || [])] })
|
supplier_categories: [...(row.supplier_categories || [])], customer_categories: [...(row.customer_categories || [])] })
|
||||||
dialogVisible.value = true
|
dialogVisible.value = true
|
||||||
|
|
@ -135,8 +143,9 @@ function payload() {
|
||||||
return {
|
return {
|
||||||
...form,
|
...form,
|
||||||
is_supplier: businessRoles.value.includes('supplier'),
|
is_supplier: businessRoles.value.includes('supplier'),
|
||||||
|
is_manufacturer: businessRoles.value.includes('manufacturer'),
|
||||||
is_customer: businessRoles.value.includes('customer'),
|
is_customer: businessRoles.value.includes('customer'),
|
||||||
[`${props.role}_status`]: form.status
|
...(hasCooperationFields.value ? { [statusField.value]: form.status } : {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function save() {
|
async function save() {
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,8 @@ func (h *Handler) RegisterRoutes(r *gin.Engine) {
|
||||||
materials.Use(middleware.AuthMiddleware(), middleware.OrganizationMiddleware())
|
materials.Use(middleware.AuthMiddleware(), middleware.OrganizationMiddleware())
|
||||||
{
|
{
|
||||||
materials.GET("", h.ListMaterials)
|
materials.GET("", h.ListMaterials)
|
||||||
|
materials.GET("/partners", h.ListMaterialPartners)
|
||||||
|
materials.POST("/duplicate-check", h.CheckMaterialDuplicates)
|
||||||
materials.GET("/:id", h.GetMaterial)
|
materials.GET("/:id", h.GetMaterial)
|
||||||
materials.POST("", h.CreateMaterial)
|
materials.POST("", h.CreateMaterial)
|
||||||
materials.PUT("/:id", h.UpdateMaterial)
|
materials.PUT("/:id", h.UpdateMaterial)
|
||||||
|
|
@ -313,6 +315,60 @@ func (h *Handler) GetMaterial(c *gin.Context) {
|
||||||
response.Success(c, material)
|
response.Success(c, material)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) CheckMaterialDuplicates(c *gin.Context) {
|
||||||
|
var req model.MaterialDuplicateCheckRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
orgID, ok := organizationID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items, err := h.materialSvc.FindExactDuplicates(orgID, &req)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, service.ErrInvalidMaterialSupplier) || errors.Is(err, service.ErrInvalidMaterialManufacturer) {
|
||||||
|
response.BadRequest(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.InternalError(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Success(c, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ListMaterialPartners(c *gin.Context) {
|
||||||
|
orgID, ok := organizationID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
role := c.Query("role")
|
||||||
|
if role != "supplier" && role != "manufacturer" {
|
||||||
|
response.BadRequest(c, "role must be supplier or manufacturer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limit, offset := 100, 0
|
||||||
|
if parsed, err := strconv.Atoi(c.Query("limit")); err == nil && parsed > 0 {
|
||||||
|
limit = parsed
|
||||||
|
if limit > 100 {
|
||||||
|
limit = 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if parsed, err := strconv.Atoi(c.Query("offset")); err == nil && parsed >= 0 {
|
||||||
|
offset = parsed
|
||||||
|
}
|
||||||
|
partners, total, err := h.partnerSvc.List(orgID, role, limit, offset, "")
|
||||||
|
if err != nil {
|
||||||
|
response.InternalError(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
options := make([]model.MaterialPartnerOption, 0, len(partners))
|
||||||
|
for _, partner := range partners {
|
||||||
|
options = append(options, model.MaterialPartnerOption{ID: partner.ID, PartnerCode: partner.PartnerCode, LegalName: partner.LegalName})
|
||||||
|
}
|
||||||
|
response.PaginatedSuccess(c, options, limit, offset, total)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) CreateMaterial(c *gin.Context) {
|
func (h *Handler) CreateMaterial(c *gin.Context) {
|
||||||
var req model.CreateMaterialRequest
|
var req model.CreateMaterialRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
|
@ -332,7 +388,7 @@ func (h *Handler) CreateMaterial(c *gin.Context) {
|
||||||
|
|
||||||
material, err := h.materialSvc.Create(&req, operator)
|
material, err := h.materialSvc.Create(&req, operator)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, service.ErrInvalidMaterialCategory) || errors.Is(err, service.ErrInvalidMaterialUnit) || errors.Is(err, service.ErrMaterialNameTooLong) || errors.Is(err, service.ErrMaterialSpecTooLong) {
|
if errors.Is(err, service.ErrInvalidMaterialCategory) || errors.Is(err, service.ErrInvalidMaterialUnit) || errors.Is(err, service.ErrInvalidMaterialSupplier) || errors.Is(err, service.ErrInvalidMaterialManufacturer) || errors.Is(err, service.ErrMaterialNameTooLong) || errors.Is(err, service.ErrMaterialSpecTooLong) || errors.Is(err, service.ErrMaterialDuplicate) {
|
||||||
response.BadRequest(c, err.Error())
|
response.BadRequest(c, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -371,7 +427,7 @@ func (h *Handler) UpdateMaterial(c *gin.Context) {
|
||||||
|
|
||||||
material, err := h.materialSvc.Update(orgID, id, &req, operator)
|
material, err := h.materialSvc.Update(orgID, id, &req, operator)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, service.ErrInvalidMaterialCategory) || errors.Is(err, service.ErrInvalidMaterialUnit) || errors.Is(err, service.ErrMaterialNameTooLong) || errors.Is(err, service.ErrMaterialSpecTooLong) {
|
if errors.Is(err, service.ErrInvalidMaterialCategory) || errors.Is(err, service.ErrInvalidMaterialUnit) || errors.Is(err, service.ErrInvalidMaterialSupplier) || errors.Is(err, service.ErrInvalidMaterialManufacturer) || errors.Is(err, service.ErrMaterialNameTooLong) || errors.Is(err, service.ErrMaterialSpecTooLong) {
|
||||||
response.BadRequest(c, err.Error())
|
response.BadRequest(c, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,9 +22,9 @@ import (
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// 以下是与 repository/service 里 SQL 字面量对得上的稳定子串(sqlmock 用正则匹配查询)。
|
// 以下是与 repository/service 里 SQL 字面量对得上的稳定子串(sqlmock 用正则匹配查询)。
|
||||||
materialListPattern = `WHERE organization_id = \? AND status = \? ORDER BY id DESC LIMIT \? OFFSET \?`
|
materialListPattern = `WHERE m.organization_id = \? AND m.status = \? ORDER BY m.id DESC LIMIT \? OFFSET \?`
|
||||||
materialCountPattern = `SELECT COUNT\(\*\) FROM materials WHERE organization_id = \? AND status = \?`
|
materialCountPattern = `SELECT COUNT\(\*\) FROM materials WHERE organization_id = \? AND materials.status = \?`
|
||||||
materialGetByIDPattern = `FROM materials WHERE organization_id = \? AND id = \?`
|
materialGetByIDPattern = `WHERE m.organization_id = \? AND m.id = \?`
|
||||||
materialInsertPattern = `INSERT INTO materials \(organization_id, material_code`
|
materialInsertPattern = `INSERT INTO materials \(organization_id, material_code`
|
||||||
materialLogInsertPattern = `INSERT INTO material_logs`
|
materialLogInsertPattern = `INSERT INTO material_logs`
|
||||||
lockCodeRulePattern = `FROM code_rules WHERE organization_id = \? AND category_id = \?`
|
lockCodeRulePattern = `FROM code_rules WHERE organization_id = \? AND category_id = \?`
|
||||||
|
|
@ -45,7 +45,7 @@ func materialRow(columns []string, values ...driver.Value) *sqlmock.Rows {
|
||||||
|
|
||||||
func materialColumns() []string {
|
func materialColumns() []string {
|
||||||
return []string{"id", "organization_id", "material_code", "name", "material_image", "spec", "manufacturer_model",
|
return []string{"id", "organization_id", "material_code", "name", "material_image", "spec", "manufacturer_model",
|
||||||
"category_id", "base_unit_id", "unit", "status", "version", "created_at", "updated_at"}
|
"supplier_id", "manufacturer_id", "supplier_name", "manufacturer_name", "category_id", "base_unit_id", "unit", "status", "version", "created_at", "updated_at"}
|
||||||
}
|
}
|
||||||
|
|
||||||
// expectMaterialOrgAccess 让 OrganizationMiddleware 的 CanAccess 放行指定公司。
|
// expectMaterialOrgAccess 让 OrganizationMiddleware 的 CanAccess 放行指定公司。
|
||||||
|
|
@ -94,7 +94,7 @@ func TestMaterialListScopesToRequestedOrganization(t *testing.T) {
|
||||||
WillReturnRows(materialRow([]string{"count"}, int64(1)))
|
WillReturnRows(materialRow([]string{"count"}, int64(1)))
|
||||||
mock.ExpectQuery(materialListPattern).WithArgs(tc.orgID, "active", 20, 0).
|
mock.ExpectQuery(materialListPattern).WithArgs(tc.orgID, "active", 20, 0).
|
||||||
WillReturnRows(materialRow(materialColumns(),
|
WillReturnRows(materialRow(materialColumns(),
|
||||||
int64(7), tc.orgID, tc.wantCode, "轴承", nil, "", nil, int64(12), nil, "pcs", "active", 1, time.Now(), time.Now()))
|
int64(7), tc.orgID, tc.wantCode, "轴承", nil, "", nil, nil, nil, "", "", int64(12), nil, "pcs", "active", 1, time.Now(), time.Now()))
|
||||||
|
|
||||||
w := newMaterialRequest(t, "GET", "/api/materials", "", tc.orgID)
|
w := newMaterialRequest(t, "GET", "/api/materials", "", tc.orgID)
|
||||||
if w.Code != 200 || !strings.Contains(w.Body.String(), tc.wantCode) {
|
if w.Code != 200 || !strings.Contains(w.Body.String(), tc.wantCode) {
|
||||||
|
|
@ -150,17 +150,17 @@ func TestCreateMaterialUsesHeaderOrganizationInsteadOfBody(t *testing.T) {
|
||||||
mock.ExpectQuery(lockCodeRulePattern).WithArgs(int64(1), int64(12)).
|
mock.ExpectQuery(lockCodeRulePattern).WithArgs(int64(1), int64(12)).
|
||||||
WillReturnRows(materialRow([]string{"id", "organization_id", "category_id", "prefix", "sequence_length", "next_sequence"}))
|
WillReturnRows(materialRow([]string{"id", "organization_id", "category_id", "prefix", "sequence_length", "next_sequence"}))
|
||||||
mock.ExpectExec(materialInsertPattern).
|
mock.ExpectExec(materialInsertPattern).
|
||||||
WithArgs(int64(1), "HDR-001", "轴承", nil, "", nil, int64(12), nil, "个", "active", 1).
|
WithArgs(int64(1), "HDR-001", "轴承", nil, "", nil, nil, nil, int64(12), nil, "个", "active", 1).
|
||||||
WillReturnResult(sqlmock.NewResult(7, 1))
|
WillReturnResult(sqlmock.NewResult(7, 1))
|
||||||
mock.ExpectCommit()
|
mock.ExpectCommit()
|
||||||
mock.ExpectQuery(materialGetByIDPattern).WithArgs(int64(1), int64(7)).
|
mock.ExpectQuery(materialGetByIDPattern).WithArgs(int64(1), int64(7)).
|
||||||
WillReturnRows(materialRow(materialColumns(),
|
WillReturnRows(materialRow(materialColumns(),
|
||||||
int64(7), int64(1), "HDR-001", "轴承", nil, "", nil, int64(12), nil, "个", "active", 1, time.Now(), time.Now()))
|
int64(7), int64(1), "HDR-001", "轴承", nil, "", nil, nil, nil, "", "", int64(12), nil, "个", "active", 1, time.Now(), time.Now()))
|
||||||
mock.ExpectExec(materialLogInsertPattern).
|
mock.ExpectExec(materialLogInsertPattern).
|
||||||
WithArgs("CREATE", int64(7), "", sqlmock.AnyArg(), "operator", sqlmock.AnyArg()).
|
WithArgs("CREATE", int64(7), "", sqlmock.AnyArg(), "operator", sqlmock.AnyArg()).
|
||||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||||
|
|
||||||
body := `{"organization_id":2,"material_code":"HDR-001","name":"轴承","category_id":12}`
|
body := `{"organization_id":2,"material_code":"HDR-001","name":"轴承","category_id":12,"allow_duplicate":true}`
|
||||||
w := newMaterialRequest(t, "POST", "/api/materials", body, 1)
|
w := newMaterialRequest(t, "POST", "/api/materials", body, 1)
|
||||||
if w.Code != 201 {
|
if w.Code != 201 {
|
||||||
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ func emptyWarehouseSummary() *sqlmock.Rows {
|
||||||
func activeMaterialRows(id int64) *sqlmock.Rows {
|
func activeMaterialRows(id int64) *sqlmock.Rows {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
return sqlmock.NewRows(materialColumns()).
|
return sqlmock.NewRows(materialColumns()).
|
||||||
AddRow(id, 1, "MAT-005", "轴承", nil, "", nil, 1, 1, "个", "active", 1, now, now)
|
AddRow(id, 1, "MAT-005", "轴承", nil, "", nil, nil, nil, "", "", 1, 1, "个", "active", 1, now, now)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestOrderDetailScopesToOrganization:单据详情按请求公司过滤,传别家公司的单据 ID 读不到,回 404。
|
// TestOrderDetailScopesToOrganization:单据详情按请求公司过滤,传别家公司的单据 ID 读不到,回 404。
|
||||||
|
|
|
||||||
|
|
@ -5,22 +5,26 @@ import "time"
|
||||||
// MaterialImportRow 是解析后的一行原始数据。Row 保留 Excel 真实行号(从 1
|
// MaterialImportRow 是解析后的一行原始数据。Row 保留 Excel 真实行号(从 1
|
||||||
// 开始、含表头行),校验失败时用它定位,用户可直接对着 Excel 找。
|
// 开始、含表头行),校验失败时用它定位,用户可直接对着 Excel 找。
|
||||||
type MaterialImportRow struct {
|
type MaterialImportRow struct {
|
||||||
Row int
|
Row int
|
||||||
Name string
|
Name string
|
||||||
Spec string
|
Spec string
|
||||||
Unit string
|
Unit string
|
||||||
MajorName string
|
Supplier string
|
||||||
MinorName string
|
Manufacturer string
|
||||||
|
MajorName string
|
||||||
|
MinorName string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Data 返回该行的原始内容,用于错误明细里的"原始数据"。
|
// Data 返回该行的原始内容,用于错误明细里的"原始数据"。
|
||||||
func (r MaterialImportRow) Data() map[string]string {
|
func (r MaterialImportRow) Data() map[string]string {
|
||||||
return map[string]string{
|
return map[string]string{
|
||||||
"name": r.Name,
|
"name": r.Name,
|
||||||
"spec": r.Spec,
|
"spec": r.Spec,
|
||||||
"unit": r.Unit,
|
"unit": r.Unit,
|
||||||
"major": r.MajorName,
|
"supplier": r.Supplier,
|
||||||
"minor": r.MinorName,
|
"manufacturer": r.Manufacturer,
|
||||||
|
"major": r.MajorName,
|
||||||
|
"minor": r.MinorName,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,10 @@ type Material struct {
|
||||||
MaterialImage *string `json:"material_image,omitempty"`
|
MaterialImage *string `json:"material_image,omitempty"`
|
||||||
Spec string `json:"spec,omitempty"`
|
Spec string `json:"spec,omitempty"`
|
||||||
ManufacturerModel *string `json:"manufacturer_model,omitempty"`
|
ManufacturerModel *string `json:"manufacturer_model,omitempty"`
|
||||||
|
SupplierID *int64 `json:"supplier_id,omitempty"`
|
||||||
|
ManufacturerID *int64 `json:"manufacturer_id,omitempty"`
|
||||||
|
SupplierName string `json:"supplier_name,omitempty"`
|
||||||
|
ManufacturerName string `json:"manufacturer_name,omitempty"`
|
||||||
CategoryID *int64 `json:"category_id,omitempty"`
|
CategoryID *int64 `json:"category_id,omitempty"`
|
||||||
BaseUnitID *int64 `json:"base_unit_id,omitempty"`
|
BaseUnitID *int64 `json:"base_unit_id,omitempty"`
|
||||||
Unit string `json:"unit,omitempty"`
|
Unit string `json:"unit,omitempty"`
|
||||||
|
|
@ -230,6 +234,9 @@ type CreateMaterialRequest struct {
|
||||||
MaterialImage *string `json:"material_image"`
|
MaterialImage *string `json:"material_image"`
|
||||||
Spec string `json:"spec"`
|
Spec string `json:"spec"`
|
||||||
ManufacturerModel *string `json:"manufacturer_model"`
|
ManufacturerModel *string `json:"manufacturer_model"`
|
||||||
|
SupplierID *int64 `json:"supplier_id"`
|
||||||
|
ManufacturerID *int64 `json:"manufacturer_id"`
|
||||||
|
AllowDuplicate bool `json:"allow_duplicate"`
|
||||||
CategoryID *int64 `json:"category_id" binding:"required,gt=0"`
|
CategoryID *int64 `json:"category_id" binding:"required,gt=0"`
|
||||||
BaseUnitID *int64 `json:"base_unit_id"`
|
BaseUnitID *int64 `json:"base_unit_id"`
|
||||||
Unit string `json:"unit"`
|
Unit string `json:"unit"`
|
||||||
|
|
@ -242,12 +249,30 @@ type UpdateMaterialRequest struct {
|
||||||
MaterialImage *string `json:"material_image"`
|
MaterialImage *string `json:"material_image"`
|
||||||
Spec string `json:"spec"`
|
Spec string `json:"spec"`
|
||||||
ManufacturerModel *string `json:"manufacturer_model"`
|
ManufacturerModel *string `json:"manufacturer_model"`
|
||||||
|
SupplierID *int64 `json:"supplier_id"`
|
||||||
|
ManufacturerID *int64 `json:"manufacturer_id"`
|
||||||
CategoryID *int64 `json:"category_id" binding:"required,gt=0"`
|
CategoryID *int64 `json:"category_id" binding:"required,gt=0"`
|
||||||
BaseUnitID *int64 `json:"base_unit_id"`
|
BaseUnitID *int64 `json:"base_unit_id"`
|
||||||
Unit string `json:"unit"`
|
Unit string `json:"unit"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MaterialDuplicateCheckRequest struct {
|
||||||
|
Name string `json:"name" binding:"required"`
|
||||||
|
Spec string `json:"spec"`
|
||||||
|
Unit string `json:"unit"`
|
||||||
|
BaseUnitID *int64 `json:"base_unit_id"`
|
||||||
|
SupplierID *int64 `json:"supplier_id"`
|
||||||
|
ManufacturerID *int64 `json:"manufacturer_id"`
|
||||||
|
ExcludeID int64 `json:"exclude_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MaterialPartnerOption struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
PartnerCode string `json:"partner_code"`
|
||||||
|
LegalName string `json:"legal_name"`
|
||||||
|
}
|
||||||
|
|
||||||
// CreateInboundRequest represents the request to create an inbound order
|
// CreateInboundRequest represents the request to create an inbound order
|
||||||
type CreateInboundRequest struct {
|
type CreateInboundRequest struct {
|
||||||
Handler string `json:"handler" binding:"required"`
|
Handler string `json:"handler" binding:"required"`
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ type BusinessPartner struct {
|
||||||
ContactPhone string `json:"contact_phone"`
|
ContactPhone string `json:"contact_phone"`
|
||||||
Address string `json:"address"`
|
Address string `json:"address"`
|
||||||
IsSupplier bool `json:"is_supplier"`
|
IsSupplier bool `json:"is_supplier"`
|
||||||
|
IsManufacturer bool `json:"is_manufacturer"`
|
||||||
IsCustomer bool `json:"is_customer"`
|
IsCustomer bool `json:"is_customer"`
|
||||||
SupplierStatus string `json:"supplier_status"`
|
SupplierStatus string `json:"supplier_status"`
|
||||||
CustomerStatus string `json:"customer_status"`
|
CustomerStatus string `json:"customer_status"`
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,17 @@ func (r *MaterialRepo) IsActiveUnit(unitID, organizationID int64) (bool, error)
|
||||||
return exists, err
|
return exists, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsPartnerForRole 校验伙伴属于当前公司并具有物料字段要求的身份。
|
||||||
|
func (r *MaterialRepo) IsPartnerForRole(partnerID, organizationID int64, role string) (bool, error) {
|
||||||
|
field := "is_supplier"
|
||||||
|
if role == "manufacturer" {
|
||||||
|
field = "is_manufacturer"
|
||||||
|
}
|
||||||
|
var exists bool
|
||||||
|
err := database.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM organization_partners WHERE id = ? AND organization_id = ? AND `+field+` = TRUE)`, partnerID, organizationID).Scan(&exists)
|
||||||
|
return exists, err
|
||||||
|
}
|
||||||
|
|
||||||
// sqlExecer 让插入语句既能直接走连接池,也能跑在事务里。
|
// sqlExecer 让插入语句既能直接走连接池,也能跑在事务里。
|
||||||
type sqlExecer interface {
|
type sqlExecer interface {
|
||||||
Exec(query string, args ...interface{}) (sql.Result, error)
|
Exec(query string, args ...interface{}) (sql.Result, error)
|
||||||
|
|
@ -63,9 +74,9 @@ type sqlExecer interface {
|
||||||
// insertMaterial 写入物料并回填自增主键,唯一键冲突统一翻译成
|
// insertMaterial 写入物料并回填自增主键,唯一键冲突统一翻译成
|
||||||
// ErrDuplicateMaterialCode。
|
// ErrDuplicateMaterialCode。
|
||||||
func insertMaterial(execer sqlExecer, m *model.Material) error {
|
func insertMaterial(execer sqlExecer, m *model.Material) error {
|
||||||
query := `INSERT INTO materials (organization_id, material_code, name, material_image, spec, manufacturer_model, category_id, base_unit_id, unit, status, version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
query := `INSERT INTO materials (organization_id, material_code, name, material_image, spec, manufacturer_model, supplier_id, manufacturer_id, category_id, base_unit_id, unit, status, version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
result, err := execer.Exec(query, m.OrganizationID, m.MaterialCode, m.Name, m.MaterialImage, m.Spec,
|
result, err := execer.Exec(query, m.OrganizationID, m.MaterialCode, m.Name, m.MaterialImage, m.Spec,
|
||||||
m.ManufacturerModel, m.CategoryID, m.BaseUnitID, m.Unit, m.Status, m.Version)
|
m.ManufacturerModel, m.SupplierID, m.ManufacturerID, m.CategoryID, m.BaseUnitID, m.Unit, m.Status, m.Version)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(err.Error(), "Duplicate entry") {
|
if strings.Contains(err.Error(), "Duplicate entry") {
|
||||||
return ErrDuplicateMaterialCode
|
return ErrDuplicateMaterialCode
|
||||||
|
|
@ -165,13 +176,20 @@ func (r *MaterialRepo) CreateWithCodeRule(m *model.Material, submittedCode strin
|
||||||
// GetByID 读取本公司物料。organization_id 与 id 同时作为条件,跨公司记录读不到
|
// GetByID 读取本公司物料。organization_id 与 id 同时作为条件,跨公司记录读不到
|
||||||
// 会返回 sql.ErrNoRows,由上层映射成 404。
|
// 会返回 sql.ErrNoRows,由上层映射成 404。
|
||||||
func (r *MaterialRepo) GetByID(organizationID, id int64, m *model.Material) error {
|
func (r *MaterialRepo) GetByID(organizationID, id int64, m *model.Material) error {
|
||||||
query := `SELECT id, organization_id, material_code, name, material_image, spec, manufacturer_model,
|
query := `SELECT m.id, m.organization_id, m.material_code, m.name, m.material_image, m.spec, m.manufacturer_model,
|
||||||
category_id, base_unit_id, unit, status, version, created_at, updated_at
|
m.supplier_id, m.manufacturer_id, COALESCE(sb.legal_name, ''), COALESCE(mb.legal_name, ''),
|
||||||
FROM materials WHERE organization_id = ? AND id = ?`
|
m.category_id, m.base_unit_id, m.unit, m.status, m.version, m.created_at, m.updated_at
|
||||||
|
FROM materials m
|
||||||
|
LEFT JOIN organization_partners sop ON sop.organization_id = m.organization_id AND sop.id = m.supplier_id
|
||||||
|
LEFT JOIN business_partners sb ON sb.id = sop.business_partner_id
|
||||||
|
LEFT JOIN organization_partners mop ON mop.organization_id = m.organization_id AND mop.id = m.manufacturer_id
|
||||||
|
LEFT JOIN business_partners mb ON mb.id = mop.business_partner_id
|
||||||
|
WHERE m.organization_id = ? AND m.id = ?`
|
||||||
var materialImage sql.NullString
|
var materialImage sql.NullString
|
||||||
var manufacturerModel sql.NullString
|
var manufacturerModel sql.NullString
|
||||||
|
var supplierID, manufacturerID sql.NullInt64
|
||||||
err := database.DB.QueryRow(query, organizationID, id).Scan(&m.ID, &m.OrganizationID, &m.MaterialCode, &m.Name, &materialImage,
|
err := database.DB.QueryRow(query, organizationID, id).Scan(&m.ID, &m.OrganizationID, &m.MaterialCode, &m.Name, &materialImage,
|
||||||
&m.Spec, &manufacturerModel, &m.CategoryID, &m.BaseUnitID,
|
&m.Spec, &manufacturerModel, &supplierID, &manufacturerID, &m.SupplierName, &m.ManufacturerName, &m.CategoryID, &m.BaseUnitID,
|
||||||
&m.Unit, &m.Status, &m.Version, &m.CreatedAt, &m.UpdatedAt)
|
&m.Unit, &m.Status, &m.Version, &m.CreatedAt, &m.UpdatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -182,27 +200,24 @@ func (r *MaterialRepo) GetByID(organizationID, id int64, m *model.Material) erro
|
||||||
if manufacturerModel.Valid {
|
if manufacturerModel.Valid {
|
||||||
m.ManufacturerModel = &manufacturerModel.String
|
m.ManufacturerModel = &manufacturerModel.String
|
||||||
}
|
}
|
||||||
|
if supplierID.Valid {
|
||||||
|
m.SupplierID = &supplierID.Int64
|
||||||
|
}
|
||||||
|
if manufacturerID.Valid {
|
||||||
|
m.ManufacturerID = &manufacturerID.Int64
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *MaterialRepo) GetByMaterialCode(organizationID int64, code string) (*model.Material, error) {
|
func (r *MaterialRepo) GetByMaterialCode(organizationID int64, code string) (*model.Material, error) {
|
||||||
m := &model.Material{}
|
var id int64
|
||||||
query := `SELECT id, organization_id, material_code, name, material_image, spec, manufacturer_model,
|
err := database.DB.QueryRow(`SELECT id FROM materials WHERE organization_id = ? AND material_code = ?`, organizationID, code).Scan(&id)
|
||||||
category_id, base_unit_id, unit, status, version, created_at, updated_at
|
|
||||||
FROM materials WHERE organization_id = ? AND material_code = ?`
|
|
||||||
var materialImage sql.NullString
|
|
||||||
var manufacturerModel sql.NullString
|
|
||||||
err := database.DB.QueryRow(query, organizationID, code).Scan(&m.ID, &m.OrganizationID, &m.MaterialCode, &m.Name, &materialImage,
|
|
||||||
&m.Spec, &manufacturerModel, &m.CategoryID, &m.BaseUnitID,
|
|
||||||
&m.Unit, &m.Status, &m.Version, &m.CreatedAt, &m.UpdatedAt)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if materialImage.Valid {
|
m := &model.Material{}
|
||||||
m.MaterialImage = &materialImage.String
|
if err := r.GetByID(organizationID, id, m); err != nil {
|
||||||
}
|
return nil, err
|
||||||
if manufacturerModel.Valid {
|
|
||||||
m.ManufacturerModel = &manufacturerModel.String
|
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
@ -211,12 +226,12 @@ func (r *MaterialRepo) List(organizationID int64, search, status string, limit,
|
||||||
countQuery := `SELECT COUNT(*) FROM materials WHERE organization_id = ?`
|
countQuery := `SELECT COUNT(*) FROM materials WHERE organization_id = ?`
|
||||||
countArgs := []interface{}{organizationID}
|
countArgs := []interface{}{organizationID}
|
||||||
if status != "all" {
|
if status != "all" {
|
||||||
countQuery += ` AND status = ?`
|
countQuery += ` AND materials.status = ?`
|
||||||
countArgs = append(countArgs, status)
|
countArgs = append(countArgs, status)
|
||||||
}
|
}
|
||||||
|
|
||||||
if search != "" {
|
if search != "" {
|
||||||
countQuery += ` AND (material_code LIKE ? OR name LIKE ?)`
|
countQuery += ` AND (materials.material_code LIKE ? OR materials.name LIKE ?)`
|
||||||
searchPattern := "%" + search + "%"
|
searchPattern := "%" + search + "%"
|
||||||
countArgs = append(countArgs, searchPattern, searchPattern)
|
countArgs = append(countArgs, searchPattern, searchPattern)
|
||||||
}
|
}
|
||||||
|
|
@ -227,21 +242,27 @@ func (r *MaterialRepo) List(organizationID int64, search, status string, limit,
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
query := `SELECT id, organization_id, material_code, name, material_image, spec, manufacturer_model,
|
query := `SELECT m.id, m.organization_id, m.material_code, m.name, m.material_image, m.spec, m.manufacturer_model,
|
||||||
category_id, base_unit_id, unit, status, version, created_at, updated_at
|
m.supplier_id, m.manufacturer_id, COALESCE(sb.legal_name, ''), COALESCE(mb.legal_name, ''),
|
||||||
FROM materials WHERE organization_id = ?`
|
m.category_id, m.base_unit_id, m.unit, m.status, m.version, m.created_at, m.updated_at
|
||||||
|
FROM materials m
|
||||||
|
LEFT JOIN organization_partners sop ON sop.organization_id = m.organization_id AND sop.id = m.supplier_id
|
||||||
|
LEFT JOIN business_partners sb ON sb.id = sop.business_partner_id
|
||||||
|
LEFT JOIN organization_partners mop ON mop.organization_id = m.organization_id AND mop.id = m.manufacturer_id
|
||||||
|
LEFT JOIN business_partners mb ON mb.id = mop.business_partner_id
|
||||||
|
WHERE m.organization_id = ?`
|
||||||
args := []interface{}{organizationID}
|
args := []interface{}{organizationID}
|
||||||
if status != "all" {
|
if status != "all" {
|
||||||
query += ` AND status = ?`
|
query += ` AND m.status = ?`
|
||||||
args = append(args, status)
|
args = append(args, status)
|
||||||
}
|
}
|
||||||
|
|
||||||
if search != "" {
|
if search != "" {
|
||||||
query += ` AND (material_code LIKE ? OR name LIKE ?)`
|
query += ` AND (m.material_code LIKE ? OR m.name LIKE ?)`
|
||||||
searchPattern := "%" + search + "%"
|
searchPattern := "%" + search + "%"
|
||||||
args = append(args, searchPattern, searchPattern)
|
args = append(args, searchPattern, searchPattern)
|
||||||
}
|
}
|
||||||
query += ` ORDER BY id DESC LIMIT ? OFFSET ?`
|
query += ` ORDER BY m.id DESC LIMIT ? OFFSET ?`
|
||||||
args = append(args, limit, offset)
|
args = append(args, limit, offset)
|
||||||
|
|
||||||
rows, err := database.DB.Query(query, args...)
|
rows, err := database.DB.Query(query, args...)
|
||||||
|
|
@ -255,8 +276,9 @@ func (r *MaterialRepo) List(organizationID int64, search, status string, limit,
|
||||||
var m model.Material
|
var m model.Material
|
||||||
var materialImage sql.NullString
|
var materialImage sql.NullString
|
||||||
var manufacturerModel sql.NullString
|
var manufacturerModel sql.NullString
|
||||||
|
var supplierID, manufacturerID sql.NullInt64
|
||||||
err := rows.Scan(&m.ID, &m.OrganizationID, &m.MaterialCode, &m.Name, &materialImage, &m.Spec,
|
err := rows.Scan(&m.ID, &m.OrganizationID, &m.MaterialCode, &m.Name, &materialImage, &m.Spec,
|
||||||
&manufacturerModel, &m.CategoryID, &m.BaseUnitID,
|
&manufacturerModel, &supplierID, &manufacturerID, &m.SupplierName, &m.ManufacturerName, &m.CategoryID, &m.BaseUnitID,
|
||||||
&m.Unit, &m.Status, &m.Version, &m.CreatedAt, &m.UpdatedAt)
|
&m.Unit, &m.Status, &m.Version, &m.CreatedAt, &m.UpdatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
|
|
@ -267,11 +289,112 @@ func (r *MaterialRepo) List(organizationID int64, search, status string, limit,
|
||||||
if manufacturerModel.Valid {
|
if manufacturerModel.Valid {
|
||||||
m.ManufacturerModel = &manufacturerModel.String
|
m.ManufacturerModel = &manufacturerModel.String
|
||||||
}
|
}
|
||||||
|
if supplierID.Valid {
|
||||||
|
m.SupplierID = &supplierID.Int64
|
||||||
|
}
|
||||||
|
if manufacturerID.Valid {
|
||||||
|
m.ManufacturerID = &manufacturerID.Int64
|
||||||
|
}
|
||||||
materials = append(materials, m)
|
materials = append(materials, m)
|
||||||
}
|
}
|
||||||
return materials, total, nil
|
return materials, total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FindExactDuplicates 返回名称、规格、单位及供应商/制造商全部相同的本公司物料。
|
||||||
|
func (r *MaterialRepo) FindExactDuplicates(organizationID int64, request *model.MaterialDuplicateCheckRequest) ([]model.Material, error) {
|
||||||
|
query := `SELECT id FROM materials
|
||||||
|
WHERE organization_id = ? AND name = ?
|
||||||
|
AND COALESCE(spec, '') = ? AND COALESCE(unit, '') = ?
|
||||||
|
AND COALESCE(supplier_id, 0) = COALESCE(?, 0)
|
||||||
|
AND COALESCE(manufacturer_id, 0) = COALESCE(?, 0)`
|
||||||
|
args := []interface{}{organizationID, strings.TrimSpace(request.Name), request.Spec, request.Unit,
|
||||||
|
request.SupplierID, request.ManufacturerID}
|
||||||
|
if request.ExcludeID > 0 {
|
||||||
|
query += ` AND id <> ?`
|
||||||
|
args = append(args, request.ExcludeID)
|
||||||
|
}
|
||||||
|
query += ` ORDER BY id DESC`
|
||||||
|
rows, err := database.DB.Query(query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ids := make([]int64, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var id int64
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items := make([]model.Material, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
var item model.Material
|
||||||
|
if err := r.GetByID(organizationID, id, &item); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListByNames loads only duplicate-check dimensions in bounded queries for an import batch.
|
||||||
|
func (r *MaterialRepo) ListByNames(organizationID int64, names []string) ([]model.Material, error) {
|
||||||
|
const batchSize = 400
|
||||||
|
items := make([]model.Material, 0)
|
||||||
|
for start := 0; start < len(names); start += batchSize {
|
||||||
|
end := start + batchSize
|
||||||
|
if end > len(names) {
|
||||||
|
end = len(names)
|
||||||
|
}
|
||||||
|
batch := names[start:end]
|
||||||
|
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(batch)), ",")
|
||||||
|
args := make([]interface{}, 0, len(batch)+1)
|
||||||
|
args = append(args, organizationID)
|
||||||
|
for _, name := range batch {
|
||||||
|
args = append(args, strings.TrimSpace(name))
|
||||||
|
}
|
||||||
|
rows, err := database.DB.Query(`SELECT id, material_code, name, COALESCE(spec, ''), COALESCE(unit, ''), base_unit_id, supplier_id, manufacturer_id
|
||||||
|
FROM materials WHERE organization_id = ? AND name IN (`+placeholders+`)`, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var item model.Material
|
||||||
|
var baseUnitID, supplierID, manufacturerID sql.NullInt64
|
||||||
|
if err := rows.Scan(&item.ID, &item.MaterialCode, &item.Name, &item.Spec, &item.Unit, &baseUnitID, &supplierID, &manufacturerID); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if baseUnitID.Valid {
|
||||||
|
item.BaseUnitID = &baseUnitID.Int64
|
||||||
|
}
|
||||||
|
if supplierID.Valid {
|
||||||
|
item.SupplierID = &supplierID.Int64
|
||||||
|
}
|
||||||
|
if manufacturerID.Valid {
|
||||||
|
item.ManufacturerID = &manufacturerID.Int64
|
||||||
|
}
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *MaterialRepo) Update(organizationID, id int64, req *model.UpdateMaterialRequest) error {
|
func (r *MaterialRepo) Update(organizationID, id int64, req *model.UpdateMaterialRequest) error {
|
||||||
var sets []string
|
var sets []string
|
||||||
var args []interface{}
|
var args []interface{}
|
||||||
|
|
@ -304,6 +427,14 @@ func (r *MaterialRepo) Update(organizationID, id int64, req *model.UpdateMateria
|
||||||
sets = append(sets, "base_unit_id = ?")
|
sets = append(sets, "base_unit_id = ?")
|
||||||
args = append(args, req.BaseUnitID)
|
args = append(args, req.BaseUnitID)
|
||||||
}
|
}
|
||||||
|
if req.SupplierID != nil {
|
||||||
|
sets = append(sets, "supplier_id = NULLIF(?, 0)")
|
||||||
|
args = append(args, *req.SupplierID)
|
||||||
|
}
|
||||||
|
if req.ManufacturerID != nil {
|
||||||
|
sets = append(sets, "manufacturer_id = NULLIF(?, 0)")
|
||||||
|
args = append(args, *req.ManufacturerID)
|
||||||
|
}
|
||||||
if req.Status != "" {
|
if req.Status != "" {
|
||||||
sets = append(sets, "status = ?")
|
sets = append(sets, "status = ?")
|
||||||
args = append(args, req.Status)
|
args = append(args, req.Status)
|
||||||
|
|
@ -544,15 +675,15 @@ func nextAvailableMaterialCode(rule *model.CodeRule, start int64, taken map[stri
|
||||||
// buildMaterialInsertBatch 拼一条多值 INSERT,返回 SQL 与按顺序展开的参数。
|
// buildMaterialInsertBatch 拼一条多值 INSERT,返回 SQL 与按顺序展开的参数。
|
||||||
func buildMaterialInsertBatch(batch []*model.Material) (string, []interface{}) {
|
func buildMaterialInsertBatch(batch []*model.Material) (string, []interface{}) {
|
||||||
var builder strings.Builder
|
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 `)
|
builder.WriteString(`INSERT INTO materials (organization_id, material_code, name, material_image, spec, manufacturer_model, supplier_id, manufacturer_id, category_id, base_unit_id, unit, status, version) VALUES `)
|
||||||
args := make([]interface{}, 0, len(batch)*11)
|
args := make([]interface{}, 0, len(batch)*13)
|
||||||
for i, m := range batch {
|
for i, m := range batch {
|
||||||
if i > 0 {
|
if i > 0 {
|
||||||
builder.WriteString(", ")
|
builder.WriteString(", ")
|
||||||
}
|
}
|
||||||
builder.WriteString("(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
|
builder.WriteString("(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
|
||||||
args = append(args, m.OrganizationID, m.MaterialCode, m.Name, m.MaterialImage, m.Spec,
|
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)
|
m.ManufacturerModel, m.SupplierID, m.ManufacturerID, m.CategoryID, m.BaseUnitID, m.Unit, m.Status, m.Version)
|
||||||
}
|
}
|
||||||
return builder.String(), args
|
return builder.String(), args
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import (
|
||||||
|
|
||||||
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 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
const insertMaterialBatchStmt = `INSERT INTO materials (organization_id, material_code, name, material_image, spec, manufacturer_model, supplier_id, manufacturer_id, category_id, base_unit_id, unit, status, version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
|
||||||
func newImportMock(t *testing.T) (sqlmock.Sqlmock, func()) {
|
func newImportMock(t *testing.T) (sqlmock.Sqlmock, func()) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
@ -48,8 +48,8 @@ func TestMaterialRepoImportTxAllocatesCodesAndInsertsOneByOne(t *testing.T) {
|
||||||
// 两行折叠成一条多值 INSERT
|
// 两行折叠成一条多值 INSERT
|
||||||
mock.ExpectExec(regexp.QuoteMeta(insertMaterialBatchStmt)).
|
mock.ExpectExec(regexp.QuoteMeta(insertMaterialBatchStmt)).
|
||||||
WithArgs(
|
WithArgs(
|
||||||
int64(1), "A010202", "螺栓", nil, "", nil, int64(12), nil, "个", "active", 1,
|
int64(1), "A010202", "螺栓", nil, "", nil, nil, nil, int64(12), nil, "个", "active", 1,
|
||||||
int64(1), "A010203", "螺母", nil, "", nil, int64(12), nil, "个", "active", 1,
|
int64(1), "A010203", "螺母", nil, "", nil, nil, nil, int64(12), nil, "个", "active", 1,
|
||||||
).
|
).
|
||||||
WillReturnResult(sqlmock.NewResult(31, 2))
|
WillReturnResult(sqlmock.NewResult(31, 2))
|
||||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT id, material_code FROM materials WHERE organization_id = ? AND material_code IN (?,?)`)).
|
mock.ExpectQuery(regexp.QuoteMeta(`SELECT id, material_code FROM materials WHERE organization_id = ? AND material_code IN (?,?)`)).
|
||||||
|
|
|
||||||
|
|
@ -23,13 +23,19 @@ func TestMaterialRepoGetByIDReturnsOnlyMasterData(t *testing.T) {
|
||||||
})
|
})
|
||||||
database.DB = db
|
database.DB = db
|
||||||
|
|
||||||
query := `SELECT id, organization_id, material_code, name, material_image, spec, manufacturer_model,
|
query := `SELECT m.id, m.organization_id, m.material_code, m.name, m.material_image, m.spec, m.manufacturer_model,
|
||||||
category_id, base_unit_id, unit, status, version, created_at, updated_at
|
m.supplier_id, m.manufacturer_id, COALESCE(sb.legal_name, ''), COALESCE(mb.legal_name, ''),
|
||||||
FROM materials WHERE organization_id = ? AND id = ?`
|
m.category_id, m.base_unit_id, m.unit, m.status, m.version, m.created_at, m.updated_at
|
||||||
|
FROM materials m
|
||||||
|
LEFT JOIN organization_partners sop ON sop.organization_id = m.organization_id AND sop.id = m.supplier_id
|
||||||
|
LEFT JOIN business_partners sb ON sb.id = sop.business_partner_id
|
||||||
|
LEFT JOIN organization_partners mop ON mop.organization_id = m.organization_id AND mop.id = m.manufacturer_id
|
||||||
|
LEFT JOIN business_partners mb ON mb.id = mop.business_partner_id
|
||||||
|
WHERE m.organization_id = ? AND m.id = ?`
|
||||||
now := time.Date(2026, 9, 15, 9, 0, 0, 0, time.UTC)
|
now := time.Date(2026, 9, 15, 9, 0, 0, 0, time.UTC)
|
||||||
mock.ExpectQuery(regexp.QuoteMeta(query)).WithArgs(int64(1), int64(7)).WillReturnRows(
|
mock.ExpectQuery(regexp.QuoteMeta(query)).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"}).
|
sqlmock.NewRows([]string{"id", "organization_id", "material_code", "name", "material_image", "spec", "manufacturer_model", "supplier_id", "manufacturer_id", "supplier_name", "manufacturer_name", "category_id", "base_unit_id", "unit", "status", "version", "created_at", "updated_at"}).
|
||||||
AddRow(7, 1, "MAT-007", "轴承", nil, "6205", nil, 12, 3, "件", "active", 2, now, now),
|
AddRow(7, 1, "MAT-007", "轴承", nil, "6205", nil, nil, nil, "", "", 12, 3, "件", "active", 2, now, now),
|
||||||
)
|
)
|
||||||
|
|
||||||
var material model.Material
|
var material model.Material
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,46 @@ import (
|
||||||
|
|
||||||
type PartnerRepository struct{}
|
type PartnerRepository struct{}
|
||||||
|
|
||||||
|
var ErrMaterialPartnerNotFound = errors.New("material partner not found for role")
|
||||||
|
var ErrMaterialPartnerAmbiguous = errors.New("material partner name is ambiguous")
|
||||||
|
|
||||||
func NewPartnerRepository() *PartnerRepository { return &PartnerRepository{} }
|
func NewPartnerRepository() *PartnerRepository { return &PartnerRepository{} }
|
||||||
|
|
||||||
|
// FindMaterialPartner resolves an organization partner by its exact display name or code
|
||||||
|
// and verifies that it has the requested material role.
|
||||||
|
func (r *PartnerRepository) FindMaterialPartner(organizationID int64, role, value string) (int64, error) {
|
||||||
|
field := "op.is_supplier"
|
||||||
|
if role == "manufacturer" {
|
||||||
|
field = "op.is_manufacturer"
|
||||||
|
}
|
||||||
|
rows, err := database.DB.Query(`SELECT DISTINCT op.id FROM organization_partners op
|
||||||
|
JOIN business_partners bp ON bp.id = op.business_partner_id
|
||||||
|
WHERE op.organization_id = ? AND `+field+` = TRUE AND (bp.legal_name = ? OR op.partner_code = ?) LIMIT 2`,
|
||||||
|
organizationID, value, value)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var ids []int64
|
||||||
|
for rows.Next() {
|
||||||
|
var id int64
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return 0, fmt.Errorf("%w: %s", ErrMaterialPartnerNotFound, value)
|
||||||
|
}
|
||||||
|
if len(ids) > 1 {
|
||||||
|
return 0, fmt.Errorf("%w: %s", ErrMaterialPartnerAmbiguous, value)
|
||||||
|
}
|
||||||
|
return ids[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *PartnerRepository) List(organizationID int64, role string, limit, offset int, category string) ([]model.BusinessPartner, int64, error) {
|
func (r *PartnerRepository) List(organizationID int64, role string, limit, offset int, category string) ([]model.BusinessPartner, int64, error) {
|
||||||
// 角色过滤写成一份,由计数和取数共用:两处各写一遍,改一处漏一处就会让
|
// 角色过滤写成一份,由计数和取数共用:两处各写一遍,改一处漏一处就会让
|
||||||
// 总数和列表对不上(总数算的是全部,列表却只有供应商)。
|
// 总数和列表对不上(总数算的是全部,列表却只有供应商)。
|
||||||
|
|
@ -27,6 +65,9 @@ func (r *PartnerRepository) List(organizationID int64, role string, limit, offse
|
||||||
if role == "customer" {
|
if role == "customer" {
|
||||||
filter += ` AND op.is_customer = TRUE`
|
filter += ` AND op.is_customer = TRUE`
|
||||||
}
|
}
|
||||||
|
if role == "manufacturer" {
|
||||||
|
filter += ` AND op.is_manufacturer = TRUE`
|
||||||
|
}
|
||||||
if category != "" {
|
if category != "" {
|
||||||
// 字段名仅由已知角色选择,筛选值始终使用参数绑定。
|
// 字段名仅由已知角色选择,筛选值始终使用参数绑定。
|
||||||
field := "op.supplier_categories"
|
field := "op.supplier_categories"
|
||||||
|
|
@ -55,7 +96,7 @@ func (r *PartnerRepository) List(organizationID int64, role string, limit, offse
|
||||||
COALESCE(bp.contact_phone, ''), COALESCE(bp.address, ''),
|
COALESCE(bp.contact_phone, ''), COALESCE(bp.address, ''),
|
||||||
op.is_supplier, op.is_customer, COALESCE(op.supplier_status, ''),
|
op.is_supplier, op.is_customer, COALESCE(op.supplier_status, ''),
|
||||||
COALESCE(op.customer_status, ''), COALESCE(op.settlement_currency, ''),
|
COALESCE(op.customer_status, ''), COALESCE(op.settlement_currency, ''),
|
||||||
COALESCE(op.payment_terms, ''), op.supplier_categories, op.customer_categories
|
COALESCE(op.payment_terms, ''), op.supplier_categories, op.customer_categories, op.is_manufacturer
|
||||||
` + filter + `
|
` + filter + `
|
||||||
ORDER BY op.partner_code
|
ORDER BY op.partner_code
|
||||||
LIMIT ? OFFSET ?`
|
LIMIT ? OFFSET ?`
|
||||||
|
|
@ -73,7 +114,7 @@ func (r *PartnerRepository) List(organizationID int64, role string, limit, offse
|
||||||
if err := rows.Scan(&item.ID, &item.OrganizationID, &item.BusinessPartnerID, &item.PartnerCode,
|
if err := rows.Scan(&item.ID, &item.OrganizationID, &item.BusinessPartnerID, &item.PartnerCode,
|
||||||
&item.LegalName, &item.UnifiedCreditCode, &item.ContactName, &item.ContactPhone,
|
&item.LegalName, &item.UnifiedCreditCode, &item.ContactName, &item.ContactPhone,
|
||||||
&item.Address, &item.IsSupplier, &item.IsCustomer, &item.SupplierStatus,
|
&item.Address, &item.IsSupplier, &item.IsCustomer, &item.SupplierStatus,
|
||||||
&item.CustomerStatus, &item.SettlementCurrency, &item.PaymentTerms, &supplierCategories, &customerCategories); err != nil {
|
&item.CustomerStatus, &item.SettlementCurrency, &item.PaymentTerms, &supplierCategories, &customerCategories, &item.IsManufacturer); err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
item.SupplierCategories = splitPartnerCategories(supplierCategories)
|
item.SupplierCategories = splitPartnerCategories(supplierCategories)
|
||||||
|
|
@ -108,11 +149,11 @@ func (r *PartnerRepository) Create(item *model.BusinessPartner) (err error) {
|
||||||
|
|
||||||
result, err = tx.Exec(`INSERT INTO organization_partners
|
result, err = tx.Exec(`INSERT INTO organization_partners
|
||||||
(organization_id, business_partner_id, partner_code, is_supplier, is_customer,
|
(organization_id, business_partner_id, partner_code, is_supplier, is_customer,
|
||||||
supplier_status, customer_status, settlement_currency, payment_terms, supplier_categories, customer_categories)
|
supplier_status, customer_status, settlement_currency, payment_terms, supplier_categories, customer_categories, is_manufacturer)
|
||||||
VALUES (?, ?, ?, ?, ?, NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''), ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''), ?, ?, ?)`,
|
||||||
item.OrganizationID, item.BusinessPartnerID, item.PartnerCode, item.IsSupplier, item.IsCustomer,
|
item.OrganizationID, item.BusinessPartnerID, item.PartnerCode, item.IsSupplier, item.IsCustomer,
|
||||||
item.SupplierStatus, item.CustomerStatus, item.SettlementCurrency, item.PaymentTerms,
|
item.SupplierStatus, item.CustomerStatus, item.SettlementCurrency, item.PaymentTerms,
|
||||||
strings.Join(item.SupplierCategories, ","), strings.Join(item.CustomerCategories, ","))
|
strings.Join(item.SupplierCategories, ","), strings.Join(item.CustomerCategories, ","), item.IsManufacturer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -151,11 +192,11 @@ func (r *PartnerRepository) Update(item *model.BusinessPartner) (err error) {
|
||||||
if _, err = tx.Exec(`UPDATE organization_partners SET is_supplier = ?, is_customer = ?,
|
if _, err = tx.Exec(`UPDATE organization_partners SET is_supplier = ?, is_customer = ?,
|
||||||
supplier_status = NULLIF(?, ''), customer_status = NULLIF(?, ''),
|
supplier_status = NULLIF(?, ''), customer_status = NULLIF(?, ''),
|
||||||
settlement_currency = NULLIF(?, ''), payment_terms = NULLIF(?, ''),
|
settlement_currency = NULLIF(?, ''), payment_terms = NULLIF(?, ''),
|
||||||
supplier_categories = COALESCE(?, supplier_categories), customer_categories = COALESCE(?, customer_categories)
|
supplier_categories = COALESCE(?, supplier_categories), customer_categories = COALESCE(?, customer_categories), is_manufacturer = ?
|
||||||
WHERE id = ? AND organization_id = ?`,
|
WHERE id = ? AND organization_id = ?`,
|
||||||
item.IsSupplier, item.IsCustomer, item.SupplierStatus, item.CustomerStatus,
|
item.IsSupplier, item.IsCustomer, item.SupplierStatus, item.CustomerStatus,
|
||||||
item.SettlementCurrency, item.PaymentTerms, optionalPartnerCategories(item.SupplierCategories),
|
item.SettlementCurrency, item.PaymentTerms, optionalPartnerCategories(item.SupplierCategories),
|
||||||
optionalPartnerCategories(item.CustomerCategories), item.ID, item.OrganizationID); err != nil {
|
optionalPartnerCategories(item.CustomerCategories), item.IsManufacturer, item.ID, item.OrganizationID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err = tx.Commit(); err != nil {
|
if err = tx.Commit(); err != nil {
|
||||||
|
|
|
||||||
44
internal/repository/partner_manufacturer_test.go
Normal file
44
internal/repository/partner_manufacturer_test.go
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/DATA-DOG/go-sqlmock"
|
||||||
|
"github.com/wmms/wmms/pkg/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestManufacturerListIsOrganizationScopedAndReturnsRole(t *testing.T) {
|
||||||
|
db, mock, err := sqlmock.New()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
database.DB = db
|
||||||
|
t.Cleanup(func() { database.DB = nil; db.Close() })
|
||||||
|
mock.ExpectQuery(`SELECT COUNT\(\*\).*WHERE op.organization_id = \? AND op.is_manufacturer = TRUE`).WithArgs(int64(2)).WillReturnRows(sqlmock.NewRows([]string{"total"}).AddRow(1))
|
||||||
|
mock.ExpectQuery(`SELECT op.id.*WHERE op.organization_id = \? AND op.is_manufacturer = TRUE`).WithArgs(int64(2), 20, 0).WillReturnRows(sqlmock.NewRows([]string{
|
||||||
|
"id", "organization_id", "business_partner_id", "partner_code", "legal_name", "unified_credit_code", "contact_name", "contact_phone", "address",
|
||||||
|
"is_supplier", "is_customer", "supplier_status", "customer_status", "settlement_currency", "payment_terms", "supplier_categories", "customer_categories", "is_manufacturer",
|
||||||
|
}).AddRow(3, 2, 4, "MFG-001", "制造企业", "", "", "", "", false, false, "", "", "", "", "", "", true))
|
||||||
|
items, total, err := NewPartnerRepository().List(2, "manufacturer", 20, 0, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if total != 1 || len(items) != 1 {
|
||||||
|
t.Fatalf("unexpected list: %v / %v", items, total)
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(items[0])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var result map[string]interface{}
|
||||||
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result["is_manufacturer"] != true {
|
||||||
|
t.Fatalf("manufacturer flag lost: %s", body)
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -24,6 +24,7 @@ func TestPartnerCreateSeparatesLegalEntityAndOrganizationRelationship(t *testing
|
||||||
PartnerCode: "SUP-001",
|
PartnerCode: "SUP-001",
|
||||||
LegalName: "示例供应商",
|
LegalName: "示例供应商",
|
||||||
IsSupplier: true,
|
IsSupplier: true,
|
||||||
|
IsManufacturer: true,
|
||||||
SupplierStatus: "active",
|
SupplierStatus: "active",
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal([]byte(`{"supplier_categories":["material","finished_product"],"customer_categories":[]}`), partner); err != nil {
|
if err := json.Unmarshal([]byte(`{"supplier_categories":["material","finished_product"],"customer_categories":[]}`), partner); err != nil {
|
||||||
|
|
@ -38,9 +39,9 @@ func TestPartnerCreateSeparatesLegalEntityAndOrganizationRelationship(t *testing
|
||||||
WillReturnResult(sqlmock.NewResult(9, 1))
|
WillReturnResult(sqlmock.NewResult(9, 1))
|
||||||
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO organization_partners
|
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO organization_partners
|
||||||
(organization_id, business_partner_id, partner_code, is_supplier, is_customer,
|
(organization_id, business_partner_id, partner_code, is_supplier, is_customer,
|
||||||
supplier_status, customer_status, settlement_currency, payment_terms, supplier_categories, customer_categories)
|
supplier_status, customer_status, settlement_currency, payment_terms, supplier_categories, customer_categories, is_manufacturer)
|
||||||
VALUES (?, ?, ?, ?, ?, NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''), ?, ?)`)).
|
VALUES (?, ?, ?, ?, ?, NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''), ?, ?, ?)`)).
|
||||||
WithArgs(int64(1), int64(9), "SUP-001", true, false, "active", "", "", "", "material,finished_product", "").
|
WithArgs(int64(1), int64(9), "SUP-001", true, false, "active", "", "", "", "material,finished_product", "", true).
|
||||||
WillReturnResult(sqlmock.NewResult(15, 1))
|
WillReturnResult(sqlmock.NewResult(15, 1))
|
||||||
mock.ExpectCommit()
|
mock.ExpectCommit()
|
||||||
|
|
||||||
|
|
@ -65,9 +66,9 @@ func TestPartnerListReturnsIndependentCategoriesAndEmptyArrays(t *testing.T) {
|
||||||
mock.ExpectQuery("SELECT COUNT").WithArgs(int64(1)).WillReturnRows(sqlmock.NewRows([]string{"total"}).AddRow(2))
|
mock.ExpectQuery("SELECT COUNT").WithArgs(int64(1)).WillReturnRows(sqlmock.NewRows([]string{"total"}).AddRow(2))
|
||||||
mock.ExpectQuery("SELECT op.id").WithArgs(int64(1), 20, 0).WillReturnRows(sqlmock.NewRows([]string{
|
mock.ExpectQuery("SELECT op.id").WithArgs(int64(1), 20, 0).WillReturnRows(sqlmock.NewRows([]string{
|
||||||
"id", "organization_id", "business_partner_id", "partner_code", "legal_name", "unified_credit_code", "contact_name", "contact_phone", "address",
|
"id", "organization_id", "business_partner_id", "partner_code", "legal_name", "unified_credit_code", "contact_name", "contact_phone", "address",
|
||||||
"is_supplier", "is_customer", "supplier_status", "customer_status", "settlement_currency", "payment_terms", "supplier_categories", "customer_categories",
|
"is_supplier", "is_customer", "supplier_status", "customer_status", "settlement_currency", "payment_terms", "supplier_categories", "customer_categories", "is_manufacturer",
|
||||||
}).AddRow(1, 1, 10, "P-001", "公司", "", "", "", "", true, true, "active", "active", "", "", "material,finished_product", "finished_product").
|
}).AddRow(1, 1, 10, "P-001", "公司", "", "", "", "", true, true, "active", "active", "", "", "material,finished_product", "finished_product", false).
|
||||||
AddRow(2, 1, 11, "P-002", "历史公司", "", "", "", "", true, false, "active", "", "", "", "", ""))
|
AddRow(2, 1, 11, "P-002", "历史公司", "", "", "", "", true, false, "active", "", "", "", "", "", false))
|
||||||
items, total, err := NewPartnerRepository().List(1, "supplier", 20, 0, "")
|
items, total, err := NewPartnerRepository().List(1, "supplier", 20, 0, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|
@ -110,7 +111,7 @@ func TestPartnerUpdatePreservesOmittedCategoriesAndAllowsClearing(t *testing.T)
|
||||||
mock.ExpectQuery("SELECT business_partner_id").WithArgs(int64(12), int64(2)).WillReturnRows(sqlmock.NewRows([]string{"business_partner_id"}).AddRow(7))
|
mock.ExpectQuery("SELECT business_partner_id").WithArgs(int64(12), int64(2)).WillReturnRows(sqlmock.NewRows([]string{"business_partner_id"}).AddRow(7))
|
||||||
mock.ExpectExec("UPDATE business_partners").WillReturnResult(sqlmock.NewResult(0, 1))
|
mock.ExpectExec("UPDATE business_partners").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||||
mock.ExpectExec("UPDATE organization_partners SET.*supplier_categories = COALESCE.*customer_categories = COALESCE").
|
mock.ExpectExec("UPDATE organization_partners SET.*supplier_categories = COALESCE.*customer_categories = COALESCE").
|
||||||
WithArgs(true, true, "", "", "", "", tc.supplier, tc.customer, int64(12), int64(2)).WillReturnResult(sqlmock.NewResult(0, 1))
|
WithArgs(true, true, "", "", "", "", tc.supplier, tc.customer, false, int64(12), int64(2)).WillReturnResult(sqlmock.NewResult(0, 1))
|
||||||
mock.ExpectCommit()
|
mock.ExpectCommit()
|
||||||
if err := NewPartnerRepository().Update(&item); err != nil {
|
if err := NewPartnerRepository().Update(&item); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|
|
||||||
|
|
@ -299,8 +299,8 @@ func (s *PartnerService) List(orgID int64, role string, limit, offset int, categ
|
||||||
if orgID <= 0 {
|
if orgID <= 0 {
|
||||||
return nil, 0, ErrInvalidOrganizationID
|
return nil, 0, ErrInvalidOrganizationID
|
||||||
}
|
}
|
||||||
if role != "" && role != "supplier" && role != "customer" {
|
if role != "" && role != "supplier" && role != "manufacturer" && role != "customer" {
|
||||||
return nil, 0, errors.New("role must be supplier or customer")
|
return nil, 0, errors.New("role must be supplier, manufacturer or customer")
|
||||||
}
|
}
|
||||||
return s.repo.List(orgID, role, limit, offset, category)
|
return s.repo.List(orgID, role, limit, offset, category)
|
||||||
}
|
}
|
||||||
|
|
@ -340,12 +340,12 @@ func (s *PartnerService) Update(item *model.BusinessPartner) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// normalizePartner 是新建与更新共用的字段清理、默认值补全和角色校验:
|
// normalizePartner 是新建与更新共用的字段清理、默认值补全和角色校验:
|
||||||
// 至少要有供应商或客户其中一个角色,编辑时也可能把两个都取消掉。
|
// 至少要有供应商、制造商或客户其中一个角色。
|
||||||
// 编码是否必填由两个入口各自判断。
|
// 编码是否必填由两个入口各自判断。
|
||||||
func normalizePartner(item *model.BusinessPartner) error {
|
func normalizePartner(item *model.BusinessPartner) error {
|
||||||
item.PartnerCode = strings.TrimSpace(item.PartnerCode)
|
item.PartnerCode = strings.TrimSpace(item.PartnerCode)
|
||||||
item.LegalName = strings.TrimSpace(item.LegalName)
|
item.LegalName = strings.TrimSpace(item.LegalName)
|
||||||
if !item.IsSupplier && !item.IsCustomer {
|
if !item.IsSupplier && !item.IsManufacturer && !item.IsCustomer {
|
||||||
return errors.New("at least one partner role is required")
|
return errors.New("at least one partner role is required")
|
||||||
}
|
}
|
||||||
for _, categories := range []*[]string{&item.SupplierCategories, &item.CustomerCategories} {
|
for _, categories := range []*[]string{&item.SupplierCategories, &item.CustomerCategories} {
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ func TestInboundCreateFillsOrderDimensionsFromResolvedScope(t *testing.T) {
|
||||||
mock.ExpectExec("UPDATE inbound_orders").WillReturnResult(sqlmock.NewResult(0, 1))
|
mock.ExpectExec("UPDATE inbound_orders").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||||
mock.ExpectExec("INSERT INTO inbound_items").WithArgs(int64(5), int64(7), 2).WillReturnResult(sqlmock.NewResult(6, 1))
|
mock.ExpectExec("INSERT INTO inbound_items").WithArgs(int64(5), int64(7), 2).WillReturnResult(sqlmock.NewResult(6, 1))
|
||||||
// 明细补物料编码/名称时再读一次物料。
|
// 明细补物料编码/名称时再读一次物料。
|
||||||
mock.ExpectQuery("SELECT id, organization_id, material_code, name, material_image").WithArgs(int64(1), int64(7)).WillReturnRows(activeMaterialRow(7))
|
mock.ExpectQuery("SELECT m.id, m.organization_id, m.material_code, m.name, m.material_image").WithArgs(int64(1), int64(7)).WillReturnRows(activeMaterialRow(7))
|
||||||
mock.ExpectExec("INSERT INTO inventory_balances").WithArgs(int64(1), int64(10), int64(20), int64(7), "2").WillReturnResult(sqlmock.NewResult(0, 1))
|
mock.ExpectExec("INSERT INTO inventory_balances").WithArgs(int64(1), int64(10), int64(20), int64(7), "2").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||||
mock.ExpectExec("INSERT INTO stock_logs").WithArgs(int64(1), int64(10), int64(20), int64(7), "in", 2, "inbound", int64(5)).WillReturnResult(sqlmock.NewResult(1, 1))
|
mock.ExpectExec("INSERT INTO stock_logs").WithArgs(int64(1), int64(10), int64(20), int64(7), "in", 2, "inbound", int64(5)).WillReturnResult(sqlmock.NewResult(1, 1))
|
||||||
mock.ExpectCommit()
|
mock.ExpectCommit()
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,9 @@ var ErrInvalidMaterialCategory = errors.New("请选择有效的小类类型")
|
||||||
|
|
||||||
var ErrMaterialNameTooLong = errors.New("物料名称过长,请小于200字")
|
var ErrMaterialNameTooLong = errors.New("物料名称过长,请小于200字")
|
||||||
var ErrMaterialSpecTooLong = errors.New("物料规格过长,请小于200字")
|
var ErrMaterialSpecTooLong = errors.New("物料规格过长,请小于200字")
|
||||||
|
var ErrInvalidMaterialSupplier = errors.New("请选择本公司的有效供应商")
|
||||||
|
var ErrInvalidMaterialManufacturer = errors.New("请选择本公司的有效制造商")
|
||||||
|
var ErrMaterialDuplicate = errors.New("发现名称、规格、单位、供应商和制造商均相同的物料,请先检查重复项")
|
||||||
|
|
||||||
const maxMaterialNameLength = 200
|
const maxMaterialNameLength = 200
|
||||||
const maxMaterialSpecLength = 200
|
const maxMaterialSpecLength = 200
|
||||||
|
|
@ -67,6 +70,36 @@ func (s *MaterialService) validateUnit(unitID *int64, organizationID int64) erro
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *MaterialService) validateMaterialPartner(id *int64, organizationID int64, role string) error {
|
||||||
|
if id == nil || *id == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if *id < 0 {
|
||||||
|
if role == "manufacturer" {
|
||||||
|
return ErrInvalidMaterialManufacturer
|
||||||
|
}
|
||||||
|
return ErrInvalidMaterialSupplier
|
||||||
|
}
|
||||||
|
valid, err := s.repo.IsPartnerForRole(*id, organizationID, role)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !valid {
|
||||||
|
if role == "manufacturer" {
|
||||||
|
return ErrInvalidMaterialManufacturer
|
||||||
|
}
|
||||||
|
return ErrInvalidMaterialSupplier
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func optionalMaterialID(id *int64) *int64 {
|
||||||
|
if id == nil || *id <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
func (s *MaterialService) Create(req *model.CreateMaterialRequest, operator string) (*model.Material, error) {
|
func (s *MaterialService) Create(req *model.CreateMaterialRequest, operator string) (*model.Material, error) {
|
||||||
organizationID := req.OrganizationID
|
organizationID := req.OrganizationID
|
||||||
if organizationID <= 0 {
|
if organizationID <= 0 {
|
||||||
|
|
@ -85,10 +118,28 @@ func (s *MaterialService) Create(req *model.CreateMaterialRequest, operator stri
|
||||||
if err := s.validateUnit(req.BaseUnitID, organizationID); err != nil {
|
if err := s.validateUnit(req.BaseUnitID, organizationID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if err := s.validateMaterialPartner(req.SupplierID, organizationID, "supplier"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.validateMaterialPartner(req.ManufacturerID, organizationID, "manufacturer"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
unit := req.Unit
|
unit := req.Unit
|
||||||
if unit == "" {
|
if unit == "" {
|
||||||
unit = "个"
|
unit = "个"
|
||||||
}
|
}
|
||||||
|
if !req.AllowDuplicate {
|
||||||
|
matches, err := s.repo.FindExactDuplicates(organizationID, &model.MaterialDuplicateCheckRequest{
|
||||||
|
Name: req.Name, Spec: req.Spec, Unit: unit,
|
||||||
|
BaseUnitID: req.BaseUnitID, SupplierID: optionalMaterialID(req.SupplierID), ManufacturerID: optionalMaterialID(req.ManufacturerID),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(matches) > 0 {
|
||||||
|
return nil, ErrMaterialDuplicate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var materialImage *string
|
var materialImage *string
|
||||||
if req.MaterialImage != nil {
|
if req.MaterialImage != nil {
|
||||||
|
|
@ -100,6 +151,8 @@ func (s *MaterialService) Create(req *model.CreateMaterialRequest, operator stri
|
||||||
MaterialCode: req.MaterialCode,
|
MaterialCode: req.MaterialCode,
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
MaterialImage: materialImage,
|
MaterialImage: materialImage,
|
||||||
|
SupplierID: optionalMaterialID(req.SupplierID),
|
||||||
|
ManufacturerID: optionalMaterialID(req.ManufacturerID),
|
||||||
Spec: req.Spec,
|
Spec: req.Spec,
|
||||||
CategoryID: req.CategoryID,
|
CategoryID: req.CategoryID,
|
||||||
BaseUnitID: req.BaseUnitID,
|
BaseUnitID: req.BaseUnitID,
|
||||||
|
|
@ -191,6 +244,16 @@ func (s *MaterialService) List(organizationID int64, search, status string, limi
|
||||||
return s.repo.List(organizationID, search, status, limit, offset)
|
return s.repo.List(organizationID, search, status, limit, offset)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *MaterialService) FindExactDuplicates(organizationID int64, req *model.MaterialDuplicateCheckRequest) ([]model.Material, error) {
|
||||||
|
if err := s.validateMaterialPartner(req.SupplierID, organizationID, "supplier"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.validateMaterialPartner(req.ManufacturerID, organizationID, "manufacturer"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.repo.FindExactDuplicates(organizationID, req)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *MaterialService) Update(organizationID, id int64, req *model.UpdateMaterialRequest, operator string) (*model.Material, error) {
|
func (s *MaterialService) Update(organizationID, id int64, req *model.UpdateMaterialRequest, operator string) (*model.Material, error) {
|
||||||
// 获取修改前的数据;不存在或不属于本公司时直接以 sql.ErrNoRows 结束。
|
// 获取修改前的数据;不存在或不属于本公司时直接以 sql.ErrNoRows 结束。
|
||||||
oldMaterial, err := s.GetByID(organizationID, id)
|
oldMaterial, err := s.GetByID(organizationID, id)
|
||||||
|
|
@ -217,6 +280,16 @@ func (s *MaterialService) Update(organizationID, id int64, req *model.UpdateMate
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if req.SupplierID != nil && (oldMaterial.SupplierID == nil || *req.SupplierID != *oldMaterial.SupplierID) {
|
||||||
|
if err := s.validateMaterialPartner(req.SupplierID, organizationID, "supplier"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if req.ManufacturerID != nil && (oldMaterial.ManufacturerID == nil || *req.ManufacturerID != *oldMaterial.ManufacturerID) {
|
||||||
|
if err := s.validateMaterialPartner(req.ManufacturerID, organizationID, "manufacturer"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
err = s.repo.Update(organizationID, id, req)
|
err = s.repo.Update(organizationID, id, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -17,6 +18,7 @@ type MaterialImportService struct {
|
||||||
masterRepo *repository.MasterDataRepository
|
masterRepo *repository.MasterDataRepository
|
||||||
logRepo *repository.MaterialLogRepo
|
logRepo *repository.MaterialLogRepo
|
||||||
importRepo *repository.MaterialImportLogRepo
|
importRepo *repository.MaterialImportLogRepo
|
||||||
|
partnerRepo *repository.PartnerRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
// materialImportItem 保留导入时匹配到的分类、单位名称,供历史日志展示。
|
// materialImportItem 保留导入时匹配到的分类、单位名称,供历史日志展示。
|
||||||
|
|
@ -24,6 +26,7 @@ type materialImportItem struct {
|
||||||
*model.Material
|
*model.Material
|
||||||
categoryLabel string
|
categoryLabel string
|
||||||
unitLabel string
|
unitLabel string
|
||||||
|
row int
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMaterialImportService() *MaterialImportService {
|
func NewMaterialImportService() *MaterialImportService {
|
||||||
|
|
@ -32,6 +35,7 @@ func NewMaterialImportService() *MaterialImportService {
|
||||||
masterRepo: repository.NewMasterDataRepository(),
|
masterRepo: repository.NewMasterDataRepository(),
|
||||||
logRepo: repository.NewMaterialLogRepo(),
|
logRepo: repository.NewMaterialLogRepo(),
|
||||||
importRepo: repository.NewMaterialImportLogRepo(),
|
importRepo: repository.NewMaterialImportLogRepo(),
|
||||||
|
partnerRepo: repository.NewPartnerRepository(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -133,6 +137,29 @@ func (s *MaterialImportService) validateRows(organizationID int64, rows []model.
|
||||||
|
|
||||||
materials := make([]*materialImportItem, 0, len(rows))
|
materials := make([]*materialImportItem, 0, len(rows))
|
||||||
importErrors := make([]model.MaterialImportError, 0)
|
importErrors := make([]model.MaterialImportError, 0)
|
||||||
|
uniqueNames := make([]string, 0, len(rows))
|
||||||
|
nameSeen := make(map[string]struct{}, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
name := strings.TrimSpace(row.Name)
|
||||||
|
if name != "" {
|
||||||
|
if _, exists := nameSeen[name]; !exists {
|
||||||
|
nameSeen[name] = struct{}{}
|
||||||
|
uniqueNames = append(uniqueNames, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
existingMaterials, err := s.materialRepo.ListByNames(organizationID, uniqueNames)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
existingByKey := make(map[materialDuplicateKey]*model.Material, len(existingMaterials))
|
||||||
|
for i := range existingMaterials {
|
||||||
|
item := &existingMaterials[i]
|
||||||
|
existingByKey[duplicateKey(item.Name, item.Spec, item.Unit, item.SupplierID, item.ManufacturerID)] = item
|
||||||
|
}
|
||||||
|
seenInFile := make(map[materialDuplicateKey]int)
|
||||||
|
partnerIDs := make(map[string]int64)
|
||||||
|
partnerProblems := make(map[string]error)
|
||||||
// 同一小类的规则只查一次:两万行通常只落在少数几个小类上,
|
// 同一小类的规则只查一次:两万行通常只落在少数几个小类上,
|
||||||
// 逐行去查会把一次导入放大成两万次往返。
|
// 逐行去查会把一次导入放大成两万次往返。
|
||||||
ruleAvailable := make(map[int64]bool)
|
ruleAvailable := make(map[int64]bool)
|
||||||
|
|
@ -181,6 +208,44 @@ func (s *MaterialImportService) validateRows(organizationID int64, rows []model.
|
||||||
if unit == nil {
|
if unit == nil {
|
||||||
unit = unitByCode[unitName]
|
unit = unitByCode[unitName]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resolvePartner := func(role, value string) (*int64, string, error) {
|
||||||
|
if value == "" {
|
||||||
|
return nil, "", nil
|
||||||
|
}
|
||||||
|
key := role + "\x00" + value
|
||||||
|
if problem, cached := partnerProblems[key]; cached {
|
||||||
|
return nil, partnerProblemText(problem, role, value), nil
|
||||||
|
}
|
||||||
|
if id, cached := partnerIDs[key]; cached {
|
||||||
|
copyID := id
|
||||||
|
return ©ID, "", nil
|
||||||
|
}
|
||||||
|
id, err := s.partnerRepo.FindMaterialPartner(organizationID, role, value)
|
||||||
|
if errors.Is(err, repository.ErrMaterialPartnerNotFound) || errors.Is(err, repository.ErrMaterialPartnerAmbiguous) {
|
||||||
|
partnerProblems[key] = err
|
||||||
|
return nil, partnerProblemText(err, role, value), nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
partnerIDs[key] = id
|
||||||
|
return &id, "", nil
|
||||||
|
}
|
||||||
|
supplierID, supplierProblem, err := resolvePartner("supplier", row.Supplier)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if supplierProblem != "" {
|
||||||
|
reasons = append(reasons, supplierProblem)
|
||||||
|
}
|
||||||
|
manufacturerID, manufacturerProblem, err := resolvePartner("manufacturer", row.Manufacturer)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if manufacturerProblem != "" {
|
||||||
|
reasons = append(reasons, manufacturerProblem)
|
||||||
|
}
|
||||||
if unit == nil {
|
if unit == nil {
|
||||||
if row.Unit == "" {
|
if row.Unit == "" {
|
||||||
reasons = append(reasons, "本单位缺少默认单位「个」,请在模板中填写单位")
|
reasons = append(reasons, "本单位缺少默认单位「个」,请在模板中填写单位")
|
||||||
|
|
@ -222,21 +287,58 @@ func (s *MaterialImportService) validateRows(organizationID int64, rows []model.
|
||||||
// 模板里填的是名称时也落编码:编码是单位表的唯一键,名称是可变的显示名,
|
// 模板里填的是名称时也落编码:编码是单位表的唯一键,名称是可变的显示名,
|
||||||
// 物料表的 unit 只该有一种写法(与单条新增一致),否则库存页直接显示该
|
// 物料表的 unit 只该有一种写法(与单条新增一致),否则库存页直接显示该
|
||||||
// 字段的地方会出现「千克」「kg」并存。
|
// 字段的地方会出现「千克」「kg」并存。
|
||||||
materials = append(materials, &materialImportItem{Material: &model.Material{
|
material := &model.Material{
|
||||||
OrganizationID: organizationID,
|
OrganizationID: organizationID,
|
||||||
Name: name,
|
Name: name,
|
||||||
Spec: row.Spec,
|
Spec: row.Spec,
|
||||||
|
SupplierID: supplierID,
|
||||||
|
ManufacturerID: manufacturerID,
|
||||||
CategoryID: &categoryID,
|
CategoryID: &categoryID,
|
||||||
BaseUnitID: baseUnitID,
|
BaseUnitID: baseUnitID,
|
||||||
Unit: unit.Code,
|
Unit: unit.Code,
|
||||||
Status: "active",
|
Status: "active",
|
||||||
Version: 1,
|
Version: 1,
|
||||||
}, categoryLabel: major.Name + " / " + minor.Name, unitLabel: unit.Name})
|
}
|
||||||
|
key := duplicateKey(material.Name, material.Spec, material.Unit, material.SupplierID, material.ManufacturerID)
|
||||||
|
if duplicate := existingByKey[key]; duplicate != nil {
|
||||||
|
importErrors = append(importErrors, model.MaterialImportError{Row: row.Row, Data: row.Data(), Reason: fmt.Sprintf("与现有物料「%s」(编码 %s)完全重复", duplicate.Name, duplicate.MaterialCode)})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if duplicateRow := seenInFile[key]; duplicateRow > 0 {
|
||||||
|
importErrors = append(importErrors, model.MaterialImportError{Row: row.Row, Data: row.Data(), Reason: fmt.Sprintf("与本文件第 %d 行物料完全重复", duplicateRow)})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seenInFile[key] = row.Row
|
||||||
|
materials = append(materials, &materialImportItem{Material: material, categoryLabel: major.Name + " / " + minor.Name, unitLabel: unit.Name, row: row.Row})
|
||||||
}
|
}
|
||||||
|
|
||||||
return materials, importErrors, nil
|
return materials, importErrors, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type materialDuplicateKey struct {
|
||||||
|
name, spec, unit string
|
||||||
|
supplierID, manufacturerID int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func duplicateKey(name, spec, unit string, supplierID, manufacturerID *int64) materialDuplicateKey {
|
||||||
|
key := materialDuplicateKey{name: strings.TrimSpace(name), spec: strings.TrimSpace(spec), unit: strings.TrimSpace(unit)}
|
||||||
|
if supplierID != nil {
|
||||||
|
key.supplierID = *supplierID
|
||||||
|
}
|
||||||
|
if manufacturerID != nil {
|
||||||
|
key.manufacturerID = *manufacturerID
|
||||||
|
}
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
func partnerProblemText(err error, role, value string) string {
|
||||||
|
roleLabel := map[string]string{"supplier": "供应商", "manufacturer": "制造商"}[role]
|
||||||
|
if errors.Is(err, repository.ErrMaterialPartnerAmbiguous) {
|
||||||
|
return fmt.Sprintf("%s「%s」对应多条记录,请改用伙伴编码", roleLabel, value)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s「%s」不存在或不具备对应身份", roleLabel, value)
|
||||||
|
}
|
||||||
|
|
||||||
// persist 在单个事务里插入物料并写审计日志,任一步失败整批回滚。
|
// persist 在单个事务里插入物料并写审计日志,任一步失败整批回滚。
|
||||||
func (s *MaterialImportService) persist(organizationID int64, operator string, materials []*materialImportItem) error {
|
func (s *MaterialImportService) persist(organizationID int64, operator string, materials []*materialImportItem) error {
|
||||||
tx, err := repository.BeginTx()
|
tx, err := repository.BeginTx()
|
||||||
|
|
|
||||||
|
|
@ -29,10 +29,10 @@ var (
|
||||||
)
|
)
|
||||||
|
|
||||||
// materialImportHeaders 是生成模板时的列顺序(含选填列)。
|
// materialImportHeaders 是生成模板时的列顺序(含选填列)。
|
||||||
var materialImportHeaders = []string{"物料名称", "规格", "单位", "大类", "小类"}
|
var materialImportHeaders = []string{"物料名称", "规格", "单位", "供应商", "制造商", "大类", "小类"}
|
||||||
|
|
||||||
// materialImportRequiredHeaders 是表头里必须存在的列;缺任何一列都判定为
|
// materialImportRequiredHeaders 是表头里必须存在的列;缺任何一列都判定为
|
||||||
// 模板不合法。规格和单位是选填,用户删掉这两个空列是合理操作,不拦。
|
// 模板不合法。规格、单位、供应商、制造商均为选填,用户删掉空列也不拦。
|
||||||
var materialImportRequiredHeaders = []string{"物料名称", "大类", "小类"}
|
var materialImportRequiredHeaders = []string{"物料名称", "大类", "小类"}
|
||||||
|
|
||||||
// BuildMaterialImportTemplate 生成说明在前、空白数据表在后的导入模板。
|
// BuildMaterialImportTemplate 生成说明在前、空白数据表在后的导入模板。
|
||||||
|
|
@ -51,25 +51,27 @@ func BuildMaterialImportTemplate() ([]byte, error) {
|
||||||
{"物料名称", "必填", "内六角螺栓", "不超过 200 字,不可留空。"},
|
{"物料名称", "必填", "内六角螺栓", "不超过 200 字,不可留空。"},
|
||||||
{"规格", "选填", "M8×30", "不超过 200 字,可留空;含前导零的规格按文本填写。"},
|
{"规格", "选填", "M8×30", "不超过 200 字,可留空;含前导零的规格按文本填写。"},
|
||||||
{"单位", "选填", "个 / kg", "填写当前组织已启用的单位名称或编码;留空默认「个」。"},
|
{"单位", "选填", "个 / kg", "填写当前组织已启用的单位名称或编码;留空默认「个」。"},
|
||||||
|
{"供应商", "选填", "示例供应商或供应商编码", "填写当前组织已有供应商的名称或编码;留空表示不指定。"},
|
||||||
|
{"制造商", "选填", "示例制造商或制造商编码", "填写当前组织已有制造商的名称或编码;留空表示不指定。"},
|
||||||
{"大类", "必填", "机械结构类", "填写当前组织已启用的大类名称、编码或「编码 名称」。"},
|
{"大类", "必填", "机械结构类", "填写当前组织已启用的大类名称、编码或「编码 名称」。"},
|
||||||
{"小类", "必填", "标准件", "填写所选大类下的小类名称、编码或「编码 名称」;小类须配置启用的编码规则。"},
|
{"小类", "必填", "标准件", "填写所选大类下的小类名称、编码或「编码 名称」;小类须配置启用的编码规则。"},
|
||||||
}
|
}
|
||||||
sections := map[int]string{1: "物料批量导入 · 填写说明", 4: "一、字段填写规则", 12: "二、填写与上传步骤", 18: "三、导入注意事项", 25: "四、填写示例(仅供参考)"}
|
sections := map[int]string{1: "物料批量导入 · 填写说明", 4: "一、字段填写规则", 14: "二、填写与上传步骤", 20: "三、导入注意事项", 27: "四、填写示例(仅供参考)"}
|
||||||
notes := map[int]string{
|
notes := map[int]string{
|
||||||
2: "先阅读本页,再切换到第二个工作表「物料数据」填写。",
|
2: "先阅读本页,再切换到第二个工作表「物料数据」填写。",
|
||||||
13: "1. 确认页面当前组织,核对该组织的分类、单位及小类编码规则。",
|
15: "1. 确认页面当前组织,核对该组织的分类、单位、供应商、制造商及小类编码规则。",
|
||||||
14: "2. 在「物料数据」从第 2 行开始填写;第 1 行表头及工作表名称请保留。",
|
16: "2. 在「物料数据」从第 2 行开始填写;第 1 行表头及工作表名称请保留。",
|
||||||
15: "3. 保存为 .xlsx 文件,回到「物料」页面点击「批量导入」并选择文件。",
|
17: "3. 保存为 .xlsx 文件,回到「物料」页面点击「批量导入」并选择文件。",
|
||||||
16: "4. 有错误时查看行号和原因,可导出错误明细;修正原 .xlsx 后重新上传。",
|
18: "4. 有错误时查看行号和原因,可导出错误明细;修正原 .xlsx 后重新上传。",
|
||||||
19: "文件限制:仅支持 .xlsx,大小不超过 1MB;非空数据最多 10000 行,表头和全空行不计数。",
|
21: "文件限制:仅支持 .xlsx,大小不超过 1MB;非空数据最多 10000 行,表头和全空行不计数。",
|
||||||
20: "整批校验:任意一行校验失败,本次所有物料均不导入;请全部修正后重传。",
|
22: "整批校验:任意一行校验失败(包含完全重复物料),本次所有物料均不导入;请全部修正后重传。",
|
||||||
21: "编码与库存:物料编码由系统生成,无需填写;仅新增物料,不增加库存,也不更新已有物料。",
|
23: "重复检查:名称、规格、单位、供应商和制造商全部相同才判为重复;同一文件内也会检查。",
|
||||||
22: "重复导入:同名同规格不会自动去重;成功后重复上传同一文件,会再次新增物料。",
|
24: "编码与库存:物料编码由系统生成,无需填写;仅新增物料,不增加库存,也不更新已有物料。",
|
||||||
23: "默认单位:单位留空时使用「个」;当前组织未启用「个」时,请填写其他有效单位。",
|
25: "默认单位:单位留空时使用「个」;当前组织未启用「个」时,请填写其他有效单位。",
|
||||||
30: "示例只展示写法,请替换为当前组织实际启用的分类与单位;示例不在数据页,不会自动导入。",
|
32: "示例只展示写法,请替换为当前组织实际启用的数据;示例不在数据页,不会自动导入。",
|
||||||
}
|
}
|
||||||
for row, text := range sections {
|
for row, text := range sections {
|
||||||
if err := f.MergeCell(MaterialImportSheetGuide, fmt.Sprintf("A%d", row), fmt.Sprintf("E%d", row)); err != nil {
|
if err := f.MergeCell(MaterialImportSheetGuide, fmt.Sprintf("A%d", row), fmt.Sprintf("G%d", row)); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := f.SetCellValue(MaterialImportSheetGuide, fmt.Sprintf("A%d", row), text); err != nil {
|
if err := f.SetCellValue(MaterialImportSheetGuide, fmt.Sprintf("A%d", row), text); err != nil {
|
||||||
|
|
@ -77,7 +79,7 @@ func BuildMaterialImportTemplate() ([]byte, error) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for row, text := range notes {
|
for row, text := range notes {
|
||||||
if err := f.MergeCell(MaterialImportSheetGuide, fmt.Sprintf("A%d", row), fmt.Sprintf("E%d", row)); err != nil {
|
if err := f.MergeCell(MaterialImportSheetGuide, fmt.Sprintf("A%d", row), fmt.Sprintf("G%d", row)); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := f.SetCellValue(MaterialImportSheetGuide, fmt.Sprintf("A%d", row), text); err != nil {
|
if err := f.SetCellValue(MaterialImportSheetGuide, fmt.Sprintf("A%d", row), text); err != nil {
|
||||||
|
|
@ -92,14 +94,14 @@ func BuildMaterialImportTemplate() ([]byte, error) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := f.MergeCell(MaterialImportSheetGuide, fmt.Sprintf("D%d", i+5), fmt.Sprintf("E%d", i+5)); err != nil {
|
if err := f.MergeCell(MaterialImportSheetGuide, fmt.Sprintf("D%d", i+5), fmt.Sprintf("G%d", i+5)); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sampleRows := [][]string{materialImportHeaders, {"内六角螺栓", "M8×30", "个", "机械结构类", "标准件"}, {"六角螺母", "M8", "个", "机械结构类", "标准件"}}
|
sampleRows := [][]string{materialImportHeaders, {"内六角螺栓", "M8×30", "个", "示例供应商", "示例制造商", "机械结构类", "标准件"}, {"六角螺母", "M8", "个", "", "", "机械结构类", "标准件"}}
|
||||||
for i, row := range sampleRows {
|
for i, row := range sampleRows {
|
||||||
for j, text := range row {
|
for j, text := range row {
|
||||||
cell, _ := excelize.CoordinatesToCellName(j+1, i+26)
|
cell, _ := excelize.CoordinatesToCellName(j+1, i+28)
|
||||||
if err := f.SetCellValue(MaterialImportSheetGuide, cell, text); err != nil {
|
if err := f.SetCellValue(MaterialImportSheetGuide, cell, text); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -140,64 +142,64 @@ func styleMaterialImportTemplate(f *excelize.File) error {
|
||||||
}
|
}
|
||||||
ids[i] = id
|
ids[i] = id
|
||||||
}
|
}
|
||||||
if err := f.SetCellStyle(MaterialImportSheetGuide, "A1", "E30", ids[0]); err != nil {
|
if err := f.SetCellStyle(MaterialImportSheetGuide, "A1", "G32", ids[0]); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := f.SetCellStyle(MaterialImportSheetGuide, "A2", "E2", ids[5]); err != nil {
|
if err := f.SetCellStyle(MaterialImportSheetGuide, "A2", "G2", ids[5]); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := f.SetCellStyle(MaterialImportSheetGuide, "A1", "E1", ids[1]); err != nil {
|
if err := f.SetCellStyle(MaterialImportSheetGuide, "A1", "G1", ids[1]); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for _, row := range []int{4, 12, 18, 25} {
|
for _, row := range []int{4, 14, 20, 27} {
|
||||||
if err := f.SetCellStyle(MaterialImportSheetGuide, fmt.Sprintf("A%d", row), fmt.Sprintf("E%d", row), ids[2]); err != nil {
|
if err := f.SetCellStyle(MaterialImportSheetGuide, fmt.Sprintf("A%d", row), fmt.Sprintf("G%d", row), ids[2]); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, row := range []int{5, 26} {
|
for _, row := range []int{5, 28} {
|
||||||
if err := f.SetCellStyle(MaterialImportSheetGuide, fmt.Sprintf("A%d", row), fmt.Sprintf("E%d", row), ids[3]); err != nil {
|
if err := f.SetCellStyle(MaterialImportSheetGuide, fmt.Sprintf("A%d", row), fmt.Sprintf("G%d", row), ids[3]); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := f.SetCellStyle(MaterialImportSheetGuide, "A6", "A10", ids[6]); err != nil {
|
if err := f.SetCellStyle(MaterialImportSheetGuide, "A6", "A12", ids[6]); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for _, cell := range []string{"B6", "B9", "B10"} {
|
for _, cell := range []string{"B6", "B11", "B12"} {
|
||||||
if err := f.SetCellStyle(MaterialImportSheetGuide, cell, cell, ids[7]); err != nil {
|
if err := f.SetCellStyle(MaterialImportSheetGuide, cell, cell, ids[7]); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for i, width := range []float64{20, 12, 24, 34} {
|
for i, width := range []float64{20, 12, 24, 28, 18, 34, 18} {
|
||||||
col, _ := excelize.ColumnNumberToName(i + 1)
|
col, _ := excelize.ColumnNumberToName(i + 1)
|
||||||
if err := f.SetColWidth(MaterialImportSheetGuide, col, col, width); err != nil {
|
if err := f.SetColWidth(MaterialImportSheetGuide, col, col, width); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for row := 1; row <= 30; row++ {
|
for row := 1; row <= 32; row++ {
|
||||||
height := 30.0
|
height := 30.0
|
||||||
if row == 1 {
|
if row == 1 {
|
||||||
height = 42
|
height = 42
|
||||||
}
|
}
|
||||||
if row >= 6 && row <= 10 {
|
if row >= 6 && row <= 12 {
|
||||||
height = 54
|
height = 54
|
||||||
}
|
}
|
||||||
if row >= 19 && row <= 23 || row == 30 {
|
if row >= 21 && row <= 25 || row == 32 {
|
||||||
height = 42
|
height = 42
|
||||||
}
|
}
|
||||||
if err := f.SetRowHeight(MaterialImportSheetGuide, row, height); err != nil {
|
if err := f.SetRowHeight(MaterialImportSheetGuide, row, height); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := f.SetColStyle(MaterialImportSheetData, "A:E", ids[4]); err != nil {
|
if err := f.SetColStyle(MaterialImportSheetData, "A:G", ids[4]); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := f.SetCellStyle(MaterialImportSheetData, "A1", "E1", ids[3]); err != nil {
|
if err := f.SetCellStyle(MaterialImportSheetData, "A1", "G1", ids[3]); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := f.SetRowHeight(MaterialImportSheetData, 1, 32); err != nil {
|
if err := f.SetRowHeight(MaterialImportSheetData, 1, 32); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for i, width := range []float64{34, 28, 16, 26} {
|
for i, width := range []float64{34, 28, 16, 28, 28, 26, 18} {
|
||||||
col, _ := excelize.ColumnNumberToName(i + 1)
|
col, _ := excelize.ColumnNumberToName(i + 1)
|
||||||
if err := f.SetColWidth(MaterialImportSheetData, col, col, width); err != nil {
|
if err := f.SetColWidth(MaterialImportSheetData, col, col, width); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -257,14 +259,16 @@ func ParseMaterialImportFile(r io.Reader) ([]model.MaterialImportRow, error) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
row := model.MaterialImportRow{
|
row := model.MaterialImportRow{
|
||||||
Row: rowNumber, // 包含被跳过的空行,保留真实 Excel 行号。
|
Row: rowNumber, // 包含被跳过的空行,保留真实 Excel 行号。
|
||||||
Name: materialImportColumnValue(raw, columns, "物料名称"),
|
Name: materialImportColumnValue(raw, columns, "物料名称"),
|
||||||
Spec: materialImportColumnValue(raw, columns, "规格"),
|
Spec: materialImportColumnValue(raw, columns, "规格"),
|
||||||
Unit: materialImportColumnValue(raw, columns, "单位"),
|
Unit: materialImportColumnValue(raw, columns, "单位"),
|
||||||
MajorName: materialImportColumnValue(raw, columns, "大类"),
|
Supplier: materialImportColumnValue(raw, columns, "供应商"),
|
||||||
MinorName: materialImportColumnValue(raw, columns, "小类"),
|
Manufacturer: materialImportColumnValue(raw, columns, "制造商"),
|
||||||
|
MajorName: materialImportColumnValue(raw, columns, "大类"),
|
||||||
|
MinorName: materialImportColumnValue(raw, columns, "小类"),
|
||||||
}
|
}
|
||||||
if row.Name == "" && row.Spec == "" && row.Unit == "" && row.MajorName == "" && row.MinorName == "" {
|
if row.Name == "" && row.Spec == "" && row.Unit == "" && row.Supplier == "" && row.Manufacturer == "" && row.MajorName == "" && row.MinorName == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if len(rows) == MaterialImportMaxRows {
|
if len(rows) == MaterialImportMaxRows {
|
||||||
|
|
|
||||||
|
|
@ -234,7 +234,7 @@ func TestBuildMaterialImportTemplateRoundTrips(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read rows: %v", err)
|
t.Fatalf("read rows: %v", err)
|
||||||
}
|
}
|
||||||
if len(headers) == 0 || headers[0][0] != "物料名称" || headers[0][4] != "小类" {
|
if len(headers) == 0 || headers[0][0] != "物料名称" || headers[0][6] != "小类" {
|
||||||
t.Fatalf("表头 = %v", headers)
|
t.Fatalf("表头 = %v", headers)
|
||||||
}
|
}
|
||||||
// 模板必须能被自己的解析器接受(表头一致)
|
// 模板必须能被自己的解析器接受(表头一致)
|
||||||
|
|
@ -254,6 +254,14 @@ func buildTestWorkbook(t *testing.T, dataRows [][]string) []byte {
|
||||||
// buildTestWorkbookWithHeaders 允许指定表头,用来覆盖缺列、乱序、多余列的解析分支。
|
// buildTestWorkbookWithHeaders 允许指定表头,用来覆盖缺列、乱序、多余列的解析分支。
|
||||||
func buildTestWorkbookWithHeaders(t *testing.T, headers []string, dataRows [][]string) []byte {
|
func buildTestWorkbookWithHeaders(t *testing.T, headers []string, dataRows [][]string) []byte {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
// 旧的五列测试数据为「名称、规格、单位、大类、小类」;补上新增的两个选填列。
|
||||||
|
if strings.Join(headers, "\x00") == strings.Join(materialImportHeaders, "\x00") {
|
||||||
|
for i, row := range dataRows {
|
||||||
|
if len(row) == 5 {
|
||||||
|
dataRows[i] = []string{row[0], row[1], row[2], "", "", row[3], row[4]}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
f := excelize.NewFile()
|
f := excelize.NewFile()
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
f.SetSheetName("Sheet1", MaterialImportSheetData)
|
f.SetSheetName("Sheet1", MaterialImportSheetData)
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ import (
|
||||||
const importCategoryQuery = `SELECT id, organization_id, code, name, description, parent_id, status FROM material_categories WHERE organization_id = ? ORDER BY code`
|
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 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'`
|
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'`
|
||||||
|
const importExistingMaterialsQuery = `SELECT id, material_code, name, COALESCE(spec, ''), COALESCE(unit, ''), base_unit_id, supplier_id, manufacturer_id
|
||||||
|
FROM materials WHERE organization_id = ? AND name IN (?)`
|
||||||
|
|
||||||
func newImportServiceMock(t *testing.T) (sqlmock.Sqlmock, func()) {
|
func newImportServiceMock(t *testing.T) (sqlmock.Sqlmock, func()) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
@ -40,6 +42,8 @@ func expectImportMasterData(mock sqlmock.Sqlmock, withRule bool) {
|
||||||
mock.ExpectQuery(regexp.QuoteMeta(importUnitQuery)).WithArgs(int64(1)).
|
mock.ExpectQuery(regexp.QuoteMeta(importUnitQuery)).WithArgs(int64(1)).
|
||||||
WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "code", "name", "precision_scale", "status"}).
|
WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "code", "name", "precision_scale", "status"}).
|
||||||
AddRow(1, 1, "个", "个", 0, "active"))
|
AddRow(1, 1, "个", "个", 0, "active"))
|
||||||
|
mock.ExpectQuery(regexp.QuoteMeta(importExistingMaterialsQuery)).WithArgs(int64(1), sqlmock.AnyArg()).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"id", "material_code", "name", "spec", "unit", "base_unit_id", "supplier_id", "manufacturer_id"}))
|
||||||
if withRule {
|
if withRule {
|
||||||
mock.ExpectQuery(regexp.QuoteMeta(importRuleQuery)).WithArgs(int64(1), int64(12)).
|
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"}).
|
WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "category_id", "prefix", "sequence_length", "next_sequence", "status"}).
|
||||||
|
|
@ -320,6 +324,8 @@ func TestMaterialImportServiceRejectsDisabledCategoryAliases(t *testing.T) {
|
||||||
mock.ExpectQuery(regexp.QuoteMeta(importUnitQuery)).WithArgs(int64(1)).
|
mock.ExpectQuery(regexp.QuoteMeta(importUnitQuery)).WithArgs(int64(1)).
|
||||||
WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "code", "name", "precision_scale", "status"}).
|
WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "code", "name", "precision_scale", "status"}).
|
||||||
AddRow(1, 1, "个", "个", 0, "active"))
|
AddRow(1, 1, "个", "个", 0, "active"))
|
||||||
|
mock.ExpectQuery(regexp.QuoteMeta(importExistingMaterialsQuery)).WithArgs(int64(1), "内六角螺栓").
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"id", "material_code", "name", "spec", "unit", "base_unit_id", "supplier_id", "manufacturer_id"}))
|
||||||
// 大类查不到,小类不再定位,也就不会去查编码规则
|
// 大类查不到,小类不再定位,也就不会去查编码规则
|
||||||
|
|
||||||
defer expectNoImportLog(t, mock)()
|
defer expectNoImportLog(t, mock)()
|
||||||
|
|
@ -392,6 +398,9 @@ func TestMaterialImportServiceStoresUnitCodeWhenTemplateUsesUnitName(t *testing.
|
||||||
mock.ExpectQuery(regexp.QuoteMeta(importUnitQuery)).WithArgs(int64(1)).
|
mock.ExpectQuery(regexp.QuoteMeta(importUnitQuery)).WithArgs(int64(1)).
|
||||||
WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "code", "name", "precision_scale", "status"}).
|
WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "code", "name", "precision_scale", "status"}).
|
||||||
AddRow(7, 1, "kg", "千克", 3, "active"))
|
AddRow(7, 1, "kg", "千克", 3, "active"))
|
||||||
|
mock.ExpectQuery(`SELECT id, material_code, name, COALESCE\(spec, ''\), COALESCE\(unit, ''\), base_unit_id, supplier_id, manufacturer_id\s+FROM materials WHERE organization_id = \? AND name IN \(\?,\?\)`).
|
||||||
|
WithArgs(int64(1), "内六角螺栓", "垫圈").
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"id", "material_code", "name", "spec", "unit", "base_unit_id", "supplier_id", "manufacturer_id"}))
|
||||||
mock.ExpectQuery(regexp.QuoteMeta(importRuleQuery)).WithArgs(int64(1), int64(12)).
|
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"}).
|
WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "category_id", "prefix", "sequence_length", "next_sequence", "status"}).
|
||||||
AddRow(5, 1, 12, "A01", 4, 201, "active"))
|
AddRow(5, 1, 12, "A01", 4, 201, "active"))
|
||||||
|
|
|
||||||
|
|
@ -59,8 +59,8 @@ func TestMaterialServiceTreatsObsoleteMaterialAsInactive(t *testing.T) {
|
||||||
query := getMaterialQuery
|
query := getMaterialQuery
|
||||||
now := time.Date(2026, 9, 20, 9, 0, 0, 0, time.UTC)
|
now := time.Date(2026, 9, 20, 9, 0, 0, 0, time.UTC)
|
||||||
mock.ExpectQuery(regexp.QuoteMeta(query)).WithArgs(int64(1), int64(7)).WillReturnRows(
|
mock.ExpectQuery(regexp.QuoteMeta(query)).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"}).
|
sqlmock.NewRows([]string{"id", "organization_id", "material_code", "name", "material_image", "spec", "manufacturer_model", "supplier_id", "manufacturer_id", "supplier_name", "manufacturer_name", "category_id", "base_unit_id", "unit", "status", "version", "created_at", "updated_at"}).
|
||||||
AddRow(7, 1, "MAT-007", "轴承", nil, "6205", nil, 12, 3, "件", "obsolete", 1, now, now),
|
AddRow(7, 1, "MAT-007", "轴承", nil, "6205", nil, nil, nil, "", "", 12, 3, "件", "obsolete", 1, now, now),
|
||||||
)
|
)
|
||||||
|
|
||||||
active, err := NewMaterialService().IsActive(1, 7)
|
active, err := NewMaterialService().IsActive(1, 7)
|
||||||
|
|
@ -80,8 +80,8 @@ func TestMaterialServiceUpdatesWithoutChangingDisabledCategory(t *testing.T) {
|
||||||
t.Cleanup(cleanup)
|
t.Cleanup(cleanup)
|
||||||
now := time.Date(2026, 9, 20, 9, 0, 0, 0, time.UTC)
|
now := time.Date(2026, 9, 20, 9, 0, 0, 0, time.UTC)
|
||||||
materialRows := func(name string) *sqlmock.Rows {
|
materialRows := func(name string) *sqlmock.Rows {
|
||||||
return 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"}).
|
return sqlmock.NewRows([]string{"id", "organization_id", "material_code", "name", "material_image", "spec", "manufacturer_model", "supplier_id", "manufacturer_id", "supplier_name", "manufacturer_name", "category_id", "base_unit_id", "unit", "status", "version", "created_at", "updated_at"}).
|
||||||
AddRow(7, 1, "MAT-007", name, nil, "", nil, 12, nil, "pcs", "active", 1, now, now)
|
AddRow(7, 1, "MAT-007", name, nil, "", nil, nil, nil, "", "", 12, nil, "pcs", "active", 1, now, now)
|
||||||
}
|
}
|
||||||
mock.ExpectQuery(regexp.QuoteMeta(getMaterialQuery)).WithArgs(int64(1), int64(7)).WillReturnRows(materialRows("原名称"))
|
mock.ExpectQuery(regexp.QuoteMeta(getMaterialQuery)).WithArgs(int64(1), int64(7)).WillReturnRows(materialRows("原名称"))
|
||||||
mock.ExpectExec(regexp.QuoteMeta("UPDATE materials SET name = ?, category_id = ?, version = version + 1 WHERE organization_id = ? AND id = ?")).
|
mock.ExpectExec(regexp.QuoteMeta("UPDATE materials SET name = ?, category_id = ?, version = version + 1 WHERE organization_id = ? AND id = ?")).
|
||||||
|
|
@ -112,12 +112,18 @@ const (
|
||||||
)`
|
)`
|
||||||
lockCodeRuleQuery = `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`
|
lockCodeRuleQuery = `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`
|
||||||
codeTakenQuery = `SELECT EXISTS(SELECT 1 FROM materials WHERE organization_id = ? AND material_code = ?)`
|
codeTakenQuery = `SELECT EXISTS(SELECT 1 FROM materials WHERE organization_id = ? AND material_code = ?)`
|
||||||
insertMaterialStmt = `INSERT INTO materials (organization_id, material_code, name, material_image, spec, manufacturer_model, category_id, base_unit_id, unit, status, version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
insertMaterialStmt = `INSERT INTO materials (organization_id, material_code, name, material_image, spec, manufacturer_model, supplier_id, manufacturer_id, category_id, base_unit_id, unit, status, version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
advanceSequenceStmt = `UPDATE code_rules SET next_sequence = ? WHERE id = ?`
|
advanceSequenceStmt = `UPDATE code_rules SET next_sequence = ? WHERE id = ?`
|
||||||
materialLogStmt = `INSERT INTO material_logs (operation_type, material_id, old_value, new_value, operator, description) VALUES (?, ?, ?, ?, ?, ?)`
|
materialLogStmt = `INSERT INTO material_logs (operation_type, material_id, old_value, new_value, operator, description) VALUES (?, ?, ?, ?, ?, ?)`
|
||||||
getMaterialQuery = `SELECT id, organization_id, material_code, name, material_image, spec, manufacturer_model,
|
getMaterialQuery = `SELECT m.id, m.organization_id, m.material_code, m.name, m.material_image, m.spec, m.manufacturer_model,
|
||||||
category_id, base_unit_id, unit, status, version, created_at, updated_at
|
m.supplier_id, m.manufacturer_id, COALESCE(sb.legal_name, ''), COALESCE(mb.legal_name, ''),
|
||||||
FROM materials WHERE organization_id = ? AND id = ?`
|
m.category_id, m.base_unit_id, m.unit, m.status, m.version, m.created_at, m.updated_at
|
||||||
|
FROM materials m
|
||||||
|
LEFT JOIN organization_partners sop ON sop.organization_id = m.organization_id AND sop.id = m.supplier_id
|
||||||
|
LEFT JOIN business_partners sb ON sb.id = sop.business_partner_id
|
||||||
|
LEFT JOIN organization_partners mop ON mop.organization_id = m.organization_id AND mop.id = m.manufacturer_id
|
||||||
|
LEFT JOIN business_partners mb ON mb.id = mop.business_partner_id
|
||||||
|
WHERE m.organization_id = ? AND m.id = ?`
|
||||||
)
|
)
|
||||||
|
|
||||||
func newMaterialServiceMock(t *testing.T) (sqlmock.Sqlmock, func()) {
|
func newMaterialServiceMock(t *testing.T) (sqlmock.Sqlmock, func()) {
|
||||||
|
|
@ -156,15 +162,15 @@ func expectCodeTaken(mock sqlmock.Sqlmock, code string, taken bool) {
|
||||||
|
|
||||||
func expectMaterialInsert(mock sqlmock.Sqlmock, code string) {
|
func expectMaterialInsert(mock sqlmock.Sqlmock, code string) {
|
||||||
mock.ExpectExec(regexp.QuoteMeta(insertMaterialStmt)).
|
mock.ExpectExec(regexp.QuoteMeta(insertMaterialStmt)).
|
||||||
WithArgs(int64(1), code, "轴承", nil, "", nil, int64(12), nil, "个", "active", 1).
|
WithArgs(int64(1), code, "轴承", nil, "", nil, nil, nil, int64(12), nil, "个", "active", 1).
|
||||||
WillReturnResult(sqlmock.NewResult(7, 1))
|
WillReturnResult(sqlmock.NewResult(7, 1))
|
||||||
}
|
}
|
||||||
|
|
||||||
func expectCreatedMaterial(mock sqlmock.Sqlmock, code string) {
|
func expectCreatedMaterial(mock sqlmock.Sqlmock, code string) {
|
||||||
now := time.Date(2026, 9, 20, 9, 0, 0, 0, time.UTC)
|
now := time.Date(2026, 9, 20, 9, 0, 0, 0, time.UTC)
|
||||||
mock.ExpectQuery(regexp.QuoteMeta(getMaterialQuery)).WithArgs(int64(1), int64(7)).WillReturnRows(
|
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"}).
|
sqlmock.NewRows([]string{"id", "organization_id", "material_code", "name", "material_image", "spec", "manufacturer_model", "supplier_id", "manufacturer_id", "supplier_name", "manufacturer_name", "category_id", "base_unit_id", "unit", "status", "version", "created_at", "updated_at"}).
|
||||||
AddRow(7, 1, code, "轴承", nil, "", nil, 12, nil, "个", "active", 1, now, now),
|
AddRow(7, 1, code, "轴承", nil, "", nil, nil, nil, "", "", 12, nil, "个", "active", 1, now, now),
|
||||||
)
|
)
|
||||||
mock.ExpectExec(regexp.QuoteMeta(materialLogStmt)).
|
mock.ExpectExec(regexp.QuoteMeta(materialLogStmt)).
|
||||||
WithArgs("CREATE", int64(7), "", sqlmock.AnyArg(), "tester", sqlmock.AnyArg()).
|
WithArgs("CREATE", int64(7), "", sqlmock.AnyArg(), "tester", sqlmock.AnyArg()).
|
||||||
|
|
@ -179,6 +185,7 @@ func createMaterialWithCode(t *testing.T, code string) (*model.Material, error)
|
||||||
MaterialCode: code,
|
MaterialCode: code,
|
||||||
Name: "轴承",
|
Name: "轴承",
|
||||||
CategoryID: &categoryID,
|
CategoryID: &categoryID,
|
||||||
|
AllowDuplicate: true,
|
||||||
}, "tester")
|
}, "tester")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -289,7 +296,7 @@ func TestMaterialServiceReportsDuplicateManuallyEditedCode(t *testing.T) {
|
||||||
expectActiveSubcategory(mock)
|
expectActiveSubcategory(mock)
|
||||||
expectLockedCodeRule(mock, &next)
|
expectLockedCodeRule(mock, &next)
|
||||||
mock.ExpectExec(regexp.QuoteMeta(insertMaterialStmt)).
|
mock.ExpectExec(regexp.QuoteMeta(insertMaterialStmt)).
|
||||||
WithArgs(int64(1), "CUSTOM-001", "轴承", nil, "", nil, int64(12), nil, "个", "active", 1).
|
WithArgs(int64(1), "CUSTOM-001", "轴承", nil, "", nil, nil, nil, int64(12), nil, "个", "active", 1).
|
||||||
WillReturnError(errors.New("Error 1062: Duplicate entry 'CUSTOM-001' for key 'uk_materials_org_code'"))
|
WillReturnError(errors.New("Error 1062: Duplicate entry 'CUSTOM-001' for key 'uk_materials_org_code'"))
|
||||||
mock.ExpectRollback()
|
mock.ExpectRollback()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,13 +26,13 @@ func outboundMock(t *testing.T) sqlmock.Sqlmock {
|
||||||
|
|
||||||
// activeMaterialRow 造一行启用物料,供物料归属/存在性校验与明细补名使用。
|
// activeMaterialRow 造一行启用物料,供物料归属/存在性校验与明细补名使用。
|
||||||
func activeMaterialRow(id int64) *sqlmock.Rows {
|
func activeMaterialRow(id int64) *sqlmock.Rows {
|
||||||
return 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"}).
|
return sqlmock.NewRows([]string{"id", "organization_id", "material_code", "name", "material_image", "spec", "manufacturer_model", "supplier_id", "manufacturer_id", "supplier_name", "manufacturer_name", "category_id", "base_unit_id", "unit", "status", "version", "created_at", "updated_at"}).
|
||||||
AddRow(id, 1, "M-1", "Material", nil, "", nil, 1, 1, "pc", "active", 1, time.Now(), time.Now())
|
AddRow(id, 1, "M-1", "Material", nil, "", nil, nil, nil, "", "", 1, 1, "pc", "active", 1, time.Now(), time.Now())
|
||||||
}
|
}
|
||||||
|
|
||||||
func expectActiveMaterial(mock sqlmock.Sqlmock, id int64) {
|
func expectActiveMaterial(mock sqlmock.Sqlmock, id int64) {
|
||||||
for i := 0; i < 2; i++ {
|
for i := 0; i < 2; i++ {
|
||||||
mock.ExpectQuery("SELECT id, organization_id, material_code, name, material_image").WithArgs(int64(1), id).
|
mock.ExpectQuery("SELECT m.id, m.organization_id, m.material_code, m.name, m.material_image").WithArgs(int64(1), id).
|
||||||
WillReturnRows(activeMaterialRow(id))
|
WillReturnRows(activeMaterialRow(id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -96,7 +96,7 @@ func TestOutboundCreateRollsBackWhenMaterialReadFailsAfterOrderInsert(t *testing
|
||||||
mock.ExpectExec("INSERT INTO outbound_orders").WillReturnResult(sqlmock.NewResult(11, 1))
|
mock.ExpectExec("INSERT INTO outbound_orders").WillReturnResult(sqlmock.NewResult(11, 1))
|
||||||
mock.ExpectExec("UPDATE outbound_orders").WillReturnResult(sqlmock.NewResult(0, 1))
|
mock.ExpectExec("UPDATE outbound_orders").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||||
mock.ExpectExec("INSERT INTO outbound_items").WillReturnResult(sqlmock.NewResult(12, 1))
|
mock.ExpectExec("INSERT INTO outbound_items").WillReturnResult(sqlmock.NewResult(12, 1))
|
||||||
mock.ExpectQuery("SELECT id, organization_id, material_code, name, material_image").WithArgs(int64(1), int64(7)).WillReturnError(errors.New("material read failed"))
|
mock.ExpectQuery("SELECT m.id, m.organization_id, m.material_code, m.name, m.material_image").WithArgs(int64(1), int64(7)).WillReturnError(errors.New("material read failed"))
|
||||||
mock.ExpectRollback()
|
mock.ExpectRollback()
|
||||||
_, err := NewOutboundService().Create(1, createOutboundRequest(), "operator")
|
_, err := NewOutboundService().Create(1, createOutboundRequest(), "operator")
|
||||||
if err == nil || err.Error() != "material read failed" {
|
if err == nil || err.Error() != "material read failed" {
|
||||||
|
|
@ -183,7 +183,7 @@ func TestOutboundCreateFillsOrderDimensionsFromResolvedScope(t *testing.T) {
|
||||||
WillReturnResult(sqlmock.NewResult(11, 1))
|
WillReturnResult(sqlmock.NewResult(11, 1))
|
||||||
mock.ExpectExec("UPDATE outbound_orders").WillReturnResult(sqlmock.NewResult(0, 1))
|
mock.ExpectExec("UPDATE outbound_orders").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||||
mock.ExpectExec("INSERT INTO outbound_items").WithArgs(int64(11), int64(7), 2).WillReturnResult(sqlmock.NewResult(12, 1))
|
mock.ExpectExec("INSERT INTO outbound_items").WithArgs(int64(11), int64(7), 2).WillReturnResult(sqlmock.NewResult(12, 1))
|
||||||
mock.ExpectQuery("SELECT id, organization_id, material_code, name, material_image").WithArgs(int64(1), int64(7)).WillReturnRows(activeMaterialRow(7))
|
mock.ExpectQuery("SELECT m.id, m.organization_id, m.material_code, m.name, m.material_image").WithArgs(int64(1), int64(7)).WillReturnRows(activeMaterialRow(7))
|
||||||
mock.ExpectExec("INSERT INTO stock_logs").WithArgs(int64(1), int64(10), int64(20), int64(7), "out", 2, "outbound", int64(11)).WillReturnResult(sqlmock.NewResult(1, 1))
|
mock.ExpectExec("INSERT INTO stock_logs").WithArgs(int64(1), int64(10), int64(20), int64(7), "out", 2, "outbound", int64(11)).WillReturnResult(sqlmock.NewResult(1, 1))
|
||||||
mock.ExpectCommit()
|
mock.ExpectCommit()
|
||||||
|
|
||||||
|
|
|
||||||
41
internal/service/partner_manufacturer_test.go
Normal file
41
internal/service/partner_manufacturer_test.go
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/DATA-DOG/go-sqlmock"
|
||||||
|
"github.com/wmms/wmms/internal/model"
|
||||||
|
"github.com/wmms/wmms/pkg/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestManufacturerCanBeTheOnlyPartnerRole(t *testing.T) {
|
||||||
|
var item model.BusinessPartner
|
||||||
|
if err := json.Unmarshal([]byte(`{"is_manufacturer":true,"legal_name":"制造企业","partner_code":"MFG-001"}`), &item); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := normalizePartner(&item); err != nil {
|
||||||
|
t.Fatalf("manufacturer role rejected: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManufacturerServiceListAcceptsRole(t *testing.T) {
|
||||||
|
db, mock, err := sqlmock.New()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
database.DB = db
|
||||||
|
t.Cleanup(func() { database.DB = nil; db.Close() })
|
||||||
|
mock.ExpectQuery(`SELECT COUNT\(\*\).*op.is_manufacturer = TRUE`).WithArgs(int64(2)).WillReturnRows(sqlmock.NewRows([]string{"total"}).AddRow(0))
|
||||||
|
mock.ExpectQuery(`SELECT op.id.*op.is_manufacturer = TRUE`).WithArgs(int64(2), 20, 0).WillReturnRows(sqlmock.NewRows([]string{"id"}))
|
||||||
|
items, total, err := NewPartnerService().List(2, "manufacturer", 20, 0, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if total != 0 || len(items) != 0 {
|
||||||
|
t.Fatalf("unexpected list: %v / %v", items, total)
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -16,7 +16,7 @@ CREATE TABLE revoked_tokens (jti VARCHAR(64) PRIMARY KEY, user_id BIGINT NOT NUL
|
||||||
CREATE TABLE material_types (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL, code VARCHAR(50) NOT NULL, name VARCHAR(100) NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'active', UNIQUE KEY uk_material_types_org_code(organization_id,code), FOREIGN KEY(organization_id) REFERENCES organizations(id) ON DELETE RESTRICT) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
CREATE TABLE material_types (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL, code VARCHAR(50) NOT NULL, name VARCHAR(100) NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'active', UNIQUE KEY uk_material_types_org_code(organization_id,code), FOREIGN KEY(organization_id) REFERENCES organizations(id) ON DELETE RESTRICT) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
CREATE TABLE material_categories (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL, code VARCHAR(50) NOT NULL, name VARCHAR(100) NOT NULL, description VARCHAR(1000) NOT NULL DEFAULT '', parent_id BIGINT NULL, status VARCHAR(20) NOT NULL DEFAULT 'active', UNIQUE KEY uk_material_categories_org_code(organization_id,code), FOREIGN KEY(organization_id) REFERENCES organizations(id) ON DELETE RESTRICT, FOREIGN KEY(parent_id) REFERENCES material_categories(id) ON DELETE RESTRICT) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
CREATE TABLE material_categories (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL, code VARCHAR(50) NOT NULL, name VARCHAR(100) NOT NULL, description VARCHAR(1000) NOT NULL DEFAULT '', parent_id BIGINT NULL, status VARCHAR(20) NOT NULL DEFAULT 'active', UNIQUE KEY uk_material_categories_org_code(organization_id,code), FOREIGN KEY(organization_id) REFERENCES organizations(id) ON DELETE RESTRICT, FOREIGN KEY(parent_id) REFERENCES material_categories(id) ON DELETE RESTRICT) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
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 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 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), supplier_id BIGINT NULL COMMENT 'Default supplier organization_partner ID', manufacturer_id BIGINT NULL COMMENT 'Manufacturer organization_partner ID', 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 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_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 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;
|
||||||
|
|
@ -24,7 +24,13 @@ CREATE TABLE tags (id BIGINT AUTO_INCREMENT PRIMARY KEY, material_id BIGINT NOT
|
||||||
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;
|
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;
|
||||||
-- Partner categories support selecting material and finished_product together;
|
-- Partner categories support selecting material and finished_product together;
|
||||||
-- an empty set means unclassified. Both fields are created with the table.
|
-- an empty set means unclassified. Both fields are created with the table.
|
||||||
CREATE TABLE organization_partners (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL, business_partner_id BIGINT NOT NULL, partner_code VARCHAR(50) NOT NULL, is_supplier BOOLEAN NOT NULL DEFAULT FALSE, is_customer BOOLEAN NOT NULL DEFAULT FALSE, supplier_status VARCHAR(20), supplier_categories SET('material','finished_product') NOT NULL DEFAULT '', customer_status VARCHAR(20), customer_categories SET('material','finished_product') NOT NULL DEFAULT '', settlement_currency CHAR(3), payment_terms VARCHAR(200), created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_organization_partners_org_partner(organization_id,business_partner_id), UNIQUE KEY uk_organization_partners_org_code(organization_id,partner_code), FOREIGN KEY(organization_id) REFERENCES organizations(id), FOREIGN KEY(business_partner_id) REFERENCES business_partners(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
CREATE TABLE organization_partners (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL, business_partner_id BIGINT NOT NULL, partner_code VARCHAR(50) NOT NULL, is_supplier BOOLEAN NOT NULL DEFAULT FALSE, is_manufacturer BOOLEAN NOT NULL DEFAULT FALSE, is_customer BOOLEAN NOT NULL DEFAULT FALSE, supplier_status VARCHAR(20), supplier_categories SET('material','finished_product') NOT NULL DEFAULT '', customer_status VARCHAR(20), customer_categories SET('material','finished_product') NOT NULL DEFAULT '', settlement_currency CHAR(3), payment_terms VARCHAR(200), created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_organization_partners_org_id(organization_id,id), UNIQUE KEY uk_organization_partners_org_partner(organization_id,business_partner_id), UNIQUE KEY uk_organization_partners_org_code(organization_id,partner_code), FOREIGN KEY(organization_id) REFERENCES organizations(id), FOREIGN KEY(business_partner_id) REFERENCES business_partners(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
-- Partner references must belong to the same organization as the material.
|
||||||
|
ALTER TABLE materials
|
||||||
|
ADD KEY idx_materials_org_supplier(organization_id,supplier_id),
|
||||||
|
ADD KEY idx_materials_org_manufacturer(organization_id,manufacturer_id),
|
||||||
|
ADD CONSTRAINT fk_materials_supplier FOREIGN KEY(organization_id,supplier_id) REFERENCES organization_partners(organization_id,id) ON DELETE RESTRICT ON UPDATE RESTRICT,
|
||||||
|
ADD CONSTRAINT fk_materials_manufacturer FOREIGN KEY(organization_id,manufacturer_id) REFERENCES organization_partners(organization_id,id) ON DELETE RESTRICT ON UPDATE RESTRICT;
|
||||||
CREATE TABLE material_suppliers (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL, material_id BIGINT NOT NULL, organization_partner_id BIGINT NOT NULL, price DECIMAL(20,6) NOT NULL, currency CHAR(3) NOT NULL, minimum_order_quantity DECIMAL(20,6) NOT NULL DEFAULT 0, lead_time_days INT NOT NULL DEFAULT 0, is_preferred BOOLEAN NOT NULL DEFAULT FALSE, effective_from DATE NOT NULL, effective_to DATE, status VARCHAR(20) NOT NULL DEFAULT 'active', KEY idx_material_suppliers_material(organization_id,material_id), FOREIGN KEY(organization_id) REFERENCES organizations(id), FOREIGN KEY(material_id) REFERENCES materials(id), FOREIGN KEY(organization_partner_id) REFERENCES organization_partners(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
CREATE TABLE material_suppliers (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL, material_id BIGINT NOT NULL, organization_partner_id BIGINT NOT NULL, price DECIMAL(20,6) NOT NULL, currency CHAR(3) NOT NULL, minimum_order_quantity DECIMAL(20,6) NOT NULL DEFAULT 0, lead_time_days INT NOT NULL DEFAULT 0, is_preferred BOOLEAN NOT NULL DEFAULT FALSE, effective_from DATE NOT NULL, effective_to DATE, status VARCHAR(20) NOT NULL DEFAULT 'active', KEY idx_material_suppliers_material(organization_id,material_id), FOREIGN KEY(organization_id) REFERENCES organizations(id), FOREIGN KEY(material_id) REFERENCES materials(id), FOREIGN KEY(organization_partner_id) REFERENCES organization_partners(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
CREATE TABLE warehouses (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL, code VARCHAR(50) NOT NULL, name VARCHAR(150) NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'active', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_warehouses_org_code(organization_id,code), UNIQUE KEY uk_warehouses_organization(organization_id), FOREIGN KEY(organization_id) REFERENCES organizations(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
CREATE TABLE warehouses (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL, code VARCHAR(50) NOT NULL, name VARCHAR(150) NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'active', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_warehouses_org_code(organization_id,code), UNIQUE KEY uk_warehouses_organization(organization_id), FOREIGN KEY(organization_id) REFERENCES organizations(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
CREATE TABLE warehouse_locations (id BIGINT AUTO_INCREMENT PRIMARY KEY, warehouse_id BIGINT NOT NULL, code VARCHAR(50) NOT NULL, name VARCHAR(150) NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'active', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_warehouse_locations_warehouse_code(warehouse_id,code), UNIQUE KEY uk_warehouse_locations_warehouse(warehouse_id), FOREIGN KEY(warehouse_id) REFERENCES warehouses(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
CREATE TABLE warehouse_locations (id BIGINT AUTO_INCREMENT PRIMARY KEY, warehouse_id BIGINT NOT NULL, code VARCHAR(50) NOT NULL, name VARCHAR(150) NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'active', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_warehouse_locations_warehouse_code(warehouse_id,code), UNIQUE KEY uk_warehouse_locations_warehouse(warehouse_id), FOREIGN KEY(warehouse_id) REFERENCES warehouses(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
@ -61,6 +67,8 @@ 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 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 schema_migrations(version,status,completed_at,details) VALUES('2026-10-12-material-partner-roles','completed',NOW(),JSON_OBJECT('version','2026-10-12-material-partner-roles','source','fresh-init'));
|
||||||
|
|
||||||
INSERT INTO organizations (id,code,name,status) VALUES (1,'DEFAULT','默认公司组织','active');
|
INSERT INTO organizations (id,code,name,status) VALUES (1,'DEFAULT','默认公司组织','active');
|
||||||
-- 常用计量单位归属默认公司;计数、包装使用整数,重量、容量、长度、面积保留 3 位小数。
|
-- 常用计量单位归属默认公司;计数、包装使用整数,重量、容量、长度、面积保留 3 位小数。
|
||||||
INSERT INTO units (organization_id,code,name,precision_scale,status) VALUES
|
INSERT INTO units (organization_id,code,name,precision_scale,status) VALUES
|
||||||
|
|
|
||||||
20
scripts/upgrades/2026-10-12-material-partner-roles.sql
Normal file
20
scripts/upgrades/2026-10-12-material-partner-roles.sql
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
-- 物料默认供应商、制造商及伙伴制造商身份。选择目标数据库后执行一次。
|
||||||
|
-- 前置条件:已完成公司隔离升级,存在 schema_migrations。
|
||||||
|
-- 不修改已有伙伴身份;物料新字段默认 NULL。
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
|
||||||
|
ALTER TABLE organization_partners
|
||||||
|
ADD COLUMN is_manufacturer BOOLEAN NOT NULL DEFAULT FALSE AFTER is_supplier,
|
||||||
|
ADD UNIQUE KEY uk_organization_partners_org_id(organization_id,id);
|
||||||
|
|
||||||
|
ALTER TABLE materials
|
||||||
|
ADD COLUMN supplier_id BIGINT NULL COMMENT 'Default supplier organization_partner ID' AFTER manufacturer_model,
|
||||||
|
ADD COLUMN manufacturer_id BIGINT NULL COMMENT 'Manufacturer organization_partner ID' AFTER supplier_id,
|
||||||
|
ADD KEY idx_materials_org_supplier(organization_id,supplier_id),
|
||||||
|
ADD KEY idx_materials_org_manufacturer(organization_id,manufacturer_id),
|
||||||
|
ADD CONSTRAINT fk_materials_supplier FOREIGN KEY(organization_id,supplier_id) REFERENCES organization_partners(organization_id,id) ON DELETE RESTRICT ON UPDATE RESTRICT,
|
||||||
|
ADD CONSTRAINT fk_materials_manufacturer FOREIGN KEY(organization_id,manufacturer_id) REFERENCES organization_partners(organization_id,id) ON DELETE RESTRICT ON UPDATE RESTRICT;
|
||||||
|
|
||||||
|
-- 复合外键约束组织归属,供应商/制造商角色匹配由业务接口校验。
|
||||||
|
INSERT INTO schema_migrations(version,status,completed_at,details)
|
||||||
|
VALUES('2026-10-12-material-partner-roles','completed',NOW(),JSON_OBJECT('version','2026-10-12-material-partner-roles','source','upgrade'));
|
||||||
37
启动项目.bat
37
启动项目.bat
|
|
@ -3,6 +3,40 @@ title Warehouse Management System Launcher
|
||||||
|
|
||||||
cd /d "%~dp0"
|
cd /d "%~dp0"
|
||||||
|
|
||||||
|
echo Building frontend...
|
||||||
|
pushd "%~dp0frontend"
|
||||||
|
if not exist "node_modules\vite\bin\vite.js" (
|
||||||
|
call npm ci
|
||||||
|
if errorlevel 1 (
|
||||||
|
popd
|
||||||
|
echo Frontend dependency installation failed.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
call npm run build
|
||||||
|
if errorlevel 1 (
|
||||||
|
popd
|
||||||
|
echo Frontend build failed. Fix the errors above and run this launcher again.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
popd
|
||||||
|
|
||||||
|
rem go:embed resolves dist relative to internal/web, not the project root.
|
||||||
|
echo Preparing embedded frontend...
|
||||||
|
xcopy "%~dp0frontend\dist\*" "%~dp0internal\web\dist\" /E /I /H /Y >nul
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo Copying frontend build output failed.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
if not exist "%~dp0internal\web\dist\index.html" (
|
||||||
|
echo Embedded frontend index.html is missing.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
echo Building backend...
|
echo Building backend...
|
||||||
rem Rebuild on every launch so the executable can never be a stale one.
|
rem Rebuild on every launch so the executable can never be a stale one.
|
||||||
go build -o server.exe ./cmd/server
|
go build -o server.exe ./cmd/server
|
||||||
|
|
@ -13,6 +47,9 @@ if errorlevel 1 (
|
||||||
exit /b 1
|
exit /b 1
|
||||||
)
|
)
|
||||||
|
|
||||||
|
rem Optional validation mode: build both components without starting services.
|
||||||
|
if /I "%~1"=="--build-only" exit /b 0
|
||||||
|
|
||||||
echo Starting backend (8082)...
|
echo Starting backend (8082)...
|
||||||
rem Work dir must stay at the project root: frontend/dist and uploads are relative paths.
|
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"
|
start "Warehouse MMS - Backend" /D "%~dp0" cmd /k "server.exe"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user