This commit is contained in:
wchino
2026-06-13 17:38:25 +08:00
parent e7e938f261
commit 7b33d9f9fa
190 changed files with 23222 additions and 4336 deletions

View File

@@ -0,0 +1,137 @@
import { MarketsService } from './markets.service';
function selection(selectionCode: string, odds = 1.9) {
return {
selectionCode,
selectionName: selectionCode,
odds,
status: 'OPEN',
};
}
function errorCode(error: unknown) {
const response = (error as { getResponse?: () => unknown }).getResponse?.();
return (response as { code?: string } | undefined)?.code;
}
function createPrismaMock() {
let marketId = 100n;
let selectionId = 1000n;
return {
match: {
findUnique: jest.fn().mockResolvedValue({ id: 1n }),
},
market: {
findFirst: jest.fn().mockResolvedValue(null),
create: jest.fn().mockImplementation(async ({ data }) => ({ id: marketId++, ...data })),
update: jest.fn().mockImplementation(async ({ where, data }) => ({ id: where.id, ...data })),
findUnique: jest.fn().mockImplementation(async ({ where }) => ({ id: where.id, selections: [] })),
findMany: jest.fn().mockResolvedValue([]),
updateMany: jest.fn().mockResolvedValue({ count: 0 }),
},
marketSelection: {
findMany: jest.fn().mockResolvedValue([]),
create: jest.fn().mockImplementation(async ({ data }) => ({ id: selectionId++, ...data })),
update: jest.fn(),
updateMany: jest.fn().mockResolvedValue({ count: 0 }),
},
oddsChangeLog: {
create: jest.fn(),
},
};
}
describe('MarketsService line value rules', () => {
it('exposes usesLineValue in market definitions', () => {
const service = new MarketsService(createPrismaMock() as never);
const definitions = service.listMarketDefinitions();
expect(definitions.find((d) => d.marketType === 'FT_HANDICAP')?.usesLineValue).toBe(true);
expect(definitions.find((d) => d.marketType === 'FT_1X2')?.usesLineValue).toBe(false);
});
it('rejects line markets without a numeric line value', async () => {
const service = new MarketsService(createPrismaMock() as never);
let caughtCode: string | undefined;
try {
await service.saveMatchMarkets(1n, [
{
marketType: 'FT_HANDICAP',
lineValue: null,
selections: [selection('HOME'), selection('AWAY')],
},
]);
} catch (error) {
caughtCode = errorCode(error);
}
expect(caughtCode).toBe('MARKET_LINE_REQUIRED');
});
it('rejects non-line markets with a line value', async () => {
const service = new MarketsService(createPrismaMock() as never);
let caughtCode: string | undefined;
try {
await service.saveMatchMarkets(1n, [
{
marketType: 'FT_1X2',
lineValue: 1,
selections: [selection('HOME'), selection('DRAW'), selection('AWAY')],
},
]);
} catch (error) {
caughtCode = errorCode(error);
}
expect(caughtCode).toBe('MARKET_LINE_NOT_ALLOWED');
});
it('allows non-line markets with null line value', async () => {
const prisma = createPrismaMock();
const service = new MarketsService(prisma as never);
const result = await service.saveMatchMarkets(1n, [
{
marketType: 'FT_1X2',
lineValue: null,
selections: [selection('HOME'), selection('DRAW'), selection('AWAY')],
},
]);
expect(result).toEqual({ updated: 1, closed: 0 });
expect(prisma.market.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
marketType: 'FT_1X2',
lineKey: 'FT_1X2:none',
lineValue: null,
}),
}),
);
});
it('keeps distinct line keys for copied line markets', async () => {
const prisma = createPrismaMock();
const service = new MarketsService(prisma as never);
const result = await service.saveMatchMarkets(1n, [
{
marketType: 'FT_HANDICAP',
lineValue: -0.5,
selections: [selection('HOME'), selection('AWAY')],
},
{
marketType: 'FT_HANDICAP',
lineValue: -0.25,
selections: [selection('HOME'), selection('AWAY')],
},
]);
expect(result).toEqual({ updated: 2, closed: 0 });
expect(prisma.market.create.mock.calls.map(([arg]) => arg.data.lineKey)).toEqual([
'FT_HANDICAP:-0.50',
'FT_HANDICAP:-0.25',
]);
});
});

View File

