币商转账
This commit is contained in:
parent
aa7ce50e4f
commit
1447de8563
@ -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" });
|
||||
|
||||
@ -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<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> {
|
||||
const endpoint = API_ENDPOINTS.getCoinSellerSalaryRates;
|
||||
return apiRequest<CoinSellerSalaryRatesDto>(
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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) => <SellerActions item={item} page={page} />,
|
||||
width: "minmax(90px, 0.5fr)",
|
||||
width: "minmax(130px, 0.65fr)",
|
||||
}
|
||||
: null,
|
||||
]
|
||||
@ -303,6 +305,52 @@ export function HostCoinSellersPage() {
|
||||
</AdminFormSection>
|
||||
</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
|
||||
className={styles.ledgerDrawer}
|
||||
contentClassName={styles.ledgerDrawerBody}
|
||||
@ -464,6 +512,16 @@ function SellerActions({ item, page }) {
|
||||
<MonetizationOnOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
) : 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>
|
||||
);
|
||||
}
|
||||
@ -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",
|
||||
},
|
||||
};
|
||||
|
||||
@ -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<typeof bdStatusSchema>;
|
||||
export type CreateCoinSellerForm = z.infer<typeof createCoinSellerSchema>;
|
||||
export type CoinSellerStatusForm = z.infer<typeof coinSellerStatusSchema>;
|
||||
export type CoinSellerStockCreditForm = z.infer<typeof coinSellerStockCreditSchema>;
|
||||
export type CoinSellerStockDebitForm = z.infer<typeof coinSellerStockDebitSchema>;
|
||||
export type CreateAgencyForm = z.infer<typeof createAgencySchema>;
|
||||
export type AgencyCloseForm = z.infer<typeof agencyCloseSchema>;
|
||||
export type AgencyJoinEnabledForm = z.infer<typeof agencyJoinEnabledSchema>;
|
||||
|
||||
@ -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) => <AmountValue entry={entry} />,
|
||||
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",
|
||||
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);
|
||||
}
|
||||
|
||||
@ -228,10 +228,15 @@ export interface CoinSellerLedgerDto {
|
||||
metadata?: Record<string, unknown>;
|
||||
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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user