1098 lines
33 KiB
Go
1098 lines
33 KiB
Go
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")
|
||
}
|