Files
bj_power/bj_power_wms/internal/handler/inbound.go
T
SunYF 12ca493c61 feat: 新增检测单关联、基础数据优化与入库单升级
1. 新增出库单检测单号字段并添加索引支持溯源
2. 优化基础数据页面物料类型展示与选择逻辑
3. 升级入库单导入功能,支持自动生成单号并返回导入结果
4. 优化接驳台查询排序方式
5. 修复字符串拼接空格问题
2026-09-21 15:28:14 +08:00

633 lines
22 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 (
"fmt"
"net/http"
"strconv"
"strings"
"time"
"bj_power_wms/ent"
"bj_power_wms/ent/inbounddetail"
"bj_power_wms/ent/inboundorder"
"bj_power_wms/ent/inspectionrecord"
"bj_power_wms/ent/inventory"
"bj_power_wms/ent/material"
"bj_power_wms/ent/outboundorder"
"bj_power_wms/internal/svc"
"github.com/zeromicro/go-zero/core/logx"
)
// 物料品类:4=其他(辅料/工装/试验设备),与 schema/material.go 的 item_type 保持一致
const invItemOther = 4
// nextDailyNo 生成日期+流水号,格式:IB/OB + YYYYMMDD + -NNN(如 IB20260921-001)。
// 同一天内流水号递增,跨天重置为 001。并发冲突时自动重试最多 5 次。
// 注意:历史旧单号(IB+UUID 片段)不受影响,仅新生成的用新格式。
func nextDailyNo(ctx *svc.ServiceContext, prefix string) (string, error) {
const maxRetry = 5
var lastErr error
for attempt := 0; attempt < maxRetry; attempt++ {
no, err := genDailyNoOnce(ctx, prefix)
if err != nil {
lastErr = err
time.Sleep(20 * time.Millisecond * time.Duration(attempt+1))
continue
}
// 校验唯一性——如果刚好撞号,重试
if prefix == "IB" {
if _, e := ctx.EntClient.InboundOrder.Query().Where(inboundorder.InboundNoEQ(no)).Only(ctx0()); e == nil {
lastErr = fmt.Errorf("单号 %s 已存在,重试中", no)
time.Sleep(20 * time.Millisecond * time.Duration(attempt+1))
continue
}
} else if prefix == "OB" {
if _, e := ctx.EntClient.OutboundOrder.Query().Where(outboundorder.OutboundNoEQ(no)).Only(ctx0()); e == nil {
lastErr = fmt.Errorf("单号 %s 已存在,重试中", no)
time.Sleep(20 * time.Millisecond * time.Duration(attempt+1))
continue
}
}
return no, nil
}
if lastErr != nil {
return "", fmt.Errorf("生成单号失败(重试 %d 次仍冲突): %w", maxRetry, lastErr)
}
return "", fmt.Errorf("生成单号失败(未知错误)")
}
func genDailyNoOnce(ctx *svc.ServiceContext, prefix string) (string, error) {
now := time.Now()
datePart := now.Format("20060102")
prefixDate := prefix + datePart + "-"
var maxSeq int
var rows []string
var err error
if prefix == "IB" {
rows, err = ctx.EntClient.InboundOrder.Query().
Where(inboundorder.InboundNoHasPrefix(prefixDate)).
Select(inboundorder.FieldInboundNo).
Strings(ctx0())
} else if prefix == "OB" {
rows, err = ctx.EntClient.OutboundOrder.Query().
Where(outboundorder.OutboundNoHasPrefix(prefixDate)).
Select(outboundorder.FieldOutboundNo).
Strings(ctx0())
} else {
return "", fmt.Errorf("未知前缀: %s", prefix)
}
if err != nil {
return "", err
}
for _, n := range rows {
suffix := strings.TrimPrefix(n, prefixDate)
if s, e := strconv.Atoi(suffix); e == nil && s > maxSeq {
maxSeq = s
}
}
seq := maxSeq + 1
return fmt.Sprintf("%s%03d", prefixDate, seq), nil
}
// validateUniqueNo 校验单据号唯一(InboundNo 或 OutboundNo 不能重复)。
func validateUniqueNo(ctx *svc.ServiceContext, kind, no string) error {
if no == "" {
return nil // 空值表示走自动生成
}
switch kind {
case "IB":
if _, e := ctx.EntClient.InboundOrder.Query().Where(inboundorder.InboundNoEQ(no)).Only(ctx0()); e == nil {
return fmt.Errorf("入库单号 %s 已存在", no)
}
case "OB":
if _, e := ctx.EntClient.OutboundOrder.Query().Where(outboundorder.OutboundNoEQ(no)).Only(ctx0()); e == nil {
return fmt.Errorf("出库单号 %s 已存在", no)
}
}
return nil
}
// validateInboundQuality 入库质量门禁:该物料/批次/SN 若已被判定"不合格",拒绝入库。
// 依据客户诉求《问题记录》L135:「不合格不入库、不上产线,仅合格品入库存数量」。
func validateInboundQuality(ctx *svc.ServiceContext, materialCode, batchNo string, snList []string) error {
if batchNo != "" {
n, err := ctx.EntClient.InspectionRecord.Query().
Where(
inspectionrecord.MaterialCodeEQ(materialCode),
inspectionrecord.TargetIDEQ(batchNo),
inspectionrecord.StatusEQ("不合格"),
).Count(ctx0())
if err == nil && n > 0 {
return fmt.Errorf("批次 %s 已被判定不合格,禁止入库(请走退货/报废处置)", batchNo)
}
}
if len(snList) > 0 {
recs, err := ctx.EntClient.InspectionRecord.Query().
Where(
inspectionrecord.MaterialCodeEQ(materialCode),
inspectionrecord.TargetIDIn(snList...),
inspectionrecord.StatusEQ("不合格"),
).Limit(1).All(ctx0())
if err == nil && len(recs) > 0 {
return fmt.Errorf("SN %s 已被判定不合格,禁止入库", recs[0].TargetID)
}
}
return nil
}
// createInboundHandler 入库
// 结构件: { inboundType, materialCode, batchNo, quantity, zoneCode, operator }
// 电气件: { inboundType, materialCode, snList: [sn...], zoneCode, operator }
//
// 事务保证:入库单 + 库存行(inventory) + 入库明细 三者要么全成功,要么全回滚,杜绝半成品状态。
func createInboundHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req struct {
InboundNo string `json:"inboundNo"` // 可选:留空自动生成;填了必须唯一(如 IB20260921-001
InboundType string `json:"inboundType"` // purchase/semi/finished/return/other
MaterialCode string `json:"materialCode"`
BatchNo string `json:"batchNo"`
Quantity int `json:"quantity"`
SnList []string `json:"snList"`
ZoneCode string `json:"zoneCode"`
ShelfNo string `json:"shelfNo"`
LayerNo string `json:"layerNo"`
PositionNo string `json:"positionNo"`
Operator string `json:"operator"`
ProductionDate string `json:"productionDate"`
Supplier string `json:"supplier"`
Remark string `json:"remark"`
InspectionNo string `json:"inspectionNo"`
OwnershipType string `json:"ownershipType"` // workorder/project/other/none
OwnershipNo string `json:"ownershipNo"`
}
if err := parseJSON(r, &req); err != nil {
fail(w, http.StatusBadRequest, "参数错误: "+err.Error())
return
}
if req.MaterialCode == "" {
fail(w, http.StatusBadRequest, "图号必填")
return
}
if req.ZoneCode == "" {
fail(w, http.StatusBadRequest, "区域必填,请先选择入库区域")
return
}
if req.InspectionNo == "" {
fail(w, http.StatusBadRequest, "检测单号必填,请填写入库检报告单号")
return
}
// 物料档案
m, err := ctx.EntClient.Material.Query().
Where(material.CodeEQ(req.MaterialCode)).
Only(ctx0())
if err != nil {
fail(w, http.StatusBadRequest, "物料不存在: "+req.MaterialCode)
return
}
// 其他类物料(item_type=4:辅料/工装/试验设备):规格强制必填(客户诉求 问题记录 L50)
if m.ItemType == invItemOther {
if strings.TrimSpace(m.Spec) == "" {
fail(w, http.StatusBadRequest, "其他类物料必须填写规格型号,请先在物料档案补全")
return
}
req.InboundType = "other"
}
// 质量门禁(P0-2):已被判定不合格的批次/SN 禁止再入库——
// 客户诉求 L135「不合格不入库、不上产线,仅合格品计入库存数量」。
if qerr := validateInboundQuality(ctx, req.MaterialCode, req.BatchNo, req.SnList); qerr != nil {
fail(w, http.StatusConflict, qerr.Error())
return
}
inboundNo := strings.TrimSpace(req.InboundNo)
if inboundNo != "" {
if err := validateUniqueNo(ctx, "IB", inboundNo); err != nil {
fail(w, http.StatusBadRequest, err.Error())
return
}
} else {
no, err := nextDailyNo(ctx, "IB")
if err != nil {
fail(w, http.StatusInternalServerError, "生成入库单号失败: "+err.Error())
return
}
inboundNo = no
}
// 质量状态自动带出(客户诉求 问题记录 L129:入库记录增加 合格/不合格 列)
// 按检测单号查最近一次检验结论:合格→合格;不合格且部分入库→部分;不合格→不合格;无记录→空(未关联)
qualityStatus := ""
if req.InspectionNo != "" {
if rec, qerr := ctx.EntClient.InspectionRecord.Query().
Where(inspectionrecord.InspectionNoEQ(req.InspectionNo)).
Order(ent.Desc("created_at"), ent.Desc("id")).
First(ctx0()); qerr == nil && rec != nil {
qualityStatus = rec.Status
if rec.Status == "不合格" && rec.DisposalType == "PARTIAL" {
qualityStatus = "部分"
}
}
}
// 开启事务:入库单 + 库存行 + 明细 原子提交
tx, err := ctx.EntClient.Tx(ctx0())
if err != nil {
fail(w, http.StatusInternalServerError, "开启事务失败: "+err.Error())
return
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback()
}
}()
ib, err := tx.InboundOrder.Create().
SetInboundNo(inboundNo).
SetNillableInboundType(strPtr(req.InboundType)).
SetMaterialCode(req.MaterialCode).
SetNillableMaterialName(strPtr(m.Name)).
SetManageMode(m.ManageMode).
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)).
SetInspectionNo(req.InspectionNo).
SetOwnershipType(ownershipTypeOrNone(req.OwnershipType)).
SetNillableOwnershipNo(strPtr(req.OwnershipNo)).
SetNillableQualityStatus(strPtr(qualityStatus)).
Save(ctx0())
if err != nil {
fail(w, http.StatusInternalServerError, "创建入库单失败: "+err.Error())
return
}
totalQty := 0
// 结构件(批次管理)
if m.ManageMode == 1 {
// 结构件走批次入库,不允许携带 SN 清单
if len(req.SnList) > 0 {
fail(w, http.StatusBadRequest, "该物料为结构件(按批次管理),不应提交 SN 清单,请使用『结构件入库』")
return
}
batchNo := req.BatchNo
if batchNo == "" {
batchNo = genBatchNo(ctx, req.MaterialCode)
}
// 回写入库单头:便于「入库记录」列表直接展示批次号(库存行/明细已含批次,此处补齐表头)
if _, e := tx.InboundOrder.UpdateOneID(ib.ID).SetBatchNo(batchNo).Save(ctx0()); e != nil {
fail(w, http.StatusInternalServerError, "回写入库单批次号失败: "+e.Error())
return
}
qty := req.Quantity
if qty <= 0 {
fail(w, http.StatusBadRequest, "数量必填且大于 0,请输入本次入库数量")
return
}
// 同批次追加(多批到货追加)
existing, e := tx.Inventory.Query().
Where(inventory.ManageModeEQ(1), inventory.BatchNoEQ(batchNo)).Only(ctx0())
if e == nil {
// 盘点冻结守卫(问题2):盘点期间库存量冻结,禁止向已锁定批次追加入库。
if existing.StocktakeNo != "" {
fail(w, http.StatusConflict, "批次 "+batchNo+" 正在盘点单 "+existing.StocktakeNo+" 锁定中,盘点期间禁止入库追加")
return
}
if _, err = tx.Inventory.UpdateOneID(existing.ID).
AddQuantity(qty).
SetNillableSupplier(strPtr(req.Supplier)).
Save(ctx0()); err != nil {
fail(w, http.StatusInternalServerError, "追加批次失败: "+err.Error())
return
}
} else {
if _, err = tx.Inventory.Create().
SetManageMode(1).
SetMaterialCode(req.MaterialCode).
SetNillableMaterialName(strPtr(m.Name)).
SetBatchNo(batchNo).
SetQuantity(qty).SetLockedQty(0).
SetNillableProductionDate(strPtr(req.ProductionDate)).
SetNillableSupplier(strPtr(req.Supplier)).
SetQualityStatus("未检").
SetNillableZoneCode(strPtr(req.ZoneCode)).
SetNillableShelfNo(strPtr(req.ShelfNo)).
SetNillableLayerNo(strPtr(req.LayerNo)).
SetNillablePositionNo(strPtr(req.PositionNo)).
SetStatus("在库").
SetInboundNo(inboundNo).
Save(ctx0()); err != nil {
fail(w, http.StatusInternalServerError, "创建库存行失败: "+err.Error())
return
}
}
// 明细
if _, err = tx.InboundDetail.Create().
SetInboundNo(inboundNo).
SetBatchNo(batchNo).
SetMaterialCode(req.MaterialCode).
SetQuantity(qty).
Save(ctx0()); err != nil {
fail(w, http.StatusInternalServerError, "创建入库明细失败: "+err.Error())
return
}
totalQty = qty
}
// 电气件(SN 管理)
if m.ManageMode == 2 {
if len(req.SnList) == 0 {
fail(w, http.StatusBadRequest, "该物料为电气件(按 SN 管理),请至少录入 1 条序列号(SN)")
return
}
for _, sn := range req.SnList {
// 查重(P2-2):SN 全局唯一(一物一码)——不再按 manage_mode 分区隔离,
// 避免同一 SN 在不同管理模式/物料下共存;DB 侧另有部分唯一索引 ux_inventories_sn_code 兜底。
exists, _ := tx.Inventory.Query().
Where(inventory.SnCodeEQ(sn)).
Exist(ctx0())
if exists {
fail(w, http.StatusConflict, "SN 已存在: "+sn)
return
}
if _, err = tx.Inventory.Create().
SetManageMode(2).
SetMaterialCode(req.MaterialCode).
SetNillableMaterialName(strPtr(m.Name)).
SetSnCode(sn).
SetQuantity(1).SetLockedQty(0).
SetQualityStatus("未检").
SetNillableZoneCode(strPtr(req.ZoneCode)).
SetNillableShelfNo(strPtr(req.ShelfNo)).
SetNillableLayerNo(strPtr(req.LayerNo)).
SetNillablePositionNo(strPtr(req.PositionNo)).
SetStatus("在库").
SetInboundNo(inboundNo).
Save(ctx0()); err != nil {
fail(w, http.StatusInternalServerError, "创建 SN 库存行失败: "+err.Error())
return
}
if _, err = tx.InboundDetail.Create().
SetInboundNo(inboundNo).
SetSnCode(sn).
SetMaterialCode(req.MaterialCode).
SetQuantity(1).
Save(ctx0()); err != nil {
fail(w, http.StatusInternalServerError, "创建入库明细失败: "+err.Error())
return
}
}
totalQty = len(req.SnList)
}
// 更新入库单数量
if _, err = tx.InboundOrder.UpdateOneID(ib.ID).
SetQuantity(totalQty).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(), "inbound.create", r.Header.Get("X-Username"), "inbound", inboundNo,
"入库 "+req.MaterialCode+" 数量 "+strconv.Itoa(totalQty), map[string]any{
"inboundType": req.InboundType, "quantity": totalQty, "snCount": len(req.SnList)})
logx.Infof("inbound: no=%s material=%s qty=%d by=%s", inboundNo, req.MaterialCode, totalQty, req.Operator)
ok(w, map[string]any{
"inboundNo": inboundNo,
"quantity": totalQty,
"batchNo": req.BatchNo,
"snCount": len(req.SnList),
})
}
}
// excelInboundHandler 实现见 excel.go
// inboundRow 列表/导出统一结构:入库单 + 明细总数。
// 注意:明细(批次/SN)不在此内嵌——电气件单可能上万条 SN,
// 列表查询若一次性加载全部明细会撑爆内存,故只带总数,明细按需分页加载。
type inboundRow struct {
*ent.InboundOrder
DetailCount int `json:"detailCount"`
}
// applyInboundFilters 统一列表与导出的筛选条件
// 结构件(manageMode=1)与电气件(manageMode=2)同主表(inbound_order),按 manageMode 区分类型
func applyInboundFilters(q *ent.InboundOrderQuery, r *http.Request) *ent.InboundOrderQuery {
if v := r.URL.Query().Get("inboundNo"); v != "" {
q = q.Where(inboundorder.InboundNoContainsFold(v))
}
if v := r.URL.Query().Get("materialCode"); v != "" {
q = q.Where(inboundorder.MaterialCodeContainsFold(v))
}
if v := r.URL.Query().Get("manageMode"); v != "" {
if m := atoi(v, 0); m != 0 {
q = q.Where(inboundorder.ManageModeEQ(m))
}
}
if v := r.URL.Query().Get("inboundType"); v != "" {
q = q.Where(inboundorder.InboundTypeEQ(v))
}
if v := r.URL.Query().Get("materialName"); v != "" {
// 2026-09-08 用户明确:禁止"关键字"多字段混搜,各字段独立筛选
q = q.Where(inboundorder.MaterialNameContainsFold(v))
}
if v := r.URL.Query().Get("inspectionNo"); v != "" {
q = q.Where(inboundorder.InspectionNoContainsFold(v))
}
// 单据状态:有效 / 已作废(作废单保留可查,但不参与库存)
if v := r.URL.Query().Get("voided"); v == "true" {
q = q.Where(inboundorder.VoidedEQ(true))
} else if v == "false" {
q = q.Where(inboundorder.VoidedEQ(false))
}
if v := r.URL.Query().Get("startDate"); v != "" {
if t, err := time.ParseInLocation("2006-01-02", v, time.Local); err == nil {
q = q.Where(inboundorder.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(inboundorder.CreatedAtLTE(t.Add(24 * time.Hour).Unix()))
}
}
return q
}
// buildInboundRows 仅组装入库单 + 各单明细总数(count,代价极低,不加载明细内容)
func buildInboundRows(ctx *svc.ServiceContext, list []*ent.InboundOrder) []inboundRow {
rows := make([]inboundRow, 0, len(list))
for _, ib := range list {
cnt, _ := ctx.EntClient.InboundDetail.Query().
Where(inbounddetail.InboundNoEQ(ib.InboundNo)).Count(ctx0())
rows = append(rows, inboundRow{ib, cnt})
}
return rows
}
// inboundDetailsHandler 入库单明细分页查询(批次/SN)。
// 单独接口、按需加载、分页返回——避免一次拉取上万条 SN 撑爆内存/前端。
// 参数:inboundNo(必填) page pageSize(默认 50)
func inboundDetailsHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
inboundNo := r.URL.Query().Get("inboundNo")
if inboundNo == "" {
fail(w, http.StatusBadRequest, "inboundNo 必填")
return
}
page := atoi(r.URL.Query().Get("page"), 1)
pageSize := atoi(r.URL.Query().Get("pageSize"), 50)
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 500 {
pageSize = 50
}
q := ctx.EntClient.InboundDetail.Query().
Where(inbounddetail.InboundNoEQ(inboundNo))
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
}
ok(w, map[string]any{
"total": total,
"list": list,
"page": page,
"pageSize": pageSize,
})
}
}
// queryInboundHandler 入库单查询(分页列表)
// 结构件/电气件同主表,默认返回全部;支持多条件筛选。
func queryInboundHandler(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)
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 20
}
q := ctx.EntClient.InboundOrder.Query()
q = applyInboundFilters(q, r)
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
}
ok(w, map[string]any{
"total": total,
"list": buildInboundRows(ctx, list),
"page": page,
"pageSize": pageSize,
})
}
}
// exportInboundHandler 入库单导出:按筛选条件返回全部(不分页),前端转 CSV,
// 与 /api/outbound/export 保持一致。
func exportInboundHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if _, _, rerr := normalizeExportRange(r.URL.Query().Get("startDate"), r.URL.Query().Get("endDate")); rerr != nil {
fail(w, http.StatusBadRequest, rerr.Error())
return
}
q := ctx.EntClient.InboundOrder.Query()
q = applyInboundFilters(q, r)
list, err := q.Order(ent.Desc("created_at"), ent.Desc("id")).All(ctx0())
if err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
rows := buildInboundRows(ctx, list)
// xlsx 导出:格式化 = xlsx → 直接生成二进制文件;否则保持 JSON 兼容
if r.URL.Query().Get("format") == "xlsx" {
headers := []string{"入库单号", "图号", "物料名称", "类型", "数量", "区域", "库位", "归属单号", "检测单号", "质量状态", "操作人", "备注", "明细条数", "入库时间"}
matrix := make([][]any, 0, len(rows))
for _, row := range rows {
ib := row.InboundOrder
matrix = append(matrix, []any{
ib.InboundNo, ib.MaterialCode, ib.MaterialName,
manageModeLabel(ib.ManageMode), ib.Quantity, ib.ZoneCode, locLabel(ib.ZoneCode, ib.ShelfNo, ib.LayerNo, ib.PositionNo),
ib.OwnershipNo, ib.InspectionNo, ib.QualityStatus,
ib.Operator, ib.Remark, row.DetailCount,
unixFmt(ib.CreatedAt),
})
}
sendExcel(w, xlsxFilename("入库管理"), headers, matrix)
return
}
ok(w, map[string]any{"total": len(rows), "list": rows})
}
}
// ownershipTypeOrNone 归属类型归一化(需求22)。
// 归属三选一:工单编号(workorder) / 科技项目号(project) / 其他(other),默认「其他」。
// 这里的「工单编号」是独立维度标签,与 MES 工单号不是同一东西,手动输入、不联动 MES、不校验格式(长度≤64 由前端约束)。
// 历史遗留的 "none"/空 一律归为 "other"(需求22:显示「无」不对,应为「其他」)。
func ownershipTypeOrNone(v string) string {
switch v {
case "workorder", "project":
return v
default:
return "other"
}
}
// genBatchNo 生成批次号:物料编码-日期-流水号(客户诉求 问题记录 L88-92)。
// 格式 {materialCode}-YYYYMMDD-{3位流水},流水号按"当日该物料已有最大流水+1"取,
// 保证同物料同日不重号(跨物料因前缀不同天然不冲突)。可手填覆盖,非强制系统生成。
func genBatchNo(ctx *svc.ServiceContext, materialCode string) string {
day := timeNow().Format("20060102")
prefix := materialCode + "-" + day + "-"
// 查当日该物料已有批次中最大流水号
maxSeq := 0
rows, err := ctx.EntClient.Inventory.Query().
Where(inventory.ManageModeEQ(1), inventory.BatchNoHasPrefix(prefix)).
All(ctx0())
if err == nil {
for _, r := range rows {
suffix := strings.TrimPrefix(r.BatchNo, prefix)
if n, e := strconv.Atoi(suffix); e == nil && n > maxSeq {
maxSeq = n
}
}
}
return fmt.Sprintf("%s%03d", prefix, maxSeq+1)
}