glanzlicht / MACHWEIS

Mach’s. Beleg’s. Rechne es ab. MACHWEIS ist eine Local-First-Flutter-App für schnelle, kontrollierbare Leistungsnachweise im Handwerk. Die Oberfläche läuft auf iOS, Android und im Web und ist vollständig deutschsprachig.

Verified secure showcase
180 Files 605 KB Public size 26 Redacted Dart · PHP · SQL · Markdown · XML · PLIST

Project overview

MACHWEIS is a verified code showcase with 180 visible project entries across 8 top-level modules. The primary detected language is Dart; protected files remain visible in the structure while their contents stay private.

README

MACHWEIS

Mach’s. Beleg’s. Rechne es ab.

Produktansicht

MACHWEIS Dashboard auf Laptop und Smartphone

MACHWEIS ist eine Local-First-Flutter-App für schnelle, kontrollierbare Leistungsnachweise im Handwerk. Die Oberfläche läuft auf iOS, Android und im Web und ist vollständig deutschsprachig.

Aktueller Funktionsumfang

  • lokale Firmen-, Kunden-, Auftrags- und Mitarbeiterdaten (SQLite auf Android/iOS, Browser-Speicher im Web)
  • mobile Stammdaten-Synchronisation für Kunden, Baustellen, Aufträge, Mitarbeiterprofile und Auftragszuweisungen über die serverseitigen Push-/Pull-Endpunkte
  • atomare lokale Änderungen mit Outbox, Cursor, Retry, sichtbaren Konflikten und einer ausdrücklichen Importentscheidung bei der ersten Verbindung
  • unveränderliche Berichtsversionen mit sichtbarem Prüfverlauf
  • persistente Offline-Synchronisationswarteschlange mit Retry-Strategie
  • lokale Fotodokumentation per Kamera, Galerie oder Web-Dateiauswahl
  • gebrandete PDF-Leistungsnachweise mit Unterschrift, Fotodokumentation und vorbereitetem Teilen per E-Mail
  • prüfpflichtiger KI-Assistent für Baustellennotizen mit OpenAI, Google Gemini oder eigenem Proxy
  • Rollen: Inhaber, Büro, Bauleiter und Monteur
  • strukturierte Arbeitszeit mit Leistungsdatum, Pause und Nettozeit
  • mehrere Leistungs- und Materialpositionen
  • Zusatzaufträge mit Kundenbestätigung
  • Vollständigkeitsprüfung vor Unterschrift
  • digitale Kundenunterschrift und unveränderlicher Abschluss
  • CSV-Export und vorbereitete PDF-Renderer-Schnittstelle
  • responsive Smartphone- und Webnavigation
  • aktuelle App-Version und Build-Nummer dauerhaft am unteren Rand der Navigation

Lokale Entwicklung

flutter pub get
flutter analyze
flutter test
flutter run -d chrome

Produktkontext und technische Leitplanken stehen in [docs/PRODUCT_BRIEF.md](docs/PRODUCT_BRIEF.md). Architektur und Serververtrag der KI-Unterstützung beschreibt [docs/AI_INTEGRATION.md](docs/AI_INTEGRATION.md).

Die Stammdaten-Synchronisation ist ausschließlich für iOS und Android aktiviert. Sie verwendet das Access-Token der mobilen Anmeldung und erneuert es bei Bedarf über die bestehende Authentifizierung. Im Web bleibt die Synchronisation bewusst deaktiviert.

Backend-Grundgerüst

Das separate PHP-8.4-REST-Grundgerüst liegt in [backend/](backend/README.md). Es enthält in diesem Stand ausschließlich Infrastruktur und den Health-Endpunkt; Login, Fachlogik und KI sind noch nicht enthalten.

