合并物料功能与初始化 SQL
This commit is contained in:
parent
9c9ea5c62e
commit
85525c97a0
BIN
db-migration-original/material-apply-8090北京/material.db
Normal file
BIN
db-migration-original/material-apply-8090北京/material.db
Normal file
Binary file not shown.
BIN
db-migration-original/material-apply-8090北京/material.db-shm
Normal file
BIN
db-migration-original/material-apply-8090北京/material.db-shm
Normal file
Binary file not shown.
BIN
db-migration-original/material-apply-8090北京/material.db-wal
Normal file
BIN
db-migration-original/material-apply-8090北京/material.db-wal
Normal file
Binary file not shown.
BIN
db-migration-original/material-apply武汉/material.db
Normal file
BIN
db-migration-original/material-apply武汉/material.db
Normal file
Binary file not shown.
BIN
db-migration-original/material-apply武汉/material.db-shm
Normal file
BIN
db-migration-original/material-apply武汉/material.db-shm
Normal file
Binary file not shown.
BIN
db-migration-original/material-apply武汉/material.db-wal
Normal file
BIN
db-migration-original/material-apply武汉/material.db-wal
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
22
docs/api.md
22
docs/api.md
|
|
@ -182,6 +182,8 @@
|
||||||
"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",
|
||||||
|
"source": "外购",
|
||||||
|
"remark": "常用标准件",
|
||||||
"supplier_id": 21,
|
"supplier_id": 21,
|
||||||
"manufacturer_id": 22,
|
"manufacturer_id": 22,
|
||||||
"supplier_name": "示例供应商",
|
"supplier_name": "示例供应商",
|
||||||
|
|
@ -203,7 +205,7 @@
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`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` 是伙伴显示名称。
|
`material_image`、`spec`、`manufacturer_model`、`source`、`remark`、`supplier_id`、`manufacturer_id`、`category_id`、`base_unit_id`、`unit` 为空时**不会出现在响应里**(后端 `omitempty`),前端取值时需要做空值兜底。`source` 可为空,非空时仅为「外购」或「外协」;`remark` 可为空且最多 200 字。`manufacturer_model` 是制造商为产品指定的型号或订货识别型号;`spec` 表示物料规格,`manufacturer_id` 表示制造商伙伴关联。`supplier_name` 和 `manufacturer_name` 是伙伴显示名称。
|
||||||
|
|
||||||
**状态码:**
|
**状态码:**
|
||||||
|
|
||||||
|
|
@ -244,6 +246,8 @@
|
||||||
"name": "内六角螺栓",
|
"name": "内六角螺栓",
|
||||||
"spec": "M8x30",
|
"spec": "M8x30",
|
||||||
"manufacturer_model": "GB/T 70.1",
|
"manufacturer_model": "GB/T 70.1",
|
||||||
|
"source": "外购",
|
||||||
|
"remark": "常用标准件",
|
||||||
"category_id": 12,
|
"category_id": 12,
|
||||||
"base_unit_id": 3,
|
"base_unit_id": 3,
|
||||||
"unit": "个",
|
"unit": "个",
|
||||||
|
|
@ -260,6 +264,8 @@
|
||||||
| category_id | integer | 是 | 小类分类 ID,必须大于 0 且真实存在 |
|
| category_id | integer | 是 | 小类分类 ID,必须大于 0 且真实存在 |
|
||||||
| spec | string | 否 | 规格 |
|
| spec | string | 否 | 规格 |
|
||||||
| manufacturer_model| string | 否 | 制造商为产品指定的型号或订货识别型号 |
|
| manufacturer_model| string | 否 | 制造商为产品指定的型号或订货识别型号 |
|
||||||
|
| source | string | 否 | 来源,可留空;非空时填写「外购」或「外协」 |
|
||||||
|
| remark | string | 否 | 备注,可留空,最多 200 字 |
|
||||||
| base_unit_id | integer | 否 | 基本计量单位 ID |
|
| base_unit_id | integer | 否 | 基本计量单位 ID |
|
||||||
| unit | string | 否 | 单位编码(冗余展示字段) |
|
| unit | string | 否 | 单位编码(冗余展示字段) |
|
||||||
| material_image | string | 否 | 图片地址 |
|
| material_image | string | 否 | 图片地址 |
|
||||||
|
|
@ -322,6 +328,8 @@
|
||||||
| name | string | 否 | 物料名称 |
|
| name | string | 否 | 物料名称 |
|
||||||
| spec | string | 否 | 规格 |
|
| spec | string | 否 | 规格 |
|
||||||
| manufacturer_model| string | 否 | 制造商为产品指定的型号或订货识别型号 |
|
| manufacturer_model| string | 否 | 制造商为产品指定的型号或订货识别型号 |
|
||||||
|
| source | string | 否 | 来源,可留空;传空字符串清空;非空时填写「外购」或「外协」 |
|
||||||
|
| remark | string | 否 | 备注,可留空;传空字符串清空;最多 200 字 |
|
||||||
| base_unit_id | integer | 否 | 基本计量单位 ID |
|
| base_unit_id | integer | 否 | 基本计量单位 ID |
|
||||||
| unit | string | 否 | 单位编码 |
|
| unit | string | 否 | 单位编码 |
|
||||||
| material_image | string | 否 | 图片地址 |
|
| material_image | string | 否 | 图片地址 |
|
||||||
|
|
@ -421,13 +429,13 @@
|
||||||
| 工作表 | 内容 |
|
| 工作表 | 内容 |
|
||||||
|--------|------|
|
|--------|------|
|
||||||
| 填写说明 | 字段规则、填写与上传步骤、容量限制、整批校验、重复检测规则和参考示例 |
|
| 填写说明 | 字段规则、填写与上传步骤、容量限制、整批校验、重复检测规则和参考示例 |
|
||||||
| 物料数据 | 第 1 行是冻结表头,从第 2 行填写;七列采用文本格式,数据页不放示例物料 |
|
| 物料数据 | 第 1 行是冻结表头,从第 2 行填写;九列采用文本格式,数据页不放示例物料 |
|
||||||
|
|
||||||
`物料数据` 的表头是 7 列:`物料名称`、`规格`、`单位`、`供应商`、`制造商`、`大类`、`小类`。解析时**按列名定位,不按列位置**:
|
`物料数据` 的表头是 9 列:`物料名称`、`规格`、`单位`、`供应商`、`制造商`、`来源`、`备注`、`大类`、`小类`。解析时**按列名定位,不按列位置**:
|
||||||
|
|
||||||
- 列顺序可以随意调整;表头允许带首尾空格和末尾的 `*` 必填标记;不认识的列忽略;
|
- 列顺序可以随意调整;表头允许带首尾空格和末尾的 `*` 必填标记;不认识的列忽略;
|
||||||
- 必填列 `物料名称`、`大类`、`小类` 缺任何一列,判为模板不合法并返回「模板格式不正确,请重新下载最新模板」;
|
- 必填列 `物料名称`、`大类`、`小类` 缺任何一列,判为模板不合法并返回「模板格式不正确,请重新下载最新模板」;
|
||||||
- 选填列 `规格`、`单位`、`供应商`、`制造商` 可以整列删掉,取值按空处理。
|
- 选填列 `规格`、`单位`、`供应商`、`制造商`、`来源`、`备注` 可以整列删掉,取值按空处理;来源只能为「外购」或「外协」,备注最多 200 字。
|
||||||
|
|
||||||
分类**按名称**填写;名称之外也接受**编码**和界面上的「编码 名称」写法(界面一律显示成 `A 机械结构类`,用户整段照抄进模板是常事)。后端按「大类 + 小类」在本公司内组合匹配(小类名在一个公司内会重名,例如多个大类下都有「其他」,所以必须两级组合)。名称、编码、「编码 名称」三种写法同名或同码时以**名称**优先。单位按名称匹配,同时兼容按编码匹配(如 `kg` 与 `千克`)。
|
分类**按名称**填写;名称之外也接受**编码**和界面上的「编码 名称」写法(界面一律显示成 `A 机械结构类`,用户整段照抄进模板是常事)。后端按「大类 + 小类」在本公司内组合匹配(小类名在一个公司内会重名,例如多个大类下都有「其他」,所以必须两级组合)。名称、编码、「编码 名称」三种写法同名或同码时以**名称**优先。单位按名称匹配,同时兼容按编码匹配(如 `kg` 与 `千克`)。
|
||||||
|
|
||||||
|
|
@ -1274,6 +1282,12 @@
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 操作日志
|
||||||
|
|
||||||
|
`GET /logs` 与 `GET /logs/materials/:materialId` 的物料日志对象包含 `operator`(操作人名称)和 `operator_id`(操作人用户 ID)。新产生的登录用户操作会从同一条 `users` 记录写入 ID 和 username,确保两者匹配;旧日志和系统生成日志的 `operator_id` 可为 `null`。前端可用用户 ID 区分同名操作人。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 状态码汇总
|
## 状态码汇总
|
||||||
|
|
||||||
| 状态码 | 说明 |
|
| 状态码 | 说明 |
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# 物料管理系统数据库表说明
|
# 物料管理系统数据库表说明
|
||||||
|
|
||||||
更新时间:2026-10-12。本文前半部分保留已有库的结构说明;第 11 节说明公司隔离升级后的运行结构。表数量随升级增加审计、迁移版本及对账表,以目标库 `information_schema` 查询为准。
|
更新时间:2026-10-14。本文前半部分保留已有库的结构说明;第 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(`supplier_id`)、制造商关联ID(`manufacturer_id`)、分类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`)、来源(`source`)、备注(`remark`)、默认供应商关联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`) |
|
||||||
|
|
@ -80,7 +80,7 @@
|
||||||
|
|
||||||
| 表 | 保存什么 | 主要字段 |
|
| 表 | 保存什么 | 主要字段 |
|
||||||
| --------------- | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
| --------------- | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `material_logs` | 物料资料新增、修改等操作及修改前后快照。 | 日志ID(`id`)、操作类型(`operation_type`)、物料ID(`material_id`)、旧值快照(`old_value`)、新值快照(`new_value`)、操作人(`operator`)、说明(`description`)、`created_at` |
|
| `material_logs` | 物料资料新增、修改等操作及修改前后快照。 | 日志ID(`id`)、操作类型(`operation_type`)、物料ID(`material_id`)、操作人用户ID(`operator_id`,可空)、旧值快照(`old_value`)、新值快照(`new_value`)、操作人名称(`operator`)、说明(`description`)、`created_at` |
|
||||||
| `stock_logs` | 库存增减数量、方向和来源单据。 | 流水ID(`id`)、物料ID(`material_id`)、方向(`type`)、数量(`quantity`)、来源类型(`related_order_type`)、来源记录ID(`related_order_id`)、`created_at` |
|
| `stock_logs` | 库存增减数量、方向和来源单据。 | 流水ID(`id`)、物料ID(`material_id`)、方向(`type`)、数量(`quantity`)、来源类型(`related_order_type`)、来源记录ID(`related_order_id`)、`created_at` |
|
||||||
|
|
||||||
`material_logs` 描述资料变化,`stock_logs` 描述数量变化。库存流水的 `type` 为 `in` 或 `out`;来源类型包含 `inbound`、`outbound`、`application`,分别指向入库单、出库单、申请主键。
|
`material_logs` 描述资料变化,`stock_logs` 描述数量变化。库存流水的 `type` 为 `in` 或 `out`;来源类型包含 `inbound`、`outbound`、`application`,分别指向入库单、出库单、申请主键。
|
||||||
|
|
@ -253,3 +253,11 @@
|
||||||
组织关联表增加唯一键 `(organization_id,id)`;物料通过 `(organization_id,supplier_id)` 和 `(organization_id,manufacturer_id)` 复合外键保证引用属于同一组织,删除或修改被引用的关联键会被拒绝。角色匹配需要由业务接口校验,外键本身不验证 `is_supplier` / `is_manufacturer`。
|
组织关联表增加唯一键 `(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`。
|
现有 `material_suppliers` 继续保存多个供应商的价格与供货条件;物料的 `supplier_id` 只表示默认选择。已有库使用 `scripts/upgrades/2026-10-12-material-partner-roles.sql` 升级,空库使用 `scripts/init.sql`。
|
||||||
|
|
||||||
|
## 13. 物料来源与备注(2026-10-14)
|
||||||
|
|
||||||
|
`materials.source` 为可空来源字段,允许值为「外购」或「外协」;`materials.remark` 为可空备注,最多 200 字。旧记录升级后两列均保持 `NULL`,新建和批量导入也允许留空。已有库使用 `scripts/upgrades/2026-10-14-material-source-remark.sql` 升级,空库使用 `scripts/init.sql`。
|
||||||
|
|
||||||
|
## 14. 物料日志操作人用户 ID(2026-10-14)
|
||||||
|
|
||||||
|
`material_logs.operator_id` 保存当前登录用户 ID,允许为空以兼容旧日志和系统生成日志;`operator` 名称继续保存并用于展示。该列不设置用户外键,保留用户被删除后的历史标识。已有库使用 `scripts/upgrades/2026-10-14-material-log-operator-id.sql` 升级,空库使用 `scripts/init.sql`。
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,11 @@
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="material_id" label="物料ID" width="80" />
|
<el-table-column prop="material_id" label="物料ID" width="80" />
|
||||||
<el-table-column prop="operator" label="操作人" />
|
<el-table-column prop="operator" label="操作人">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ operatorLabel(row) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column prop="description" label="详情" show-overflow-tooltip>
|
<el-table-column prop="description" label="详情" show-overflow-tooltip>
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button type="primary" link @click="showDetail(row)">查看详情</el-button>
|
<el-button type="primary" link @click="showDetail(row)">查看详情</el-button>
|
||||||
|
|
@ -70,7 +74,7 @@
|
||||||
<el-descriptions :column="2" border v-if="currentRow">
|
<el-descriptions :column="2" border v-if="currentRow">
|
||||||
<el-descriptions-item label="物料ID">{{ currentRow.material_id }}</el-descriptions-item>
|
<el-descriptions-item label="物料ID">{{ currentRow.material_id }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="操作类型" :span="2"><StatusBadge :status="currentRow.operation_type" /></el-descriptions-item>
|
<el-descriptions-item label="操作类型" :span="2"><StatusBadge :status="currentRow.operation_type" /></el-descriptions-item>
|
||||||
<el-descriptions-item label="操作人">{{ currentRow.operator }}</el-descriptions-item>
|
<el-descriptions-item label="操作人">{{ operatorLabel(currentRow) }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="时间">{{ formatDate(currentRow.created_at) }}</el-descriptions-item>
|
<el-descriptions-item label="时间">{{ formatDate(currentRow.created_at) }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="详情" :span="2">
|
<el-descriptions-item label="详情" :span="2">
|
||||||
<span class="log-description" style="white-space: pre-wrap; overflow-wrap: anywhere">{{ currentRow.description }}</span>
|
<span class="log-description" style="white-space: pre-wrap; overflow-wrap: anywhere">{{ currentRow.description }}</span>
|
||||||
|
|
@ -104,6 +108,10 @@ const logsStore = useLogsStore()
|
||||||
const materialsStore = useMaterialsStore()
|
const materialsStore = useMaterialsStore()
|
||||||
|
|
||||||
const detailVisible = ref(false)
|
const detailVisible = ref(false)
|
||||||
|
|
||||||
|
function operatorLabel(row) {
|
||||||
|
return row?.operator_id ? `${row.operator || '系统'}(用户 ID:${row.operator_id})` : (row?.operator || '系统')
|
||||||
|
}
|
||||||
const currentRow = ref(null)
|
const currentRow = ref(null)
|
||||||
const logsMaterialSearchText = ref('')
|
const logsMaterialSearchText = ref('')
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,9 @@
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="name" label="物料名称" />
|
<el-table-column prop="name" label="物料名称" />
|
||||||
<el-table-column prop="spec" label="规格" />
|
<el-table-column prop="spec" label="规格" />
|
||||||
|
<el-table-column label="来源" width="90">
|
||||||
|
<template #default="{ row }">{{ row.source || '-' }}</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="单位" width="60">
|
<el-table-column label="单位" width="60">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
{{ getUnitDisplay(row) }}
|
{{ getUnitDisplay(row) }}
|
||||||
|
|
@ -115,6 +118,15 @@
|
||||||
<el-form-item label="规格">
|
<el-form-item label="规格">
|
||||||
<el-input v-model="form.spec" />
|
<el-input v-model="form.spec" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item label="来源">
|
||||||
|
<el-select v-model="form.source" clearable placeholder="选填" style="width: 100%">
|
||||||
|
<el-option label="外购" value="外购" />
|
||||||
|
<el-option label="外协" value="外协" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="备注">
|
||||||
|
<el-input v-model="form.remark" type="textarea" maxlength="200" show-word-limit placeholder="选填,最多200字" />
|
||||||
|
</el-form-item>
|
||||||
<el-form-item label="大类类型" required>
|
<el-form-item label="大类类型" required>
|
||||||
<el-select v-model="form.major_category_id" style="width: 100%" @change="syncMajorCategory">
|
<el-select v-model="form.major_category_id" style="width: 100%" @change="syncMajorCategory">
|
||||||
<el-option v-for="category in majorCategories" :key="category.id" :label="categoryLabel(category, true)" :value="category.id" :disabled="category.status !== 'active'" />
|
<el-option v-for="category in majorCategories" :key="category.id" :label="categoryLabel(category, true)" :value="category.id" :disabled="category.status !== 'active'" />
|
||||||
|
|
@ -153,13 +165,15 @@
|
||||||
<el-descriptions-item label="ID">{{ detailRow.id }}</el-descriptions-item>
|
<el-descriptions-item label="ID">{{ detailRow.id }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="名称">{{ detailRow.name }}</el-descriptions-item>
|
<el-descriptions-item label="名称">{{ detailRow.name }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="规格">{{ detailRow.spec || '-' }}</el-descriptions-item>
|
<el-descriptions-item label="规格">{{ detailRow.spec || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="来源">{{ detailRow.source || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="单位">{{ getUnitDisplay(detailRow) }}</el-descriptions-item>
|
||||||
<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="供应商">{{ detailRow.supplier_name || '-' }}</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="制造商">{{ 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="更新时间">{{ formatDate(detailRow.updated_at, MATERIAL_DATE_OPTIONS) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="备注">{{ detailRow.remark || '-' }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="物料图片" :span="2">
|
<el-descriptions-item label="物料图片" :span="2">
|
||||||
<el-image
|
<el-image
|
||||||
v-if="detailRow.material_image"
|
v-if="detailRow.material_image"
|
||||||
|
|
@ -581,6 +595,10 @@ async function handleSave() {
|
||||||
ElMessage.warning('物料规格过长,请小于200字')
|
ElMessage.warning('物料规格过长,请小于200字')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (Array.from(form.remark || '').length > 200) {
|
||||||
|
ElMessage.warning('备注不能超过200字')
|
||||||
|
return
|
||||||
|
}
|
||||||
saveLoading.value = true
|
saveLoading.value = true
|
||||||
try {
|
try {
|
||||||
const formData = toMaterialPayload(form)
|
const formData = toMaterialPayload(form)
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ const materialKeys = [
|
||||||
'material_code',
|
'material_code',
|
||||||
'name',
|
'name',
|
||||||
'spec',
|
'spec',
|
||||||
|
'source',
|
||||||
|
'remark',
|
||||||
'category',
|
'category',
|
||||||
'category_id',
|
'category_id',
|
||||||
'base_unit_id',
|
'base_unit_id',
|
||||||
|
|
@ -19,6 +21,8 @@ export function createMaterialForm(source = {}, categories = []) {
|
||||||
material_code: source.material_code || '',
|
material_code: source.material_code || '',
|
||||||
name: source.name || '',
|
name: source.name || '',
|
||||||
spec: source.spec || '',
|
spec: source.spec || '',
|
||||||
|
source: source.source || '',
|
||||||
|
remark: source.remark || '',
|
||||||
category: source.category || '',
|
category: source.category || '',
|
||||||
unit: source.unit || '个',
|
unit: source.unit || '个',
|
||||||
material_image: source.material_image || '',
|
material_image: source.material_image || '',
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,13 @@ type Handler struct {
|
||||||
materialImportSvc *service.MaterialImportService
|
materialImportSvc *service.MaterialImportService
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func currentOperatorID(userID int64) *int64 {
|
||||||
|
if userID <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &userID
|
||||||
|
}
|
||||||
|
|
||||||
func userFacingServiceErrorMessage(err error) string {
|
func userFacingServiceErrorMessage(err error) string {
|
||||||
if errors.Is(err, service.ErrInsufficientStock) {
|
if errors.Is(err, service.ErrInsufficientStock) {
|
||||||
return "库存不足"
|
return "库存不足"
|
||||||
|
|
@ -386,9 +393,9 @@ func (h *Handler) CreateMaterial(c *gin.Context) {
|
||||||
operator = "系统"
|
operator = "系统"
|
||||||
}
|
}
|
||||||
|
|
||||||
material, err := h.materialSvc.Create(&req, operator)
|
material, err := h.materialSvc.CreateWithOperatorID(&req, currentOperatorID(middleware.GetCurrentUserID(c)), operator)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
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) {
|
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.ErrInvalidMaterialSource) || errors.Is(err, service.ErrMaterialRemarkTooLong) || errors.Is(err, service.ErrMaterialDuplicate) {
|
||||||
response.BadRequest(c, err.Error())
|
response.BadRequest(c, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -425,9 +432,9 @@ func (h *Handler) UpdateMaterial(c *gin.Context) {
|
||||||
operator = "系统"
|
operator = "系统"
|
||||||
}
|
}
|
||||||
|
|
||||||
material, err := h.materialSvc.Update(orgID, id, &req, operator)
|
material, err := h.materialSvc.UpdateWithOperatorID(orgID, id, &req, currentOperatorID(middleware.GetCurrentUserID(c)), operator)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
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) {
|
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.ErrInvalidMaterialSource) || errors.Is(err, service.ErrMaterialRemarkTooLong) {
|
||||||
response.BadRequest(c, err.Error())
|
response.BadRequest(c, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -458,7 +465,7 @@ func (h *Handler) DeleteMaterial(c *gin.Context) {
|
||||||
operator = "系统"
|
operator = "系统"
|
||||||
}
|
}
|
||||||
|
|
||||||
err = h.materialSvc.Delete(orgID, id, operator)
|
err = h.materialSvc.DeleteWithOperatorID(orgID, id, currentOperatorID(middleware.GetCurrentUserID(c)), operator)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
response.NotFound(c, "Material not found")
|
response.NotFound(c, "Material not found")
|
||||||
|
|
@ -484,7 +491,7 @@ func (h *Handler) ObsoleteMaterial(c *gin.Context) {
|
||||||
if operator == "" {
|
if operator == "" {
|
||||||
operator = "系统"
|
operator = "系统"
|
||||||
}
|
}
|
||||||
if err := h.materialSvc.Obsolete(orgID, id, operator); err != nil {
|
if err := h.materialSvc.ObsoleteWithOperatorID(orgID, id, currentOperatorID(middleware.GetCurrentUserID(c)), operator); err != nil {
|
||||||
if strings.Contains(err.Error(), "仍有库存") {
|
if strings.Contains(err.Error(), "仍有库存") {
|
||||||
response.Error(c, 409, err.Error())
|
response.Error(c, 409, err.Error())
|
||||||
return
|
return
|
||||||
|
|
@ -515,7 +522,7 @@ func (h *Handler) RestoreMaterial(c *gin.Context) {
|
||||||
if operator == "" {
|
if operator == "" {
|
||||||
operator = "系统"
|
operator = "系统"
|
||||||
}
|
}
|
||||||
if err := h.materialSvc.Restore(orgID, id, operator); err != nil {
|
if err := h.materialSvc.RestoreWithOperatorID(orgID, id, currentOperatorID(middleware.GetCurrentUserID(c)), operator); err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) || strings.Contains(err.Error(), "物料不存在") {
|
if errors.Is(err, sql.ErrNoRows) || strings.Contains(err.Error(), "物料不存在") {
|
||||||
response.Error(c, 404, "物料不存在")
|
response.Error(c, 404, "物料不存在")
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,7 @@ func (h *Handler) ImportMaterials(c *gin.Context) {
|
||||||
operator = "系统"
|
operator = "系统"
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := h.materialImportSvc.Import(orgID, operator, file.Filename, source)
|
result, err := h.materialImportSvc.ImportWithOperatorID(orgID, currentOperatorID(middleware.GetCurrentUserID(c)), operator, file.Filename, source)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, service.ErrImportTemplateInvalid) || errors.Is(err, service.ErrImportTooManyRows) {
|
if errors.Is(err, service.ErrImportTemplateInvalid) || errors.Is(err, service.ErrImportTooManyRows) {
|
||||||
response.BadRequest(c, err.Error())
|
response.BadRequest(c, err.Error())
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,9 @@ const (
|
||||||
|
|
||||||
// materialRow 构造单行结果集;不传值表示空结果集(用于"查不到"的路径)。
|
// materialRow 构造单行结果集;不传值表示空结果集(用于"查不到"的路径)。
|
||||||
func materialRow(columns []string, values ...driver.Value) *sqlmock.Rows {
|
func materialRow(columns []string, values ...driver.Value) *sqlmock.Rows {
|
||||||
|
if len(columns) == 20 && len(values) == 18 {
|
||||||
|
values = append(values[:7], append([]driver.Value{nil, nil}, values[7:]...)...)
|
||||||
|
}
|
||||||
rows := sqlmock.NewRows(columns)
|
rows := sqlmock.NewRows(columns)
|
||||||
if len(values) > 0 {
|
if len(values) > 0 {
|
||||||
rows.AddRow(values...)
|
rows.AddRow(values...)
|
||||||
|
|
@ -44,7 +47,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", "source", "remark",
|
||||||
"supplier_id", "manufacturer_id", "supplier_name", "manufacturer_name", "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"}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -157,7 +160,7 @@ func TestCreateMaterialUsesHeaderOrganizationInsteadOfBody(t *testing.T) {
|
||||||
WillReturnRows(materialRow(materialColumns(),
|
WillReturnRows(materialRow(materialColumns(),
|
||||||
int64(7), int64(1), "HDR-001", "轴承", nil, "", nil, 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(), sqlmock.AnyArg(), int64(7)).
|
||||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||||
|
|
||||||
body := `{"organization_id":2,"material_code":"HDR-001","name":"轴承","category_id":12,"allow_duplicate":true}`
|
body := `{"organization_id":2,"material_code":"HDR-001","name":"轴承","category_id":12,"allow_duplicate":true}`
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ type MaterialImportRow struct {
|
||||||
Unit string
|
Unit string
|
||||||
Supplier string
|
Supplier string
|
||||||
Manufacturer string
|
Manufacturer string
|
||||||
|
Source string
|
||||||
|
Remark string
|
||||||
MajorName string
|
MajorName string
|
||||||
MinorName string
|
MinorName string
|
||||||
}
|
}
|
||||||
|
|
@ -23,6 +25,8 @@ func (r MaterialImportRow) Data() map[string]string {
|
||||||
"unit": r.Unit,
|
"unit": r.Unit,
|
||||||
"supplier": r.Supplier,
|
"supplier": r.Supplier,
|
||||||
"manufacturer": r.Manufacturer,
|
"manufacturer": r.Manufacturer,
|
||||||
|
"source": r.Source,
|
||||||
|
"remark": r.Remark,
|
||||||
"major": r.MajorName,
|
"major": r.MajorName,
|
||||||
"minor": r.MinorName,
|
"minor": r.MinorName,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ 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"`
|
||||||
|
Source string `json:"source,omitempty"`
|
||||||
|
Remark string `json:"remark,omitempty"`
|
||||||
SupplierID *int64 `json:"supplier_id,omitempty"`
|
SupplierID *int64 `json:"supplier_id,omitempty"`
|
||||||
ManufacturerID *int64 `json:"manufacturer_id,omitempty"`
|
ManufacturerID *int64 `json:"manufacturer_id,omitempty"`
|
||||||
SupplierName string `json:"supplier_name,omitempty"`
|
SupplierName string `json:"supplier_name,omitempty"`
|
||||||
|
|
@ -234,6 +236,8 @@ 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"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
Remark string `json:"remark"`
|
||||||
SupplierID *int64 `json:"supplier_id"`
|
SupplierID *int64 `json:"supplier_id"`
|
||||||
ManufacturerID *int64 `json:"manufacturer_id"`
|
ManufacturerID *int64 `json:"manufacturer_id"`
|
||||||
AllowDuplicate bool `json:"allow_duplicate"`
|
AllowDuplicate bool `json:"allow_duplicate"`
|
||||||
|
|
@ -249,6 +253,8 @@ 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"`
|
||||||
|
Source *string `json:"source"`
|
||||||
|
Remark *string `json:"remark"`
|
||||||
SupplierID *int64 `json:"supplier_id"`
|
SupplierID *int64 `json:"supplier_id"`
|
||||||
ManufacturerID *int64 `json:"manufacturer_id"`
|
ManufacturerID *int64 `json:"manufacturer_id"`
|
||||||
CategoryID *int64 `json:"category_id" binding:"required,gt=0"`
|
CategoryID *int64 `json:"category_id" binding:"required,gt=0"`
|
||||||
|
|
@ -335,6 +341,7 @@ type MaterialLog struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
OperationType string `json:"operation_type"`
|
OperationType string `json:"operation_type"`
|
||||||
MaterialID int64 `json:"material_id"`
|
MaterialID int64 `json:"material_id"`
|
||||||
|
OperatorID *int64 `json:"operator_id"`
|
||||||
OldValue string `json:"old_value,omitempty"`
|
OldValue string `json:"old_value,omitempty"`
|
||||||
NewValue string `json:"new_value,omitempty"`
|
NewValue string `json:"new_value,omitempty"`
|
||||||
Operator string `json:"operator,omitempty"`
|
Operator string `json:"operator,omitempty"`
|
||||||
|
|
|
||||||
|
|
@ -74,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, supplier_id, manufacturer_id, category_id, base_unit_id, unit, status, version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
query := `INSERT INTO materials (organization_id, material_code, name, material_image, spec, manufacturer_model, source, remark, supplier_id, manufacturer_id, category_id, base_unit_id, unit, status, version) VALUES (?, ?, ?, ?, ?, ?, NULLIF(?, ''), NULLIF(?, ''), ?, ?, ?, ?, ?, ?, ?)`
|
||||||
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.SupplierID, m.ManufacturerID, m.CategoryID, m.BaseUnitID, m.Unit, m.Status, m.Version)
|
m.ManufacturerModel, m.Source, m.Remark, 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
|
||||||
|
|
@ -176,7 +176,7 @@ 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 m.id, m.organization_id, m.material_code, m.name, m.material_image, m.spec, m.manufacturer_model,
|
query := `SELECT m.id, m.organization_id, m.material_code, m.name, m.material_image, m.spec, m.manufacturer_model, m.source, m.remark,
|
||||||
m.supplier_id, m.manufacturer_id, COALESCE(sb.legal_name, ''), COALESCE(mb.legal_name, ''),
|
m.supplier_id, m.manufacturer_id, COALESCE(sb.legal_name, ''), COALESCE(mb.legal_name, ''),
|
||||||
m.category_id, m.base_unit_id, m.unit, m.status, m.version, m.created_at, m.updated_at
|
m.category_id, m.base_unit_id, m.unit, m.status, m.version, m.created_at, m.updated_at
|
||||||
FROM materials m
|
FROM materials m
|
||||||
|
|
@ -186,10 +186,10 @@ func (r *MaterialRepo) GetByID(organizationID, id int64, m *model.Material) erro
|
||||||
LEFT JOIN business_partners mb ON mb.id = mop.business_partner_id
|
LEFT JOIN business_partners mb ON mb.id = mop.business_partner_id
|
||||||
WHERE m.organization_id = ? AND m.id = ?`
|
WHERE m.organization_id = ? AND m.id = ?`
|
||||||
var materialImage sql.NullString
|
var materialImage sql.NullString
|
||||||
var manufacturerModel sql.NullString
|
var manufacturerModel, source, remark sql.NullString
|
||||||
var supplierID, manufacturerID sql.NullInt64
|
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, &supplierID, &manufacturerID, &m.SupplierName, &m.ManufacturerName, &m.CategoryID, &m.BaseUnitID,
|
&m.Spec, &manufacturerModel, &source, &remark, &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
|
||||||
|
|
@ -200,6 +200,12 @@ 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 source.Valid {
|
||||||
|
m.Source = source.String
|
||||||
|
}
|
||||||
|
if remark.Valid {
|
||||||
|
m.Remark = remark.String
|
||||||
|
}
|
||||||
if supplierID.Valid {
|
if supplierID.Valid {
|
||||||
m.SupplierID = &supplierID.Int64
|
m.SupplierID = &supplierID.Int64
|
||||||
}
|
}
|
||||||
|
|
@ -242,7 +248,7 @@ func (r *MaterialRepo) List(organizationID int64, search, status string, limit,
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
query := `SELECT m.id, m.organization_id, m.material_code, m.name, m.material_image, m.spec, m.manufacturer_model,
|
query := `SELECT m.id, m.organization_id, m.material_code, m.name, m.material_image, m.spec, m.manufacturer_model, m.source, m.remark,
|
||||||
m.supplier_id, m.manufacturer_id, COALESCE(sb.legal_name, ''), COALESCE(mb.legal_name, ''),
|
m.supplier_id, m.manufacturer_id, COALESCE(sb.legal_name, ''), COALESCE(mb.legal_name, ''),
|
||||||
m.category_id, m.base_unit_id, m.unit, m.status, m.version, m.created_at, m.updated_at
|
m.category_id, m.base_unit_id, m.unit, m.status, m.version, m.created_at, m.updated_at
|
||||||
FROM materials m
|
FROM materials m
|
||||||
|
|
@ -275,10 +281,10 @@ func (r *MaterialRepo) List(organizationID int64, search, status string, limit,
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var m model.Material
|
var m model.Material
|
||||||
var materialImage sql.NullString
|
var materialImage sql.NullString
|
||||||
var manufacturerModel sql.NullString
|
var manufacturerModel, source, remark sql.NullString
|
||||||
var supplierID, manufacturerID sql.NullInt64
|
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, &supplierID, &manufacturerID, &m.SupplierName, &m.ManufacturerName, &m.CategoryID, &m.BaseUnitID,
|
&manufacturerModel, &source, &remark, &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
|
||||||
|
|
@ -289,6 +295,12 @@ 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 source.Valid {
|
||||||
|
m.Source = source.String
|
||||||
|
}
|
||||||
|
if remark.Valid {
|
||||||
|
m.Remark = remark.String
|
||||||
|
}
|
||||||
if supplierID.Valid {
|
if supplierID.Valid {
|
||||||
m.SupplierID = &supplierID.Int64
|
m.SupplierID = &supplierID.Int64
|
||||||
}
|
}
|
||||||
|
|
@ -415,6 +427,14 @@ func (r *MaterialRepo) Update(organizationID, id int64, req *model.UpdateMateria
|
||||||
sets = append(sets, "manufacturer_model = ?")
|
sets = append(sets, "manufacturer_model = ?")
|
||||||
args = append(args, *req.ManufacturerModel)
|
args = append(args, *req.ManufacturerModel)
|
||||||
}
|
}
|
||||||
|
if req.Source != nil {
|
||||||
|
sets = append(sets, "source = NULLIF(?, '')")
|
||||||
|
args = append(args, *req.Source)
|
||||||
|
}
|
||||||
|
if req.Remark != nil {
|
||||||
|
sets = append(sets, "remark = NULLIF(?, '')")
|
||||||
|
args = append(args, *req.Remark)
|
||||||
|
}
|
||||||
if req.CategoryID != nil {
|
if req.CategoryID != nil {
|
||||||
sets = append(sets, "category_id = ?")
|
sets = append(sets, "category_id = ?")
|
||||||
args = append(args, req.CategoryID)
|
args = append(args, req.CategoryID)
|
||||||
|
|
@ -675,19 +695,26 @@ 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, supplier_id, manufacturer_id, category_id, base_unit_id, unit, status, version) VALUES `)
|
builder.WriteString(`INSERT INTO materials (organization_id, material_code, name, material_image, spec, manufacturer_model, source, remark, supplier_id, manufacturer_id, category_id, base_unit_id, unit, status, version) VALUES `)
|
||||||
args := make([]interface{}, 0, len(batch)*13)
|
args := make([]interface{}, 0, len(batch)*15)
|
||||||
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.SupplierID, m.ManufacturerID, m.CategoryID, m.BaseUnitID, m.Unit, m.Status, m.Version)
|
m.ManufacturerModel, nullableMaterialText(m.Source), nullableMaterialText(m.Remark), m.SupplierID, m.ManufacturerID, m.CategoryID, m.BaseUnitID, m.Unit, m.Status, m.Version)
|
||||||
}
|
}
|
||||||
return builder.String(), args
|
return builder.String(), args
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func nullableMaterialText(value string) interface{} {
|
||||||
|
if value == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
// fillMaterialIDs 按物料编码回查自增 ID 并回填。多值插入拿不到逐行 ID,
|
// fillMaterialIDs 按物料编码回查自增 ID 并回填。多值插入拿不到逐行 ID,
|
||||||
// 而后续写 material_logs 需要它。
|
// 而后续写 material_logs 需要它。
|
||||||
func fillMaterialIDs(tx *sql.Tx, organizationID int64, materials []*model.Material) error {
|
func fillMaterialIDs(tx *sql.Tx, organizationID int64, materials []*model.Material) error {
|
||||||
|
|
|
||||||
|
|
@ -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, supplier_id, manufacturer_id, category_id, base_unit_id, unit, status, version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
const insertMaterialBatchStmt = `INSERT INTO materials (organization_id, material_code, name, material_image, spec, manufacturer_model, source, remark, 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, nil, nil, int64(12), nil, "个", "active", 1,
|
int64(1), "A010202", "螺栓", nil, "", nil, nil, nil, nil, int64(12), nil, "个", "active", 1,
|
||||||
int64(1), "A010203", "螺母", nil, "", nil, nil, nil, int64(12), nil, "个", "active", 1,
|
int64(1), "A010203", "螺母", nil, "", 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 (?,?)`)).
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ package repository
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/wmms/wmms/internal/model"
|
"github.com/wmms/wmms/internal/model"
|
||||||
|
|
@ -10,6 +12,8 @@ import (
|
||||||
|
|
||||||
type MaterialLogRepo struct{}
|
type MaterialLogRepo struct{}
|
||||||
|
|
||||||
|
var ErrMaterialLogOperatorNotFound = errors.New("material log operator does not exist")
|
||||||
|
|
||||||
func NewMaterialLogRepo() *MaterialLogRepo {
|
func NewMaterialLogRepo() *MaterialLogRepo {
|
||||||
return &MaterialLogRepo{}
|
return &MaterialLogRepo{}
|
||||||
}
|
}
|
||||||
|
|
@ -28,11 +32,30 @@ func normalizeEndDate(endDate string) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *MaterialLogRepo) insertLog(execer sqlExecer, log *model.MaterialLog) error {
|
func (r *MaterialLogRepo) insertLog(execer sqlExecer, log *model.MaterialLog) error {
|
||||||
query := `INSERT INTO material_logs (operation_type, material_id, old_value, new_value, operator, description) VALUES (?, ?, ?, ?, ?, ?)`
|
var result sql.Result
|
||||||
result, err := execer.Exec(query, log.OperationType, log.MaterialID, log.OldValue, log.NewValue, log.Operator, log.Description)
|
var err error
|
||||||
|
if log.OperatorID == nil {
|
||||||
|
query := `INSERT INTO material_logs (operation_type, material_id, operator_id, old_value, new_value, operator, description) VALUES (?, ?, NULL, ?, ?, ?, ?)`
|
||||||
|
result, err = execer.Exec(query, log.OperationType, log.MaterialID, log.OldValue, log.NewValue, log.Operator, log.Description)
|
||||||
|
} else {
|
||||||
|
// Take both identity fields from one users row so an old token username or
|
||||||
|
// caller-provided name can never be stored beside a different user ID.
|
||||||
|
query := `INSERT INTO material_logs (operation_type, material_id, operator_id, old_value, new_value, operator, description)
|
||||||
|
SELECT ?, ?, u.id, ?, ?, u.username, ? FROM users u WHERE u.id = ?`
|
||||||
|
result, err = execer.Exec(query, log.OperationType, log.MaterialID, log.OldValue, log.NewValue, log.Description, *log.OperatorID)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if log.OperatorID != nil {
|
||||||
|
rowsAffected, rowsErr := result.RowsAffected()
|
||||||
|
if rowsErr != nil {
|
||||||
|
return rowsErr
|
||||||
|
}
|
||||||
|
if rowsAffected != 1 {
|
||||||
|
return ErrMaterialLogOperatorNotFound
|
||||||
|
}
|
||||||
|
}
|
||||||
id, err := result.LastInsertId()
|
id, err := result.LastInsertId()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -79,7 +102,7 @@ func (r *MaterialLogRepo) List(organizationID int64, operationType, materialId s
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
query := `SELECT ml.id, ml.operation_type, ml.material_id, ml.old_value, ml.new_value, ml.operator, ml.description, ml.created_at
|
query := `SELECT ml.id, ml.operation_type, ml.material_id, ml.operator_id, ml.old_value, ml.new_value, ml.operator, ml.description, ml.created_at
|
||||||
FROM material_logs ml JOIN materials m ON m.id = ml.material_id WHERE m.organization_id = ?`
|
FROM material_logs ml JOIN materials m ON m.id = ml.material_id WHERE m.organization_id = ?`
|
||||||
args := []interface{}{organizationID}
|
args := []interface{}{organizationID}
|
||||||
|
|
||||||
|
|
@ -111,14 +134,18 @@ func (r *MaterialLogRepo) List(organizationID int64, operationType, materialId s
|
||||||
var logs []model.MaterialLog
|
var logs []model.MaterialLog
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var log model.MaterialLog
|
var log model.MaterialLog
|
||||||
|
var operatorID sql.NullInt64
|
||||||
var oldValue, newValue []byte
|
var oldValue, newValue []byte
|
||||||
err := rows.Scan(&log.ID, &log.OperationType, &log.MaterialID, &oldValue, &newValue, &log.Operator, &log.Description, &log.CreatedAt)
|
err := rows.Scan(&log.ID, &log.OperationType, &log.MaterialID, &operatorID, &oldValue, &newValue, &log.Operator, &log.Description, &log.CreatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
if oldValue != nil {
|
if oldValue != nil {
|
||||||
log.OldValue = string(oldValue)
|
log.OldValue = string(oldValue)
|
||||||
}
|
}
|
||||||
|
if operatorID.Valid {
|
||||||
|
log.OperatorID = &operatorID.Int64
|
||||||
|
}
|
||||||
if newValue != nil {
|
if newValue != nil {
|
||||||
log.NewValue = string(newValue)
|
log.NewValue = string(newValue)
|
||||||
}
|
}
|
||||||
|
|
@ -134,22 +161,56 @@ func (r *MaterialLogRepo) CreateBatchTx(tx *sql.Tx, logs []*model.MaterialLog) e
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var builder strings.Builder
|
var builder strings.Builder
|
||||||
builder.WriteString(`INSERT INTO material_logs (operation_type, material_id, old_value, new_value, operator, description) VALUES `)
|
builder.WriteString(`INSERT INTO material_logs (operation_type, material_id, operator_id, old_value, new_value, operator, description) `)
|
||||||
args := make([]interface{}, 0, len(logs)*6)
|
args := make([]interface{}, 0, len(logs)*7)
|
||||||
|
withUserID := false
|
||||||
|
for _, log := range logs {
|
||||||
|
if log.OperatorID != nil {
|
||||||
|
withUserID = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !withUserID {
|
||||||
|
builder.WriteString(`VALUES `)
|
||||||
|
}
|
||||||
for i, log := range logs {
|
for i, log := range logs {
|
||||||
if i > 0 {
|
if i > 0 {
|
||||||
|
if withUserID {
|
||||||
|
builder.WriteString(" UNION ALL ")
|
||||||
|
} else {
|
||||||
builder.WriteString(", ")
|
builder.WriteString(", ")
|
||||||
}
|
}
|
||||||
builder.WriteString("(?, ?, ?, ?, ?, ?)")
|
}
|
||||||
|
if withUserID && log.OperatorID != nil {
|
||||||
|
builder.WriteString(`SELECT ?, ?, u.id, ?, ?, u.username, ? FROM users u WHERE u.id = ?`)
|
||||||
|
args = append(args, log.OperationType, log.MaterialID, log.OldValue, log.NewValue, log.Description, *log.OperatorID)
|
||||||
|
} else if withUserID {
|
||||||
|
builder.WriteString(`SELECT ?, ?, NULL, ?, ?, ?, ?`)
|
||||||
|
args = append(args, log.OperationType, log.MaterialID, log.OldValue, log.NewValue, log.Operator, log.Description)
|
||||||
|
} else {
|
||||||
|
builder.WriteString("(?, ?, NULL, ?, ?, ?, ?)")
|
||||||
args = append(args, log.OperationType, log.MaterialID, log.OldValue, log.NewValue, log.Operator, log.Description)
|
args = append(args, log.OperationType, log.MaterialID, log.OldValue, log.NewValue, log.Operator, log.Description)
|
||||||
}
|
}
|
||||||
_, err := tx.Exec(builder.String(), args...)
|
}
|
||||||
|
result, err := tx.Exec(builder.String(), args...)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
}
|
||||||
|
if withUserID {
|
||||||
|
rowsAffected, rowsErr := result.RowsAffected()
|
||||||
|
if rowsErr != nil {
|
||||||
|
return rowsErr
|
||||||
|
}
|
||||||
|
if rowsAffected != int64(len(logs)) {
|
||||||
|
return fmt.Errorf("%w: expected %d rows, inserted %d", ErrMaterialLogOperatorNotFound, len(logs), rowsAffected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetByMaterialID 只返回属于当前公司的该物料日志;物料不属于本公司时结果为空。
|
// GetByMaterialID 只返回属于当前公司的该物料日志;物料不属于本公司时结果为空。
|
||||||
func (r *MaterialLogRepo) GetByMaterialID(organizationID, materialID int64) ([]model.MaterialLog, error) {
|
func (r *MaterialLogRepo) GetByMaterialID(organizationID, materialID int64) ([]model.MaterialLog, error) {
|
||||||
query := `SELECT ml.id, ml.operation_type, ml.material_id, ml.old_value, ml.new_value, ml.operator, ml.description, ml.created_at
|
query := `SELECT ml.id, ml.operation_type, ml.material_id, ml.operator_id, ml.old_value, ml.new_value, ml.operator, ml.description, ml.created_at
|
||||||
FROM material_logs ml JOIN materials m ON m.id = ml.material_id
|
FROM material_logs ml JOIN materials m ON m.id = ml.material_id
|
||||||
WHERE m.organization_id = ? AND ml.material_id = ? ORDER BY ml.id DESC`
|
WHERE m.organization_id = ? AND ml.material_id = ? ORDER BY ml.id DESC`
|
||||||
rows, err := database.DB.Query(query, organizationID, materialID)
|
rows, err := database.DB.Query(query, organizationID, materialID)
|
||||||
|
|
@ -161,14 +222,18 @@ func (r *MaterialLogRepo) GetByMaterialID(organizationID, materialID int64) ([]m
|
||||||
var logs []model.MaterialLog
|
var logs []model.MaterialLog
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var log model.MaterialLog
|
var log model.MaterialLog
|
||||||
|
var operatorID sql.NullInt64
|
||||||
var oldValue, newValue []byte
|
var oldValue, newValue []byte
|
||||||
err := rows.Scan(&log.ID, &log.OperationType, &log.MaterialID, &oldValue, &newValue, &log.Operator, &log.Description, &log.CreatedAt)
|
err := rows.Scan(&log.ID, &log.OperationType, &log.MaterialID, &operatorID, &oldValue, &newValue, &log.Operator, &log.Description, &log.CreatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if oldValue != nil {
|
if oldValue != nil {
|
||||||
log.OldValue = string(oldValue)
|
log.OldValue = string(oldValue)
|
||||||
}
|
}
|
||||||
|
if operatorID.Valid {
|
||||||
|
log.OperatorID = &operatorID.Int64
|
||||||
|
}
|
||||||
if newValue != nil {
|
if newValue != nil {
|
||||||
log.NewValue = string(newValue)
|
log.NewValue = string(newValue)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ func TestMaterialRepoGetByIDReturnsOnlyMasterData(t *testing.T) {
|
||||||
})
|
})
|
||||||
database.DB = db
|
database.DB = db
|
||||||
|
|
||||||
query := `SELECT m.id, m.organization_id, m.material_code, m.name, m.material_image, m.spec, m.manufacturer_model,
|
query := `SELECT m.id, m.organization_id, m.material_code, m.name, m.material_image, m.spec, m.manufacturer_model, m.source, m.remark,
|
||||||
m.supplier_id, m.manufacturer_id, COALESCE(sb.legal_name, ''), COALESCE(mb.legal_name, ''),
|
m.supplier_id, m.manufacturer_id, COALESCE(sb.legal_name, ''), COALESCE(mb.legal_name, ''),
|
||||||
m.category_id, m.base_unit_id, m.unit, m.status, m.version, m.created_at, m.updated_at
|
m.category_id, m.base_unit_id, m.unit, m.status, m.version, m.created_at, m.updated_at
|
||||||
FROM materials m
|
FROM materials m
|
||||||
|
|
@ -34,8 +34,8 @@ func TestMaterialRepoGetByIDReturnsOnlyMasterData(t *testing.T) {
|
||||||
WHERE m.organization_id = ? AND m.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", "supplier_id", "manufacturer_id", "supplier_name", "manufacturer_name", "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", "source", "remark", "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, nil, nil, "", "", 12, 3, "件", "active", 2, now, now),
|
AddRow(7, 1, "MAT-007", "轴承", nil, "6205", nil, nil, nil, nil, nil, "", "", 12, 3, "件", "active", 2, now, now),
|
||||||
)
|
)
|
||||||
|
|
||||||
var material model.Material
|
var material model.Material
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ CREATE TABLE inbound_orders (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_
|
||||||
CREATE TABLE inbound_items (id BIGINT AUTO_INCREMENT PRIMARY KEY, inbound_order_id BIGINT NOT NULL, material_id BIGINT NOT NULL, quantity INT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_inbound_items_order(inbound_order_id), FOREIGN KEY(inbound_order_id) REFERENCES inbound_orders(id) ON DELETE CASCADE, FOREIGN KEY(material_id) REFERENCES materials(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
CREATE TABLE inbound_items (id BIGINT AUTO_INCREMENT PRIMARY KEY, inbound_order_id BIGINT NOT NULL, material_id BIGINT NOT NULL, quantity INT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_inbound_items_order(inbound_order_id), FOREIGN KEY(inbound_order_id) REFERENCES inbound_orders(id) ON DELETE CASCADE, FOREIGN KEY(material_id) REFERENCES materials(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
CREATE TABLE outbound_orders (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL DEFAULT 1, warehouse_id BIGINT NOT NULL DEFAULT 1, location_id BIGINT NOT NULL DEFAULT 1, outbound_no VARCHAR(32) UNIQUE, operator VARCHAR(100), outbound_date DATETIME NOT NULL, handler VARCHAR(100) NOT NULL, receiver VARCHAR(100) NOT NULL, remark TEXT, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_outbound_orders_date(outbound_date), FOREIGN KEY(organization_id) REFERENCES organizations(id), FOREIGN KEY(warehouse_id) REFERENCES warehouses(id), FOREIGN KEY(location_id) REFERENCES warehouse_locations(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
CREATE TABLE outbound_orders (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL DEFAULT 1, warehouse_id BIGINT NOT NULL DEFAULT 1, location_id BIGINT NOT NULL DEFAULT 1, outbound_no VARCHAR(32) UNIQUE, operator VARCHAR(100), outbound_date DATETIME NOT NULL, handler VARCHAR(100) NOT NULL, receiver VARCHAR(100) NOT NULL, remark TEXT, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_outbound_orders_date(outbound_date), FOREIGN KEY(organization_id) REFERENCES organizations(id), FOREIGN KEY(warehouse_id) REFERENCES warehouses(id), FOREIGN KEY(location_id) REFERENCES warehouse_locations(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
CREATE TABLE outbound_items (id BIGINT AUTO_INCREMENT PRIMARY KEY, outbound_order_id BIGINT NOT NULL, material_id BIGINT NOT NULL, quantity INT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_outbound_items_order(outbound_order_id), FOREIGN KEY(outbound_order_id) REFERENCES outbound_orders(id) ON DELETE CASCADE, FOREIGN KEY(material_id) REFERENCES materials(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
CREATE TABLE outbound_items (id BIGINT AUTO_INCREMENT PRIMARY KEY, outbound_order_id BIGINT NOT NULL, material_id BIGINT NOT NULL, quantity INT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_outbound_items_order(outbound_order_id), FOREIGN KEY(outbound_order_id) REFERENCES outbound_orders(id) ON DELETE CASCADE, FOREIGN KEY(material_id) REFERENCES materials(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
CREATE TABLE material_logs (id BIGINT AUTO_INCREMENT PRIMARY KEY, operation_type VARCHAR(50) NOT NULL, material_id BIGINT NOT NULL, old_value TEXT, new_value TEXT, operator VARCHAR(100), description TEXT, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_material_logs_material(material_id), FOREIGN KEY(material_id) REFERENCES materials(id) ON DELETE CASCADE) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
CREATE TABLE material_logs (id BIGINT AUTO_INCREMENT PRIMARY KEY, operation_type VARCHAR(50) NOT NULL, material_id BIGINT NOT NULL, operator_id BIGINT NULL, old_value TEXT, new_value TEXT, operator VARCHAR(100), description TEXT, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_material_logs_material(material_id), KEY idx_material_logs_operator(operator_id), FOREIGN KEY(material_id) REFERENCES materials(id) ON DELETE CASCADE) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
CREATE TABLE stock_logs (id BIGINT AUTO_INCREMENT PRIMARY KEY, material_id BIGINT NOT NULL, type ENUM('in','out') NOT NULL, quantity INT NOT NULL, related_order_type VARCHAR(50) NOT NULL, related_order_id BIGINT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_stock_logs_material(material_id), KEY idx_stock_logs_related_order(related_order_type,related_order_id), FOREIGN KEY(material_id) REFERENCES materials(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
CREATE TABLE stock_logs (id BIGINT AUTO_INCREMENT PRIMARY KEY, material_id BIGINT NOT NULL, type ENUM('in','out') NOT NULL, quantity INT NOT NULL, related_order_type VARCHAR(50) NOT NULL, related_order_id BIGINT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_stock_logs_material(material_id), KEY idx_stock_logs_related_order(related_order_type,related_order_id), FOREIGN KEY(material_id) REFERENCES materials(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
CREATE TABLE material_applications (id BIGINT AUTO_INCREMENT PRIMARY KEY, user_id BIGINT NOT NULL, purpose VARCHAR(500) NOT NULL DEFAULT '', status ENUM('pending','approved','rejected','cancelled') NOT NULL DEFAULT 'pending', rejection_reason VARCHAR(500) NOT NULL DEFAULT '', approved_by BIGINT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, FOREIGN KEY(user_id) REFERENCES users(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
CREATE TABLE material_applications (id BIGINT AUTO_INCREMENT PRIMARY KEY, user_id BIGINT NOT NULL, purpose VARCHAR(500) NOT NULL DEFAULT '', status ENUM('pending','approved','rejected','cancelled') NOT NULL DEFAULT 'pending', rejection_reason VARCHAR(500) NOT NULL DEFAULT '', approved_by BIGINT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, FOREIGN KEY(user_id) REFERENCES users(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
CREATE TABLE application_items (id BIGINT AUTO_INCREMENT PRIMARY KEY, application_id BIGINT NOT NULL, material_id BIGINT NOT NULL, quantity INT NOT NULL, FOREIGN KEY(application_id) REFERENCES material_applications(id) ON DELETE CASCADE, FOREIGN KEY(material_id) REFERENCES materials(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
CREATE TABLE application_items (id BIGINT AUTO_INCREMENT PRIMARY KEY, application_id BIGINT NOT NULL, material_id BIGINT NOT NULL, quantity INT NOT NULL, FOREIGN KEY(application_id) REFERENCES material_applications(id) ON DELETE CASCADE, FOREIGN KEY(material_id) REFERENCES materials(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"log"
|
"log"
|
||||||
|
"strings"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/wmms/wmms/internal/model"
|
"github.com/wmms/wmms/internal/model"
|
||||||
|
|
@ -14,12 +15,19 @@ 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 ErrInvalidMaterialSource = errors.New("物料来源只能填写外购或外协")
|
||||||
|
var ErrMaterialRemarkTooLong = errors.New("物料备注不能超过200字")
|
||||||
var ErrInvalidMaterialSupplier = errors.New("请选择本公司的有效供应商")
|
var ErrInvalidMaterialSupplier = errors.New("请选择本公司的有效供应商")
|
||||||
var ErrInvalidMaterialManufacturer = errors.New("请选择本公司的有效制造商")
|
var ErrInvalidMaterialManufacturer = errors.New("请选择本公司的有效制造商")
|
||||||
var ErrMaterialDuplicate = errors.New("发现名称、规格、单位、供应商和制造商均相同的物料,请先检查重复项")
|
var ErrMaterialDuplicate = errors.New("发现名称、规格、单位、供应商和制造商均相同的物料,请先检查重复项")
|
||||||
|
|
||||||
const maxMaterialNameLength = 200
|
const maxMaterialNameLength = 200
|
||||||
const maxMaterialSpecLength = 200
|
const maxMaterialSpecLength = 200
|
||||||
|
const maxMaterialRemarkLength = 200
|
||||||
|
|
||||||
|
func validMaterialSource(source string) bool {
|
||||||
|
return source == "" || source == "外购" || source == "外协"
|
||||||
|
}
|
||||||
|
|
||||||
// ErrInvalidMaterialUnit 表示物料引用的基本单位无效:要么不属于当前公司,
|
// ErrInvalidMaterialUnit 表示物料引用的基本单位无效:要么不属于当前公司,
|
||||||
// 要么已停用。创建以及"改选"单位时都要求是本公司启用中的单位。
|
// 要么已停用。创建以及"改选"单位时都要求是本公司启用中的单位。
|
||||||
|
|
@ -101,6 +109,10 @@ func optionalMaterialID(id *int64) *int64 {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *MaterialService) Create(req *model.CreateMaterialRequest, operator string) (*model.Material, error) {
|
func (s *MaterialService) Create(req *model.CreateMaterialRequest, operator string) (*model.Material, error) {
|
||||||
|
return s.CreateWithOperatorID(req, nil, operator)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MaterialService) CreateWithOperatorID(req *model.CreateMaterialRequest, operatorID *int64, operator string) (*model.Material, error) {
|
||||||
organizationID := req.OrganizationID
|
organizationID := req.OrganizationID
|
||||||
if organizationID <= 0 {
|
if organizationID <= 0 {
|
||||||
return nil, ErrInvalidOrganizationID
|
return nil, ErrInvalidOrganizationID
|
||||||
|
|
@ -111,6 +123,14 @@ func (s *MaterialService) Create(req *model.CreateMaterialRequest, operator stri
|
||||||
if utf8.RuneCountInString(req.Spec) > maxMaterialSpecLength {
|
if utf8.RuneCountInString(req.Spec) > maxMaterialSpecLength {
|
||||||
return nil, ErrMaterialSpecTooLong
|
return nil, ErrMaterialSpecTooLong
|
||||||
}
|
}
|
||||||
|
req.Source = strings.TrimSpace(req.Source)
|
||||||
|
req.Remark = strings.TrimSpace(req.Remark)
|
||||||
|
if !validMaterialSource(req.Source) {
|
||||||
|
return nil, ErrInvalidMaterialSource
|
||||||
|
}
|
||||||
|
if utf8.RuneCountInString(req.Remark) > maxMaterialRemarkLength {
|
||||||
|
return nil, ErrMaterialRemarkTooLong
|
||||||
|
}
|
||||||
if err := s.validateCategory(req.CategoryID, organizationID); err != nil {
|
if err := s.validateCategory(req.CategoryID, organizationID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -154,6 +174,8 @@ func (s *MaterialService) Create(req *model.CreateMaterialRequest, operator stri
|
||||||
SupplierID: optionalMaterialID(req.SupplierID),
|
SupplierID: optionalMaterialID(req.SupplierID),
|
||||||
ManufacturerID: optionalMaterialID(req.ManufacturerID),
|
ManufacturerID: optionalMaterialID(req.ManufacturerID),
|
||||||
Spec: req.Spec,
|
Spec: req.Spec,
|
||||||
|
Source: req.Source,
|
||||||
|
Remark: req.Remark,
|
||||||
CategoryID: req.CategoryID,
|
CategoryID: req.CategoryID,
|
||||||
BaseUnitID: req.BaseUnitID,
|
BaseUnitID: req.BaseUnitID,
|
||||||
Unit: unit,
|
Unit: unit,
|
||||||
|
|
@ -172,7 +194,7 @@ func (s *MaterialService) Create(req *model.CreateMaterialRequest, operator stri
|
||||||
}
|
}
|
||||||
|
|
||||||
// 记录日志
|
// 记录日志
|
||||||
if err := s.logSvc.LogCreate(m, operator); err != nil {
|
if err := s.logSvc.LogCreateWithOperatorID(m, operatorID, operator); err != nil {
|
||||||
log.Printf("Warning: failed to create material log: %v", err)
|
log.Printf("Warning: failed to create material log: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -183,6 +205,10 @@ func (s *MaterialService) Create(req *model.CreateMaterialRequest, operator stri
|
||||||
// 以 sql.ErrNoRows 冒到接口,映射成 404。状态更新与变更日志在同一事务里完成,
|
// 以 sql.ErrNoRows 冒到接口,映射成 404。状态更新与变更日志在同一事务里完成,
|
||||||
// 日志写失败则整体回滚,不会出现"改了状态却没有日志"。
|
// 日志写失败则整体回滚,不会出现"改了状态却没有日志"。
|
||||||
func (s *MaterialService) Obsolete(organizationID, id int64, operator string) error {
|
func (s *MaterialService) Obsolete(organizationID, id int64, operator string) error {
|
||||||
|
return s.ObsoleteWithOperatorID(organizationID, id, nil, operator)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MaterialService) ObsoleteWithOperatorID(organizationID, id int64, operatorID *int64, operator string) error {
|
||||||
material, err := s.GetByID(organizationID, id)
|
material, err := s.GetByID(organizationID, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -200,7 +226,7 @@ func (s *MaterialService) Obsolete(organizationID, id int64, operator string) er
|
||||||
updated := *material
|
updated := *material
|
||||||
updated.Status = "obsolete"
|
updated.Status = "obsolete"
|
||||||
updated.Version++
|
updated.Version++
|
||||||
if err := s.logSvc.LogUpdateTx(tx, &updated, material, operator); err != nil {
|
if err := s.logSvc.LogUpdateTxWithOperatorID(tx, &updated, material, operatorID, operator); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
|
|
@ -208,6 +234,10 @@ func (s *MaterialService) Obsolete(organizationID, id int64, operator string) er
|
||||||
|
|
||||||
// Restore 恢复本公司物料,语义与 Obsolete 对称。
|
// Restore 恢复本公司物料,语义与 Obsolete 对称。
|
||||||
func (s *MaterialService) Restore(organizationID, id int64, operator string) error {
|
func (s *MaterialService) Restore(organizationID, id int64, operator string) error {
|
||||||
|
return s.RestoreWithOperatorID(organizationID, id, nil, operator)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MaterialService) RestoreWithOperatorID(organizationID, id int64, operatorID *int64, operator string) error {
|
||||||
material, err := s.GetByID(organizationID, id)
|
material, err := s.GetByID(organizationID, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -225,7 +255,7 @@ func (s *MaterialService) Restore(organizationID, id int64, operator string) err
|
||||||
updated := *material
|
updated := *material
|
||||||
updated.Status = "active"
|
updated.Status = "active"
|
||||||
updated.Version++
|
updated.Version++
|
||||||
if err := s.logSvc.LogUpdateTx(tx, &updated, material, operator); err != nil {
|
if err := s.logSvc.LogUpdateTxWithOperatorID(tx, &updated, material, operatorID, operator); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
|
|
@ -255,6 +285,10 @@ func (s *MaterialService) FindExactDuplicates(organizationID int64, req *model.M
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
||||||
|
return s.UpdateWithOperatorID(organizationID, id, req, nil, operator)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MaterialService) UpdateWithOperatorID(organizationID, id int64, req *model.UpdateMaterialRequest, operatorID *int64, operator string) (*model.Material, error) {
|
||||||
// 获取修改前的数据;不存在或不属于本公司时直接以 sql.ErrNoRows 结束。
|
// 获取修改前的数据;不存在或不属于本公司时直接以 sql.ErrNoRows 结束。
|
||||||
oldMaterial, err := s.GetByID(organizationID, id)
|
oldMaterial, err := s.GetByID(organizationID, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -266,6 +300,20 @@ func (s *MaterialService) Update(organizationID, id int64, req *model.UpdateMate
|
||||||
if utf8.RuneCountInString(req.Spec) > maxMaterialSpecLength {
|
if utf8.RuneCountInString(req.Spec) > maxMaterialSpecLength {
|
||||||
return nil, ErrMaterialSpecTooLong
|
return nil, ErrMaterialSpecTooLong
|
||||||
}
|
}
|
||||||
|
if req.Source != nil {
|
||||||
|
source := strings.TrimSpace(*req.Source)
|
||||||
|
req.Source = &source
|
||||||
|
if !validMaterialSource(source) {
|
||||||
|
return nil, ErrInvalidMaterialSource
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if req.Remark != nil {
|
||||||
|
remark := strings.TrimSpace(*req.Remark)
|
||||||
|
req.Remark = &remark
|
||||||
|
if utf8.RuneCountInString(remark) > maxMaterialRemarkLength {
|
||||||
|
return nil, ErrMaterialRemarkTooLong
|
||||||
|
}
|
||||||
|
}
|
||||||
if req.CategoryID == nil || *req.CategoryID <= 0 {
|
if req.CategoryID == nil || *req.CategoryID <= 0 {
|
||||||
return nil, ErrInvalidMaterialCategory
|
return nil, ErrInvalidMaterialCategory
|
||||||
}
|
}
|
||||||
|
|
@ -303,7 +351,7 @@ func (s *MaterialService) Update(organizationID, id int64, req *model.UpdateMate
|
||||||
}
|
}
|
||||||
|
|
||||||
// 记录日志
|
// 记录日志
|
||||||
if err := s.logSvc.LogUpdate(newMaterial, oldMaterial, operator); err != nil {
|
if err := s.logSvc.LogUpdateWithOperatorID(newMaterial, oldMaterial, operatorID, operator); err != nil {
|
||||||
log.Printf("Warning: failed to create material log: %v", err)
|
log.Printf("Warning: failed to create material log: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -311,6 +359,10 @@ func (s *MaterialService) Update(organizationID, id int64, req *model.UpdateMate
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *MaterialService) Delete(organizationID, id int64, operator string) error {
|
func (s *MaterialService) Delete(organizationID, id int64, operator string) error {
|
||||||
|
return s.DeleteWithOperatorID(organizationID, id, nil, operator)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MaterialService) DeleteWithOperatorID(organizationID, id int64, operatorID *int64, operator string) error {
|
||||||
// 获取删除前的数据;不存在或不属于本公司时不再继续。
|
// 获取删除前的数据;不存在或不属于本公司时不再继续。
|
||||||
material, err := s.GetByID(organizationID, id)
|
material, err := s.GetByID(organizationID, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -322,7 +374,7 @@ func (s *MaterialService) Delete(organizationID, id int64, operator string) erro
|
||||||
}
|
}
|
||||||
|
|
||||||
// 记录日志
|
// 记录日志
|
||||||
if err := s.logSvc.LogDelete(material, operator); err != nil {
|
if err := s.logSvc.LogDeleteWithOperatorID(material, operatorID, operator); err != nil {
|
||||||
log.Printf("Warning: failed to create material log: %v", err)
|
log.Printf("Warning: failed to create material log: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/wmms/wmms/internal/model"
|
"github.com/wmms/wmms/internal/model"
|
||||||
"github.com/wmms/wmms/internal/repository"
|
"github.com/wmms/wmms/internal/repository"
|
||||||
|
|
@ -46,6 +47,10 @@ func NewMaterialImportService() *MaterialImportService {
|
||||||
//
|
//
|
||||||
// 仅在事务提交成功后写一条成功导入记录,失败不保存历史记录。
|
// 仅在事务提交成功后写一条成功导入记录,失败不保存历史记录。
|
||||||
func (s *MaterialImportService) Import(organizationID int64, operator, fileName string, r io.Reader) (*model.MaterialImportResult, error) {
|
func (s *MaterialImportService) Import(organizationID int64, operator, fileName string, r io.Reader) (*model.MaterialImportResult, error) {
|
||||||
|
return s.ImportWithOperatorID(organizationID, nil, operator, fileName, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MaterialImportService) ImportWithOperatorID(organizationID int64, operatorID *int64, operator, fileName string, r io.Reader) (*model.MaterialImportResult, error) {
|
||||||
if organizationID <= 0 {
|
if organizationID <= 0 {
|
||||||
return nil, ErrInvalidOrganizationID
|
return nil, ErrInvalidOrganizationID
|
||||||
}
|
}
|
||||||
|
|
@ -69,7 +74,7 @@ func (s *MaterialImportService) Import(organizationID int64, operator, fileName
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.persist(organizationID, operator, materials); err != nil {
|
if err := s.persist(organizationID, operatorID, operator, materials); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -166,6 +171,8 @@ func (s *MaterialImportService) validateRows(organizationID int64, rows []model.
|
||||||
|
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
reasons := make([]string, 0, 3)
|
reasons := make([]string, 0, 3)
|
||||||
|
source := strings.TrimSpace(row.Source)
|
||||||
|
remark := strings.TrimSpace(row.Remark)
|
||||||
|
|
||||||
name := row.Name
|
name := row.Name
|
||||||
if name == "" {
|
if name == "" {
|
||||||
|
|
@ -176,6 +183,12 @@ func (s *MaterialImportService) validateRows(organizationID int64, rows []model.
|
||||||
if len([]rune(row.Spec)) > 200 {
|
if len([]rune(row.Spec)) > 200 {
|
||||||
reasons = append(reasons, "规格超过 200 字")
|
reasons = append(reasons, "规格超过 200 字")
|
||||||
}
|
}
|
||||||
|
if !validMaterialSource(source) {
|
||||||
|
reasons = append(reasons, "来源只能填写外购或外协")
|
||||||
|
}
|
||||||
|
if utf8.RuneCountInString(remark) > maxMaterialRemarkLength {
|
||||||
|
reasons = append(reasons, "备注超过 200 字")
|
||||||
|
}
|
||||||
|
|
||||||
majorName := normalizeWhitespace(row.MajorName)
|
majorName := normalizeWhitespace(row.MajorName)
|
||||||
major := majors[majorName]
|
major := majors[majorName]
|
||||||
|
|
@ -291,6 +304,8 @@ func (s *MaterialImportService) validateRows(organizationID int64, rows []model.
|
||||||
OrganizationID: organizationID,
|
OrganizationID: organizationID,
|
||||||
Name: name,
|
Name: name,
|
||||||
Spec: row.Spec,
|
Spec: row.Spec,
|
||||||
|
Source: source,
|
||||||
|
Remark: remark,
|
||||||
SupplierID: supplierID,
|
SupplierID: supplierID,
|
||||||
ManufacturerID: manufacturerID,
|
ManufacturerID: manufacturerID,
|
||||||
CategoryID: &categoryID,
|
CategoryID: &categoryID,
|
||||||
|
|
@ -340,7 +355,7 @@ func partnerProblemText(err error, role, value string) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// persist 在单个事务里插入物料并写审计日志,任一步失败整批回滚。
|
// persist 在单个事务里插入物料并写审计日志,任一步失败整批回滚。
|
||||||
func (s *MaterialImportService) persist(organizationID int64, operator string, materials []*materialImportItem) error {
|
func (s *MaterialImportService) persist(organizationID int64, operatorID *int64, operator string, materials []*materialImportItem) error {
|
||||||
tx, err := repository.BeginTx()
|
tx, err := repository.BeginTx()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -368,6 +383,7 @@ func (s *MaterialImportService) persist(organizationID int64, operator string, m
|
||||||
logs = append(logs, &model.MaterialLog{
|
logs = append(logs, &model.MaterialLog{
|
||||||
OperationType: "CREATE",
|
OperationType: "CREATE",
|
||||||
MaterialID: material.ID,
|
MaterialID: material.ID,
|
||||||
|
OperatorID: operatorID,
|
||||||
NewValue: string(newValue),
|
NewValue: string(newValue),
|
||||||
Operator: operator,
|
Operator: operator,
|
||||||
Description: fmt.Sprintf("批量导入创建:%s(%s)\n物料编码:%s\n名称:%s\n规格:%s\n分类:%s\n单位:%s\n状态:%s",
|
Description: fmt.Sprintf("批量导入创建:%s(%s)\n物料编码:%s\n名称:%s\n规格:%s\n分类:%s\n单位:%s\n状态:%s",
|
||||||
|
|
|
||||||
|
|
@ -29,10 +29,10 @@ var (
|
||||||
)
|
)
|
||||||
|
|
||||||
// materialImportHeaders 是生成模板时的列顺序(含选填列)。
|
// materialImportHeaders 是生成模板时的列顺序(含选填列)。
|
||||||
var materialImportHeaders = []string{"物料名称", "规格", "单位", "供应商", "制造商", "大类", "小类"}
|
var materialImportHeaders = []string{"物料名称", "规格", "单位", "供应商", "制造商", "来源", "备注", "大类", "小类"}
|
||||||
|
|
||||||
// materialImportRequiredHeaders 是表头里必须存在的列;缺任何一列都判定为
|
// materialImportRequiredHeaders 是模板必需列。
|
||||||
// 模板不合法。规格、单位、供应商、制造商均为选填,用户删掉空列也不拦。
|
// 来源和备注可留空,也可删除整列。
|
||||||
var materialImportRequiredHeaders = []string{"物料名称", "大类", "小类"}
|
var materialImportRequiredHeaders = []string{"物料名称", "大类", "小类"}
|
||||||
|
|
||||||
// BuildMaterialImportTemplate 生成说明在前、空白数据表在后的导入模板。
|
// BuildMaterialImportTemplate 生成说明在前、空白数据表在后的导入模板。
|
||||||
|
|
@ -53,25 +53,28 @@ func BuildMaterialImportTemplate() ([]byte, error) {
|
||||||
{"单位", "选填", "个 / kg", "填写当前组织已启用的单位名称或编码;留空默认「个」。"},
|
{"单位", "选填", "个 / kg", "填写当前组织已启用的单位名称或编码;留空默认「个」。"},
|
||||||
{"供应商", "选填", "示例供应商或供应商编码", "填写当前组织已有供应商的名称或编码;留空表示不指定。"},
|
{"供应商", "选填", "示例供应商或供应商编码", "填写当前组织已有供应商的名称或编码;留空表示不指定。"},
|
||||||
{"制造商", "选填", "示例制造商或制造商编码", "填写当前组织已有制造商的名称或编码;留空表示不指定。"},
|
{"制造商", "选填", "示例制造商或制造商编码", "填写当前组织已有制造商的名称或编码;留空表示不指定。"},
|
||||||
|
{"来源", "选填", "外购 / 外协", "只能填写外购或外协;可留空。"},
|
||||||
|
{"备注", "选填", "物料用途说明", "可留空,最多 200 字。"},
|
||||||
{"大类", "必填", "机械结构类", "填写当前组织已启用的大类名称、编码或「编码 名称」。"},
|
{"大类", "必填", "机械结构类", "填写当前组织已启用的大类名称、编码或「编码 名称」。"},
|
||||||
{"小类", "必填", "标准件", "填写所选大类下的小类名称、编码或「编码 名称」;小类须配置启用的编码规则。"},
|
{"小类", "必填", "标准件", "填写所选大类下的小类名称、编码或「编码 名称」;小类须配置启用的编码规则。"},
|
||||||
}
|
}
|
||||||
sections := map[int]string{1: "物料批量导入 · 填写说明", 4: "一、字段填写规则", 14: "二、填写与上传步骤", 20: "三、导入注意事项", 27: "四、填写示例(仅供参考)"}
|
sections := map[int]string{1: "物料批量导入 · 填写说明", 4: "一、字段填写规则", 16: "二、填写与上传步骤", 22: "三、导入注意事项", 29: "四、填写示例(仅供参考)"}
|
||||||
notes := map[int]string{
|
notes := map[int]string{
|
||||||
2: "先阅读本页,再切换到第二个工作表「物料数据」填写。",
|
2: "先阅读本页,再切换到第二个工作表「物料数据」填写。",
|
||||||
15: "1. 确认页面当前组织,核对该组织的分类、单位、供应商、制造商及小类编码规则。",
|
17: "1. 确认页面当前组织,核对该组织的分类、单位、供应商、制造商及小类编码规则。",
|
||||||
16: "2. 在「物料数据」从第 2 行开始填写;第 1 行表头及工作表名称请保留。",
|
18: "2. 在「物料数据」从第 2 行开始填写;第 1 行表头及工作表名称请保留。",
|
||||||
17: "3. 保存为 .xlsx 文件,回到「物料」页面点击「批量导入」并选择文件。",
|
19: "3. 保存为 .xlsx 文件,回到「物料」页面点击「批量导入」并选择文件。",
|
||||||
18: "4. 有错误时查看行号和原因,可导出错误明细;修正原 .xlsx 后重新上传。",
|
20: "4. 有错误时查看行号和原因,可导出错误明细;修正原 .xlsx 后重新上传。",
|
||||||
21: "文件限制:仅支持 .xlsx,大小不超过 1MB;非空数据最多 10000 行,表头和全空行不计数。",
|
23: "文件限制:仅支持 .xlsx,大小不超过 1MB;非空数据最多 10000 行,表头和全空行不计数。",
|
||||||
22: "整批校验:任意一行校验失败(包含完全重复物料),本次所有物料均不导入;请全部修正后重传。",
|
24: "整批校验:任意一行校验失败(包含完全重复物料),本次所有物料均不导入;请全部修正后重传。",
|
||||||
23: "重复检查:名称、规格、单位、供应商和制造商全部相同才判为重复;同一文件内也会检查。",
|
25: "重复检查:名称、规格、单位、供应商和制造商全部相同才判为重复;同一文件内也会检查。",
|
||||||
24: "编码与库存:物料编码由系统生成,无需填写;仅新增物料,不增加库存,也不更新已有物料。",
|
26: "编码与库存:物料编码由系统生成,无需填写;仅新增物料,不增加库存,也不更新已有物料。",
|
||||||
25: "默认单位:单位留空时使用「个」;当前组织未启用「个」时,请填写其他有效单位。",
|
27: "默认单位:单位留空时使用「个」;当前组织未启用「个」时,请填写其他有效单位。",
|
||||||
32: "示例只展示写法,请替换为当前组织实际启用的数据;示例不在数据页,不会自动导入。",
|
28: "来源和备注均可留空;来源仅支持外购或外协,备注最多 200 字。",
|
||||||
|
34: "示例只展示写法,请替换为当前组织实际启用的数据;示例不在数据页,不会自动导入。",
|
||||||
}
|
}
|
||||||
for row, text := range sections {
|
for row, text := range sections {
|
||||||
if err := f.MergeCell(MaterialImportSheetGuide, fmt.Sprintf("A%d", row), fmt.Sprintf("G%d", row)); err != nil {
|
if err := f.MergeCell(MaterialImportSheetGuide, fmt.Sprintf("A%d", row), fmt.Sprintf("I%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 {
|
||||||
|
|
@ -79,7 +82,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("G%d", row)); err != nil {
|
if err := f.MergeCell(MaterialImportSheetGuide, fmt.Sprintf("A%d", row), fmt.Sprintf("I%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 {
|
||||||
|
|
@ -94,14 +97,14 @@ func BuildMaterialImportTemplate() ([]byte, error) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := f.MergeCell(MaterialImportSheetGuide, fmt.Sprintf("D%d", i+5), fmt.Sprintf("G%d", i+5)); err != nil {
|
if err := f.MergeCell(MaterialImportSheetGuide, fmt.Sprintf("D%d", i+5), fmt.Sprintf("I%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+28)
|
cell, _ := excelize.CoordinatesToCellName(j+1, i+30)
|
||||||
if err := f.SetCellValue(MaterialImportSheetGuide, cell, text); err != nil {
|
if err := f.SetCellValue(MaterialImportSheetGuide, cell, text); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -142,64 +145,64 @@ func styleMaterialImportTemplate(f *excelize.File) error {
|
||||||
}
|
}
|
||||||
ids[i] = id
|
ids[i] = id
|
||||||
}
|
}
|
||||||
if err := f.SetCellStyle(MaterialImportSheetGuide, "A1", "G32", ids[0]); err != nil {
|
if err := f.SetCellStyle(MaterialImportSheetGuide, "A1", "I34", ids[0]); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := f.SetCellStyle(MaterialImportSheetGuide, "A2", "G2", ids[5]); err != nil {
|
if err := f.SetCellStyle(MaterialImportSheetGuide, "A2", "I2", ids[5]); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := f.SetCellStyle(MaterialImportSheetGuide, "A1", "G1", ids[1]); err != nil {
|
if err := f.SetCellStyle(MaterialImportSheetGuide, "A1", "I1", ids[1]); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for _, row := range []int{4, 14, 20, 27} {
|
for _, row := range []int{4, 16, 22, 29} {
|
||||||
if err := f.SetCellStyle(MaterialImportSheetGuide, fmt.Sprintf("A%d", row), fmt.Sprintf("G%d", row), ids[2]); err != nil {
|
if err := f.SetCellStyle(MaterialImportSheetGuide, fmt.Sprintf("A%d", row), fmt.Sprintf("I%d", row), ids[2]); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, row := range []int{5, 28} {
|
for _, row := range []int{5, 30} {
|
||||||
if err := f.SetCellStyle(MaterialImportSheetGuide, fmt.Sprintf("A%d", row), fmt.Sprintf("G%d", row), ids[3]); err != nil {
|
if err := f.SetCellStyle(MaterialImportSheetGuide, fmt.Sprintf("A%d", row), fmt.Sprintf("I%d", row), ids[3]); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := f.SetCellStyle(MaterialImportSheetGuide, "A6", "A12", ids[6]); err != nil {
|
if err := f.SetCellStyle(MaterialImportSheetGuide, "A6", "A14", ids[6]); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for _, cell := range []string{"B6", "B11", "B12"} {
|
for _, cell := range []string{"B6", "B13", "B14"} {
|
||||||
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, 28, 18, 34, 18} {
|
for i, width := range []float64{20, 12, 24, 28, 18, 16, 28, 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 <= 32; row++ {
|
for row := 1; row <= 34; row++ {
|
||||||
height := 30.0
|
height := 30.0
|
||||||
if row == 1 {
|
if row == 1 {
|
||||||
height = 42
|
height = 42
|
||||||
}
|
}
|
||||||
if row >= 6 && row <= 12 {
|
if row >= 6 && row <= 14 {
|
||||||
height = 54
|
height = 54
|
||||||
}
|
}
|
||||||
if row >= 21 && row <= 25 || row == 32 {
|
if row >= 23 && row <= 28 || row == 34 {
|
||||||
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:G", ids[4]); err != nil {
|
if err := f.SetColStyle(MaterialImportSheetData, "A:I", ids[4]); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := f.SetCellStyle(MaterialImportSheetData, "A1", "G1", ids[3]); err != nil {
|
if err := f.SetCellStyle(MaterialImportSheetData, "A1", "I1", 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, 28, 28, 26, 18} {
|
for i, width := range []float64{34, 28, 16, 28, 28, 16, 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
|
||||||
|
|
@ -265,10 +268,12 @@ func ParseMaterialImportFile(r io.Reader) ([]model.MaterialImportRow, error) {
|
||||||
Unit: materialImportColumnValue(raw, columns, "单位"),
|
Unit: materialImportColumnValue(raw, columns, "单位"),
|
||||||
Supplier: materialImportColumnValue(raw, columns, "供应商"),
|
Supplier: materialImportColumnValue(raw, columns, "供应商"),
|
||||||
Manufacturer: materialImportColumnValue(raw, columns, "制造商"),
|
Manufacturer: materialImportColumnValue(raw, columns, "制造商"),
|
||||||
|
Source: materialImportColumnValue(raw, columns, "来源"),
|
||||||
|
Remark: 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.Supplier == "" && row.Manufacturer == "" && row.MajorName == "" && row.MinorName == "" {
|
if row.Name == "" && row.Spec == "" && row.Unit == "" && row.Supplier == "" && row.Manufacturer == "" && row.Source == "" && row.Remark == "" && row.MajorName == "" && row.MinorName == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if len(rows) == MaterialImportMaxRows {
|
if len(rows) == MaterialImportMaxRows {
|
||||||
|
|
|
||||||
|
|
@ -22,10 +22,15 @@ func NewMaterialLogService() *MaterialLogService {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *MaterialLogService) LogCreate(material *model.Material, operator string) error {
|
func (s *MaterialLogService) LogCreate(material *model.Material, operator string) error {
|
||||||
|
return s.LogCreateWithOperatorID(material, nil, operator)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MaterialLogService) LogCreateWithOperatorID(material *model.Material, operatorID *int64, operator string) error {
|
||||||
newValue, _ := json.Marshal(material)
|
newValue, _ := json.Marshal(material)
|
||||||
log := &model.MaterialLog{
|
log := &model.MaterialLog{
|
||||||
OperationType: "CREATE",
|
OperationType: "CREATE",
|
||||||
MaterialID: material.ID,
|
MaterialID: material.ID,
|
||||||
|
OperatorID: operatorID,
|
||||||
NewValue: string(newValue),
|
NewValue: string(newValue),
|
||||||
Operator: operator,
|
Operator: operator,
|
||||||
Description: fmt.Sprintf("新增物料:%s", material.Name),
|
Description: fmt.Sprintf("新增物料:%s", material.Name),
|
||||||
|
|
@ -34,28 +39,37 @@ func (s *MaterialLogService) LogCreate(material *model.Material, operator string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *MaterialLogService) LogUpdate(material *model.Material, oldMaterial *model.Material, operator string) error {
|
func (s *MaterialLogService) LogUpdate(material *model.Material, oldMaterial *model.Material, operator string) error {
|
||||||
|
return s.LogUpdateWithOperatorID(material, oldMaterial, nil, operator)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MaterialLogService) LogUpdateWithOperatorID(material *model.Material, oldMaterial *model.Material, operatorID *int64, operator string) error {
|
||||||
// 上层取「修改后」的物料失败时仍会走到这里,material 为 nil,取字段会 panic
|
// 上层取「修改后」的物料失败时仍会走到这里,material 为 nil,取字段会 panic
|
||||||
if material == nil {
|
if material == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return s.logRepo.Create(buildUpdateLog(material, oldMaterial, operator))
|
return s.logRepo.Create(buildUpdateLog(material, oldMaterial, operatorID, operator))
|
||||||
}
|
}
|
||||||
|
|
||||||
// LogUpdateTx 在调用方事务内写修改日志,供作废、恢复等要求主记录与日志原子提交的
|
// LogUpdateTx 在调用方事务内写修改日志,供作废、恢复等要求主记录与日志原子提交的
|
||||||
// 场景使用;material 为 nil 时同样跳过,避免解引用 panic。
|
// 场景使用;material 为 nil 时同样跳过,避免解引用 panic。
|
||||||
func (s *MaterialLogService) LogUpdateTx(tx *sql.Tx, material *model.Material, oldMaterial *model.Material, operator string) error {
|
func (s *MaterialLogService) LogUpdateTx(tx *sql.Tx, material *model.Material, oldMaterial *model.Material, operator string) error {
|
||||||
|
return s.LogUpdateTxWithOperatorID(tx, material, oldMaterial, nil, operator)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MaterialLogService) LogUpdateTxWithOperatorID(tx *sql.Tx, material *model.Material, oldMaterial *model.Material, operatorID *int64, operator string) error {
|
||||||
if material == nil {
|
if material == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return s.logRepo.CreateTx(tx, buildUpdateLog(material, oldMaterial, operator))
|
return s.logRepo.CreateTx(tx, buildUpdateLog(material, oldMaterial, operatorID, operator))
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildUpdateLog(material, oldMaterial *model.Material, operator string) *model.MaterialLog {
|
func buildUpdateLog(material, oldMaterial *model.Material, operatorID *int64, operator string) *model.MaterialLog {
|
||||||
oldValue, _ := json.Marshal(oldMaterial)
|
oldValue, _ := json.Marshal(oldMaterial)
|
||||||
newValue, _ := json.Marshal(material)
|
newValue, _ := json.Marshal(material)
|
||||||
return &model.MaterialLog{
|
return &model.MaterialLog{
|
||||||
OperationType: "UPDATE",
|
OperationType: "UPDATE",
|
||||||
MaterialID: material.ID,
|
MaterialID: material.ID,
|
||||||
|
OperatorID: operatorID,
|
||||||
OldValue: string(oldValue),
|
OldValue: string(oldValue),
|
||||||
NewValue: string(newValue),
|
NewValue: string(newValue),
|
||||||
Operator: operator,
|
Operator: operator,
|
||||||
|
|
@ -90,6 +104,8 @@ func materialChanges(oldMaterial, newMaterial *model.Material) []string {
|
||||||
{"名称", oldMaterial.Name, newMaterial.Name},
|
{"名称", oldMaterial.Name, newMaterial.Name},
|
||||||
{"规格", oldMaterial.Spec, newMaterial.Spec},
|
{"规格", oldMaterial.Spec, newMaterial.Spec},
|
||||||
{"厂商型号", derefString(oldMaterial.ManufacturerModel), derefString(newMaterial.ManufacturerModel)},
|
{"厂商型号", derefString(oldMaterial.ManufacturerModel), derefString(newMaterial.ManufacturerModel)},
|
||||||
|
{"来源", oldMaterial.Source, newMaterial.Source},
|
||||||
|
{"备注", oldMaterial.Remark, newMaterial.Remark},
|
||||||
{"分类", derefID(oldMaterial.CategoryID), derefID(newMaterial.CategoryID)},
|
{"分类", derefID(oldMaterial.CategoryID), derefID(newMaterial.CategoryID)},
|
||||||
{"基本单位", derefID(oldMaterial.BaseUnitID), derefID(newMaterial.BaseUnitID)},
|
{"基本单位", derefID(oldMaterial.BaseUnitID), derefID(newMaterial.BaseUnitID)},
|
||||||
{"单位", oldMaterial.Unit, newMaterial.Unit},
|
{"单位", oldMaterial.Unit, newMaterial.Unit},
|
||||||
|
|
@ -145,10 +161,15 @@ func materialStatusLabel(status string) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *MaterialLogService) LogDelete(material *model.Material, operator string) error {
|
func (s *MaterialLogService) LogDelete(material *model.Material, operator string) error {
|
||||||
|
return s.LogDeleteWithOperatorID(material, nil, operator)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MaterialLogService) LogDeleteWithOperatorID(material *model.Material, operatorID *int64, operator string) error {
|
||||||
oldValue, _ := json.Marshal(material)
|
oldValue, _ := json.Marshal(material)
|
||||||
log := &model.MaterialLog{
|
log := &model.MaterialLog{
|
||||||
OperationType: "DELETE",
|
OperationType: "DELETE",
|
||||||
MaterialID: material.ID,
|
MaterialID: material.ID,
|
||||||
|
OperatorID: operatorID,
|
||||||
OldValue: string(oldValue),
|
OldValue: string(oldValue),
|
||||||
Operator: operator,
|
Operator: operator,
|
||||||
Description: fmt.Sprintf("删除物料:%s", material.Name),
|
Description: fmt.Sprintf("删除物料:%s", material.Name),
|
||||||
|
|
|
||||||
|
|
@ -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", "supplier_id", "manufacturer_id", "supplier_name", "manufacturer_name", "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", "source", "remark", "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, nil, nil, "", "", 12, 3, "件", "obsolete", 1, now, now),
|
AddRow(7, 1, "MAT-007", "轴承", nil, "6205", nil, nil, 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", "supplier_id", "manufacturer_id", "supplier_name", "manufacturer_name", "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", "source", "remark", "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, nil, nil, "", "", 12, nil, "pcs", "active", 1, now, now)
|
AddRow(7, 1, "MAT-007", name, nil, "", nil, 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,10 +112,10 @@ 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, supplier_id, manufacturer_id, category_id, base_unit_id, unit, status, version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
insertMaterialStmt = `INSERT INTO materials (organization_id, material_code, name, material_image, spec, manufacturer_model, source, remark, supplier_id, manufacturer_id, category_id, base_unit_id, unit, status, version) VALUES (?, ?, ?, ?, ?, ?, NULLIF(?, ''), NULLIF(?, ''), ?, ?, ?, ?, ?, ?, ?)`
|
||||||
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, operator_id, old_value, new_value, operator, description) VALUES (?, ?, NULL, ?, ?, ?, ?)`
|
||||||
getMaterialQuery = `SELECT m.id, m.organization_id, m.material_code, m.name, m.material_image, m.spec, m.manufacturer_model,
|
getMaterialQuery = `SELECT m.id, m.organization_id, m.material_code, m.name, m.material_image, m.spec, m.manufacturer_model, m.source, m.remark,
|
||||||
m.supplier_id, m.manufacturer_id, COALESCE(sb.legal_name, ''), COALESCE(mb.legal_name, ''),
|
m.supplier_id, m.manufacturer_id, COALESCE(sb.legal_name, ''), COALESCE(mb.legal_name, ''),
|
||||||
m.category_id, m.base_unit_id, m.unit, m.status, m.version, m.created_at, m.updated_at
|
m.category_id, m.base_unit_id, m.unit, m.status, m.version, m.created_at, m.updated_at
|
||||||
FROM materials m
|
FROM materials m
|
||||||
|
|
@ -162,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, 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", "supplier_id", "manufacturer_id", "supplier_name", "manufacturer_name", "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", "source", "remark", "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, nil, nil, "", "", 12, nil, "个", "active", 1, now, now),
|
AddRow(7, 1, code, "轴承", nil, "", nil, 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()).
|
||||||
|
|
|
||||||
|
|
@ -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), 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 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), source VARCHAR(20) NULL COMMENT '物料来源:外购或外协', remark VARCHAR(200) NULL COMMENT '物料备注,最多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;
|
||||||
|
|
@ -41,7 +41,7 @@ CREATE TABLE inbound_orders (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_
|
||||||
CREATE TABLE inbound_items (id BIGINT AUTO_INCREMENT PRIMARY KEY, inbound_order_id BIGINT NOT NULL, material_id BIGINT NOT NULL, quantity INT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_inbound_items_order(inbound_order_id), FOREIGN KEY(inbound_order_id) REFERENCES inbound_orders(id) ON DELETE CASCADE, FOREIGN KEY(material_id) REFERENCES materials(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
CREATE TABLE inbound_items (id BIGINT AUTO_INCREMENT PRIMARY KEY, inbound_order_id BIGINT NOT NULL, material_id BIGINT NOT NULL, quantity INT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_inbound_items_order(inbound_order_id), FOREIGN KEY(inbound_order_id) REFERENCES inbound_orders(id) ON DELETE CASCADE, FOREIGN KEY(material_id) REFERENCES materials(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
CREATE TABLE outbound_orders (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL, warehouse_id BIGINT NOT NULL, location_id BIGINT NOT NULL, outbound_no VARCHAR(32) UNIQUE, operator VARCHAR(100), outbound_date DATETIME NOT NULL, handler VARCHAR(100) NOT NULL, receiver VARCHAR(100) NOT NULL, remark TEXT, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_outbound_orders_date(outbound_date), FOREIGN KEY(organization_id) REFERENCES organizations(id), FOREIGN KEY(warehouse_id) REFERENCES warehouses(id), FOREIGN KEY(location_id) REFERENCES warehouse_locations(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
CREATE TABLE outbound_orders (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL, warehouse_id BIGINT NOT NULL, location_id BIGINT NOT NULL, outbound_no VARCHAR(32) UNIQUE, operator VARCHAR(100), outbound_date DATETIME NOT NULL, handler VARCHAR(100) NOT NULL, receiver VARCHAR(100) NOT NULL, remark TEXT, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_outbound_orders_date(outbound_date), FOREIGN KEY(organization_id) REFERENCES organizations(id), FOREIGN KEY(warehouse_id) REFERENCES warehouses(id), FOREIGN KEY(location_id) REFERENCES warehouse_locations(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
CREATE TABLE outbound_items (id BIGINT AUTO_INCREMENT PRIMARY KEY, outbound_order_id BIGINT NOT NULL, material_id BIGINT NOT NULL, quantity INT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_outbound_items_order(outbound_order_id), FOREIGN KEY(outbound_order_id) REFERENCES outbound_orders(id) ON DELETE CASCADE, FOREIGN KEY(material_id) REFERENCES materials(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
CREATE TABLE outbound_items (id BIGINT AUTO_INCREMENT PRIMARY KEY, outbound_order_id BIGINT NOT NULL, material_id BIGINT NOT NULL, quantity INT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_outbound_items_order(outbound_order_id), FOREIGN KEY(outbound_order_id) REFERENCES outbound_orders(id) ON DELETE CASCADE, FOREIGN KEY(material_id) REFERENCES materials(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
CREATE TABLE material_logs (id BIGINT AUTO_INCREMENT PRIMARY KEY, operation_type VARCHAR(50) NOT NULL, material_id BIGINT NOT NULL, old_value TEXT, new_value TEXT, operator VARCHAR(100), description TEXT, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_material_logs_material(material_id), FOREIGN KEY(material_id) REFERENCES materials(id) ON DELETE CASCADE) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
CREATE TABLE material_logs (id BIGINT AUTO_INCREMENT PRIMARY KEY, operation_type VARCHAR(50) NOT NULL, material_id BIGINT NOT NULL, operator_id BIGINT NULL, old_value TEXT, new_value TEXT, operator VARCHAR(100), description TEXT, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_material_logs_material(material_id), KEY idx_material_logs_operator(operator_id), FOREIGN KEY(material_id) REFERENCES materials(id) ON DELETE CASCADE) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
CREATE TABLE stock_logs (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL, warehouse_id BIGINT NOT NULL, location_id BIGINT NOT NULL, KEY idx_stock_logs_org_material_created(organization_id,material_id,created_at), FOREIGN KEY(organization_id) REFERENCES organizations(id), FOREIGN KEY(warehouse_id) REFERENCES warehouses(id), FOREIGN KEY(location_id) REFERENCES warehouse_locations(id), material_id BIGINT NOT NULL, type ENUM('in','out') NOT NULL, quantity INT NOT NULL, related_order_type VARCHAR(50) NOT NULL, related_order_id BIGINT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_stock_logs_material(material_id), KEY idx_stock_logs_related_order(related_order_type,related_order_id), FOREIGN KEY(material_id) REFERENCES materials(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
CREATE TABLE stock_logs (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL, warehouse_id BIGINT NOT NULL, location_id BIGINT NOT NULL, KEY idx_stock_logs_org_material_created(organization_id,material_id,created_at), FOREIGN KEY(organization_id) REFERENCES organizations(id), FOREIGN KEY(warehouse_id) REFERENCES warehouses(id), FOREIGN KEY(location_id) REFERENCES warehouse_locations(id), material_id BIGINT NOT NULL, type ENUM('in','out') NOT NULL, quantity INT NOT NULL, related_order_type VARCHAR(50) NOT NULL, related_order_id BIGINT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_stock_logs_material(material_id), KEY idx_stock_logs_related_order(related_order_type,related_order_id), FOREIGN KEY(material_id) REFERENCES materials(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
CREATE TABLE material_applications (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL, KEY idx_material_applications_org_user_created(organization_id,user_id,created_at), KEY idx_material_applications_org_status_created(organization_id,status,created_at), FOREIGN KEY(organization_id) REFERENCES organizations(id), user_id BIGINT NOT NULL, purpose VARCHAR(500) NOT NULL DEFAULT '', status ENUM('pending','approved','rejected','cancelled') NOT NULL DEFAULT 'pending', rejection_reason VARCHAR(500) NOT NULL DEFAULT '', approved_by BIGINT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, FOREIGN KEY(user_id) REFERENCES users(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
CREATE TABLE material_applications (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL, KEY idx_material_applications_org_user_created(organization_id,user_id,created_at), KEY idx_material_applications_org_status_created(organization_id,status,created_at), FOREIGN KEY(organization_id) REFERENCES organizations(id), user_id BIGINT NOT NULL, purpose VARCHAR(500) NOT NULL DEFAULT '', status ENUM('pending','approved','rejected','cancelled') NOT NULL DEFAULT 'pending', rejection_reason VARCHAR(500) NOT NULL DEFAULT '', approved_by BIGINT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, FOREIGN KEY(user_id) REFERENCES users(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
CREATE TABLE application_items (id BIGINT AUTO_INCREMENT PRIMARY KEY, application_id BIGINT NOT NULL, material_id BIGINT NOT NULL, quantity INT NOT NULL, FOREIGN KEY(application_id) REFERENCES material_applications(id) ON DELETE CASCADE, FOREIGN KEY(material_id) REFERENCES materials(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
CREATE TABLE application_items (id BIGINT AUTO_INCREMENT PRIMARY KEY, application_id BIGINT NOT NULL, material_id BIGINT NOT NULL, quantity INT NOT NULL, FOREIGN KEY(application_id) REFERENCES material_applications(id) ON DELETE CASCADE, FOREIGN KEY(material_id) REFERENCES materials(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
@ -67,8 +67,14 @@ 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-10-material-bulk-import','completed',NOW(),JSON_OBJECT('version','2026-10-10-material-bulk-import','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 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 schema_migrations(version,status,completed_at,details) VALUES('2026-10-14-material-source-remark','completed',NOW(),JSON_OBJECT('version','2026-10-14-material-source-remark','source','fresh-init'));
|
||||||
|
|
||||||
|
INSERT INTO schema_migrations(version,status,completed_at,details) VALUES('2026-10-14-material-log-operator-id','completed',NOW(),JSON_OBJECT('version','2026-10-14-material-log-operator-id','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
|
||||||
|
|
|
||||||
|
|
@ -1,224 +0,0 @@
|
||||||
-- MySQL 8.0; run once in the selected database, after backup and stopping writes.
|
|
||||||
-- Task 1 cannot deploy alone: Tasks 5A-5C must supply explicit dimensions in INSERTs.
|
|
||||||
-- MySQL DDL implicitly commits. A failed/running version requires manual recovery
|
|
||||||
-- from backup; never delete its version record and blindly rerun partial DDL.
|
|
||||||
SET NAMES utf8mb4;
|
|
||||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
||||||
version VARCHAR(100) PRIMARY KEY, status VARCHAR(20) NOT NULL,
|
|
||||||
started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, completed_at DATETIME NULL,
|
|
||||||
details JSON NOT NULL
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
||||||
CREATE TABLE IF NOT EXISTS schema_migration_reconciliations (
|
|
||||||
version VARCHAR(100) NOT NULL, phase VARCHAR(10) NOT NULL, organization_id BIGINT NOT NULL,
|
|
||||||
on_hand DECIMAL(30,6) NOT NULL, reserved DECIMAL(30,6) NOT NULL, locked DECIMAL(30,6) NOT NULL,
|
|
||||||
in_transit DECIMAL(30,6) NOT NULL, inbound_count BIGINT NOT NULL, outbound_count BIGINT NOT NULL,
|
|
||||||
application_count BIGINT NOT NULL, stock_log_count BIGINT NOT NULL, captured_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
PRIMARY KEY(version,phase,organization_id), FOREIGN KEY(organization_id) REFERENCES organizations(id)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
||||||
DELIMITER $$
|
|
||||||
DROP PROCEDURE IF EXISTS upgrade_company_isolation_20261008$$
|
|
||||||
CREATE PROCEDURE upgrade_company_isolation_20261008()
|
|
||||||
BEGIN
|
|
||||||
DECLARE lock_acquired INT DEFAULT 0;
|
|
||||||
DECLARE version_started BOOLEAN DEFAULT FALSE;
|
|
||||||
DECLARE migration_version VARCHAR(100) DEFAULT '2026-10-08-company-master-data-isolation';
|
|
||||||
DECLARE EXIT HANDLER FOR SQLEXCEPTION
|
|
||||||
BEGIN
|
|
||||||
IF version_started THEN
|
|
||||||
UPDATE schema_migrations SET status='failed',details=JSON_SET(details,'$.recovery','DDL may have committed; restore backup or review partial schema before retry') WHERE version=BINARY migration_version;
|
|
||||||
END IF;
|
|
||||||
IF lock_acquired=1 THEN DO RELEASE_LOCK(CONCAT(DATABASE(),':company_isolation')); END IF;
|
|
||||||
RESIGNAL;
|
|
||||||
END;
|
|
||||||
SELECT GET_LOCK(CONCAT(DATABASE(),':company_isolation'),0) INTO lock_acquired;
|
|
||||||
IF lock_acquired IS NULL OR lock_acquired<>1 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Company isolation migration is already running'; END IF;
|
|
||||||
IF EXISTS(SELECT 1 FROM schema_migrations WHERE version=BINARY migration_version) THEN
|
|
||||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Company isolation migration version already recorded; do not rerun';
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
-- Generated from scripts/checks/company-master-data-integrity.sql; edit that source.
|
|
||||||
-- Sync: pwsh -NoProfile -File scripts/sync-company-integrity.ps1 (verify with -Check).
|
|
||||||
-- BEGIN GENERATED COMPANY INTEGRITY CHECKS
|
|
||||||
DROP TEMPORARY TABLE IF EXISTS tmp_company_application_dimensions;
|
|
||||||
CREATE TEMPORARY TABLE tmp_company_application_dimensions AS
|
|
||||||
SELECT a.id, COUNT(ai.id) item_count, COUNT(DISTINCT m.organization_id) organization_count,
|
|
||||||
MIN(m.organization_id) organization_id, COUNT(m.id) material_count
|
|
||||||
FROM material_applications a LEFT JOIN application_items ai ON ai.application_id=a.id
|
|
||||||
LEFT JOIN materials m ON m.id=ai.material_id GROUP BY a.id;
|
|
||||||
|
|
||||||
DROP TEMPORARY TABLE IF EXISTS tmp_company_log_dimensions;
|
|
||||||
CREATE TEMPORARY TABLE tmp_company_log_dimensions AS
|
|
||||||
SELECT s.id, o.organization_id, o.warehouse_id, o.location_id
|
|
||||||
FROM stock_logs s JOIN inbound_orders o ON s.related_order_type='inbound' AND s.related_order_id=o.id
|
|
||||||
JOIN inbound_items i ON i.inbound_order_id=o.id AND i.material_id=s.material_id
|
|
||||||
WHERE s.type='in' AND s.quantity=i.quantity
|
|
||||||
UNION
|
|
||||||
SELECT s.id, o.organization_id, o.warehouse_id, o.location_id
|
|
||||||
FROM stock_logs s JOIN outbound_orders o ON s.related_order_type='outbound' AND s.related_order_id=o.id
|
|
||||||
JOIN outbound_items i ON i.outbound_order_id=o.id AND i.material_id=s.material_id
|
|
||||||
WHERE s.type='out' AND s.quantity=i.quantity
|
|
||||||
UNION
|
|
||||||
SELECT s.id, d.organization_id, b.warehouse_id, b.location_id
|
|
||||||
FROM stock_logs s JOIN material_applications a ON s.related_order_type='application' AND s.related_order_id=a.id
|
|
||||||
JOIN tmp_company_application_dimensions d ON d.id=a.id AND d.item_count>0 AND d.organization_count=1 AND d.material_count=d.item_count
|
|
||||||
JOIN application_items i ON i.application_id=a.id AND i.material_id=s.material_id
|
|
||||||
JOIN inventory_balances b ON b.organization_id=d.organization_id AND b.material_id=s.material_id
|
|
||||||
JOIN warehouses w ON w.id=b.warehouse_id AND w.organization_id=d.organization_id
|
|
||||||
JOIN warehouse_locations l ON l.id=b.location_id AND l.warehouse_id=w.id
|
|
||||||
WHERE s.type='out' AND a.status='approved' AND s.quantity=i.quantity;
|
|
||||||
|
|
||||||
DROP TEMPORARY TABLE IF EXISTS tmp_company_integrity_anomalies;
|
|
||||||
CREATE TEMPORARY TABLE tmp_company_integrity_anomalies (
|
|
||||||
issue VARCHAR(100) NOT NULL, entity_id BIGINT NOT NULL, detail VARCHAR(500) NOT NULL
|
|
||||||
);
|
|
||||||
INSERT INTO tmp_company_integrity_anomalies
|
|
||||||
SELECT 'category_parent',c.id,'Parent category belongs to another company or is missing'
|
|
||||||
FROM material_categories c LEFT JOIN material_categories p ON p.id=c.parent_id
|
|
||||||
WHERE c.parent_id IS NOT NULL AND (p.id IS NULL OR p.organization_id<>c.organization_id);
|
|
||||||
INSERT INTO tmp_company_integrity_anomalies
|
|
||||||
SELECT 'material_category_unit_type',m.id,'Material category, base unit or type belongs to another company or is missing'
|
|
||||||
FROM materials m LEFT JOIN material_categories c ON c.id=m.category_id
|
|
||||||
LEFT JOIN units u ON u.id=m.base_unit_id LEFT JOIN material_types mt ON mt.id=m.material_type_id
|
|
||||||
WHERE c.id IS NULL OR c.organization_id<>m.organization_id
|
|
||||||
OR (m.base_unit_id IS NOT NULL AND (u.id IS NULL OR u.organization_id<>m.organization_id))
|
|
||||||
OR (m.material_type_id IS NOT NULL AND (mt.id IS NULL OR mt.organization_id<>m.organization_id));
|
|
||||||
INSERT INTO tmp_company_integrity_anomalies
|
|
||||||
SELECT 'rule_category',r.id,'Code rule category belongs to another company or is missing'
|
|
||||||
FROM code_rules r LEFT JOIN material_categories c ON c.id=r.category_id
|
|
||||||
WHERE c.id IS NULL OR c.organization_id<>r.organization_id;
|
|
||||||
INSERT INTO tmp_company_integrity_anomalies
|
|
||||||
SELECT 'duplicate_default',user_id,'User has more than one default organization'
|
|
||||||
FROM user_organizations WHERE is_default<>0 GROUP BY user_id HAVING COUNT(*)>1;
|
|
||||||
INSERT INTO tmp_company_integrity_anomalies
|
|
||||||
SELECT 'user_without_access',u.id,'User has no enabled company grant and no enabled super_admin role'
|
|
||||||
FROM users u WHERE NOT EXISTS(SELECT 1 FROM user_organizations g JOIN organizations o ON o.id=g.organization_id AND o.status='active' WHERE g.user_id=u.id)
|
|
||||||
AND NOT EXISTS(SELECT 1 FROM user_roles ur JOIN roles r ON r.id=ur.role_id AND r.code='super_admin' AND r.enabled=1 WHERE ur.user_id=u.id);
|
|
||||||
INSERT INTO tmp_company_integrity_anomalies
|
|
||||||
SELECT 'multiple_warehouses',organization_id,'Company has multiple warehouses (including inactive ones)'
|
|
||||||
FROM warehouses GROUP BY organization_id HAVING COUNT(*)>1;
|
|
||||||
INSERT INTO tmp_company_integrity_anomalies
|
|
||||||
SELECT 'multiple_locations',warehouse_id,'Warehouse has multiple locations (including inactive ones)'
|
|
||||||
FROM warehouse_locations GROUP BY warehouse_id HAVING COUNT(*)>1;
|
|
||||||
INSERT INTO tmp_company_integrity_anomalies
|
|
||||||
SELECT 'inventory_dimensions',b.id,'Inventory material, company, warehouse or location do not agree'
|
|
||||||
FROM inventory_balances b LEFT JOIN materials m ON m.id=b.material_id LEFT JOIN warehouses w ON w.id=b.warehouse_id
|
|
||||||
LEFT JOIN warehouse_locations l ON l.id=b.location_id LEFT JOIN organizations o ON o.id=b.organization_id
|
|
||||||
WHERE o.id IS NULL OR m.id IS NULL OR w.id IS NULL OR l.id IS NULL OR m.organization_id<>b.organization_id OR w.organization_id<>b.organization_id OR l.warehouse_id<>b.warehouse_id;
|
|
||||||
INSERT INTO tmp_company_integrity_anomalies
|
|
||||||
SELECT 'warning_dimensions',r.id,'Warning material, company or warehouse do not agree'
|
|
||||||
FROM stock_warning_rules r LEFT JOIN materials m ON m.id=r.material_id LEFT JOIN warehouses w ON w.id=r.warehouse_id
|
|
||||||
WHERE m.id IS NULL OR w.id IS NULL OR m.organization_id<>r.organization_id OR w.organization_id<>r.organization_id;
|
|
||||||
INSERT INTO tmp_company_integrity_anomalies
|
|
||||||
SELECT 'inbound_dimensions',o.id,'Inbound company, warehouse, location, materials or actual inventory dimensions do not agree'
|
|
||||||
FROM inbound_orders o LEFT JOIN warehouses w ON w.id=o.warehouse_id LEFT JOIN warehouse_locations l ON l.id=o.location_id
|
|
||||||
WHERE w.id IS NULL OR l.id IS NULL OR w.organization_id<>o.organization_id OR l.warehouse_id<>o.warehouse_id
|
|
||||||
OR NOT EXISTS(SELECT 1 FROM inbound_items i WHERE i.inbound_order_id=o.id)
|
|
||||||
OR EXISTS(SELECT 1 FROM inbound_items i LEFT JOIN materials m ON m.id=i.material_id
|
|
||||||
WHERE i.inbound_order_id=o.id AND (m.id IS NULL OR m.organization_id<>o.organization_id
|
|
||||||
OR NOT EXISTS(SELECT 1 FROM inventory_balances b WHERE b.material_id=i.material_id AND b.organization_id=o.organization_id AND b.warehouse_id=o.warehouse_id AND b.location_id=o.location_id)));
|
|
||||||
INSERT INTO tmp_company_integrity_anomalies
|
|
||||||
SELECT 'outbound_dimensions',o.id,'Outbound company, warehouse, location, materials or actual inventory dimensions do not agree'
|
|
||||||
FROM outbound_orders o LEFT JOIN warehouses w ON w.id=o.warehouse_id LEFT JOIN warehouse_locations l ON l.id=o.location_id
|
|
||||||
WHERE w.id IS NULL OR l.id IS NULL OR w.organization_id<>o.organization_id OR l.warehouse_id<>o.warehouse_id
|
|
||||||
OR NOT EXISTS(SELECT 1 FROM outbound_items i WHERE i.outbound_order_id=o.id)
|
|
||||||
OR EXISTS(SELECT 1 FROM outbound_items i LEFT JOIN materials m ON m.id=i.material_id
|
|
||||||
WHERE i.outbound_order_id=o.id AND (m.id IS NULL OR m.organization_id<>o.organization_id
|
|
||||||
OR NOT EXISTS(SELECT 1 FROM inventory_balances b WHERE b.material_id=i.material_id AND b.organization_id=o.organization_id AND b.warehouse_id=o.warehouse_id AND b.location_id=o.location_id)));
|
|
||||||
INSERT INTO tmp_company_integrity_anomalies
|
|
||||||
SELECT 'application_dimensions',id,'Application has empty, missing or mixed-company material details'
|
|
||||||
FROM tmp_company_application_dimensions WHERE item_count=0 OR organization_count<>1 OR material_count<>item_count;
|
|
||||||
INSERT INTO tmp_company_integrity_anomalies
|
|
||||||
SELECT 'unattributable_stock_log',s.id,'Log has no unique verified material/document/company/warehouse/location association'
|
|
||||||
FROM stock_logs s LEFT JOIN tmp_company_log_dimensions d ON d.id=s.id
|
|
||||||
LEFT JOIN materials m ON m.id=s.material_id LEFT JOIN warehouses w ON w.id=d.warehouse_id LEFT JOIN warehouse_locations l ON l.id=d.location_id
|
|
||||||
GROUP BY s.id,m.id,m.organization_id
|
|
||||||
HAVING COUNT(d.id)<>1 OR COUNT(m.id)=0 OR SUM(d.organization_id<>m.organization_id OR w.organization_id<>d.organization_id OR l.warehouse_id<>d.warehouse_id)>0;
|
|
||||||
|
|
||||||
-- The same check file must work before the columns exist and after backfill.
|
|
||||||
SET @company_check_sql = IF(
|
|
||||||
(SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='stock_logs' AND column_name IN ('organization_id','warehouse_id','location_id'))=3,
|
|
||||||
'INSERT INTO tmp_company_integrity_anomalies SELECT ''stored_stock_log_dimensions'',s.id,''Stored log dimensions differ from verified source document'' FROM stock_logs s LEFT JOIN tmp_company_log_dimensions d ON d.id=s.id WHERE d.id IS NULL OR s.organization_id IS NULL OR s.warehouse_id IS NULL OR s.location_id IS NULL OR s.organization_id<>d.organization_id OR s.warehouse_id<>d.warehouse_id OR s.location_id<>d.location_id',
|
|
||||||
'DO 0');
|
|
||||||
PREPARE company_check FROM @company_check_sql;
|
|
||||||
EXECUTE company_check;
|
|
||||||
DEALLOCATE PREPARE company_check;
|
|
||||||
SET @company_check_sql = IF(
|
|
||||||
(SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='material_applications' AND column_name='organization_id')=1,
|
|
||||||
'INSERT INTO tmp_company_integrity_anomalies SELECT ''stored_application_dimensions'',a.id,''Stored application company differs from verified detail materials'' FROM material_applications a JOIN tmp_company_application_dimensions d ON d.id=a.id WHERE a.organization_id IS NULL OR a.organization_id<>d.organization_id',
|
|
||||||
'DO 0');
|
|
||||||
PREPARE company_check FROM @company_check_sql;
|
|
||||||
EXECUTE company_check;
|
|
||||||
DEALLOCATE PREPARE company_check;
|
|
||||||
|
|
||||||
-- END GENERATED COMPANY INTEGRITY CHECKS
|
|
||||||
IF EXISTS(SELECT 1 FROM tmp_company_integrity_anomalies) THEN
|
|
||||||
SELECT issue,entity_id,detail FROM tmp_company_integrity_anomalies ORDER BY issue,entity_id;
|
|
||||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Company isolation integrity anomalies; run scripts/checks/company-master-data-integrity.sql and repair before upgrade';
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
INSERT INTO schema_migrations(version,status,details) VALUES(migration_version,'running',JSON_OBJECT('version',migration_version,'executor',CURRENT_USER()));
|
|
||||||
SET version_started=TRUE;
|
|
||||||
INSERT INTO schema_migration_reconciliations(version,phase,organization_id,on_hand,reserved,locked,in_transit,inbound_count,outbound_count,application_count,stock_log_count)
|
|
||||||
SELECT migration_version,'before',o.id,
|
|
||||||
COALESCE((SELECT SUM(on_hand) FROM inventory_balances WHERE organization_id=o.id),0),
|
|
||||||
COALESCE((SELECT SUM(reserved) FROM inventory_balances WHERE organization_id=o.id),0),
|
|
||||||
COALESCE((SELECT SUM(locked) FROM inventory_balances WHERE organization_id=o.id),0),
|
|
||||||
COALESCE((SELECT SUM(in_transit) FROM inventory_balances WHERE organization_id=o.id),0),
|
|
||||||
(SELECT COUNT(*) FROM inbound_orders WHERE organization_id=o.id),
|
|
||||||
(SELECT COUNT(*) FROM outbound_orders WHERE organization_id=o.id),
|
|
||||||
(SELECT COUNT(*) FROM tmp_company_application_dimensions WHERE organization_id=o.id),
|
|
||||||
(SELECT COUNT(*) FROM tmp_company_log_dimensions WHERE organization_id=o.id)
|
|
||||||
FROM organizations o;
|
|
||||||
|
|
||||||
ALTER TABLE user_organizations ADD COLUMN default_user_id BIGINT GENERATED ALWAYS AS (CASE WHEN is_default THEN user_id ELSE NULL END) VIRTUAL, ADD UNIQUE KEY uk_user_organizations_default(default_user_id);
|
|
||||||
ALTER TABLE warehouses ADD UNIQUE KEY uk_warehouses_organization(organization_id);
|
|
||||||
ALTER TABLE warehouse_locations ADD UNIQUE KEY uk_warehouse_locations_warehouse(warehouse_id);
|
|
||||||
CREATE TABLE organization_audit_logs (
|
|
||||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,actor_user_id BIGINT NOT NULL,organization_id BIGINT NULL,
|
|
||||||
action VARCHAR(100) NOT NULL,old_value JSON NULL,new_value JSON NULL,created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
KEY idx_organization_audit_org_created(organization_id,created_at),KEY idx_organization_audit_actor_created(actor_user_id,created_at),
|
|
||||||
FOREIGN KEY(actor_user_id) REFERENCES users(id),FOREIGN KEY(organization_id) REFERENCES organizations(id)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
||||||
ALTER TABLE material_applications ADD COLUMN organization_id BIGINT NULL;
|
|
||||||
ALTER TABLE stock_logs ADD COLUMN organization_id BIGINT NULL,ADD COLUMN warehouse_id BIGINT NULL,ADD COLUMN location_id BIGINT NULL;
|
|
||||||
UPDATE material_applications a JOIN tmp_company_application_dimensions d ON d.id=a.id SET a.organization_id=d.organization_id;
|
|
||||||
UPDATE stock_logs s JOIN tmp_company_log_dimensions d ON d.id=s.id SET s.organization_id=d.organization_id,s.warehouse_id=d.warehouse_id,s.location_id=d.location_id;
|
|
||||||
IF EXISTS(SELECT 1 FROM material_applications WHERE organization_id IS NULL)
|
|
||||||
OR EXISTS(SELECT 1 FROM stock_logs WHERE organization_id IS NULL OR warehouse_id IS NULL OR location_id IS NULL) THEN
|
|
||||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Company isolation backfill incomplete; restore backup and investigate';
|
|
||||||
END IF;
|
|
||||||
ALTER TABLE material_applications MODIFY organization_id BIGINT NOT NULL,
|
|
||||||
ADD KEY idx_material_applications_org_user_created(organization_id,user_id,created_at),
|
|
||||||
ADD KEY idx_material_applications_org_status_created(organization_id,status,created_at),
|
|
||||||
ADD FOREIGN KEY(organization_id) REFERENCES organizations(id);
|
|
||||||
ALTER TABLE stock_logs MODIFY organization_id BIGINT NOT NULL,MODIFY warehouse_id BIGINT NOT NULL,MODIFY location_id BIGINT NOT NULL,
|
|
||||||
ADD KEY idx_stock_logs_org_material_created(organization_id,material_id,created_at),
|
|
||||||
ADD FOREIGN KEY(organization_id) REFERENCES organizations(id),ADD FOREIGN KEY(warehouse_id) REFERENCES warehouses(id),ADD FOREIGN KEY(location_id) REFERENCES warehouse_locations(id);
|
|
||||||
ALTER TABLE inbound_orders ALTER COLUMN organization_id DROP DEFAULT,ALTER COLUMN warehouse_id DROP DEFAULT,ALTER COLUMN location_id DROP DEFAULT;
|
|
||||||
ALTER TABLE outbound_orders ALTER COLUMN organization_id DROP DEFAULT,ALTER COLUMN warehouse_id DROP DEFAULT,ALTER COLUMN location_id DROP DEFAULT;
|
|
||||||
|
|
||||||
INSERT INTO schema_migration_reconciliations(version,phase,organization_id,on_hand,reserved,locked,in_transit,inbound_count,outbound_count,application_count,stock_log_count)
|
|
||||||
SELECT migration_version,'after',o.id,
|
|
||||||
COALESCE((SELECT SUM(on_hand) FROM inventory_balances WHERE organization_id=o.id),0),
|
|
||||||
COALESCE((SELECT SUM(reserved) FROM inventory_balances WHERE organization_id=o.id),0),
|
|
||||||
COALESCE((SELECT SUM(locked) FROM inventory_balances WHERE organization_id=o.id),0),
|
|
||||||
COALESCE((SELECT SUM(in_transit) FROM inventory_balances WHERE organization_id=o.id),0),
|
|
||||||
(SELECT COUNT(*) FROM inbound_orders WHERE organization_id=o.id),
|
|
||||||
(SELECT COUNT(*) FROM outbound_orders WHERE organization_id=o.id),
|
|
||||||
(SELECT COUNT(*) FROM material_applications WHERE organization_id=o.id),
|
|
||||||
(SELECT COUNT(*) FROM stock_logs WHERE organization_id=o.id)
|
|
||||||
FROM organizations o;
|
|
||||||
IF EXISTS(SELECT 1 FROM schema_migration_reconciliations b JOIN schema_migration_reconciliations a
|
|
||||||
ON a.version=b.version AND a.organization_id=b.organization_id AND a.phase='after'
|
|
||||||
WHERE b.version=BINARY migration_version AND b.phase='before'
|
|
||||||
AND (a.on_hand<>b.on_hand OR a.reserved<>b.reserved OR a.locked<>b.locked OR a.in_transit<>b.in_transit
|
|
||||||
OR a.inbound_count<>b.inbound_count OR a.outbound_count<>b.outbound_count OR a.application_count<>b.application_count OR a.stock_log_count<>b.stock_log_count)) THEN
|
|
||||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='Company isolation reconciliation mismatch; restore backup and investigate';
|
|
||||||
END IF;
|
|
||||||
UPDATE schema_migrations SET status='completed',completed_at=NOW() WHERE version=BINARY migration_version;
|
|
||||||
SET version_started=FALSE;
|
|
||||||
DO RELEASE_LOCK(CONCAT(DATABASE(),':company_isolation'));
|
|
||||||
END$$
|
|
||||||
CALL upgrade_company_isolation_20261008()$$
|
|
||||||
DROP PROCEDURE upgrade_company_isolation_20261008$$
|
|
||||||
DELIMITER ;
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
-- 物料批量导入:新增导入记录表。仅在已部署的库上执行一次;本地开发库走 init.sql 重建。
|
|
||||||
SET NAMES utf8mb4;
|
|
||||||
CREATE TABLE material_import_logs (id BIGINT AUTO_INCREMENT PRIMARY KEY, organization_id BIGINT NOT NULL, operator VARCHAR(100) NOT NULL, file_name VARCHAR(255) NOT NULL, total_rows INT NOT NULL DEFAULT 0, success_rows INT NOT NULL DEFAULT 0, failed_rows INT NOT NULL DEFAULT 0, status VARCHAR(20) NOT NULL, failure_reason VARCHAR(1000) NOT NULL DEFAULT '', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_material_import_logs_org_created(organization_id,created_at), FOREIGN KEY(organization_id) REFERENCES organizations(id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
||||||
INSERT INTO schema_migrations(version,status,completed_at,details) VALUES('2026-10-10-material-bulk-import','completed',NOW(),JSON_OBJECT('version','2026-10-10-material-bulk-import','source','upgrade'));
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
-- 物料默认供应商、制造商及伙伴制造商身份。选择目标数据库后执行一次。
|
|
||||||
-- 前置条件:已完成公司隔离升级,存在 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'));
|
|
||||||
Loading…
Reference in New Issue
Block a user