feat: 完成客户第12条需求+多系统改造

1. 新增预警表添加工单号字段,支持按工单号检索预警
2. 添加工单过滤在制品/绩效报表/预警中心查询
3. 补全WMS台账字段口径与权限配置
4. 修复备料单与数量不符处理流程
5. 新增配送进度/库位联动/自动出库功能
6. 修复AGV配送状态更新逻辑
7. 优化退料与库存管理细节
This commit is contained in:
SunYF
2026-09-19 17:04:58 +08:00
parent 62e45b7379
commit 05b0b8a909
89 changed files with 7912 additions and 429 deletions
+45
View File
@@ -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/codeent 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 {
+8
View File
@@ -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)
+60 -147
View File
@@ -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
}
+97
View File
@@ -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})
}
}
+4 -5
View File
@@ -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 != "" {
+7 -1
View File
@@ -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 {
+164 -20
View File
@@ -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":
+24 -1
View File
@@ -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_qtyout_qty 始终为“仓库已发出量”语义)。
// 与备料台账(/api/ledger/query)并列;退料不 out_qtyout_qty 恒为「仓库已发出量」),
// 而是累加 ledgers.returned_qty(已退回量),出库超量判定与「领料完结」按净额 out_qtyreturned_qty。
func queryReturnLedgerHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
+7
View File
@@ -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))},
+46
View File
@@ -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})
}
}
+54
View File
@@ -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,
})
}
}