采购单优化
This commit is contained in:
File diff suppressed because one or more lines are too long
+188
-101
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
$rows[] = [
|
||||
// 导出范围:系统全部上架商品 ∪ 本采购单有订货的商品(含已删/下架)
|
||||
$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;
|
||||
}
|
||||
|
||||
$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);
|
||||
|
||||
$sort = 1;
|
||||
return $this->items = $items->map(static function (array $row) use (&$sort): array {
|
||||
$row['sort'] = $sort++;
|
||||
return $row;
|
||||
})->values();
|
||||
if ($items === []) {
|
||||
throw new RepositoryException('该供应商在系统中无商品行,无法导出');
|
||||
}
|
||||
|
||||
public function headings(): array
|
||||
{
|
||||
$this->collection();
|
||||
// ===== 手工建行 =====
|
||||
$rows = [];
|
||||
$rowIndex = 0;
|
||||
|
||||
return array_merge(
|
||||
['序号', '分类', '品名', '供应商', '包规', '单位', '成本', '单价', '数量', '实际称重', '金额'],
|
||||
// 标题行
|
||||
$rows[] = ['采购单 ' . $this->purchase->purchase_no . '(采购日期 ' . $this->purchase->purchase_date->toDateString() . ')'];
|
||||
$this->specialRows[++$rowIndex] = 'title';
|
||||
|
||||
// 范围行:供应商筛选 / 导出时间
|
||||
$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;
|
||||
|
||||
// 明细行
|
||||
$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;
|
||||
}
|
||||
}
|
||||
|
||||
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']));
|
||||
// 合计行(行合计 + 门店列合计)
|
||||
$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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,12 +679,11 @@ 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();
|
||||
|
||||
return DB::transaction(function () use ($purchase, $productId, $validated) {
|
||||
$items = StoreOrderItemModel::query()
|
||||
->where('purchase_id', $purchase->id)
|
||||
->where('product_id', $productId)
|
||||
@@ -517,13 +696,28 @@ class PurchaseOrderController extends BaseController
|
||||
$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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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', '该供应商在此采购单中无采购明细');
|
||||
}
|
||||
}
|
||||
|
||||
+179
-102
@@ -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'], '订货金额 = Σ明细 amount(5 × 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]]])
|
||||
$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+32(4 包 × 成本 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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', '商品不存在或已被删除,无法添加');
|
||||
}
|
||||
|
||||
/** 订单列表按包含的商品名称搜索 */
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+481
-10
@@ -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,13 +1005,15 @@ 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">
|
||||
<div className="mb-3 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Text>选择门店:</Text>
|
||||
<Select
|
||||
value={storeId || undefined}
|
||||
@@ -788,6 +1025,29 @@ const PurchaseOrderPage: React.FC = () => {
|
||||
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 ? (
|
||||
<Table<IPurchaseStoreItem>
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user