商品列表优化
This commit is contained in:
@@ -1 +1 @@
|
|||||||
{"version":2,"defects":{"Tests\\Feature\\ProductPriceTest::test_product_list_returns_price_for_store_level":8,"Tests\\Feature\\ProductPriceTest::test_store_without_level_is_rejected":8,"Tests\\Feature\\ProductPriceTest::test_batch_price_updates_and_notifies_affected_stores":8,"Tests\\Feature\\ProductPriceTest::test_price_matrix_structure":8,"Tests\\Feature\\StoreOrderTest::test_place_order_snapshots_level_price_and_recalculates":8,"Tests\\Feature\\StoreOrderTest::test_client_supplied_amount_is_ignored":8,"Tests\\Feature\\StoreOrderTest::test_product_without_level_price_rejected":8,"Tests\\Feature\\StoreOrderTest::test_cancel_only_pending_orders":8,"Tests\\Feature\\StoreOrderTest::test_summarized_order_cannot_be_cancelled":8,"Tests\\Feature\\StoreOrderTest::test_order_data_isolated_between_stores":7,"Tests\\Feature\\PurchaseGenerateTest::test_generate_aggregates_orders_by_product":7,"Tests\\Feature\\PurchaseGenerateTest::test_generate_writes_back_order_status":7,"Tests\\Feature\\PurchaseGenerateTest::test_generate_without_pending_orders_fails":8,"Tests\\Feature\\PurchaseGenerateTest::test_generate_is_idempotent":7,"Tests\\Feature\\AllocationTest::test_allocation_conserves_amount_with_tail_correction":7,"Tests\\Feature\\AllocationTest::test_allocation_follows_order_ratio":7,"Tests\\Feature\\AllocationTest::test_allocation_is_idempotent":7,"Tests\\Feature\\AllocationTest::test_allocation_rejected_without_actual_amount":8,"Tests\\Feature\\MiniAuthTest::test_login_creates_user_and_issues_token":7,"Tests\\Feature\\MiniAuthTest::test_phone_binding_matches_store":7,"Tests\\Feature\\MiniAuthTest::test_phone_no_match_stays_pending":7,"Tests\\Feature\\ExportTest::test_export_xlsx_with_encoded_chinese_filename":7},"times":{"Tests\\Feature\\ExampleTest::test_the_application_returns_a_successful_response":0.002,"Tests\\Feature\\ProductPriceTest::test_product_list_returns_price_for_store_level":0.008,"Tests\\Feature\\ProductPriceTest::test_store_without_level_is_rejected":0.007,"Tests\\Feature\\ProductPriceTest::test_batch_price_updates_and_notifies_affected_stores":0.013,"Tests\\Feature\\ProductPriceTest::test_price_matrix_structure":0.008,"Tests\\Feature\\StoreOrderTest::test_place_order_snapshots_level_price_and_recalculates":0.008,"Tests\\Feature\\StoreOrderTest::test_client_supplied_amount_is_ignored":0.009,"Tests\\Feature\\StoreOrderTest::test_product_without_level_price_rejected":0.009,"Tests\\Feature\\StoreOrderTest::test_cancel_only_pending_orders":0.013,"Tests\\Feature\\StoreOrderTest::test_summarized_order_cannot_be_cancelled":0.01,"Tests\\Feature\\StoreOrderTest::test_order_data_isolated_between_stores":0.011,"Tests\\Feature\\PurchaseGenerateTest::test_generate_aggregates_orders_by_product":0.03,"Tests\\Feature\\PurchaseGenerateTest::test_generate_writes_back_order_status":0.019,"Tests\\Feature\\PurchaseGenerateTest::test_generate_without_pending_orders_fails":0.006,"Tests\\Feature\\PurchaseGenerateTest::test_generate_is_idempotent":0.024,"Tests\\Feature\\AllocationTest::test_allocation_conserves_amount_with_tail_correction":0.071,"Tests\\Feature\\AllocationTest::test_allocation_follows_order_ratio":0.02,"Tests\\Feature\\AllocationTest::test_allocation_is_idempotent":0.023,"Tests\\Feature\\AllocationTest::test_allocation_rejected_without_actual_amount":0.016,"Tests\\Feature\\DebugGenerateTest::test_debug_generate":0.047,"Tests\\Feature\\ReconciliationTest::test_build_creates_reconciliation_items":0.027,"Tests\\Feature\\ReconciliationTest::test_build_filters_by_supplier":0.032,"Tests\\Feature\\ReconciliationTest::test_update_item_recalculates_diff_and_header":0.028,"Tests\\Feature\\ReconciliationTest::test_toggle_reconciled_flag":0.027,"Tests\\Feature\\ReconciliationTest::test_settle_creates_settlements_per_store":0.03,"Tests\\Feature\\StatementTest::test_generate_snapshots_payment_cycle":0.011,"Tests\\Feature\\StatementTest::test_statement_isolated_between_stores":0.013,"Tests\\Feature\\StatementTest::test_generate_excludes_cancelled_and_used_items":0.015,"Tests\\Feature\\MiniAuthTest::test_login_creates_user_and_issues_token":0.008,"Tests\\Feature\\MiniAuthTest::test_disabled_user_cannot_login":0.013,"Tests\\Feature\\MiniAuthTest::test_phone_binding_matches_store":0.012,"Tests\\Feature\\MiniAuthTest::test_phone_no_match_stays_pending":0.006,"Tests\\Feature\\MiniAuthTest::test_sys_token_cannot_access_mini":0.005,"Tests\\Feature\\MiniAuthTest::test_mini_token_cannot_access_admin_api":0.005,"Tests\\Feature\\ExportTest::test_export_xlsx_with_encoded_chinese_filename":0.059,"Tests\\Feature\\ExportTest::test_export_category_filters_to_vegetables":0.017,"Tests\\Feature\\ExportTest::test_export_pdf":1.324,"Tests\\Feature\\ExportTest::test_export_invalid_format_rejected":0.016,"Tests\\Feature\\ExportTest::test_export_requires_export_permission":0.016,"Tests\\Unit\\ExampleTest::test_that_true_is_true":0.001,"Tests\\Feature\\MiniAuthTest::test_login_fails_when_wechat_rejects_code":0.004}}
|
{"version":2,"defects":{"Tests\\Feature\\ProductPriceTest::test_product_list_returns_price_for_store_level":8,"Tests\\Feature\\ProductPriceTest::test_store_without_level_is_rejected":8,"Tests\\Feature\\ProductPriceTest::test_batch_price_updates_and_notifies_affected_stores":8,"Tests\\Feature\\ProductPriceTest::test_price_matrix_structure":8,"Tests\\Feature\\StoreOrderTest::test_place_order_snapshots_level_price_and_recalculates":8,"Tests\\Feature\\StoreOrderTest::test_client_supplied_amount_is_ignored":8,"Tests\\Feature\\StoreOrderTest::test_product_without_level_price_rejected":8,"Tests\\Feature\\StoreOrderTest::test_cancel_only_pending_orders":8,"Tests\\Feature\\StoreOrderTest::test_summarized_order_cannot_be_cancelled":8,"Tests\\Feature\\StoreOrderTest::test_order_data_isolated_between_stores":7,"Tests\\Feature\\PurchaseGenerateTest::test_generate_aggregates_orders_by_product":7,"Tests\\Feature\\PurchaseGenerateTest::test_generate_writes_back_order_status":7,"Tests\\Feature\\PurchaseGenerateTest::test_generate_without_pending_orders_fails":8,"Tests\\Feature\\PurchaseGenerateTest::test_generate_is_idempotent":7,"Tests\\Feature\\AllocationTest::test_allocation_conserves_amount_with_tail_correction":7,"Tests\\Feature\\AllocationTest::test_allocation_follows_order_ratio":7,"Tests\\Feature\\AllocationTest::test_allocation_is_idempotent":7,"Tests\\Feature\\AllocationTest::test_allocation_rejected_without_actual_amount":8,"Tests\\Feature\\MiniAuthTest::test_login_creates_user_and_issues_token":7,"Tests\\Feature\\MiniAuthTest::test_phone_binding_matches_store":7,"Tests\\Feature\\MiniAuthTest::test_phone_no_match_stays_pending":7,"Tests\\Feature\\ExportTest::test_export_xlsx_with_encoded_chinese_filename":7},"times":{"Tests\\Feature\\ExampleTest::test_the_application_returns_a_successful_response":0.002,"Tests\\Feature\\ProductPriceTest::test_product_list_returns_price_for_store_level":0.021,"Tests\\Feature\\ProductPriceTest::test_store_without_level_is_rejected":0.001,"Tests\\Feature\\ProductPriceTest::test_batch_price_updates_and_notifies_affected_stores":0.008,"Tests\\Feature\\ProductPriceTest::test_price_matrix_structure":0.002,"Tests\\Feature\\StoreOrderTest::test_place_order_snapshots_level_price_and_recalculates":0.008,"Tests\\Feature\\StoreOrderTest::test_client_supplied_amount_is_ignored":0.009,"Tests\\Feature\\StoreOrderTest::test_product_without_level_price_rejected":0.009,"Tests\\Feature\\StoreOrderTest::test_cancel_only_pending_orders":0.013,"Tests\\Feature\\StoreOrderTest::test_summarized_order_cannot_be_cancelled":0.01,"Tests\\Feature\\StoreOrderTest::test_order_data_isolated_between_stores":0.011,"Tests\\Feature\\PurchaseGenerateTest::test_generate_aggregates_orders_by_product":0.03,"Tests\\Feature\\PurchaseGenerateTest::test_generate_writes_back_order_status":0.019,"Tests\\Feature\\PurchaseGenerateTest::test_generate_without_pending_orders_fails":0.006,"Tests\\Feature\\PurchaseGenerateTest::test_generate_is_idempotent":0.024,"Tests\\Feature\\AllocationTest::test_allocation_conserves_amount_with_tail_correction":0.071,"Tests\\Feature\\AllocationTest::test_allocation_follows_order_ratio":0.02,"Tests\\Feature\\AllocationTest::test_allocation_is_idempotent":0.023,"Tests\\Feature\\AllocationTest::test_allocation_rejected_without_actual_amount":0.016,"Tests\\Feature\\DebugGenerateTest::test_debug_generate":0.047,"Tests\\Feature\\ReconciliationTest::test_build_creates_reconciliation_items":0.027,"Tests\\Feature\\ReconciliationTest::test_build_filters_by_supplier":0.032,"Tests\\Feature\\ReconciliationTest::test_update_item_recalculates_diff_and_header":0.028,"Tests\\Feature\\ReconciliationTest::test_toggle_reconciled_flag":0.027,"Tests\\Feature\\ReconciliationTest::test_settle_creates_settlements_per_store":0.03,"Tests\\Feature\\StatementTest::test_generate_snapshots_payment_cycle":0.011,"Tests\\Feature\\StatementTest::test_statement_isolated_between_stores":0.013,"Tests\\Feature\\StatementTest::test_generate_excludes_cancelled_and_used_items":0.015,"Tests\\Feature\\MiniAuthTest::test_login_creates_user_and_issues_token":0.008,"Tests\\Feature\\MiniAuthTest::test_disabled_user_cannot_login":0.013,"Tests\\Feature\\MiniAuthTest::test_phone_binding_matches_store":0.012,"Tests\\Feature\\MiniAuthTest::test_phone_no_match_stays_pending":0.006,"Tests\\Feature\\MiniAuthTest::test_sys_token_cannot_access_mini":0.005,"Tests\\Feature\\MiniAuthTest::test_mini_token_cannot_access_admin_api":0.005,"Tests\\Feature\\ExportTest::test_export_xlsx_with_encoded_chinese_filename":0.059,"Tests\\Feature\\ExportTest::test_export_category_filters_to_vegetables":0.017,"Tests\\Feature\\ExportTest::test_export_pdf":1.324,"Tests\\Feature\\ExportTest::test_export_invalid_format_rejected":0.016,"Tests\\Feature\\ExportTest::test_export_requires_export_permission":0.016,"Tests\\Unit\\ExampleTest::test_that_true_is_true":0.001,"Tests\\Feature\\MiniAuthTest::test_login_fails_when_wechat_rejects_code":0.004}}
|
||||||
@@ -140,7 +140,7 @@ class ProductController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A2 价格矩阵:行=商品(支持 category_id / keyword 过滤),列=全部启用等级,值=price(缺失为 null)
|
* A2 价格矩阵:行=商品(支持 category_id / keyword 过滤 + page/pageSize 服务端分页),列=全部启用等级,值=price(缺失为 null)
|
||||||
*/
|
*/
|
||||||
#[GetRoute('/priceMatrix', 'query')]
|
#[GetRoute('/priceMatrix', 'query')]
|
||||||
public function priceMatrix(Request $request): JsonResponse
|
public function priceMatrix(Request $request): JsonResponse
|
||||||
@@ -156,14 +156,16 @@ class ProductController extends BaseController
|
|||||||
->orWhere('spec', 'like', '%' . $keyword . '%');
|
->orWhere('spec', 'like', '%' . $keyword . '%');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
$products = $query->orderBy('sort')->orderBy('id')->get();
|
$pageSize = max(1, min(100, (int) $request->input('pageSize', 20)));
|
||||||
|
$page = max(1, (int) $request->input('page', 1));
|
||||||
|
$products = $query->orderBy('sort')->orderBy('id')->paginate($pageSize, ['*'], 'page', $page);
|
||||||
|
|
||||||
$levels = CustomerLevelModel::query()
|
$levels = CustomerLevelModel::query()
|
||||||
->where('status', CustomerLevelModel::STATUS_NORMAL)
|
->where('status', CustomerLevelModel::STATUS_NORMAL)
|
||||||
->orderBy('sort')
|
->orderBy('sort')
|
||||||
->get(['id', 'name']);
|
->get(['id', 'name']);
|
||||||
|
|
||||||
$rows = $products->map(function (ProductModel $product) use ($levels) {
|
$rows = $products->getCollection()->map(function (ProductModel $product) use ($levels) {
|
||||||
$priceMap = $product->prices->keyBy('level_id');
|
$priceMap = $product->prices->keyBy('level_id');
|
||||||
$row = [
|
$row = [
|
||||||
'id' => $product->id,
|
'id' => $product->id,
|
||||||
@@ -181,7 +183,8 @@ class ProductController extends BaseController
|
|||||||
|
|
||||||
return $this->success([
|
return $this->success([
|
||||||
'levels' => $levels->toArray(),
|
'levels' => $levels->toArray(),
|
||||||
'rows' => $rows->toArray(),
|
'rows' => $rows->values()->toArray(),
|
||||||
|
'total' => $products->total(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import type { IBatchPriceUpdate, IPriceMatrix } from '@/domain/iProduct.ts';
|
|||||||
export interface PriceMatrixParams {
|
export interface PriceMatrixParams {
|
||||||
category_id?: number;
|
category_id?: number;
|
||||||
keyword?: string;
|
keyword?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A2 价格矩阵:行=商品,列=启用等级,值=price(缺失 null) */
|
/** A2 价格矩阵:行=商品,列=启用等级,值=price(缺失 null) */
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ export type IPriceMatrixRow = {
|
|||||||
export interface IPriceMatrix {
|
export interface IPriceMatrix {
|
||||||
levels: { id: number; name: string }[];
|
levels: { id: number; name: string }[];
|
||||||
rows: IPriceMatrixRow[];
|
rows: IPriceMatrixRow[];
|
||||||
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IBatchPriceUpdate {
|
export interface IBatchPriceUpdate {
|
||||||
|
|||||||
+152
-83
@@ -1,24 +1,23 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button, Card,
|
||||||
Drawer,
|
Drawer,
|
||||||
Form, Image,
|
Form, Image,
|
||||||
Input,
|
Input,
|
||||||
InputNumber,
|
InputNumber,
|
||||||
message,
|
message,
|
||||||
Select,
|
|
||||||
Space,
|
Space,
|
||||||
Table,
|
Table,
|
||||||
Tag,
|
Tag, Tree,
|
||||||
TreeSelect,
|
TreeSelect,
|
||||||
Typography,
|
Typography,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import { MinusCircleOutlined, PlusOutlined, TableOutlined } from '@ant-design/icons';
|
import { TableOutlined } from '@ant-design/icons';
|
||||||
import type { TableProps } from 'antd';
|
import type { FormInstance, TableProps } from 'antd';
|
||||||
import XinTable from '@/components/XinTable';
|
import XinTable from '@/components/XinTable';
|
||||||
import type { XinTableColumn, XinTableProps } from '@/components/XinTable/typings.ts';
|
import type { XinTableColumn, XinTableInstance, XinTableProps } from '@/components/XinTable/typings.ts';
|
||||||
import type IProduct from '@/domain/iProduct.ts';
|
import type IProduct from '@/domain/iProduct.ts';
|
||||||
import type { IBatchPriceUpdate, IPriceMatrixRow } from '@/domain/iProduct.ts';
|
import type { IBatchPriceUpdate, IPriceMatrixRow, IProductPrice } from '@/domain/iProduct.ts';
|
||||||
import { PRODUCT_STATUS_MAP } from '@/domain/iProduct.ts';
|
import { PRODUCT_STATUS_MAP } from '@/domain/iProduct.ts';
|
||||||
import type ICustomerLevel from '@/domain/iCustomerLevel.ts';
|
import type ICustomerLevel from '@/domain/iCustomerLevel.ts';
|
||||||
import type {IProductCategoryTree} from '@/domain/iProductCategory.ts';
|
import type {IProductCategoryTree} from '@/domain/iProductCategory.ts';
|
||||||
@@ -30,6 +29,53 @@ import { batchPrice, getPriceMatrix } from '@/api/product/goods.ts';
|
|||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 等级价格表单
|
||||||
|
*/
|
||||||
|
const LevelPriceFields: React.FC<{
|
||||||
|
form: FormInstance;
|
||||||
|
levels: ICustomerLevel[];
|
||||||
|
}> = ({ form, levels }) => {
|
||||||
|
const prices = Form.useWatch<IProductPrice[]>('prices', form) ?? [];
|
||||||
|
|
||||||
|
if (levels.length === 0) {
|
||||||
|
return (
|
||||||
|
<Text type="secondary">
|
||||||
|
暂无可配置的客户等级,请先到「客户等级」中新增
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const setLevelPrice = (levelId: number, price: number | null) => {
|
||||||
|
const next = prices.filter((p) => p.level_id !== levelId);
|
||||||
|
if (price !== null) {
|
||||||
|
next.push({ level_id: levelId, price });
|
||||||
|
}
|
||||||
|
form.setFieldValue('prices', next);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Space wrap>
|
||||||
|
{levels.map((level) => {
|
||||||
|
if (level.id == null) return null;
|
||||||
|
const row = prices.find((p) => p.level_id === level.id);
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 商品档案管理(A1 商品列表 / A2 价格矩阵与批量调价)
|
* 商品档案管理(A1 商品列表 / A2 价格矩阵与批量调价)
|
||||||
*/
|
*/
|
||||||
@@ -38,15 +84,24 @@ const ProductGoodsPage: React.FC = () => {
|
|||||||
const [suppliers, setSuppliers] = useState<ISupplier[]>([]);
|
const [suppliers, setSuppliers] = useState<ISupplier[]>([]);
|
||||||
const [categoryTree, setCategoryTree] = useState<IProductCategoryTree[]>([]);
|
const [categoryTree, setCategoryTree] = useState<IProductCategoryTree[]>([]);
|
||||||
|
|
||||||
|
// ===== 分类侧栏 =====
|
||||||
|
const [activeCategory, setActiveCategory] = useState<number | undefined>(undefined);
|
||||||
|
const tableRef = useRef<XinTableInstance<IProduct> | null>(null);
|
||||||
|
useEffect(() => { tableRef.current?.reset() }, [activeCategory]);
|
||||||
|
|
||||||
// ===== 价格矩阵抽屉 =====
|
// ===== 价格矩阵抽屉 =====
|
||||||
const [matrixOpen, setMatrixOpen] = useState(false);
|
const [matrixOpen, setMatrixOpen] = useState(false);
|
||||||
const [matrixLoading, setMatrixLoading] = useState(false);
|
const [matrixLoading, setMatrixLoading] = useState(false);
|
||||||
const [saveLoading, setSaveLoading] = useState(false);
|
const [saveLoading, setSaveLoading] = useState(false);
|
||||||
const [matrixRows, setMatrixRows] = useState<IPriceMatrixRow[]>([]);
|
const [matrixRows, setMatrixRows] = useState<IPriceMatrixRow[]>([]);
|
||||||
const [matrixSnapshot, setMatrixSnapshot] = useState<IPriceMatrixRow[]>([]);
|
const [matrixPage, setMatrixPage] = useState(1);
|
||||||
|
const [matrixPageSize, setMatrixPageSize] = useState(20);
|
||||||
|
const [matrixTotal, setMatrixTotal] = useState(0);
|
||||||
const [matrixLevels, setMatrixLevels] = useState<ICustomerLevel[]>([]);
|
const [matrixLevels, setMatrixLevels] = useState<ICustomerLevel[]>([]);
|
||||||
const [matrixKeyword, setMatrixKeyword] = useState('');
|
const [matrixKeyword, setMatrixKeyword] = useState('');
|
||||||
const [matrixCategory, setMatrixCategory] = useState<number | undefined>(undefined);
|
const [matrixCategory, setMatrixCategory] = useState<number | undefined>(undefined);
|
||||||
|
/** 跨页未保存的调价:`${productId}:${levelId}` → price(用 ref 避免异步闭包读到旧值) */
|
||||||
|
const matrixDirtyRef = useRef<Record<string, number | null>>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getLevelOptions().then((res) => setLevels(res.data.data ?? []));
|
getLevelOptions().then((res) => setLevels(res.data.data ?? []));
|
||||||
@@ -56,17 +111,33 @@ const ProductGoodsPage: React.FC = () => {
|
|||||||
|
|
||||||
const loadMatrix = async (
|
const loadMatrix = async (
|
||||||
keyword = matrixKeyword,
|
keyword = matrixKeyword,
|
||||||
categoryId = matrixCategory
|
categoryId = matrixCategory,
|
||||||
|
page = matrixPage,
|
||||||
|
pageSize = matrixPageSize
|
||||||
) => {
|
) => {
|
||||||
setMatrixLoading(true);
|
setMatrixLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await getPriceMatrix({
|
const res = await getPriceMatrix({
|
||||||
keyword: keyword || undefined,
|
keyword: keyword || undefined,
|
||||||
category_id: categoryId,
|
category_id: categoryId,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
});
|
});
|
||||||
const rows = res.data.data?.rows ?? [];
|
const rows = res.data.data?.rows ?? [];
|
||||||
setMatrixRows(rows);
|
// 叠加跨页未保存的修改,保证翻页后输入值不回退
|
||||||
setMatrixSnapshot(JSON.parse(JSON.stringify(rows)));
|
const dirty = matrixDirtyRef.current;
|
||||||
|
const merged = rows.map((row) => {
|
||||||
|
const next = { ...row };
|
||||||
|
Object.keys(dirty).forEach((key) => {
|
||||||
|
const [pid, lid] = key.split(':');
|
||||||
|
if (String(row.id) === pid) {
|
||||||
|
next[`price_${lid}`] = dirty[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setMatrixRows(merged);
|
||||||
|
setMatrixTotal(res.data.data?.total ?? 0);
|
||||||
setMatrixLevels(res.data.data?.levels ?? []);
|
setMatrixLevels(res.data.data?.levels ?? []);
|
||||||
} finally {
|
} finally {
|
||||||
setMatrixLoading(false);
|
setMatrixLoading(false);
|
||||||
@@ -75,7 +146,9 @@ const ProductGoodsPage: React.FC = () => {
|
|||||||
|
|
||||||
const openMatrix = () => {
|
const openMatrix = () => {
|
||||||
setMatrixOpen(true);
|
setMatrixOpen(true);
|
||||||
loadMatrix('', undefined);
|
setMatrixPage(1);
|
||||||
|
setMatrixPageSize(20);
|
||||||
|
loadMatrix('', undefined, 1, 20);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onMatrixPriceChange = (
|
const onMatrixPriceChange = (
|
||||||
@@ -88,22 +161,17 @@ const ProductGoodsPage: React.FC = () => {
|
|||||||
row.id === productId ? { ...row, [`price_${levelId}`]: value } : row
|
row.id === productId ? { ...row, [`price_${levelId}`]: value } : row
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
matrixDirtyRef.current[`${productId}:${levelId}`] = value;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** diff 出被修改的价格行,提交批量调价 */
|
/** 提交所有跨页未保存的调价(null 视为清除,不提交) */
|
||||||
const saveMatrix = async () => {
|
const saveMatrix = async () => {
|
||||||
const updates: IBatchPriceUpdate[] = [];
|
const updates: IBatchPriceUpdate[] = [];
|
||||||
for (const row of matrixRows) {
|
Object.entries(matrixDirtyRef.current).forEach(([key, price]) => {
|
||||||
const old = matrixSnapshot.find((r) => r.id === row.id);
|
if (price === null || price === undefined) return;
|
||||||
for (const level of matrixLevels) {
|
const [pid, lid] = key.split(':');
|
||||||
const key = `price_${level.id}`;
|
updates.push({ product_id: Number(pid), level_id: Number(lid), price });
|
||||||
const next = row[key];
|
});
|
||||||
const prev = old?.[key];
|
|
||||||
if (next !== null && next !== undefined && String(next) !== String(prev ?? '')) {
|
|
||||||
updates.push({ product_id: row.id, level_id: level.id!, price: next as number });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (updates.length === 0) {
|
if (updates.length === 0) {
|
||||||
message.info('没有需要保存的价格调整');
|
message.info('没有需要保存的价格调整');
|
||||||
return;
|
return;
|
||||||
@@ -112,6 +180,7 @@ const ProductGoodsPage: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
await batchPrice(updates);
|
await batchPrice(updates);
|
||||||
message.success(`已更新 ${updates.length} 条价格,受影响门店将收到通知`);
|
message.success(`已更新 ${updates.length} 条价格,受影响门店将收到通知`);
|
||||||
|
matrixDirtyRef.current = {};
|
||||||
await loadMatrix();
|
await loadMatrix();
|
||||||
} finally {
|
} finally {
|
||||||
setSaveLoading(false);
|
setSaveLoading(false);
|
||||||
@@ -200,6 +269,14 @@ const ProductGoodsPage: React.FC = () => {
|
|||||||
valueType: 'text',
|
valueType: 'text',
|
||||||
colProps: { span: 24 },
|
colProps: { span: 24 },
|
||||||
required: true,
|
required: true,
|
||||||
|
render: (_, record) => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className={"mb-1.5"}>{ record.name }</div>
|
||||||
|
<div className={"text-[#999] text-[12px]"}>{ record.remark }</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
rules: [{ required: true, message: '请输入商品名称' }],
|
rules: [{ required: true, message: '请输入商品名称' }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -240,6 +317,7 @@ const ProductGoodsPage: React.FC = () => {
|
|||||||
treeNodeFilterProp: 'name',
|
treeNodeFilterProp: 'name',
|
||||||
placeholder: '选择分类',
|
placeholder: '选择分类',
|
||||||
},
|
},
|
||||||
|
hideInSearch: true,
|
||||||
render: (_, record) =>
|
render: (_, record) =>
|
||||||
record.category ? <Tag color="cyan">{record.category.name}</Tag> : '-',
|
record.category ? <Tag color="cyan">{record.category.name}</Tag> : '-',
|
||||||
},
|
},
|
||||||
@@ -322,58 +400,7 @@ const ProductGoodsPage: React.FC = () => {
|
|||||||
hideInTable: true,
|
hideInTable: true,
|
||||||
hideInSearch: true,
|
hideInSearch: true,
|
||||||
colProps: { span: 24 },
|
colProps: { span: 24 },
|
||||||
fieldRender: () => (
|
fieldRender: (form) => <LevelPriceFields form={form} levels={levels} />,
|
||||||
<Form.List name="prices">
|
|
||||||
{(fields, { add, remove }) => (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{fields.map(({ key, name, ...restField }) => (
|
|
||||||
<Space key={key} align="baseline" className="flex">
|
|
||||||
<Form.Item
|
|
||||||
{...restField}
|
|
||||||
name={[name, 'level_id']}
|
|
||||||
rules={[{ required: true, message: '请选择等级' }]}
|
|
||||||
className="mb-0!"
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
style={{ width: 180 }}
|
|
||||||
placeholder="选择客户等级"
|
|
||||||
options={levels.map((l) => ({ label: l.name, value: l.id }))}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
{...restField}
|
|
||||||
name={[name, 'price']}
|
|
||||||
rules={[{ required: true, message: '请输入单价' }]}
|
|
||||||
className="mb-0!"
|
|
||||||
>
|
|
||||||
<InputNumber
|
|
||||||
min={0}
|
|
||||||
precision={2}
|
|
||||||
prefix="¥"
|
|
||||||
placeholder="单价"
|
|
||||||
className="w-32"
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
danger
|
|
||||||
icon={<MinusCircleOutlined />}
|
|
||||||
onClick={() => remove(name)}
|
|
||||||
/>
|
|
||||||
</Space>
|
|
||||||
))}
|
|
||||||
<Button
|
|
||||||
type="dashed"
|
|
||||||
block
|
|
||||||
icon={<PlusOutlined />}
|
|
||||||
onClick={() => add()}
|
|
||||||
>
|
|
||||||
添加等级价格
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Form.List>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '创建时间',
|
title: '创建时间',
|
||||||
@@ -389,9 +416,11 @@ const ProductGoodsPage: React.FC = () => {
|
|||||||
columns,
|
columns,
|
||||||
rowKey: 'id',
|
rowKey: 'id',
|
||||||
accessName: 'product.goods',
|
accessName: 'product.goods',
|
||||||
|
tableRef,
|
||||||
scroll: { x: 1200 },
|
scroll: { x: 1200 },
|
||||||
actionBarRender: (dom) => [
|
actionBarRender: (dom) => [
|
||||||
dom.add,
|
dom.add,
|
||||||
|
dom.search,
|
||||||
<Button key="matrix" icon={<TableOutlined />} onClick={openMatrix}>
|
<Button key="matrix" icon={<TableOutlined />} onClick={openMatrix}>
|
||||||
价格矩阵
|
价格矩阵
|
||||||
</Button>,
|
</Button>,
|
||||||
@@ -403,8 +432,13 @@ const ProductGoodsPage: React.FC = () => {
|
|||||||
rowProps: { gutter: 20 },
|
rowProps: { gutter: 20 },
|
||||||
layout: 'vertical',
|
layout: 'vertical',
|
||||||
},
|
},
|
||||||
|
requestParams: (params) => ({
|
||||||
|
...params,
|
||||||
|
category_id: activeCategory
|
||||||
|
}),
|
||||||
modalProps: {
|
modalProps: {
|
||||||
width: 800,
|
width: 800,
|
||||||
|
centered: true,
|
||||||
classNames: { body: "overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden h-[80vh]" },
|
classNames: { body: "overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden h-[80vh]" },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -417,13 +451,33 @@ const ProductGoodsPage: React.FC = () => {
|
|||||||
商品档案与多等级价格体系;「价格矩阵」支持按等级批量调价,调价后自动通知受影响门店。
|
商品档案与多等级价格体系;「价格矩阵」支持按等级批量调价,调价后自动通知受影响门店。
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
<XinTable<IProduct> {...tableProps} />
|
<div className="flex items-start gap-4">
|
||||||
|
<Card style={{ width: 200 }} title={'商品分类'}>
|
||||||
|
<div
|
||||||
|
className={`cursor-pointer rounded-2xl mb-2 px-2 h-6 leading-6 ${activeCategory === undefined ? 'bg-blue-50' : 'hover:bg-gray-50'}`}
|
||||||
|
onClick={() => { setActiveCategory(undefined) }}
|
||||||
|
>
|
||||||
|
全部商品
|
||||||
|
</div>
|
||||||
|
<Tree<any>
|
||||||
|
showLine
|
||||||
|
blockNode
|
||||||
|
onSelect={(selectedKeys) => setActiveCategory(Number(selectedKeys[0]))}
|
||||||
|
treeData={categoryTree}
|
||||||
|
selectedKeys={activeCategory ? [activeCategory] : undefined}
|
||||||
|
fieldNames={{title: 'name', key: 'id', children: 'children'}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<XinTable<IProduct> {...tableProps} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Drawer
|
<Drawer
|
||||||
title="价格矩阵 · 批量调价"
|
title="价格矩阵 · 批量调价"
|
||||||
open={matrixOpen}
|
open={matrixOpen}
|
||||||
onClose={() => setMatrixOpen(false)}
|
onClose={() => setMatrixOpen(false)}
|
||||||
size={800}
|
size={1200}
|
||||||
extra={
|
extra={
|
||||||
<Space>
|
<Space>
|
||||||
<Button onClick={() => loadMatrix()}>刷新</Button>
|
<Button onClick={() => loadMatrix()}>刷新</Button>
|
||||||
@@ -439,13 +493,13 @@ const ProductGoodsPage: React.FC = () => {
|
|||||||
placeholder="按分类筛选"
|
placeholder="按分类筛选"
|
||||||
allowClear
|
allowClear
|
||||||
treeDefaultExpandAll
|
treeDefaultExpandAll
|
||||||
treeNodeFilterProp="name"
|
|
||||||
fieldNames={{ label: 'name', value: 'id', children: 'children' }}
|
fieldNames={{ label: 'name', value: 'id', children: 'children' }}
|
||||||
treeData={categoryTree}
|
treeData={categoryTree}
|
||||||
value={matrixCategory}
|
value={matrixCategory}
|
||||||
onChange={(v) => {
|
onChange={(v) => {
|
||||||
setMatrixCategory(v);
|
setMatrixCategory(v);
|
||||||
loadMatrix(matrixKeyword, v);
|
setMatrixPage(1);
|
||||||
|
loadMatrix(matrixKeyword, v, 1, matrixPageSize);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Input.Search
|
<Input.Search
|
||||||
@@ -454,7 +508,10 @@ const ProductGoodsPage: React.FC = () => {
|
|||||||
allowClear
|
allowClear
|
||||||
value={matrixKeyword}
|
value={matrixKeyword}
|
||||||
onChange={(e) => setMatrixKeyword(e.target.value)}
|
onChange={(e) => setMatrixKeyword(e.target.value)}
|
||||||
onSearch={(v) => loadMatrix(v, matrixCategory)}
|
onSearch={(v) => {
|
||||||
|
setMatrixPage(1);
|
||||||
|
loadMatrix(v, matrixCategory, 1, matrixPageSize);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Space>
|
</Space>
|
||||||
<Table<IPriceMatrixRow>
|
<Table<IPriceMatrixRow>
|
||||||
@@ -463,7 +520,19 @@ const ProductGoodsPage: React.FC = () => {
|
|||||||
loading={matrixLoading}
|
loading={matrixLoading}
|
||||||
columns={matrixColumns}
|
columns={matrixColumns}
|
||||||
dataSource={matrixRows}
|
dataSource={matrixRows}
|
||||||
pagination={{ pageSize: 20, showSizeChanger: false }}
|
pagination={{
|
||||||
|
current: matrixPage,
|
||||||
|
pageSize: matrixPageSize,
|
||||||
|
total: matrixTotal,
|
||||||
|
showSizeChanger: true,
|
||||||
|
pageSizeOptions: [20, 50, 100],
|
||||||
|
showTotal: (total) => `共 ${total} 件商品`,
|
||||||
|
onChange: (page, pageSize) => {
|
||||||
|
setMatrixPage(page);
|
||||||
|
setMatrixPageSize(pageSize);
|
||||||
|
loadMatrix(matrixKeyword, matrixCategory, page, pageSize);
|
||||||
|
},
|
||||||
|
}}
|
||||||
scroll={{ x: matrixLevels.length * 150 + 160 }}
|
scroll={{ x: matrixLevels.length * 150 + 160 }}
|
||||||
/>
|
/>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|||||||
Reference in New Issue
Block a user