diff --git a/src/features/host-org/api.test.ts b/src/features/host-org/api.test.ts index bdabdfa..a5ed83b 100644 --- a/src/features/host-org/api.test.ts +++ b/src/features/host-org/api.test.ts @@ -6,6 +6,7 @@ import { createCoinSeller, createRegion, creditCoinSellerStock, + debitCoinSellerStock, deleteAgency, disableRegion, listAgencies, @@ -61,6 +62,11 @@ test("host org list APIs use generated admin paths and filters", async () => { rechargeAmount: "100.000000", type: "usdt_purchase", }); + await debitCoinSellerStock("1001", { + coinAmount: 1000, + commandId: "coin-seller-stock-debit-test", + reason: "deduct bad stock", + }); await deleteAgency(7001, { commandId: "agency-delete-test", reason: "cleanup" }); const [bdUrl, bdInit] = vi.mocked(fetch).mock.calls[0]; @@ -72,7 +78,8 @@ test("host org list APIs use generated admin paths and filters", async () => { const [coinSellerCreateUrl, coinSellerCreateInit] = vi.mocked(fetch).mock.calls[6]; const [coinSellerStatusUrl, coinSellerStatusInit] = vi.mocked(fetch).mock.calls[7]; const [coinSellerStockUrl, coinSellerStockInit] = vi.mocked(fetch).mock.calls[8]; - const [agencyDeleteUrl, agencyDeleteInit] = vi.mocked(fetch).mock.calls[9]; + const [coinSellerStockDebitUrl, coinSellerStockDebitInit] = vi.mocked(fetch).mock.calls[9]; + const [agencyDeleteUrl, agencyDeleteInit] = vi.mocked(fetch).mock.calls[10]; expect(String(bdUrl)).toContain("/api/v1/admin/bds?"); expect(String(bdUrl)).toContain("parent_leader_user_id=21"); @@ -104,6 +111,8 @@ test("host org list APIs use generated admin paths and filters", async () => { expect(coinSellerStatusInit?.method).toBe("PATCH"); expect(String(coinSellerStockUrl)).toContain("/api/v1/admin/coin-sellers/1001/stock-credits"); expect(coinSellerStockInit?.method).toBe("POST"); + expect(String(coinSellerStockDebitUrl)).toContain("/api/v1/admin/coin-sellers/1001/stock-debits"); + expect(coinSellerStockDebitInit?.method).toBe("POST"); expect(String(agencyDeleteUrl)).toContain("/api/v1/admin/agencies/7001/delete"); expect(agencyDeleteInit?.method).toBe("POST"); expect(JSON.parse(String(agencyDeleteInit?.body))).toMatchObject({ commandId: "agency-delete-test" }); diff --git a/src/features/host-org/api.ts b/src/features/host-org/api.ts index 30428aa..4a95e79 100644 --- a/src/features/host-org/api.ts +++ b/src/features/host-org/api.ts @@ -14,6 +14,8 @@ import type { CoinSellerSalaryRatesPayload, CoinSellerStockCreditDto, CoinSellerStockCreditPayload, + CoinSellerStockDebitDto, + CoinSellerStockDebitPayload, CoinSellerStatusPayload, CountryDto, CountryPayload, @@ -283,6 +285,19 @@ export function creditCoinSellerStock( ); } +export function debitCoinSellerStock( + userId: EntityId, + payload: CoinSellerStockDebitPayload, +): Promise { + return apiRequest( + `/v1/admin/coin-sellers/${encodeURIComponent(String(userId))}/stock-debits`, + { + body: payload, + method: "POST", + }, + ); +} + export function getCoinSellerSalaryRates(regionId: EntityId): Promise { const endpoint = API_ENDPOINTS.getCoinSellerSalaryRates; return apiRequest( diff --git a/src/features/host-org/hooks/useHostCoinSellersPage.js b/src/features/host-org/hooks/useHostCoinSellersPage.js index e2c9489..3a6d3e9 100644 --- a/src/features/host-org/hooks/useHostCoinSellersPage.js +++ b/src/features/host-org/hooks/useHostCoinSellersPage.js @@ -6,6 +6,7 @@ import { useToast } from "@/shared/ui/ToastProvider.jsx"; import { createCoinSeller, creditCoinSellerStock, + debitCoinSellerStock, getCoinSellerSalaryRates, listCoinSellers, replaceCoinSellerSalaryRates, @@ -15,6 +16,7 @@ import { useCoinSellerAbilities } from "@/features/host-org/permissions.js"; import { coinSellerStatusSchema, coinSellerStockCreditSchema, + coinSellerStockDebitSchema, createCoinSellerSchema, } from "@/features/host-org/schema"; @@ -35,6 +37,11 @@ const emptyStockForm = () => ({ rechargeAmount: "", type: "usdt_purchase", }); +const emptyStockDebitForm = () => ({ + coinAmount: "", + commandId: makeCommandId("coin-seller-stock-debit"), + reason: "", +}); const emptyRateTier = (index = 0) => ({ coinPerUsd: "", enabled: true, @@ -57,6 +64,7 @@ export function useHostCoinSellersPage() { const [createForm, setCreateForm] = useState(emptyCreateForm); const [editForm, setEditForm] = useState(emptyEditForm); const [stockForm, setStockForm] = useState(emptyStockForm); + const [stockDebitForm, setStockDebitForm] = useState(emptyStockDebitForm); const [rateRegionId, setRateRegionId] = useState(""); const [rateTiers, setRateTiers] = useState([]); const [loadingRates, setLoadingRates] = useState(false); @@ -158,6 +166,15 @@ export function useHostCoinSellersPage() { setActiveAction("stock"); }; + const openStockDebit = (seller) => { + if (!seller?.userId || seller.status !== "active" || !abilities.canStockCredit) { + return; + } + setSelectedSeller(seller); + setStockDebitForm(emptyStockDebitForm()); + setActiveAction("stock-debit"); + }; + const openSellerLedger = (seller) => { if (!seller?.userId || !abilities.canLedger) { return; @@ -308,6 +325,20 @@ export function useHostCoinSellersPage() { }); }; + const submitStockDebit = async (event) => { + event.preventDefault(); + if (!selectedSeller?.userId) { + return; + } + await runAction(`coin-seller-stock-debit-${selectedSeller.userId}`, "币商金币已扣除", async () => { + const payload = parseForm(coinSellerStockDebitSchema, stockDebitForm); + await debitCoinSellerStock(selectedSeller.userId, payload); + setStockDebitForm(emptyStockDebitForm()); + closeAction(); + await reload(); + }); + }; + const submitRateSettings = async (event) => { event.preventDefault(); if (!rateRegionId) { @@ -383,6 +414,7 @@ export function useHostCoinSellersPage() { openEditSeller, openSellerLedger, openStockCredit, + openStockDebit, page, query, regionId, @@ -400,7 +432,9 @@ export function useHostCoinSellersPage() { setEditForm, setPage, setStockForm, + setStockDebitForm, status, + stockDebitForm, stockForm, sortBy, sortDirection, @@ -408,6 +442,7 @@ export function useHostCoinSellersPage() { submitCreateSeller, submitEditSeller, submitStockCredit, + submitStockDebit, toggleSeller, updateRateTier, }; diff --git a/src/features/host-org/pages/HostCoinSellersPage.jsx b/src/features/host-org/pages/HostCoinSellersPage.jsx index af24c32..5e430bc 100644 --- a/src/features/host-org/pages/HostCoinSellersPage.jsx +++ b/src/features/host-org/pages/HostCoinSellersPage.jsx @@ -3,6 +3,7 @@ import ArrowDownwardOutlined from "@mui/icons-material/ArrowDownwardOutlined"; import ArrowUpwardOutlined from "@mui/icons-material/ArrowUpwardOutlined"; import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined"; import MonetizationOnOutlined from "@mui/icons-material/MonetizationOnOutlined"; +import RemoveCircleOutlineOutlined from "@mui/icons-material/RemoveCircleOutlineOutlined"; import ReceiptLongOutlined from "@mui/icons-material/ReceiptLongOutlined"; import SettingsOutlined from "@mui/icons-material/SettingsOutlined"; import SwapVertOutlined from "@mui/icons-material/SwapVertOutlined"; @@ -79,6 +80,7 @@ export function HostCoinSellersPage() { const page = useHostCoinSellersPage(); const items = page.data.items || []; const total = page.data.total || 0; + const stockDebitForm = page.stockDebitForm || { coinAmount: "", reason: "" }; const createDisabled = !page.abilities.canCreate; const updateDisabled = !page.abilities.canUpdate; const columns = [ @@ -95,7 +97,7 @@ export function HostCoinSellersPage() { key: "actions", label: "操作", render: (item) => , - width: "minmax(90px, 0.5fr)", + width: "minmax(130px, 0.65fr)", } : null, ] @@ -303,6 +305,52 @@ export function HostCoinSellersPage() { + + + + + + + + + + page.setStockDebitForm?.({ ...stockDebitForm, coinAmount: event.target.value }) + } + /> + + + page.setStockDebitForm?.({ ...stockDebitForm, reason: event.target.value })} + /> + + + ) : null} + {page.abilities.canStockCredit ? ( + page.openStockDebit(item)} + > + + + ) : null} ); } @@ -555,3 +613,17 @@ const coinCreditActionSx = { color: "#f59e0b", }, }; + +const coinDebitActionSx = { + borderColor: "rgba(220, 38, 38, 0.34)", + backgroundColor: "rgba(254, 226, 226, 0.65)", + color: "#b91c1c", + "&:hover": { + borderColor: "rgba(220, 38, 38, 0.58)", + backgroundColor: "rgba(254, 202, 202, 0.75)", + color: "#991b1b", + }, + "& .MuiSvgIcon-root": { + color: "#dc2626", + }, +}; diff --git a/src/features/host-org/schema.ts b/src/features/host-org/schema.ts index 366643a..4728162 100644 --- a/src/features/host-org/schema.ts +++ b/src/features/host-org/schema.ts @@ -130,6 +130,12 @@ export const coinSellerStockCreditSchema = z } }); +export const coinSellerStockDebitSchema = z.object({ + coinAmount: z.coerce.number().int().positive("请输入扣除金币"), + commandId: z.string().trim().min(1, "请输入 Command ID").max(120, "Command ID 不能超过 120 个字符"), + reason: z.string().trim().min(1, "请输入扣除原因").max(512, "原因不能超过 512 个字符"), +}); + export const createAgencySchema = commandBaseSchema.extend({ joinEnabled: z.boolean().default(true), name: z.string().trim().min(1, "请输入 Agency 名称").max(80, "Agency 名称不能超过 80 个字符"), @@ -181,6 +187,7 @@ export type BDStatusForm = z.infer; export type CreateCoinSellerForm = z.infer; export type CoinSellerStatusForm = z.infer; export type CoinSellerStockCreditForm = z.infer; +export type CoinSellerStockDebitForm = z.infer; export type CreateAgencyForm = z.infer; export type AgencyCloseForm = z.infer; export type AgencyJoinEnabledForm = z.infer; diff --git a/src/features/operations/components/CoinSellerLedgerTable.jsx b/src/features/operations/components/CoinSellerLedgerTable.jsx index 29a101d..cb3794d 100644 --- a/src/features/operations/components/CoinSellerLedgerTable.jsx +++ b/src/features/operations/components/CoinSellerLedgerTable.jsx @@ -30,8 +30,9 @@ const ledgerTypeLabels = { const bizTypeLabels = { coin_seller_coin_compensation: "金币补偿", - coin_seller_stock_purchase: "币商进货", + coin_seller_stock_purchase: "USDT进货", coin_seller_transfer: "币商转用户", + manual_credit: "金币增加", salary_transfer_to_coin_seller: "工资转币商", }; @@ -50,10 +51,22 @@ const baseColumns = [ }, { key: "amount", - label: "转账金额", + label: "金币变动", render: (entry) => , width: "minmax(130px, 0.65fr)", }, + { + key: "transferUsdMinor", + label: "转账金额", + render: (entry) => , + width: "minmax(130px, 0.65fr)", + }, + { + key: "paidAmountMicro", + label: "USDT数量", + render: (entry) => , + width: "minmax(130px, 0.65fr)", + }, { key: "receiver", label: "收款人信息", @@ -197,7 +210,7 @@ export function CoinSellerLedgerTable({ lockedSeller = null, sellerUserId = "" } columns={tableColumns} emptyLabel="当前无币商流水" items={items} - minWidth={fixedSellerUserId ? "890px" : "1160px"} + minWidth={fixedSellerUserId ? "1130px" : "1380px"} pagination={ total > 0 ? { @@ -247,11 +260,44 @@ function AmountValue({ entry }) { } function ledgerTypeLabel(entry) { - return ( - ledgerTypeLabels[entry.ledgerType] || bizTypeLabels[entry.bizType] || entry.ledgerType || entry.bizType || "-" - ); + if (entry.bizType === "manual_credit" && (entry.direction === "expense" || Number(entry.availableDelta || 0) < 0)) { + return "金币扣除"; + } + return bizTypeLabels[entry.bizType] || ledgerTypeLabels[entry.ledgerType] || entry.ledgerType || entry.bizType || "-"; } function formatNumber(value) { return Number(value || 0).toLocaleString("zh-CN"); } + +function SalaryTransferAmount({ entry }) { + if (entry.bizType !== "salary_transfer_to_coin_seller" && entry.ledgerType !== "salary_transfer_received") { + return "-"; + } + const amountMinor = Number(entry.transferUsdMinor || entry.metadata?.salary_usd_minor || 0); + return amountMinor > 0 ? `$${formatUSDMinor(amountMinor)}` : "-"; +} + +function PaidUSDTAmount({ entry }) { + const stockType = entry.stockType || entry.metadata?.stock_type; + if (entry.bizType !== "coin_seller_stock_purchase" && stockType !== "usdt_purchase") { + return "-"; + } + const amountMicro = Number(entry.paidAmountMicro || entry.metadata?.paid_amount_micro || 0); + return amountMicro > 0 ? formatUSDTMicro(amountMicro) : "-"; +} + +function formatUSDMinor(value) { + return (Number(value || 0) / 100).toFixed(2); +} + +function formatUSDTMicro(value) { + const amount = Number(value || 0); + if (!Number.isFinite(amount) || amount <= 0) { + return "0"; + } + const whole = Math.trunc(amount / 1_000_000); + const fraction = Math.trunc(Math.abs(amount % 1_000_000)); + const trimmedFraction = String(fraction).padStart(6, "0").replace(/0+$/, ""); + return trimmedFraction ? `${whole}.${trimmedFraction}` : String(whole); +} diff --git a/src/shared/api/types.ts b/src/shared/api/types.ts index f6dd515..b37689a 100644 --- a/src/shared/api/types.ts +++ b/src/shared/api/types.ts @@ -228,10 +228,15 @@ export interface CoinSellerLedgerDto { metadata?: Record; operator?: CoinAdjustmentOperatorDto; operatorUserId?: string; + paidAmountMicro?: number; + paidCurrencyCode?: string; receiver: CoinLedgerUserDto; + reason?: string; seller: CoinLedgerUserDto; sellerBalanceAfter: number; + stockType?: string; transactionId: string; + transferUsdMinor?: number; } export interface CoinAdjustmentOperatorDto { @@ -601,6 +606,21 @@ export interface CoinSellerStockCreditDto { transactionId: string; } +export interface CoinSellerStockDebitPayload { + coinAmount: number; + commandId: string; + evidenceRef?: string; + reason: string; +} + +export interface CoinSellerStockDebitDto { + availableDelta: number; + balanceAfter: number; + coinAmount: number; + sellerUserId: string; + transactionId: string; +} + export interface CoinSellerSalaryRateTierDto { coinPerUsd: number; enabled?: boolean;