2026-08-28 15:06:01 +08:00
|
|
|
|
package handler
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"net/http"
|
2026-09-20 14:52:36 +08:00
|
|
|
|
"strings"
|
2026-08-28 15:06:01 +08:00
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
|
|
"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/stocktakeitem"
|
|
|
|
|
|
"bj_power_wms/ent/stocktakeorder"
|
2026-09-20 14:52:36 +08:00
|
|
|
|
"bj_power_wms/ent/user"
|
2026-08-28 15:06:01 +08:00
|
|
|
|
"bj_power_wms/internal/svc"
|
|
|
|
|
|
|
|
|
|
|
|
"github.com/google/uuid"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-09-20 14:52:36 +08:00
|
|
|
|
// ============================ 盘点15问 最优方案(2026-09-20) ============================
|
|
|
|
|
|
// 盘点盘的是「库存量」:账面数=快照库存量,实盘数=实际数到,差异=实盘-账面。
|
|
|
|
|
|
// 核心闭环:①发起(冻结快照 + 锁库存) → ②扫码实盘(SN自动=1/批次填数) → ③完成(处理未盘项 +
|
|
|
|
|
|
// 二次确认写回 + 生成盘点调整单 + 校验 库存量=可用量+锁定量 + 解锁)。
|
|
|
|
|
|
// 状态机:进行中(已锁定) → 已完成(仅出结论) / 已写回(差异已修正库存) / 已取消。
|
|
|
|
|
|
// ========================================================================================
|
|
|
|
|
|
|
|
|
|
|
|
// hasPermCode 在当前请求上下文里判定登录用户是否拥有指定权限码(支持通配 *)。
|
|
|
|
|
|
// 用于同一 handler 内按分支做细粒度鉴权(如「完成盘点」写回分支需 stocktake:writeback)。
|
|
|
|
|
|
func hasPermCode(ctx *svc.ServiceContext, r *http.Request, code string) bool {
|
|
|
|
|
|
username := strings.TrimSpace(r.Header.Get("X-Username"))
|
|
|
|
|
|
if username == "" {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
u, err := ctx.EntClient.User.Query().Where(user.UsernameEQ(username)).Only(ctx0())
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
return hasCode(permissionsForUser(ctx, u), code)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// frozenByStocktake 返回锁定该批次/SN/物料的进行中盘点单号,空串=未冻结。
|
|
|
|
|
|
// 出库/入库/锁定入口据此拦截,保证盘点期间「库存量/锁定量/可用量」冻结(问题2 最严重)。
|
|
|
|
|
|
func frozenByStocktake(ctx *svc.ServiceContext, batchNo, snCode, materialCode string) string {
|
|
|
|
|
|
q := ctx.EntClient.Inventory.Query().Where(inventory.StocktakeNoNEQ(""))
|
|
|
|
|
|
switch {
|
|
|
|
|
|
case strings.TrimSpace(snCode) != "":
|
|
|
|
|
|
q = q.Where(inventory.SnCodeEQ(snCode))
|
|
|
|
|
|
case strings.TrimSpace(batchNo) != "":
|
|
|
|
|
|
q = q.Where(inventory.BatchNoEQ(batchNo))
|
|
|
|
|
|
case strings.TrimSpace(materialCode) != "":
|
|
|
|
|
|
q = q.Where(inventory.MaterialCodeEQ(materialCode))
|
|
|
|
|
|
default:
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
row, err := q.First(ctx0())
|
|
|
|
|
|
if err != nil || row == nil {
|
|
|
|
|
|
return ""
|
|
|
|
|
|
}
|
|
|
|
|
|
return row.StocktakeNo
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// assertNotFrozenTargets 出库/锁定入口前置守卫:批次/SN 若被进行中盘点锁定则拦截(问题2)。
|
|
|
|
|
|
// 返回 true=未被冻结可继续;返回 false=已写回 409 冲突响应,调用方应直接 return。
|
|
|
|
|
|
func assertNotFrozenTargets(ctx *svc.ServiceContext, w http.ResponseWriter, batchNo string, snList []string) bool {
|
|
|
|
|
|
if strings.TrimSpace(batchNo) != "" {
|
|
|
|
|
|
if no := frozenByStocktake(ctx, batchNo, "", ""); no != "" {
|
|
|
|
|
|
fail(w, http.StatusConflict, "批次 "+batchNo+" 正在盘点单 "+no+" 锁定中,盘点期间禁止出库/锁定")
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, sn := range snList {
|
|
|
|
|
|
if no := frozenByStocktake(ctx, "", sn, ""); no != "" {
|
|
|
|
|
|
fail(w, http.StatusConflict, "SN "+sn+" 正在盘点单 "+no+" 锁定中,盘点期间禁止出库/锁定")
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// startStocktakeHandler 发起盘点:冻结当前库存快照 + 锁定该范围库存(问题2/7)。
|
2026-09-10 16:59:15 +08:00
|
|
|
|
// 快照统一来自 inventory 一张表;结构件以批次为单位(bookQty=批次余量),电气件以 SN 为单位(bookQty=1)。
|
2026-09-20 14:52:36 +08:00
|
|
|
|
// 目标数量=本次覆盖的批次/SN 总数(问题15),发起时自动算出。
|
2026-08-28 15:06:01 +08:00
|
|
|
|
func startStocktakeHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
|
|
|
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
|
var req struct {
|
2026-09-20 14:52:36 +08:00
|
|
|
|
Operator string `json:"operator"`
|
|
|
|
|
|
Zones []string `json:"zones"` // 可选:限定盘点区域,空=全部
|
|
|
|
|
|
MaterialCodes []string `json:"materialCodes"` // 可选:限定盘点物料,空=全部
|
|
|
|
|
|
ManageMode int `json:"manageMode"` // 可选:1结构件/2电气件,0=全部
|
|
|
|
|
|
QualityStatus string `json:"qualityStatus"` // 可选:限定质量状态,空=全部
|
2026-08-28 15:06:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
if err := parseJSON(r, &req); err != nil {
|
|
|
|
|
|
fail(w, http.StatusBadRequest, "参数错误: "+err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
no := "ST" + time.Now().Format("20060102150405") + uuid.NewString()[:4]
|
|
|
|
|
|
|
2026-09-02 08:21:11 +08:00
|
|
|
|
q := ctx.EntClient.Inventory.Query().Where(inventory.StatusIn("在库", "锁定"))
|
|
|
|
|
|
if len(req.Zones) > 0 {
|
|
|
|
|
|
q = q.Where(inventory.ZoneCodeIn(req.Zones...))
|
2026-08-28 15:06:01 +08:00
|
|
|
|
}
|
2026-09-03 14:20:37 +08:00
|
|
|
|
if len(req.MaterialCodes) > 0 {
|
|
|
|
|
|
q = q.Where(inventory.MaterialCodeIn(req.MaterialCodes...))
|
|
|
|
|
|
}
|
|
|
|
|
|
if req.ManageMode != 0 {
|
|
|
|
|
|
q = q.Where(inventory.ManageModeEQ(req.ManageMode))
|
|
|
|
|
|
}
|
|
|
|
|
|
if req.QualityStatus != "" {
|
|
|
|
|
|
q = q.Where(inventory.QualityStatusEQ(req.QualityStatus))
|
|
|
|
|
|
}
|
2026-09-02 08:21:11 +08:00
|
|
|
|
rows, err := q.All(ctx0())
|
2026-08-28 15:06:01 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
fail(w, http.StatusInternalServerError, err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-09-20 14:52:36 +08:00
|
|
|
|
if len(rows) == 0 {
|
|
|
|
|
|
fail(w, http.StatusBadRequest, "盘点范围内没有在库库存,无需发起")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
// 冲突守卫:范围内若有库存正被别的进行中盘点单锁定,禁止重复发起(避免两单争抢同一批库存)。
|
|
|
|
|
|
for _, inv := range rows {
|
|
|
|
|
|
if inv.StocktakeNo != "" {
|
|
|
|
|
|
fail(w, http.StatusConflict, "范围内库存正被盘点单 "+inv.StocktakeNo+" 锁定,请先完成或取消该盘点单")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-09-02 08:21:11 +08:00
|
|
|
|
|
2026-09-20 14:52:36 +08:00
|
|
|
|
// 快照 + 锁库存整体事务化:要么完整落库并冻结、要么全回滚。
|
2026-09-17 12:49:07 +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-09-02 08:21:11 +08:00
|
|
|
|
count := 0
|
2026-09-20 14:52:36 +08:00
|
|
|
|
ids := make([]int, 0, len(rows))
|
2026-09-02 08:21:11 +08:00
|
|
|
|
for _, inv := range rows {
|
|
|
|
|
|
tt := "BATCH"
|
|
|
|
|
|
tid := inv.BatchNo
|
|
|
|
|
|
book := inv.Quantity
|
|
|
|
|
|
if inv.ManageMode == 2 {
|
|
|
|
|
|
tt = "SN"
|
|
|
|
|
|
tid = inv.SnCode
|
|
|
|
|
|
book = 1
|
|
|
|
|
|
}
|
|
|
|
|
|
if tid == "" {
|
2026-08-28 15:06:01 +08:00
|
|
|
|
continue
|
|
|
|
|
|
}
|
2026-09-17 12:49:07 +08:00
|
|
|
|
// 库位四级贯通(U18):快照冻结区域+货架/层/位置,盘点差异可精确定位到货位。
|
|
|
|
|
|
if _, e := tx.StocktakeItem.Create().
|
2026-08-28 15:06:01 +08:00
|
|
|
|
SetStocktakeNo(no).
|
2026-09-02 08:21:11 +08:00
|
|
|
|
SetTargetType(tt).
|
|
|
|
|
|
SetTargetID(tid).
|
|
|
|
|
|
SetNillableMaterialCode(strPtr(inv.MaterialCode)).
|
|
|
|
|
|
SetNillableZoneCode(strPtr(inv.ZoneCode)).
|
2026-09-17 12:49:07 +08:00
|
|
|
|
SetNillableShelfNo(strPtr(inv.ShelfNo)).
|
|
|
|
|
|
SetNillableLayerNo(strPtr(inv.LayerNo)).
|
|
|
|
|
|
SetNillablePositionNo(strPtr(inv.PositionNo)).
|
2026-09-02 08:21:11 +08:00
|
|
|
|
SetBookQty(book).
|
2026-09-17 12:49:07 +08:00
|
|
|
|
Save(ctx0()); e != nil {
|
|
|
|
|
|
fail(w, http.StatusInternalServerError, "创建盘点明细失败: "+e.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-09-20 14:52:36 +08:00
|
|
|
|
ids = append(ids, inv.ID)
|
2026-08-28 15:06:01 +08:00
|
|
|
|
count++
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-20 14:52:36 +08:00
|
|
|
|
// 锁库存(问题2):给快照覆盖的库存行打盘点单号标记,出库/入库/锁定入口据此拦截。
|
|
|
|
|
|
if len(ids) > 0 {
|
|
|
|
|
|
if _, e := tx.Inventory.Update().
|
|
|
|
|
|
Where(inventory.IDIn(ids...)).
|
|
|
|
|
|
SetStocktakeNo(no).
|
|
|
|
|
|
Save(ctx0()); e != nil {
|
|
|
|
|
|
fail(w, http.StatusInternalServerError, "锁定库存失败: "+e.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-17 12:49:07 +08:00
|
|
|
|
rec, err := tx.StocktakeOrder.Create().
|
2026-08-28 15:06:01 +08:00
|
|
|
|
SetStocktakeNo(no).
|
2026-09-20 14:52:36 +08:00
|
|
|
|
SetStatus("进行中").
|
2026-08-28 15:06:01 +08:00
|
|
|
|
SetNillableOperator(strPtr(req.Operator)).
|
|
|
|
|
|
SetTotalTargets(count).
|
|
|
|
|
|
Save(ctx0())
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
fail(w, http.StatusInternalServerError, "创建盘点单失败: "+err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-09-17 12:49:07 +08:00
|
|
|
|
if err = tx.Commit(); err != nil {
|
|
|
|
|
|
fail(w, http.StatusInternalServerError, "提交事务失败: "+err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
committed = true
|
|
|
|
|
|
|
2026-09-04 14:18:53 +08:00
|
|
|
|
ctx.EventLog.Write(ctx0(), "stocktake.start", r.Header.Get("X-Username"), "stocktake", rec.StocktakeNo,
|
2026-09-20 14:52:36 +08:00
|
|
|
|
"发起盘点 "+rec.StocktakeNo+"(已锁定库存)", map[string]any{"totalTargets": count, "zones": req.Zones,
|
2026-09-04 14:18:53 +08:00
|
|
|
|
"materialCodes": req.MaterialCodes, "manageMode": req.ManageMode})
|
2026-09-20 14:52:36 +08:00
|
|
|
|
ok(w, map[string]any{"stocktakeNo": rec.StocktakeNo, "totalTargets": count, "status": rec.Status})
|
2026-08-28 15:06:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-20 14:52:36 +08:00
|
|
|
|
// recordStocktakeHandler 扫码盘点记录:查快照并记录实盘数与差异(问题6)。
|
|
|
|
|
|
// 电气件(SN)逐件扫码,实盘数强制=1(不需用户填);结构件(BATCH)按用户填入的数量。
|
2026-08-28 15:06:01 +08:00
|
|
|
|
func recordStocktakeHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
|
|
|
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
|
var req struct {
|
|
|
|
|
|
StocktakeNo string `json:"stocktakeNo"`
|
2026-09-20 14:52:36 +08:00
|
|
|
|
TargetType string `json:"targetType"` // BATCH/SN,可空则按快照判断
|
2026-08-28 15:06:01 +08:00
|
|
|
|
TargetId string `json:"targetId"`
|
|
|
|
|
|
ScanQty int `json:"scanQty"`
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := parseJSON(r, &req); err != nil {
|
|
|
|
|
|
fail(w, http.StatusBadRequest, "参数错误")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if req.StocktakeNo == "" || req.TargetId == "" {
|
|
|
|
|
|
fail(w, http.StatusBadRequest, "stocktakeNo 和 targetId 必填")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
order, err := ctx.EntClient.StocktakeOrder.Query().
|
|
|
|
|
|
Where(stocktakeorder.StocktakeNoEQ(req.StocktakeNo)).Only(ctx0())
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
fail(w, http.StatusNotFound, "盘点单不存在: "+req.StocktakeNo)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if order.Status != "进行中" {
|
2026-09-20 14:52:36 +08:00
|
|
|
|
fail(w, http.StatusConflict, "盘点单状态为「"+order.Status+"」,禁止录入")
|
2026-08-28 15:06:01 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
item, err := ctx.EntClient.StocktakeItem.Query().
|
|
|
|
|
|
Where(
|
|
|
|
|
|
stocktakeitem.StocktakeNoEQ(req.StocktakeNo),
|
|
|
|
|
|
stocktakeitem.TargetIDEQ(req.TargetId),
|
|
|
|
|
|
).First(ctx0())
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
fail(w, http.StatusNotFound, "该目标不在本次盘点快照中: "+req.TargetId)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
qty := req.ScanQty
|
2026-09-20 14:52:36 +08:00
|
|
|
|
// SN 逐件扫码,实盘数恒为 1(问题6);批次由用户填入非负整数。
|
|
|
|
|
|
if item.TargetType == "SN" {
|
|
|
|
|
|
qty = 1
|
|
|
|
|
|
} else if qty < 0 {
|
2026-08-28 15:06:01 +08:00
|
|
|
|
fail(w, http.StatusBadRequest, "scanQty 不能为负数")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
diff := qty - item.BookQty
|
2026-09-20 14:52:36 +08:00
|
|
|
|
nowTs := time.Now().Unix()
|
2026-08-28 15:06:01 +08:00
|
|
|
|
ctx.EntClient.StocktakeItem.UpdateOneID(item.ID).
|
|
|
|
|
|
SetScannedQty(qty).
|
|
|
|
|
|
SetDiffQty(diff).
|
|
|
|
|
|
SetCounted(true).
|
2026-09-20 14:52:36 +08:00
|
|
|
|
SetNillableCountedBy(strPtr(r.Header.Get("X-Username"))).
|
|
|
|
|
|
SetCountedAt(nowTs).
|
|
|
|
|
|
SetUpdatedAt(nowTs).
|
2026-08-28 15:06:01 +08:00
|
|
|
|
ExecX(ctx0())
|
|
|
|
|
|
|
|
|
|
|
|
ok(w, map[string]any{
|
2026-09-02 08:21:11 +08:00
|
|
|
|
"targetId": req.TargetId,
|
2026-09-20 14:52:36 +08:00
|
|
|
|
"targetType": item.TargetType,
|
2026-09-02 08:21:11 +08:00
|
|
|
|
"bookQty": item.BookQty,
|
2026-08-28 15:06:01 +08:00
|
|
|
|
"scannedQty": qty,
|
2026-09-02 08:21:11 +08:00
|
|
|
|
"diffQty": diff,
|
2026-09-17 12:49:07 +08:00
|
|
|
|
// 库位四级贯通(U18):扫码即回显货位,操作员当场核对位置。
|
|
|
|
|
|
"zoneCode": item.ZoneCode,
|
|
|
|
|
|
"shelfNo": item.ShelfNo,
|
|
|
|
|
|
"layerNo": item.LayerNo,
|
|
|
|
|
|
"positionNo": item.PositionNo,
|
2026-08-28 15:06:01 +08:00
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-20 14:52:36 +08:00
|
|
|
|
// finishStocktakeHandler 完成盘点:处理未盘项 → 统计差异 → 按需写回(生成盘点调整单) → 解锁库存。
|
|
|
|
|
|
//
|
|
|
|
|
|
// 未盘项处理(问题5):uncountedMode=keep 按账面保留(差异0) / zero 盘亏为0(实盘0,差异=-账面)。
|
|
|
|
|
|
// 差异写回(问题4/11):只按差异增减「库存量」,锁定量不动,可用量自动重算;
|
|
|
|
|
|
//
|
|
|
|
|
|
// 写回后强制校验 库存量 >= 锁定量(即 可用量>=0,等式 库存量=可用量+锁定量 成立),否则整体回滚。
|
|
|
|
|
|
// 写回走盘点调整单号 adjust_no,调整前/调整量/调整后全部入操作日志,可追溯谁在何时改了什么。
|
|
|
|
|
|
//
|
|
|
|
|
|
// 权限(问题12):写回需 stocktake:writeback(主管以上),仅出结论无需写回权限。
|
2026-08-28 15:06:01 +08:00
|
|
|
|
func finishStocktakeHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
|
|
|
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
|
var req struct {
|
2026-09-20 14:52:36 +08:00
|
|
|
|
StocktakeNo string `json:"stocktakeNo"`
|
|
|
|
|
|
Adjust bool `json:"adjust"` // true=把差异写回库存
|
|
|
|
|
|
UncountedMode string `json:"uncountedMode"` // keep=按账面保留 / zero=盘亏为0
|
2026-08-28 15:06:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
if err := parseJSON(r, &req); err != nil {
|
|
|
|
|
|
fail(w, http.StatusBadRequest, "参数错误")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if req.StocktakeNo == "" {
|
|
|
|
|
|
fail(w, http.StatusBadRequest, "stocktakeNo 必填")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-09-20 14:52:36 +08:00
|
|
|
|
uncountedMode := req.UncountedMode
|
|
|
|
|
|
if uncountedMode != "zero" {
|
|
|
|
|
|
uncountedMode = "keep"
|
|
|
|
|
|
}
|
|
|
|
|
|
username := r.Header.Get("X-Username")
|
|
|
|
|
|
if req.Adjust && !hasPermCode(ctx, r, "stocktake:writeback") {
|
|
|
|
|
|
fail(w, http.StatusForbidden, "差异写回需主管以上权限(stocktake:writeback)")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-28 15:06:01 +08:00
|
|
|
|
order, err := ctx.EntClient.StocktakeOrder.Query().
|
|
|
|
|
|
Where(stocktakeorder.StocktakeNoEQ(req.StocktakeNo)).Only(ctx0())
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
fail(w, http.StatusNotFound, "盘点单不存在")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-09-20 14:52:36 +08:00
|
|
|
|
if order.Status != "进行中" {
|
|
|
|
|
|
fail(w, http.StatusConflict, "盘点单状态为「"+order.Status+"」,不能重复完成")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-08-28 15:06:01 +08:00
|
|
|
|
items, err := ctx.EntClient.StocktakeItem.Query().
|
|
|
|
|
|
Where(stocktakeitem.StocktakeNoEQ(req.StocktakeNo)).All(ctx0())
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
fail(w, http.StatusInternalServerError, err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type diffRow struct {
|
|
|
|
|
|
TargetId string `json:"targetId"`
|
|
|
|
|
|
Type string `json:"type"`
|
|
|
|
|
|
Book int `json:"bookQty"`
|
|
|
|
|
|
Scanned int `json:"scannedQty"`
|
|
|
|
|
|
Diff int `json:"diffQty"`
|
2026-09-17 12:49:07 +08:00
|
|
|
|
// 库位四级贯通(U18):差异行携带货位,盘盈盘亏可精确定位到区域/货架/层/位置。
|
|
|
|
|
|
ZoneCode string `json:"zoneCode"`
|
|
|
|
|
|
ShelfNo string `json:"shelfNo"`
|
|
|
|
|
|
LayerNo string `json:"layerNo"`
|
|
|
|
|
|
PositionNo string `json:"positionNo"`
|
2026-08-28 15:06:01 +08:00
|
|
|
|
}
|
2026-09-20 14:52:36 +08:00
|
|
|
|
// 调整单审计行:调整前/调整量/调整后(问题4)
|
|
|
|
|
|
type adjustRow struct {
|
|
|
|
|
|
TargetId string `json:"targetId"`
|
|
|
|
|
|
Type string `json:"type"`
|
|
|
|
|
|
Before int `json:"before"`
|
|
|
|
|
|
Delta int `json:"delta"`
|
|
|
|
|
|
After int `json:"after"`
|
|
|
|
|
|
Note string `json:"note"`
|
|
|
|
|
|
}
|
2026-09-02 08:21:11 +08:00
|
|
|
|
|
2026-09-20 14:52:36 +08:00
|
|
|
|
tx, err := ctx.EntClient.Tx(ctx0())
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
fail(w, http.StatusInternalServerError, "开启事务失败: "+err.Error())
|
|
|
|
|
|
return
|
2026-09-02 08:21:11 +08:00
|
|
|
|
}
|
|
|
|
|
|
committed := false
|
|
|
|
|
|
defer func() {
|
2026-09-20 14:52:36 +08:00
|
|
|
|
if !committed {
|
2026-09-02 08:21:11 +08:00
|
|
|
|
_ = tx.Rollback()
|
|
|
|
|
|
}
|
|
|
|
|
|
}()
|
|
|
|
|
|
|
2026-09-20 14:52:36 +08:00
|
|
|
|
now := time.Now().Unix()
|
|
|
|
|
|
adjustNo := "ADJ" + time.Now().Format("20060102150405") + uuid.NewString()[:4]
|
|
|
|
|
|
diffs := []diffRow{}
|
|
|
|
|
|
adjusts := []adjustRow{}
|
|
|
|
|
|
counted, uncounted, diffCount := 0, 0, 0
|
|
|
|
|
|
|
2026-08-28 15:06:01 +08:00
|
|
|
|
for _, it := range items {
|
2026-09-20 14:52:36 +08:00
|
|
|
|
book := it.BookQty
|
|
|
|
|
|
scanned := it.ScannedQty
|
|
|
|
|
|
diff := it.DiffQty
|
|
|
|
|
|
isCounted := it.Counted
|
|
|
|
|
|
|
|
|
|
|
|
// 未盘项处理(问题5)
|
|
|
|
|
|
if !isCounted {
|
|
|
|
|
|
if uncountedMode == "zero" {
|
|
|
|
|
|
// 盘亏为0:实盘0,差异=-账面,落库以便追溯
|
|
|
|
|
|
scanned = 0
|
|
|
|
|
|
diff = -book
|
|
|
|
|
|
isCounted = true
|
|
|
|
|
|
if _, e := tx.StocktakeItem.UpdateOneID(it.ID).
|
|
|
|
|
|
SetScannedQty(0).SetDiffQty(diff).SetCounted(true).
|
|
|
|
|
|
SetNillableCountedBy(strPtr(username)).SetCountedAt(now).SetUpdatedAt(now).
|
|
|
|
|
|
Save(ctx0()); e != nil {
|
|
|
|
|
|
fail(w, http.StatusInternalServerError, "处理未盘项失败: "+e.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 按账面保留:不计差异,仍标记未盘
|
|
|
|
|
|
uncounted++
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
2026-08-28 15:06:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
counted++
|
2026-09-20 14:52:36 +08:00
|
|
|
|
if diff == 0 {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
diffCount++
|
|
|
|
|
|
diffs = append(diffs, diffRow{it.TargetID, it.TargetType, book, scanned, diff,
|
|
|
|
|
|
it.ZoneCode, it.ShelfNo, it.LayerNo, it.PositionNo})
|
|
|
|
|
|
|
|
|
|
|
|
if !req.Adjust {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
// 写回:只改库存量,锁定量不动,可用量随之重算(问题11)
|
|
|
|
|
|
if it.TargetType == "BATCH" {
|
|
|
|
|
|
row, e := tx.Inventory.Query().
|
|
|
|
|
|
Where(inventory.ManageModeEQ(1), inventory.BatchNoEQ(it.TargetID)).Only(ctx0())
|
|
|
|
|
|
if e != nil || row == nil {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
after := row.Quantity + diff
|
|
|
|
|
|
if after < 0 {
|
|
|
|
|
|
after = 0
|
|
|
|
|
|
}
|
|
|
|
|
|
if after < row.LockedQty {
|
|
|
|
|
|
fail(w, http.StatusConflict,
|
|
|
|
|
|
"批次 "+it.TargetID+" 盘亏后库存量("+itoa(after)+")将小于锁定量("+itoa(row.LockedQty)+"),违反 库存量=可用量+锁定量,请先解除锁定再写回")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if _, e = tx.Inventory.UpdateOneID(row.ID).
|
|
|
|
|
|
SetQuantity(after).SetUpdatedAt(now).Save(ctx0()); e != nil {
|
|
|
|
|
|
fail(w, http.StatusInternalServerError, "调整批次失败: "+e.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
adjusts = append(adjusts, adjustRow{it.TargetID, "BATCH", row.Quantity, diff, after, "批次余量调整"})
|
|
|
|
|
|
} else if it.TargetType == "SN" && diff < 0 {
|
|
|
|
|
|
// 账面1实盘0 → 报废(移出在库,库存量随之减少)
|
|
|
|
|
|
aff, e := tx.Inventory.Update().
|
|
|
|
|
|
Where(inventory.ManageModeEQ(2), inventory.SnCodeEQ(it.TargetID), inventory.StatusIn("在库", "锁定")).
|
|
|
|
|
|
SetStatus("报废").SetLockedQty(0).SetUpdatedAt(now).
|
|
|
|
|
|
Save(ctx0())
|
|
|
|
|
|
if e != nil {
|
|
|
|
|
|
fail(w, http.StatusInternalServerError, "报废 SN 失败: "+e.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if aff > 0 {
|
|
|
|
|
|
adjusts = append(adjusts, adjustRow{it.TargetID, "SN", 1, -1, 0, "盘亏报废"})
|
2026-08-28 15:06:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-20 14:52:36 +08:00
|
|
|
|
// 解锁库存(问题2/7):完成即释放冻结,业务恢复可出入库/锁定。
|
|
|
|
|
|
if _, e := tx.Inventory.Update().
|
|
|
|
|
|
Where(inventory.StocktakeNoEQ(req.StocktakeNo)).
|
|
|
|
|
|
SetStocktakeNo("").
|
|
|
|
|
|
Save(ctx0()); e != nil {
|
|
|
|
|
|
fail(w, http.StatusInternalServerError, "解锁库存失败: "+e.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
upd := tx.StocktakeOrder.UpdateOneID(order.ID).
|
|
|
|
|
|
SetFinishedAt(now).
|
|
|
|
|
|
SetDiffCount(diffCount).
|
|
|
|
|
|
SetUncountedMode(uncountedMode)
|
2026-09-02 08:21:11 +08:00
|
|
|
|
if req.Adjust {
|
2026-09-20 14:52:36 +08:00
|
|
|
|
upd = upd.SetStatus("已写回").SetAdjusted(true).SetAdjustNo(adjustNo).
|
|
|
|
|
|
SetWrittenAt(now).SetNillableWrittenBy(strPtr(username))
|
|
|
|
|
|
} else {
|
|
|
|
|
|
upd = upd.SetStatus("已完成")
|
|
|
|
|
|
}
|
|
|
|
|
|
if _, e := upd.Save(ctx0()); e != nil {
|
|
|
|
|
|
fail(w, http.StatusInternalServerError, "更新盘点单失败: "+e.Error())
|
|
|
|
|
|
return
|
2026-09-02 08:21:11 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-20 14:52:36 +08:00
|
|
|
|
if err = tx.Commit(); err != nil {
|
|
|
|
|
|
fail(w, http.StatusInternalServerError, "提交事务失败: "+err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
committed = true
|
2026-08-28 15:06:01 +08:00
|
|
|
|
|
2026-09-20 14:52:36 +08:00
|
|
|
|
newStatus := "已完成"
|
|
|
|
|
|
if req.Adjust {
|
|
|
|
|
|
newStatus = "已写回"
|
|
|
|
|
|
}
|
|
|
|
|
|
ctx.EventLog.Write(ctx0(), "stocktake.finish", username, "stocktake", req.StocktakeNo,
|
|
|
|
|
|
"完成盘点 "+req.StocktakeNo+"("+newStatus+")", map[string]any{
|
|
|
|
|
|
"adjusted": req.Adjust, "adjustNo": adjustNo, "counted": counted,
|
|
|
|
|
|
"uncounted": uncounted, "diffCount": diffCount, "uncountedMode": uncountedMode,
|
|
|
|
|
|
"diffs": diffs, "adjustments": adjusts})
|
2026-08-28 15:06:01 +08:00
|
|
|
|
ok(w, map[string]any{
|
2026-09-20 14:52:36 +08:00
|
|
|
|
"stocktakeNo": req.StocktakeNo,
|
|
|
|
|
|
"status": newStatus,
|
|
|
|
|
|
"adjusted": req.Adjust,
|
|
|
|
|
|
"adjustNo": adjustNo,
|
|
|
|
|
|
"counted": counted,
|
|
|
|
|
|
"uncounted": uncounted,
|
|
|
|
|
|
"diffCount": diffCount,
|
|
|
|
|
|
"uncountedMode": uncountedMode,
|
|
|
|
|
|
"diffs": diffs,
|
|
|
|
|
|
"adjustments": adjusts,
|
2026-08-28 15:06:01 +08:00
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-20 14:52:36 +08:00
|
|
|
|
// cancelStocktakeHandler 取消盘点(问题9):解锁库存、状态=已取消、记录原因。
|
|
|
|
|
|
// 仅「进行中」可取消;已写回/已完成为终态不可撤。权限:发起人以上(stocktake:start)。
|
|
|
|
|
|
func cancelStocktakeHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
|
|
|
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
|
var req struct {
|
|
|
|
|
|
StocktakeNo string `json:"stocktakeNo"`
|
|
|
|
|
|
Reason string `json:"reason"`
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := parseJSON(r, &req); err != nil {
|
|
|
|
|
|
fail(w, http.StatusBadRequest, "参数错误")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if req.StocktakeNo == "" {
|
|
|
|
|
|
fail(w, http.StatusBadRequest, "stocktakeNo 必填")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
order, err := ctx.EntClient.StocktakeOrder.Query().
|
|
|
|
|
|
Where(stocktakeorder.StocktakeNoEQ(req.StocktakeNo)).Only(ctx0())
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
fail(w, http.StatusNotFound, "盘点单不存在")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if order.Status != "进行中" {
|
|
|
|
|
|
fail(w, http.StatusConflict, "盘点单状态为「"+order.Status+"」,仅进行中可取消")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
tx, err := ctx.EntClient.Tx(ctx0())
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
fail(w, http.StatusInternalServerError, "开启事务失败: "+err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
committed := false
|
|
|
|
|
|
defer func() {
|
|
|
|
|
|
if !committed {
|
|
|
|
|
|
_ = tx.Rollback()
|
|
|
|
|
|
}
|
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
|
|
// 解锁库存
|
|
|
|
|
|
if _, e := tx.Inventory.Update().
|
|
|
|
|
|
Where(inventory.StocktakeNoEQ(req.StocktakeNo)).
|
|
|
|
|
|
SetStocktakeNo("").
|
|
|
|
|
|
Save(ctx0()); e != nil {
|
|
|
|
|
|
fail(w, http.StatusInternalServerError, "解锁库存失败: "+e.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if _, e := tx.StocktakeOrder.UpdateOneID(order.ID).
|
|
|
|
|
|
SetStatus("已取消").
|
|
|
|
|
|
SetNillableCancelReason(strPtr(req.Reason)).
|
|
|
|
|
|
SetFinishedAt(time.Now().Unix()).
|
|
|
|
|
|
Save(ctx0()); e != nil {
|
|
|
|
|
|
fail(w, http.StatusInternalServerError, "更新盘点单失败: "+e.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if err = tx.Commit(); err != nil {
|
|
|
|
|
|
fail(w, http.StatusInternalServerError, "提交事务失败: "+err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
committed = true
|
|
|
|
|
|
|
|
|
|
|
|
ctx.EventLog.Write(ctx0(), "stocktake.cancel", r.Header.Get("X-Username"), "stocktake", req.StocktakeNo,
|
|
|
|
|
|
"取消盘点 "+req.StocktakeNo+"(已解锁库存)", map[string]any{"reason": req.Reason})
|
|
|
|
|
|
ok(w, map[string]any{"stocktakeNo": req.StocktakeNo, "status": "已取消"})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// queryStocktakeHandler 盘点结果查询:
|
|
|
|
|
|
// - 无 stocktakeNo:返回历史盘点单列表(含状态/差异项数/是否写回/写回时间/取消原因,问题13),支持按状态筛选。
|
|
|
|
|
|
// - 有 stocktakeNo:返回该单明细(分页)。
|
2026-08-28 15:06:01 +08:00
|
|
|
|
func queryStocktakeHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
|
|
|
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
|
no := r.URL.Query().Get("stocktakeNo")
|
|
|
|
|
|
page := atoi(r.URL.Query().Get("page"), 1)
|
|
|
|
|
|
pageSize := atoi(r.URL.Query().Get("pageSize"), 50)
|
|
|
|
|
|
|
|
|
|
|
|
if no == "" {
|
2026-09-20 14:52:36 +08:00
|
|
|
|
q := ctx.EntClient.StocktakeOrder.Query()
|
|
|
|
|
|
if st := strings.TrimSpace(r.URL.Query().Get("status")); st != "" {
|
|
|
|
|
|
q = q.Where(stocktakeorder.StatusEQ(st))
|
|
|
|
|
|
}
|
|
|
|
|
|
orders, _ := q.Order(ent.Desc("created_at"), ent.Desc("id")).Limit(100).All(ctx0())
|
2026-08-28 15:06:01 +08:00
|
|
|
|
ok(w, map[string]any{"list": orders})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
total, err := ctx.EntClient.StocktakeItem.Query().
|
|
|
|
|
|
Where(stocktakeitem.StocktakeNoEQ(no)).Count(ctx0())
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
fail(w, http.StatusInternalServerError, err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
list, err := ctx.EntClient.StocktakeItem.Query().
|
|
|
|
|
|
Where(stocktakeitem.StocktakeNoEQ(no)).
|
2026-09-03 18:13:59 +08:00
|
|
|
|
Order(ent.Desc("created_at"), ent.Desc("id")).
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
order, _ := ctx.EntClient.StocktakeOrder.Query().
|
|
|
|
|
|
Where(stocktakeorder.StocktakeNoEQ(no)).First(ctx0())
|
|
|
|
|
|
|
|
|
|
|
|
ok(w, map[string]any{
|
|
|
|
|
|
"order": order,
|
|
|
|
|
|
"total": total,
|
|
|
|
|
|
"list": list,
|
|
|
|
|
|
"page": page,
|
|
|
|
|
|
"pageSize": pageSize,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-09-20 14:52:36 +08:00
|
|
|
|
|
|
|
|
|
|
// activeStocktakeHandler GET /api/stocktake/active:返回当前进行中的盘点单(问题7/8)。
|
|
|
|
|
|
// 前端进入页面/发起后据此自动带入盘点单号,扫码区与完成按钮自动关联当前进行中单,无需手动粘贴。
|
|
|
|
|
|
func activeStocktakeHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
|
|
|
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
|
order, err := ctx.EntClient.StocktakeOrder.Query().
|
|
|
|
|
|
Where(stocktakeorder.StatusEQ("进行中")).
|
|
|
|
|
|
Order(ent.Desc("created_at"), ent.Desc("id")).First(ctx0())
|
|
|
|
|
|
if err != nil || order == nil {
|
|
|
|
|
|
ok(w, map[string]any{"active": nil})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
counted, _ := ctx.EntClient.StocktakeItem.Query().
|
|
|
|
|
|
Where(stocktakeitem.StocktakeNoEQ(order.StocktakeNo), stocktakeitem.CountedEQ(true)).Count(ctx0())
|
|
|
|
|
|
ok(w, map[string]any{
|
|
|
|
|
|
"active": map[string]any{
|
|
|
|
|
|
"stocktakeNo": order.StocktakeNo,
|
|
|
|
|
|
"totalTargets": order.TotalTargets,
|
|
|
|
|
|
"counted": counted,
|
|
|
|
|
|
"operator": order.Operator,
|
|
|
|
|
|
"createdAt": order.CreatedAt,
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// exportStocktakeHandler GET /api/stocktake/export?stocktakeNo= 导出盘点明细(问题14)。
|
|
|
|
|
|
// 列:盘点单号/类型/批次或SN/物料编码/货位/账面数/实盘数/差异/是否已盘/发起人/发起时间/完成时间。
|
|
|
|
|
|
func exportStocktakeHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
|
|
|
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
|
no := strings.TrimSpace(r.URL.Query().Get("stocktakeNo"))
|
|
|
|
|
|
if no == "" {
|
|
|
|
|
|
fail(w, http.StatusBadRequest, "stocktakeNo 必填")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
order, err := ctx.EntClient.StocktakeOrder.Query().
|
|
|
|
|
|
Where(stocktakeorder.StocktakeNoEQ(no)).First(ctx0())
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
fail(w, http.StatusNotFound, "盘点单不存在: "+no)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
items, err := ctx.EntClient.StocktakeItem.Query().
|
|
|
|
|
|
Where(stocktakeitem.StocktakeNoEQ(no)).
|
|
|
|
|
|
Order(ent.Asc("id")).All(ctx0())
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
fail(w, http.StatusInternalServerError, err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
headers := []string{"盘点单号", "类型", "批次/SN", "物料编码", "货位", "账面数", "实盘数", "差异", "是否已盘", "实盘人", "实盘时间", "发起人", "发起时间"}
|
|
|
|
|
|
matrix := make([][]any, 0, len(items))
|
|
|
|
|
|
for _, it := range items {
|
|
|
|
|
|
typ := "批次"
|
|
|
|
|
|
if it.TargetType == "SN" {
|
|
|
|
|
|
typ = "SN"
|
|
|
|
|
|
}
|
|
|
|
|
|
scanned := "-"
|
|
|
|
|
|
if it.Counted {
|
|
|
|
|
|
scanned = itoa(it.ScannedQty)
|
|
|
|
|
|
}
|
|
|
|
|
|
counted := "否"
|
|
|
|
|
|
if it.Counted {
|
|
|
|
|
|
counted = "是"
|
|
|
|
|
|
}
|
|
|
|
|
|
matrix = append(matrix, []any{
|
|
|
|
|
|
no, typ, it.TargetID, it.MaterialCode, locLabel(it.ZoneCode, it.ShelfNo, it.LayerNo, it.PositionNo),
|
|
|
|
|
|
itoa(it.BookQty), scanned, itoa(it.DiffQty), counted,
|
|
|
|
|
|
it.CountedBy, unixFmt(it.CountedAt), order.Operator, unixFmt(order.CreatedAt),
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
sendExcel(w, xlsxFilename("盘点明细_"+no), headers, matrix)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|