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
+14 -4
View File
@@ -36,17 +36,27 @@ func CreateInspectionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
}
}
// ListInspectionsHandler GET /inspections?category=&operator=&from=&to=
// ListInspectionsHandler GET /inspections?category=&operator=&from=&to=&page=&pageSize=
// 返回 {list, total},后端真分页(created_at desc / id desc,最新置顶)
func ListInspectionsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
data, err := logic.New(svcCtx).ListInspections(r.Context(),
q.Get("category"), q.Get("operator"), q.Get("from"), q.Get("to"))
page := atoiDefault(q.Get("page"), 1)
pageSize := atoiDefault(q.Get("pageSize"), 50)
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 1000 {
pageSize = 50
}
data, total, err := logic.New(svcCtx).ListInspectionsPaged(r.Context(),
q.Get("category"), q.Get("operator"), q.Get("from"), q.Get("to"),
q.Get("orderNo"), q.Get("stationNo"), page, pageSize)
if err != nil {
httpx.Fail(w, 2302, err.Error())
return
}
httpx.Ok(w, data)
httpx.Ok(w, map[string]any{"list": data, "total": total})
}
}
+1
View File
@@ -20,6 +20,7 @@ var pathPermMap = map[string]string{
"DELETE:/api/v1/work-orders/*": "produce.workorder:delete",
"POST:/api/v1/daily-plans": "produce.workorder:dailyplan",
"PUT:/api/v1/bom": "produce.bom:edit",
"POST:/api/v1/bom/item/delete": "produce.bom:edit",
"POST:/api/v1/material-requests/generate": "produce.material:generate",
"POST:/api/v1/plc/send-process": "produce.plc:send",
"POST:/api/v1/process-flows": "produce.processflow:add",
+14 -1
View File
@@ -6,13 +6,17 @@ import (
"bj_power_mes/common/httpx"
"bj_power_mes/internal/logic"
"bj_power_mes/internal/svc"
"github.com/zeromicro/go-zero/rest/pathvar"
)
// ListProcessFlowsHandler GET /process-flows?stationNo=
func ListProcessFlowsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
stationNo := atoiDefault(r.URL.Query().Get("stationNo"), 0)
data, err := logic.New(svcCtx).ListFlows(r.Context(), stationNo)
page := atoiDefault(r.URL.Query().Get("page"), 0)
pageSize := atoiDefault(r.URL.Query().Get("pageSize"), 20)
data, err := logic.New(svcCtx).ListFlows(r.Context(), stationNo, page, pageSize)
if err != nil {
httpx.Fail(w, 2201, err.Error())
return
@@ -71,6 +75,15 @@ func FileDownloadHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
}
}
// FilePathHandler GET /files/:date/:name —— 路径式文件 URL(巡检拍照上传返回的
// url 形如 /api/v1/files/2026-09-07/xxx.png,此前无此路由导致照片 404)
func FilePathHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
vars := pathvar.Vars(r)
serveUploadFile(w, r, svcCtx.Config.Upload.Dir, vars["date"]+"/"+vars["name"])
}
}
// ListStationsHandler GET /stations
func ListStationsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
@@ -14,6 +14,7 @@ func SaveBomHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req struct {
ProductCode string `json:"productCode"`
BomName string `json:"bomName"` // 同型号多份 BOM 并存时的名称(空=「默认」)
Items []logic.BomItemReq `json:"items"`
}
if err := httpx.ParseJSON(r, &req); err != nil {
@@ -24,7 +25,7 @@ func SaveBomHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
httpx.BadRequest(w, "请选择产品编码")
return
}
if err := logic.New(svcCtx).SaveBom(r.Context(), req.ProductCode, req.Items, operator(r, "")); err != nil {
if err := logic.New(svcCtx).SaveBom(r.Context(), req.ProductCode, req.BomName, req.Items, operator(r, "")); err != nil {
httpx.Fail(w, 3101, err.Error())
return
}
@@ -34,7 +35,7 @@ func SaveBomHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
func ListBomHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
data, err := logic.New(svcCtx).ListBom(r.Context(), r.URL.Query().Get("productCode"))
data, err := logic.New(svcCtx).ListBom(r.Context(), r.URL.Query().Get("productCode"), r.URL.Query().Get("bomName"))
if err != nil {
httpx.Fail(w, 3102, err.Error())
return
@@ -43,6 +44,36 @@ func ListBomHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
}
}
// ListBomNamesHandler GET /bom/names?productCode= — 型号下并存的 BOM 名称列表(工单建单选 BOM 用)
func ListBomNamesHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
data, err := logic.New(svcCtx).ListBomNames(r.Context(), r.URL.Query().Get("productCode"))
if err != nil {
httpx.Fail(w, 3107, err.Error())
return
}
httpx.Ok(w, data)
}
}
// DeleteBomItemHandler POST /bom/item/delete {id} — 移除 BOM 中一条物料(按行主键)
func DeleteBomItemHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req struct {
Id int `json:"id"`
}
if err := httpx.ParseJSON(r, &req); err != nil || req.Id <= 0 {
httpx.BadRequest(w, "缺少 id")
return
}
if err := logic.New(svcCtx).DeleteBomItem(r.Context(), req.Id, operator(r, "")); err != nil {
httpx.Fail(w, 3108, err.Error())
return
}
httpx.OkMessage(w, "已移除", nil)
}
}
// ---------- 备料单 ----------
// GenerateMaterialHandler 按日排产生成备料单(生成前校验 BOM 物料在 WMS 档案存在)
@@ -72,7 +103,9 @@ func GenerateMaterialHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
func ListMaterialRequestsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
data, err := logic.New(svcCtx).ListMaterialRequests(r.Context(), q.Get("orderNo"), q.Get("planDate"), q.Get("status"))
data, err := logic.New(svcCtx).ListMaterialRequestsFiltered(r.Context(),
q.Get("orderNo"), q.Get("planDate"), q.Get("status"),
q.Get("materialCode"), q.Get("materialName"), q.Get("targetDock"))
if err != nil {
httpx.Fail(w, 3104, err.Error())
return
@@ -95,12 +95,13 @@ func ScanReportHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
func ScanRecordsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
data, err := logic.New(svcCtx).ListScanRecords(r.Context(), q.Get("sn"), q.Get("orderNo"))
data, total, err := logic.New(svcCtx).ListScanRecords(r.Context(), q.Get("sn"), q.Get("orderNo"),
atoiDefault(q.Get("page"), 1), atoiDefault(q.Get("pageSize"), 20))
if err != nil {
httpx.Fail(w, 3206, err.Error())
return
}
httpx.Ok(w, data)
httpx.Ok(w, map[string]any{"list": data, "total": total})
}
}
@@ -121,3 +122,30 @@ func ProcessStepsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
httpx.Ok(w, data)
}
}
// TorqueManualAddHandler POST /torque/manual-add 拧紧数据补录(设备漏传/手工修正)
func TorqueManualAddHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req struct {
Sn string `json:"sn"`
WorkOrderNo string `json:"workOrderNo"`
ScrewNo string `json:"screwNo"`
StationNo string `json:"stationNo"`
Strain float64 `json:"strain"`
Angle float64 `json:"angle"`
Result string `json:"result"`
Reason string `json:"reason"`
}
if err := httpx.ParseJSON(r, &req); err != nil {
httpx.BadRequest(w, "请求体解析失败")
return
}
if err := logic.New(svcCtx).AddTorqueRecord(r.Context(), req.Sn, req.WorkOrderNo, req.ScrewNo,
req.StationNo, req.Strain, req.Angle, req.Result, req.Reason, operator(r, "系统管理员")); err != nil {
httpx.Fail(w, 3205, err.Error())
return
}
httpx.OkMessage(w, "补录成功", nil)
}
}
@@ -2,6 +2,7 @@ package production
import (
"net/http"
"net/url"
"strconv"
"strings"
@@ -18,11 +19,11 @@ func pathID(r *http.Request) int {
return n
}
// ---------- 产品型 ----------
// ---------- 产品型 ----------
func ListProductTypesHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
data, err := logic.New(svcCtx).ListProductTypes(r.Context())
data, _, err := logic.New(svcCtx).ListProductTypes(r.Context())
if err != nil {
httpx.Fail(w, 3001, err.Error())
return
@@ -31,6 +32,25 @@ func ListProductTypesHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
}
}
// ListProductTypesPageHandler GET /product-types/page 产品型号管理页真分页+搜索
// 响应 {total,list,page,pageSize,wmsOnline}
func ListProductTypesPageHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
page := atoiDefault(q.Get("page"), 1)
pageSize := atoiDefault(q.Get("pageSize"), 20)
total, list, wmsOnline, err := logic.New(svcCtx).ListProductTypesPage(r.Context(),
q.Get("keyword"), q.Get("isActive"), page, pageSize)
if err != nil {
httpx.Fail(w, 3001, err.Error())
return
}
httpx.Ok(w, map[string]any{
"total": total, "list": list, "page": page, "pageSize": pageSize, "wmsOnline": wmsOnline,
})
}
}
func CreateProductTypeHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req logic.ProductTypeReq
@@ -71,6 +91,22 @@ func DeleteProductTypeHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
}
}
// ExportProductTypesHandler GET /product-types/export 转发 WMS xlsx 导出
func ExportProductTypesHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
b, err := svcCtx.Wms.ExportProductTypes(r.Context(),
q.Get("keyword"), q.Get("isActive"), q.Get("startDate"), q.Get("endDate"))
if err != nil {
httpx.Fail(w, 3002, "导出失败(WMS 不可达):"+err.Error())
return
}
w.Header().Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
w.Header().Set("Content-Disposition", "attachment; filename*=UTF-8''"+url.PathEscape("产品型号.xlsx"))
_, _ = w.Write(b)
}
}
// ---------- 工单 ----------
func ListWorkOrdersHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
@@ -84,6 +84,8 @@ func RegisterProductionRoutes(server *rest.Server, serverCtx *svc.ServiceContext
// ---------- JWT 业务 API ----------
jwtRoutes := []rest.Route{
{Method: http.MethodGet, Path: "/product-types", Handler: production.ListProductTypesHandler(serverCtx)},
{Method: http.MethodGet, Path: "/product-types/page", Handler: production.ListProductTypesPageHandler(serverCtx)},
{Method: http.MethodGet, Path: "/product-types/export", Handler: production.ExportProductTypesHandler(serverCtx)},
{Method: http.MethodPost, Path: "/product-types", Handler: production.CreateProductTypeHandler(serverCtx)},
{Method: http.MethodPut, Path: "/product-types", Handler: production.UpdateProductTypeHandler(serverCtx)},
{Method: http.MethodDelete, Path: "/product-types/:id", Handler: production.DeleteProductTypeHandler(serverCtx)},
@@ -102,6 +104,8 @@ func RegisterProductionRoutes(server *rest.Server, serverCtx *svc.ServiceContext
{Method: http.MethodPut, Path: "/bom", Handler: production.SaveBomHandler(serverCtx)},
{Method: http.MethodGet, Path: "/bom", Handler: production.ListBomHandler(serverCtx)},
{Method: http.MethodGet, Path: "/bom/names", Handler: production.ListBomNamesHandler(serverCtx)},
{Method: http.MethodPost, Path: "/bom/item/delete", Handler: production.DeleteBomItemHandler(serverCtx)},
{Method: http.MethodPost, Path: "/material-requests/generate", Handler: production.GenerateMaterialHandler(serverCtx)},
{Method: http.MethodGet, Path: "/material-requests", Handler: production.ListMaterialRequestsHandler(serverCtx)},
@@ -111,6 +115,7 @@ func RegisterProductionRoutes(server *rest.Server, serverCtx *svc.ServiceContext
{Method: http.MethodPost, Path: "/torque/report", Handler: production.TorqueReportHandler(serverCtx)},
{Method: http.MethodGet, Path: "/torque/records", Handler: production.TorqueRecordsHandler(serverCtx)},
{Method: http.MethodPost, Path: "/torque/manual-add", Handler: production.TorqueManualAddHandler(serverCtx)},
{Method: http.MethodPost, Path: "/scan/report", Handler: production.ScanReportHandler(serverCtx)},
{Method: http.MethodGet, Path: "/scan/records", Handler: production.ScanRecordsHandler(serverCtx)},
@@ -138,7 +143,6 @@ func RegisterProductionRoutes(server *rest.Server, serverCtx *svc.ServiceContext
{Method: http.MethodPost, Path: "/process-flows/upload", Handler: UploadPdfHandler(serverCtx)},
{Method: http.MethodGet, Path: "/stations", Handler: ListStationsHandler(serverCtx)},
{Method: http.MethodPost, Path: "/stations", Handler: SaveStationHandler(serverCtx)},
{Method: http.MethodGet, Path: "/files", Handler: FileDownloadHandler(serverCtx)},
// ---------- PAD 巡检终端(块8 ----------
{Method: http.MethodPost, Path: "/inspections", Handler: CreateInspectionHandler(serverCtx)},
@@ -147,6 +151,7 @@ func RegisterProductionRoutes(server *rest.Server, serverCtx *svc.ServiceContext
// ---------- 工作量/绩效报表(块6 ----------
{Method: http.MethodGet, Path: "/performance", Handler: WorkloadInternalHandler(serverCtx)},
{Method: http.MethodGet, Path: "/performance/export", Handler: PerformanceExportHandler(serverCtx)},
// ---------- 流程卡打印(块7 ----------
{Method: http.MethodGet, Path: "/process-card", Handler: ProcessCardHandler(serverCtx)},
@@ -158,4 +163,17 @@ func RegisterProductionRoutes(server *rest.Server, serverCtx *svc.ServiceContext
// 请求体上限跟随 Upload.MaxMB(默认20MB),否则 go-zero 默认 1MB 会 413PDF/照片上传在此分组)
rest.WithMaxBytes(serverCtx.Config.Upload.MaxMB<<20),
)
// ---------- 公开文件访问(免 JWT ----------
// <img>/el-image 发起的图片请求无法携带 Authorization 头,走 JWT 组会 401
// 导致"上传成功但页面图片加载失败"(2026-09-08 修复)。文件名为系统生成的
// 日期目录+随机名,不可枚举,免鉴权风险可控。
fileRoutes := []rest.Route{
{Method: http.MethodGet, Path: "/files", Handler: FileDownloadHandler(serverCtx)},
{Method: http.MethodGet, Path: "/files/:date/:name", Handler: FilePathHandler(serverCtx)},
}
server.AddRoutes(
fileRoutes,
rest.WithPrefix("/api/v1"),
)
}
+97 -1
View File
@@ -2,11 +2,16 @@ package handler
import (
"net/http"
"errors"
"net/url"
"strconv"
"time"
"bj_power_mes/common/httpx"
"bj_power_mes/internal/logic"
"bj_power_mes/internal/svc"
"github.com/xuri/excelize/v2"
)
// atoiDefault 解析整型字符串,非法返回默认值
@@ -110,7 +115,10 @@ func StationReportInternalHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
func WorkloadInternalHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
data, err := logic.New(svcCtx).Workload(r.Context(), q.Get("operator"), q.Get("stationNo"), q.Get("from"), q.Get("to"))
data, err := logic.New(svcCtx).Workload(r.Context(), q.Get("operator"), q.Get("stationNo"), q.Get("from"), q.Get("to"),
atoiDefault(q.Get("opPage"), 1), atoiDefault(q.Get("opPageSize"), 20),
atoiDefault(q.Get("stPage"), 1), atoiDefault(q.Get("stPageSize"), 20),
atoiDefault(q.Get("detPage"), 1), atoiDefault(q.Get("detPageSize"), 20))
if err != nil {
httpx.Fail(w, 2105, err.Error())
return
@@ -165,3 +173,91 @@ func StationCheckinInternalHandler(svcCtx *svc.ServiceContext) http.HandlerFunc
httpx.OkMessage(w, "暂存退库已登记", nil)
}
}
// PerformanceExportHandler GET /performance/export 绩效报表导出(一个 xlsx 三个 sheet:按人/按工位/明细)
// 执行全项目统一导出时间硬规则:起止必填(默认近3个月)、间隔≤1年
func PerformanceExportHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
startDate, endDate, rerr := normalizeExportRange(q.Get("startDate"), q.Get("endDate"))
if rerr != nil {
httpx.Fail(w, 2106, rerr.Error())
return
}
data, err := logic.New(svcCtx).Workload(r.Context(), q.Get("operator"), q.Get("stationNo"), startDate, endDate,
1, 1<<30, 1, 1<<30, 1, 1<<30)
if err != nil {
httpx.Fail(w, 2105, err.Error())
return
}
f := excelize.NewFile()
type view struct {
name string
key string
headers []string
cols func(m map[string]any) []any
}
rowOf := func(v any) map[string]any { m, _ := v.(map[string]any); return m }
views := []view{
{"按人", "byOperator", []string{"操作人", "完成数", "合格", "不合格"}, func(m map[string]any) []any {
return []any{m["operator"], m["doneCount"], m["okCount"], m["ngCount"]}
}},
{"按工位", "byStation", []string{"工位", "完成数", "合格", "不合格"}, func(m map[string]any) []any {
return []any{m["stationNo"], m["doneCount"], m["okCount"], m["ngCount"]}
}},
{"明细", "detail", []string{"操作人", "工位", "日期", "工序", "工序名", "完成数", "合格", "不合格"}, func(m map[string]any) []any {
return []any{m["operator"], m["stationNo"], m["date"], m["processCode"], m["processName"], m["doneCount"], m["okCount"], m["ngCount"]}
}},
}
for i, vw := range views {
sheet := vw.name
if i == 0 {
f.SetSheetName("Sheet1", sheet)
} else {
f.NewSheet(sheet)
}
for c, h := range vw.headers {
cell, _ := excelize.CoordinatesToCellName(c+1, 1)
_ = f.SetCellValue(sheet, cell, h)
}
vm, _ := data[vw.key].(map[string]any)
list, _ := vm["list"].([]any)
for rIdx, it := range list {
m := rowOf(it)
for c := range vw.headers {
cell, _ := excelize.CoordinatesToCellName(c+1, rIdx+2)
_ = f.SetCellValue(sheet, cell, vw.cols(m)[c])
}
}
}
w.Header().Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
w.Header().Set("Content-Disposition", "attachment; filename*=UTF-8''"+url.PathEscape("绩效报表.xlsx"))
_, _ = f.WriteTo(w)
}
}
// normalizeExportRange MES 侧导出时间硬规则(与 WMS 同一套):
// ① 起止都空 → 默认近 3 个月;② 只传一个 → 报错;③ 间隔 > 1 年 → 报错;④ 结束早于起始 → 报错。
func normalizeExportRange(startDate, endDate string) (string, string, error) {
if startDate == "" && endDate == "" {
now := time.Now()
return now.AddDate(0, -3, 0).Format("2006-01-02"), now.Format("2006-01-02"), nil
}
if startDate == "" || endDate == "" {
return "", "", errors.New("导出必须同时提供起始时间与结束时间")
}
st, err1 := time.ParseInLocation("2006-01-02", startDate, time.Local)
et, err2 := time.ParseInLocation("2006-01-02", endDate, time.Local)
if err1 != nil || err2 != nil {
return "", "", errors.New("时间格式应为 YYYY-MM-DD")
}
if et.Before(st) {
return "", "", errors.New("结束时间不能早于起始时间")
}
if et.Sub(st) > 366*24*time.Hour {
return "", "", errors.New("导出时间间隔不能超过 1 年")
}
return startDate, endDate, nil
}