特殊行索引(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 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); // 商品 => 一级分类解析(明细行「一级分类」列与分类过滤共用同一份结果) [$categories, $rootIds] = $this->resolveRootCategories($items); $categoryName = '全部'; if ($this->categoryId > 0) { [$items, $categoryName] = $this->filterByRootCategory($items, $categories, $rootIds); } $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'], $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 = (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' => 'H', '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' => '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(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]; } }