feat(pricing): 开发定价方案
This commit is contained in:
@@ -0,0 +1,676 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
App,
|
||||
Modal,
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Collapse,
|
||||
Descriptions,
|
||||
Empty,
|
||||
Input,
|
||||
InputNumber,
|
||||
Radio,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Statistic,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import type {
|
||||
PricingCatalog,
|
||||
PricingOption,
|
||||
PricingParameters,
|
||||
PricingPolicy,
|
||||
PricingPreview,
|
||||
PricingPreviewRequest,
|
||||
} from '@atlas/contracts';
|
||||
import { pricingPreviewRequestSchema } from '@atlas/contracts';
|
||||
import { pricingApi } from '@/services/pricing';
|
||||
import { apiErrorMessage } from '@/services/api';
|
||||
import { percentToRate, rateToPercent } from './pricingPercentage';
|
||||
|
||||
export function defaultPricing(catalog: PricingCatalog): PricingParameters | null {
|
||||
const policy = catalog.policies.find(
|
||||
(p) =>
|
||||
p.is_default && catalog.rate_cards.some((card) => card.id === p.parameters.rate_card_id)
|
||||
);
|
||||
if (policy) return structuredClone(policy.parameters);
|
||||
const card = catalog.rate_cards[0];
|
||||
if (!card || !card.transports.length) return null;
|
||||
return {
|
||||
pricing_policy_id: null,
|
||||
rate_card_id: card.id,
|
||||
transport_method_ids: card.transports.map((t) => t.id),
|
||||
default_transport_method_id: card.transports[0].id,
|
||||
domestic_shipping_and_label_fee_cny: '0',
|
||||
ad_rate: '0',
|
||||
last_mile_rate: '0',
|
||||
withdrawal_rate: '0',
|
||||
other_rate: '0',
|
||||
desired_markup_rate: '0.5',
|
||||
list_price_discount_rate: '0',
|
||||
mode: 'target_markup',
|
||||
selling_price: null,
|
||||
fx: { mode: 'system' },
|
||||
temporary_commission_rate: null,
|
||||
};
|
||||
}
|
||||
export function PricingParametersFields({
|
||||
value: p,
|
||||
onChange,
|
||||
catalog,
|
||||
policyEditing = false,
|
||||
}: {
|
||||
policyEditing?: boolean;
|
||||
value: PricingParameters;
|
||||
onChange: (p: PricingParameters) => void;
|
||||
catalog: PricingCatalog;
|
||||
}) {
|
||||
const patch = (v: Partial<PricingParameters>) => onChange({ ...p, ...v });
|
||||
const rates: Array<[keyof PricingParameters, string]> = [
|
||||
['desired_markup_rate', '目标加价率'],
|
||||
['list_price_discount_rate', '划线折扣率'],
|
||||
['ad_rate', '广告费率'],
|
||||
['last_mile_rate', '尾程费率'],
|
||||
['withdrawal_rate', '提现费率'],
|
||||
['other_rate', '其他费率'],
|
||||
];
|
||||
return (
|
||||
<Space orientation="vertical" style={{ width: '100%' }} size="middle">
|
||||
<div>
|
||||
<Typography.Text type="secondary">定价方案</Typography.Text>
|
||||
<Select
|
||||
aria-label="定价方案"
|
||||
style={{ width: '100%' }}
|
||||
value={p.pricing_policy_id}
|
||||
placeholder="本次自定义参数"
|
||||
allowClear
|
||||
options={catalog.policies.map((x) => ({
|
||||
value: x.id,
|
||||
label: x.name + (x.is_default ? ' · 默认' : ''),
|
||||
}))}
|
||||
onChange={(id) => {
|
||||
const selected = catalog.policies.find((x) => x.id === id);
|
||||
if (selected)
|
||||
onChange({
|
||||
...structuredClone(selected.parameters),
|
||||
mode: p.mode,
|
||||
selling_price: p.selling_price,
|
||||
});
|
||||
else patch({ pricing_policy_id: null });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text type="secondary">物流费率</Typography.Text>
|
||||
<Select
|
||||
aria-label="物流费率"
|
||||
style={{ width: '100%' }}
|
||||
value={p.rate_card_id}
|
||||
options={catalog.rate_cards.map((x) => ({
|
||||
value: x.id,
|
||||
label: `${x.name} · ${x.version}${x.development_only ? ' · 开发草稿' : ''}`,
|
||||
}))}
|
||||
onChange={(id) => {
|
||||
const c = catalog.rate_cards.find((x) => x.id === id)!;
|
||||
patch({
|
||||
rate_card_id: id,
|
||||
pricing_policy_id: null,
|
||||
transport_method_ids: c.transports.map((t) => t.id),
|
||||
default_transport_method_id: c.transports[0]?.id ?? null,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Row gutter={[12, 12]}>
|
||||
{rates.map(([key, label]) => (
|
||||
<Col span={12} key={key}>
|
||||
<Typography.Text type="secondary">{label}(%)</Typography.Text>
|
||||
<InputNumber<string>
|
||||
aria-label={label}
|
||||
style={{ width: '100%' }}
|
||||
min="0"
|
||||
max={key === 'desired_markup_rate' ? '999' : '99.99'}
|
||||
step="1"
|
||||
precision={2}
|
||||
addonAfter="%"
|
||||
stringMode
|
||||
value={rateToPercent(p[key] as string)}
|
||||
onChange={(v) => patch({ [key]: percentToRate(v ?? '0') })}
|
||||
/>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
<Typography.Text type="secondary">
|
||||
目标加价率 50% 表示按采购成本增加 50% 的目标利润;预估利润以已配置费用为准。
|
||||
</Typography.Text>
|
||||
<Collapse
|
||||
items={[
|
||||
{
|
||||
key: 'fees',
|
||||
label: '国内费用、汇率与临时佣金',
|
||||
children: (
|
||||
<Space orientation="vertical" style={{ width: '100%' }}>
|
||||
<label>
|
||||
国内运费 / 贴单(CNY)
|
||||
<InputNumber<string>
|
||||
aria-label="国内运费"
|
||||
stringMode
|
||||
min="0"
|
||||
value={p.domestic_shipping_and_label_fee_cny}
|
||||
onChange={(v) =>
|
||||
patch({ domestic_shipping_and_label_fee_cny: v ?? '0' })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<Select
|
||||
aria-label="汇率来源"
|
||||
style={{ width: '100%' }}
|
||||
value={p.fx.mode}
|
||||
options={[
|
||||
{
|
||||
value: 'system',
|
||||
label: `CBR 系统汇率${catalog.exchange_rate ? ` · ${catalog.exchange_rate.rate} · ${catalog.exchange_rate.effective_on}` : ' · 暂无数据'}`,
|
||||
},
|
||||
{ value: 'policy_fixed', label: '方案固定汇率' },
|
||||
...(!policyEditing
|
||||
? [
|
||||
{
|
||||
value: 'one_time_override',
|
||||
label: '本次临时汇率',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
onChange={(mode) =>
|
||||
patch({
|
||||
fx: { mode, rate: p.fx.rate, reason: p.fx.reason },
|
||||
})
|
||||
}
|
||||
/>
|
||||
{p.fx.mode !== 'system' && (
|
||||
<>
|
||||
<InputNumber<string>
|
||||
aria-label="CNY 兑 RUB 汇率"
|
||||
stringMode
|
||||
min="0.00000001"
|
||||
value={p.fx.rate}
|
||||
disabled={
|
||||
p.fx.mode === 'policy_fixed' && !policyEditing
|
||||
}
|
||||
onChange={(v) =>
|
||||
patch({ fx: { ...p.fx, rate: v ?? undefined } })
|
||||
}
|
||||
placeholder="1 CNY = ? RUB"
|
||||
/>
|
||||
{p.fx.mode === 'one_time_override' && (
|
||||
<Input
|
||||
aria-label="汇率覆盖原因"
|
||||
value={p.fx.reason}
|
||||
placeholder="本次覆盖原因"
|
||||
onChange={(e) =>
|
||||
patch({
|
||||
fx: { ...p.fx, reason: e.target.value },
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<label>
|
||||
临时佣金率(%,仅缺少正式规则时)
|
||||
<InputNumber<string>
|
||||
aria-label="临时佣金率"
|
||||
stringMode
|
||||
min="0"
|
||||
max="99.99"
|
||||
step="1"
|
||||
precision={2}
|
||||
addonAfter="%"
|
||||
value={p.temporary_commission_rate === null ? null : rateToPercent(p.temporary_commission_rate)}
|
||||
onChange={(v) => patch({ temporary_commission_rate: v === null ? null : percentToRate(v) })}
|
||||
/>
|
||||
</label>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<div>
|
||||
<Typography.Text type="secondary">默认运输方式</Typography.Text>
|
||||
<Select
|
||||
aria-label="默认运输方式"
|
||||
style={{ width: '100%' }}
|
||||
value={p.default_transport_method_id}
|
||||
options={catalog.rate_cards
|
||||
.find((c) => c.id === p.rate_card_id)
|
||||
?.transports.map((t) => ({ value: t.id, label: t.name }))}
|
||||
onChange={(id) =>
|
||||
patch({
|
||||
default_transport_method_id: id,
|
||||
transport_method_ids: [...new Set([...p.transport_method_ids, id])],
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
export function PricingCalculator({
|
||||
initial,
|
||||
catalog,
|
||||
onApply,
|
||||
onCancel,
|
||||
onPolicySaved,
|
||||
previous,
|
||||
}: {
|
||||
previous?: PricingOption | null;
|
||||
initial: PricingPreviewRequest;
|
||||
catalog: PricingCatalog;
|
||||
onApply?: (input: PricingPreviewRequest, preview: PricingPreview, optionId: string) => void;
|
||||
onCancel?: () => void;
|
||||
onPolicySaved?: (policy: PricingPolicy) => void;
|
||||
}) {
|
||||
const { message } = App.useApp();
|
||||
const [policyName, setPolicyName] = useState<string | null>(null);
|
||||
const [policySaving, setPolicySaving] = useState(false);
|
||||
const [localCatalog, setLocalCatalog] = useState(catalog);
|
||||
const [input, setInput] = useState(() => structuredClone(initial));
|
||||
const [preview, setPreview] = useState<PricingPreview | null>(null);
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const sequence = useRef(0);
|
||||
const [refresh, setRefresh] = useState(0);
|
||||
const [now, setNow] = useState(Date.now());
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const seq = ++sequence.current,
|
||||
controller = new AbortController();
|
||||
setPreview(null);
|
||||
setError('');
|
||||
setBusy(false);
|
||||
const parsed = pricingPreviewRequestSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
setError(parsed.error.issues.map((i) => i.message).join(';'));
|
||||
return () => controller.abort();
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
setBusy(true);
|
||||
void pricingApi
|
||||
.preview(parsed.data, controller.signal)
|
||||
.then((p) => {
|
||||
if (sequence.current === seq) {
|
||||
setPreview(p);
|
||||
setSelected(p.recommended_option_id);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
if (sequence.current === seq && !controller.signal.aborted)
|
||||
setError(apiErrorMessage(e));
|
||||
})
|
||||
.finally(() => {
|
||||
if (sequence.current === seq) setBusy(false);
|
||||
});
|
||||
}, 400);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
controller.abort();
|
||||
};
|
||||
}, [input, refresh]);
|
||||
const option = preview?.options.find((o) => o.id === selected),
|
||||
expired = !!preview && Date.parse(preview.expires_at) <= now;
|
||||
return (
|
||||
<div className="pricing-calculator">
|
||||
<Modal
|
||||
title="将当前参数另存为门店方案"
|
||||
open={policyName !== null}
|
||||
confirmLoading={policySaving}
|
||||
onCancel={() => setPolicyName(null)}
|
||||
onOk={async () => {
|
||||
setPolicySaving(true);
|
||||
try {
|
||||
const p = input.pricing;
|
||||
const saved = await pricingApi.createPolicy(input.store_id, {
|
||||
name: policyName ?? '',
|
||||
note: null,
|
||||
allow_commission_override: !!localCatalog.policies.find(
|
||||
(x) => x.id === p.pricing_policy_id
|
||||
)?.allow_commission_override,
|
||||
parameters: {
|
||||
...p,
|
||||
pricing_policy_id: null,
|
||||
mode: 'target_markup',
|
||||
selling_price: null,
|
||||
temporary_commission_rate: null,
|
||||
fx:
|
||||
p.fx.mode === 'one_time_override'
|
||||
? { mode: 'policy_fixed', rate: p.fx.rate }
|
||||
: p.fx,
|
||||
},
|
||||
});
|
||||
setLocalCatalog({
|
||||
...localCatalog,
|
||||
policies: [...localCatalog.policies, saved],
|
||||
});
|
||||
setPolicyName(null);
|
||||
onPolicySaved?.(saved);
|
||||
message.success('已保存方案;可在方案列表中选择,当前报价未改变');
|
||||
} catch (error) {
|
||||
message.error(apiErrorMessage(error));
|
||||
} finally {
|
||||
setPolicySaving(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
aria-label="另存方案名称"
|
||||
placeholder="方案名称"
|
||||
value={policyName ?? ''}
|
||||
onChange={(e) => setPolicyName(e.target.value)}
|
||||
/>
|
||||
<Typography.Paragraph type="secondary">
|
||||
保存费用、加价、折扣和运输偏好。采购价、包装、手动售价及临时佣金不保存。
|
||||
{input.pricing.fx.mode === 'one_time_override'
|
||||
? '本次汇率将保存为方案固定汇率。'
|
||||
: ''}
|
||||
</Typography.Paragraph>
|
||||
</Modal>
|
||||
<Row gutter={[24, 24]}>
|
||||
<Col xs={24} lg={11}>
|
||||
<Space orientation="vertical" style={{ width: '100%' }} size="middle">
|
||||
<Typography.Text strong>
|
||||
{catalog.store.name} · {catalog.store.currency_code}
|
||||
</Typography.Text>
|
||||
<label>
|
||||
采购价(CNY)
|
||||
<InputNumber<string>
|
||||
aria-label="采购价"
|
||||
style={{ width: '100%' }}
|
||||
stringMode
|
||||
min="0"
|
||||
precision={2}
|
||||
value={input.purchase_cost_cny}
|
||||
onChange={(v) => setInput({ ...input, purchase_cost_cny: v ?? '' })}
|
||||
/>
|
||||
</label>
|
||||
<Card
|
||||
size="small"
|
||||
title={onApply ? '有效包装 · 修改后应用为此 SKU 的覆盖值' : '商品包装'}
|
||||
>
|
||||
{input.package.status !== 'confirmed' && (
|
||||
<Alert
|
||||
type="warning"
|
||||
title="包装规格尚未确认"
|
||||
action={
|
||||
<Button
|
||||
onClick={() =>
|
||||
setInput({
|
||||
...input,
|
||||
package: {
|
||||
...input.package,
|
||||
status: 'confirmed',
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
核对后确认包装
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Row gutter={[8, 8]}>
|
||||
{(
|
||||
[
|
||||
['shipping_weight_g', '重量 g'],
|
||||
['length_mm', '长 mm'],
|
||||
['width_mm', '宽 mm'],
|
||||
['height_mm', '高 mm'],
|
||||
] as const
|
||||
).map(([k, label]) => (
|
||||
<Col span={12} key={k}>
|
||||
<label>
|
||||
{label}
|
||||
<InputNumber<string>
|
||||
aria-label={label}
|
||||
style={{ width: '100%' }}
|
||||
stringMode
|
||||
min="0.001"
|
||||
value={input.package[k]}
|
||||
onChange={(v) =>
|
||||
setInput({
|
||||
...input,
|
||||
package: { ...input.package, [k]: v ?? '' },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</Card>
|
||||
<Radio.Group
|
||||
value={input.pricing.mode}
|
||||
onChange={(e) =>
|
||||
setInput({
|
||||
...input,
|
||||
pricing: {
|
||||
...input.pricing,
|
||||
mode: e.target.value,
|
||||
selling_price:
|
||||
e.target.value === 'target_markup'
|
||||
? null
|
||||
: input.pricing.selling_price,
|
||||
},
|
||||
})
|
||||
}
|
||||
options={[
|
||||
{ value: 'target_markup', label: '按目标算价' },
|
||||
{ value: 'fixed_sale', label: '手动售价' },
|
||||
]}
|
||||
/>
|
||||
{input.pricing.mode === 'fixed_sale' && (
|
||||
<InputNumber<string>
|
||||
aria-label="手动售价"
|
||||
stringMode
|
||||
min="0.01"
|
||||
value={input.pricing.selling_price}
|
||||
onChange={(v) =>
|
||||
setInput({
|
||||
...input,
|
||||
pricing: { ...input.pricing, selling_price: v },
|
||||
})
|
||||
}
|
||||
placeholder={`销售价 ${catalog.store.currency_code}`}
|
||||
/>
|
||||
)}
|
||||
<PricingParametersFields
|
||||
value={input.pricing}
|
||||
catalog={localCatalog}
|
||||
onChange={(pricing) => setInput({ ...input, pricing })}
|
||||
/>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col xs={24} lg={13}>
|
||||
<Space orientation="vertical" style={{ width: '100%' }} size="middle">
|
||||
{previous && (
|
||||
<Alert
|
||||
type="info"
|
||||
title={`上次报价:${previous.selling_price} ${previous.currency_code} · 预估净利润 ¥${previous.profit_cny}`}
|
||||
description={
|
||||
option
|
||||
? `本次报价:${option.selling_price} ${option.currency_code} · 预估净利润 ¥${option.profit_cny}。点击应用才接受本次结果。`
|
||||
: '重新试算后可对比价格与利润。'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
试算结果 {busy && <Tag color="processing">计算中</Tag>}
|
||||
</Typography.Title>
|
||||
{error && <Alert type="warning" title={error} />}
|
||||
{expired && (
|
||||
<Alert
|
||||
type="warning"
|
||||
title="预览已过期,请重新计算"
|
||||
action={
|
||||
<Button onClick={() => setRefresh((x) => x + 1)}>
|
||||
重新计算
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{option ? (
|
||||
<>
|
||||
<Card style={{ background: '#f7f5ff' }}>
|
||||
<Row gutter={12}>
|
||||
<Col span={12}>
|
||||
<Statistic
|
||||
title={`销售价 · ${option.currency_code}`}
|
||||
value={option.selling_price}
|
||||
precision={2}
|
||||
/>
|
||||
<Typography.Text type="secondary">
|
||||
划线价 {option.list_price}
|
||||
</Typography.Text>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Statistic
|
||||
title="预估净利润 · CNY"
|
||||
value={option.profit_cny}
|
||||
precision={2}
|
||||
styles={{
|
||||
content: {
|
||||
color:
|
||||
Number(option.profit_cny) < 0
|
||||
? '#cf1322'
|
||||
: '#389e0d',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Typography.Text type="secondary">
|
||||
占销售额{' '}
|
||||
{rateToPercent(option.margin_rate)}%
|
||||
</Typography.Text>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
{option.warnings.map((w) => (
|
||||
<Alert key={w} title={w} type="warning" />
|
||||
))}
|
||||
<Descriptions
|
||||
title="费用明细 · CNY"
|
||||
size="small"
|
||||
column={1}
|
||||
items={Object.entries(option.fees).map(([k, v]) => ({
|
||||
key: k,
|
||||
label: (
|
||||
{
|
||||
logistics_cny: '跨境物流',
|
||||
domestic_cny: '国内运费 / 贴单',
|
||||
commission_cny: '平台佣金',
|
||||
ad_cny: '广告',
|
||||
last_mile_cny: '尾程',
|
||||
withdrawal_cny: '提现',
|
||||
other_cny: '其他',
|
||||
} as Record<string, string>
|
||||
)[k],
|
||||
children: v,
|
||||
}))}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Empty
|
||||
description={
|
||||
preview?.options.length
|
||||
? '请选择运输方案'
|
||||
: '填好资料后自动试算'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{preview && (
|
||||
<>
|
||||
<Typography.Text strong>运输方案比较</Typography.Text>
|
||||
<Radio.Group
|
||||
value={selected}
|
||||
onChange={(e) => setSelected(e.target.value)}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Space orientation="vertical" style={{ width: '100%' }}>
|
||||
{preview.options.map((o) => (
|
||||
<Radio key={o.id} value={o.id}>
|
||||
{o.transport_name} · {o.product_type_name}
|
||||
{o.selling_price} {o.currency_code} 利润 ¥
|
||||
{o.profit_cny}
|
||||
{o.id === preview.options[0]?.id && (
|
||||
<Tag>最低售价</Tag>
|
||||
)}
|
||||
{o.id === preview.recommended_option_id && (
|
||||
<Tag color="purple">方案默认</Tag>
|
||||
)}
|
||||
</Radio>
|
||||
))}
|
||||
</Space>
|
||||
</Radio.Group>
|
||||
{preview.unavailable_options.map((u) => (
|
||||
<Typography.Paragraph
|
||||
key={u.transport_method_id}
|
||||
type="secondary"
|
||||
>
|
||||
{
|
||||
catalog.rate_cards
|
||||
.flatMap((c) => c.transports)
|
||||
.find((t) => t.id === u.transport_method_id)?.name
|
||||
}
|
||||
:{u.reason}
|
||||
</Typography.Paragraph>
|
||||
))}
|
||||
<Typography.Text type="secondary">
|
||||
规则 {preview.context.rule_version} · 汇率{' '}
|
||||
{preview.context.fx_rate}(
|
||||
{(
|
||||
{
|
||||
system_rate: 'CBR 官方参考',
|
||||
policy_fixed: '方案固定',
|
||||
quote_override: '本次覆盖',
|
||||
} as Record<string, string>
|
||||
)[preview.context.fx_origin] ?? preview.context.fx_origin}
|
||||
)
|
||||
</Typography.Text>
|
||||
{preview.context.warnings.map((w) => (
|
||||
<Alert key={w} type="warning" title={w} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<Space wrap>
|
||||
<Button onClick={() => setPolicyName('')}>参数另存方案</Button>
|
||||
{onCancel && <Button onClick={onCancel}>取消</Button>}
|
||||
<Button loading={busy} onClick={() => setRefresh((x) => x + 1)}>
|
||||
重新计算
|
||||
</Button>
|
||||
{onApply && (
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={!option || expired || busy || !option.can_confirm}
|
||||
onClick={() => onApply(input, preview!, selected!)}
|
||||
>
|
||||
应用到 SKU
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
{onApply && (
|
||||
<Typography.Text type="secondary">
|
||||
应用后仍需保存商品,才会确认报价。
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Button, Form, InputNumber, Modal, Space, Tag, Typography } from 'antd';
|
||||
import type { FormInstance } from 'antd';
|
||||
import type {
|
||||
PricingCatalog,
|
||||
PricingOption,
|
||||
PricingParameters,
|
||||
PricingPreview,
|
||||
PricingPreviewRequest,
|
||||
ProductEditorSnapshot,
|
||||
SaveProductEditorDraftRequest,
|
||||
} from '@atlas/contracts';
|
||||
import { pricingPreviewRequestSchema, pricingInputKey } from '@atlas/contracts';
|
||||
import { pricingApi } from '@/services/pricing';
|
||||
import { apiErrorMessage } from '@/services/api';
|
||||
import { defaultPricing, PricingCalculator } from './PricingCalculator';
|
||||
type Values = SaveProductEditorDraftRequest;
|
||||
export function skuPricingInput(
|
||||
values: Values,
|
||||
index: number,
|
||||
profileId: string,
|
||||
parameters: PricingParameters
|
||||
): PricingPreviewRequest {
|
||||
const v = values.variants[index],
|
||||
base = values.common.default_package_spec,
|
||||
pkg = v.package_spec;
|
||||
return {
|
||||
store_id: values.profile.target_store_id,
|
||||
platform_product_category_id: values.profile.platform_product_category_id ?? '',
|
||||
fulfillment: values.profile.fulfillment as 'rfbs',
|
||||
binding: {
|
||||
profile_id: profileId,
|
||||
variant_id: v.id,
|
||||
client_key: v.id ? null : v.client_key,
|
||||
},
|
||||
purchase_cost_cny: v.purchase_cost_cny ?? '',
|
||||
package: {
|
||||
shipping_weight_g: pkg.shipping_weight_g ?? base.shipping_weight_g ?? '',
|
||||
length_mm: pkg.length_mm ?? base.length_mm ?? '',
|
||||
width_mm: pkg.width_mm ?? base.width_mm ?? '',
|
||||
height_mm: pkg.height_mm ?? base.height_mm ?? '',
|
||||
status: (pkg.status ?? base.status) as 'confirmed',
|
||||
},
|
||||
pricing: parameters,
|
||||
};
|
||||
}
|
||||
const keyFor = pricingInputKey;
|
||||
export function SkuPricing({
|
||||
form,
|
||||
index,
|
||||
snapshot,
|
||||
auto,
|
||||
batch,
|
||||
onChanged,
|
||||
}: {
|
||||
form: FormInstance<Values>;
|
||||
index: number;
|
||||
snapshot: ProductEditorSnapshot;
|
||||
auto: boolean;
|
||||
batch: number;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
Form.useWatch('variants', form);
|
||||
Form.useWatch('common', form);
|
||||
Form.useWatch('profile', form);
|
||||
const values = form.getFieldsValue(true) as Values,
|
||||
v = values.variants[index],
|
||||
original = snapshot.variants.find((x) => x.id === v?.id);
|
||||
const [catalog, setCatalog] = useState<PricingCatalog | null>(null),
|
||||
[error, setError] = useState(''),
|
||||
[busy, setBusy] = useState(false),
|
||||
[result, setResult] = useState<{
|
||||
inputKey: string;
|
||||
preview: PricingPreview;
|
||||
option: PricingOption;
|
||||
} | null>(null),
|
||||
[modal, setModal] = useState<PricingPreviewRequest | null>(null);
|
||||
const restoreInheritance = useRef(false);
|
||||
const sequence = useRef(0),
|
||||
controller = useRef<AbortController | null>(null),
|
||||
lastRequest = useRef(''),
|
||||
lastBatch = useRef(batch);
|
||||
const store = values.profile?.target_store_id;
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setCatalog(null);
|
||||
setError('');
|
||||
void pricingApi
|
||||
.catalog(store)
|
||||
.then((c) => {
|
||||
if (active) setCatalog(c);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (active) setError(apiErrorMessage(e));
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [store]);
|
||||
useEffect(
|
||||
() => () => {
|
||||
controller.current?.abort();
|
||||
sequence.current++;
|
||||
},
|
||||
[]
|
||||
);
|
||||
const params = v?.pricing_draft ?? (catalog ? defaultPricing(catalog) : null);
|
||||
const live = params && v ? skuPricingInput(values, index, snapshot.profile.id, params) : null;
|
||||
const liveKey = live ? keyFor(live) : '';
|
||||
const liveRef = useRef(liveKey);
|
||||
liveRef.current = liveKey;
|
||||
const [now, setNow] = useState(Date.now());
|
||||
useEffect(() => {
|
||||
if (!result) return;
|
||||
const timer = setTimeout(
|
||||
() => setNow(Date.now()),
|
||||
Math.max(0, Date.parse(result.preview.expires_at) - Date.now()) + 20
|
||||
);
|
||||
return () => clearTimeout(timer);
|
||||
}, [result]);
|
||||
const resultValid =
|
||||
(!!v?.pricing_selection || result?.option.can_confirm === false) &&
|
||||
result?.inputKey === liveKey &&
|
||||
Date.parse(result.preview.expires_at) > now;
|
||||
const savedInput = original?.listing.pricing_draft
|
||||
? {
|
||||
store_id: snapshot.profile.target_store.id,
|
||||
platform_product_category_id: snapshot.profile.category.id ?? '',
|
||||
fulfillment: snapshot.profile.fulfillment as 'rfbs',
|
||||
binding: {
|
||||
profile_id: snapshot.profile.id,
|
||||
variant_id: original.id,
|
||||
client_key: null,
|
||||
},
|
||||
purchase_cost_cny: original.purchase_cost_cny ?? '',
|
||||
package: {
|
||||
...original.effective_package_spec,
|
||||
status: original.effective_package_spec.status as 'confirmed',
|
||||
},
|
||||
pricing: original.listing.pricing_draft,
|
||||
}
|
||||
: null;
|
||||
const confirmed =
|
||||
!!original?.listing.pricing_valid &&
|
||||
!!savedInput &&
|
||||
liveKey === keyFor(savedInput as PricingPreviewRequest) &&
|
||||
!v?.pricing_selection;
|
||||
const option = resultValid
|
||||
? result!.option
|
||||
: confirmed
|
||||
? original?.listing.pricing_result
|
||||
: null;
|
||||
const setParameters = (p: PricingParameters) => {
|
||||
form.setFieldValue(['variants', index, 'pricing_draft'], p);
|
||||
onChanged();
|
||||
};
|
||||
function apply(
|
||||
input: PricingPreviewRequest,
|
||||
preview: PricingPreview,
|
||||
optionId: string,
|
||||
copyInputs: boolean
|
||||
) {
|
||||
if (copyInputs) {
|
||||
form.setFieldValue(['variants', index, 'purchase_cost_cny'], input.purchase_cost_cny);
|
||||
if (
|
||||
restoreInheritance.current &&
|
||||
JSON.stringify(input.package) === JSON.stringify(modal?.package)
|
||||
) {
|
||||
form.setFieldValue(['variants', index, 'package_spec'], {
|
||||
shipping_weight_g: null,
|
||||
length_mm: null,
|
||||
width_mm: null,
|
||||
height_mm: null,
|
||||
status: null,
|
||||
});
|
||||
} else if (JSON.stringify(input.package) !== JSON.stringify(modal?.package)) {
|
||||
form.setFieldValue(['variants', index, 'package_spec'], input.package);
|
||||
}
|
||||
}
|
||||
setParameters(input.pricing);
|
||||
form.setFieldValue(['variants', index, 'pricing_selection'], {
|
||||
preview_id: preview.preview_id,
|
||||
option_id: optionId,
|
||||
input_hash: preview.input_hash,
|
||||
});
|
||||
setResult({
|
||||
inputKey: keyFor(input),
|
||||
preview,
|
||||
option: preview.options.find((o) => o.id === optionId)!,
|
||||
});
|
||||
setError('');
|
||||
onChanged();
|
||||
}
|
||||
async function calculate(force = false, override?: PricingParameters) {
|
||||
if (!catalog || !v || original?.listing.status === 'imported') return;
|
||||
const current = form.getFieldValue(['variants', index]) as Values['variants'][number];
|
||||
const parameters = override ?? current.pricing_draft ?? defaultPricing(catalog);
|
||||
if (!parameters) {
|
||||
setError('没有已审核物流费率,请先配置费率');
|
||||
return;
|
||||
}
|
||||
const input = skuPricingInput(
|
||||
form.getFieldsValue(true),
|
||||
index,
|
||||
snapshot.profile.id,
|
||||
parameters
|
||||
),
|
||||
inputKey = keyFor(input);
|
||||
if (!force && ((lastRequest.current === inputKey && resultValid) || confirmed)) return;
|
||||
const parsed = pricingPreviewRequestSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
setError('请补齐采购价、类目,并确认有效包装重量和尺寸');
|
||||
return;
|
||||
}
|
||||
setParameters(parameters);
|
||||
lastRequest.current = inputKey;
|
||||
controller.current?.abort();
|
||||
controller.current = new AbortController();
|
||||
const seq = ++sequence.current;
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
const p = await pricingApi.preview(parsed.data, controller.current.signal);
|
||||
if (seq !== sequence.current || liveRef.current !== inputKey) return;
|
||||
const recommended = p.options.find((o) => o.id === p.recommended_option_id);
|
||||
if (!recommended) {
|
||||
setError(
|
||||
p.unavailable_options.map((x) => x.reason).join(';') ||
|
||||
'默认运输方式不可用,请在详情中选择'
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!recommended.can_confirm) {
|
||||
setResult({ inputKey, preview: p, option: recommended });
|
||||
setError('当前售价亏损,不能确认,请调整售价');
|
||||
return;
|
||||
}
|
||||
apply(input, p, recommended.id, false);
|
||||
} catch (e) {
|
||||
if (seq === sequence.current && !controller.current.signal.aborted)
|
||||
setError(apiErrorMessage(e));
|
||||
} finally {
|
||||
if (seq === sequence.current) setBusy(false);
|
||||
}
|
||||
}
|
||||
// 包装和门店上下文变化自动重算;采购价与手动售价仅在失焦/Enter 时提交。
|
||||
const contextKey = live
|
||||
? JSON.stringify([
|
||||
live.store_id,
|
||||
live.platform_product_category_id,
|
||||
live.fulfillment,
|
||||
live.package,
|
||||
])
|
||||
: '';
|
||||
const previousContext = useRef(contextKey);
|
||||
useEffect(() => {
|
||||
if (previousContext.current === contextKey) return;
|
||||
previousContext.current = contextKey;
|
||||
sequence.current++;
|
||||
controller.current?.abort();
|
||||
setBusy(false);
|
||||
if (auto && live && result?.inputKey !== liveKey) {
|
||||
const timer = setTimeout(() => void calculate(true), 400);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [contextKey, auto]);
|
||||
useEffect(() => {
|
||||
if (batch === lastBatch.current) return;
|
||||
lastBatch.current = batch;
|
||||
void calculate(true);
|
||||
}, [batch]);
|
||||
if (!v) return null;
|
||||
const imported = original?.listing.status === 'imported';
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'start', flexWrap: 'wrap', flex: 1 }}>
|
||||
<div className="sku-card-field sku-price-field">
|
||||
<span>采购价(CNY)</span>
|
||||
<InputNumber<string>
|
||||
aria-label={`SKU ${index + 1} 采购价`}
|
||||
min="0"
|
||||
stringMode
|
||||
precision={2}
|
||||
value={v.purchase_cost_cny}
|
||||
onChange={(value) => {
|
||||
form.setFieldValue(['variants', index, 'purchase_cost_cny'], value);
|
||||
onChanged();
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (auto && !imported) void calculate();
|
||||
}}
|
||||
onPressEnter={(event) => {
|
||||
event.preventDefault();
|
||||
void calculate(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="sku-card-field sku-price-field">
|
||||
<span>
|
||||
销售价({catalog?.store.currency_code ?? original?.listing.currency_code ?? '—'}
|
||||
)
|
||||
</span>
|
||||
<InputNumber<string>
|
||||
aria-label={`SKU ${index + 1} 销售价`}
|
||||
min="0.01"
|
||||
stringMode
|
||||
precision={2}
|
||||
disabled={imported || !params}
|
||||
value={
|
||||
params?.mode === 'fixed_sale'
|
||||
? params.selling_price
|
||||
: (option?.selling_price ?? original?.listing.selling_price)
|
||||
}
|
||||
onChange={(value) => {
|
||||
if (params)
|
||||
setParameters({ ...params, mode: 'fixed_sale', selling_price: value });
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (auto) void calculate(true);
|
||||
}}
|
||||
onPressEnter={(event) => {
|
||||
event.preventDefault();
|
||||
void calculate(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="sku-card-value">
|
||||
<span>预估净利润(CNY)</span>
|
||||
<strong
|
||||
style={{
|
||||
color: option && Number(option.profit_cny) < 0 ? '#cf1322' : '#389e0d',
|
||||
}}
|
||||
>
|
||||
{option?.profit_cny ?? '—'}
|
||||
</strong>
|
||||
</div>
|
||||
{!imported && (
|
||||
<Space>
|
||||
<Button loading={busy} onClick={() => void calculate(true)}>
|
||||
计算
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!catalog || !params}
|
||||
onClick={() => {
|
||||
restoreInheritance.current = false;
|
||||
setModal(
|
||||
skuPricingInput(
|
||||
form.getFieldsValue(true),
|
||||
index,
|
||||
snapshot.profile.id,
|
||||
params!
|
||||
)
|
||||
);
|
||||
}}
|
||||
>
|
||||
试算详情
|
||||
</Button>
|
||||
</Space>
|
||||
)}
|
||||
<div style={{ width: '100%' }}>
|
||||
<Space wrap>
|
||||
<Tag color={confirmed ? 'success' : resultValid ? 'purple' : 'default'}>
|
||||
{busy
|
||||
? '计算中'
|
||||
: confirmed
|
||||
? '已确认'
|
||||
: resultValid
|
||||
? result?.option.can_confirm === false
|
||||
? '亏损 · 请调整售价'
|
||||
: '已试算 · 待保存'
|
||||
: v.pricing_selection
|
||||
? '输入已变化 · 需重新试算'
|
||||
: '待定价'}
|
||||
</Tag>
|
||||
{catalog?.rate_cards.find((card) => card.id === params?.rate_card_id)
|
||||
?.development_only && <Tag color="orange">开发草稿费率 · 不可上架</Tag>}
|
||||
{params?.mode === 'fixed_sale' && (
|
||||
<>
|
||||
<Tag>手动售价</Tag>
|
||||
<Button
|
||||
size="small"
|
||||
type="link"
|
||||
onClick={() => {
|
||||
const p = {
|
||||
...params,
|
||||
mode: 'target_markup' as const,
|
||||
selling_price: null,
|
||||
};
|
||||
setParameters(p);
|
||||
void calculate(true, p);
|
||||
}}
|
||||
>
|
||||
使用建议售价
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Typography.Text type="secondary">
|
||||
{catalog?.policies.find((p) => p.id === params?.pricing_policy_id)?.name ??
|
||||
'本次参数'}
|
||||
{option ? ` · ${option.transport_name}` : ''}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
{error && (
|
||||
<div role="alert" style={{ color: '#ad6800', fontSize: 12 }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Modal
|
||||
title={`${v.display_name ?? 'SKU'} · 定价试算`}
|
||||
open={!!modal}
|
||||
width={1000}
|
||||
footer={null}
|
||||
destroyOnHidden
|
||||
onCancel={() => setModal(null)}
|
||||
>
|
||||
{modal && catalog && (
|
||||
<>
|
||||
<Typography.Paragraph type="secondary">
|
||||
包装沿用当前有效规格,修改后只覆盖本 SKU。取消不会改变草稿。
|
||||
</Typography.Paragraph>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
restoreInheritance.current = true;
|
||||
const base = form.getFieldValue(['common', 'default_package_spec']);
|
||||
setModal({ ...modal, package: { ...base, status: base.status } });
|
||||
}}
|
||||
>
|
||||
使用 SPU 包装重新试算
|
||||
</Button>
|
||||
<PricingCalculator
|
||||
key={JSON.stringify(modal)}
|
||||
initial={modal}
|
||||
previous={result?.option ?? original?.listing.pricing_result}
|
||||
catalog={catalog}
|
||||
onCancel={() => setModal(null)}
|
||||
onApply={(input, p, id) => {
|
||||
apply(input, p, id, true);
|
||||
setModal(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/** 界面使用百分数,API 保留十进制比例字符串;移位换算避免浮点误差。 */
|
||||
export function rateToPercent(rate: string): string {
|
||||
const negative = rate.startsWith('-');
|
||||
const [integer, fraction = ''] = (negative ? rate.slice(1) : rate).split('.');
|
||||
const digits = integer + fraction.padEnd(2, '0');
|
||||
const point = integer.length + 2;
|
||||
const whole = digits.slice(0, point).replace(/^0+(?=\d)/, '');
|
||||
const decimals = digits.slice(point);
|
||||
const hundredths =
|
||||
BigInt(whole) * 100n +
|
||||
BigInt(decimals.padEnd(2, '0').slice(0, 2)) +
|
||||
(Number(decimals[2] ?? '0') >= 5 ? 1n : 0n);
|
||||
return `${negative && hundredths > 0n ? '-' : ''}${hundredths / 100n}.${String(hundredths % 100n).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function percentToRate(percent: string): string {
|
||||
const [integer, fraction = ''] = percent.split('.');
|
||||
const hundredths =
|
||||
BigInt(integer || '0') * 100n +
|
||||
BigInt(fraction.padEnd(2, '0').slice(0, 2)) +
|
||||
(Number(fraction[2] ?? '0') >= 5 ? 1n : 0n);
|
||||
const whole = String(hundredths / 10000n);
|
||||
const decimals = String(hundredths % 10000n).padStart(4, '0').replace(/0+$/, '');
|
||||
return decimals ? `${whole}.${decimals}` : whole;
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
import { Button, Layout, Menu, Result, Spin } from 'antd';
|
||||
import { AppstoreOutlined, ArrowLeftOutlined, DatabaseOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
AppstoreOutlined,
|
||||
ArrowLeftOutlined,
|
||||
CalculatorOutlined,
|
||||
DatabaseOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useSystemAdminAccess } from '@/services/useSystemAdminAccess';
|
||||
import './SystemAdminLayout.css';
|
||||
@@ -12,12 +17,21 @@ export default function SystemAdminLayout() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
if (access === 'checking') {
|
||||
return <div className="system-admin-gate"><Spin description="正在验证系统管理员权限…" /></div>;
|
||||
return (
|
||||
<div className="system-admin-gate">
|
||||
<Spin description="正在验证系统管理员权限…" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (access === 'denied') {
|
||||
return <Result status="403" title="无权访问系统管理" subTitle="请联系系统维护者授予权限" extra={
|
||||
<Button onClick={() => navigate('/online-products')}>返回工作台</Button>
|
||||
} />;
|
||||
return (
|
||||
<Result
|
||||
status="403"
|
||||
title="无权访问系统管理"
|
||||
subTitle="请联系系统维护者授予权限"
|
||||
extra={<Button onClick={() => navigate('/online-products')}>返回工作台</Button>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Layout className="system-admin-layout">
|
||||
@@ -28,24 +42,45 @@ export default function SystemAdminLayout() {
|
||||
<Menu
|
||||
theme="dark"
|
||||
mode="inline"
|
||||
selectedKeys={[location.pathname.startsWith('/system/platform-catalog')
|
||||
? '/system/platform-catalog' : location.pathname]}
|
||||
items={[{
|
||||
key: '/system/platform-catalog',
|
||||
icon: <DatabaseOutlined />,
|
||||
label: '平台类目与属性',
|
||||
}]}
|
||||
selectedKeys={[
|
||||
location.pathname.startsWith('/system/platform-catalog')
|
||||
? '/system/platform-catalog'
|
||||
: location.pathname,
|
||||
]}
|
||||
items={[
|
||||
{
|
||||
key: '/system/platform-catalog',
|
||||
icon: <DatabaseOutlined />,
|
||||
label: '平台类目与属性',
|
||||
},
|
||||
{
|
||||
key: '/system/pricing-rates',
|
||||
icon: <CalculatorOutlined />,
|
||||
label: '物流费率审核',
|
||||
},
|
||||
]}
|
||||
onClick={({ key }) => navigate(key)}
|
||||
/>
|
||||
</Sider>
|
||||
<Layout>
|
||||
<Header className="system-admin-header">
|
||||
<span>系统管理 / 平台类目与属性</span>
|
||||
<Button type="text" icon={<ArrowLeftOutlined />} onClick={() => navigate('/online-products')}>
|
||||
<span>
|
||||
系统管理 /{' '}
|
||||
{location.pathname.startsWith('/system/pricing-rates')
|
||||
? '物流费率审核'
|
||||
: '平台类目与属性'}
|
||||
</span>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => navigate('/online-products')}
|
||||
>
|
||||
返回工作台
|
||||
</Button>
|
||||
</Header>
|
||||
<Content className="system-admin-content"><Outlet /></Content>
|
||||
<Content className="system-admin-content">
|
||||
<Outlet />
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,451 @@
|
||||
import PagePlaceholder from '@/components/PagePlaceholder';
|
||||
|
||||
/** 定价工具:独立试算与物流计价规则(业务模块尚未实现) */
|
||||
const PricingPage = () => (
|
||||
<PagePlaceholder title="定价工具" description="独立试算与物流计价规则,模块开发中。" />
|
||||
);
|
||||
|
||||
export default PricingPage;
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
App,
|
||||
Button,
|
||||
Card,
|
||||
Empty,
|
||||
Input,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import type {
|
||||
PricingCatalog,
|
||||
PricingCategory,
|
||||
PricingPolicy,
|
||||
PricingPolicyRequest,
|
||||
Store,
|
||||
} from '@atlas/contracts';
|
||||
import {
|
||||
PricingCalculator,
|
||||
PricingParametersFields,
|
||||
defaultPricing,
|
||||
} from '@/components/pricing/PricingCalculator';
|
||||
import { pricingApi } from '@/services/pricing';
|
||||
import { storesApi } from '@/services/stores';
|
||||
import { usePlatform } from '@/services/platform';
|
||||
import { apiErrorMessage } from '@/services/api';
|
||||
import { useSystemAdminAccess } from '@/services/useSystemAdminAccess';
|
||||
import { rateToPercent } from '@/components/pricing/pricingPercentage';
|
||||
export default function PricingPage() {
|
||||
const { currentPlatform, context } = usePlatform(),
|
||||
{ message } = App.useApp();
|
||||
const navigate = useNavigate();
|
||||
const adminAccess = useSystemAdminAccess();
|
||||
const [stores, setStores] = useState<Store[]>([]),
|
||||
[store, setStore] = useState<string | null>(null),
|
||||
[catalog, setCatalog] = useState<PricingCatalog | null>(null),
|
||||
[error, setError] = useState(''),
|
||||
[categories, setCategories] = useState<PricingCategory[]>([]),
|
||||
[category, setCategory] = useState<string | null>(null),
|
||||
[query, setQuery] = useState(''),
|
||||
[editing, setEditing] = useState<{
|
||||
policy: PricingPolicy | null;
|
||||
input: PricingPolicyRequest;
|
||||
} | null>(null),
|
||||
[saving, setSaving] = useState(false);
|
||||
const request = useRef(0);
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setStore(null);
|
||||
setCatalog(null);
|
||||
if (currentPlatform)
|
||||
void (async () => {
|
||||
let cursor: string | undefined;
|
||||
const all: Store[] = [];
|
||||
do {
|
||||
const r = await storesApi.list(currentPlatform.id, cursor);
|
||||
all.push(...r.data);
|
||||
cursor = r.meta.next_cursor ?? undefined;
|
||||
} while (cursor);
|
||||
if (active) {
|
||||
setStores(all);
|
||||
setStore(
|
||||
all.find((s) => s.id === context?.default_store?.id)?.id ??
|
||||
all[0]?.id ??
|
||||
null
|
||||
);
|
||||
}
|
||||
})().catch((e) => {
|
||||
if (active) setError(apiErrorMessage(e));
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [currentPlatform?.id]);
|
||||
const reload = async () => {
|
||||
if (store) setCatalog(await pricingApi.catalog(store));
|
||||
};
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setCatalog(null);
|
||||
setCategory(null);
|
||||
setError('');
|
||||
if (store)
|
||||
void pricingApi
|
||||
.catalog(store)
|
||||
.then((c) => {
|
||||
if (active) setCatalog(c);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (active) setError(apiErrorMessage(e));
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [store]);
|
||||
useEffect(() => {
|
||||
const seq = ++request.current;
|
||||
setCategories([]);
|
||||
if (!store) return;
|
||||
const timer = setTimeout(
|
||||
() =>
|
||||
void pricingApi
|
||||
.categories(store, query)
|
||||
.then((r) => {
|
||||
if (request.current === seq) setCategories(r);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (request.current === seq) setError(apiErrorMessage(e));
|
||||
}),
|
||||
250
|
||||
);
|
||||
return () => clearTimeout(timer);
|
||||
}, [store, query]);
|
||||
const parameters = catalog ? defaultPricing(catalog) : null;
|
||||
const rateCardNotice =
|
||||
catalog && !parameters ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
title="当前平台没有可用于正式试算的物流费率"
|
||||
description={
|
||||
<Space orientation="vertical" size="small">
|
||||
{catalog.pending_rate_cards.length > 0 && (
|
||||
<span>
|
||||
待复核:
|
||||
{catalog.pending_rate_cards
|
||||
.map((card) => `${card.name}(${card.version})`)
|
||||
.join('、')}
|
||||
</span>
|
||||
)}
|
||||
<span>
|
||||
待复核费率不能用于正式报价。系统管理员须在「系统管理 →
|
||||
物流费率审核」录入真实账单样本,复算通过后按生效日期启用。
|
||||
</span>
|
||||
{adminAccess === 'allowed' && (
|
||||
<Button
|
||||
type="link"
|
||||
style={{ padding: 0 }}
|
||||
onClick={() => navigate('/system/pricing-rates')}
|
||||
>
|
||||
前往物流费率审核
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
) : null;
|
||||
const mutate = async (fn: () => Promise<unknown>) => {
|
||||
try {
|
||||
await fn();
|
||||
await reload();
|
||||
} catch (e) {
|
||||
message.error(apiErrorMessage(e));
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Space orientation="vertical" size="large" style={{ width: '100%' }}>
|
||||
<div>
|
||||
<Typography.Title level={3}>定价工具</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
按门店方案试算,比较物流费用与预估净利润。
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Select
|
||||
aria-label="定价门店"
|
||||
placeholder="选择门店"
|
||||
style={{ width: 320 }}
|
||||
value={store}
|
||||
options={stores.map((s) => ({
|
||||
value: s.id,
|
||||
label: `${s.name} · ${s.currency_code}`,
|
||||
}))}
|
||||
onChange={setStore}
|
||||
/>
|
||||
{error && <Alert type="error" title={error} />}{' '}
|
||||
{!store && !error && <Empty description="请先创建目标门店" />}
|
||||
{catalog && (
|
||||
catalog.rate_cards.some((card) => card.development_only) && (
|
||||
<Alert
|
||||
type="warning"
|
||||
title="当前使用开发草稿费率"
|
||||
description="可用于方案、试算和商品编辑联调;按草稿费率确认的 SKU 不能首次上架,正式审核后需重新试算确认。"
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{catalog && (
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'trial',
|
||||
label: '即时试算',
|
||||
children: (
|
||||
<Card>
|
||||
<Space
|
||||
orientation="vertical"
|
||||
style={{ width: '100%' }}
|
||||
size="large"
|
||||
>
|
||||
<Select
|
||||
aria-label="试算商品类目"
|
||||
showSearch={{ filterOption: false, onSearch: setQuery }}
|
||||
style={{ width: '100%' }}
|
||||
placeholder="搜索商品类目"
|
||||
value={category}
|
||||
options={categories.map((c) => ({
|
||||
value: c.id,
|
||||
label: c.path || c.name,
|
||||
}))}
|
||||
onChange={setCategory}
|
||||
/>
|
||||
{!parameters ? (
|
||||
rateCardNotice
|
||||
) : !category ? (
|
||||
<Empty description="选择类目后开始试算" />
|
||||
) : (
|
||||
<PricingCalculator
|
||||
key={`${store}:${category}`}
|
||||
catalog={catalog}
|
||||
onPolicySaved={(policy) =>
|
||||
setCatalog(
|
||||
(current) =>
|
||||
current && {
|
||||
...current,
|
||||
policies: [
|
||||
...current.policies,
|
||||
policy,
|
||||
],
|
||||
}
|
||||
)
|
||||
}
|
||||
initial={{
|
||||
store_id: store!,
|
||||
platform_product_category_id: category,
|
||||
fulfillment: 'rfbs',
|
||||
binding: null,
|
||||
purchase_cost_cny: '',
|
||||
package: {
|
||||
shipping_weight_g: '',
|
||||
length_mm: '',
|
||||
width_mm: '',
|
||||
height_mm: '',
|
||||
status: 'confirmed',
|
||||
},
|
||||
pricing: parameters,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'policies',
|
||||
label: '定价方案',
|
||||
children: (
|
||||
<Card
|
||||
title="门店方案"
|
||||
extra={
|
||||
<Tooltip
|
||||
title={
|
||||
!parameters
|
||||
? '需要先启用经过审核的物流费率'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={!parameters}
|
||||
onClick={() =>
|
||||
setEditing({
|
||||
policy: null,
|
||||
input: {
|
||||
name: '',
|
||||
note: null,
|
||||
parameters: {
|
||||
...parameters!,
|
||||
pricing_policy_id: null,
|
||||
},
|
||||
allow_commission_override: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
新建方案
|
||||
</Button>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{rateCardNotice}
|
||||
<Table
|
||||
rowKey="id"
|
||||
dataSource={catalog.policies}
|
||||
pagination={false}
|
||||
columns={[
|
||||
{
|
||||
title: '方案',
|
||||
dataIndex: 'name',
|
||||
render: (name, p) => (
|
||||
<>
|
||||
{name}{' '}
|
||||
{p.is_default && (
|
||||
<Tag color="purple">默认</Tag>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '目标加价率',
|
||||
render: (_, p) =>
|
||||
`${rateToPercent(p.parameters.desired_markup_rate)}%`,
|
||||
},
|
||||
{
|
||||
title: '汇率',
|
||||
render: (_, p) =>
|
||||
p.parameters.fx.mode === 'policy_fixed'
|
||||
? p.parameters.fx.rate
|
||||
: 'CBR 系统汇率',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
render: (_, p) => (
|
||||
<Space>
|
||||
<Button
|
||||
type="link"
|
||||
onClick={() =>
|
||||
setEditing({
|
||||
policy: p,
|
||||
input: structuredClone({
|
||||
name: p.name,
|
||||
note: p.note,
|
||||
parameters: p.parameters,
|
||||
allow_commission_override:
|
||||
p.allow_commission_override,
|
||||
}),
|
||||
})
|
||||
}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
{!p.is_default && (
|
||||
<Button
|
||||
type="link"
|
||||
onClick={() =>
|
||||
void mutate(() =>
|
||||
pricingApi.defaultPolicy(
|
||||
p.id
|
||||
)
|
||||
)
|
||||
}
|
||||
>
|
||||
设为默认
|
||||
</Button>
|
||||
)}
|
||||
<Popconfirm
|
||||
title="归档此方案?历史报价仍会保留。"
|
||||
onConfirm={() =>
|
||||
mutate(() =>
|
||||
pricingApi.archivePolicy(p.id)
|
||||
)
|
||||
}
|
||||
>
|
||||
<Button danger type="link">
|
||||
归档
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
<Modal
|
||||
title={editing?.policy ? '编辑定价方案' : '新建定价方案'}
|
||||
open={!!editing}
|
||||
width={620}
|
||||
destroyOnHidden
|
||||
confirmLoading={saving}
|
||||
onCancel={() => setEditing(null)}
|
||||
onOk={async () => {
|
||||
if (!editing || !store) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editing.policy)
|
||||
await pricingApi.updatePolicy(editing.policy, editing.input);
|
||||
else await pricingApi.createPolicy(store, editing.input);
|
||||
await reload();
|
||||
setEditing(null);
|
||||
message.success('方案已保存');
|
||||
} catch (e) {
|
||||
message.error(apiErrorMessage(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{editing && catalog && (
|
||||
<Space orientation="vertical" style={{ width: '100%' }} size="middle">
|
||||
<Input
|
||||
aria-label="方案名称"
|
||||
placeholder="方案名称"
|
||||
value={editing.input.name}
|
||||
onChange={(e) =>
|
||||
setEditing({
|
||||
...editing,
|
||||
input: { ...editing.input, name: e.target.value },
|
||||
})
|
||||
}
|
||||
/>
|
||||
<PricingParametersFields
|
||||
policyEditing
|
||||
value={editing.input.parameters}
|
||||
catalog={{ ...catalog, policies: [] }}
|
||||
onChange={(parameters) =>
|
||||
setEditing({ ...editing, input: { ...editing.input, parameters } })
|
||||
}
|
||||
/>
|
||||
<Space>
|
||||
<Switch
|
||||
checked={editing.input.allow_commission_override}
|
||||
onChange={(v) =>
|
||||
setEditing({
|
||||
...editing,
|
||||
input: { ...editing.input, allow_commission_override: v },
|
||||
})
|
||||
}
|
||||
/>
|
||||
缺少正式规则时允许临时佣金
|
||||
</Space>
|
||||
<Typography.Text type="secondary">
|
||||
方案只保存费用、利润、汇率和运输偏好,不包含采购价与包装。修改不影响已确认报价。
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
)}
|
||||
</Modal>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -148,6 +148,8 @@ function formValues(snapshot: ProductEditorSnapshot): EditorFormValues {
|
||||
source_sku: variant.source_sku,
|
||||
specification_values: variant.specification_values,
|
||||
purchase_cost_cny: variant.purchase_cost_cny,
|
||||
pricing_draft: variant.listing.pricing_draft ?? null,
|
||||
pricing_selection: null,
|
||||
title: variant.title,
|
||||
tags: variant.tags,
|
||||
description: variant.description,
|
||||
@@ -801,6 +803,7 @@ const ProductEditPage = () => {
|
||||
const useVariantTitle = () => setUsesVariantTitle(true);
|
||||
/** 保存草稿:携带快照版本号做乐观锁;来源更新/版本冲突时重新加载合并结果。 */
|
||||
const save = async (values: EditorFormValues) => {
|
||||
values = { ...values, ...form.getFieldsValue(true) };
|
||||
if (!productId || !currentPlatform || !snapshot) return false;
|
||||
setSaving(true);
|
||||
try {
|
||||
@@ -824,7 +827,22 @@ const ProductEditPage = () => {
|
||||
await load();
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (apiErrorCode(error) === 'SOURCE_SNAPSHOT_CHANGED') {
|
||||
if (apiErrorCode(error) === 'PRICING_PREVIEW_STALE') {
|
||||
Modal.confirm({
|
||||
title: '报价已失效',
|
||||
content: apiErrorMessage(error),
|
||||
okText: '仅保存草稿,稍后定价',
|
||||
cancelText: '返回重新试算',
|
||||
onOk: async () => {
|
||||
const draft = {
|
||||
...values,
|
||||
variants: values.variants.map((v) => ({ ...v, pricing_selection: null })),
|
||||
};
|
||||
form.setFieldsValue(draft);
|
||||
await save(draft);
|
||||
},
|
||||
});
|
||||
} else if (apiErrorCode(error) === 'SOURCE_SNAPSHOT_CHANGED') {
|
||||
message.warning('采集来源已更新,已重新加载合并结果,请确认后再次保存');
|
||||
await load();
|
||||
} else if (apiErrorCode(error) === 'VERSION_CONFLICT') {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { SkuPricing } from '@/components/pricing/SkuPricing';
|
||||
import { Switch, Space as PricingSpace } from 'antd';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
@@ -166,6 +168,8 @@ export function ProductVariantSection({
|
||||
onCompareCopy,
|
||||
onImagesChanged,
|
||||
}: Props) {
|
||||
const [pricingAuto, setPricingAuto] = useState(() => localStorage.getItem('atlas.pricing.auto') !== 'false');
|
||||
const [pricingBatch, setPricingBatch] = useState(0);
|
||||
// 实时监听变体草稿,驱动已选图片和主图回显。
|
||||
const draftVariants = (Form.useWatch('variants', form) ?? []) as VariantValues[];
|
||||
// 展开状态按 Form.List 的 field.key(而非下标)记录,增删/复制 SKU 后不会错位。
|
||||
@@ -220,7 +224,7 @@ export function ProductVariantSection({
|
||||
number="03"
|
||||
title="变体设置"
|
||||
description="卡片用于快速核对与填写;展开后可维护图片和该 SKU 的包装覆盖。"
|
||||
extra={<Tag>{snapshot.variants.length} 个 SKU</Tag>}
|
||||
extra={<PricingSpace><span>自动试算</span><Switch checked={pricingAuto} onChange={value=>{setPricingAuto(value);localStorage.setItem('atlas.pricing.auto',String(value));}}/><Button onClick={()=>setPricingBatch(x=>x+1)}>按方案计算全部</Button><Tag>{snapshot.variants.length} 个 SKU</Tag></PricingSpace>}
|
||||
/>
|
||||
<Form.List name="variants">
|
||||
{(fields, { add, remove }) => {
|
||||
@@ -316,7 +320,8 @@ export function ProductVariantSection({
|
||||
...variantValue,
|
||||
id: null,
|
||||
client_key: crypto.randomUUID(),
|
||||
copy_from_variant_id: original?.id ?? null,
|
||||
copy_from_variant_id: null,
|
||||
pricing_selection: null,
|
||||
display_name: variantValue.display_name
|
||||
? `${variantValue.display_name} 副本`
|
||||
: 'SKU 副本',
|
||||
@@ -423,48 +428,7 @@ export function ProductVariantSection({
|
||||
<Input placeholder="首次上架前填写" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div className="sku-card-field sku-price-field">
|
||||
<span>采购价(CNY)</span>
|
||||
<Form.Item
|
||||
name={[field.name, 'purchase_cost_cny']}
|
||||
noStyle
|
||||
rules={[
|
||||
{
|
||||
pattern: /^\d+(\.\d{1,2})?$/,
|
||||
message: '请输入最多两位小数的金额',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<InputNumber<string>
|
||||
min="0"
|
||||
step="0.01"
|
||||
precision={2}
|
||||
stringMode
|
||||
placeholder="—"
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div className="sku-card-field sku-price-field">
|
||||
<span>
|
||||
销售价(
|
||||
{original?.listing.currency_code ?? '₽'}
|
||||
)
|
||||
</span>
|
||||
<InputNumber<string>
|
||||
value={
|
||||
original?.listing.selling_price ??
|
||||
''
|
||||
}
|
||||
placeholder="—"
|
||||
readOnly
|
||||
controls={false}
|
||||
stringMode
|
||||
/>
|
||||
</div>
|
||||
<div className="sku-card-value">
|
||||
<span>净利润</span>
|
||||
<strong>—</strong>
|
||||
</div>
|
||||
<SkuPricing form={form} index={field.name} snapshot={snapshot} auto={pricingAuto} batch={pricingBatch} onChanged={onImagesChanged} />
|
||||
<Button
|
||||
className="sku-expand-button"
|
||||
type="text"
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
App,
|
||||
Button,
|
||||
Card,
|
||||
Input,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import {
|
||||
pricingRateReviewRequestSchema,
|
||||
type PricingRateCardAdmin,
|
||||
type PricingRateReviewRequest,
|
||||
} from '@atlas/contracts';
|
||||
import { platformCatalogAdminApi, type AdminPlatform } from '@/services/platformCatalogAdmin';
|
||||
import { pricingAdminApi } from '@/services/pricingAdmin';
|
||||
import { apiErrorMessage } from '@/services/api';
|
||||
|
||||
type Sample = PricingRateReviewRequest['samples'][number];
|
||||
const emptySample = (ruleId: string): Sample => ({
|
||||
rule_id: ruleId,
|
||||
invoice_reference: '',
|
||||
merchandise_value_cny: '',
|
||||
shipping_weight_g: '',
|
||||
length_mm: '',
|
||||
width_mm: '',
|
||||
height_mm: '',
|
||||
billed_logistics_cny: '',
|
||||
});
|
||||
|
||||
export default function SystemPricingRatesPage() {
|
||||
const { message } = App.useApp();
|
||||
const [platforms, setPlatforms] = useState<AdminPlatform[]>([]);
|
||||
const [platformId, setPlatformId] = useState<string | null>(null);
|
||||
const [cards, setCards] = useState<PricingRateCardAdmin[]>([]);
|
||||
const [cardId, setCardId] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [reviewOpen, setReviewOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [sourceReference, setSourceReference] = useState('');
|
||||
const [reviewNote, setReviewNote] = useState('');
|
||||
const [effectiveFrom, setEffectiveFrom] = useState('');
|
||||
const [samples, setSamples] = useState<Record<string, Sample>>({});
|
||||
const approvalAttempt = useRef<{ signature: string; key: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void platformCatalogAdminApi
|
||||
.platforms()
|
||||
.then((items) => {
|
||||
setPlatforms(items);
|
||||
setPlatformId(
|
||||
items.find((item) => item.code.toUpperCase() === 'OZON')?.id ??
|
||||
items[0]?.id ??
|
||||
null
|
||||
);
|
||||
})
|
||||
.catch((cause) => setError(apiErrorMessage(cause)));
|
||||
}, []);
|
||||
const reload = async (id: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await pricingAdminApi.cards(id);
|
||||
setCards(result);
|
||||
setCardId((current) =>
|
||||
result.some((card) => card.id === current) ? current : (result[0]?.id ?? null)
|
||||
);
|
||||
setError('');
|
||||
} catch (cause) {
|
||||
setError(apiErrorMessage(cause));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
if (platformId) void reload(platformId);
|
||||
}, [platformId]);
|
||||
const card = cards.find((item) => item.id === cardId) ?? null;
|
||||
const beginReview = () => {
|
||||
if (!card) return;
|
||||
setSourceReference('');
|
||||
approvalAttempt.current = null;
|
||||
setReviewNote('');
|
||||
setEffectiveFrom('');
|
||||
setSamples(
|
||||
Object.fromEntries(
|
||||
card.types.map((type) => [
|
||||
type.id,
|
||||
emptySample(
|
||||
card.rules.find((rule) => rule.product_type_id === type.id)?.id ?? ''
|
||||
),
|
||||
])
|
||||
)
|
||||
);
|
||||
setReviewOpen(true);
|
||||
};
|
||||
const updateSample = (typeId: string, patch: Partial<Sample>) =>
|
||||
setSamples((current) => ({ ...current, [typeId]: { ...current[typeId], ...patch } }));
|
||||
const approve = async () => {
|
||||
if (!card || !platformId) return;
|
||||
const date = new Date(effectiveFrom);
|
||||
if (!Number.isFinite(date.getTime())) {
|
||||
message.error('请选择费率生效日期和时间');
|
||||
return;
|
||||
}
|
||||
const parsed = pricingRateReviewRequestSchema.safeParse({
|
||||
rules_hash: card.rules_hash,
|
||||
source_reference: sourceReference,
|
||||
review_note: reviewNote,
|
||||
effective_from: date.toISOString(),
|
||||
samples: card.types.map((type) => samples[type.id]),
|
||||
});
|
||||
if (!parsed.success) {
|
||||
message.error('请填写来源、至少 20 字复核说明,以及每种产品类型的完整账单样本');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const signature = JSON.stringify(parsed.data);
|
||||
if (approvalAttempt.current?.signature !== signature)
|
||||
approvalAttempt.current = { signature, key: crypto.randomUUID() };
|
||||
await pricingAdminApi.approve(card.id, parsed.data, approvalAttempt.current.key);
|
||||
await reload(platformId);
|
||||
setReviewOpen(false);
|
||||
message.success('账单样本复算通过,费率卡已按生效日期启用');
|
||||
} catch (cause) {
|
||||
message.error(apiErrorMessage(cause));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Space orientation="vertical" size="large" style={{ width: '100%' }}>
|
||||
<div>
|
||||
<Typography.Title level={3}>物流费率审核</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
查看平台资费版本,用实际物流账单逐类型复算后启用。审核记录会保留来源、样本和审核人。
|
||||
</Typography.Text>
|
||||
</div>
|
||||
{error && <Alert type="error" title={error} />}
|
||||
<Space wrap>
|
||||
<Select
|
||||
aria-label="费率平台"
|
||||
style={{ width: 240 }}
|
||||
value={platformId}
|
||||
onChange={setPlatformId}
|
||||
options={platforms.map((platform) => ({
|
||||
value: platform.id,
|
||||
label: platform.name,
|
||||
}))}
|
||||
/>
|
||||
<Select
|
||||
aria-label="费率版本"
|
||||
style={{ minWidth: 360 }}
|
||||
value={cardId}
|
||||
onChange={setCardId}
|
||||
loading={loading}
|
||||
options={cards.map((item) => ({
|
||||
value: item.id,
|
||||
label: `${item.source_name} · ${item.version} · ${item.status === 'draft' ? '待审核' : '已启用'}`,
|
||||
}))}
|
||||
/>
|
||||
<Button onClick={() => platformId && void reload(platformId)}>刷新</Button>
|
||||
</Space>
|
||||
{!card && !loading && <Alert type="info" title="当前平台没有物流费率卡" />}
|
||||
{card && (
|
||||
<>
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
{card.source_name}
|
||||
<Tag color={card.status === 'active' ? 'green' : 'orange'}>
|
||||
{card.status === 'active' ? '已启用' : '待审核'}
|
||||
</Tag>
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
card.status === 'draft' && (
|
||||
<Button type="primary" onClick={beginReview}>
|
||||
录入账单并审核启用
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
<Space orientation="vertical">
|
||||
<span>
|
||||
版本:{card.version} 服务商:{card.provider_code} 生效时间:
|
||||
{new Date(card.effective_from).toLocaleString()}
|
||||
</span>
|
||||
<span>
|
||||
产品类型 {card.types.length} 种,运价规则 {card.rules.length}{' '}
|
||||
条。已审核版本的规则变更应发布新版本。
|
||||
</span>
|
||||
{card.review && (
|
||||
<span>
|
||||
审核人:{card.reviewer_name ?? '已注销用户'} 审核时间:
|
||||
{new Date(card.review.reviewed_at).toLocaleString()} 证据:
|
||||
{card.review.source_reference}
|
||||
</span>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
<Card title="产品类型与适用范围">
|
||||
<Table
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
size="small"
|
||||
dataSource={card.types}
|
||||
scroll={{ x: 1450 }}
|
||||
columns={[
|
||||
{ title: '类型', dataIndex: 'code' },
|
||||
{
|
||||
title: '货值 CNY',
|
||||
render: (_, row) =>
|
||||
`${row.min_merchandise_value !== null && row.min_value_inclusive ? '[' : '('}${row.min_merchandise_value ?? '−∞'}, ${row.max_merchandise_value ?? '+∞'}${row.max_merchandise_value !== null && row.max_value_inclusive ? ']' : ')'}`,
|
||||
},
|
||||
{
|
||||
title: '实际重量 kg',
|
||||
render: (_, row) =>
|
||||
`${row.min_actual_weight_inclusive ? '[' : '('}${row.min_actual_weight_kg}, ${row.max_actual_weight_kg ?? '不限'}]`,
|
||||
},
|
||||
{
|
||||
title: '尺寸上限 cm(三边和 / 最长 / 次长)',
|
||||
render: (_, row) =>
|
||||
`${row.max_sum_dimensions_cm ?? '不限'} / ${row.max_longest_side_cm ?? '不限'} / ${row.max_second_longest_side_cm ?? '不限'}`,
|
||||
},
|
||||
{
|
||||
title: '体积重与进位',
|
||||
render: (_, row) =>
|
||||
row.billing_basis === 'actual_only'
|
||||
? `仅实重;计费重向上取 ${row.billing_round_scale ?? '原值'} 位`
|
||||
: `实重/体积取大;除数 ${row.volume_divisor};体积重向上取 ${row.volume_round_scale ?? '原值'} 位;计费重向上取 ${row.billing_round_scale ?? '原值'} 位`,
|
||||
},
|
||||
{
|
||||
title: '体积重资格上限 kg',
|
||||
render: (_, row) =>
|
||||
row.max_volume_weight_kg
|
||||
? `${row.max_volume_weight_kg}(先向上取 ${row.volume_limit_round_scale ?? '原值'} 位)`
|
||||
: '不限',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
<Card title="运输规则(CNY)">
|
||||
<Table
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
size="small"
|
||||
dataSource={card.rules}
|
||||
scroll={{ x: 850 }}
|
||||
columns={[
|
||||
{ title: '产品类型', dataIndex: 'product_type_code' },
|
||||
{ title: '运输方式', dataIndex: 'transport_method_name' },
|
||||
{ title: '每票固定费', dataIndex: 'fixed_fee_cny' },
|
||||
{ title: '每计费 kg', dataIndex: 'per_billable_kg_fee_cny' },
|
||||
{
|
||||
title: '最低收费',
|
||||
render: (_, row) => row.min_charge_cny ?? '—',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
render: (_, row) =>
|
||||
row.status === 'draft' ? '待审核' : '已启用',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
{card.review && (
|
||||
<Card title="审核证据与样本">
|
||||
<Typography.Paragraph>{card.review.review_note}</Typography.Paragraph>
|
||||
<Table
|
||||
rowKey="invoice_reference"
|
||||
pagination={false}
|
||||
size="small"
|
||||
dataSource={card.review.samples}
|
||||
scroll={{ x: 900 }}
|
||||
columns={[
|
||||
{ title: '账单编号', dataIndex: 'invoice_reference' },
|
||||
{
|
||||
title: '规则',
|
||||
render: (_, sample) => {
|
||||
const rule = card.rules.find(
|
||||
(item) => item.id === sample.rule_id
|
||||
);
|
||||
return rule
|
||||
? `${rule.product_type_code} · ${rule.transport_method_name}`
|
||||
: '历史规则';
|
||||
},
|
||||
},
|
||||
{ title: '货值 CNY', dataIndex: 'merchandise_value_cny' },
|
||||
{ title: '重量 g', dataIndex: 'shipping_weight_g' },
|
||||
{
|
||||
title: '包装 mm',
|
||||
render: (_, sample) =>
|
||||
`${sample.length_mm} × ${sample.width_mm} × ${sample.height_mm}`,
|
||||
},
|
||||
{ title: '账单物流费 CNY', dataIndex: 'billed_logistics_cny' },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Modal
|
||||
title="用真实账单复核并启用费率"
|
||||
open={reviewOpen}
|
||||
width={1050}
|
||||
styles={{ body: { maxHeight: '65vh', overflowY: 'auto' } }}
|
||||
confirmLoading={saving}
|
||||
okText="复算通过后启用"
|
||||
onOk={() => void approve()}
|
||||
onCancel={() => setReviewOpen(false)}
|
||||
>
|
||||
<Space orientation="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Alert
|
||||
type="warning"
|
||||
title="此操作会使费率参与正式报价"
|
||||
description="至少为每种产品类型录入一条实际账单。账单金额请换算为 CNY 跨境物流费,服务端按当前规则复算,差额须不超过 0.01 CNY。账单真实性由审核人确认。"
|
||||
/>
|
||||
<Input
|
||||
aria-label="来源与账单批次"
|
||||
placeholder="资费源表、合同或账单批次编号"
|
||||
value={sourceReference}
|
||||
onChange={(event) => setSourceReference(event.target.value)}
|
||||
/>
|
||||
<Input
|
||||
aria-label="生效时间"
|
||||
type="datetime-local"
|
||||
value={effectiveFrom}
|
||||
onChange={(event) => setEffectiveFrom(event.target.value)}
|
||||
/>
|
||||
<Input.TextArea
|
||||
aria-label="复核说明"
|
||||
placeholder="说明账单来源、口径、适用市场和复核结论(至少 20 字)"
|
||||
rows={2}
|
||||
value={reviewNote}
|
||||
onChange={(event) => setReviewNote(event.target.value)}
|
||||
/>
|
||||
{card?.types.map((type) => {
|
||||
const sample = samples[type.id];
|
||||
if (!sample) return null;
|
||||
const fields: Array<[keyof Sample, string]> = [
|
||||
['invoice_reference', '账单编号'],
|
||||
['merchandise_value_cny', '货值 CNY'],
|
||||
['shipping_weight_g', '重量 g'],
|
||||
['length_mm', '长 mm'],
|
||||
['width_mm', '宽 mm'],
|
||||
['height_mm', '高 mm'],
|
||||
['billed_logistics_cny', '账单物流费 CNY'],
|
||||
];
|
||||
return (
|
||||
<Card key={type.id} size="small" title={`${type.code} · 账单样本`}>
|
||||
<Space wrap>
|
||||
<Select
|
||||
aria-label={`${type.code} 运输规则`}
|
||||
style={{ width: 190 }}
|
||||
value={sample.rule_id}
|
||||
options={card.rules
|
||||
.filter((rule) => rule.product_type_id === type.id)
|
||||
.map((rule) => ({
|
||||
value: rule.id,
|
||||
label: rule.transport_method_name,
|
||||
}))}
|
||||
onChange={(value) =>
|
||||
updateSample(type.id, { rule_id: value })
|
||||
}
|
||||
/>
|
||||
{fields.map(([field, label]) => (
|
||||
<Input
|
||||
key={field}
|
||||
aria-label={`${type.code} ${label}`}
|
||||
placeholder={label}
|
||||
style={{
|
||||
width: field === 'invoice_reference' ? 210 : 135,
|
||||
}}
|
||||
value={sample[field]}
|
||||
onChange={(event) =>
|
||||
updateSample(type.id, {
|
||||
[field]: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Space>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Space>
|
||||
</Modal>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { PlatformProvider } from '@/services/platform';
|
||||
import { useSession } from '@/services/session';
|
||||
import SystemAdminLayout from '@/layouts/SystemAdminLayout';
|
||||
import SystemCatalogPage from '@/pages/system/SystemCatalogPage';
|
||||
import SystemPricingRatesPage from '@/pages/system/SystemPricingRatesPage';
|
||||
|
||||
/** 主布局守卫:会话恢复中显示启动屏;未认证跳登录页并记住来源路径 */
|
||||
function RequireSession({ children }: { children: ReactNode }) {
|
||||
@@ -75,10 +76,15 @@ export const router = createBrowserRouter([
|
||||
},
|
||||
{
|
||||
path: '/system',
|
||||
element: <RequireSession><SystemAdminLayout /></RequireSession>,
|
||||
element: (
|
||||
<RequireSession>
|
||||
<SystemAdminLayout />
|
||||
</RequireSession>
|
||||
),
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="/system/platform-catalog" replace /> },
|
||||
{ path: 'platform-catalog', element: <SystemCatalogPage /> },
|
||||
{ path: 'pricing-rates', element: <SystemPricingRatesPage /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type {
|
||||
PricingCatalog,
|
||||
PricingCategory,
|
||||
PricingPolicy,
|
||||
PricingPolicyRequest,
|
||||
PricingPreview,
|
||||
PricingPreviewRequest,
|
||||
} from '@atlas/contracts';
|
||||
import { api } from './api';
|
||||
const headers = () => ({ 'Idempotency-Key': crypto.randomUUID() });
|
||||
export const pricingApi = {
|
||||
categories: (store: string, q: string) =>
|
||||
api.get<PricingCategory[]>('/pricing/categories', { params: { store_id: store, q } }),
|
||||
catalog: (store: string) =>
|
||||
api.get<PricingCatalog>('/pricing/catalog', { params: { store_id: store } }),
|
||||
preview: (input: PricingPreviewRequest, signal?: AbortSignal) =>
|
||||
api.post<PricingPreview>('/pricing/quote-previews', input, { headers: headers(), signal }),
|
||||
createPolicy: (store: string, input: PricingPolicyRequest) =>
|
||||
api.post<PricingPolicy>(`/stores/${store}/pricing-policies`, input, { headers: headers() }),
|
||||
updatePolicy: (policy: PricingPolicy, input: PricingPolicyRequest) =>
|
||||
api.patch<PricingPolicy>(`/pricing-policies/${policy.id}`, input, {
|
||||
headers: { ...headers(), 'If-Match': `"${policy.revision}"` },
|
||||
}),
|
||||
defaultPolicy: (id: string) =>
|
||||
api.post<PricingPolicy>(`/pricing-policies/${id}/set-default`, {}, { headers: headers() }),
|
||||
archivePolicy: (id: string) => api.delete(`/pricing-policies/${id}`, { headers: headers() }),
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { PricingRateCardAdmin, PricingRateReviewRequest } from '@atlas/contracts';
|
||||
import { api } from './api';
|
||||
|
||||
export const pricingAdminApi = {
|
||||
cards: (platformId: string) =>
|
||||
api.get<PricingRateCardAdmin[]>('/system/pricing/rate-cards', {
|
||||
params: { platform_id: platformId },
|
||||
}),
|
||||
approve: (cardId: string, input: PricingRateReviewRequest, idempotencyKey: string) =>
|
||||
api.post<PricingRateCardAdmin>(`/system/pricing/rate-cards/${cardId}/reviews`, input, {
|
||||
headers: { 'Idempotency-Key': idempotencyKey },
|
||||
}),
|
||||
};
|
||||
Reference in New Issue
Block a user