Files
xin-procurement/app/Exports/BillExport.php
T
2026-11-01 16:17:12 +08:00

404 lines
16 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 App\Services\WeightEstimator;
use Illuminate\Support\Collection;
use Maatwebsite\Excel\Concerns\FromCollection;
use Maatwebsite\Excel\Concerns\WithStyles;
use Maatwebsite\Excel\Concerns\WithTitle;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Border;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
/**
* 门店账单导出:单账单可直接下载;多账单按商品合并为一张汇总表(批量导出按门店分组),
* 明细可按一级分类过滤,表尾汇总商品金额/配送费/附加金额/售后金额/总金额
*/
class BillExport implements FromCollection, WithStyles, WithTitle
{
/** 数量列填充色(浅黄) */
private const string QUANTITY_FILL = 'FFFFF2CC';
/** 金额列填充色(浅红) */
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/grand) */
private array $specialRows = [];
/** @var array<int, int> 标签需左合并 A:G 的合计行索引 */
private array $mergeRows = [];
/** @var array<int, array{row: int, column: string, value: float}> 条件填色单元格(F=数量浅黄,H=金额浅红,非 0 填色) */
private array $fillCells = [];
/** 列头所在行索引 */
private int $headerRow = 1;
/** 数据末行索引 */
private int $lastRow = 1;
/** @var Collection<int, BillModel> */
private Collection $bills;
/**
* @param BillModel|Collection<int, BillModel> $bills 导出账单(with store);多账单须属于同一门店
* @param int $categoryId 一级分类ID(0=全部)
* @param string $sheetTitle 工作表名称
*/
public function __construct(
BillModel|Collection $bills,
private readonly int $categoryId = 0,
private readonly string $sheetTitle = '门店账单',
) {
$this->bills = $bills instanceof BillModel ? collect([$bills]) : $bills->values();
}
public function title(): string
{
return $this->sheetTitle;
}
/**
* 导出行:所选门店账单合并为一张表格,同商品及账单级费用分别汇总
*/
public function collection(): Collection
{
if ($this->rows !== null) {
return $this->rows;
}
$rows = [];
$rowIndex = 0;
$this->appendBillRows($this->bills, $rows, $rowIndex);
return $this->rows = collect($rows);
}
/**
* 追加所选账单的合并表格
*
* @param Collection<int, BillModel> $bills
* @param array<int, array<int, mixed>> $rows
*/
private function appendBillRows(Collection $bills, array &$rows, int &$rowIndex): void
{
$items = $this->mergedItems($bills);
// 商品 => 一级分类解析(明细行「一级分类」列与分类过滤共用同一份结果)
[$categories, $rootIds] = $this->resolveRootCategories($items);
$categoryName = '全部';
if ($this->categoryId > 0) {
[$items, $categoryName] = $this->filterByRootCategory($items, $categories, $rootIds);
}
$bill = $bills->first();
$isMerged = $bills->count() > 1;
// 标题行
$rows[] = [$isMerged ? '门店账单 合并导出(' . $bills->count() . '张)' : '门店账单 ' . $bill->bill_no];
$this->specialRows[++$rowIndex] = 'title';
// 范围行:门店 / 账单日期(合并时取起止日期)/ 分类 / 导出时间
$billDate = $bill->bill_date?->toDateString();
if ($isMerged) {
$dates = $bills->map(static fn (BillModel $item): ?string => $item->bill_date?->toDateString())
->filter()
->sort()
->values();
$billDate = $dates->first() === $dates->last()
? $dates->first()
: $dates->first() . ' 至 ' . $dates->last();
}
$storeName = $bill->store->name ?? ('门店#' . $bill->store_id);
$rows[] = [
'门店:' . $storeName
. ' 账单日期:' . $billDate
. ($isMerged ? ' 账单数量:' . $bills->count() : '')
. ' 分类:' . $categoryName
. ' 导出时间:' . now('Asia/Shanghai')->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'],
$categories->get($rootIds[$item['product_id']] ?? 0)?->name ?? '',
$item['product_spec'] . $item['unit'],
self::retailPriceLabel($item),
(int) $item['quantity'],
(float) $item['weight'],
(float) $item['amount'],
];
$rowIndex++;
$this->fillCells[] = ['row' => $rowIndex, 'column' => 'F', 'value' => (float) $item['quantity']];
$this->fillCells[] = ['row' => $rowIndex, 'column' => 'H', 'value' => (float) $item['amount']];
$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';
$this->fillCells[] = ['row' => $rowIndex, 'column' => 'F', 'value' => (float) $totalQuantity];
$this->fillCells[] = ['row' => $rowIndex, 'column' => 'H', 'value' => (float) $totalAmount];
// 配送费/附加金额/售后金额按所选账单分别累加,不受分类过滤影响
$deliveryTotal = '0';
$addedTotal = '0';
$afterSaleTotal = '0';
$boxNum = 0;
$trayNum = 0;
foreach ($bills as $selectedBill) {
$deliveryTotal = bcadd($deliveryTotal, (string) $selectedBill->delivery_fee, 2);
$addedTotal = bcadd($addedTotal, (string) $selectedBill->added_amount, 2);
$afterSaleTotal = bcadd($afterSaleTotal, (string) $selectedBill->after_sale, 2);
$boxNum += (int) $selectedBill->box_num;
$trayNum += (int) $selectedBill->tray_num;
}
$grandTotal = bcadd(bcadd(bcadd($totalAmount, $deliveryTotal, 2), $addedTotal, 2), $afterSaleTotal, 2);
$rows[] = ['配送费', '', '', '', '', '', '', (float) $deliveryTotal];
$this->specialRows[++$rowIndex] = 'summary';
$this->mergeRows[] = $rowIndex;
$this->fillCells[] = ['row' => $rowIndex, 'column' => 'H', 'value' => (float) $deliveryTotal];
$rows[] = ['附加金额(周转筐 ' . $boxNum . ' 个 / 周转托盘 ' . $trayNum . ' 个)', '', '', '', '', '', '', (float) $addedTotal];
$this->specialRows[++$rowIndex] = 'summary';
$this->mergeRows[] = $rowIndex;
$this->fillCells[] = ['row' => $rowIndex, 'column' => 'H', 'value' => (float) $addedTotal];
$rows[] = ['售后金额(可正负)', '', '', '', '', '', '', (float) $afterSaleTotal];
$this->specialRows[++$rowIndex] = 'summary';
$this->mergeRows[] = $rowIndex;
$this->fillCells[] = ['row' => $rowIndex, 'column' => 'H', 'value' => (float) $afterSaleTotal];
$rows[] = ['总金额(商品金额+配送费+附加金额+售后金额)', '', '', '', '', '', '', (float) $grandTotal];
$this->specialRows[++$rowIndex] = 'grand';
$this->mergeRows[] = $rowIndex;
$this->fillCells[] = ['row' => $rowIndex, 'column' => 'H', 'value' => (float) $grandTotal];
$this->lastRow = $rowIndex;
}
/**
* 合并所选账单商品:数量、重量、金额累加,单价按总金额 ÷ 总数量重新加权
*
* @param Collection<int, BillModel> $bills
* @return array<int, array{product_id: int, product_name: string, product_spec: string, unit: string, price: string, quantity: int, weight: string, amount: string, spec: string, price_unit: string, image: string}>
*/
private function mergedItems(Collection $bills): array
{
$merged = [];
$service = app(BillDetailService::class);
foreach ($bills as $bill) {
foreach ($service->mergedItems($bill) as $item) {
$productId = (int) $item['product_id'];
if (! isset($merged[$productId])) {
$merged[$productId] = $item;
continue;
}
$merged[$productId]['quantity'] += (int) $item['quantity'];
$merged[$productId]['weight'] = bcadd($merged[$productId]['weight'], $item['weight'], 3);
$merged[$productId]['amount'] = bcadd($merged[$productId]['amount'], $item['amount'], 2);
$merged[$productId]['price'] = bcdiv(
$merged[$productId]['amount'],
(string) $merged[$productId]['quantity'],
2
);
}
}
return array_values($merged);
}
/**
* 标题合并 A:H 并居中;标题/列头/合计行加粗,合计行标签左合并 A:G;全表居中 + 全边框;金额列货币格式;
* 数量(浅黄)/金额(浅红)非 0 条件填色(0 不填、列头固定填色),冻结列头
*/
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(12);
$sheet->getColumnDimension('D')->setWidth(14);
$sheet->getColumnDimension('E')->setWidth(12);
$sheet->getColumnDimension('F')->setWidth(10);
$sheet->getColumnDimension('G')->setWidth(12);
$sheet->getColumnDimension('H')->setWidth(12);
$sheet->mergeCells('A1:H1');
foreach ($this->mergeRows as $row) {
$sheet->mergeCells('A' . $row . ':G' . $row);
}
// 全部单元格水平/垂直居中(合计行标签保持左对齐)
$sheet->getStyle('A1:H' . $this->lastRow)
->getAlignment()
->setHorizontal(Alignment::HORIZONTAL_CENTER)
->setVertical(Alignment::VERTICAL_CENTER);
foreach ($this->mergeRows as $row) {
$sheet->getStyle('A' . $row)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_LEFT);
}
// 表格区域(列头 → 末行)添加所有边框
$sheet->getStyle('A' . $this->headerRow . ':H' . $this->lastRow)
->getBorders()
->getAllBorders()
->setBorderStyle(Border::BORDER_THIN);
// 金额列货币格式:¥ + 两位小数(单价列为文本,不设格式)
$sheet->getStyle('H' . ($this->headerRow + 1) . ':H' . $this->lastRow)
->getNumberFormat()
->setFormatCode(self::CURRENCY_FORMAT);
// 列头固定填色:数量列(F)浅黄、金额列(H)浅红
$this->fillCell($sheet, 'F' . $this->headerRow, self::QUANTITY_FILL);
$this->fillCell($sheet, 'H' . $this->headerRow, self::AMOUNT_FILL);
// 数据区条件填色:非 0 填色,0 无背景(金额含负值)
foreach ($this->fillCells as $cell) {
if ((float) $cell['value'] !== 0.0) {
$this->fillCell(
$sheet,
$cell['column'] . $cell['row'],
$cell['column'] === 'F' ? self::QUANTITY_FILL : self::AMOUNT_FILL
);
}
}
$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;
}
/**
* 单元格纯色填充
*/
private function fillCell(Worksheet $sheet, string $coordinate, string $argb): void
{
$sheet->getStyle($coordinate)
->getFill()
->setFillType(Fill::FILL_SOLID)
->getStartColor()
->setARGB($argb);
}
/**
* 单价列文本:每件零售价(明细每件价 ÷ 包规折算每件斤数)+ 单价单位(如「1.74元/斤」);
* 包规无法折算(散装/无数字)时按每件原价显示
*
* @param array{price: string, product_spec: string, unit: string, price_unit?: string} $item
*/
private static function retailPriceLabel(array $item): string
{
$jinPerPiece = WeightEstimator::perUnitJin($item['product_spec'], $item['unit']);
$retail = (float) $jinPerPiece > 0
? bcdiv($item['price'], $jinPerPiece, 2)
: $item['price'];
return $retail . ($item['price_unit'] ?? '');
}
/**
* 按一级分类过滤明细:仅保留根分类为所选分类的行
*
* @param array<int, array> $items 合并后的商品明细
* @param Collection<int, ProductCategoryModel> $categories 全部分类(id => 分类)
* @param array<int, int> $rootIds 商品ID => 一级分类ID
* @return array{0: array<int, array>, 1: string} [过滤后明细, 一级分类名]
*/
private function filterByRootCategory(array $items, Collection $categories, array $rootIds): array
{
$categoryName = $categories->get($this->categoryId)?->name ?? ('分类#' . $this->categoryId);
$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];
}
/**
* 解析明细商品的一级分类:沿 parent_id 上溯取根分类(商品含软删除,保证历史账单可导出)
*
* @param array<int, array> $items 合并后的商品明细
* @return array{0: Collection<int, ProductCategoryModel>, 1: array<int, int>} [全部分类(id=>分类), 商品ID=>一级分类ID]
*/
private function resolveRootCategories(array $items): array
{
$categories = ProductCategoryModel::all()->keyBy('id');
$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;
}
return [$categories, $rootIds];
}
}