@@ -1,189 +1,291 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { Decimal } from '@prisma/client/runtime/library';
import {
FT_CORRECT_SCORE_TEMPLATE,
HT_CORRECT_SCORE_TEMPLATE,
} from '../settlement/domain/settlement-calculator';
DEFAULT_MARKET_TYPES,
FOOTBALL_MARKET_CATALOG,
buildMarketLineKey,
buildMarketTemplate,
defaultSelectionName,
isSettlementSupportedMarketType,
marketUsesLineValue,
resolveMarketText,
sanitizeLocalizedText,
type LocalizedText,
} from '@thebet365/shared';
import { appBadRequest, appNotFound } from '../../shared/common/app-error';
type LocalizedInput = Record<string, string | undefined | null>;
type SelectionDraft = {
id?: string;
selectionCode: string;
selectionName?: string;
nameI18n?: LocalizedInput | null;
odds: number;
status?: string;
sortOrder?: number;
};
type MarketDraft = {
id?: string;
marketType: string;
marketKey?: string | null;
lineKey?: string | null;
period?: string;
lineValue?: number | null;
paramsJson?: Record<string, unknown> | null;
status?: string;
allowSingle?: boolean;
allowParlay?: boolean;
showOnPlayer?: boolean;
sortOrder?: number;
promoLabel?: string | null;
promoLabelI18n?: LocalizedInput | null;
nameI18n?: LocalizedInput | null;
selections?: SelectionDraft[];
};
type TemplateDraft = {
name?: string;
nameI18n?: LocalizedInput | null;
description?: string | null;
isDefault?: boolean;
status?: string;
sortOrder?: number;
items?: MarketDraft[];
};
@Injectable()
export class MarketsService {
constructor(private prisma: PrismaService) {}
async generateTemplates(matchId: bigint, marketTypes: string[]) {
listMarketDefinitions() {
return Object.entries(FOOTBALL_MARKET_CATALOG).map(([marketType, entry]) => ({
marketType,
marketKey: entry.marketKey,
period: entry.period,
sortOrder: entry.sortOrder,
defaultLineValue: entry.defaultLineValue,
allowSingle: entry.allowSingle,
allowParlay: entry.allowParlay,
showOnPlayer: entry.showOnPlayer,
settlementSupported: entry.settlementSupported,
settlementKind: entry.settlementKind,
renderType: entry.renderType,
usesLineValue: marketUsesLineValue(marketType),
nameI18n: entry.nameI18n,
selectionTemplate: entry.selectionTemplate,
}));
}
async listTemplates() {
await this.ensureDefaultTemplate();
const templates = await this.prisma.marketTemplate.findMany({
where: { sportType: 'FOOTBALL' },
include: { items: { include: { selections: true } } },
orderBy: [{ isDefault: 'desc' }, { sortOrder: 'asc' }, { id: 'asc' }],
});
return templates.map((t) => this.mapTemplate(t));
}
async getTemplate(templateId: bigint) {
await this.ensureDefaultTemplate();
const template = await this.prisma.marketTemplate.findUnique({
where: { id: templateId },
include: {
items: {
include: { selections: { orderBy: { sortOrder: 'asc' } } },
orderBy: { sortOrder: 'asc' },
},
},
});
if (!template) throw appNotFound('MARKET_TEMPLATE_NOT_FOUND');
return this.mapTemplate(template);
}
async createTemplate(data: TemplateDraft) {
const nameI18n = sanitizeLocalizedText(data.nameI18n);
const name = data.name?.trim() || resolveMarketText(nameI18n, 'zh-CN', '足球盘口模板');
const template = await this.prisma.marketTemplate.create({
data: {
sportType: 'FOOTBALL',
name,
nameI18n: this.jsonOrNull(nameI18n),
description: data.description?.trim() || null,
isDefault: data.isDefault ?? false,
status: data.status ?? 'ACTIVE',
sortOrder: data.sortOrder ?? 0,
},
});
if (data.items?.length) {
await this.replaceTemplateItems(template.id, data.items);
}
if (data.isDefault) await this.setDefaultTemplate(template.id);
return this.getTemplate(template.id);
}
async updateTemplate(templateId: bigint, data: TemplateDraft) {
const existing = await this.prisma.marketTemplate.findUnique({ where: { id: templateId } });
if (!existing) throw appNotFound('MARKET_TEMPLATE_NOT_FOUND');
const nameI18n =
data.nameI18n !== undefined
? sanitizeLocalizedText(data.nameI18n)
: this.asLocalizedText(existing.nameI18n);
await this.prisma.marketTemplate.update({
where: { id: templateId },
data: {
...(data.name !== undefined
? { name: data.name.trim() || resolveMarketText(nameI18n, 'zh-CN', existing.name) }
: {}),
...(data.nameI18n !== undefined ? { nameI18n: this.jsonOrNull(nameI18n) } : {}),
...(data.description !== undefined ? { description: data.description?.trim() || null } : {}),
...(data.status !== undefined ? { status: data.status } : {}),
...(data.sortOrder !== undefined ? { sortOrder: data.sortOrder } : {}),
...(data.isDefault !== undefined ? { isDefault: data.isDefault } : {}),
},
});
if (data.items) await this.replaceTemplateItems(templateId, data.items);
if (data.isDefault) await this.setDefaultTemplate(templateId);
return this.getTemplate(templateId);
}
async duplicateTemplate(templateId: bigint) {
const source = await this.prisma.marketTemplate.findUnique({
where: { id: templateId },
include: { items: { include: { selections: true }, orderBy: { sortOrder: 'asc' } } },
});
if (!source) throw appNotFound('MARKET_TEMPLATE_NOT_FOUND');
const copy = await this.prisma.marketTemplate.create({
data: {
sportType: source.sportType,
name: `${source.name} Copy`,
nameI18n: source.nameI18n ?? undefined,
description: source.description,
isDefault: false,
status: source.status,
sortOrder: source.sortOrder + 1,
},
});
await this.replaceTemplateItems(
copy.id,
source.items.map((item) => ({
marketType: item.marketType,
marketKey: item.marketKey,
lineKey: item.lineKey,
period: item.period,
lineValue: item.lineValue == null ? null : Number(item.lineValue),
paramsJson: (item.paramsJson as Record<string, unknown> | null) ?? null,
status: item.status,
allowSingle: item.allowSingle,
allowParlay: item.allowParlay,
showOnPlayer: item.showOnPlayer,
sortOrder: item.sortOrder,
promoLabel: item.promoLabel,
promoLabelI18n: this.asLocalizedText(item.promoLabelI18n),
nameI18n: this.asLocalizedText(item.nameI18n),
selections: item.selections.map((s) => ({
selectionCode: s.selectionCode,
selectionName: s.selectionName,
nameI18n: this.asLocalizedText(s.nameI18n),
odds: Number(s.odds),
status: s.status,
sortOrder: s.sortOrder,
})),
})),
);
return this.getTemplate(copy.id);
}
async setDefaultTemplate(templateId: bigint) {
const existing = await this.prisma.marketTemplate.findUnique({ where: { id: templateId } });
if (!existing) throw appNotFound('MARKET_TEMPLATE_NOT_FOUND');
await this.prisma.$transaction([
this.prisma.marketTemplate.updateMany({
where: { sportType: 'FOOTBALL', id: { not: templateId } },
data: { isDefault: false },
}),
this.prisma.marketTemplate.update({
where: { id: templateId },
data: { isDefault: true, status: 'ACTIVE' },
}),
]);
return this.getTemplate(templateId);
}
async applyTemplateToMatch(matchId: bigint, templateId?: bigint | null) {
const match = await this.prisma.match.findUnique({ where: { id: matchId } });
if (!match) throw appNotFound('MATCH_NOT_FOUND');
const template = templateId
? await this.prisma.marketTemplate.findUnique({
where: { id: templateId },
include: { items: { include: { selections: true }, orderBy: { sortOrder: 'asc' } } },
})
: await this.getDefaultTemplateRecord();
if (!template) throw appNotFound('MARKET_TEMPLATE_NOT_FOUND');
const created = [];
const results = [];
for (const item of template.items) {
if (item.status === 'DELETED') continue;
const draft: MarketDraft = {
marketType: item.marketType,
marketKey: item.marketKey,
lineKey: item.lineKey,
period: item.period,
lineValue: item.lineValue == null ? null : Number(item.lineValue),
paramsJson: (item.paramsJson as Record<string, unknown> | null) ?? null,
status: item.status,
allowSingle: item.allowSingle,
allowParlay: item.allowParlay,
showOnPlayer: item.showOnPlayer,
sortOrder: item.sortOrder,
promoLabel: item.promoLabel,
promoLabelI18n: this.asLocalizedText(item.promoLabelI18n),
nameI18n: this.asLocalizedText(item.nameI18n),
selections: item.selections.map((s) => ({
selectionCode: s.selectionCode,
selectionName: s.selectionName,
nameI18n: this.asLocalizedText(s.nameI18n),
odds: Number(s.odds),
status: s.status,
sortOrder: s.sortOrder,
})),
};
results.push(await this.upsertMatchMarket(matchId, draft, undefined, item.id));
}
return { templateId: template.id.toString(), applied: results.length };
}
for (const marketType of marketTypes) {
const existing = await this.prisma.market.findFirst({
where: { matchId, marketType },
});
if (existing) continue;
const config = this.getMarketConfig(marketType);
const market = await this.prisma.market.create({
data: {
matchId,
marketType,
period: config.period,
lineValue: config.lineValue,
allowSingle: true,
allowParlay: config.allowParlay,
sortOrder: config.sortOrder,
selections: {
create: config.selections.map((s, i) => ({
selectionCode: s.code,
selectionName: s.name,
odds: s.odds ?? 1.01,
sortOrder: i,
})),
},
},
include: { selections: true },
});
created.push(market);
async saveMatchMarkets(matchId: bigint, items: MarketDraft[], operatorId?: bigint) {
const match = await this.prisma.match.findUnique({ where: { id: matchId } });
if (!match) throw appNotFound('MATCH_NOT_FOUND');
const saved = [];
const activeIds = new Set<string>();
for (const item of items) {
const market = await this.upsertMatchMarket(matchId, item, operatorId);
if (!market) throw appNotFound('MARKET_NOT_FOUND');
activeIds.add(market.id.toString());
saved.push(market);
}
return created;
const existing = await this.prisma.market.findMany({ where: { matchId }, select: { id: true } });
const missing = existing.filter((m) => !activeIds.has(m.id.toString())).map((m) => m.id);
if (missing.length) {
await this.prisma.market.updateMany({
where: { id: { in: missing } },
data: { status: 'CLOSED', showOnPlayer: false },
});
}
return { updated: saved.length, closed: missing.length };
}
private formatHandicapName(side: 'home' | 'away', line: number, half = false) {
const sideLabel = side === 'home' ? '主队' : '客队';
const value = side === 'home' ? line : -line;
const lineText = value > 0 ? `+${value}` : `${value}`;
return half ? `半场${sideLabel} ${lineText}` : `${sideLabel} ${lineText}`;
}
private formatOuName(side: 'over' | 'under', line: number, half = false) {
const sideLabel = side === 'over' ? '大' : '小';
return half ? `半场${sideLabel} ${line}` : `${sideLabel} ${line}`;
}
private formatScoreName(code: string) {
return code.replace('SCORE_', '').replace('_', '-');
}
private getMarketConfig(marketType: string) {
const configs: Record<string, {
period: string;
lineValue?: number;
allowParlay: boolean;
sortOrder: number;
selections: Array<{ code: string; name: string; odds?: number }>;
}> = {
FT_1X2: {
period: 'FT',
allowParlay: true,
sortOrder: 1,
selections: [
{ code: 'HOME', name: '主胜', odds: 2.5 },
{ code: 'DRAW', name: '和', odds: 3.2 },
{ code: 'AWAY', name: '客胜', odds: 2.8 },
],
},
HT_1X2: {
period: 'HT',
allowParlay: true,
sortOrder: 5,
selections: [
{ code: 'HOME', name: '半场主胜', odds: 3.0 },
{ code: 'DRAW', name: '半场和', odds: 2.0 },
{ code: 'AWAY', name: '半场客胜', odds: 3.5 },
],
},
FT_HANDICAP: {
period: 'FT',
lineValue: -0.5,
allowParlay: true,
sortOrder: 2,
selections: [
{ code: 'HOME', name: this.formatHandicapName('home', -0.5), odds: 1.9 },
{ code: 'AWAY', name: this.formatHandicapName('away', -0.5), odds: 1.9 },
],
},
HT_HANDICAP: {
period: 'HT',
lineValue: -0.5,
allowParlay: true,
sortOrder: 6,
selections: [
{ code: 'HOME', name: this.formatHandicapName('home', -0.5, true), odds: 1.9 },
{ code: 'AWAY', name: this.formatHandicapName('away', -0.5, true), odds: 1.9 },
],
},
FT_OVER_UNDER: {
period: 'FT',
lineValue: 2.5,
allowParlay: true,
sortOrder: 3,
selections: [
{ code: 'OVER', name: this.formatOuName('over', 2.5), odds: 1.85 },
{ code: 'UNDER', name: this.formatOuName('under', 2.5), odds: 1.95 },
],
},
HT_OVER_UNDER: {
period: 'HT',
lineValue: 1.5,
allowParlay: true,
sortOrder: 7,
selections: [
{ code: 'OVER', name: this.formatOuName('over', 1.5, true), odds: 2.0 },
{ code: 'UNDER', name: this.formatOuName('under', 1.5, true), odds: 1.75 },
],
},
FT_ODD_EVEN: {
period: 'FT',
allowParlay: true,
sortOrder: 4,
selections: [
{ code: 'ODD', name: '单', odds: 1.9 },
{ code: 'EVEN', name: '双', odds: 1.9 },
],
},
FT_CORRECT_SCORE: {
period: 'FT',
allowParlay: true,
sortOrder: 8,
selections: FT_CORRECT_SCORE_TEMPLATE.map((code) => ({
code,
name: this.formatScoreName(code),
odds: 8.0,
})),
},
HT_CORRECT_SCORE: {
period: 'HT',
allowParlay: true,
sortOrder: 9,
selections: HT_CORRECT_SCORE_TEMPLATE.map((code) => ({
code,
name: this.formatScoreName(code),
odds: 6.0,
})),
},
SH_CORRECT_SCORE: {
period: 'SH',
allowParlay: true,
sortOrder: 10,
selections: HT_CORRECT_SCORE_TEMPLATE.map((code) => ({
code,
name: this.formatScoreName(code),
odds: 6.0,
})),
},
OUTRIGHT_WINNER: {
period: 'OUTRIGHT',
allowParlay: false,
sortOrder: 1,
selections: [],
},
};
const config = configs[marketType];
if (!config) throw appBadRequest('UNKNOWN_MARKET_TYPE', { marketType });
return config;
async generateTemplates(matchId: bigint, marketTypes: string[]) {
const items = marketTypes.map((marketType, i) => {
const config = this.getMarketConfig(marketType);
return this.defaultDraftFromConfig(marketType, config.defaultLineValue, i);
});
const result = await this.saveMatchMarkets(matchId, items);
return { created: result.updated };
}
async updateOdds(selectionId: bigint, newOdds: number, operatorId: bigint) {
@@ -226,24 +328,42 @@ export class MarketsService {
async updateMarket(
marketId: bigint,
data: { promoLabel?: string | null; status?: string; lineValue?: number | null },
data: {
promoLabel?: string | null;
promoLabelI18n?: LocalizedInput | null;
nameI18n?: LocalizedInput | null;
status?: string;
lineValue?: number | null;
showOnPlayer?: boolean;
},
) {
const market = await this.prisma.market.findUnique({ where: { id: marketId } });
if (!market) throw appNotFound('MARKET_NOT_FOUND');
const lineValue = data.lineValue !== undefined ? data.lineValue : market.lineValue == null ? null : Number(market.lineValue);
if (data.lineValue !== undefined) {
this.assertLineValueAllowed(market.marketType, lineValue);
}
const lineKey =
data.lineValue !== undefined
? buildMarketLineKey(market.marketType, lineValue, (market.paramsJson as Record<string, unknown> | null) ?? null)
: market.lineKey;
return this.prisma.market.update({
where: { id: marketId },
data: {
...(data.promoLabel !== undefined ? { promoLabel: data.promoLabel?.trim() || null } : {}),
...(data.promoLabelI18n !== undefined ? { promoLabelI18n: this.jsonOrNull(sanitizeLocalizedText(data.promoLabelI18n)) } : {}),
...(data.nameI18n !== undefined ? { nameI18n: this.jsonOrNull(sanitizeLocalizedText(data.nameI18n)) } : {}),
...(data.status !== undefined ? { status: data.status } : {}),
...(data.lineValue !== undefined ? { lineValue: data.lineValue } : {}),
...(data.lineValue !== undefined ? { lineValue, lineKey } : {}),
...(data.showOnPlayer !== undefined ? { showOnPlayer: data.showOnPlayer } : {}),
},
});
}
async updateSelection(
selectionId: bigint,
data: { selectionName?: string; odds?: number; status?: string },
data: { selectionName?: string; nameI18n?: LocalizedInput | null; odds?: number; status?: string },
operatorId?: bigint,
) {
const selection = await this.prisma.marketSelection.findUnique({
@@ -253,15 +373,355 @@ export class MarketsService {
if (data.odds != null) {
if (!operatorId) throw appBadRequest('OPERATOR_REQUIRED');
return this.updateOdds(selectionId, data.odds, operatorId);
await this.updateOdds(selectionId, data.odds, operatorId);
}
return this.prisma.marketSelection.update({
where: { id: selectionId },
data: {
...(data.selectionName !== undefined ? { selectionName: data.selectionName.trim() } : {}),
...(data.nameI18n !== undefined ? { nameI18n: this.jsonOrNull(sanitizeLocalizedText(data.nameI18n)) } : {}),
...(data.status !== undefined ? { status: data.status } : {}),
},
});
}
private async ensureDefaultTemplate() {
const existing = await this.prisma.marketTemplate.findFirst({
where: { sportType: 'FOOTBALL', isDefault: true, status: 'ACTIVE' },
});
if (existing) return existing;
const template = await this.prisma.marketTemplate.create({
data: {
sportType: 'FOOTBALL',
name: '默认足球盘口模板',
nameI18n: this.jsonOrNull({
'zh-CN': '默认足球盘口模板',
'en-US': 'Default Football Market Template',
'ms-MY': 'Templat Pasaran Bola Sepak Lalai',
}),
isDefault: true,
status: 'ACTIVE',
sortOrder: 0,
},
});
await this.replaceTemplateItems(
template.id,
DEFAULT_MARKET_TYPES.map((marketType, i) => {
const config = this.getMarketConfig(marketType);
return this.defaultDraftFromConfig(marketType, config.defaultLineValue, i);
}),
);
return template;
}
private async getDefaultTemplateRecord() {
await this.ensureDefaultTemplate();
return this.prisma.marketTemplate.findFirst({
where: { sportType: 'FOOTBALL', isDefault: true, status: 'ACTIVE' },
include: { items: { include: { selections: true }, orderBy: { sortOrder: 'asc' } } },
});
}
private async replaceTemplateItems(templateId: bigint, items: MarketDraft[]) {
await this.prisma.marketTemplateItem.deleteMany({ where: { templateId } });
for (const [index, item] of items.entries()) {
const normalized = this.normalizeDraft(item, index);
await this.prisma.marketTemplateItem.create({
data: {
templateId,
marketType: normalized.marketType,
marketKey: normalized.marketKey,
lineKey: normalized.lineKey,
period: normalized.period,
lineValue: normalized.lineValue,
paramsJson: this.jsonValueOrNull(normalized.paramsJson),
status: normalized.status,
allowSingle: normalized.allowSingle,
allowParlay: normalized.allowParlay,
showOnPlayer: normalized.showOnPlayer,
sortOrder: normalized.sortOrder,
promoLabel: normalized.promoLabel,
promoLabelI18n: this.jsonOrNull(normalized.promoLabelI18n),
nameI18n: this.jsonOrNull(normalized.nameI18n),
selections: {
create: normalized.selections.map((s) => ({
selectionCode: s.selectionCode,
selectionName: s.selectionName,
nameI18n: this.jsonOrNull(s.nameI18n),
odds: s.odds,
status: s.status,
sortOrder: s.sortOrder,
})),
},
},
});
}
}
private async upsertMatchMarket(
matchId: bigint,
draft: MarketDraft,
operatorId?: bigint,
templateItemId?: bigint,
) {
const normalized = this.normalizeDraft(draft, draft.sortOrder ?? 0);
const existing = draft.id
? await this.prisma.market.findFirst({ where: { id: BigInt(draft.id), matchId }, include: { selections: true } })
: await this.prisma.market.findFirst({ where: { matchId, lineKey: normalized.lineKey }, include: { selections: true } });
const showOnPlayer = normalized.showOnPlayer && isSettlementSupportedMarketType(normalized.marketType);
const data = {
marketType: normalized.marketType,
marketKey: normalized.marketKey,
lineKey: normalized.lineKey,
period: normalized.period,
lineValue: normalized.lineValue,
paramsJson: this.jsonValueOrNull(normalized.paramsJson),
status: normalized.status,
allowSingle: normalized.allowSingle,
allowParlay: normalized.allowParlay,
showOnPlayer,
sortOrder: normalized.sortOrder,
promoLabel: normalized.promoLabel,
promoLabelI18n: this.jsonOrNull(normalized.promoLabelI18n),
nameI18n: this.jsonOrNull(normalized.nameI18n),
templateItemId: templateItemId ?? undefined,
};
const market = existing
? await this.prisma.market.update({ where: { id: existing.id }, data })
: await this.prisma.market.create({ data: { matchId, ...data } });
await this.syncMarketSelections(market.id, normalized.selections, operatorId);
return this.prisma.market.findUnique({
where: { id: market.id },
include: { selections: { orderBy: { sortOrder: 'asc' } } },
});
}
private async syncMarketSelections(
marketId: bigint,
selections: Array<SelectionDraft & { selectionName: string; nameI18n: LocalizedText; status: string; sortOrder: number }>,
operatorId?: bigint,
) {
const existing = await this.prisma.marketSelection.findMany({ where: { marketId } });
const seen = new Set<string>();
for (const sel of selections) {
if (!sel.odds || sel.odds <= 1) throw appBadRequest('ODDS_MIN');
const selectionId = sel.id;
const current = selectionId
? existing.find((s) => s.id === BigInt(selectionId))
: existing.find((s) => s.selectionCode === sel.selectionCode);
if (current) {
seen.add(current.id.toString());
const oddsChanged = Number(current.odds) !== Number(sel.odds);
const newVersion = oddsChanged ? current.oddsVersion + BigInt(1) : current.oddsVersion;
if (oddsChanged) {
await this.prisma.oddsChangeLog.create({
data: {
selectionId: current.id,
oldOdds: current.odds,
newOdds: sel.odds,
oddsVersion: newVersion,
changedBy: operatorId,
},
});
}
await this.prisma.marketSelection.update({
where: { id: current.id },
data: {
selectionCode: sel.selectionCode,
selectionName: sel.selectionName,
nameI18n: this.jsonOrNull(sel.nameI18n),
odds: sel.odds,
oddsVersion: newVersion,
status: sel.status,
sortOrder: sel.sortOrder,
},
});
} else {
const created = await this.prisma.marketSelection.create({
data: {
marketId,
selectionCode: sel.selectionCode,
selectionName: sel.selectionName,
nameI18n: this.jsonOrNull(sel.nameI18n),
odds: sel.odds,
status: sel.status,
sortOrder: sel.sortOrder,
},
});
seen.add(created.id.toString());
}
}
const toClose = existing.filter((s) => !seen.has(s.id.toString())).map((s) => s.id);
if (toClose.length) {
await this.prisma.marketSelection.updateMany({
where: { id: { in: toClose } },
data: { status: 'CLOSED' },
});
}
}
private normalizeDraft(item: MarketDraft, fallbackOrder: number) {
const config = this.getMarketConfig(item.marketType);
const lineValue = item.lineValue !== undefined ? item.lineValue : config.defaultLineValue;
this.assertLineValueAllowed(item.marketType, lineValue);
const paramsJson = item.paramsJson ?? config.defaultParams ?? null;
const lineKey = item.lineKey?.trim() || buildMarketLineKey(item.marketType, lineValue, paramsJson);
const nameI18n = {
...config.nameI18n,
...sanitizeLocalizedText(item.nameI18n),
};
const promoLabelI18n = sanitizeLocalizedText(item.promoLabelI18n);
const sourceSelections = item.selections?.length
? item.selections
: config.selectionTemplate.map((s, index) => ({
selectionCode: s.code,
selectionName: s.name,
nameI18n: s.nameI18n,
odds: s.odds,
status: 'OPEN',
sortOrder: index,
}));
return {
marketType: item.marketType,
marketKey: item.marketKey?.trim() || config.marketKey,
lineKey,
period: item.period || config.period,
lineValue,
paramsJson,
status: item.status || 'OPEN',
allowSingle: item.allowSingle ?? config.allowSingle,
allowParlay: item.allowParlay ?? config.allowParlay,
showOnPlayer: item.showOnPlayer ?? config.showOnPlayer,
sortOrder: item.sortOrder ?? config.sortOrder ?? fallbackOrder,
promoLabel: item.promoLabel?.trim() || null,
promoLabelI18n,
nameI18n,
selections: sourceSelections.map((s, index) => {
const defaultName = defaultSelectionName(item.marketType, s.selectionCode, 'zh-CN');
const rawNameI18n = sanitizeLocalizedText(s.nameI18n);
const nameI18n = Object.keys(rawNameI18n).length
? rawNameI18n
: { 'zh-CN': s.selectionName?.trim() || defaultName };
return {
id: 'id' in s ? s.id : undefined,
selectionCode: s.selectionCode,
selectionName: s.selectionName?.trim() || resolveMarketText(nameI18n, 'zh-CN', defaultName),
nameI18n,
odds: Number(s.odds),
status: s.status || 'OPEN',
sortOrder: s.sortOrder ?? index,
};
}),
};
}
private defaultDraftFromConfig(marketType: string, lineValue: number | null, index: number): MarketDraft {
const config = this.getMarketConfig(marketType);
this.assertLineValueAllowed(marketType, lineValue);
return {
marketType,
marketKey: config.marketKey,
lineKey: buildMarketLineKey(marketType, lineValue, null),
period: config.period,
lineValue,
status: 'OPEN',
allowSingle: config.allowSingle,
allowParlay: config.allowParlay,
showOnPlayer: config.showOnPlayer,
sortOrder: config.sortOrder ?? index,
nameI18n: config.nameI18n,
selections: config.selectionTemplate.map((s, i) => ({
selectionCode: s.code,
selectionName: s.name,
nameI18n: s.nameI18n,
odds: s.odds,
status: 'OPEN',
sortOrder: i,
})),
};
}
private mapTemplate(template: Prisma.MarketTemplateGetPayload<{ include: { items: { include: { selections: true } } } }>) {
return {
id: template.id.toString(),
sportType: template.sportType,
name: template.name,
nameI18n: this.asLocalizedText(template.nameI18n),
description: template.description,
isDefault: template.isDefault,
status: template.status,
sortOrder: template.sortOrder,
items: template.items
.slice()
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((item) => ({
id: item.id.toString(),
marketType: item.marketType,
marketKey: item.marketKey,
lineKey: item.lineKey,
period: item.period,
lineValue: item.lineValue == null ? null : Number(item.lineValue),
paramsJson: item.paramsJson,
status: item.status,
allowSingle: item.allowSingle,
allowParlay: item.allowParlay,
showOnPlayer: item.showOnPlayer,
sortOrder: item.sortOrder,
promoLabel: item.promoLabel ?? '',
promoLabelI18n: this.asLocalizedText(item.promoLabelI18n),
nameI18n: this.asLocalizedText(item.nameI18n),
selections: item.selections
.slice()
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((s) => ({
id: s.id.toString(),
selectionCode: s.selectionCode,
selectionName: s.selectionName,
nameI18n: this.asLocalizedText(s.nameI18n),
odds: Number(s.odds),
status: s.status,
sortOrder: s.sortOrder,
})),
})),
};
}
private getMarketConfig(marketType: string) {
try {
return buildMarketTemplate(marketType);
} catch {
throw appBadRequest('UNKNOWN_MARKET_TYPE', { marketType });
}
}
private assertLineValueAllowed(marketType: string, lineValue: number | null | undefined) {
if (marketUsesLineValue(marketType)) {
if (typeof lineValue !== 'number' || !Number.isFinite(lineValue)) {
throw appBadRequest('MARKET_LINE_REQUIRED');
}
return;
}
if (lineValue != null) {
throw appBadRequest('MARKET_LINE_NOT_ALLOWED');
}
}
private jsonOrNull(value: LocalizedText | Record<string, unknown> | null | undefined) {
if (!value || !Object.keys(value).length) return Prisma.JsonNull;
return value as Prisma.InputJsonValue;
}
private jsonValueOrNull(value: Record<string, unknown> | null | undefined) {
if (value == null) return Prisma.JsonNull;
return value as Prisma.InputJsonValue;
}
private asLocalizedText(value: unknown): LocalizedText {
return sanitizeLocalizedText(value);
}
}