Compare commits

..

2 Commits

Author SHA1 Message Date
xinadmin 364471a086 前端打包 2026-09-05 14:05:16 +08:00
xinadmin 5c7a5829af 导出优化 2026-09-05 14:03:23 +08:00
9 changed files with 90 additions and 60 deletions
File diff suppressed because one or more lines are too long
+10 -4
View File
@@ -20,8 +20,9 @@ use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet; use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
/** /**
* 采购单商品明细导出:系统全部商品行(含本采购单无订货的商品,数量 0), * 采购单商品明细导出:系统全部未删除商品行(含本采购单无订货的商品,数量 0),
* 支持按供应商筛选;行尾合计 + 门店列合计;数量/金额/门店数量按值条件填色 * 支持按供应商筛选;行尾合计 + 门店列合计;数量/金额/门店数量按值条件填色
* 序号/分类/包规/单位/成本/单价/实际称重/金额列在表格中默认隐藏(数据照常导出,Excel 中可取消隐藏)
*/ */
class PurchaseOrderExport implements FromCollection, WithStrictNullComparison, WithStyles class PurchaseOrderExport implements FromCollection, WithStrictNullComparison, WithStyles
{ {
@@ -114,10 +115,10 @@ class PurchaseOrderExport implements FromCollection, WithStrictNullComparison, W
]; ];
} }
// 导出范围:系统全部上架商品 本采购单有订货的商品(含已删/下架 // 导出范围:系统全部未删除商品(含下架) 本采购单有订货的商品(含已删)
$products = ProductModel::withTrashed() $products = ProductModel::withTrashed()
->with('category:id,sort') ->with('category:id,sort')
->where('status', ProductModel::STATUS_ON) ->whereNull('deleted_at')
->orWhereIn('id', array_keys($itemGroups)) ->orWhereIn('id', array_keys($itemGroups))
->get() ->get()
->keyBy('id'); ->keyBy('id');
@@ -267,6 +268,11 @@ class PurchaseOrderExport implements FromCollection, WithStrictNullComparison, W
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex(13 + $i))->setWidth(12); $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) $sheet->getStyle('A1:' . $lastColumn . $this->lastRow)
->getAlignment() ->getAlignment()
+5 -26
View File
@@ -30,7 +30,7 @@ class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison,
private ?Collection $rows = null; private ?Collection $rows = null;
/** @var array<int, string> 特殊行索引(1 起)=> 类型(title/header/summary */ /** @var array<int, string> 特殊行索引(1 起)=> 类型(title/header */
private array $specialRows = []; private array $specialRows = [];
/** 列头所在行索引 */ /** 列头所在行索引 */
@@ -42,9 +42,6 @@ class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison,
/** @var array<int, array{quantity: int, store_quantities: array<int, int>}> 明细行索引 => 填色判断数据 */ /** @var array<int, array{quantity: int, store_quantities: array<int, int>}> 明细行索引 => 填色判断数据 */
private array $rowData = []; private array $rowData = [];
/** @var array{quantity: int, stores: array<int, int>} 合计行填色判断数据 */
private array $summaryTotals = ['quantity' => 0, 'stores' => []];
/** /**
* @param PurchaseOrderModel $purchase 采购单 * @param PurchaseOrderModel $purchase 采购单
* @param SupplierModel $supplier 供应商 * @param SupplierModel $supplier 供应商
@@ -64,7 +61,7 @@ class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison,
} }
/** /**
* 导出行:标题/空行/列头(品名、汇总、市场、各门店)/明细/合计 * 导出行:标题/空行/列头(品名、汇总、市场、各门店)/明细
*/ */
public function collection(): Collection public function collection(): Collection
{ {
@@ -91,8 +88,6 @@ class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison,
$this->headerRow = $rowIndex; $this->headerRow = $rowIndex;
// 明细行(0 数量的门店格留空不填色) // 明细行(0 数量的门店格留空不填色)
$totalQuantity = 0;
$storeTotals = array_fill_keys($storeIds, 0);
foreach ($this->productRows as $productRow) { foreach ($this->productRows as $productRow) {
$line = [ $line = [
$productRow['product_name'], $productRow['product_name'],
@@ -104,24 +99,14 @@ class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison,
$quantity = (int) ($productRow['store_quantities'][$storeId] ?? 0); $quantity = (int) ($productRow['store_quantities'][$storeId] ?? 0);
$line[] = $quantity > 0 ? $quantity : ''; $line[] = $quantity > 0 ? $quantity : '';
$storeQuantities[$storeId] = $quantity; $storeQuantities[$storeId] = $quantity;
$storeTotals[$storeId] += $quantity;
} }
$rows[] = $line; $rows[] = $line;
$this->rowData[++$rowIndex] = [ $this->rowData[++$rowIndex] = [
'quantity' => (int) $productRow['quantity'], 'quantity' => (int) $productRow['quantity'],
'store_quantities' => $storeQuantities, 'store_quantities' => $storeQuantities,
]; ];
$totalQuantity += (int) $productRow['quantity'];
} }
// 合计行
$rows[] = array_merge(
['合计', $totalQuantity, ''],
array_map(static fn (int $storeId): int => $storeTotals[$storeId], $storeIds),
);
$this->specialRows[++$rowIndex] = 'summary';
$this->lastRow = $rowIndex; $this->lastRow = $rowIndex;
$this->summaryTotals = ['quantity' => $totalQuantity, 'stores' => $storeTotals];
return $this->rows = collect($rows); return $this->rows = collect($rows);
} }
@@ -132,7 +117,7 @@ class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison,
} }
/** /**
* 标题/列头/合计加粗;全表居中 + 全边框; * 标题/列头加粗;全表居中 + 全边框;
* 汇总列(浅黄)/门店数量列(浅蓝)按值条件填色(0 不填、列头固定填色),冻结列头 * 汇总列(浅黄)/门店数量列(浅蓝)按值条件填色(0 不填、列头固定填色),冻结列头
*/ */
public function styles(Worksheet $sheet): array public function styles(Worksheet $sheet): array
@@ -157,7 +142,7 @@ class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison,
->setHorizontal(Alignment::HORIZONTAL_CENTER) ->setHorizontal(Alignment::HORIZONTAL_CENTER)
->setVertical(Alignment::VERTICAL_CENTER); ->setVertical(Alignment::VERTICAL_CENTER);
// 表格区域(列头 → 合计行)添加所有边框 // 表格区域(列头 → 数据末行)添加所有边框
$sheet->getStyle('A' . $this->headerRow . ':' . $lastColumn . $this->lastRow) $sheet->getStyle('A' . $this->headerRow . ':' . $lastColumn . $this->lastRow)
->getBorders() ->getBorders()
->getAllBorders() ->getAllBorders()
@@ -170,9 +155,6 @@ class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison,
$this->fillCell($sheet, 'B' . $row, self::QUANTITY_FILL); $this->fillCell($sheet, 'B' . $row, self::QUANTITY_FILL);
} }
} }
if ($this->summaryTotals['quantity'] > 0) {
$this->fillCell($sheet, 'B' . $this->lastRow, self::QUANTITY_FILL);
}
// 门店数量列(D 起):有数据浅蓝,0 无背景,列头固定浅蓝 // 门店数量列(D 起):有数据浅蓝,0 无背景,列头固定浅蓝
foreach ($storeIds as $i => $storeId) { foreach ($storeIds as $i => $storeId) {
@@ -183,16 +165,13 @@ class PurchaseSupplierSheet implements FromCollection, WithStrictNullComparison,
$this->fillCell($sheet, $column . $row, self::STORE_FILL); $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 = []; $styles = [];
foreach ($this->specialRows as $row => $type) { foreach ($this->specialRows as $row => $type) {
$style = match ($type) { $style = match ($type) {
'title' => ['font' => ['bold' => true, 'size' => 14]], 'title' => ['font' => ['bold' => true, 'size' => 14]],
'header', 'summary' => ['font' => ['bold' => true]], 'header' => ['font' => ['bold' => true]],
default => [], default => [],
}; };
if ($style !== []) { if ($style !== []) {
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -5,7 +5,7 @@
<link rel="icon" type="image/svg+xml" href="/favicons.svg" /> <link rel="icon" type="image/svg+xml" href="/favicons.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>XinAdmin</title> <title>XinAdmin</title>
<script type="module" crossorigin src="/assets/index-B3rYtk3Q.js"></script> <script type="module" crossorigin src="/assets/index-wuX9SPpH.js"></script>
<link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-BgaNhQyE.js"> <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-BgaNhQyE.js">
<link rel="modulepreload" crossorigin href="/assets/jsx-runtime-CRBytmvs.js"> <link rel="modulepreload" crossorigin href="/assets/jsx-runtime-CRBytmvs.js">
<link rel="modulepreload" crossorigin href="/assets/chunk-KS7C4IRE-Zm15rq6F.js"> <link rel="modulepreload" crossorigin href="/assets/chunk-KS7C4IRE-Zm15rq6F.js">
+48 -4
View File
@@ -13,6 +13,7 @@ use App\Models\StoreModel;
use App\Models\StoreOrderModel; use App\Models\StoreOrderModel;
use App\Models\SupplierModel; use App\Models\SupplierModel;
use Maatwebsite\Excel\Facades\Excel; use Maatwebsite\Excel\Facades\Excel;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
/** /**
* 采购单导出(仅 Excel 表格,全品类):xlsx Content-Type / * 采购单导出(仅 Excel 表格,全品类):xlsx Content-Type /
@@ -188,6 +189,48 @@ class ExportTest extends ProcurementTestCase
); );
} }
/** 商品明细导出:序号/分类/包规/单位/成本/单价/实际称重/金额列在表格中默认隐藏(数据照常导出) */
public function test_export_hides_detail_columns_by_default(): void
{
[$purchase] = $this->buildPurchaseWithSuppliers();
$export = new PurchaseOrderExport($purchase);
$sheet = (new Spreadsheet())->getActiveSheet();
$export->styles($sheet);
// 序号A/分类B/包规F/单位G/成本H/单价I/实际称重K/金额L 默认隐藏
foreach (['A', 'B', 'F', 'G', 'H', 'I', 'K', 'L'] as $column) {
$this->assertFalse($sheet->getColumnDimension($column)->getVisible(), "{$column} 应默认隐藏");
}
// 品名/供应商/市场/数量与门店列保持可见
foreach (['C', 'D', 'E', 'J', 'M', 'N'] as $column) {
$this->assertTrue($sheet->getColumnDimension($column)->getVisible(), "{$column} 应保持可见");
}
// 数据照常导出:列头与合计行仍含隐藏列数据
$rows = $export->collection()->values();
$header = $rows[3];
$this->assertSame('序号', $header[0]);
$this->assertSame('金额', $header[11]);
$total = $rows->last();
$this->assertSame('合计', $total[2]);
$this->assertSame(45.0, (float) $total[11]);
}
/** 商品明细导出:范围=全部未删除商品(含下架)∪ 本采购单有订货商品;已删除且无订货的商品不导出 */
public function test_export_scope_includes_off_shelf_but_not_deleted(): void
{
[$purchase] = $this->buildPurchaseWithSuppliers();
$offShelf = ProductModel::factory()->create(['status' => ProductModel::STATUS_OFF, 'cost_price' => '8.00']);
$deleted = ProductModel::factory()->create(['status' => ProductModel::STATUS_ON, 'cost_price' => '9.00']);
$deleted->delete();
$rows = (new PurchaseOrderExport($purchase))->collection()->values();
$names = $rows->slice(4, -1)->map(static fn ($row) => $row[2])->all();
$this->assertContains($offShelf->name, $names);
$this->assertNotContains($deleted->name, $names);
}
/** 商品明细导出:按供应商筛选(范围行标注供应商,仅含其商品行) */ /** 商品明细导出:按供应商筛选(范围行标注供应商,仅含其商品行) */
public function test_export_supplier_filter(): void public function test_export_supplier_filter(): void
{ {
@@ -307,8 +350,9 @@ class ExportTest extends ProcurementTestCase
|| (int) $vegRow[3] !== 2 || (int) $vegRow[4] !== 3) { || (int) $vegRow[3] !== 2 || (int) $vegRow[4] !== 3) {
return false; return false;
} }
$totalA = $rowsA->last(); // 无合计行:末行即最后一条明细
if ($totalA[0] !== '合计' || (int) $totalA[1] !== 5 || (int) $totalA[3] !== 2 || (int) $totalA[4] !== 3) { $lastA = $rowsA->last();
if ($lastA[0] === '合计') {
return false; return false;
} }
// 供应商乙·岳各庄:肉 1 件;门店列仅门店A // 供应商乙·岳各庄:肉 1 件;门店列仅门店A
@@ -366,10 +410,10 @@ class ExportTest extends ProcurementTestCase
if ($titles !== [$supplier->name . '·岳各庄', $supplier->name . '·新发地', $supplier->name . '·未设置']) { if ($titles !== [$supplier->name . '·岳各庄', $supplier->name . '·新发地', $supplier->name . '·未设置']) {
return false; return false;
} }
// 每个工作表仅含本市场商品 // 每个工作表仅含本市场商品(无合计行,明细自第 4 行起)
foreach ($sheets as $sheet) { foreach ($sheets as $sheet) {
$rows = $sheet->collection()->values(); $rows = $sheet->collection()->values();
$names = $rows->slice(3, -1)->map(static fn ($row) => $row[0])->values()->all(); $names = $rows->slice(3)->map(static fn ($row) => $row[0])->values()->all();
$expected = match (true) { $expected = match (true) {
str_ends_with($sheet->title(), '新发地') => [$vegA->name], str_ends_with($sheet->title(), '新发地') => [$vegA->name],
str_ends_with($sheet->title(), '岳各庄') => [$vegB->name], str_ends_with($sheet->title(), '岳各庄') => [$vegB->name],
+22 -21
View File
@@ -294,13 +294,30 @@ const ProductGoodsPage: React.FC = () => {
]; ];
const columns: XinTableColumn<IProduct>[] = [ const columns: XinTableColumn<IProduct>[] = [
// {
// title: 'ID',
// dataIndex: 'id',
// hideInForm: true,
// hideInSearch: true,
// width: 70,
// align: 'center',
// },
{ {
title: 'ID', title: '商品名称',
dataIndex: 'id',
hideInForm: true,
hideInSearch: true,
width: 70,
align: 'center', align: 'center',
dataIndex: 'name',
valueType: 'text',
colProps: { span: 24 },
required: true,
render: (_, record) => {
return (
<div>
<div className={"mb-1.5"}>{ record.name }</div>
<div className={"text-[#999] text-[12px]"}>{ record.remark }</div>
</div>
)
},
rules: [{ required: true, message: '请输入商品名称' }],
}, },
{ {
title: '商品图片', title: '商品图片',
@@ -335,22 +352,6 @@ const ProductGoodsPage: React.FC = () => {
hideInSearch: true, hideInSearch: true,
colProps: { span: 24 }, colProps: { span: 24 },
}, },
{
title: '商品名称',
dataIndex: 'name',
valueType: 'text',
colProps: { span: 24 },
required: true,
render: (_, record) => {
return (
<div>
<div className={"mb-1.5"}>{ record.name }</div>
<div className={"text-[#999] text-[12px]"}>{ record.remark }</div>
</div>
)
},
rules: [{ required: true, message: '请输入商品名称' }],
},
{ {
title: '商品描述', title: '商品描述',
dataIndex: 'remark', dataIndex: 'remark',