404 lines
17 KiB
PHP
404 lines
17 KiB
PHP
<?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()->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;
|
||
}
|
||
}
|