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