采购单优化

This commit is contained in:
liu
2026-08-17 18:13:33 +08:00
parent a06c45dc49
commit 3dbb9205a4
16 changed files with 2215 additions and 437 deletions
File diff suppressed because one or more lines are too long
+188 -101
View File
@@ -2,6 +2,7 @@
namespace App\Exports;
use App\Exceptions\RepositoryException;
use App\Models\ProductCategoryModel;
use App\Models\ProductModel;
use App\Models\PurchaseOrderModel;
@@ -10,44 +11,54 @@ use App\Models\StoreOrderItemModel;
use App\Models\SupplierModel;
use Illuminate\Support\Collection;
use Maatwebsite\Excel\Concerns\FromCollection;
use Maatwebsite\Excel\Concerns\WithHeadings;
use Maatwebsite\Excel\Concerns\WithMapping;
use Maatwebsite\Excel\Concerns\WithStrictNullComparison;
use Maatwebsite\Excel\Concerns\WithStyles;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
/**
* 采购单导出
* 采购单商品明细导出:系统全部商品行(含本采购单无订货的商品,数量 0),
* 支持按供应商筛选;行尾合计 + 门店列合计;门店列整列填充突出颜色
*/
class PurchaseOrderExport implements FromCollection, WithHeadings, WithMapping, WithStyles
class PurchaseOrderExport implements FromCollection, WithStrictNullComparison, WithStyles
{
/** @var array<int, string> 商品ID => 顶级分类名 */
private array $categoryNames = [];
/** 门店列填充色(浅橙,突出显示) */
private const string STORE_FILL = 'FFFFF7E6';
/** @var array<int, string> 供应商ID => 名称 */
private array $supplierNames = [];
private ?Collection $rows = null;
/** @var array<int, string> 特殊行索引(1 起)=> 类型(title/scope/header/summary */
private array $specialRows = [];
/** 列头所在行索引 */
private int $headerRow = 1;
/** 数据末行索引 */
private int $lastRow = 1;
/** @var array<int, string> 门店ID => 名称(导出列) */
private array $storeNames = [];
private ?Collection $items = null;
/**
* @param PurchaseOrderModel $purchase 采购单
* @param int $supplierId 供应商筛选(0=全部)
*/
public function __construct(
private readonly PurchaseOrderModel $purchase,
private readonly int $supplierId = 0,
) {
}
/**
* 导出行(商品聚合行,按 分类sort → 商品sort 排序
* 导出行:标题/范围/列头/明细/合计全部手工构建(行位置不固定,不用 WithHeadings
*/
public function collection(): Collection
{
if ($this->items !== null) {
return $this->items;
if ($this->rows !== null) {
return $this->rows;
}
// 本采购单订货明细(关联订单过滤软删)
$orderItems = StoreOrderItemModel::query()
->join('store_order', 'store_order.id', '=', 'store_order_item.order_id')
->where('store_order_item.purchase_id', $this->purchase->id)
@@ -61,135 +72,210 @@ class PurchaseOrderExport implements FromCollection, WithHeadings, WithMapping,
->pluck('name', 'id')
->toArray();
$products = ProductModel::withTrashed()
->with('category:id,sort')
->whereIn('id', $orderItems->pluck('product_id')->unique())
->get()
->keyBy('id');
$rows = [];
// 明细按商品聚合(快照字段取首条;有订货的商品以快照供应商为准)
$itemGroups = [];
foreach ($orderItems->groupBy('product_id') as $productId => $group) {
$first = $group->first();
$product = $products->get((int) $productId);
$unitCost = $first->cost_price ?? '0';
$quantity = '0';
$weight = '0';
$amount = '0';
$storeQuantities = array_fill_keys(array_keys($this->storeNames), '0');
$storeQuantities = array_fill_keys(array_keys($this->storeNames), 0);
foreach ($group as $item) {
$quantity = bcadd($quantity, (string) $item->quantity, 2);
$weight = bcadd($weight, (string) $item->weight, 3);
// 金额 = 数量 × 每包成本价(单价/包规不参与金额计算)
$amount = bcadd($amount, bcmul((string) $item->quantity, (string) $item->cost_price, 2), 2);
$storeQuantities[(int) $item->store_id] = bcadd(
$storeQuantities[(int) $item->store_id] ?? '0',
(string) $item->quantity,
2
);
$storeQuantities[(int) $item->store_id] = ($storeQuantities[(int) $item->store_id] ?? 0) + (int) $item->quantity;
}
$itemGroups[(int) $productId] = [
'snapshot' => $first,
'quantity' => $quantity,
'weight' => $weight,
'amount' => $amount,
'store_quantities' => $storeQuantities,
];
}
// 导出范围:系统全部上架商品 ∪ 本采购单有订货的商品(含已删/下架)
$products = ProductModel::withTrashed()
->with('category:id,sort')
->where('status', ProductModel::STATUS_ON)
->orWhereIn('id', array_keys($itemGroups))
->get()
->keyBy('id');
$categoryNames = $this->rootCategoryNames($products->pluck('category_id', 'id')->all());
$supplierNames = SupplierModel::withTrashed()->pluck('name', 'id')->toArray();
// 组装商品行(供应商筛选:有订货按快照 supplier_id,无订货按档案 supplier_id
$items = [];
foreach ($products as $productId => $product) {
$group = $itemGroups[(int) $productId] ?? null;
$snapshot = $group['snapshot'] ?? null;
$rowSupplierId = (int) ($snapshot->supplier_id ?? $product->supplier_id);
if ($this->supplierId > 0 && $rowSupplierId !== $this->supplierId) {
continue;
}
$rows[] = [
$quantity = $group['quantity'] ?? '0';
$amount = $group['amount'] ?? '0';
$spec = (string) ($snapshot->product_spec ?? $product->spec);
$items[] = [
'product_id' => (int) $productId,
'supplier_id' => (int) $first->supplier_id,
'product_name' => $first->product_name,
'product_spec' => $first->product_spec,
'unit' => $first->unit,
'cost_price' => (float) ($first->cost_price ?? 0),
'unit_cost' => (float) $unitCost,
'category' => $categoryNames[(int) $productId] ?? '',
'product_name' => (string) ($snapshot->product_name ?? $product->name),
'supplier' => $supplierNames[$rowSupplierId] ?? '',
'product_spec' => $spec,
'unit' => (string) ($snapshot->unit ?? $product->unit),
'cost_price' => (float) ($snapshot->cost_price ?? $product->cost_price),
// 参考零售价 = 加权平均售价 ÷ 包规数值(无订货行无售价数据,留空)
'retail_price' => $group !== null && (float) $quantity > 0
? $this->unitRefPrice((float) bcdiv($amount, $quantity, 4), $spec)
: null,
'quantity' => (float) $quantity,
'weight' => (float) $weight,
'weight' => (float) ($group['weight'] ?? '0'),
'amount' => (float) $amount,
'store_quantities' => array_map('floatval', $storeQuantities),
'store_quantities' => $group['store_quantities']
?? array_fill_keys(array_keys($this->storeNames), 0),
'category_sort' => (int) ($product->category->sort ?? 9999),
'product_sort' => (int) ($product->sort ?? 9999),
];
}
usort($rows, static fn (array $a, array $b): int =>
usort($items, 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']]);
$items = collect($rows);
$this->loadLookups($items);
if ($items === []) {
throw new RepositoryException('该供应商在系统中无商品行,无法导出');
}
$sort = 1;
return $this->items = $items->map(static function (array $row) use (&$sort): array {
$row['sort'] = $sort++;
return $row;
})->values();
}
// ===== 手工建行 =====
$rows = [];
$rowIndex = 0;
public function headings(): array
{
$this->collection();
// 标题行
$rows[] = ['采购单 ' . $this->purchase->purchase_no . '(采购日期 ' . $this->purchase->purchase_date->toDateString() . ''];
$this->specialRows[++$rowIndex] = 'title';
return array_merge(
['序号', '分类', '品名', '供应商', '包规', '单位', '成本', '单价', '数量', '实际称重', '金额'],
// 范围行:供应商筛选 / 导出时间
$scopeSupplier = '全部';
if ($this->supplierId > 0) {
$scopeSupplier = $supplierNames[$this->supplierId] ?? ('供应商#' . $this->supplierId);
}
$rows[] = ['供应商:' . $scopeSupplier . ' 导出时间:' . now()->format('Y-m-d H:i:s')];
$this->specialRows[++$rowIndex] = 'scope';
// 空行
$rows[] = [''];
$rowIndex++;
// 列头
$rows[] = array_merge(
['序号', '分类', '品名', '供应商', '包规', '单位', '成本', '参考零售价', '数量', '实际称重', '金额'],
array_values($this->storeNames),
);
}
$this->specialRows[++$rowIndex] = 'header';
$this->headerRow = $rowIndex;
public function map($row): array
{
return array_merge([
$row['sort'],
$this->categoryNames[$row['product_id']] ?? '',
$row['product_name'],
$this->supplierNames[$row['supplier_id']] ?? '',
$row['product_spec'],
$row['unit'],
$row['cost_price'],
$row['unit_cost'],
$row['quantity'],
$row['weight'],
$row['amount'],
], array_values($row['store_quantities']));
// 明细行
$totalQuantity = '0';
$totalWeight = '0';
$totalAmount = '0';
$storeTotals = array_fill_keys(array_keys($this->storeNames), 0);
foreach (array_values($items) as $sort => $item) {
$rows[] = array_merge([
$sort + 1,
$item['category'],
$item['product_name'],
$item['supplier'],
$item['product_spec'],
$item['unit'],
$item['cost_price'],
$item['retail_price'] !== null ? $item['retail_price'] : '',
$item['quantity'],
$item['weight'],
$item['amount'],
], array_values($item['store_quantities']));
$rowIndex++;
$totalQuantity = bcadd($totalQuantity, (string) $item['quantity'], 2);
$totalWeight = bcadd($totalWeight, (string) $item['weight'], 3);
$totalAmount = bcadd($totalAmount, (string) $item['amount'], 2);
foreach ($item['store_quantities'] as $storeId => $qty) {
$storeTotals[$storeId] += $qty;
}
}
// 合计行(行合计 + 门店列合计)
$rows[] = array_merge(
['', '', '合计', '', '', '', '', '', (float) $totalQuantity, (float) $totalWeight, (float) $totalAmount],
array_values($storeTotals),
);
$this->specialRows[++$rowIndex] = 'summary';
$this->lastRow = $rowIndex;
return $this->rows = collect($rows);
}
/**
* 表头加粗 + 冻结首行
* 标题/列头/合计加粗,门店列整列填充突出颜色,冻结列头
*/
public function styles(Worksheet $sheet): array
{
$sheet->freezePane('A2');
$this->collection();
return [
1 => ['font' => ['bold' => true]],
];
$sheet->freezePane('A' . ($this->headerRow + 1));
$widths = [6, 10, 20, 12, 12, 8, 10, 12, 10, 12, 12];
foreach ($widths as $index => $width) {
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex($index + 1))->setWidth($width);
}
// 门店列整列(列头 → 合计行)填充突出颜色
$storeCount = count($this->storeNames);
for ($i = 0; $i < $storeCount; $i++) {
$column = Coordinate::stringFromColumnIndex(12 + $i);
$sheet->getColumnDimension($column)->setWidth(12);
$sheet->getStyle($column . $this->headerRow . ':' . $column . $this->lastRow)
->getFill()
->setFillType(\PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID)
->getStartColor()
->setARGB(self::STORE_FILL);
}
$styles = [];
foreach ($this->specialRows as $row => $type) {
$style = match ($type) {
'title' => ['font' => ['bold' => true, 'size' => 14]],
'header', 'summary' => ['font' => ['bold' => true]],
default => [],
};
if ($style !== []) {
$styles[$row] = $style;
}
}
return $styles;
}
/**
* PDF 模板视图数据
* 每单位参考价 = 整单价 ÷ 包规数值(包规解析不出正数时按 1 处理;仅展示参考)
*/
private function unitRefPrice(float $price, string $spec): float
{
$pack = (float) preg_replace('/[^0-9.].*$/', '', $spec);
return $pack > 0 ? round($price / $pack, 2) : round($price, 2);
}
/**
* 商品ID => 顶级分类名(沿 parent_id 上溯取根分类)
*
* @return array{purchase: PurchaseOrderModel, items: Collection, storeNames: array<int, string>, categoryNames: array<int, string>, supplierNames: array<int, string>}
* @param array<int, int> $productCategoryIds 商品ID => 分类ID
* @return array<int, string>
*/
public function viewData(): array
private function rootCategoryNames(array $productCategoryIds): array
{
return [
'purchase' => $this->purchase,
'items' => $this->collection(),
'storeNames' => $this->storeNames,
'categoryNames' => $this->categoryNames,
'supplierNames' => $this->supplierNames,
];
}
/**
* 预加载供应商名与商品顶级分类名(商品/供应商均含软删除,保证历史单据可导出)
*/
private function loadLookups(Collection $items): void
{
$this->supplierNames = SupplierModel::withTrashed()
->whereIn('id', $items->pluck('supplier_id')->unique())
->pluck('name', 'id')
->toArray();
$productCategoryIds = ProductModel::withTrashed()
->whereIn('id', $items->pluck('product_id')->unique())
->pluck('category_id', 'id');
$categories = ProductCategoryModel::all()->keyBy('id');
$this->categoryNames = [];
$names = [];
foreach ($productCategoryIds as $productId => $categoryId) {
$rootName = '';
$cursor = (int) $categoryId;
@@ -202,7 +288,8 @@ class PurchaseOrderExport implements FromCollection, WithHeadings, WithMapping,
$rootName = $category->name;
$cursor = (int) $category->parent_id;
}
$this->categoryNames[(int) $productId] = $rootName;
$names[(int) $productId] = $rootName;
}
return $names;
}
}
+65
View File
@@ -0,0 +1,65 @@
<?php
namespace App\Exports;
use App\Exceptions\RepositoryException;
use App\Models\PurchaseOrderModel;
use App\Models\StoreModel;
use App\Models\StoreOrderItemModel;
use Maatwebsite\Excel\Concerns\WithMultipleSheets;
/**
* 门店购买详情导出:多门店合并为一个 XLSX(每门店一个工作表,工作表名=门店名称);
* 构造传入 storeId 时仅导出该门店(单工作表)
*/
class PurchaseStoreExport implements WithMultipleSheets
{
/**
* @param PurchaseOrderModel $purchase 采购单
* @param int $storeId 单门店导出(0=全部门店)
*/
public function __construct(
private readonly PurchaseOrderModel $purchase,
private readonly int $storeId = 0,
) {
}
/**
* @return array<int, PurchaseStoreSheet>
*/
public function sheets(): array
{
// 本采购单内有明细的门店(含软删除,保证历史单据可导出)
$query = StoreOrderItemModel::query()
->join('store_order', 'store_order.id', '=', 'store_order_item.order_id')
->where('store_order_item.purchase_id', $this->purchase->id)
->whereNull('store_order.deleted_at');
if ($this->storeId > 0) {
$query->where('store_order_item.store_id', $this->storeId);
}
$storeIds = $query->distinct()->pluck('store_order_item.store_id');
if ($storeIds->isEmpty()) {
throw new RepositoryException(
$this->storeId > 0 ? '该门店在此采购单中无采购商品' : '该采购单无门店采购明细,无法导出'
);
}
$stores = StoreModel::withTrashed()
->whereIn('id', $storeIds)
->orderBy('id')
->get(['id', 'name']);
$usedNames = [];
$sheets = [];
foreach ($stores as $store) {
$sheets[] = new PurchaseStoreSheet(
$this->purchase,
$store,
SheetName::make((string) $store->name, (int) $store->id, $usedNames),
);
}
return $sheets;
}
}
+123
View File
@@ -0,0 +1,123 @@
<?php
namespace App\Exports;
use App\Models\PurchaseOrderModel;
use App\Models\StoreModel;
use App\Services\PurchaseItemService;
use Illuminate\Support\Collection;
use Maatwebsite\Excel\Concerns\FromCollection;
use Maatwebsite\Excel\Concerns\WithStrictNullComparison;
use Maatwebsite\Excel\Concerns\WithStyles;
use Maatwebsite\Excel\Concerns\WithTitle;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
/**
* 门店购买详情导出 · 单门店工作表
*/
class PurchaseStoreSheet implements FromCollection, WithStrictNullComparison, WithStyles, WithTitle
{
private ?Collection $rows = null;
/** @var array<int, string> 特殊行索引(1 起)=> 类型(title/header/summary */
private array $specialRows = [];
/** 列头所在行索引 */
private int $headerRow = 1;
public function __construct(
private readonly PurchaseOrderModel $purchase,
private readonly StoreModel $store,
private readonly string $sheetTitle,
) {
}
/**
* 导出行:标题/空行/列头/明细/合计
*/
public function collection(): Collection
{
if ($this->rows !== null) {
return $this->rows;
}
$items = app(PurchaseItemService::class)->storeSummaryRows($this->purchase->id, $this->store->id);
$rows = [];
$rowIndex = 0;
// 标题行
$rows[] = [$this->store->name . ' · 采购单 ' . $this->purchase->purchase_no];
$this->specialRows[++$rowIndex] = 'title';
// 空行
$rows[] = [''];
$rowIndex++;
// 列头
$rows[] = ['序号', '品名', '包规', '单位', '单价', '数量', '重量(斤)', '预计金额'];
$this->specialRows[++$rowIndex] = 'header';
$this->headerRow = $rowIndex;
// 明细行
$totalQuantity = 0;
$totalWeight = '0';
$totalAmount = '0';
foreach (array_values($items) as $sort => $item) {
$rows[] = [
$sort + 1,
$item['product_name'],
$item['product_spec'],
$item['unit'],
(float) $item['price'],
(int) $item['quantity'],
(float) $item['weight'],
(float) $item['amount'],
];
$rowIndex++;
$totalQuantity += (int) $item['quantity'];
$totalWeight = bcadd($totalWeight, (string) $item['weight'], 3);
$totalAmount = bcadd($totalAmount, (string) $item['amount'], 2);
}
// 合计行
$rows[] = ['', '合计', '', '', '', $totalQuantity, (float) $totalWeight, (float) $totalAmount];
$this->specialRows[++$rowIndex] = 'summary';
return $this->rows = collect($rows);
}
public function title(): string
{
return $this->sheetTitle;
}
/**
* 标题/列头/合计加粗,冻结列头
*/
public function styles(Worksheet $sheet): array
{
$this->collection();
$sheet->freezePane('A' . ($this->headerRow + 1));
$widths = [6, 24, 14, 8, 10, 10, 12, 12];
foreach ($widths as $index => $width) {
$sheet->getColumnDimension(\PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($index + 1))
->setWidth($width);
}
$styles = [];
foreach ($this->specialRows as $row => $type) {
$style = match ($type) {
'title' => ['font' => ['bold' => true, 'size' => 14]],
'header', 'summary' => ['font' => ['bold' => true]],
default => [],
};
if ($style !== []) {
$styles[$row] = $style;
}
}
return $styles;
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
namespace App\Exports;
use App\Exceptions\RepositoryException;
use App\Models\PurchaseOrderModel;
use App\Models\StoreOrderItemModel;
use App\Models\SupplierModel;
use Maatwebsite\Excel\Concerns\WithMultipleSheets;
/**
* 供应商采购明细导出:多供应商合并为一个 XLSX(每供应商一个工作表,工作表名=供应商名称);
* 构造传入 supplierId 时仅导出该供应商(单工作表)
*/
class PurchaseSupplierExport implements WithMultipleSheets
{
/**
* @param PurchaseOrderModel $purchase 采购单
* @param int $supplierId 单供应商导出(0=全部供应商)
*/
public function __construct(
private readonly PurchaseOrderModel $purchase,
private readonly int $supplierId = 0,
) {
}
/**
* @return array<int, PurchaseSupplierSheet>
*/
public function sheets(): array
{
// 本采购单内有明细的供应商(按订货明细快照 supplier_id 归集)
$query = StoreOrderItemModel::query()
->join('store_order', 'store_order.id', '=', 'store_order_item.order_id')
->where('store_order_item.purchase_id', $this->purchase->id)
->whereNull('store_order.deleted_at')
->where('store_order_item.supplier_id', '>', 0);
if ($this->supplierId > 0) {
$query->where('store_order_item.supplier_id', $this->supplierId);
}
$supplierIds = $query->distinct()->pluck('store_order_item.supplier_id');
if ($supplierIds->isEmpty()) {
throw new RepositoryException(
$this->supplierId > 0 ? '该供应商在此采购单中无采购明细' : '该采购单无供应商采购明细,无法导出'
);
}
$suppliers = SupplierModel::withTrashed()
->whereIn('id', $supplierIds)
->orderBy('id')
->get(['id', 'name']);
$usedNames = [];
$sheets = [];
foreach ($suppliers as $supplier) {
$sheets[] = new PurchaseSupplierSheet(
$this->purchase,
$supplier,
SheetName::make((string) $supplier->name, (int) $supplier->id, $usedNames),
);
}
return $sheets;
}
}
+123
View File
@@ -0,0 +1,123 @@
<?php
namespace App\Exports;
use App\Models\PurchaseOrderModel;
use App\Models\SupplierModel;
use App\Services\PurchaseItemService;
use Illuminate\Support\Collection;
use Maatwebsite\Excel\Concerns\FromCollection;
use Maatwebsite\Excel\Concerns\WithStrictNullComparison;
use Maatwebsite\Excel\Concerns\WithStyles;
use Maatwebsite\Excel\Concerns\WithTitle;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
/**
* 供应商采购明细导出 · 单供应商工作表(成本口径:金额=Σ数量×成本价)
*/
class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison, WithStyles, WithTitle
{
private ?Collection $rows = null;
/** @var array<int, string> 特殊行索引(1 起)=> 类型(title/header/summary */
private array $specialRows = [];
/** 列头所在行索引 */
private int $headerRow = 1;
public function __construct(
private readonly PurchaseOrderModel $purchase,
private readonly SupplierModel $supplier,
private readonly string $sheetTitle,
) {
}
/**
* 导出行:标题/空行/列头/明细/合计
*/
public function collection(): Collection
{
if ($this->rows !== null) {
return $this->rows;
}
$items = app(PurchaseItemService::class)->supplierRows($this->purchase->id, $this->supplier->id);
$rows = [];
$rowIndex = 0;
// 标题行
$rows[] = [$this->supplier->name . ' · 采购单 ' . $this->purchase->purchase_no];
$this->specialRows[++$rowIndex] = 'title';
// 空行
$rows[] = [''];
$rowIndex++;
// 列头
$rows[] = ['序号', '品名', '包规', '单位', '成本价', '数量', '重量(斤)', '金额'];
$this->specialRows[++$rowIndex] = 'header';
$this->headerRow = $rowIndex;
// 明细行
$totalQuantity = 0;
$totalWeight = '0';
$totalAmount = '0';
foreach (array_values($items) as $sort => $item) {
$rows[] = [
$sort + 1,
$item['product_name'],
$item['product_spec'],
$item['unit'],
(float) $item['cost_price'],
(int) $item['quantity'],
(float) $item['weight'],
(float) $item['amount'],
];
$rowIndex++;
$totalQuantity += (int) $item['quantity'];
$totalWeight = bcadd($totalWeight, (string) $item['weight'], 3);
$totalAmount = bcadd($totalAmount, (string) $item['amount'], 2);
}
// 合计行
$rows[] = ['', '合计', '', '', '', $totalQuantity, (float) $totalWeight, (float) $totalAmount];
$this->specialRows[++$rowIndex] = 'summary';
return $this->rows = collect($rows);
}
public function title(): string
{
return $this->sheetTitle;
}
/**
* 标题/列头/合计加粗,冻结列头
*/
public function styles(Worksheet $sheet): array
{
$this->collection();
$sheet->freezePane('A' . ($this->headerRow + 1));
$widths = [6, 24, 14, 8, 10, 10, 12, 12];
foreach ($widths as $index => $width) {
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex($index + 1))->setWidth($width);
}
$styles = [];
foreach ($this->specialRows as $row => $type) {
$style = match ($type) {
'title' => ['font' => ['bold' => true, 'size' => 14]],
'header', 'summary' => ['font' => ['bold' => true]],
default => [],
};
if ($style !== []) {
$styles[$row] = $style;
}
}
return $styles;
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace App\Exports;
/**
* Excel 工作表名合法化:剥离非法字符、31 字截断、重名追加 #id
*/
final class SheetName
{
/**
* @param string $name 原始名称(门店名/供应商名)
* @param int $id 实体ID(重名时追加)
* @param array<int, string> $used 已用名列表(引用传入,调用方维护)
*/
public static function make(string $name, int $id, array &$used): string
{
$base = str_replace(['[', ']', ':', '*', '?', '/', '\\'], '', $name);
$base = mb_substr($base === '' ? '未命名' : $base, 0, 28);
$title = $base;
if (in_array($title, $used, true)) {
$title = mb_substr($base, 0, 25) . '#' . $id;
}
$used[] = $title;
return $title;
}
}
@@ -4,22 +4,29 @@ namespace App\Http\Controllers\Purchase;
use App\Exceptions\RepositoryException;
use App\Exports\PurchaseOrderExport;
use App\Exports\PurchaseStoreExport;
use App\Exports\PurchaseSupplierExport;
use App\Http\Requests\Purchase\PurchaseBillGenerateRequest;
use App\Http\Requests\Purchase\PurchaseCellUpdateRequest;
use App\Http\Requests\Purchase\PurchaseRowUpdateRequest;
use App\Http\Requests\Purchase\PurchaseStoreItemRequest;
use App\Models\BillModel;
use App\Models\CustomerLevelModel;
use App\Models\ProductModel;
use App\Models\PurchaseOrderModel;
use App\Models\StoreModel;
use App\Models\StoreOrderItemModel;
use App\Models\StoreOrderModel;
use App\Models\SupplierModel;
use App\Services\BillGenerateService;
use App\Services\ItemImageResolver;
use App\Services\PurchaseGenerateService;
use App\Services\PurchaseItemService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Maatwebsite\Excel\Facades\Excel;
use Modules\AnnoRoute\Attribute\DeleteRoute;
use Modules\AnnoRoute\Attribute\GetRoute;
use Modules\AnnoRoute\Attribute\PostRoute;
use Modules\AnnoRoute\Attribute\PutRoute;
@@ -98,6 +105,8 @@ class PurchaseOrderController extends BaseController
$quantity = 0;
// 采购总重量
$weight = '0';
// 采购总金额(Σ明细 amount,参考零售价按 金额÷数量 加权)
$amount = '0';
// 门店明细
$cellsMap = [];
@@ -106,6 +115,7 @@ class PurchaseOrderController extends BaseController
// 累加总量
$quantity += (int) $item->quantity;
$weight = bcadd($weight, (string) $item->weight, 3);
$amount = bcadd($amount, (string) $item->amount, 2);
// 按门店合并
if (!isset($cellsMap[$storeId])) {
@@ -127,6 +137,7 @@ class PurchaseOrderController extends BaseController
'cost_price' => $first->cost_price, // 成本价
'quantity' => $quantity,
'weight' => (float) $weight,
'amount' => $amount,
'cells' => $cellsMap,
'category_sort' => (int) ($product->category->sort ?? 9999),
'product_sort' => (int) ($product->sort ?? 9999),
@@ -247,22 +258,68 @@ class PurchaseOrderController extends BaseController
}
/**
* 导出采购单 Excel 表格
* 导出采购单商品明细 Excel 表格(系统全部商品行,支持按供应商筛选)
*
* @throws
*/
#[GetRoute(route: '/{id}/export', authorize: 'export', where: ['id' => '[0-9]+'])]
public function export(int $id): Response
public function export(int $id, Request $request): Response
{
$purchase = PurchaseOrderModel::find($id);
if (empty($purchase)) {
throw new RepositoryException('采购单不存在');
}
$supplierId = (int) $request->query('supplier_id', 0);
return Excel::download(
new PurchaseOrderExport($purchase),
$purchase->purchase_no . '_采购单.xlsx',
);
$filename = $purchase->purchase_no . '_采购单.xlsx';
if ($supplierId > 0) {
$supplier = SupplierModel::withTrashed()->find($supplierId);
$filename = $purchase->purchase_no . '_采购单_' . ($supplier->name ?? ('供应商' . $supplierId)) . '.xlsx';
}
return Excel::download(new PurchaseOrderExport($purchase, $supplierId), $filename);
}
/**
* 导出门店购买详情(多工作表:每门店一个工作表;?store_id= 单门店导出)
*/
#[GetRoute(route: '/{id}/exportStores', authorize: 'export', where: ['id' => '[0-9]+'])]
public function exportStores(int $id, Request $request): Response
{
$purchase = PurchaseOrderModel::find($id);
if (empty($purchase)) {
throw new RepositoryException('采购单不存在');
}
$storeId = (int) $request->query('store_id', 0);
$filename = $purchase->purchase_no . '_门店购买详情.xlsx';
if ($storeId > 0) {
$store = StoreModel::withTrashed()->find($storeId);
$filename = $purchase->purchase_no . '_门店购买详情_' . ($store->name ?? ('门店' . $storeId)) . '.xlsx';
}
return Excel::download(new PurchaseStoreExport($purchase, $storeId), $filename);
}
/**
* 导出供应商采购明细(多工作表:每供应商一个工作表;?supplier_id= 单供应商导出)
*/
#[GetRoute(route: '/{id}/exportSuppliers', authorize: 'export', where: ['id' => '[0-9]+'])]
public function exportSuppliers(int $id, Request $request): Response
{
$purchase = PurchaseOrderModel::find($id);
if (empty($purchase)) {
throw new RepositoryException('采购单不存在');
}
$supplierId = (int) $request->query('supplier_id', 0);
$filename = $purchase->purchase_no . '_供应商采购明细.xlsx';
if ($supplierId > 0) {
$supplier = SupplierModel::withTrashed()->find($supplierId);
$filename = $purchase->purchase_no . '_供应商采购明细_' . ($supplier->name ?? ('供应商' . $supplierId)) . '.xlsx';
}
return Excel::download(new PurchaseSupplierExport($purchase, $supplierId), $filename);
}
/**
@@ -348,68 +405,191 @@ class PurchaseOrderController extends BaseController
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),
'items' => app(PurchaseItemService::class)->storeSummaryRows($purchase->id, $storeId),
]);
}
/**
* 门店购买详情:新增单品(挂靠该门店在采购单中的最新一笔订单,
* 单价按门店等级上浮比例换算,无等级按成本价兜底)
*
* @throws Throwable
*/
#[PostRoute(route: '/{id}/store/{storeId}/item', authorize: 'update', where: ['id' => '[0-9]+', 'storeId' => '[0-9]+'])]
public function storeItemStore(int $id, int $storeId, PurchaseStoreItemRequest $request): JsonResponse
{
$purchase = PurchaseOrderModel::find($id);
if (empty($purchase)) {
throw new RepositoryException('采购单不存在');
}
$this->assertPurchaseEditable($purchase);
$validated = $request->validated();
$productId = (int) $validated['product_id'];
$product = ProductModel::find($productId);
if ($product === null) {
throw new RepositoryException('商品不存在或已被删除,无法添加');
}
return DB::transaction(function () use ($purchase, $storeId, $product, $validated) {
// 该门店在采购单中的最新一笔订单
$order = StoreOrderModel::query()
->where('purchase_id', $purchase->id)
->where('store_id', $storeId)
->orderByDesc('id')
->lockForUpdate()
->first();
if ($order === null) {
throw new RepositoryException('该门店不在此采购单中,无法添加单品');
}
$duplicated = StoreOrderItemModel::query()
->where('purchase_id', $purchase->id)
->where('store_id', $storeId)
->where('product_id', $product->id)
->exists();
if ($duplicated) {
throw new RepositoryException('该商品已在此门店采购明细中,请直接修改数量');
}
// 单价:门店等级上浮换算价(无等级按成本价兜底)
$store = StoreModel::withTrashed()->find($storeId);
$level = ($store !== null && $store->level_id > 0) ? $store->level : null;
$price = $level !== null
? CustomerLevelModel::calcLevelPrice($product->cost_price, $level->percent)
: bcadd((string) $product->cost_price, '0', 2);
$quantity = (int) $validated['quantity'];
$item = StoreOrderItemModel::create([
'order_id' => $order->id,
'purchase_id' => $purchase->id,
'bill_id' => 0,
'store_id' => $storeId,
'product_id' => $product->id,
'category_id' => (int) $product->category_id,
'supplier_id' => (int) $product->supplier_id,
'product_name' => $product->name,
'product_spec' => $product->spec,
'unit' => (string) $product->unit,
'price' => $price,
'image_ids' => implode(',', (array) $product->image_ids),
'content' => (string) $product->content,
'shelf_life' => (int) $product->shelf_life,
'quantity' => $quantity,
'weight' => bcadd((string) ($validated['weight'] ?? 0), '0', 3),
'amount' => bcmul($price, (string) $quantity, 2),
'cost_price' => (string) $product->cost_price,
'remark' => '',
]);
$service = app(PurchaseItemService::class);
$service->recalcOrder($order->id);
$service->recalcPurchase($purchase->id);
return $this->success(['id' => $item->id], '已添加单品');
});
}
/**
* 门店购买详情:修改单品(数量/称重/单价);同门店同商品多笔订单明细时合并到最早一条
*
* @throws Throwable
*/
#[PutRoute(route: '/{id}/store/{storeId}/item/{productId}', authorize: 'update', where: ['id' => '[0-9]+', 'storeId' => '[0-9]+', 'productId' => '[0-9]+'])]
public function storeItemUpdate(int $id, int $storeId, int $productId, PurchaseStoreItemRequest $request): JsonResponse
{
$purchase = PurchaseOrderModel::find($id);
if (empty($purchase)) {
throw new RepositoryException('采购单不存在');
}
$this->assertPurchaseEditable($purchase);
$validated = $request->validated();
return DB::transaction(function () use ($purchase, $storeId, $productId, $validated) {
$items = StoreOrderItemModel::query()
->where('purchase_id', $purchase->id)
->where('store_id', $storeId)
->where('product_id', $productId)
->orderBy('id')
->lockForUpdate()
->get();
if ($items->isEmpty()) {
throw new RepositoryException('该门店在此采购单中无此商品明细');
}
$affectedOrderIds = $items->pluck('order_id')->unique()->all();
// 多笔订单明细合并到最早一条
$survivor = $items->first();
foreach ($items->skip(1) as $extra) {
$extra->delete();
}
$survivor->quantity = (int) $validated['quantity'];
if (isset($validated['price'])) {
$survivor->price = bcadd((string) $validated['price'], '0', 2);
}
if (isset($validated['weight'])) {
$survivor->weight = bcadd((string) $validated['weight'], '0', 3);
}
$survivor->amount = bcmul((string) $survivor->quantity, (string) $survivor->price, 2);
$survivor->save();
$service = app(PurchaseItemService::class);
foreach ($affectedOrderIds as $orderId) {
$service->recalcOrder((int) $orderId);
}
$service->recalcPurchase($purchase->id);
return $this->success([], '单品已更新');
});
}
/**
* 门店购买详情:移除单品(该门店此商品的全部订货明细一并删除)
*
* @throws Throwable
*/
#[DeleteRoute(route: '/{id}/store/{storeId}/item/{productId}', authorize: 'update', where: ['id' => '[0-9]+', 'storeId' => '[0-9]+', 'productId' => '[0-9]+'])]
public function storeItemDelete(int $id, int $storeId, int $productId): JsonResponse
{
$purchase = PurchaseOrderModel::find($id);
if (empty($purchase)) {
throw new RepositoryException('采购单不存在');
}
$this->assertPurchaseEditable($purchase);
DB::transaction(function () use ($purchase, $storeId, $productId) {
$items = StoreOrderItemModel::query()
->where('purchase_id', $purchase->id)
->where('store_id', $storeId)
->where('product_id', $productId)
->lockForUpdate()
->get();
if ($items->isEmpty()) {
throw new RepositoryException('该门店在此采购单中无此商品明细');
}
$affectedOrderIds = $items->pluck('order_id')->unique()->all();
foreach ($items as $item) {
$item->delete();
}
$service = app(PurchaseItemService::class);
foreach ($affectedOrderIds as $orderId) {
$service->recalcOrder((int) $orderId);
}
$service->recalcPurchase($purchase->id);
});
return $this->success([], '已移除单品');
}
/**
* 账单生成预览
*/
@@ -489,7 +669,7 @@ class PurchaseOrderController extends BaseController
}
/**
* 商品行修改:品名/供应商/包规/单位/成本
* 商品行修改:品名/供应商/包规/单位/成本(同步更新商品档案;商品已删除则跳过档案同步)
* @throws Throwable
*/
#[PutRoute(route: '/{id}/row/{productId}', authorize: 'update', where: ['id' => '[0-9]+', 'productId' => '[0-9]+'])]
@@ -499,31 +679,45 @@ class PurchaseOrderController extends BaseController
if (empty($purchase)) {
throw new RepositoryException('采购单不存在');
}
if ($purchase->status !== PurchaseOrderModel::STATUS_PENDING) {
throw new RepositoryException('采购单已完成,不允许修改明细');
}
$this->assertPurchaseEditable($purchase);
$validated = $request->validated();
$items = StoreOrderItemModel::query()
->where('purchase_id', $purchase->id)
->where('product_id', $productId)
->lockForUpdate()
->get();
if ($items->isEmpty()) {
throw new RepositoryException('该采购单下无此商品的订货明细');
}
foreach ($items as $item) {
$item->fill($validated)->save();
}
return DB::transaction(function () use ($purchase, $productId, $validated) {
$items = StoreOrderItemModel::query()
->where('purchase_id', $purchase->id)
->where('product_id', $productId)
->lockForUpdate()
->get();
if ($items->isEmpty()) {
throw new RepositoryException('该采购单下无此商品的订货明细');
}
foreach ($items as $item) {
$item->fill($validated)->save();
}
// 重算采购单成本
$query = StoreOrderItemModel::query()->where('purchase_id', $purchase->id);
$purchase->total_quantity = $query->sum('quantity');
$purchase->estimate_amount = $query->sum(DB::raw('quantity * cost_price'));
$purchase->save();
// 同步保存到商品档案(软删除商品跳过,明细照常更新)
$product = ProductModel::find($productId);
$productSynced = $product !== null;
if ($productSynced) {
$product->update([
'name' => $validated['product_name'],
'supplier_id' => $validated['supplier_id'],
'spec' => $validated['product_spec'],
'unit' => $validated['unit'],
'cost_price' => $validated['cost_price'],
]);
}
return $this->success(['count' => $items->count()], '已同步 ' . $items->count() . ' 条订货明细');
app(PurchaseItemService::class)->recalcPurchase($purchase->id);
return $this->success(
['count' => $items->count()],
$productSynced
? '已同步 ' . $items->count() . ' 条订货明细与商品档案'
: '已同步 ' . $items->count() . ' 条订货明细;商品已删除,档案未同步'
);
});
}
/**
@@ -544,31 +738,31 @@ class PurchaseOrderController extends BaseController
if (empty($purchase)) {
throw new RepositoryException('采购单不存在');
}
if ($purchase->status !== PurchaseOrderModel::STATUS_PENDING) {
throw new RepositoryException('采购单已完成,不允许修改明细');
}
$this->assertPurchaseEditable($purchase);
$item->quantity = $validated['quantity'];
$item->price = $validated['price'];
if (array_key_exists('weight', $validated)) {
$item->weight = bcadd((string) $validated['weight'], '0', 3);
}
$item->amount = bcmul($validated['quantity'], $validated['price'], 3);
$item->amount = bcmul((string) $validated['quantity'], (string) $validated['price'], 2);
$item->save();
// 重算订单金额
$order = StoreOrderModel::query()->lockForUpdate()->find($item->order_id);
$order->total_weight = $order->items()->sum('weight');
$order->total_amount = $order->items()->sum('amount');
$order->save();
// 重算采购单重量
$purchase->total_weight = StoreOrderItemModel::query()
->where('purchase_id', $purchase->id)
->sum('weight');
$purchase->save();
$service = app(PurchaseItemService::class);
$service->recalcOrder((int) $item->order_id);
$service->recalcPurchase($purchase->id);
return $this->success();
});
}
/**
* 采购单编辑闸:仅进行中(待采购)允许修改明细
*/
private function assertPurchaseEditable(PurchaseOrderModel $purchase): void
{
if ($purchase->status !== PurchaseOrderModel::STATUS_PENDING) {
throw new RepositoryException('采购单已完成,不允许修改明细');
}
}
}
@@ -0,0 +1,45 @@
<?php
namespace App\Http\Requests\Purchase;
use Modules\Common\Http\Requests\BaseFormRequest;
/**
* 采购单门店单品 新增/修改 验证(amount = 数量×单价 由后端重算)
*/
class PurchaseStoreItemRequest extends BaseFormRequest
{
protected $stopOnFirstFailure = true;
public function rules(): array
{
if ($this->isUpdate()) {
return [
'quantity' => 'required|integer|min:0',
'price' => 'nullable|numeric|min:0',
'weight' => 'nullable|numeric|min:0',
];
}
return [
'product_id' => 'required|integer|exists:product,id',
'quantity' => 'required|integer|min:1',
'weight' => 'nullable|numeric|min:0',
];
}
public function messages(): array
{
return [
'product_id.required' => '请选择商品',
'product_id.exists' => '商品不存在或已删除',
'quantity.required' => '采购数量不能为空',
'quantity.integer' => '采购数量必须为整数',
'quantity.min' => '采购数量不能小于 0',
'price.numeric' => '单价必须为数字',
'price.min' => '单价不能小于 0',
'weight.numeric' => '称重必须为数字',
'weight.min' => '称重不能小于 0',
];
}
}
+187
View File
@@ -0,0 +1,187 @@
<?php
namespace App\Services;
use App\Models\ProductModel;
use App\Models\PurchaseOrderModel;
use App\Models\StoreOrderItemModel;
use App\Models\StoreOrderModel;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
/**
* 采购单明细共享服务:订单/采购单汇总重算 + 门店/供应商维度的商品行聚合
*
* 汇总口径(勿偏离):
* - 订单:total_quantity=Σ数量、total_weight=Σ称重、total_amount=Σ金额
* - 采购单:total_quantity=Σ数量、estimate_amount=Σ(数量×成本价)(预估成本)、total_weight=Σ称重
*/
class PurchaseItemService
{
/**
* 重算门店订单汇总(明细增删改后调用)
*/
public function recalcOrder(int $orderId): void
{
$order = StoreOrderModel::query()->find($orderId);
if ($order === null) {
return;
}
$order->total_quantity = (int) $order->items()->sum('quantity');
$order->total_weight = $order->items()->sum('weight');
$order->total_amount = $order->items()->sum('amount');
$order->save();
}
/**
* 重算采购单汇总(明细增删改后调用)
*/
public function recalcPurchase(int $purchaseId): void
{
$purchase = PurchaseOrderModel::query()->find($purchaseId);
if ($purchase === null) {
return;
}
$purchase->total_quantity = (int) StoreOrderItemModel::query()
->where('purchase_id', $purchaseId)->sum('quantity');
$purchase->estimate_amount = StoreOrderItemModel::query()
->where('purchase_id', $purchaseId)->sum(DB::raw('quantity * cost_price'));
$purchase->total_weight = StoreOrderItemModel::query()
->where('purchase_id', $purchaseId)->sum('weight');
$purchase->save();
}
/**
* 采购单内指定门店的采购汇总行(按商品聚合,单价=加权平均 Σ金额÷Σ数量,
* 排序:分类 sort → 商品 sort,与明细矩阵同序)
*
* @return array<int, array<string, mixed>>
*/
public function storeSummaryRows(int $purchaseId, int $storeId): array
{
$items = $this->purchaseItems($purchaseId)
->where('store_id', $storeId)
->sortBy('id');
$rows = [];
foreach ($items->groupBy('product_id') as $productId => $group) {
$first = $group->first();
$quantity = 0;
$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) data_get($first, 'category_sort', 9999),
'product_sort' => (int) data_get($first, 'product_sort', 9999),
];
}
return $this->sortRows($rows);
}
/**
* 采购单内指定供应商的商品聚合行(成本口径:金额=Σ数量×成本价,
* 排序:分类 sort → 商品 sort)
*
* @return array<int, array<string, mixed>>
*/
public function supplierRows(int $purchaseId, int $supplierId): array
{
$items = $this->purchaseItems($purchaseId)
->where('supplier_id', $supplierId)
->sortBy('id');
$rows = [];
foreach ($items->groupBy('product_id') as $productId => $group) {
$first = $group->first();
$quantity = 0;
$weight = '0';
$amount = '0';
foreach ($group as $item) {
$quantity += (int) $item->quantity;
$weight = bcadd($weight, (string) $item->weight, 3);
$amount = bcadd($amount, bcmul((string) $item->quantity, (string) $item->cost_price, 2), 2);
}
$rows[] = [
'product_id' => (int) $productId,
'product_name' => $first->product_name,
'product_spec' => $first->product_spec,
'unit' => $first->unit,
'cost_price' => (string) $first->cost_price,
'quantity' => $quantity,
'weight' => $weight,
'amount' => $amount,
'category_sort' => (int) data_get($first, 'category_sort', 9999),
'product_sort' => (int) data_get($first, 'product_sort', 9999),
];
}
return $this->sortRows($rows);
}
/**
* 采购单订货明细(关联订单过滤软删、成本价可见、附分类/商品排序键)
*
* @return Collection<int, StoreOrderItemModel>
*/
private function purchaseItems(int $purchaseId): Collection
{
$items = StoreOrderItemModel::query()
->join('store_order', 'store_order.id', '=', 'store_order_item.order_id')
->where('store_order_item.purchase_id', $purchaseId)
->whereNull('store_order.deleted_at')
->select('store_order_item.*')
->get()
->makeVisible('cost_price');
// 排序键:商品分类 sort → 商品 sort(商品含软删除,保证历史单据可导出)
$products = ProductModel::withTrashed()
->with('category:id,sort')
->whereIn('id', $items->pluck('product_id')->unique())
->get(['id', 'category_id', 'sort'])
->keyBy('id');
foreach ($items as $item) {
$product = $products->get((int) $item->product_id);
$item->setAttribute('category_sort', (int) ($product?->category?->sort ?? 9999));
$item->setAttribute('product_sort', (int) ($product?->sort ?? 9999));
}
return $items;
}
/**
* 排序并剥离排序键
*
* @param array<int, array<string, mixed>> $rows
* @return array<int, array<string, mixed>>
*/
private function sortRows(array $rows): array
{
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']]);
return array_map(static function (array $row): array {
unset($row['category_sort'], $row['product_sort']);
return $row;
}, $rows);
}
}
+260
View File
@@ -2,13 +2,18 @@
namespace Tests\Feature;
use App\Exports\PurchaseOrderExport;
use App\Exports\PurchaseStoreExport;
use App\Exports\PurchaseSupplierExport;
use App\Models\CustomerLevelModel;
use App\Models\ProductCategoryModel;
use App\Models\ProductModel;
use App\Models\PurchaseOrderModel;
use App\Models\StoreModel;
use App\Models\StoreOrderModel;
use App\Models\SupplierModel;
use App\Models\UserModel;
use Maatwebsite\Excel\Facades\Excel;
/**
* 采购单导出(仅 Excel 表格,全品类):xlsx Content-Type /
@@ -85,4 +90,259 @@ class ExportTest extends ProcurementTestCase
$response = $this->get("/purchase/order/{$purchase->id}/export");
$this->assertFalse($response->json('success'), '缺少权限点应被拦截');
}
/**
* 造含双门店/双供应商的采购单:
* 门店A 订 蔬菜2件(供应商甲)+ 肉1件(供应商乙);门店B 订 蔬菜3件(供应商甲)
*
* @return array{0: PurchaseOrderModel, 1: ProductModel, 2: ProductModel, 3: StoreModel, 4: StoreModel, 5: SupplierModel, 6: SupplierModel}
*/
private function buildPurchaseWithSuppliers(): array
{
$supplierA = SupplierModel::factory()->create();
$supplierB = SupplierModel::factory()->create();
$level = CustomerLevelModel::factory()->create();
$storeA = StoreModel::factory()->create(['level_id' => $level->id]);
$storeB = StoreModel::factory()->create(['level_id' => $level->id]);
$veg = ProductModel::factory()->create([
'status' => ProductModel::STATUS_ON,
'cost_price' => '5.00',
'spec' => '10斤/箱',
'supplier_id' => $supplierA->id,
]);
$meat = ProductModel::factory()->create([
'status' => ProductModel::STATUS_ON,
'cost_price' => '20.00',
'spec' => '20斤/箱',
'supplier_id' => $supplierB->id,
]);
$this->actingAsMiniUser(UserModel::factory()->forStore($storeA->id)->create());
$this->postJson('/mini/order', ['items' => [
['product_id' => $veg->id, 'quantity' => 2],
['product_id' => $meat->id, 'quantity' => 1],
]])->assertJsonPath('success', true);
$this->actingAsMiniUser(UserModel::factory()->forStore($storeB->id)->create());
$this->postJson('/mini/order', ['items' => [
['product_id' => $veg->id, 'quantity' => 3],
]])->assertJsonPath('success', true);
StoreOrderModel::query()->update(['status' => StoreOrderModel::STATUS_SUMMARIZED]);
$this->actingAsSysUser();
$this->postJson('/purchase/order/generate', ['purchase_date' => now()->toDateString()])
->assertJsonPath('success', true);
return [PurchaseOrderModel::first(), $veg, $meat, $storeA, $storeB, $supplierA, $supplierB];
}
/** 商品明细导出:含系统全部商品行(无订货数量 0)+ 行列合计 + 参考零售价列 */
public function test_export_lists_all_catalog_products_with_totals_row(): void
{
[$purchase, $veg, $meat, $storeA, $storeB] = $this->buildPurchaseWithSuppliers();
// 无订货的上架商品也应出现在导出中
$extra = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'cost_price' => '7.00']);
Excel::fake();
$this->actingAsSysUser();
$this->get("/purchase/order/{$purchase->id}/export")->assertOk();
Excel::assertDownloaded(
$purchase->purchase_no . '_采购单.xlsx',
static function (PurchaseOrderExport $export) use ($veg, $meat, $extra, $storeA, $storeB): bool {
$rows = $export->collection()->values();
// 标题1 + 范围1 + 空行1 + 列头1 + 商品3 + 合计1 = 8 行
if ($rows->count() !== 8) {
return false;
}
// 列头含参考零售价与门店列
$header = $rows[3];
if ($header[7] !== '参考零售价' || $header[11] !== $storeA->name || $header[12] !== $storeB->name) {
return false;
}
// 蔬菜行:数量 2+3=5、金额 25、参考零售价 5÷10=0.50、门店列 2/3
$vegRow = $rows->firstWhere(2, $veg->name);
if ($vegRow === null
|| (float) $vegRow[8] !== 5.0 || (float) $vegRow[10] !== 25.0
|| (float) $vegRow[7] !== 0.5
|| (float) $vegRow[11] !== 2.0 || (float) $vegRow[12] !== 3.0) {
return false;
}
// 无订货商品行:数量 0、参考零售价留空、门店列 0
$extraRow = $rows->firstWhere(2, $extra->name);
if ($extraRow === null
|| (float) $extraRow[8] !== 0.0 || $extraRow[7] !== ''
|| (float) $extraRow[11] !== 0.0) {
return false;
}
// 合计行:总数量 6、总金额 45、门店列合计 3/3
$total = $rows->last();
if ($total[2] !== '合计') {
return false;
}
return (float) $total[8] === 6.0 && (float) $total[10] === 45.0
&& (float) $total[11] === 3.0 && (float) $total[12] === 3.0;
}
);
}
/** 商品明细导出:按供应商筛选(范围行标注供应商,仅含其商品行) */
public function test_export_supplier_filter(): void
{
[$purchase, $veg, $meat, , , $supplierA] = $this->buildPurchaseWithSuppliers();
Excel::fake();
$this->actingAsSysUser();
$this->get("/purchase/order/{$purchase->id}/export?supplier_id={$supplierA->id}")->assertOk();
Excel::assertDownloaded(
$purchase->purchase_no . '_采购单_' . $supplierA->name . '.xlsx',
static function (PurchaseOrderExport $export) use ($veg, $meat, $supplierA): bool {
$rows = $export->collection()->values();
// 范围行标注供应商
if (! str_contains((string) $rows[1][0], $supplierA->name)) {
return false;
}
// 数据行(去头尾)只含供应商甲的蔬菜,不含供应商乙的肉
$names = $rows->slice(4, -1)->map(static fn ($row) => $row[2])->all();
return in_array($veg->name, $names, true) && ! in_array($meat->name, $names, true);
}
);
}
/** 门店购买详情导出:多门店合并一个 XLSX(工作表名=门店名),含合计行 */
public function test_export_stores_multi_sheet(): void
{
[$purchase, , , $storeA, $storeB] = $this->buildPurchaseWithSuppliers();
Excel::fake();
$this->actingAsSysUser();
$this->get("/purchase/order/{$purchase->id}/exportStores")->assertOk();
Excel::assertDownloaded(
$purchase->purchase_no . '_门店购买详情.xlsx',
static function (PurchaseStoreExport $export) use ($storeA, $storeB): bool {
$sheets = $export->sheets();
if (count($sheets) !== 2) {
return false;
}
$titles = array_map(static fn ($sheet) => $sheet->title(), $sheets);
if ($titles !== [$storeA->name, $storeB->name]) {
return false;
}
// 门店A 工作表:标题/空行/列头/明细2/合计 = 6 行
$rows = $sheets[0]->collection()->values();
if ($rows->count() !== 6) {
return false;
}
if ($rows[2] !== ['序号', '品名', '包规', '单位', '单价', '数量', '重量(斤)', '预计金额']) {
return false;
}
// 合计:数量 2+1=3,金额 10+20=30
$total = $rows->last();
return $total[1] === '合计' && (int) $total[5] === 3 && (float) $total[7] === 30.0;
}
);
}
/** 门店购买详情导出:单门店导出 + 无明细门店拒绝 */
public function test_export_stores_single_store(): void
{
[$purchase, , , , $storeB] = $this->buildPurchaseWithSuppliers();
Excel::fake();
$this->actingAsSysUser();
$this->get("/purchase/order/{$purchase->id}/exportStores?store_id={$storeB->id}")->assertOk();
Excel::assertDownloaded(
$purchase->purchase_no . '_门店购买详情_' . $storeB->name . '.xlsx',
static function (PurchaseStoreExport $export) use ($storeB): bool {
$sheets = $export->sheets();
return count($sheets) === 1 && $sheets[0]->title() === $storeB->name;
}
);
}
/** 门店购买详情导出:门店无明细 → 拒绝(真实执行 sheets() 才触发空校验,不走 fake */
public function test_export_stores_without_items_rejected(): void
{
[$purchase] = $this->buildPurchaseWithSuppliers();
$otherStore = StoreModel::factory()->create();
$this->actingAsSysUser();
$this->get("/purchase/order/{$purchase->id}/exportStores?store_id={$otherStore->id}")
->assertJsonPath('success', false)
->assertJsonPath('msg', '该门店在此采购单中无采购商品');
}
/** 供应商采购明细导出:多供应商合并一个 XLSX(工作表名=供应商名),按供应商聚合 */
public function test_export_suppliers_multi_sheet(): void
{
[$purchase, $veg, $meat, , , $supplierA, $supplierB] = $this->buildPurchaseWithSuppliers();
Excel::fake();
$this->actingAsSysUser();
$this->get("/purchase/order/{$purchase->id}/exportSuppliers")->assertOk();
Excel::assertDownloaded(
$purchase->purchase_no . '_供应商采购明细.xlsx',
static function (PurchaseSupplierExport $export) use ($supplierA, $supplierB, $veg, $meat): bool {
$sheets = $export->sheets();
if (count($sheets) !== 2) {
return false;
}
$titles = array_map(static fn ($sheet) => $sheet->title(), $sheets);
if ($titles !== [$supplierA->name, $supplierB->name]) {
return false;
}
// 供应商甲:蔬菜 2+3=5 件、金额 5×5=25
$rowsA = $sheets[0]->collection()->values();
if ($rowsA[2] !== ['序号', '品名', '包规', '单位', '成本价', '数量', '重量(斤)', '金额']) {
return false;
}
$vegRow = $rowsA->firstWhere(1, $veg->name);
if ($vegRow === null || (int) $vegRow[5] !== 5 || (float) $vegRow[7] !== 25.0) {
return false;
}
$totalA = $rowsA->last();
if ($totalA[1] !== '合计' || (int) $totalA[5] !== 5 || (float) $totalA[7] !== 25.0) {
return false;
}
// 供应商乙:肉 1 件、金额 20
$rowsB = $sheets[1]->collection()->values();
$meatRow = $rowsB->firstWhere(1, $meat->name);
return $meatRow !== null && (int) $meatRow[5] === 1 && (float) $meatRow[7] === 20.0;
}
);
}
/** 供应商采购明细导出:单供应商导出 + 无明细供应商拒绝 */
public function test_export_suppliers_single(): void
{
[$purchase, , , , , , $supplierB] = $this->buildPurchaseWithSuppliers();
Excel::fake();
$this->actingAsSysUser();
$this->get("/purchase/order/{$purchase->id}/exportSuppliers?supplier_id={$supplierB->id}")->assertOk();
Excel::assertDownloaded(
$purchase->purchase_no . '_供应商采购明细_' . $supplierB->name . '.xlsx',
static function (PurchaseSupplierExport $export) use ($supplierB): bool {
$sheets = $export->sheets();
return count($sheets) === 1 && $sheets[0]->title() === $supplierB->name;
}
);
}
/** 供应商采购明细导出:供应商无明细 → 拒绝(真实执行 sheets() 才触发空校验,不走 fake */
public function test_export_suppliers_without_items_rejected(): void
{
[$purchase] = $this->buildPurchaseWithSuppliers();
$otherSupplier = SupplierModel::factory()->create();
$this->actingAsSysUser();
$this->get("/purchase/order/{$purchase->id}/exportSuppliers?supplier_id={$otherSupplier->id}")
->assertJsonPath('success', false)
->assertJsonPath('msg', '该供应商在此采购单中无采购明细');
}
}
+180 -103
View File
@@ -12,8 +12,9 @@ use App\Models\SupplierModel;
use App\Models\UserModel;
/**
* C4 采购单数据修改:详情矩阵(商品行 × 门店列)、门店单元格下钻编辑/同步、行级成本,
* 修改同步订货明细并重算订单/采购单汇总(金额口径:数量(包) × 每包价格,单价/包规不参与金额计算)
* C4 采购单数据修改:详情矩阵(商品行 × 门店列)、门店单元格下钻编辑、行级修改同步商品档案、
* 门店购买详情单品增删改,修改后重算订单/采购单汇总
* (金额口径:订单=数量×单价;采购单预估成本=Σ数量×每包成本价)
*/
class PurchaseEditTest extends ProcurementTestCase
{
@@ -69,6 +70,7 @@ class PurchaseEditTest extends ProcurementTestCase
$this->assertSame('10斤/箱', $row['product_spec']);
$this->assertSame('斤', $row['unit']);
$this->assertSame('10.00', (string) $row['cost_price']);
$this->assertSame('50.00', (string) $row['amount'], '订货金额 = Σ明细 amount5 × 10.00');
$this->assertSame(5, $row['quantity'], '2+3');
$cells = $row['cells'];
@@ -76,16 +78,15 @@ class PurchaseEditTest extends ProcurementTestCase
$this->assertSame(3, $cells[$stores[1]->id]);
}
/** 门店单元格数量修改 → 明细金额重算(数量×单价),订单与采购单汇总同步 */
/** 门店单元格数量/单价修改 → 明细金额重算(数量×单价),订单与采购单汇总同步 */
public function test_update_cell_syncs_order_item_and_totals(): void
{
[$purchase, , $stores] = $this->buildPurchase();
$this->actingAsSysUser();
$item = StoreOrderItemModel::where('store_id', $stores[0]->id)->first();
$this->putJson("/purchase/order/cell/{$item->id}", ['quantity' => 5])
->assertJsonPath('success', true)
->assertJsonPath('data.amount', 50);
$this->putJson("/purchase/order/cell/{$item->id}", ['quantity' => 5, 'price' => 10])
->assertJsonPath('success', true);
$item = $item->fresh();
$this->assertSame(5, $item->quantity);
@@ -93,45 +94,59 @@ class PurchaseEditTest extends ProcurementTestCase
$order = StoreOrderModel::find($item->order_id);
$this->assertSame(5, $order->total_quantity);
$this->assertSame('50.00', (string) $order->product_amount);
$this->assertSame('50.00', (string) $order->total_amount);
$purchase = $purchase->fresh();
$this->assertSame('8.00', (string) $purchase->total_quantity, '5+3');
$this->assertSame('80.00', (string) $purchase->estimate_amount, '50+30');
$this->assertSame('80.00', (string) $purchase->actual_amount, '8 包 × 每包成本 10.00');
$this->assertSame('80.00', (string) $purchase->estimate_amount, '8 包 × 每包成本 10.00');
}
/** 行级成本修改 → 同步该商品全部订货明细,采购单实际金额按 数量×每包成本 重算 */
/** 行级成本修改 → 同步该商品全部订货明细与商品档案,采购单预估成本重算 */
public function test_update_row_cost_syncs_all_order_items(): void
{
[$purchase, $product] = $this->buildPurchase();
$this->actingAsSysUser();
$this->putJson("/purchase/order/{$purchase->id}/row/{$product->id}", ['cost_price' => 30])
->assertJsonPath('success', true)
$this->putJson("/purchase/order/{$purchase->id}/row/{$product->id}", [
'product_name' => $product->name,
'supplier_id' => SupplierModel::factory()->create()->id,
'product_spec' => $product->spec,
'unit' => $product->unit,
'cost_price' => 30,
])->assertJsonPath('success', true)
->assertJsonPath('data.count', 2);
$this->assertSame(2, StoreOrderItemModel::where('cost_price', '30.00')->count());
$this->assertSame('30.00', (string) $product->fresh()->cost_price, '商品档案成本价应同步更新');
$purchase = $purchase->fresh();
$this->assertSame('150.00', (string) $purchase->actual_amount, '5 包 × 每包成本 30.00');
$this->assertSame('50.00', (string) $purchase->estimate_amount, '订货金额不受成本修改影响');
$this->assertSame('150.00', (string) $purchase->estimate_amount, '5 包 × 每包成本 30.00');
}
/** 行级属性修改:品名/供应商/包规/单位/成本一键同步该商品全部订货明细(重量为手动录入,不受行修改影响 */
/** 行级属性修改不影响称重(重量为手动录入,仅作参考 */
public function test_update_row_does_not_touch_manual_weight(): void
{
[$purchase, $product] = $this->buildPurchase();
[$purchase, $product, $stores] = $this->buildPurchase();
$supplier = SupplierModel::factory()->create();
$this->actingAsSysUser();
$this->putJson("/purchase/order/{$purchase->id}/row/{$product->id}", ['cost_price' => 30])
// 先录入一笔手动称重
$item = StoreOrderItemModel::where('store_id', $stores[0]->id)->first();
$this->putJson("/purchase/order/cell/{$item->id}", ['quantity' => 2, 'price' => 10, 'weight' => 4.5])
->assertJsonPath('success', true);
$this->assertSame(0, StoreOrderItemModel::where('weight', '<>', 0)->count(), '重量保持手动录入值,不自动计算');
$this->putJson("/purchase/order/{$purchase->id}/row/{$product->id}", [
'product_name' => $product->name,
'supplier_id' => $supplier->id,
'product_spec' => $product->spec,
'unit' => $product->unit,
'cost_price' => 30,
])->assertJsonPath('success', true);
$this->assertSame('4.500', (string) $item->fresh()->weight, '重量保持手动录入值,不受行修改影响');
}
/** 行级属性修改:品名/供应商/包规/单位/成本一键同步该商品全部订货明细 */
/** 行级属性修改:品名/供应商/包规/单位/成本一键同步该商品全部订货明细与商品档案 */
public function test_update_row_syncs_product_attributes(): void
{
[$purchase, $product] = $this->buildPurchase();
@@ -157,29 +172,7 @@ class PurchaseEditTest extends ProcurementTestCase
$this->assertSame('25.00', (string) $item->cost_price);
}
// 未传 sync_product:商品档案保持原值
$product = $product->fresh();
$this->assertNotSame('优选土豆', $product->name);
$this->assertSame('125.00', (string) $purchase->fresh()->actual_amount, '5 包 × 每包成本 25.00');
}
/** sync_product=true:订货明细与商品档案同步更新;软删除商品拒绝 */
public function test_update_row_syncs_to_product_catalog(): void
{
[$purchase, $product] = $this->buildPurchase();
$supplier = SupplierModel::factory()->create();
$this->actingAsSysUser();
$this->putJson("/purchase/order/{$purchase->id}/row/{$product->id}", [
'product_name' => '优选土豆',
'supplier_id' => $supplier->id,
'product_spec' => '5斤/袋',
'unit' => '袋',
'cost_price' => 25,
'sync_product' => true,
])->assertJsonPath('success', true);
// 商品档案同步保存(商品列表可见)
$product = $product->fresh();
$this->assertSame('优选土豆', $product->name);
$this->assertSame($supplier->id, $product->supplier_id);
@@ -187,16 +180,33 @@ class PurchaseEditTest extends ProcurementTestCase
$this->assertSame('袋', $product->unit);
$this->assertSame('25.00', (string) $product->cost_price);
// 软删除商品 → 拒绝并回滚(订货明细不同步)
$product->delete();
$this->putJson("/purchase/order/{$purchase->id}/row/{$product->id}", [
'product_name' => '再次改名',
'sync_product' => true,
])->assertJsonPath('success', false);
$this->assertSame('优选土豆', StoreOrderItemModel::first()->product_name, '回滚后明细保持原值');
$this->assertSame('125.00', (string) $purchase->fresh()->estimate_amount, '5 包 × 每包成本 25.00');
}
/** 行级修改:属性与称重至少一项 */
/** 行级修改:商品已软删除时明细照常更新、跳过档案同步 */
public function test_update_row_skips_catalog_sync_when_product_deleted(): void
{
[$purchase, $product] = $this->buildPurchase();
$supplier = SupplierModel::factory()->create();
$this->actingAsSysUser();
$product->delete(); // 软删除
$this->putJson("/purchase/order/{$purchase->id}/row/{$product->id}", [
'product_name' => '已删商品新名',
'supplier_id' => $supplier->id,
'product_spec' => '5斤/袋',
'unit' => '袋',
'cost_price' => 25,
])->assertOk()
->assertJsonPath('success', true)
->assertJsonPath('msg', '已同步 2 条订货明细;商品已删除,档案未同步');
$this->assertSame('已删商品新名', StoreOrderItemModel::first()->product_name, '订货明细照常更新');
$this->assertNotSame('已删商品新名', $product->fresh()->name, '商品档案不同步');
}
/** 行级修改:必填字段校验(空 payload 拒绝) */
public function test_update_row_requires_at_least_one_field(): void
{
[$purchase, $product] = $this->buildPurchase();
@@ -213,11 +223,11 @@ class PurchaseEditTest extends ProcurementTestCase
$this->actingAsSysUser();
$item = StoreOrderItemModel::where('store_id', $stores[0]->id)->first();
$this->putJson("/purchase/order/cell/{$item->id}", ['quantity' => -1])
$this->putJson("/purchase/order/cell/{$item->id}", ['quantity' => -1, 'price' => 10])
->assertJsonPath('success', false);
}
/** 单元格下钻:返回该门店该商品的全部订货明细(订单号/状态/快照/可编辑标记),其他门店无明细 */
/** 单元格下钻:返回该门店该商品的全部订货明细(订单号/状态/快照),其他门店无明细 */
public function test_cell_detail_returns_items_with_order_info(): void
{
[$purchase, $product, $stores] = $this->buildPurchase();
@@ -237,7 +247,6 @@ class PurchaseEditTest extends ProcurementTestCase
$this->assertSame(2, $item['quantity']);
$this->assertSame('20.00', (string) $item['amount']);
$this->assertSame(StoreOrderModel::STATUS_DELIVERING, $item['order_status'], '生成采购单后源订单为采购中');
$this->assertTrue($item['editable']);
// 其他门店 → 另一条明细
$this->getJson("/purchase/order/{$purchase->id}/cell?product_id={$product->id}&store_id={$stores[1]->id}")
@@ -262,9 +271,8 @@ class PurchaseEditTest extends ProcurementTestCase
$this->actingAsSysUser();
$item = StoreOrderItemModel::where('store_id', $stores[0]->id)->first();
$this->putJson("/purchase/order/cell/{$item->id}", ['quantity' => 5, 'weight' => 4.5])
->assertJsonPath('success', true)
->assertJsonPath('data.amount', 50);
$this->putJson("/purchase/order/cell/{$item->id}", ['quantity' => 5, 'price' => 10, 'weight' => 4.5])
->assertJsonPath('success', true);
$item = $item->fresh();
$this->assertSame('4.500', (string) $item->weight);
@@ -272,60 +280,120 @@ class PurchaseEditTest extends ProcurementTestCase
$order = StoreOrderModel::find($item->order_id);
$this->assertSame('4.500', (string) $order->total_weight);
$this->assertSame('50.00', (string) $order->product_amount);
$this->assertSame('50.00', (string) $order->total_amount);
$purchase = $purchase->fresh();
$this->assertSame('4.500', (string) $purchase->total_weight);
$this->assertSame('80.00', (string) $purchase->estimate_amount, '50+30 订货金额');
$this->assertSame('80.00', (string) $purchase->actual_amount, '8 包 × 每包成本 10.00(称重仅参考,不参与金额)');
$this->assertSame('80.00', (string) $purchase->estimate_amount, '8 包 × 每包成本 10.00(称重仅参考,不参与金额');
}
/** 单元格一键同步:按商品ID同步档案 + 等级上浮价重算,订货单与采购单汇总级联 */
public function test_cell_sync_refreshes_snapshot_and_cascades(): void
/** 门店购买详情-新增单品:挂靠最新一笔订单,汇总级联重算;重复添加拒绝 */
public function test_store_item_add_creates_item_and_recalculates(): void
{
// 等级上浮 30%:下单时成本 10.00 → 售价 13.00
$level = CustomerLevelModel::factory()->create(['percent' => 30]);
$store = StoreModel::factory()->create(['level_id' => $level->id]);
$product = ProductModel::factory()->create([
'status' => ProductModel::STATUS_ON,
'cost_price' => '10.00',
'spec' => '1斤/袋',
'unit' => '斤',
]);
[$purchase, , $stores] = $this->buildPurchase();
$extra = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'cost_price' => 8]);
$this->actingAsSysUser();
$this->actingAsMiniUser(UserModel::factory()->forStore($store->id)->create());
$this->postJson('/mini/order', ['items' => [['product_id' => $product->id, 'quantity' => 2]]])
->assertJsonPath('success', true);
$this->postJson("/purchase/order/{$purchase->id}/store/{$stores[0]->id}/item", [
'product_id' => $extra->id,
'quantity' => 4,
'weight' => 1.5,
])->assertOk()->assertJsonPath('success', true);
$item = StoreOrderItemModel::where('product_id', $extra->id)->first();
$this->assertNotNull($item);
$this->assertSame($stores[0]->id, $item->store_id);
$this->assertSame($purchase->id, $item->purchase_id);
$this->assertSame('8.00', (string) $item->price, '等级上浮 0% → 单价=成本价');
$this->assertSame('32.00', (string) $item->amount, '8.00 × 4');
$this->assertSame('1.500', (string) $item->weight);
$order = StoreOrderModel::find($item->order_id);
$this->assertSame(6, $order->total_quantity, '2+4');
$this->assertSame('52.00', (string) $order->total_amount, '20+32');
$purchase = $purchase->fresh();
$this->assertSame('9.00', (string) $purchase->total_quantity, '2+3+4');
$this->assertSame('82.00', (string) $purchase->estimate_amount, '50+324 包 × 成本 8.00');
$this->assertSame('1.500', (string) $purchase->total_weight);
// 重复添加同一商品 → 拒绝
$this->postJson("/purchase/order/{$purchase->id}/store/{$stores[0]->id}/item", [
'product_id' => $extra->id,
'quantity' => 1,
])->assertJsonPath('success', false)
->assertJsonPath('msg', '该商品已在此门店采购明细中,请直接修改数量');
}
/** 门店购买详情-修改单品:同商品多笔订单明细合并到最早一条,涉及订单逐一重算 */
public function test_store_item_update_merges_multi_order_rows(): void
{
$level = CustomerLevelModel::factory()->create();
$store = StoreModel::factory()->create(['level_id' => $level->id]);
$product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'cost_price' => 10]);
// 同一门店两笔订单各订 2 件(同商品)→ 同一采购单
$user = UserModel::factory()->forStore($store->id)->create();
$this->actingAsMiniUser($user);
foreach ([2, 2] 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();
$this->assertSame(2, StoreOrderItemModel::count(), '两笔订单各一条明细');
$this->putJson("/purchase/order/{$purchase->id}/store/{$store->id}/item/{$product->id}", [
'quantity' => 10,
'price' => 12,
'weight' => 5,
])->assertJsonPath('success', true);
$this->assertSame(1, StoreOrderItemModel::count(), '多笔明细合并为一条');
$item = StoreOrderItemModel::first();
$this->assertSame('13.00', (string) $item->price, '下单时 10 元上浮 30%');
$this->assertSame('26.00', (string) $purchase->estimate_amount);
$this->assertSame(10, $item->quantity);
$this->assertSame('12.00', (string) $item->price);
$this->assertSame('120.00', (string) $item->amount);
$this->assertSame('5.000', (string) $item->weight);
// 成本上调后同步:单价按最新成本重算,金额与两级汇总级联
$product->update(['cost_price' => '20.00']);
$this->putJson("/purchase/order/cell/{$item->id}/sync")
->assertJsonPath('success', true)
->assertJsonPath('data.amount', 52);
$item->refresh();
$this->assertSame('20.00', (string) $item->cost_price);
$this->assertSame('26.00', (string) $item->price, '20 元上浮 30% = 26.00');
$this->assertSame('52.00', (string) $item->amount, '26.00 × 2');
$order = StoreOrderModel::first();
$this->assertSame('52.00', (string) $order->product_amount);
$this->assertSame('52.00', (string) $order->total_amount);
// 保留行所在订单重算;被合并订单清零
$survivedOrder = StoreOrderModel::find($item->order_id);
$this->assertSame(10, $survivedOrder->total_quantity);
$this->assertSame('120.00', (string) $survivedOrder->total_amount);
$mergedOrder = StoreOrderModel::where('id', '<>', $item->order_id)->first();
$this->assertSame(0, $mergedOrder->total_quantity);
$this->assertSame('0.00', (string) $mergedOrder->total_amount);
$purchase = $purchase->fresh();
$this->assertSame('52.00', (string) $purchase->estimate_amount);
$this->assertSame('40.00', (string) $purchase->actual_amount, '2× 每包成本 20.00');
$this->assertSame('10.00', (string) $purchase->total_quantity);
$this->assertSame('100.00', (string) $purchase->estimate_amount, '10× 成本 10.00');
$this->assertSame('5.000', (string) $purchase->total_weight);
}
/** 门店购买详情-移除单品:明细删除,订单与采购单汇总级联重算 */
public function test_store_item_remove_deletes_and_recalculates(): void
{
[$purchase, $product, $stores] = $this->buildPurchase();
$this->actingAsSysUser();
$this->deleteJson("/purchase/order/{$purchase->id}/store/{$stores[0]->id}/item/{$product->id}")
->assertOk()->assertJsonPath('success', true);
$this->assertSame(0, StoreOrderItemModel::where('store_id', $stores[0]->id)->count());
$this->assertSame(1, StoreOrderItemModel::count(), '门店B 明细不受影响');
$order = StoreOrderModel::where('store_id', $stores[0]->id)->first();
$this->assertSame(0, $order->total_quantity);
$this->assertSame('0.00', (string) $order->total_amount);
$purchase = $purchase->fresh();
$this->assertSame('3.00', (string) $purchase->total_quantity, '仅剩门店B 3 件');
$this->assertSame('30.00', (string) $purchase->estimate_amount);
}
/** 门店购买详情:按商品聚合该门店采购汇总(数量/包规/单位/单价/预计金额),仅含该门店明细 */
@@ -408,28 +476,37 @@ class PurchaseEditTest extends ProcurementTestCase
->assertJsonPath('msg', '采购单不存在');
}
/** 采购单已完成:单元格编辑/同步、行修改均拒绝,明细不变 */
/** 采购单已完成:单元格编辑、行修改、门店单品增删改均拒绝,明细不变 */
public function test_edits_rejected_when_purchase_completed(): void
{
[$purchase, $product, $stores] = $this->buildPurchase();
$item = StoreOrderItemModel::where('store_id', $stores[0]->id)->first();
$supplier = SupplierModel::factory()->create();
$purchase->update(['status' => PurchaseOrderModel::STATUS_COMPLETED]);
$this->actingAsSysUser();
$this->putJson("/purchase/order/cell/{$item->id}", ['quantity' => 5])
$this->putJson("/purchase/order/cell/{$item->id}", ['quantity' => 5, 'price' => 10])
->assertJsonPath('success', false)
->assertJsonPath('msg', '采购单已完成,不允许修改明细');
$this->putJson("/purchase/order/cell/{$item->id}/sync")
->assertJsonPath('success', false);
$this->putJson("/purchase/order/{$purchase->id}/row/{$product->id}", ['cost_price' => 30])
$this->putJson("/purchase/order/{$purchase->id}/row/{$product->id}", [
'product_name' => '改名',
'supplier_id' => $supplier->id,
'product_spec' => '5斤/袋',
'unit' => '袋',
'cost_price' => 30,
])->assertJsonPath('success', false)
->assertJsonPath('msg', '采购单已完成,不允许修改明细');
$this->postJson("/purchase/order/{$purchase->id}/store/{$stores[0]->id}/item", [
'product_id' => $product->id,
'quantity' => 1,
])->assertJsonPath('success', false);
$this->putJson("/purchase/order/{$purchase->id}/store/{$stores[0]->id}/item/{$product->id}", [
'quantity' => 5,
])->assertJsonPath('success', false);
$this->deleteJson("/purchase/order/{$purchase->id}/store/{$stores[0]->id}/item/{$product->id}")
->assertJsonPath('success', false);
$this->assertSame(2, $item->fresh()->quantity, '被拒绝后明细不变');
$this->assertSame('10.00', (string) $item->fresh()->cost_price);
// 下钻明细同步标记不可编辑
$this->getJson("/purchase/order/{$purchase->id}/cell?product_id={$product->id}&store_id={$stores[0]->id}")
->assertOk()
->assertJsonPath('data.items.0.editable', false);
}
}
+82 -112
View File
@@ -4,14 +4,16 @@ namespace Tests\Feature;
use App\Models\CustomerLevelModel;
use App\Models\ProductModel;
use App\Models\PurchaseOrderModel;
use App\Models\StoreModel;
use App\Models\StoreOrderItemModel;
use App\Models\StoreOrderModel;
use App\Models\SupplierModel;
use App\Models\UserModel;
/**
* 门店订单明细(商品快照):
* 下单快照完整商品档案、成本价防泄漏、后台明细修改重算总价、一键同步商品档案、按商品名称搜索订单
* 下单快照完整商品档案、成本价防泄漏、采购单内门店单品增删改重算汇总、按商品名称搜索订单
*/
class StoreOrderItemTest extends ProcurementTestCase
{
@@ -50,6 +52,17 @@ class StoreOrderItemTest extends ProcurementTestCase
return $order;
}
/** 下单 → 接单 → 生成采购单,返回采购单 */
private function generatePurchase(): PurchaseOrderModel
{
StoreOrderModel::query()->update(['status' => StoreOrderModel::STATUS_SUMMARIZED]);
$this->actingAsSysUser();
$this->postJson('/purchase/order/generate', ['purchase_date' => now()->toDateString()])
->assertJsonPath('success', true);
return PurchaseOrderModel::first();
}
/** 下单时明细快照完整商品档案(分类/供应商/单位/图片/图文/保质期/成本价) */
public function test_place_order_snapshots_full_product_info(): void
{
@@ -114,33 +127,23 @@ class StoreOrderItemTest extends ProcurementTestCase
->assertJsonPath('data.items.0.supplier.name', $supplier->name);
}
/** 修改明细:重算单品金额与订单总量/总额(总额 = 商品金额,附加金额已迁入账单) */
public function test_update_item_recalculates_order_totals(): void
/** 采购单内修改门店单品:重算单品金额与订单/采购单汇总 */
public function test_update_store_item_recalculates_totals(): void
{
[$store, $product, $user] = $this->makeStoreWithProduct('5.00');
$order = $this->placeOrder($product, $user, 2, StoreOrderModel::STATUS_SUMMARIZED);
$item = $order->items->first();
$supplier = SupplierModel::factory()->create();
$purchase = $this->generatePurchase();
$this->actingAsSysUser();
$this->putJson("/order/store/item/{$item->id}", [
'supplier_id' => $supplier->id,
'product_name' => '改名后的商品',
'product_spec' => '新规格',
'unit' => '箱',
$this->putJson("/purchase/order/{$purchase->id}/store/{$store->id}/item/{$product->id}", [
'price' => '6.00',
'cost_price' => '3.50',
'quantity' => 5,
'weight' => '2.5',
])->assertOk()->assertJsonPath('success', true);
$item->refresh();
$this->assertSame($supplier->id, $item->supplier_id);
$this->assertSame('改名后的商品', $item->product_name);
$this->assertSame('新规格', $item->product_spec);
$this->assertSame('箱', $item->unit);
$this->assertSame('6.00', (string) $item->price);
$this->assertSame('3.50', (string) $item->cost_price);
$this->assertSame(5, $item->quantity);
$this->assertSame('2.500', (string) $item->weight);
$this->assertSame('30.00', (string) $item->amount, '6.00 × 5');
@@ -148,139 +151,106 @@ class StoreOrderItemTest extends ProcurementTestCase
$order->refresh();
$this->assertSame(5, $order->total_quantity, '订货总量 = 明细合计');
$this->assertSame('2.500', (string) $order->total_weight, '总重量 = 明细合计');
$this->assertSame('30.00', (string) $order->product_amount, '商品总金额 = 明细金额合计');
$this->assertSame('30.00', (string) $order->total_amount, '订单总金额 = 商品金额');
$this->assertSame('30.00', (string) $order->total_amount, '订单总金额 = 明细金额合计');
$purchase->refresh();
$this->assertSame('5.00', (string) $purchase->total_quantity);
$this->assertSame('2.500', (string) $purchase->total_weight);
$this->assertSame('20.00', (string) $purchase->estimate_amount, '5 包 × 成本 4.00');
}
/** 已完成/已取消订单不允许修改明细 */
public function test_update_item_rejected_when_completed_or_cancelled(): void
/** 采购单已完成:门店单品增删改均拒绝,明细不变 */
public function test_store_item_edits_rejected_when_purchase_completed(): void
{
[, $product, $user] = $this->makeStoreWithProduct('5.00');
$order = $this->placeOrder($product, $user, 1);
$item = $order->items->first();
$payload = [
'supplier_id' => 0,
'product_name' => '不应生效',
'product_spec' => '',
'unit' => '斤',
'price' => '1.00',
'cost_price' => '1.00',
'quantity' => 1,
'weight' => 0,
];
$order = $this->placeOrder($product, $user, 1, StoreOrderModel::STATUS_SUMMARIZED);
$purchase = $this->generatePurchase();
$store = StoreModel::find($order->store_id);
$purchase->update(['status' => PurchaseOrderModel::STATUS_COMPLETED]);
$this->actingAsSysUser();
foreach ([StoreOrderModel::STATUS_COMPLETED, StoreOrderModel::STATUS_CANCELLED] as $status) {
$order->update(['status' => $status]);
$this->putJson("/order/store/item/{$item->id}", $payload)
->assertOk()->assertJsonPath('success', false);
}
$this->assertNotSame('不应生效', $item->fresh()->product_name, '被拒绝后明细不变');
$this->putJson("/purchase/order/{$purchase->id}/store/{$store->id}/item/{$product->id}", [
'quantity' => 9,
])->assertOk()->assertJsonPath('success', false)
->assertJsonPath('msg', '采购单已完成,不允许修改明细');
$this->postJson("/purchase/order/{$purchase->id}/store/{$store->id}/item", [
'product_id' => $product->id,
'quantity' => 1,
])->assertJsonPath('success', false);
$this->deleteJson("/purchase/order/{$purchase->id}/store/{$store->id}/item/{$product->id}")
->assertJsonPath('success', false);
$item = $order->items->first();
$this->assertSame(1, $item->fresh()->quantity, '被拒绝后明细不变');
$this->assertSame('5.00', (string) $order->fresh()->total_amount, '被拒绝后总金额不变');
}
/** 修改明细参数校验:负单价被拦截 */
public function test_update_item_validates_negative_price(): void
/** 修改门店单品参数校验:负单价/负数量被拦截 */
public function test_update_store_item_validates_negative_values(): void
{
[, $product, $user] = $this->makeStoreWithProduct('5.00');
$order = $this->placeOrder($product, $user, 1, StoreOrderModel::STATUS_SUMMARIZED);
$item = $order->items->first();
[$store, $product, $user] = $this->makeStoreWithProduct('5.00');
$this->placeOrder($product, $user, 1, StoreOrderModel::STATUS_SUMMARIZED);
$purchase = $this->generatePurchase();
$this->actingAsSysUser();
$this->putJson("/order/store/item/{$item->id}", [
'supplier_id' => 0,
'product_name' => $item->product_name,
'unit' => '斤',
'price' => -1,
'cost_price' => 0,
$this->putJson("/purchase/order/{$purchase->id}/store/{$store->id}/item/{$product->id}", [
'quantity' => 1,
'weight' => 0,
'price' => -1,
])->assertOk()
->assertJsonPath('success', false)
->assertJsonPath('msg', '单价不能小于 0');
$this->putJson("/purchase/order/{$purchase->id}/store/{$store->id}/item/{$product->id}", [
'quantity' => -1,
])->assertOk()
->assertJsonPath('success', false)
->assertJsonPath('msg', '采购数量不能小于 0');
}
/** 一键同步:按商品ID同步最新档案(固定价等级保留原单价 */
public function test_sync_item_updates_snapshot_from_product(): void
/** 新增单品:单价按门店等级上浮比例换算(成本 20 上浮 30% = 26.00 */
public function test_add_store_item_uses_level_percent_price(): void
{
[$store, $product, $user] = $this->makeStoreWithProduct('5.50', '4.00');
$order = $this->placeOrder($product, $user, 3, StoreOrderModel::STATUS_SUMMARIZED);
$item = $order->items->first();
$this->assertSame('16.50', (string) $item->amount);
// 下单后商品档案变更:改名/改规格/改单位/换供应商/调成本价
$supplier = SupplierModel::factory()->create();
$product->update([
'name' => '同步后的品名',
'spec' => '同步后的规格',
'unit' => '箱',
'supplier_id' => $supplier->id,
'cost_price' => '9.99',
]);
$this->actingAsSysUser();
$this->putJson("/order/store/item/{$item->id}/sync")
->assertOk()->assertJsonPath('success', true);
$item->refresh();
$this->assertSame('同步后的品名', $item->product_name);
$this->assertSame('同步后的规格', $item->product_spec);
$this->assertSame('箱', $item->unit);
$this->assertSame($supplier->id, $item->supplier_id);
$this->assertSame('9.99', (string) $item->cost_price);
$this->assertSame('5.50', (string) $item->price, '固定价等级单价不随档案变化');
$this->assertSame('16.50', (string) $item->amount, '单价未变,金额不变');
}
/** 一键同步:百分比计价等级按最新成本价重算单价与订单总价 */
public function test_sync_item_recalculates_percent_price_with_latest_cost(): void
{
// 等级上浮 30%:下单时成本 10.00 → 售价 13.00
$level = CustomerLevelModel::factory()->create(['percent' => 30]);
$store = StoreModel::factory()->create(['level_id' => $level->id]);
$product = ProductModel::factory()->create([
'status' => ProductModel::STATUS_ON,
'cost_price' => '10.00',
]);
$product = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'cost_price' => '10.00']);
$extra = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'cost_price' => '20.00']);
$user = UserModel::factory()->forStore($store->id)->create();
$order = $this->placeOrder($product, $user, 2, StoreOrderModel::STATUS_SUMMARIZED);
$item = $order->items->first();
$this->assertSame('13.00', (string) $item->price, '下单时 10 元上浮 30%');
$this->assertSame('26.00', (string) $order->total_amount);
// 成本价上调后同步:单价应按最新成本重算
$product->update(['cost_price' => '20.00']);
$this->placeOrder($product, $user, 2, StoreOrderModel::STATUS_SUMMARIZED);
$purchase = $this->generatePurchase();
$this->actingAsSysUser();
$this->putJson("/order/store/item/{$item->id}/sync")
->assertOk()->assertJsonPath('success', true);
$this->postJson("/purchase/order/{$purchase->id}/store/{$store->id}/item", [
'product_id' => $extra->id,
'quantity' => 2,
])->assertOk()->assertJsonPath('success', true);
$item->refresh();
$this->assertSame('20.00', (string) $item->cost_price);
$item = StoreOrderItemModel::where('product_id', $extra->id)->first();
$this->assertNotNull($item);
$this->assertSame('26.00', (string) $item->price, '20 元上浮 30% = 26.00');
$this->assertSame('52.00', (string) $item->amount, '26.00 × 2');
$order->refresh();
$this->assertSame('52.00', (string) $order->product_amount);
$this->assertSame('52.00', (string) $order->total_amount);
$this->assertSame('20.00', (string) $item->cost_price, '快照成本价');
$this->assertSame($extra->name, $item->product_name, '快照品名取自商品档案');
}
/** 一键同步:商品已删除时拒绝同步 */
public function test_sync_item_rejected_when_product_deleted(): void
/** 新增单品:商品已删除时拒绝 */
public function test_add_store_item_rejected_when_product_deleted(): void
{
[, $product, $user] = $this->makeStoreWithProduct('5.00');
$order = $this->placeOrder($product, $user, 1, StoreOrderModel::STATUS_SUMMARIZED);
$item = $order->items->first();
[$store, $product, $user] = $this->makeStoreWithProduct('5.00');
$this->placeOrder($product, $user, 1, StoreOrderModel::STATUS_SUMMARIZED);
$purchase = $this->generatePurchase();
$product->delete(); // 软删除
$extra = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'cost_price' => '9.00']);
$extra->delete(); // 软删除
$this->actingAsSysUser();
$this->putJson("/order/store/item/{$item->id}/sync")
->assertOk()
$this->postJson("/purchase/order/{$purchase->id}/store/{$store->id}/item", [
'product_id' => $extra->id,
'quantity' => 1,
])->assertOk()
->assertJsonPath('success', false)
->assertJsonPath('msg', '商品不存在或已被删除,无法同步');
->assertJsonPath('msg', '商品不存在或已被删除,无法添加');
}
/** 订单列表按包含的商品名称搜索 */
+54 -3
View File
@@ -5,6 +5,8 @@ import type {
IPurchaseCell,
IPurchaseDetail,
IPurchaseStoreSummary,
PurchaseStoreItemAddParams,
PurchaseStoreItemUpdateParams,
} from '@/domain/iPurchaseOrder.ts';
/** 订单商品参数修改 */
@@ -102,11 +104,60 @@ export async function generateBill(purchaseId: number, stores: BillGenerateStore
});
}
/** 导出采购单(Excel 表格,全品类 */
export async function exportPurchase(id: number) {
/** 门店购买详情:新增单品(挂靠该门店在采购单中的最新一笔订单 */
export async function addPurchaseStoreItem(purchaseId: number, storeId: number, data: PurchaseStoreItemAddParams) {
return createAxios<{ id: number }>({
url: `/purchase/order/${purchaseId}/store/${storeId}/item`,
method: 'post',
data,
});
}
/** 门店购买详情:修改单品(数量/称重/单价;同商品多笔订单明细时合并到最早一条) */
export async function updatePurchaseStoreItem(
purchaseId: number,
storeId: number,
productId: number,
data: PurchaseStoreItemUpdateParams,
) {
return createAxios({
url: `/purchase/order/${purchaseId}/store/${storeId}/item/${productId}`,
method: 'put',
data,
});
}
/** 门店购买详情:移除单品 */
export async function removePurchaseStoreItem(purchaseId: number, storeId: number, productId: number) {
return createAxios({
url: `/purchase/order/${purchaseId}/store/${storeId}/item/${productId}`,
method: 'delete',
});
}
/** 导出采购单商品明细(系统全部商品行,支持按供应商筛选) */
export async function exportPurchase(id: number, supplierId?: number) {
return downloadBlob(
`/purchase/order/${id}/export`,
{},
supplierId ? { supplier_id: supplierId } : {},
`采购单_${id}.xlsx`,
);
}
/** 导出门店购买详情(多工作表;storeId 单门店导出) */
export async function exportPurchaseStores(id: number, storeId?: number) {
return downloadBlob(
`/purchase/order/${id}/exportStores`,
storeId ? { store_id: storeId } : {},
`门店购买详情_${id}.xlsx`,
);
}
/** 导出供应商采购明细(多工作表;supplierId 单供应商导出) */
export async function exportPurchaseSuppliers(id: number, supplierId?: number) {
return downloadBlob(
`/purchase/order/${id}/exportSuppliers`,
supplierId ? { supplier_id: supplierId } : {},
`供应商采购明细_${id}.xlsx`,
);
}
+33
View File
@@ -23,9 +23,42 @@ export interface IPurchaseDetailRow {
quantity: number;
/** 合计实际称重 */
weight: number;
/** 合计订货金额(Σ明细 amount;参考零售价 = amount÷quantity÷包规数值) */
amount: string;
cells: Record<number, number>;
}
/** 供应商采购明细行(供应商维度聚合,成本口径) */
export interface IPurchaseSupplierItem {
product_id: number;
product_name: string;
/** 规格/包规 */
product_spec: string;
unit: string;
/** 成本价 */
cost_price: string;
/** 数量(包数) */
quantity: number;
/** 重量(斤) */
weight: string;
/** 金额 = Σ 数量×成本价 */
amount: string;
}
/** 门店购买详情:新增单品参数 */
export interface PurchaseStoreItemAddParams {
product_id: number;
quantity: number;
weight?: number;
}
/** 门店购买详情:修改单品参数(同商品多笔订单明细时合并到最早一条) */
export interface PurchaseStoreItemUpdateParams {
quantity: number;
price?: number;
weight?: number;
}
/** 采购单 */
export default interface IPurchaseOrder {
id?: number;
+491 -20
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
Button,
Descriptions,
@@ -11,6 +11,7 @@ import {
message,
Modal,
Popconfirm,
Radio,
Select,
Space,
Spin,
@@ -19,7 +20,7 @@ import {
Tag,
Typography,
} from 'antd';
import {AccountBookOutlined, DownloadOutlined, EditOutlined, UnorderedListOutlined} from '@ant-design/icons';
import {AccountBookOutlined, DeleteOutlined, DownloadOutlined, EditOutlined, PlusOutlined, UnorderedListOutlined} from '@ant-design/icons';
import type { TableProps } from 'antd';
import XinTable from '@/components/XinTable';
import type {
@@ -37,25 +38,35 @@ import type {
IPurchaseDetailRow,
IPurchaseStoreItem,
IPurchaseStoreSummary,
IPurchaseSupplierItem,
PurchaseStoreItemAddParams,
PurchaseStoreItemUpdateParams,
} from '@/domain/iPurchaseOrder.ts';
import { PURCHASE_STATUS_MAP, BILL_STATUS_MAP } from '@/domain/iPurchaseOrder.ts';
import { STORE_ORDER_STATUS_MAP } from '@/domain/iStoreOrder.ts';
import {
addPurchaseStoreItem,
exportPurchase,
exportPurchaseStores,
exportPurchaseSuppliers,
generateBill,
getBillPrepare,
getPurchaseCell,
getPurchaseDetail,
getPurchaseStoreSummary,
removePurchaseStoreItem,
type BillGenerateStoreParams,
type PurchaseCellUpdateParams,
type PurchaseRowUpdateParams,
updatePurchaseCellItem,
updatePurchaseRow,
updatePurchaseStoreItem,
} from '@/api/purchase/order.ts';
import { Update } from '@/api/common/table.ts';
import { getSupplierOptions } from '@/api/customer/supplier.ts';
import { getProductOptions } from '@/api/product/goods.ts';
import type ISupplier from '@/domain/iSupplier.ts';
import type IProduct from '@/domain/iProduct.ts';
import AuthButton from '@/components/AuthButton';
const { Title, Text } = Typography;
@@ -103,6 +114,56 @@ const PurchaseOrderPage: React.FC = () => {
const [storeSummary, setStoreSummary] = useState<IPurchaseStoreSummary | null>(null);
const [storeLoading, setStoreLoading] = useState(false);
// 门店购买详情:新增单品弹窗
const [addItemOpen, setAddItemOpen] = useState(false);
const [addItemSaving, setAddItemSaving] = useState(false);
const [addItemForm] = Form.useForm<PurchaseStoreItemAddParams>();
const [productOptions, setProductOptions] = useState<IProduct[]>([]);
// 门店购买详情:单品编辑弹窗(数量/称重/单价)
const [storeItemTarget, setStoreItemTarget] = useState<IPurchaseStoreItem | null>(null);
const [storeItemSaving, setStoreItemSaving] = useState(false);
const [storeItemForm] = Form.useForm<PurchaseStoreItemUpdateParams>();
// 供应商采购明细页签
const [supplierId, setSupplierId] = useState<number>(0);
// 导出弹窗:商品明细(供应商筛选)/ 门店购买详情 / 供应商采购明细
const [itemExportOpen, setItemExportOpen] = useState(false);
const [itemExportSupplier, setItemExportSupplier] = useState<number>(0);
const [storeExportOpen, setStoreExportOpen] = useState(false);
const [storeExportScope, setStoreExportScope] = useState<'current' | 'all'>('current');
const [supplierExportOpen, setSupplierExportOpen] = useState(false);
const [supplierExportScope, setSupplierExportScope] = useState<'current' | 'all'>('current');
/** 采购单内出现的供应商(矩阵/供应商页签筛选与导出选项共用) */
const purchaseSuppliers = useMemo(() => {
const map = new Map<number, string>();
(detail?.items ?? []).forEach((row) => {
if (row.supplier_id > 0 && row.supplier?.name) {
map.set(row.supplier_id, row.supplier.name);
}
});
return Array.from(map, ([id, name]) => ({ id, name }));
}, [detail?.items]);
/** 供应商采购明细行:按供应商过滤矩阵行,金额=数量×成本价(成本口径) */
const supplierItems = useMemo<IPurchaseSupplierItem[]>(() => {
if (!detail || supplierId <= 0) return [];
return detail.items
.filter((row) => row.supplier_id === supplierId)
.map((row) => ({
product_id: row.product_id,
product_name: row.product_name,
product_spec: row.product_spec,
unit: row.unit,
cost_price: String(row.cost_price),
quantity: row.quantity,
weight: String(row.weight),
amount: (row.quantity * Number(row.cost_price)).toFixed(2),
}));
}, [detail, supplierId]);
// 生成账单(已完成采购单按门店生成:填写配送费/周转筐/托盘数量,金额只读)
const [billOpen, setBillOpen] = useState(false);
const [billPrepare, setBillPrepare] = useState<IBillPrepare | null>(null);
@@ -144,6 +205,15 @@ const PurchaseOrderPage: React.FC = () => {
}
}, [detail]);
// 详情加载后默认选中第一个供应商(当前选中供应商仍在采购单内则保留)
useEffect(() => {
if (purchaseSuppliers.length > 0) {
setSupplierId((prev) => (purchaseSuppliers.some((s) => s.id === prev) ? prev : purchaseSuppliers[0].id));
} else {
setSupplierId(0);
}
}, [purchaseSuppliers]);
// 切到「门店购买详情」页签或切换门店时加载汇总
useEffect(() => {
if (detailOpen && detailTab === 'stores' && detail && storeId > 0) {
@@ -182,6 +252,76 @@ const PurchaseOrderPage: React.FC = () => {
}
};
/** 门店单品增删改后:刷新门店汇总、采购单详情与列表 */
const refreshAfterStoreItemChange = async () => {
await loadStoreSummary();
if (detail) {
await loadDetail(detail.purchase.id!);
}
await tableRef.current?.reload();
};
/** 打开新增单品弹窗(懒加载商品选项) */
const openAddItem = () => {
addItemForm.resetFields();
setAddItemOpen(true);
if (productOptions.length === 0) {
getProductOptions().then((res) => setProductOptions(res.data.data ?? []));
}
};
/** 提交新增单品 */
const handleAddItemSave = async (values: PurchaseStoreItemAddParams) => {
if (!detail || storeId <= 0) {
return;
}
setAddItemSaving(true);
try {
await addPurchaseStoreItem(detail.purchase.id!, storeId, values);
message.success('已添加单品,订货单与采购单汇总已重算');
setAddItemOpen(false);
await refreshAfterStoreItemChange();
} finally {
setAddItemSaving(false);
}
};
/** 打开单品编辑弹窗(回显加权平均单价/数量/称重) */
const openStoreItemEdit = (row: IPurchaseStoreItem) => {
setStoreItemTarget(row);
storeItemForm.setFieldsValue({
quantity: row.quantity,
price: Number(row.price ?? 0),
weight: Number(row.weight ?? 0),
});
};
/** 提交单品修改:同商品多笔订单明细合并到最早一条,级联重算 */
const handleStoreItemSave = async (values: PurchaseStoreItemUpdateParams) => {
if (!detail || !storeItemTarget || storeId <= 0) {
return;
}
setStoreItemSaving(true);
try {
await updatePurchaseStoreItem(detail.purchase.id!, storeId, storeItemTarget.product_id, values);
message.success('单品已更新,订货单与采购单汇总已重算');
setStoreItemTarget(null);
await refreshAfterStoreItemChange();
} finally {
setStoreItemSaving(false);
}
};
/** 移除单品 */
const handleStoreItemRemove = async (row: IPurchaseStoreItem) => {
if (!detail || storeId <= 0) {
return;
}
await removePurchaseStoreItem(detail.purchase.id!, storeId, row.product_id);
message.success('已移除单品,订货单与采购单汇总已重算');
await refreshAfterStoreItemChange();
};
/** 打开单元格下钻:门店 + 商品 → 该采购单下全部订货明细 */
const openCell = (row: IPurchaseDetailRow, store: { id: number; name: string }) => {
setCellQuery({ productId: row.product_id, storeId: store.id });
@@ -323,7 +463,7 @@ const PurchaseOrderPage: React.FC = () => {
}
};
/** 明细矩阵列:品名/供应商/价/包规/单位/成本 + 每门店一列(数量)+ 合计 + 操作 */
/** 明细矩阵列:品名/供应商/参考零售价/包规/单位/成本 + 每门店一列(数量)+ 合计 + 操作 */
const buildItemColumns = (): TableProps<IPurchaseDetailRow>['columns'] => {
const fixed: NonNullable<TableProps<IPurchaseDetailRow>['columns']> = [
{ title: '品名', dataIndex: 'product_name', width: 120, align: 'center' },
@@ -335,11 +475,17 @@ const PurchaseOrderPage: React.FC = () => {
render: (_, row) => row.supplier?.name ?? '-',
},
{
title: '参考成本单价',
key: 'unit_cost',
title: '参考零售价',
key: 'retail_price',
width: 100,
align: 'center',
render: (_, row) => `¥${calcUnitRefPrice(Number(row.cost_price), row.product_spec ?? '').toFixed(2)}`,
render: (_, row) => {
// 加权平均售价 ÷ 包规数值(无订货数量时无参考价)
const quantity = Number(row.quantity ?? 0);
if (quantity <= 0) return <Text type="secondary">-</Text>;
const weightedPrice = Number(row.amount ?? 0) / quantity;
return `¥${calcUnitRefPrice(weightedPrice, row.product_spec ?? '').toFixed(2)}`;
},
},
{ title: '包规', dataIndex: 'product_spec', align: 'center', width: 90, render: (v) => v || '-' },
{ title: '单位', dataIndex: 'unit', width: 70, align: 'center', render: (v) => v || '-' },
@@ -430,7 +576,7 @@ const PurchaseOrderPage: React.FC = () => {
);
};
/** 门店购买详情列:商品/购买参考零售价(单价÷包规)/包规/单位/单价/数量/预计金额 */
/** 门店购买详情列:商品/购买参考零售价(单价÷包规)/包规/单位/单价/数量/预计金额 + 操作 */
const storeColumns: TableProps<IPurchaseStoreItem>['columns'] = [
{ title: '商品', dataIndex: 'product_name', width: 160, align: 'center' },
{
@@ -464,6 +610,40 @@ const PurchaseOrderPage: React.FC = () => {
align: 'center',
render: (v) => <Text strong>¥{Number(v).toFixed(2)}</Text>,
},
{
title: '操作',
key: 'action',
width: 130,
align: 'center',
render: (_, row) =>
detail?.purchase.status === 0 ? (
<Space size={0}>
<AuthButton auth="purchase.order.update">
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => openStoreItemEdit(row)}
>
</Button>
</AuthButton>
<AuthButton auth="purchase.order.update">
<Popconfirm
title="移除该单品?"
description={`将从「${storeSummary?.store?.name ?? '该门店'}」采购明细中移除「${row.product_name}`}
onConfirm={() => handleStoreItemRemove(row)}
>
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</AuthButton>
</Space>
) : (
<Text type="secondary">-</Text>
),
},
];
/** 门店购买详情合计行:总数量 + 总预计金额 */
@@ -486,6 +666,61 @@ const PurchaseOrderPage: React.FC = () => {
<Table.Summary.Cell index={7} align="center">
<Text strong type="danger">¥{totalAmount.toFixed(2)}</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={8} align="center">
<Text strong>-</Text>
</Table.Summary.Cell>
</Table.Summary.Row>
);
};
/** 供应商采购明细列:品名/包规/单位/成本价/数量/重量/金额(成本口径) */
const supplierColumns: TableProps<IPurchaseSupplierItem>['columns'] = [
{ title: '品名', dataIndex: 'product_name', width: 180, align: 'center' },
{ title: '包规', dataIndex: 'product_spec', width: 110, align: 'center', render: (v) => v || '-' },
{ title: '单位', dataIndex: 'unit', width: 90, align: 'center', render: (v) => v || '-' },
{
title: '成本价',
dataIndex: 'cost_price',
width: 110,
align: 'center',
render: (v) => `¥${Number(v).toFixed(2)}`,
},
{
title: '数量',
dataIndex: 'quantity',
width: 100,
align: 'center',
render: (v) => <Text strong>{v}</Text>,
},
{ title: '重量', dataIndex: 'weight', width: 110, align: 'center', render: (v) => `${Number(v).toFixed(3)}` },
{
title: '金额',
dataIndex: 'amount',
width: 120,
align: 'center',
render: (v) => <Text strong>¥{Number(v).toFixed(2)}</Text>,
},
];
/** 供应商采购明细合计行 */
const renderSupplierTotal = () => {
const totalQuantity = supplierItems.reduce((sum, row) => sum + Number(row.quantity), 0);
const totalWeight = supplierItems.reduce((sum, row) => sum + Number(row.weight), 0);
const totalAmount = supplierItems.reduce((sum, row) => sum + Number(row.amount), 0);
return (
<Table.Summary.Row>
<Table.Summary.Cell index={0} colSpan={4} align="center">
<Text strong></Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={4} align="center">
<Text strong>{totalQuantity}</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={5} align="center">
<Text strong>{totalWeight.toFixed(3)}</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={6} align="center">
<Text strong type="danger">¥{totalAmount.toFixed(2)}</Text>
</Table.Summary.Cell>
</Table.Summary.Row>
);
};
@@ -770,23 +1005,48 @@ const PurchaseOrderPage: React.FC = () => {
items={[
{ key: 'items', label: '商品明细' },
{ key: 'stores', label: '门店购买详情' },
{ key: 'suppliers', label: '供应商采购明细' },
{ key: 'bills', label: '门店账单' },
]}
/>
{ detailTab === 'stores' ? (
<>
<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 className="mb-3 flex items-center justify-between gap-2">
<div className="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>
<Space>
{detail.purchase.status === 0 && (
<AuthButton auth="purchase.order.update">
<Button icon={<PlusOutlined />} onClick={openAddItem}>
</Button>
</AuthButton>
)}
<AuthButton auth="purchase.order.export">
<Button
type="primary"
ghost
icon={<DownloadOutlined />}
onClick={() => {
setStoreExportScope('current');
setStoreExportOpen(true);
}}
>
</Button>
</AuthButton>
</Space>
</div>
<Spin spinning={storeLoading}>
{storeSummary && storeSummary.items.length > 0 ? (
@@ -810,6 +1070,53 @@ const PurchaseOrderPage: React.FC = () => {
)}
</Spin>
</>
) : detailTab === 'suppliers' ? (
<>
<div className="mb-3 flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Text></Text>
<Select
value={supplierId || undefined}
onChange={(value) => setSupplierId(value)}
placeholder="选择供应商"
className="w-60!"
showSearch
optionFilterProp="label"
options={purchaseSuppliers.map((s) => ({ value: s.id, label: s.name }))}
/>
</div>
<AuthButton auth="purchase.order.export">
<Button
type="primary"
ghost
icon={<DownloadOutlined />}
onClick={() => {
setSupplierExportScope('current');
setSupplierExportOpen(true);
}}
>
</Button>
</AuthButton>
</div>
{supplierItems.length > 0 ? (
<Table<IPurchaseSupplierItem>
rowKey="product_id"
size="small"
bordered
columns={supplierColumns}
dataSource={supplierItems}
pagination={false}
summary={renderSupplierTotal}
/>
) : (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="该供应商在此采购单中无采购明细"
className="py-8!"
/>
)}
</>
) : detailTab === 'bills' ? (
detail.bills.length > 0 ? (
<Table<IBill>
@@ -836,7 +1143,10 @@ const PurchaseOrderPage: React.FC = () => {
type="primary"
ghost
icon={<DownloadOutlined />}
onClick={() => detail && exportPurchase(detail.purchase.id!)}
onClick={() => {
setItemExportSupplier(0);
setItemExportOpen(true);
}}
>
</Button>
@@ -874,7 +1184,7 @@ const PurchaseOrderPage: React.FC = () => {
]}
>
<div className="py-2 text-gray-500">
</div>
<Form form={editForm} layout="vertical" onFinish={handleEditSave}>
<Form.Item
@@ -1187,6 +1497,167 @@ const PurchaseOrderPage: React.FC = () => {
)}
</Spin>
</Modal>
{/* 新增单品:选择商品 + 采购数量 + 称重 */}
<Modal
title={`新增单品 · ${storeSummary?.store?.name ?? ''}`}
open={addItemOpen}
onCancel={() => setAddItemOpen(false)}
onOk={() => addItemForm.submit()}
confirmLoading={addItemSaving}
okText="添加"
destroyOnHidden
>
<div className="py-2 text-gray-500">
</div>
<Form form={addItemForm} layout="vertical" onFinish={handleAddItemSave}>
<Form.Item
label="商品"
name="product_id"
rules={[{ required: true, message: '请选择商品' }]}
>
<Select
showSearch={{ optionFilterProp: 'label' }}
placeholder="搜索并选择商品"
options={productOptions.map((p) => ({
value: p.id!,
label: `${p.name}${p.spec ? `${p.spec}` : ''}`,
}))}
/>
</Form.Item>
<Form.Item
label="采购数量"
name="quantity"
rules={[{ required: true, message: '请输入采购数量' }]}
>
<InputNumber className="w-full" min={1} precision={0} placeholder="请输入采购数量(包数)" />
</Form.Item>
<Form.Item label="称重(斤,可选)" name="weight">
<InputNumber className="w-full" min={0} precision={3} placeholder="请输入称重" />
</Form.Item>
</Form>
</Modal>
{/* 单品编辑:数量/称重/单价(同商品多笔订单明细合并到最早一条) */}
<Modal
title={storeItemTarget ? `编辑「${storeItemTarget.product_name}` : '编辑单品'}
open={storeItemTarget !== null}
onCancel={() => setStoreItemTarget(null)}
onOk={() => storeItemForm.submit()}
confirmLoading={storeItemSaving}
okText="保存"
destroyOnHidden
>
<div className="py-2 text-gray-500">
</div>
<Form form={storeItemForm} layout="vertical" onFinish={handleStoreItemSave}>
<Form.Item
label="单价(元)"
name="price"
rules={[{ required: true, message: '请输入单价' }]}
>
<InputNumber className="w-full" min={0} precision={2} placeholder="请输入单价" />
</Form.Item>
<Form.Item
label="采购数量"
name="quantity"
rules={[{ required: true, message: '请输入采购数量' }]}
>
<InputNumber className="w-full" min={0} precision={0} placeholder="请输入采购数量" />
</Form.Item>
<Form.Item label="称重(斤)" name="weight" rules={[{ required: true, message: '请输入称重' }]}>
<InputNumber className="w-full" min={0} precision={3} placeholder="请输入称重" />
</Form.Item>
</Form>
</Modal>
{/* 商品明细导出:供应商筛选(全部/单个) */}
<Modal
title="导出商品明细"
open={itemExportOpen}
onCancel={() => setItemExportOpen(false)}
onOk={() => {
if (detail) {
void exportPurchase(detail.purchase.id!, itemExportSupplier > 0 ? itemExportSupplier : undefined);
}
setItemExportOpen(false);
}}
okText="导出"
destroyOnHidden
>
<div className="py-2 text-gray-500">
0
</div>
<div className="flex items-center gap-2 py-2">
<Text></Text>
<Select
value={itemExportSupplier}
onChange={setItemExportSupplier}
className="flex-1"
options={[
{ value: 0, label: '全部供应商' },
...purchaseSuppliers.map((s) => ({ value: s.id, label: s.name })),
]}
/>
</div>
</Modal>
{/* 门店购买详情导出:当前门店 / 全部门店(多工作表) */}
<Modal
title="导出门店购买详情"
open={storeExportOpen}
onCancel={() => setStoreExportOpen(false)}
onOk={() => {
if (detail) {
void exportPurchaseStores(
detail.purchase.id!,
storeExportScope === 'current' && storeId > 0 ? storeId : undefined,
);
}
setStoreExportOpen(false);
}}
okText="导出"
destroyOnHidden
>
<Radio.Group
className="py-2"
value={storeExportScope}
onChange={(e) => setStoreExportScope(e.target.value)}
options={[
{ value: 'current', label: `当前门店(${detail?.stores.find((s) => s.id === storeId)?.name ?? '-'}` },
{ value: 'all', label: '全部门店(合并为一个 XLSX,每门店一个工作表)' },
]}
/>
</Modal>
{/* 供应商采购明细导出:当前供应商 / 全部供应商(多工作表) */}
<Modal
title="导出供应商采购明细"
open={supplierExportOpen}
onCancel={() => setSupplierExportOpen(false)}
onOk={() => {
if (detail) {
void exportPurchaseSuppliers(
detail.purchase.id!,
supplierExportScope === 'current' && supplierId > 0 ? supplierId : undefined,
);
}
setSupplierExportOpen(false);
}}
okText="导出"
destroyOnHidden
>
<Radio.Group
className="py-2"
value={supplierExportScope}
onChange={(e) => setSupplierExportScope(e.target.value)}
options={[
{ value: 'current', label: `当前供应商(${purchaseSuppliers.find((s) => s.id === supplierId)?.name ?? '-'}` },
{ value: 'all', label: '全部供应商(合并为一个 XLSX,每供应商一个工作表)' },
]}
/>
</Modal>
</>
);
};