Files
qitongxue-api/internal/logic/event_layout.go
2026-09-29 10:57:01 +08:00

1098 lines
33 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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")
}