特殊行索引(1 起)=> 类型(title/scope/header/summary/grand) */ private array $specialRows = []; /** @var array 标签需左合并 A:G 的合计行索引 */ private array $mergeRows = []; /** @var array 条件填色单元格(F=数量浅黄,H=金额浅红,非 0 填色) */ private array $fillCells = []; /** 列头所在行索引 */ private int $headerRow = 1; /** 数据末行索引 */ private int $lastRow = 1; /** @var Collection */ private Collection $bills; /** * @param BillModel|Collection $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 $bills * @param array> $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 $bills * @return array */ 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: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); 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 $items 合并后的商品明细 * @param Collection $categories 全部分类(id => 分类) * @param array $rootIds 商品ID => 一级分类ID * @return array{0: 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 $items 合并后的商品明细 * @return array{0: Collection, 1: array} [全部分类(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]; } }