fix(fulfillment): 收口渠道商品与统一结算边界

This commit is contained in:
yml2213
2026-11-06 17:12:44 +08:00
parent e6b3835c9f
commit a333c05df4
23 changed files with 1113 additions and 106 deletions
@@ -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)
}
+30 -1
View File
@@ -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'))
})