package handler import ( "bytes" "fmt" "net/http" "net/url" "strings" "time" "unicode/utf8" "bj_power_wms/ent/inventory" "bj_power_wms/ent/material" "bj_power_wms/internal/svc" "github.com/google/uuid" "github.com/xuri/excelize/v2" ) // xlsxColumn 导出列定义:列名 + 取值函数(返回字符串) type xlsxColumn struct { Title string Get func(map[string]any) string } // xlsxFilename 生成导出文件名:{页面名}_{YYYYMMDDHHMMSS}.xlsx(URL 安全,避免中文编码问题) func xlsxFilename(page string) string { return fmt.Sprintf("%s_%s.xlsx", page, time.Now().Format("20060102_150405")) } // sendExcel 用 excelize 生成 .xlsx 并写回响应(UTF-8 表头,列宽自适应,冻结首行) // headers 与 rows 为字符串二维数组;filename 用于 Content-Disposition。 func sendExcel(w http.ResponseWriter, filename string, headers []string, rows [][]any /* any: string|int|int64 */) { buf, err := buildXlsx(headers, rows) if err != nil { fail(w, http.StatusInternalServerError, "生成 Excel 失败: "+err.Error()) return } // RFC 5987:中文文件名用 filename*=UTF-8''...(URL 编码),并提供 ASCII 兜底文件名 asciiName := "export.xlsx" encName := "" if utf8.ValidString(filename) { asciiName = strings.TrimSuffix(filename, ".xlsx") + ".xlsx" encName = "filename*=UTF-8''" + url.QueryEscape(filename) } w.Header().Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") cd := fmt.Sprintf("attachment; filename=\"%s\"", asciiName) if encName != "" { cd += "; " + encName } w.Header().Set("Content-Disposition", cd) w.Header().Set("X-Excel-Filename", url.QueryEscape(filename)) w.WriteHeader(http.StatusOK) _, _ = w.Write(buf.Bytes()) } // buildXlsx 生成 xlsx 字节流(含表头样式、列宽自适应) func buildXlsx(headers []string, rows [][]any) (*bytes.Buffer, error) { f := excelize.NewFile() defer f.Close() sheet := f.GetSheetName(0) for i, h := range headers { cell, _ := excelize.CoordinatesToCellName(i+1, 1) _ = f.SetCellValue(sheet, cell, h) } style, _ := f.NewStyle(&excelize.Style{ Font: &excelize.Font{Bold: true}, Fill: excelize.Fill{Type: "pattern", Color: []string{"D9E1F2"}, Pattern: 1}, Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"}, }) _ = f.SetRowStyle(sheet, 1, 1, style) for r, row := range rows { for c, v := range row { cell, _ := excelize.CoordinatesToCellName(c+1, r+2) _ = f.SetCellValue(sheet, cell, v) } } // 列宽按显示宽度估算(中文=2,ASCII=1,宁宽勿窄) widths := make([]float64, len(headers)) for c := range headers { widths[c] = float64(displayWidth(headers[c]) + 2) } for _, row := range rows { for c, v := range row { if c >= len(widths) { break } cw := float64(displayWidth(fmt.Sprintf("%v", v)) + 2) if cw > widths[c] { widths[c] = cw } } } for c, wd := range widths { col, _ := excelize.ColumnNumberToName(c + 1) _ = f.SetColWidth(sheet, col, col, wd) } return f.WriteToBuffer() } // displayWidth 估算字符串在 Excel 中的显示宽度(中文=2,ASCII=1) func displayWidth(s string) int { n := 0 for _, r := range s { if r > 0x2E7F { // 含中文/全角 n += 2 } else { n++ } } return n } // manageModeLabel 管理粒度中文名:1=结构件 2=精密件 func manageModeLabel(m int) string { if m == 1 { return "结构件" } if m == 2 { return "精密件" } return "-" } // unixFmt 把 unix 秒转成 yyyy-MM-dd HH:mm:ss(时间戳<=0 返回空串) func unixFmt(ts int64) string { if ts <= 0 { return "" } return time.Unix(ts, 0).Format("2006-01-02 15:04:05") } // inspectionTypeLabel 检验类型中文名 func inspectionTypeLabel(t string) string { switch t { case "incoming": return "来料检" case "process": return "过程检" case "finished": return "成品检" } return t } // importErr 单行校验错误(行号 + 物料 + 原因),用于"全失败"时回给前端定位 type importErr struct { Row int `json:"row"` MaterialCode string `json:"materialCode"` Message string `json:"message"` } // excelInboundHandler Excel 批量导入入库 // mode=batch(结构件):列头(首行忽略) 物料编码 | 批次号(可空自动生成) | 数量 | 生产日期 | 供应商 | 区域 | 备注 // mode=sn(精密件): 列头(首行忽略) 物料编码 | SN | 区域 | 生产日期 | 供应商 // // 强约束:全成功或全失败(一次性全导入 or 全失败,禁止部分成功)。 // 流程:阶段1 逐行解析+校验(物料存在/数量>0/SN不重复/区域必填) → 任一错则整体拒绝并列出错误行(一行不写); // 阶段2 全部通过 → 开启单事务,整体提交,任一步失败整体回滚。 func excelInboundHandler(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 } mode := r.FormValue("mode") if mode != "sn" { mode = "batch" } 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 batchNo string sn string qty int prodDate string supplier string zone string remark string } plans := make([]planRow, 0, len(rows)-1) errs := make([]importErr, 0) // ---- 阶段1:解析 + 校验(不落库) ---- snSeen := map[string]bool{} 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) if mode == "sn" { pr.sn = cell(1) pr.zone = cell(2) // 精密件模板: 物料编码|SN|区域|生产日期|供应商 pr.prodDate = cell(3) pr.supplier = cell(4) pr.remark = cell(4) } else { pr.batchNo = cell(1) pr.qty = atoi(cell(2), 0) pr.prodDate = cell(3) pr.supplier = cell(4) pr.zone = cell(5) // 结构件模板: 物料编码|批次号|数量|生产日期|供应商|区域|备注 pr.remark = cell(6) } if pr.code == "" { errs = append(errs, importErr{pr.row, pr.code, "物料编码为空"}) continue } m, e := ctx.EntClient.Material.Query().Where(material.CodeEQ(pr.code)).Only(ctx0()) if e != nil { errs = append(errs, importErr{pr.row, pr.code, "物料不存在"}) continue } if mode == "sn" { if m.ManageMode != 2 { errs = append(errs, importErr{pr.row, pr.code, "该物料为结构件,SN 导入仅支持精密件(按 SN 管理)"}) continue } if pr.sn == "" { errs = append(errs, importErr{pr.row, pr.code, "SN 为空"}) continue } if snSeen[pr.sn] { errs = append(errs, importErr{pr.row, pr.code, "SN 在文件中重复: "+pr.sn}) continue } snSeen[pr.sn] = true exists, _ := ctx.EntClient.Inventory.Query(). Where(inventory.ManageModeEQ(2), inventory.SnCodeEQ(pr.sn)).Exist(ctx0()) if exists { errs = append(errs, importErr{pr.row, pr.code, "SN 已存在: "+pr.sn}) continue } } else { if m.ManageMode != 1 { errs = append(errs, importErr{pr.row, pr.code, "该物料为精密件,请使用 SN 导入"}) continue } if pr.qty <= 0 { errs = append(errs, importErr{pr.row, pr.code, "数量必须大于 0"}) continue } } if pr.zone == "" { errs = append(errs, importErr{pr.row, 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 { inboundNo := "IB" + uuid.NewString()[:12] m, _ := tx.Material.Query().Where(material.CodeEQ(pr.code)).Only(ctx0()) if mode == "sn" { if _, e = tx.Inventory.Create(). SetManageMode(2). SetMaterialCode(pr.code). SetNillableMaterialName(strPtr(m.Name)). SetSnCode(pr.sn). SetQuantity(1).SetLockedQty(0). SetQualityStatus("未检"). SetNillableProductionDate(strPtr(pr.prodDate)). SetNillableSupplier(strPtr(pr.supplier)). SetNillableZoneCode(strPtr(pr.zone)). SetStatus("在库"). SetInboundNo(inboundNo). Save(ctx0()); e != nil { fail(w, http.StatusInternalServerError, "创建 SN 库存失败: "+e.Error()) return } if _, e = tx.InboundDetail.Create(). SetInboundNo(inboundNo).SetSnCode(pr.sn).SetMaterialCode(pr.code).SetQuantity(1). Save(ctx0()); e != nil { fail(w, http.StatusInternalServerError, "创建入库明细失败: "+e.Error()) return } } else { batchNo := pr.batchNo if batchNo == "" { // 同一导入内多行空批次需保证批次号唯一,追加行号后缀 batchNo = genBatchNo(pr.code) + fmt.Sprintf("-%d", pr.row) } existing, e2 := tx.Inventory.Query(). Where(inventory.ManageModeEQ(1), inventory.BatchNoEQ(batchNo)).Only(ctx0()) if e2 == nil { if _, e = tx.Inventory.UpdateOneID(existing.ID). AddQuantity(pr.qty). SetNillableSupplier(strPtr(pr.supplier)). Save(ctx0()); e != nil { fail(w, http.StatusInternalServerError, "追加批次失败: "+e.Error()) return } } else { if _, e = tx.Inventory.Create(). SetManageMode(1). SetBatchNo(batchNo). SetMaterialCode(pr.code). SetNillableMaterialName(strPtr(m.Name)). SetQuantity(pr.qty).SetLockedQty(0). SetNillableProductionDate(strPtr(pr.prodDate)). SetNillableSupplier(strPtr(pr.supplier)). SetQualityStatus("未检"). SetNillableZoneCode(strPtr(pr.zone)). SetStatus("在库"). SetInboundNo(inboundNo). Save(ctx0()); e != nil { fail(w, http.StatusInternalServerError, "创建库存失败: "+e.Error()) return } } if _, e = tx.InboundDetail.Create(). SetInboundNo(inboundNo).SetBatchNo(batchNo).SetMaterialCode(pr.code).SetQuantity(pr.qty). Save(ctx0()); e != nil { fail(w, http.StatusInternalServerError, "创建入库明细失败: "+e.Error()) return } } if _, e = tx.InboundOrder.Create(). SetInboundNo(inboundNo). SetNillableInboundType(strPtr("purchase")). SetMaterialCode(pr.code). SetNillableMaterialName(strPtr(m.Name)). SetManageMode(m.ManageMode). SetNillableZoneCode(strPtr(pr.zone)). SetQuantity(ternary(mode == "sn", 1, pr.qty)). SetNillableRemark(strPtr(pr.remark)). SetOperator("excel-import"). 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 ok(w, map[string]any{"success": len(plans), "failed": 0, "errors": []importErr{}}) } } // ternary 小工具:condition 为真返回 a,否则 b func ternary(cond bool, a, b int) int { if cond { return a } return b }