feat(odds): 支持多开注商赔率快照与配置管理
Some checks failed
lotteryadmin CI / build (push) Has been cancelled

- 在赔率行数据结构和接口中新增 provider_code 字段支持多开注商
- 管理后台赔率配置页新增开注商筛选,支持查看和编辑指定开注商赔率
- 未指定专属赔率时,自动继承通用(Global)赔率并显示继承标识
- 新增赔率快照展示组件,显示票据下注时锁定的开注商赔率快照
- 优化赔率保存流程,支持基于开注商区分并批量更新赔率数据
- 更新多语言文案,增加开注商与赔率来源标识翻译
- 调整环境配置文档和默认域名为新的开注商域名地址
- 优化界面标签样式,清晰区分通用、专属和继承赔率来源
- Tickets模块新增赔率快照数据类型及详细展示表格
- 修复管理后台及票据详情页部分链接和操作按钮,提升用户体验
This commit is contained in:
2026-07-09 10:51:56 +08:00
parent 4f612ccbda
commit 330063abf6
17 changed files with 357 additions and 62 deletions

View File

@@ -12,7 +12,7 @@
LOTTERY_API_UPSTREAM=http://127.0.0.1:8000
# 线上Laravel 在本机时用内网地址更快)
# LOTTERY_API_UPSTREAM=http://127.0.0.1:8000
# LOTTERY_API_UPSTREAM=https://lotterylaravel.tanumo.com
# LOTTERY_API_UPSTREAM=https://lottery-api.cjdhr.top
# Next 开发:局域网 IP 访问(逗号分隔 host无协议
# ALLOWED_DEV_ORIGINS=192.168.0.101

View File

@@ -48,7 +48,7 @@ This version has breaking changes — APIs, conventions, and file structure may
- 客户对外文档:`/docs``/docs/integration``/docs/admin` 公开免登录;读者为接入方与站点运营/代理;顶栏「管理后台」链 `/admin`SSO 无登录换票、JWT+`player/me`、iframe `data.token`Docs 侧栏 `--docs-sticky-top`;静态校验用 `document.body.innerText`
- `SettlementBillRow``currency_code`;账单金额展示用玩家 `default_currency`
- 浏览器 `/api/v1/*` 由 Next 转发到 `LOTTERY_API_UPSTREAM`;与本地 Postgres 对账前须确认 upstream 与所查库一致。
- Tanumo 联调/生产默认H5 `https://front.tanumo.com`、API `https://lotterylaravel.tanumo.com`、管理端/文档 `https://lotteryadmin.tanumo.com`
- 当前联调/生产默认H5 `https://lottery-front.cjdhr.top`、API `https://lottery-api.cjdhr.top`、管理端/文档 `https://lottery-admin.cjdhr.top`
- 风控页默认:风险池仅显示有占用/高风险;单号详情可看占用来源;组合明细在注单详情页。
- 模块边界:对账中心=主站↔彩票钱包划转;报表中心=钱包盘经营分析;结算中心=信用账期占成/收付。
- 报表中心:默认近 30 天;盈亏类按 `draws.business_date`;金额读 API `currency_code`;导出仅后端 `report-jobs` 全量(按筛选,无需先预览);注单盈亏≠信用账期结算。

View File

