feat: 完成WMS系统权限体系与业务功能迭代
本提交完成了WMS系统的多维度优化升级: 1. 新增RBAC权限系统,支持角色/菜单/按钮三级权限管控 2. 重构库存盘点、物料管理、区域维护等模块的查询筛选与展示逻辑 3. 优化入库/出库/质检等业务流程,完善数据冗余与业务闭环 4. 移除旧装箱表,将装箱逻辑合并到出库主表 5. 新增前端权限判断工具、导出工具与端到端测试用例 6. 补充完善各类注释与数据库字段说明
This commit is contained in:
@@ -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}.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 字段")
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user