1. 新增出库单检测单号字段并添加索引支持溯源 2. 优化基础数据页面物料类型展示与选择逻辑 3. 升级入库单导入功能,支持自动生成单号并返回导入结果 4. 优化接驳台查询排序方式 5. 修复字符串拼接空格问题
906 lines
32 KiB
Go
906 lines
32 KiB
Go
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})
|
||
}
|
||
}
|