2026-08-28 15:06:01 +08:00
|
|
|
|
package handler
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
2026-09-03 14:20:37 +08:00
|
|
|
|
"fmt"
|
2026-08-28 15:06:01 +08:00
|
|
|
|
"net/http"
|
2026-09-03 14:20:37 +08:00
|
|
|
|
"time"
|
2026-08-28 15:06:01 +08:00
|
|
|
|
|
|
|
|
|
|
"bj_power_wms/ent"
|
|
|
|
|
|
"bj_power_wms/ent/inspectionrecord"
|
2026-09-02 08:21:11 +08:00
|
|
|
|
"bj_power_wms/ent/inventory"
|
2026-09-17 12:49:07 +08:00
|
|
|
|
"bj_power_wms/ent/material"
|
2026-08-28 15:06:01 +08:00
|
|
|
|
"bj_power_wms/internal/svc"
|
|
|
|
|
|
|
|
|
|
|
|
"github.com/zeromicro/go-zero/core/logx"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-09-03 14:20:37 +08:00
|
|
|
|
// 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
|
|
|
|
|
|
}
|
2026-09-10 16:59:15 +08:00
|
|
|
|
// 再按 SN(电气件)
|
2026-09-03 14:20:37 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-28 15:06:01 +08:00
|
|
|
|
// createInspectionHandler 创建检验记录
|
2026-09-03 14:20:37 +08:00
|
|
|
|
// body: { targetType: BATCH/SN, targetId, status, inspector }
|
2026-09-02 08:21:11 +08:00
|
|
|
|
// 同步库存检验状态:BATCH → inventory(manage_mode=1, batch_no);SN → inventory(manage_mode=2, sn_code)
|
2026-08-28 15:06:01 +08:00
|
|
|
|
func createInspectionHandler(ctx *svc.ServiceContext) http.HandlerFunc {
|
|
|
|
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
|
var req struct {
|
2026-09-15 16:37:39 +08:00
|
|
|
|
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"`
|
2026-09-17 12:49:07 +08:00
|
|
|
|
// 归属 / 相关标准 / 生产厂家(客户诉求 问题记录 L107/L149/L494)
|
|
|
|
|
|
OwnershipType string `json:"ownershipType"` // workorder/project/other
|
|
|
|
|
|
OwnershipNo string `json:"ownershipNo"`
|
|
|
|
|
|
RelatedStandard string `json:"relatedStandard"` // 相关标准(检验细则号)
|
|
|
|
|
|
Manufacturer string `json:"manufacturer"` // 生产厂家
|
2026-08-28 15:06:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-03 14:20:37 +08:00
|
|
|
|
// 定位真实库存行:自动按 批次号/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"
|
|
|
|
|
|
}
|
2026-09-17 12:49:07 +08:00
|
|
|
|
// 物料名称冗余(列表直接展示/名称模糊查询,客户诉求 L507-510 三框制)
|
|
|
|
|
|
materialName := inv.MaterialName
|
|
|
|
|
|
if materialName == "" {
|
|
|
|
|
|
if m, merr := ctx.EntClient.Material.Query().Where(material.CodeEQ(inv.MaterialCode)).First(ctx0()); merr == nil && m != nil {
|
|
|
|
|
|
materialName = m.Name
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-09-03 14:20:37 +08:00
|
|
|
|
|
2026-09-02 08:21:11 +08:00
|
|
|
|
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().
|
2026-08-28 15:06:01 +08:00
|
|
|
|
SetTargetType(req.TargetType).
|
|
|
|
|
|
SetTargetID(req.TargetId).
|
|
|
|
|
|
SetMaterialCode(req.MaterialCode).
|
2026-09-17 12:49:07 +08:00
|
|
|
|
SetNillableMaterialName(strPtr(materialName)).
|
|
|
|
|
|
SetNillableOwnershipType(strPtr(req.OwnershipType)).
|
|
|
|
|
|
SetNillableOwnershipNo(strPtr(req.OwnershipNo)).
|
|
|
|
|
|
SetNillableRelatedStandard(strPtr(req.RelatedStandard)).
|
|
|
|
|
|
SetNillableManufacturer(strPtr(req.Manufacturer)).
|
2026-08-28 15:06:01 +08:00
|
|
|
|
SetInspectionType(req.InspectionType).
|
|
|
|
|
|
SetStatus(req.Status).
|
|
|
|
|
|
SetNillableResultValue(strPtr(req.ResultValue)).
|
2026-09-03 14:20:37 +08:00
|
|
|
|
SetInspectQty(req.InspectQty).
|
|
|
|
|
|
SetPassQty(req.PassQty).
|
|
|
|
|
|
SetNillableCheckDesc(strPtr(req.CheckDesc)).
|
2026-08-28 15:06:01 +08:00
|
|
|
|
SetNillableInspector(strPtr(req.Inspector)).
|
|
|
|
|
|
SetNillableRemark(strPtr(req.Remark)).
|
2026-09-10 16:59:15 +08:00
|
|
|
|
SetNillableReportNo(strPtr(req.ReportNo)).
|
|
|
|
|
|
SetNillableInspectionNo(strPtr(req.InspectionNo)).
|
2026-09-15 16:37:39 +08:00
|
|
|
|
SetDisposalType(disposalTypeOrNone(req.DisposalType)).
|
|
|
|
|
|
SetNillableDisposalRemark(strPtr(req.DisposalRemark)).
|
|
|
|
|
|
SetNillableReturnTrackingNo(strPtr(req.ReturnTrackingNo)).
|
2026-08-28 15:06:01 +08:00
|
|
|
|
Save(ctx0())
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
fail(w, http.StatusInternalServerError, err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-15 16:37:39 +08:00
|
|
|
|
// 同步库存检验状态:直接更新定位到的那条库存行(精确,绝不误伤)。
|
|
|
|
|
|
// 处置为"退货"时置 待退(隔离、禁止出库);否则不合格置 不合格(同样隔离)。
|
2026-09-03 14:20:37 +08:00
|
|
|
|
if _, err = tx.Inventory.UpdateOneID(inv.ID).
|
2026-09-15 16:37:39 +08:00
|
|
|
|
SetQualityStatus(qualityAfterInspection(req.Status, req.DisposalType)).Save(ctx0()); err != nil {
|
2026-09-02 08:21:11 +08:00
|
|
|
|
fail(w, http.StatusInternalServerError, "同步库存检验状态失败: "+err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if err = tx.Commit(); err != nil {
|
|
|
|
|
|
fail(w, http.StatusInternalServerError, "提交事务失败: "+err.Error())
|
|
|
|
|
|
return
|
2026-08-28 15:06:01 +08:00
|
|
|
|
}
|
2026-09-02 08:21:11 +08:00
|
|
|
|
committed = true
|
2026-08-28 15:06:01 +08:00
|
|
|
|
|
2026-09-04 14:18:53 +08:00
|
|
|
|
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})
|
2026-08-28 15:06:01 +08:00
|
|
|
|
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 {
|
2026-09-15 16:37:39 +08:00
|
|
|
|
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"` // 退货货运单号
|
2026-09-17 12:49:07 +08:00
|
|
|
|
OwnershipType string `json:"ownershipType"`
|
|
|
|
|
|
OwnershipNo string `json:"ownershipNo"`
|
|
|
|
|
|
RelatedStandard string `json:"relatedStandard"`
|
|
|
|
|
|
Manufacturer string `json:"manufacturer"`
|
2026-08-28 15:06:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-03 14:20:37 +08:00
|
|
|
|
// 预校验:所有编号必须能定位到库存行,任意一条找不到则整体拒绝(避免部分成功、假闭环)
|
|
|
|
|
|
type target struct {
|
2026-09-10 16:59:15 +08:00
|
|
|
|
id string
|
|
|
|
|
|
inv *ent.Inventory
|
2026-09-03 14:20:37 +08:00
|
|
|
|
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})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-02 08:21:11 +08:00
|
|
|
|
tx, err := ctx.EntClient.Tx(ctx0())
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
fail(w, http.StatusInternalServerError, "开启事务失败: "+err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
committed := false
|
|
|
|
|
|
defer func() {
|
|
|
|
|
|
if !committed {
|
|
|
|
|
|
_ = tx.Rollback()
|
|
|
|
|
|
}
|
|
|
|
|
|
}()
|
|
|
|
|
|
|
2026-08-28 15:06:01 +08:00
|
|
|
|
flipped := 0
|
2026-09-03 14:20:37 +08:00
|
|
|
|
for _, t := range targets {
|
2026-09-17 12:49:07 +08:00
|
|
|
|
matName := t.inv.MaterialName
|
|
|
|
|
|
if matName == "" {
|
|
|
|
|
|
if m, merr := tx.Material.Query().Where(material.CodeEQ(t.material)).First(ctx0()); merr == nil && m != nil {
|
|
|
|
|
|
matName = m.Name
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-09-02 08:21:11 +08:00
|
|
|
|
if _, err = tx.InspectionRecord.Create().
|
2026-09-03 14:20:37 +08:00
|
|
|
|
SetTargetType(t.targetType).
|
|
|
|
|
|
SetTargetID(t.id).
|
|
|
|
|
|
SetMaterialCode(t.material).
|
2026-09-17 12:49:07 +08:00
|
|
|
|
SetNillableMaterialName(strPtr(matName)).
|
|
|
|
|
|
SetNillableOwnershipType(strPtr(req.OwnershipType)).
|
|
|
|
|
|
SetNillableOwnershipNo(strPtr(req.OwnershipNo)).
|
|
|
|
|
|
SetNillableRelatedStandard(strPtr(req.RelatedStandard)).
|
|
|
|
|
|
SetNillableManufacturer(strPtr(req.Manufacturer)).
|
2026-08-28 15:06:01 +08:00
|
|
|
|
SetStatus(req.Status).
|
2026-09-03 14:20:37 +08:00
|
|
|
|
SetInspectQty(req.InspectQty).
|
|
|
|
|
|
SetPassQty(req.PassQty).
|
|
|
|
|
|
SetNillableCheckDesc(strPtr(req.CheckDesc)).
|
2026-08-28 15:06:01 +08:00
|
|
|
|
SetNillableInspector(strPtr(req.Inspector)).
|
2026-09-15 16:37:39 +08:00
|
|
|
|
SetNillableReportNo(strPtr(req.ReportNo)).
|
|
|
|
|
|
SetNillableInspectionNo(strPtr(req.InspectionNo)).
|
|
|
|
|
|
SetDisposalType(disposalTypeOrNone(req.DisposalType)).
|
|
|
|
|
|
SetNillableDisposalRemark(strPtr(req.DisposalRemark)).
|
|
|
|
|
|
SetNillableReturnTrackingNo(strPtr(req.ReturnTrackingNo)).
|
2026-09-02 08:21:11 +08:00
|
|
|
|
Save(ctx0()); err != nil {
|
|
|
|
|
|
fail(w, http.StatusInternalServerError, "创建检验记录失败: "+err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-09-03 14:20:37 +08:00
|
|
|
|
if _, err = tx.Inventory.UpdateOneID(t.inv.ID).
|
2026-09-15 16:37:39 +08:00
|
|
|
|
SetQualityStatus(qualityAfterInspection(req.Status, req.DisposalType)).Save(ctx0()); err != nil {
|
2026-09-02 08:21:11 +08:00
|
|
|
|
fail(w, http.StatusInternalServerError, "同步库存检验状态失败: "+err.Error())
|
|
|
|
|
|
return
|
2026-08-28 15:06:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
flipped++
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-02 08:21:11 +08:00
|
|
|
|
if err = tx.Commit(); err != nil {
|
|
|
|
|
|
fail(w, http.StatusInternalServerError, "提交事务失败: "+err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
committed = true
|
|
|
|
|
|
|
2026-08-28 15:06:01 +08:00
|
|
|
|
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")
|
2026-09-03 14:20:37 +08:00
|
|
|
|
inspector := r.URL.Query().Get("inspector")
|
|
|
|
|
|
startDate := r.URL.Query().Get("startDate")
|
|
|
|
|
|
endDate := r.URL.Query().Get("endDate")
|
2026-09-08 11:44:04 +08:00
|
|
|
|
startDate, endDate, rerr := normalizeExportRange(startDate, endDate)
|
|
|
|
|
|
if rerr != nil {
|
|
|
|
|
|
fail(w, http.StatusBadRequest, rerr.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-08-28 15:06:01 +08:00
|
|
|
|
|
|
|
|
|
|
q := ctx.EntClient.InspectionRecord.Query()
|
|
|
|
|
|
if targetId != "" {
|
2026-09-08 11:44:04 +08:00
|
|
|
|
q = q.Where(inspectionrecord.TargetIDContainsFold(targetId))
|
2026-08-28 15:06:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
if materialCode != "" {
|
2026-09-08 11:44:04 +08:00
|
|
|
|
q = q.Where(inspectionrecord.MaterialCodeContainsFold(materialCode))
|
2026-08-28 15:06:01 +08:00
|
|
|
|
}
|
2026-09-17 12:49:07 +08:00
|
|
|
|
// 名称模糊 / 归属单号(客户诉求 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("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...))
|
|
|
|
|
|
}
|
2026-08-28 15:06:01 +08:00
|
|
|
|
if inspectionType != "" {
|
|
|
|
|
|
q = q.Where(inspectionrecord.InspectionTypeEQ(inspectionType))
|
|
|
|
|
|
}
|
|
|
|
|
|
if status != "" {
|
|
|
|
|
|
q = q.Where(inspectionrecord.StatusEQ(status))
|
|
|
|
|
|
}
|
2026-09-03 14:20:37 +08:00
|
|
|
|
if inspector != "" {
|
2026-09-08 11:44:04 +08:00
|
|
|
|
q = q.Where(inspectionrecord.InspectorContainsFold(inspector))
|
2026-09-03 14:20:37 +08:00
|
|
|
|
}
|
|
|
|
|
|
// 时间筛选(默认近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()))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-08-28 15:06:01 +08:00
|
|
|
|
|
|
|
|
|
|
total, err := q.Count(ctx0())
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
fail(w, http.StatusInternalServerError, err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-09-03 18:13:59 +08:00
|
|
|
|
list, err := q.Order(ent.Desc("created_at"), ent.Desc("id")).
|
2026-08-28 15:06:01 +08:00
|
|
|
|
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,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-09-03 14:20:37 +08:00
|
|
|
|
|
|
|
|
|
|
// 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")
|
2026-09-08 11:44:04 +08:00
|
|
|
|
startDate, endDate, rerr := normalizeExportRange(startDate, endDate)
|
|
|
|
|
|
if rerr != nil {
|
|
|
|
|
|
fail(w, http.StatusBadRequest, rerr.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-09-03 14:20:37 +08:00
|
|
|
|
|
|
|
|
|
|
q := ctx.EntClient.InspectionRecord.Query()
|
|
|
|
|
|
if targetId != "" {
|
2026-09-08 11:44:04 +08:00
|
|
|
|
q = q.Where(inspectionrecord.TargetIDContainsFold(targetId))
|
2026-09-03 14:20:37 +08:00
|
|
|
|
}
|
|
|
|
|
|
if materialCode != "" {
|
2026-09-08 11:44:04 +08:00
|
|
|
|
q = q.Where(inspectionrecord.MaterialCodeContainsFold(materialCode))
|
2026-09-03 14:20:37 +08:00
|
|
|
|
}
|
2026-09-17 12:49:07 +08:00
|
|
|
|
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("spec"); v != "" {
|
|
|
|
|
|
codes := materialCodesBySpec(ctx, v)
|
|
|
|
|
|
if len(codes) == 0 {
|
|
|
|
|
|
ok(w, map[string]any{"list": []any{}})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
q = q.Where(inspectionrecord.MaterialCodeIn(codes...))
|
|
|
|
|
|
}
|
2026-09-03 14:20:37 +08:00
|
|
|
|
if inspectionType != "" {
|
|
|
|
|
|
q = q.Where(inspectionrecord.InspectionTypeEQ(inspectionType))
|
|
|
|
|
|
}
|
|
|
|
|
|
if status != "" {
|
|
|
|
|
|
q = q.Where(inspectionrecord.StatusEQ(status))
|
|
|
|
|
|
}
|
|
|
|
|
|
if inspector != "" {
|
2026-09-08 11:44:04 +08:00
|
|
|
|
q = q.Where(inspectionrecord.InspectorContainsFold(inspector))
|
2026-09-03 14:20:37 +08:00
|
|
|
|
}
|
|
|
|
|
|
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()))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-03 18:13:59 +08:00
|
|
|
|
list, err := q.Order(ent.Desc("created_at"), ent.Desc("id")).All(ctx0())
|
2026-09-03 14:20:37 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
fail(w, http.StatusInternalServerError, err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
// xlsx 导出
|
|
|
|
|
|
if r.URL.Query().Get("format") == "xlsx" {
|
2026-09-17 12:49:07 +08:00
|
|
|
|
// 列名按客户口径(问题记录 L150):检验说明→相关标准、检验员→记录人、检验日期→记录日期、增加归属;
|
|
|
|
|
|
// 图号=物料编码(L18 合并裁决)。
|
|
|
|
|
|
headers := []string{"ID", "对象", "批次/SN", "图号", "名称", "检验类型", "结论", "检验数", "合格数", "相关标准", "报检单号", "检测单号", "归属单号", "生产厂家", "记录人", "备注", "记录日期"}
|
2026-09-03 14:20:37 +08:00
|
|
|
|
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{
|
2026-09-17 12:49:07 +08:00
|
|
|
|
rec.ID, obj, rec.TargetID, rec.MaterialCode, rec.MaterialName,
|
2026-09-03 14:20:37 +08:00
|
|
|
|
inspectionTypeLabel(rec.InspectionType), rec.Status,
|
2026-09-17 12:49:07 +08:00
|
|
|
|
rec.InspectQty, rec.PassQty, rec.RelatedStandard, rec.ReportNo,
|
|
|
|
|
|
rec.InspectionNo, rec.OwnershipNo, rec.Manufacturer, rec.Inspector, rec.Remark,
|
|
|
|
|
|
unixFmt(rec.CreatedAt),
|
2026-09-03 14:20:37 +08:00
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
sendExcel(w, xlsxFilename("检验记录"), headers, matrix)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
ok(w, map[string]any{"list": list})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-09-10 16:59:15 +08:00
|
|
|
|
|
2026-09-17 12:49:07 +08:00
|
|
|
|
// 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-15 16:37:39 +08:00
|
|
|
|
// 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})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-10 16:59:15 +08:00
|
|
|
|
// 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})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|