diff --git a/.phpunit.result.cache b/.phpunit.result.cache index 6da0743..5f94a10 100644 --- a/.phpunit.result.cache +++ b/.phpunit.result.cache @@ -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}} \ No newline at end of file +{"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}} \ No newline at end of file diff --git a/app/Http/Controllers/Product/ProductController.php b/app/Http/Controllers/Product/ProductController.php index 6031314..082a099 100644 --- a/app/Http/Controllers/Product/ProductController.php +++ b/app/Http/Controllers/Product/ProductController.php @@ -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')] public function priceMatrix(Request $request): JsonResponse @@ -156,14 +156,16 @@ class ProductController extends BaseController ->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() ->where('status', CustomerLevelModel::STATUS_NORMAL) ->orderBy('sort') ->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'); $row = [ 'id' => $product->id, @@ -181,7 +183,8 @@ class ProductController extends BaseController return $this->success([ 'levels' => $levels->toArray(), - 'rows' => $rows->toArray(), + 'rows' => $rows->values()->toArray(), + 'total' => $products->total(), ]); } diff --git a/web/api/product/goods.ts b/web/api/product/goods.ts index 7e3816b..e9d0876 100644 --- a/web/api/product/goods.ts +++ b/web/api/product/goods.ts @@ -5,6 +5,8 @@ import type { IBatchPriceUpdate, IPriceMatrix } from '@/domain/iProduct.ts'; export interface PriceMatrixParams { category_id?: number; keyword?: string; + page?: number; + pageSize?: number; } /** A2 价格矩阵:行=商品,列=启用等级,值=price(缺失 null) */ diff --git a/web/domain/iProduct.ts b/web/domain/iProduct.ts index 632bac1..4793f0f 100644 --- a/web/domain/iProduct.ts +++ b/web/domain/iProduct.ts @@ -66,6 +66,7 @@ export type IPriceMatrixRow = { export interface IPriceMatrix { levels: { id: number; name: string }[]; rows: IPriceMatrixRow[]; + total: number; } export interface IBatchPriceUpdate { diff --git a/web/pages/product/goods.tsx b/web/pages/product/goods.tsx index f5715f5..f164388 100644 --- a/web/pages/product/goods.tsx +++ b/web/pages/product/goods.tsx @@ -1,24 +1,23 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { - Button, + Button, Card, Drawer, Form, Image, Input, InputNumber, message, - Select, Space, Table, - Tag, + Tag, Tree, TreeSelect, Typography, } from 'antd'; -import { MinusCircleOutlined, PlusOutlined, TableOutlined } from '@ant-design/icons'; -import type { TableProps } from 'antd'; +import { TableOutlined } from '@ant-design/icons'; +import type { FormInstance, TableProps } from 'antd'; 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 { IBatchPriceUpdate, IPriceMatrixRow } from '@/domain/iProduct.ts'; +import type { IBatchPriceUpdate, IPriceMatrixRow, IProductPrice } from '@/domain/iProduct.ts'; import { PRODUCT_STATUS_MAP } from '@/domain/iProduct.ts'; import type ICustomerLevel from '@/domain/iCustomerLevel.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 LevelPriceFields: React.FC<{ + form: FormInstance; + levels: ICustomerLevel[]; +}> = ({ form, levels }) => { + const prices = Form.useWatch('prices', form) ?? []; + + if (levels.length === 0) { + return ( + + 暂无可配置的客户等级,请先到「客户等级」中新增 + + ); + } + + 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 ( + + {levels.map((level) => { + if (level.id == null) return null; + const row = prices.find((p) => p.level_id === level.id); + return ( + {level.name}} + suffix={'¥'} + placeholder="未设定" + value={(row?.price as number | null) ?? null} + onChange={(v) => setLevelPrice(level.id as number, v)} + style={{ width: 240 }} + /> + ); + })} + + ); +}; + /** * 商品档案管理(A1 商品列表 / A2 价格矩阵与批量调价) */ @@ -38,15 +84,24 @@ const ProductGoodsPage: React.FC = () => { const [suppliers, setSuppliers] = useState([]); const [categoryTree, setCategoryTree] = useState([]); + // ===== 分类侧栏 ===== + const [activeCategory, setActiveCategory] = useState(undefined); + const tableRef = useRef | null>(null); + useEffect(() => { tableRef.current?.reset() }, [activeCategory]); + // ===== 价格矩阵抽屉 ===== const [matrixOpen, setMatrixOpen] = useState(false); const [matrixLoading, setMatrixLoading] = useState(false); const [saveLoading, setSaveLoading] = useState(false); const [matrixRows, setMatrixRows] = useState([]); - const [matrixSnapshot, setMatrixSnapshot] = useState([]); + const [matrixPage, setMatrixPage] = useState(1); + const [matrixPageSize, setMatrixPageSize] = useState(20); + const [matrixTotal, setMatrixTotal] = useState(0); const [matrixLevels, setMatrixLevels] = useState([]); const [matrixKeyword, setMatrixKeyword] = useState(''); const [matrixCategory, setMatrixCategory] = useState(undefined); + /** 跨页未保存的调价:`${productId}:${levelId}` → price(用 ref 避免异步闭包读到旧值) */ + const matrixDirtyRef = useRef>({}); useEffect(() => { getLevelOptions().then((res) => setLevels(res.data.data ?? [])); @@ -56,17 +111,33 @@ const ProductGoodsPage: React.FC = () => { const loadMatrix = async ( keyword = matrixKeyword, - categoryId = matrixCategory + categoryId = matrixCategory, + page = matrixPage, + pageSize = matrixPageSize ) => { setMatrixLoading(true); try { const res = await getPriceMatrix({ keyword: keyword || undefined, category_id: categoryId, + page, + pageSize, }); 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 ?? []); } finally { setMatrixLoading(false); @@ -75,7 +146,9 @@ const ProductGoodsPage: React.FC = () => { const openMatrix = () => { setMatrixOpen(true); - loadMatrix('', undefined); + setMatrixPage(1); + setMatrixPageSize(20); + loadMatrix('', undefined, 1, 20); }; const onMatrixPriceChange = ( @@ -88,22 +161,17 @@ const ProductGoodsPage: React.FC = () => { row.id === productId ? { ...row, [`price_${levelId}`]: value } : row ) ); + matrixDirtyRef.current[`${productId}:${levelId}`] = value; }; - /** diff 出被修改的价格行,提交批量调价 */ + /** 提交所有跨页未保存的调价(null 视为清除,不提交) */ const saveMatrix = async () => { const updates: IBatchPriceUpdate[] = []; - for (const row of matrixRows) { - const old = matrixSnapshot.find((r) => r.id === row.id); - for (const level of matrixLevels) { - const key = `price_${level.id}`; - 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 }); - } - } - } + 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 }); + }); if (updates.length === 0) { message.info('没有需要保存的价格调整'); return; @@ -112,6 +180,7 @@ const ProductGoodsPage: React.FC = () => { try { await batchPrice(updates); message.success(`已更新 ${updates.length} 条价格,受影响门店将收到通知`); + matrixDirtyRef.current = {}; await loadMatrix(); } finally { setSaveLoading(false); @@ -200,6 +269,14 @@ const ProductGoodsPage: React.FC = () => { valueType: 'text', colProps: { span: 24 }, required: true, + render: (_, record) => { + return ( +
+
{ record.name }
+
{ record.remark }
+
+ ) + }, rules: [{ required: true, message: '请输入商品名称' }], }, { @@ -240,6 +317,7 @@ const ProductGoodsPage: React.FC = () => { treeNodeFilterProp: 'name', placeholder: '选择分类', }, + hideInSearch: true, render: (_, record) => record.category ? {record.category.name} : '-', }, @@ -322,58 +400,7 @@ const ProductGoodsPage: React.FC = () => { hideInTable: true, hideInSearch: true, colProps: { span: 24 }, - fieldRender: () => ( - - {(fields, { add, remove }) => ( -
- {fields.map(({ key, name, ...restField }) => ( - - -