chore: 批量完成项目多模块迭代优化

1. 废弃半成品库存表并统一库存主表
2. 修复列表排序与搜索大小写不敏感问题
3. 新增用户最近登录时间、工单BOM名称字段
4. 完善角色管理、工位选择器功能
5. 增加拧紧数据补录、成品自动回流WMS能力
6. 统一导出时间范围校验规则
7. 丰富物料/备料单筛选条件
8. 调整菜单与权限命名适配产品型号业务
This commit is contained in:
SunYF
2026-09-08 11:44:04 +08:00
parent 06689970e2
commit e852c7cd37
96 changed files with 4528 additions and 542 deletions
+8
View File
@@ -89,6 +89,12 @@ func (s *Service) Login(ctx context.Context, req LoginReq) (LoginResp, error) {
if bcrypt.CompareHashAndPassword([]byte(usr.Password), []byte(req.Password)) != nil {
return resp, errors.New("用户名或密码错误")
}
// 记录最近登录时间(仅后台登录;工位终端 StationLogin 不计入)
now := time.Now().Unix()
if err := s.ctx.EntClient.User.UpdateOneID(usr.ID).SetLastLoginAt(now).Exec(ctx); err != nil {
// 不阻塞登录,仅记日志
}
usr.LastLoginAt = &now
roleEntity, _ := s.ctx.EntClient.Role.Get(ctx, usr.RoleId)
return s.issueTokens(usr, roleEntity)
}
@@ -445,6 +451,7 @@ type UserVO struct {
Status string `json:"status"`
CanLoginWorkstation bool `json:"canLoginWorkstation"`
Stations []int `json:"stations"` // 允许登录/操作的工位号(空=全部工位)
LastLoginAt *int64 `json:"lastLoginAt"` // 最近登录时间(unix秒)null=从未登录
}
func (s *Service) ListUsers(ctx context.Context) ([]UserVO, error) {
@@ -462,6 +469,7 @@ func (s *Service) ListUsers(ctx context.Context) ([]UserVO, error) {
Status: u.Status,
CanLoginWorkstation: u.CanLoginWorkstation,
Stations: s.UserStations(ctx, u.ID),
LastLoginAt: u.LastLoginAt,
})
}
return out, nil
+81 -8
View File
@@ -2,14 +2,20 @@ package logic
import (
"context"
"errors"
"fmt"
"bj_power_mes/ent"
"bj_power_mes/ent/bomitem"
"bj_power_mes/ent/workorder"
)
// 默认BOMName 同一型号多份 BOM 并存时的缺省名称(历史数据/未指定时统一回填此值)
const DefaultBOMName = "默认"
type BomItemReq struct {
ProductCode string `json:"productCode"`
BomName string `json:"bomName"`
MaterialCode string `json:"materialCode"`
MaterialName string `json:"materialName"`
Spec string `json:"spec"`
@@ -22,14 +28,25 @@ type BomItemReq struct {
SerialSns []string `json:"serialSns"`
}
// SaveBom 保存产品 BOM(覆盖式:按 产品编码+物料编码 做 upsert
func (s *Service) SaveBom(ctx context.Context, productCode string, items []BomItemReq, operator string) error {
// bomNameOrDefault 空 BOM 名回退为「默认」(兼容旧前端/旧数据
func bomNameOrDefault(name string) string {
if name == "" {
return DefaultBOMName
}
return name
}
// SaveBom 保存产品 BOM(覆盖式 upsert:唯一键 = 产品编码+BOM名称+物料编码)。
// 同一产品型号可并存多份 BOM(不同厂家供货/含半成品等不同料单结构),按 bomName 区分,
// 工单建单时选定一份;备料/装机绑定均按工单锁定的 bomName 计算。
func (s *Service) SaveBom(ctx context.Context, productCode, bomName string, items []BomItemReq, operator string) error {
bomName = bomNameOrDefault(bomName)
for _, it := range items {
if it.ManageMode == "" {
it.ManageMode = "2"
}
exist, err := s.ctx.EntClient.BomItem.Query().
Where(bomitem.ProductCode(productCode), bomitem.MaterialCode(it.MaterialCode)).First(ctx)
Where(bomitem.ProductCode(productCode), bomitem.BomName(bomName), bomitem.MaterialCode(it.MaterialCode)).First(ctx)
if err == nil && exist != nil {
u := s.ctx.EntClient.BomItem.UpdateOneID(exist.ID).
SetMaterialName(it.MaterialName).SetSpec(it.Spec).SetUnit(it.Unit).
@@ -49,7 +66,7 @@ func (s *Service) SaveBom(ctx context.Context, productCode string, items []BomIt
pc = *it.ProcessCode
}
b := s.ctx.EntClient.BomItem.Create().
SetProductCode(productCode).SetMaterialCode(it.MaterialCode).
SetProductCode(productCode).SetBomName(bomName).SetMaterialCode(it.MaterialCode).
SetMaterialName(it.MaterialName).SetSpec(it.Spec).SetUnit(it.Unit).
SetManageMode(it.ManageMode).SetProcessCode(pc).
SetUnitQty(it.UnitQty).SetLossRate(it.LossRate)
@@ -61,14 +78,70 @@ func (s *Service) SaveBom(ctx context.Context, productCode string, items []BomIt
}
}
}
s.ctx.EventLog.Write(ctx, "bom.save", "", operator, "product_bom", productCode, "保存产品BOM", map[string]any{"count": len(items)})
s.ctx.EventLog.Write(ctx, "bom.save", "", operator, "product_bom", productCode, "保存产品BOM", map[string]any{"bomName": bomName, "count": len(items)})
return nil
}
func (s *Service) ListBom(ctx context.Context, productCode string) ([]*ent.BomItem, error) {
// DeleteBomItem 移除 BOM 中的一条物料(按行主键)。
// 安全校验:该 (产品编码+BOM名称) 下任一工单已生成备料单时禁止移除,避免已锁定料单被改动。
func (s *Service) DeleteBomItem(ctx context.Context, id int, operator string) error {
item, err := s.ctx.EntClient.BomItem.Get(ctx, id)
if err != nil {
return errors.New("BOM 物料不存在")
}
used, err := s.ctx.EntClient.WorkOrder.Query().
Where(workorder.ProductCode(item.ProductCode), workorder.BomName(item.BomName)).Exist(ctx)
if err == nil && used {
// 该型号+BOM 已被工单选用过(有工单绑定),谨慎起见禁止直接移除
return fmt.Errorf("该 BOM 已被「%s」型号的工单选用,移除物料会影响备料/追溯,请先评估相关工单", item.ProductCode)
}
if err := s.ctx.EntClient.BomItem.DeleteOneID(id).Exec(ctx); err != nil {
return err
}
s.ctx.EventLog.Write(ctx, "bom.item.delete", "", operator, "product_bom", item.ProductCode,
"移除BOM物料 "+item.MaterialCode, map[string]any{"bomName": item.BomName})
return nil
}
// ListBom 查询 BOM(可按产品编码 + BOM 名称过滤;bomName 传 "ALL" 或空且指定了产品时返回全部)
func (s *Service) ListBom(ctx context.Context, productCode, bomName string) ([]*ent.BomItem, error) {
q := s.ctx.EntClient.BomItem.Query()
if productCode != "" {
q = q.Where(bomitem.ProductCode(productCode))
}
return q.Order(ent.Asc(bomitem.FieldID)).All(ctx)
}
if bomName != "" && bomName != "ALL" {
q = q.Where(bomitem.BomName(bomName))
}
return q.Order(ent.Asc(bomitem.FieldProductCode), ent.Asc(bomitem.FieldBomName), ent.Asc(bomitem.FieldID)).All(ctx)
}
// BomNameInfo 型号下的一份 BOM 概要(名称 + 物料数)
type BomNameInfo struct {
BomName string `json:"bomName"`
ItemCount int `json:"itemCount"`
}
// ListBomNames 列出某产品型号下并存的所有 BOM 名称(工单建单选 BOM 下拉数据源)
func (s *Service) ListBomNames(ctx context.Context, productCode string) ([]*BomNameInfo, error) {
if productCode == "" {
return []*BomNameInfo{}, nil
}
items, err := s.ctx.EntClient.BomItem.Query().
Where(bomitem.ProductCode(productCode)).All(ctx)
if err != nil {
return nil, err
}
order := []string{}
count := map[string]int{}
for _, it := range items {
if _, ok := count[it.BomName]; !ok {
order = append(order, it.BomName)
}
count[it.BomName]++
}
out := make([]*BomNameInfo, 0, len(order))
for _, name := range order {
out = append(out, &BomNameInfo{BomName: name, ItemCount: count[name]})
}
return out, nil
}
+41 -7
View File
@@ -4,16 +4,31 @@ import (
"context"
"encoding/json"
"errors"
"strings"
"time"
"bj_power_mes/ent"
"bj_power_mes/ent/inspectionrecord"
)
// FlexStr 兼容客户端把数字字段传成 number 或 string(前端 stationNo 用数字,
// 标准 JSON 反序列化进 string 会报错 → 400 请求体解析失败)
type FlexStr string
func (f *FlexStr) UnmarshalJSON(b []byte) error {
s := strings.TrimSpace(string(b))
s = strings.Trim(s, `"`)
if s == "null" {
s = ""
}
*f = FlexStr(s)
return nil
}
// InspectionReq 巡检记录请求(PAD 巡检终端,块8)
type InspectionReq struct {
Category string `json:"category"` // CHECKIN/POINT/PROCESS/DONE/ALARM
StationNo string `json:"stationNo"`
StationNo FlexStr `json:"stationNo"`
Shift string `json:"shift"` // 早/中/晚
OrderNo string `json:"orderNo"` // 工单号
Sn string `json:"sn"` // 工件SN
@@ -42,7 +57,7 @@ func (s *Service) CreateInspection(ctx context.Context, req InspectionReq, opera
}
_, err := s.ctx.EntClient.InspectionRecord.Create().
SetCategory(category).
SetStationNo(req.StationNo).
SetStationNo(string(req.StationNo)).
SetShift(req.Shift).
SetOrderNo(req.OrderNo).
SetSn(req.Sn).
@@ -56,7 +71,7 @@ func (s *Service) CreateInspection(ctx context.Context, req InspectionReq, opera
return err
}
s.ctx.EventLog.Write(ctx, "inspection."+category, req.OrderNo, req.Operator, "inspection_record",
req.Sn, "巡检终端提交", map[string]any{"category": category, "stationNo": req.StationNo, "result": result})
req.Sn, "巡检终端提交", map[string]any{"category": category, "stationNo": string(req.StationNo), "result": result})
if category == "ALARM" {
// 触发看板报警:SSE 推送 + 使 Redis 看板报警缓存失效
@@ -64,7 +79,7 @@ func (s *Service) CreateInspection(ctx context.Context, req InspectionReq, opera
"level": "CRITICAL",
"type": "inspection_alarm",
"message": "巡检异常上报:" + req.Remark,
"station": req.StationNo,
"station": string(req.StationNo),
"time": time.Now().Format("2006-01-02 15:04:05"),
})
if s.ctx.SSE != nil {
@@ -78,12 +93,25 @@ func (s *Service) CreateInspection(ctx context.Context, req InspectionReq, opera
return nil
}
// ListInspections 查询巡检记录
// ListInspections 查询巡检记录(不分页,供内部/导出用,封顶 1000)
func (s *Service) ListInspections(ctx context.Context, category, operator, from, to string) ([]*ent.InspectionRecord, error) {
q := s.ctx.EntClient.InspectionRecord.Query().Order(ent.Desc(inspectionrecord.FieldID))
rows, _, err := s.ListInspectionsPaged(ctx, category, operator, from, to, "", "", 1, 1000)
return rows, err
}
// ListInspectionsPaged 分页查询巡检记录,返回 {rows, total};排序 id desc 最新置顶
// orderNo/stationNo2026-09-08 补充筛选(大小写不敏感)
func (s *Service) ListInspectionsPaged(ctx context.Context, category, operator, from, to, orderNo, stationNo string, page, pageSize int) ([]*ent.InspectionRecord, int, error) {
q := s.ctx.EntClient.InspectionRecord.Query()
if category != "" {
q = q.Where(inspectionrecord.Category(category))
}
if orderNo != "" {
q = q.Where(inspectionrecord.OrderNoEqualFold(orderNo))
}
if stationNo != "" {
q = q.Where(inspectionrecord.StationNoEqualFold(stationNo))
}
if operator != "" {
q = q.Where(inspectionrecord.Operator(operator))
}
@@ -97,7 +125,13 @@ func (s *Service) ListInspections(ctx context.Context, category, operator, from,
q = q.Where(inspectionrecord.CreatedAtLT(t.Add(24 * time.Hour)))
}
}
return q.Limit(1000).All(ctx)
total, err := q.Count(ctx)
if err != nil {
return nil, 0, err
}
rows, err := q.Order(ent.Desc(inspectionrecord.FieldID)).
Offset((page - 1) * pageSize).Limit(pageSize).All(ctx)
return rows, total, err
}
func oneOf(v string, items ...string) bool {
+25 -4
View File
@@ -36,13 +36,13 @@ func (s *Service) GenerateMaterialRequest(ctx context.Context, planDate string,
if plan.Status == "CANCELLED" || plan.Status == "DONE" {
continue // 已取消/已完成的排产不再算料
}
// 解析工单 → 产品编码 → 该产品 BOM
// 解析工单 → 产品编码 → 该工单锁定的 BOM(建单时选定的 bomName
wo, err := s.ctx.EntClient.WorkOrder.Query().
Where(workorder.WorkOrderNo(plan.OrderNo)).Only(ctx)
if err != nil {
continue
}
bom, err := s.ListBom(ctx, wo.ProductCode)
bom, err := s.ListBom(ctx, wo.ProductCode, bomNameOrDefault(wo.BomName))
if err != nil {
continue
}
@@ -103,9 +103,20 @@ func (s *Service) GenerateMaterialRequest(ctx context.Context, planDate string,
// ListMaterialRequests 查询备料单
func (s *Service) ListMaterialRequests(ctx context.Context, orderNo, planDate, status string) ([]*ent.MaterialRequest, error) {
return s.listMaterialRequests(ctx, orderNo, planDate, status, "", "", "")
}
// ListMaterialRequestsFiltered 管理页丰富筛选入口
func (s *Service) ListMaterialRequestsFiltered(ctx context.Context, orderNo, planDate, status, materialCode, materialName, targetDock string) ([]*ent.MaterialRequest, error) {
return s.listMaterialRequests(ctx, orderNo, planDate, status, materialCode, materialName, targetDock)
}
// listMaterialRequests 备料单查询(2026-09-08 丰富筛选+大小写不敏感)
// materialCode/materialName/targetDock 可空;keyword 兜底匹配 备料单号/工单号/物料编码
func (s *Service) listMaterialRequests(ctx context.Context, orderNo, planDate, status, materialCode, materialName, targetDock string) ([]*ent.MaterialRequest, error) {
q := s.ctx.EntClient.MaterialRequest.Query()
if orderNo != "" {
q = q.Where(materialrequest.OrderNo(orderNo))
q = q.Where(materialrequest.OrderNoEqualFold(orderNo))
}
if planDate != "" {
q = q.Where(materialrequest.PlanDate(planDate))
@@ -113,9 +124,19 @@ func (s *Service) ListMaterialRequests(ctx context.Context, orderNo, planDate, s
if status != "" {
q = q.Where(materialrequest.Status(status))
}
return q.Order(materialrequest.ByID()).All(ctx)
if materialCode != "" {
q = q.Where(materialrequest.MaterialCodeContainsFold(materialCode))
}
if materialName != "" {
q = q.Where(materialrequest.MaterialNameContainsFold(materialName))
}
if targetDock != "" {
q = q.Where(materialrequest.TargetDockEqualFold(targetDock))
}
return q.Order(ent.Desc(materialrequest.FieldCreatedAt), ent.Desc(materialrequest.FieldID)).All(ctx)
}
// SetMaterialRequestStatus 推进备料单状态(WMS 下发 AGV DELIVERING / 到位 DONE
func (s *Service) SetMaterialRequestStatus(ctx context.Context, requestNo, status string) error {
n, err := s.ctx.EntClient.MaterialRequest.Update().
+85 -20
View File
@@ -46,7 +46,8 @@ type FlowVO struct {
}
// ListFlows 查询工艺流程(可按工位号过滤:命中绑定该工位或主工序号的流程)
func (s *Service) ListFlows(ctx context.Context, stationNo int) ([]*FlowVO, error) {
// page>0 时真分页返回 map{total,list,page,pageSize}(管理页用);page=0 全量数组(工位终端兼容)
func (s *Service) ListFlows(ctx context.Context, stationNo, page, pageSize int) (any, error) {
q := s.ctx.EntClient.ProcessFlow.Query().
Order(ent.Asc(processflow.FieldProcessCode), ent.Asc(processflow.FieldID))
flows, err := q.All(ctx)
@@ -85,6 +86,18 @@ func (s *Service) ListFlows(ctx context.Context, stationNo int) ([]*FlowVO, erro
}
out = append(out, vo)
}
if page > 0 {
total := len(out)
start := (page - 1) * pageSize
if start > total {
start = total
}
end := start + pageSize
if end > total {
end = total
}
return map[string]any{"total": total, "list": out[start:end], "page": page, "pageSize": pageSize}, nil
}
return out, nil
}
@@ -95,8 +108,10 @@ func (s *Service) SaveFlow(ctx context.Context, req FlowReq, operator string) er
if req.Name == "" {
return errors.New("流程名称必填")
}
if req.StationNo < 1 || req.StationNo > 12 {
return errors.New("请选择主工序编号(1~12")
// 工序编号与物理工位解耦:工序是流程步骤编号(1~999,流程内唯一即可),
// 工位对应关系由「关联工位」页维护(2026-09-08)
if req.StationNo < 1 || req.StationNo > 999 {
return errors.New("主工序编号应为 1~999 的整数")
}
// 解析绑定工位列表:显式 stations 优先,否则回退到 StationNo(兼容旧调用)
bindStations := req.Stations
@@ -122,21 +137,8 @@ func (s *Service) SaveFlow(ctx context.Context, req FlowReq, operator string) er
if status != "ACTIVE" && status != "INACTIVE" {
return errors.New("非法状态")
}
// 校验:绑定工位不得已被其他"启用"流程占用(一个工位只能绑一个启用流程)
bound, err := s.ctx.EntClient.Station.Query().
Where(station.StationNoIn(stations...), station.FlowIdGT(0)).All(ctx)
if err == nil {
flowID := req.Id
for _, st := range bound {
if flowID > 0 && st.FlowId == flowID {
continue // 绑定的是本流程自身,允许
}
other, gErr := s.ctx.EntClient.ProcessFlow.Get(ctx, st.FlowId)
if gErr == nil && other.Status == "ACTIVE" {
return fmt.Errorf("工位 %d 已被启用流程「%s」绑定,请先在流程/工位中解除绑定", st.StationNo, other.Name)
}
}
}
// 绑定语义(2026-09-08 定稿):直接更换——一个工位只有一条关联数据,
// 保存时所选工位一律改绑到本流程(覆盖旧绑定),不再报"已被启用流程绑定"冲突。
var flowID int
if req.Id > 0 {
upd := s.ctx.EntClient.ProcessFlow.UpdateOneID(req.Id).
@@ -395,7 +397,8 @@ type WorkloadRow struct {
NgCount int `json:"ngCount"`
}
func (s *Service) Workload(ctx context.Context, operator, stationNo, from, to string) (map[string]any, error) {
func (s *Service) Workload(ctx context.Context, operator, stationNo, from, to string,
opPage, opSize, stPage, stSize, detPage, detSize int) (map[string]any, error) {
q := s.ctx.EntClient.WorkpieceProcess.Query()
if operator != "" {
q = q.Where(workpieceprocess.Operator(operator))
@@ -449,5 +452,67 @@ func (s *Service) Workload(ctx context.Context, operator, stationNo, from, to st
}
return list[i].ProcessCode < list[j].ProcessCode
})
return map[string]any{"rows": list, "detail": rows}, nil
// 三个视图分别聚合(2026-09-08 绩效报表改造:按人/按工位/明细,各自真分页)
byOpAgg := map[string]*WorkloadRow{}
byStAgg := map[string]*WorkloadRow{}
for _, v := range list {
opKey := v.Operator
if o, ok := byOpAgg[opKey]; ok {
o.DoneCount += v.DoneCount; o.OkCount += v.OkCount; o.NgCount += v.NgCount
} else {
byOpAgg[opKey] = &WorkloadRow{Operator: v.Operator, DoneCount: v.DoneCount, OkCount: v.OkCount, NgCount: v.NgCount}
}
stKey := v.StationNo
if o, ok := byStAgg[stKey]; ok {
o.DoneCount += v.DoneCount; o.OkCount += v.OkCount; o.NgCount += v.NgCount
} else {
byStAgg[stKey] = &WorkloadRow{StationNo: v.StationNo, DoneCount: v.DoneCount, OkCount: v.OkCount, NgCount: v.NgCount}
}
}
paginateRows := func(all []*ent.WorkpieceProcess, page, size int) (int, []*ent.WorkpieceProcess) {
total := len(all)
start := (page - 1) * size
if start > total {
start = total
}
end := start + size
if end > total {
end = total
}
return total, all[start:end]
}
paginate := func(all []*WorkloadRow, page, size int) (int, []*WorkloadRow) {
total := len(all)
start := (page - 1) * size
if start > total {
start = total
}
end := start + size
if end > total {
end = total
}
return total, all[start:end]
}
byOpList := make([]*WorkloadRow, 0, len(byOpAgg))
for _, v := range byOpAgg {
byOpList = append(byOpList, v)
}
sort.Slice(byOpList, func(i, j int) bool { return byOpList[i].Operator < byOpList[j].Operator })
byStList := make([]*WorkloadRow, 0, len(byStAgg))
for _, v := range byStAgg {
byStList = append(byStList, v)
}
sort.Slice(byStList, func(i, j int) bool { return byStList[i].StationNo < byStList[j].StationNo })
opTotal, byOpPage := paginate(byOpList, opPage, opSize)
stTotal, byStPage := paginate(byStList, stPage, stSize)
detTotal, detRowsPage := paginateRows(rows, detPage, detSize)
return map[string]any{
"byOperator": map[string]any{"total": opTotal, "list": byOpPage},
"byStation": map[string]any{"total": stTotal, "list": byStPage},
"detail": map[string]any{"total": detTotal, "list": detRowsPage},
}, nil
}
+110 -11
View File
@@ -3,11 +3,20 @@ package logic
import (
"context"
"errors"
"fmt"
"strings"
"bj_power_mes/ent"
"bj_power_mes/ent/bomitem"
"bj_power_mes/ent/producttype"
"bj_power_mes/ent/workorder"
"bj_power_mes/internal/wmsclient"
)
// 产品型号(成品档案):编码主数据单一来源 = WMSmaterials item_type=3)。
// MES 本页改为实时代理 WMS;WMS 不可达时降级读本地 product_types 只读缓存(仅查询,
// 写操作必须 WMS 可用),前端以 wmsOnline 标识提示。
type ProductTypeReq struct {
Id int `json:"id"`
Name string `json:"name"`
@@ -22,25 +31,115 @@ func (s *Service) CreateProductType(ctx context.Context, req ProductTypeReq) err
if !req.IsActive {
active = true
}
return s.ctx.EntClient.ProductType.Create().
SetName(req.Name).SetCode(req.Code).SetCategory(req.Category).
SetRemark(req.Remark).SetIsActive(active).Exec(ctx)
if err := s.ctx.Wms.CreateProductType(ctx, req.Code, req.Name, req.Category, req.Remark, active); err != nil {
return errors.New("WMS 不可达或拒绝写入(编码主数据以 WMS 为单一来源):" + err.Error())
}
return nil
}
func (s *Service) UpdateProductType(ctx context.Context, req ProductTypeReq) error {
if req.Id == 0 {
return errors.New("缺少 id")
}
return s.ctx.EntClient.ProductType.UpdateOneID(req.Id).
SetName(req.Name).SetCode(req.Code).SetCategory(req.Category).
SetRemark(req.Remark).SetIsActive(req.IsActive).Exec(ctx)
if err := s.ctx.Wms.UpdateProductType(ctx, req.Id, req.Name, req.Category, req.Remark, req.IsActive); err != nil {
return errors.New("WMS 不可达或拒绝写入:" + err.Error())
}
return nil
}
// DeleteProductType 删除保护:被工单/BOM 引用过的型号禁删(MES 侧引用校验,
// WMS 侧库存引用由 WMS 内部接口再校验一层)
func (s *Service) DeleteProductType(ctx context.Context, id int) error {
return s.ctx.EntClient.ProductType.DeleteOneID(id).Exec(ctx)
row, err := s.productTypeByID(ctx, id)
if err != nil {
return err
}
woCnt, _ := s.ctx.EntClient.WorkOrder.Query().Where(workorder.ProductCode(row.Code)).Count(ctx)
if woCnt > 0 {
return fmt.Errorf("该产品型号已被 %d 张工单引用,不可删除;建议停用(下架)", woCnt)
}
bomCnt, _ := s.ctx.EntClient.BomItem.Query().Where(bomitem.ProductCode(row.Code)).Count(ctx)
if bomCnt > 0 {
return fmt.Errorf("该产品型号在物料清单中有 %d 条配置,不可删除;建议停用(下架)", bomCnt)
}
if err := s.ctx.Wms.DeleteProductType(ctx, id); err != nil {
return errors.New("WMS 不可达或拒绝删除:" + err.Error())
}
return nil
}
func (s *Service) ListProductTypes(ctx context.Context) ([]*ent.ProductType, error) {
return s.ctx.EntClient.ProductType.Query().
Order(ent.Desc(producttype.FieldID)).All(ctx)
}
// ListProductTypes 全量列表(下拉/兼容形态,id asc 新建在底部)。
// degraded=false 表示 WMS 不可达、数据来自本地只读缓存。
func (s *Service) ListProductTypes(ctx context.Context) ([]wmsclient.ProductTypeRow, bool, error) {
list, err := s.ctx.Wms.ProductTypes(ctx, "", "")
if err == nil {
return list, true, nil
}
fallback, ferr := s.localProductTypes(ctx, "")
if ferr != nil {
return nil, false, errors.New("WMS 不可达且本地缓存读取失败")
}
return fallback, false, nil
}
// ListProductTypesPage 分页+搜索(管理页真分页)
func (s *Service) ListProductTypesPage(ctx context.Context, keyword, isActive string, page, pageSize int) (int64, []wmsclient.ProductTypeRow, bool, error) {
total, list, err := s.ctx.Wms.ProductTypePage(ctx, keyword, isActive, page, pageSize)
if err == nil {
return total, list, true, nil
}
fallback, ferr := s.localProductTypes(ctx, keyword)
if ferr != nil {
return 0, nil, false, errors.New("WMS 不可达且本地缓存读取失败")
}
start := (page - 1) * pageSize
if start > len(fallback) {
start = len(fallback)
}
end := start + pageSize
if end > len(fallback) {
end = len(fallback)
}
return int64(len(fallback)), fallback[start:end], false, nil
}
func (s *Service) productTypeByID(ctx context.Context, id int) (*wmsclient.ProductTypeRow, error) {
list, err := s.ctx.Wms.ProductTypes(ctx, "", "")
if err == nil {
for i := range list {
if list[i].ID == id {
return &list[i], nil
}
}
return nil, errors.New("产品型号不存在")
}
// 降级:本地缓存
pt, err := s.ctx.EntClient.ProductType.Get(ctx, id)
if err != nil {
return nil, errors.New("产品型号不存在(WMS 不可达,无法校验引用)")
}
return &wmsclient.ProductTypeRow{ID: pt.ID, Code: pt.Code, Name: pt.Name}, nil
}
func (s *Service) localProductTypes(ctx context.Context, keyword string) ([]wmsclient.ProductTypeRow, error) {
list, err := s.ctx.EntClient.ProductType.Query().Order(ent.Asc(producttype.FieldID)).All(ctx)
if err != nil {
return nil, err
}
rows := make([]wmsclient.ProductTypeRow, 0, len(list))
kw := strings.ToLower(strings.TrimSpace(keyword))
for _, p := range list {
if kw != "" &&
!strings.Contains(strings.ToLower(p.Code), kw) &&
!strings.Contains(strings.ToLower(p.Name), kw) &&
!strings.Contains(strings.ToLower(p.Category), kw) {
continue
}
rows = append(rows, wmsclient.ProductTypeRow{
ID: p.ID, Code: p.Code, Name: p.Name, Category: p.Category,
Remark: p.Remark, IsActive: p.IsActive,
CreatedAt: p.CreatedAt.Unix(),
})
}
return rows, nil
}
+12 -3
View File
@@ -92,13 +92,22 @@ func (s *Service) bumpDailyPlanCompleted(ctx context.Context, orderNo string, t
}
// ListScanRecords 查询扫码报工记录
func (s *Service) ListScanRecords(ctx context.Context, sn, orderNo string) ([]*ent.ScanRecord, error) {
func (s *Service) ListScanRecords(ctx context.Context, sn, orderNo string, page, pageSize int) ([]*ent.ScanRecord, int, error) {
q := s.ctx.EntClient.ScanRecord.Query()
if sn != "" {
q = q.Where(scanrecord.Sn(sn))
}
if orderNo != "" {
q = q.Where(scanrecord.OrderNo(orderNo))
q = q.Where(scanrecord.OrderNoEqualFold(orderNo))
}
return q.Order(ent.Desc(scanrecord.FieldID)).Limit(1000).All(ctx)
if sn != "" {
q = q.Where(scanrecord.SnEqualFold(sn))
}
total, err := q.Count(ctx)
if err != nil {
return nil, 0, err
}
rows, err := q.Order(ent.Desc(scanrecord.FieldID)).
Offset((page - 1) * pageSize).Limit(pageSize).All(ctx)
return rows, total, err
}
+4 -4
View File
@@ -80,7 +80,7 @@ func (s *Service) Seed(ctx context.Context) error {
{"produce.performance", "绩效报表", "MENU", "/performance"},
{"produce.scan", "手动报工", "MENU", "/scan"},
{"produce.trace", "工件追溯", "MENU", "/trace"},
{"produce.product", "产品型", "MENU", "/product-type"},
{"produce.product", "产品型", "MENU", "/product-type"},
{"sys.inspect", "巡检终端", "MENU", "/inspect"},
{"sys.eventlog", "操作日志", "MENU", "/event-log"},
// sys.rbac(角色权限管理) 已拆分:账号管理 + 角色管理(对齐 WMS 系统管理菜单)
@@ -106,9 +106,9 @@ func (s *Service) Seed(ctx context.Context) error {
{"produce.bom:edit", "物料清单-维护", "BUTTON", "", "produce.bom"},
{"produce.material:generate", "备料单-生成", "BUTTON", "", "produce.material"},
{"produce.plc:send", "工位组合-下发", "BUTTON", "", "produce.plc"},
{"produce.product:add", "产品型-新增", "BUTTON", "", "produce.product"},
{"produce.product:edit", "产品型-编辑", "BUTTON", "", "produce.product"},
{"produce.product:delete", "产品型-删除", "BUTTON", "", "produce.product"},
{"produce.product:add", "产品型-新增", "BUTTON", "", "produce.product"},
{"produce.product:edit", "产品型-编辑", "BUTTON", "", "produce.product"},
{"produce.product:delete", "产品型-删除", "BUTTON", "", "produce.product"},
{"produce.processflow:add", "工艺流程-新增", "BUTTON", "", "produce.processflow"},
{"produce.processflow:edit", "工艺流程-编辑", "BUTTON", "", "produce.processflow"},
{"produce.processflow:delete", "工艺流程-删除", "BUTTON", "", "produce.processflow"},
+28 -2
View File
@@ -85,14 +85,40 @@ func (s *Service) evaluateTorqueCriterion(ctx context.Context, sn, stationNo str
func (s *Service) ListTorqueRecords(ctx context.Context, sn, workOrderNo string, from, to time.Time) ([]*ent.TorqueRecord, error) {
q := s.ctx.EntClient.TorqueRecord.Query()
if sn != "" {
q = q.Where(torquerecord.Sn(sn))
// 大小写不敏感(2026-09-08 全项目搜索规范)
q = q.Where(torquerecord.SnEqualFold(sn))
}
if workOrderNo != "" {
q = q.Where(torquerecord.WorkOrderNo(workOrderNo))
q = q.Where(torquerecord.WorkOrderNoEqualFold(workOrderNo))
}
return q.Order(ent.Desc(torquerecord.FieldTime)).Limit(500).All(ctx)
}
// AddTorqueRecord 拧紧数据补录(管理端"拧紧查询-拧紧补录"tab 用,定位为设备漏传/手工修正)
func (s *Service) AddTorqueRecord(ctx context.Context, sn, workOrderNo, screwNo, stationNo string, strain, angle float64, result, reason, operator string) error {
if sn == "" || workOrderNo == "" {
return errors.New("工单号与 SN 必填")
}
if reason == "" {
return errors.New("补录原因必填(如:设备漏传/手工修正)")
}
if result == "" {
result = "OK"
}
now := time.Now()
_, err := s.ctx.EntClient.TorqueRecord.Create().
SetSn(sn).SetWorkOrderNo(workOrderNo).SetScrewNo(screwNo).SetStationNo(stationNo).
SetStrain(strain).SetTorque(strain).SetAngle(angle).
SetResult(result).SetOperator(operator+"(补录)").SetTime(now).
Save(ctx)
if err != nil {
return err
}
s.ctx.EventLog.Write(ctx, "torque.manual.add", workOrderNo, operator, "torque_record", sn,
"拧紧数据补录 "+workOrderNo+" "+sn, map[string]any{"reason": reason, "result": result, "screwNo": screwNo, "stationNo": stationNo})
return nil
}
// 计算一块工件某工序的扭矩步骤是否已报
func (s *Service) hasWorkpieceProcess(ctx context.Context, sn string, processCode int) bool {
cnt, _ := s.ctx.EntClient.WorkpieceProcess.Query().
+34 -2
View File
@@ -18,6 +18,7 @@ type WorkOrderReq struct {
WorkOrderNo string `json:"workOrderNo"`
ProductTypeId int `json:"productTypeId"`
ProductCode string `json:"productCode"`
BomName string `json:"bomName"` // 建单选定的 BOM 名称(同型号多份并存;空=「默认」)
ProductName string `json:"productName"`
Quantity int `json:"quantity"`
ProcessSeq string `json:"processSeq"`
@@ -26,13 +27,34 @@ type WorkOrderReq struct {
PlanEnd string `json:"planEnd"`
}
// validateBomName 校验该产品型号下是否存在指定名称的 BOM(型号下无任何 BOM 时放行,允许先建单后补 BOM)
func (s *Service) validateBomName(ctx context.Context, productCode, bomName string) error {
if productCode == "" {
return nil
}
names, err := s.ListBomNames(ctx, productCode)
if err != nil {
return nil // 查询异常不阻塞建单(与 WMS 降级策略同思路)
}
if len(names) == 0 {
return nil
}
target := bomNameOrDefault(bomName)
for _, n := range names {
if n.BomName == target {
return nil
}
}
return fmt.Errorf("产品型号 %s 下不存在名为「%s」的BOM,请先在物料清单中创建", productCode, target)
}
// CreateWorkOrder 创建工单(状态恒 CREATED,后续用状态流转按钮推进)
func (s *Service) CreateWorkOrder(ctx context.Context, req WorkOrderReq, operator string) error {
if req.WorkOrderNo == "" {
return errors.New("工单号不能为空")
}
if req.ProductTypeId == 0 {
return errors.New("请选择产品型")
return errors.New("请选择产品型")
}
if req.Quantity <= 0 {
req.Quantity = 1
@@ -50,10 +72,14 @@ func (s *Service) CreateWorkOrder(ctx context.Context, req WorkOrderReq, operato
return err
}
req.ProcessSeq = NormalizeProcessSeq(req.ProcessSeq)
if err := s.validateBomName(ctx, req.ProductCode, req.BomName); err != nil {
return err
}
b := s.ctx.EntClient.WorkOrder.Create().
SetWorkOrderNo(req.WorkOrderNo).
SetProductTypeId(req.ProductTypeId).
SetProductCode(req.ProductCode).
SetBomName(bomNameOrDefault(req.BomName)).
SetProductName(req.ProductName).
SetQuantity(req.Quantity).
SetProcessSeq(req.ProcessSeq).
@@ -101,6 +127,12 @@ func (s *Service) UpdateWorkOrder(ctx context.Context, req WorkOrderReq, operato
if req.ProductCode != "" {
u.SetProductCode(req.ProductCode)
}
if req.BomName != "" {
if err := s.validateBomName(ctx, req.ProductCode, req.BomName); err != nil {
return err
}
u.SetBomName(bomNameOrDefault(req.BomName))
}
if req.ProductName != "" {
u.SetProductName(req.ProductName)
}
@@ -140,7 +172,7 @@ func (s *Service) ListWorkOrders(ctx context.Context, orderNo, status, productCo
if productName != "" {
q = q.Where(workorder.ProductNameContains(productName))
}
return q.Order(ent.Desc(workorder.FieldID)).All(ctx)
return q.Order(ent.Desc(workorder.FieldCreatedAt), ent.Desc(workorder.FieldID)).All(ctx)
}
func (s *Service) GetWorkOrder(ctx context.Context, id int) (*ent.WorkOrder, error) {
+22
View File
@@ -226,10 +226,32 @@ func (s *Service) DoneWorkpiece(ctx context.Context, req DoneReq, operator strin
s.bumpWorkOrderProgress(ctx, wp.OrderNo, req.Sn)
s.bumpDailyPlanCompleted(ctx, wp.OrderNo, now)
s.ctx.EventLog.Write(ctx, "workpiece.done", wp.OrderNo, operator, "workpiece", req.Sn, "完工+关联追溯", map[string]any{"batchItems": req.BatchItems, "serialItems": req.SerialItems})
// 成品完工自动回流 WMS(统一库存主表 category=3):
// 成品 SN = 工件 SN(进线登记即系统发号,全程唯一,保证对号可追溯)。
// WMS 不可达/校验失败时降级:记 eventlog 不阻塞完工,与备料 WMS 校验降级策略同模式。
s.retainFinishedToWms(ctx, wp, operator)
s.notifyDashboard()
return nil
}
// retainFinishedToWms 完工品回流 WMS 库存。失败仅记日志(wms.finished.retain_failed),
// 不回滚完工状态——仓管可在 WMS 侧人工补录,避免 WMS 抖动卡死产线。
func (s *Service) retainFinishedToWms(ctx context.Context, wp *ent.Workpiece, operator string) {
wo, err := s.ctx.EntClient.WorkOrder.Get(ctx, wp.WorkOrderId)
if err != nil || wo == nil {
s.ctx.EventLog.Write(ctx, "wms.finished.retain_failed", wp.OrderNo, operator, "workpiece", wp.Sn,
"成品回流WMS失败:工单不存在", map[string]any{"error": "work order not found"})
return
}
if wmsErr := s.ctx.Wms.FinishedInbound(ctx, wo.ProductCode, wo.ProductName, wp.Sn, wp.OrderNo, operator); wmsErr != nil {
s.ctx.EventLog.Write(ctx, "wms.finished.retain_failed", wp.OrderNo, operator, "workpiece", wp.Sn,
"成品回流WMS失败(库存未入,请人工补录)", map[string]any{"error": wmsErr.Error(), "productCode": wo.ProductCode})
return
}
s.ctx.EventLog.Write(ctx, "wms.finished.retain", wp.OrderNo, operator, "workpiece", wp.Sn,
"成品完工已回流WMS库存", map[string]any{"productCode": wo.ProductCode})
}
// Trace 追溯查询(SN 维度):工序时间线/操作人/步骤数据/物料批次SN/拧紧数据
func (s *Service) Trace(ctx context.Context, sn string) (map[string]any, error) {
wp, err := s.ctx.EntClient.Workpiece.Query().Where(workpiece.Sn(sn)).First(ctx)
+18 -16
View File
@@ -81,26 +81,27 @@ type bindTarget struct {
UnitQty float64
}
// workpieceProduct 解析工件所属产品编码(sn → workpiece → workOrder → productCode
func (s *Service) workpieceProduct(ctx context.Context, sn string) (*ent.Workpiece, string, error) {
// workpieceProduct 解析工件所属产品编码与工单锁定的 BOM 名称sn → workpiece → workOrder
func (s *Service) workpieceProduct(ctx context.Context, sn string) (*ent.Workpiece, *ent.WorkOrder, error) {
wp, err := s.ctx.EntClient.Workpiece.Query().Where(workpiece.Sn(sn)).First(ctx)
if err != nil {
return nil, "", errors.New("工件未进线登记")
return nil, nil, errors.New("工件未进线登记")
}
if wp.WorkOrderId <= 0 {
return wp, "", errors.New("工件未关联工单")
return wp, nil, errors.New("工件未关联工单")
}
wo, err := s.ctx.EntClient.WorkOrder.Get(ctx, wp.WorkOrderId)
if err != nil || wo == nil {
return wp, "", errors.New("工件所属工单不存在")
return wp, nil, errors.New("工件所属工单不存在")
}
return wp, wo.ProductCode, nil
return wp, wo, nil
}
// stationBindTargets 取某产品在某工序(processCode>0)要求装配的物料清单
func (s *Service) stationBindTargets(ctx context.Context, productCode string, processCode int) ([]bindTarget, error) {
// stationBindTargets 取某工单(产品+BOM)在某工序(processCode>0)要求装配的物料清单
// 一型号多份 BOM 并存,必须按工单锁定的 bomName 过滤,否则不同厂家料单互相串。
func (s *Service) stationBindTargets(ctx context.Context, productCode, bomName string, processCode int) ([]bindTarget, error) {
items, err := s.ctx.EntClient.BomItem.Query().
Where(bomitem.ProductCode(productCode), bomitem.ProcessCode(processCode)).All(ctx)
Where(bomitem.ProductCode(productCode), bomitem.BomName(bomNameOrDefault(bomName)), bomitem.ProcessCode(processCode)).All(ctx)
if err != nil {
return nil, err
}
@@ -123,17 +124,18 @@ func (s *Service) applyBinds(ctx context.Context, sn, orderNo string, processCod
if len(items) == 0 {
return 0, nil
}
wp, productCode, err := s.workpieceProduct(ctx, sn)
wp, wo, err := s.workpieceProduct(ctx, sn)
if err != nil {
return 0, err
}
productCode := wo.ProductCode
if wp.Status == "DONE" || wp.Status == "SCRAPPED" {
return 0, errors.New("已完工/报废工件不能再绑定物料")
}
// 产品 BOM 全量索引:materialCode → target(校验料合法性 + 取物料快照)
// 工单锁定 BOM 全量索引:materialCode → target(校验料合法性 + 取物料快照)
bomMap := map[string]*ent.BomItem{}
allBom, _ := s.ctx.EntClient.BomItem.Query().
Where(bomitem.ProductCode(productCode)).All(ctx)
Where(bomitem.ProductCode(productCode), bomitem.BomName(bomNameOrDefault(wo.BomName))).All(ctx)
for _, b := range allBom {
bomMap[b.MaterialCode] = b
}
@@ -186,11 +188,11 @@ func (s *Service) applyBinds(ctx context.Context, sn, orderNo string, processCod
// 返回缺料明细(空串=齐套)。判定:精密件(SN) 绑定的不同SN数 ≥ 单台用量;结构件(批次) 至少绑定1个批次号。
// 若该产品该工序未配置任何装配物料(processCode未配置),视为不启用绑定校验,放行。
func (s *Service) validateStationBinds(ctx context.Context, sn string, processCode int) (string, error) {
_, productCode, err := s.workpieceProduct(ctx, sn)
_, wo, err := s.workpieceProduct(ctx, sn)
if err != nil {
return "", err
}
targets, err := s.stationBindTargets(ctx, productCode, processCode)
targets, err := s.stationBindTargets(ctx, wo.ProductCode, wo.BomName, processCode)
if err != nil {
return "", err
}
@@ -310,11 +312,11 @@ type StationBindPanel struct {
// StationBindPanelData 供工位终端/手动报工页在报工前拉取:本工件本工序该装什么、已装什么
func (s *Service) StationBindPanelData(ctx context.Context, sn string, processCode int) (*StationBindPanel, error) {
_, productCode, err := s.workpieceProduct(ctx, sn)
_, wo, err := s.workpieceProduct(ctx, sn)
if err != nil {
return nil, err
}
targets, err := s.stationBindTargets(ctx, productCode, processCode)
targets, err := s.stationBindTargets(ctx, wo.ProductCode, wo.BomName, processCode)
if err != nil {
return nil, err
}