backend/tests/reports_contract.phpPHP
<?php
declare(strict_types=1);
use Machweis\Backend\Auth\TokenService;
use Machweis\Backend\Config;
use Machweis\Backend\Kernel;
use Machweis\Backend\Logger;
use Machweis\Backend\ReportService;
use Machweis\Backend\Request;
use Machweis\Backend\SyncService;
use Machweis\Backend\SmtpMailer;
require dirname(__DIR__) . '/src/autoload.php';
function check(bool $condition, string $message): void
{
if (!$condition) throw new RuntimeException($message);
}
function testUuid(): string
{
$hex = bin2hex(random_bytes(16));
return substr($hex, 0, 8) . '-' . substr($hex, 8, 4) . '-4' . substr($hex, 13, 3) . '-8' . substr($hex, 17, 3) . '-' . substr($hex, 20);
}
function expectDatabaseRejection(callable $operation, string $message): void
{
try {
$operation();
} catch (PDOException) {
return;
}
throw new RuntimeException($message);
}
$config = new Config(
'test', false, (string) getenv('DB_HOST'), (int) getenv('DB_PORT'), (string) getenv('DB_NAME'),
(string) getenv('DB_USER'), (string) getenv('DB_PASSWORD'), [], true, str_repeat('reports-contract-secret-', 2),
);
try { (new SmtpMailer($config))->sendPdf('customer@example.test', 'Test', 'Body', 'test.pdf', '%PDF-'); throw new RuntimeException('unconfigured SMTP must not send'); } catch (RuntimeException $error) { check($error->getMessage() === 'Transactional mail is not configured.', 'SMTP must reject missing credentials before connecting'); }
$pdo = new PDO(
sprintf('mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4', $config->dbHost, $config->dbPort, $config->dbName),
$config->dbUser,
$config->dbPassword,
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES => false],
);
$tenantA = testUuid();
$tenantB = testUuid();
$actor = testUuid();
$tenantInsert = $pdo->prepare('INSERT INTO tenants (id, name, status, created_at) VALUES (?, ?, ?, UTC_TIMESTAMP()), (?, ?, ?, UTC_TIMESTAMP())');
$tenantInsert->execute([$tenantA, 'Test A', 'active', $tenantB, 'Test B', 'active']);
// Exercise the actual HTTP guard: owner/office/supervisor may write; technician may only read.
$tokens = new TokenService($config);
$kernel = new Kernel($config, new Logger('test'));
$tokensByRole = [];
foreach (['owner', 'office', 'supervisor', 'technician'] as $index => $role) {
$tokensByRole[$role] = $tokens->issueAccess(['sub' => sprintf('40000000-0000-4000-8000-%012d', $index + 1), 'tid' => $tenantA, 'role' => $role]);
}
foreach (['owner', 'office', 'supervisor'] as $role) {
$response = $kernel->handle(new Request('POST', '/api/v1/reports', ['authorization' => 'Bearer ' . $tokensByRole[$role]], json_encode(['payload' => ['role' => $role]], JSON_THROW_ON_ERROR)));
check($response->status === 201, "$role must be allowed to create reports");
}
$forbidden = $kernel->handle(new Request('POST', '/api/v1/reports', ['authorization' => 'Bearer ' . $tokensByRole['technician']], json_encode(['payload' => ['role' => 'technician']], JSON_THROW_ON_ERROR)));
check($forbidden->status === 403 && $forbidden->body['error']['code'] === 'forbidden', 'technician must not create reports');
$service = new ReportService($pdo);
$created = $service->create($tenantA, $actor, ['payload' => ['title' => 'original', 'items' => [1]]]);
$reportId = (string) $created['id'];
$service->addVersion($tenantA, $reportId, $actor, ['payload' => ['title' => 'second', 'items' => [1, 2]]]);
$versioned = $service->addVersion($tenantA, $reportId, $actor, ['payload' => ['title' => 'third', 'items' => [1, 2, 3]]]);
check((int) $versioned['current_version'] === 3, 'current version must advance to three');
check(array_column($versioned['versions'], 'version') === [3, 2, 1], 'all versions must be returned newest first');
check($versioned['versions'][2]['payload'] === ['title' => 'original', 'items' => [1]], 'first snapshot must remain unchanged');
check($versioned['versions'][1]['payload'] === ['title' => 'second', 'items' => [1, 2]], 'second snapshot must remain unchanged');
$searchable = $service->create($tenantA, $actor, ['payload' => ['customer' => 'Müller GmbH', 'site' => 'Nordhalle', 'activity' => 'Montage']]);
$searchResult = $service->list($tenantA, 'draft', 'Nordhalle', 1, 1);
check((int) $searchResult['total'] === 1, 'report search must return a tenant-scoped total');
check(count($searchResult['items']) === 1 && $searchResult['items'][0]['id'] === $searchable['id'], 'report search must match the current immutable payload');
check($service->list($tenantB, null, 'Nordhalle', 1, 10)['total'] === 0, 'report search must remain tenant isolated');
$technicianRead = $kernel->handle(new Request('GET', '/api/v1/reports/' . $reportId, ['authorization' => 'Bearer ' . $tokensByRole['technician']]));
check($technicianRead->status === 200, 'technician must be allowed to read reports');
$technicianVersion = $kernel->handle(new Request('POST', '/api/v1/reports/' . $reportId . '/versions', ['authorization' => 'Bearer ' . $tokensByRole['technician']], json_encode(['payload' => ['forbidden' => true]], JSON_THROW_ON_ERROR)));
check($technicianVersion->status === 403, 'technician must not add report versions');
try {
$service->one($tenantB, $reportId);
throw new RuntimeException('tenant isolation failed');
} catch (RuntimeException $error) {
if ($error->getMessage() === 'tenant isolation failed') throw $error;
}
$events = $pdo->query('SELECT event_type, version, actor_user_id FROM report_audit_events WHERE report_id = ' . $pdo->quote($reportId) . ' ORDER BY version')->fetchAll();
check(count($events) === 3, 'every immutable version must have one audit event');
check(array_column($events, 'event_type') === ['created', 'version_created', 'version_created'], 'audit event sequence must match version creation');
check(array_map('intval', array_column($events, 'version')) === [1, 2, 3], 'audit versions must be gap-free');
check(count(array_unique(array_column($events, 'actor_user_id'))) === 1 && $events[0]['actor_user_id'] === $actor, 'audit actor must be preserved');
expectDatabaseRejection(
fn () => $pdo->exec("INSERT INTO report_audit_events (id, report_id, tenant_id, event_type, version, actor_user_id, created_at) VALUES (" . $pdo->quote(testUuid()) . ', ' . $pdo->quote($reportId) . ", '$tenantA', 'orphan', 99, '$actor', UTC_TIMESTAMP())"),
'database must reject audit events without a matching version',
);
expectDatabaseRejection(
fn () => $pdo->exec("INSERT INTO report_audit_events (id, report_id, tenant_id, event_type, version, actor_user_id, created_at) VALUES (" . $pdo->quote(testUuid()) . ', ' . $pdo->quote($reportId) . ", '$tenantB', 'cross_tenant', 1, '$actor', UTC_TIMESTAMP())"),
'database must reject cross-tenant audit references',
);
// Mobile outbox contract: client IDs, stable idempotency and optimistic revisions.
$sync = new SyncService($pdo);
$mobileReportId = testUuid();
$createOperation = [
'operation_id' => testUuid(),
'idempotency_key' => 'report-create-' . $mobileReportId,
'entity' => 'reports',
'entity_id' => $mobileReportId,
'action' => 'create',
'base_revision' => 0,
'payload' => ['id' => $mobileReportId, 'activity' => 'Montage', 'version' => 1, 'isFinalized' => false],
];
$createdSync = $sync->push($tenantA, 'supervisor', [$createOperation], $actor);
check($createdSync['status'] === 200 && $createdSync['results'][0]['row_version'] === 1, 'mobile create must apply at revision one');
$replayedSync = $sync->push($tenantA, 'supervisor', [$createOperation], $actor);
check($replayedSync['results'][0]['replayed'] === true, 'identical report operation must be replayed');
check((int)$pdo->query('SELECT COUNT(*) FROM report_versions WHERE report_id = ' . $pdo->quote($mobileReportId))->fetchColumn() === 1, 'idempotent replay must not append a version');
$updateOperation = [
'operation_id' => testUuid(),
'idempotency_key' => 'report-update-' . $mobileReportId,
'entity' => 'reports',
'entity_id' => $mobileReportId,
'action' => 'update',
'base_revision' => 1,
'payload' => ['id' => $mobileReportId, 'activity' => 'Abnahme', 'version' => 2, 'isFinalized' => true],
];
$updatedSync = $sync->push($tenantA, 'supervisor', [$updateOperation], $actor);
check($updatedSync['results'][0]['row_version'] === 2, 'mobile update must advance the immutable revision');
$pdfResponse = $kernel->handle(new Request('POST', '/api/v1/reports/' . $mobileReportId . '/pdf', ['authorization' => 'Bearer ' . $tokensByRole['technician']]));
check($pdfResponse->status === 200, 'any report reader must be allowed to generate a finalized PDF');
check(($pdfResponse->headers['Content-Type'] ?? '') === 'application/pdf', 'finalized PDF must have its PDF content type');
check(str_starts_with((string) $pdfResponse->binaryBody, '%PDF-'), 'finalized PDF must be valid PDF bytes');
check(str_contains((string) $pdfResponse->binaryBody, 'MACHWEIS'), 'finalized PDF must contain the document heading');
$pdfAudit = $pdo->query('SELECT event_type, version FROM report_audit_events WHERE report_id = ' . $pdo->quote($mobileReportId) . " AND event_type = 'pdf_generated'")->fetch();
check(is_array($pdfAudit) && $pdfAudit['event_type'] === 'pdf_generated' && (int) $pdfAudit['version'] === 2, 'PDF generation must be audited against the immutable version');
$draftPdf = $kernel->handle(new Request('POST', '/api/v1/reports/' . $reportId . '/pdf', ['authorization' => 'Bearer ' . $tokensByRole['owner']]));
check($draftPdf->status === 422, 'draft reports must not receive a server PDF');
$staleOperation = $updateOperation;
$staleOperation['operation_id'] = testUuid();
$staleOperation['idempotency_key'] = 'report-stale-' . $mobileReportId;
$stale = $sync->push($tenantA, 'supervisor', [$staleOperation], $actor);
check($stale['status'] === 409 && $stale['results'][0]['code'] === 'row_version_conflict', 'stale report base revision must conflict');
check($stale['results'][0]['current_row_version'] === 2, 'conflict must expose the current server revision');
$mobileVersions = $pdo->query('SELECT version, payload_json FROM report_versions WHERE report_id = ' . $pdo->quote($mobileReportId) . ' ORDER BY version')->fetchAll();
check(count($mobileVersions) === 2, 'conflict and replay must not append report versions');
check(json_decode($mobileVersions[0]['payload_json'], true, 512, JSON_THROW_ON_ERROR)['activity'] === 'Montage', 'first mobile snapshot must remain immutable');
$mobileAudit = $pdo->query('SELECT event_type, version, actor_user_id FROM report_audit_events WHERE report_id = ' . $pdo->quote($mobileReportId) . " AND event_type <> 'pdf_generated' ORDER BY version")->fetchAll();
check(array_column($mobileAudit, 'event_type') === ['created', 'finalized'], 'mobile audit events must describe create and finalization');
check(count(array_unique(array_column($mobileAudit, 'actor_user_id'))) === 1 && $mobileAudit[0]['actor_user_id'] === $actor, 'mobile audit actor must be authenticated identity');
$technicianOperation = $createOperation;
$technicianOperation['operation_id'] = testUuid();
$technicianOperation['idempotency_key'] = 'report-technician-' . $mobileReportId;
$technicianOperation['entity_id'] = testUuid();
$technicianDenied = $sync->push($tenantA, 'technician', [$technicianOperation], $actor);
check($technicianDenied['status'] === 403 && $technicianDenied['results'][0]['code'] === 'forbidden', 'report sync must preserve write role policy');
fwrite(STDOUT, "reports_contract: OK\n");

Architecture

High-level modules detected from the verified public snapshot. Protected implementation details are intentionally omitted.

backendmodule
libmodule
iosmodule
testmodule
androidmodule
docsmodule
webmodule
assetsmodule

Technology stack

DartPHPLOCKPBXPROJSQLMarkdownFlutter / Dart
Dart66.7%
PHP14.5%
LOCK4.0%
PBXPROJ3.8%
SQL3.1%
Markdown1.3%
XML0.9%
YAML0.9%