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/sync_contract.phpPHP
<?php
declare(strict_types=1);
use Machweis\Backend\SyncService;
use Machweis\Backend\MasterDataService;
use Machweis\Backend\EmployeeProfileService;
require dirname(__DIR__) . '/src/autoload.php';
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
@$pdo->sqliteCreateFunction('UTC_TIMESTAMP', static fn(): string => gmdate('Y-m-d H:i:s'));
$pdo->exec(<<<'SQL'
CREATE TABLE tenants (id TEXT PRIMARY KEY);
CREATE TABLE tenant_memberships (tenant_id TEXT NOT NULL, user_id TEXT NOT NULL, status TEXT NOT NULL, UNIQUE(tenant_id,user_id));
CREATE TABLE customers (id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, name TEXT NOT NULL, contact TEXT NOT NULL DEFAULT '', email TEXT NOT NULL DEFAULT '', phone TEXT NOT NULL DEFAULT '', address TEXT NOT NULL DEFAULT '', row_version INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, deleted_at TEXT NULL);
CREATE TABLE sites (id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, customer_id TEXT NOT NULL, name TEXT NOT NULL, address TEXT NOT NULL DEFAULT '', row_version INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, deleted_at TEXT NULL);
CREATE TABLE jobs (id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, customer_id TEXT NOT NULL, site_id TEXT NULL, number TEXT NOT NULL, title TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'open', row_version INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, deleted_at TEXT NULL);
CREATE TABLE employee_profiles (id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, user_id TEXT NULL, employee_number TEXT NOT NULL, first_name TEXT NOT NULL, last_name TEXT NOT NULL, email TEXT NOT NULL DEFAULT '', phone TEXT NOT NULL DEFAULT '', is_active INTEGER NOT NULL DEFAULT 1, row_version INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, deleted_at TEXT NULL);
CREATE TABLE job_assignments (id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, job_id TEXT NOT NULL, employee_id TEXT NOT NULL, assigned_from TEXT NULL, assigned_until TEXT NULL, row_version INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, deleted_at TEXT NULL, UNIQUE(job_id,employee_id));
CREATE TABLE sync_changes (change_id INTEGER PRIMARY KEY AUTOINCREMENT, tenant_id TEXT NOT NULL, entity_type TEXT NOT NULL, entity_id TEXT NOT NULL, operation TEXT NOT NULL, row_version INTEGER NOT NULL, data_json TEXT NOT NULL, changed_at TEXT NOT NULL);
CREATE INDEX sync_changes_tenant_cursor ON sync_changes(tenant_id,change_id);
CREATE TABLE sync_operations (id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, operation_id TEXT NOT NULL, idempotency_key TEXT NOT NULL, request_hash TEXT NOT NULL, result_json TEXT NULL, http_status INTEGER NULL, created_at TEXT NOT NULL, completed_at TEXT NULL, UNIQUE(tenant_id,operation_id), UNIQUE(tenant_id,idempotency_key));
INSERT INTO tenants (id) VALUES ('tenant-a'), ('tenant-b');
SQL);
$sync = new SyncService($pdo);
$ids = [
'customer' => '10000000-0000-4000-8000-000000000001',
'site' => '10000000-0000-4000-8000-000000000002',
'job' => '10000000-0000-4000-8000-000000000003',
'employee' => '10000000-0000-4000-8000-000000000004',
'assignment' => '10000000-0000-4000-8000-000000000005',
'tenant_b_customer' => '20000000-0000-4000-8000-000000000001',
];
$operation = static fn(string $operationId, string $entity, string $action, string $entityId, int $baseRevision, array $payload = []): array => [
'operation_id' => $operationId,
'idempotency_key' => 'key-' . $operationId,
'entity' => $entity,
'action' => $action,
'entity_id' => $entityId,
'base_revision' => $baseRevision,
'payload' => $payload,
];
$createCustomer = $operation('op-customer-create', 'customers', 'create', $ids['customer'], 0, ['name' => 'Kunde A']);
$first = $sync->push('tenant-a', 'owner', [$createCustomer]);
assert($first['status'] === 200);
assert($first['results'][0]['status'] === 'applied');
assert($first['results'][0]['row_version'] === 1);
assert($first['results'][0]['replayed'] === false);
$repeat = $sync->push('tenant-a', 'owner', [$createCustomer]);
assert($repeat['status'] === 200);
assert($repeat['results'][0]['replayed'] === true);
assert((int) $pdo->query("SELECT COUNT(*) FROM customers WHERE tenant_id='tenant-a'")->fetchColumn() === 1);
assert((int) $pdo->query("SELECT COUNT(*) FROM sync_changes WHERE tenant_id='tenant-a'")->fetchColumn() === 1);
$reusedKey = $createCustomer;
$reusedKey['payload']['name'] = 'Manipulierter Inhalt';
$reused = $sync->push('tenant-a', 'owner', [$reusedKey]);
assert($reused['status'] === 422);
assert($reused['results'][0]['code'] === 'idempotency_key_reused');
$batch = [
$operation('op-site-create', 'sites', 'create', $ids['site'], 0, ['customer_id'=>$ids['customer'],'name'=>'Baustelle']),
$operation('op-job-create', 'jobs', 'create', $ids['job'], 0, ['customer_id'=>$ids['customer'],'site_id'=>$ids['site'],'number'=>'A-1','title'=>'Auftrag']),
$operation('op-employee-create', 'employee_profiles', 'create', $ids['employee'], 0, ['employee_number'=>'M-1','first_name'=>'Max','last_name'=>'Muster','is_active'=>true]),
$operation('op-assignment-create', 'job_assignments', 'create', $ids['assignment'], 0, ['job_id'=>$ids['job'],'employee_id'=>$ids['employee']]),
];
$created = $sync->push('tenant-a', 'owner', $batch);
assert($created['status'] === 200);
assert(array_column($created['results'], 'status') === ['applied','applied','applied','applied']);
$update = $operation('op-customer-update', 'customers', 'update', $ids['customer'], 1, ['name'=>'Kunde A aktualisiert']);
$updated = $sync->push('tenant-a', 'office', [$update]);
assert($updated['status'] === 200);
assert($updated['results'][0]['row_version'] === 2);
$stale = $operation('op-customer-stale', 'customers', 'update', $ids['customer'], 1, ['name'=>'Veraltet']);
$conflict = $sync->push('tenant-a', 'owner', [$stale]);
assert($conflict['status'] === 409);
assert($conflict['results'][0]['code'] === 'row_version_conflict');
assert($conflict['results'][0]['current_row_version'] === 2);
assert($sync->push('tenant-a', 'owner', [$stale])['results'][0]['replayed'] === true);
$malformed = $sync->push('tenant-a','owner',[['entity'=>'customers']]);
assert($malformed['status'] === 422);
assert($malformed['results'][0]['code'] === 'validation_failed');
$forbiddenEmployee = $operation('op-employee-supervisor', 'employee_profiles', 'update', $ids['employee'], 1, ['phone'=>'123']);
$forbidden = $sync->push('tenant-a', 'supervisor', [$forbiddenEmployee]);
assert($forbidden['status'] === 403);
assert($forbidden['results'][0]['code'] === 'forbidden');
$delete = $operation('op-assignment-delete', 'job_assignments', 'delete', $ids['assignment'], 1);
$deleted = $sync->push('tenant-a', 'supervisor', [$delete]);
assert($deleted['status'] === 200);
assert($deleted['results'][0]['row_version'] === 2);
$pullFirstPage = $sync->pull('tenant-a', 0, 2);
assert(count($pullFirstPage['changes']) === 2);
assert($pullFirstPage['has_more'] === true);
$pullNextPage = $sync->pull('tenant-a', $pullFirstPage['cursor'], 100);
assert(count($pullNextPage['changes']) === 5);
assert($pullNextPage['has_more'] === false);
assert($pullNextPage['changes'][array_key_last($pullNextPage['changes'])]['operation'] === 'delete');
assert($sync->pull('tenant-a', $pullNextPage['cursor'])['changes'] === []);
$tenantBOperation = $operation('op-customer-create', 'customers', 'create', $ids['tenant_b_customer'], 0, ['name'=>'Kunde B']);
$tenantB = $sync->push('tenant-b', 'owner', [$tenantBOperation]);
assert($tenantB['status'] === 200);
$tenantBPull = $sync->pull('tenant-b', 0);
assert(count($tenantBPull['changes']) === 1);
assert($tenantBPull['changes'][0]['data']['tenant_id'] === 'tenant-b');
foreach ($sync->pull('tenant-a', 0)['changes'] as $change) assert($change['data']['tenant_id'] === 'tenant-a');
$crossTenantUpdate=$operation('op-cross-tenant','customers','update',$ids['customer'],2,['name'=>'Fremdzugriff']);
$crossTenant=$sync->push('tenant-b','owner',[$crossTenantUpdate]);
assert($crossTenant['status']===409);
assert($crossTenant['results'][0]['current_row_version']===null);
assert($pdo->query("SELECT name FROM customers WHERE id='".$ids['customer']."'")->fetchColumn()==='Kunde A aktualisiert');
$beforeRestWrites=(int)$pdo->query("SELECT COUNT(*) FROM sync_changes WHERE tenant_id='tenant-a'")->fetchColumn();
(new MasterDataService($pdo))->createCustomer('tenant-a',['name'=>'REST-Kunde']);
(new EmployeeProfileService($pdo))->create('tenant-a',['employeeNumber'=>'REST-M-1','firstName'=>'Erika','lastName'=>'Muster','isActive'=>true]);
assert((int)$pdo->query("SELECT COUNT(*) FROM sync_changes WHERE tenant_id='tenant-a'")->fetchColumn()===$beforeRestWrites+2);
$migration=file_get_contents(dirname(__DIR__).'/migrations/005_master_data_sync.sql');
assert(is_string($migration));
assert(str_contains($migration,'UNIQUE KEY sync_operations_tenant_operation (tenant_id, operation_id)'));
assert(str_contains($migration,'UNIQUE KEY sync_operations_tenant_idempotency (tenant_id, idempotency_key)'));
assert(str_contains($migration,'INDEX sync_changes_tenant_cursor (tenant_id, change_id)'));
$serviceSource=file_get_contents(dirname(__DIR__).'/src/SyncService.php');
assert(is_string($serviceSource));
assert(!str_contains($serviceSource,'change_id AS cursor'));
assert(str_contains($serviceSource,"'cursor' => (int) \$row['change_id']"));
fwrite(STDOUT, "sync_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%