fix(fulfillment): 收口渠道商品与统一结算边界
This commit is contained in:
@@ -183,9 +183,11 @@ export function ManualFulfillmentConfirmModal({
|
||||
onSuccess: (response) => {
|
||||
const result = response.data
|
||||
message.success(
|
||||
result.onAccountBillId
|
||||
? `履约已确认,已生成统一结算虚拟账单 ¥${(result.costFen / 100).toFixed(2)}`
|
||||
: `履约已确认,扣 ${result.creditQuantity} 积分,成本 ¥${(result.costFen / 100).toFixed(2)}`,
|
||||
result.costPending
|
||||
? '履约已确认,已生成待对账的统一结算账单;实际成本待对账后入账'
|
||||
: result.onAccountBillId
|
||||
? `履约已确认,已生成统一结算虚拟账单 ¥${(result.costFen / 100).toFixed(2)}`
|
||||
: `履约已确认,扣 ${result.creditQuantity} 积分,成本 ¥${(result.costFen / 100).toFixed(2)}`,
|
||||
)
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-manual-fulfillment-pending'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-order-detail'] })
|
||||
|
||||
@@ -60,6 +60,7 @@ const REASON_LABELS: Record<string, string> = {
|
||||
cost_missing: '成本缺失',
|
||||
manual_config_missing: '人工 SKU 未配置',
|
||||
manual_cost_missing: '人工成本未记录',
|
||||
on_account_cost_pending: '统一结算待对账',
|
||||
carrier_missing: '缺少履约载体',
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { PlusOutlined, ReloadOutlined, RollbackOutlined } from '@ant-design/icons'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Alert,
|
||||
App,
|
||||
@@ -34,18 +34,22 @@ import {
|
||||
createAdminFundExpense,
|
||||
createAdminFundReturn,
|
||||
fetchAdminFundMyAccount,
|
||||
fetchAdminOnAccountBills,
|
||||
fetchAdminPlatformCreditAccounts,
|
||||
fetchAdminPlatformCreditEntries,
|
||||
reconcileAdminOnAccountBill,
|
||||
} from '@/services/admin'
|
||||
import type {
|
||||
AdminFundEntry,
|
||||
AdminFundExpense,
|
||||
AdminFundExpenseCategory,
|
||||
AdminFundPayeeType,
|
||||
OnAccountBill,
|
||||
PlatformCreditEntry,
|
||||
} from '@/types/admin'
|
||||
import type { UploadedFile } from '@/types/worker-platform'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
import { hasAdminPermission } from '@/utils/admin-auth'
|
||||
import { formatCreditUnitPriceLabel } from '@/utils/credit-unit-price'
|
||||
import {
|
||||
FUND_ACCOUNT_STATUS_LABELS,
|
||||
@@ -79,14 +83,23 @@ type ReturnFormValues = {
|
||||
note?: string
|
||||
}
|
||||
|
||||
type OnAccountReconcileFormValues = {
|
||||
amountYuan: number
|
||||
payeeName?: string
|
||||
note?: string
|
||||
}
|
||||
|
||||
export default function FundSupportView() {
|
||||
const { message } = App.useApp()
|
||||
const queryClient = useQueryClient()
|
||||
const [expenseOpen, setExpenseOpen] = useState(false)
|
||||
const [returnOpen, setReturnOpen] = useState(false)
|
||||
const [reconcileBill, setReconcileBill] = useState<OnAccountBill | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [expenseForm] = Form.useForm<ExpenseFormValues>()
|
||||
const [returnForm] = Form.useForm<ReturnFormValues>()
|
||||
const [reconcileForm] = Form.useForm<OnAccountReconcileFormValues>()
|
||||
const canReconcileOnAccount = hasAdminPermission('finance.reconcile')
|
||||
|
||||
const myQuery = useQuery({
|
||||
queryKey: ['admin-fund-my'],
|
||||
@@ -110,9 +123,35 @@ export default function FundSupportView() {
|
||||
refetchOnMount: 'always',
|
||||
refetchInterval: 15_000,
|
||||
})
|
||||
const onAccountBillsQuery = useQuery({
|
||||
queryKey: ['admin-on-account-bills', 'pending'],
|
||||
queryFn: () => fetchAdminOnAccountBills({ status: 'pending', pageSize: 100 }),
|
||||
refetchOnMount: 'always',
|
||||
})
|
||||
const creditAccounts = creditAccountsQuery.data?.data.items || []
|
||||
const creditEntries = creditEntriesQuery.data?.data.items || []
|
||||
const creditPlatforms = creditAccountsQuery.data?.data.platforms || []
|
||||
const onAccountBills = onAccountBillsQuery.data?.data.items || []
|
||||
|
||||
const reconcileMutation = useMutation({
|
||||
mutationFn: (values: OnAccountReconcileFormValues) =>
|
||||
reconcileAdminOnAccountBill(reconcileBill!.id, {
|
||||
amountFen: toFen(values.amountYuan),
|
||||
payeeName: values.payeeName || '',
|
||||
note: values.note || '',
|
||||
}),
|
||||
onSuccess: () => {
|
||||
message.success('统一结算账单已对账,实际成本已入账')
|
||||
setReconcileBill(null)
|
||||
reconcileForm.resetFields()
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-on-account-bills'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-sales-profit'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-finance'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-orders'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-order-detail'] })
|
||||
},
|
||||
onError: (error) => message.error(error instanceof Error ? error.message : '统一结算对账失败'),
|
||||
})
|
||||
|
||||
/** 客服自己的三个积分钱包,按平台顺序排好。 */
|
||||
const myCreditWallets = creditPlatforms.map((platform) => {
|
||||
@@ -132,6 +171,7 @@ export default function FundSupportView() {
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-fund-expenses'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-platform-credit-accounts'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-platform-credit-entries'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin-on-account-bills'] })
|
||||
}
|
||||
|
||||
function openExpense() {
|
||||
@@ -155,6 +195,15 @@ export default function FundSupportView() {
|
||||
setReturnOpen(true)
|
||||
}
|
||||
|
||||
function openReconcileBill(bill: OnAccountBill) {
|
||||
setReconcileBill(bill)
|
||||
reconcileForm.setFieldsValue({
|
||||
amountYuan: bill.amountFen > 0 ? bill.amountFen / 100 : undefined,
|
||||
payeeName: bill.payeeName || '',
|
||||
note: bill.note || '',
|
||||
})
|
||||
}
|
||||
|
||||
async function submitExpense(values: ExpenseFormValues) {
|
||||
if (!account) return
|
||||
setSubmitting(true)
|
||||
@@ -384,6 +433,56 @@ export default function FundSupportView() {
|
||||
{ title: '说明', dataIndex: 'note', render: (value: string) => value || '-' },
|
||||
]
|
||||
|
||||
const onAccountBillColumns: TableColumnsType<OnAccountBill> = [
|
||||
{ title: '账单号', dataIndex: 'billNo', width: 180 },
|
||||
{
|
||||
title: '订单 / SKU',
|
||||
width: 260,
|
||||
render: (_, row) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Typography.Text copyable={{ text: row.platformOrderId }}>
|
||||
{row.platformOrderId || `#${row.orderId}`}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{row.skuName || row.skuKey}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '订单收入',
|
||||
dataIndex: 'revenueFen',
|
||||
width: 120,
|
||||
render: (value: number) => formatFundAmount(value),
|
||||
},
|
||||
{
|
||||
title: '待结算金额',
|
||||
dataIndex: 'amountFen',
|
||||
width: 130,
|
||||
render: (value: number) => (value > 0 ? formatFundAmount(value) : '待填写'),
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
width: 170,
|
||||
render: (value: string) => formatAdminDateTime(value),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 110,
|
||||
render: (_, row) => (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
disabled={!canReconcileOnAccount}
|
||||
onClick={() => openReconcileBill(row)}
|
||||
>
|
||||
对账
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<PageHeader
|
||||
@@ -475,6 +574,38 @@ export default function FundSupportView() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card
|
||||
title={`统一结算待对账(${onAccountBillsQuery.data?.data.pagination.total || 0})`}
|
||||
extra={
|
||||
<Button
|
||||
size="small"
|
||||
icon={<ReloadOutlined />}
|
||||
loading={onAccountBillsQuery.isFetching}
|
||||
onClick={() => void onAccountBillsQuery.refetch()}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 12 }}
|
||||
message="统一结算金额未确认前不会生成 actual 成本快照"
|
||||
description="完成对账后,系统按实际金额生成成本快照并重算销售利润。"
|
||||
/>
|
||||
<PercentTable<OnAccountBill>
|
||||
rowKey="id"
|
||||
size="small"
|
||||
columns={onAccountBillColumns}
|
||||
dataSource={onAccountBills}
|
||||
loading={onAccountBillsQuery.isLoading}
|
||||
pagination={false}
|
||||
scroll={{ x: 980 }}
|
||||
locale={{ emptyText: '没有待对账的统一结算账单' }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{account ? (
|
||||
<Card>
|
||||
<Descriptions column={2} size="small">
|
||||
@@ -699,6 +830,43 @@ export default function FundSupportView() {
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={`统一结算对账${reconcileBill ? ` · ${reconcileBill.billNo}` : ''}`}
|
||||
open={Boolean(reconcileBill)}
|
||||
onCancel={() => setReconcileBill(null)}
|
||||
onOk={() => reconcileForm.submit()}
|
||||
confirmLoading={reconcileMutation.isPending}
|
||||
okText="确认对账"
|
||||
destroyOnHidden
|
||||
>
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 14 }}
|
||||
message="确认后将按本次金额生成 actual 成本快照"
|
||||
description="订单收入不会改变;如果此前已有固定成本快照,会用本次实际结算金额替代。"
|
||||
/>
|
||||
<Form<OnAccountReconcileFormValues>
|
||||
form={reconcileForm}
|
||||
layout="vertical"
|
||||
onFinish={(values) => reconcileMutation.mutate(values)}
|
||||
>
|
||||
<Form.Item
|
||||
name="amountYuan"
|
||||
label="实际结算金额(元)"
|
||||
rules={[{ required: true, message: '请输入实际结算金额' }]}
|
||||
>
|
||||
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="payeeName" label="实际收款方">
|
||||
<Input maxLength={120} />
|
||||
</Form.Item>
|
||||
<Form.Item name="note" label="对账备注">
|
||||
<Input.TextArea rows={2} maxLength={300} showCount />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ import type {
|
||||
ManualSkuListResult,
|
||||
ManualSkuOverview,
|
||||
ManualSkuScanResult,
|
||||
OnAccountBill,
|
||||
OnAccountBillListResult,
|
||||
ProductCatalogItem,
|
||||
ProductCatalogListResult,
|
||||
TemplateProductBootstrapPreview,
|
||||
@@ -555,3 +557,26 @@ export function deleteAdminVoucherProduct(productId: number) {
|
||||
}
|
||||
}>(`/api/v1/admin/cost-center/voucher-products/${productId}`)
|
||||
}
|
||||
|
||||
export function fetchAdminOnAccountBills(
|
||||
params: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
keyword?: string
|
||||
status?: string
|
||||
} = {},
|
||||
) {
|
||||
return apiGet<OnAccountBillListResult>('/api/v1/admin/finance/on-account-bills', params)
|
||||
}
|
||||
|
||||
export function reconcileAdminOnAccountBill(
|
||||
billId: number,
|
||||
payload: { amountFen: number; payeeName?: string; note?: string },
|
||||
) {
|
||||
return apiPost<{
|
||||
bill: OnAccountBill
|
||||
orderItemId: number
|
||||
snapshotId: number
|
||||
amountFen: number
|
||||
}>(`/api/v1/admin/finance/on-account-bills/${billId}/reconcile`, payload)
|
||||
}
|
||||
|
||||
@@ -564,7 +564,7 @@ export interface ManualFulfillmentPendingListResult {
|
||||
export interface ManualFulfillmentConfirmResult {
|
||||
orderItemId: number
|
||||
orderId: number
|
||||
costSnapshotId: number
|
||||
costSnapshotId: number | null
|
||||
platformKey: string
|
||||
platformLabel: string
|
||||
creditQuantity: number
|
||||
@@ -573,4 +573,33 @@ export interface ManualFulfillmentConfirmResult {
|
||||
creditEntryId: number | null
|
||||
balanceAfter: number | null
|
||||
onAccountBillId: number | null
|
||||
costPending: boolean
|
||||
}
|
||||
|
||||
export interface OnAccountBill {
|
||||
id: number
|
||||
billNo: string
|
||||
orderId: number
|
||||
orderItemId: number
|
||||
skuKey: string
|
||||
skuName: string
|
||||
canonicalProductId: number | null
|
||||
platformOrderId: string
|
||||
provider: string
|
||||
shopId: string
|
||||
revenueFen: number
|
||||
amountFen: number
|
||||
status: 'pending' | 'reconciled' | 'void'
|
||||
payeeName: string
|
||||
note: string
|
||||
metadata: Record<string, unknown>
|
||||
createdByAdminUserId: number | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
reconciledAt: string | null
|
||||
}
|
||||
|
||||
export interface OnAccountBillListResult {
|
||||
items: OnAccountBill[]
|
||||
pagination: { page: number; pageSize: number; total: number }
|
||||
}
|
||||
|
||||
@@ -11,3 +11,21 @@ test('客服我的钱包展示统一结算虚拟账单入口', async () => {
|
||||
assert.ok(source.includes('生成虚拟账单,不扣积分或备用金'))
|
||||
assert.ok(source.includes('support-wallet-grid'))
|
||||
})
|
||||
|
||||
test('客服钱包提供统一结算待对账列表和实际金额确认', async () => {
|
||||
const [source, service, confirmModal] = await Promise.all([
|
||||
readFile(new URL('../src/pages/admin/fund/FundSupportView.tsx', import.meta.url), 'utf8'),
|
||||
readFile(new URL('../src/services/admin/cost-center.ts', import.meta.url), 'utf8'),
|
||||
readFile(
|
||||
new URL('../src/components/admin/ManualFulfillmentConfirmModal.tsx', import.meta.url),
|
||||
'utf8',
|
||||
),
|
||||
])
|
||||
|
||||
assert.ok(source.includes('统一结算待对账'))
|
||||
assert.ok(source.includes('实际结算金额(元)'))
|
||||
assert.ok(source.includes('确认对账'))
|
||||
assert.ok(confirmModal.includes('costPending'))
|
||||
assert.ok(service.includes('/api/v1/admin/finance/on-account-bills'))
|
||||
assert.ok(service.includes('/reconcile'))
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user