feat: 完成WMS系统权限体系与业务功能迭代

本提交完成了WMS系统的多维度优化升级:
1. 新增RBAC权限系统,支持角色/菜单/按钮三级权限管控
2. 重构库存盘点、物料管理、区域维护等模块的查询筛选与展示逻辑
3. 优化入库/出库/质检等业务流程,完善数据冗余与业务闭环
4. 移除旧装箱表,将装箱逻辑合并到出库主表
5. 新增前端权限判断工具、导出工具与端到端测试用例
6. 补充完善各类注释与数据库字段说明
This commit is contained in:
SunYF
2026-09-03 14:20:37 +08:00
parent 665ff257dc
commit 572fa975bc
101 changed files with 14531 additions and 4692 deletions
+344 -74
View File
@@ -1,25 +1,176 @@
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"
)
// excelInboundHandler Excel 批量导入入库(结构件)
// 列头(第一行忽略):物料编码 | 批次号(可空,自动生成) | 数量 | 生产日期 | 供应商 | 区域 | 备注
// 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 全部通过 → 开启单事务,整体提交,任一步失败整体回滚。
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 字段")
@@ -39,17 +190,27 @@ func excelInboundHandler(ctx *svc.ServiceContext) http.HandlerFunc {
fail(w, http.StatusBadRequest, "读取工作表失败: "+err.Error())
return
}
type resultRow struct {
Row int `json:"row"`
Code string `json:"materialCode"`
BatchNo string `json:"batchNo"`
Qty int `json:"quantity"`
Error string `json:"error,omitempty"`
if len(rows) <= 1 {
fail(w, http.StatusBadRequest, "文件无数据行(首行为表头,需至少一行数据)")
return
}
results := []resultRow{}
success := 0
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 // 表头
@@ -60,75 +221,184 @@ func excelInboundHandler(ctx *svc.ServiceContext) http.HandlerFunc {
}
return ""
}
code := cell(0)
batchNo := cell(1)
qtyStr := cell(2)
qty := atoi(qtyStr, 0)
prodDate := cell(3)
supplier := cell(4)
zone := cell(5)
remark := cell(6)
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)
}
res := resultRow{Row: i + 1, Code: code, BatchNo: batchNo, Qty: qty}
switch {
case code == "":
res.Error = "物料编码为空"
case qty <= 0:
res.Error = "数量必须大于 0"
default:
m, err := ctx.EntClient.Material.Query().
Where(material.CodeEQ(code)).Only(ctx0())
if err != nil {
res.Error = "物料不存在"
} else if m.ManageMode == 2 {
res.Error = "精密件请使用 SN 扫码入库,不支持 Excel 导入"
} else {
if batchNo == "" {
batchNo = genBatchNo(code)
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
}
// 同批次追加或新建
existing, err := ctx.EntClient.Inventory.Query().
Where(inventory.ManageModeEQ(1), inventory.BatchNoEQ(batchNo)).Only(ctx0())
if err == nil {
ctx.EntClient.Inventory.UpdateOneID(existing.ID).
AddQuantity(qty).
SetNillableSupplier(strPtr(supplier)).
ExecX(ctx0())
} else {
ctx.EntClient.Inventory.Create().
SetManageMode(1).
SetBatchNo(batchNo).
SetMaterialCode(code).
SetNillableMaterialName(strPtr(m.Name)).
SetQuantity(qty).
SetLockedQty(0).
SetNillableProductionDate(strPtr(prodDate)).
SetNillableSupplier(strPtr(supplier)).
SetQualityStatus("未检").
SetNillableZoneCode(strPtr(zone)).
SetStatus("在库").
SaveX(ctx0())
if pr.sn == "" {
errs = append(errs, importErr{pr.row, pr.code, "SN 为空"})
continue
}
inboundNo := "IB" + nowStr() + fmt.Sprintf("%03d", i)
ctx.EntClient.InboundOrder.Create().
SetInboundNo(inboundNo).
SetNillableInboundType(strPtr("purchase")).
SetMaterialCode(code).
SetNillableMaterialName(strPtr(m.Name)).
SetManageMode(1).
SetNillableZoneCode(strPtr(zone)).
SetQuantity(qty).
SetNillableRemark(strPtr(remark)).
SetOperator("excel-import").
SaveX(ctx0())
res.BatchNo = batchNo
success++
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
}
}
results = append(results, res)
if pr.zone == "" {
errs = append(errs, importErr{pr.row, pr.code, "区域为空(必填)"})
continue
}
plans = append(plans, pr)
}
ok(w, map[string]any{"total": len(results), "success": success, "rows": results})
// ---- 阶段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
}