feat: 完成客户第12条需求+多系统改造
1. 新增预警表添加工单号字段,支持按工单号检索预警 2. 添加工单过滤在制品/绩效报表/预警中心查询 3. 补全WMS台账字段口径与权限配置 4. 修复备料单与数量不符处理流程 5. 新增配送进度/库位联动/自动出库功能 6. 修复AGV配送状态更新逻辑 7. 优化退料与库存管理细节
This commit is contained in:
@@ -75,6 +75,8 @@ func EnsureDB(c DatabaseConf) error {
|
||||
// AutoMigrate 启动时自动建表/迁移
|
||||
func AutoMigrate(client *ent.Client) error {
|
||||
ctx := contextBackdrop()
|
||||
// 迁移前补列:必须先于 ent 的 Schema.Create 执行(详见 applyPreMigratePatches 说明)。
|
||||
applyPreMigratePatches(ctx)
|
||||
if err := client.Schema.Create(ctx); err != nil {
|
||||
return fmt.Errorf("自动迁移失败: %w", err)
|
||||
}
|
||||
@@ -94,6 +96,34 @@ func AutoMigrate(client *ent.Client) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyPreMigratePatches 必须在 ent 的 Schema.Create 之前执行。
|
||||
//
|
||||
// 背景(2026-09-19 启动 panic 根因):ent(v0.14/Atlas) 给「已存在的表」新增「非空且无默认值」的列时,
|
||||
// 只会先 ADD COLUMN(可空)再 ALTER COLUMN SET NOT NULL;存量行填不上值即报
|
||||
// `column "xxx" of relation "yyy" contains null values (SQLSTATE 23502)`,直接 panic 打断启动。
|
||||
// 因此这类列必须先用「带默认值」的 DDL 补齐(存量行被一次填满),Schema.Create 才能顺利收紧约束;
|
||||
// 业务真实值随后由 applyColumnPatches 回填、并 DROP DEFAULT,使库内结构与 ent schema 完全一致。
|
||||
//
|
||||
// 登记口径:给「既有表」加非空且无默认值的列 → 必须登记在此;
|
||||
// 只加可空列或带默认值的列 → 无需登记(ent 自己 ADD COLUMN 即可)。
|
||||
func applyPreMigratePatches(ctx context.Context) {
|
||||
if rawDB == nil {
|
||||
return
|
||||
}
|
||||
stmts := []string{
|
||||
// 区域库位父子层级重构:存量 zones 表补 level/code(ent schema 中二者均非空且无默认值)。
|
||||
// 这里的默认值仅用于让存量行通过 NOT NULL 收紧,真实层级值由 applyColumnPatches 回填。
|
||||
`ALTER TABLE zones ADD COLUMN IF NOT EXISTS level bigint NOT NULL DEFAULT 4`,
|
||||
`ALTER TABLE zones ADD COLUMN IF NOT EXISTS code character varying NOT NULL DEFAULT ''`,
|
||||
}
|
||||
for _, s := range stmts {
|
||||
if _, err := rawDB.ExecContext(ctx, s); err != nil {
|
||||
// 全新库此时表尚未建立,Schema.Create 会按 schema 全新建表,属正常情况
|
||||
slog.Warn("迁移前补列跳过(新库属正常): " + err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// applyColumnPatches 幂等补齐已有表的新增列。
|
||||
// 背景:ent 的 Schema.Create 对已存在表不会 ADD COLUMN,故新增字段必须在此登记,
|
||||
// 否则新库正常、存量库缺列导致运行时 500。
|
||||
@@ -124,6 +154,10 @@ func applyColumnPatches(ctx context.Context) {
|
||||
`ALTER TABLE outbound_orders ADD COLUMN IF NOT EXISTS ownership_type text`,
|
||||
`ALTER TABLE outbound_orders ADD COLUMN IF NOT EXISTS ownership_no text`,
|
||||
`ALTER TABLE outbound_orders ADD COLUMN IF NOT EXISTS target_station text`,
|
||||
// 出库库位四级贯通(客户诉求:出库与入库同口径,2026-09-19):区域已有 zone_code,补货架/层/位置
|
||||
`ALTER TABLE outbound_orders ADD COLUMN IF NOT EXISTS shelf_no text`,
|
||||
`ALTER TABLE outbound_orders ADD COLUMN IF NOT EXISTS layer_no text`,
|
||||
`ALTER TABLE outbound_orders ADD COLUMN IF NOT EXISTS position_no text`,
|
||||
// 库存明细:库位贯通 货架/层/位置(问题记录 一.4)
|
||||
`ALTER TABLE inventories ADD COLUMN IF NOT EXISTS shelf_no text`,
|
||||
`ALTER TABLE inventories ADD COLUMN IF NOT EXISTS layer_no text`,
|
||||
@@ -183,6 +217,10 @@ func applyColumnPatches(ctx context.Context) {
|
||||
SELECT DISTINCT 3, z.layer_no, z.shelf_no, z.zone_code, z.shelf_no, z.layer_no, '启用', EXTRACT(EPOCH FROM NOW())::bigint, EXTRACT(EPOCH FROM NOW())::bigint
|
||||
FROM zones z WHERE z.layer_no IS NOT NULL AND z.layer_no <> ''
|
||||
AND NOT EXISTS (SELECT 1 FROM zones l WHERE l.level = 3 AND l.zone_code = z.zone_code AND l.shelf_no = z.shelf_no AND l.code = z.layer_no)`,
|
||||
// level/code 回填完毕,撤销 applyPreMigratePatches 补列时的临时默认值,
|
||||
// 使库内结构与 ent schema 对齐(非空、无默认值)。
|
||||
`ALTER TABLE zones ALTER COLUMN level DROP DEFAULT`,
|
||||
`ALTER TABLE zones ALTER COLUMN code DROP DEFAULT`,
|
||||
// U18 盘点四级贯通:盘点快照冻结货架/层/位置,盘点差异可精确定位到货位。
|
||||
`ALTER TABLE stocktake_items ADD COLUMN IF NOT EXISTS shelf_no text`,
|
||||
`ALTER TABLE stocktake_items ADD COLUMN IF NOT EXISTS layer_no text`,
|
||||
@@ -204,6 +242,13 @@ func applyColumnPatches(ctx context.Context) {
|
||||
`ALTER TABLE attachments ADD COLUMN IF NOT EXISTS deleted boolean NOT NULL DEFAULT false`,
|
||||
`ALTER TABLE attachments ADD COLUMN IF NOT EXISTS deleted_at bigint`,
|
||||
`ALTER TABLE attachments ADD COLUMN IF NOT EXISTS deleted_by text`,
|
||||
// 台账口径拆分(2026-09-19 出库与配送闭环):一个字段只有一个写者。
|
||||
// returned_qty = 退库收货回写;consumed_qty = 报工反冲核销。均带默认值,存量行走 ent 亦可。
|
||||
`ALTER TABLE order_material_ledgers ADD COLUMN IF NOT EXISTS returned_qty bigint NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE order_material_ledgers ADD COLUMN IF NOT EXISTS consumed_qty bigint NOT NULL DEFAULT 0`,
|
||||
// 出库单状态 + 是否叫AGV(配送进度只读展示用;「已接料」不落在本表)
|
||||
`ALTER TABLE outbound_orders ADD COLUMN IF NOT EXISTS status text NOT NULL DEFAULT '已出库'`,
|
||||
`ALTER TABLE outbound_orders ADD COLUMN IF NOT EXISTS need_agv boolean NOT NULL DEFAULT false`,
|
||||
}
|
||||
for _, s := range stmts {
|
||||
if _, err := rawDB.ExecContext(ctx, s); err != nil {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"bj_power_wms/ent"
|
||||
"bj_power_wms/ent/agvtask"
|
||||
"bj_power_wms/ent/dock"
|
||||
"bj_power_wms/ent/outboundorder"
|
||||
"bj_power_wms/internal/svc"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -167,6 +168,13 @@ func refreshAgvHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
ctx.EntClient.AgvTask.UpdateOneID(t.ID).
|
||||
SetStatus("DONE").SetArrivedAt(time.Now().Unix()).
|
||||
Exec(ctx0())
|
||||
// 出库单配送状态 → 已送达(仅在绑定了出库单号时;出库单状态只有 已出库/已送达,
|
||||
// 「已接料」归 MES 备料单,避免三处状态互斗)。
|
||||
if t.RelatedOutboundNo != "" {
|
||||
ctx.EntClient.OutboundOrder.Update().
|
||||
Where(outboundorder.OutboundNoEQ(t.RelatedOutboundNo)).
|
||||
SetStatus("已送达").Exec(ctx0())
|
||||
}
|
||||
// 到位后回写 MES 备料单 → DONE
|
||||
mats := []agvSubmitRow{}
|
||||
_ = json.Unmarshal([]byte(t.MaterialsJSON), &mats)
|
||||
|
||||
@@ -1,28 +1,32 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"bj_power_wms/ent"
|
||||
"bj_power_wms/ent/inventory"
|
||||
"bj_power_wms/ent/ordermaterialledger"
|
||||
"bj_power_wms/internal/svc"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/zeromicro/go-zero/core/logx"
|
||||
)
|
||||
|
||||
// backflushRequest 反冲扣账请求(MES 报工完成时调用,X-API-TOKEN 内部接口)
|
||||
// 物料在该道工序被消耗,不依赖事先发料:报工完成即反冲扣库存。
|
||||
// items 由 MES 按「工单产品 BOM + 当前工序 processCode」算好(含损耗),WMS 只负责 FIFO 扣减与记账。
|
||||
// backflushRequest 报工反冲核销请求(MES 报工完成时调用,X-API-TOKEN 内部接口)
|
||||
//
|
||||
// 口径(2026-09-19 定稿 · 方案 A「出库扣减,反冲只核销」):
|
||||
// 一次物理位移 = 一次库存账变。备料出库时库存已经扣过,报工消耗**不再动库存、不再生成出库单**,
|
||||
// 只把台账「已消耗(consumed_qty)」累加,供 已发出 / 已接料 / 已消耗 三方对账。
|
||||
//
|
||||
// 核销上限 = net_out_qty − consumed_qty,其中 net_out_qty = out_qty − returned_qty。
|
||||
// 上限的意义:没走出库的料(工位自取辅料、现场替换)不会被核销成负数,
|
||||
// 避免出现「库存从未扣过、台账却记了消耗」的漏扣假象。
|
||||
type backflushRequest struct {
|
||||
OrderNo string `json:"orderNo"` // 关联工单号(可空=不挂台账)
|
||||
StationNo int `json:"stationNo"` // 当前工位号(记录用)
|
||||
OrderNo string `json:"orderNo"` // 关联工单号(空=不做核销)
|
||||
StationNo int `json:"stationNo"` // 当前工位号(记录用)
|
||||
ProcessCode int `json:"processCode"` // 当前工序号(记录用)
|
||||
Operator string `json:"operator"`
|
||||
Items []backflushItem `json:"items"` // 本次消耗的物料明细
|
||||
Operator string `json:"operator"`
|
||||
Items []backflushItem `json:"items"` // 本次消耗的物料明细
|
||||
}
|
||||
|
||||
type backflushItem struct {
|
||||
@@ -30,9 +34,8 @@ type backflushItem struct {
|
||||
Qty int `json:"qty"` // 该工序单件消耗(已含损耗)* 完成件数
|
||||
}
|
||||
|
||||
// backflushInternalHandler 反冲扣账(库存 FIFO 扣减 + 生成 backflush 类型出库单 + 台账累计)
|
||||
// 事务保证:出库单 + 库存扣减 + 出库明细 + 工单台账 原子提交。
|
||||
// 任意物料库存不足(FIFO 无法足额)时,扣足可用部分、记短欠日志、不阻塞产线(反冲降级同成品回流)。
|
||||
// backflushInternalHandler 报工反冲核销:只累加台账 consumed_qty。
|
||||
// 不扣库存、不写出库单、不写 out_qty。库存不足/无台账不阻塞产线,只记 shortfall 供对账。
|
||||
func backflushInternalHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req backflushRequest
|
||||
@@ -41,11 +44,15 @@ func backflushInternalHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
if len(req.Items) == 0 {
|
||||
ok(w, map[string]any{"outboundNo": "", "consumed": 0, "shortfall": false, "note": "无反冲物料"})
|
||||
ok(w, map[string]any{"consumed": 0, "shortfall": false, "note": "无反冲物料"})
|
||||
return
|
||||
}
|
||||
if req.OrderNo == "" {
|
||||
// 不挂工单的消耗没有台账可承载,不阻塞产线
|
||||
logx.Infof("backflush skipped: no orderNo, station=%d", req.StationNo)
|
||||
ok(w, map[string]any{"consumed": 0, "shortfall": false, "note": "未挂工单,不做核销"})
|
||||
return
|
||||
}
|
||||
|
||||
outboundNo := "BF" + uuid.NewString()[:12]
|
||||
|
||||
tx, err := ctx.EntClient.Tx(ctx0())
|
||||
if err != nil {
|
||||
@@ -59,67 +66,45 @@ func backflushInternalHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
}
|
||||
}()
|
||||
|
||||
// 1. 创建反冲出库单(type=backflush,数量后置更新)
|
||||
ob, err := tx.OutboundOrder.Create().
|
||||
SetOutboundNo(outboundNo).
|
||||
SetOutboundType("backflush").
|
||||
SetNillableOrderNo(strPtr(req.OrderNo)).
|
||||
SetMaterialCode(""). // 反冲单为整份 BOM,主表物料留空,明细承载
|
||||
SetManageMode(1).
|
||||
SetQuantity(0).
|
||||
SetNillableOperator(strPtr(req.Operator)).
|
||||
SetNillableTargetStation(strPtr(strconv.Itoa(req.StationNo))).
|
||||
SetRemark("反冲扣账(工序" + strconv.Itoa(req.ProcessCode) + ")").
|
||||
Save(ctx0())
|
||||
if err != nil {
|
||||
fail(w, http.StatusInternalServerError, "创建反冲单失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 2. FIFO 逐物料扣减 + 台账累计
|
||||
totalConsumed := 0
|
||||
shortfall := false
|
||||
notes := make([]string, 0, 4)
|
||||
for _, it := range req.Items {
|
||||
if it.MaterialCode == "" || it.Qty <= 0 {
|
||||
continue
|
||||
}
|
||||
consumed, sf := fifoConsume(tx, it.MaterialCode, it.Qty, outboundNo, req.OrderNo)
|
||||
totalConsumed += consumed
|
||||
if sf {
|
||||
ledger, lerr := tx.OrderMaterialLedger.Query().
|
||||
Where(
|
||||
ordermaterialledger.OrderNoEQ(req.OrderNo),
|
||||
ordermaterialledger.MaterialCodeEQ(it.MaterialCode),
|
||||
).Only(ctx0())
|
||||
if lerr != nil || ledger == nil {
|
||||
shortfall = true
|
||||
logx.Errorf("backflush shortfall: order=%s material=%s need=%d got=%d", req.OrderNo, it.MaterialCode, it.Qty, consumed)
|
||||
notes = append(notes, it.MaterialCode+" 无台账")
|
||||
continue
|
||||
}
|
||||
// 台账累计(仅挂工单时)
|
||||
if req.OrderNo != "" {
|
||||
ledger, lerr := tx.OrderMaterialLedger.Query().
|
||||
Where(
|
||||
ordermaterialledger.OrderNoEQ(req.OrderNo),
|
||||
ordermaterialledger.MaterialCodeEQ(it.MaterialCode),
|
||||
).Only(ctx0())
|
||||
if lerr == nil && ledger != nil {
|
||||
newOut := ledger.OutQty + consumed
|
||||
status := ledger.Status
|
||||
if newOut >= ledger.TotalQty {
|
||||
status = "领料完结"
|
||||
}
|
||||
upd := tx.OrderMaterialLedger.UpdateOneID(ledger.ID).
|
||||
SetOutQty(newOut).
|
||||
SetStatus(status)
|
||||
if status == "领料完结" && ledger.Status != "领料完结" {
|
||||
upd.SetCompletedAt(time.Now().Unix())
|
||||
}
|
||||
if _, lerr = upd.Save(ctx0()); lerr != nil {
|
||||
fail(w, http.StatusInternalServerError, "更新台账失败: "+lerr.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
// 可核销量 = 净领用 − 已消耗(负数归零:退料后可能出现「已消耗 > 净领用」)
|
||||
capa := (ledger.OutQty - ledger.ReturnedQty) - ledger.ConsumedQty
|
||||
if capa < 0 {
|
||||
capa = 0
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 回写出库单实际数量
|
||||
if _, err = tx.OutboundOrder.UpdateOneID(ob.ID).SetQuantity(totalConsumed).Save(ctx0()); err != nil {
|
||||
fail(w, http.StatusInternalServerError, "更新反冲单失败: "+err.Error())
|
||||
return
|
||||
take := it.Qty
|
||||
if take > capa {
|
||||
take = capa
|
||||
shortfall = true
|
||||
notes = append(notes, fmt.Sprintf("%s 超出可核销量(需%d/可%d)", it.MaterialCode, it.Qty, capa))
|
||||
}
|
||||
if take <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, uerr := tx.OrderMaterialLedger.UpdateOneID(ledger.ID).
|
||||
AddConsumedQty(take).
|
||||
SetUpdatedAt(time.Now().Unix()).
|
||||
Save(ctx0()); uerr != nil {
|
||||
fail(w, http.StatusInternalServerError, "更新台账失败: "+uerr.Error())
|
||||
return
|
||||
}
|
||||
totalConsumed += take
|
||||
}
|
||||
|
||||
if err = tx.Commit(); err != nil {
|
||||
@@ -128,86 +113,14 @@ func backflushInternalHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
}
|
||||
committed = true
|
||||
|
||||
ctx.EventLog.Write(ctx0(), "material.backflush", req.Operator, "backflush", outboundNo,
|
||||
"反冲扣账 工单 "+req.OrderNo+" 工序"+strconv.Itoa(req.ProcessCode)+" 共消耗 ×"+strconv.Itoa(totalConsumed),
|
||||
map[string]any{"orderNo": req.OrderNo, "stationNo": req.StationNo, "consumed": totalConsumed, "shortfall": shortfall})
|
||||
logx.Infof("backflush: order=%s station=%d consumed=%d shortfall=%v", req.OrderNo, req.StationNo, totalConsumed, shortfall)
|
||||
ctx.EventLog.Write(ctx0(), "material.backflush", req.Operator, "order_material_ledger", req.OrderNo,
|
||||
"报工消耗核销 工单 "+req.OrderNo+" 工序"+strconv.Itoa(req.ProcessCode)+" 共核销 ×"+strconv.Itoa(totalConsumed),
|
||||
map[string]any{"orderNo": req.OrderNo, "stationNo": req.StationNo, "consumed": totalConsumed, "shortfall": shortfall, "notes": notes})
|
||||
logx.Infof("backflush(consume-only): order=%s station=%d consumed=%d shortfall=%v", req.OrderNo, req.StationNo, totalConsumed, shortfall)
|
||||
ok(w, map[string]any{
|
||||
"outboundNo": outboundNo,
|
||||
"consumed": totalConsumed,
|
||||
"shortfall": shortfall,
|
||||
"consumed": totalConsumed,
|
||||
"shortfall": shortfall,
|
||||
"notes": notes,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// fifoConsume 按 created_at 升序(先进先出)扣减某物料库存,跨批次/单件 SN 统一处理。
|
||||
// 返回实际扣减数;库存不足足额时 shortfall=true(已扣可用部分),不报错。
|
||||
func fifoConsume(tx *ent.Tx, materialCode string, qty int, outboundNo, orderNo string) (int, bool) {
|
||||
rows, err := tx.Inventory.Query().
|
||||
Where(
|
||||
inventory.MaterialCodeEQ(materialCode),
|
||||
inventory.StatusEQ("在库"),
|
||||
// 可用口径(客户口径):仅「合格」可被消耗(未检/不合格/待退 不计可用)
|
||||
inventory.QualityStatusEQ(QStatusPass),
|
||||
).
|
||||
Order(ent.Asc("created_at")).
|
||||
All(ctx0())
|
||||
if err != nil {
|
||||
return 0, true
|
||||
}
|
||||
remaining := qty
|
||||
consumed := 0
|
||||
for _, row := range rows {
|
||||
if remaining <= 0 {
|
||||
break
|
||||
}
|
||||
avail := row.Quantity - row.LockedQty
|
||||
if avail <= 0 {
|
||||
continue
|
||||
}
|
||||
take := avail
|
||||
if take > remaining {
|
||||
take = remaining
|
||||
}
|
||||
if row.ManageMode == 2 {
|
||||
// SN 单件:整件消耗并置出库
|
||||
aff, e := tx.Inventory.Update().
|
||||
Where(inventory.ID(row.ID), inventory.StatusEQ("在库")).
|
||||
SetStatus("出库").Save(ctx0())
|
||||
if e != nil || aff == 0 {
|
||||
continue
|
||||
}
|
||||
if _, e = tx.OutboundDetail.Create().
|
||||
SetOutboundNo(outboundNo).
|
||||
SetSnCode(row.SnCode).
|
||||
SetMaterialCode(materialCode).
|
||||
SetQuantity(1).
|
||||
SetNillableOrderNo(strPtr(orderNo)).Save(ctx0()); e != nil {
|
||||
return consumed, true
|
||||
}
|
||||
consumed += 1
|
||||
remaining -= 1
|
||||
} else {
|
||||
// 批次:条件原子扣减(quantity>=take 才减,避免超扣/并发负库存)
|
||||
aff, e := tx.Inventory.Update().
|
||||
Where(inventory.ID(row.ID), inventory.QuantityGTE(take)).
|
||||
AddQuantity(-take).
|
||||
AddLockedQty(-min(take, row.LockedQty)).
|
||||
Save(ctx0())
|
||||
if e != nil || aff == 0 {
|
||||
continue
|
||||
}
|
||||
if _, e = tx.OutboundDetail.Create().
|
||||
SetOutboundNo(outboundNo).
|
||||
SetBatchNo(row.BatchNo).
|
||||
SetMaterialCode(materialCode).
|
||||
SetQuantity(take).
|
||||
SetNillableOrderNo(strPtr(orderNo)).Save(ctx0()); e != nil {
|
||||
return consumed, true
|
||||
}
|
||||
consumed += take
|
||||
remaining -= take
|
||||
}
|
||||
}
|
||||
return consumed, remaining > 0
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"bj_power_wms/ent"
|
||||
"bj_power_wms/ent/ordermaterialledger"
|
||||
"bj_power_wms/internal/svc"
|
||||
)
|
||||
|
||||
// ledgerProgressRow 配送进度读模型行(WMS 侧可见的四段量)。
|
||||
//
|
||||
// 口径(2026-09-19 定稿):一个字段只有一个写者,跨系统只读聚合、绝不双写。
|
||||
// - outQty 已发出 [写者:出库]
|
||||
// - returnedQty 已退回 [写者:退库收货]
|
||||
// - consumedQty 已消耗 [写者:报工反冲核销]
|
||||
// - netOut 净领用 = outQty − returnedQty
|
||||
// - gap 待发缺口 = totalQty − netOut
|
||||
//
|
||||
// 在途 / 线边两段由调用方(MES)用本地 received_qty 派生,WMS 不猜:
|
||||
//
|
||||
// 在途 = outQty − receivedQty 线边 = receivedQty − consumedQty
|
||||
type ledgerProgressRow struct {
|
||||
OrderNo string `json:"orderNo"`
|
||||
MaterialCode string `json:"materialCode"`
|
||||
MaterialName string `json:"materialName"`
|
||||
TargetStation string `json:"targetStation"`
|
||||
TotalQty int `json:"totalQty"`
|
||||
OutQty int `json:"outQty"`
|
||||
ReturnedQty int `json:"returnedQty"`
|
||||
ConsumedQty int `json:"consumedQty"`
|
||||
NetOut int `json:"netOut"`
|
||||
Gap int `json:"gap"`
|
||||
Status string `json:"status"`
|
||||
CompletedAt int64 `json:"completedAt"`
|
||||
}
|
||||
|
||||
// ledgerProgressInternalHandler 台账配送进度(X-API-TOKEN 内部接口)。
|
||||
// MES 备料/配送进度页调用:拿四段量 + 缺口,再与本地已接料量合成完整去向。
|
||||
func ledgerProgressInternalHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
orderNo := q.Get("orderNo")
|
||||
materialCode := q.Get("materialCode")
|
||||
page := atoi(q.Get("page"), 1)
|
||||
pageSize := atoi(q.Get("pageSize"), 50)
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 1000 {
|
||||
pageSize = 50
|
||||
}
|
||||
|
||||
builder := ctx.EntClient.OrderMaterialLedger.Query()
|
||||
if orderNo != "" {
|
||||
builder = builder.Where(ordermaterialledger.OrderNoEQ(orderNo))
|
||||
}
|
||||
if materialCode != "" {
|
||||
builder = builder.Where(ordermaterialledger.MaterialCodeEqualFold(materialCode))
|
||||
}
|
||||
total, err := builder.Count(ctx0())
|
||||
if err != nil {
|
||||
fail(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
list, err := builder.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
|
||||
}
|
||||
|
||||
rows := make([]ledgerProgressRow, 0, len(list))
|
||||
for _, l := range list {
|
||||
netOut := l.OutQty - l.ReturnedQty
|
||||
gap := l.TotalQty - netOut
|
||||
if gap < 0 {
|
||||
gap = 0
|
||||
}
|
||||
rows = append(rows, ledgerProgressRow{
|
||||
OrderNo: l.OrderNo,
|
||||
MaterialCode: l.MaterialCode,
|
||||
MaterialName: l.MaterialName,
|
||||
TargetStation: l.TargetStation,
|
||||
TotalQty: l.TotalQty,
|
||||
OutQty: l.OutQty,
|
||||
ReturnedQty: l.ReturnedQty,
|
||||
ConsumedQty: l.ConsumedQty,
|
||||
NetOut: netOut,
|
||||
Gap: gap,
|
||||
Status: l.Status,
|
||||
CompletedAt: l.CompletedAt,
|
||||
})
|
||||
}
|
||||
ok(w, map[string]any{"total": total, "list": rows, "page": page, "pageSize": pageSize})
|
||||
}
|
||||
}
|
||||
@@ -277,13 +277,12 @@ func queryInspectionHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
inspectionType := r.URL.Query().Get("inspectionType")
|
||||
status := r.URL.Query().Get("status")
|
||||
inspector := r.URL.Query().Get("inspector")
|
||||
// 时间筛选:起止两端各自独立生效(只填一端合法,另一端不限制),**不做导出那套归一化校验**。
|
||||
// 注意:normalizeExportRange(默认近3个月 / 只传一端即报错 / 间隔≤1年)是**导出**专用硬规则,
|
||||
// 列表查询套用会导致进入「检测记录」Tab(默认只带 startDate)立即 400
|
||||
// 「导出必须同时提供起始时间与结束时间」。口径与 statsInspectionHandler 保持一致。
|
||||
startDate := r.URL.Query().Get("startDate")
|
||||
endDate := r.URL.Query().Get("endDate")
|
||||
startDate, endDate, rerr := normalizeExportRange(startDate, endDate)
|
||||
if rerr != nil {
|
||||
fail(w, http.StatusBadRequest, rerr.Error())
|
||||
return
|
||||
}
|
||||
|
||||
q := ctx.EntClient.InspectionRecord.Query()
|
||||
if targetId != "" {
|
||||
|
||||
@@ -44,9 +44,15 @@ func ledgerSyncHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
ordermaterialledger.MaterialCodeEQ(it.MaterialCode),
|
||||
).Only(ctx0())
|
||||
if err == nil {
|
||||
// 只更新需求量;状态按净额派生(不能无条件置「进行中」,
|
||||
// 否则已领料完结的工单被重新同步会误标为进行中)。
|
||||
st := "进行中"
|
||||
if existing.OutQty-existing.ReturnedQty >= it.TotalQty && it.TotalQty > 0 {
|
||||
st = "领料完结"
|
||||
}
|
||||
ctx.EntClient.OrderMaterialLedger.UpdateOneID(existing.ID).
|
||||
SetTotalQty(it.TotalQty).
|
||||
SetStatus("进行中").
|
||||
SetStatus(st).
|
||||
SetUpdatedAt(time.Now().Unix()).
|
||||
ExecX(ctx0())
|
||||
} else {
|
||||
|
||||
@@ -33,7 +33,17 @@ type deductStockRequest struct {
|
||||
Reviewer string `json:"reviewer"`
|
||||
TargetDock string `json:"targetDock"`
|
||||
ZoneCode string `json:"zoneCode"`
|
||||
ShelfNo string `json:"shelfNo"`
|
||||
LayerNo string `json:"layerNo"`
|
||||
PositionNo string `json:"positionNo"`
|
||||
Remark string `json:"remark"`
|
||||
// OutboundType 出库类型:workorder(备料出库,默认) / refill(工位叫料·补料出库)
|
||||
OutboundType string `json:"outboundType"`
|
||||
// NeedAgv 是否需要 AGV 配送到接驳台(备料/补料一般为 true)
|
||||
NeedAgv bool `json:"needAgv"`
|
||||
// OverReason 超 BOM 领用原因(损耗/报废/返修/其他)。
|
||||
// 超领不硬卡:net_out > 需求时,填了原因即放行并留痕;未填仍 409 拦截。
|
||||
OverReason string `json:"overReason"`
|
||||
}
|
||||
|
||||
// deductStockHandler 出库扣减
|
||||
@@ -93,15 +103,29 @@ func deductStockHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
}
|
||||
}()
|
||||
|
||||
// 2. 创建出库单
|
||||
// 1.5 出库库位兜底(客户诉求 一.9「出库相同」):未填的层级按
|
||||
// 「批次在库记录 → 首个 SN 在库记录」补全,保证出库单留痕到具体货位。
|
||||
req.ZoneCode, req.ShelfNo, req.LayerNo, req.PositionNo =
|
||||
outboundLocationFallback(ctx, req.BatchNo, req.SnList, req.ZoneCode, req.ShelfNo, req.LayerNo, req.PositionNo)
|
||||
|
||||
// 2. 创建出库单(出库即扣库存 → 初始状态「已出库」;叫AGV到位后置「已送达」)
|
||||
obType := req.OutboundType
|
||||
if obType != "refill" {
|
||||
obType = "workorder"
|
||||
}
|
||||
ob, err := tx.OutboundOrder.Create().
|
||||
SetOutboundNo(outboundNo).
|
||||
SetOutboundType("workorder").
|
||||
SetOutboundType(obType).
|
||||
SetStatus("已出库").
|
||||
SetNeedAgv(req.NeedAgv).
|
||||
SetNillableOrderNo(strPtr(req.OrderNo)).
|
||||
SetMaterialCode(req.MaterialCode).
|
||||
SetManageMode(1).
|
||||
SetNillableBatchNo(strPtr(req.BatchNo)).
|
||||
SetNillableZoneCode(strPtr(req.ZoneCode)).
|
||||
SetNillableShelfNo(strPtr(req.ShelfNo)).
|
||||
SetNillableLayerNo(strPtr(req.LayerNo)).
|
||||
SetNillablePositionNo(strPtr(req.PositionNo)).
|
||||
SetQuantity(0).
|
||||
SetNillableOperator(strPtr(req.Operator)).
|
||||
SetNillableReviewer(strPtr(req.Reviewer)).
|
||||
@@ -204,13 +228,27 @@ func deductStockHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
newOutQty := ledgerNow.OutQty + actualQty
|
||||
if newOutQty > ledgerNow.TotalQty {
|
||||
fail(w, http.StatusConflict, "出库数量超过工单 BOM 需求: 累计 "+itoa(ledgerNow.OutQty)+" + "+itoa(actualQty)+" > 需求 "+itoa(ledgerNow.TotalQty))
|
||||
return
|
||||
// 超量拦截按「净领用」判定(out_qty − returned_qty):退料会回补缺口。
|
||||
// 否则「出库100 → 退料20 → 再补料20」累计 120 > 需求 100,会被 409 永久拦死。
|
||||
netOut := newOutQty - ledgerNow.ReturnedQty
|
||||
overQty := 0
|
||||
if netOut > ledgerNow.TotalQty {
|
||||
// 超 BOM 领用不硬卡:损耗/报废/返修会真实超出 BOM 需求,硬卡会停线。
|
||||
// 规则:填了超领原因即放行并留痕(可统计、可预警);未填仍拦截。
|
||||
if req.OverReason == "" {
|
||||
fail(w, http.StatusConflict, "出库数量超过工单 BOM 需求: 净领用 "+itoa(netOut)+" > 需求 "+itoa(ledgerNow.TotalQty)+
|
||||
"(已发出 "+itoa(ledgerNow.OutQty)+" − 已退回 "+itoa(ledgerNow.ReturnedQty)+" + 本次 "+itoa(actualQty)+");"+
|
||||
"如确需超领(损耗/报废/返修),请填写超领原因后重试")
|
||||
return
|
||||
}
|
||||
overQty = netOut - ledgerNow.TotalQty
|
||||
}
|
||||
status := ledgerNow.Status
|
||||
if newOutQty >= ledgerNow.TotalQty {
|
||||
if netOut >= ledgerNow.TotalQty {
|
||||
status = "领料完结"
|
||||
} else if status == "领料完结" {
|
||||
// 退料回补后净额回落 → 状态回归「进行中」,允许再次补料
|
||||
status = "进行中"
|
||||
}
|
||||
// 台账 completed_at = 最后一笔领料完结出库时间(客户诉求 问题记录 L238-240:台账增加完成时间筛选)
|
||||
ledgerUpd := tx.OrderMaterialLedger.UpdateOneID(ledgerNow.ID).
|
||||
@@ -233,15 +271,26 @@ func deductStockHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
|
||||
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)
|
||||
map[string]any{"orderNo": req.OrderNo, "qty": actualQty, "overQty": overQty, "overReason": req.OverReason})
|
||||
if overQty > 0 {
|
||||
// 超领留痕:可统计、可预警(某工位频繁超领即异常信号)
|
||||
ctx.EventLog.Write(ctx0(), "material.over", r.Header.Get("X-Username"), "order_material_ledger", req.OrderNo,
|
||||
"超BOM领用 "+req.MaterialCode+" 超 "+itoa(overQty)+" 原因:"+req.OverReason,
|
||||
map[string]any{"orderNo": req.OrderNo, "materialCode": req.MaterialCode,
|
||||
"overQty": overQty, "reason": req.OverReason, "netOut": netOut, "total": ledgerNow.TotalQty})
|
||||
}
|
||||
logx.Infof("deduct stock: order=%s material=%s qty=%d over=%d", req.OrderNo, req.MaterialCode, actualQty, overQty)
|
||||
ok(w, map[string]any{
|
||||
"outboundNo": outboundNo,
|
||||
"qty": actualQty,
|
||||
"orderNo": req.OrderNo,
|
||||
"ledgerOut": newOutQty,
|
||||
"ledgerTotal": ledgerNow.TotalQty,
|
||||
"status": status,
|
||||
"outboundNo": outboundNo,
|
||||
"qty": actualQty,
|
||||
"orderNo": req.OrderNo,
|
||||
"outboundType": obType,
|
||||
"ledgerOut": newOutQty,
|
||||
"ledgerReturned": ledgerNow.ReturnedQty,
|
||||
"ledgerNet": netOut,
|
||||
"ledgerTotal": ledgerNow.TotalQty,
|
||||
"overQty": overQty,
|
||||
"status": status,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -262,8 +311,12 @@ func queryOutboundHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
ownershipNo := r.URL.Query().Get("ownershipNo")
|
||||
materialName := r.URL.Query().Get("materialName")
|
||||
targetStation := r.URL.Query().Get("targetStation")
|
||||
status := r.URL.Query().Get("status")
|
||||
|
||||
q := ctx.EntClient.OutboundOrder.Query()
|
||||
if status != "" {
|
||||
q = q.Where(outboundorder.StatusEQ(status))
|
||||
}
|
||||
if orderNo != "" {
|
||||
q = q.Where(outboundorder.OrderNoEqualFold(orderNo))
|
||||
}
|
||||
@@ -346,6 +399,9 @@ type generalOutboundRequest struct {
|
||||
Qty int `json:"qty"`
|
||||
Operator string `json:"operator"`
|
||||
ZoneCode string `json:"zoneCode"`
|
||||
ShelfNo string `json:"shelfNo"`
|
||||
LayerNo string `json:"layerNo"`
|
||||
PositionNo string `json:"positionNo"`
|
||||
BoxNo string `json:"boxNo"`
|
||||
ContractNo string `json:"contractNo"`
|
||||
Remark string `json:"remark"`
|
||||
@@ -353,6 +409,8 @@ type generalOutboundRequest struct {
|
||||
OwnershipType string `json:"ownershipType"` // workorder/project/other
|
||||
OwnershipNo string `json:"ownershipNo"`
|
||||
TargetStation string `json:"targetStation"`
|
||||
TargetDock string `json:"targetDock"` // 目标接驳台(叫AGV时必填)
|
||||
NeedAgv bool `json:"needAgv"` // 是否叫AGV配送到接驳台(可选)
|
||||
}
|
||||
|
||||
// generalOutboundHandler 通用出库(手动出库,不依赖工单)
|
||||
@@ -399,6 +457,10 @@ func generalOutboundHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// 库位兜底(与工单出库同口径):未填的层级按「批次在库 → 首个 SN 在库」补全
|
||||
req.ZoneCode, req.ShelfNo, req.LayerNo, req.PositionNo =
|
||||
outboundLocationFallback(ctx, req.BatchNo, req.SnList, req.ZoneCode, req.ShelfNo, req.LayerNo, req.PositionNo)
|
||||
|
||||
outboundNo := "OB" + uuid.NewString()[:12]
|
||||
|
||||
tx, err := ctx.EntClient.Tx(ctx0())
|
||||
@@ -416,6 +478,9 @@ func generalOutboundHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
ob, err := tx.OutboundOrder.Create().
|
||||
SetOutboundNo(outboundNo).
|
||||
SetOutboundType("general").
|
||||
SetStatus("已出库").
|
||||
SetNeedAgv(req.NeedAgv).
|
||||
SetNillableTargetDock(strPtr(req.TargetDock)).
|
||||
SetNillableOutboundCategory(strPtr(req.OutboundCategory)).
|
||||
SetNillableOwnershipType(strPtr(req.OwnershipType)).
|
||||
SetNillableOwnershipNo(strPtr(req.OwnershipNo)).
|
||||
@@ -424,6 +489,9 @@ func generalOutboundHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
SetManageMode(manageMode).
|
||||
SetNillableBatchNo(strPtr(req.BatchNo)).
|
||||
SetNillableZoneCode(strPtr(req.ZoneCode)).
|
||||
SetNillableShelfNo(strPtr(req.ShelfNo)).
|
||||
SetNillableLayerNo(strPtr(req.LayerNo)).
|
||||
SetNillablePositionNo(strPtr(req.PositionNo)).
|
||||
SetQuantity(0).
|
||||
SetNillableOperator(strPtr(req.Operator)).
|
||||
SetNillableRemark(strPtr(req.Remark)).
|
||||
@@ -556,6 +624,7 @@ func snListJSON(sn []string) string {
|
||||
func exportOutboundHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
outboundType := r.URL.Query().Get("outboundType")
|
||||
status := r.URL.Query().Get("status")
|
||||
orderNo := r.URL.Query().Get("orderNo")
|
||||
materialCode := r.URL.Query().Get("materialCode")
|
||||
boxNo := r.URL.Query().Get("boxNo")
|
||||
@@ -571,6 +640,9 @@ func exportOutboundHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
if outboundType != "" {
|
||||
q = q.Where(outboundorder.OutboundTypeEQ(outboundType))
|
||||
}
|
||||
if status != "" {
|
||||
q = q.Where(outboundorder.StatusEQ(status))
|
||||
}
|
||||
if orderNo != "" {
|
||||
q = q.Where(outboundorder.OrderNoEqualFold(orderNo))
|
||||
}
|
||||
@@ -612,17 +684,14 @@ func exportOutboundHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
|
||||
// xlsx 导出
|
||||
if r.URL.Query().Get("format") == "xlsx" {
|
||||
headers := []string{"出库单号", "类型", "类别", "工单号", "归属单号", "图号", "物料名称", "数量", "目标工位", "装箱号", "合同号", "SN清单", "创建时间"}
|
||||
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, outboundCategoryLabel(ob.OutboundCategory), ob.OrderNo,
|
||||
ob.OutboundNo, outboundTypeLabel(ob.OutboundType), ob.Status, outboundCategoryLabel(ob.OutboundCategory), ob.OrderNo,
|
||||
ob.OwnershipNo, ob.MaterialCode, ob.MaterialName,
|
||||
locationText(ob.ZoneCode, ob.ShelfNo, ob.LayerNo, ob.PositionNo),
|
||||
ob.Quantity, ob.TargetStation, ob.BoxNo, ob.ContractNo, row.SnCodes,
|
||||
unixFmt(ob.CreatedAt),
|
||||
})
|
||||
@@ -642,7 +711,82 @@ func min(a, b int) int {
|
||||
return b
|
||||
}
|
||||
|
||||
// locationText 库位展示串。入库/出库/库存统一口径:`Z02 A架/3层/12位`,无内容返回空串。
|
||||
// outboundLocationFallback 出库四级库位兜底(客户诉求 一.9「出库相同」)。
|
||||
//
|
||||
// 调用方(MES 备料自动出库、前端手工出库)可能只传了区域甚至什么都没传;
|
||||
// 这里按「指定批次在库的实际货位 → 首个出库 SN 在库的实际货位」顺序,
|
||||
// 把**缺失的层级**补全,使出库单、列表、导出都能留痕到具体货位(货架/层/位置号)。
|
||||
// 只补空值、不覆盖调用方显式指定的值。
|
||||
func outboundLocationFallback(ctx *svc.ServiceContext, batchNo string, snList []string,
|
||||
zoneCode, shelfNo, layerNo, positionNo string) (string, string, string, string) {
|
||||
if zoneCode != "" && shelfNo != "" && layerNo != "" && positionNo != "" {
|
||||
return zoneCode, shelfNo, layerNo, positionNo
|
||||
}
|
||||
var src *ent.Inventory
|
||||
if batchNo != "" {
|
||||
src, _ = ctx.EntClient.Inventory.Query().
|
||||
Where(inventory.ManageModeEQ(1), inventory.BatchNoEQ(batchNo)).Only(ctx0())
|
||||
}
|
||||
if src == nil && len(snList) > 0 {
|
||||
src, _ = ctx.EntClient.Inventory.Query().
|
||||
Where(inventory.ManageModeEQ(2), inventory.SnCodeEQ(snList[0])).Only(ctx0())
|
||||
}
|
||||
if src == nil {
|
||||
return zoneCode, shelfNo, layerNo, positionNo
|
||||
}
|
||||
if zoneCode == "" {
|
||||
zoneCode = src.ZoneCode
|
||||
}
|
||||
if shelfNo == "" {
|
||||
shelfNo = src.ShelfNo
|
||||
}
|
||||
if layerNo == "" {
|
||||
layerNo = src.LayerNo
|
||||
}
|
||||
if positionNo == "" {
|
||||
positionNo = src.PositionNo
|
||||
}
|
||||
return zoneCode, shelfNo, layerNo, positionNo
|
||||
}
|
||||
|
||||
// locationText 库位显示文案(区域/货架/层/位置号)——列表与导出共用。
|
||||
func locationText(zoneCode, shelfNo, layerNo, positionNo string) string {
|
||||
parts := make([]string, 0, 3)
|
||||
if shelfNo != "" {
|
||||
parts = append(parts, shelfNo+"架")
|
||||
}
|
||||
if layerNo != "" {
|
||||
parts = append(parts, layerNo+"层")
|
||||
}
|
||||
if positionNo != "" {
|
||||
parts = append(parts, positionNo+"位")
|
||||
}
|
||||
loc := strings.Join(parts, "/")
|
||||
if zoneCode == "" {
|
||||
return loc
|
||||
}
|
||||
if loc == "" {
|
||||
return zoneCode
|
||||
}
|
||||
return zoneCode + " " + loc
|
||||
}
|
||||
|
||||
// outboundCategoryLabel 出库类别显示中文(客户诉求 L96-108:退料/样品/报废/发货/其他)
|
||||
// outboundTypeLabel 出库类型中文(界面禁英文)
|
||||
func outboundTypeLabel(v string) string {
|
||||
switch v {
|
||||
case "workorder":
|
||||
return "备料出库"
|
||||
case "refill":
|
||||
return "补料出库"
|
||||
case "general":
|
||||
return "通用出库"
|
||||
default:
|
||||
return "通用出库"
|
||||
}
|
||||
}
|
||||
|
||||
func outboundCategoryLabel(v string) string {
|
||||
switch v {
|
||||
case "return":
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"bj_power_wms/ent"
|
||||
"bj_power_wms/ent/inventory"
|
||||
"bj_power_wms/ent/material"
|
||||
"bj_power_wms/ent/ordermaterialledger"
|
||||
"bj_power_wms/ent/returnorder"
|
||||
"bj_power_wms/internal/svc"
|
||||
)
|
||||
@@ -175,6 +176,27 @@ func confirmReturnOrderHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// 退料回收台账缺口:累加 returned_qty,并把「领料完结」拉回「进行中」。
|
||||
// 目的:出库超量判定已改为净额(out_qty − returned_qty),若退料不回写,
|
||||
// 会出「出库 100 完结 → 退料 20 → 再叫料 20 被 409 永久拦死」的死锁。
|
||||
if ro.OrderNo != "" {
|
||||
led, lerr := tx.OrderMaterialLedger.Query().
|
||||
Where(ordermaterialledger.OrderNoEQ(ro.OrderNo), ordermaterialledger.MaterialCodeEQ(ro.MaterialCode)).
|
||||
Only(ctx0())
|
||||
if lerr == nil && led != nil {
|
||||
newReturned := led.ReturnedQty + ro.Qty
|
||||
st := led.Status
|
||||
if st == "领料完结" && (led.OutQty-newReturned) < led.TotalQty {
|
||||
st = "进行中"
|
||||
}
|
||||
if _, uerr := tx.OrderMaterialLedger.UpdateOneID(led.ID).
|
||||
SetReturnedQty(newReturned).SetStatus(st).Save(ctx0()); uerr != nil {
|
||||
fail(w, http.StatusInternalServerError, "回写台账退回量失败: "+uerr.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err = tx.Commit(); err != nil {
|
||||
fail(w, http.StatusInternalServerError, "提交事务失败: "+err.Error())
|
||||
return
|
||||
@@ -234,7 +256,8 @@ type returnLedgerRow struct {
|
||||
}
|
||||
|
||||
// queryReturnLedgerHandler 退料台账查询:return_orders(RECEIVED) 按工单+物料聚合的读模型。
|
||||
// 与备料台账(/api/ledger/query)并列;退料不回写备料台账 out_qty(out_qty 始终为“仓库已发出量”语义)。
|
||||
// 与备料台账(/api/ledger/query)并列;退料不动 out_qty(out_qty 恒为「仓库已发出量」),
|
||||
// 而是累加 ledgers.returned_qty(已退回量),出库超量判定与「领料完结」按净额 out_qty−returned_qty。
|
||||
func queryReturnLedgerHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
|
||||
@@ -87,6 +87,8 @@ func RegisterHandlers(server *rest.Server, ctx *svc.ServiceContext) {
|
||||
{Method: http.MethodPost, Path: "/api/outbound/general", Handler: generalOutboundHandler(ctx)},
|
||||
{Method: http.MethodGet, Path: "/api/outbound/query", Handler: queryOutboundHandler(ctx)},
|
||||
{Method: http.MethodGet, Path: "/api/outbound/export", Handler: exportOutboundHandler(ctx)},
|
||||
// 出库库位联动:优先批次在库库位,其次该物料最近一次入库库位(客户诉求 一.9「出库相同」)
|
||||
{Method: http.MethodGet, Path: "/api/outbound/location-hint", Handler: outboundLocationHintHandler(ctx)},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -151,6 +153,8 @@ func RegisterHandlers(server *rest.Server, ctx *svc.ServiceContext) {
|
||||
{Method: http.MethodPost, Path: "/api/zone/batch-generate", Handler: requirePerm(ctx, "zone:create", batchGenerateHandler(ctx))},
|
||||
// 下拉(兼容旧调用 + 层级级联),原样保留无权限包裹
|
||||
{Method: http.MethodGet, Path: "/api/zone/picker", Handler: listZonesForPicker(ctx)},
|
||||
// 同区空位推荐(入库选物料后无历史库位时自动推荐),无权限包裹
|
||||
{Method: http.MethodGet, Path: "/api/zone/vacant", Handler: vacantZoneHandler(ctx)},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -215,11 +219,14 @@ func RegisterHandlers(server *rest.Server, ctx *svc.ServiceContext) {
|
||||
server.AddRoutes(
|
||||
[]rest.Route{
|
||||
{Method: http.MethodPost, Path: "/api/internal/ledger/sync", Handler: wrapInternal(ctx)(ledgerSyncHandler(ctx))},
|
||||
{Method: http.MethodGet, Path: "/api/internal/ledger/progress", Handler: wrapInternal(ctx)(ledgerProgressInternalHandler(ctx))},
|
||||
{Method: http.MethodPost, Path: "/api/internal/stock/check", Handler: wrapInternal(ctx)(checkStockHandler(ctx))},
|
||||
{Method: http.MethodPost, Path: "/api/internal/stock/lock", Handler: wrapInternal(ctx)(lockStockHandler(ctx))},
|
||||
{Method: http.MethodPost, Path: "/api/internal/stock/unlock", Handler: wrapInternal(ctx)(unlockStockHandler(ctx))},
|
||||
{Method: http.MethodPost, Path: "/api/internal/stock/deduct", Handler: wrapInternal(ctx)(deductStockHandler(ctx))},
|
||||
{Method: http.MethodGet, Path: "/api/internal/stock/query", Handler: wrapInternal(ctx)(queryStockHandler(ctx))},
|
||||
// 备料自动出库:MES 侧按此接口取可用批次/SN 后,逐笔调 stock/deduct 完成自动领料
|
||||
{Method: http.MethodGet, Path: "/api/internal/stock/batches", Handler: wrapInternal(ctx)(listStockBatchesInternalHandler(ctx))},
|
||||
{Method: http.MethodPost, Path: "/api/internal/semi/inbound", Handler: wrapInternal(ctx)(semiInboundHandler(ctx))},
|
||||
{Method: http.MethodPost, Path: "/api/internal/semi/outbound", Handler: wrapInternal(ctx)(semiOutboundHandler(ctx))},
|
||||
{Method: http.MethodPost, Path: "/api/internal/finished/inbound", Handler: wrapInternal(ctx)(finishedInboundHandler(ctx))},
|
||||
|
||||
@@ -1085,3 +1085,49 @@ func shortageHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
ok(w, map[string]any{"list": rows, "total": len(rows)})
|
||||
}
|
||||
}
|
||||
|
||||
// listStockBatchesInternalHandler GET /api/internal/stock/batches?materialCode=&zoneCode=
|
||||
// 供 MES「工单备料自动出库」分配批次用:返回该物料可出库的批次明细(结构件)与可用 SN 列表(电气件)。
|
||||
// 结构件按批次号升序(FIFO);统一质量门禁:仅「合格」库存可出库,未检/不合格/待退不返回。
|
||||
func listStockBatchesInternalHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
materialCode := strings.TrimSpace(r.URL.Query().Get("materialCode"))
|
||||
if materialCode == "" {
|
||||
fail(w, http.StatusBadRequest, "materialCode 必填")
|
||||
return
|
||||
}
|
||||
q := ctx.EntClient.Inventory.Query().
|
||||
Where(
|
||||
inventory.MaterialCodeEQ(materialCode),
|
||||
inventory.QuantityGT(0),
|
||||
inventory.QualityStatusEQ(QStatusPass),
|
||||
)
|
||||
if zc := strings.TrimSpace(r.URL.Query().Get("zoneCode")); zc != "" {
|
||||
q = q.Where(inventory.ZoneCodeEQ(zc))
|
||||
}
|
||||
rows, err := q.Order(ent.Asc("batch_no"), ent.Asc("created_at")).All(ctx0())
|
||||
if err != nil {
|
||||
fail(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
batches := make([]map[string]any, 0, len(rows))
|
||||
sns := make([]string, 0, len(rows))
|
||||
for _, inv := range rows {
|
||||
avail := inv.Quantity - inv.LockedQty
|
||||
if avail <= 0 {
|
||||
continue
|
||||
}
|
||||
if inv.ManageMode == 2 {
|
||||
if inv.SnCode != "" {
|
||||
sns = append(sns, inv.SnCode)
|
||||
}
|
||||
continue
|
||||
}
|
||||
batches = append(batches, map[string]any{
|
||||
"batchNo": inv.BatchNo, "quantity": inv.Quantity, "lockedQty": inv.LockedQty,
|
||||
"avail": avail, "zoneCode": inv.ZoneCode, "qualityStatus": inv.QualityStatus,
|
||||
})
|
||||
}
|
||||
ok(w, map[string]any{"batches": batches, "snList": sns})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"bj_power_wms/ent"
|
||||
"bj_power_wms/ent/inventory"
|
||||
"bj_power_wms/ent/zone"
|
||||
"bj_power_wms/internal/svc"
|
||||
)
|
||||
@@ -299,6 +300,59 @@ func updateZoneNodeHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
// listZonesForPicker 库位下拉(兼容 + 层级两用):
|
||||
// - 不带 level:返回所有启用位置号(level4)扁平结构(兼容 Outbound/Stocktake/Inventory 旧调用)
|
||||
// - 带 level:返回该层级节点(用于入库页区域→货架→层→位置 级联下拉)
|
||||
// locKey 四级货位比较键:区域/货架忽略大小写(存量数据存在 z01/Z01 混用),层/位置原样。
|
||||
func locKey(zoneCode, shelfNo, layerNo, positionNo string) string {
|
||||
return strings.ToLower(strings.TrimSpace(zoneCode)) + "|" +
|
||||
strings.ToLower(strings.TrimSpace(shelfNo)) + "|" +
|
||||
strings.TrimSpace(layerNo) + "|" + strings.TrimSpace(positionNo)
|
||||
}
|
||||
|
||||
// vacantZoneHandler 同区空位推荐:返回指定区域(不传 zoneCode 则不限区域)内「未被库存占用」的位置节点(level=4)。
|
||||
// 用途:入库选中物料后,若该物料无历史库位(新物料),自动推荐同区空位,替代人工逐个挑选货架/层/位置。
|
||||
// 占用判定:inventories 中 quantity>0 且 status 非「出库/报废」的记录,其四级货位视为已占用。
|
||||
func vacantZoneHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
wantZone := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("zoneCode")))
|
||||
limit := atoi(r.URL.Query().Get("limit"), 20)
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 20
|
||||
}
|
||||
rows, err := ctx.EntClient.Zone.Query().
|
||||
Where(zone.LevelEQ(zoneLevelPos), zone.StatusEQ("启用")).
|
||||
Order(ent.Asc("zone_code"), ent.Asc("shelf_no"), ent.Asc("layer_no"), ent.Asc("code")).
|
||||
All(ctx0())
|
||||
if err != nil {
|
||||
fail(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
occupied := map[string]bool{}
|
||||
if invs, ierr := ctx.EntClient.Inventory.Query().
|
||||
Where(inventory.QuantityGT(0), inventory.StatusNotIn("出库", "报废")).
|
||||
All(ctx0()); ierr == nil {
|
||||
for _, iv := range invs {
|
||||
occupied[locKey(iv.ZoneCode, iv.ShelfNo, iv.LayerNo, iv.PositionNo)] = true
|
||||
}
|
||||
}
|
||||
list := make([]map[string]any, 0, limit)
|
||||
for _, z := range rows {
|
||||
if wantZone != "" && strings.ToLower(z.ZoneCode) != wantZone {
|
||||
continue
|
||||
}
|
||||
if occupied[locKey(z.ZoneCode, z.ShelfNo, z.LayerNo, z.PositionNo)] {
|
||||
continue
|
||||
}
|
||||
list = append(list, map[string]any{
|
||||
"zoneCode": z.ZoneCode, "zoneName": z.ZoneName, "shelfNo": z.ShelfNo,
|
||||
"layerNo": z.LayerNo, "positionNo": z.PositionNo, "name": z.Name, "code": z.Code,
|
||||
})
|
||||
if len(list) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
ok(w, list)
|
||||
}
|
||||
}
|
||||
|
||||
func listZonesForPicker(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
level := atoi(r.URL.Query().Get("level"), 0)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"bj_power_wms/ent"
|
||||
"bj_power_wms/ent/inboundorder"
|
||||
"bj_power_wms/ent/inventory"
|
||||
"bj_power_wms/internal/svc"
|
||||
)
|
||||
|
||||
@@ -38,3 +39,49 @@ func lastInboundLocationHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// outboundLocationHintHandler 出库库位联动(客户诉求 一.9「出库相同」)。
|
||||
// 取值优先级:① 指定批次在库的实际四级库位(最准,出库就是从该货位取货)
|
||||
// ② 该物料最近一次入库的库位(历史常用库位)③ 均无则返回空,前端可继续走同区空位推荐。
|
||||
func outboundLocationHintHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
materialCode := strings.TrimSpace(r.URL.Query().Get("materialCode"))
|
||||
batchNo := strings.TrimSpace(r.URL.Query().Get("batchNo"))
|
||||
emptyHint := map[string]any{"zoneCode": "", "shelfNo": "", "layerNo": "", "positionNo": ""}
|
||||
if materialCode == "" && batchNo == "" {
|
||||
ok(w, emptyHint)
|
||||
return
|
||||
}
|
||||
if batchNo != "" {
|
||||
if b, err := ctx.EntClient.Inventory.Query().
|
||||
Where(inventory.ManageModeEQ(1), inventory.BatchNoEQ(batchNo)).
|
||||
Only(ctx0()); err == nil {
|
||||
ok(w, map[string]any{
|
||||
"zoneCode": b.ZoneCode,
|
||||
"shelfNo": b.ShelfNo,
|
||||
"layerNo": b.LayerNo,
|
||||
"positionNo": b.PositionNo,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
if materialCode == "" {
|
||||
ok(w, emptyHint)
|
||||
return
|
||||
}
|
||||
ib, err := ctx.EntClient.InboundOrder.Query().
|
||||
Where(inboundorder.MaterialCodeEQ(materialCode)).
|
||||
Order(ent.Desc("created_at"), ent.Desc("id")).
|
||||
First(ctx0())
|
||||
if err != nil {
|
||||
ok(w, emptyHint)
|
||||
return
|
||||
}
|
||||
ok(w, map[string]any{
|
||||
"zoneCode": ib.ZoneCode,
|
||||
"shelfNo": ib.ShelfNo,
|
||||
"layerNo": ib.LayerNo,
|
||||
"positionNo": ib.PositionNo,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user