Files
bj_power/bj_power_wms/internal/handler/excel.go
T

405 lines
12 KiB
Go
Raw Normal View History

2026-08-28 15:06:01 +08:00
package handler
import (
"bytes"
2026-08-28 15:06:01 +08:00
"fmt"
"net/http"
"net/url"
2026-08-28 15:06:01 +08:00
"strings"
"time"
"unicode/utf8"
2026-08-28 15:06:01 +08:00
"bj_power_wms/ent/inventory"
2026-08-28 15:06:01 +08:00
"bj_power_wms/ent/material"
"bj_power_wms/internal/svc"
"github.com/google/uuid"
2026-08-28 15:06:01 +08:00
"github.com/xuri/excelize/v2"
)
// xlsxColumn 导出列定义:列名 + 取值函数(返回字符串)
type xlsxColumn struct {
Title string
Get func(map[string]any) string
}
// xlsxFilename 生成导出文件名:{页面名}_{YYYYMMDDHHMMSS}.xlsxURL 安全,避免中文编码问题)
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 中的显示宽度(中文=2ASCII=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 全部通过 → 开启单事务,整体提交,任一步失败整体回滚。
2026-08-28 15:06:01 +08:00
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"
}
2026-08-28 15:06:01 +08:00
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
}
2026-08-28 15:06:01 +08:00
type planRow struct {
row int
code string
batchNo string
sn string
qty int
prodDate string
supplier string
zone string
remark string
2026-08-28 15:06:01 +08:00
}
plans := make([]planRow, 0, len(rows)-1)
errs := make([]importErr, 0)
2026-08-28 15:06:01 +08:00
// ---- 阶段1:解析 + 校验(不落库) ----
snSeen := map[string]bool{}
2026-08-28 15:06:01 +08:00
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("在库").
2026-08-28 15:06:01 +08:00
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
2026-08-28 15:06:01 +08:00
}
}
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
}
2026-08-28 15:06:01 +08:00
}
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
2026-08-28 15:06:01 +08:00
}
return b
2026-08-28 15:06:01 +08:00
}