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/internal/svc" "github.com/google/uuid" "github.com/zeromicro/go-zero/core/logx" ) // 物料品类:4=其他(辅料/工装/试验设备),与 schema/material.go 的 item_type 保持一致 const invItemOther = 4 // 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 { 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 := "IB" + uuid.NewString()[:12] // 质量状态自动带出(客户诉求 问题记录 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 { 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("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 归属类型归一化(工单号/科技项目号/其他/无) func ownershipTypeOrNone(v string) string { switch v { case "workorder", "project", "other": return v default: return "none" } } // 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) }