Files
xin-procurement/app/Exports/BillExport.php
T
2026-09-25 00:30:02 +08:00

308 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\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 PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Border;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
/**
* 门店账单单独导出:账单内明细按商品合并(可按一级分类过滤),
* 表尾汇总商品金额/配送费/附加金额/售后金额/总金额
*/
class BillExport implements FromCollection, WithStyles
{
/** 数量列填充色(浅黄) */
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:F 的合计行索引 */
private array $mergeRows = [];
/** @var array<int, array{row: int, column: string, value: float}> 条件填色单元格(E=数量浅黄,G=金额浅红,非 0 填色) */
private array $fillCells = [];
/** 列头所在行索引 */
private int $headerRow = 1;
/** 数据末行索引 */
private int $lastRow = 1;
/**
* @param BillModel $bill 导出账单(with store)
* @param int $categoryId 一级分类ID(0=全部)
*/
public function __construct(
private readonly BillModel $bill,
private readonly int $categoryId = 0,
) {
}
/**
* 导出行:标题/范围/列头/明细/合计全部手工构建(行位置不固定,不用 WithHeadings)
*/
public function collection(): Collection
{
if ($this->rows !== null) {
return $this->rows;
}
$items = app(BillDetailService::class)->mergedItems($this->bill);
$categoryName = '全部';
if ($this->categoryId > 0) {
[$items, $categoryName] = $this->filterByRootCategory($items);
}
$bill = $this->bill;
$rows = [];
$rowIndex = 0;
// 标题行
$rows[] = ['门店账单 ' . $bill->bill_no];
$this->specialRows[++$rowIndex] = 'title';
// 范围行:门店 / 账单日期 / 分类 / 导出时间
$storeName = $bill->store->name ?? ('门店#' . $bill->store_id);
$rows[] = [
'门店:' . $storeName
. ' 账单日期:' . $bill->bill_date?->toDateString()
. ' 分类:' . $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'],
$item['product_spec'] . $item['unit'],
self::retailPriceLabel($item),
(int) $item['quantity'],
(float) $item['weight'],
(float) $item['amount'],
];
$rowIndex++;
$this->fillCells[] = ['row' => $rowIndex, 'column' => 'E', 'value' => (float) $item['quantity']];
$this->fillCells[] = ['row' => $rowIndex, 'column' => 'G', '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' => 'E', 'value' => (float) $totalQuantity];
$this->fillCells[] = ['row' => $rowIndex, 'column' => 'G', 'value' => (float) $totalAmount];
// 配送费/附加金额/售后金额/总金额(账单级费用全额计入,不受分类过滤影响)
$deliveryTotal = (string) $bill->delivery_fee;
$addedTotal = (string) $bill->added_amount;
$afterSaleTotal = (string) $bill->after_sale;
$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' => 'G', 'value' => (float) $deliveryTotal];
$rows[] = ['附加金额(周转筐 ' . (int) $bill->box_num . ' 个 / 周转托盘 ' . (int) $bill->tray_num . ' 个)', '', '', '', '', '', (float) $addedTotal];
$this->specialRows[++$rowIndex] = 'summary';
$this->mergeRows[] = $rowIndex;
$this->fillCells[] = ['row' => $rowIndex, 'column' => 'G', 'value' => (float) $addedTotal];
$rows[] = ['售后金额(可正负)', '', '', '', '', '', (float) $afterSaleTotal];
$this->specialRows[++$rowIndex] = 'summary';
$this->mergeRows[] = $rowIndex;
$this->fillCells[] = ['row' => $rowIndex, 'column' => 'G', 'value' => (float) $afterSaleTotal];
$rows[] = ['总金额(商品金额+配送费+附加金额+售后金额)', '', '', '', '', '', (float) $grandTotal];
$this->specialRows[++$rowIndex] = 'grand';
$this->mergeRows[] = $rowIndex;
$this->fillCells[] = ['row' => $rowIndex, 'column' => 'G', 'value' => (float) $grandTotal];
$this->lastRow = $rowIndex;
return $this->rows = collect($rows);
}
/**
* 标题/列头/合计行加粗,合计行标签左合并 A:F;全表居中 + 全边框;金额列货币格式;
* 数量(浅黄)/金额(浅红)非 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(14);
$sheet->getColumnDimension('D')->setWidth(12);
$sheet->getColumnDimension('E')->setWidth(10);
$sheet->getColumnDimension('F')->setWidth(12);
$sheet->getColumnDimension('G')->setWidth(12);
foreach ($this->mergeRows as $row) {
$sheet->mergeCells('A' . $row . ':F' . $row);
}
// 全部单元格水平/垂直居中(合计行标签保持左对齐)
$sheet->getStyle('A1:G' . $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 . ':G' . $this->lastRow)
->getBorders()
->getAllBorders()
->setBorderStyle(Border::BORDER_THIN);
// 金额列货币格式:¥ + 两位小数(单价列为文本,不设格式)
$sheet->getStyle('G' . ($this->headerRow + 1) . ':G' . $this->lastRow)
->getNumberFormat()
->setFormatCode(self::CURRENCY_FORMAT);
// 列头固定填色:数量列(E)浅黄、金额列(G)浅红
$this->fillCell($sheet, 'E' . $this->headerRow, self::QUANTITY_FILL);
$this->fillCell($sheet, 'G' . $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'] === 'E' ? 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'] ?? '');
}
/**
* 按一级分类过滤明细:沿 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];
}
}