feat: 完成产线与仓储系统多模块迭代升级

本次迭代覆盖MES与WMS核心业务:
1. 新增接驳台托盘传感器读取与AGV对接能力
2. 完善工单排产、备料流程与权限体系拆分
3. 优化看板接口与前端路由、样式
4. 新增操作日志、库存盘点与角色保护逻辑
5. 修复代理地址、BOM保存等已知问题
This commit is contained in:
SunYF
2026-09-04 14:18:53 +08:00
parent c1fc6d32b2
commit b6799c9de4
138 changed files with 20613 additions and 2220 deletions
+228
View File
@@ -254,6 +254,207 @@ func aggregateStock(ctx *svc.ServiceContext, a aggregateStockArgs) ([]*stockAggR
return rows, total, nil
}
// materialAggRow 物料汇总行(按 物料编码 聚合,忽略区域与质量状态)
// 回答"这个物料总共有多少":总数/锁定/可用 + 质量分布(合格/未检/不合格) + 分布区域列表。
type materialAggRow struct {
MaterialCode string `json:"materialCode"`
MaterialName string `json:"materialName"`
Spec string `json:"spec"`
ManageMode int `json:"manageMode"` // 1结构件/2精密件
TotalQty int `json:"totalQty"` // 总数量(结构件=数量求和;精密件=SN 行数)
LockedQty int `json:"lockedQty"` // 锁定量
AvailQty int `json:"availQty"` // 可用量 = total - locked
QtyQualified int `json:"qtyQualified"` // 合格数量
QtyPending int `json:"qtyPending"` // 未检数量
QtyRejected int `json:"qtyRejected"` // 不合格数量
Zones []string `json:"zones"` // 分布区域列表
ZoneCount int `json:"zoneCount"` // 分布区域数
BatchCount int `json:"batchCount"` // 批次数(结构件维度的行数)
SnCount int `json:"snCount"` // SN 数(精密件维度的行数)
LastInboundNo string `json:"lastInboundNo"` // 最近入库单号
CreatedAt int64 `json:"createdAt"` // 最近一次入库时间
}
// materialSummaryArgs 物料汇总的筛选条件
type materialSummaryArgs struct {
MaterialCode string
MaterialName string
ManageMode string
Page int
PageSize int
}
// materialSummaryHandler 物料汇总(按物料聚合所有区域/质量状态)
// GET /api/stock/material-summary?materialCode=&materialName=&manageMode=&page=&pageSize=
// 返回 rows: 一物料一行,含 总数/可用/锁定/合格/未检/不合格/分布区域。明细经 /stock/details 点击下钻。
// 与主列表不同:汇总忽略区域/质量/入库时间筛选(回答总量问题,只认物料与类型)。
func materialSummaryHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
rows, total, err := aggregateMaterialSummary(ctx, materialSummaryArgs{
MaterialCode: r.URL.Query().Get("materialCode"),
MaterialName: r.URL.Query().Get("materialName"),
ManageMode: r.URL.Query().Get("manageMode"),
Page: atoi(r.URL.Query().Get("page"), 1),
PageSize: atoi(r.URL.Query().Get("pageSize"), 20),
})
if err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
ok(w, map[string]any{"rows": rows, "total": total})
}
}
// aggregateMaterialSummary 按物料编码聚合全部在库库存(忽略区域/质量/时间维度),Go 侧内存聚合 + 分页。
func aggregateMaterialSummary(ctx *svc.ServiceContext, a materialSummaryArgs) ([]*materialAggRow, int, error) {
q := ctx.EntClient.Inventory.Query()
q = q.Where(inventory.StatusIn("在库", "锁定"))
q = q.Where(inventory.Or(inventory.ManageModeEQ(2), inventory.QuantityGT(0)))
if a.MaterialCode != "" {
q = q.Where(inventory.MaterialCodeContains(a.MaterialCode))
}
if a.MaterialName != "" {
q = q.Where(inventory.MaterialNameContains(a.MaterialName))
}
if a.ManageMode == "1" {
q = q.Where(inventory.ManageModeEQ(1))
} else if a.ManageMode == "2" {
q = q.Where(inventory.ManageModeEQ(2))
}
all, err := q.Order(ent.Desc("created_at"), ent.Desc("id")).All(ctx0())
if err != nil {
return nil, 0, err
}
// 物料名称/规格补全
matName := map[string]string{}
matSpec := map[string]string{}
matCodes := []string{}
seen := map[string]bool{}
for _, inv := range all {
if !seen[inv.MaterialCode] {
seen[inv.MaterialCode] = true
matCodes = append(matCodes, inv.MaterialCode)
}
}
if len(matCodes) > 0 {
mats, _ := ctx.EntClient.Material.Query().
Where(material.CodeIn(matCodes...)).All(ctx0())
for _, m := range mats {
matName[m.Code] = m.Name
matSpec[m.Code] = m.Spec
}
}
type agg struct {
totalQty int
lockedQty int
qQualified int
qPending int
qRejected int
mMode int // 管理粒度(同物料唯一,取首见行的值)
batchCnt int
snCnt int
zones []string
zoneSeen map[string]bool
lastInbNo string
lastTime int64
}
group := map[string]*agg{} // key: materialCode
order := []string{}
for _, inv := range all {
key := inv.MaterialCode
g, ok := group[key]
if !ok {
g = &agg{mMode: inv.ManageMode, zoneSeen: map[string]bool{}}
group[key] = g
order = append(order, key)
}
// 数量
if inv.ManageMode == 1 {
g.totalQty += inv.Quantity
g.lockedQty += inv.LockedQty
g.batchCnt++
} else {
g.totalQty++
if inv.Status == "锁定" {
g.lockedQty++
}
g.snCnt++
}
// 质量分布(未检=空或非合格/不合格)
switch inv.QualityStatus {
case "合格":
g.qQualified += qtyOf(inv)
case "不合格":
g.qRejected += qtyOf(inv)
default:
g.qPending += qtyOf(inv)
}
// 分布区域
zc := inv.ZoneCode
if zc == "" {
zc = "未分区"
}
if !g.zoneSeen[zc] {
g.zoneSeen[zc] = true
g.zones = append(g.zones, zc)
}
if inv.CreatedAt > g.lastTime {
g.lastTime = inv.CreatedAt
g.lastInbNo = inv.InboundNo
}
}
total := len(order)
start := (a.Page - 1) * a.PageSize
if start < 0 {
start = 0
}
end := start + a.PageSize
if end > total {
end = total
}
rows := make([]*materialAggRow, 0, end-start)
for i := start; i < end; i++ {
code := order[i]
g := group[code]
name := matName[code]
if name == "" {
name = code
}
rows = append(rows, &materialAggRow{
MaterialCode: code,
MaterialName: name,
Spec: matSpec[code],
ManageMode: g.mMode,
TotalQty: g.totalQty,
LockedQty: g.lockedQty,
AvailQty: g.totalQty - g.lockedQty,
QtyQualified: g.qQualified,
QtyPending: g.qPending,
QtyRejected: g.qRejected,
Zones: g.zones,
ZoneCount: len(g.zones),
BatchCount: g.batchCnt,
SnCount: g.snCnt,
LastInboundNo: g.lastInbNo,
CreatedAt: g.lastTime,
})
}
return rows, total, nil
}
// qtyOf 库存行计入数量的口径:结构件按行数量、精密件按 1
func qtyOf(inv *ent.Inventory) int {
if inv.ManageMode == 1 {
return inv.Quantity
}
return 1
}
// stockDetailsHandler 库存明细(点击聚合行下钻,分页懒加载)
// GET /api/stock/details?materialCode=&zoneCode=&qualityStatus=&manageMode=&page=&pageSize=
// 返回该物料/区域/质量/类型下的原始批次/SN 明细行(扁平展示,含 SN/批次号)。
@@ -307,9 +508,36 @@ func stockDetailsHandler(ctx *svc.ServiceContext) http.HandlerFunc {
// exportStockHandler 库存汇总全量导出(xlsx)
// 与 /stock/query 同一套聚合逻辑,但导出当前筛选下的全部行(不分页)。
// 支持 ?view=material 导出「物料汇总」(/stock/material-summary 的全量版本)。
// 返回 .xlsx 二进制,文件名 = 库存汇总_时间.xlsx。
func exportStockHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("view") == "material" {
rows, _, err := aggregateMaterialSummary(ctx, materialSummaryArgs{
MaterialCode: r.URL.Query().Get("materialCode"),
MaterialName: r.URL.Query().Get("materialName"),
ManageMode: r.URL.Query().Get("manageMode"),
Page: 1,
PageSize: 1000000, // 全量
})
if err != nil {
fail(w, http.StatusInternalServerError, err.Error())
return
}
headers := []string{"物料编码", "物料名称", "规格", "类型", "总数量", "锁定量", "可用量", "合格", "未检", "不合格", "分布区域", "批次数", "SN数", "最近入库单", "最近入库时间"}
matrix := make([][]any, 0, len(rows))
for _, rw := range rows {
matrix = append(matrix, []any{
rw.MaterialCode, rw.MaterialName, rw.Spec,
manageModeLabel(rw.ManageMode), rw.TotalQty, rw.LockedQty, rw.AvailQty,
rw.QtyQualified, rw.QtyPending, rw.QtyRejected,
strings.Join(rw.Zones, "、"), rw.BatchCount, rw.SnCount,
rw.LastInboundNo, unixFmt(rw.CreatedAt),
})
}
sendExcel(w, xlsxFilename("物料汇总"), headers, matrix)
return
}
rows, _, err := aggregateStock(ctx, aggregateStockArgs{
MaterialCode: r.URL.Query().Get("materialCode"),
MaterialName: r.URL.Query().Get("materialName"),