feat(reports, risk, deployment): 支持开注商经营报表、风险池按开注商操作并调整 PM2 部署目录
Some checks failed
lotteryadmin CI / build (push) Has been cancelled

This commit is contained in:
2026-07-10 10:23:46 +08:00
parent 1164d9d728
commit d6826d5a1c
22 changed files with 147 additions and 33 deletions

View File

@@ -10,12 +10,12 @@ REMOTE_DIR="/www/wwwroot/lotteryAdmin"
echo "🔨 构建中..." echo "🔨 构建中..."
npm run build npm run build
echo "📦 上传到服务器..." echo "📦 上传到服务器 app/ ..."
ssh ${SERVER} "mkdir -p ${REMOTE_DIR}/.next/standalone ${REMOTE_DIR}/logs" ssh ${SERVER} "mkdir -p ${REMOTE_DIR}/app ${REMOTE_DIR}/logs"
rsync -avz --delete .next/standalone/ ${SERVER}:${REMOTE_DIR}/.next/standalone/ rsync -az --delete .next/standalone/ ${SERVER}:${REMOTE_DIR}/app/
rsync -avz ecosystem.config.cjs ${SERVER}:${REMOTE_DIR}/ecosystem.config.cjs rsync -az ecosystem.config.cjs ${SERVER}:${REMOTE_DIR}/ecosystem.config.cjs
echo "🚀 服务器重启 PM2..." echo "🚀 重启 PM2..."
ssh ${SERVER} "source /etc/profile && source ~/.bashrc && mkdir -p ${REMOTE_DIR}/logs && cd ${REMOTE_DIR} && (pm2 restart lotteryadmin 2>/dev/null || pm2 start ecosystem.config.cjs) && pm2 save" ssh ${SERVER} "source /etc/profile && source ~/.bashrc && cd ${REMOTE_DIR} && (pm2 delete lotteryadmin 2>/dev/null || true) && pm2 start ecosystem.config.cjs --only lotteryadmin --update-env && pm2 save"
echo "✅ lotteryAdmin 部署完成!" echo "✅ lotteryAdmin 部署完成!"

View File

