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