Files
xin-procurement/app/Exports/BillExport.php
T
2026-08-29 13:54:06 +08:00

233 lines
8.7 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\BillModel;
use App\Models\ProductCategoryModel;
use App\Models\ProductModel;
use App\Services\BillDetailService;
use Illuminate\Support\Collection;
use Maatwebsite\Excel\Concerns\FromCollection;
use Maatwebsite\Excel\Concerns\WithStyles;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
/**
* 门店账单合并导出:勾选账单跨账单按商品合并明细(可按一级分类过滤),
* 表尾汇总商品金额/配送费/附加金额/售后金额/总金额
*/
class BillExport implements FromCollection, WithStyles
{
private ?Collection $rows = null;
/** @var array<int, string> 特殊行索引(1 起)=> 类型(title/scope/header/summary/grand */
private array $specialRows = [];
/** @var array<int, int> 标签需左合并 A:G 的合计行索引 */
private array $mergeRows = [];
/** 列头所在行索引 */
private int $headerRow = 1;
/**
* @param Collection<int, BillModel> $bills 勾选账单(已按 bill_date/id 排序,with store
* @param int $categoryId 一级分类ID0=全部)
*/
public function __construct(
private readonly Collection $bills,
private readonly int $categoryId = 0,
) {
}
/**
* 导出行:标题/范围/列头/明细/合计全部手工构建(行位置不固定,不用 WithHeadings
*/
public function collection(): Collection
{
if ($this->rows !== null) {
return $this->rows;
}
$items = app(BillDetailService::class)->mergedItemsOfBills(
$this->bills->pluck('id')->map(static fn ($id): int => (int) $id)->all()
);
$categoryName = '全部';
if ($this->categoryId > 0) {
[$items, $categoryName] = $this->filterByRootCategory($items);
}
$rows = [];
$rowIndex = 0;
// 标题行
$rows[] = ['门店账单合并导出(共 ' . $this->bills->count() . ' 张)'];
$this->specialRows[++$rowIndex] = 'title';
// 范围行:账单号 / 门店 / 分类 / 导出时间
$billNos = $this->bills->pluck('bill_no')->all();
$billNoText = count($billNos) > 8
? implode('、', array_slice($billNos, 0, 8)) . ' 等 ' . count($billNos) . ' 张'
: implode('、', $billNos);
$storeNames = $this->bills
->map(static fn (BillModel $bill): string => $bill->store->name ?? ('门店#' . $bill->store_id))
->unique()
->values()
->all();
$rows[] = ['账单号:' . $billNoText];
$this->specialRows[++$rowIndex] = 'scope';
$rows[] = ['门店:' . implode('、', $storeNames) . ' 分类:' . $categoryName . ' 导出时间:' . now()->format('Y-m-d H:i:s')];
$this->specialRows[++$rowIndex] = 'scope';
// 空行
$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, $item['weight'], 3);
$totalAmount = bcadd($totalAmount, $item['amount'], 2);
}
// 商品合计行
$rows[] = ['', '合计', '', '', '', $totalQuantity, (float) $totalWeight, (float) $totalAmount];
$this->specialRows[++$rowIndex] = 'summary';
// 配送费/附加金额/售后金额/总金额(账单级费用全额汇总,不受分类过滤影响)
$deliveryTotal = '0';
$addedTotal = '0';
$afterSaleTotal = '0';
$boxNum = 0;
$trayNum = 0;
foreach ($this->bills as $bill) {
$deliveryTotal = bcadd($deliveryTotal, (string) $bill->delivery_fee, 2);
$addedTotal = bcadd($addedTotal, (string) $bill->added_amount, 2);
$afterSaleTotal = bcadd($afterSaleTotal, (string) $bill->after_sale, 2);
$boxNum += (int) $bill->box_num;
$trayNum += (int) $bill->tray_num;
}
$grandTotal = bcadd(bcadd(bcadd($totalAmount, $deliveryTotal, 2), $addedTotal, 2), $afterSaleTotal, 2);
$rows[] = ['配送费合计', '', '', '', '', '', '', (float) $deliveryTotal];
$this->specialRows[++$rowIndex] = 'summary';
$this->mergeRows[] = $rowIndex;
$rows[] = ['附加金额合计(周转筐 ' . $boxNum . ' 个 / 周转托盘 ' . $trayNum . ' 个)', '', '', '', '', '', '', (float) $addedTotal];
$this->specialRows[++$rowIndex] = 'summary';
$this->mergeRows[] = $rowIndex;
$rows[] = ['售后金额合计(可正负)', '', '', '', '', '', '', (float) $afterSaleTotal];
$this->specialRows[++$rowIndex] = 'summary';
$this->mergeRows[] = $rowIndex;
$rows[] = ['总金额(商品金额+配送费+附加金额+售后金额)', '', '', '', '', '', '', (float) $grandTotal];
$this->specialRows[++$rowIndex] = 'grand';
$this->mergeRows[] = $rowIndex;
return $this->rows = collect($rows);
}
/**
* 标题/列头/合计行加粗,合计行标签左合并 A:G,冻结列头
*/
public function styles(Worksheet $sheet): array
{
$this->collection();
$sheet->freezePane('A' . ($this->headerRow + 1));
$sheet->getColumnDimension('A')->setWidth(6);
$sheet->getColumnDimension('B')->setWidth(24);
$sheet->getColumnDimension('C')->setWidth(14);
$sheet->getColumnDimension('D')->setWidth(8);
$sheet->getColumnDimension('E')->setWidth(10);
$sheet->getColumnDimension('F')->setWidth(10);
$sheet->getColumnDimension('G')->setWidth(12);
$sheet->getColumnDimension('H')->setWidth(12);
foreach ($this->mergeRows as $row) {
$sheet->mergeCells('A' . $row . ':G' . $row);
}
$styles = [];
foreach ($this->specialRows as $row => $type) {
$style = match ($type) {
'title' => ['font' => ['bold' => true, 'size' => 14]],
'header', 'summary' => ['font' => ['bold' => true]],
'grand' => ['font' => ['bold' => true, 'size' => 12]],
default => [],
};
if ($style !== []) {
$styles[$row] = $style;
}
}
return $styles;
}
/**
* 按一级分类过滤明细:沿 parent_id 上溯取根分类(商品含软删除,保证历史账单可导出),
* 仅保留根分类为所选分类的行
*
* @param array<int, array> $items 合并后的商品明细
* @return array{0: array<int, array>, 1: string} [过滤后明细, 一级分类名]
*/
private function filterByRootCategory(array $items): array
{
$categories = ProductCategoryModel::all()->keyBy('id');
$categoryName = $categories->get($this->categoryId)->name ?? ('分类#' . $this->categoryId);
$productCategoryIds = ProductModel::withTrashed()
->whereIn('id', array_column($items, 'product_id'))
->pluck('category_id', 'id');
// 商品ID => 根分类ID(与采购单导出同口径的上溯解析)
$rootIds = [];
foreach ($productCategoryIds as $productId => $categoryId) {
$rootId = 0;
$cursor = (int) $categoryId;
$guard = 0;
while ($cursor > 0 && $guard++ < 20) {
$category = $categories->get($cursor);
if ($category === null) {
break;
}
$rootId = (int) $category->id;
$cursor = (int) $category->parent_id;
}
$rootIds[(int) $productId] = $rootId;
}
$categoryId = $this->categoryId;
$filtered = array_values(array_filter(
$items,
static fn (array $row): bool => ($rootIds[$row['product_id']] ?? 0) === $categoryId
));
if ($filtered === []) {
throw new RepositoryException('所选账单在「' . $categoryName . '」分类下无商品明细');
}
return [$filtered, $categoryName];
}
}