Let a destination say how it is laid out and how long anything stays
Two destinations doing two jobs. A handover directory a NAS collects from wants a folder per day and wants emptying — thirty days is enough to notice a NAS that stopped collecting, and keeping more only hides it. An archive on the NAS itself wants a folder per year and wants nothing deleted, ever. So both are per destination rather than global. Deleting is safe here in a way it usually is not, and that is worth saying plainly: the invoice is a frozen document in the database and its PDF is rendered from that on demand. Nothing is moved anywhere — it is copied, and anything removed from a directory can be produced again and re-exported. The directory is a handover point, not the record. keep_days is null by default and stays null unless somebody sets it: a retention rule nobody asked for is a deletion nobody expected. The prune only touches folders whose NAME is one of the two shapes this application writes, and leaves everything else alone — a prune that deletes what it does not recognise is how an archive loses something nobody was watching. It also skips a destination whose path is missing rather than acting on whatever is underneath an absent mountpoint. The shape is matched with a pattern before parsing rather than by handing nonsense to Carbon: under strict mode createFromFormat throws instead of returning false, so one folder somebody made by hand would have ended the whole prune instead of being skipped. The test with a foreign folder in it found that. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>feat/granted-plans
parent
0528b56a32
commit
d8ff717cb5
|
|
@ -0,0 +1,129 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\ExportTarget;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* Empty a handover directory of folders nobody needs any more.
|
||||
*
|
||||
* Only where a destination asks for it — keep_days is null by default, because
|
||||
* a retention rule nobody configured is a deletion nobody expected.
|
||||
*
|
||||
* Safe in a way deleting usually is not: the invoice lives in the database as a
|
||||
* frozen document and its PDF is rendered from that on demand, so anything
|
||||
* removed here can be produced again and re-exported. The directory is a
|
||||
* handover point, not the record.
|
||||
*
|
||||
* Only ever removes folders whose NAME is a date it can parse and that are old
|
||||
* enough. Anything else in there — a stray file, a directory somebody made by
|
||||
* hand, a folder from a different layout — is left alone. A prune that deletes
|
||||
* what it does not recognise is how an archive loses something nobody was
|
||||
* watching.
|
||||
*/
|
||||
class PruneExportFolders extends Command
|
||||
{
|
||||
protected $signature = 'clupilot:prune-exports {--dry-run}';
|
||||
|
||||
protected $description = 'Remove dated folders older than a destination keeps them';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$targets = ExportTarget::query()
|
||||
->whereNotNull('keep_days')
|
||||
->where('keep_days', '>', 0)
|
||||
// Only where this machine can see the files. An SFTP destination is
|
||||
// somebody else's disk with somebody else's retention on it.
|
||||
->where('driver', ExportTarget::LOCAL)
|
||||
->get();
|
||||
|
||||
foreach ($targets as $target) {
|
||||
$this->prune($target);
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function prune(ExportTarget $target): void
|
||||
{
|
||||
$root = rtrim($target->path, '/');
|
||||
|
||||
if (! is_dir($root)) {
|
||||
// The mount is gone. Saying nothing and doing nothing is right:
|
||||
// this is a cleanup, and a cleanup must never be the thing that
|
||||
// reports a broken mount — nor act on whatever is underneath it.
|
||||
$this->warn("{$target->name}: path is not there, skipping ({$root})");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$cutoff = now()->subDays((int) $target->keep_days)->startOfDay();
|
||||
$removed = 0;
|
||||
|
||||
foreach ((array) scandir($root) as $entry) {
|
||||
if ($entry === '.' || $entry === '..' || ! is_dir($root.'/'.$entry)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$date = $this->dateFrom($entry);
|
||||
|
||||
// Not a dated folder: left alone, deliberately. A prune that
|
||||
// deletes what it does not recognise is how an archive loses
|
||||
// something nobody was watching.
|
||||
if ($date === null || $date->gte($cutoff)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->option('dry-run')) {
|
||||
$this->line("would remove {$root}/{$entry}");
|
||||
$removed++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->removeTree($root.'/'.$entry);
|
||||
$removed++;
|
||||
}
|
||||
|
||||
$this->line("{$target->name}: {$removed} folder(s) older than {$target->keep_days} days.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Only the exact shapes this application writes.
|
||||
*
|
||||
* Matched with a pattern BEFORE parsing, not by asking Carbon and catching
|
||||
* what it does with nonsense: under strict mode createFromFormat throws
|
||||
* rather than returning false, so a folder somebody made by hand would end
|
||||
* the whole prune instead of being skipped. The pattern also says plainly
|
||||
* which two shapes are recognised, which is the thing a reader wants to
|
||||
* know here.
|
||||
*/
|
||||
private function dateFrom(string $name): ?Carbon
|
||||
{
|
||||
if (preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $name, $m)) {
|
||||
return Carbon::createFromFormat('Y-m-d', $name)?->startOfDay();
|
||||
}
|
||||
|
||||
if (preg_match('/^\d{4}$/', $name)) {
|
||||
return Carbon::createFromFormat('Y-m-d', $name.'-12-31')?->startOfDay();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function removeTree(string $directory): void
|
||||
{
|
||||
foreach ((array) scandir($directory) as $entry) {
|
||||
if ($entry === '.' || $entry === '..') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = $directory.'/'.$entry;
|
||||
is_dir($path) ? $this->removeTree($path) : @unlink($path);
|
||||
}
|
||||
|
||||
@rmdir($directory);
|
||||
}
|
||||
}
|
||||
|
|
@ -26,6 +26,12 @@ class EditExportTarget extends ModalComponent
|
|||
|
||||
public string $path = '';
|
||||
|
||||
/** 'year' for an archive somebody keeps, 'day' for a handover point. */
|
||||
public string $layout = ExportTarget::BY_YEAR;
|
||||
|
||||
/** Empty means keep for ever, which is the default and the safe one. */
|
||||
public ?int $keepDays = null;
|
||||
|
||||
public string $host = '';
|
||||
|
||||
public ?int $port = 22;
|
||||
|
|
@ -52,6 +58,8 @@ class EditExportTarget extends ModalComponent
|
|||
$this->name = $target->name;
|
||||
$this->driver = $target->driver;
|
||||
$this->path = $target->path;
|
||||
$this->layout = $target->layout;
|
||||
$this->keepDays = $target->keep_days;
|
||||
$this->host = (string) $target->host;
|
||||
$this->port = $target->port ?: 22;
|
||||
$this->username = (string) $target->username;
|
||||
|
|
@ -67,6 +75,10 @@ class EditExportTarget extends ModalComponent
|
|||
'name' => 'required|string|max:120',
|
||||
'driver' => 'required|in:local,sftp',
|
||||
'path' => 'required|string|max:255',
|
||||
'layout' => 'required|in:year,day',
|
||||
// Nothing, or a real number of days. Zero would read as "delete
|
||||
// immediately" to somebody who typed it meaning "never".
|
||||
'keepDays' => 'nullable|integer|min:1|max:3650',
|
||||
'active' => 'boolean',
|
||||
];
|
||||
|
||||
|
|
@ -93,6 +105,8 @@ class EditExportTarget extends ModalComponent
|
|||
'name' => $data['name'],
|
||||
'driver' => $data['driver'],
|
||||
'path' => rtrim($data['path'], '/') ?: '/',
|
||||
'layout' => $data['layout'],
|
||||
'keep_days' => $data['keepDays'] ?: null,
|
||||
'host' => $this->driver === ExportTarget::SFTP ? $this->host : null,
|
||||
'port' => $this->driver === ExportTarget::SFTP ? $this->port : null,
|
||||
'username' => $this->driver === ExportTarget::SFTP ? $this->username : null,
|
||||
|
|
|
|||
|
|
@ -22,13 +22,19 @@ class ExportTarget extends Model
|
|||
|
||||
public const SFTP = 'sftp';
|
||||
|
||||
/** One folder per year — an archive somebody keeps. */
|
||||
public const BY_YEAR = 'year';
|
||||
|
||||
/** One folder per day — a handover point somebody collects from. */
|
||||
public const BY_DAY = 'day';
|
||||
|
||||
protected $fillable = [
|
||||
'name', 'driver', 'path', 'host', 'port', 'username', 'secret_key', 'active',
|
||||
'name', 'driver', 'path', 'layout', 'keep_days', 'host', 'port', 'username', 'secret_key', 'active',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['active' => 'boolean', 'port' => 'integer'];
|
||||
return ['active' => 'boolean', 'port' => 'integer', 'keep_days' => 'integer'];
|
||||
}
|
||||
|
||||
public function exports(): HasMany
|
||||
|
|
@ -36,6 +42,16 @@ class ExportTarget extends Model
|
|||
return $this->hasMany(InvoiceExport::class);
|
||||
}
|
||||
|
||||
/** The folder an invoice issued on this date belongs in. */
|
||||
public function folderFor(?\Illuminate\Support\Carbon $issuedOn): string
|
||||
{
|
||||
$date = $issuedOn ?? now();
|
||||
|
||||
return $this->layout === self::BY_DAY
|
||||
? $date->format('Y-m-d')
|
||||
: $date->format('Y');
|
||||
}
|
||||
|
||||
/** Human-readable, for a list and for an error message. */
|
||||
public function describe(): string
|
||||
{
|
||||
|
|
|
|||
|
|
@ -51,10 +51,11 @@ final class InvoiceArchive
|
|||
|
||||
$disk = $this->disk($target);
|
||||
|
||||
// Grouped by year, because seven years of invoices in one directory is
|
||||
// a directory nobody opens twice.
|
||||
$year = $invoice->issued_on?->format('Y') ?? 'unbekannt';
|
||||
$relative = $year.'/'.$invoice->number.'.pdf';
|
||||
// Per year for an archive somebody keeps, per day for a handover point
|
||||
// somebody collects from — the destination says which, because the two
|
||||
// do different jobs. Either way it is grouped: seven years of invoices
|
||||
// in one directory is a directory nobody opens twice.
|
||||
$relative = $target->folderFor($invoice->issued_on).'/'.$invoice->number.'.pdf';
|
||||
|
||||
$pdf = $this->renderer->forInvoice($invoice);
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* How a destination is laid out, and how long anything stays there.
|
||||
*
|
||||
* Both per destination, because the two destinations do different jobs. A
|
||||
* handover directory that a NAS collects from wants a folder per day and wants
|
||||
* emptying — thirty days of folders is enough to notice a NAS that stopped
|
||||
* collecting, and keeping more only hides it. The archive on the NAS itself
|
||||
* wants a folder per year and wants nothing deleted, ever.
|
||||
*
|
||||
* Deleting is safe here in a way it usually is not: the invoice lives in the
|
||||
* database as a frozen document and the PDF is rendered from it on demand, so
|
||||
* anything removed from a directory can be produced again. The directory is a
|
||||
* handover point, not the record.
|
||||
*
|
||||
* keep_days NULL means keep for ever, and that is the default: a retention rule
|
||||
* nobody asked for is a deletion nobody expected.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('export_targets', function (Blueprint $table) {
|
||||
$table->string('layout', 8)->default('year')->after('path');
|
||||
$table->unsignedSmallInteger('keep_days')->nullable()->after('layout');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('export_targets', function (Blueprint $table) {
|
||||
$table->dropColumn(['layout', 'keep_days']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -72,6 +72,12 @@ return [
|
|||
'path' => 'Pfad',
|
||||
'path_local_hint' => 'Absoluter Pfad. Der Einhängepunkt selbst wird nie angelegt — fehlt er, gilt der Mount als weg und es wird nichts geschrieben.',
|
||||
'path_sftp_hint' => 'Verzeichnis auf der Gegenstelle.',
|
||||
'layout' => 'Ordnerstruktur',
|
||||
'layout_year' => 'Ein Ordner je Jahr',
|
||||
'layout_day' => 'Ein Ordner je Tag',
|
||||
'layout_hint' => 'Jahr für ein Archiv, das bleibt. Tag für einen Übergabeort, den jemand abholt.',
|
||||
'keep_days' => 'Aufbewahrung (Tage)',
|
||||
'keep_days_hint' => 'Leer heißt: nichts wird gelöscht. Ein Wert löscht Ordner, die älter sind — ungefährlich, weil jede Rechnung aus der Datenbank neu erzeugt werden kann.',
|
||||
'host' => 'Host',
|
||||
'port' => 'Port',
|
||||
'username' => 'Benutzer',
|
||||
|
|
|
|||
|
|
@ -72,6 +72,12 @@ return [
|
|||
'path' => 'Path',
|
||||
'path_local_hint' => 'Absolute path. The mount point itself is never created — if it is missing, the mount counts as gone and nothing is written.',
|
||||
'path_sftp_hint' => 'Directory on the far side.',
|
||||
'layout' => 'Folder layout',
|
||||
'layout_year' => 'One folder per year',
|
||||
'layout_day' => 'One folder per day',
|
||||
'layout_hint' => 'Year for an archive that stays. Day for a handover point somebody collects from.',
|
||||
'keep_days' => 'Keep for (days)',
|
||||
'keep_days_hint' => 'Empty means nothing is deleted. A value removes folders older than that — safe, because every invoice can be produced again from the database.',
|
||||
'host' => 'Host',
|
||||
'port' => 'Port',
|
||||
'username' => 'Username',
|
||||
|
|
|
|||
|
|
@ -18,6 +18,20 @@
|
|||
:hint="$driver === 'local' ? __('finance.target.path_local_hint') : __('finance.target.path_sftp_hint')"
|
||||
:placeholder="$driver === 'local' ? '/mnt/rechnungen' : '/rechnungen'" />
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="text-sm font-medium text-body" for="layout">{{ __('finance.target.layout') }}</label>
|
||||
<select id="layout" wire:model="layout"
|
||||
class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-body">
|
||||
<option value="year">{{ __('finance.target.layout_year') }}</option>
|
||||
<option value="day">{{ __('finance.target.layout_day') }}</option>
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-muted">{{ __('finance.target.layout_hint') }}</p>
|
||||
</div>
|
||||
<x-ui.input name="keepDays" wire:model="keepDays" type="number"
|
||||
:label="__('finance.target.keep_days')" :hint="__('finance.target.keep_days_hint')" placeholder="30" />
|
||||
</div>
|
||||
|
||||
@if ($driver === 'sftp')
|
||||
<div class="grid gap-4 sm:grid-cols-3">
|
||||
<div class="sm:col-span-2"><x-ui.input name="host" wire:model="host" :label="__('finance.target.host')" placeholder="backup.example.com" /></div>
|
||||
|
|
|
|||
|
|
@ -56,3 +56,10 @@ Schedule::call(fn () => \App\Models\StripePendingEvent::query()
|
|||
Schedule::command('clupilot:archive-invoices')
|
||||
->hourly()
|
||||
->withoutOverlapping();
|
||||
|
||||
// Empty a handover directory of folders past their keep-by. Only where a
|
||||
// destination asks for it, and only folders whose name is a date this
|
||||
// application wrote — see the command for why it leaves everything else alone.
|
||||
Schedule::command('clupilot:prune-exports')
|
||||
->dailyAt('03:20')
|
||||
->withoutOverlapping();
|
||||
|
|
|
|||
|
|
@ -157,3 +157,62 @@ it('sweeps up whatever the queue gave up on', function () {
|
|||
|
||||
Queue::assertPushed(ArchiveInvoice::class, 2);
|
||||
});
|
||||
|
||||
it('puts a handover destination into a folder per day', function () {
|
||||
// A NAS that collects wants today's folder. A folder per year is for an
|
||||
// archive somebody keeps, and the two do different jobs.
|
||||
$this->target->update(['layout' => ExportTarget::BY_DAY]);
|
||||
$invoice = anInvoice();
|
||||
|
||||
(new ArchiveInvoice($invoice, $this->target->refresh()))->handle(app(InvoiceArchive::class));
|
||||
|
||||
expect(is_file($this->archive.'/'.now()->format('Y-m-d').'/'.$invoice->number.'.pdf'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('removes handover folders past their keep-by, and only those', function () {
|
||||
// Safe in a way deleting usually is not: the invoice is a frozen document
|
||||
// in the database and its PDF is rendered on demand, so anything removed
|
||||
// here can be produced again.
|
||||
$this->target->update(['layout' => ExportTarget::BY_DAY, 'keep_days' => 30]);
|
||||
|
||||
$old = $this->archive.'/'.now()->subDays(40)->format('Y-m-d');
|
||||
$recent = $this->archive.'/'.now()->subDays(3)->format('Y-m-d');
|
||||
$foreign = $this->archive.'/nicht-von-uns';
|
||||
|
||||
foreach ([$old, $recent, $foreign] as $dir) {
|
||||
mkdir($dir, 0o775, true);
|
||||
file_put_contents($dir.'/RE-2026-0001.pdf', 'x');
|
||||
}
|
||||
|
||||
$this->artisan('clupilot:prune-exports')->assertExitCode(0);
|
||||
|
||||
expect(is_dir($old))->toBeFalse()
|
||||
->and(is_dir($recent))->toBeTrue()
|
||||
// A prune that deletes what it does not recognise is how an archive
|
||||
// loses something nobody was watching.
|
||||
->and(is_dir($foreign))->toBeTrue();
|
||||
});
|
||||
|
||||
it('deletes nothing at all where no retention is configured', function () {
|
||||
// The default, and deliberately so: a retention rule nobody asked for is a
|
||||
// deletion nobody expected.
|
||||
$this->target->update(['layout' => ExportTarget::BY_DAY, 'keep_days' => null]);
|
||||
|
||||
$old = $this->archive.'/'.now()->subYears(3)->format('Y-m-d');
|
||||
mkdir($old, 0o775, true);
|
||||
|
||||
$this->artisan('clupilot:prune-exports')->assertExitCode(0);
|
||||
|
||||
expect(is_dir($old))->toBeTrue();
|
||||
});
|
||||
|
||||
it('does not prune a destination whose mount has gone away', function () {
|
||||
// A cleanup must never be the thing that acts on whatever is underneath an
|
||||
// absent mountpoint.
|
||||
$gone = sys_get_temp_dir().'/clupilot-absent-'.bin2hex(random_bytes(4));
|
||||
$this->target->update(['path' => $gone, 'layout' => ExportTarget::BY_DAY, 'keep_days' => 1]);
|
||||
|
||||
$this->artisan('clupilot:prune-exports')->assertExitCode(0);
|
||||
|
||||
expect(is_dir($gone))->toBeFalse();
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue