本次提交覆盖多个业务模块的功能完善与体验优化:
1. **鉴权与配置调整**:
- 统一JWT滑动续签逻辑,简化Token存储,移除RefreshToken相关冗余代码
- 调整多项目配置文件中JWT过期时间为3600秒,统一会话闲置窗口
- 工位配置放开1~12限制,改为仅校验大于0
2. **术语统一替换**:全链路将"精密件"替换为"电气件",修正物料管理描述
3. **功能新增**:
- 新增工位类型、工艺路线与产线点位台账模块
- 添加工艺PDF预览面板、工位终端代理转发接口
- 新增操作日志按操作人列表筛选、工位登出日志记录
- 新增PLC移料指令与产线点位状态管理
4. **业务流程优化**:
- 调整BOM物料删除校验逻辑,优化工单备料计算
- 补充物料图号、检测单号等追溯字段
- 完善工艺流程图与工位绑定关系说明
- 优化前端页面文案与交互细节
5. **代码规范与维护**:
- 新增通用工具函数与前端静态资源
- 整理路由权限与中间件逻辑
- 修复部分接口与配置的不兼容问题
439 lines
13 KiB
Go
439 lines
13 KiB
Go
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}.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 兜底名恒为纯 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 中的显示宽度(中文=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
|
||
}
|