Files
bj_power/bj_power_wms/internal/handler/excel.go
T
SunYF 6d8bb75696 refactor(wms/mes): 统一多场景查询逻辑,移除关键字混搜
1. 移除所有多字段关键字混搜,改为各筛选字段独立查询
2. 调整excel导出相关逻辑,优化文件名处理与导入模板
3. 修复物料导入的类型解析与token获取逻辑
4. 更新依赖与前端页面文案、筛选参数
2026-09-08 13:43:08 +08:00

439 lines
13 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 (
"bytes"
"errors"
"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"
)
// normalizeExportRange 导出时间范围硬规则(2026-09-08 全项目统一):
// ① 起止都空 → 默认近 3 个月;② 只传一个 → 报错;③ 间隔 > 1 年 → 报错;④ 结束早于起始 → 报错。
// 返回归一化后的 YYYY-MM-DD 字符串,调用方按原有逻辑解析过滤。
func normalizeExportRange(startDate, endDate string) (string, string, error) {
if startDate == "" && endDate == "" {
now := time.Now()
return now.AddDate(0, -3, 0).Format("2006-01-02"), now.Format("2006-01-02"), nil
}
if startDate == "" || endDate == "" {
return "", "", errors.New("导出必须同时提供起始时间与结束时间")
}
st, err1 := time.ParseInLocation("2006-01-02", startDate, time.Local)
et, err2 := time.ParseInLocation("2006-01-02", endDate, time.Local)
if err1 != nil || err2 != nil {
return "", "", errors.New("时间格式应为 YYYY-MM-DD")
}
if et.Before(st) {
return "", "", errors.New("结束时间不能早于起始时间")
}
if et.Sub(st) > 366*24*time.Hour {
return "", "", errors.New("导出时间间隔不能超过 1 年")
}
return startDate, endDate, nil
}
// exportRangeUnix 把 YYYY-MM-DD 区间转为 unix 秒闭区间 [start, end+1天)
func exportRangeUnix(startDate, endDate string) (int64, int64) {
st, _ := time.ParseInLocation("2006-01-02", startDate, time.Local)
et, _ := time.ParseInLocation("2006-01-02", endDate, time.Local)
return st.Unix(), et.Add(24*time.Hour).Unix() - 1
}
// 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 兜底名恒为纯 ASCII(中文原样放 filename="" 会被浏览器按 latin-1 渲染成乱码),
// 前端优先解析 filename* 拿到中文原名。
asciiName := "export.xlsx"
encName := ""
if utf8.ValidString(filename) {
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 全部通过 → 开启单事务,整体提交,任一步失败整体回滚。
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
}