账单导出

This commit is contained in:
liu
2026-10-24 21:57:02 +08:00
parent 5123065def
commit 42a3fa00d4
3 changed files with 84 additions and 62 deletions
File diff suppressed because one or more lines are too long
+65 -47
View File
@@ -17,7 +17,7 @@ use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
/**
* 门店账单单独导出:账单内明细按商品合并(可按一级分类过滤),
* 门店账单单独导出:账单内明细按商品合并(可按一级分类过滤),明细行展示商品一级分类,
* 表尾汇总商品金额/配送费/附加金额/售后金额/总金额
*/
class BillExport implements FromCollection, WithStyles
@@ -36,10 +36,10 @@ class BillExport implements FromCollection, WithStyles
/** @var array<int, string> 特殊行索引(1 起)=> 类型(title/scope/header/summary/grand) */
private array $specialRows = [];
/** @var array<int, int> 标签需左合并 A:F 的合计行索引 */
/** @var array<int, int> 标签需左合并 A:G 的合计行索引 */
private array $mergeRows = [];
/** @var array<int, array{row: int, column: string, value: float}> 条件填色单元格(E=数量浅黄,G=金额浅红,非 0 填色) */
/** @var array<int, array{row: int, column: string, value: float}> 条件填色单元格(F=数量浅黄,H=金额浅红,非 0 填色) */
private array $fillCells = [];
/** 列头所在行索引 */
@@ -69,9 +69,12 @@ class BillExport implements FromCollection, WithStyles
$items = app(BillDetailService::class)->mergedItems($this->bill);
// 商品 => 一级分类解析(明细行「一级分类」列与分类过滤共用同一份结果)
[$categories, $rootIds] = $this->resolveRootCategories($items);
$categoryName = '全部';
if ($this->categoryId > 0) {
[$items, $categoryName] = $this->filterByRootCategory($items);
[$items, $categoryName] = $this->filterByRootCategory($items, $categories, $rootIds);
}
$bill = $this->bill;
@@ -96,8 +99,8 @@ class BillExport implements FromCollection, WithStyles
$rows[] = [''];
$rowIndex++;
// 列头(包规=包规+单位合并;单价=每件零售价+单价单位)
$rows[] = ['序号', '品名', '包规', '单价', '数量', '重量(斤)', '金额'];
// 列头(包规=包规+单位合并;单价=每件零售价+单价单位;一级分类=商品根分类名)
$rows[] = ['序号', '品名', '一级分类', '包规', '单价', '数量', '重量(斤)', '金额'];
$this->specialRows[++$rowIndex] = 'header';
$this->headerRow = $rowIndex;
@@ -109,6 +112,7 @@ class BillExport implements FromCollection, WithStyles
$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'],
@@ -116,18 +120,18 @@ class BillExport implements FromCollection, WithStyles
(float) $item['amount'],
];
$rowIndex++;
$this->fillCells[] = ['row' => $rowIndex, 'column' => 'E', 'value' => (float) $item['quantity']];
$this->fillCells[] = ['row' => $rowIndex, 'column' => 'G', 'value' => (float) $item['amount']];
$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];
$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];
$this->fillCells[] = ['row' => $rowIndex, 'column' => 'F', 'value' => (float) $totalQuantity];
$this->fillCells[] = ['row' => $rowIndex, 'column' => 'H', 'value' => (float) $totalAmount];
// 配送费/附加金额/售后金额/总金额(账单级费用全额计入,不受分类过滤影响)
$deliveryTotal = (string) $bill->delivery_fee;
@@ -135,32 +139,32 @@ class BillExport implements FromCollection, WithStyles
$afterSaleTotal = (string) $bill->after_sale;
$grandTotal = bcadd(bcadd(bcadd($totalAmount, $deliveryTotal, 2), $addedTotal, 2), $afterSaleTotal, 2);
$rows[] = ['配送费', '', '', '', '', '', (float) $deliveryTotal];
$rows[] = ['配送费', '', '', '', '', '', '', (float) $deliveryTotal];
$this->specialRows[++$rowIndex] = 'summary';
$this->mergeRows[] = $rowIndex;
$this->fillCells[] = ['row' => $rowIndex, 'column' => 'G', 'value' => (float) $deliveryTotal];
$this->fillCells[] = ['row' => $rowIndex, 'column' => 'H', 'value' => (float) $deliveryTotal];
$rows[] = ['附加金额(周转筐 ' . (int) $bill->box_num . ' 个 / 周转托盘 ' . (int) $bill->tray_num . ' 个)', '', '', '', '', '', (float) $addedTotal];
$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];
$this->fillCells[] = ['row' => $rowIndex, 'column' => 'H', 'value' => (float) $addedTotal];
$rows[] = ['售后金额(可正负)', '', '', '', '', '', (float) $afterSaleTotal];
$rows[] = ['售后金额(可正负)', '', '', '', '', '', '', (float) $afterSaleTotal];
$this->specialRows[++$rowIndex] = 'summary';
$this->mergeRows[] = $rowIndex;
$this->fillCells[] = ['row' => $rowIndex, 'column' => 'G', 'value' => (float) $afterSaleTotal];
$this->fillCells[] = ['row' => $rowIndex, 'column' => 'H', 'value' => (float) $afterSaleTotal];
$rows[] = ['总金额(商品金额+配送费+附加金额+售后金额)', '', '', '', '', '', (float) $grandTotal];
$rows[] = ['总金额(商品金额+配送费+附加金额+售后金额)', '', '', '', '', '', '', (float) $grandTotal];
$this->specialRows[++$rowIndex] = 'grand';
$this->mergeRows[] = $rowIndex;
$this->fillCells[] = ['row' => $rowIndex, 'column' => 'G', 'value' => (float) $grandTotal];
$this->fillCells[] = ['row' => $rowIndex, 'column' => 'H', 'value' => (float) $grandTotal];
$this->lastRow = $rowIndex;
return $this->rows = collect($rows);
}
/**
* 标题/列头/合计行加粗,合计行标签左合并 A:F;全表居中 + 全边框;金额列货币格式;
* 标题/列头/合计行加粗,合计行标签左合并 A:G;全表居中 + 全边框;金额列货币格式;
* 数量(浅黄)/金额(浅红)非 0 条件填色(0 不填、列头固定填色),冻结列头
*/
public function styles(Worksheet $sheet): array
@@ -170,18 +174,19 @@ class BillExport implements FromCollection, WithStyles
$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('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 . ':F' . $row);
$sheet->mergeCells('A' . $row . ':G' . $row);
}
// 全部单元格水平/垂直居中(合计行标签保持左对齐)
$sheet->getStyle('A1:G' . $this->lastRow)
$sheet->getStyle('A1:H' . $this->lastRow)
->getAlignment()
->setHorizontal(Alignment::HORIZONTAL_CENTER)
->setVertical(Alignment::VERTICAL_CENTER);
@@ -190,19 +195,19 @@ class BillExport implements FromCollection, WithStyles
}
// 表格区域(列头 → 末行)添加所有边框
$sheet->getStyle('A' . $this->headerRow . ':G' . $this->lastRow)
$sheet->getStyle('A' . $this->headerRow . ':H' . $this->lastRow)
->getBorders()
->getAllBorders()
->setBorderStyle(Border::BORDER_THIN);
// 金额列货币格式:¥ + 两位小数(单价列为文本,不设格式)
$sheet->getStyle('G' . ($this->headerRow + 1) . ':G' . $this->lastRow)
$sheet->getStyle('H' . ($this->headerRow + 1) . ':H' . $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);
// 列头固定填色:数量列(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) {
@@ -210,7 +215,7 @@ class BillExport implements FromCollection, WithStyles
$this->fillCell(
$sheet,
$cell['column'] . $cell['row'],
$cell['column'] === 'E' ? self::QUANTITY_FILL : self::AMOUNT_FILL
$cell['column'] === 'F' ? self::QUANTITY_FILL : self::AMOUNT_FILL
);
}
}
@@ -260,16 +265,39 @@ class BillExport implements FromCollection, WithStyles
}
/**
* 按一级分类过滤明细:沿 parent_id 上溯取根分类(商品含软删除,保证历史账单可导出),
* 仅保留根分类为所选分类的行
* 按一级分类过滤明细:仅保留根分类为所选分类的行
*
* @param array<int, array> $items 合并后的商品明细
* @param Collection<int, ProductCategoryModel> $categories 全部分类(id => 分类)
* @param array<int, int> $rootIds 商品ID => 一级分类ID
* @return array{0: array<int, array>, 1: string} [过滤后明细, 一级分类名]
*/
private function filterByRootCategory(array $items): array
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<int, array> $items 合并后的商品明细
* @return array{0: Collection<int, ProductCategoryModel>, 1: array<int, int>} [全部分类(id=>分类), 商品ID=>一级分类ID]
*/
private function resolveRootCategories(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'))
@@ -292,16 +320,6 @@ class BillExport implements FromCollection, WithStyles
$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];
return [$categories, $rootIds];
}
}
+18 -14
View File
@@ -98,38 +98,42 @@ class BillExportTest extends ProcurementTestCase
if ($rowA === null || $rowB === null) {
return false;
}
// 一级分类列=商品根分类名
if ($rowA[2] !== '蔬菜' || $rowB[2] !== '蔬菜') {
return false;
}
// 包规列=包规+单位合并;单价列=每件价÷每件斤数+单价单位(A:2.37÷10=0.23元/斤)
if ($rowA[2] !== '10斤/箱' || $rowA[3] !== '0.23元/斤') {
if ($rowA[3] !== '10斤/箱' || $rowA[4] !== '0.23元/斤') {
return false;
}
// 散装无法折算 → 单价按每件原价显示(B:5.00元/斤)
if ($rowB[2] !== '散装筐' || $rowB[3] !== '5.00元/斤') {
if ($rowB[3] !== '散装筐' || $rowB[4] !== '5.00元/斤') {
return false;
}
// 账单内同商品合并:数量 16、金额 38.00
if ((int) $rowA[4] !== 16 || (float) $rowA[6] !== 38.0) {
if ((int) $rowA[5] !== 16 || (float) $rowA[7] !== 38.0) {
return false;
}
if ((int) $rowB[4] !== 4 || (float) $rowB[6] !== 20.0) {
if ((int) $rowB[5] !== 4 || (float) $rowB[7] !== 20.0) {
return false;
}
$summary = $rows->slice(6)->values();
// 合计:数量 20、商品金额 58.00;配送费 10.00;附加 30.00(筐 2 / 托盘 1);售后 -8.00;总金额 90.00
if ((int) $summary[0][4] !== 20 || (float) $summary[0][6] !== 58.0) {
if ((int) $summary[0][5] !== 20 || (float) $summary[0][7] !== 58.0) {
return false;
}
if ($summary[1][0] !== '配送费' || (float) $summary[1][6] !== 10.0) {
if ($summary[1][0] !== '配送费' || (float) $summary[1][7] !== 10.0) {
return false;
}
if (! str_contains((string) $summary[2][0], '筐 2 个 / 周转托盘 1 个') || (float) $summary[2][6] !== 30.0) {
if (! str_contains((string) $summary[2][0], '筐 2 个 / 周转托盘 1 个') || (float) $summary[2][7] !== 30.0) {
return false;
}
if (! str_contains((string) $summary[3][0], '售后金额') || (float) $summary[3][6] !== -8.0) {
if (! str_contains((string) $summary[3][0], '售后金额') || (float) $summary[3][7] !== -8.0) {
return false;
}
return (float) $summary[4][6] === 90.0;
return (float) $summary[4][7] === 90.0;
}
);
}
@@ -161,22 +165,22 @@ class BillExportTest extends ProcurementTestCase
if (! str_contains((string) $rows[1][0], '分类:蔬菜')) {
return false;
}
// 明细仅剩蔬菜(子分类归入根分类),肉禽被忽略
// 明细仅剩蔬菜(子分类归入根分类),肉禽被忽略;一级分类列显示根分类名
$detail = $rows->slice(4, 1)->values()->first();
if ($detail === null || $detail[1] !== $veg->name) {
if ($detail === null || $detail[1] !== $veg->name || $detail[2] !== '蔬菜') {
return false;
}
$summary = $rows->slice(5)->values();
// 商品金额合计 20.00(仅蔬菜);配送费 10.00 / 附加 30.00 / 售后 0.00 全额;总金额 60.00
if ((float) $summary[0][6] !== 20.0) {
if ((float) $summary[0][7] !== 20.0) {
return false;
}
if ((float) $summary[1][6] !== 10.0 || (float) $summary[2][6] !== 30.0 || (float) $summary[3][6] !== 0.0) {
if ((float) $summary[1][7] !== 10.0 || (float) $summary[2][7] !== 30.0 || (float) $summary[3][7] !== 0.0) {
return false;
}
return (float) $summary[4][6] === 60.0;
return (float) $summary[4][7] === 60.0;
}
);
}