package logic import ( "context" "sort" "strconv" "strings" "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/errors/gcode" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gtime" v1 "tool-api/api/admin/v1" "tool-api/internal/consts" "tool-api/internal/model/entity" ) // ============================================================================ // 年会域:活动 / 布局 / 桌 / 座 / 自动排座 // 依据:PRD-02 §4.2.1-4.2.2、§5.3;架构 §4.2、§5.2 A9-A17、§8.6.5。 // // 关键约定: // - 桌与座必须一致:建桌即生成 1..capacity 座;改 capacity 联动补/删座; // - 座位号默认 1..capacity(顺时针自桌正上方 12 点起);is_manual 标记人工改号; // - 布局并发编辑用 CAS(layout_version);冲突返回 code=4009; // - 自动排座 fill 保留手工号 / reorder 覆盖手工号(§4.2.2)。 // ============================================================================ // seatBrief 座位规划用的轻量快照(纯函数入参,便于单测) type seatBrief struct { Id int64 TableId int64 SeatNo int IsManual bool EmployeeId int64 // 0 = 空位 Excluded bool // true = 所属桌「不参与自动排序」(该桌座位与人员完全不被触碰) } // seatAssign 一条待写入的座位绑定 type seatAssign struct { SeatId int64 EmployeeId int64 } // tableBrief 桌的「排除校验」用轻量快照(纯函数入参,便于单测) type tableBrief struct { Id int64 TableNo string Name string ExcludeAuto int } // tableDisplayName 桌的可读名(用于错误提示列出被排除的桌) func tableDisplayName(t tableBrief) string { if t.Name != "" { return t.Name } if t.TableNo != "" { return t.TableNo + " 号桌" } return "桌#" + strconv.FormatInt(t.Id, 10) } // planAutoGuards 自动排座的前置校验(纯函数,单测直接覆盖)。 // // fail-closed(D4):**全部桌被排除**(无 exclude_auto=0 的桌)→ 返回 4018, // message 列出被排除的桌,不静默「排成功 0 人」。仅 1 张桌可参与时**允许**执行(提示由前端据本地桌数据给出)。 func planAutoGuards(tables []tableBrief) error { if len(tables) == 0 { return nil // 无桌:交由后续逻辑自然得到 0 结果(保持老行为) } participants := 0 excluded := make([]string, 0) for _, t := range tables { if t.ExcludeAuto == 1 { excluded = append(excluded, tableDisplayName(t)) } else { participants++ } } if participants == 0 { return gerror.NewCode(gcode.New(consts.CodeSeatAutoInvalid, "", nil), "没有可参与自动排序的桌(已排除:"+strings.Join(excluded, "、")+");请先取消部分桌的「不参与自动排序」") } return nil } // gcodeLayoutConflict 布局版本冲突(4009) func gcodeLayoutConflict() gcode.Code { return gcode.New(consts.CodeLayoutConflict, "", nil) } // ============================================================================ // 纯规划函数(不碰 DB,单测直接覆盖) // ============================================================================ // planSeatNumbers 生成默认座位号 1..capacity。 func planSeatNumbers(capacity int) []int { if capacity < 0 { capacity = 0 } out := make([]int, 0, capacity) for n := 1; n <= capacity; n++ { out = append(out, n) } return out } // planCapacityChange 计算 capacity 变更需要补出的座位号与需要移除的座位。 // - toAddNos:1..newCapacity 中当前不存在的号(升序); // - toRemove:seat_no > newCapacity 的现有座位(其 EmployeeId 会被调用方计入「未分配」)。 func planCapacityChange(seats []seatBrief, newCapacity int) (toAddNos []int, toRemove []seatBrief) { if newCapacity < 0 { newCapacity = 0 } existing := make(map[int]bool, len(seats)) for _, s := range seats { existing[s.SeatNo] = true } toAddNos = make([]int, 0) for n := 1; n <= newCapacity; n++ { if !existing[n] { toAddNos = append(toAddNos, n) } } toRemove = make([]seatBrief, 0) for _, s := range seats { if s.SeatNo > newCapacity { toRemove = append(toRemove, s) } } sort.SliceStable(toRemove, func(i, j int) bool { return toRemove[i].SeatNo < toRemove[j].SeatNo }) return toAddNos, toRemove } // planAutoAssign 计算自动排座结果。 // // mode=fill :候选 = 非手工且空位的座位(跳过 is_manual=1),编号不动;manualOverridden=0; // mode=reorder :先视为清空全部手工标记,候选 = 全部空位座位;manualOverridden=手工座位数。 // // **排除桌(s.Excluded=true)**:其座位**完全不被触碰** —— 不分配人、不重编座号、 // 不清其手工标记、也不计入 manualOverridden(N4 / S14)。已坐人的座位一律保持不动; // 未分配员工按 (tableId, seatNo) 升序依次填位。 func planAutoAssign(seats []seatBrief, unassignedEmp []int64, mode string) (assigns []seatAssign, remaining []int64, manualOverridden int) { ordered := make([]seatBrief, len(seats)) copy(ordered, seats) sort.SliceStable(ordered, func(i, j int) bool { if ordered[i].TableId != ordered[j].TableId { return ordered[i].TableId < ordered[j].TableId } return ordered[i].SeatNo < ordered[j].SeatNo }) candidates := make([]seatBrief, 0, len(ordered)) for _, s := range ordered { if s.Excluded { continue // 排除桌:座位与人员完全不被触碰 } if mode == consts.SeatModeReorder && s.IsManual { manualOverridden++ } if s.EmployeeId != 0 { continue // 已坐人,保持不动 } if mode == consts.SeatModeFill && s.IsManual { continue // fill 跳过手工座位(保留其编号与空位状态) } candidates = append(candidates, s) } assigns = make([]seatAssign, 0, len(candidates)) remaining = make([]int64, 0) for i, emp := range unassignedEmp { if i < len(candidates) { assigns = append(assigns, seatAssign{SeatId: candidates[i].Id, EmployeeId: emp}) } else { remaining = append(remaining, emp) } } return assigns, remaining, manualOverridden } // ============================================================================ // 活动 // ============================================================================ // eventRow 读取活动,不存在报错。 func eventRow(ctx context.Context, eventId int64) (*entity.AnnualEvents, error) { record, err := g.Model(consts.TableAnnualEvents).Where("id", eventId).One() if err != nil { return nil, err } if record.IsEmpty() { return nil, gerror.New("活动不存在") } event := &entity.AnnualEvents{} if err = record.Struct(event); err != nil { return nil, err } return event, nil } // AdminEventList 活动列表 func AdminEventList(ctx context.Context, req *v1.EventListReq) (*v1.EventListRes, error) { model := g.Model(consts.TableAnnualEvents) if req.EnterpriseId > 0 { model = model.Where("enterprise_id", req.EnterpriseId) } total, err := model.Count() if err != nil { return nil, err } page, size := normalizeAdminPage(req.Page, req.PageSize) records, err := model.OrderDesc("id").Page(page, size).All() if err != nil { return nil, err } list := make([]v1.EventItem, 0, len(records)) for _, r := range records { event := &entity.AnnualEvents{} if err = r.Struct(event); err != nil { continue } name := "" if ent, err := g.Model(consts.TableEnterprises).Where("id", event.EnterpriseId).One(); err == nil && !ent.IsEmpty() { name = ent["name"].String() } tableCount, err := g.Model(consts.TableEventTables).Where("event_id", event.Id).Count() if err != nil { return nil, err } list = append(list, v1.EventItem{ Id: event.Id, EnterpriseId: event.EnterpriseId, EnterpriseName: name, Title: event.Title, Venue: event.Venue, EventTime: formatGTime(event.EventTime), Status: event.Status, LayoutVersion: event.LayoutVersion, CodeUrl: event.CodeUrl, TableCount: int64(tableCount), }) } return &v1.EventListRes{List: list, Total: total}, nil } // AdminEventSave 建/改活动。新建默认 status=draft、layout_version=0。 func AdminEventSave(ctx context.Context, req *v1.EventSaveReq) (*v1.EventSaveRes, error) { if exists, err := g.Model(consts.TableEnterprises).Where("id", req.EnterpriseId).Count(); err != nil { return nil, err } else if exists == 0 { return nil, gerror.New("企业不存在") } eventTime, err := parseEventTime(req.EventTime) if err != nil { return nil, err } now := gtime.Now() if req.Id > 0 { result, err := g.Model(consts.TableAnnualEvents).Where("id", req.Id).Data(g.Map{ "enterprise_id": req.EnterpriseId, "title": req.Title, "venue": req.Venue, "event_time": eventTime, "updated_at": now, }).Update() if err != nil { return nil, err } if affected, _ := result.RowsAffected(); affected == 0 { if exists, err := g.Model(consts.TableAnnualEvents).Where("id", req.Id).Count(); err != nil { return nil, err } else if exists == 0 { return nil, gerror.New("活动不存在") } } return &v1.EventSaveRes{Id: req.Id}, nil } id, err := g.Model(consts.TableAnnualEvents).Data(g.Map{ "enterprise_id": req.EnterpriseId, "title": req.Title, "venue": req.Venue, "event_time": eventTime, "status": consts.EventStatusDraft, "hall_layout": "", "code_url": "", "layout_version": 0, "created_at": now, "updated_at": now, }).InsertAndGetId() if err != nil { return nil, err } return &v1.EventSaveRes{Id: id}, nil } // AdminEventStatus 发布/结束/回草稿 func AdminEventStatus(ctx context.Context, req *v1.EventStatusReq) (*v1.EventStatusRes, error) { before, err := eventRow(ctx, req.EventId) if err != nil { return nil, err } if _, err := g.Model(consts.TableAnnualEvents).Where("id", req.EventId).Data(g.Map{ "status": req.Status, "updated_at": gtime.Now(), }).Update(); err != nil { return nil, err } // T17 审计:发布/结束活动属敏感操作 WriteAudit(ctx, AuditEntry{ Action: "event.status", TargetType: "event", TargetId: auditId(req.EventId), Before: g.Map{"status": before.Status}, After: g.Map{"status": req.Status}, Result: consts.AuditResultSuccess, }) return &v1.EventStatusRes{}, nil } // AdminEventDetail 设计器全量:活动 + 桌(含座)+ 候场数 + 未分配池。 func AdminEventDetail(ctx context.Context, req *v1.EventDetailReq) (*v1.EventDetailRes, error) { event, err := eventRow(ctx, req.EventId) if err != nil { return nil, err } tables, err := loadTables(ctx, req.EventId) if err != nil { return nil, err } seats, err := loadSeats(ctx, req.EventId) if err != nil { return nil, err } nameMap, err := loadEmployeeNames(ctx, collectEmployeeIds(seats)) if err != nil { return nil, err } seatsByTable := map[int64][]v1.SeatOut{} seatCount := 0 for _, s := range seats { seatCount++ empId := int64(0) if s.EmployeeId != nil { empId = *s.EmployeeId } seatsByTable[s.TableId] = append(seatsByTable[s.TableId], v1.SeatOut{ Id: s.Id, SeatNo: s.SeatNo, IsManual: s.IsManual == 1, EmployeeId: empId, EmployeeName: nameMap[empId], }) } tableOuts := make([]v1.TableOut, 0, len(tables)) for _, t := range tables { tableOuts = append(tableOuts, v1.TableOut{ Id: t.Id, TableNo: t.TableNo, Name: t.Name, X: t.X, Y: t.Y, Capacity: t.Capacity, Rotation: t.Rotation, TableType: t.TableType, Remark: t.Remark, ExcludeAuto: t.ExcludeAuto, Seats: seatsByTable[t.Id], }) } participantCount, err := g.Model(consts.TableEventParticipants).Where("event_id", req.EventId).Count() if err != nil { return nil, err } unassigned, err := unassignedParticipants(ctx, req.EventId) if err != nil { return nil, err } return &v1.EventDetailRes{ Id: event.Id, EnterpriseId: event.EnterpriseId, Title: event.Title, Venue: event.Venue, EventTime: formatGTime(event.EventTime), Status: event.Status, LayoutVersion: event.LayoutVersion, HallLayout: event.HallLayout, Tables: tableOuts, ParticipantCount: participantCount, SeatCount: seatCount, Unassigned: unassigned, }, nil } // ============================================================================ // 布局保存(CAS) // ============================================================================ // AdminEventLayoutSave 保存大厅布局:CAS 更新 layout_version,冲突返回 4009;同时 upsert 桌。 func AdminEventLayoutSave(ctx context.Context, req *v1.EventLayoutSaveReq) (*v1.EventLayoutSaveRes, error) { event, err := eventRow(ctx, req.EventId) // 先确保活动存在 if err != nil { return nil, err } if req.LayoutVersion < 0 { return nil, gerror.NewCode(gcode.CodeValidationFailed, "缺少布局版本号") } newVersion := req.LayoutVersion + 1 now := gtime.Now() err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { result, err := tx.Model(consts.TableAnnualEvents). Where("id", req.EventId). Where("layout_version", req.LayoutVersion). Data(g.Map{ "hall_layout": req.HallLayout, "layout_version": gdb.Raw("layout_version + 1"), "updated_at": now, }).Update() if err != nil { return err } if affected, _ := result.RowsAffected(); affected == 0 { return gerror.NewCode(gcodeLayoutConflict(), "布局已被他人修改(当前版本 "+itoa(event.LayoutVersion)+"),请刷新后重试") } for _, tbl := range req.Tables { if _, _, _, _, err = saveTableRow(ctx, tx, req.EventId, tbl); err != nil { return err } } return nil }) if err != nil { return nil, err } return &v1.EventLayoutSaveRes{LayoutVersion: newVersion}, nil } // ============================================================================ // 桌(含座位联动) // ============================================================================ // saveTableRow 事务内 upsert 一张桌并同步座位。 // 返回:桌 id、补座数、删座数、因减座移入未分配的人数。 func saveTableRow(ctx context.Context, tx gdb.TX, eventId int64, in v1.TableIn) (tableId int64, added, removed, moved int, err error) { now := gtime.Now() capacity := in.Capacity // 新建 if in.Id == 0 { if capacity < 0 { capacity = 0 // 特殊桌(签到台/媒体席/备用桌)允许 0 座 } tableId, err = tx.Model(consts.TableEventTables).Data(g.Map{ "event_id": eventId, "table_no": in.TableNo, "name": in.Name, "x": in.X, "y": in.Y, "capacity": capacity, "rotation": in.Rotation, "table_type": in.TableType, "remark": in.Remark, "exclude_auto": in.ExcludeAuto, "created_at": now, "updated_at": now, }).InsertAndGetId() if err != nil { return 0, 0, 0, 0, err } for _, no := range planSeatNumbers(capacity) { if _, err = tx.Model(consts.TableEventSeats).Data(g.Map{ "event_id": eventId, "table_id": tableId, "seat_no": no, "is_manual": 0, "employee_id": nil, "created_at": now, "updated_at": now, }).Insert(); err != nil { return 0, 0, 0, 0, err } } return tableId, capacity, 0, 0, nil } // 更新:行锁读取现有桌 tableId = in.Id record, err := tx.Model(consts.TableEventTables). Where("id", in.Id).Where("event_id", eventId).LockUpdate().One() if err != nil { return 0, 0, 0, 0, err } if record.IsEmpty() { return 0, 0, 0, 0, gerror.New("桌不存在") } oldCapacity := record["capacity"].Int() if capacity < 0 { capacity = oldCapacity } // 现有座位快照 briefs, err := loadSeatBriefs(ctx, tx, eventId, tableId) if err != nil { return 0, 0, 0, 0, err } toAddNos, toRemove := planCapacityChange(briefs, capacity) // 减座:删除多余座位;其上员工自动回到「未分配」池 for _, s := range toRemove { if s.EmployeeId != 0 { moved++ } if _, err = tx.Model(consts.TableEventSeats).Where("id", s.Id).Delete(); err != nil { return 0, 0, 0, 0, err } } // 增座:补出空位 for _, no := range toAddNos { if _, err = tx.Model(consts.TableEventSeats).Data(g.Map{ "event_id": eventId, "table_id": tableId, "seat_no": no, "is_manual": 0, "employee_id": nil, "created_at": now, "updated_at": now, }).Insert(); err != nil { return 0, 0, 0, 0, err } } // 更新桌 if _, err = tx.Model(consts.TableEventTables).Where("id", tableId).Data(g.Map{ "table_no": in.TableNo, "name": in.Name, "x": in.X, "y": in.Y, "capacity": capacity, "rotation": in.Rotation, "table_type": in.TableType, "remark": in.Remark, "exclude_auto": in.ExcludeAuto, "updated_at": now, }).Update(); err != nil { return 0, 0, 0, 0, err } return tableId, len(toAddNos), len(toRemove), moved, nil } // AdminTableSave 建/改桌(改人数联动补/删座)。 func AdminTableSave(ctx context.Context, req *v1.TableSaveReq) (*v1.TableSaveRes, error) { if _, err := eventRow(ctx, req.EventId); err != nil { return nil, err } var ( tableId int64 added int removed int moved int ) err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { var e error tableId, added, removed, moved, e = saveTableRow(ctx, tx, req.EventId, v1.TableIn{ Id: req.Id, TableNo: req.TableNo, Name: req.Name, X: req.X, Y: req.Y, Capacity: req.Capacity, Rotation: req.Rotation, TableType: req.TableType, Remark: req.Remark, ExcludeAuto: req.ExcludeAuto, }) return e }) if err != nil { return nil, err } return &v1.TableSaveRes{ TableId: tableId, AddedSeats: added, RemovedSeats: removed, MovedToUnassigned: moved, }, nil } // AdminTableDelete 删桌(连同座位;其上员工回到未分配池)。 func AdminTableDelete(ctx context.Context, req *v1.TableDeleteReq) (*v1.TableDeleteRes, error) { if _, err := eventRow(ctx, req.EventId); err != nil { return nil, err } moved := 0 err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { occupied, err := tx.Model(consts.TableEventSeats). Where("event_id", req.EventId).Where("table_id", req.TableId). WhereNotNull("employee_id").Count() if err != nil { return err } moved = occupied if _, err = tx.Model(consts.TableEventSeats). Where("event_id", req.EventId).Where("table_id", req.TableId).Delete(); err != nil { return err } result, err := tx.Model(consts.TableEventTables). Where("id", req.TableId).Where("event_id", req.EventId).Delete() if err != nil { return err } if affected, _ := result.RowsAffected(); affected == 0 { return gerror.New("桌不存在") } return nil }) if err != nil { return nil, err } // T17 审计:删桌属敏感(会移动已绑定员工) WriteAudit(ctx, AuditEntry{ Action: "event.table_delete", TargetType: "table", TargetId: auditId(req.TableId), After: g.Map{"event_id": req.EventId, "moved_to_unassigned": moved}, Result: consts.AuditResultSuccess, }) return &v1.TableDeleteRes{MovedToUnassigned: moved}, nil } // ============================================================================ // 座位:改号(对调语义)/ 绑定 / 解绑 // ============================================================================ // seatSwapPlan 座位对调计划(纯计算结果) type seatSwapPlan struct { SeatAId int64 // 被改号的座位 SeatANewNo int // 目标号 SeatBId int64 // 原占目标号的座位(0 = 无需对调,幂等) SeatBNewNo int // B 的新号(= A 的原号) TempNo int // 交换过程中 B 的中转号(避开 uk_evt_tbl_seat) } // planSeatSwap 计算把 seatAId 的座位号改为 targetNo 的**对调**计划。 // // 语义(用户拍板 = 对调编号): // - 目标号必须已在同一桌存在 —— 对调两者编号,而非新增编号; // - targetNo 不存在于该桌(含超出 1..capacity)→ 报错「该桌没有 N 号座位」; // - targetNo == 当前号 → 幂等(SeatBId=0)。 // // 注意:对调仅交换「编号标签」,座位行与其上员工绑定均不动(员工不随号迁移)。 func planSeatSwap(seats []seatBrief, seatAId int64, targetNo int) (*seatSwapPlan, error) { var cur *seatBrief for i := range seats { if seats[i].Id == seatAId { cur = &seats[i] break } } if cur == nil { return nil, gerror.New("座位不存在") } if targetNo <= 0 { return nil, gerror.New("座位号非法") } if targetNo == cur.SeatNo { return &seatSwapPlan{SeatAId: seatAId, SeatANewNo: targetNo}, nil } var target *seatBrief for i := range seats { if seats[i].SeatNo == targetNo { target = &seats[i] break } } if target == nil { return nil, gerror.Newf("该桌没有 %d 号座位", targetNo) } return &seatSwapPlan{ SeatAId: cur.Id, SeatANewNo: targetNo, SeatBId: target.Id, SeatBNewNo: cur.SeatNo, TempNo: maxSeatNo(seats) + 100000, }, nil } // maxSeatNo 桌上最大座位号(用于生成安全的中转号)。 func maxSeatNo(seats []seatBrief) int { m := 0 for _, s := range seats { if s.SeatNo > m { m = s.SeatNo } } return m } // AdminSeatSave 改座位号(**对调语义**)/ 绑定 / 解绑。 // // 改号:把座位 A(当前 cur)改为 N 时,与同桌上 seat_no=N 的座位 B **对调编号** // (A→N、B→cur),二者 is_manual 均置 1(后续 fill 会跳过它们,不被自动排座抹掉)。 // **员工跟随座位行不动**(只换编号标签):例如原「3 号·张三」「8 号·李四」对调后 // 变为「8 号·张三」「3 号·李四」,两人都没离开自己的物理位置 —— 这决定了查座页显示什么。 func AdminSeatSave(ctx context.Context, req *v1.SeatSaveReq) (*v1.SeatSaveRes, error) { record, err := g.Model(consts.TableEventSeats). Where("id", req.SeatId).Where("event_id", req.EventId).One() if err != nil { return nil, err } if record.IsEmpty() { return nil, gerror.New("座位不存在") } tableId := record["table_id"].Int64() curSeatNo := record["seat_no"].Int() bindChanged := false var bindValue interface{} // nil=解绑;非 nil=绑定 if req.EmployeeId != nil { bindChanged = true if *req.EmployeeId == 0 { bindValue = nil } else { bindValue = *req.EmployeeId } } renumber := req.SeatNo > 0 && req.SeatNo != curSeatNo // 仅改绑定(或幂等无操作) if !renumber { if bindChanged { if _, err = g.Model(consts.TableEventSeats).Where("id", req.SeatId).Data(g.Map{ "employee_id": bindValue, "updated_at": gtime.Now(), }).Update(); err != nil { return nil, err } } return &v1.SeatSaveRes{}, nil } // 改号:事务内三步走对调(规避 UNIQUE(event_id, table_id, seat_no)) now := gtime.Now() err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { briefs, e := loadSeatBriefs(ctx, tx, req.EventId, tableId) if e != nil { return e } plan, e := planSeatSwap(briefs, req.SeatId, req.SeatNo) if e != nil { return e } if plan.SeatBId == 0 { return nil // 幂等 } // 1) B 先挪到中转号(确保后续写入不撞唯一键) if _, e = tx.Model(consts.TableEventSeats).Where("id", plan.SeatBId).Data(g.Map{ "seat_no": plan.TempNo, "updated_at": now, }).Update(); e != nil { return e } // 2) A 设为目标号(含可能的绑定变更);置 is_manual=1 aData := g.Map{"seat_no": plan.SeatANewNo, "is_manual": 1, "updated_at": now} if bindChanged { aData["employee_id"] = bindValue } if _, e = tx.Model(consts.TableEventSeats).Where("id", plan.SeatAId).Data(aData).Update(); e != nil { return e } // 3) B 落到 A 的原号;B 亦被人工调整 → is_manual=1 if _, e = tx.Model(consts.TableEventSeats).Where("id", plan.SeatBId).Data(g.Map{ "seat_no": plan.SeatBNewNo, "is_manual": 1, "updated_at": now, }).Update(); e != nil { return e } return nil }) if err != nil { return nil, err } return &v1.SeatSaveRes{}, nil } // ============================================================================ // 自动排座 // ============================================================================ // AdminSeatAuto 自动排座:mode=fill 增量补位(保留手工号)/ reorder 全部重排(覆盖手工号)。 // // N4 排除语义: // - 「不参与自动排序」的桌(exclude_auto=1):其座位与人员**完全不被触碰**(不分配、不重编、 // 不清手工号、不计入 manual_overridden); // - 「不参与排座」的员工(event_participants.exclude_assign=1):**永不被分配**,一直留在员工池, // 并计入响应的 excluded; // - fail-closed(D4):**全部桌被排除** → 返回 4018,message 列出被排除的桌,不静默「排成功 0 人」。 func AdminSeatAuto(ctx context.Context, req *v1.SeatAutoReq) (*v1.SeatAutoRes, error) { if _, err := eventRow(ctx, req.EventId); err != nil { return nil, err } // 1) 桌快照 + fail-closed 前置校验 tables, err := loadTableBriefs(ctx, req.EventId) if err != nil { return nil, err } if err = planAutoGuards(tables); err != nil { return nil, err } excludedTables := map[int64]bool{} excludedTableIds := make([]int64, 0) for _, t := range tables { if t.ExcludeAuto == 1 { excludedTables[t.Id] = true excludedTableIds = append(excludedTableIds, t.Id) } } // 2) 座位快照,标记排除桌 briefs, err := loadSeatBriefs(ctx, g.DB(), req.EventId, 0) if err != nil { return nil, err } for i := range briefs { if excludedTables[briefs[i].TableId] { briefs[i].Excluded = true } } // 3) 未分配员工,剔除「不参与排座」者 unassigned, err := unassignedEmployeeIds(ctx, req.EventId) if err != nil { return nil, err } parts, err := g.Model(consts.TableEventParticipants).Where("event_id", req.EventId).All() if err != nil { return nil, err } excludedEmp := map[int64]bool{} excludedCount := 0 for _, p := range parts { if p["exclude_assign"].Int() == 1 { excludedEmp[p["employee_id"].Int64()] = true excludedCount++ } } if excludedCount > 0 { kept := make([]int64, 0, len(unassigned)) for _, id := range unassigned { if !excludedEmp[id] { kept = append(kept, id) } } unassigned = kept } assigns, remaining, manualOverridden := planAutoAssign(briefs, unassigned, req.Mode) now := gtime.Now() err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { if req.Mode == consts.SeatModeReorder { // 覆盖含手工号在内的编号并清空手工标记;但**排除桌的手工号不动** model := tx.Model(consts.TableEventSeats). Where("event_id", req.EventId).Where("is_manual", 1) if len(excludedTableIds) > 0 { model = model.WhereNotIn("table_id", excludedTableIds) } if _, err := model.Data(g.Map{"is_manual": 0, "updated_at": now}).Update(); err != nil { return err } } for _, a := range assigns { if _, err := tx.Model(consts.TableEventSeats).Where("id", a.SeatId).Data(g.Map{ "employee_id": a.EmployeeId, "updated_at": now, }).Update(); err != nil { return err } } return nil }) if err != nil { return nil, err } return &v1.SeatAutoRes{ Assigned: len(assigns), Unassigned: len(remaining), ManualOverridden: manualOverridden, Excluded: excludedCount, }, nil } // loadTableBriefs 读取该活动的桌(排除校验用快照)。 func loadTableBriefs(ctx context.Context, eventId int64) ([]tableBrief, error) { records, err := g.Model(consts.TableEventTables).Where("event_id", eventId).OrderAsc("id").All() if err != nil { return nil, err } out := make([]tableBrief, 0, len(records)) for _, r := range records { out = append(out, tableBrief{ Id: r["id"].Int64(), TableNo: r["table_no"].String(), Name: r["name"].String(), ExcludeAuto: r["exclude_auto"].Int(), }) } return out, nil } // ============================================================================ // 数据读取辅助 // ============================================================================ // querier 抽象 g.DB() 与 gdb.TX 的公共查询能力(Model 方法) type querier interface { Model(tableNameOrStruct ...interface{}) *gdb.Model } func loadTables(ctx context.Context, eventId int64) ([]*entity.EventTables, error) { records, err := g.Model(consts.TableEventTables).Where("event_id", eventId).OrderAsc("id").All() if err != nil { return nil, err } out := make([]*entity.EventTables, 0, len(records)) for _, r := range records { t := &entity.EventTables{} if err = r.Struct(t); err != nil { continue } out = append(out, t) } return out, nil } func loadSeats(ctx context.Context, eventId int64) ([]*entity.EventSeats, error) { records, err := g.Model(consts.TableEventSeats). Where("event_id", eventId). OrderAsc("table_id").OrderAsc("seat_no").All() if err != nil { return nil, err } out := make([]*entity.EventSeats, 0, len(records)) for _, r := range records { s := &entity.EventSeats{} if err = r.Struct(s); err != nil { continue } out = append(out, s) } return out, nil } // loadSeatBriefs 读取座位快照;tableId=0 表示整场活动。 func loadSeatBriefs(ctx context.Context, q querier, eventId, tableId int64) ([]seatBrief, error) { model := q.Model(consts.TableEventSeats).Where("event_id", eventId) if tableId > 0 { model = model.Where("table_id", tableId) } records, err := model.OrderAsc("table_id").OrderAsc("seat_no").All() if err != nil { return nil, err } out := make([]seatBrief, 0, len(records)) for _, r := range records { empId := int64(0) if v := r["employee_id"]; !v.IsNil() { empId = v.Int64() } out = append(out, seatBrief{ Id: r["id"].Int64(), TableId: r["table_id"].Int64(), SeatNo: r["seat_no"].Int(), IsManual: r["is_manual"].Int() == 1, EmployeeId: empId, }) } return out, nil } // unassignedParticipants 候场名单中未绑定任何座位的成员(含姓名/手机/部门)。 func unassignedParticipants(ctx context.Context, eventId int64) ([]v1.UnassignedItem, error) { ids, err := unassignedEmployeeIds(ctx, eventId) if err != nil { return nil, err } out := make([]v1.UnassignedItem, 0, len(ids)) if len(ids) == 0 { return out, nil } records, err := g.Model(consts.TableEmployees).WhereIn("id", ids).All() if err != nil { return nil, err } nameMap := map[int64]v1.UnassignedItem{} for _, r := range records { nameMap[r["id"].Int64()] = v1.UnassignedItem{ EmployeeId: r["id"].Int64(), Name: r["name"].String(), Phone: r["phone"].String(), Dept: r["dept"].String(), } } for _, id := range ids { if item, ok := nameMap[id]; ok { out = append(out, item) } else { out = append(out, v1.UnassignedItem{EmployeeId: id}) } } return out, nil } // unassignedEmployeeIds 候场名单员工 id,剔除已绑定座位的;保持候场顺序(按 participant id)。 func unassignedEmployeeIds(ctx context.Context, eventId int64) ([]int64, error) { parts, err := g.Model(consts.TableEventParticipants). Where("event_id", eventId).OrderAsc("id").All() if err != nil { return nil, err } boundRecords, err := g.Model(consts.TableEventSeats). Where("event_id", eventId).WhereNotNull("employee_id").All() if err != nil { return nil, err } bound := make(map[int64]bool, len(boundRecords)) for _, r := range boundRecords { bound[r["employee_id"].Int64()] = true } out := make([]int64, 0, len(parts)) for _, p := range parts { id := p["employee_id"].Int64() if !bound[id] { out = append(out, id) } } return out, nil } func collectEmployeeIds(seats []*entity.EventSeats) []int64 { seen := map[int64]bool{} out := make([]int64, 0) for _, s := range seats { if s.EmployeeId != nil && !seen[*s.EmployeeId] { seen[*s.EmployeeId] = true out = append(out, *s.EmployeeId) } } return out } func loadEmployeeNames(ctx context.Context, ids []int64) (map[int64]string, error) { out := map[int64]string{} if len(ids) == 0 { return out, nil } records, err := g.Model(consts.TableEmployees).WhereIn("id", ids).All() if err != nil { return nil, err } for _, r := range records { out[r["id"].Int64()] = r["name"].String() } return out, nil } // parseEventTime 解析活动时间字符串;空串表示不设。 func parseEventTime(s string) (*gtime.Time, error) { s = strings.TrimSpace(s) if s == "" { return nil, nil } t, err := gtime.StrToTime(s) if err != nil { return nil, gerror.NewCode(gcode.CodeValidationFailed, "活动时间格式不正确") } return t, nil } // formatGTime 时间格式化为 yyyy-MM-dd HH:mm:ss;空返回空串。 func formatGTime(t *gtime.Time) string { if t == nil || t.IsZero() { return "" } return t.Format("Y-m-d H:i:s") }