1
|
|
|
<?php |
2
|
|
|
|
3
|
|
|
declare(strict_types=1); |
4
|
|
|
|
5
|
|
|
namespace Application\Handler; |
6
|
|
|
|
7
|
|
|
use Application\Model\AbstractModel; |
8
|
|
|
use DateTimeImmutable; |
9
|
|
|
use DateTimeInterface; |
10
|
|
|
use Doctrine\ORM\Query; |
11
|
|
|
use Ecodev\Felix\Handler\AbstractHandler; |
12
|
|
|
use Laminas\Diactoros\Response; |
13
|
|
|
use Money\Currencies\ISOCurrencies; |
14
|
|
|
use Money\Formatter\DecimalMoneyFormatter; |
15
|
|
|
use Money\Money; |
16
|
|
|
use PhpOffice\PhpSpreadsheet\Shared\Date; |
17
|
|
|
use PhpOffice\PhpSpreadsheet\Spreadsheet; |
18
|
|
|
use PhpOffice\PhpSpreadsheet\Style\Alignment; |
19
|
|
|
use PhpOffice\PhpSpreadsheet\Style\Border; |
20
|
|
|
use PhpOffice\PhpSpreadsheet\Style\Fill; |
21
|
|
|
use PhpOffice\PhpSpreadsheet\Style\NumberFormat; |
22
|
|
|
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet; |
23
|
|
|
use PhpOffice\PhpSpreadsheet\Writer\Xlsx; |
24
|
|
|
use Psr\Http\Message\ResponseInterface; |
25
|
|
|
use Psr\Http\Message\ServerRequestInterface; |
26
|
|
|
use Psr\Http\Server\RequestHandlerInterface; |
27
|
|
|
|
28
|
|
|
abstract class AbstractExcel extends AbstractHandler |
29
|
|
|
{ |
30
|
|
|
/** |
31
|
|
|
* Column of current cell we are writing in |
32
|
|
|
* |
33
|
|
|
* @var int |
34
|
|
|
*/ |
35
|
|
|
protected $column = 1; |
36
|
|
|
|
37
|
|
|
/** |
38
|
|
|
* Row of current cell we are writing in |
39
|
|
|
* |
40
|
|
|
* @var int |
41
|
|
|
*/ |
42
|
|
|
protected $row = 1; |
43
|
|
|
|
44
|
|
|
/** |
45
|
|
|
* @var Spreadsheet |
46
|
|
|
*/ |
47
|
|
|
protected $workbook; |
48
|
|
|
|
49
|
|
|
/** |
50
|
|
|
* @var string |
51
|
|
|
*/ |
52
|
|
|
protected $outputFileName = 'export.xlsx'; |
53
|
|
|
|
54
|
|
|
/** |
55
|
|
|
* @var string |
56
|
|
|
*/ |
57
|
|
|
protected $tmpDir = 'data/tmp/excel'; |
58
|
|
|
|
59
|
|
|
/** |
60
|
|
|
* @var string |
61
|
|
|
*/ |
62
|
|
|
protected $hostname; |
63
|
|
|
|
64
|
|
|
/** |
65
|
|
|
* @var string |
66
|
|
|
*/ |
67
|
|
|
protected $routeName; |
68
|
|
|
|
69
|
|
|
protected DecimalMoneyFormatter $moneyFormatter; |
70
|
|
|
|
71
|
|
|
/** |
72
|
|
|
* The model class name |
73
|
|
|
* |
74
|
|
|
* @return string |
75
|
|
|
*/ |
76
|
|
|
abstract protected function getModelClass(); |
77
|
|
|
|
78
|
|
|
protected static $dateFormat = [ |
79
|
|
|
'numberFormat' => ['formatCode' => NumberFormat::FORMAT_DATE_XLSX14], |
80
|
|
|
]; |
81
|
|
|
|
82
|
|
|
protected static $defaultFormat = [ |
83
|
|
|
'font' => ['size' => 11], |
84
|
|
|
'alignment' => ['vertical' => Alignment::VERTICAL_CENTER], |
85
|
|
|
]; |
86
|
|
|
|
87
|
|
|
protected static array $titleFormat = [ |
88
|
|
|
'font' => ['size' => 14], |
89
|
|
|
]; |
90
|
|
|
|
91
|
|
|
protected static $headerFormat = [ |
92
|
|
|
'font' => ['bold' => true, 'color' => ['argb' => 'FFEAEAEA']], |
93
|
|
|
'alignment' => ['wrapText' => true], |
94
|
|
|
'fill' => [ |
95
|
|
|
'fillType' => Fill::FILL_SOLID, |
96
|
|
|
'startColor' => [ |
97
|
|
|
'argb' => 'FF666666', |
98
|
|
|
], |
99
|
|
|
], |
100
|
|
|
]; |
101
|
|
|
|
102
|
|
|
protected static $totalFormat = [ |
103
|
|
|
'font' => ['bold' => true], |
104
|
|
|
'alignment' => ['wrapText' => true], |
105
|
|
|
'fill' => [ |
106
|
|
|
'fillType' => Fill::FILL_SOLID, |
107
|
|
|
'startColor' => [ |
108
|
|
|
'argb' => 'FFDDDDDD', |
109
|
|
|
], |
110
|
|
|
], |
111
|
|
|
]; |
112
|
|
|
|
113
|
|
|
protected static $centerFormat = [ |
114
|
|
|
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER], |
115
|
|
|
]; |
116
|
|
|
|
117
|
|
|
protected static $rightFormat = [ |
118
|
|
|
'alignment' => ['horizontal' => Alignment::HORIZONTAL_RIGHT], |
119
|
|
|
]; |
120
|
|
|
|
121
|
|
|
protected static $wrapFormat = [ |
122
|
|
|
'alignment' => ['wrapText' => true], |
123
|
|
|
]; |
124
|
|
|
|
125
|
|
|
/** |
126
|
|
|
* Define border cells inside list of data (very light borders) |
127
|
|
|
* |
128
|
|
|
* @var array |
129
|
|
|
*/ |
130
|
|
|
protected static $bordersInside = [ |
131
|
|
|
'borders' => [ |
132
|
|
|
'inside' => [ |
133
|
|
|
'borderStyle' => Border::BORDER_HAIR, |
134
|
|
|
], |
135
|
|
|
'outline' => [ |
136
|
|
|
'borderStyle' => Border::BORDER_MEDIUM, |
137
|
|
|
], |
138
|
|
|
], |
139
|
|
|
]; |
140
|
|
|
|
141
|
|
|
/** |
142
|
|
|
* Define border cells for total row (thick border) |
143
|
|
|
* |
144
|
|
|
* @var array |
145
|
|
|
*/ |
146
|
|
|
protected static $bordersTotal = [ |
147
|
|
|
'borders' => [ |
148
|
|
|
'outline' => [ |
149
|
|
|
'borderStyle' => Border::BORDER_THICK, |
150
|
|
|
], |
151
|
|
|
], |
152
|
|
|
]; |
153
|
|
|
|
154
|
|
|
/** |
155
|
|
|
* @var array |
156
|
|
|
*/ |
157
|
|
|
protected static $bordersBottom = [ |
158
|
|
|
'borders' => [ |
159
|
|
|
'bottom' => [ |
160
|
|
|
'borderStyle' => Border::BORDER_MEDIUM, |
161
|
|
|
], |
162
|
|
|
], |
163
|
|
|
]; |
164
|
|
|
|
165
|
|
|
/** |
166
|
|
|
* @var array |
167
|
|
|
*/ |
168
|
|
|
protected static $bordersBottomLight = [ |
169
|
|
|
'borders' => [ |
170
|
|
|
'bottom' => [ |
171
|
|
|
'borderStyle' => Border::BORDER_HAIR, |
172
|
|
|
], |
173
|
|
|
], |
174
|
|
|
]; |
175
|
|
|
|
176
|
|
|
/** |
177
|
|
|
* Constructor |
178
|
|
|
*/ |
179
|
2 |
|
public function __construct(string $hostname, string $routeName) |
180
|
|
|
{ |
181
|
2 |
|
$this->hostname = $hostname; |
182
|
2 |
|
$this->routeName = $routeName; |
183
|
2 |
|
$currencies = new ISOCurrencies(); |
184
|
2 |
|
$this->moneyFormatter = new DecimalMoneyFormatter($currencies); |
185
|
2 |
|
$this->workbook = new Spreadsheet(); |
186
|
2 |
|
$this->workbook->setActiveSheetIndex(0); |
187
|
2 |
|
} |
188
|
|
|
|
189
|
|
|
/** |
190
|
|
|
* @param AbstractModel[] $items |
191
|
|
|
*/ |
192
|
2 |
|
protected function writeHeaders(Worksheet $sheet, array $items): void |
193
|
|
|
{ |
194
|
|
|
// Headers |
195
|
2 |
|
foreach ($this->getHeaders() as $header) { |
196
|
|
|
// Apply width |
197
|
2 |
|
if (isset($header['width'])) { |
198
|
2 |
|
$colDimension = $sheet->getColumnDimensionByColumn($this->column); |
199
|
2 |
|
if ($header['width'] === 'auto') { |
200
|
|
|
$colDimension->setAutoSize(true); |
201
|
|
|
} else { |
202
|
2 |
|
$colDimension->setWidth($header['width']); |
203
|
|
|
} |
204
|
|
|
} |
205
|
|
|
// Apply default format |
206
|
2 |
|
if (!isset($header['formats'])) { |
207
|
|
|
$header['formats'] = [self::$headerFormat]; |
208
|
|
|
} |
209
|
|
|
|
210
|
2 |
|
if (isset($header['colspan']) && $header['colspan'] > 1) { |
211
|
1 |
|
$sheet->mergeCellsByColumnAndRow($this->column, $this->row, $this->column + (int) $header['colspan'] - 1, $this->row); |
212
|
|
|
} |
213
|
|
|
|
214
|
2 |
|
if (isset($header['autofilter'])) { |
215
|
1 |
|
$sheet->setAutoFilterByColumnAndRow($this->column, $this->row, $this->column - 1, $this->row + count($items)); |
216
|
|
|
} |
217
|
|
|
|
218
|
2 |
|
$this->write($sheet, $header['label'], ...$header['formats']); |
219
|
|
|
|
220
|
2 |
|
if (isset($header['colspan']) && $header['colspan'] > 1) { |
221
|
1 |
|
$this->column += (int) $header['colspan'] - 1; |
222
|
|
|
} |
223
|
|
|
} |
224
|
2 |
|
} |
225
|
|
|
|
226
|
|
|
/** |
227
|
|
|
* Write the items, one per line, in the body part of the sheet |
228
|
|
|
* |
229
|
|
|
* @param AbstractModel[] $items |
230
|
|
|
*/ |
231
|
|
|
abstract protected function writeData(Worksheet $sheet, array $items): void; |
232
|
|
|
|
233
|
|
|
/** |
234
|
|
|
* Write the footer line |
235
|
|
|
* |
236
|
|
|
* @param AbstractModel[] $items |
237
|
|
|
*/ |
238
|
|
|
abstract protected function writeFooter(Worksheet $sheet, array $items): void; |
239
|
|
|
|
240
|
|
|
abstract protected function getHeaders(): array; |
241
|
|
|
|
242
|
|
|
/** |
243
|
|
|
* Write the value and style in the cell selected by `column` and `row` variables and move to next column |
244
|
|
|
* |
245
|
|
|
* @param mixed $value |
246
|
|
|
* @param array[] ...$formats optional list of formats to be applied successively |
247
|
|
|
*/ |
248
|
2 |
|
protected function write(Worksheet $sheet, $value, array ...$formats): void |
249
|
|
|
{ |
250
|
2 |
|
$cell = $sheet->getCellByColumnAndRow($this->column++, $this->row); |
251
|
2 |
|
if ($formats) { |
|
|
|
|
252
|
2 |
|
$style = $cell->getStyle(); |
253
|
2 |
|
foreach ($formats as $format) { |
254
|
2 |
|
$style->applyFromArray($format); |
255
|
|
|
} |
256
|
|
|
} |
257
|
|
|
|
258
|
|
|
// Automatic conversion of date to Excel format |
259
|
2 |
|
if ($value instanceof DateTimeInterface) { |
260
|
|
|
$dateTime = new DateTimeImmutable($value->format('c')); |
261
|
|
|
$value = Date::PHPToExcel($dateTime); |
262
|
2 |
|
} elseif ($value instanceof Money) { |
263
|
1 |
|
$value = $this->moneyFormatter->format($value); |
264
|
|
|
} |
265
|
|
|
|
266
|
2 |
|
$cell->setValue($value); |
267
|
2 |
|
} |
268
|
|
|
|
269
|
|
|
/** |
270
|
|
|
* Called by the field resolver or repository to generate a spreadsheet from the query builder |
271
|
|
|
* |
272
|
|
|
* @return string the generated spreadsheet file path |
273
|
|
|
*/ |
274
|
1 |
|
public function generate(Query $query): string |
275
|
|
|
{ |
276
|
1 |
|
$items = $query->getResult(); |
277
|
|
|
|
278
|
1 |
|
$this->workbook->getDefaultStyle()->applyFromArray(self::$defaultFormat); |
279
|
1 |
|
$sheet = $this->workbook->getActiveSheet(); |
280
|
1 |
|
$this->row = 1; |
281
|
1 |
|
$this->column = 1; |
282
|
1 |
|
$this->writeHeaders($sheet, $items); |
283
|
1 |
|
++$this->row; |
284
|
1 |
|
$this->column = 1; |
285
|
1 |
|
$this->writeData($sheet, $items); |
286
|
1 |
|
$this->column = 1; |
287
|
1 |
|
$this->writeFooter($sheet, $items); |
288
|
|
|
|
289
|
1 |
|
$writer = new Xlsx($this->workbook); |
290
|
|
|
|
291
|
1 |
|
$tmpFile = bin2hex(random_bytes(16)); |
292
|
1 |
|
@mkdir($this->tmpDir); |
293
|
1 |
|
$writer->save($this->tmpDir . '/' . $tmpFile); |
294
|
|
|
|
295
|
1 |
|
return 'https://' . $this->hostname . '/export/' . $this->routeName . '/' . $tmpFile . '/' . $this->outputFileName; |
296
|
|
|
} |
297
|
|
|
|
298
|
|
|
/** |
299
|
|
|
* Process the GET query to download previously generated spreasheet on disk |
300
|
|
|
*/ |
301
|
2 |
|
public function handle(ServerRequestInterface $request): ResponseInterface |
302
|
|
|
{ |
303
|
|
|
// Read file from disk |
304
|
2 |
|
$tmpFile = $this->tmpDir . '/' . $request->getAttribute('key'); |
305
|
|
|
|
306
|
2 |
|
if (!file_exists($tmpFile)) { |
307
|
|
|
return new Response\EmptyResponse(404); |
308
|
|
|
} |
309
|
|
|
|
310
|
2 |
|
$size = filesize($tmpFile); |
311
|
2 |
|
$output = fopen($tmpFile, 'rb'); |
312
|
|
|
|
313
|
2 |
|
$response = new Response($output, 200, [ |
|
|
|
|
314
|
2 |
|
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', |
315
|
2 |
|
'Content-Disposition' => sprintf('attachment; filename=%s', $request->getAttribute('name')), |
316
|
2 |
|
'Access-Control-Expose-Headers' => 'Content-Disposition', |
317
|
2 |
|
'Expire' => 0, |
318
|
2 |
|
'Pragma' => 'public', |
319
|
2 |
|
'Content-Length' => $size, |
320
|
|
|
]); |
321
|
|
|
|
322
|
2 |
|
return $response; |
323
|
|
|
} |
324
|
|
|
} |
325
|
|
|
|
This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.
Consider making the comparison explicit by using
empty(..)
or! empty(...)
instead.