Initial Kaffeekasse SaaS restart
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use PDO;
|
||||
|
||||
final class AuditService
|
||||
{
|
||||
public function __construct(private readonly PDO $pdo)
|
||||
{
|
||||
}
|
||||
|
||||
public function log(
|
||||
?int $tenantId,
|
||||
?int $actorUserId,
|
||||
string $action,
|
||||
string $targetType,
|
||||
?int $targetId,
|
||||
string $result,
|
||||
array $meta = []
|
||||
): void {
|
||||
$statement = $this->pdo->prepare(
|
||||
'INSERT INTO audit_events (
|
||||
tenant_id,
|
||||
actor_user_id,
|
||||
action,
|
||||
target_type,
|
||||
target_id,
|
||||
result,
|
||||
request_id,
|
||||
ip_hash,
|
||||
user_agent_hash,
|
||||
meta_json
|
||||
) VALUES (
|
||||
:tenant_id,
|
||||
:actor_user_id,
|
||||
:action,
|
||||
:target_type,
|
||||
:target_id,
|
||||
:result,
|
||||
:request_id,
|
||||
:ip_hash,
|
||||
:user_agent_hash,
|
||||
:meta_json
|
||||
)'
|
||||
);
|
||||
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
|
||||
$userAgent = $_SERVER['HTTP_USER_AGENT'] ?? '';
|
||||
$requestId = $_SERVER['HTTP_X_REQUEST_ID'] ?? bin2hex(random_bytes(18));
|
||||
|
||||
$statement->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'actor_user_id' => $actorUserId,
|
||||
'action' => $action,
|
||||
'target_type' => $targetType,
|
||||
'target_id' => $targetId,
|
||||
'result' => $result,
|
||||
'request_id' => substr($requestId, 0, 36),
|
||||
'ip_hash' => $ip !== '' ? hash('sha256', $ip) : null,
|
||||
'user_agent_hash' => $userAgent !== '' ? hash('sha256', $userAgent) : null,
|
||||
'meta_json' => $meta !== [] ? json_encode($meta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use PDO;
|
||||
use RuntimeException;
|
||||
|
||||
final class LedgerService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PDO $pdo,
|
||||
private readonly AuditService $audit
|
||||
) {
|
||||
}
|
||||
|
||||
public function createConsumption(
|
||||
int $tenantId,
|
||||
int $memberId,
|
||||
int $productId,
|
||||
string $sourceCode,
|
||||
string $effectiveAt,
|
||||
int $recordedByUserId,
|
||||
float $quantity,
|
||||
?string $notes = null,
|
||||
?int $paperSheetLineId = null,
|
||||
?string $sourceReference = null
|
||||
): void {
|
||||
if ($quantity <= 0) {
|
||||
throw new RuntimeException('Die Menge muss groesser als 0 sein.');
|
||||
}
|
||||
|
||||
$source = $this->findSource($tenantId, $sourceCode);
|
||||
$priceCents = $this->resolvePriceCents($tenantId, $productId, $effectiveAt);
|
||||
$totalCents = (int) round($priceCents * $quantity);
|
||||
$description = sprintf('Verbrauch %s x #%d', rtrim(rtrim(number_format($quantity, 2, '.', ''), '0'), '.'), $productId);
|
||||
$recordedAt = gmdate('Y-m-d H:i:s');
|
||||
|
||||
$this->pdo->beginTransaction();
|
||||
|
||||
try {
|
||||
$eventInsert = $this->pdo->prepare(
|
||||
'INSERT INTO consumption_events (
|
||||
tenant_id,
|
||||
member_id,
|
||||
product_id,
|
||||
source_id,
|
||||
paper_sheet_line_id,
|
||||
quantity,
|
||||
unit_price_cents,
|
||||
total_cents,
|
||||
effective_at,
|
||||
recorded_at,
|
||||
recorded_by_user_id,
|
||||
source_reference,
|
||||
notes
|
||||
) VALUES (
|
||||
:tenant_id,
|
||||
:member_id,
|
||||
:product_id,
|
||||
:source_id,
|
||||
:paper_sheet_line_id,
|
||||
:quantity,
|
||||
:unit_price_cents,
|
||||
:total_cents,
|
||||
:effective_at,
|
||||
:recorded_at,
|
||||
:recorded_by_user_id,
|
||||
:source_reference,
|
||||
:notes
|
||||
)'
|
||||
);
|
||||
$eventInsert->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'member_id' => $memberId,
|
||||
'product_id' => $productId,
|
||||
'source_id' => $source['id'],
|
||||
'paper_sheet_line_id' => $paperSheetLineId,
|
||||
'quantity' => $quantity,
|
||||
'unit_price_cents' => $priceCents,
|
||||
'total_cents' => $totalCents,
|
||||
'effective_at' => $effectiveAt,
|
||||
'recorded_at' => $recordedAt,
|
||||
'recorded_by_user_id' => $recordedByUserId,
|
||||
'source_reference' => $sourceReference,
|
||||
'notes' => $notes,
|
||||
]);
|
||||
$eventId = (int) $this->pdo->lastInsertId();
|
||||
|
||||
$entryInsert = $this->pdo->prepare(
|
||||
'INSERT INTO ledger_entries (
|
||||
tenant_id,
|
||||
member_id,
|
||||
source_id,
|
||||
type,
|
||||
reference_type,
|
||||
reference_id,
|
||||
description,
|
||||
occurred_at,
|
||||
created_by_user_id
|
||||
) VALUES (
|
||||
:tenant_id,
|
||||
:member_id,
|
||||
:source_id,
|
||||
"consumption",
|
||||
"consumption_event",
|
||||
:reference_id,
|
||||
:description,
|
||||
:occurred_at,
|
||||
:created_by_user_id
|
||||
)'
|
||||
);
|
||||
$entryInsert->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'member_id' => $memberId,
|
||||
'source_id' => $source['id'],
|
||||
'reference_id' => $eventId,
|
||||
'description' => $description,
|
||||
'occurred_at' => $effectiveAt,
|
||||
'created_by_user_id' => $recordedByUserId,
|
||||
]);
|
||||
$entryId = (int) $this->pdo->lastInsertId();
|
||||
|
||||
$lineInsert = $this->pdo->prepare(
|
||||
'INSERT INTO ledger_lines (
|
||||
ledger_entry_id,
|
||||
tenant_id,
|
||||
member_id,
|
||||
account_code,
|
||||
amount_cents
|
||||
) VALUES (
|
||||
:ledger_entry_id,
|
||||
:tenant_id,
|
||||
:member_id,
|
||||
:account_code,
|
||||
:amount_cents
|
||||
)'
|
||||
);
|
||||
|
||||
$lineInsert->execute([
|
||||
'ledger_entry_id' => $entryId,
|
||||
'tenant_id' => $tenantId,
|
||||
'member_id' => $memberId,
|
||||
'account_code' => 'member_balance',
|
||||
'amount_cents' => $totalCents,
|
||||
]);
|
||||
|
||||
$lineInsert->execute([
|
||||
'ledger_entry_id' => $entryId,
|
||||
'tenant_id' => $tenantId,
|
||||
'member_id' => null,
|
||||
'account_code' => 'coffee_revenue',
|
||||
'amount_cents' => -$totalCents,
|
||||
]);
|
||||
|
||||
$this->pdo->commit();
|
||||
} catch (\Throwable $throwable) {
|
||||
$this->pdo->rollBack();
|
||||
throw $throwable;
|
||||
}
|
||||
|
||||
$this->audit->log($tenantId, $recordedByUserId, 'consumption.created', 'consumption_event', $eventId, 'success', [
|
||||
'member_id' => $memberId,
|
||||
'product_id' => $productId,
|
||||
'quantity' => $quantity,
|
||||
'source' => $sourceCode,
|
||||
'total_cents' => $totalCents,
|
||||
]);
|
||||
}
|
||||
|
||||
public function createPayment(
|
||||
int $tenantId,
|
||||
int $memberId,
|
||||
int $amountCents,
|
||||
string $occurredAt,
|
||||
int $recordedByUserId,
|
||||
?string $notes = null
|
||||
): void {
|
||||
if ($amountCents <= 0) {
|
||||
throw new RuntimeException('Der Einzahlungsbetrag muss groesser als 0 sein.');
|
||||
}
|
||||
|
||||
$source = $this->findSource($tenantId, 'admin_backoffice');
|
||||
|
||||
$entryInsert = $this->pdo->prepare(
|
||||
'INSERT INTO ledger_entries (
|
||||
tenant_id,
|
||||
member_id,
|
||||
source_id,
|
||||
type,
|
||||
description,
|
||||
occurred_at,
|
||||
created_by_user_id,
|
||||
reference_type
|
||||
) VALUES (
|
||||
:tenant_id,
|
||||
:member_id,
|
||||
:source_id,
|
||||
"payment",
|
||||
:description,
|
||||
:occurred_at,
|
||||
:created_by_user_id,
|
||||
"payment"
|
||||
)'
|
||||
);
|
||||
|
||||
$this->pdo->beginTransaction();
|
||||
|
||||
try {
|
||||
$entryInsert->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'member_id' => $memberId,
|
||||
'source_id' => $source['id'],
|
||||
'description' => $notes !== null && trim($notes) !== '' ? $notes : 'Einzahlung',
|
||||
'occurred_at' => $occurredAt,
|
||||
'created_by_user_id' => $recordedByUserId,
|
||||
]);
|
||||
|
||||
$entryId = (int) $this->pdo->lastInsertId();
|
||||
|
||||
$lineInsert = $this->pdo->prepare(
|
||||
'INSERT INTO ledger_lines (
|
||||
ledger_entry_id,
|
||||
tenant_id,
|
||||
member_id,
|
||||
account_code,
|
||||
amount_cents
|
||||
) VALUES (
|
||||
:ledger_entry_id,
|
||||
:tenant_id,
|
||||
:member_id,
|
||||
:account_code,
|
||||
:amount_cents
|
||||
)'
|
||||
);
|
||||
|
||||
$lineInsert->execute([
|
||||
'ledger_entry_id' => $entryId,
|
||||
'tenant_id' => $tenantId,
|
||||
'member_id' => null,
|
||||
'account_code' => 'cashbox',
|
||||
'amount_cents' => $amountCents,
|
||||
]);
|
||||
|
||||
$lineInsert->execute([
|
||||
'ledger_entry_id' => $entryId,
|
||||
'tenant_id' => $tenantId,
|
||||
'member_id' => $memberId,
|
||||
'account_code' => 'member_balance',
|
||||
'amount_cents' => -$amountCents,
|
||||
]);
|
||||
|
||||
$this->pdo->commit();
|
||||
} catch (\Throwable $throwable) {
|
||||
$this->pdo->rollBack();
|
||||
throw $throwable;
|
||||
}
|
||||
|
||||
$this->audit->log($tenantId, $recordedByUserId, 'payment.created', 'ledger_entry', $entryId, 'success', [
|
||||
'member_id' => $memberId,
|
||||
'amount_cents' => $amountCents,
|
||||
]);
|
||||
}
|
||||
|
||||
public function createPaperSheet(
|
||||
int $tenantId,
|
||||
string $title,
|
||||
?string $periodLabel,
|
||||
?string $periodStart,
|
||||
?string $periodEnd,
|
||||
?string $notes,
|
||||
int $createdByUserId
|
||||
): int {
|
||||
$statement = $this->pdo->prepare(
|
||||
'INSERT INTO paper_sheets (
|
||||
tenant_id,
|
||||
title,
|
||||
period_label,
|
||||
period_start,
|
||||
period_end,
|
||||
notes,
|
||||
created_by_user_id
|
||||
) VALUES (
|
||||
:tenant_id,
|
||||
:title,
|
||||
:period_label,
|
||||
:period_start,
|
||||
:period_end,
|
||||
:notes,
|
||||
:created_by_user_id
|
||||
)'
|
||||
);
|
||||
$statement->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'title' => $title,
|
||||
'period_label' => $periodLabel,
|
||||
'period_start' => $periodStart ?: null,
|
||||
'period_end' => $periodEnd ?: null,
|
||||
'notes' => $notes,
|
||||
'created_by_user_id' => $createdByUserId,
|
||||
]);
|
||||
|
||||
$sheetId = (int) $this->pdo->lastInsertId();
|
||||
|
||||
$this->audit->log($tenantId, $createdByUserId, 'paper_sheet.created', 'paper_sheet', $sheetId, 'success');
|
||||
|
||||
return $sheetId;
|
||||
}
|
||||
|
||||
public function addPaperSheetLine(
|
||||
int $tenantId,
|
||||
int $sheetId,
|
||||
int $memberId,
|
||||
int $productId,
|
||||
float $quantity,
|
||||
?string $effectiveAt,
|
||||
?string $note,
|
||||
int $actorUserId
|
||||
): void {
|
||||
if ($quantity <= 0) {
|
||||
throw new RuntimeException('Die Menge muss groesser als 0 sein.');
|
||||
}
|
||||
|
||||
$sheet = $this->pdo->prepare(
|
||||
'SELECT id, status FROM paper_sheets WHERE id = :id AND tenant_id = :tenant_id LIMIT 1'
|
||||
);
|
||||
$sheet->execute([
|
||||
'id' => $sheetId,
|
||||
'tenant_id' => $tenantId,
|
||||
]);
|
||||
$paperSheet = $sheet->fetch();
|
||||
|
||||
if (!$paperSheet) {
|
||||
throw new RuntimeException('Die Papierliste wurde nicht gefunden.');
|
||||
}
|
||||
|
||||
if ($paperSheet['status'] !== 'draft') {
|
||||
throw new RuntimeException('Nur Entwuerfe koennen erweitert werden.');
|
||||
}
|
||||
|
||||
$statement = $this->pdo->prepare(
|
||||
'INSERT INTO paper_sheet_lines (
|
||||
paper_sheet_id,
|
||||
tenant_id,
|
||||
member_id,
|
||||
product_id,
|
||||
quantity,
|
||||
effective_at,
|
||||
note
|
||||
) VALUES (
|
||||
:paper_sheet_id,
|
||||
:tenant_id,
|
||||
:member_id,
|
||||
:product_id,
|
||||
:quantity,
|
||||
:effective_at,
|
||||
:note
|
||||
)'
|
||||
);
|
||||
$statement->execute([
|
||||
'paper_sheet_id' => $sheetId,
|
||||
'tenant_id' => $tenantId,
|
||||
'member_id' => $memberId,
|
||||
'product_id' => $productId,
|
||||
'quantity' => $quantity,
|
||||
'effective_at' => $effectiveAt ?: null,
|
||||
'note' => $note,
|
||||
]);
|
||||
|
||||
$lineId = (int) $this->pdo->lastInsertId();
|
||||
|
||||
$this->audit->log($tenantId, $actorUserId, 'paper_sheet.line_added', 'paper_sheet_line', $lineId, 'success', [
|
||||
'paper_sheet_id' => $sheetId,
|
||||
]);
|
||||
}
|
||||
|
||||
public function postPaperSheet(int $tenantId, int $sheetId, int $actorUserId): void
|
||||
{
|
||||
$sheetStatement = $this->pdo->prepare(
|
||||
'SELECT id, status FROM paper_sheets WHERE id = :id AND tenant_id = :tenant_id LIMIT 1'
|
||||
);
|
||||
$sheetStatement->execute([
|
||||
'id' => $sheetId,
|
||||
'tenant_id' => $tenantId,
|
||||
]);
|
||||
$sheet = $sheetStatement->fetch();
|
||||
|
||||
if (!$sheet) {
|
||||
throw new RuntimeException('Die Papierliste wurde nicht gefunden.');
|
||||
}
|
||||
|
||||
if ($sheet['status'] !== 'draft') {
|
||||
throw new RuntimeException('Die Papierliste wurde bereits verbucht.');
|
||||
}
|
||||
|
||||
$linesStatement = $this->pdo->prepare(
|
||||
'SELECT * FROM paper_sheet_lines WHERE paper_sheet_id = :paper_sheet_id AND tenant_id = :tenant_id ORDER BY id ASC'
|
||||
);
|
||||
$linesStatement->execute([
|
||||
'paper_sheet_id' => $sheetId,
|
||||
'tenant_id' => $tenantId,
|
||||
]);
|
||||
$lines = $linesStatement->fetchAll();
|
||||
|
||||
if ($lines === []) {
|
||||
throw new RuntimeException('Die Papierliste enthaelt noch keine Positionen.');
|
||||
}
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$this->createConsumption(
|
||||
$tenantId,
|
||||
(int) $line['member_id'],
|
||||
(int) $line['product_id'],
|
||||
'paper_sheet',
|
||||
$line['effective_at'] ?: gmdate('Y-m-d H:i:s'),
|
||||
$actorUserId,
|
||||
(float) $line['quantity'],
|
||||
$line['note'] ?: 'Papierliste',
|
||||
(int) $line['id'],
|
||||
'sheet:' . $sheetId
|
||||
);
|
||||
}
|
||||
|
||||
$update = $this->pdo->prepare(
|
||||
'UPDATE paper_sheets SET status = "posted", posted_at = :posted_at WHERE id = :id AND tenant_id = :tenant_id'
|
||||
);
|
||||
$update->execute([
|
||||
'posted_at' => gmdate('Y-m-d H:i:s'),
|
||||
'id' => $sheetId,
|
||||
'tenant_id' => $tenantId,
|
||||
]);
|
||||
|
||||
$this->audit->log($tenantId, $actorUserId, 'paper_sheet.posted', 'paper_sheet', $sheetId, 'success');
|
||||
}
|
||||
|
||||
private function resolvePriceCents(int $tenantId, int $productId, string $effectiveAt): int
|
||||
{
|
||||
$statement = $this->pdo->prepare(
|
||||
'SELECT price_cents
|
||||
FROM product_prices
|
||||
WHERE tenant_id = :tenant_id
|
||||
AND product_id = :product_id
|
||||
AND valid_from <= :effective_at
|
||||
AND (valid_until IS NULL OR valid_until > :effective_at)
|
||||
ORDER BY valid_from DESC
|
||||
LIMIT 1'
|
||||
);
|
||||
$statement->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'product_id' => $productId,
|
||||
'effective_at' => $effectiveAt,
|
||||
]);
|
||||
$price = $statement->fetchColumn();
|
||||
|
||||
if ($price === false) {
|
||||
throw new RuntimeException('Fuer das Produkt ist kein aktiver Preis hinterlegt.');
|
||||
}
|
||||
|
||||
return (int) $price;
|
||||
}
|
||||
|
||||
private function findSource(int $tenantId, string $code): array
|
||||
{
|
||||
$statement = $this->pdo->prepare(
|
||||
'SELECT id, code FROM capture_sources WHERE tenant_id = :tenant_id AND code = :code LIMIT 1'
|
||||
);
|
||||
$statement->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'code' => $code,
|
||||
]);
|
||||
$source = $statement->fetch();
|
||||
|
||||
if (!$source) {
|
||||
throw new RuntimeException('Die Erfassungsquelle wurde nicht gefunden.');
|
||||
}
|
||||
|
||||
return $source;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use PDO;
|
||||
use RuntimeException;
|
||||
|
||||
final class RfidService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PDO $pdo,
|
||||
private readonly AuditService $audit
|
||||
) {
|
||||
}
|
||||
|
||||
public function createDevice(int $tenantId, string $name, ?string $location, int $actorUserId): string
|
||||
{
|
||||
if (trim($name) === '') {
|
||||
throw new RuntimeException('Bitte einen Geraetenamen angeben.');
|
||||
}
|
||||
|
||||
$token = bin2hex(random_bytes(20));
|
||||
$statement = $this->pdo->prepare(
|
||||
'INSERT INTO rfid_devices (tenant_id, name, location_label, device_token, status)
|
||||
VALUES (:tenant_id, :name, :location_label, :device_token, "planned")'
|
||||
);
|
||||
$statement->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'name' => trim($name),
|
||||
'location_label' => $location,
|
||||
'device_token' => $token,
|
||||
]);
|
||||
$deviceId = (int) $this->pdo->lastInsertId();
|
||||
|
||||
$this->audit->log($tenantId, $actorUserId, 'rfid.device_created', 'rfid_device', $deviceId, 'success');
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
public function assignTag(int $tenantId, int $memberId, string $uid, ?string $label, int $actorUserId): void
|
||||
{
|
||||
if (trim($uid) === '') {
|
||||
throw new RuntimeException('Die Karten-UID darf nicht leer sein.');
|
||||
}
|
||||
|
||||
$statement = $this->pdo->prepare(
|
||||
'INSERT INTO rfid_tags (tenant_id, member_id, uid_hash, label, status)
|
||||
VALUES (:tenant_id, :member_id, :uid_hash, :label, "active")'
|
||||
);
|
||||
$statement->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'member_id' => $memberId,
|
||||
'uid_hash' => hash('sha256', trim($uid)),
|
||||
'label' => $label,
|
||||
]);
|
||||
|
||||
$this->audit->log($tenantId, $actorUserId, 'rfid.tag_assigned', 'rfid_tag', (int) $this->pdo->lastInsertId(), 'success');
|
||||
}
|
||||
|
||||
public function ingest(array $payload): void
|
||||
{
|
||||
$deviceToken = trim((string) ($payload['device_token'] ?? ''));
|
||||
$uid = trim((string) ($payload['uid'] ?? ''));
|
||||
$externalEventId = trim((string) ($payload['event_id'] ?? ''));
|
||||
|
||||
if ($deviceToken === '' || $uid === '') {
|
||||
throw new RuntimeException('device_token und uid sind Pflichtfelder.');
|
||||
}
|
||||
|
||||
$deviceStatement = $this->pdo->prepare(
|
||||
'SELECT d.id, d.tenant_id, d.status
|
||||
FROM rfid_devices d
|
||||
WHERE d.device_token = :device_token
|
||||
LIMIT 1'
|
||||
);
|
||||
$deviceStatement->execute(['device_token' => $deviceToken]);
|
||||
$device = $deviceStatement->fetch();
|
||||
|
||||
if (!$device) {
|
||||
throw new RuntimeException('Das RFID-Geraet ist unbekannt.');
|
||||
}
|
||||
|
||||
$status = 'received';
|
||||
$tagStatement = $this->pdo->prepare(
|
||||
'SELECT id FROM rfid_tags WHERE tenant_id = :tenant_id AND uid_hash = :uid_hash LIMIT 1'
|
||||
);
|
||||
$tagStatement->execute([
|
||||
'tenant_id' => $device['tenant_id'],
|
||||
'uid_hash' => hash('sha256', $uid),
|
||||
]);
|
||||
|
||||
if ($tagStatement->fetch()) {
|
||||
$status = 'linked';
|
||||
}
|
||||
|
||||
$statement = $this->pdo->prepare(
|
||||
'INSERT INTO rfid_events (
|
||||
tenant_id,
|
||||
device_id,
|
||||
external_event_id,
|
||||
uid_hash,
|
||||
payload_json,
|
||||
status,
|
||||
received_at
|
||||
) VALUES (
|
||||
:tenant_id,
|
||||
:device_id,
|
||||
:external_event_id,
|
||||
:uid_hash,
|
||||
:payload_json,
|
||||
:status,
|
||||
:received_at
|
||||
)'
|
||||
);
|
||||
$statement->execute([
|
||||
'tenant_id' => $device['tenant_id'],
|
||||
'device_id' => $device['id'],
|
||||
'external_event_id' => $externalEventId !== '' ? $externalEventId : null,
|
||||
'uid_hash' => hash('sha256', $uid),
|
||||
'payload_json' => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
'status' => $status,
|
||||
'received_at' => gmdate('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
$this->audit->log((int) $device['tenant_id'], null, 'rfid.event_received', 'rfid_event', (int) $this->pdo->lastInsertId(), 'success', [
|
||||
'status' => $status,
|
||||
'device_id' => (int) $device['id'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use PDO;
|
||||
use RuntimeException;
|
||||
|
||||
final class SetupService
|
||||
{
|
||||
public function __construct(private readonly string $rootPath)
|
||||
{
|
||||
}
|
||||
|
||||
public function isInstalled(): bool
|
||||
{
|
||||
return is_file($this->rootPath . '/storage/installed.lock');
|
||||
}
|
||||
|
||||
public function install(array $input): void
|
||||
{
|
||||
$required = [
|
||||
'app_url',
|
||||
'db_host',
|
||||
'db_port',
|
||||
'db_name',
|
||||
'db_user',
|
||||
'admin_name',
|
||||
'admin_email',
|
||||
'admin_password',
|
||||
];
|
||||
|
||||
foreach ($required as $field) {
|
||||
if (trim((string) ($input[$field] ?? '')) === '') {
|
||||
throw new RuntimeException('Bitte alle Pflichtfelder ausfuellen.');
|
||||
}
|
||||
}
|
||||
|
||||
if (!filter_var($input['admin_email'], FILTER_VALIDATE_EMAIL)) {
|
||||
throw new RuntimeException('Die Admin-E-Mail ist ungueltig.');
|
||||
}
|
||||
|
||||
if (mb_strlen($input['admin_password']) < 12) {
|
||||
throw new RuntimeException('Das Admin-Passwort muss mindestens 12 Zeichen lang sein.');
|
||||
}
|
||||
|
||||
$config = [
|
||||
'host' => trim((string) $input['db_host']),
|
||||
'port' => (int) $input['db_port'],
|
||||
'name' => trim((string) $input['db_name']),
|
||||
'user' => trim((string) $input['db_user']),
|
||||
'pass' => (string) ($input['db_pass'] ?? ''),
|
||||
'charset' => 'utf8mb4',
|
||||
];
|
||||
|
||||
$dsn = sprintf(
|
||||
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
|
||||
$config['host'],
|
||||
$config['port'],
|
||||
$config['name'],
|
||||
$config['charset']
|
||||
);
|
||||
|
||||
$pdo = new PDO($dsn, $config['user'], $config['pass'], [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
]);
|
||||
|
||||
$schema = file_get_contents($this->rootPath . '/database/schema.sql');
|
||||
|
||||
if (!is_string($schema) || $schema === '') {
|
||||
throw new RuntimeException('Das Datenbankschema konnte nicht geladen werden.');
|
||||
}
|
||||
|
||||
$pdo->exec($schema);
|
||||
|
||||
$statement = $pdo->prepare('SELECT id FROM users WHERE email = :email LIMIT 1');
|
||||
$statement->execute(['email' => mb_strtolower((string) $input['admin_email'])]);
|
||||
$existing = $statement->fetchColumn();
|
||||
|
||||
if (!$existing) {
|
||||
$insert = $pdo->prepare(
|
||||
'INSERT INTO users (full_name, email, password_hash, platform_role)
|
||||
VALUES (:full_name, :email, :password_hash, "platform_admin")'
|
||||
);
|
||||
$insert->execute([
|
||||
'full_name' => trim((string) $input['admin_name']),
|
||||
'email' => mb_strtolower((string) $input['admin_email']),
|
||||
'password_hash' => secure_password_hash((string) $input['admin_password']),
|
||||
]);
|
||||
}
|
||||
|
||||
$envContent = $this->buildEnvFile($input);
|
||||
|
||||
if (file_put_contents($this->rootPath . '/.env', $envContent) === false) {
|
||||
throw new RuntimeException('Die .env-Datei konnte nicht geschrieben werden.');
|
||||
}
|
||||
|
||||
$lockContent = json_encode([
|
||||
'installed_at' => gmdate(DATE_ATOM),
|
||||
'app_url' => trim((string) $input['app_url']),
|
||||
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
file_put_contents($this->rootPath . '/storage/installed.lock', (string) $lockContent);
|
||||
}
|
||||
|
||||
private function buildEnvFile(array $input): string
|
||||
{
|
||||
$appKey = bin2hex(random_bytes(32));
|
||||
$rfidSecret = bin2hex(random_bytes(24));
|
||||
|
||||
$lines = [
|
||||
'APP_NAME="Kaffeekasse SaaS"',
|
||||
'APP_ENV=production',
|
||||
'APP_DEBUG=0',
|
||||
'APP_URL=' . trim((string) $input['app_url']),
|
||||
'APP_TIMEZONE=Europe/Berlin',
|
||||
'APP_KEY=' . $appKey,
|
||||
'',
|
||||
'DB_HOST=' . trim((string) $input['db_host']),
|
||||
'DB_PORT=' . (int) $input['db_port'],
|
||||
'DB_NAME=' . trim((string) $input['db_name']),
|
||||
'DB_USER=' . trim((string) $input['db_user']),
|
||||
'DB_PASS=' . (string) ($input['db_pass'] ?? ''),
|
||||
'',
|
||||
'MAIL_FROM=' . trim((string) ($input['mail_from'] ?? 'noreply@example.com')),
|
||||
'RFID_SHARED_SECRET=' . $rfidSecret,
|
||||
];
|
||||
|
||||
return implode(PHP_EOL, $lines) . PHP_EOL;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use PDO;
|
||||
use RuntimeException;
|
||||
|
||||
final class TenantRegistrationService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PDO $pdo,
|
||||
private readonly AuditService $audit
|
||||
) {
|
||||
}
|
||||
|
||||
public function register(array $input): string
|
||||
{
|
||||
$name = trim((string) ($input['tenant_name'] ?? ''));
|
||||
$slug = $this->slugify((string) ($input['tenant_slug'] ?? $name));
|
||||
$ownerName = trim((string) ($input['owner_name'] ?? ''));
|
||||
$ownerEmail = mb_strtolower(trim((string) ($input['owner_email'] ?? '')));
|
||||
$ownerPassword = (string) ($input['owner_password'] ?? '');
|
||||
$plan = (string) ($input['plan'] ?? 'starter');
|
||||
$defaultPriceCents = max(50, (int) round(((float) ($input['default_price_eur'] ?? 1.20)) * 100));
|
||||
|
||||
if ($name === '' || $slug === '' || $ownerName === '' || $ownerEmail === '' || $ownerPassword === '') {
|
||||
throw new RuntimeException('Bitte alle Pflichtfelder ausfuellen.');
|
||||
}
|
||||
|
||||
if (!filter_var($ownerEmail, FILTER_VALIDATE_EMAIL)) {
|
||||
throw new RuntimeException('Die E-Mail-Adresse ist ungueltig.');
|
||||
}
|
||||
|
||||
if (mb_strlen($ownerPassword) < 12) {
|
||||
throw new RuntimeException('Das Passwort muss mindestens 12 Zeichen lang sein.');
|
||||
}
|
||||
|
||||
$allowedPlans = ['starter', 'team', 'business'];
|
||||
|
||||
if (!in_array($plan, $allowedPlans, true)) {
|
||||
$plan = 'starter';
|
||||
}
|
||||
|
||||
$existingTenant = $this->pdo->prepare('SELECT id FROM tenants WHERE slug = :slug LIMIT 1');
|
||||
$existingTenant->execute(['slug' => $slug]);
|
||||
|
||||
if ($existingTenant->fetchColumn()) {
|
||||
throw new RuntimeException('Der gewuenschte Kurzname ist bereits vergeben.');
|
||||
}
|
||||
|
||||
$existingUser = $this->pdo->prepare('SELECT id FROM users WHERE email = :email LIMIT 1');
|
||||
$existingUser->execute(['email' => $ownerEmail]);
|
||||
|
||||
if ($existingUser->fetchColumn()) {
|
||||
throw new RuntimeException('Die E-Mail-Adresse ist bereits vorhanden. Fuer weitere Tenants bitte spaeter Einladungen nutzen.');
|
||||
}
|
||||
|
||||
$now = gmdate('Y-m-d H:i:s');
|
||||
|
||||
$this->pdo->beginTransaction();
|
||||
|
||||
try {
|
||||
$userInsert = $this->pdo->prepare(
|
||||
'INSERT INTO users (full_name, email, password_hash, platform_role)
|
||||
VALUES (:full_name, :email, :password_hash, "user")'
|
||||
);
|
||||
$userInsert->execute([
|
||||
'full_name' => $ownerName,
|
||||
'email' => $ownerEmail,
|
||||
'password_hash' => secure_password_hash($ownerPassword),
|
||||
]);
|
||||
$userId = (int) $this->pdo->lastInsertId();
|
||||
|
||||
$tenantInsert = $this->pdo->prepare(
|
||||
'INSERT INTO tenants (name, slug, plan, status, created_at)
|
||||
VALUES (:name, :slug, :plan, "active", :created_at)'
|
||||
);
|
||||
$tenantInsert->execute([
|
||||
'name' => $name,
|
||||
'slug' => $slug,
|
||||
'plan' => $plan,
|
||||
'created_at' => $now,
|
||||
]);
|
||||
$tenantId = (int) $this->pdo->lastInsertId();
|
||||
|
||||
$membershipInsert = $this->pdo->prepare(
|
||||
'INSERT INTO tenant_memberships (tenant_id, user_id, role, status)
|
||||
VALUES (:tenant_id, :user_id, "owner", "active")'
|
||||
);
|
||||
$membershipInsert->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
|
||||
$settingsInsert = $this->pdo->prepare(
|
||||
'INSERT INTO tenant_settings (
|
||||
tenant_id,
|
||||
currency_code,
|
||||
default_product_price_cents,
|
||||
allow_self_service,
|
||||
allow_paper_lists,
|
||||
allow_rfid
|
||||
) VALUES (
|
||||
:tenant_id,
|
||||
"EUR",
|
||||
:default_product_price_cents,
|
||||
1,
|
||||
1,
|
||||
0
|
||||
)'
|
||||
);
|
||||
$settingsInsert->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'default_product_price_cents' => $defaultPriceCents,
|
||||
]);
|
||||
|
||||
$memberInsert = $this->pdo->prepare(
|
||||
'INSERT INTO members (tenant_id, user_id, display_name, email, status)
|
||||
VALUES (:tenant_id, :user_id, :display_name, :email, "active")'
|
||||
);
|
||||
$memberInsert->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'user_id' => $userId,
|
||||
'display_name' => $ownerName,
|
||||
'email' => $ownerEmail,
|
||||
]);
|
||||
|
||||
$sourceInsert = $this->pdo->prepare(
|
||||
'INSERT INTO capture_sources (tenant_id, code, name, channel, is_system)
|
||||
VALUES (:tenant_id, :code, :name, :channel, 1)'
|
||||
);
|
||||
|
||||
foreach ([
|
||||
['digital_self', 'Digitale Selbstbuchung', 'digital'],
|
||||
['paper_sheet', 'Papierliste / Nacherfassung', 'paper'],
|
||||
['admin_backoffice', 'Backoffice-Buchung', 'admin'],
|
||||
['rfid_reader', 'RFID-Geraet', 'rfid'],
|
||||
] as [$code, $sourceName, $channel]) {
|
||||
$sourceInsert->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'code' => $code,
|
||||
'name' => $sourceName,
|
||||
'channel' => $channel,
|
||||
]);
|
||||
}
|
||||
|
||||
foreach ([
|
||||
['Kaffee', $defaultPriceCents],
|
||||
['Espresso', $defaultPriceCents],
|
||||
['Tee', max(80, $defaultPriceCents - 20)],
|
||||
] as [$productName, $priceCents]) {
|
||||
$productInsert = $this->pdo->prepare(
|
||||
'INSERT INTO products (tenant_id, name, sku, is_active)
|
||||
VALUES (:tenant_id, :name, :sku, 1)'
|
||||
);
|
||||
$productInsert->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'name' => $productName,
|
||||
'sku' => strtolower(str_replace(' ', '-', $productName)),
|
||||
]);
|
||||
$productId = (int) $this->pdo->lastInsertId();
|
||||
|
||||
$priceInsert = $this->pdo->prepare(
|
||||
'INSERT INTO product_prices (tenant_id, product_id, price_cents, valid_from)
|
||||
VALUES (:tenant_id, :product_id, :price_cents, :valid_from)'
|
||||
);
|
||||
$priceInsert->execute([
|
||||
'tenant_id' => $tenantId,
|
||||
'product_id' => $productId,
|
||||
'price_cents' => $priceCents,
|
||||
'valid_from' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->pdo->commit();
|
||||
} catch (\Throwable $throwable) {
|
||||
$this->pdo->rollBack();
|
||||
throw $throwable;
|
||||
}
|
||||
|
||||
$this->audit->log($tenantId, $userId, 'tenant.registered', 'tenant', $tenantId, 'success', [
|
||||
'plan' => $plan,
|
||||
'slug' => $slug,
|
||||
]);
|
||||
|
||||
return $slug;
|
||||
}
|
||||
|
||||
private function slugify(string $value): string
|
||||
{
|
||||
$value = mb_strtolower($value);
|
||||
$value = preg_replace('/[^a-z0-9]+/u', '-', $value) ?? '';
|
||||
$value = trim($value, '-');
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user