package handler import ( "fmt" "net/http" "strconv" "time" "bj_power_wms/ent" "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" ) // resolveInspectionTarget 通过编号(批次号或 SN)定位唯一库存行。 // 检验必须落在真实存在的库存记录上,才能同步质量状态——杜绝"提示成功但库存未更新"的假闭环。 // 返回错误或定位到的库存行(含物料编码与管理粒度,供后续写入检验记录与翻状态使用)。 func resolveInspectionTarget(ctx *svc.ServiceContext, targetId string) (*ent.Inventory, error) { // 优先按批次号(结构件) if inv, err := ctx.EntClient.Inventory.Query(). Where(inventory.ManageModeEQ(1), inventory.BatchNoEQ(targetId)).Only(ctx0()); err == nil { return inv, nil } // 再按 SN(电气件) if inv, err := ctx.EntClient.Inventory.Query(). Where(inventory.ManageModeEQ(2), inventory.SnCodeEQ(targetId)).Only(ctx0()); err == nil { return inv, nil } return nil, fmt.Errorf("未找到对应库存记录(批次号/SN):%s,请确认编号是否已入库、或该记录是否已出库", targetId) } // batchFlipInspectionHandler 逐个判定批量翻转检验状态(问题记录 L150 三问题之三) // body: { items: [ { targetId, status, inspectQty, passQty, disposalType, // // returnTrackingNo, disposalRemark, ownershipType, ownershipNo, // relatedStandard, manufacturer, checkDesc, inspector, reportNo, inspectionNo } ] } // // 每个对象按自身数量写记录、按自身结论翻库存状态(修复旧版"所有记录同数量"的 bug)。 // reject_qty 后端复核 = inspectQty - passQty,不信任前端传入值。 func batchFlipInspectionHandler(ctx *svc.ServiceContext) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var req struct { Items []struct { TargetId string `json:"targetId"` Status string `json:"status"` // 合格/不合格 InspectQty int `json:"inspectQty"` PassQty int `json:"passQty"` DisposalType string `json:"disposalType"` ReturnTrackingNo string `json:"returnTrackingNo"` DisposalRemark string `json:"disposalRemark"` OwnershipType string `json:"ownershipType"` OwnershipNo string `json:"ownershipNo"` RelatedStandard string `json:"relatedStandard"` Manufacturer string `json:"manufacturer"` CheckDesc string `json:"checkDesc"` Inspector string `json:"inspector"` ReportNo string `json:"reportNo"` InspectionNo string `json:"inspectionNo"` } `json:"items"` } if err := parseJSON(r, &req); err != nil { fail(w, http.StatusBadRequest, "参数错误") return } if len(req.Items) == 0 { fail(w, http.StatusBadRequest, "items 必填") return } // 预校验:每条必须含 targetId 且结论合法、能定位到库存行;任意一条失败整体拒绝(避免部分成功假闭环) type item struct { id string inv *ent.Inventory targetType string material string status string inspectQty int passQty int rejectQty int disposal string retTrack string dispRemark string ownType string ownNo string relStd string mfr string checkDesc string inspector string reportNo string inspNo string } items := make([]item, 0, len(req.Items)) codes := make([]string, 0, len(req.Items)) seenCode := map[string]bool{} for _, it := range req.Items { if it.TargetId == "" || (it.Status != "合格" && it.Status != "不合格") { fail(w, http.StatusBadRequest, "每条必须含 targetId 与 status(合格/不合格): "+it.TargetId) return } inv, err := resolveInspectionTarget(ctx, it.TargetId) if err != nil { fail(w, http.StatusBadRequest, err.Error()) return } tt := "BATCH" if inv.ManageMode == 2 { tt = "SN" } inspectQty := it.InspectQty passQty := it.PassQty if inspectQty <= 0 { inspectQty = 1 // 电气件/其他:单件判定默认检验数=1 } if passQty < 0 || passQty > inspectQty { fail(w, http.StatusBadRequest, "合格数必须在 0~检验数 之间: "+it.TargetId) return } // 后端复核不合格数 = 检验数 - 合格数(不信任前端) rejectQty := inspectQty - passQty items = append(items, item{ id: it.TargetId, inv: inv, targetType: tt, material: inv.MaterialCode, status: it.Status, inspectQty: inspectQty, passQty: passQty, rejectQty: rejectQty, disposal: disposalTypeOrNone(it.DisposalType), retTrack: it.ReturnTrackingNo, dispRemark: it.DisposalRemark, ownType: it.OwnershipType, ownNo: it.OwnershipNo, relStd: it.RelatedStandard, mfr: it.Manufacturer, checkDesc: it.CheckDesc, inspector: it.Inspector, reportNo: it.ReportNo, inspNo: it.InspectionNo, }) if inv.MaterialCode != "" && !seenCode[inv.MaterialCode] { seenCode[inv.MaterialCode] = true codes = append(codes, inv.MaterialCode) } } // 「其他」类物料(item_type=4)单独记对象类型 OTHER(§B1:其他类此前混在批次(结构件)里无法单独识别) if len(codes) > 0 { if mats, e := ctx.EntClient.Material.Query().Where(material.CodeIn(codes...)).All(ctx0()); e == nil { otherSet := map[string]bool{} for _, m := range mats { if m.ItemType == invItemOther { otherSet[m.Code] = true } } for i := range items { if items[i].targetType == "BATCH" && otherSet[items[i].material] { items[i].targetType = "OTHER" } } } } tx, err := ctx.EntClient.Tx(ctx0()) if err != nil { fail(w, http.StatusInternalServerError, "开启事务失败: "+err.Error()) return } committed := false defer func() { if !committed { _ = tx.Rollback() } }() // 同库存行被多条记录引用的极端情况:批量送检若勾选了同一批次多行,按 id 去重取首条, // 避免重复写记录(正常待检清单按入库单分组,行级唯一,此去重仅为防御)。 seen := map[string]bool{} flipped := 0 for _, it := range items { if seen[it.id] { continue } seen[it.id] = true matName := it.inv.MaterialName if matName == "" { if m, merr := tx.Material.Query().Where(material.CodeEQ(it.material)).First(ctx0()); merr == nil && m != nil { matName = m.Name } } if _, err = tx.InspectionRecord.Create(). SetTargetType(it.targetType). SetTargetID(it.id). SetMaterialCode(it.material). SetNillableMaterialName(strPtr(matName)). SetNillableOwnershipType(strPtr(ownershipTypeOrNone(it.ownType))). SetNillableOwnershipNo(strPtr(it.ownNo)). SetNillableRelatedStandard(strPtr(it.relStd)). SetNillableManufacturer(strPtr(it.mfr)). SetStatus(it.status). SetInspectQty(it.inspectQty). SetPassQty(it.passQty). SetRejectQty(it.rejectQty). SetNillableCheckDesc(strPtr(it.checkDesc)). SetNillableInspector(strPtr(it.inspector)). SetNillableReportNo(strPtr(it.reportNo)). SetNillableInspectionNo(strPtr(it.inspNo)). SetDisposalType(it.disposal). SetNillableDisposalRemark(strPtr(it.dispRemark)). SetNillableReturnTrackingNo(strPtr(it.retTrack)). Save(ctx0()); err != nil { fail(w, http.StatusInternalServerError, "创建检验记录失败: "+err.Error()) return } if _, err = tx.Inventory.UpdateOneID(it.inv.ID). SetQualityStatus(qualityAfterInspection(it.status, it.disposal)).Save(ctx0()); err != nil { fail(w, http.StatusInternalServerError, "同步库存检验状态失败: "+err.Error()) return } flipped++ } if err = tx.Commit(); err != nil { fail(w, http.StatusInternalServerError, "提交事务失败: "+err.Error()) return } committed = true ok(w, map[string]any{"flipped": flipped}) } } // queryInspectionHandler 检验记录查询 func queryInspectionHandler(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) targetId := r.URL.Query().Get("targetId") materialCode := r.URL.Query().Get("materialCode") inspectionType := r.URL.Query().Get("inspectionType") status := r.URL.Query().Get("status") inspector := r.URL.Query().Get("inspector") // 时间筛选:起止两端各自独立生效(只填一端合法,另一端不限制),**不做导出那套归一化校验**。 // 注意:normalizeExportRange(默认近3个月 / 只传一端即报错 / 间隔≤1年)是**导出**专用硬规则, // 列表查询套用会导致进入「检测记录」Tab(默认只带 startDate)立即 400 // 「导出必须同时提供起始时间与结束时间」。口径与 statsInspectionHandler 保持一致。 startDate := r.URL.Query().Get("startDate") endDate := r.URL.Query().Get("endDate") q := ctx.EntClient.InspectionRecord.Query() if targetId != "" { q = q.Where(inspectionrecord.TargetIDContainsFold(targetId)) } if materialCode != "" { q = q.Where(inspectionrecord.MaterialCodeContainsFold(materialCode)) } // 名称模糊 / 归属单号(客户诉求 L507-510 三框制:图号/名称/型号,每框内模糊、多框取交集;L149 增加归属筛选) if v := r.URL.Query().Get("materialName"); v != "" { q = q.Where(inspectionrecord.MaterialNameContainsFold(v)) } if v := r.URL.Query().Get("ownershipNo"); v != "" { q = q.Where(inspectionrecord.OwnershipNoContainsFold(v)) } if v := r.URL.Query().Get("itemType"); v != "" { // 按品类(itemType)过滤:先取对应物料编码集再 IN(避免逐行 join,同 materialCodesBySpec 套路) if code := atoi(v, 0); code > 0 { codes := materialCodesByItemType(ctx, code) if len(codes) == 0 { ok(w, map[string]any{"total": 0, "list": []any{}, "page": page, "pageSize": pageSize}) return } q = q.Where(inspectionrecord.MaterialCodeIn(codes...)) } } if v := r.URL.Query().Get("spec"); v != "" { // 型号=物料档案规格型号,需 join 物料表取编码集合 codes := materialCodesBySpec(ctx, v) if len(codes) == 0 { ok(w, map[string]any{"total": 0, "list": []any{}, "page": page, "pageSize": pageSize}) return } q = q.Where(inspectionrecord.MaterialCodeIn(codes...)) } if inspectionType != "" { q = q.Where(inspectionrecord.InspectionTypeEQ(inspectionType)) } if status != "" { q = q.Where(inspectionrecord.StatusEQ(status)) } if inspector != "" { q = q.Where(inspectionrecord.InspectorContainsFold(inspector)) } // 时间筛选(默认近3个月由前端传入;兼容两端口径) if startDate != "" { if t, err := time.ParseInLocation("2006-01-02", startDate, time.Local); err == nil { q = q.Where(inspectionrecord.CreatedAtGTE(t.Unix())) } } if endDate != "" { if t, err := time.ParseInLocation("2006-01-02", endDate, time.Local); err == nil { q = q.Where(inspectionrecord.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 } ok(w, map[string]any{ "total": total, "list": list, "page": page, "pageSize": pageSize, }) } } // exportInspectionHandler 检验记录全量导出(按条件返回全部 JSON,前端转 CSV/Excel) // GET /api/inspection/export?targetId=&materialCode=&inspectionType=&status=&inspector=&startDate=&endDate= func exportInspectionHandler(ctx *svc.ServiceContext) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { targetId := r.URL.Query().Get("targetId") materialCode := r.URL.Query().Get("materialCode") inspectionType := r.URL.Query().Get("inspectionType") status := r.URL.Query().Get("status") inspector := r.URL.Query().Get("inspector") startDate := r.URL.Query().Get("startDate") endDate := r.URL.Query().Get("endDate") startDate, endDate, rerr := normalizeExportRange(startDate, endDate) if rerr != nil { fail(w, http.StatusBadRequest, rerr.Error()) return } q := ctx.EntClient.InspectionRecord.Query() if targetId != "" { q = q.Where(inspectionrecord.TargetIDContainsFold(targetId)) } if materialCode != "" { q = q.Where(inspectionrecord.MaterialCodeContainsFold(materialCode)) } if v := r.URL.Query().Get("materialName"); v != "" { q = q.Where(inspectionrecord.MaterialNameContainsFold(v)) } if v := r.URL.Query().Get("ownershipNo"); v != "" { q = q.Where(inspectionrecord.OwnershipNoContainsFold(v)) } if v := r.URL.Query().Get("itemType"); v != "" { // 按品类(itemType)过滤:先取对应物料编码集再 IN(避免逐行 join,同 materialCodesBySpec 套路) if code := atoi(v, 0); code > 0 { codes := materialCodesByItemType(ctx, code) if len(codes) == 0 { ok(w, map[string]any{"list": []any{}}) return } q = q.Where(inspectionrecord.MaterialCodeIn(codes...)) } } if v := r.URL.Query().Get("spec"); v != "" { codes := materialCodesBySpec(ctx, v) if len(codes) == 0 { ok(w, map[string]any{"list": []any{}}) return } q = q.Where(inspectionrecord.MaterialCodeIn(codes...)) } if inspectionType != "" { q = q.Where(inspectionrecord.InspectionTypeEQ(inspectionType)) } if status != "" { q = q.Where(inspectionrecord.StatusEQ(status)) } if inspector != "" { q = q.Where(inspectionrecord.InspectorContainsFold(inspector)) } if startDate != "" { if t, err := time.ParseInLocation("2006-01-02", startDate, time.Local); err == nil { q = q.Where(inspectionrecord.CreatedAtGTE(t.Unix())) } } if endDate != "" { if t, err := time.ParseInLocation("2006-01-02", endDate, time.Local); err == nil { q = q.Where(inspectionrecord.CreatedAtLTE(t.Add(24 * time.Hour).Unix())) } } list, err := q.Order(ent.Desc("created_at"), ent.Desc("id")).All(ctx0()) if err != nil { fail(w, http.StatusInternalServerError, err.Error()) return } // xlsx 导出 if r.URL.Query().Get("format") == "xlsx" { // 列名按客户口径(问题记录 L150):检验说明→相关标准、检验员→记录人、检验日期→记录日期、增加归属; // 图号=物料编码(L18 合并裁决)。 headers := []string{"ID", "对象", "批次/SN", "图号", "名称", "检验类型", "结论", "检验数", "合格数", "不合格数", "相关标准", "报检单号", "检测单号", "归属单号", "生产厂家", "记录人", "备注", "记录日期"} matrix := make([][]any, 0, len(list)) for _, rec := range list { obj := rec.TargetType if obj == "BATCH" { obj = "批次" } else if obj == "SN" { obj = "SN" } matrix = append(matrix, []any{ rec.ID, obj, rec.TargetID, rec.MaterialCode, rec.MaterialName, inspectionTypeLabel(rec.InspectionType), rec.Status, rec.InspectQty, rec.PassQty, rec.RejectQty, rec.RelatedStandard, rec.ReportNo, rec.InspectionNo, rec.OwnershipNo, rec.Manufacturer, rec.Inspector, rec.Remark, unixFmt(rec.CreatedAt), }) } sendExcel(w, xlsxFilename("检验记录"), headers, matrix) return } ok(w, map[string]any{"list": list}) } } // materialCodesBySpec 按规格型号模糊匹配物料编码集合(检验查询「型号」框用,L507-510) func materialCodesBySpec(ctx *svc.ServiceContext, spec string) []string { mats, err := ctx.EntClient.Material.Query().Where(material.SpecContainsFold(spec)).All(ctx0()) if err != nil { return nil } codes := make([]string, 0, len(mats)) for _, m := range mats { codes = append(codes, m.Code) } return codes } // disposalTypeOrNone 处置方式归一化(需求23:不合格可转退货 / 换修(返修)/ 让步接收 / 报废 / 部分入库) func disposalTypeOrNone(v string) string { switch v { case "RETURN", "SCRAP", "PARTIAL", "REWORK", "CONCESSION": return v default: return "NONE" } } // qualityAfterInspection 检验后的库存质量状态: // 合格 → 合格(可出库);让步接收(CONCESSION) → 合格(审批后放行使用); // 退货(RETURN) → 待退(隔离);返修(REWORK)/报废/其余不合格 → 不合格(隔离,禁止上线)。 func qualityAfterInspection(status, disposalType string) string { if status == "合格" { return "合格" } switch disposalTypeOrNone(disposalType) { case "CONCESSION": return "合格" case "RETURN": return "待退" } return "不合格" } // disposeInspectionHandler 不合格品处置(退货 / 报废 / 部分入库) // POST /api/inspection/dispose body: { recordId, disposalType, disposalRemark, returnTrackingNo, operator } // // RETURN 退货 → 库存置「待退」隔离(保持数量,等待退货运单确认后出库) // SCRAP 报废 → 库存置「不合格」且生命周期「报废」,退出可用库存 // PARTIAL 部分入库 → 仅批次管理:按合格数拆行(合格行入库 + 差额行隔离),数量守恒 func disposeInspectionHandler(ctx *svc.ServiceContext) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var req struct { RecordID int `json:"recordId"` DisposalType string `json:"disposalType"` DisposalRemark string `json:"disposalRemark"` ReturnTrackingNo string `json:"returnTrackingNo"` Operator string `json:"operator"` } if err := parseJSON(r, &req); err != nil || req.RecordID <= 0 { fail(w, http.StatusBadRequest, "参数错误:recordId 必填") return } dt := disposalTypeOrNone(req.DisposalType) if dt == "NONE" { fail(w, http.StatusBadRequest, "请选择处置方式(退货/报废/部分入库)") return } rec, err := ctx.EntClient.InspectionRecord.Get(ctx0(), req.RecordID) if err != nil { fail(w, http.StatusNotFound, "检验记录不存在") return } if rec.Status != "不合格" { fail(w, http.StatusBadRequest, "仅「不合格」记录可执行处置") return } inv, err := resolveInspectionTarget(ctx, rec.TargetID) if err != nil { fail(w, http.StatusBadRequest, err.Error()) return } if dt == "RETURN" && req.ReturnTrackingNo == "" { fail(w, http.StatusBadRequest, "退货处置必须填写货运单号") 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 _, err = tx.InspectionRecord.UpdateOneID(rec.ID). SetDisposalType(dt). SetNillableDisposalRemark(strPtr(req.DisposalRemark)). SetNillableReturnTrackingNo(strPtr(req.ReturnTrackingNo)). Save(ctx0()); err != nil { fail(w, http.StatusInternalServerError, "更新处置信息失败: "+err.Error()) return } switch dt { case "RETURN": if _, err = tx.Inventory.UpdateOneID(inv.ID).SetQualityStatus("待退").Save(ctx0()); err != nil { fail(w, http.StatusInternalServerError, "更新库存状态失败: "+err.Error()) return } case "REWORK": // 换修/返修:隔离为不合格,备注标返修,等待返修后重新报检(不上线、不发货) if _, err = tx.Inventory.UpdateOneID(inv.ID).SetQualityStatus("不合格").Save(ctx0()); err != nil { fail(w, http.StatusInternalServerError, "更新库存状态失败: "+err.Error()) return } case "CONCESSION": // 让步接收:不合格但审批后放行使用,质量状态置合格(可出库/上线) if _, err = tx.Inventory.UpdateOneID(inv.ID).SetQualityStatus("合格").Save(ctx0()); err != nil { fail(w, http.StatusInternalServerError, "更新库存状态失败: "+err.Error()) return } case "SCRAP": if _, err = tx.Inventory.UpdateOneID(inv.ID).SetQualityStatus("不合格").SetStatus("报废").Save(ctx0()); err != nil { fail(w, http.StatusInternalServerError, "更新库存状态失败: "+err.Error()) return } case "PARTIAL": if inv.ManageMode != 1 { fail(w, http.StatusBadRequest, "部分入库仅适用于批次管理物料(结构件)") return } total := inv.Quantity if rec.PassQty <= 0 || rec.PassQty >= total { fail(w, http.StatusBadRequest, fmt.Sprintf("部分入库:合格数须在 0 与批次数量(%d) 之间", total)) return } bad := total - rec.PassQty if _, err = tx.Inventory.UpdateOneID(inv.ID). SetQuantity(rec.PassQty).SetQualityStatus("合格").Save(ctx0()); err != nil { fail(w, http.StatusInternalServerError, "拆分合格批次失败: "+err.Error()) return } if _, err = tx.Inventory.Create(). SetManageMode(1). SetCategory(inv.Category). SetMaterialCode(inv.MaterialCode). SetNillableMaterialName(strPtr(inv.MaterialName)). SetBatchNo(inv.BatchNo + "-NG"). SetQuantity(bad).SetLockedQty(0). SetQualityStatus("不合格"). SetNillableZoneCode(strPtr(inv.ZoneCode)). SetStatus("在库"). SetNillableInboundNo(strPtr(inv.InboundNo)). SetRemark("不合格隔离(部分入库差额)"). 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(), "inspection.dispose", r.Header.Get("X-Username"), "inspection", rec.TargetID, "不合格处置 "+rec.TargetID+" → "+dt, map[string]any{"recordId": rec.ID, "disposalType": dt}) ok(w, map[string]any{"recordId": rec.ID, "disposalType": dt}) } } // statsInspectionHandler 检验统计:按物料汇总检验次数/合格数/不合格数/合格率 // 对应《问题记录》附二9 的替代方案(不合并记录,另出统计视图) func statsInspectionHandler(ctx *svc.ServiceContext) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { inspectionType := r.URL.Query().Get("inspectionType") materialCode := r.URL.Query().Get("materialCode") // 需求13:统计先选物料,按物料聚焦 startDate := r.URL.Query().Get("startDate") endDate := r.URL.Query().Get("endDate") q := ctx.EntClient.InspectionRecord.Query() if inspectionType != "" { q = q.Where(inspectionrecord.InspectionTypeEQ(inspectionType)) } if materialCode != "" { q = q.Where(inspectionrecord.MaterialCodeContainsFold(materialCode)) } if startDate != "" { if t, err := time.ParseInLocation("2006-01-02", startDate, time.Local); err == nil { q = q.Where(inspectionrecord.CreatedAtGTE(t.Unix())) } } if endDate != "" { if t, err := time.ParseInLocation("2006-01-02", endDate, time.Local); err == nil { q = q.Where(inspectionrecord.CreatedAtLTE(t.Add(24 * time.Hour).Unix())) } } recs, err := q.All(ctx0()) if err != nil { fail(w, http.StatusInternalServerError, err.Error()) return } type statRow struct { MaterialCode string `json:"materialCode"` Total int `json:"total"` Pass int `json:"pass"` Fail int `json:"fail"` PassRate float64 `json:"passRate"` } agg := map[string]*statRow{} order := []string{} for _, rec := range recs { r, found := agg[rec.MaterialCode] if !found { r = &statRow{MaterialCode: rec.MaterialCode} agg[rec.MaterialCode] = r order = append(order, rec.MaterialCode) } r.Total++ if rec.Status == "合格" { r.Pass++ } else if rec.Status == "不合格" { r.Fail++ } } list := make([]*statRow, 0, len(agg)) for _, code := range order { r := agg[code] if r.Total > 0 { r.PassRate = float64(int(float64(r.Pass)/float64(r.Total)*10000)) / 100 } list = append(list, r) } ok(w, map[string]any{"list": list}) } } // materialCodesByItemType 按品类(itemType)取物料编码集合(来料检验「其他」类过滤用,B1)。 // 先查 material 取编码集,再 IN 查 inventory/inspection_record,避免逐行 join(同 materialCodesBySpec 套路)。 func materialCodesByItemType(ctx *svc.ServiceContext, itemType int) []string { mats, err := ctx.EntClient.Material.Query().Where(material.ItemTypeEQ(itemType)).All(ctx0()) if err != nil { return nil } codes := make([]string, 0, len(mats)) for _, m := range mats { codes = append(codes, m.Code) } return codes } // inboundNosByType 取符合入库类型的入库单号集合(待检清单按入库类型过滤用,B1)。 func inboundNosByType(ctx *svc.ServiceContext, inboundType string) ([]string, error) { orders, err := ctx.EntClient.InboundOrder.Query().Where(inboundorder.InboundTypeEQ(inboundType)).All(ctx0()) if err != nil { return nil, err } nos := make([]string, 0, len(orders)) for _, o := range orders { if o.InboundNo != "" { nos = append(nos, o.InboundNo) } } return nos, nil } // 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 行数) DetailsTotal int `json:"detailsTotal"` // 未检批次数/SN 总数(抽屉分页拉明细) InboundTime int64 `json:"inboundTime"` } // pendingInspectionHandler 待检清单(来料检验问题2):列出 quality_status=未检 的库存行, // 按入库单为主行分组(展开看该单下批次/SN 明细),支持 物料/入库类型/目标类型(结构件·电气件·其他) 筛选。 // 勾选带入录入页时,归属/入库类型/物料一并从入库单与库存行回填(§E1/E2)。 // GET /api/inspection/pending?materialCode=&inboundType=&targetType=&page=&pageSize= func pendingInspectionHandler(ctx *svc.ServiceContext) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { materialCode := r.URL.Query().Get("materialCode") inboundType := r.URL.Query().Get("inboundType") // purchase/semi/finished/return/other targetType := r.URL.Query().Get("targetType") // BATCH(结构件)/SN(电气件)/OTHER(其他) page := atoi(r.URL.Query().Get("page"), 1) pageSize := atoi(r.URL.Query().Get("pageSize"), 20) q := ctx.EntClient.Inventory.Query(). Where(inventory.StatusIn("在库", "锁定"), inventory.QualityStatusEQ("未检")) if materialCode != "" { q = q.Where(inventory.MaterialCodeContainsFold(materialCode)) } switch targetType { case "BATCH": q = q.Where(inventory.ManageModeEQ(1)) case "SN": q = q.Where(inventory.ManageModeEQ(2)) case "OTHER": // 其他入库物料:itemType=4(先取编码集再 IN,避免逐行 join) codes := materialCodesByItemType(ctx, 4) if len(codes) == 0 { ok(w, map[string]any{"list": []any{}, "total": 0}) return } q = q.Where(inventory.MaterialCodeIn(codes...)) } if inboundType != "" { // 入库类型在 inbound_orders 表,先取符合 inbound_type 的 inbound_no 集再 IN nos, err := inboundNosByType(ctx, inboundType) if err != nil || len(nos) == 0 { ok(w, map[string]any{"list": []any{}, "total": 0}) return } q = q.Where(inventory.InboundNoIn(nos...)) } all, err := q.Order(ent.Desc("created_at"), ent.Desc("id")).All(ctx0()) if err != nil { fail(w, http.StatusInternalServerError, err.Error()) return } // 入库单维度信息(类型/归属/入库时间)map inboundNos := []string{} seenNo := map[string]bool{} for _, inv := range all { if inv.InboundNo != "" && !seenNo[inv.InboundNo] { seenNo[inv.InboundNo] = true inboundNos = append(inboundNos, inv.InboundNo) } } inboundMap := map[string]*ent.InboundOrder{} if len(inboundNos) > 0 { if orders, e := ctx.EntClient.InboundOrder.Query().Where(inboundorder.InboundNoIn(inboundNos...)).All(ctx0()); e == nil { for _, o := range orders { inboundMap[o.InboundNo] = o } } } // 物料名称 map matName := map[string]string{} codeSet := []string{} seenC := map[string]bool{} for _, inv := range all { if !seenC[inv.MaterialCode] { seenC[inv.MaterialCode] = true codeSet = append(codeSet, inv.MaterialCode) } } if len(codeSet) > 0 { if mats, e := ctx.EntClient.Material.Query().Where(material.CodeIn(codeSet...)).All(ctx0()); e == nil { for _, m := range mats { matName[m.Code] = m.Name } } } // 分组:主行 = (入库单号, 物料编码) group := map[string]*pendingInspectionRow{} order := []string{} for _, inv := range all { o := inboundMap[inv.InboundNo] key := inv.InboundNo + "|" + inv.MaterialCode g, ok := group[key] if !ok { ownType, ownNo := "", "" if o != nil { ownType = o.OwnershipType ownNo = o.OwnershipNo } g = &pendingInspectionRow{ InboundNo: inv.InboundNo, InboundType: strOrEmpty(o), OwnershipType: ownershipTypeOrNone(ownType), OwnershipNo: ownNo, MaterialCode: inv.MaterialCode, MaterialName: matName[inv.MaterialCode], } group[key] = g order = append(order, key) } qty := 1 if inv.ManageMode == 1 { qty = inv.Quantity } g.UnInspectedQty += qty g.DetailsTotal++ if o != nil && o.CreatedAt > g.InboundTime { g.InboundTime = o.CreatedAt } } total := len(order) start := (page - 1) * pageSize if start < 0 { start = 0 } end := start + pageSize if end > total { end = total } rows := make([]*pendingInspectionRow, 0, end-start) for i := start; i < end; i++ { rows = append(rows, group[order[i]]) } ok(w, map[string]any{"list": rows, "total": total, "page": page, "pageSize": pageSize}) } } // strOrEmpty 取入库单的入库类型(nil 安全) func strOrEmpty(o *ent.InboundOrder) string { if o == nil { return "" } 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}) } }