Files
bj_power/bj_power_wms/internal/handler/outbound.go
T
SunYF 1cc93795ce feat(mes): 添加定时同步可生产数量到WMS及BOM项相关标准字段
- 在main.go中添加定时任务每10分钟同步可生产数量到WMS系统
- 为BomItem实体添加relatedStandard字段及相关CRUD方法
- 为InspectionRecord实体添加reportNo、materialCode、materialName等字段
- 更新ent schema确保新字段的验证和默认值设置
- 添加必要的数据库迁移和字段映射逻辑
2026-09-17 12:49:07 +08:00

675 lines
23 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 (
"encoding/json"
"net/http"
"strings"
"time"
"bj_power_wms/ent"
"bj_power_wms/ent/inventory"
"bj_power_wms/ent/ordermaterialledger"
"bj_power_wms/ent/outbounddetail"
"bj_power_wms/ent/outboundorder"
"bj_power_wms/internal/svc"
"github.com/google/uuid"
"github.com/zeromicro/go-zero/core/logx"
)
// deductStockRequest 出库扣减请求
// createOutboundHandler 创建出库单(等价于扣减库存,走同一套约束)
func createOutboundHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return deductStockHandler(ctx)
}
type deductStockRequest struct {
OrderNo string `json:"orderNo"`
MaterialCode string `json:"materialCode"`
BatchNo string `json:"batchNo"`
SnList []string `json:"snList"`
Qty int `json:"qty"`
Operator string `json:"operator"`
Reviewer string `json:"reviewer"`
TargetDock string `json:"targetDock"`
ZoneCode string `json:"zoneCode"`
Remark string `json:"remark"`
}
// deductStockHandler 出库扣减
// body: { orderNo, materialCode, batchNo?, snList?[], qty, operator, targetDock? }
// 强约束:累计出库 ≤ 工单 BOM 需求量;等于时领料完结。
//
// 事务保证:出库单 + 库存行扣减(inventory) + 出库明细 + 工单台账 原子提交,
// 任一环节失败整体回滚,杜绝"出库单生成了但库存没扣"或"台账没更新"的中间态。
// 扣减本身用条件原子更新(AddQuantity(-qty) WHERE quantity>=qty),即使并发也不会出现负库存。
func deductStockHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req deductStockRequest
if err := parseJSON(r, &req); err != nil {
fail(w, http.StatusBadRequest, "参数错误: "+err.Error())
return
}
if req.OrderNo == "" || req.MaterialCode == "" {
fail(w, http.StatusBadRequest, "orderNo 和 materialCode 必填")
return
}
// 1. 工单台账强约束:累计出库 ≤ 总需求
ledger, err := ctx.EntClient.OrderMaterialLedger.Query().
Where(
ordermaterialledger.OrderNoEQ(req.OrderNo),
ordermaterialledger.MaterialCodeEQ(req.MaterialCode),
).
Only(ctx0())
if err != nil {
fail(w, http.StatusBadRequest, "工单物料台账不存在,请先在 MES 同步工单 BOM 后再领料;如无需挂靠工单(退料/样品/报废/发货),请改用「通用出库」: "+req.OrderNo+"/"+req.MaterialCode)
return
}
if ledger.Status == "领料完结" {
fail(w, http.StatusConflict, "工单领料已完结,禁止再出库")
return
}
// 实际扣减数量
actualQty := req.Qty
if req.BatchNo == "" && len(req.SnList) == 0 {
fail(w, http.StatusBadRequest, "batchNo 或 snList 至少提供一个")
return
}
outboundNo := "OB" + uuid.NewString()[:12]
// 开启事务:出库单 + 库存扣减 + 明细 + 台账 原子提交
tx, err := ctx.EntClient.Tx(ctx0())
if err != nil {
fail(w, http.StatusInternalServerError, "开启事务失败: "+err.Error())
return
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback()
}
}()
// 2. 创建出库单
ob, err := tx.OutboundOrder.Create().
SetOutboundNo(outboundNo).
SetOutboundType("workorder").
SetNillableOrderNo(strPtr(req.OrderNo)).
SetMaterialCode(req.MaterialCode).
SetManageMode(1).
SetNillableBatchNo(strPtr(req.BatchNo)).
SetNillableZoneCode(strPtr(req.ZoneCode)).
SetQuantity(0).
SetNillableOperator(strPtr(req.Operator)).
SetNillableReviewer(strPtr(req.Reviewer)).
SetNillableTargetDock(strPtr(req.TargetDock)).
SetNillableRemark(strPtr(req.Remark)).
Save(ctx0())
if err != nil {
fail(w, http.StatusInternalServerError, "创建出库单失败: "+err.Error())
return
}
// 3. 扣减批次(原子:quantity>=qty 才减,避免超扣/负数;locked_qty 同步回收)
if req.BatchNo != "" {
b, err := tx.Inventory.Query().
Where(inventory.ManageModeEQ(1), inventory.BatchNoEQ(req.BatchNo)).
Only(ctx0())
if err != nil {
fail(w, http.StatusBadRequest, "批次不存在: "+req.BatchNo)
return
}
avail := b.Quantity - b.LockedQty
if avail < req.Qty {
fail(w, http.StatusConflict, "批次库存不足: "+req.BatchNo+" 可用 "+itoa(avail))
return
}
// 质量门禁(P0-2):不合格/待退 批次禁止上线领料(客户诉求 L135:不合格不上产线)
if b.QualityStatus == "不合格" || b.QualityStatus == "待退" {
fail(w, http.StatusConflict, "批次 "+req.BatchNo+" 质量状态为「"+b.QualityStatus+"」,禁止领料上线(请先完成不合格处置)")
return
}
aff, err := tx.Inventory.Update().
Where(inventory.ID(b.ID), inventory.QuantityGTE(req.Qty)).
AddQuantity(-req.Qty).
AddLockedQty(-min(req.Qty, b.LockedQty)).
Save(ctx0())
if err != nil || aff == 0 {
fail(w, http.StatusConflict, "批次扣减失败(库存不足或并发冲突): "+req.BatchNo)
return
}
if _, err = tx.OutboundDetail.Create().
SetOutboundNo(outboundNo).
SetBatchNo(req.BatchNo).
SetMaterialCode(req.MaterialCode).
SetQuantity(req.Qty).
SetNillableOrderNo(strPtr(req.OrderNo)).
Save(ctx0()); err != nil {
fail(w, http.StatusInternalServerError, "创建出库明细失败: "+err.Error())
return
}
actualQty = req.Qty
}
// 4. 扣减 SN(原子:仅 在库/锁定 的 SN 可出库 → 置出库)
// 质量门禁(P0-2):不合格/待退 的 SN 禁止领料上线(请走「通用出库」办理退货/报废)
if len(req.SnList) > 0 {
for _, sn := range req.SnList {
aff, err := tx.Inventory.Update().
Where(inventory.ManageModeEQ(2), inventory.SnCodeEQ(sn), inventory.StatusIn("在库", "锁定"),
inventory.QualityStatusNotIn("不合格", "待退")).
SetStatus("出库").
SetNillableZoneCode(strPtr(req.ZoneCode)).
Save(ctx0())
if err != nil {
fail(w, http.StatusInternalServerError, "SN 出库失败: "+sn+" "+err.Error())
return
}
if aff == 0 {
fail(w, http.StatusBadRequest, "SN 不可出库(不存在、非在库/锁定,或质量状态为不合格/待退): "+sn)
return
}
if _, err = tx.OutboundDetail.Create().
SetOutboundNo(outboundNo).
SetSnCode(sn).
SetMaterialCode(req.MaterialCode).
SetQuantity(1).
SetNillableOrderNo(strPtr(req.OrderNo)).
Save(ctx0()); err != nil {
fail(w, http.StatusInternalServerError, "创建出库明细失败: "+err.Error())
return
}
}
actualQty = len(req.SnList)
}
// 5. 更新出库单实际数量
if _, err = tx.OutboundOrder.UpdateOneID(ob.ID).
SetQuantity(actualQty).Save(ctx0()); err != nil {
fail(w, http.StatusInternalServerError, "更新出库单失败: "+err.Error())
return
}
// 6. 更新工单台账:累计出库(在事务内重读台账,避免并发重复累计)
ledgerNow, err := tx.OrderMaterialLedger.Query().
Where(
ordermaterialledger.OrderNoEQ(req.OrderNo),
ordermaterialledger.MaterialCodeEQ(req.MaterialCode),
).Only(ctx0())
if err != nil {
fail(w, http.StatusInternalServerError, "重读台账失败: "+err.Error())
return
}
newOutQty := ledgerNow.OutQty + actualQty
if newOutQty > ledgerNow.TotalQty {
fail(w, http.StatusConflict, "出库数量超过工单 BOM 需求: 累计 "+itoa(ledgerNow.OutQty)+" + "+itoa(actualQty)+" > 需求 "+itoa(ledgerNow.TotalQty))
return
}
status := ledgerNow.Status
if newOutQty >= ledgerNow.TotalQty {
status = "领料完结"
}
// 台账 completed_at = 最后一笔领料完结出库时间(客户诉求 问题记录 L238-240:台账增加完成时间筛选)
ledgerUpd := tx.OrderMaterialLedger.UpdateOneID(ledgerNow.ID).
SetOutQty(newOutQty).
SetStatus(status)
if status == "领料完结" && ledgerNow.Status != "领料完结" {
ledgerUpd.SetCompletedAt(time.Now().Unix())
}
if _, err = ledgerUpd.Save(ctx0()); err != nil {
fail(w, http.StatusInternalServerError, "更新台账失败: "+err.Error())
return
}
// 7. 提交事务
if err = tx.Commit(); err != nil {
fail(w, http.StatusInternalServerError, "提交事务失败: "+err.Error())
return
}
committed = true
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)
ok(w, map[string]any{
"outboundNo": outboundNo,
"qty": actualQty,
"orderNo": req.OrderNo,
"ledgerOut": newOutQty,
"ledgerTotal": ledgerNow.TotalQty,
"status": status,
})
}
}
// queryOutboundHandler 出库单查询
// 客户诉求 L96-108:按 归属单号 / 名称图号 / 日期范围 / 目标工位 查询,多条件取交集;
// 修复此前"前端传了 outboundType/日期但后端忽略"的缺陷。
func queryOutboundHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
page := atoi(r.URL.Query().Get("page"), 1)
pageSize := atoi(r.URL.Query().Get("pageSize"), 20)
orderNo := r.URL.Query().Get("orderNo")
materialCode := r.URL.Query().Get("materialCode")
outboundNo := r.URL.Query().Get("outboundNo")
boxNo := r.URL.Query().Get("boxNo")
outboundType := r.URL.Query().Get("outboundType")
outboundCategory := r.URL.Query().Get("outboundCategory")
ownershipNo := r.URL.Query().Get("ownershipNo")
materialName := r.URL.Query().Get("materialName")
targetStation := r.URL.Query().Get("targetStation")
q := ctx.EntClient.OutboundOrder.Query()
if orderNo != "" {
q = q.Where(outboundorder.OrderNoEqualFold(orderNo))
}
if materialCode != "" {
q = q.Where(outboundorder.MaterialCodeEqualFold(materialCode))
}
if outboundNo != "" {
q = q.Where(outboundorder.OutboundNoEqualFold(outboundNo))
}
if boxNo != "" {
q = q.Where(outboundorder.BoxNoContainsFold(boxNo))
}
if outboundType != "" {
q = q.Where(outboundorder.OutboundTypeEQ(outboundType))
}
if outboundCategory != "" {
q = q.Where(outboundorder.OutboundCategoryEQ(outboundCategory))
}
if ownershipNo != "" {
q = q.Where(outboundorder.OwnershipNoContainsFold(ownershipNo))
}
if materialName != "" {
q = q.Where(outboundorder.MaterialNameContainsFold(materialName))
}
if targetStation != "" {
q = q.Where(outboundorder.TargetStationEQ(targetStation))
}
if v := r.URL.Query().Get("startDate"); v != "" {
if t, err := time.ParseInLocation("2006-01-02", v, time.Local); err == nil {
q = q.Where(outboundorder.CreatedAtGTE(t.Unix()))
}
}
if v := r.URL.Query().Get("endDate"); v != "" {
if t, err := time.ParseInLocation("2006-01-02", v, time.Local); err == nil {
q = q.Where(outboundorder.CreatedAtLTE(t.Add(24 * time.Hour).Unix()))
}
}
total, err := q.Count(ctx0())
if err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
list, err := q.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
}
// 附带明细
type row struct {
*ent.OutboundOrder
Details []*ent.OutboundDetail `json:"details"`
}
rows := make([]row, 0, len(list))
for _, ob := range list {
details, _ := ctx.EntClient.OutboundDetail.Query().
Where(outbounddetail.OutboundNoEQ(ob.OutboundNo)).
All(ctx0())
rows = append(rows, row{ob, details})
}
ok(w, map[string]any{
"total": total,
"list": rows,
"page": page,
"pageSize": pageSize,
})
}
}
// generalOutboundRequest 通用出库请求(不依赖 MES 工单)
// 客户诉求 问题记录 L96-108:通用出库先点选类别(退料/样品/报废/发货/其他手输),
// 并记录归属(工单号/科技项目号/其他)与目标工位。
type generalOutboundRequest struct {
MaterialCode string `json:"materialCode"`
BatchNo string `json:"batchNo"`
SnList []string `json:"snList"`
Qty int `json:"qty"`
Operator string `json:"operator"`
ZoneCode string `json:"zoneCode"`
BoxNo string `json:"boxNo"`
ContractNo string `json:"contractNo"`
Remark string `json:"remark"`
OutboundCategory string `json:"outboundCategory"` // return(退料)/supplier_return(退货退供应商)/sample(样品)/scrap(报废)/deliver(发货)/other(其他)
OwnershipType string `json:"ownershipType"` // workorder/project/other
OwnershipNo string `json:"ownershipNo"`
TargetStation string `json:"targetStation"`
}
// generalOutboundHandler 通用出库(手动出库,不依赖工单)
// 适用:退料 / 样品 / 报废 / 发货 等不挂靠 MES 工单的场景。
// 支持结构件批次与电气件 SN;填箱号时一并把装箱信息(箱号/SN清单/合同号)
// 落到统一的出库主表,不再另建装箱表。
// 事务保证:出库单 + 库存扣减 + 出库明细 原子提交;库存用条件原子更新避免负库存。
func generalOutboundHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req generalOutboundRequest
if err := parseJSON(r, &req); err != nil {
fail(w, http.StatusBadRequest, "参数错误: "+err.Error())
return
}
if req.MaterialCode == "" {
fail(w, http.StatusBadRequest, "materialCode 必填")
return
}
if req.BatchNo == "" && len(req.SnList) == 0 {
fail(w, http.StatusBadRequest, "batchNo 或 snList 至少提供一个")
return
}
// 客户诉求 L96-108:出库类别必选;选「其他」时备注必填(承载手输原因)
if req.OutboundCategory == "" {
fail(w, http.StatusBadRequest, "出库类别必选(退料/样品/报废/发货/其他)")
return
}
if req.OutboundCategory == "other" && strings.TrimSpace(req.Remark) == "" {
fail(w, http.StatusBadRequest, "出库类别为「其他」时必须填写原因")
return
}
manageMode := 1
if len(req.SnList) > 0 {
manageMode = 2
}
// SN 出库数量以列表长度计
actualQty := req.Qty
if manageMode == 2 {
actualQty = len(req.SnList)
}
if actualQty <= 0 {
fail(w, http.StatusBadRequest, "数量必须大于 0")
return
}
outboundNo := "OB" + uuid.NewString()[:12]
tx, err := ctx.EntClient.Tx(ctx0())
if err != nil {
fail(w, http.StatusInternalServerError, "开启事务失败: "+err.Error())
return
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback()
}
}()
ob, err := tx.OutboundOrder.Create().
SetOutboundNo(outboundNo).
SetOutboundType("general").
SetNillableOutboundCategory(strPtr(req.OutboundCategory)).
SetNillableOwnershipType(strPtr(req.OwnershipType)).
SetNillableOwnershipNo(strPtr(req.OwnershipNo)).
SetNillableTargetStation(strPtr(req.TargetStation)).
SetMaterialCode(req.MaterialCode).
SetManageMode(manageMode).
SetNillableBatchNo(strPtr(req.BatchNo)).
SetNillableZoneCode(strPtr(req.ZoneCode)).
SetQuantity(0).
SetNillableOperator(strPtr(req.Operator)).
SetNillableRemark(strPtr(req.Remark)).
SetNillableBoxNo(strPtr(req.BoxNo)).
SetNillableContractNo(strPtr(req.ContractNo)).
Save(ctx0())
if err != nil {
fail(w, http.StatusInternalServerError, "创建出库单失败: "+err.Error())
return
}
// 批次扣减(结构件)
if req.BatchNo != "" {
b, err := tx.Inventory.Query().
Where(inventory.ManageModeEQ(1), inventory.BatchNoEQ(req.BatchNo)).
Only(ctx0())
if err != nil {
fail(w, http.StatusBadRequest, "批次不存在: "+req.BatchNo)
return
}
// 质量门禁(U5,客户口径):发货/样品/其他类出库禁止不合格/待退品流向下游;退货/报废/退料类豁免(本身即处置不合格品)。
if outboundBlocksUnqualified(req.OutboundCategory) && (b.QualityStatus == "不合格" || b.QualityStatus == "待退") {
fail(w, http.StatusConflict, "批次 "+req.BatchNo+" 质量状态为「"+b.QualityStatus+"」,"+outboundCategoryLabel(req.OutboundCategory)+"出库禁止发出(请走退货/报废处置)")
return
}
avail := b.Quantity - b.LockedQty
if avail < actualQty {
fail(w, http.StatusConflict, "批次库存不足: "+req.BatchNo+" 可用 "+itoa(avail))
return
}
aff, err := tx.Inventory.Update().
Where(inventory.ID(b.ID), inventory.QuantityGTE(actualQty)).
AddQuantity(-actualQty).
AddLockedQty(-min(actualQty, b.LockedQty)).
Save(ctx0())
if err != nil || aff == 0 {
fail(w, http.StatusConflict, "批次扣减失败(库存不足或并发冲突): "+req.BatchNo)
return
}
if _, err = tx.OutboundDetail.Create().
SetOutboundNo(outboundNo).
SetBatchNo(req.BatchNo).
SetMaterialCode(req.MaterialCode).
SetQuantity(actualQty).
Save(ctx0()); err != nil {
fail(w, http.StatusInternalServerError, "创建出库明细失败: "+err.Error())
return
}
}
// SN 扣减(电气件):仅在库/锁定的 SN 可出库
if len(req.SnList) > 0 {
// 质量门禁(U5,客户口径):发货/样品/其他类出库禁止不合格/待退 SN 流出;退货/报废/退料类豁免。
if outboundBlocksUnqualified(req.OutboundCategory) {
bad, _ := tx.Inventory.Query().
Where(inventory.ManageModeEQ(2), inventory.SnCodeIn(req.SnList...), inventory.QualityStatusIn("不合格", "待退")).
Count(ctx0())
if bad > 0 {
fail(w, http.StatusConflict, "所选 SN 含不合格/待退品,"+outboundCategoryLabel(req.OutboundCategory)+"出库禁止发出(请走退货/报废处置)")
return
}
}
for _, sn := range req.SnList {
aff, err := tx.Inventory.Update().
Where(inventory.ManageModeEQ(2), inventory.SnCodeEQ(sn), inventory.StatusIn("在库", "锁定")).
SetStatus("出库").
SetNillableZoneCode(strPtr(req.ZoneCode)).
Save(ctx0())
if err != nil {
fail(w, http.StatusInternalServerError, "SN 出库失败: "+sn+" "+err.Error())
return
}
if aff == 0 {
fail(w, http.StatusBadRequest, "SN 不可出库(不存在或非在库/锁定): "+sn)
return
}
if _, err = tx.OutboundDetail.Create().
SetOutboundNo(outboundNo).
SetSnCode(sn).
SetMaterialCode(req.MaterialCode).
SetQuantity(1).
Save(ctx0()); err != nil {
fail(w, http.StatusInternalServerError, "创建出库明细失败: "+err.Error())
return
}
}
}
// 回写实际数量 + 装箱 SN 清单(JSON)
upd := tx.OutboundOrder.UpdateOneID(ob.ID).SetQuantity(actualQty)
if len(req.SnList) > 0 && req.BoxNo != "" {
upd.SetBoxSnList(snListJSON(req.SnList))
}
if _, err = upd.Save(ctx0()); err != nil {
fail(w, http.StatusInternalServerError, "更新出库单失败: "+err.Error())
return
}
if err = tx.Commit(); err != nil {
fail(w, http.StatusInternalServerError, "提交事务失败: "+err.Error())
return
}
committed = true
ctx.EventLog.Write(ctx0(), "outbound.general", r.Header.Get("X-Username"), "outbound", outboundNo,
"通用出库 "+req.MaterialCode+" ×"+itoa(actualQty),
map[string]any{"qty": actualQty, "boxNo": req.BoxNo, "snCount": len(req.SnList)})
logx.Infof("general outbound: material=%s qty=%d box=%s", req.MaterialCode, actualQty, req.BoxNo)
ok(w, map[string]any{
"outboundNo": outboundNo,
"qty": actualQty,
"boxNo": req.BoxNo,
"type": "general",
})
}
}
// snListJSON 把 SN 列表序列化为 JSON 数组字符串
func snListJSON(sn []string) string {
b, err := json.Marshal(sn)
if err != nil {
return "[]"
}
return string(b)
}
// exportOutboundHandler 统一主表全部出库记录导出
// 不依赖前端分页,一次返回全部(可按类型/工单/物料/箱号过滤),便于整表导出。
func exportOutboundHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
outboundType := r.URL.Query().Get("outboundType")
orderNo := r.URL.Query().Get("orderNo")
materialCode := r.URL.Query().Get("materialCode")
boxNo := r.URL.Query().Get("boxNo")
startDate, endDate, rerr := normalizeExportRange(r.URL.Query().Get("startDate"), r.URL.Query().Get("endDate"))
if rerr != nil {
fail(w, http.StatusBadRequest, rerr.Error())
return
}
startUnix, endUnix := exportRangeUnix(startDate, endDate)
q := ctx.EntClient.OutboundOrder.Query()
q = q.Where(outboundorder.CreatedAtGTE(startUnix), outboundorder.CreatedAtLTE(endUnix))
if outboundType != "" {
q = q.Where(outboundorder.OutboundTypeEQ(outboundType))
}
if orderNo != "" {
q = q.Where(outboundorder.OrderNoEqualFold(orderNo))
}
if materialCode != "" {
q = q.Where(outboundorder.MaterialCodeEqualFold(materialCode))
}
if boxNo != "" {
q = q.Where(outboundorder.BoxNoContainsFold(boxNo))
}
list, err := q.Order(ent.Desc("created_at"), ent.Desc("id")).All(ctx0())
if err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
type exportRow struct {
*ent.OutboundOrder
SnCodes string `json:"snCodes"`
Details []*ent.OutboundDetail `json:"details"`
}
rows := make([]exportRow, 0, len(list))
for _, ob := range list {
details, _ := ctx.EntClient.OutboundDetail.Query().
Where(outbounddetail.OutboundNoEQ(ob.OutboundNo)).
All(ctx0())
er := exportRow{OutboundOrder: ob, SnCodes: "", Details: details}
sns := make([]string, 0, len(details))
for _, d := range details {
if d.SnCode != "" {
sns = append(sns, d.SnCode)
}
}
if len(sns) > 0 {
er.SnCodes = strings.Join(sns, ",")
}
rows = append(rows, er)
}
// xlsx 导出
if r.URL.Query().Get("format") == "xlsx" {
headers := []string{"出库单号", "类型", "类别", "工单号", "归属单号", "图号", "物料名称", "数量", "目标工位", "装箱号", "合同号", "SN清单", "创建时间"}
matrix := make([][]any, 0, len(rows))
for _, row := range rows {
ob := row.OutboundOrder
tn := "通用出库"
if ob.OutboundType == "workorder" {
tn = "备料出库"
}
matrix = append(matrix, []any{
ob.OutboundNo, tn, outboundCategoryLabel(ob.OutboundCategory), ob.OrderNo,
ob.OwnershipNo, ob.MaterialCode, ob.MaterialName,
ob.Quantity, ob.TargetStation, ob.BoxNo, ob.ContractNo, row.SnCodes,
unixFmt(ob.CreatedAt),
})
}
sendExcel(w, xlsxFilename("出库管理"), headers, matrix)
return
}
ok(w, map[string]any{"total": len(rows), "list": rows})
}
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
// outboundCategoryLabel 出库类别显示中文(客户诉求 L96-108:退料/样品/报废/发货/其他)
func outboundCategoryLabel(v string) string {
switch v {
case "return":
return "退料"
case "sample":
return "样品"
case "scrap":
return "报废"
case "deliver":
return "发货"
case "supplier_return":
return "退货"
case "other":
return "其他"
default:
return ""
}
}
// outboundBlocksUnqualified 出库质量门禁判定(U5,客户口径):
// 发货(deliver)/样品(sample)/其他(other) 及任何未知类别 → 拦截不合格/待退品(默认拦截,避免漏洞);
// 退货(supplier_return,退回供应商)/报废(scrap)/退料(return,工位退料) → 豁免(本身即在处置不合格品)。
func outboundBlocksUnqualified(category string) bool {
switch category {
case "supplier_return", "scrap", "return":
return false
default:
return true
}
}