feat(pricing): 开发定价方案

This commit is contained in:
R524809
2026-09-28 17:34:33 +08:00
parent a29dd92676
commit 84b230e69a
54 changed files with 6264 additions and 236 deletions
@@ -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;
}
+50 -15
View File
@@ -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>
);
+451 -8
View File
@@ -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>
);
}
+7 -1
View File
@@ -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 /> },
],
},
{
+27
View File
@@ -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() }),
};
+13
View File
@@ -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 },
}),
};