币商转账

This commit is contained in:
zhx 2026-06-15 01:28:58 +08:00
parent aa7ce50e4f
commit 1447de8563
7 changed files with 212 additions and 8 deletions

View File

@ -6,6 +6,7 @@ import {
createCoinSeller, createCoinSeller,
createRegion, createRegion,
creditCoinSellerStock, creditCoinSellerStock,
debitCoinSellerStock,
deleteAgency, deleteAgency,
disableRegion, disableRegion,
listAgencies, listAgencies,
@ -61,6 +62,11 @@ test("host org list APIs use generated admin paths and filters", async () => {
rechargeAmount: "100.000000", rechargeAmount: "100.000000",
type: "usdt_purchase", 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" }); await deleteAgency(7001, { commandId: "agency-delete-test", reason: "cleanup" });
const [bdUrl, bdInit] = vi.mocked(fetch).mock.calls[0]; 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 [coinSellerCreateUrl, coinSellerCreateInit] = vi.mocked(fetch).mock.calls[6];
const [coinSellerStatusUrl, coinSellerStatusInit] = vi.mocked(fetch).mock.calls[7]; const [coinSellerStatusUrl, coinSellerStatusInit] = vi.mocked(fetch).mock.calls[7];
const [coinSellerStockUrl, coinSellerStockInit] = vi.mocked(fetch).mock.calls[8]; 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("/api/v1/admin/bds?");
expect(String(bdUrl)).toContain("parent_leader_user_id=21"); 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(coinSellerStatusInit?.method).toBe("PATCH");
expect(String(coinSellerStockUrl)).toContain("/api/v1/admin/coin-sellers/1001/stock-credits"); expect(String(coinSellerStockUrl)).toContain("/api/v1/admin/coin-sellers/1001/stock-credits");
expect(coinSellerStockInit?.method).toBe("POST"); 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(String(agencyDeleteUrl)).toContain("/api/v1/admin/agencies/7001/delete");
expect(agencyDeleteInit?.method).toBe("POST"); expect(agencyDeleteInit?.method).toBe("POST");
expect(JSON.parse(String(agencyDeleteInit?.body))).toMatchObject({ commandId: "agency-delete-test" }); expect(JSON.parse(String(agencyDeleteInit?.body))).toMatchObject({ commandId: "agency-delete-test" });

View File

@ -14,6 +14,8 @@ import type {
CoinSellerSalaryRatesPayload, CoinSellerSalaryRatesPayload,
CoinSellerStockCreditDto, CoinSellerStockCreditDto,
CoinSellerStockCreditPayload, CoinSellerStockCreditPayload,
CoinSellerStockDebitDto,
CoinSellerStockDebitPayload,
CoinSellerStatusPayload, CoinSellerStatusPayload,
CountryDto, CountryDto,
CountryPayload, CountryPayload,
@ -283,6 +285,19 @@ export function creditCoinSellerStock(
); );
} }
export function debitCoinSellerStock(
userId: EntityId,
payload: CoinSellerStockDebitPayload,
): Promise<CoinSellerStockDebitDto> {
return apiRequest<CoinSellerStockDebitDto, CoinSellerStockDebitPayload>(
`/v1/admin/coin-sellers/${encodeURIComponent(String(userId))}/stock-debits`,
{
body: payload,
method: "POST",
},
);
}
export function getCoinSellerSalaryRates(regionId: EntityId): Promise<CoinSellerSalaryRatesDto> { export function getCoinSellerSalaryRates(regionId: EntityId): Promise<CoinSellerSalaryRatesDto> {
const endpoint = API_ENDPOINTS.getCoinSellerSalaryRates; const endpoint = API_ENDPOINTS.getCoinSellerSalaryRates;
return apiRequest<CoinSellerSalaryRatesDto>( return apiRequest<CoinSellerSalaryRatesDto>(

View File

@ -6,6 +6,7 @@ import { useToast } from "@/shared/ui/ToastProvider.jsx";
import { import {
createCoinSeller, createCoinSeller,
creditCoinSellerStock, creditCoinSellerStock,
debitCoinSellerStock,
getCoinSellerSalaryRates, getCoinSellerSalaryRates,
listCoinSellers, listCoinSellers,
replaceCoinSellerSalaryRates, replaceCoinSellerSalaryRates,
@ -15,6 +16,7 @@ import { useCoinSellerAbilities } from "@/features/host-org/permissions.js";
import { import {
coinSellerStatusSchema, coinSellerStatusSchema,
coinSellerStockCreditSchema, coinSellerStockCreditSchema,
coinSellerStockDebitSchema,
createCoinSellerSchema, createCoinSellerSchema,
} from "@/features/host-org/schema"; } from "@/features/host-org/schema";
@ -35,6 +37,11 @@ const emptyStockForm = () => ({
rechargeAmount: "", rechargeAmount: "",
type: "usdt_purchase", type: "usdt_purchase",
}); });
const emptyStockDebitForm = () => ({
coinAmount: "",
commandId: makeCommandId("coin-seller-stock-debit"),
reason: "",
});
const emptyRateTier = (index = 0) => ({ const emptyRateTier = (index = 0) => ({
coinPerUsd: "", coinPerUsd: "",
enabled: true, enabled: true,
@ -57,6 +64,7 @@ export function useHostCoinSellersPage() {
const [createForm, setCreateForm] = useState(emptyCreateForm); const [createForm, setCreateForm] = useState(emptyCreateForm);
const [editForm, setEditForm] = useState(emptyEditForm); const [editForm, setEditForm] = useState(emptyEditForm);
const [stockForm, setStockForm] = useState(emptyStockForm); const [stockForm, setStockForm] = useState(emptyStockForm);
const [stockDebitForm, setStockDebitForm] = useState(emptyStockDebitForm);
const [rateRegionId, setRateRegionId] = useState(""); const [rateRegionId, setRateRegionId] = useState("");
const [rateTiers, setRateTiers] = useState([]); const [rateTiers, setRateTiers] = useState([]);
const [loadingRates, setLoadingRates] = useState(false); const [loadingRates, setLoadingRates] = useState(false);
@ -158,6 +166,15 @@ export function useHostCoinSellersPage() {
setActiveAction("stock"); setActiveAction("stock");
}; };
const openStockDebit = (seller) => {
if (!seller?.userId || seller.status !== "active" || !abilities.canStockCredit) {
return;
}
setSelectedSeller(seller);
setStockDebitForm(emptyStockDebitForm());
setActiveAction("stock-debit");
};
const openSellerLedger = (seller) => { const openSellerLedger = (seller) => {
if (!seller?.userId || !abilities.canLedger) { if (!seller?.userId || !abilities.canLedger) {
return; 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) => { const submitRateSettings = async (event) => {
event.preventDefault(); event.preventDefault();
if (!rateRegionId) { if (!rateRegionId) {
@ -383,6 +414,7 @@ export function useHostCoinSellersPage() {
openEditSeller, openEditSeller,
openSellerLedger, openSellerLedger,
openStockCredit, openStockCredit,
openStockDebit,
page, page,
query, query,
regionId, regionId,
@ -400,7 +432,9 @@ export function useHostCoinSellersPage() {
setEditForm, setEditForm,
setPage, setPage,
setStockForm, setStockForm,
setStockDebitForm,
status, status,
stockDebitForm,
stockForm, stockForm,
sortBy, sortBy,
sortDirection, sortDirection,
@ -408,6 +442,7 @@ export function useHostCoinSellersPage() {
submitCreateSeller, submitCreateSeller,
submitEditSeller, submitEditSeller,
submitStockCredit, submitStockCredit,
submitStockDebit,
toggleSeller, toggleSeller,
updateRateTier, updateRateTier,
}; };

View File

@ -3,6 +3,7 @@ import ArrowDownwardOutlined from "@mui/icons-material/ArrowDownwardOutlined";
import ArrowUpwardOutlined from "@mui/icons-material/ArrowUpwardOutlined"; import ArrowUpwardOutlined from "@mui/icons-material/ArrowUpwardOutlined";
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined"; import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
import MonetizationOnOutlined from "@mui/icons-material/MonetizationOnOutlined"; import MonetizationOnOutlined from "@mui/icons-material/MonetizationOnOutlined";
import RemoveCircleOutlineOutlined from "@mui/icons-material/RemoveCircleOutlineOutlined";
import ReceiptLongOutlined from "@mui/icons-material/ReceiptLongOutlined"; import ReceiptLongOutlined from "@mui/icons-material/ReceiptLongOutlined";
import SettingsOutlined from "@mui/icons-material/SettingsOutlined"; import SettingsOutlined from "@mui/icons-material/SettingsOutlined";
import SwapVertOutlined from "@mui/icons-material/SwapVertOutlined"; import SwapVertOutlined from "@mui/icons-material/SwapVertOutlined";
@ -79,6 +80,7 @@ export function HostCoinSellersPage() {
const page = useHostCoinSellersPage(); const page = useHostCoinSellersPage();
const items = page.data.items || []; const items = page.data.items || [];
const total = page.data.total || 0; const total = page.data.total || 0;
const stockDebitForm = page.stockDebitForm || { coinAmount: "", reason: "" };
const createDisabled = !page.abilities.canCreate; const createDisabled = !page.abilities.canCreate;
const updateDisabled = !page.abilities.canUpdate; const updateDisabled = !page.abilities.canUpdate;
const columns = [ const columns = [
@ -95,7 +97,7 @@ export function HostCoinSellersPage() {
key: "actions", key: "actions",
label: "操作", label: "操作",
render: (item) => <SellerActions item={item} page={page} />, render: (item) => <SellerActions item={item} page={page} />,
width: "minmax(90px, 0.5fr)", width: "minmax(130px, 0.65fr)",
} }
: null, : null,
] ]
@ -303,6 +305,52 @@ export function HostCoinSellersPage() {
</AdminFormSection> </AdminFormSection>
</HostOrgActionModal> </HostOrgActionModal>
<HostOrgActionModal
disabled={!page.abilities.canStockCredit || page.selectedSeller?.status !== "active"}
loading={String(page.loadingAction).startsWith("coin-seller-stock-debit")}
open={page.activeAction === "stock-debit"}
sectioned={false}
size="standard"
submitLabel="确认扣除"
onClose={page.closeAction}
onSubmit={page.submitStockDebit}
title="币商金币扣除"
>
<AdminFormSection title="基础信息">
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
<AdminFormReadOnlyField label="币商用户 ID" value={page.selectedSeller?.userId || ""} />
<AdminFormReadOnlyField
label="当前库存余额"
value={formatNumber(page.selectedSeller?.merchantBalance)}
/>
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection title="扣除信息">
<AdminFormAmountField
disabled={!page.abilities.canStockCredit}
inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }}
label="扣除金币"
required
unit="金币"
value={stockDebitForm.coinAmount}
onChange={(event) =>
page.setStockDebitForm?.({ ...stockDebitForm, coinAmount: event.target.value })
}
/>
</AdminFormSection>
<AdminFormSection title="备注信息">
<TextField
disabled={!page.abilities.canStockCredit}
label="原因"
multiline
required
minRows={2}
value={stockDebitForm.reason}
onChange={(event) => page.setStockDebitForm?.({ ...stockDebitForm, reason: event.target.value })}
/>
</AdminFormSection>
</HostOrgActionModal>
<SideDrawer <SideDrawer
className={styles.ledgerDrawer} className={styles.ledgerDrawer}
contentClassName={styles.ledgerDrawerBody} contentClassName={styles.ledgerDrawerBody}
@ -464,6 +512,16 @@ function SellerActions({ item, page }) {
<MonetizationOnOutlined fontSize="small" /> <MonetizationOnOutlined fontSize="small" />
</AdminActionIconButton> </AdminActionIconButton>
) : null} ) : null}
{page.abilities.canStockCredit ? (
<AdminActionIconButton
disabled={item.status !== "active" || page.loadingAction === `coin-seller-stock-debit-${item.userId}`}
label="扣金币"
sx={coinDebitActionSx}
onClick={() => page.openStockDebit(item)}
>
<RemoveCircleOutlineOutlined fontSize="small" />
</AdminActionIconButton>
) : null}
</AdminRowActions> </AdminRowActions>
); );
} }
@ -555,3 +613,17 @@ const coinCreditActionSx = {
color: "#f59e0b", 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",
},
};

View File

@ -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({ export const createAgencySchema = commandBaseSchema.extend({
joinEnabled: z.boolean().default(true), joinEnabled: z.boolean().default(true),
name: z.string().trim().min(1, "请输入 Agency 名称").max(80, "Agency 名称不能超过 80 个字符"), name: z.string().trim().min(1, "请输入 Agency 名称").max(80, "Agency 名称不能超过 80 个字符"),
@ -181,6 +187,7 @@ export type BDStatusForm = z.infer<typeof bdStatusSchema>;
export type CreateCoinSellerForm = z.infer<typeof createCoinSellerSchema>; export type CreateCoinSellerForm = z.infer<typeof createCoinSellerSchema>;
export type CoinSellerStatusForm = z.infer<typeof coinSellerStatusSchema>; export type CoinSellerStatusForm = z.infer<typeof coinSellerStatusSchema>;
export type CoinSellerStockCreditForm = z.infer<typeof coinSellerStockCreditSchema>; export type CoinSellerStockCreditForm = z.infer<typeof coinSellerStockCreditSchema>;
export type CoinSellerStockDebitForm = z.infer<typeof coinSellerStockDebitSchema>;
export type CreateAgencyForm = z.infer<typeof createAgencySchema>; export type CreateAgencyForm = z.infer<typeof createAgencySchema>;
export type AgencyCloseForm = z.infer<typeof agencyCloseSchema>; export type AgencyCloseForm = z.infer<typeof agencyCloseSchema>;
export type AgencyJoinEnabledForm = z.infer<typeof agencyJoinEnabledSchema>; export type AgencyJoinEnabledForm = z.infer<typeof agencyJoinEnabledSchema>;

View File

@ -30,8 +30,9 @@ const ledgerTypeLabels = {
const bizTypeLabels = { const bizTypeLabels = {
coin_seller_coin_compensation: "金币补偿", coin_seller_coin_compensation: "金币补偿",
coin_seller_stock_purchase: "币商进货", coin_seller_stock_purchase: "USDT进货",
coin_seller_transfer: "币商转用户", coin_seller_transfer: "币商转用户",
manual_credit: "金币增加",
salary_transfer_to_coin_seller: "工资转币商", salary_transfer_to_coin_seller: "工资转币商",
}; };
@ -50,10 +51,22 @@ const baseColumns = [
}, },
{ {
key: "amount", key: "amount",
label: "转账金额", label: "金币变动",
render: (entry) => <AmountValue entry={entry} />, render: (entry) => <AmountValue entry={entry} />,
width: "minmax(130px, 0.65fr)", width: "minmax(130px, 0.65fr)",
}, },
{
key: "transferUsdMinor",
label: "转账金额",
render: (entry) => <SalaryTransferAmount entry={entry} />,
width: "minmax(130px, 0.65fr)",
},
{
key: "paidAmountMicro",
label: "USDT数量",
render: (entry) => <PaidUSDTAmount entry={entry} />,
width: "minmax(130px, 0.65fr)",
},
{ {
key: "receiver", key: "receiver",
label: "收款人信息", label: "收款人信息",
@ -197,7 +210,7 @@ export function CoinSellerLedgerTable({ lockedSeller = null, sellerUserId = "" }
columns={tableColumns} columns={tableColumns}
emptyLabel="当前无币商流水" emptyLabel="当前无币商流水"
items={items} items={items}
minWidth={fixedSellerUserId ? "890px" : "1160px"} minWidth={fixedSellerUserId ? "1130px" : "1380px"}
pagination={ pagination={
total > 0 total > 0
? { ? {
@ -247,11 +260,44 @@ function AmountValue({ entry }) {
} }
function ledgerTypeLabel(entry) { function ledgerTypeLabel(entry) {
return ( if (entry.bizType === "manual_credit" && (entry.direction === "expense" || Number(entry.availableDelta || 0) < 0)) {
ledgerTypeLabels[entry.ledgerType] || bizTypeLabels[entry.bizType] || entry.ledgerType || entry.bizType || "-" return "金币扣除";
); }
return bizTypeLabels[entry.bizType] || ledgerTypeLabels[entry.ledgerType] || entry.ledgerType || entry.bizType || "-";
} }
function formatNumber(value) { function formatNumber(value) {
return Number(value || 0).toLocaleString("zh-CN"); 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);
}

View File

@ -228,10 +228,15 @@ export interface CoinSellerLedgerDto {
metadata?: Record<string, unknown>; metadata?: Record<string, unknown>;
operator?: CoinAdjustmentOperatorDto; operator?: CoinAdjustmentOperatorDto;
operatorUserId?: string; operatorUserId?: string;
paidAmountMicro?: number;
paidCurrencyCode?: string;
receiver: CoinLedgerUserDto; receiver: CoinLedgerUserDto;
reason?: string;
seller: CoinLedgerUserDto; seller: CoinLedgerUserDto;
sellerBalanceAfter: number; sellerBalanceAfter: number;
stockType?: string;
transactionId: string; transactionId: string;
transferUsdMinor?: number;
} }
export interface CoinAdjustmentOperatorDto { export interface CoinAdjustmentOperatorDto {
@ -601,6 +606,21 @@ export interface CoinSellerStockCreditDto {
transactionId: string; 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 { export interface CoinSellerSalaryRateTierDto {
coinPerUsd: number; coinPerUsd: number;
enabled?: boolean; enabled?: boolean;