2026-08-28 15:06:01 +08:00
|
|
|
package handler
|
|
|
|
|
|
|
|
|
|
import (
|
2026-09-03 14:20:37 +08:00
|
|
|
"encoding/json"
|
2026-08-28 15:06:01 +08:00
|
|
|
"net/http"
|
2026-09-03 14:20:37 +08:00
|
|
|
"strings"
|
2026-08-28 15:06:01 +08:00
|
|
|
|
|
|
|
|
"bj_power_wms/ent"
|
2026-09-02 08:21:11 +08:00
|
|
|
"bj_power_wms/ent/inventory"
|
2026-08-28 15:06:01 +08:00
|
|
|
"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? }
|
2026-09-02 08:21:11 +08:00
|
|
|
// 强约束:累计出库 ≤ 工单 BOM 需求量;等于时领料完结。
|
|
|
|
|
//
|
|
|
|
|
// 事务保证:出库单 + 库存行扣减(inventory) + 出库明细 + 工单台账 原子提交,
|
|
|
|
|
// 任一环节失败整体回滚,杜绝"出库单生成了但库存没扣"或"台账没更新"的中间态。
|
|
|
|
|
// 扣减本身用条件原子更新(AddQuantity(-qty) WHERE quantity>=qty),即使并发也不会出现负库存。
|
2026-08-28 15:06:01 +08:00
|
|
|
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 {
|
2026-09-03 14:20:37 +08:00
|
|
|
fail(w, http.StatusBadRequest, "工单物料台账不存在,请先在 MES 同步工单 BOM 后再领料;如无需挂靠工单(退料/样品/报废/发货),请改用「通用出库」: "+req.OrderNo+"/"+req.MaterialCode)
|
2026-08-28 15:06:01 +08:00
|
|
|
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]
|
|
|
|
|
|
2026-09-02 08:21:11 +08:00
|
|
|
// 开启事务:出库单 + 库存扣减 + 明细 + 台账 原子提交
|
|
|
|
|
tx, err := ctx.EntClient.Tx(ctx0())
|
|
|
|
|
if err != nil {
|
|
|
|
|
fail(w, http.StatusInternalServerError, "开启事务失败: "+err.Error())
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
committed := false
|
|
|
|
|
defer func() {
|
|
|
|
|
if !committed {
|
|
|
|
|
_ = tx.Rollback()
|
|
|
|
|
}
|
|
|
|
|
}()
|
|
|
|
|
|
2026-08-28 15:06:01 +08:00
|
|
|
// 2. 创建出库单
|
2026-09-02 08:21:11 +08:00
|
|
|
ob, err := tx.OutboundOrder.Create().
|
2026-08-28 15:06:01 +08:00
|
|
|
SetOutboundNo(outboundNo).
|
|
|
|
|
SetOutboundType("workorder").
|
|
|
|
|
SetNillableOrderNo(strPtr(req.OrderNo)).
|
|
|
|
|
SetMaterialCode(req.MaterialCode).
|
|
|
|
|
SetManageMode(1).
|
|
|
|
|
SetNillableBatchNo(strPtr(req.BatchNo)).
|
|
|
|
|
SetNillableZoneCode(strPtr(req.ZoneCode)).
|
2026-09-02 08:21:11 +08:00
|
|
|
SetQuantity(0).
|
2026-08-28 15:06:01 +08:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-02 08:21:11 +08:00
|
|
|
// 3. 扣减批次(原子:quantity>=qty 才减,避免超扣/负数;locked_qty 同步回收)
|
2026-08-28 15:06:01 +08:00
|
|
|
if req.BatchNo != "" {
|
2026-09-02 08:21:11 +08:00
|
|
|
b, err := tx.Inventory.Query().
|
|
|
|
|
Where(inventory.ManageModeEQ(1), inventory.BatchNoEQ(req.BatchNo)).
|
2026-08-28 15:06:01 +08:00
|
|
|
Only(ctx0())
|
|
|
|
|
if err != nil {
|
|
|
|
|
fail(w, http.StatusBadRequest, "批次不存在: "+req.BatchNo)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-09-02 08:21:11 +08:00
|
|
|
avail := b.Quantity - b.LockedQty
|
|
|
|
|
if avail < req.Qty {
|
|
|
|
|
fail(w, http.StatusConflict, "批次库存不足: "+req.BatchNo+" 可用 "+itoa(avail))
|
2026-08-28 15:06:01 +08:00
|
|
|
return
|
|
|
|
|
}
|
2026-09-02 08:21:11 +08:00
|
|
|
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().
|
2026-08-28 15:06:01 +08:00
|
|
|
SetOutboundNo(outboundNo).
|
|
|
|
|
SetBatchNo(req.BatchNo).
|
|
|
|
|
SetMaterialCode(req.MaterialCode).
|
|
|
|
|
SetQuantity(req.Qty).
|
|
|
|
|
SetNillableOrderNo(strPtr(req.OrderNo)).
|
2026-09-02 08:21:11 +08:00
|
|
|
Save(ctx0()); err != nil {
|
|
|
|
|
fail(w, http.StatusInternalServerError, "创建出库明细失败: "+err.Error())
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
actualQty = req.Qty
|
2026-08-28 15:06:01 +08:00
|
|
|
}
|
|
|
|
|
|
2026-09-02 08:21:11 +08:00
|
|
|
// 4. 扣减 SN(原子:仅 在库/锁定 的 SN 可出库 → 置出库)
|
2026-08-28 15:06:01 +08:00
|
|
|
if len(req.SnList) > 0 {
|
|
|
|
|
for _, sn := range req.SnList {
|
2026-09-02 08:21:11 +08:00
|
|
|
aff, err := tx.Inventory.Update().
|
|
|
|
|
Where(inventory.ManageModeEQ(2), inventory.SnCodeEQ(sn), inventory.StatusIn("在库", "锁定")).
|
|
|
|
|
SetStatus("出库").
|
|
|
|
|
SetNillableZoneCode(strPtr(req.ZoneCode)).
|
|
|
|
|
Save(ctx0())
|
2026-08-28 15:06:01 +08:00
|
|
|
if err != nil {
|
2026-09-02 08:21:11 +08:00
|
|
|
fail(w, http.StatusInternalServerError, "SN 出库失败: "+sn+" "+err.Error())
|
2026-08-28 15:06:01 +08:00
|
|
|
return
|
|
|
|
|
}
|
2026-09-02 08:21:11 +08:00
|
|
|
if aff == 0 {
|
|
|
|
|
fail(w, http.StatusBadRequest, "SN 不可出库(不存在或非在库/锁定): "+sn)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if _, err = tx.OutboundDetail.Create().
|
2026-08-28 15:06:01 +08:00
|
|
|
SetOutboundNo(outboundNo).
|
|
|
|
|
SetSnCode(sn).
|
|
|
|
|
SetMaterialCode(req.MaterialCode).
|
|
|
|
|
SetQuantity(1).
|
|
|
|
|
SetNillableOrderNo(strPtr(req.OrderNo)).
|
2026-09-02 08:21:11 +08:00
|
|
|
Save(ctx0()); err != nil {
|
|
|
|
|
fail(w, http.StatusInternalServerError, "创建出库明细失败: "+err.Error())
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-08-28 15:06:01 +08:00
|
|
|
}
|
|
|
|
|
actualQty = len(req.SnList)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 5. 更新出库单实际数量
|
2026-09-02 08:21:11 +08:00
|
|
|
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())
|
2026-08-28 15:06:01 +08:00
|
|
|
return
|
|
|
|
|
}
|
2026-09-02 08:21:11 +08:00
|
|
|
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 {
|
2026-08-28 15:06:01 +08:00
|
|
|
status = "领料完结"
|
|
|
|
|
}
|
2026-09-02 08:21:11 +08:00
|
|
|
if _, err = tx.OrderMaterialLedger.UpdateOneID(ledgerNow.ID).
|
2026-08-28 15:06:01 +08:00
|
|
|
SetOutQty(newOutQty).
|
|
|
|
|
SetStatus(status).
|
2026-09-02 08:21:11 +08:00
|
|
|
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
|
2026-08-28 15:06:01 +08:00
|
|
|
|
|
|
|
|
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,
|
2026-09-02 08:21:11 +08:00
|
|
|
"ledgerTotal": ledgerNow.TotalQty,
|
2026-08-28 15:06:01 +08:00
|
|
|
"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")
|
2026-09-03 14:20:37 +08:00
|
|
|
boxNo := r.URL.Query().Get("boxNo")
|
2026-08-28 15:06:01 +08:00
|
|
|
|
|
|
|
|
q := ctx.EntClient.OutboundOrder.Query()
|
|
|
|
|
if orderNo != "" {
|
|
|
|
|
q = q.Where(outboundorder.OrderNoEQ(orderNo))
|
|
|
|
|
}
|
|
|
|
|
if materialCode != "" {
|
|
|
|
|
q = q.Where(outboundorder.MaterialCodeEQ(materialCode))
|
|
|
|
|
}
|
|
|
|
|
if outboundNo != "" {
|
|
|
|
|
q = q.Where(outboundorder.OutboundNoEQ(outboundNo))
|
|
|
|
|
}
|
2026-09-03 14:20:37 +08:00
|
|
|
if boxNo != "" {
|
|
|
|
|
q = q.Where(outboundorder.BoxNoContains(boxNo))
|
|
|
|
|
}
|
2026-08-28 15:06:01 +08:00
|
|
|
|
|
|
|
|
total, err := q.Count(ctx0())
|
|
|
|
|
if err != nil {
|
|
|
|
|
fail(w, http.StatusInternalServerError, err.Error())
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-09-03 14:20:37 +08:00
|
|
|
list, err := q.Order(ent.Desc("created_at")).
|
2026-08-28 15:06:01 +08:00
|
|
|
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,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-03 14:20:37 +08:00
|
|
|
// 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
|
|
|
|
|
|
|
|
|
|
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")
|
|
|
|
|
|
|
|
|
|
q := ctx.EntClient.OutboundOrder.Query()
|
|
|
|
|
if outboundType != "" {
|
|
|
|
|
q = q.Where(outboundorder.OutboundTypeEQ(outboundType))
|
|
|
|
|
}
|
|
|
|
|
if orderNo != "" {
|
|
|
|
|
q = q.Where(outboundorder.OrderNoEQ(orderNo))
|
|
|
|
|
}
|
|
|
|
|
if materialCode != "" {
|
|
|
|
|
q = q.Where(outboundorder.MaterialCodeEQ(materialCode))
|
|
|
|
|
}
|
|
|
|
|
if boxNo != "" {
|
|
|
|
|
q = q.Where(outboundorder.BoxNoContains(boxNo))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
list, err := q.Order(ent.Desc("created_at")).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})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-28 15:06:01 +08:00
|
|
|
func min(a, b int) int {
|
|
|
|
|
if a < b {
|
|
|
|
|
return a
|
|
|
|
|
}
|
|
|
|
|
return b
|
|
|
|
|
}
|