feat: 新增检测单关联、基础数据优化与入库单升级

1. 新增出库单检测单号字段并添加索引支持溯源
2. 优化基础数据页面物料类型展示与选择逻辑
3. 升级入库单导入功能,支持自动生成单号并返回导入结果
4. 优化接驳台查询排序方式
5. 修复字符串拼接空格问题
This commit is contained in:
SunYF
2026-09-21 15:28:14 +08:00
parent 0e769f3813
commit 12ca493c61
70 changed files with 25782 additions and 119 deletions
+16 -10
View File
@@ -14,7 +14,6 @@ import (
"bj_power_wms/ent/material"
"bj_power_wms/internal/svc"
"github.com/google/uuid"
"github.com/xuri/excelize/v2"
)
@@ -313,14 +312,14 @@ func excelInboundHandler(ctx *svc.ServiceContext) http.HandlerFunc {
continue
}
if snSeen[pr.sn] {
errs = append(errs, importErr{pr.row, pr.code, "SN 在文件中重复: "+pr.sn})
errs = append(errs, importErr{pr.row, pr.code, "SN 在文件中重复: " + pr.sn})
continue
}
snSeen[pr.sn] = true
exists, _ := ctx.EntClient.Inventory.Query().
Where(inventory.ManageModeEQ(2), inventory.SnCodeEQ(pr.sn)).Exist(ctx0())
if exists {
errs = append(errs, importErr{pr.row, pr.code, "SN 已存在: "+pr.sn})
errs = append(errs, importErr{pr.row, pr.code, "SN 已存在: " + pr.sn})
continue
}
} else {
@@ -352,6 +351,7 @@ func excelInboundHandler(ctx *svc.ServiceContext) http.HandlerFunc {
fail(w, http.StatusInternalServerError, "开启事务失败: "+e.Error())
return
}
var generatedNos []string
committed := false
defer func() {
if !committed {
@@ -360,7 +360,8 @@ func excelInboundHandler(ctx *svc.ServiceContext) http.HandlerFunc {
}()
for _, pr := range plans {
inboundNo := "IB" + uuid.NewString()[:12]
inboundNo, _ := nextDailyNo(ctx, "IB")
generatedNos = append(generatedNos, inboundNo)
m, _ := tx.Material.Query().Where(material.CodeEQ(pr.code)).Only(ctx0())
if mode == "sn" {
if _, e = tx.Inventory.Create().
@@ -386,11 +387,11 @@ func excelInboundHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return
}
} else {
batchNo := pr.batchNo
if batchNo == "" {
// 自动批次号(物料编码-日期-流水号);同一导入内多行空批次自动取得递增流水,天然唯一
batchNo = genBatchNo(ctx, pr.code)
}
batchNo := pr.batchNo
if batchNo == "" {
// 自动批次号(物料编码-日期-流水号);同一导入内多行空批次自动取得递增流水,天然唯一
batchNo = genBatchNo(ctx, pr.code)
}
existing, e2 := tx.Inventory.Query().
Where(inventory.ManageModeEQ(1), inventory.BatchNoEQ(batchNo)).Only(ctx0())
if e2 == nil {
@@ -447,7 +448,12 @@ func excelInboundHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return
}
committed = true
ok(w, map[string]any{"success": len(plans), "failed": 0, "errors": []importErr{}})
ok(w, map[string]any{
"success": len(plans),
"failed": 0,
"errors": []importErr{},
"inboundNos": generatedNos, // 每张入库单生成的单号(与导入行一一对应)
})
}
}
+106 -2
View File
@@ -13,15 +13,102 @@ import (
"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/google/uuid"
"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 {
@@ -58,6 +145,7 @@ func validateInboundQuality(ctx *svc.ServiceContext, materialCode, batchNo strin
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"`
@@ -117,7 +205,20 @@ func createInboundHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return
}
inboundNo := "IB" + uuid.NewString()[:12]
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:入库记录增加 合格/不合格 列)
// 按检测单号查最近一次检验结论:合格→合格;不合格且部分入库→部分;不合格→不合格;无记录→空(未关联)
@@ -348,6 +449,9 @@ func applyInboundFilters(q *ent.InboundOrderQuery, r *http.Request) *ent.Inbound
// 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))
+79 -32
View File
@@ -3,6 +3,7 @@ package handler
import (
"fmt"
"net/http"
"strconv"
"time"
"bj_power_wms/ent"
@@ -680,28 +681,18 @@ func inboundNosByType(ctx *svc.ServiceContext, inboundType string) ([]string, er
return nos, nil
}
// pendingInspectionItem 待检清单展开明细行
type pendingInspectionItem struct {
ID int `json:"id"` // inventory.id(真实主键,禁止伪 id)
TargetId string `json:"targetId"` // 批次号或 SN
ManageMode int `json:"manageMode"` // 1结构件/2电气件
ItemType int `json:"itemType"` // 品类 1原材料/2半成品/3成品/4其他(§E:带入录入页判定其他类)
Quantity int `json:"quantity"`
ZoneCode string `json:"zoneCode"`
}
// pendingInspectionRow 待检清单主行(按 入库单 + 物料 分组,§E2)
// 详情明细改为按需分页加载(右侧抽屉),主行只带总数不塞 details 数组,避免 3000 SN 一次进内存
type pendingInspectionRow struct {
InboundNo string `json:"inboundNo"`
InboundType string `json:"inboundType"`
OwnershipType string `json:"ownershipType"`
OwnershipNo string `json:"ownershipNo"`
MaterialCode string `json:"materialCode"`
MaterialName string `json:"materialName"`
UnInspectedQty int `json:"unInspectedQty"` // 未检数量(结构件=数量求和;电气件=SN 行数)
BatchSnCount int `json:"batchSnCount"` // 未检批次数/SN
InboundTime int64 `json:"inboundTime"`
Details []pendingInspectionItem `json:"details"`
InboundNo string `json:"inboundNo"`
InboundType string `json:"inboundType"`
OwnershipType string `json:"ownershipType"`
OwnershipNo string `json:"ownershipNo"`
MaterialCode string `json:"materialCode"`
MaterialName string `json:"materialName"`
UnInspectedQty int `json:"unInspectedQty"` // 未检数量(结构件=数量求和;电气件=SN 行数)
DetailsTotal int `json:"detailsTotal"` // 未检批次数/SN 总数(抽屉分页拉明细)
InboundTime int64 `json:"inboundTime"`
}
// pendingInspectionHandler 待检清单(来料检验问题2):列出 quality_status=未检 的库存行,
@@ -769,9 +760,8 @@ func pendingInspectionHandler(ctx *svc.ServiceContext) http.HandlerFunc {
}
}
// 物料名称/品类 map
// 物料名称 map
matName := map[string]string{}
matItemType := map[string]int{}
codeSet := []string{}
seenC := map[string]bool{}
for _, inv := range all {
@@ -784,7 +774,6 @@ func pendingInspectionHandler(ctx *svc.ServiceContext) http.HandlerFunc {
if mats, e := ctx.EntClient.Material.Query().Where(material.CodeIn(codeSet...)).All(ctx0()); e == nil {
for _, m := range mats {
matName[m.Code] = m.Name
matItemType[m.Code] = m.ItemType
}
}
}
@@ -818,15 +807,7 @@ func pendingInspectionHandler(ctx *svc.ServiceContext) http.HandlerFunc {
qty = inv.Quantity
}
g.UnInspectedQty += qty
g.BatchSnCount++
tid := inv.BatchNo
if inv.ManageMode == 2 {
tid = inv.SnCode
}
g.Details = append(g.Details, pendingInspectionItem{
ID: inv.ID, TargetId: tid, ManageMode: inv.ManageMode, ItemType: matItemType[inv.MaterialCode],
Quantity: inv.Quantity, ZoneCode: inv.ZoneCode,
})
g.DetailsTotal++
if o != nil && o.CreatedAt > g.InboundTime {
g.InboundTime = o.CreatedAt
}
@@ -856,3 +837,69 @@ func strOrEmpty(o *ent.InboundOrder) string {
}
return o.InboundType
}
// pendingDetailsInspectionHandler GET /api/inspection/pending/details
// 待检清单主行抽屉里的明细分页(右侧抽屉懒加载,不再主行带 details 数组)。
// 入参:inboundNo + materialCode + page + pageSize(默认 20)。
func pendingDetailsInspectionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
inboundNo := q.Get("inboundNo")
materialCode := q.Get("materialCode")
if inboundNo == "" || materialCode == "" {
fail(w, http.StatusBadRequest, "inboundNo 和 materialCode 不能为空")
return
}
page, _ := strconv.Atoi(q.Get("page"))
pageSize, _ := strconv.Atoi(q.Get("pageSize"))
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 500 {
pageSize = 20
}
qry := svcCtx.EntClient.Inventory.Query().
Where(
inventory.InboundNoEQ(inboundNo),
inventory.MaterialCodeEQ(materialCode),
inventory.QualityStatusEQ("未检"),
)
total, err := qry.Count(ctx0())
if err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
start := (page - 1) * pageSize
list, err := qry.
Order(inventory.ByID()).
Offset(start).
Limit(pageSize).
All(ctx0())
if err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
// itemType 从物料档案补查(一个 materialCode 唯一)
itemType := 0
if m, e := svcCtx.EntClient.Material.Query().Where(material.CodeEQ(materialCode)).Only(ctx0()); e == nil {
itemType = m.ItemType
}
rows := make([]map[string]any, 0, len(list))
for _, inv := range list {
tid := inv.BatchNo
if inv.ManageMode == 2 {
tid = inv.SnCode
}
rows = append(rows, map[string]any{
"targetId": tid,
"manageMode": inv.ManageMode,
"itemType": itemType,
"quantity": inv.Quantity,
"zoneCode": inv.ZoneCode,
"id": inv.ID,
})
}
ok(w, map[string]any{"list": rows, "total": total, "page": page, "pageSize": pageSize})
}
}
+36 -3
View File
@@ -13,7 +13,6 @@ import (
"bj_power_wms/ent/outboundorder"
"bj_power_wms/internal/svc"
"github.com/google/uuid"
"github.com/zeromicro/go-zero/core/logx"
)
@@ -37,6 +36,7 @@ type deductStockRequest struct {
LayerNo string `json:"layerNo"`
PositionNo string `json:"positionNo"`
Remark string `json:"remark"`
OutboundNo string `json:"outboundNo"` // 可选:留空自动生成;填了必须唯一(如 OB20260921-001
// OutboundType 出库类型:workorder(备料出库,默认) / refill(工位叫料·补料出库)
OutboundType string `json:"outboundType"`
// NeedAgv 是否需要 AGV 配送到接驳台(备料/补料一般为 true)
@@ -92,7 +92,20 @@ func deductStockHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return
}
outboundNo := "OB" + uuid.NewString()[:12]
outboundNo := strings.TrimSpace(req.OutboundNo)
if outboundNo != "" {
if err := validateUniqueNo(ctx, "OB", outboundNo); err != nil {
fail(w, http.StatusBadRequest, err.Error())
return
}
} else {
no, err := nextDailyNo(ctx, "OB")
if err != nil {
fail(w, http.StatusInternalServerError, "生成出库单号失败: "+err.Error())
return
}
outboundNo = no
}
// 开启事务:出库单 + 库存扣减 + 明细 + 台账 原子提交
tx, err := ctx.EntClient.Tx(ctx0())
@@ -316,6 +329,7 @@ func queryOutboundHandler(ctx *svc.ServiceContext) http.HandlerFunc {
materialName := r.URL.Query().Get("materialName")
targetStation := r.URL.Query().Get("targetStation")
status := r.URL.Query().Get("status")
inspectionNo := r.URL.Query().Get("inspectionNo")
q := ctx.EntClient.OutboundOrder.Query()
if status != "" {
@@ -348,6 +362,9 @@ func queryOutboundHandler(ctx *svc.ServiceContext) http.HandlerFunc {
if targetStation != "" {
q = q.Where(outboundorder.TargetStationEQ(targetStation))
}
if inspectionNo != "" {
q = q.Where(outboundorder.InspectionNoContainsFold(inspectionNo))
}
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()))
@@ -409,7 +426,9 @@ type generalOutboundRequest struct {
BoxNo string `json:"boxNo"`
ContractNo string `json:"contractNo"`
Remark string `json:"remark"`
OutboundNo string `json:"outboundNo"` // 可选:留空自动生成;填了必须唯一(如 OB20260921-001
OutboundCategory string `json:"outboundCategory"` // return(退料)/supplier_return(退货退供应商)/sample(样品)/scrap(报废)/deliver(发货)/other(其他)
InspectionNo string `json:"inspectionNo"` // 可选:检测单号,退货/报废等需溯源检验单据
OwnershipType string `json:"ownershipType"` // workorder/project/other
OwnershipNo string `json:"ownershipNo"`
TargetStation string `json:"targetStation"`
@@ -469,7 +488,20 @@ func generalOutboundHandler(ctx *svc.ServiceContext) http.HandlerFunc {
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]
outboundNo := strings.TrimSpace(req.OutboundNo)
if outboundNo != "" {
if err := validateUniqueNo(ctx, "OB", outboundNo); err != nil {
fail(w, http.StatusBadRequest, err.Error())
return
}
} else {
no, err := nextDailyNo(ctx, "OB")
if err != nil {
fail(w, http.StatusInternalServerError, "生成出库单号失败: "+err.Error())
return
}
outboundNo = no
}
tx, err := ctx.EntClient.Tx(ctx0())
if err != nil {
@@ -492,6 +524,7 @@ func generalOutboundHandler(ctx *svc.ServiceContext) http.HandlerFunc {
SetNillableOutboundCategory(strPtr(req.OutboundCategory)).
SetNillableOwnershipType(strPtr(req.OwnershipType)).
SetNillableOwnershipNo(strPtr(req.OwnershipNo)).
SetNillableInspectionNo(strPtr(req.InspectionNo)).
SetNillableTargetStation(strPtr(req.TargetStation)).
SetMaterialCode(req.MaterialCode).
SetManageMode(manageMode).
+1
View File
@@ -133,6 +133,7 @@ func RegisterHandlers(server *rest.Server, ctx *svc.ServiceContext) {
{Method: http.MethodGet, Path: "/api/inspection/export", Handler: exportInspectionHandler(ctx)},
{Method: http.MethodGet, Path: "/api/inspection/stats", Handler: statsInspectionHandler(ctx)},
{Method: http.MethodGet, Path: "/api/inspection/pending", Handler: pendingInspectionHandler(ctx)},
{Method: http.MethodGet, Path: "/api/inspection/pending/details", Handler: pendingDetailsInspectionHandler(ctx)},
},
)