Files
bj_power/bj_power_wms/internal/handler/material.go
T
SunYF 62e45b7379 feat: 完成附件存储重构与磁盘监控功能,同步物料档案与入库单规则更新
1.  新增跨平台磁盘空间监控能力,每日7点自动检测附件目录剩余空间,触发阈值告警
2.  重构附件存储方案为年/月/日/文件类型分层结构,统一MES与WMS的附件管理逻辑
3.  对齐物料档案与入库单的质量状态校验规则,仅合格品计入库存与出库
4.  实现入库单作废功能与区域库位的多级父子结构管理
5.  删除物料简称字段,补充规格型号与单位必填项,统一系统数据口径
6.  新增附件中心与磁盘状态查询接口,完善权限控制与操作日志
2026-09-19 10:54:15 +08:00

608 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handler
import (
"net/http"
"strings"
"time"
"bj_power_wms/ent"
"bj_power_wms/ent/material"
"bj_power_wms/internal/svc"
"github.com/xuri/excelize/v2"
"github.com/zeromicro/go-zero/core/logx"
)
// itemTypeLabel 物料品类标签:1原材料/2半成品/3成品/4其他(辅料/工装/试验设备)
func itemTypeLabel(t int) string {
switch t {
case invCatSemi:
return "半成品"
case invCatFinished:
return "成品"
case invItemOther:
return "其他"
default:
return "原材料"
}
}
// validateMaterialRequired 物料档案必填校验(2026-09-19 用户裁决:
// 新增/编辑表单除「说明」外全部必填)。返回空串表示通过,否则返回中文错误文案。
//
// 说明:图号 code 只在新增时必填(编辑不可改),由调用方单独校验;
// 「其他」类物料不做 SN,管理粒度由后端强制收敛为 1(结构件),故不参与必填。
func validateMaterialRequired(name, spec, unit string, itemType, manageMode int) string {
switch {
case strings.TrimSpace(name) == "":
return "名称为必填项"
case strings.TrimSpace(spec) == "":
return "规格型号为必填项"
case strings.TrimSpace(unit) == "":
return "单位为必填项"
case itemType < 1 || itemType > invItemOther:
return "品类为必填项(原材料 / 半成品 / 成品 / 其他)"
case itemType != invItemOther && manageMode != 1 && manageMode != 2:
return "类型为必填项(结构件 / 电气件)"
}
return ""
}
// createMaterialHandler 创建物料档案
func createMaterialHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req struct {
Code string `json:"code"`
Name string `json:"name"`
Spec string `json:"spec"`
Unit string `json:"unit"`
Description string `json:"description"`
ManageMode int `json:"manageMode"` // 1批次/2序列号
TemplateCode string `json:"templateCode"`
ItemType int `json:"itemType"` // 1原材料/2半成品/3成品/4其他
SafetyStock int `json:"safetyStock"` // 安全库存(低于触发缺货预警)
}
if err := parseJSON(r, &req); err != nil {
fail(w, http.StatusBadRequest, "参数错误")
return
}
// 必填口径(2026-09-19 用户裁决):除「说明」外全部必填。
if strings.TrimSpace(req.Code) == "" {
fail(w, http.StatusBadRequest, "图号为必填项")
return
}
if msg := validateMaterialRequired(req.Name, req.Spec, req.Unit, req.ItemType, req.ManageMode); msg != "" {
fail(w, http.StatusBadRequest, msg)
return
}
// 校验编码唯一
exists, err := ctx.EntClient.Material.Query().
Where(material.CodeEQ(req.Code)).
Exist(ctx0())
if err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
if exists {
fail(w, http.StatusConflict, "图号已存在: "+req.Code)
return
}
// 品类/管理粒度已在上方校验,这里只做「其他类不做 SN」的业务收敛
manageMode := req.ManageMode
itemType := req.ItemType
// 其他类(辅料/工装/试验设备)只按数量管理,不做 SN
if itemType == invItemOther {
manageMode = 1
}
m, err := ctx.EntClient.Material.Create().
SetCode(req.Code).
SetName(req.Name).
SetNillableSpec(strPtr(req.Spec)).
SetNillableUnit(strPtr(req.Unit)).
SetNillableDescription(strPtr(req.Description)).
SetManageMode(manageMode).
SetIsBatchManaged(manageMode == 1).
SetIsSerialManaged(manageMode == 2).
SetNillableTemplateCode(strPtr(req.TemplateCode)).
SetItemType(itemType).
SetSafetyStock(req.SafetyStock).
Save(ctx0())
if err != nil {
logx.Errorf("create material failed: %v", err)
fail(w, http.StatusInternalServerError, "创建物料失败: "+err.Error())
return
}
ctx.EventLog.Write(ctx0(), "material.create", r.Header.Get("X-Username"), "material", req.Code,
"新增物料 "+req.Code+" "+req.Name, map[string]any{"manageMode": manageMode})
ok(w, m)
}
}
// updateMaterialHandler 更新物料档案
func updateMaterialHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req struct {
ID int `json:"id"`
Name string `json:"name"`
Spec string `json:"spec"`
Unit string `json:"unit"`
Description string `json:"description"`
ManageMode int `json:"manageMode"`
ItemType int `json:"itemType"`
SafetyStock int `json:"safetyStock"`
}
if err := parseJSON(r, &req); err != nil || req.ID <= 0 {
fail(w, http.StatusBadRequest, "参数错误")
return
}
// 必填口径(2026-09-19 用户裁决):除「说明」外全部必填
if msg := validateMaterialRequired(req.Name, req.Spec, req.Unit, req.ItemType, req.ManageMode); msg != "" {
fail(w, http.StatusBadRequest, msg)
return
}
cur, gerr := ctx.EntClient.Material.Get(ctx0(), req.ID)
if gerr != nil {
fail(w, http.StatusNotFound, "物料不存在")
return
}
manageMode := req.ManageMode
if req.ItemType == invItemOther {
manageMode = 1
}
m, err := ctx.EntClient.Material.UpdateOneID(req.ID).
SetName(req.Name).
SetSpec(req.Spec).
SetUnit(req.Unit).
SetDescription(req.Description).
SetItemType(req.ItemType).
SetSafetyStock(req.SafetyStock).
SetManageMode(manageMode).
SetIsBatchManaged(manageMode == 1).
SetIsSerialManaged(manageMode == 2).
Save(ctx0())
if err != nil {
fail(w, http.StatusInternalServerError, "更新物料失败: "+err.Error())
return
}
ctx.EventLog.Write(ctx0(), "material.update", r.Header.Get("X-Username"), "material", cur.Code,
"编辑物料 "+cur.Code+" "+cur.Name, nil)
ok(w, m)
}
}
// queryMaterialsHandler 分页查询物料
func queryMaterialsHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
page := atoi(r.URL.Query().Get("page"), 1)
pageSize := atoi(r.URL.Query().Get("pageSize"), 20)
materialCode := r.URL.Query().Get("materialCode")
name := r.URL.Query().Get("name")
spec := r.URL.Query().Get("spec")
manageMode := atoi(r.URL.Query().Get("manageMode"), 0)
itemType := atoi(r.URL.Query().Get("itemType"), 0)
// 排除品类:入库各页签按管理粒度过滤时,需把「其他」品类(item_type=4)排除,
// 否则档案里选「其他」时 manage_mode 保留默认值 1,会漏进结构件入库的下拉。
excludeItemType := atoi(r.URL.Query().Get("excludeItemType"), 0)
startDate := r.URL.Query().Get("startDate")
endDate := r.URL.Query().Get("endDate")
keyword := r.URL.Query().Get("keyword") // 仅供下拉选择框(MaterialSelect)远程搜索:编码/名称任一匹配;列表页禁用此混搜模式
q := ctx.EntClient.Material.Query()
if materialCode != "" {
q = q.Where(material.CodeContainsFold(materialCode))
}
if name != "" {
q = q.Where(material.NameContainsFold(name))
}
if spec != "" {
q = q.Where(material.SpecContainsFold(spec))
}
if manageMode != 0 {
q = q.Where(material.ManageModeEQ(manageMode))
}
if itemType != 0 {
q = q.Where(material.ItemTypeEQ(itemType))
}
if excludeItemType != 0 {
q = q.Where(material.ItemTypeNEQ(excludeItemType))
}
if keyword != "" {
q = q.Where(
material.Or(
material.CodeContainsFold(keyword),
material.NameContainsFold(keyword),
),
)
}
if startDate != "" {
if t, err := time.ParseInLocation("2006-01-02", startDate, time.Local); err == nil {
q = q.Where(material.CreatedAtGTE(t.Unix()))
}
}
if endDate != "" {
if t, err := time.ParseInLocation("2006-01-02", endDate, time.Local); err == nil {
q = q.Where(material.CreatedAtLTE(t.Add(24 * time.Hour).Unix()))
}
}
total, err := q.Count(ctx0())
if err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
list, err := q.Order(ent.Desc("id")).
Offset((page - 1) * pageSize).
Limit(pageSize).
All(ctx0())
if err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
ok(w, map[string]any{
"total": total,
"list": list,
"page": page,
"pageSize": pageSize,
})
}
}
// listMaterialsHandler 下拉框全量列表(不翻页)
func listMaterialsHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
list, err := ctx.EntClient.Material.Query().Order(ent.Desc("created_at"), ent.Desc("id")).Limit(100).All(ctx0())
if err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
ok(w, list)
}
}
// getMaterialHandler 物料详情
func getMaterialHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := atoi(r.URL.Query().Get("id"), 0)
if id <= 0 {
fail(w, http.StatusBadRequest, "id 必填")
return
}
m, err := ctx.EntClient.Material.Get(ctx0(), id)
if err != nil {
fail(w, http.StatusNotFound, "物料不存在")
return
}
ok(w, m)
}
}
// materialTypesHandler 内部接口:批量查询物料品类(item_type)。
// body: {"codes":["A","B"]} → data: {"types":{"A":1,"B":3}}1原材料/2半成品/3成品/4其他)。
// 供 MES 递归展开产品物料清单使用:成品子项(item_type=3)需继续展开到原材料。
func materialTypesHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req struct {
Codes []string `json:"codes"`
}
if err := parseJSON(r, &req); err != nil {
fail(w, http.StatusBadRequest, "参数错误")
return
}
seen := map[string]bool{}
codes := []string{}
for _, c := range req.Codes {
c = strings.TrimSpace(c)
if c == "" || seen[c] {
continue
}
seen[c] = true
codes = append(codes, c)
}
types := map[string]int{}
if len(codes) > 0 {
list, err := ctx.EntClient.Material.Query().
Where(material.CodeIn(codes...)).
Select(material.FieldCode, material.FieldItemType).
All(ctx0())
if err != nil {
fail(w, http.StatusInternalServerError, "查询物料品类失败: "+err.Error())
return
}
for _, m := range list {
types[m.Code] = m.ItemType
}
}
ok(w, map[string]any{"types": types})
}
}
// materialExistsHandler 内部接口:批量校验物料编码是否存在于物料档案。
// body: {"codes":["A","B"]} → data: {"missing":["B"]}(不存在于档案的编码列表)。
// 供 MES 备料单生成前防呆使用(wrapInternal + X-API-TOKEN 保护)。
func materialExistsHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req struct {
Codes []string `json:"codes"`
}
if err := parseJSON(r, &req); err != nil {
fail(w, http.StatusBadRequest, "参数错误")
return
}
// 去重并过滤空串
seen := map[string]bool{}
codes := []string{}
for _, c := range req.Codes {
c = strings.TrimSpace(c)
if c == "" || seen[c] {
continue
}
seen[c] = true
codes = append(codes, c)
}
missing := []string{}
if len(codes) > 0 {
// 查存在的编码集合
exist, err := ctx.EntClient.Material.Query().
Where(material.CodeIn(codes...)).
Select(material.FieldCode).
All(ctx0())
if err == nil {
have := map[string]bool{}
for _, m := range exist {
have[m.Code] = true
}
for _, c := range codes {
if !have[c] {
missing = append(missing, c)
}
}
}
}
ok(w, map[string]any{"missing": missing})
}
}
// exportMaterialsHandler 物料档案全量导出(xlsx),按当前筛选条件导出
func exportMaterialsHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
materialCode := r.URL.Query().Get("materialCode")
name := r.URL.Query().Get("name")
startDate, endDate, rerr := normalizeExportRange(r.URL.Query().Get("startDate"), r.URL.Query().Get("endDate"))
if rerr != nil {
fail(w, http.StatusBadRequest, rerr.Error())
return
}
startUnix, endUnix := exportRangeUnix(startDate, endDate)
spec := r.URL.Query().Get("spec")
manageMode := atoi(r.URL.Query().Get("manageMode"), 0)
itemType := atoi(r.URL.Query().Get("itemType"), 0)
q := ctx.EntClient.Material.Query()
q = q.Where(material.CreatedAtGTE(startUnix), material.CreatedAtLTE(endUnix))
if materialCode != "" {
q = q.Where(material.CodeContainsFold(materialCode))
}
if name != "" {
q = q.Where(material.NameContainsFold(name))
}
if spec != "" {
q = q.Where(material.SpecContainsFold(spec))
}
if manageMode != 0 {
q = q.Where(material.ManageModeEQ(manageMode))
}
if itemType != 0 {
q = q.Where(material.ItemTypeEQ(itemType))
}
list, err := q.Order(ent.Desc("id")).All(ctx0())
if err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
type exportRow struct {
ID int
Code string
Name string
Spec string
Unit string
ItemType int
ManageMode int
Description string
CreatedAt int64
}
rows := make([]exportRow, 0, len(list))
for _, m := range list {
rows = append(rows, exportRow{
ID: m.ID, Code: m.Code, Name: m.Name, Spec: m.Spec,
Unit: m.Unit, ItemType: m.ItemType, ManageMode: m.ManageMode,
Description: m.Description, CreatedAt: m.CreatedAt,
})
}
if r.URL.Query().Get("format") == "xlsx" {
// 图号=物料编码(问题记录 L18 合并裁决):导出表头统一叫「图号」
// 「简称」已按 2026-09-19 用户裁决整体删除,导出列同步移除
headers := []string{"ID", "图号", "名称", "规格", "单位", "品类", "类型", "说明", "创建时间"}
matrix := make([][]any, 0, len(rows))
for _, rr := range rows {
matrix = append(matrix, []any{
rr.ID, rr.Code, rr.Name, rr.Spec, rr.Unit,
itemTypeLabel(rr.ItemType), manageModeLabel(rr.ManageMode),
rr.Description, unixFmt(rr.CreatedAt),
})
}
sendExcel(w, xlsxFilename("物料档案"), headers, matrix)
return
}
ok(w, map[string]any{"total": len(rows), "list": rows})
}
}
// parseManageMode 解析导入模板"类型"列:支持中文(结构件/电气件,含"(批次)/(SN)"后缀)
// 与旧数字格式(1/2)双向兼容;无法识别返回 0。
func parseManageMode(s string) int {
if s == "" {
return 0
}
if strings.Contains(s, "电气") || strings.EqualFold(s, "SN") {
return 2
}
if strings.Contains(s, "结构") || strings.Contains(s, "批次") {
return 1
}
if n := atoi(s, 0); n == 1 || n == 2 {
return n
}
return 0
}
// excelMaterialImportHandler 物料档案 Excel 批量导入
// 模板(首行忽略表头):图号 | 名称 | 规格 | 单位 | 类型(结构件/电气件) | 说明
// (图号=物料编码,问题记录 L18 合并裁决,不再单设图号列;「简称」列已按 2026-09-19 裁决移除)
// 强约束:全成功或全失败(一次性全导入 or 全失败,禁止部分成功)。
// 阶段1 逐行解析+校验(图号必填且唯一 / 名称、规格、单位必填 / 类型合法) → 任一错则整体拒绝并列出错误行;
// 阶段2 全部通过 → 开启单事务整体提交,任一步失败整体回滚。
func excelMaterialImportHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(20 << 20); err != nil {
fail(w, http.StatusBadRequest, "文件上传失败: "+err.Error())
return
}
f, _, err := r.FormFile("file")
if err != nil {
fail(w, http.StatusBadRequest, "缺少 file 字段")
return
}
defer f.Close()
xl, err := excelize.OpenReader(f)
if err != nil {
fail(w, http.StatusBadRequest, "无法解析 Excel: "+err.Error())
return
}
defer xl.Close()
sheet := xl.GetSheetList()[0]
rows, err := xl.GetRows(sheet)
if err != nil {
fail(w, http.StatusBadRequest, "读取工作表失败: "+err.Error())
return
}
if len(rows) <= 1 {
fail(w, http.StatusBadRequest, "文件无数据行(首行为表头,需至少一行数据)")
return
}
type planRow struct {
row int
code string
name string
spec string
unit string
manageMode int
description string
}
plans := make([]planRow, 0, len(rows)-1)
errs := make([]importErr, 0)
// 阶段1:解析 + 校验(不落库)
for i, row := range rows {
if i == 0 {
continue // 表头
}
cell := func(idx int) string {
if idx < len(row) {
return strings.TrimSpace(row[idx])
}
return ""
}
pr := planRow{row: i + 1}
pr.code = cell(0)
pr.name = cell(1)
pr.spec = cell(2)
pr.unit = cell(3)
pr.manageMode = parseManageMode(cell(4))
pr.description = cell(5)
if pr.code == "" {
errs = append(errs, importErr{pr.row, pr.code, "图号为空"})
continue
}
if pr.name == "" {
errs = append(errs, importErr{pr.row, pr.code, "名称为空"})
continue
}
if pr.spec == "" {
errs = append(errs, importErr{pr.row, pr.code, "规格型号为空"})
continue
}
if pr.unit == "" {
errs = append(errs, importErr{pr.row, pr.code, "单位为空"})
continue
}
if pr.manageMode == 0 {
errs = append(errs, importErr{pr.row, pr.code, "类型无法识别,请填写「结构件」或「电气件」(兼容 1/2)"})
continue
}
exists, _ := ctx.EntClient.Material.Query().
Where(material.CodeEQ(pr.code)).Exist(ctx0())
if exists {
errs = append(errs, importErr{pr.row, pr.code, "图号已存在: " + pr.code})
continue
}
plans = append(plans, pr)
}
// 阶段2:有错则整体拒绝,一行都不写
if len(errs) > 0 {
ok(w, map[string]any{"success": 0, "failed": len(errs), "errors": errs})
return
}
// 阶段3:全部通过 → 单事务整体提交(原子,全成功或全失败)
tx, e := ctx.EntClient.Tx(ctx0())
if e != nil {
fail(w, http.StatusInternalServerError, "开启事务失败: "+e.Error())
return
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback()
}
}()
for _, pr := range plans {
if _, e = tx.Material.Create().
SetCode(pr.code).
SetName(pr.name).
SetNillableSpec(strPtr(pr.spec)).
SetNillableUnit(strPtr(pr.unit)).
SetNillableDescription(strPtr(pr.description)).
SetManageMode(pr.manageMode).
SetIsBatchManaged(pr.manageMode == 1).
SetIsSerialManaged(pr.manageMode == 2).
Save(ctx0()); e != nil {
fail(w, http.StatusInternalServerError, "创建物料失败: "+e.Error())
return
}
}
if e = tx.Commit(); e != nil {
fail(w, http.StatusInternalServerError, "提交事务失败: "+e.Error())
return
}
committed = true
ctx.EventLog.Write(ctx0(), "material.import", r.Header.Get("X-Username"), "material", "",
"Excel 导入物料", map[string]any{"success": len(plans)})
ok(w, map[string]any{"success": len(plans), "failed": 0, "errors": []importErr{}})
}
}