特殊行索引(1 起)=> 类型(title/scope/header/summary) */ private array $specialRows = []; /** @var array}> 明细行索引 => 填色判断数据 */ private array $rowData = []; /** @var array{quantity: float, amount: float, stores: array} 合计行填色判断数据 */ private array $summaryTotals = ['quantity' => 0.0, 'amount' => 0.0, 'stores' => []]; /** 列头所在行索引 */ private int $headerRow = 1; /** 数据末行索引 */ private int $lastRow = 1; /** @var array 门店ID => 名称(导出列) */ private array $storeNames = []; /** * @param PurchaseOrderModel $purchase 采购单 * @param int $supplierId 供应商筛选(0=全部) */ public function __construct( private readonly PurchaseOrderModel $purchase, private readonly int $supplierId = 0, ) { } /** * 导出行:标题/范围/列头/明细/合计全部手工构建(行位置不固定,不用 WithHeadings) */ public function collection(): Collection { if ($this->rows !== null) { return $this->rows; } // 本采购单订货明细(关联订单过滤软删) $orderItems = StoreOrderItemModel::query() ->join('store_order', 'store_order.id', '=', 'store_order_item.order_id') ->where('store_order_item.purchase_id', $this->purchase->id) ->whereNull('store_order.deleted_at') ->select('store_order_item.*') ->get() ->makeVisible('cost_price'); $this->storeNames = StoreModel::withTrashed() ->whereIn('id', $orderItems->pluck('store_id')->unique()) ->pluck('name', 'id') ->toArray(); // 明细按商品聚合(快照字段取首条;有订货的商品以快照供应商为准) $itemGroups = []; foreach ($orderItems->groupBy('product_id') as $productId => $group) { $first = $group->first(); $quantity = '0'; $weight = '0'; $amount = '0'; $storeQuantities = array_fill_keys(array_keys($this->storeNames), 0); foreach ($group as $item) { $quantity = bcadd($quantity, (string) $item->quantity, 2); $weight = bcadd($weight, (string) $item->weight, 3); // 金额 = 数量 × 每包成本价(单价/包规不参与金额计算) $amount = bcadd($amount, bcmul((string) $item->quantity, (string) $item->cost_price, 2), 2); $storeQuantities[(int) $item->store_id] = ($storeQuantities[(int) $item->store_id] ?? 0) + (int) $item->quantity; } $itemGroups[(int) $productId] = [ 'snapshot' => $first, 'quantity' => $quantity, 'weight' => $weight, 'amount' => $amount, 'store_quantities' => $storeQuantities, ]; } // 导出范围:系统全部未删除商品(含下架)∪ 本采购单有订货的商品(含已删) $products = ProductModel::withTrashed() ->with('category:id,sort') ->whereNull('deleted_at') ->orWhereIn('id', array_keys($itemGroups)) ->get() ->keyBy('id'); $categoryNames = $this->rootCategoryNames($products->pluck('category_id', 'id')->all()); $supplierNames = SupplierModel::withTrashed()->pluck('name', 'id')->toArray(); // 组装商品行(供应商筛选:有订货按快照 supplier_id,无订货按档案 supplier_id) $items = []; foreach ($products as $productId => $product) { $group = $itemGroups[(int) $productId] ?? null; $snapshot = $group['snapshot'] ?? null; $rowSupplierId = (int) ($snapshot->supplier_id ?? $product->supplier_id); if ($this->supplierId > 0 && $rowSupplierId !== $this->supplierId) { continue; } $quantity = $group['quantity'] ?? '0'; $amount = $group['amount'] ?? '0'; $spec = (string) ($snapshot->product_spec ?? $product->spec); $items[] = [ 'product_id' => (int) $productId, 'category' => $categoryNames[(int) $productId] ?? '', 'product_name' => (string) ($snapshot->product_name ?? $product->name), 'supplier' => $supplierNames[$rowSupplierId] ?? '', 'market' => (string) ($product->market ?? ''), 'product_spec' => $spec, 'unit' => (string) ($snapshot->unit ?? $product->unit), 'cost_price' => (float) ($snapshot->cost_price ?? $product->cost_price), // 单价 = 加权平均售价 ÷ 包规数值(无订货时按成本价换算,保证始终有单价) 'retail_price' => $group !== null && (float) $quantity > 0 ? $this->unitRefPrice((float) bcdiv($amount, $quantity, 4), $spec) : $this->unitRefPrice((float) ($snapshot->cost_price ?? $product->cost_price), $spec), 'quantity' => (float) $quantity, 'weight' => (float) ($group['weight'] ?? '0'), 'amount' => (float) $amount, 'store_quantities' => $group['store_quantities'] ?? array_fill_keys(array_keys($this->storeNames), 0), 'category_sort' => (int) ($product->category->sort ?? 9999), 'product_sort' => (int) ($product->sort ?? 9999), ]; } usort($items, static fn (array $a, array $b): int => [$a['category_sort'], $a['product_sort'], $a['product_id']] <=> [$b['category_sort'], $b['product_sort'], $b['product_id']]); if ($items === []) { throw new RepositoryException('该供应商在系统中无商品行,无法导出'); } // ===== 手工建行 ===== $rows = []; $rowIndex = 0; // 标题行 $rows[] = ['采购单 ' . $this->purchase->purchase_no . '(采购日期 ' . $this->purchase->purchase_date->toDateString() . ')']; $this->specialRows[++$rowIndex] = 'title'; // 范围行:供应商筛选 / 导出时间 $scopeSupplier = '全部'; if ($this->supplierId > 0) { $scopeSupplier = $supplierNames[$this->supplierId] ?? ('供应商#' . $this->supplierId); } $rows[] = ['供应商:' . $scopeSupplier . ' 导出时间:' . now()->format('Y-m-d H:i:s')]; $this->specialRows[++$rowIndex] = 'scope'; // 空行 $rows[] = ['']; $rowIndex++; // 列头 $rows[] = array_merge( ['序号', '分类', '品名', '供应商', '市场', '包规', '单位', '成本', '单价', '数量', '实际称重', '金额'], array_values($this->storeNames), ); $this->specialRows[++$rowIndex] = 'header'; $this->headerRow = $rowIndex; // 明细行 $totalQuantity = '0'; $totalWeight = '0'; $totalAmount = '0'; $storeTotals = array_fill_keys(array_keys($this->storeNames), 0); foreach (array_values($items) as $sort => $item) { $this->rowData[++$rowIndex] = [ 'quantity' => $item['quantity'], 'amount' => $item['amount'], 'store_quantities' => $item['store_quantities'], ]; $rows[] = array_merge([ $sort + 1, $item['category'], $item['product_name'], $item['supplier'], $item['market'], $item['product_spec'], $item['unit'], $item['cost_price'], $item['retail_price'], $item['quantity'], $item['weight'], $item['amount'], ], array_values($item['store_quantities'])); $totalQuantity = bcadd($totalQuantity, (string) $item['quantity'], 2); $totalWeight = bcadd($totalWeight, (string) $item['weight'], 3); $totalAmount = bcadd($totalAmount, (string) $item['amount'], 2); foreach ($item['store_quantities'] as $storeId => $qty) { $storeTotals[$storeId] += $qty; } } // 合计行(行合计 + 门店列合计) $rows[] = array_merge( ['', '', '合计', '', '', '', '', '', '', (float) $totalQuantity, (float) $totalWeight, (float) $totalAmount], array_values($storeTotals), ); $this->specialRows[++$rowIndex] = 'summary'; $this->lastRow = $rowIndex; $this->summaryTotals = [ 'quantity' => (float) $totalQuantity, 'amount' => (float) $totalAmount, 'stores' => $storeTotals, ]; return $this->rows = collect($rows); } /** * 标题/列头/合计加粗;全表居中 + 全边框;金额类列货币格式; * 数量(浅黄)/门店数量(浅蓝)/金额(浅红)按值条件填色(0 不填、列头固定填色),冻结列头 */ public function styles(Worksheet $sheet): array { $this->collection(); $sheet->freezePane('A' . ($this->headerRow + 1)); $widths = [6, 10, 20, 12, 12, 12, 8, 10, 12, 10, 12, 12]; foreach ($widths as $index => $width) { $sheet->getColumnDimension(Coordinate::stringFromColumnIndex($index + 1))->setWidth($width); } $storeIds = array_keys($this->storeNames); $storeCount = count($storeIds); $lastColumn = Coordinate::stringFromColumnIndex(12 + max($storeCount, 1)); foreach ($storeIds as $i => $storeId) { $sheet->getColumnDimension(Coordinate::stringFromColumnIndex(13 + $i))->setWidth(12); } // 默认隐藏列:序号/分类/包规/单位/成本/单价/实际称重/金额(数据照常导出,Excel 中可取消隐藏) foreach ([1, 2, 6, 7, 8, 9, 11, 12] as $hiddenIndex) { $sheet->getColumnDimension(Coordinate::stringFromColumnIndex($hiddenIndex))->setVisible(false); } // 全部单元格水平/垂直居中 $sheet->getStyle('A1:' . $lastColumn . $this->lastRow) ->getAlignment() ->setHorizontal(Alignment::HORIZONTAL_CENTER) ->setVertical(Alignment::VERTICAL_CENTER); // 表格区域(列头 → 合计行)添加所有边框 $sheet->getStyle('A' . $this->headerRow . ':' . $lastColumn . $this->lastRow) ->getBorders() ->getAllBorders() ->setBorderStyle(Border::BORDER_THIN); // 金额类列(成本/单价/金额)货币格式:¥ + 两位小数 foreach (['H', 'I', 'L'] as $column) { $sheet->getStyle($column . ($this->headerRow + 1) . ':' . $column . $this->lastRow) ->getNumberFormat() ->setFormatCode(self::CURRENCY_FORMAT); } // 数量列(J):有数据浅黄,0 无背景,列头固定浅黄 $this->fillCell($sheet, 'J' . $this->headerRow, self::QUANTITY_FILL); foreach ($this->rowData as $row => $data) { if ($data['quantity'] > 0) { $this->fillCell($sheet, 'J' . $row, self::QUANTITY_FILL); } } if ($this->summaryTotals['quantity'] > 0) { $this->fillCell($sheet, 'J' . $this->lastRow, self::QUANTITY_FILL); } // 金额列(L):有数据浅红,0 无背景,列头固定浅红 $this->fillCell($sheet, 'L' . $this->headerRow, self::AMOUNT_FILL); foreach ($this->rowData as $row => $data) { if ($data['amount'] > 0) { $this->fillCell($sheet, 'L' . $row, self::AMOUNT_FILL); } } if ($this->summaryTotals['amount'] > 0) { $this->fillCell($sheet, 'L' . $this->lastRow, self::AMOUNT_FILL); } // 门店数量列:有数据浅蓝,0 无背景,列头固定浅蓝 foreach ($storeIds as $i => $storeId) { $column = Coordinate::stringFromColumnIndex(13 + $i); $this->fillCell($sheet, $column . $this->headerRow, self::STORE_FILL); foreach ($this->rowData as $row => $data) { if (($data['store_quantities'][$storeId] ?? 0) > 0) { $this->fillCell($sheet, $column . $row, self::STORE_FILL); } } if (($this->summaryTotals['stores'][$storeId] ?? 0) > 0) { $this->fillCell($sheet, $column . $this->lastRow, self::STORE_FILL); } } $styles = []; foreach ($this->specialRows as $row => $type) { $style = match ($type) { 'title' => ['font' => ['bold' => true, 'size' => 14]], 'header', 'summary' => ['font' => ['bold' => true]], 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 处理;仅展示参考) */ private function unitRefPrice(float $price, string $spec): float { $pack = (float) preg_replace('/[^0-9.].*$/', '', $spec); return $pack > 0 ? round($price / $pack, 2) : round($price, 2); } /** * 商品ID => 顶级分类名(沿 parent_id 上溯取根分类) * * @param array $productCategoryIds 商品ID => 分类ID * @return array */ private function rootCategoryNames(array $productCategoryIds): array { $categories = ProductCategoryModel::all()->keyBy('id'); $names = []; foreach ($productCategoryIds as $productId => $categoryId) { $rootName = ''; $cursor = (int) $categoryId; $guard = 0; while ($cursor > 0 && $guard++ < 20) { $category = $categories->get($cursor); if ($category === null) { break; } $rootName = $category->name; $cursor = (int) $category->parent_id; } $names[(int) $productId] = $rootName; } return $names; } }