重构
This commit is contained in:
137
apps/api/src/domains/odds/markets.service.spec.ts
Normal file
137
apps/api/src/domains/odds/markets.service.spec.ts
Normal 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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user