@@ -14,7 +14,7 @@
const path = require("path");
/** 改成服务器上的绝对路径,例如 /www/wwwroot/lotteryadmin.tanumo.com */
/** 改成服务器上的绝对路径,例如 /www/wwwroot/lottery-admin.cjdhr.top */
const APP_CWD = path.resolve(__dirname);
module.exports = {

View File

@@ -107,8 +107,10 @@ export async function postOddsVersion(body?: {
export async function putOddsItems(
id: number,
items: Array<{
provider_code?: string;
play_code: string;
prize_scope: string;
dimension?: number | null;
odds_value: number;
rebate_rate?: number;
commission_rate?: number;

View File

@@ -245,6 +245,11 @@
"readOnlyHint": "This version is read-only. Create a draft before editing odds.",
"rebateRate": "Base rebate rate (%)",
"rebateRateHint": "This is the platform base rebate. It writes rebate_rate to all prize scopes under this play type; agent/player rebate is added on top of it.",
"scopeSource": {
"global": "Global",
"provider": "Provider",
"inherited": "Inherited"
},
"rollbackDialog": {
"confirm": "Confirm rollback",
"description": "A new draft will be cloned from version v{{version}}. The active version will not be overwritten directly.",

View File

@@ -54,6 +54,13 @@
"backToList": "Back to ticket list",
"viewTicketDetail": "View ticket detail",
"combinationCount": "Expanded combinations",
"oddsSnapshotTitle": "Odds snapshot",
"oddsSnapshotHint": "Odds rows locked when the ticket was placed. Provider-specific tiers show that provider; inherited tiers show GLOBAL.",
"oddsSnapshotEmpty": "No odds snapshot",
"prizeScope": "Prize tier",
"oddsValue": "Odds",
"rebateRate": "Rebate",
"commissionRate": "Commission",
"combinationsTitle": "Combination breakdown",
"combinationsHint": "One bet line may expand into multiple 4D numbers for risk and settlement.",
"number4d": "4D number",

View File

@@ -245,6 +245,11 @@
"readOnlyHint": "यो संस्करण केवल पढ्न मिल्ने छ। अड्स सम्पादन गर्नुअघि ड्राफ्ट बनाउनुहोस्।",
"rebateRate": "रिबेट दर (%)",
"rebateRateHint": "यसले यो खेल प्रकारअन्तर्गत सबै prize scope मा rebate_rate लेख्छ।",
"scopeSource": {
"global": "Global",
"provider": "Provider",
"inherited": "Inherited"
},
"rollbackDialog": {
"confirm": "रोलब्याक पुष्टि गर्नुहोस्",
"description": "संस्करण v{{version}} बाट नयाँ ड्राफ्ट क्लोन हुनेछ। हाल सक्रिय संस्करण प्रत्यक्ष रूपमा ओभरराइट हुँदैन।",

View File

@@ -9,6 +9,7 @@
"combinationsTitle": "संयोजन विवरण",
"comboBetAmount": "संयोजन बेट",
"comboEstimatedPayout": "अनुमानित भुक्तानी रिजर्भ",
"commissionRate": "Commission",
"detailLoadFailed": "टिकट विवरण लोड असफल",
"detailTitle": "टिकट विवरण",
"drawNo": "ड्रअ नं.",
@@ -24,15 +25,21 @@
"number4d": "4D नम्बर",
"numberKeyword": "नम्बर / टिकट / अर्डर",
"numberKeywordPlaceholder": "नम्बर, टिकट नं. वा अर्डर नं. बाट खोज्नुहोस्",
"oddsSnapshotEmpty": "Odds snapshot छैन",
"oddsSnapshotHint": "टिकट राख्दा लक भएको odds पंक्ति। Provider-specific tier मा provider देखिन्छ; inherited tier मा GLOBAL देखिन्छ।",
"oddsSnapshotTitle": "Odds snapshot",
"oddsValue": "Odds",
"orderNo": "अर्डर नं.",
"placedAt": "बेट समय",
"placedDateRange": "बेट गरिएको मिति दायरा",
"playCode": "प्ले",
"player": "खेलाडी",
"prizeScope": "Prize tier",
"playerId": "खेलाडी ID / खाता",
"playerIdPlaceholder": "सबै देखाउन खाली छोड्नुहोस्; ID वा खाता लेख्नुहोस्",
"playerTicketQuery": "टिकट खोज",
"query": "खोज",
"rebateRate": "Rebate",
"refreshCurrentPage": "हालको पृष्ठ रिफ्रेस",
"resetFilters": "फिल्टर रिसेट",
"status": "स्थिति",

View File

@@ -542,6 +542,11 @@
"missingScopeRow": "缺少 {{scope}} 对应行,请检查种子或版本数据。",
"rebateRate": "基础回水 (%)",
"rebateRateHint": "输入基础回水比例,如 5 表示 5%。",
"scopeSource": {
"global": "通用",
"provider": "专属",
"inherited": "继承通用"
},
"placeholders": {
"multiplier": "请输入赔率倍数",
"rebateRate": "请输入基础回水比例"

View File

@@ -54,6 +54,13 @@
"backToList": "返回注单列表",
"viewTicketDetail": "查看注单详情",
"combinationCount": "展开组合数",
"oddsSnapshotTitle": "赔率快照",
"oddsSnapshotHint": "下注成功时锁定的赔率行;开注商专属档位显示对应开注商,未单独配置的档位显示 GLOBAL。",
"oddsSnapshotEmpty": "暂无赔率快照",
"prizeScope": "奖项档位",
"oddsValue": "赔率",
"rebateRate": "回水",
"commissionRate": "佣金",
"combinationsTitle": "展开组合明细",
"combinationsHint": "系统按玩法规则将一注展开为多个 4D 号码;此处为风控与结算用的组合列表。",
"number4d": "4D 号码",

View File

@@ -2,6 +2,7 @@ import type { OddsItemRow } from "@/types/api/admin-config";
function oddsItemFingerprint(row: OddsItemRow): string {
return [
row.provider_code ?? "GLOBAL",
row.play_code,
row.prize_scope,
row.odds_value,
@@ -17,10 +18,10 @@ export function oddsDraftIsDirty(draftRows: OddsItemRow[], savedRows: OddsItemRo
return true;
}
const saved = new Map(savedRows.map((row) => [`${row.play_code}|${row.prize_scope}`, row]));
const saved = new Map(savedRows.map((row) => [`${row.provider_code ?? "GLOBAL"}|${row.play_code}|${row.prize_scope}`, row]));
for (const draft of draftRows) {
const key = `${draft.play_code}|${draft.prize_scope}`;
const key = `${draft.provider_code ?? "GLOBAL"}|${draft.play_code}|${draft.prize_scope}`;
const baseline = saved.get(key);
if (!baseline) {
return true;

View File

@@ -14,6 +14,7 @@ import {
publishOddsVersion,
putOddsItems,
} from "@/api/admin-config";
import { getAdminBetProviders } from "@/api/admin-bet-providers";
import { Button } from "@/components/ui/button";
import { ConfigChip, ConfigChipGroup } from "@/modules/config/config-chip-group";
import { ConfigDocPage, ConfigDocToolbar } from "@/modules/config/config-doc-page";
@@ -50,6 +51,7 @@ import type {
OddsItemRow,
OddsVersionDetail,
} from "@/types/api/admin-config";
import type { AdminBetProviderRow } from "@/types/api/admin-bet-provider";
import { OddsConfigDraftBar } from "@/modules/config/doc/odds-config-draft-bar";
import { OddsConfigPlayNav } from "@/modules/config/doc/odds-config-play-nav";
@@ -74,6 +76,7 @@ import {
} from "@/modules/config/doc/prize-scopes";
type CatTab = OddsCategoryTab;
const GLOBAL_PROVIDER_CODE = "GLOBAL";
function oddsMultiplierLabel(oddsValue: number): string {
return (oddsValue / 10000).toFixed(4);
@@ -112,6 +115,7 @@ export function OddsConfigDocScreen({
const profile = useAdminProfile();
const canManage = adminHasAnyPermission(profile?.permissions, [PRD_ODDS_MANAGE, PRD_REBATE_MANAGE]);
const [types, setTypes] = useState<AdminPlayTypeRow[]>([]);
const [betProviders, setBetProviders] = useState<AdminBetProviderRow[]>([]);
const [list, setList] = useState<ConfigVersionSummary[]>([]);
const [internalSelectedId, setInternalSelectedId] = useState("");
const selectedId = workspace?.selectedId ?? controlledVersionId ?? internalSelectedId;
@@ -135,6 +139,7 @@ export function OddsConfigDocScreen({
const resolvedError = workspace?.error ?? error;
const [catTab, setCatTab] = useState<CatTab>("all");
const [providerCode, setProviderCode] = useState<string>(GLOBAL_PROVIDER_CODE);
/** User-selected play type. Empty means none selected yet and falls back to the first item in the category. */
const [playCode, setPlayCode] = useState<string>("");
@@ -157,6 +162,15 @@ export function OddsConfigDocScreen({
}
}, [tRef]);
const refreshBetProviders = useCallback(async () => {
try {
const data = await getAdminBetProviders();
setBetProviders(data.items);
} catch {
setBetProviders([]);
}
}, []);
const refreshList = useCallback(async () => {
setLoadingList(true);
setError(null);
@@ -177,8 +191,15 @@ export function OddsConfigDocScreen({
if (workspace) {
return;
}
void Promise.all([refreshTypes(), refreshList()]);
}, [workspace]);
void Promise.all([refreshTypes(), refreshList(), refreshBetProviders()]);
}, [refreshBetProviders, workspace]);
useAsyncEffect(() => {
if (!workspace) {
return;
}
void refreshBetProviders();
}, [refreshBetProviders, workspace]);
const loadDetail = useCallback(async (id: number) => {
setLoadingDetail(true);
@@ -284,13 +305,32 @@ export function OddsConfigDocScreen({
if (row.play_code !== resolvedPlayCode) {
continue;
}
const rowProviderCode = row.provider_code ?? GLOBAL_PROVIDER_CODE;
if (rowProviderCode !== providerCode) {
continue;
}
const scope = row.prize_scope as PrizeScopeCode;
if (PRIZE_SCOPE_ORDER.includes(scope)) {
rows[scope] = row;
}
}
if (providerCode !== GLOBAL_PROVIDER_CODE) {
for (const row of resolvedDraftRows) {
if (row.play_code !== resolvedPlayCode) {
continue;
}
const rowProviderCode = row.provider_code ?? GLOBAL_PROVIDER_CODE;
if (rowProviderCode !== GLOBAL_PROVIDER_CODE) {
continue;
}
const scope = row.prize_scope as PrizeScopeCode;
if (PRIZE_SCOPE_ORDER.includes(scope) && !rows[scope]) {
rows[scope] = { ...row, id: 0, provider_code: providerCode };
}
}
}
return rows;
}, [resolvedDraftRows, resolvedPlayCode]);
}, [providerCode, resolvedDraftRows, resolvedPlayCode]);
const rebatePercentUi = useMemo(() => {
const first = PRIZE_SCOPE_ORDER.map((s) => scopeRows[s]).find(Boolean);
@@ -300,8 +340,13 @@ export function OddsConfigDocScreen({
return ratioToPercentUi(String(first.rebate_rate));
}, [scopeRows]);
function rowIndex(play_code: string, prize_scope: string): number {
return resolvedDraftRows.findIndex((r) => r.play_code === play_code && r.prize_scope === prize_scope);
function rowIndex(play_code: string, prize_scope: string, nextProviderCode = providerCode): number {
return resolvedDraftRows.findIndex(
(r) =>
(r.provider_code ?? GLOBAL_PROVIDER_CODE) === nextProviderCode &&
r.play_code === play_code &&
r.prize_scope === prize_scope,
);
}
function updateOddsRow(idx: number, patch: Partial<OddsItemRow>) {
@@ -312,35 +357,73 @@ export function OddsConfigDocScreen({
const idx = rowIndex(resolvedPlayCode, scope);
if (idx >= 0) {
updateOddsRow(idx, patch);
return;
}
const globalIdx = rowIndex(resolvedPlayCode, scope, GLOBAL_PROVIDER_CODE);
const globalRow = globalIdx >= 0 ? resolvedDraftRows[globalIdx] : null;
if (!globalRow) {
return;
}
setResolvedDraftRows((prev) => [
...prev,
{ ...globalRow, id: 0, provider_code: providerCode, ...patch },
]);
}
function setRebateForPlayPercent(percentStr: string) {
const p = Number.parseFloat(percentStr);
const rate = Number.isFinite(p) ? p / 100 : 0;
setResolvedDraftRows((prev) =>
prev.map((r) =>
r.play_code === resolvedPlayCode ? { ...r, rebate_rate: String(rate) } : r,
),
{
let rows = prev.map((r) =>
r.play_code === resolvedPlayCode && (r.provider_code ?? GLOBAL_PROVIDER_CODE) === providerCode
? { ...r, rebate_rate: String(rate) }
: r,
);
if (providerCode !== GLOBAL_PROVIDER_CODE) {
const hasProviderRows = rows.some(
(r) => r.play_code === resolvedPlayCode && (r.provider_code ?? GLOBAL_PROVIDER_CODE) === providerCode,
);
if (!hasProviderRows) {
const copies = rows
.filter(
(r) =>
r.play_code === resolvedPlayCode &&
(r.provider_code ?? GLOBAL_PROVIDER_CODE) === GLOBAL_PROVIDER_CODE,
)
.map((r) => ({ ...r, id: 0, provider_code: providerCode, rebate_rate: String(rate) }));
rows = [...rows, ...copies];
}
}
return rows;
},
);
}
function buildOddsItemsPayload(rows: OddsItemRow[]) {
return rows.map((r) => ({
play_code: r.play_code,
provider_code: r.provider_code ?? GLOBAL_PROVIDER_CODE,
prize_scope: r.prize_scope,
dimension: r.dimension ?? null,
odds_value: r.odds_value,
rebate_rate: Number.parseFloat(String(r.rebate_rate)) || 0,
commission_rate: Number.parseFloat(String(r.commission_rate)) || 0,
currency_code: r.currency_code,
extra_config_json: r.extra_config_json,
}));
}
async function handleSave() {
if (!resolvedDetail || !canEditDraft) {
return;
}
setSaving(true);
try {
const payload = resolvedDraftRows.map((r) => ({
play_code: r.play_code,
prize_scope: r.prize_scope,
odds_value: r.odds_value,
rebate_rate: Number.parseFloat(String(r.rebate_rate)) || 0,
commission_rate: Number.parseFloat(String(r.commission_rate)) || 0,
currency_code: r.currency_code,
extra_config_json: r.extra_config_json,
}));
const d = await putOddsItems(resolvedDetail.id, payload);
const d = await putOddsItems(resolvedDetail.id, buildOddsItemsPayload(resolvedDraftRows));
if (workspace) {
workspace.applyDetail(d);
} else {
@@ -362,7 +445,8 @@ export function OddsConfigDocScreen({
}
setSaving(true);
try {
const d = await publishOddsVersion(resolvedDetail.id);
const saved = await putOddsItems(resolvedDetail.id, buildOddsItemsPayload(resolvedDraftRows));
const d = await publishOddsVersion(saved.id);
if (workspace) {
workspace.applyDetail(d);
} else {
@@ -479,8 +563,28 @@ export function OddsConfigDocScreen({
const selectedPlay = resolvedPlayCode;
return PRIZE_SCOPE_ORDER.map((scope) => {
const next = resolvedDraftRows.find((r) => r.play_code === selectedPlay && r.prize_scope === scope);
const old = activeCompareRows.find((r) => r.play_code === selectedPlay && r.prize_scope === scope);
const next = resolvedDraftRows.find(
(r) =>
(r.provider_code ?? GLOBAL_PROVIDER_CODE) === providerCode &&
r.play_code === selectedPlay &&
r.prize_scope === scope,
) ?? resolvedDraftRows.find(
(r) =>
(r.provider_code ?? GLOBAL_PROVIDER_CODE) === GLOBAL_PROVIDER_CODE &&
r.play_code === selectedPlay &&
r.prize_scope === scope,
);
const old = activeCompareRows.find(
(r) =>
(r.provider_code ?? GLOBAL_PROVIDER_CODE) === providerCode &&
r.play_code === selectedPlay &&
r.prize_scope === scope,
) ?? activeCompareRows.find(
(r) =>
(r.provider_code ?? GLOBAL_PROVIDER_CODE) === GLOBAL_PROVIDER_CODE &&
r.play_code === selectedPlay &&
r.prize_scope === scope,
);
return {
scope,
label: prizeScopeLabel(scope, t),
@@ -488,7 +592,7 @@ export function OddsConfigDocScreen({
newValue: next?.odds_value ?? null,
};
});
}, [activeCompareRows, resolvedDetail, resolvedDraftRows, resolvedPlayCode, t]);
}, [activeCompareRows, providerCode, resolvedDetail, resolvedDraftRows, resolvedPlayCode, t]);
const catTabs: { id: CatTab; label: string }[] = [
{ id: "all", label: t("odds.tabs.all", { ns: "config" }) },
@@ -500,9 +604,29 @@ export function OddsConfigDocScreen({
const activePlayLabel = resolvedPlayCode
? resolveAdminPlayTypeDisplayName(resolvedPlayCode, i18n.language, sortedTypes.find((t) => t.play_code === resolvedPlayCode))
: "—";
const activeProviderLabel = providerCode === GLOBAL_PROVIDER_CODE
? t("odds.globalProvider", { ns: "config", defaultValue: "通用赔率" })
: (betProviders.find((provider) => provider.code === providerCode)?.name ?? providerCode);
const filtersInner = (
<>
<ConfigChipGroup label={t("betProviders.title", { ns: "config", defaultValue: "开注商" })}>
<ConfigChip
active={providerCode === GLOBAL_PROVIDER_CODE}
onClick={() => setProviderCode(GLOBAL_PROVIDER_CODE)}
>
{t("odds.globalProvider", { ns: "config", defaultValue: "通用赔率" })}
</ConfigChip>
{betProviders.map((provider) => (
<ConfigChip
key={provider.code}
active={providerCode === provider.code}
onClick={() => setProviderCode(provider.code)}
>
{provider.name}
</ConfigChip>
))}
</ConfigChipGroup>
<ConfigChipGroup label={t("odds.category", { ns: "config" })}>
{catTabs.map((tab) => (
<ConfigChip
@@ -608,8 +732,18 @@ export function OddsConfigDocScreen({
const scopeEditorRows = PRIZE_SCOPE_ORDER.map((scope) => {
const row = scopeRows[scope];
const hint = mergedLayout ? null : PRIZE_SCOPE_MULTIPLIER_HINT[scope];
const idx = row ? rowIndex(resolvedPlayCode, scope) : -1;
return { scope, row, hint, idx };
const hasProviderRow = providerCode === GLOBAL_PROVIDER_CODE || rowIndex(resolvedPlayCode, scope) >= 0;
const sourceLabel = providerCode === GLOBAL_PROVIDER_CODE
? t("odds.scopeSource.global", { ns: "config" })
: hasProviderRow
? t("odds.scopeSource.provider", { ns: "config" })
: t("odds.scopeSource.inherited", { ns: "config" });
const sourceClassName = providerCode === GLOBAL_PROVIDER_CODE
? "border-slate-200 bg-slate-50 text-slate-600"
: hasProviderRow
? "border-emerald-200 bg-emerald-50 text-emerald-700"
: "border-amber-200 bg-amber-50 text-amber-700";
return { scope, row, hint, sourceLabel, sourceClassName };
});
const mergedOddsTable = (
@@ -621,14 +755,21 @@ export function OddsConfigDocScreen({
</TableRow>
</TableHeader>
<TableBody>
{scopeEditorRows.map(({ scope, row, hint, idx }) => (
{scopeEditorRows.map(({ scope, row, hint, sourceLabel, sourceClassName }) => (
<TableRow key={scope}>
<TableCell className="font-medium">
{prizeScopeLabel(scope, t)}
{hint ? <span className="ml-1 text-xs font-normal text-muted-foreground">{hint}</span> : null}
<div className="flex flex-wrap items-center gap-1.5">
<span>{prizeScopeLabel(scope, t)}</span>
{hint ? <span className="text-xs font-normal text-muted-foreground">{hint}</span> : null}
{row ? (
<span className={cn("rounded-full border px-1.5 py-0.5 text-[11px] font-semibold", sourceClassName)}>
{sourceLabel}
</span>
) : null}
</div>
</TableCell>
<TableCell className="text-right">
{row && idx >= 0 ? (
{row ? (
canEditDraft ? (
<Input
type="text"
@@ -682,13 +823,20 @@ export function OddsConfigDocScreen({
const classicOddsGrid = (
<div className="grid grid-cols-2 gap-x-4 gap-y-4 sm:grid-cols-3">
{scopeEditorRows.map(({ scope, row, hint, idx }) => (
{scopeEditorRows.map(({ scope, row, hint, sourceLabel, sourceClassName }) => (
<div key={scope} className="grid min-w-0 gap-1.5">
<Label className="truncate text-xs font-medium text-muted-foreground">
{prizeScopeLabel(scope, t)}
{hint ? <span className="ml-1 font-normal">{hint}</span> : null}
</Label>
{row && idx >= 0 ? (
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
<Label className="text-xs font-medium text-muted-foreground">
{prizeScopeLabel(scope, t)}
{hint ? <span className="ml-1 font-normal">{hint}</span> : null}
</Label>
{row ? (
<span className={cn("rounded-full border px-1.5 py-0.5 text-[11px] font-semibold", sourceClassName)}>
{sourceLabel}
</span>
) : null}
</div>
{row ? (
canEditDraft ? (
<Input
type="text"
@@ -829,7 +977,24 @@ export function OddsConfigDocScreen({
<div className="space-y-4">
{toolbarBlock}
<div className="grid gap-0 rounded-lg border border-border/60 lg:grid-cols-[minmax(0,13rem)_minmax(0,1fr)]">
<aside className="border-b border-border/50 px-4 py-4 lg:border-r lg:border-b-0">
<aside className="space-y-4 border-b border-border/50 px-4 py-4 lg:border-r lg:border-b-0">
<ConfigChipGroup label={t("betProviders.title", { ns: "config", defaultValue: "开注商" })}>
<ConfigChip
active={providerCode === GLOBAL_PROVIDER_CODE}
onClick={() => setProviderCode(GLOBAL_PROVIDER_CODE)}
>
{t("odds.globalProvider", { ns: "config", defaultValue: "通用赔率" })}
</ConfigChip>
{betProviders.map((provider) => (
<ConfigChip
key={provider.code}
active={providerCode === provider.code}
onClick={() => setProviderCode(provider.code)}
>
{provider.name}
</ConfigChip>
))}
</ConfigChipGroup>
<OddsConfigPlayNav
catTab={catTab}
onCatTabChange={setCatTab}
@@ -841,6 +1006,7 @@ export function OddsConfigDocScreen({
<div className="min-w-0">
<div className="border-b border-border/50 px-4 py-2.5 sm:px-5">
<h3 className="text-base font-semibold">{activePlayLabel}</h3>
<p className="text-xs text-muted-foreground">{activeProviderLabel}</p>
</div>
<div className="px-4 py-4 sm:px-5">{mainBlock}</div>
{isDraft && canManage ? (

View File

@@ -325,6 +325,20 @@ export function RebateConfigDocScreen({
});
}
function buildOddsItemsPayload(rows: OddsItemRow[]) {
return rows.map((r) => ({
provider_code: r.provider_code ?? "GLOBAL",
play_code: r.play_code,
prize_scope: r.prize_scope,
dimension: r.dimension ?? null,
odds_value: r.odds_value,
rebate_rate: Number.parseFloat(String(r.rebate_rate)) || 0,
commission_rate: Number.parseFloat(String(r.commission_rate)) || 0,
currency_code: r.currency_code,
extra_config_json: r.extra_config_json,
}));
}
async function handleSave() {
if (!resolvedDetail || !canEditDraft) {
return;
@@ -332,16 +346,7 @@ export function RebateConfigDocScreen({
setSaving(true);
try {
const nextRows = applyDimensionPercentsToRows(resolvedDraftRows);
const payload = nextRows.map((r) => ({
play_code: r.play_code,
prize_scope: r.prize_scope,
odds_value: r.odds_value,
rebate_rate: Number.parseFloat(String(r.rebate_rate)) || 0,
commission_rate: Number.parseFloat(String(r.commission_rate)) || 0,
currency_code: r.currency_code,
extra_config_json: r.extra_config_json,
}));
const d = await putOddsItems(resolvedDetail.id, payload);
const d = await putOddsItems(resolvedDetail.id, buildOddsItemsPayload(nextRows));
const rows = d.items.map((it) => ({ ...it }));
if (workspace) {
workspace.applyDetail(d);
@@ -367,7 +372,9 @@ export function RebateConfigDocScreen({
}
setSaving(true);
try {
const d = await publishOddsVersion(resolvedDetail.id);
const nextRows = applyDimensionPercentsToRows(resolvedDraftRows);
const saved = await putOddsItems(resolvedDetail.id, buildOddsItemsPayload(nextRows));
const d = await publishOddsVersion(saved.id);
const rows = d.items.map((it) => ({ ...it }));
if (workspace) {
workspace.applyDetail(d);

View File

@@ -448,16 +448,24 @@ export function PlayerTicketsConsole(): React.ReactElement {
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
<AdminRowActionsMenu
actions={
row.player_id
? [
{
key: "view-player",
label: t("viewPlayer"),
icon: Eye,
href: adminPlayerDetailPath(row.player_id),
},
]
: []
[
{
key: "view-ticket-detail",
label: t("viewTicketDetail"),
icon: Eye,
href: ticketDetailPath(row.ticket_no),
},
...(row.player_id
? [
{
key: "view-player",
label: t("viewPlayer"),
icon: Eye,
href: adminPlayerDetailPath(row.player_id),
},
]
: []),
]
}
/>
</TableCell>

View File

@@ -29,7 +29,7 @@ import { adminPlayerDetailPath } from "@/lib/admin-player-paths";
import { formatAdminMinorUnits } from "@/lib/money";
import { cn } from "@/lib/utils";
import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminTicketItemDetail } from "@/types/api/admin-tickets";
import type { AdminTicketItemDetail, AdminTicketOddsSnapshotRow } from "@/types/api/admin-tickets";
function ticketStatusText(value: string, t: (key: string) => string): string {
const key = `statusOptions.${value}`;
@@ -41,6 +41,22 @@ function providerLabel(providerName?: string | null, providerCode?: string | nul
return providerName || providerCode || "—";
}
function oddsSnapshotRows(value: unknown): AdminTicketOddsSnapshotRow[] {
if (!Array.isArray(value)) {
return [];
}
return value.filter((row): row is AdminTicketOddsSnapshotRow => typeof row === "object" && row !== null);
}
function oddsMultiplierLabel(value?: number | null): string {
if (typeof value !== "number" || !Number.isFinite(value)) {
return "—";
}
return (value / 10000).toFixed(4);
}
export function TicketDetailConsole({ ticketNo }: { ticketNo: string }) {
const { t } = useTranslation(["tickets", "common"]);
const tRef = useTranslationRef(["tickets", "common"]);
@@ -97,6 +113,7 @@ export function TicketDetailConsole({ ticketNo }: { ticketNo: string }) {
}
const currencyCode = data?.currency_code ?? "NPR";
const oddsRows = oddsSnapshotRows(data?.odds_snapshot_json);
return (
<div className="flex flex-col gap-4">
@@ -178,6 +195,53 @@ export function TicketDetailConsole({ ticketNo }: { ticketNo: string }) {
</CardContent>
</Card>
<Card className="admin-list-card">
<CardHeader className="admin-list-header">
<CardTitle className="admin-list-title">{t("oddsSnapshotTitle")}</CardTitle>
<CardDescription className="text-xs">{t("oddsSnapshotHint")}</CardDescription>
</CardHeader>
<CardContent className="admin-list-content">
<div className="admin-table-shell">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("provider", { defaultValue: "开注商" })}</TableHead>
<TableHead>{t("prizeScope")}</TableHead>
<TableHead className="text-center">{t("oddsValue")}</TableHead>
<TableHead className="text-center">{t("rebateRate")}</TableHead>
<TableHead className="text-center">{t("commissionRate")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading && !data ? <AdminTableLoadingRow colSpan={5} /> : null}
{!loading && oddsRows.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="py-6 text-center text-sm text-muted-foreground">
{t("oddsSnapshotEmpty")}
</TableCell>
</TableRow>
) : null}
{oddsRows.map((row, index) => (
<TableRow key={`${row.provider_code ?? "GLOBAL"}-${row.prize_scope ?? "scope"}-${index}`}>
<TableCell className="font-mono text-xs">{row.provider_code ?? "GLOBAL"}</TableCell>
<TableCell className="font-mono text-xs">{row.prize_scope ?? "—"}</TableCell>
<TableCell className="text-center font-semibold tabular-nums">
{oddsMultiplierLabel(row.odds_value ?? null)}
</TableCell>
<TableCell className="text-center font-mono text-xs">
{row.rebate_rate ?? "—"}
</TableCell>
<TableCell className="text-center font-mono text-xs">
{row.commission_rate ?? "—"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
<Card className="admin-list-card">
<CardHeader className="admin-list-header">
<CardTitle className="admin-list-title">{t("combinationsTitle")}</CardTitle>

View File

@@ -60,8 +60,10 @@ export type PlayConfigVersionDetail = ConfigVersionSummary & {
export type OddsItemRow = {
id: number;
provider_code?: string;
play_code: string;
prize_scope: string;
dimension?: number | null;
odds_value: number;
rebate_rate: string;
commission_rate: string;

View File

@@ -48,6 +48,14 @@ export type AdminTicketCombinationRow = {
estimated_payout: number;
};
export type AdminTicketOddsSnapshotRow = {
provider_code?: string | null;
prize_scope?: string | null;
odds_value?: number | null;
rebate_rate?: string | number | null;
commission_rate?: string | number | null;
};
export type AdminTicketItemDetail = {
id: number;
ticket_no: string;
@@ -79,6 +87,7 @@ export type AdminTicketItemDetail = {
actual_deduct_amount_minor: number;
actual_deduct_amount_formatted: string;
risk_locked_amount: number;
odds_snapshot_json: unknown;
status: string;
fail_reason_code: string | null;
fail_reason_text: string | null;