特殊行索引(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; /** * @param Collection $bills 勾选账单(已按 bill_date/id 排序,with store) * @param int $categoryId 一级分类ID(0=全部) */ 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('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'], (float) $item['price'], (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 ($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; $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; return $this->rows = collect($rows); } /** * 标题/列头/合计行加粗,合计行标签左合并 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(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); } // 全部单元格水平/垂直居中(合计行标签保持左对齐) $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); // 金额类列(单价/金额)货币格式:¥ + 两位小数 foreach (['E', 'H'] as $column) { $sheet->getStyle($column . ($this->headerRow + 1) . ':' . $column . $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); } /** * 按一级分类过滤明细:沿 parent_id 上溯取根分类(商品含软删除,保证历史账单可导出), * 仅保留根分类为所选分类的行 * * @param array $items 合并后的商品明细 * @return array{0: 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]; } }