Files
bj_power/bj_power_wms/internal/handler/stocktake.go
T
SunYF f35e9b5b75 docs(deployment): 更新部署手册和业务架构说明
- 更新MES和WMS服务端口配置说明
- 详细说明产线工位设备配置(扫码枪+拧紧枪)
- 明确AGV配送和接驳台的模拟模式与真实对接方案
- 更新WMS中AGV配送默认模拟模式说明
- 完善厂内物流(AGV/接驳台)的业务架构约束
- 修订临时文档为详细的项目问题和解决方案规划
2026-09-20 14:52:36 +08:00

685 lines
24 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handler
import (
"net/http"
"strings"
"time"
"bj_power_wms/ent"
"bj_power_wms/ent/inventory"
"bj_power_wms/ent/stocktakeitem"
"bj_power_wms/ent/stocktakeorder"
"bj_power_wms/ent/user"
"bj_power_wms/internal/svc"
"github.com/google/uuid"
)
// ============================ 盘点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)。
// 快照统一来自 inventory 一张表;结构件以批次为单位(bookQty=批次余量),电气件以 SN 为单位(bookQty=1)。
// 目标数量=本次覆盖的批次/SN 总数(问题15),发起时自动算出。
func startStocktakeHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req struct {
Operator string `json:"operator"`
Zones []string `json:"zones"` // 可选:限定盘点区域,空=全部
MaterialCodes []string `json:"materialCodes"` // 可选:限定盘点物料,空=全部
ManageMode int `json:"manageMode"` // 可选:1结构件/2电气件,0=全部
QualityStatus string `json:"qualityStatus"` // 可选:限定质量状态,空=全部
}
if err := parseJSON(r, &req); err != nil {
fail(w, http.StatusBadRequest, "参数错误: "+err.Error())
return
}
no := "ST" + time.Now().Format("20060102150405") + uuid.NewString()[:4]
q := ctx.EntClient.Inventory.Query().Where(inventory.StatusIn("在库", "锁定"))
if len(req.Zones) > 0 {
q = q.Where(inventory.ZoneCodeIn(req.Zones...))
}
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))
}
rows, err := q.All(ctx0())
if err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
if len(rows) == 0 {
fail(w, http.StatusBadRequest, "盘点范围内没有在库库存,无需发起")
return
}
// 冲突守卫:范围内若有库存正被别的进行中盘点单锁定,禁止重复发起(避免两单争抢同一批库存)。
for _, inv := range rows {
if inv.StocktakeNo != "" {
fail(w, http.StatusConflict, "范围内库存正被盘点单 "+inv.StocktakeNo+" 锁定,请先完成或取消该盘点单")
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()
}
}()
count := 0
ids := make([]int, 0, len(rows))
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 == "" {
continue
}
// 库位四级贯通(U18):快照冻结区域+货架/层/位置,盘点差异可精确定位到货位。
if _, e := tx.StocktakeItem.Create().
SetStocktakeNo(no).
SetTargetType(tt).
SetTargetID(tid).
SetNillableMaterialCode(strPtr(inv.MaterialCode)).
SetNillableZoneCode(strPtr(inv.ZoneCode)).
SetNillableShelfNo(strPtr(inv.ShelfNo)).
SetNillableLayerNo(strPtr(inv.LayerNo)).
SetNillablePositionNo(strPtr(inv.PositionNo)).
SetBookQty(book).
Save(ctx0()); e != nil {
fail(w, http.StatusInternalServerError, "创建盘点明细失败: "+e.Error())
return
}
ids = append(ids, inv.ID)
count++
}
// 锁库存(问题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
}
}
rec, err := tx.StocktakeOrder.Create().
SetStocktakeNo(no).
SetStatus("进行中").
SetNillableOperator(strPtr(req.Operator)).
SetTotalTargets(count).
Save(ctx0())
if err != nil {
fail(w, http.StatusInternalServerError, "创建盘点单失败: "+err.Error())
return
}
if err = tx.Commit(); err != nil {
fail(w, http.StatusInternalServerError, "提交事务失败: "+err.Error())
return
}
committed = true
ctx.EventLog.Write(ctx0(), "stocktake.start", r.Header.Get("X-Username"), "stocktake", rec.StocktakeNo,
"发起盘点 "+rec.StocktakeNo+"(已锁定库存)", map[string]any{"totalTargets": count, "zones": req.Zones,
"materialCodes": req.MaterialCodes, "manageMode": req.ManageMode})
ok(w, map[string]any{"stocktakeNo": rec.StocktakeNo, "totalTargets": count, "status": rec.Status})
}
}
// recordStocktakeHandler 扫码盘点记录:查快照并记录实盘数与差异(问题6)。
// 电气件(SN)逐件扫码,实盘数强制=1(不需用户填);结构件(BATCH)按用户填入的数量。
func recordStocktakeHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req struct {
StocktakeNo string `json:"stocktakeNo"`
TargetType string `json:"targetType"` // BATCH/SN,可空则按快照判断
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 != "进行中" {
fail(w, http.StatusConflict, "盘点单状态为「"+order.Status+"」,禁止录入")
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
// SN 逐件扫码,实盘数恒为 1(问题6);批次由用户填入非负整数。
if item.TargetType == "SN" {
qty = 1
} else if qty < 0 {
fail(w, http.StatusBadRequest, "scanQty 不能为负数")
return
}
diff := qty - item.BookQty
nowTs := time.Now().Unix()
ctx.EntClient.StocktakeItem.UpdateOneID(item.ID).
SetScannedQty(qty).
SetDiffQty(diff).
SetCounted(true).
SetNillableCountedBy(strPtr(r.Header.Get("X-Username"))).
SetCountedAt(nowTs).
SetUpdatedAt(nowTs).
ExecX(ctx0())
ok(w, map[string]any{
"targetId": req.TargetId,
"targetType": item.TargetType,
"bookQty": item.BookQty,
"scannedQty": qty,
"diffQty": diff,
// 库位四级贯通(U18):扫码即回显货位,操作员当场核对位置。
"zoneCode": item.ZoneCode,
"shelfNo": item.ShelfNo,
"layerNo": item.LayerNo,
"positionNo": item.PositionNo,
})
}
}
// finishStocktakeHandler 完成盘点:处理未盘项 → 统计差异 → 按需写回(生成盘点调整单) → 解锁库存。
//
// 未盘项处理(问题5):uncountedMode=keep 按账面保留(差异0) / zero 盘亏为0(实盘0,差异=-账面)。
// 差异写回(问题4/11):只按差异增减「库存量」,锁定量不动,可用量自动重算;
//
// 写回后强制校验 库存量 >= 锁定量(即 可用量>=0,等式 库存量=可用量+锁定量 成立),否则整体回滚。
// 写回走盘点调整单号 adjust_no,调整前/调整量/调整后全部入操作日志,可追溯谁在何时改了什么。
//
// 权限(问题12):写回需 stocktake:writeback(主管以上),仅出结论无需写回权限。
func finishStocktakeHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req struct {
StocktakeNo string `json:"stocktakeNo"`
Adjust bool `json:"adjust"` // true=把差异写回库存
UncountedMode string `json:"uncountedMode"` // keep=按账面保留 / zero=盘亏为0
}
if err := parseJSON(r, &req); err != nil {
fail(w, http.StatusBadRequest, "参数错误")
return
}
if req.StocktakeNo == "" {
fail(w, http.StatusBadRequest, "stocktakeNo 必填")
return
}
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
}
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
}
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"`
// 库位四级贯通(U18):差异行携带货位,盘盈盘亏可精确定位到区域/货架/层/位置。
ZoneCode string `json:"zoneCode"`
ShelfNo string `json:"shelfNo"`
LayerNo string `json:"layerNo"`
PositionNo string `json:"positionNo"`
}
// 调整单审计行:调整前/调整量/调整后(问题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"`
}
tx, err := ctx.EntClient.Tx(ctx0())
if err != nil {
fail(w, http.StatusInternalServerError, "开启事务失败: "+err.Error())
return
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback()
}
}()
now := time.Now().Unix()
adjustNo := "ADJ" + time.Now().Format("20060102150405") + uuid.NewString()[:4]
diffs := []diffRow{}
adjusts := []adjustRow{}
counted, uncounted, diffCount := 0, 0, 0
for _, it := range items {
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
}
}
counted++
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, "盘亏报废"})
}
}
}
// 解锁库存(问题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)
if req.Adjust {
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
}
if err = tx.Commit(); err != nil {
fail(w, http.StatusInternalServerError, "提交事务失败: "+err.Error())
return
}
committed = true
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})
ok(w, map[string]any{
"stocktakeNo": req.StocktakeNo,
"status": newStatus,
"adjusted": req.Adjust,
"adjustNo": adjustNo,
"counted": counted,
"uncounted": uncounted,
"diffCount": diffCount,
"uncountedMode": uncountedMode,
"diffs": diffs,
"adjustments": adjusts,
})
}
}
// 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:返回该单明细(分页)。
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 == "" {
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())
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)).
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
}
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,
})
}
}
// 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)
}
}