feat&refactor: 完成多模块功能迭代与配置优化

本次提交覆盖多个业务模块的功能完善与体验优化:
1.  **鉴权与配置调整**:
    - 统一JWT滑动续签逻辑,简化Token存储,移除RefreshToken相关冗余代码
    - 调整多项目配置文件中JWT过期时间为3600秒,统一会话闲置窗口
    - 工位配置放开1~12限制,改为仅校验大于0
2.  **术语统一替换**:全链路将"精密件"替换为"电气件",修正物料管理描述
3.  **功能新增**:
    - 新增工位类型、工艺路线与产线点位台账模块
    - 添加工艺PDF预览面板、工位终端代理转发接口
    - 新增操作日志按操作人列表筛选、工位登出日志记录
    - 新增PLC移料指令与产线点位状态管理
4.  **业务流程优化**:
    - 调整BOM物料删除校验逻辑,优化工单备料计算
    - 补充物料图号、检测单号等追溯字段
    - 完善工艺流程图与工位绑定关系说明
    - 优化前端页面文案与交互细节
5.  **代码规范与维护**:
    - 新增通用工具函数与前端静态资源
    - 整理路由权限与中间件逻辑
    - 修复部分接口与配置的不兼容问题
This commit is contained in:
SunYF
2026-09-10 16:59:15 +08:00
parent 6d8bb75696
commit 0ab21b1234
202 changed files with 21397 additions and 3172 deletions
+2 -1
View File
@@ -18,7 +18,8 @@ type PostgresConfig struct {
type AuthConfig struct {
AccessSecret string `json:",default=Hardman_2026"`
AccessExpire int64 `json:",default=1800"`
// AccessExpire 会话闲置窗口(秒):滑动续签下 = 距最后一次请求多久后需要重新登录
AccessExpire int64 `json:",default=3600"`
}
// InternalConfig 项目间内部 API 配置
+2 -2
View File
@@ -87,7 +87,7 @@ func SeedIfEmpty(client *ent.Client) error {
SaveX(ctx)
}
// 演示库存(统一 inventory 表,manage_mode 区分结构件/精密件)
// 演示库存(统一 inventory 表,manage_mode 区分结构件/电气件)
today := time.Now().Format("2006-01-02")
// 结构件批次(已检合格)
client.Inventory.Create().
@@ -115,7 +115,7 @@ func SeedIfEmpty(client *ent.Client) error {
SetStatus("在库").
SaveX(ctx)
// 精密件 SN(在库,合格)
// 电气件 SN(在库,合格)
for i := 1; i <= 5; i++ {
client.Inventory.Create().
SetManageMode(2).
+41 -2
View File
@@ -43,8 +43,8 @@ func isWeakPassword(pwd string) bool {
func loginHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req struct {
Username string `json:"username"`
Password string `json:"password"`
Username string `json:"username"`
Password string `json:"password"`
}
if err := parseJSON(r, &req); err != nil {
fail(w, http.StatusBadRequest, "参数错误")
@@ -78,6 +78,9 @@ func loginHandler(ctx *svc.ServiceContext) http.HandlerFunc {
fail(w, http.StatusInternalServerError, "签发 token 失败")
return
}
// 操作日志:登录(写失败不阻断登录)
ctx.EventLog.Write(ctx0(), "auth.login", u.Username, "auth", u.Username, "登录成功",
map[string]any{"ip": clientIP(r)})
ok(w, map[string]any{
"token": token,
"expireAt": expireAt,
@@ -92,6 +95,42 @@ func loginHandler(ctx *svc.ServiceContext) http.HandlerFunc {
}
}
// clientIP 取客户端 IP(优先代理头,兜底 RemoteAddr)
func clientIP(r *http.Request) string {
if v := r.Header.Get("X-Real-IP"); v != "" {
return v
}
if v := r.Header.Get("X-Forwarded-For"); v != "" {
// 多级代理取第一个
for i := 0; i < len(v); i++ {
if v[i] == ',' {
return strings.TrimSpace(v[:i])
}
}
return strings.TrimSpace(v)
}
if host := r.RemoteAddr; host != "" {
if i := strings.LastIndex(host, ":"); i > 0 {
return host[:i]
}
return host
}
return ""
}
// logoutHandler POST /api/user/logout 退出登录(需登录 JWT
// 记录退出日志;真正的登录态清除由前端完成(无状态 JWT 无服务端会话)。
func logoutHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
username := strings.TrimSpace(r.Header.Get("X-Username"))
if username != "" {
ctx.EventLog.Write(ctx0(), "auth.logout", username, "auth", username, "退出登录",
map[string]any{"ip": clientIP(r)})
}
ok(w, nil)
}
}
// changePasswordHandler POST /api/user/change-password(需登录 JWT
// 当前登录用户修改自己的密码;登录人由中间件写入的 X-Username 头确定。
func changePasswordHandler(ctx *svc.ServiceContext) http.HandlerFunc {
+64 -2
View File
@@ -4,11 +4,14 @@ import (
"net/http"
"strconv"
"bj_power_wms/ent/user"
"bj_power_wms/internal/svc"
)
// eventLogListHandler GET /api/event/logs 操作日志分页查询
// 筛选:eventType(事件类型) operator(操作人) entityType(对象类型) from/to(unix 秒)
// 可见性:admin 可看全部并可按操作人筛选;普通用户强制只看自己的日志(忽略 operator 参数)。
// operatorName:操作人中文名(users.real_name,按 operator 批量映射,历史数据同样生效)。
func eventLogListHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return requirePerm(ctx, "eventlog:manage", func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
@@ -16,13 +19,72 @@ func eventLogListHandler(ctx *svc.ServiceContext) http.HandlerFunc {
pageSize, _ := strconv.Atoi(q.Get("pageSize"))
from, _ := strconv.ParseInt(q.Get("from"), 10, 64)
to, _ := strconv.ParseInt(q.Get("to"), 10, 64)
isAdmin := roleFromRequest(r) == "admin"
operator := q.Get("operator")
if !isAdmin {
// 普通用户仅能查看自己的操作日志
operator = r.Header.Get("X-Username")
}
list, total, err := ctx.EventLog.Query(ctx0(),
q.Get("eventType"), q.Get("operator"), q.Get("entityType"),
q.Get("eventType"), operator, q.Get("entityType"),
from, to, page, pageSize)
if err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
ok(w, map[string]any{"list": list, "total": total})
// 批量映射操作人中文名(real_name),缺档/系统写入显示原用户名
ops := make([]string, 0, len(list))
for _, e := range list {
ops = append(ops, e.Operator)
}
nameMap := realNameMapOf(ctx, ops)
rows := make([]map[string]any, 0, len(list))
for _, e := range list {
name := nameMap[e.Operator]
if name == "" {
name = e.Operator
}
rows = append(rows, map[string]any{
"id": e.ID,
"eventType": e.EventType,
"description": e.Description,
"entityType": e.EntityType,
"entityId": e.EntityID,
"operator": e.Operator,
"operatorName": name,
"payload": e.Payload,
"createdAt": e.CreatedAt,
})
}
ok(w, map[string]any{"list": rows, "total": total, "isAdmin": isAdmin})
})
}
// realNameMapOf 批量取操作人中文名
func realNameMapOf(ctx *svc.ServiceContext, operators []string) map[string]string {
set := make([]string, 0, len(operators))
seen := map[string]bool{}
for _, op := range operators {
if op != "" && !seen[op] {
seen[op] = true
set = append(set, op)
}
}
out := map[string]string{}
if len(set) == 0 {
return out
}
us, err := ctx.EntClient.User.Query().Where(user.UsernameIn(set...)).All(ctx0())
if err != nil {
return out
}
for _, u := range us {
if u.RealName != "" {
out[u.Username] = u.RealName
}
}
return out
}
+6 -6
View File
@@ -149,13 +149,13 @@ func displayWidth(s string) int {
return n
}
// manageModeLabel 管理粒度中文名:1=结构件 2=精密
// manageModeLabel 管理粒度中文名:1=结构件 2=电气
func manageModeLabel(m int) string {
if m == 1 {
return "结构件"
}
if m == 2 {
return "精密件"
return "电气件"
}
return "-"
}
@@ -190,7 +190,7 @@ type importErr struct {
// excelInboundHandler Excel 批量导入入库
// mode=batch(结构件):列头(首行忽略) 物料编码 | 批次号(可空自动生成) | 数量 | 生产日期 | 供应商 | 区域 | 备注
// mode=sn(精密件): 列头(首行忽略) 物料编码 | SN | 区域 | 生产日期 | 供应商
// mode=sn(电气件): 列头(首行忽略) 物料编码 | SN | 区域 | 生产日期 | 供应商
//
// 强约束:全成功或全失败(一次性全导入 or 全失败,禁止部分成功)。
// 流程:阶段1 逐行解析+校验(物料存在/数量>0/SN不重复/区域必填) → 任一错则整体拒绝并列出错误行(一行不写);
@@ -259,7 +259,7 @@ func excelInboundHandler(ctx *svc.ServiceContext) http.HandlerFunc {
pr.code = cell(0)
if mode == "sn" {
pr.sn = cell(1)
pr.zone = cell(2) // 精密件模板: 物料编码|SN|区域|生产日期|供应商
pr.zone = cell(2) // 电气件模板: 物料编码|SN|区域|生产日期|供应商
pr.prodDate = cell(3)
pr.supplier = cell(4)
pr.remark = cell(4)
@@ -283,7 +283,7 @@ func excelInboundHandler(ctx *svc.ServiceContext) http.HandlerFunc {
}
if mode == "sn" {
if m.ManageMode != 2 {
errs = append(errs, importErr{pr.row, pr.code, "该物料为结构件,SN 导入仅支持精密件(按 SN 管理)"})
errs = append(errs, importErr{pr.row, pr.code, "该物料为结构件,SN 导入仅支持电气件(按 SN 管理)"})
continue
}
if pr.sn == "" {
@@ -303,7 +303,7 @@ func excelInboundHandler(ctx *svc.ServiceContext) http.HandlerFunc {
}
} else {
if m.ManageMode != 1 {
errs = append(errs, importErr{pr.row, pr.code, "该物料为精密件,请使用 SN 导入"})
errs = append(errs, importErr{pr.row, pr.code, "该物料为电气件,请使用 SN 导入"})
continue
}
if pr.qty <= 0 {
+16 -9
View File
@@ -18,7 +18,7 @@ import (
// createInboundHandler 入库
// 结构件: { inboundType, materialCode, batchNo, quantity, zoneCode, operator }
// 精密件: { inboundType, materialCode, snList: [sn...], zoneCode, operator }
// 电气件: { inboundType, materialCode, snList: [sn...], zoneCode, operator }
//
// 事务保证:入库单 + 库存行(inventory) + 入库明细 三者要么全成功,要么全回滚,杜绝半成品状态。
func createInboundHandler(ctx *svc.ServiceContext) http.HandlerFunc {
@@ -34,6 +34,7 @@ func createInboundHandler(ctx *svc.ServiceContext) http.HandlerFunc {
ProductionDate string `json:"productionDate"`
Supplier string `json:"supplier"`
Remark string `json:"remark"`
InspectionNo string `json:"inspectionNo"`
}
if err := parseJSON(r, &req); err != nil {
fail(w, http.StatusBadRequest, "参数错误: "+err.Error())
@@ -44,8 +45,12 @@ func createInboundHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return
}
if req.ZoneCode == "" {
fail(w, http.StatusBadRequest, "区域必填,请先选择入库区域")
return
fail(w, http.StatusBadRequest, "区域必填,请先选择入库区域")
return
}
if req.InspectionNo == "" {
fail(w, http.StatusBadRequest, "检测单号必填,请填写入库检报告单号")
return
}
// 物料档案
@@ -82,6 +87,7 @@ func createInboundHandler(ctx *svc.ServiceContext) http.HandlerFunc {
SetQuantity(0).
SetNillableOperator(strPtr(req.Operator)).
SetNillableRemark(strPtr(req.Remark)).
SetInspectionNo(req.InspectionNo).
Save(ctx0())
if err != nil {
fail(w, http.StatusInternalServerError, "创建入库单失败: "+err.Error())
@@ -150,10 +156,10 @@ func createInboundHandler(ctx *svc.ServiceContext) http.HandlerFunc {
totalQty = qty
}
// 精密件(SN 管理)
// 电气件(SN 管理)
if m.ManageMode == 2 {
if len(req.SnList) == 0 {
fail(w, http.StatusBadRequest, "该物料为精密件(按 SN 管理),请至少录入 1 条序列号(SN)")
fail(w, http.StatusBadRequest, "该物料为电气件(按 SN 管理),请至少录入 1 条序列号(SN)")
return
}
for _, sn := range req.SnList {
@@ -221,7 +227,7 @@ func createInboundHandler(ctx *svc.ServiceContext) http.HandlerFunc {
// excelInboundHandler 实现见 excel.go
// inboundRow 列表/导出统一结构:入库单 + 明细总数。
// 注意:明细(批次/SN)不在此内嵌——精密件单可能上万条 SN
// 注意:明细(批次/SN)不在此内嵌——电气件单可能上万条 SN
// 列表查询若一次性加载全部明细会撑爆内存,故只带总数,明细按需分页加载。
type inboundRow struct {
*ent.InboundOrder
@@ -229,7 +235,7 @@ type inboundRow struct {
}
// applyInboundFilters 统一列表与导出的筛选条件
// 结构件(manageMode=1)与精密件(manageMode=2)同主表(inbound_order),按 manageMode 区分类型
// 结构件(manageMode=1)与电气件(manageMode=2)同主表(inbound_order),按 manageMode 区分类型
func applyInboundFilters(q *ent.InboundOrderQuery, r *http.Request) *ent.InboundOrderQuery {
if v := r.URL.Query().Get("inboundNo"); v != "" {
q = q.Where(inboundorder.InboundNoContainsFold(v))
@@ -315,7 +321,7 @@ func inboundDetailsHandler(ctx *svc.ServiceContext) http.HandlerFunc {
}
// queryInboundHandler 入库单查询(分页列表)
// 结构件/精密件同主表,默认返回全部;支持多条件筛选。
// 结构件/电气件同主表,默认返回全部;支持多条件筛选。
func queryInboundHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
page := atoi(r.URL.Query().Get("page"), 1)
@@ -368,7 +374,7 @@ func exportInboundHandler(ctx *svc.ServiceContext) http.HandlerFunc {
rows := buildInboundRows(ctx, list)
// xlsx 导出:格式化 = xlsx → 直接生成二进制文件;否则保持 JSON 兼容
if r.URL.Query().Get("format") == "xlsx" {
headers := []string{"入库单号", "物料编码", "物料名称", "类型", "数量", "区域", "操作人", "备注", "明细条数", "入库时间"}
headers := []string{"入库单号", "物料编码", "物料名称", "类型", "数量", "区域", "操作人", "备注", "检测单号", "明细条数", "入库时间"}
matrix := make([][]any, 0, len(rows))
for _, row := range rows {
ib := row.InboundOrder
@@ -376,6 +382,7 @@ func exportInboundHandler(ctx *svc.ServiceContext) http.HandlerFunc {
ib.InboundNo, ib.MaterialCode, ib.MaterialName,
manageModeLabel(ib.ManageMode), ib.Quantity, ib.ZoneCode,
ib.Operator, ib.Remark, row.DetailCount,
ib.InspectionNo,
unixFmt(ib.CreatedAt),
})
}
+73 -9
View File
@@ -22,7 +22,7 @@ func resolveInspectionTarget(ctx *svc.ServiceContext, targetId string) (*ent.Inv
Where(inventory.ManageModeEQ(1), inventory.BatchNoEQ(targetId)).Only(ctx0()); err == nil {
return inv, nil
}
// 再按 SN精密件)
// 再按 SN电气件)
if inv, err := ctx.EntClient.Inventory.Query().
Where(inventory.ManageModeEQ(2), inventory.SnCodeEQ(targetId)).Only(ctx0()); err == nil {
return inv, nil
@@ -45,9 +45,10 @@ func createInspectionHandler(ctx *svc.ServiceContext) http.HandlerFunc {
InspectQty int `json:"inspectQty"` // 检验数量
PassQty int `json:"passQty"` // 合格数量
CheckDesc string `json:"checkDesc"` // 检测说明
FailReason string `json:"failReason"` // 不合格原因
ReportNo string `json:"reportNo"` // 报检单号(生产报检关联)
Inspector string `json:"inspector"`
Remark string `json:"remark"`
InspectionNo string `json:"inspectionNo"`
}
if err := parseJSON(r, &req); err != nil {
fail(w, http.StatusBadRequest, "参数错误")
@@ -97,9 +98,10 @@ func createInspectionHandler(ctx *svc.ServiceContext) http.HandlerFunc {
SetInspectQty(req.InspectQty).
SetPassQty(req.PassQty).
SetNillableCheckDesc(strPtr(req.CheckDesc)).
SetNillableFailReason(strPtr(req.FailReason)).
SetNillableInspector(strPtr(req.Inspector)).
SetNillableRemark(strPtr(req.Remark)).
SetNillableReportNo(strPtr(req.ReportNo)).
SetNillableInspectionNo(strPtr(req.InspectionNo)).
Save(ctx0())
if err != nil {
fail(w, http.StatusInternalServerError, err.Error())
@@ -138,7 +140,6 @@ func batchFlipInspectionHandler(ctx *svc.ServiceContext) http.HandlerFunc {
InspectQty int `json:"inspectQty"` // 检验数量
PassQty int `json:"passQty"` // 合格数量
CheckDesc string `json:"checkDesc"` // 检测说明
FailReason string `json:"failReason"` // 不合格原因
Inspector string `json:"inspector"`
}
if err := parseJSON(r, &req); err != nil {
@@ -152,8 +153,8 @@ func batchFlipInspectionHandler(ctx *svc.ServiceContext) http.HandlerFunc {
// 预校验:所有编号必须能定位到库存行,任意一条找不到则整体拒绝(避免部分成功、假闭环)
type target struct {
id string
inv *ent.Inventory
id string
inv *ent.Inventory
targetType string
material string
}
@@ -193,7 +194,6 @@ func batchFlipInspectionHandler(ctx *svc.ServiceContext) http.HandlerFunc {
SetInspectQty(req.InspectQty).
SetPassQty(req.PassQty).
SetNillableCheckDesc(strPtr(req.CheckDesc)).
SetNillableFailReason(strPtr(req.FailReason)).
SetNillableInspector(strPtr(req.Inspector)).
Save(ctx0()); err != nil {
fail(w, http.StatusInternalServerError, "创建检验记录失败: "+err.Error())
@@ -335,7 +335,7 @@ func exportInspectionHandler(ctx *svc.ServiceContext) http.HandlerFunc {
}
// xlsx 导出
if r.URL.Query().Get("format") == "xlsx" {
headers := []string{"ID", "对象", "批次/SN", "物料编码", "检验类型", "结论", "检验数", "合格数", "检测说明", "不合格原因", "检验员", "备注", "检验时间"}
headers := []string{"ID", "对象", "批次/SN", "物料编码", "检验类型", "结论", "检验数", "合格数", "检测说明", "报检单号", "检验员", "备注", "检测单号", "检验时间"}
matrix := make([][]any, 0, len(list))
for _, rec := range list {
obj := rec.TargetType
@@ -347,8 +347,9 @@ func exportInspectionHandler(ctx *svc.ServiceContext) http.HandlerFunc {
matrix = append(matrix, []any{
rec.ID, obj, rec.TargetID, rec.MaterialCode,
inspectionTypeLabel(rec.InspectionType), rec.Status,
rec.InspectQty, rec.PassQty, rec.CheckDesc, rec.FailReason,
rec.InspectQty, rec.PassQty, rec.CheckDesc, rec.ReportNo,
rec.Inspector, rec.Remark, unixFmt(rec.CreatedAt),
rec.InspectionNo,
})
}
sendExcel(w, xlsxFilename("检验记录"), headers, matrix)
@@ -357,3 +358,66 @@ func exportInspectionHandler(ctx *svc.ServiceContext) http.HandlerFunc {
ok(w, map[string]any{"list": list})
}
}
// statsInspectionHandler 检验统计:按物料汇总检验次数/合格数/不合格数/合格率
// 对应《问题记录》附二9 的替代方案(不合并记录,另出统计视图)
func statsInspectionHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
inspectionType := r.URL.Query().Get("inspectionType")
startDate := r.URL.Query().Get("startDate")
endDate := r.URL.Query().Get("endDate")
q := ctx.EntClient.InspectionRecord.Query()
if inspectionType != "" {
q = q.Where(inspectionrecord.InspectionTypeEQ(inspectionType))
}
if startDate != "" {
if t, err := time.ParseInLocation("2006-01-02", startDate, time.Local); err == nil {
q = q.Where(inspectionrecord.CreatedAtGTE(t.Unix()))
}
}
if endDate != "" {
if t, err := time.ParseInLocation("2006-01-02", endDate, time.Local); err == nil {
q = q.Where(inspectionrecord.CreatedAtLTE(t.Add(24 * time.Hour).Unix()))
}
}
recs, err := q.All(ctx0())
if err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
type statRow struct {
MaterialCode string `json:"materialCode"`
Total int `json:"total"`
Pass int `json:"pass"`
Fail int `json:"fail"`
PassRate float64 `json:"passRate"`
}
agg := map[string]*statRow{}
order := []string{}
for _, rec := range recs {
r, found := agg[rec.MaterialCode]
if !found {
r = &statRow{MaterialCode: rec.MaterialCode}
agg[rec.MaterialCode] = r
order = append(order, rec.MaterialCode)
}
r.Total++
if rec.Status == "合格" {
r.Pass++
} else if rec.Status == "不合格" {
r.Fail++
}
}
list := make([]*statRow, 0, len(agg))
for _, code := range order {
r := agg[code]
if r.Total > 0 {
r.PassRate = float64(int(float64(r.Pass)/float64(r.Total)*10000)) / 100
}
list = append(list, r)
}
ok(w, map[string]any{"list": list})
}
}
+1 -1
View File
@@ -151,7 +151,7 @@ func finishedInboundHandler(ctx *svc.ServiceContext) http.HandlerFunc {
quality = "合格"
}
// SN 全局防重(同品类成品 + 半成品 + 精密件 SN 都不允许撞号)
// SN 全局防重(同品类成品 + 半成品 + 电气件 SN 都不允许撞号)
dup, err := ctx.EntClient.Inventory.Query().
Where(inventory.ManageModeEQ(2), inventory.SnCodeEQ(req.Sn)).Exist(ctx0())
if err != nil {
+12 -11
View File
@@ -21,12 +21,17 @@ type JWTClaims struct {
jwt.RegisteredClaims
}
// sessionExpire 会话闲置窗口(秒),默认 1 小时
func sessionExpire(ctx *svc.ServiceContext) int64 {
if ctx.Config.Auth.AccessExpire > 0 {
return ctx.Config.Auth.AccessExpire
}
return 3600
}
// SignToken 签发 JWT
func SignToken(ctx *svc.ServiceContext, userID int, username, realName, role string) (string, int64, error) {
expire := ctx.Config.Auth.AccessExpire
if expire <= 0 {
expire = 1800
}
expire := sessionExpire(ctx)
exp := time.Now().Add(time.Duration(expire) * time.Second)
claims := &JWTClaims{
UserID: userID,
@@ -62,14 +67,10 @@ func ParseToken(ctx *svc.ServiceContext, tokenStr string) (*JWTClaims, error) {
return claims, nil
}
// renewIfNearExpiry 滑动续签:剩余有效期不足一半时重签,写入响应头 X-Renewed-Token
// renewIfNearExpiry 滑动续签:每个携带有效 token 的请求都重签,
// 会话有效期 = 距最后一次请求 sessionExpire 秒(默认 1 小时),闲置超时即 401。
func renewIfNearExpiry(ctx *svc.ServiceContext, w http.ResponseWriter, claims *JWTClaims) {
expire := ctx.Config.Auth.AccessExpire
if expire <= 0 {
expire = 1800
}
remaining := time.Until(claims.ExpiresAt.Time)
if remaining > 0 && remaining < time.Duration(expire)*time.Second/2 {
if time.Until(claims.ExpiresAt.Time) > 0 {
if newToken, _, err := SignToken(ctx, claims.UserID, claims.Username, claims.RealName, claims.Role); err == nil {
w.Header().Set("X-Renewed-Token", newToken)
}
+18 -8
View File
@@ -37,6 +37,7 @@ func createMaterialHandler(ctx *svc.ServiceContext) http.HandlerFunc {
Description string `json:"description"`
ManageMode int `json:"manageMode"` // 1批次/2序列号
TemplateCode string `json:"templateCode"`
DrawingNo string `json:"drawingNo"`
}
if err := parseJSON(r, &req); err != nil {
fail(w, http.StatusBadRequest, "参数错误")
@@ -76,6 +77,7 @@ func createMaterialHandler(ctx *svc.ServiceContext) http.HandlerFunc {
SetIsBatchManaged(manageMode == 1).
SetIsSerialManaged(manageMode == 2).
SetNillableTemplateCode(strPtr(req.TemplateCode)).
SetNillableDrawingNo(strPtr(req.DrawingNo)).
Save(ctx0())
if err != nil {
logx.Errorf("create material failed: %v", err)
@@ -98,9 +100,10 @@ func updateMaterialHandler(ctx *svc.ServiceContext) http.HandlerFunc {
ShortName string `json:"shortName"`
Spec string `json:"spec"`
Unit string `json:"unit"`
Description string `json:"description"`
ManageMode int `json:"manageMode"`
}
Description string `json:"description"`
ManageMode int `json:"manageMode"`
DrawingNo string `json:"drawingNo"`
}
if err := parseJSON(r, &req); err != nil || req.ID <= 0 {
fail(w, http.StatusBadRequest, "参数错误")
return
@@ -119,6 +122,7 @@ func updateMaterialHandler(ctx *svc.ServiceContext) http.HandlerFunc {
upd.SetSpec(req.Spec)
upd.SetUnit(req.Unit)
upd.SetDescription(req.Description)
upd.SetDrawingNo(req.DrawingNo)
if req.ManageMode > 0 {
upd.SetManageMode(req.ManageMode).
SetIsBatchManaged(req.ManageMode == 1).
@@ -332,6 +336,7 @@ func exportMaterialsHandler(ctx *svc.ServiceContext) http.HandlerFunc {
ItemType int
ManageMode int
Description string
DrawingNo string
CreatedAt int64
}
rows := make([]exportRow, 0, len(list))
@@ -340,17 +345,19 @@ func exportMaterialsHandler(ctx *svc.ServiceContext) http.HandlerFunc {
ID: m.ID, Code: m.Code, Name: m.Name, ShortName: m.ShortName, Spec: m.Spec,
Unit: m.Unit, ItemType: m.ItemType, ManageMode: m.ManageMode,
Description: m.Description, CreatedAt: m.CreatedAt,
DrawingNo: m.DrawingNo,
})
}
if r.URL.Query().Get("format") == "xlsx" {
headers := []string{"ID", "物料编码", "名称", "简称", "规格", "单位", "品类", "类型", "说明", "创建时间"}
headers := []string{"ID", "物料编码", "名称", "简称", "规格", "单位", "品类", "类型", "说明", "图号", "创建时间"}
matrix := make([][]any, 0, len(rows))
for _, rr := range rows {
matrix = append(matrix, []any{
rr.ID, rr.Code, rr.Name, rr.ShortName, rr.Spec, rr.Unit,
itemTypeLabel(rr.ItemType), manageModeLabel(rr.ManageMode),
rr.Description, unixFmt(rr.CreatedAt),
rr.DrawingNo,
})
}
sendExcel(w, xlsxFilename("物料档案"), headers, matrix)
@@ -360,13 +367,13 @@ func exportMaterialsHandler(ctx *svc.ServiceContext) http.HandlerFunc {
}
}
// parseManageMode 解析导入模板"类型"列:支持中文(结构件/精密件,含"(批次)/(SN)"后缀)
// parseManageMode 解析导入模板"类型"列:支持中文(结构件/电气件,含"(批次)/(SN)"后缀)
// 与旧数字格式(1/2)双向兼容;无法识别返回 0。
func parseManageMode(s string) int {
if s == "" {
return 0
}
if strings.Contains(s, "精密") || strings.EqualFold(s, "SN") {
if strings.Contains(s, "电气") || strings.EqualFold(s, "SN") {
return 2
}
if strings.Contains(s, "结构") || strings.Contains(s, "批次") {
@@ -379,7 +386,7 @@ func parseManageMode(s string) int {
}
// excelMaterialImportHandler 物料档案 Excel 批量导入
// 模板(首行忽略表头):物料编码 | 名称 | 简称 | 规格 | 单位 | 类型(结构件/精密件) | 说明
// 模板(首行忽略表头):物料编码 | 名称 | 简称 | 规格 | 单位 | 类型(结构件/电气件) | 说明 | 图号(可选)
// 强约束:全成功或全失败(一次性全导入 or 全失败,禁止部分成功)。
// 阶段1 逐行解析+校验(编码必填且唯一 / 名称必填 / 类型合法) → 任一错则整体拒绝并列出错误行;
// 阶段2 全部通过 → 开启单事务整体提交,任一步失败整体回滚。
@@ -422,6 +429,7 @@ func excelMaterialImportHandler(ctx *svc.ServiceContext) http.HandlerFunc {
unit string
manageMode int
description string
drawingNo string
}
plans := make([]planRow, 0, len(rows)-1)
errs := make([]importErr, 0)
@@ -445,6 +453,7 @@ func excelMaterialImportHandler(ctx *svc.ServiceContext) http.HandlerFunc {
pr.unit = cell(4)
pr.manageMode = parseManageMode(cell(5))
pr.description = cell(6)
pr.drawingNo = cell(7)
if pr.code == "" {
errs = append(errs, importErr{pr.row, pr.code, "物料编码为空"})
@@ -455,7 +464,7 @@ func excelMaterialImportHandler(ctx *svc.ServiceContext) http.HandlerFunc {
continue
}
if pr.manageMode == 0 {
errs = append(errs, importErr{pr.row, pr.code, "类型无法识别,请填写「结构件」或「精密件」(兼容 1/2"})
errs = append(errs, importErr{pr.row, pr.code, "类型无法识别,请填写「结构件」或「电气件」(兼容 1/2"})
continue
}
exists, _ := ctx.EntClient.Material.Query().
@@ -494,6 +503,7 @@ func excelMaterialImportHandler(ctx *svc.ServiceContext) http.HandlerFunc {
SetNillableSpec(strPtr(pr.spec)).
SetNillableUnit(strPtr(pr.unit)).
SetNillableDescription(strPtr(pr.description)).
SetNillableDrawingNo(strPtr(pr.drawingNo)).
SetManageMode(pr.manageMode).
SetIsBatchManaged(pr.manageMode == 1).
SetIsSerialManaged(pr.manageMode == 2).
+2 -2
View File
@@ -307,7 +307,7 @@ type generalOutboundRequest struct {
// generalOutboundHandler 通用出库(手动出库,不依赖工单)
// 适用:退料 / 样品 / 报废 / 发货 等不挂靠 MES 工单的场景。
// 支持结构件批次与精密件 SN;填箱号时一并把装箱信息(箱号/SN清单/合同号)
// 支持结构件批次与电气件 SN;填箱号时一并把装箱信息(箱号/SN清单/合同号)
// 落到统一的出库主表,不再另建装箱表。
// 事务保证:出库单 + 库存扣减 + 出库明细 原子提交;库存用条件原子更新避免负库存。
func generalOutboundHandler(ctx *svc.ServiceContext) http.HandlerFunc {
@@ -406,7 +406,7 @@ func generalOutboundHandler(ctx *svc.ServiceContext) http.HandlerFunc {
}
}
// SN 扣减(精密件):仅在库/锁定的 SN 可出库
// SN 扣减(电气件):仅在库/锁定的 SN 可出库
if len(req.SnList) > 0 {
for _, sn := range req.SnList {
aff, err := tx.Inventory.Update().
+8 -6
View File
@@ -22,6 +22,7 @@ func RegisterHandlers(server *rest.Server, ctx *svc.ServiceContext) {
server.AddRoutes(
[]rest.Route{
{Method: http.MethodPost, Path: "/api/user/change-password", Handler: changePasswordHandler(ctx)},
{Method: http.MethodPost, Path: "/api/user/logout", Handler: logoutHandler(ctx)},
{Method: http.MethodGet, Path: "/api/user/info", Handler: userInfoHandler(ctx)},
{Method: http.MethodGet, Path: "/api/user/list", Handler: listUsersHandler(ctx)},
{Method: http.MethodPost, Path: "/api/user/create", Handler: createUserHandler(ctx)},
@@ -57,16 +58,16 @@ func RegisterHandlers(server *rest.Server, ctx *svc.ServiceContext) {
},
)
// 入库
server.AddRoutes(
[]rest.Route{
{Method: http.MethodPost, Path: "/api/inbound/create", Handler: createInboundHandler(ctx)},
// 入库
server.AddRoutes(
[]rest.Route{
{Method: http.MethodPost, Path: "/api/inbound/create", Handler: createInboundHandler(ctx)},
{Method: http.MethodGet, Path: "/api/inbound/query", Handler: queryInboundHandler(ctx)},
{Method: http.MethodGet, Path: "/api/inbound/details", Handler: inboundDetailsHandler(ctx)},
{Method: http.MethodGet, Path: "/api/inbound/export", Handler: exportInboundHandler(ctx)},
{Method: http.MethodPost, Path: "/api/inbound/batch-excel", Handler: excelInboundHandler(ctx)},
},
)
},
)
// 出库(统一主表:备料出库 workorder / 通用出库 general,装箱信息落在同一张表)
server.AddRoutes(
@@ -111,6 +112,7 @@ func RegisterHandlers(server *rest.Server, ctx *svc.ServiceContext) {
{Method: http.MethodPost, Path: "/api/inspection/batch-flip", Handler: batchFlipInspectionHandler(ctx)},
{Method: http.MethodGet, Path: "/api/inspection/query", Handler: queryInspectionHandler(ctx)},
{Method: http.MethodGet, Path: "/api/inspection/export", Handler: exportInspectionHandler(ctx)},
{Method: http.MethodGet, Path: "/api/inspection/stats", Handler: statsInspectionHandler(ctx)},
},
)
+14 -14
View File
@@ -44,14 +44,14 @@ type stockAggRow struct {
MaterialCode string `json:"materialCode"` // 物料编码
MaterialName string `json:"materialName"` // 物料名称
Spec string `json:"spec"` // 规格型号
ManageMode int `json:"manageMode"` // 1结构件/2精密
ManageMode int `json:"manageMode"` // 1结构件/2电气
ZoneCode string `json:"zoneCode"` // 区域
QualityStatus string `json:"qualityStatus"` // 质量状态
TotalQty int `json:"totalQty"` // 总数量(结构件=数量求和;精密件=SN 行数)
TotalQty int `json:"totalQty"` // 总数量(结构件=数量求和;电气件=SN 行数)
LockedQty int `json:"lockedQty"` // 锁定量
AvailQty int `json:"availQty"` // 可用量 = total - locked
BatchCount int `json:"batchCount"` // 批次数量(结构件维度的行数)
SnCount int `json:"snCount"` // SN 数量(精密件维度的行数)
SnCount int `json:"snCount"` // SN 数量(电气件维度的行数)
LastInboundNo string `json:"lastInboundNo"` // 最近入库单号
CreatedAt int64 `json:"createdAt"` // 最近一次入库时间(聚合组内最大 created_at)
}
@@ -117,7 +117,7 @@ type aggregateStockArgs struct {
}
// aggregateStock 按物料聚合库存主数据。
// 用相机内存聚合(inventory 表每物料的行数有限:结构件=批次数、精密件=SN 数),
// 用相机内存聚合(inventory 表每物料的行数有限:结构件=批次数、电气件=SN 数),
// 先拉取符合状态条件的行,在 Go 侧分组聚合,再做内存分页。
// 这样避免手写 GROUP BY 子查询的复杂度,且数据量受"在库/锁定"上限约束,安全可控。
func aggregateStock(ctx *svc.ServiceContext, a aggregateStockArgs) ([]*stockAggRow, int, error) {
@@ -299,9 +299,9 @@ type materialAggRow struct {
MaterialCode string `json:"materialCode"`
MaterialName string `json:"materialName"`
Spec string `json:"spec"`
ManageMode int `json:"manageMode"` // 1结构件/2精密
ManageMode int `json:"manageMode"` // 1结构件/2电气
Category int `json:"category"` // 品类 1原材料/2半成品/3成品
TotalQty int `json:"totalQty"` // 总数量(结构件=数量求和;精密件=SN 行数)
TotalQty int `json:"totalQty"` // 总数量(结构件=数量求和;电气件=SN 行数)
LockedQty int `json:"lockedQty"` // 锁定量
AvailQty int `json:"availQty"` // 可用量 = total - locked
QtyQualified int `json:"qtyQualified"` // 合格数量
@@ -310,7 +310,7 @@ type materialAggRow struct {
Zones []string `json:"zones"` // 分布区域列表
ZoneCount int `json:"zoneCount"` // 分布区域数
BatchCount int `json:"batchCount"` // 批次数(结构件维度的行数)
SnCount int `json:"snCount"` // SN 数(精密件维度的行数)
SnCount int `json:"snCount"` // SN 数(电气件维度的行数)
LastInboundNo string `json:"lastInboundNo"` // 最近入库单号
MaterialCreatedAt int64 `json:"materialCreatedAt"` // 物料主表创建时间(material.created_at
CreatedAt int64 `json:"createdAt"` // 最近一次入库时间
@@ -504,7 +504,7 @@ func aggregateMaterialSummary(ctx *svc.ServiceContext, a materialSummaryArgs) ([
return rows, total, nil
}
// qtyOf 库存行计入数量的口径:结构件按行数量、精密件按 1
// qtyOf 库存行计入数量的口径:结构件按行数量、电气件按 1
func qtyOf(inv *ent.Inventory) int {
if inv.ManageMode == 1 {
return inv.Quantity
@@ -665,7 +665,7 @@ type zoneSummRow struct {
ZoneCode string `json:"zoneCode"`
ZoneName string `json:"zoneName"`
Quality string `json:"quality"`
Type string `json:"type"` // 批次/精密
Type string `json:"type"` // 批次/电气
Qty int `json:"qty"`
ZoneCreatedAt int64 `json:"zoneCreatedAt"` // 区域主表创建时间(zone.created_at
LastCreatedAt int64 `json:"lastCreatedAt"` // 组内最近一次入库时间
@@ -703,7 +703,7 @@ func buildZoneSummary(ctx *svc.ServiceContext) ([]zoneSummRow, error) {
if zc == "" {
zc = "未分区"
}
// 类型标签:批次 / 精密件 / 半成品 / 成品(统一主表收编后按品类细分)
// 类型标签:批次 / 电气件 / 半成品 / 成品(统一主表收编后按品类细分)
t := "批次"
if inv.ManageMode == 2 {
switch inv.Category {
@@ -712,7 +712,7 @@ func buildZoneSummary(ctx *svc.ServiceContext) ([]zoneSummRow, error) {
case invCatFinished:
t = "成品"
default:
t = "精密件"
t = "电气件"
}
}
key := zc + "|" + inv.QualityStatus + "|" + t
@@ -793,7 +793,7 @@ func invAvailGTE(need int) predicate.Inventory {
// lockStockHandler 工单锁定库存(库存占用)
// body: { orderNo, items: [{ materialCode, batchNo?, sn?, qty }] }
// 锁定为"增量"操作:结构件 locked_qty += qty精密件状态置 锁定。绝不覆盖终值。
// 锁定为"增量"操作:结构件 locked_qty += qty电气件状态置 锁定。绝不覆盖终值。
// 锁批次用原子增量 + 可用量条件校验;锁 SN 用状态条件原子更新,杜绝超锁与并发冲突。
func lockStockHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
@@ -820,7 +820,7 @@ func lockStockHandler(ctx *svc.ServiceContext) http.HandlerFunc {
if item.MaterialCode == "" {
continue
}
// 精密件:锁 SN(原子:仅 在库 才能锁)
// 电气件:锁 SN(原子:仅 在库 才能锁)
if item.Sn != "" {
aff, err := ctx.EntClient.Inventory.Update().
Where(inventory.ManageModeEQ(2), inventory.SnCodeEQ(item.Sn), inventory.StatusEQ("在库")).
@@ -956,7 +956,7 @@ func checkStockHandler(ctx *svc.ServiceContext) http.HandlerFunc {
results := make([]map[string]any, 0, len(req.Items))
allOk := true
for _, item := range req.Items {
// 汇总可用量:结构件 quantity-locked_qty精密件 在库数量
// 汇总可用量:结构件 quantity-locked_qty电气件 在库数量
rows, _ := ctx.EntClient.Inventory.Query().
Where(inventory.MaterialCodeEQ(item.MaterialCode)).All(ctx0())
availBatch := 0
+3 -3
View File
@@ -14,14 +14,14 @@ import (
)
// startStocktakeHandler 发起盘点:冻结当前库存快照
// 快照统一来自 inventory 一张表;结构件以批次为单位(bookQty=批次余量),精密件以 SN 为单位(bookQty=1)。
// 快照统一来自 inventory 一张表;结构件以批次为单位(bookQty=批次余量),电气件以 SN 为单位(bookQty=1)。
func startStocktakeHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req struct {
Operator string `json:"operator"`
Zones []string `json:"zones"` // 可选:限定盘点区域,空=全部
MaterialCodes []string `json:"materialCodes"` // 可选:限定盘点物料,空=全部
ManageMode int `json:"manageMode"` // 可选:1结构件/2精密件,0=全部
ManageMode int `json:"manageMode"` // 可选:1结构件/2电气件,0=全部
QualityStatus string `json:"qualityStatus"` // 可选:限定质量状态,空=全部
}
if err := parseJSON(r, &req); err != nil {
@@ -154,7 +154,7 @@ func recordStocktakeHandler(ctx *svc.ServiceContext) http.HandlerFunc {
// finishStocktakeHandler 完成盘点:统计差异、按需按"增量"修正库存
// 调整原则(问题2):绝不覆盖最终值,只按差异增减——quantity += diffQty。
// - 结构件:批次余量 +diff(盘盈)/-diff(盘亏)
// - 精密件:差异为 -1(账面1实盘0)按报废处理;其余差异仅记录
// - 电气件:差异为 -1(账面1实盘0)按报废处理;其余差异仅记录
// 整个调整包在事务内,保证盘点修正原子性。
func finishStocktakeHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {