Impment webcron and internal scheduling methods and change settings to a key value store
This commit is contained in:
@@ -3,20 +3,27 @@
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use App\Services\CaldavService;
|
||||
use App\Models\Todo;
|
||||
use App\Models\TodoType;
|
||||
use Sabre\VObject\Component\VCalendar;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class CaldavSyncCommand extends Command
|
||||
{
|
||||
protected $signature = 'caldav:sync {--calendar= : optional calendar path}';
|
||||
protected $signature = 'caldav:sync';
|
||||
protected $description = 'Sync CalDAV VTODOs into local todos table';
|
||||
|
||||
public function handle(CaldavService $service)
|
||||
{
|
||||
$this->info('Starting CalDAV sync...');
|
||||
// only run every 5 minutes although the task is called every minute
|
||||
$cacheKey = 'caldav_sync_last_run';
|
||||
if (\Illuminate\Support\Facades\Cache::has($cacheKey)) {
|
||||
return 0;
|
||||
}
|
||||
Cache::put($cacheKey, true, 300);
|
||||
|
||||
Log::info('Running CalDAV sync');
|
||||
$this->info('Starting CalDAV sync');
|
||||
|
||||
$todos = $service->getTodos();
|
||||
|
||||
|
||||
@@ -21,12 +21,6 @@ class Kernel extends ConsoleKernel
|
||||
*/
|
||||
protected function schedule(Schedule $schedule): void
|
||||
{
|
||||
// Beispiel: CalDAV Sync jede Stunde
|
||||
$schedule->command('caldav:sync')->hourly();
|
||||
|
||||
// Alternativen:
|
||||
// $schedule->command('caldav:sync')->daily();
|
||||
// $schedule->command('caldav:sync --calendar=/calendars/me/default/')->dailyAt('02:00');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,21 +7,22 @@
|
||||
|
||||
use Inertia\Inertia;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\LineItem;
|
||||
use App\Models\Setting;
|
||||
use App\Mail\Reminder;
|
||||
use App\Support\ApiDataTransformer;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Barryvdh\DomPDF\Facade\Pdf;
|
||||
use tbQuar\Facades\Quar;
|
||||
use horstoeko\zugferdlaravel\Facades\ZugferdLaravel;
|
||||
// use horstoeko\zugferd\codelists\ZugferdInvoiceType;
|
||||
use horstoeko\zugferd\codelists\ZugferdUnitCodes;
|
||||
use horstoeko\zugferd\codelists\ZugferdVatCategoryCodes;
|
||||
use horstoeko\zugferd\codelists\ZugferdVatTypeCodes;
|
||||
use horstoeko\zugferd\codelists\ZugferdDocumentType;
|
||||
use App\Models\LineItem;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Support\ApiDataTransformer;
|
||||
use DateTime;
|
||||
use tbQuar\Facades\Quar;
|
||||
use App\Mail\Reminder;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
|
||||
class InvoiceController extends Controller
|
||||
@@ -691,19 +692,53 @@ public function remind(Request $request, int $id)
|
||||
*/
|
||||
protected function generateInvoiceNumber()
|
||||
{
|
||||
$settings = \App\Models\Setting::firstOrCreate([]);
|
||||
$lastInvoice = Invoice::orderByRaw("CAST(SUBSTRING(nr, 4) AS UNSIGNED) DESC")
|
||||
->first();
|
||||
// Key/value Einstellungen
|
||||
$format = Setting::where('key', 'invoice.number_format')->value('value') ?? 'RE-{number}';
|
||||
$start = (int) (Setting::where('key', 'invoice.number_start')->value('value') ?? 1);
|
||||
|
||||
if ($lastInvoice) {
|
||||
$lastNumber = (int) str_replace('RE-', '', $lastInvoice->nr);
|
||||
$newNumber = $lastNumber + 1;
|
||||
// prefix / suffix aus dem Format ableiten (erwartet genau ein {number})
|
||||
$parts = explode('{number}', $format);
|
||||
$prefix = $parts[0] ?? '';
|
||||
$suffix = $parts[1] ?? '';
|
||||
|
||||
// Query: nur Rechnungen mit Prefix (falls vorhanden) - reduziert Datensatz
|
||||
$query = \App\Models\Invoice::query();
|
||||
if ($prefix !== '') {
|
||||
$query->where('nr', 'like', $prefix . '%');
|
||||
}
|
||||
// optional: further restrict by suffix if known
|
||||
if ($suffix !== '') {
|
||||
$query->where('nr', 'like', '%' . $suffix);
|
||||
}
|
||||
|
||||
// Hole die Nummern (nur Spalte 'nr') — bei großen Datenmengen ggf. limit/stream verwenden
|
||||
$numbers = $query->pluck('nr')->toArray();
|
||||
|
||||
$max = 0;
|
||||
foreach ($numbers as $nr) {
|
||||
// Entferne Prefix/Suffix falls vorhanden
|
||||
if ($prefix !== '' && str_starts_with($nr, $prefix)) {
|
||||
$nrCore = substr($nr, strlen($prefix));
|
||||
} else {
|
||||
$newNumber = $settings->invoice_number_start;
|
||||
$nrCore = $nr;
|
||||
}
|
||||
if ($suffix !== '' && str_ends_with($nrCore, $suffix)) {
|
||||
$nrCore = substr($nrCore, 0, -strlen($suffix));
|
||||
}
|
||||
|
||||
return str_replace('{number}', $newNumber, $settings->invoice_number_format);
|
||||
// Extrahiere erste Ziffernfolge (handhabt auch führende Nullen)
|
||||
if (preg_match('/\d+/', $nrCore, $m)) {
|
||||
$val = intval($m[0]);
|
||||
if ($val > $max) $max = $val;
|
||||
}
|
||||
}
|
||||
|
||||
$newNumber = $max > 0 ? $max + 1 : $start;
|
||||
|
||||
// Setze neue Nummer ins Format (bei Bedarf Padding hier ergänzen)
|
||||
return str_replace('{number}', (string)$newNumber, $format);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Generate a GiroCode for the invoice
|
||||
|
||||
@@ -9,19 +9,27 @@ class SettingController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
return Setting::firstOrCreate([]);
|
||||
return response()->json(Setting::allKeyValue());
|
||||
}
|
||||
|
||||
public function update(Request $request)
|
||||
{
|
||||
$validatedData = $request->validate([
|
||||
'invoice_number_format' => 'required|string',
|
||||
'invoice_number_start' => 'required|integer',
|
||||
// Batch: {"settings": {"invoice_number_start": 2, "foo": "bar"}}
|
||||
if ($request->has('settings') && is_array($request->input('settings'))) {
|
||||
foreach ($request->input('settings') as $k => $v) {
|
||||
Setting::updateOrCreate(['key' => $k], ['value' => is_scalar($v) ? (string)$v : json_encode($v)]);
|
||||
}
|
||||
return response()->json(Setting::allKeyValue());
|
||||
}
|
||||
|
||||
// Single: {"key":"invoice_number_start","value":2}
|
||||
$validated = $request->validate([
|
||||
'key' => 'required|string',
|
||||
'value' => 'nullable',
|
||||
]);
|
||||
|
||||
$setting = Setting::firstOrCreate([]);
|
||||
$setting->update($validatedData);
|
||||
Setting::updateOrCreate(['key' => $validated['key']], ['value' => isset($validated['value']) && !is_scalar($validated['value']) ? json_encode($validated['value']) : (string)($validated['value'] ?? null)]);
|
||||
|
||||
return response()->json($setting, 200);
|
||||
return response()->json(Setting::allKeyValue());
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use App\Jobs\CheckInvoiceDueDatesJob;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class CheckInvoiceDueDatesListener
|
||||
{
|
||||
public function handle($event)
|
||||
{
|
||||
try {
|
||||
$today = now()->toDateString();
|
||||
$cacheKey = 'invoice_due_dates_checked_' . $today;
|
||||
|
||||
if (!Cache::has($cacheKey)) {
|
||||
// Cache a timestamped key, so we only check once a day
|
||||
Cache::put($cacheKey, true, now()->endOfDay());
|
||||
Log::info("Starting daily job to check for due invoices.");
|
||||
(new CheckInvoiceDueDatesJob())->handle();
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::error("Error in CheckInvoiceDueDatesListener: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use App\Models\Setting;
|
||||
|
||||
|
||||
class ScheduleListener
|
||||
{
|
||||
/**
|
||||
* Handle the event.
|
||||
*
|
||||
* This listener is attached to RouteMatched when app.schedule_method == 'internal'.
|
||||
* It will trigger the scheduler at most once per minute (throttle via cache).
|
||||
*/
|
||||
public function handle($event)
|
||||
{
|
||||
// only run when internal scheduling is enabled (defensive)
|
||||
$method = Setting::where('key', 'app.schedule_method')->value('value') ?? 'internal';
|
||||
if ($method !== 'internal') {
|
||||
return;
|
||||
}
|
||||
|
||||
// throttle key: run at most once every 55 seconds
|
||||
$cacheKey = 'caramel_scheduler_last_run';
|
||||
$ttlSeconds = 55;
|
||||
|
||||
if (Cache::has($cacheKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// mark as run
|
||||
Cache::put($cacheKey, true, $ttlSeconds);
|
||||
|
||||
try {
|
||||
Log::info('Triggering scheduler via ScheduleListener');
|
||||
Artisan::call('schedule:run');
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Error running scheduler: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
-2
@@ -9,8 +9,18 @@ class Setting extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $primaryKey = 'key';
|
||||
public $incrementing = false;
|
||||
protected $keyType = 'string';
|
||||
|
||||
protected $fillable = [
|
||||
'invoice_number_format',
|
||||
'invoice_number_start',
|
||||
'key',
|
||||
'value',
|
||||
];
|
||||
|
||||
// Hilfsmethode: alle Einstellungen als key=>value Array
|
||||
public static function allKeyValue(): array
|
||||
{
|
||||
return self::query()->pluck('value', 'key')->toArray();
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Inertia\Inertia;
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
use Illuminate\Support\Facades\Schedule;
|
||||
use App\Jobs\CheckInvoiceDueDatesJob;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
|
||||
@@ -3,14 +3,17 @@
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Schedule;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Routing\Events\RouteMatched;
|
||||
use App\Models\Setting;
|
||||
use App\Listeners\ScheduleListener;
|
||||
use App\Jobs\CheckInvoiceDueDatesJob;
|
||||
|
||||
class EventServiceProvider extends ServiceProvider
|
||||
{
|
||||
protected $listen = [
|
||||
'Illuminate\Routing\Events\RouteMatched' => [
|
||||
\App\Listeners\CheckInvoiceDueDatesListener::class,
|
||||
],
|
||||
];
|
||||
protected $listen = [];
|
||||
|
||||
/**
|
||||
* Bootstrap services.
|
||||
@@ -18,5 +21,21 @@ class EventServiceProvider extends ServiceProvider
|
||||
public function boot(): void
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
$method = Setting::where('key', 'app.schedule_method')->value('value') ?? 'internal';
|
||||
if ($method === 'internal') {
|
||||
Event::listen(RouteMatched::class, [ScheduleListener::class, 'handle']);
|
||||
}
|
||||
|
||||
// TODO: read where to put these
|
||||
// it seems to work here, but where is the apropriate place?
|
||||
// Kernel::schedule did not work
|
||||
Schedule::command('caldav:sync')
|
||||
->everyMinute()
|
||||
->withoutOverlapping();
|
||||
|
||||
Schedule::job(new CheckInvoiceDueDatesJob())
|
||||
->daily()
|
||||
->withoutOverlapping();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,22 +6,17 @@
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::dropIfExists('settings');
|
||||
|
||||
Schema::create('settings', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('invoice_number_format')->default('RE-{number}');
|
||||
$table->integer('invoice_number_start')->default(1);
|
||||
$table->string('key')->primary();
|
||||
$table->text('value')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('settings');
|
||||
|
||||
@@ -7,14 +7,10 @@
|
||||
|
||||
class SettingsTableSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Run the database seeds.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
Setting::firstOrCreate([], [
|
||||
'invoice_number_format' => 'RE-{number}',
|
||||
'invoice_number_start' => 1,
|
||||
]);
|
||||
Setting::updateOrCreate(['key' => 'invoices.number_format'], ['value' => 'RE-{number}']);
|
||||
Setting::updateOrCreate(['key' => 'invoices.number_start'], ['value' => '1']);
|
||||
Setting::updateOrCreate(['key' => 'app.schedule_method'], ['value' => '1']);
|
||||
}
|
||||
}
|
||||
+50
-2
@@ -1,8 +1,11 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Inertia\Inertia;
|
||||
use App\Http\Controllers\TodoController;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use App\Http\Controllers\InvoiceController;
|
||||
use App\Http\Controllers\CustomerController;
|
||||
use App\Http\Controllers\ProductController;
|
||||
@@ -61,6 +64,51 @@
|
||||
Route::get('proceduralDocumentation', function () {
|
||||
return Inertia::render('ProceduralDocumentation');
|
||||
})->name('proceduralDocumentation');
|
||||
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Web cron route
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Example: GET /webcron?token=SECRET or set header X-WEBCron-Token: SECRET
|
||||
| Configure secret in .env as WEBCRON_SECRET (optional). If no secret is set,
|
||||
| the route is open (not recommended in production).
|
||||
|
|
||||
*/
|
||||
Route::get('/webcron', function (Request $request) {
|
||||
// only allow if scheduling method is webcron
|
||||
$method = \App\Models\Setting::where('key', 'app.schedule_method')->value('value') ?? 'internal';
|
||||
if ($method !== 'webcron') {
|
||||
return response('Not Found', 404);
|
||||
}
|
||||
|
||||
$secret = env('WEBCRON_SECRET', null);
|
||||
|
||||
// basic token protection
|
||||
if ($secret) {
|
||||
$token = $request->query('token') ?? $request->header('X-WEBCron-Token');
|
||||
if (!$token || !hash_equals((string)$secret, (string)$token)) {
|
||||
return response('Forbidden', 403);
|
||||
}
|
||||
}
|
||||
|
||||
// quick throttle to avoid abuse (server-side)
|
||||
$cacheKey = 'caramel_webcron_last_run';
|
||||
if (\Illuminate\Support\Facades\Cache::has($cacheKey)) {
|
||||
return response('Throttled', 429);
|
||||
}
|
||||
Cache::put($cacheKey, true, 55);
|
||||
|
||||
try {
|
||||
Log::info('Triggering scheduler via /webcron route');
|
||||
Artisan::call('schedule:run');
|
||||
return response('OK', 200);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Error running scheduler: ' . $e->getMessage());
|
||||
return response('Error', 500);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user