feat: 主人拍板的 4 条全部落地(单条作废/重录、破纪录驳回、历史纪录、仪表盘卡片)+ 迁移 0021(dev_0.3.0)
## ① 成绩「作废本条 / 重新录入」——手滑填错了怎么办
- 新增 `POST /api/results/{id}/reopen`:把已发布的那条退回草稿(state=draft、
status=ok、rank=NULL、note=原因)并重算推榜;口径与既有 `void` 完全一致
(回滚类动作,不受 `standings.live_on_publish` 约束)。
- 成绩录入页新增「操作」列:已发布行可「作废本条」,已作废行可「重新录入」,
两者都必填原因、确认后立即重算+推大屏。**验收用例**:作废→重录→改→重发,
名次/流水/榜单与「一开始就填对」逐字节一致。
## ② 破纪录的驳回 / 作废
- 迁移 `0021_record_breaks_note.sql`:`record_breaks` 加一列可空 `note`。
- 新增 `GET /api/record-breaks?state=`(recorder 起可读,guest 403)与
`POST /api/record-breaks/{id}/reject`(必填原因):已批准的登记被作废时
**撤回该笔加分、重算推榜、并把该级别当前纪录恢复为上一条**;`approve`
放开为可对 rejected 再次批准。
- 破纪录页新增「已批准的破纪录」卡片(作废入口);原先只存在浏览器内存里的
「拒绝」改为持久化(先登记再驳回,刷新不再复现)。
## ③ 历史纪录的写入路径(`records.source` 以前恒为「本届」)
- 新增 `POST /api/records`(source 只收 history)与 `DELETE /api/records/{id}`
(删当前纪录时恢复上一条)。纪录库卡片新增「新增历史纪录」与「删除」。
- 至此《使用手册》6.2 承诺的「来源(本届/历史)」这一列才真正有意义。
## ④ 仪表盘补齐承诺的卡片
今天的小项(按 scheduledAt 的本地时区当天 + 进度)、草稿待发布、待确认破纪录、
大屏在线数;数据源读不到显示 `—` 而不是 0。文档里「管理端在线数」服务端没有
接口,按实际改写成本机管理端连接状态。
## 旧版数据包兼容(主人特别交代,三处验证)
迁移只加**可空**列、不重建表、不动数据行;旧赛事包导入后由 `main.go` 的
`store.MigrateEvent` 在启动时补跑迁移。证据:`migration_0021_test.go`
(旧库升级 + 幂等 + 老行取 NULL)、`migration_legacy_test.go` 扩到 0021、
`legacy_pack_upgrade_test.go`(真实旧 event.db 走 main 同一条路径后装配 API,
驳回老登记 → 加分撤回、纪录回退、旧数据逐表一行不差),另有真二进制冒烟。
未迁移的库上不存在任何新列读取。
## 文档
升级说明新增 dev_0.3.0(含「旧包仍可导入、自动补跑迁移」);使用手册新增
§4.10 与 §6.1/§6.2 明细;常见问题新增 Q98/Q99/Q100;devdocs 04/05/07/13/02
同步(含新接口表与错误分级)。
自检:服务端 13 包 ×2 全绿(httpapi 新增 reopen/reject/records/旧包 四个用例文件)、
前端 vue-tsc + 604 例 ×2 + build、Console 19 文件 + 54 例。
This commit is contained in:
1 parent
6841cf49aa
commit
ed2c0b3f3c
35 files changed
+4003
-115
No files matched your search
@@ -726,10 +726,12 @@ func (s *server) teamBreakdown(ctx context.Context, teamID int64) (teamBreakdown
|
||||
|
||||
// ---------- records ----------
|
||||
|
||||
// recordSourceHistory 是手工登记的历史纪录的来源标记,与 records.source 的 CHECK 一致。
|
||||
// 只认它:source='current' 只能由「确认破纪录」写出来(见 upsertCurrentRecord)。
|
||||
const recordSourceHistory = "history"
|
||||
|
||||
func (s *server) listRecords(ctx context.Context, eventID int64) ([]recordEntryDTO, error) {
|
||||
query := `SELECT id, event_id, level, COALESCE(result_value, 0), COALESCE(result_text, ''),
|
||||
holder_name, holder_team_id, achieved_at, is_current, source
|
||||
FROM records WHERE is_current = 1`
|
||||
query := `SELECT ` + recordEntryColumns + ` FROM records WHERE is_current = 1`
|
||||
args := []any{}
|
||||
if eventID > 0 {
|
||||
query += ` AND event_id = ?`
|
||||
@@ -745,19 +747,10 @@ func (s *server) listRecords(ctx context.Context, eventID int64) ([]recordEntryD
|
||||
|
||||
out := make([]recordEntryDTO, 0)
|
||||
for rows.Next() {
|
||||
var (
|
||||
r recordEntryDTO
|
||||
holderName sql.NullString
|
||||
holderTeamID sql.NullInt64
|
||||
achievedAt sql.NullString
|
||||
)
|
||||
if err := rows.Scan(&r.ID, &r.EventID, &r.Level, &r.ResultValue, &r.ResultText,
|
||||
&holderName, &holderTeamID, &achievedAt, &r.IsCurrent, &r.Source); err != nil {
|
||||
r, err := scanRecordEntry(rows)
|
||||
if err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
r.HolderName = strPtr(holderName)
|
||||
r.HolderTeamID = i64Ptr(holderTeamID)
|
||||
r.AchievedAt = strPtr(achievedAt)
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
@@ -832,30 +825,70 @@ func (s *server) validateRecordBreakEntry(ctx context.Context, in recordBreakInp
|
||||
}
|
||||
|
||||
func (s *server) getRecordBreak(ctx context.Context, id int64) (recordBreakDTO, error) {
|
||||
var (
|
||||
b recordBreakDTO
|
||||
value sql.NullFloat64
|
||||
text sql.NullString
|
||||
approvedBy sql.NullInt64
|
||||
approvedAt sql.NullString
|
||||
)
|
||||
err := s.ev.QueryRowContext(ctx,
|
||||
`SELECT id, event_id, entry_id, level, result_value, result_text, bonus_points, state, approved_by, approved_at
|
||||
FROM record_breaks WHERE id = ?`, id).
|
||||
Scan(&b.ID, &b.EventID, &b.EntryID, &b.Level, &value, &text, &b.BonusPoints, &b.State, &approvedBy, &approvedAt)
|
||||
b, err := scanRecordBreak(s.ev.QueryRowContext(ctx,
|
||||
`SELECT `+recordBreakColumns+` FROM record_breaks WHERE id = ?`, id))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return recordBreakDTO{}, notFound("破纪录登记不存在")
|
||||
}
|
||||
if err != nil {
|
||||
return recordBreakDTO{}, internal(err)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// recordBreakColumns 是 recordBreakDTO 的列清单;note 来自迁移 0021(可空)。
|
||||
const recordBreakColumns = `id, event_id, entry_id, level, result_value, result_text, bonus_points,
|
||||
state, approved_by, approved_at, note`
|
||||
|
||||
func scanRecordBreak(row interface{ Scan(...any) error }) (recordBreakDTO, error) {
|
||||
var (
|
||||
b recordBreakDTO
|
||||
value sql.NullFloat64
|
||||
text sql.NullString
|
||||
approvedBy sql.NullInt64
|
||||
approvedAt sql.NullString
|
||||
note sql.NullString
|
||||
)
|
||||
if err := row.Scan(&b.ID, &b.EventID, &b.EntryID, &b.Level, &value, &text, &b.BonusPoints,
|
||||
&b.State, &approvedBy, &approvedAt, ¬e); err != nil {
|
||||
return b, err
|
||||
}
|
||||
b.ResultValue = value.Float64
|
||||
b.ResultText = text.String
|
||||
b.ApprovedBy = i64Ptr(approvedBy)
|
||||
b.ApprovedAt = strPtr(approvedAt)
|
||||
b.Note = strPtr(note)
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// listRecordBreaks 列出破纪录登记(最新在前)。state 为空表示不过滤;
|
||||
// 传了就按它过滤,非法状态由 handler 拦在更外层(避免一个拼错的状态静默回空列表)。
|
||||
func (s *server) listRecordBreaks(ctx context.Context, state string) ([]recordBreakDTO, error) {
|
||||
query := `SELECT ` + recordBreakColumns + ` FROM record_breaks`
|
||||
args := []any{}
|
||||
if state != "" {
|
||||
query += ` WHERE state = ?`
|
||||
args = append(args, state)
|
||||
}
|
||||
query += ` ORDER BY id DESC`
|
||||
|
||||
rows, err := s.ev.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
out := make([]recordBreakDTO, 0)
|
||||
for rows.Next() {
|
||||
b, err := scanRecordBreak(rows)
|
||||
if err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
out = append(out, b)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *server) approveRecordBreak(ctx context.Context, id, userID int64) (recordBreakDTO, error) {
|
||||
tx, err := s.ev.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
@@ -879,11 +912,14 @@ func (s *server) approveRecordBreak(ctx context.Context, id, userID int64) (reco
|
||||
}
|
||||
return recordBreakDTO{}, internal(err)
|
||||
}
|
||||
if state != "pending" {
|
||||
if state != "pending" && state != "rejected" {
|
||||
return recordBreakDTO{}, stateInvalid("该破纪录登记已处理")
|
||||
}
|
||||
// 重新批准(pending → approved 或 rejected → approved)都要清掉上一次的驳回理由:
|
||||
// note 的含义是「这条登记现在为什么被驳回」,批准后它已经不成立,留着只会误导。
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE record_breaks SET state = 'approved', approved_by = ?, approved_at = datetime('now') WHERE id = ?`,
|
||||
`UPDATE record_breaks SET state = 'approved', approved_by = ?, approved_at = datetime('now'), note = NULL
|
||||
WHERE id = ?`,
|
||||
nullID(userID), id); err != nil {
|
||||
return recordBreakDTO{}, internal(err)
|
||||
}
|
||||
@@ -914,6 +950,102 @@ func (s *server) approveRecordBreak(ctx context.Context, id, userID int64) (reco
|
||||
return s.getRecordBreak(ctx, id)
|
||||
}
|
||||
|
||||
// rejectRecordBreak 驳回 / 作废一条破纪录登记(A41)。
|
||||
//
|
||||
// 原本是 approved 的登记意味着它已经给该小项加过分,所以驳回不只是改状态:
|
||||
//
|
||||
// ① 状态改 rejected 并把理由写进 note;
|
||||
// ② 重算该小项(引擎只认 approved 的登记),加分与随之变动的名次流水一起撤掉,再推榜;
|
||||
// ③ 把「确认破纪录」写进纪录库的那条 current 纪录撤下来,并恢复该级别上一条纪录。
|
||||
//
|
||||
// pending 直接驳回没有加过分,②③ 都不用做——这也是本文件里唯一区分「从哪个状态驳回」的地方。
|
||||
func (s *server) rejectRecordBreak(ctx context.Context, id, userID int64, reason string) (recordBreakDTO, error) {
|
||||
if strings.TrimSpace(reason) == "" {
|
||||
return recordBreakDTO{}, badRequest("reason 必填", nil)
|
||||
}
|
||||
tx, err := s.ev.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return recordBreakDTO{}, internal(err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
var (
|
||||
state string
|
||||
eventID int64
|
||||
level string
|
||||
)
|
||||
if err := tx.QueryRowContext(ctx,
|
||||
`SELECT state, event_id, level FROM record_breaks WHERE id = ?`, id).
|
||||
Scan(&state, &eventID, &level); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return recordBreakDTO{}, notFound("破纪录登记不存在")
|
||||
}
|
||||
return recordBreakDTO{}, internal(err)
|
||||
}
|
||||
if state == "rejected" {
|
||||
return recordBreakDTO{}, badRequest("该破纪录登记已被驳回/作废", nil)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE record_breaks SET state = 'rejected', note = ? WHERE id = ?`, reason, id); err != nil {
|
||||
return recordBreakDTO{}, internal(err)
|
||||
}
|
||||
if state == "approved" {
|
||||
if _, err := recomputeLedgerTx(ctx, tx, eventID, 0, userID, "驳回破纪录:"+reason); err != nil {
|
||||
return recordBreakDTO{}, err
|
||||
}
|
||||
if err := revertCurrentRecord(ctx, tx, eventID, level); err != nil {
|
||||
return recordBreakDTO{}, err
|
||||
}
|
||||
}
|
||||
// 登记表不进实时快照,但「破纪录登记」这一块要跟着刷新,所以照 approve 的口径写一条变更。
|
||||
if err := bumpVersion(ctx, tx, "record", id, "upsert", map[string]any{"id": id}, userID); err != nil {
|
||||
return recordBreakDTO{}, err
|
||||
}
|
||||
// 只有真的重算过才推榜:pending 驳回没动过任何分数,凭空推一次榜单只会让大屏白刷。
|
||||
if state == "approved" {
|
||||
if err := s.bumpStandings(ctx, tx, userID); err != nil {
|
||||
return recordBreakDTO{}, err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return recordBreakDTO{}, internal(err)
|
||||
}
|
||||
s.audit(ctx, "record_break.reject", "record_break", id,
|
||||
map[string]any{"state": state}, map[string]any{"state": "rejected", "reason": reason})
|
||||
return s.getRecordBreak(ctx, id)
|
||||
}
|
||||
|
||||
// revertCurrentRecord 撤下「确认破纪录」写进纪录库的那条当前纪录,并恢复该档位上一条纪录。
|
||||
//
|
||||
// 只能按 (小项, 档位) 定位:records 上没有指回 record_breaks 的列,0021 也只加了一列理由,
|
||||
// 没有为此重建表。同一 (小项, 档位) 的当前纪录本来就只有一条(upsertCurrentRecord 每次都先
|
||||
// 把旧的置 0 再插新的),所以「当前那条」就是这次批准写下的那条。
|
||||
// 恢复对象取剩下 id 最大的一条;一条都没有就把该档位留空——「无纪录」是事实,
|
||||
// 留一条刚被撤掉的纪录反而会让纪录库自相矛盾。
|
||||
func revertCurrentRecord(ctx context.Context, tx *sql.Tx, eventID int64, level string) error {
|
||||
var currentID sql.NullInt64
|
||||
err := tx.QueryRowContext(ctx,
|
||||
`SELECT id FROM records WHERE event_id = ? AND level = ? AND is_current = 1 ORDER BY id DESC LIMIT 1`,
|
||||
eventID, level).Scan(¤tID)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return internal(err)
|
||||
}
|
||||
if !currentID.Valid {
|
||||
return nil
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE records SET is_current = 0 WHERE id = ?`, currentID.Int64); err != nil {
|
||||
return internal(err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE records SET is_current = 1
|
||||
WHERE id = (SELECT id FROM records WHERE event_id = ? AND level = ? AND id <> ?
|
||||
ORDER BY id DESC LIMIT 1)`,
|
||||
eventID, level, currentID.Int64); err != nil {
|
||||
return internal(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// upsertCurrentRecord 把确认后的破纪录写入 records:同小项同档位的旧纪录改为非当前,
|
||||
// 新纪录标为 current(devdocs/04 records 表;source=current 表示本届产生)。
|
||||
func upsertCurrentRecord(ctx context.Context, tx *sql.Tx, eventID, entryID int64, level string, value sql.NullFloat64, text sql.NullString) error {
|
||||
@@ -952,6 +1084,161 @@ func upsertCurrentRecord(ctx context.Context, tx *sql.Tx, eventID, entryID int64
|
||||
return nil
|
||||
}
|
||||
|
||||
// recordEntryColumns 是 recordEntryDTO 的列清单。
|
||||
const recordEntryColumns = `id, event_id, level, COALESCE(result_value, 0), COALESCE(result_text, ''),
|
||||
holder_name, holder_team_id, achieved_at, is_current, source`
|
||||
|
||||
func scanRecordEntry(row interface{ Scan(...any) error }) (recordEntryDTO, error) {
|
||||
var (
|
||||
r recordEntryDTO
|
||||
holderName sql.NullString
|
||||
holderTeamID sql.NullInt64
|
||||
achievedAt sql.NullString
|
||||
)
|
||||
if err := row.Scan(&r.ID, &r.EventID, &r.Level, &r.ResultValue, &r.ResultText,
|
||||
&holderName, &holderTeamID, &achievedAt, &r.IsCurrent, &r.Source); err != nil {
|
||||
return r, err
|
||||
}
|
||||
r.HolderName = strPtr(holderName)
|
||||
r.HolderTeamID = i64Ptr(holderTeamID)
|
||||
r.AchievedAt = strPtr(achievedAt)
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (s *server) getRecordEntry(ctx context.Context, id int64) (recordEntryDTO, error) {
|
||||
r, err := scanRecordEntry(s.ev.QueryRowContext(ctx,
|
||||
`SELECT `+recordEntryColumns+` FROM records WHERE id = ?`, id))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return recordEntryDTO{}, notFound("纪录不存在")
|
||||
}
|
||||
if err != nil {
|
||||
return recordEntryDTO{}, internal(err)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// createRecordEntry 手工登记一条历史纪录(C11):同 (小项, 档位) 的旧 current 让位,
|
||||
// 新行成为该档位的当前纪录(与 upsertCurrentRecord 同口径,只是 source 记 history)。
|
||||
//
|
||||
// holder 名称 / 单位都可以空:很多历史纪录只留下了一个成绩数字,人是谁已经查不到了;
|
||||
// 逼着录入员编一个名字,只会往库里塞假数据。
|
||||
func (s *server) createRecordEntry(ctx context.Context, in recordEntryInput, userID int64) (recordEntryDTO, error) {
|
||||
if in.EventID == 0 {
|
||||
return recordEntryDTO{}, badRequest("eventId 必填", nil)
|
||||
}
|
||||
switch in.Level {
|
||||
case "meet", "city", "province", "national":
|
||||
default:
|
||||
return recordEntryDTO{}, badRequest("level 非法", in.Level)
|
||||
}
|
||||
if in.ResultValue == nil && in.ResultText == nil {
|
||||
return recordEntryDTO{}, badRequest("resultValue 与 resultText 至少填一个", nil)
|
||||
}
|
||||
// 只收 history:source='current' 是「确认破纪录」的产物,客户端造出来的 current
|
||||
// 会把一条没人批准过的成绩伪装成本届刚破的纪录,还会被后续批准当成上一条纪录恢复。
|
||||
if in.Source != "" && in.Source != recordSourceHistory {
|
||||
return recordEntryDTO{}, badRequest("source 只能是 history", in.Source)
|
||||
}
|
||||
|
||||
tx, err := s.ev.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return recordEntryDTO{}, internal(err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
var exists int
|
||||
err = tx.QueryRowContext(ctx, `SELECT 1 FROM events WHERE id = ?`, in.EventID).Scan(&exists)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return recordEntryDTO{}, badRequest("eventId 不存在", in.EventID)
|
||||
}
|
||||
if err != nil {
|
||||
return recordEntryDTO{}, internal(err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE records SET is_current = 0 WHERE event_id = ? AND level = ? AND is_current = 1`,
|
||||
in.EventID, in.Level); err != nil {
|
||||
return recordEntryDTO{}, internal(err)
|
||||
}
|
||||
res, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO records (event_id, level, result_value, result_text, holder_name, holder_team_id,
|
||||
achieved_at, is_current, source)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 1, 'history')`,
|
||||
in.EventID, in.Level, nullF64(in.ResultValue), nullStr(in.ResultText),
|
||||
nullStr(in.HolderName), nullI64(in.HolderTeamID), nullStr(in.AchievedAt))
|
||||
if isForeignKeyViolation(err) {
|
||||
return recordEntryDTO{}, badRequest("holderTeamId 不存在", in.HolderTeamID)
|
||||
}
|
||||
if err != nil {
|
||||
return recordEntryDTO{}, internal(err)
|
||||
}
|
||||
id, err := lastInsertID(res)
|
||||
if err != nil {
|
||||
return recordEntryDTO{}, internal(err)
|
||||
}
|
||||
// 纪录库进了实时快照(realtime.snapshot 读 records),不写变更大屏上的纪录页不会刷新。
|
||||
if err := bumpVersion(ctx, tx, "record", id, "upsert", map[string]any{"id": id}, userID); err != nil {
|
||||
return recordEntryDTO{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return recordEntryDTO{}, internal(err)
|
||||
}
|
||||
created, err := s.getRecordEntry(ctx, id)
|
||||
if err != nil {
|
||||
return recordEntryDTO{}, err
|
||||
}
|
||||
s.audit(ctx, "record.create", "record", id, nil, created)
|
||||
return created, nil
|
||||
}
|
||||
|
||||
// deleteRecordEntry 删除一条纪录;删掉的若是当前纪录,恢复同 (小项, 档位) 上一条为当前,
|
||||
// 一条都没有就把该档位留空(与 revertCurrentRecord 同一口径)。
|
||||
func (s *server) deleteRecordEntry(ctx context.Context, id, userID int64) error {
|
||||
tx, err := s.ev.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return internal(err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
var (
|
||||
eventID int64
|
||||
level string
|
||||
isCurrent bool
|
||||
)
|
||||
if err := tx.QueryRowContext(ctx,
|
||||
`SELECT event_id, level, is_current FROM records WHERE id = ?`, id).
|
||||
Scan(&eventID, &level, &isCurrent); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return notFound("纪录不存在")
|
||||
}
|
||||
return internal(err)
|
||||
}
|
||||
before, err := scanRecordEntry(tx.QueryRowContext(ctx,
|
||||
`SELECT `+recordEntryColumns+` FROM records WHERE id = ?`, id))
|
||||
if err != nil {
|
||||
return internal(err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM records WHERE id = ?`, id); err != nil {
|
||||
return internal(err)
|
||||
}
|
||||
if isCurrent {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE records SET is_current = 1
|
||||
WHERE id = (SELECT id FROM records WHERE event_id = ? AND level = ?
|
||||
ORDER BY id DESC LIMIT 1)`,
|
||||
eventID, level); err != nil {
|
||||
return internal(err)
|
||||
}
|
||||
}
|
||||
if err := bumpVersion(ctx, tx, "record", id, "void", map[string]any{"id": id}, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return internal(err)
|
||||
}
|
||||
s.audit(ctx, "record.delete", "record", id, before, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- adjustments ----------
|
||||
|
||||
func (s *server) listAdjustments(ctx context.Context, teamID int64, page, size int) (pageResult[adjustmentDTO], error) {
|
||||
|
||||
Reference in new issue
Block a user