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

564 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handler
import (
"encoding/json"
"net/http"
"strings"
"bj_power_wms/ent"
"bj_power_wms/ent/inventory"
"bj_power_wms/ent/ordermaterialledger"
"bj_power_wms/ent/outbounddetail"
"bj_power_wms/ent/outboundorder"
"bj_power_wms/internal/svc"
"github.com/google/uuid"
"github.com/zeromicro/go-zero/core/logx"
)
// deductStockRequest 出库扣减请求
// createOutboundHandler 创建出库单(等价于扣减库存,走同一套约束)
func createOutboundHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return deductStockHandler(ctx)
}
type deductStockRequest struct {
OrderNo string `json:"orderNo"`
MaterialCode string `json:"materialCode"`
BatchNo string `json:"batchNo"`
SnList []string `json:"snList"`
Qty int `json:"qty"`
Operator string `json:"operator"`
Reviewer string `json:"reviewer"`
TargetDock string `json:"targetDock"`
ZoneCode string `json:"zoneCode"`
Remark string `json:"remark"`
}
// deductStockHandler 出库扣减
// body: { orderNo, materialCode, batchNo?, snList?[], qty, operator, targetDock? }
// 强约束:累计出库 ≤ 工单 BOM 需求量;等于时领料完结。
//
// 事务保证:出库单 + 库存行扣减(inventory) + 出库明细 + 工单台账 原子提交,
// 任一环节失败整体回滚,杜绝"出库单生成了但库存没扣"或"台账没更新"的中间态。
// 扣减本身用条件原子更新(AddQuantity(-qty) WHERE quantity>=qty),即使并发也不会出现负库存。
func deductStockHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req deductStockRequest
if err := parseJSON(r, &req); err != nil {
fail(w, http.StatusBadRequest, "参数错误: "+err.Error())
return
}
if req.OrderNo == "" || req.MaterialCode == "" {
fail(w, http.StatusBadRequest, "orderNo 和 materialCode 必填")
return
}
// 1. 工单台账强约束:累计出库 ≤ 总需求
ledger, err := ctx.EntClient.OrderMaterialLedger.Query().
Where(
ordermaterialledger.OrderNoEQ(req.OrderNo),
ordermaterialledger.MaterialCodeEQ(req.MaterialCode),
).
Only(ctx0())
if err != nil {
fail(w, http.StatusBadRequest, "工单物料台账不存在,请先在 MES 同步工单 BOM 后再领料;如无需挂靠工单(退料/样品/报废/发货),请改用「通用出库」: "+req.OrderNo+"/"+req.MaterialCode)
return
}
if ledger.Status == "领料完结" {
fail(w, http.StatusConflict, "工单领料已完结,禁止再出库")
return
}
// 实际扣减数量
actualQty := req.Qty
if req.BatchNo == "" && len(req.SnList) == 0 {
fail(w, http.StatusBadRequest, "batchNo 或 snList 至少提供一个")
return
}
outboundNo := "OB" + uuid.NewString()[:12]
// 开启事务:出库单 + 库存扣减 + 明细 + 台账 原子提交
tx, err := ctx.EntClient.Tx(ctx0())
if err != nil {
fail(w, http.StatusInternalServerError, "开启事务失败: "+err.Error())
return
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback()
}
}()
// 2. 创建出库单
ob, err := tx.OutboundOrder.Create().
SetOutboundNo(outboundNo).
SetOutboundType("workorder").
SetNillableOrderNo(strPtr(req.OrderNo)).
SetMaterialCode(req.MaterialCode).
SetManageMode(1).
SetNillableBatchNo(strPtr(req.BatchNo)).
SetNillableZoneCode(strPtr(req.ZoneCode)).
SetQuantity(0).
SetNillableOperator(strPtr(req.Operator)).
SetNillableReviewer(strPtr(req.Reviewer)).
SetNillableTargetDock(strPtr(req.TargetDock)).
SetNillableRemark(strPtr(req.Remark)).
Save(ctx0())
if err != nil {
fail(w, http.StatusInternalServerError, "创建出库单失败: "+err.Error())
return
}
// 3. 扣减批次(原子:quantity>=qty 才减,避免超扣/负数;locked_qty 同步回收)
if req.BatchNo != "" {
b, err := tx.Inventory.Query().
Where(inventory.ManageModeEQ(1), inventory.BatchNoEQ(req.BatchNo)).
Only(ctx0())
if err != nil {
fail(w, http.StatusBadRequest, "批次不存在: "+req.BatchNo)
return
}
avail := b.Quantity - b.LockedQty
if avail < req.Qty {
fail(w, http.StatusConflict, "批次库存不足: "+req.BatchNo+" 可用 "+itoa(avail))
return
}
aff, err := tx.Inventory.Update().
Where(inventory.ID(b.ID), inventory.QuantityGTE(req.Qty)).
AddQuantity(-req.Qty).
AddLockedQty(-min(req.Qty, b.LockedQty)).
Save(ctx0())
if err != nil || aff == 0 {
fail(w, http.StatusConflict, "批次扣减失败(库存不足或并发冲突): "+req.BatchNo)
return
}
if _, err = tx.OutboundDetail.Create().
SetOutboundNo(outboundNo).
SetBatchNo(req.BatchNo).
SetMaterialCode(req.MaterialCode).
SetQuantity(req.Qty).
SetNillableOrderNo(strPtr(req.OrderNo)).
Save(ctx0()); err != nil {
fail(w, http.StatusInternalServerError, "创建出库明细失败: "+err.Error())
return
}
actualQty = req.Qty
}
// 4. 扣减 SN(原子:仅 在库/锁定 的 SN 可出库 → 置出库)
if len(req.SnList) > 0 {
for _, sn := range req.SnList {
aff, err := tx.Inventory.Update().
Where(inventory.ManageModeEQ(2), inventory.SnCodeEQ(sn), inventory.StatusIn("在库", "锁定")).
SetStatus("出库").
SetNillableZoneCode(strPtr(req.ZoneCode)).
Save(ctx0())
if err != nil {
fail(w, http.StatusInternalServerError, "SN 出库失败: "+sn+" "+err.Error())
return
}
if aff == 0 {
fail(w, http.StatusBadRequest, "SN 不可出库(不存在或非在库/锁定): "+sn)
return
}
if _, err = tx.OutboundDetail.Create().
SetOutboundNo(outboundNo).
SetSnCode(sn).
SetMaterialCode(req.MaterialCode).
SetQuantity(1).
SetNillableOrderNo(strPtr(req.OrderNo)).
Save(ctx0()); err != nil {
fail(w, http.StatusInternalServerError, "创建出库明细失败: "+err.Error())
return
}
}
actualQty = len(req.SnList)
}
// 5. 更新出库单实际数量
if _, err = tx.OutboundOrder.UpdateOneID(ob.ID).
SetQuantity(actualQty).Save(ctx0()); err != nil {
fail(w, http.StatusInternalServerError, "更新出库单失败: "+err.Error())
return
}
// 6. 更新工单台账:累计出库(在事务内重读台账,避免并发重复累计)
ledgerNow, err := tx.OrderMaterialLedger.Query().
Where(
ordermaterialledger.OrderNoEQ(req.OrderNo),
ordermaterialledger.MaterialCodeEQ(req.MaterialCode),
).Only(ctx0())
if err != nil {
fail(w, http.StatusInternalServerError, "重读台账失败: "+err.Error())
return
}
newOutQty := ledgerNow.OutQty + actualQty
if newOutQty > ledgerNow.TotalQty {
fail(w, http.StatusConflict, "出库数量超过工单 BOM 需求: 累计 "+itoa(ledgerNow.OutQty)+" + "+itoa(actualQty)+" > 需求 "+itoa(ledgerNow.TotalQty))
return
}
status := ledgerNow.Status
if newOutQty >= ledgerNow.TotalQty {
status = "领料完结"
}
if _, err = tx.OrderMaterialLedger.UpdateOneID(ledgerNow.ID).
SetOutQty(newOutQty).
SetStatus(status).
Save(ctx0()); err != nil {
fail(w, http.StatusInternalServerError, "更新台账失败: "+err.Error())
return
}
// 7. 提交事务
if err = tx.Commit(); err != nil {
fail(w, http.StatusInternalServerError, "提交事务失败: "+err.Error())
return
}
committed = true
ctx.EventLog.Write(ctx0(), "outbound.create", r.Header.Get("X-Username"), "outbound", outboundNo,
"备料出库 工单 "+req.OrderNo+" 物料 "+req.MaterialCode+" ×"+itoa(actualQty),
map[string]any{"orderNo": req.OrderNo, "qty": actualQty})
logx.Infof("deduct stock: order=%s material=%s qty=%d", req.OrderNo, req.MaterialCode, actualQty)
ok(w, map[string]any{
"outboundNo": outboundNo,
"qty": actualQty,
"orderNo": req.OrderNo,
"ledgerOut": newOutQty,
"ledgerTotal": ledgerNow.TotalQty,
"status": status,
})
}
}
// queryOutboundHandler 出库单查询
func queryOutboundHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
page := atoi(r.URL.Query().Get("page"), 1)
pageSize := atoi(r.URL.Query().Get("pageSize"), 20)
orderNo := r.URL.Query().Get("orderNo")
materialCode := r.URL.Query().Get("materialCode")
outboundNo := r.URL.Query().Get("outboundNo")
boxNo := r.URL.Query().Get("boxNo")
q := ctx.EntClient.OutboundOrder.Query()
if orderNo != "" {
q = q.Where(outboundorder.OrderNoEqualFold(orderNo))
}
if materialCode != "" {
q = q.Where(outboundorder.MaterialCodeEqualFold(materialCode))
}
if outboundNo != "" {
q = q.Where(outboundorder.OutboundNoEqualFold(outboundNo))
}
if boxNo != "" {
q = q.Where(outboundorder.BoxNoContainsFold(boxNo))
}
total, err := q.Count(ctx0())
if err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
list, err := q.Order(ent.Desc("created_at"), ent.Desc("id")).
Offset((page - 1) * pageSize).Limit(pageSize).All(ctx0())
if err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
// 附带明细
type row struct {
*ent.OutboundOrder
Details []*ent.OutboundDetail `json:"details"`
}
rows := make([]row, 0, len(list))
for _, ob := range list {
details, _ := ctx.EntClient.OutboundDetail.Query().
Where(outbounddetail.OutboundNoEQ(ob.OutboundNo)).
All(ctx0())
rows = append(rows, row{ob, details})
}
ok(w, map[string]any{
"total": total,
"list": rows,
"page": page,
"pageSize": pageSize,
})
}
}
// generalOutboundRequest 通用出库请求(不依赖 MES 工单)
type generalOutboundRequest struct {
MaterialCode string `json:"materialCode"`
BatchNo string `json:"batchNo"`
SnList []string `json:"snList"`
Qty int `json:"qty"`
Operator string `json:"operator"`
ZoneCode string `json:"zoneCode"`
BoxNo string `json:"boxNo"`
ContractNo string `json:"contractNo"`
Remark string `json:"remark"`
}
// generalOutboundHandler 通用出库(手动出库,不依赖工单)
// 适用:退料 / 样品 / 报废 / 发货 等不挂靠 MES 工单的场景。
// 支持结构件批次与电气件 SN;填箱号时一并把装箱信息(箱号/SN清单/合同号)
// 落到统一的出库主表,不再另建装箱表。
// 事务保证:出库单 + 库存扣减 + 出库明细 原子提交;库存用条件原子更新避免负库存。
func generalOutboundHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req generalOutboundRequest
if err := parseJSON(r, &req); err != nil {
fail(w, http.StatusBadRequest, "参数错误: "+err.Error())
return
}
if req.MaterialCode == "" {
fail(w, http.StatusBadRequest, "materialCode 必填")
return
}
if req.BatchNo == "" && len(req.SnList) == 0 {
fail(w, http.StatusBadRequest, "batchNo 或 snList 至少提供一个")
return
}
manageMode := 1
if len(req.SnList) > 0 {
manageMode = 2
}
// SN 出库数量以列表长度计
actualQty := req.Qty
if manageMode == 2 {
actualQty = len(req.SnList)
}
if actualQty <= 0 {
fail(w, http.StatusBadRequest, "数量必须大于 0")
return
}
outboundNo := "OB" + uuid.NewString()[:12]
tx, err := ctx.EntClient.Tx(ctx0())
if err != nil {
fail(w, http.StatusInternalServerError, "开启事务失败: "+err.Error())
return
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback()
}
}()
ob, err := tx.OutboundOrder.Create().
SetOutboundNo(outboundNo).
SetOutboundType("general").
SetMaterialCode(req.MaterialCode).
SetManageMode(manageMode).
SetNillableBatchNo(strPtr(req.BatchNo)).
SetNillableZoneCode(strPtr(req.ZoneCode)).
SetQuantity(0).
SetNillableOperator(strPtr(req.Operator)).
SetNillableRemark(strPtr(req.Remark)).
SetNillableBoxNo(strPtr(req.BoxNo)).
SetNillableContractNo(strPtr(req.ContractNo)).
Save(ctx0())
if err != nil {
fail(w, http.StatusInternalServerError, "创建出库单失败: "+err.Error())
return
}
// 批次扣减(结构件)
if req.BatchNo != "" {
b, err := tx.Inventory.Query().
Where(inventory.ManageModeEQ(1), inventory.BatchNoEQ(req.BatchNo)).
Only(ctx0())
if err != nil {
fail(w, http.StatusBadRequest, "批次不存在: "+req.BatchNo)
return
}
avail := b.Quantity - b.LockedQty
if avail < actualQty {
fail(w, http.StatusConflict, "批次库存不足: "+req.BatchNo+" 可用 "+itoa(avail))
return
}
aff, err := tx.Inventory.Update().
Where(inventory.ID(b.ID), inventory.QuantityGTE(actualQty)).
AddQuantity(-actualQty).
AddLockedQty(-min(actualQty, b.LockedQty)).
Save(ctx0())
if err != nil || aff == 0 {
fail(w, http.StatusConflict, "批次扣减失败(库存不足或并发冲突): "+req.BatchNo)
return
}
if _, err = tx.OutboundDetail.Create().
SetOutboundNo(outboundNo).
SetBatchNo(req.BatchNo).
SetMaterialCode(req.MaterialCode).
SetQuantity(actualQty).
Save(ctx0()); err != nil {
fail(w, http.StatusInternalServerError, "创建出库明细失败: "+err.Error())
return
}
}
// SN 扣减(电气件):仅在库/锁定的 SN 可出库
if len(req.SnList) > 0 {
for _, sn := range req.SnList {
aff, err := tx.Inventory.Update().
Where(inventory.ManageModeEQ(2), inventory.SnCodeEQ(sn), inventory.StatusIn("在库", "锁定")).
SetStatus("出库").
SetNillableZoneCode(strPtr(req.ZoneCode)).
Save(ctx0())
if err != nil {
fail(w, http.StatusInternalServerError, "SN 出库失败: "+sn+" "+err.Error())
return
}
if aff == 0 {
fail(w, http.StatusBadRequest, "SN 不可出库(不存在或非在库/锁定): "+sn)
return
}
if _, err = tx.OutboundDetail.Create().
SetOutboundNo(outboundNo).
SetSnCode(sn).
SetMaterialCode(req.MaterialCode).
SetQuantity(1).
Save(ctx0()); err != nil {
fail(w, http.StatusInternalServerError, "创建出库明细失败: "+err.Error())
return
}
}
}
// 回写实际数量 + 装箱 SN 清单(JSON)
upd := tx.OutboundOrder.UpdateOneID(ob.ID).SetQuantity(actualQty)
if len(req.SnList) > 0 && req.BoxNo != "" {
upd.SetBoxSnList(snListJSON(req.SnList))
}
if _, err = upd.Save(ctx0()); err != nil {
fail(w, http.StatusInternalServerError, "更新出库单失败: "+err.Error())
return
}
if err = tx.Commit(); err != nil {
fail(w, http.StatusInternalServerError, "提交事务失败: "+err.Error())
return
}
committed = true
ctx.EventLog.Write(ctx0(), "outbound.general", r.Header.Get("X-Username"), "outbound", outboundNo,
"通用出库 "+req.MaterialCode+" ×"+itoa(actualQty),
map[string]any{"qty": actualQty, "boxNo": req.BoxNo, "snCount": len(req.SnList)})
logx.Infof("general outbound: material=%s qty=%d box=%s", req.MaterialCode, actualQty, req.BoxNo)
ok(w, map[string]any{
"outboundNo": outboundNo,
"qty": actualQty,
"boxNo": req.BoxNo,
"type": "general",
})
}
}
// snListJSON 把 SN 列表序列化为 JSON 数组字符串
func snListJSON(sn []string) string {
b, err := json.Marshal(sn)
if err != nil {
return "[]"
}
return string(b)
}
// exportOutboundHandler 统一主表全部出库记录导出
// 不依赖前端分页,一次返回全部(可按类型/工单/物料/箱号过滤),便于整表导出。
func exportOutboundHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
outboundType := r.URL.Query().Get("outboundType")
orderNo := r.URL.Query().Get("orderNo")
materialCode := r.URL.Query().Get("materialCode")
boxNo := r.URL.Query().Get("boxNo")
startDate, endDate, rerr := normalizeExportRange(r.URL.Query().Get("startDate"), r.URL.Query().Get("endDate"))
if rerr != nil {
fail(w, http.StatusBadRequest, rerr.Error())
return
}
startUnix, endUnix := exportRangeUnix(startDate, endDate)
q := ctx.EntClient.OutboundOrder.Query()
q = q.Where(outboundorder.CreatedAtGTE(startUnix), outboundorder.CreatedAtLTE(endUnix))
if outboundType != "" {
q = q.Where(outboundorder.OutboundTypeEQ(outboundType))
}
if orderNo != "" {
q = q.Where(outboundorder.OrderNoEqualFold(orderNo))
}
if materialCode != "" {
q = q.Where(outboundorder.MaterialCodeEqualFold(materialCode))
}
if boxNo != "" {
q = q.Where(outboundorder.BoxNoContainsFold(boxNo))
}
list, err := q.Order(ent.Desc("created_at"), ent.Desc("id")).All(ctx0())
if err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
type exportRow struct {
*ent.OutboundOrder
SnCodes string `json:"snCodes"`
Details []*ent.OutboundDetail `json:"details"`
}
rows := make([]exportRow, 0, len(list))
for _, ob := range list {
details, _ := ctx.EntClient.OutboundDetail.Query().
Where(outbounddetail.OutboundNoEQ(ob.OutboundNo)).
All(ctx0())
er := exportRow{OutboundOrder: ob, SnCodes: "", Details: details}
sns := make([]string, 0, len(details))
for _, d := range details {
if d.SnCode != "" {
sns = append(sns, d.SnCode)
}
}
if len(sns) > 0 {
er.SnCodes = strings.Join(sns, ",")
}
rows = append(rows, er)
}
// xlsx 导出
if r.URL.Query().Get("format") == "xlsx" {
headers := []string{"出库单号", "类型", "工单号", "物料编码", "物料名称", "数量", "装箱号", "合同号", "SN清单", "创建时间"}
matrix := make([][]any, 0, len(rows))
for _, row := range rows {
ob := row.OutboundOrder
tn := "通用出库"
if ob.OutboundType == "workorder" {
tn = "备料出库"
}
matrix = append(matrix, []any{
ob.OutboundNo, tn, ob.OrderNo, ob.MaterialCode, ob.MaterialName,
ob.Quantity, ob.BoxNo, ob.ContractNo, row.SnCodes,
unixFmt(ob.CreatedAt),
})
}
sendExcel(w, xlsxFilename("出库管理"), headers, matrix)
return
}
ok(w, map[string]any{"total": len(rows), "list": rows})
}
}
func min(a, b int) int {
if a < b {
return a
}
return b
}