351 lines · 14.3 KB
Raw Download
1
<?php
2
// CSV export/import and the email report. Depends only on db.php so cron.php
3
// can run it without starting a session.
4
require_once __DIR__ . '/db.php';
5
6
function setting_get(string $key, ?string $default = null): ?string
7
{
8
    $st = db()->prepare('SELECT value FROM settings WHERE key = ?');
9
    $st->execute([$key]);
10
    $v = $st->fetchColumn();
11
    return $v === false ? $default : $v;
12
}
13
14
function setting_set(string $key, string $value): void
15
{
16
    db()->prepare('INSERT INTO settings (key, value) VALUES (?, ?)
17
                   ON CONFLICT(key) DO UPDATE SET value = excluded.value')->execute([$key, $value]);
18
}
19
20
// Secret for cron.php, created on first use (i.e. first visit to report.php).
21
function cron_token(): string
22
{
23
    $t = setting_get('cron_token');
24
    if ($t === null || $t === '') {
25
        $t = bin2hex(random_bytes(16));
26
        setting_set('cron_token', $t);
27
    }
28
    return $t;
29
}
30
31
// Columns that round-trip through CSV export/import, in export order.
32
function csv_columns(): array
33
{
34
    return ['id', 'type', 'color_name', 'brand', 'color_hex', 'color_hex2', 'total_weight_g', 'spool_weight_g',
35
            'original_g', 'remaining_g', 'abrasive', 'highflow', 'silk', 'matte', 'rainbow', 'transparent', 'twotone',
36
            'sparkle', 'cost', 'notes', 'last_used_at', 'created_at', 'updated_at'];
37
}
38
39
// The whole inventory as a CSV string (with UTF-8 BOM so Excel decodes it).
40
function inventory_csv(): string
41
{
42
    $cols = csv_columns();
43
    $rows = db()->query(
44
        'SELECT ' . implode(', ', $cols) . ' FROM filaments
45
         ORDER BY brand COLLATE NOCASE, type COLLATE NOCASE, id'
46
    )->fetchAll();
47
    $fh = fopen('php://temp', 'r+');
48
    fputcsv($fh, $cols, ',', '"', '');
49
    foreach ($rows as $r) {
50
        fputcsv($fh, array_values($r), ',', '"', '');
51
    }
52
    rewind($fh);
53
    $csv = stream_get_contents($fh);
54
    fclose($fh);
55
    return "\xEF\xBB\xBF" . $csv;
56
}
57
58
// Header cell -> canonical column name; '' for empty/unknown headers.
59
function csv_canon_header(string $h): string
60
{
61
    // First cell of a file may carry the UTF-8 BOM.
62
    $h = str_replace("\xEF\xBB\xBF", '', $h);
63
    $h = trim(preg_replace('/[^a-z0-9]+/', '_', strtolower($h)), '_');
64
    $alias = [
65
        'material' => 'type', 'filament_type' => 'type',
66
        'manufacturer' => 'brand', 'maker' => 'brand', 'vendor' => 'brand',
67
        'colour_name' => 'color_name', 'name' => 'color_name',
68
        'hex' => 'color_hex', 'colour_hex' => 'color_hex', 'color' => 'color_hex', 'colour' => 'color_hex',
69
        'total_weight' => 'total_weight_g', 'total_g' => 'total_weight_g', 'gross_g' => 'total_weight_g',
70
        'spool_weight' => 'spool_weight_g', 'empty_spool_g' => 'spool_weight_g', 'tare' => 'spool_weight_g', 'tare_g' => 'spool_weight_g',
71
        'original' => 'original_g', 'nominal_g' => 'original_g', 'label_weight_g' => 'original_g',
72
        'remaining' => 'remaining_g', 'left_g' => 'remaining_g', 'remaining_weight_g' => 'remaining_g',
73
        'price' => 'cost', 'note' => 'notes', 'comment' => 'notes',
74
        'clear' => 'transparent',
75
        'hex2' => 'color_hex2', 'color2' => 'color_hex2', 'colour_hex2' => 'color_hex2', 'second_color' => 'color_hex2',
76
        'two_tone' => 'twotone', 'dual_color' => 'twotone', 'color_shifting' => 'twotone',
77
        'glitter' => 'sparkle', 'galaxy' => 'sparkle', 'sparkly' => 'sparkle',
78
    ];
79
    $h = $alias[$h] ?? $h;
80
    return in_array($h, csv_columns(), true) ? $h : '';
81
}
82
83
// "1.234,5" / "1,5" / "812.4 g" -> float; ''/garbage -> null. Whichever of
84
// . and , comes last is taken as the decimal separator.
85
function csv_num(?string $s): ?float
86
{
87
    $s = preg_replace('/[^0-9,.\-]/', '', trim((string)$s)); // strip units, currency, spaces
88
    if ($s === '') {
89
        return null;
90
    }
91
    $lastComma = strrpos($s, ',');
92
    if ($lastComma !== false && $lastComma > (int)strrpos($s, '.')) {
93
        $s = str_replace(',', '.', str_replace('.', '', $s));
94
    } else {
95
        $s = str_replace(',', '', $s);
96
    }
97
    return is_numeric($s) ? (float)$s : null;
98
}
99
100
function csv_flag(?string $s): int
101
{
102
    return in_array(strtolower(trim((string)$s)), ['1', 'true', 'yes', 'y', 'x'], true) ? 1 : 0;
103
}
104
105
// Import a CSV file. Rows with an id matching an existing spool are updated
106
// (only the columns present in the file) when $updateExisting is on;
107
// everything else is inserted. Returns [inserted, updated, skipped] or throws
108
// with a user-facing message.
109
function import_csv(string $path, bool $updateExisting): array
110
{
111
    $fh = fopen($path, 'r');
112
    if (!$fh) {
113
        throw new RuntimeException('Could not read the uploaded file.');
114
    }
115
    // Excel in some locales writes semicolon-separated "CSV".
116
    $firstLine = (string)fgets($fh);
117
    $delim = substr_count($firstLine, ';') > substr_count($firstLine, ',') ? ';' : ',';
118
    rewind($fh);
119
120
    $head = fgetcsv($fh, 0, $delim, '"', '');
121
    if (!$head) {
122
        fclose($fh);
123
        throw new RuntimeException('The file is empty.');
124
    }
125
    $map = array_map('csv_canon_header', array_map('strval', $head));
126
    if (!in_array('type', $map, true)) {
127
        fclose($fh);
128
        throw new RuntimeException('No "type" column found — the first row must be a header row (as produced by the CSV export).');
129
    }
130
131
    $pdo = db();
132
    $inserted = $updated = $skipped = 0;
133
    backup_db();
134
    $pdo->beginTransaction();
135
    try {
136
        while (($line = fgetcsv($fh, 0, $delim, '"', '')) !== false) {
137
            $row = [];
138
            foreach ($map as $i => $col) {
139
                if ($col !== '' && isset($line[$i])) {
140
                    $row[$col] = trim((string)$line[$i]);
141
                }
142
            }
143
            if ($row === [] || implode('', $row) === '') {
144
                continue; // blank line
145
            }
146
147
            $type = (string)($row['type'] ?? '');
148
            if ($type === '') {
149
                $skipped++;
150
                continue;
151
            }
152
            $sw    = csv_num($row['spool_weight_g'] ?? null) ?? 0.0;
153
            $orig  = csv_num($row['original_g'] ?? null);
154
            $orig  = ($orig !== null && $orig > 0) ? $orig : 1000.0;
155
            $total = csv_num($row['total_weight_g'] ?? null);
156
            $rem   = csv_num($row['remaining_g'] ?? null);
157
            // Weights that files from other tools may not carry are derived:
158
            // total from what's on the spool, remaining from total - tare.
159
            $total ??= ($rem ?? $orig) + $sw;
160
            $rem ??= $total - $sw;
161
            $rem = max(0.0, $rem);
162
            if ($total <= 0 || $sw < 0 || $total <= $sw) {
163
                $skipped++;
164
                continue;
165
            }
166
167
            $color = strtoupper((string)($row['color_hex'] ?? ''));
168
            if ($color !== '' && preg_match('/^[0-9A-F]{6}$/', $color)) {
169
                $color = '#' . $color; // allow hex without the #
170
            }
171
            if (!preg_match('/^#[0-9A-Fa-f]{6}$/', $color)) {
172
                $color = '#888888';
173
            }
174
            $color2 = strtoupper((string)($row['color_hex2'] ?? ''));
175
            if ($color2 !== '' && preg_match('/^[0-9A-F]{6}$/', $color2)) {
176
                $color2 = '#' . $color2;
177
            }
178
            if (!preg_match('/^#[0-9A-Fa-f]{6}$/', $color2)) {
179
                $color2 = null; // the second color is optional
180
            }
181
182
            $vals = [
183
                'type' => $type,
184
                'brand' => (string)($row['brand'] ?? ''),
185
                'color_name' => (string)($row['color_name'] ?? ''),
186
                'color_hex' => $color,
187
                'color_hex2' => $color2,
188
                'total_weight_g' => $total,
189
                'spool_weight_g' => $sw,
190
                'original_g' => $orig,
191
                'remaining_g' => $rem,
192
                'abrasive' => csv_flag($row['abrasive'] ?? null),
193
                'highflow' => csv_flag($row['highflow'] ?? null),
194
                'silk' => csv_flag($row['silk'] ?? null),
195
                'matte' => csv_flag($row['matte'] ?? null),
196
                'rainbow' => csv_flag($row['rainbow'] ?? null),
197
                'transparent' => csv_flag($row['transparent'] ?? null),
198
                'twotone' => csv_flag($row['twotone'] ?? null),
199
                'sparkle' => csv_flag($row['sparkle'] ?? null),
200
                'cost' => csv_num($row['cost'] ?? null),
201
                'notes' => (string)($row['notes'] ?? ''),
202
                'last_used_at' => ($row['last_used_at'] ?? '') !== '' ? $row['last_used_at'] : null,
203
            ];
204
205
            $id = (int)($row['id'] ?? 0);
206
            $exists = false;
207
            if ($updateExisting && $id > 0) {
208
                $st = $pdo->prepare('SELECT 1 FROM filaments WHERE id = ?');
209
                $st->execute([$id]);
210
                $exists = (bool)$st->fetchColumn();
211
            }
212
            if ($exists) {
213
                // Only touch columns the file actually has values for, so a
214
                // trimmed-down CSV (say id/remaining_g) doesn't blank the rest
215
                // or overwrite real weights with derived ones.
216
                $set = [];
217
                $params = [];
218
                foreach ($vals as $col => $v) {
219
                    if (($row[$col] ?? '') !== '') {
220
                        $set[] = "$col=?";
221
                        $params[] = $v;
222
                    }
223
                }
224
                if (!$set) {
225
                    $skipped++;
226
                    continue;
227
                }
228
                $params[] = $id;
229
                $pdo->prepare('UPDATE filaments SET ' . implode(', ', $set) . ", updated_at=datetime('now') WHERE id=?")
230
                    ->execute($params);
231
                $updated++;
232
            } else {
233
                $cols = array_keys($vals);
234
                if (($row['created_at'] ?? '') !== '') {
235
                    $vals['created_at'] = $row['created_at'];
236
                    $cols[] = 'created_at';
237
                }
238
                $pdo->prepare(
239
                    'INSERT INTO filaments (' . implode(', ', $cols) . ') VALUES (' . implode(',', array_fill(0, count($cols), '?')) . ')'
240
                )->execute(array_values($vals));
241
                $inserted++;
242
            }
243
        }
244
        $pdo->commit();
245
    } catch (Throwable $ex) {
246
        $pdo->rollBack();
247
        fclose($fh);
248
        throw new RuntimeException('Import failed, nothing was changed: ' . $ex->getMessage());
249
    }
250
    fclose($fh);
251
    return [$inserted, $updated, $skipped];
252
}
253
254
// Plain-text inventory summary for the email body. Low spools first so the
255
// morning glance answers "do I need to order filament?".
256
function report_text(): string
257
{
258
    $rows = db()->query('SELECT * FROM filaments')->fetchAll();
259
    $pct = function (array $r): int {
260
        $orig = (float)($r['original_g'] ?? 0);
261
        if ($orig <= 0) {
262
            $orig = 1000.0;
263
        }
264
        return max(0, min(100, (int)round(100 * (float)$r['remaining_g'] / $orig)));
265
    };
266
    usort($rows, fn($a, $b) => $pct($a) <=> $pct($b));
267
268
    $line = function (array $r) use ($pct): string {
269
        $label = trim(($r['brand'] ?: '') . ' ' . $r['type']);
270
        if (!empty($r['color_name'])) {
271
            $label .= ' · ' . $r['color_name'];
272
        }
273
        return sprintf('  %-44s %5d g left (%d%%)', $label, round((float)$r['remaining_g']), $pct($r));
274
    };
275
276
    $totalG = array_sum(array_map(fn($r) => (float)$r['remaining_g'], $rows));
277
    $low = array_filter($rows, fn($r) => $pct($r) < 15);
278
279
    $out = config()['site_name'] . ' — report for ' . date('D, M j, Y') . "\n\n";
280
    $out .= count($rows) . ' spool' . (count($rows) === 1 ? '' : 's') . ' · '
281
          . number_format($totalG / 1000, 2) . " kg filament remaining\n";
282
    if ($low) {
283
        $out .= "\nRunning low (under 15% left):\n" . implode("\n", array_map($line, $low)) . "\n";
284
    }
285
    $out .= "\nAll spools:\n" . ($rows ? implode("\n", array_map($line, $rows)) : "  (inventory is empty)") . "\n";
286
    $out .= "\nThe full inventory is attached as CSV.\n";
287
    return $out;
288
}
289
290
// Email the report with the CSV attached. Uses PHP's mail(); $err carries a
291
// hint when sending fails.
292
function send_report_email(string $to, ?string &$err = null): bool
293
{
294
    if (!filter_var($to, FILTER_VALIDATE_EMAIL)) {
295
        $err = 'Invalid email address.';
296
        return false;
297
    }
298
    $from = (string)(config()['mail_from'] ?? '');
299
    if ($from === '') {
300
        $host = strtok((string)($_SERVER['HTTP_HOST'] ?? 'localhost'), ':');
301
        $from = 'filament-inventory@' . ($host ?: 'localhost');
302
    }
303
    $fname = 'filaments-' . date('Y-m-d') . '.csv';
304
    $boundary = 'bnd_' . bin2hex(random_bytes(12));
305
    $body = "--$boundary\r\n"
306
          . "Content-Type: text/plain; charset=UTF-8\r\n"
307
          . "Content-Transfer-Encoding: base64\r\n\r\n"
308
          . chunk_split(base64_encode(report_text()))
309
          . "--$boundary\r\n"
310
          . "Content-Type: text/csv; charset=UTF-8; name=\"$fname\"\r\n"
311
          . "Content-Disposition: attachment; filename=\"$fname\"\r\n"
312
          . "Content-Transfer-Encoding: base64\r\n\r\n"
313
          . chunk_split(base64_encode(inventory_csv()))
314
          . "--$boundary--\r\n";
315
    $headers = "From: $from\r\n"
316
             . "MIME-Version: 1.0\r\n"
317
             . "Content-Type: multipart/mixed; boundary=\"$boundary\"";
318
    $subject = config()['site_name'] . ' report — ' . date('M j, Y');
319
320
    error_clear_last();
321
    $ok = @mail($to, $subject, $body, $headers);
322
    if (!$ok) {
323
        // With html_errors on, PHP pre-escapes the message (&quot; etc.).
324
        $err = error_get_last()['message'] ?? '';
325
        $err = $err !== '' ? html_entity_decode($err, ENT_QUOTES)
326
                           : 'mail() returned false — is the server set up to send email?';
327
    }
328
    return $ok;
329
}
330
331
// Send the daily report if one is enabled, due (past the chosen hour, server
332
// time), and not yet attempted today. Called on inventory views and by
333
// cron.php, so the report goes out even without a cron job — just later,
334
// on the first visit of the day.
335
function maybe_send_daily_report(): void
336
{
337
    $to = (string)setting_get('report_email', '');
338
    if ($to === '' || setting_get('report_daily') !== '1') {
339
        return;
340
    }
341
    $today = date('Y-m-d');
342
    if (setting_get('report_last_sent') === $today || (int)date('G') < (int)setting_get('report_hour', '7')) {
343
        return;
344
    }
345
    // Mark the attempt first so a slow or crashing mail() can't fire on every
346
    // page view; a failed day is visible on report.php and can be re-sent.
347
    setting_set('report_last_sent', $today);
348
    $ok = send_report_email($to, $err);
349
    setting_set('report_last_status', ($ok ? "Sent to $to" : 'FAILED: ' . $err) . ' — ' . date('Y-m-d H:i'));
350
}
351