价格增加百分比上浮

This commit is contained in:
liu
2026-08-06 14:52:56 +08:00
parent 07b08c8915
commit c6f69c5c55
19 changed files with 958 additions and 93 deletions
+7 -2
View File
@@ -9,7 +9,10 @@ export interface PriceMatrixParams {
pageSize?: number;
}
/** A2 价格矩阵:行=商品,列=启用等级,值=price(缺失 null */
/**
* A2 价格矩阵:行=商品,列=启用等级,值=实际销售价(缺失 null);
* 行内含 cost_price 与 price_type_{level_id} / percent_{level_id}
*/
export async function getPriceMatrix(params?: PriceMatrixParams) {
return createAxios<IPriceMatrix>({
url: '/product/goods/priceMatrix',
@@ -18,7 +21,9 @@ export async function getPriceMatrix(params?: PriceMatrixParams) {
});
}
/** A2 批量调价(提交后给受影响门店生成价格变更通知) */
/**
* A2 批量调价:支持成本价 / 固定价 / 成本百分比三类更新行(提交后给受影响门店生成价格变更通知)
*/
export async function batchPrice(updates: IBatchPriceUpdate[]) {
return createAxios({
url: '/product/goods/batchPrice',
@@ -54,12 +54,13 @@ const ImageUploader: React.FC<ImageUploaderProps> = ({
const valueArray = Array.isArray(value) ? value : [value];
// changeType='id' 时表单值为文件 id:拉取文件信息回显;
if (changeType === 'id') {
const ids = valueArray as number[];
const ids = valueArray.map(i => Number(i));
const oldFileList = fileList.filter(i => ids.includes(Number(i.uid)))
const idSet = new Set(fileList.map(obj => Number(obj.uid)));
const idSet = new Set(oldFileList.map(obj => Number(obj.uid)));
// 过滤出不在 Set 中的数字
const missing = ids.filter(num => !idSet.has(Number(num)));
const missing = ids.filter(num => !idSet.has(num));
if (missing.length > 0) {
const fetchers = missing.map((id) => getFileInfo(id));
@@ -68,8 +69,10 @@ const ImageUploader: React.FC<ImageUploaderProps> = ({
.map((res) => res.data.data)
.filter(i => !!i)
.map(fileToList);
setFileList([...files, ...fileList]);
setFileList([...files, ...oldFileList]);
})
} else {
setFileList(oldFileList);
}
} else {
const newFileList: UploadFile[] = (valueArray as ISysFileInfo[]).map(fileToList);
+32 -5
View File
@@ -2,12 +2,19 @@ import type IProductCategory from '@/domain/iProductCategory.ts';
import type ISupplier from '@/domain/iSupplier.ts';
import type {ISysFileInfo} from "@/domain/iSysFile.ts";
/** 商品等级价格行 */
/** 商品等级价格行(null = 该等级未设定价格,编辑时移除该行) */
export interface IProductPrice {
id?: number;
product_id?: number;
level_id?: number;
price?: string | number;
/** 单价:固定价=实际单价;成本百分比=等价固定价 */
price?: string | number | null;
/** 计价类型:0 固定价(默认) 1 成本百分比 */
price_type?: number;
/** 成本上浮百分点(price_type=1 时生效,如 30 = 上浮 30% */
percent?: string | number | null;
/** 实际销售价(模型换算:百分比 = 成本价 × (100 + percent) / 100 */
actual_price?: string | number;
level?: { id: number; name: string };
}
@@ -38,6 +45,8 @@ export default interface IProduct {
stock?: number;
/** 状态 */
status?: number;
/** 成本价(元,成本百分比计价的基数) */
cost_price?: string | number;
/** 描述 */
remark?: string;
/** 分类关联数据 */
@@ -55,12 +64,16 @@ export const PRODUCT_STATUS_MAP: Record<number, { text: string; color: string }>
1: { text: '上架', color: 'success' },
};
/** 价格矩阵行(price_{level_id} 动态列) */
/**
* 价格矩阵行:固定列 + price_{level_id}(实际价)/ price_type_{level_id} / percent_{level_id} 动态列
*/
export type IPriceMatrixRow = {
id: number;
name: string;
spec?: string;
unit?: string;
/** 成本价(null = 未设置或已清除) */
cost_price?: string | number | null;
} & Record<string, string | number | null | undefined>;
export interface IPriceMatrix {
@@ -69,8 +82,22 @@ export interface IPriceMatrix {
total: number;
}
/**
* 批量调价更新行(三类,可混合同一行):
* - 成本行 { product_id, cost_price }
* - 固定价行 { product_id, level_id, price_type: 0, price }
* - 百分比行 { product_id, level_id, price_type: 1, percent }price 可选,等价固定价)
*/
export interface IBatchPriceUpdate {
product_id: number;
level_id: number;
price: number | string;
/** 成本价(成本行) */
cost_price?: number;
/** 客户等级ID(等级价格行) */
level_id?: number;
/** 计价类型:0 固定价(默认) 1 成本百分比 */
price_type?: number;
/** 固定价 / 百分比行的等价固定价 */
price?: number;
/** 成本上浮百分点(price_type=1 时必填) */
percent?: number;
}
+222 -38
View File
@@ -6,6 +6,7 @@ import {
Input,
InputNumber,
message,
Select,
Space,
Table,
Tag, Tree,
@@ -29,14 +30,27 @@ import { batchPrice, getPriceMatrix } from '@/api/product/goods.ts';
const { Title, Text } = Typography;
/** 计价类型:0 固定价 / 1 成本百分比(与后端 ProductPriceModel::PRICE_TYPE_* 一致) */
const PRICE_TYPE_FIXED = 0;
const PRICE_TYPE_PERCENT = 1;
const PRICE_TYPE_OPTIONS = [
{ value: PRICE_TYPE_FIXED, label: '固定价' },
{ value: PRICE_TYPE_PERCENT, label: '成本百分比' },
];
/** 四舍五入保留两位 */
const round2 = (v: number) => Math.round(v * 100) / 100;
/**
* 等级价格表单
* 等级价格表单:每个等级 = 计价类型(固定价/成本百分比)+ 对应输入框;
* 切换计价类型时按成本价自动换算(固定→百分比:percent=(price/cost-1)*100;百分比→固定:price=cost*(1+percent/100)
*/
const LevelPriceFields: React.FC<{
form: FormInstance;
levels: ICustomerLevel[];
}> = ({ form, levels }) => {
const prices = Form.useWatch<IProductPrice[]>('prices', form) ?? [];
const costPrice = Number(Form.useWatch<string | number | undefined>('cost_price', form) ?? 0);
if (levels.length === 0) {
return (
@@ -46,30 +60,92 @@ const LevelPriceFields: React.FC<{
);
}
const setLevelPrice = (levelId: number, price: number | null) => {
const updateRow = (levelId: number, patch: Partial<IProductPrice>) => {
const next = prices.filter((p) => p.level_id !== levelId);
if (price !== null) {
next.push({ level_id: levelId, price });
}
next.push({ ...patch, level_id: levelId });
form.setFieldValue('prices', next);
};
const removeRow = (levelId: number) => {
form.setFieldValue('prices', prices.filter((p) => p.level_id !== levelId));
};
/** 切换计价类型:按成本价自动换算(成本未设置时百分比计价不可用) */
const onTypeChange = (levelId: number, row: IProductPrice | undefined, type: number) => {
if (type === PRICE_TYPE_PERCENT) {
if (!(costPrice > 0)) {
message.warning('请先在商品信息中设置成本价,才能按成本百分比计价');
return; // Select 为受控组件,未更新表单值即回弹
}
const price = Number(row?.price ?? 0);
updateRow(levelId, {
price_type: type,
percent: price > 0 ? round2((price / costPrice - 1) * 100) : null,
});
} else {
const percent = Number(row?.percent ?? 0);
updateRow(levelId, {
price_type: type,
price: costPrice > 0 && percent > 0 ? round2(costPrice * (1 + percent / 100)) : (row?.price ?? null),
});
}
};
return (
<Space wrap>
{levels.map((level) => {
if (level.id == null) return null;
const row = prices.find((p) => p.level_id === level.id);
const type = row?.price_type ?? PRICE_TYPE_FIXED;
return (
<InputNumber
min={0}
precision={2}
prefix={<span style={{ color: '#666' }}>{level.name}</span>}
suffix={'¥'}
placeholder="未设定"
value={(row?.price as number | null) ?? null}
onChange={(v) => setLevelPrice(level.id as number, v)}
style={{ width: 240 }}
/>
<Space key={level.id} wrap={false}>
<Text style={{ width: 60, display: 'inline-block', textAlign: 'right' }}>{level.name}</Text>
<Select
size="middle"
style={{ width: 110 }}
value={type}
options={PRICE_TYPE_OPTIONS}
onChange={(v) => onTypeChange(level.id as number, row, v as number)}
/>
{type === PRICE_TYPE_PERCENT ? (
<InputNumber
min={0}
precision={0}
suffix={'%'}
placeholder="上浮百分点"
value={(row?.percent as number | null) ?? null}
onChange={(v) => {
if (v === null) {
removeRow(level.id as number);
return;
}
// 同步维护等价固定价 price,保证提交给后端的 price 与 percent 一致
updateRow(level.id as number, {
price_type: PRICE_TYPE_PERCENT,
percent: v,
price: costPrice > 0 ? round2(costPrice * (1 + v / 100)) : row?.price,
});
}}
style={{ width: 160 }}
/>
) : (
<InputNumber
min={0}
precision={2}
suffix={'¥'}
placeholder="未设定"
value={(row?.price as number | null) ?? null}
onChange={(v) => {
if (v === null) {
removeRow(level.id as number);
return;
}
updateRow(level.id as number, { price_type: PRICE_TYPE_FIXED, price: v });
}}
style={{ width: 160 }}
/>
)}
</Space>
);
})}
</Space>
@@ -100,8 +176,12 @@ const ProductGoodsPage: React.FC = () => {
const [matrixLevels, setMatrixLevels] = useState<ICustomerLevel[]>([]);
const [matrixKeyword, setMatrixKeyword] = useState('');
const [matrixCategory, setMatrixCategory] = useState<number | undefined>(undefined);
/** 跨页未保存的调价:`${productId}:${levelId}` → price(用 ref 避免异步闭包读到旧值) */
const matrixDirtyRef = useRef<Record<string, number | null>>({});
/** 跨页未保存的等级调价:`${productId}:${levelId}` → { price, price_type }(用 ref 避免异步闭包读到旧值) */
const matrixDirtyRef = useRef<Record<string, { price: number | null; price_type: number }>>({});
/** 跨页未保存的成本价:productId → costnull 视为未修改,不提交) */
const costDirtyRef = useRef<Record<number, number | null>>({});
/** 服务端原始成本价:productId → costloadMatrix 填充;跨页百分比行保存时反算 percent 用) */
const serverCostRef = useRef<Record<number, number>>({});
useEffect(() => {
getLevelOptions().then((res) => setLevels(res.data.data ?? []));
@@ -124,14 +204,22 @@ const ProductGoodsPage: React.FC = () => {
pageSize,
});
const rows = res.data.data?.rows ?? [];
// 服务端原始成本价快照(供跨页百分比行保存时反算 percent)
rows.forEach((row) => {
serverCostRef.current[row.id] = Number(row.cost_price ?? 0);
});
// 叠加跨页未保存的修改,保证翻页后输入值不回退
const dirty = matrixDirtyRef.current;
const merged = rows.map((row) => {
const next = { ...row };
Object.keys(dirty).forEach((key) => {
// 成本价修改
if (costDirtyRef.current[row.id] !== undefined) {
next.cost_price = costDirtyRef.current[row.id];
}
// 等级格修改(快照含 price_type
Object.entries(matrixDirtyRef.current).forEach(([key, snap]) => {
const [pid, lid] = key.split(':');
if (String(row.id) === pid) {
next[`price_${lid}`] = dirty[key];
next[`price_${lid}`] = snap.price;
}
});
return next;
@@ -157,21 +245,72 @@ const ProductGoodsPage: React.FC = () => {
value: number | null
) => {
setMatrixRows((prev) =>
prev.map((row) =>
row.id === productId ? { ...row, [`price_${levelId}`]: value } : row
)
prev.map((row) => {
if (row.id !== productId) return row;
// 快照该格的计价类型(读当前行数据,避免闭包旧 state)
const type = Number(row[`price_type_${levelId}`] ?? PRICE_TYPE_FIXED);
matrixDirtyRef.current[`${productId}:${levelId}`] = { price: value, price_type: type };
return { ...row, [`price_${levelId}`]: value };
})
);
};
/** 修改成本价:联动重算「未被手工修改过」的百分比格显示值(percent 取服务端快照) */
const onMatrixCostChange = (productId: number, value: number | null) => {
costDirtyRef.current[productId] = value;
setMatrixRows((prev) =>
prev.map((row) => {
if (row.id !== productId) return row;
const next: IPriceMatrixRow = { ...row, cost_price: value };
const cost = Number(value ?? 0);
Object.keys(next).forEach((key) => {
if (!key.startsWith('percent_')) return;
const lid = key.replace('percent_', '');
if (matrixDirtyRef.current[`${productId}:${lid}`]) return; // 已手工改过,不覆盖
if (Number(next[`price_type_${lid}`]) !== PRICE_TYPE_PERCENT) return;
const percent = Number(next[key] ?? 0);
next[`price_${lid}`] = cost > 0 ? round2(cost * (1 + percent / 100)) : null;
});
return next;
})
);
matrixDirtyRef.current[`${productId}:${levelId}`] = value;
};
/** 提交所有跨页未保存的调价(null 视为清除,不提交) */
const saveMatrix = async () => {
const updates: IBatchPriceUpdate[] = [];
Object.entries(matrixDirtyRef.current).forEach(([key, price]) => {
if (price === null || price === undefined) return;
const [pid, lid] = key.split(':');
updates.push({ product_id: Number(pid), level_id: Number(lid), price });
// 成本价行
Object.entries(costDirtyRef.current).forEach(([pid, cost]) => {
if (cost === null || cost === undefined) return;
updates.push({ product_id: Number(pid), cost_price: cost });
});
// 等级价格行(按快照计价类型组装:百分比格按「当前成本价」反算上浮百分点)
for (const [key, snap] of Object.entries(matrixDirtyRef.current)) {
if (snap.price === null || snap.price === undefined) continue;
const [pid, lid] = key.split(':');
const productId = Number(pid);
if (snap.price_type === PRICE_TYPE_PERCENT) {
const cost = costDirtyRef.current[productId] ?? serverCostRef.current[productId] ?? 0;
if (!(cost > 0)) {
message.error(`商品 #${productId} 未设置成本价,无法保存百分比价格`);
return;
}
updates.push({
product_id: productId,
level_id: Number(lid),
price_type: PRICE_TYPE_PERCENT,
percent: round2((snap.price / cost - 1) * 100),
price: snap.price, // 等价固定价
});
} else {
updates.push({
product_id: productId,
level_id: Number(lid),
price_type: PRICE_TYPE_FIXED,
price: snap.price,
});
}
}
if (updates.length === 0) {
message.info('没有需要保存的价格调整');
return;
@@ -181,6 +320,7 @@ const ProductGoodsPage: React.FC = () => {
await batchPrice(updates);
message.success(`已更新 ${updates.length} 条价格,受影响门店将收到通知`);
matrixDirtyRef.current = {};
costDirtyRef.current = {};
await loadMatrix();
} finally {
setSaveLoading(false);
@@ -203,21 +343,47 @@ const ProductGoodsPage: React.FC = () => {
</div>
),
},
...matrixLevels.map((level) => ({
title: level.name,
key: `price_${level.id}`,
width: 150,
{
title: '成本价',
key: 'cost_price',
fixed: 'left',
width: 130,
render: (_: unknown, row: IPriceMatrixRow) => (
<InputNumber
size="small"
min={0}
precision={2}
prefix="¥"
value={row[`price_${level.id}`] as number | null}
onChange={(v) => onMatrixPriceChange(row.id, level.id!, v)}
value={row.cost_price as number | null}
onChange={(v) => onMatrixCostChange(row.id, v)}
className="w-32"
/>
),
},
...matrixLevels.map((level) => ({
title: level.name,
key: `price_${level.id}`,
width: 150,
render: (_: unknown, row: IPriceMatrixRow) => {
const isPercent = Number(row[`price_type_${level.id}`] ?? PRICE_TYPE_FIXED) === PRICE_TYPE_PERCENT;
const percent = Number(row[`percent_${level.id}`] ?? 0);
return (
<div>
<InputNumber
size="small"
min={0}
precision={2}
prefix="¥"
value={row[`price_${level.id}`] as number | null}
onChange={(v) => onMatrixPriceChange(row.id, level.id!, v)}
className="w-32"
/>
{isPercent && (
<div className="text-xs text-gray-400"> {percent}%</div>
)}
</div>
);
},
})),
];
@@ -349,20 +515,38 @@ const ProductGoodsPage: React.FC = () => {
placeholder: '输入商品图文详情,支持插入图片',
},
},
{
title: '成本价',
dataIndex: 'cost_price',
valueType: 'digit',
hideInSearch: true,
align: 'center',
fieldProps: { min: 0, precision: 2, prefix: '¥' },
render: (_, record) => {
const cost = Number(record.cost_price ?? 0);
return cost > 0 ? `¥${record.cost_price}` : <Text type="secondary"></Text>;
},
},
{
title: '等级价格',
dataIndex: 'prices',
hideInForm: true,
hideInSearch: true,
width: 370,
width: 420,
align: 'center',
render: (_, record) => (
<Space wrap>
{record.prices?.length
? record.prices.map((p) => (
<Tag key={p.id} color="geekblue">
<Tag
key={p.id}
color="geekblue"
title={Number(p.price_type) === PRICE_TYPE_PERCENT ? `按成本价上浮 ${p.percent}%` : undefined}
>
{p.level?.name ?? `等级${p.level_id}`}
<span style={{color: 'red', marginLeft: 5 }}>¥{p.price ?? '未设定'}</span>
<span style={{color: 'red', marginLeft: 5 }}>
¥{p.actual_price ?? p.price ?? '未设定'}
</span>
</Tag>
))
: '-'}
@@ -533,7 +717,7 @@ const ProductGoodsPage: React.FC = () => {
loadMatrix(matrixKeyword, matrixCategory, page, pageSize);
},
}}
scroll={{ x: matrixLevels.length * 150 + 160 }}
scroll={{ x: matrixLevels.length * 150 + 290 }}
/>
</Drawer>
</>