Files
bj_power/bj_power_wms/internal/handler/inspection.go
T
SunYF 4a5e2d7bac feat: 完成多模块迭代更新
- 新增过程巡检按步骤上报、数量不符上报、工位退料功能
- 增加工单工程编号三级归属字段
- 新增物料安全库存与缺货预警
- 新增附件管理、退库单管理模块
- 优化生产看板展示逻辑与前端页面文案
- 清理冗余的工艺路线相关代码与备份文件
2026-09-15 16:37:39 +08:00

584 lines
20 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"
"time"
"bj_power_wms/ent"
"bj_power_wms/ent/inspectionrecord"
"bj_power_wms/ent/inventory"
"bj_power_wms/internal/svc"
"github.com/zeromicro/go-zero/core/logx"
)
// 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)
}
// createInspectionHandler 创建检验记录
// body: { targetType: BATCH/SN, targetId, status, inspector }
// 同步库存检验状态:BATCH → inventory(manage_mode=1, batch_no)SN → inventory(manage_mode=2, sn_code)
func createInspectionHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req struct {
TargetType string `json:"targetType"`
TargetId string `json:"targetId"`
MaterialCode string `json:"materialCode"`
InspectionType string `json:"inspectionType"`
Status string `json:"status"` // 合格/不合格
ResultValue string `json:"resultValue"`
InspectQty int `json:"inspectQty"` // 检验数量
PassQty int `json:"passQty"` // 合格数量
CheckDesc string `json:"checkDesc"` // 检测说明
ReportNo string `json:"reportNo"` // 报检单号(生产报检关联)
Inspector string `json:"inspector"`
Remark string `json:"remark"`
InspectionNo string `json:"inspectionNo"`
DisposalType string `json:"disposalType"` // NONE/RETURN/SCRAP/PARTIAL
DisposalRemark string `json:"disposalRemark"`
ReturnTrackingNo string `json:"returnTrackingNo"`
}
if err := parseJSON(r, &req); err != nil {
fail(w, http.StatusBadRequest, "参数错误")
return
}
if req.TargetId == "" || req.Status == "" {
fail(w, http.StatusBadRequest, "targetId 和 status 必填")
return
}
if req.Status != "合格" && req.Status != "不合格" {
fail(w, http.StatusBadRequest, "status 只能为 合格/不合格")
return
}
// 定位真实库存行:自动按 批次号/SN 解析(前端选错类型也能正确命中)
inv, err := resolveInspectionTarget(ctx, req.TargetId)
if err != nil {
fail(w, http.StatusBadRequest, err.Error())
return
}
// 物料编码必填字段由库存行回填,杜绝 missing required field
req.MaterialCode = inv.MaterialCode
req.TargetType = "BATCH"
if inv.ManageMode == 2 {
req.TargetType = "SN"
}
tx, err := ctx.EntClient.Tx(ctx0())
if err != nil {
fail(w, http.StatusInternalServerError, "开启事务失败: "+err.Error())
return
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback()
}
}()
rec, err := tx.InspectionRecord.Create().
SetTargetType(req.TargetType).
SetTargetID(req.TargetId).
SetMaterialCode(req.MaterialCode).
SetInspectionType(req.InspectionType).
SetStatus(req.Status).
SetNillableResultValue(strPtr(req.ResultValue)).
SetInspectQty(req.InspectQty).
SetPassQty(req.PassQty).
SetNillableCheckDesc(strPtr(req.CheckDesc)).
SetNillableInspector(strPtr(req.Inspector)).
SetNillableRemark(strPtr(req.Remark)).
SetNillableReportNo(strPtr(req.ReportNo)).
SetNillableInspectionNo(strPtr(req.InspectionNo)).
SetDisposalType(disposalTypeOrNone(req.DisposalType)).
SetNillableDisposalRemark(strPtr(req.DisposalRemark)).
SetNillableReturnTrackingNo(strPtr(req.ReturnTrackingNo)).
Save(ctx0())
if err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
// 同步库存检验状态:直接更新定位到的那条库存行(精确,绝不误伤)。
// 处置为"退货"时置 待退(隔离、禁止出库);否则不合格置 不合格(同样隔离)。
if _, err = tx.Inventory.UpdateOneID(inv.ID).
SetQualityStatus(qualityAfterInspection(req.Status, req.DisposalType)).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.create", r.Header.Get("X-Username"), "inspection", req.TargetId,
"质量检验 "+req.MaterialCode+" → "+req.Status, map[string]any{
"targetType": req.TargetType, "inspectQty": req.InspectQty, "passQty": req.PassQty})
logx.Infof("inspection: %s %s -> %s by %s", req.TargetType, req.TargetId, req.Status, req.Inspector)
ok(w, rec)
}
}
// batchFlipInspectionHandler 批量翻转检验状态
// body: { targetType: BATCH/SN, targetIds: [], status, inspector }
func batchFlipInspectionHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req struct {
TargetType string `json:"targetType"`
TargetIds []string `json:"targetIds"`
Status string `json:"status"`
InspectQty int `json:"inspectQty"` // 检验数量
PassQty int `json:"passQty"` // 合格数量
CheckDesc string `json:"checkDesc"` // 检测说明
Inspector string `json:"inspector"`
ReportNo string `json:"reportNo"` // 报检单号
InspectionNo string `json:"inspectionNo"` // 检测单号
DisposalType string `json:"disposalType"` // NONE/RETURN/SCRAP/PARTIAL
DisposalRemark string `json:"disposalRemark"` // 处置说明
ReturnTrackingNo string `json:"returnTrackingNo"` // 退货货运单号
}
if err := parseJSON(r, &req); err != nil {
fail(w, http.StatusBadRequest, "参数错误")
return
}
if len(req.TargetIds) == 0 || (req.Status != "合格" && req.Status != "不合格") {
fail(w, http.StatusBadRequest, "targetIds 和 status(合格/不合格) 必填")
return
}
// 预校验:所有编号必须能定位到库存行,任意一条找不到则整体拒绝(避免部分成功、假闭环)
type target struct {
id string
inv *ent.Inventory
targetType string
material string
}
targets := make([]target, 0, len(req.TargetIds))
for _, id := range req.TargetIds {
inv, err := resolveInspectionTarget(ctx, id)
if err != nil {
fail(w, http.StatusBadRequest, err.Error())
return
}
tt := "BATCH"
if inv.ManageMode == 2 {
tt = "SN"
}
targets = append(targets, target{id: id, inv: inv, targetType: tt, material: inv.MaterialCode})
}
tx, err := ctx.EntClient.Tx(ctx0())
if err != nil {
fail(w, http.StatusInternalServerError, "开启事务失败: "+err.Error())
return
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback()
}
}()
flipped := 0
for _, t := range targets {
if _, err = tx.InspectionRecord.Create().
SetTargetType(t.targetType).
SetTargetID(t.id).
SetMaterialCode(t.material).
SetStatus(req.Status).
SetInspectQty(req.InspectQty).
SetPassQty(req.PassQty).
SetNillableCheckDesc(strPtr(req.CheckDesc)).
SetNillableInspector(strPtr(req.Inspector)).
SetNillableReportNo(strPtr(req.ReportNo)).
SetNillableInspectionNo(strPtr(req.InspectionNo)).
SetDisposalType(disposalTypeOrNone(req.DisposalType)).
SetNillableDisposalRemark(strPtr(req.DisposalRemark)).
SetNillableReturnTrackingNo(strPtr(req.ReturnTrackingNo)).
Save(ctx0()); err != nil {
fail(w, http.StatusInternalServerError, "创建检验记录失败: "+err.Error())
return
}
if _, err = tx.Inventory.UpdateOneID(t.inv.ID).
SetQualityStatus(qualityAfterInspection(req.Status, req.DisposalType)).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")
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 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 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" {
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,
inspectionTypeLabel(rec.InspectionType), rec.Status,
rec.InspectQty, rec.PassQty, rec.CheckDesc, rec.ReportNo,
rec.Inspector, rec.Remark, unixFmt(rec.CreatedAt),
rec.InspectionNo,
})
}
sendExcel(w, xlsxFilename("检验记录"), headers, matrix)
return
}
ok(w, map[string]any{"list": list})
}
}
// disposalTypeOrNone 处置方式归一化(客户诉求 L137-145:退货/报废/部分入库)
func disposalTypeOrNone(v string) string {
switch v {
case "RETURN", "SCRAP", "PARTIAL":
return v
default:
return "NONE"
}
}
// qualityAfterInspection 检验后的库存质量状态:
// 合格 → 合格(可出库);不合格且选退货 → 待退(隔离);其余 → 不合格(隔离)。
func qualityAfterInspection(status, disposalType string) string {
if status == "合格" {
return "合格"
}
if disposalTypeOrNone(disposalType) == "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 "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")
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 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})
}
}