Files
xin-procurement/app/Exports/PurchaseOrderExport.php
T
2026-09-06 22:37:46 +08:00

404 lines
17 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\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Border;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
/**
* 采购单商品明细导出:系统全部未删除商品行(含本采购单无订货的商品,数量 0),
* 支持按供应商筛选;行尾合计 + 门店列合计;数量/金额/门店数量按值条件填色;
* 序号/分类/包规/单位/成本/单价/实际称重/金额列在表格中默认隐藏(数据照常导出,Excel 中可取消隐藏);
* 市场/数量/门店列列宽减半、列头自动换行;门店数量无数据显示空白(不显示 0)
*/
class PurchaseOrderExport implements FromCollection, WithStrictNullComparison, WithStyles
{
/** 数量列填充色(浅黄) */
private const string QUANTITY_FILL = 'FFFFF2CC';
/** 门店数量列填充色(浅蓝) */
private const string STORE_FILL = 'FFDDEBF7';
/** 金额列填充色(浅红) */
private const string AMOUNT_FILL = 'FFFFCCCC';
/** 金额格式:¥ + 两位小数 */
private const string CURRENCY_FORMAT = '"¥"#,##0.00';
private ?Collection $rows = null;
/** @var array<int, string> 特殊行索引(1 起)=> 类型(title/scope/header/summary */
private array $specialRows = [];
/** @var array<int, array{quantity: float, amount: float, store_quantities: array<int, int>}> 明细行索引 => 填色判断数据 */
private array $rowData = [];
/** @var array{quantity: float, amount: float, stores: array<int, int>} 合计行填色判断数据 */
private array $summaryTotals = ['quantity' => 0.0, 'amount' => 0.0, 'stores' => []];
/** 列头所在行索引 */
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')
->whereNull('deleted_at')
->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)
: $this->unitRefPrice((float) ($snapshot->cost_price ?? $product->cost_price), $spec),
'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('Asia/Shanghai')->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) {
$this->rowData[++$rowIndex] = [
'quantity' => $item['quantity'],
'amount' => $item['amount'],
'store_quantities' => $item['store_quantities'],
];
$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'],
$item['quantity'],
$item['weight'],
$item['amount'],
], array_map(
static fn (int $qty): int|string => $qty > 0 ? $qty : '',
array_values($item['store_quantities']),
));
$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_map(static fn (int $qty): int|string => $qty > 0 ? $qty : '', array_values($storeTotals)),
);
$this->specialRows[++$rowIndex] = 'summary';
$this->lastRow = $rowIndex;
$this->summaryTotals = [
'quantity' => (float) $totalQuantity,
'amount' => (float) $totalAmount,
'stores' => $storeTotals,
];
return $this->rows = collect($rows);
}
/**
* 标题/列头/合计加粗;全表居中 + 全边框;金额类列货币格式;
* 数量(浅黄)/门店数量(浅蓝)/金额(浅红)按值条件填色(0 不填、列头固定填色),冻结列头
*/
public function styles(Worksheet $sheet): array
{
$this->collection();
$sheet->freezePane('A' . ($this->headerRow + 1));
// 市场/数量列宽减半
$widths = [6, 10, 20, 12, 6, 12, 8, 10, 12, 5, 12, 12];
foreach ($widths as $index => $width) {
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex($index + 1))->setWidth($width);
}
$storeIds = array_keys($this->storeNames);
$storeCount = count($storeIds);
$lastColumn = Coordinate::stringFromColumnIndex(12 + max($storeCount, 1));
foreach ($storeIds as $i => $storeId) {
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex(13 + $i))->setWidth(6);
}
// 默认隐藏列:序号/分类/包规/单位/成本/单价/实际称重/金额(数据照常导出,Excel 中可取消隐藏)
foreach ([1, 2, 6, 7, 8, 9, 11, 12] as $hiddenIndex) {
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex($hiddenIndex))->setVisible(false);
}
// 全部单元格水平/垂直居中
$sheet->getStyle('A1:' . $lastColumn . $this->lastRow)
->getAlignment()
->setHorizontal(Alignment::HORIZONTAL_CENTER)
->setVertical(Alignment::VERTICAL_CENTER);
// 列头自动换行(市场/数量/门店列较窄,表头文字折行显示)
$sheet->getStyle('A' . $this->headerRow . ':' . $lastColumn . $this->headerRow)
->getAlignment()
->setWrapText(true);
// 表格区域(列头 → 合计行)添加所有边框
$sheet->getStyle('A' . $this->headerRow . ':' . $lastColumn . $this->lastRow)
->getBorders()
->getAllBorders()
->setBorderStyle(Border::BORDER_THIN);
// 金额类列(成本/单价/金额)货币格式:¥ + 两位小数
foreach (['H', 'I', 'L'] as $column) {
$sheet->getStyle($column . ($this->headerRow + 1) . ':' . $column . $this->lastRow)
->getNumberFormat()
->setFormatCode(self::CURRENCY_FORMAT);
}
// 数量列(J):有数据浅黄,0 无背景,列头固定浅黄
$this->fillCell($sheet, 'J' . $this->headerRow, self::QUANTITY_FILL);
foreach ($this->rowData as $row => $data) {
if ($data['quantity'] > 0) {
$this->fillCell($sheet, 'J' . $row, self::QUANTITY_FILL);
}
}
if ($this->summaryTotals['quantity'] > 0) {
$this->fillCell($sheet, 'J' . $this->lastRow, self::QUANTITY_FILL);
}
// 金额列(L):有数据浅红,0 无背景,列头固定浅红
$this->fillCell($sheet, 'L' . $this->headerRow, self::AMOUNT_FILL);
foreach ($this->rowData as $row => $data) {
if ($data['amount'] > 0) {
$this->fillCell($sheet, 'L' . $row, self::AMOUNT_FILL);
}
}
if ($this->summaryTotals['amount'] > 0) {
$this->fillCell($sheet, 'L' . $this->lastRow, self::AMOUNT_FILL);
}
// 门店数量列:有数据浅蓝,0 无背景,列头固定浅蓝
foreach ($storeIds as $i => $storeId) {
$column = Coordinate::stringFromColumnIndex(13 + $i);
$this->fillCell($sheet, $column . $this->headerRow, self::STORE_FILL);
foreach ($this->rowData as $row => $data) {
if (($data['store_quantities'][$storeId] ?? 0) > 0) {
$this->fillCell($sheet, $column . $row, self::STORE_FILL);
}
}
if (($this->summaryTotals['stores'][$storeId] ?? 0) > 0) {
$this->fillCell($sheet, $column . $this->lastRow, 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;
}
/**
* 单元格纯色填充
*/
private function fillCell(Worksheet $sheet, string $coordinate, string $argb): void
{
$sheet->getStyle($coordinate)
->getFill()
->setFillType(Fill::FILL_SOLID)
->getStartColor()
->setARGB($argb);
}
/**
* 每单位参考价 = 整单价 ÷ 包规数值(包规解析不出正数时按 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;
}
}