Merge remote-tracking branch 'origin/feature/material-bulk-import' into dev

This commit is contained in:
文睿 张 2026-10-13 17:29:15 +08:00
commit 9c9ea5c62e
45 changed files with 1133 additions and 214 deletions

View File

@ -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()

View File

@ -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 快照;现有日志页「查看详情」显示「批量导入创建:物料名称(物料编码)」,下方按行展示导入时的编码、名称、规格、分类、单位和状态;该详情是物料操作日志,不展示导入文件名或批次汇总。
- 首版只支持**新增**物料:不做更新,也不自动创建分类或单位。 - 首版只支持**新增**物料:不做更新,也不自动创建分类或单位。
- **不做重复检测**:同一份文件重复上传会真的导入两遍(生成不同的编码)。 - **重复检测**:物料名称、规格、单位、供应商和制造商全部相同才判为重复;批量导入也会检查已有物料和本文件前序行,发现重复行时整批不写入,并返回行号和原因。供应商、制造商可留空;填写时使用当前公司的伙伴名称或伙伴编码。
--- ---

View File

@ -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 文档。
## 完成检查
- [ ] 检查差异,确认工作区既有的无关修改仍保留。
- [ ] 不运行测试或构建,遵循本会话要求。

View File

@ -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 后结果正确。
## 执行结果记录 ## 执行结果记录

View File

@ -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`。

View File

@ -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)
}, },

View File

@ -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)
}, },

View File

@ -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

View File

@ -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 }
] ]
}, },

View File

@ -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'
]) ])

View File

@ -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',

View File

@ -10,6 +10,7 @@ describe('phase-one routes', () => {
'/organizations', '/organizations',
'/roles', '/roles',
'/partners/suppliers', '/partners/suppliers',
'/partners/manufacturers',
'/partners/customers' '/partners/customers'
])) ]))
}) })

View File

@ -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,模拟用户选中某个分类。

View File

@ -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()

View File

@ -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()

View File

@ -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

View File

@ -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
}) })
}) })
}) })

View File

@ -0,0 +1,2 @@
<template><PartnerList role="manufacturer" title="制造商" /></template>
<script setup>import PartnerList from './PartnerList.vue'</script>

View File

@ -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)

View File

@ -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() {

View File

@ -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
} }

View File

@ -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())

View File

@ -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。

View File

@ -9,6 +9,8 @@ type MaterialImportRow struct {
Name string Name string
Spec string Spec string
Unit string Unit string
Supplier string
Manufacturer string
MajorName string MajorName string
MinorName string MinorName string
} }
@ -19,6 +21,8 @@ func (r MaterialImportRow) Data() map[string]string {
"name": r.Name, "name": r.Name,
"spec": r.Spec, "spec": r.Spec,
"unit": r.Unit, "unit": r.Unit,
"supplier": r.Supplier,
"manufacturer": r.Manufacturer,
"major": r.MajorName, "major": r.MajorName,
"minor": r.MinorName, "minor": r.MinorName,
} }

View File

@ -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"`

View File

@ -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"`

View File

@ -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
} }

View File

@ -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 (?,?)`)).

View File

@ -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

View File

@ -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 {

View 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)
}
}

View File

@ -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)

View File

@ -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} {

View File

@ -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()

View File

@ -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 {

View File

@ -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 &copyID, "", 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()

View File

@ -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
@ -261,10 +263,12 @@ func ParseMaterialImportFile(r io.Reader) ([]model.MaterialImportRow, error) {
Name: materialImportColumnValue(raw, columns, "物料名称"), Name: materialImportColumnValue(raw, columns, "物料名称"),
Spec: materialImportColumnValue(raw, columns, "规格"), Spec: materialImportColumnValue(raw, columns, "规格"),
Unit: materialImportColumnValue(raw, columns, "单位"), Unit: materialImportColumnValue(raw, columns, "单位"),
Supplier: materialImportColumnValue(raw, columns, "供应商"),
Manufacturer: materialImportColumnValue(raw, columns, "制造商"),
MajorName: materialImportColumnValue(raw, columns, "大类"), MajorName: materialImportColumnValue(raw, columns, "大类"),
MinorName: 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 {

View File

@ -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)

View File

@ -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"))

View File

@ -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()

View File

@ -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()

View 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)
}
}

View File

@ -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

View 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'));

View File

@ -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"