1. 新增跨平台磁盘空间监控能力,每日7点自动检测附件目录剩余空间,触发阈值告警 2. 重构附件存储方案为年/月/日/文件类型分层结构,统一MES与WMS的附件管理逻辑 3. 对齐物料档案与入库单的质量状态校验规则,仅合格品计入库存与出库 4. 实现入库单作废功能与区域库位的多级父子结构管理 5. 删除物料简称字段,补充规格型号与单位必填项,统一系统数据口径 6. 新增附件中心与磁盘状态查询接口,完善权限控制与操作日志
608 lines
19 KiB
Go
608 lines
19 KiB
Go
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{}})
|
||
}
|
||
}
|