账单导出

This commit is contained in:
liu
2026-09-25 00:30:02 +08:00
parent 9831355386
commit 5123065def
5 changed files with 86 additions and 57 deletions
File diff suppressed because one or more lines are too long
+51 -38
View File
@@ -7,6 +7,7 @@ use App\Models\BillModel;
use App\Models\ProductCategoryModel; use App\Models\ProductCategoryModel;
use App\Models\ProductModel; use App\Models\ProductModel;
use App\Services\BillDetailService; use App\Services\BillDetailService;
use App\Services\WeightEstimator;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Maatwebsite\Excel\Concerns\FromCollection; use Maatwebsite\Excel\Concerns\FromCollection;
use Maatwebsite\Excel\Concerns\WithStyles; use Maatwebsite\Excel\Concerns\WithStyles;
@@ -35,10 +36,10 @@ class BillExport implements FromCollection, WithStyles
/** @var array<int, string> 特殊行索引(1 起)=> 类型(title/scope/header/summary/grand) */ /** @var array<int, string> 特殊行索引(1 起)=> 类型(title/scope/header/summary/grand) */
private array $specialRows = []; private array $specialRows = [];
/** @var array<int, int> 标签需左合并 A:G 的合计行索引 */ /** @var array<int, int> 标签需左合并 A:F 的合计行索引 */
private array $mergeRows = []; private array $mergeRows = [];
/** @var array<int, array{row: int, column: string, value: float}> 条件填色单元格(F=数量浅黄,H=金额浅红,非 0 填色) */ /** @var array<int, array{row: int, column: string, value: float}> 条件填色单元格(E=数量浅黄,G=金额浅红,非 0 填色) */
private array $fillCells = []; private array $fillCells = [];
/** 列头所在行索引 */ /** 列头所在行索引 */
@@ -95,12 +96,12 @@ class BillExport implements FromCollection, WithStyles
$rows[] = ['']; $rows[] = [''];
$rowIndex++; $rowIndex++;
// 列头 // 列头(包规=包规+单位合并;单价=每件零售价+单价单位)
$rows[] = ['序号', '品名', '包规', '单位', '单价', '数量', '重量(斤)', '金额']; $rows[] = ['序号', '品名', '包规', '单价', '数量', '重量(斤)', '金额'];
$this->specialRows[++$rowIndex] = 'header'; $this->specialRows[++$rowIndex] = 'header';
$this->headerRow = $rowIndex; $this->headerRow = $rowIndex;
// 明细行(账单内同商品已合并,单价为加权平均) // 明细行(账单内同商品已合并,单价为每件价折算的零售价)
$totalQuantity = 0; $totalQuantity = 0;
$totalWeight = '0'; $totalWeight = '0';
$totalAmount = '0'; $totalAmount = '0';
@@ -108,26 +109,25 @@ class BillExport implements FromCollection, WithStyles
$rows[] = [ $rows[] = [
$sort + 1, $sort + 1,
$item['product_name'], $item['product_name'],
$item['product_spec'], $item['product_spec'] . $item['unit'],
$item['unit'], self::retailPriceLabel($item),
(float) $item['price'],
(int) $item['quantity'], (int) $item['quantity'],
(float) $item['weight'], (float) $item['weight'],
(float) $item['amount'], (float) $item['amount'],
]; ];
$rowIndex++; $rowIndex++;
$this->fillCells[] = ['row' => $rowIndex, 'column' => 'F', 'value' => (float) $item['quantity']]; $this->fillCells[] = ['row' => $rowIndex, 'column' => 'E', 'value' => (float) $item['quantity']];
$this->fillCells[] = ['row' => $rowIndex, 'column' => 'H', 'value' => (float) $item['amount']]; $this->fillCells[] = ['row' => $rowIndex, 'column' => 'G', 'value' => (float) $item['amount']];
$totalQuantity += (int) $item['quantity']; $totalQuantity += (int) $item['quantity'];
$totalWeight = bcadd($totalWeight, $item['weight'], 3); $totalWeight = bcadd($totalWeight, $item['weight'], 3);
$totalAmount = bcadd($totalAmount, $item['amount'], 2); $totalAmount = bcadd($totalAmount, $item['amount'], 2);
} }
// 商品合计行 // 商品合计行
$rows[] = ['', '合计', '', '', '', $totalQuantity, (float) $totalWeight, (float) $totalAmount]; $rows[] = ['', '合计', '', '', $totalQuantity, (float) $totalWeight, (float) $totalAmount];
$this->specialRows[++$rowIndex] = 'summary'; $this->specialRows[++$rowIndex] = 'summary';
$this->fillCells[] = ['row' => $rowIndex, 'column' => 'F', 'value' => (float) $totalQuantity]; $this->fillCells[] = ['row' => $rowIndex, 'column' => 'E', 'value' => (float) $totalQuantity];
$this->fillCells[] = ['row' => $rowIndex, 'column' => 'H', 'value' => (float) $totalAmount]; $this->fillCells[] = ['row' => $rowIndex, 'column' => 'G', 'value' => (float) $totalAmount];
// 配送费/附加金额/售后金额/总金额(账单级费用全额计入,不受分类过滤影响) // 配送费/附加金额/售后金额/总金额(账单级费用全额计入,不受分类过滤影响)
$deliveryTotal = (string) $bill->delivery_fee; $deliveryTotal = (string) $bill->delivery_fee;
@@ -135,32 +135,32 @@ class BillExport implements FromCollection, WithStyles
$afterSaleTotal = (string) $bill->after_sale; $afterSaleTotal = (string) $bill->after_sale;
$grandTotal = bcadd(bcadd(bcadd($totalAmount, $deliveryTotal, 2), $addedTotal, 2), $afterSaleTotal, 2); $grandTotal = bcadd(bcadd(bcadd($totalAmount, $deliveryTotal, 2), $addedTotal, 2), $afterSaleTotal, 2);
$rows[] = ['配送费', '', '', '', '', '', '', (float) $deliveryTotal]; $rows[] = ['配送费', '', '', '', '', '', (float) $deliveryTotal];
$this->specialRows[++$rowIndex] = 'summary'; $this->specialRows[++$rowIndex] = 'summary';
$this->mergeRows[] = $rowIndex; $this->mergeRows[] = $rowIndex;
$this->fillCells[] = ['row' => $rowIndex, 'column' => 'H', 'value' => (float) $deliveryTotal]; $this->fillCells[] = ['row' => $rowIndex, 'column' => 'G', '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->specialRows[++$rowIndex] = 'summary';
$this->mergeRows[] = $rowIndex; $this->mergeRows[] = $rowIndex;
$this->fillCells[] = ['row' => $rowIndex, 'column' => 'H', 'value' => (float) $addedTotal]; $this->fillCells[] = ['row' => $rowIndex, 'column' => 'G', 'value' => (float) $addedTotal];
$rows[] = ['售后金额(可正负)', '', '', '', '', '', '', (float) $afterSaleTotal]; $rows[] = ['售后金额(可正负)', '', '', '', '', '', (float) $afterSaleTotal];
$this->specialRows[++$rowIndex] = 'summary'; $this->specialRows[++$rowIndex] = 'summary';
$this->mergeRows[] = $rowIndex; $this->mergeRows[] = $rowIndex;
$this->fillCells[] = ['row' => $rowIndex, 'column' => 'H', 'value' => (float) $afterSaleTotal]; $this->fillCells[] = ['row' => $rowIndex, 'column' => 'G', 'value' => (float) $afterSaleTotal];
$rows[] = ['总金额(商品金额+配送费+附加金额+售后金额)', '', '', '', '', '', '', (float) $grandTotal]; $rows[] = ['总金额(商品金额+配送费+附加金额+售后金额)', '', '', '', '', '', (float) $grandTotal];
$this->specialRows[++$rowIndex] = 'grand'; $this->specialRows[++$rowIndex] = 'grand';
$this->mergeRows[] = $rowIndex; $this->mergeRows[] = $rowIndex;
$this->fillCells[] = ['row' => $rowIndex, 'column' => 'H', 'value' => (float) $grandTotal]; $this->fillCells[] = ['row' => $rowIndex, 'column' => 'G', 'value' => (float) $grandTotal];
$this->lastRow = $rowIndex; $this->lastRow = $rowIndex;
return $this->rows = collect($rows); return $this->rows = collect($rows);
} }
/** /**
* 标题/列头/合计行加粗,合计行标签左合并 A:G;全表居中 + 全边框;金额类列货币格式; * 标题/列头/合计行加粗,合计行标签左合并 A:F;全表居中 + 全边框;金额列货币格式;
* 数量(浅黄)/金额(浅红)非 0 条件填色(0 不填、列头固定填色),冻结列头 * 数量(浅黄)/金额(浅红)非 0 条件填色(0 不填、列头固定填色),冻结列头
*/ */
public function styles(Worksheet $sheet): array public function styles(Worksheet $sheet): array
@@ -171,18 +171,17 @@ class BillExport implements FromCollection, WithStyles
$sheet->getColumnDimension('A')->setWidth(6); $sheet->getColumnDimension('A')->setWidth(6);
$sheet->getColumnDimension('B')->setWidth(24); $sheet->getColumnDimension('B')->setWidth(24);
$sheet->getColumnDimension('C')->setWidth(14); $sheet->getColumnDimension('C')->setWidth(14);
$sheet->getColumnDimension('D')->setWidth(8); $sheet->getColumnDimension('D')->setWidth(12);
$sheet->getColumnDimension('E')->setWidth(10); $sheet->getColumnDimension('E')->setWidth(10);
$sheet->getColumnDimension('F')->setWidth(10); $sheet->getColumnDimension('F')->setWidth(12);
$sheet->getColumnDimension('G')->setWidth(12); $sheet->getColumnDimension('G')->setWidth(12);
$sheet->getColumnDimension('H')->setWidth(12);
foreach ($this->mergeRows as $row) { foreach ($this->mergeRows as $row) {
$sheet->mergeCells('A' . $row . ':G' . $row); $sheet->mergeCells('A' . $row . ':F' . $row);
} }
// 全部单元格水平/垂直居中(合计行标签保持左对齐) // 全部单元格水平/垂直居中(合计行标签保持左对齐)
$sheet->getStyle('A1:H' . $this->lastRow) $sheet->getStyle('A1:G' . $this->lastRow)
->getAlignment() ->getAlignment()
->setHorizontal(Alignment::HORIZONTAL_CENTER) ->setHorizontal(Alignment::HORIZONTAL_CENTER)
->setVertical(Alignment::VERTICAL_CENTER); ->setVertical(Alignment::VERTICAL_CENTER);
@@ -191,21 +190,19 @@ class BillExport implements FromCollection, WithStyles
} }
// 表格区域(列头 → 末行)添加所有边框 // 表格区域(列头 → 末行)添加所有边框
$sheet->getStyle('A' . $this->headerRow . ':H' . $this->lastRow) $sheet->getStyle('A' . $this->headerRow . ':G' . $this->lastRow)
->getBorders() ->getBorders()
->getAllBorders() ->getAllBorders()
->setBorderStyle(Border::BORDER_THIN); ->setBorderStyle(Border::BORDER_THIN);
// 金额类列(单价/金额)货币格式:¥ + 两位小数 // 金额列货币格式:¥ + 两位小数(单价列为文本,不设格式)
foreach (['E', 'H'] as $column) { $sheet->getStyle('G' . ($this->headerRow + 1) . ':G' . $this->lastRow)
$sheet->getStyle($column . ($this->headerRow + 1) . ':' . $column . $this->lastRow) ->getNumberFormat()
->getNumberFormat() ->setFormatCode(self::CURRENCY_FORMAT);
->setFormatCode(self::CURRENCY_FORMAT);
}
// 列头固定填色:数量列(F)浅黄、金额列(H)浅红 // 列头固定填色:数量列(E)浅黄、金额列(G)浅红
$this->fillCell($sheet, 'F' . $this->headerRow, self::QUANTITY_FILL); $this->fillCell($sheet, 'E' . $this->headerRow, self::QUANTITY_FILL);
$this->fillCell($sheet, 'H' . $this->headerRow, self::AMOUNT_FILL); $this->fillCell($sheet, 'G' . $this->headerRow, self::AMOUNT_FILL);
// 数据区条件填色:非 0 填色,0 无背景(金额含负值) // 数据区条件填色:非 0 填色,0 无背景(金额含负值)
foreach ($this->fillCells as $cell) { foreach ($this->fillCells as $cell) {
@@ -213,7 +210,7 @@ class BillExport implements FromCollection, WithStyles
$this->fillCell( $this->fillCell(
$sheet, $sheet,
$cell['column'] . $cell['row'], $cell['column'] . $cell['row'],
$cell['column'] === 'F' ? self::QUANTITY_FILL : self::AMOUNT_FILL $cell['column'] === 'E' ? self::QUANTITY_FILL : self::AMOUNT_FILL
); );
} }
} }
@@ -246,6 +243,22 @@ class BillExport implements FromCollection, WithStyles
->setARGB($argb); ->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'] ?? '');
}
/** /**
* 按一级分类过滤明细:沿 parent_id 上溯取根分类(商品含软删除,保证历史账单可导出), * 按一级分类过滤明细:沿 parent_id 上溯取根分类(商品含软删除,保证历史账单可导出),
* 仅保留根分类为所选分类的行 * 仅保留根分类为所选分类的行
+9 -2
View File
@@ -5,6 +5,8 @@ namespace App\Services;
use App\Models\BillModel; use App\Models\BillModel;
use App\Models\ProductModel; use App\Models\ProductModel;
use App\Models\StoreOrderItemModel; use App\Models\StoreOrderItemModel;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Contracts\Container\CircularDependencyException;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
/** /**
@@ -16,7 +18,10 @@ class BillDetailService
* 合并后的商品明细:按商品聚合账单关联的全部订单明细 * 合并后的商品明细:按商品聚合账单关联的全部订单明细
* 单价为加权平均口径(Σ金额÷Σ数量),保证 单价×数量=金额;附商品首图 * 单价为加权平均口径(Σ金额÷Σ数量),保证 单价×数量=金额;附商品首图
* *
* @return array<int, array{product_id: int, product_name: string, product_spec: string, unit: string, price: string, quantity: int, weight: string, amount: string, image: string}> * @param BillModel $bill
* @return array<int, array{product_id: int, product_name: string, product_spec: string, unit: string, price: string, quantity: int, weight: string, amount: string, spec: string, price_unit: string, image: string}>
* @throws BindingResolutionException
* @throws CircularDependencyException
*/ */
public function mergedItems(BillModel $bill): array public function mergedItems(BillModel $bill): array
{ {
@@ -34,7 +39,9 @@ class BillDetailService
* 首图取明细快照 image_ids 批量解析,按「分类sort → 商品sort」排序 * 首图取明细快照 image_ids 批量解析,按「分类sort → 商品sort」排序
* *
* @param Collection<int, StoreOrderItemModel> $items * @param Collection<int, StoreOrderItemModel> $items
* @return array<int, array{product_id: int, product_name: string, product_spec: string, unit: string, price: string, quantity: int, weight: string, amount: string, image: string}> * @return array<int, array{product_id: int, product_name: string, product_spec: string, unit: string, price: string, quantity: int, weight: string, amount: string, spec: string, price_unit: string, image: string}>
* @throws BindingResolutionException
* @throws CircularDependencyException
*/ */
private function aggregateItems(Collection $items): array private function aggregateItems(Collection $items): array
{ {
+2 -2
View File
@@ -19,9 +19,9 @@ class WeightEstimator
} }
/** /**
* 单个计价单位折算重量(斤) * 单个计价单位折算重量(斤);规格无法解析时返回 '0'
*/ */
private static function perUnitJin(string $spec, string $unit): string public static function perUnitJin(string $spec, string $unit): string
{ {
// 按重量计价的单位:订货量本身就是重量 // 按重量计价的单位:订货量本身就是重量
$unitWeight = self::toJin('1', $unit); $unitWeight = self::toJin('1', $unit);
+23 -14
View File
@@ -60,13 +60,14 @@ class BillExportTest extends ProcurementTestCase
]); ]);
} }
/** 单独导出:账单内同商品合并(数量累加、单价加权平均),费用行取账单自身字段 */ /** 单独导出:账单内同商品合并(数量累加、单价加权平均),费用行取账单自身字段;
* 包规列=包规+单位合并;单价列=每件价÷包规斤数 折算零售价+单价单位(散装无法折算按原价) */
public function test_export_merges_items_within_bill(): void public function test_export_merges_items_within_bill(): void
{ {
$root = ProductCategoryModel::create(['name' => '蔬菜', 'parent_id' => 0, 'sort' => 0, 'status' => 1]); $root = ProductCategoryModel::create(['name' => '蔬菜', 'parent_id' => 0, 'sort' => 0, 'status' => 1]);
$store = StoreModel::factory()->create(); $store = StoreModel::factory()->create();
$productA = ProductModel::factory()->create(['category_id' => $root->id]); $productA = ProductModel::factory()->create(['category_id' => $root->id, 'spec' => '10', 'unit' => '斤/箱']);
$productB = ProductModel::factory()->create(['category_id' => $root->id]); $productB = ProductModel::factory()->create(['category_id' => $root->id, 'spec' => '散装', 'unit' => '筐']);
$bill = $this->makeBill($store, ['after_sale' => '-8.00']); $bill = $this->makeBill($store, ['after_sale' => '-8.00']);
// 同一商品两条明细:10×2.00 + 6×3.00 → 数量 16、金额 38.00、加权单价 2.37 // 同一商品两条明细:10×2.00 + 6×3.00 → 数量 16、金额 38.00、加权单价 2.37
@@ -97,30 +98,38 @@ class BillExportTest extends ProcurementTestCase
if ($rowA === null || $rowB === null) { if ($rowA === null || $rowB === null) {
return false; return false;
} }
// 账单内同商品合并:数量 16、加权单价 2.37、金额 38.00 // 包规列=包规+单位合并;单价列=每件价÷每件斤数+单价单位(A:2.37÷10=0.23元/斤)
if ((float) $rowA[4] !== 2.37 || (int) $rowA[5] !== 16 || (float) $rowA[7] !== 38.0) { if ($rowA[2] !== '10斤/箱' || $rowA[3] !== '0.23元/斤') {
return false; return false;
} }
if ((int) $rowB[5] !== 4 || (float) $rowB[7] !== 20.0) { // 散装无法折算 → 单价按每件原价显示(B:5.00元/斤)
if ($rowB[2] !== '散装筐' || $rowB[3] !== '5.00元/斤') {
return false;
}
// 账单内同商品合并:数量 16、金额 38.00
if ((int) $rowA[4] !== 16 || (float) $rowA[6] !== 38.0) {
return false;
}
if ((int) $rowB[4] !== 4 || (float) $rowB[6] !== 20.0) {
return false; return false;
} }
$summary = $rows->slice(6)->values(); $summary = $rows->slice(6)->values();
// 合计:数量 20、商品金额 58.00;配送费 10.00;附加 30.00(筐 2 / 托盘 1);售后 -8.00;总金额 90.00 // 合计:数量 20、商品金额 58.00;配送费 10.00;附加 30.00(筐 2 / 托盘 1);售后 -8.00;总金额 90.00
if ((int) $summary[0][5] !== 20 || (float) $summary[0][7] !== 58.0) { if ((int) $summary[0][4] !== 20 || (float) $summary[0][6] !== 58.0) {
return false; return false;
} }
if ($summary[1][0] !== '配送费' || (float) $summary[1][7] !== 10.0) { if ($summary[1][0] !== '配送费' || (float) $summary[1][6] !== 10.0) {
return false; return false;
} }
if (! str_contains((string) $summary[2][0], '筐 2 个 / 周转托盘 1 个') || (float) $summary[2][7] !== 30.0) { if (! str_contains((string) $summary[2][0], '筐 2 个 / 周转托盘 1 个') || (float) $summary[2][6] !== 30.0) {
return false; return false;
} }
if (! str_contains((string) $summary[3][0], '售后金额') || (float) $summary[3][7] !== -8.0) { if (! str_contains((string) $summary[3][0], '售后金额') || (float) $summary[3][6] !== -8.0) {
return false; return false;
} }
return (float) $summary[4][7] === 90.0; return (float) $summary[4][6] === 90.0;
} }
); );
} }
@@ -160,14 +169,14 @@ class BillExportTest extends ProcurementTestCase
$summary = $rows->slice(5)->values(); $summary = $rows->slice(5)->values();
// 商品金额合计 20.00(仅蔬菜);配送费 10.00 / 附加 30.00 / 售后 0.00 全额;总金额 60.00 // 商品金额合计 20.00(仅蔬菜);配送费 10.00 / 附加 30.00 / 售后 0.00 全额;总金额 60.00
if ((float) $summary[0][7] !== 20.0) { if ((float) $summary[0][6] !== 20.0) {
return false; return false;
} }
if ((float) $summary[1][7] !== 10.0 || (float) $summary[2][7] !== 30.0 || (float) $summary[3][7] !== 0.0) { if ((float) $summary[1][6] !== 10.0 || (float) $summary[2][6] !== 30.0 || (float) $summary[3][6] !== 0.0) {
return false; return false;
} }
return (float) $summary[4][7] === 60.0; return (float) $summary[4][6] === 60.0;
} }
); );
} }