采购单优化

This commit is contained in:
liu
2026-08-13 00:11:44 +08:00
parent 0211e5e98d
commit 6c794d0732
6 changed files with 362 additions and 19 deletions
File diff suppressed because one or more lines are too long
@@ -259,6 +259,84 @@ class PurchaseOrderController extends BaseController
]); ]);
} }
/**
* 门店购买详情:采购单内指定门店的采购汇总(按商品聚合)
* 单价为加权平均口径(Σ金额÷Σ数量),保证 单价×数量=预计金额
*/
#[GetRoute(route: '/{id}/store', authorize: 'query', where: ['id' => '[0-9]+'])]
public function storeSummary(int $id, Request $request): JsonResponse
{
$storeId = (int) $request->query('store_id', 0);
if ($storeId <= 0) {
throw new RepositoryException('缺少门店参数');
}
$purchase = PurchaseOrderModel::find($id);
if (empty($purchase)) {
throw new RepositoryException('采购单不存在');
}
$items = StoreOrderItemModel::query()
->join('store_order', 'store_order.id', '=', 'store_order_item.order_id')
->where('store_order_item.purchase_id', $purchase->id)
->where('store_order_item.store_id', $storeId)
->whereNull('store_order.deleted_at')
->select('store_order_item.*')
->orderBy('store_order_item.id')
->get();
// 排序键:分类 sort → 商品 sort(与明细矩阵同序)
$products = ProductModel::withTrashed()
->with('category:id,sort')
->whereIn('id', $items->pluck('product_id')->unique())
->get()
->keyBy('id');
$rows = [];
foreach ($items->groupBy('product_id') as $productId => $group) {
$product = $products->get((int) $productId);
$first = $group->first();
// 采购总数量(包数)
$quantity = 0;
// 预计金额 = Σ 明细 amount
$amount = '0';
// 总重量
$weight = '0';
foreach ($group as $item) {
$weight = bcadd($weight, (string) $item->weight, 3);
$quantity += (int) $item->quantity;
$amount = bcadd($amount, (string) $item->amount, 2);
}
$rows[] = [
'product_id' => (int) $productId,
'product_name' => $first->product_name,
'product_spec' => $first->product_spec, // 包规
'unit' => $first->unit, // 单位
'price' => $quantity > 0 // 加权平均单价
? bcdiv($amount, (string) $quantity, 2)
: (string) $first->price,
'quantity' => $quantity,
'amount' => $amount,
'weight' => $weight,
'category_sort' => (int) ($product->category->sort ?? 9999),
'product_sort' => (int) ($product->sort ?? 9999),
];
}
usort($rows, static fn (array $a, array $b): int =>
[$a['category_sort'], $a['product_sort'], $a['product_id']]
<=> [$b['category_sort'], $b['product_sort'], $b['product_id']]);
$store = StoreModel::withTrashed()->find($storeId);
return $this->success([
'store' => $store ? ['id' => $store->id, 'name' => $store->name] : null,
'items' => array_map(static function (array $row): array {
unset($row['category_sort'], $row['product_sort']);
return $row;
}, $rows),
]);
}
/** /**
* 商品行修改:品名/供应商/包规/单位/成本 * 商品行修改:品名/供应商/包规/单位/成本
* @throws Throwable * @throws Throwable
+81
View File
@@ -335,6 +335,87 @@ class PurchaseEditTest extends ProcurementTestCase
$this->assertSame('40.00', (string) $purchase->actual_amount, '2 包 × 每包成本 20.00'); $this->assertSame('40.00', (string) $purchase->actual_amount, '2 包 × 每包成本 20.00');
} }
/** 门店购买详情:按商品聚合该门店采购汇总(数量/包规/单位/单价/预计金额),仅含该门店明细 */
public function test_store_summary_returns_aggregated_items(): void
{
[$purchase, $product, $stores] = $this->buildPurchase();
$this->actingAsSysUser();
$response = $this->getJson("/purchase/order/{$purchase->id}/store?store_id={$stores[0]->id}")
->assertOk()
->assertJsonPath('success', true);
$data = $response->json('data');
$this->assertSame($stores[0]->name, $data['store']['name']);
$this->assertCount(1, $data['items']);
$row = $data['items'][0];
$this->assertSame($product->id, $row['product_id']);
$this->assertSame('10斤/箱', $row['product_spec']);
$this->assertSame('斤', $row['unit']);
$this->assertSame('10.00', (string) $row['price']);
$this->assertSame(2, $row['quantity']);
$this->assertSame('20.00', (string) $row['amount']);
// 另一门店只见自身明细
$this->getJson("/purchase/order/{$purchase->id}/store?store_id={$stores[1]->id}")
->assertOk()
->assertJsonPath('data.items.0.quantity', 3)
->assertJsonPath('data.items.0.amount', '30.00');
}
/** 门店购买详情:同一商品多笔订单不同单价 → 数量合计、金额求和、单价为加权平均 */
public function test_store_summary_aggregates_multiple_orders_with_weighted_price(): void
{
$level = CustomerLevelModel::factory()->create();
$store = StoreModel::factory()->create(['level_id' => $level->id]);
$product = ProductModel::factory()->create([
'status' => ProductModel::STATUS_ON,
'cost_price' => 20,
'spec' => '10斤/箱',
'unit' => '斤',
]);
ProductPriceModel::factory()->create(['product_id' => $product->id, 'level_id' => $level->id, 'price' => 10.00]);
$this->actingAsMiniUser(UserModel::factory()->forStore($store->id)->create());
foreach ([2, 3] as $qty) {
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => $qty]]])
->assertJsonPath('success', true);
}
StoreOrderModel::query()->update(['status' => StoreOrderModel::STATUS_SUMMARIZED]);
$this->actingAsSysUser();
$this->postJson('/purchase/order/generate', ['purchase_date' => now()->toDateString()])
->assertJsonPath('success', true);
$purchase = PurchaseOrderModel::first();
// 第二笔明细单价改为 20.00,构造同商品双单价:2×10 + 3×20
$secondItem = StoreOrderItemModel::orderBy('id')->skip(1)->first();
$this->putJson("/purchase/order/cell/{$secondItem->id}", ['quantity' => 3, 'price' => 20])
->assertJsonPath('success', true);
$this->getJson("/purchase/order/{$purchase->id}/store?store_id={$store->id}")
->assertOk()
->assertJsonPath('data.items.0.quantity', 5)
->assertJsonPath('data.items.0.price', '16.00', '(20+60) ÷ 5 加权平均')
->assertJsonPath('data.items.0.amount', '80.00');
}
/** 门店购买详情参数校验:缺少门店参数 / 采购单不存在均拒绝 */
public function test_store_summary_rejects_missing_or_invalid_params(): void
{
[$purchase] = $this->buildPurchase();
$this->actingAsSysUser();
$this->getJson("/purchase/order/{$purchase->id}/store")
->assertJsonPath('success', false)
->assertJsonPath('msg', '缺少门店参数');
$this->getJson('/purchase/order/99999/store?store_id=1')
->assertJsonPath('success', false)
->assertJsonPath('msg', '采购单不存在');
}
/** 采购单已完成:单元格编辑/同步、行修改均拒绝,明细不变 */ /** 采购单已完成:单元格编辑/同步、行修改均拒绝,明细不变 */
public function test_edits_rejected_when_purchase_completed(): void public function test_edits_rejected_when_purchase_completed(): void
{ {
+10
View File
@@ -2,6 +2,7 @@ import createAxios from '@/utils/request';
import type { import type {
IPurchaseCell, IPurchaseCell,
IPurchaseDetail, IPurchaseDetail,
IPurchaseStoreSummary,
} from '@/domain/iPurchaseOrder.ts'; } from '@/domain/iPurchaseOrder.ts';
/** 订单商品参数修改 */ /** 订单商品参数修改 */
@@ -64,3 +65,12 @@ export async function updatePurchaseCellItem(itemId: number, data: PurchaseCellU
data, data,
}); });
} }
/** 门店购买详情:采购单内某门店的采购汇总(按商品聚合) */
export async function getPurchaseStoreSummary(purchaseId: number, storeId: number) {
return createAxios<IPurchaseStoreSummary>({
url: `/purchase/order/${purchaseId}/store`,
method: 'get',
params: { store_id: storeId },
});
}
+22
View File
@@ -84,6 +84,28 @@ export interface IPurchaseCell {
items: IPurchaseCellItem[]; items: IPurchaseCellItem[];
} }
/** 门店购买详情行(门店采购汇总,按商品聚合) */
export interface IPurchaseStoreItem {
product_id: number;
product_name: string;
/** 规格/包规 */
product_spec: string;
unit: string;
/** 单价(每包;多笔单价时为加权平均,保证 单价×数量=预计金额) */
price: string;
/** 数量(包数) */
quantity: number;
/** 预计金额 = Σ 明细 amount */
amount: string;
weight: string;
}
/** 门店购买详情(采购单内某门店的采购汇总) */
export interface IPurchaseStoreSummary {
store: { id: number; name: string } | null;
items: IPurchaseStoreItem[];
}
export const PURCHASE_STATUS_MAP: Record<number, { text: string; color: string }> = { export const PURCHASE_STATUS_MAP: Record<number, { text: string; color: string }> = {
0: { text: '进行中', color: 'processing' }, 0: { text: '进行中', color: 'processing' },
3: { text: '已完成', color: 'success' }, 3: { text: '已完成', color: 'success' },
+170 -18
View File
@@ -15,6 +15,7 @@ import {
Space, Space,
Spin, Spin,
Table, Table,
Tabs,
Tag, Tag,
Typography, Typography,
} from 'antd'; } from 'antd';
@@ -32,12 +33,16 @@ import type {
IPurchaseCellItem, IPurchaseCellItem,
IPurchaseDetail, IPurchaseDetail,
IPurchaseDetailRow, IPurchaseDetailRow,
IPurchaseStoreItem,
IPurchaseStoreSummary,
} from '@/domain/iPurchaseOrder.ts'; } from '@/domain/iPurchaseOrder.ts';
import { PURCHASE_STATUS_MAP } from '@/domain/iPurchaseOrder.ts'; import { PURCHASE_STATUS_MAP } from '@/domain/iPurchaseOrder.ts';
import { STORE_ORDER_STATUS_MAP } from '@/domain/iStoreOrder.ts'; import { STORE_ORDER_STATUS_MAP } from '@/domain/iStoreOrder.ts';
import { import {
getPurchaseCell, getPurchaseCell,
getPurchaseDetail, type PurchaseCellUpdateParams, type PurchaseRowUpdateParams, getPurchaseDetail,
getPurchaseStoreSummary,
type PurchaseCellUpdateParams, type PurchaseRowUpdateParams,
updatePurchaseCellItem, updatePurchaseCellItem,
updatePurchaseRow, updatePurchaseRow,
} from '@/api/purchase/order.ts'; } from '@/api/purchase/order.ts';
@@ -48,10 +53,10 @@ import AuthButton from '@/components/AuthButton';
const { Title, Text } = Typography; const { Title, Text } = Typography;
/** 参考价 = 成本 / 包规数值(包规解析不出正数时按 1 处理,与后端同口径;仅展示参考,不参与金额计算) */ /** 每单位参考价 = 整单价(成本/售价) ÷ 包规数值(包规解析不出正数时按 1 处理,与后端同口径;仅展示参考,不参与金额计算) */
const calcUnitCost = (cost: number, spec: string): number => { const calcUnitRefPrice = (total: number, spec: string): number => {
const pack = parseFloat(spec); const pack = parseFloat(spec);
return Number.isFinite(pack) && pack > 0 ? cost / pack : cost; return Number.isFinite(pack) && pack > 0 ? total / pack : total;
}; };
/** /**
@@ -85,6 +90,12 @@ const PurchaseOrderPage: React.FC = () => {
const [cellItemSaving, setCellItemSaving] = useState(false); const [cellItemSaving, setCellItemSaving] = useState(false);
const [cellItemForm] = Form.useForm<PurchaseCellUpdateParams>(); const [cellItemForm] = Form.useForm<PurchaseCellUpdateParams>();
// 门店购买详情(按商品聚合的门店采购汇总)
const [detailTab, setDetailTab] = useState('items');
const [storeId, setStoreId] = useState<number>(0);
const [storeSummary, setStoreSummary] = useState<IPurchaseStoreSummary | null>(null);
const [storeLoading, setStoreLoading] = useState(false);
useEffect(() => { useEffect(() => {
getSupplierOptions().then((res) => setSuppliers(res.data.data ?? [])); getSupplierOptions().then((res) => setSuppliers(res.data.data ?? []));
}, []); }, []);
@@ -96,6 +107,20 @@ const PurchaseOrderPage: React.FC = () => {
} }
}, [cellOpen, cellQuery]); }, [cellOpen, cellQuery]);
// 详情加载后默认选中第一个门店(当前选中门店仍在采购单内则保留)
useEffect(() => {
if (detail && detail.stores.length > 0) {
setStoreId((prev) => (detail.stores.some((s) => s.id === prev) ? prev : detail.stores[0].id));
}
}, [detail]);
// 切到「门店购买详情」页签或切换门店时加载汇总
useEffect(() => {
if (detailOpen && detailTab === 'stores' && detail && storeId > 0) {
void loadStoreSummary();
}
}, [detailOpen, detailTab, storeId, detail?.purchase.id]);
const loadDetail = async (id: number) => { const loadDetail = async (id: number) => {
setDetailLoading(true); setDetailLoading(true);
try { try {
@@ -107,10 +132,26 @@ const PurchaseOrderPage: React.FC = () => {
}; };
const openDetail = async (id: number) => { const openDetail = async (id: number) => {
setDetailTab('items');
setStoreSummary(null);
setDetailOpen(true); setDetailOpen(true);
await loadDetail(id); await loadDetail(id);
}; };
/** 加载门店购买详情(门店采购汇总) */
const loadStoreSummary = async () => {
if (!detail || storeId <= 0) {
return;
}
setStoreLoading(true);
try {
const res = await getPurchaseStoreSummary(detail.purchase.id!, storeId);
setStoreSummary(res.data.data ?? null);
} finally {
setStoreLoading(false);
}
};
/** 打开单元格下钻:门店 + 商品 → 该采购单下全部订货明细 */ /** 打开单元格下钻:门店 + 商品 → 该采购单下全部订货明细 */
const openCell = (row: IPurchaseDetailRow, store: { id: number; name: string }) => { const openCell = (row: IPurchaseDetailRow, store: { id: number; name: string }) => {
setCellQuery({ productId: row.product_id, storeId: store.id }); setCellQuery({ productId: row.product_id, storeId: store.id });
@@ -226,11 +267,11 @@ const PurchaseOrderPage: React.FC = () => {
render: (_, row) => row.supplier?.name ?? '-', render: (_, row) => row.supplier?.name ?? '-',
}, },
{ {
title: '参考单价', title: '参考成本单价',
key: 'unit_cost', key: 'unit_cost',
width: 100, width: 100,
align: 'center', align: 'center',
render: (_, row) => `¥${calcUnitCost(Number(row.cost_price), row.product_spec ?? '').toFixed(2)}`, render: (_, row) => `¥${calcUnitRefPrice(Number(row.cost_price), row.product_spec ?? '').toFixed(2)}`,
}, },
{ title: '包规', dataIndex: 'product_spec', align: 'center', width: 90, render: (v) => v || '-' }, { title: '包规', dataIndex: 'product_spec', align: 'center', width: 90, render: (v) => v || '-' },
{ title: '单位', dataIndex: 'unit', width: 70, align: 'center', render: (v) => v || '-' }, { title: '单位', dataIndex: 'unit', width: 70, align: 'center', render: (v) => v || '-' },
@@ -317,6 +358,66 @@ const PurchaseOrderPage: React.FC = () => {
); );
}; };
/** 门店购买详情列:商品/购买参考零售价(单价÷包规)/包规/单位/单价/数量/预计金额 */
const storeColumns: TableProps<IPurchaseStoreItem>['columns'] = [
{ title: '商品', dataIndex: 'product_name', width: 160, align: 'center' },
{
title: '参考零售价',
key: 'retail_price',
width: 130,
align: 'center',
render: (_, row) => `¥${calcUnitRefPrice(Number(row.price), row.product_spec).toFixed(2)}`,
},
{ title: '包规', dataIndex: 'product_spec', width: 90, align: 'center', render: (v) => v || '-' },
{ title: '单位', dataIndex: 'unit', width: 90, align: 'center', render: (v) => v || '-' },
{
title: '单价',
dataIndex: 'price',
width: 100,
align: 'center',
render: (v) => `¥${Number(v).toFixed(2)}`,
},
{
title: '数量',
dataIndex: 'quantity',
width: 90,
align: 'center',
render: (v) => <Text strong>{v}</Text>,
},
{ title: '重量', dataIndex: 'weight', width: 90, align: 'center', render: (v) => `${v || '-'}` },
{
title: '预计金额',
dataIndex: 'amount',
width: 110,
align: 'center',
render: (v) => <Text strong>¥{Number(v).toFixed(2)}</Text>,
},
];
/** 门店购买详情合计行:总数量 + 总预计金额 */
const renderStoreTotal = () => {
const items = storeSummary?.items ?? [];
const totalQuantity = items.reduce((sum, row) => sum + Number(row.quantity), 0);
const totalAmount = items.reduce((sum, row) => sum + Number(row.amount), 0);
const totalWeight = items.reduce((sum, row) => sum + Number(row.weight), 0);
return (
<Table.Summary.Row>
<Table.Summary.Cell index={0} colSpan={5} align="center">
<Text strong></Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={5} align="center">
<Text strong>{totalQuantity}</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={6} align="center">
<Text strong>{totalWeight.toFixed(3)}</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={7} align="center">
<Text strong type="danger">¥{totalAmount.toFixed(2)}</Text>
</Table.Summary.Cell>
</Table.Summary.Row>
);
};
const columns: XinTableColumn<IPurchaseOrder>[] = [ const columns: XinTableColumn<IPurchaseOrder>[] = [
{ {
title: '采购单号', title: '采购单号',
@@ -444,18 +545,69 @@ const PurchaseOrderPage: React.FC = () => {
<Descriptions.Item label="总重量">{detail.purchase.total_weight}</Descriptions.Item> <Descriptions.Item label="总重量">{detail.purchase.total_weight}</Descriptions.Item>
</Descriptions> </Descriptions>
<Title level={5} className="mt-5! mb-3!"> <Tabs
activeKey={detailTab}
</Title> onChange={setDetailTab}
<Table<IPurchaseDetailRow> className="mt-3!"
rowKey="product_id" items={[
size="small" {
bordered key: 'items',
columns={buildItemColumns()} label: '商品明细',
dataSource={detail.items} children: (
pagination={false} <Table<IPurchaseDetailRow>
scroll={{ x: 'max-content' }} rowKey="product_id"
summary={renderSummary} size="small"
bordered
columns={buildItemColumns()}
dataSource={detail.items}
pagination={false}
scroll={{ x: 'max-content' }}
summary={renderSummary}
/>
),
},
{
key: 'stores',
label: '门店购买详情',
children: (
<>
<div className="mb-3 flex items-center gap-2">
<Text></Text>
<Select
value={storeId || undefined}
onChange={(value) => setStoreId(value)}
placeholder="选择门店"
className="w-60!"
showSearch
optionFilterProp="label"
options={detail.stores.map((s) => ({ value: s.id, label: s.name }))}
/>
</div>
<Spin spinning={storeLoading}>
{storeSummary && storeSummary.items.length > 0 ? (
<Table<IPurchaseStoreItem>
rowKey="product_id"
size="small"
bordered
columns={storeColumns}
dataSource={storeSummary.items}
pagination={false}
summary={renderStoreTotal}
/>
) : (
!storeLoading && (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="该门店在此采购单中无采购商品"
className="py-8!"
/>
)
)}
</Spin>
</>
),
},
]}
/> />
</> </>
)} )}