Files
xin-procurement/app/Exports/PurchaseOrderExport.php
T
2026-08-27 14:20:09 +08:00

298 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Exports;
use App\Exceptions\RepositoryException;
use App\Models\ProductCategoryModel;
use App\Models\ProductModel;
use App\Models\PurchaseOrderModel;
use App\Models\StoreModel;
use App\Models\StoreOrderItemModel;
use App\Models\SupplierModel;
use Illuminate\Support\Collection;
use Maatwebsite\Excel\Concerns\FromCollection;
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, WithStrictNullComparison, WithStyles
{
/** 门店列填充色(浅橙,突出显示) */
private const string STORE_FILL = 'FFFFF7E6';
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 = [];
/**
* @param PurchaseOrderModel $purchase 采购单
* @param int $supplierId 供应商筛选(0=全部)
*/
public function __construct(
private readonly PurchaseOrderModel $purchase,
private readonly int $supplierId = 0,
) {
}
/**
* 导出行:标题/范围/列头/明细/合计全部手工构建(行位置不固定,不用 WithHeadings
*/
public function collection(): Collection
{
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)
->whereNull('store_order.deleted_at')
->select('store_order_item.*')
->get()
->makeVisible('cost_price');
$this->storeNames = StoreModel::withTrashed()
->whereIn('id', $orderItems->pluck('store_id')->unique())
->pluck('name', 'id')
->toArray();
// 明细按商品聚合(快照字段取首条;有订货的商品以快照供应商为准)
$itemGroups = [];
foreach ($orderItems->groupBy('product_id') as $productId => $group) {
$first = $group->first();
$quantity = '0';
$weight = '0';
$amount = '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] = ($storeQuantities[(int) $item->store_id] ?? 0) + (int) $item->quantity;
}
$itemGroups[(int) $productId] = [
'snapshot' => $first,
'quantity' => $quantity,
'weight' => $weight,
'amount' => $amount,
'store_quantities' => $storeQuantities,
];
}
// 导出范围:系统全部上架商品 ∪ 本采购单有订货的商品(含已删/下架)
$products = ProductModel::withTrashed()
->with('category:id,sort')
->where('status', ProductModel::STATUS_ON)
->orWhereIn('id', array_keys($itemGroups))
->get()
->keyBy('id');
$categoryNames = $this->rootCategoryNames($products->pluck('category_id', 'id')->all());
$supplierNames = SupplierModel::withTrashed()->pluck('name', 'id')->toArray();
// 组装商品行(供应商筛选:有订货按快照 supplier_id,无订货按档案 supplier_id
$items = [];
foreach ($products as $productId => $product) {
$group = $itemGroups[(int) $productId] ?? null;
$snapshot = $group['snapshot'] ?? null;
$rowSupplierId = (int) ($snapshot->supplier_id ?? $product->supplier_id);
if ($this->supplierId > 0 && $rowSupplierId !== $this->supplierId) {
continue;
}
$quantity = $group['quantity'] ?? '0';
$amount = $group['amount'] ?? '0';
$spec = (string) ($snapshot->product_spec ?? $product->spec);
$items[] = [
'product_id' => (int) $productId,
'category' => $categoryNames[(int) $productId] ?? '',
'product_name' => (string) ($snapshot->product_name ?? $product->name),
'supplier' => $supplierNames[$rowSupplierId] ?? '',
'market' => (string) ($product->market ?? ''),
'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) ($group['weight'] ?? '0'),
'amount' => (float) $amount,
'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($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']]);
if ($items === []) {
throw new RepositoryException('该供应商在系统中无商品行,无法导出');
}
// ===== 手工建行 =====
$rows = [];
$rowIndex = 0;
// 标题行
$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['market'],
$item['product_spec'],
$item['unit'],
$item['cost_price'],
$item['retail_price'] !== null ? $item['retail_price'] : '',
$item['quantity'],
$item['weight'],
$item['amount'],
], array_values($item['store_quantities']));
$rowIndex++;
$totalQuantity = bcadd($totalQuantity, (string) $item['quantity'], 2);
$totalWeight = bcadd($totalWeight, (string) $item['weight'], 3);
$totalAmount = bcadd($totalAmount, (string) $item['amount'], 2);
foreach ($item['store_quantities'] as $storeId => $qty) {
$storeTotals[$storeId] += $qty;
}
}
// 合计行(行合计 + 门店列合计)
$rows[] = array_merge(
['', '', '合计', '', '', '', '', '', '', (float) $totalQuantity, (float) $totalWeight, (float) $totalAmount],
array_values($storeTotals),
);
$this->specialRows[++$rowIndex] = 'summary';
$this->lastRow = $rowIndex;
return $this->rows = collect($rows);
}
/**
* 标题/列头/合计加粗,门店列整列填充突出颜色,冻结列头
*/
public function styles(Worksheet $sheet): array
{
$this->collection();
$sheet->freezePane('A' . ($this->headerRow + 1));
$widths = [6, 10, 20, 12, 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(13 + $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;
}
/**
* 每单位参考价 = 整单价 ÷ 包规数值(包规解析不出正数时按 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 上溯取根分类)
*
* @param array<int, int> $productCategoryIds 商品ID => 分类ID
* @return array<int, string>
*/
private function rootCategoryNames(array $productCategoryIds): array
{
$categories = ProductCategoryModel::all()->keyBy('id');
$names = [];
foreach ($productCategoryIds as $productId => $categoryId) {
$rootName = '';
$cursor = (int) $categoryId;
$guard = 0;
while ($cursor > 0 && $guard++ < 20) {
$category = $categories->get($cursor);
if ($category === null) {
break;
}
$rootName = $category->name;
$cursor = (int) $category->parent_id;
}
$names[(int) $productId] = $rootName;
}
return $names;
}
}