@@ -5,6 +5,7 @@ import type {
AdminReportDailyProfitRow, AdminReportDailyProfitRow,
AdminReportListData, AdminReportListData,
AdminReportPlayDimensionRow, AdminReportPlayDimensionRow,
AdminReportProviderProfitRow,
AdminReportPlayerWinLossRow, AdminReportPlayerWinLossRow,
AdminReportQueryParams, AdminReportQueryParams,
} from "@/types/api/admin-reports"; } from "@/types/api/admin-reports";
@@ -19,6 +20,12 @@ export async function getAdminReportDailyProfit(
}); });
} }
export async function getAdminReportProviderProfit(
params: AdminReportQueryParams,
): Promise<AdminReportListData<AdminReportProviderProfitRow>> {
return adminRequest.get<AdminReportListData<AdminReportProviderProfitRow>>(`${A}/reports/provider-profit`, { params });
}
export async function getAdminReportPlayerWinLoss( export async function getAdminReportPlayerWinLoss(
params: AdminReportQueryParams, params: AdminReportQueryParams,
): Promise<AdminReportListData<AdminReportPlayerWinLossRow>> { ): Promise<AdminReportListData<AdminReportPlayerWinLossRow>> {

View File

@@ -12,6 +12,7 @@ const A = `/admin`;
export type AdminRiskPoolListQuery = { export type AdminRiskPoolListQuery = {
page?: number; page?: number;
per_page?: number; per_page?: number;
provider_code?: string;
sold_out_only?: boolean; sold_out_only?: boolean;
high_risk_only?: boolean; high_risk_only?: boolean;
active_only?: boolean; active_only?: boolean;
@@ -27,6 +28,7 @@ export async function getAdminRiskPools(
params: { params: {
page: q.page, page: q.page,
per_page: q.per_page, per_page: q.per_page,
provider_code: q.provider_code || undefined,
sold_out_only: q.sold_out_only === true ? 1 : undefined, sold_out_only: q.sold_out_only === true ? 1 : undefined,
high_risk_only: q.high_risk_only === true ? 1 : undefined, high_risk_only: q.high_risk_only === true ? 1 : undefined,
active_only: q.active_only === true ? 1 : undefined, active_only: q.active_only === true ? 1 : undefined,
@@ -39,26 +41,33 @@ export async function getAdminRiskPools(
export async function postAdminRiskPoolManualClose( export async function postAdminRiskPoolManualClose(
drawId: number, drawId: number,
number4d: string, number4d: string,
providerCode: string,
): Promise<AdminRiskPoolRow> { ): Promise<AdminRiskPoolRow> {
const encoded = encodeURIComponent(number4d); const encoded = encodeURIComponent(number4d);
return adminRequest.post<AdminRiskPoolRow>( return adminRequest.post<AdminRiskPoolRow>(
`${A}/draws/${drawId}/risk-pools/${encoded}/manual-close`, `${A}/draws/${drawId}/risk-pools/${encoded}/manual-close`,
undefined,
{ params: { provider_code: providerCode || undefined } },
); );
} }
export async function postAdminRiskPoolRecover( export async function postAdminRiskPoolRecover(
drawId: number, drawId: number,
number4d: string, number4d: string,
providerCode: string,
): Promise<AdminRiskPoolRow> { ): Promise<AdminRiskPoolRow> {
const encoded = encodeURIComponent(number4d); const encoded = encodeURIComponent(number4d);
return adminRequest.post<AdminRiskPoolRow>( return adminRequest.post<AdminRiskPoolRow>(
`${A}/draws/${drawId}/risk-pools/${encoded}/recover`, `${A}/draws/${drawId}/risk-pools/${encoded}/recover`,
undefined,
{ params: { provider_code: providerCode || undefined } },
); );
} }
export type AdminRiskPoolShowQuery = { export type AdminRiskPoolShowQuery = {
page?: number; page?: number;
per_page?: number; per_page?: number;
provider_code?: string;
}; };
export async function getAdminRiskPoolDetail( export async function getAdminRiskPoolDetail(
@@ -69,6 +78,6 @@ export async function getAdminRiskPoolDetail(
const encoded = encodeURIComponent(number4d); const encoded = encodeURIComponent(number4d);
return adminRequest.get<AdminRiskPoolShowData>( return adminRequest.get<AdminRiskPoolShowData>(
`${A}/draws/${drawId}/risk-pools/${encoded}`, `${A}/draws/${drawId}/risk-pools/${encoded}`,
{ params: { page: q.page, per_page: q.per_page } }, { params: { page: q.page, per_page: q.per_page, provider_code: q.provider_code || undefined } },
); );
} }

View File

@@ -2,9 +2,12 @@ import { RiskPoolDetailConsole } from "@/modules/risk/risk-pool-detail-console";
export default async function AdminDrawRiskPoolDetailPage(props: { export default async function AdminDrawRiskPoolDetailPage(props: {
params: Promise<{ drawId: string; number: string }>; params: Promise<{ drawId: string; number: string }>;
searchParams: Promise<{ provider_code?: string }>;
}) { }) {
const { drawId, number } = await props.params; const { drawId, number } = await props.params;
const { provider_code } = await props.searchParams;
const id = Number(drawId); const id = Number(drawId);
const providerCode = (provider_code ?? "SG").trim().toUpperCase() || "SG";
return <RiskPoolDetailConsole drawId={id} number4d={number} />; return <RiskPoolDetailConsole drawId={id} number4d={number} providerCode={providerCode} />;
} }

View File

@@ -2,7 +2,10 @@ import { redirect } from "next/navigation";
export default async function AdminRiskPoolDetailPage(props: { export default async function AdminRiskPoolDetailPage(props: {
params: Promise<{ drawId: string; number: string }>; params: Promise<{ drawId: string; number: string }>;
searchParams: Promise<{ provider_code?: string }>;
}) { }) {
const { drawId, number } = await props.params; const { drawId, number } = await props.params;
redirect(`/admin/draws/${drawId}/risk/pools/${number}`); const { provider_code } = await props.searchParams;
const suffix = provider_code ? `?provider_code=${encodeURIComponent(provider_code)}` : "";
redirect(`/admin/draws/${drawId}/risk/pools/${number}${suffix}`);
} }

View File

@@ -316,6 +316,10 @@
"title": "Play dimension report", "title": "Play dimension report",
"summary": "P&L by play and business date; defaults to the last 30 days. Ticket amounts — not credit-line settlement." "summary": "P&L by play and business date; defaults to the last 30 days. Ticket amounts — not credit-line settlement."
}, },
"provider_profit": {
"title": "Bet provider performance",
"summary": "Ticket items, bets, payouts, and platform gross by provider and business date; defaults to the last 30 days."
},
"sold_out_number": { "sold_out_number": {
"title": "Sold-out number report", "title": "Sold-out number report",
"summary": "Review sold-out numbers, sold-out time, and risk lock state by draw." "summary": "Review sold-out numbers, sold-out time, and risk lock state by draw."

View File

@@ -25,6 +25,7 @@
"searchNumber": "Search number", "searchNumber": "Search number",
"searchNumberPlaceholder": "For example 8888", "searchNumberPlaceholder": "For example 8888",
"riskFilter": "Risk filter", "riskFilter": "Risk filter",
"provider": "Provider",
"sort": "Sort", "sort": "Sort",
"filterAll": "All", "filterAll": "All",
"filterActive": "Active / high risk", "filterActive": "Active / high risk",
@@ -51,9 +52,9 @@
"actionFailed": "Action failed", "actionFailed": "Action failed",
"confirm": { "confirm": {
"closeTitle": "Confirm close number?", "closeTitle": "Confirm close number?",
"closeDescription": "Number {{number}} will be blocked for this draw.", "closeDescription": "Number {{number}} ({{provider}}) will be blocked for this draw.",
"recoverTitle": "Confirm recover number?", "recoverTitle": "Confirm recover number?",
"recoverDescription": "Number {{number}} will be open for betting again." "recoverDescription": "Number {{number}} ({{provider}}) will be open for betting again."
}, },
"detailTitle": "Risk pool details", "detailTitle": "Risk pool details",
"loadDetailFailed": "Failed to load risk pool details", "loadDetailFailed": "Failed to load risk pool details",

View File

@@ -68,6 +68,10 @@
"summary": "खेल र व्यावसायिक मितिअनुसार P&L; मिति नचयेमा पछिल्लो ३० दिन।", "summary": "खेल र व्यावसायिक मितिअनुसार P&L; मिति नचयेमा पछिल्लो ३० दिन।",
"title": "खेल आयाम रिपोर्ट" "title": "खेल आयाम रिपोर्ट"
}, },
"provider_profit": {
"summary": "खुलाउने कम्पनी र व्यावसायिक मितिअनुसार टिकट, बेट, पेआउट र प्लेटफर्म नाफा; मिति नचयेमा पछिल्लो ३० दिन।",
"title": "खुलाउने कम्पनी सञ्चालन रिपोर्ट"
},
"player_transfer": { "player_transfer": {
"summary": "वालेट-मोड मुख्य साइट ट्रान्सफर, रेकर्ड समय अनुसार; मिति नचयेमा पछिल्लो ३० दिन।", "summary": "वालेट-मोड मुख्य साइट ट्रान्सफर, रेकर्ड समय अनुसार; मिति नचयेमा पछिल्लो ३० दिन।",
"title": "खेलाडी ट्रान्सफर रिपोर्ट" "title": "खेलाडी ट्रान्सफर रिपोर्ट"

View File

@@ -16,9 +16,9 @@
"closeTime": "बन्द समय", "closeTime": "बन्द समय",
"combinationCount": "संयोजन", "combinationCount": "संयोजन",
"confirm": { "confirm": {
"closeDescription": "नम्बर {{number}} यो ड्रका लागि ब्लक हुनेछ।", "closeDescription": "नम्बर {{number}} ({{provider}}) यो ड्रका लागि ब्लक हुनेछ।",
"closeTitle": "नम्बर बन्द गर्ने?", "closeTitle": "नम्बर बन्द गर्ने?",
"recoverDescription": "नम्बर {{number}} फेरि बाजीका लागि खुल्नेछ।", "recoverDescription": "नम्बर {{number}} ({{provider}}) फेरि बाजीका लागि खुल्नेछ।",
"recoverTitle": "नम्बर पुन: खोल्ने?" "recoverTitle": "नम्बर पुन: खोल्ने?"
}, },
"databaseStatus": "डेटाबेस स्थिति", "databaseStatus": "डेटाबेस स्थिति",
@@ -69,6 +69,7 @@
"remainingAmount": "बाँकी", "remainingAmount": "बाँकी",
"remainingSellable": "बाँकी बिक्रीयोग्य", "remainingSellable": "बाँकी बिक्रीयोग्य",
"riskFilter": "जोखिम फिल्टर", "riskFilter": "जोखिम फिल्टर",
"provider": "प्रोभाइडर",
"search": "खोज", "search": "खोज",
"searchNumber": "नम्बर खोज्नुहोस्", "searchNumber": "नम्बर खोज्नुहोस्",
"searchNumberPlaceholder": "जस्तै 8888", "searchNumberPlaceholder": "जस्तै 8888",

View File

@@ -67,6 +67,7 @@
"transfer_orders_daily": "转账订单(日)", "transfer_orders_daily": "转账订单(日)",
"hot_number_risk_report": "热门号码风险", "hot_number_risk_report": "热门号码风险",
"play_dimension_report": "玩法维度", "play_dimension_report": "玩法维度",
"provider_profit_report": "开注商经营",
"sold_out_number_report": "售罄号码", "sold_out_number_report": "售罄号码",
"audit_operation_report": "后台操作审计" "audit_operation_report": "后台操作审计"
}, },
@@ -316,6 +317,10 @@
"title": "玩法维度报表", "title": "玩法维度报表",
"summary": "按玩法与业务日拆分盈亏;未选日期默认近 30 天。注单口径,非信用账期结算。" "summary": "按玩法与业务日拆分盈亏;未选日期默认近 30 天。注单口径,非信用账期结算。"
}, },
"provider_profit": {
"title": "开注商经营报表",
"summary": "按开注商与业务日汇总注项、投注、派彩和平台毛利;未选日期默认近 30 天。"
},
"sold_out_number": { "sold_out_number": {
"title": "售罄号码报表", "title": "售罄号码报表",
"summary": "查看单期已售罄号码、售罄时间和风险封锁情况。" "summary": "查看单期已售罄号码、售罄时间和风险封锁情况。"

View File

@@ -25,6 +25,7 @@
"searchNumber": "搜索号码", "searchNumber": "搜索号码",
"searchNumberPlaceholder": "如 8888", "searchNumberPlaceholder": "如 8888",
"riskFilter": "风险筛选", "riskFilter": "风险筛选",
"provider": "开注商",
"sort": "排序", "sort": "排序",
"filterAll": "全部", "filterAll": "全部",
"filterActive": "有占用/高风险", "filterActive": "有占用/高风险",
@@ -51,9 +52,9 @@
"actionFailed": "操作失败", "actionFailed": "操作失败",
"confirm": { "confirm": {
"closeTitle": "确认关闭该号码下注?", "closeTitle": "确认关闭该号码下注?",
"closeDescription": "号码 {{number}} 在本期将被禁止下注。", "closeDescription": "号码 {{number}}{{provider}}在本期将被禁止下注。",
"recoverTitle": "确认恢复该号码下注?", "recoverTitle": "确认恢复该号码下注?",
"recoverDescription": "号码 {{number}} 将恢复为可下注状态。" "recoverDescription": "号码 {{number}}{{provider}}将恢复为可下注状态。"
}, },
"detailTitle": "风险池详情", "detailTitle": "风险池详情",
"loadDetailFailed": "加载风险池详情失败", "loadDetailFailed": "加载风险池详情失败",

View File

@@ -17,6 +17,7 @@ export type ReportUiKey =
| "player_transfer" | "player_transfer"
| "hot_number_risk" | "hot_number_risk"
| "play_dimension" | "play_dimension"
| "provider_profit"
| "sold_out_number" | "sold_out_number"
| "admin_audit"; | "admin_audit";
@@ -28,6 +29,7 @@ export const REPORT_UI_TO_JOB_TYPE: Record<ReportUiKey, string> = {
player_transfer: "wallet_transfer_report", player_transfer: "wallet_transfer_report",
hot_number_risk: "hot_number_risk_report", hot_number_risk: "hot_number_risk_report",
play_dimension: "play_dimension_report", play_dimension: "play_dimension_report",
provider_profit: "provider_profit_report",
sold_out_number: "sold_out_number_report", sold_out_number: "sold_out_number_report",
admin_audit: "audit_operation_report", admin_audit: "audit_operation_report",
}; };

View File

@@ -65,6 +65,7 @@ type DraftRiskRow = Omit<RiskCapItemRow, "id"> & { clientKey: string };
function newRow(): DraftRiskRow { function newRow(): DraftRiskRow {
return { return {
clientKey: `new-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, clientKey: `new-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
provider_code: "GLOBAL",
draw_id: null, draw_id: null,
normalized_number: "0000", normalized_number: "0000",
cap_amount: 0, cap_amount: 0,
@@ -79,6 +80,7 @@ function isDefaultRiskRow(row: DraftRiskRow): boolean {
function defaultRiskRowFromAmount(amount: number): DraftRiskRow { function defaultRiskRowFromAmount(amount: number): DraftRiskRow {
return { return {
clientKey: `default-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, clientKey: `default-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
provider_code: "GLOBAL",
draw_id: null, draw_id: null,
normalized_number: "0000", normalized_number: "0000",
cap_amount: amount, cap_amount: amount,
@@ -175,6 +177,7 @@ export function RiskCapDocScreen() {
setDetail(d); setDetail(d);
const mapped = d.items.map((it) => ({ const mapped = d.items.map((it) => ({
clientKey: `srv-${it.id}`, clientKey: `srv-${it.id}`,
provider_code: it.provider_code ?? "GLOBAL",
draw_id: it.draw_id, draw_id: it.draw_id,
normalized_number: it.normalized_number, normalized_number: it.normalized_number,
cap_amount: it.cap_amount, cap_amount: it.cap_amount,
@@ -285,6 +288,7 @@ export function RiskCapDocScreen() {
setSaving(true); setSaving(true);
try { try {
const payload = rowsToSave.map((r) => ({ const payload = rowsToSave.map((r) => ({
provider_code: r.provider_code ?? "GLOBAL",
draw_id: r.draw_id && r.draw_id > 0 ? r.draw_id : null, draw_id: r.draw_id && r.draw_id > 0 ? r.draw_id : null,
normalized_number: r.normalized_number, normalized_number: r.normalized_number,
cap_amount: r.cap_amount, cap_amount: r.cap_amount,
@@ -294,6 +298,7 @@ export function RiskCapDocScreen() {
setDetail(d); setDetail(d);
const saved = d.items.map((it) => ({ const saved = d.items.map((it) => ({
clientKey: `srv-${it.id}`, clientKey: `srv-${it.id}`,
provider_code: it.provider_code ?? "GLOBAL",
draw_id: it.draw_id, draw_id: it.draw_id,
normalized_number: it.normalized_number, normalized_number: it.normalized_number,
cap_amount: it.cap_amount, cap_amount: it.cap_amount,
@@ -320,6 +325,7 @@ export function RiskCapDocScreen() {
setDetail(d); setDetail(d);
const pub = d.items.map((it) => ({ const pub = d.items.map((it) => ({
clientKey: `srv-${it.id}`, clientKey: `srv-${it.id}`,
provider_code: it.provider_code ?? "GLOBAL",
draw_id: it.draw_id, draw_id: it.draw_id,
normalized_number: it.normalized_number, normalized_number: it.normalized_number,
cap_amount: it.cap_amount, cap_amount: it.cap_amount,
@@ -351,6 +357,7 @@ export function RiskCapDocScreen() {
setDetail(d); setDetail(d);
const nd = d.items.map((it) => ({ const nd = d.items.map((it) => ({
clientKey: `srv-${it.id}`, clientKey: `srv-${it.id}`,
provider_code: it.provider_code ?? "GLOBAL",
draw_id: it.draw_id, draw_id: it.draw_id,
normalized_number: it.normalized_number, normalized_number: it.normalized_number,
cap_amount: it.cap_amount, cap_amount: it.cap_amount,
@@ -424,6 +431,7 @@ export function RiskCapDocScreen() {
setDetail(d); setDetail(d);
const mapped = d.items.map((it) => ({ const mapped = d.items.map((it) => ({
clientKey: `srv-${it.id}`, clientKey: `srv-${it.id}`,
provider_code: it.provider_code ?? "GLOBAL",
draw_id: it.draw_id, draw_id: it.draw_id,
normalized_number: it.normalized_number, normalized_number: it.normalized_number,
cap_amount: it.cap_amount, cap_amount: it.cap_amount,
@@ -691,4 +699,4 @@ export function RiskCapDocScreen() {
<ConfirmDialog /> <ConfirmDialog />
</ConfigDocPage> </ConfigDocPage>
); );
} }

View File

@@ -100,6 +100,13 @@ export function ReportPreviewTable({
} }
switch (result.key) { switch (result.key) {
case "provider_profit":
return (
<Table>
<TableHeader><TableRow><TableHead></TableHead><TableHead className="text-right"></TableHead><TableHead className="text-right"></TableHead><TableHead className="text-right"></TableHead><TableHead className="text-right"></TableHead></TableRow></TableHeader>
<TableBody>{result.raw.map((item) => <TableRow key={item.provider_code}><TableCell className="font-medium">{item.provider_name}<span className="ml-2 font-mono text-xs text-muted-foreground">{item.provider_code}</span></TableCell><TableCell className="text-right">{item.ticket_item_count}</TableCell><TableCell className="text-right">{formatPlainMoney(item.total_bet_minor, displayCurrency)}</TableCell><TableCell className="text-right">{formatPlainMoney(item.total_payout_minor, displayCurrency)}</TableCell><TableCell className={signedProfitCell(item.approx_house_gross_minor, displayCurrency)}>{formatPlainMoney(item.approx_house_gross_minor, displayCurrency)}</TableCell></TableRow>)}</TableBody>
</Table>
);
case "draw_profit": case "draw_profit":
return ( return (
<Table> <Table>

View File

@@ -19,6 +19,7 @@ import { downloadAdminReportJob, postAdminReportJob } from "@/api/admin-report-j
import { import {
getAdminReportDailyProfit, getAdminReportDailyProfit,
getAdminReportPlayDimension, getAdminReportPlayDimension,
getAdminReportProviderProfit,
getAdminReportPlayerWinLoss, getAdminReportPlayerWinLoss,
} from "@/api/admin-reports"; } from "@/api/admin-reports";
import { import {
@@ -375,6 +376,23 @@ export function ReportsConsole({ initialCategory = "profit" }: ReportsConsolePro
}); });
break; break;
} }
case "provider_profit": {
const payload = await getAdminReportProviderProfit(reportListParams(filters, page, perPage));
const currencyCode = resolveDisplayCurrency(payload.currency_code);
setDisplayCurrency(currencyCode);
const gross = payload.items.reduce((sum, item) => sum + item.approx_house_gross_minor, 0);
setResult({
key: "provider_profit",
raw: payload.items,
rows: payload.items,
meta: metaFromList(payload.meta),
summary: [
{ label: t("preview.stats.records"), value: String(payload.meta.total) },
{ label: pageScopedLabel("houseGross"), value: formatPlainMoney(gross, currencyCode), tone: gross >= 0 ? "good" : "bad" },
],
});
break;
}
case "admin_audit": { case "admin_audit": {
const operatorId = filters.operatorId ?? parsePositiveInteger(filters.operator); const operatorId = filters.operatorId ?? parsePositiveInteger(filters.operator);
const payload = await getAdminAuditLogs({ const payload = await getAdminAuditLogs({
@@ -841,4 +859,4 @@ export function ReportsConsole({ initialCategory = "profit" }: ReportsConsolePro
</div> </div>
</div> </div>
); );
} }

View File

@@ -1,6 +1,7 @@
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import { import {
CalendarDays, CalendarDays,
Building2,
FileSpreadsheet, FileSpreadsheet,
ListFilter, ListFilter,
ShieldAlert, ShieldAlert,
@@ -44,6 +45,7 @@ export type ReportKey =
| "player_transfer" | "player_transfer"
| "hot_number_risk" | "hot_number_risk"
| "play_dimension" | "play_dimension"
| "provider_profit"
| "sold_out_number" | "sold_out_number"
| "admin_audit"; | "admin_audit";
@@ -60,6 +62,15 @@ export type ReportDefinition = {
const PRD_REPORTS_VIEW_ACCESS_ANY = [PRD_REPORT_VIEW] as const; const PRD_REPORTS_VIEW_ACCESS_ANY = [PRD_REPORT_VIEW] as const;
export const REPORT_DEFINITIONS: ReportDefinition[] = [ export const REPORT_DEFINITIONS: ReportDefinition[] = [
{
key: "provider_profit",
category: "profit",
icon: Building2,
filterKind: "date",
fields: ["period"],
connected: true,
requiredAny: PRD_REPORTS_VIEW_ACCESS_ANY,
},
{ {
key: "draw_profit", key: "draw_profit",
category: "profit", category: "profit",
@@ -138,4 +149,4 @@ export const CATEGORY_SHORTCUT_HREF: Partial<Record<ReportCategory, string>> = {
wallet: "/admin/wallet/transfer-orders", wallet: "/admin/wallet/transfer-orders",
risk: "/admin/risk", risk: "/admin/risk",
audit: "/admin/audit-logs", audit: "/admin/audit-logs",
}; };

View File

@@ -16,6 +16,7 @@ import type { AdminTransferOrderItem } from "@/types/api/admin-wallet";
import type { import type {
AdminReportDailyProfitRow, AdminReportDailyProfitRow,
AdminReportPlayDimensionRow, AdminReportPlayDimensionRow,
AdminReportProviderProfitRow,
AdminReportPlayerWinLossRow, AdminReportPlayerWinLossRow,
} from "@/types/api/admin-reports"; } from "@/types/api/admin-reports";
@@ -59,6 +60,7 @@ export type ReportResult =
| { key: "player_transfer"; rows: ExportRow[]; summary: StatCard[]; meta: ReportMeta; raw: AdminTransferOrderItem[] } | { key: "player_transfer"; rows: ExportRow[]; summary: StatCard[]; meta: ReportMeta; raw: AdminTransferOrderItem[] }
| { key: "hot_number_risk"; rows: ExportRow[]; summary: StatCard[]; meta: ReportMeta | null; raw: AdminRiskPoolShowData } | { key: "hot_number_risk"; rows: ExportRow[]; summary: StatCard[]; meta: ReportMeta | null; raw: AdminRiskPoolShowData }
| { key: "play_dimension"; rows: ExportRow[]; summary: StatCard[]; meta: ReportMeta; raw: AdminReportPlayDimensionRow[] } | { key: "play_dimension"; rows: ExportRow[]; summary: StatCard[]; meta: ReportMeta; raw: AdminReportPlayDimensionRow[] }
| { key: "provider_profit"; rows: ExportRow[]; summary: StatCard[]; meta: ReportMeta; raw: AdminReportProviderProfitRow[] }
| { key: "sold_out_number"; rows: ExportRow[]; summary: StatCard[]; meta: ReportMeta; raw: AdminRiskPoolRow[] } | { key: "sold_out_number"; rows: ExportRow[]; summary: StatCard[]; meta: ReportMeta; raw: AdminRiskPoolRow[] }
| { key: "admin_audit"; rows: ExportRow[]; summary: StatCard[]; meta: ReportMeta; raw: AdminAuditLogRow[] }; | { key: "admin_audit"; rows: ExportRow[]; summary: StatCard[]; meta: ReportMeta; raw: AdminAuditLogRow[] };
@@ -350,4 +352,4 @@ export function headlineStats(result: ReportResult | null, reportKey: ReportKey)
return []; return [];
} }
return result.summary.slice(0, 2); return result.summary.slice(0, 2);
} }

View File

@@ -33,9 +33,11 @@ import type { AdminRiskPoolDetailLogRow, AdminRiskPoolShowData } from "@/types/a
export function RiskPoolDetailConsole({ export function RiskPoolDetailConsole({
drawId, drawId,
number4d, number4d,
providerCode,
}: { }: {
drawId: number; drawId: number;
number4d: string; number4d: string;
providerCode: string;
}) { }) {
const { t } = useTranslation(["risk", "common"]); const { t } = useTranslation(["risk", "common"]);
const tRef = useTranslationRef(["risk", "common"]); const tRef = useTranslationRef(["risk", "common"]);
@@ -53,7 +55,11 @@ export function RiskPoolDetailConsole({
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const d = await getAdminRiskPoolDetail(drawId, number4d, { page, per_page: perPage }); const d = await getAdminRiskPoolDetail(drawId, number4d, {
page,
per_page: perPage,
provider_code: providerCode,
});
setData(d); setData(d);
} catch (e) { } catch (e) {
const msg = const msg =
@@ -63,11 +69,11 @@ export function RiskPoolDetailConsole({
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [drawId, number4d, page, perPage, tRef]); }, [drawId, number4d, page, perPage, providerCode, tRef]);
useAsyncEffect(() => { useAsyncEffect(() => {
void load(); void load();
}, [drawId, number4d, page, perPage]); }, [drawId, number4d, page, perPage, providerCode]);
if (error && !data) { if (error && !data) {
return ( return (
@@ -115,7 +121,9 @@ export function RiskPoolDetailConsole({
<CardTitle className="text-lg"> <CardTitle className="text-lg">
{t("numberTitle", { number: pool.normalized_number })} {t("numberTitle", { number: pool.normalized_number })}
</CardTitle> </CardTitle>
<p className="text-sm text-muted-foreground">{t("drawMeta", { drawNo: data.draw_no })}</p> <p className="text-sm text-muted-foreground">
{t("drawMeta", { drawNo: data.draw_no })} · {t("provider")} {pool.provider_code}
</p>
</CardHeader> </CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4"> <CardContent className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<div className="rounded-lg border bg-muted/40 p-3"> <div className="rounded-lg border bg-muted/40 p-3">

View File

@@ -119,7 +119,7 @@ export function RiskPoolsConsole({
const [perPage, setPerPage] = useState(10); const [perPage, setPerPage] = useState(10);
const [data, setData] = useState<AdminRiskPoolListData | null>(null); const [data, setData] = useState<AdminRiskPoolListData | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [actingNumber, setActingNumber] = useState<string | null>(null); const [actingKey, setActingKey] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => { const load = useCallback(async () => {
@@ -152,11 +152,12 @@ export function RiskPoolsConsole({
const handleManualStatus = useCallback( const handleManualStatus = useCallback(
async (row: AdminRiskPoolRow) => { async (row: AdminRiskPoolRow) => {
setActingNumber(row.normalized_number); const rowKey = `${row.provider_code}:${row.normalized_number}`;
setActingKey(rowKey);
try { try {
const updated = row.is_sold_out const updated = row.is_sold_out
? await postAdminRiskPoolRecover(drawId, row.normalized_number) ? await postAdminRiskPoolRecover(drawId, row.normalized_number, row.provider_code)
: await postAdminRiskPoolManualClose(drawId, row.normalized_number); : await postAdminRiskPoolManualClose(drawId, row.normalized_number, row.provider_code);
setData((current) => { setData((current) => {
if (!current) return current; if (!current) return current;
@@ -164,7 +165,10 @@ export function RiskPoolsConsole({
return { return {
...current, ...current,
items: current.items.map((item) => items: current.items.map((item) =>
item.normalized_number === updated.normalized_number ? updated : item, item.normalized_number === updated.normalized_number &&
item.provider_code === updated.provider_code
? updated
: item,
), ),
}; };
}); });
@@ -172,7 +176,7 @@ export function RiskPoolsConsole({
} catch (e) { } catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("actionFailed")); toast.error(e instanceof LotteryApiBizError ? e.message : t("actionFailed"));
} finally { } finally {
setActingNumber(null); setActingKey(null);
} }
}, },
[drawId, t], [drawId, t],
@@ -275,6 +279,7 @@ export function RiskPoolsConsole({
<Table id={`risk-pools-table-${drawId}`}> <Table id={`risk-pools-table-${drawId}`}>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>{t("provider")}</TableHead>
<TableHead>{t("searchNumber")}</TableHead> <TableHead>{t("searchNumber")}</TableHead>
<TableHead className="text-center">{t("capAmount")}</TableHead> <TableHead className="text-center">{t("capAmount")}</TableHead>
<TableHead className="text-center">{t("lockedAmount")}</TableHead> <TableHead className="text-center">{t("lockedAmount")}</TableHead>
@@ -285,15 +290,15 @@ export function RiskPoolsConsole({
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{loading && !data ? <AdminTableLoadingRow colSpan={7} /> : null} {loading && !data ? <AdminTableLoadingRow colSpan={8} /> : null}
{(data?.items ?? []).map((row: AdminRiskPoolRow) => { {(data?.items ?? []).map((row: AdminRiskPoolRow) => {
const highRisk = (row.usage_ratio ?? 0) >= 0.8; const highRisk = (row.usage_ratio ?? 0) >= 0.8;
const acting = actingNumber === row.normalized_number; const acting = actingKey === `${row.provider_code}:${row.normalized_number}`;
const currencyCode = data?.currency_code ?? "NPR"; const currencyCode = data?.currency_code ?? "NPR";
return ( return (
<TableRow <TableRow
key={row.normalized_number} key={`${row.provider_code}-${row.normalized_number}`}
className={cn( className={cn(
row.is_sold_out row.is_sold_out
? "bg-red-50/90 hover:bg-red-50 dark:bg-red-950/25 dark:hover:bg-red-950/35" ? "bg-red-50/90 hover:bg-red-50 dark:bg-red-950/25 dark:hover:bg-red-950/35"
@@ -302,6 +307,7 @@ export function RiskPoolsConsole({
: null, : null,
)} )}
> >
<TableCell className="font-mono text-xs">{row.provider_code}</TableCell>
<TableCell className="font-mono font-medium">{row.normalized_number}</TableCell> <TableCell className="font-mono font-medium">{row.normalized_number}</TableCell>
<TableCell className="text-center text-sm font-semibold"> <TableCell className="text-center text-sm font-semibold">
{formatAdminMinorUnits(row.total_cap_amount, currencyCode)} {formatAdminMinorUnits(row.total_cap_amount, currencyCode)}
@@ -331,7 +337,7 @@ export function RiskPoolsConsole({
key: "view", key: "view",
label: t("view"), label: t("view"),
icon: Eye, icon: Eye,
href: `/admin/draws/${drawId}/risk/pools/${row.normalized_number}`, href: `/admin/draws/${drawId}/risk/pools/${row.normalized_number}?provider_code=${encodeURIComponent(row.provider_code)}`,
}, },
{ {
key: "toggle", key: "toggle",
@@ -347,9 +353,11 @@ export function RiskPoolsConsole({
description: row.is_sold_out description: row.is_sold_out
? t("confirm.recoverDescription", { ? t("confirm.recoverDescription", {
number: row.normalized_number, number: row.normalized_number,
provider: row.provider_code,
}) })
: t("confirm.closeDescription", { : t("confirm.closeDescription", {
number: row.normalized_number, number: row.normalized_number,
provider: row.provider_code,
}), }),
confirmVariant: row.is_sold_out ? "default" : "destructive", confirmVariant: row.is_sold_out ? "default" : "destructive",
onConfirm: () => handleManualStatus(row), onConfirm: () => handleManualStatus(row),

View File

@@ -77,6 +77,7 @@ export type OddsVersionDetail = ConfigVersionSummary & {
export type RiskCapItemRow = { export type RiskCapItemRow = {
id: number; id: number;
provider_code?: string;
draw_id: number | null; draw_id: number | null;
normalized_number: string; normalized_number: string;
cap_amount: number; cap_amount: number;

View File

@@ -24,6 +24,15 @@ export type AdminReportPlayDimensionRow = {
approx_house_gross_minor: number; approx_house_gross_minor: number;
}; };
export type AdminReportProviderProfitRow = {
provider_code: string;
provider_name: string;
ticket_item_count: number;
total_bet_minor: number;
total_payout_minor: number;
approx_house_gross_minor: number;
};
export type AdminReportListData<T> = { export type AdminReportListData<T> = {
items: T[]; items: T[];
meta: { meta: {

View File

@@ -6,6 +6,7 @@ export type AdminRiskPoolListMeta = {
}; };
export type AdminRiskPoolRow = { export type AdminRiskPoolRow = {
provider_code: string;
normalized_number: string; normalized_number: string;
total_cap_amount: number; total_cap_amount: number;
locked_amount: number; locked_amount: number;
@@ -26,6 +27,7 @@ export type AdminRiskPoolListData = {
export type AdminRiskPoolDetailLogRow = { export type AdminRiskPoolDetailLogRow = {
id: number; id: number;
provider_code: string;
action_type: string; action_type: string;
amount: number; amount: number;
source_reason: string | null; source_reason: string | null;