Adopt agents-settings kit, ship Product/SKU scan models, harden auth, verify OCR accuracy

Backend (app-pfm-ocr-v2/backend):
- Product/SKU scan feature complete: trained DINOv2 index (118 reference
  photos, 16 SKU classes) and YOLO classifier (83.3% top-1 val accuracy),
  fixed scripts/install-pipeline.sh (was missing ultralytics/torch), fully
  browser-verified end-to-end on /scan-pfm. Mobile m-scan-pfm page cancelled
  (Flutter app handles mobile; web UI is desktop-only for pipeline testing).
- Fixed a real data-loss bug: Save Ground Truth (scan-pfm and the DO-flow's
  manual-label) was silently writing into the pfm-web-app container's
  ephemeral filesystem instead of the host, because /sources wasn't
  bind-mounted in docker-compose.yml. Added the mount, recovered an
  orphaned entry.
- accounts.password is now bcrypt-hashed (bcryptjs, idempotent migration
  in db/init.ts) instead of plaintext; login route compares hashes.
- /api/v1/documents/* (list, PUT, upload) now enforces real 401 auth,
  matching what the Flutter client already sends. The "classic" routes
  deliberately stay open — they're dev-only web UI with no login flow and
  won't exist in production.
- OCR accuracy investigated end-to-end: real baseline is 95.10% overall
  (target met; accuracy_report.md was stale at 75.04%, now flagged). Fixed
  one genuine parser.ts bug (SO/DO field duplication in the global fallback
  regex); remaining gaps are OCR/layout-model limitations, not parser bugs.
- Adopted a standalone copy of the fhanyuh/agents-settings e/n workflow
  scoped to backend/ (AGENTS.md Part A/B split, SKILLS.md, plans/, docs/),
  independent of the root copy which now covers Flutter only.
- next-implementation.md deleted; content folded into
  backend/plans/next-enhancements.md for traceability.

Root:
- Adopted fhanyuh/agents-settings kit (AGENTS.md, SKILLS.md, plans/,
  docs/feature-list.md), scoped to the Flutter app only.
- Pending documents queue now persists to Hive (lib/core/storage) instead
  of memory-only, surviving an app kill mid-upload.

Removed backend_backup/ (stale Express/Prisma prototype, superseded by
pfm-web-app) and the completed plans/next-enhancement-plan.md checklist.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Rafhan Mazaya FathurrahmanandClaude Sonnet 5 committed 2026-07-08 11:56:32 +07:00
1 parent 3df9f6ec5d
commit e60ab63154
129 files changed
+8520 -6684

No files matched your search

@@ -4,7 +4,10 @@ import 'package:geolocator/geolocator.dart';
import '../../models/document_model.dart';
import '../../core/location/location_service.dart';
import '../../core/network/api_client.dart';
import '../../core/network/api_exception.dart';
import '../../core/storage/local_storage.dart';
import '../../config/app_config.dart';
import '../../main.dart';
enum PendingDocumentStatus { uploading, processing, success, error, syncFailed }
@@ -47,23 +50,139 @@ class PendingDocument {
createdAt: createdAt,
);
}
/// Round-trips with [fromJson] so the queue survives an OS-level app kill
/// instead of living in memory only (`LocalStorage.savePendingDocument`).
Map<String, dynamic> toJson() {
return {
'id': id,
'image_path': imagePath,
'status': status.name,
'document': document?.toJson(),
'latitude': latitude,
'longitude': longitude,
'error_message': errorMessage,
'created_at': createdAt.toIso8601String(),
};
}
factory PendingDocument.fromJson(Map<String, dynamic> json) {
PendingDocumentStatus status;
try {
status = PendingDocumentStatus.values.byName(json['status'] as String? ?? '');
} catch (_) {
status = PendingDocumentStatus.error;
}
final rawDocument = json['document'];
return PendingDocument(
id: json['id'] as String,
imagePath: json['image_path'] as String,
status: status,
document: rawDocument is Map
? DocumentModel.fromJson(Map<String, dynamic>.from(rawDocument))
: null,
latitude: (json['latitude'] as num?)?.toDouble(),
longitude: (json['longitude'] as num?)?.toDouble(),
errorMessage: json['error_message'] as String?,
createdAt: json['created_at'] != null ? DateTime.parse(json['created_at'] as String) : null,
);
}
}
class PendingDocumentsNotifier extends StateNotifier<List<PendingDocument>> {
PendingDocumentsNotifier(this._ref) : super([]);
PendingDocumentsNotifier(this._ref) : super([]) {
hydrate();
}
final Ref _ref;
LocalStorage get _storage => _ref.read(localStorageProvider);
/// Loads the queue persisted by a previous app run and resumes anything that
/// didn't reach a terminal state before the app was killed. Safe to resume
/// blindly: the upload endpoint dedupes by file hash server-side, so
/// re-uploading an item that actually finished before the kill just returns
/// the existing document instead of creating a duplicate.
Future<void> hydrate() async {
List<Map<String, dynamic>> persisted;
try {
persisted = _storage.getAllPendingDocuments();
} catch (_) {
// No persisted queue to resume (e.g. storage not yet initialized) -
// fall back to starting empty rather than crashing provider setup.
return;
}
if (persisted.isEmpty) return;
final docs = persisted.map(PendingDocument.fromJson).toList()
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
state = docs;
for (final doc in docs) {
if (doc.status == PendingDocumentStatus.uploading) {
// `retrySync`'s own transient flip to `uploading` is never persisted
// (see `_updateItemInMemory`), so a persisted `uploading` status only
// ever means "upload/process from scratch" - from `addDocument` or a
// `retryUpload` that got interrupted mid-flight.
_uploadAndProcess(doc.id, doc.imagePath, latitude: doc.latitude, longitude: doc.longitude);
} else if (doc.status == PendingDocumentStatus.processing && doc.document != null) {
_resumePolling(doc.id, doc.document!);
}
}
}
/// Applies [update] to the item with [id], both in in-memory state and on
/// disk, so every status transition survives an app restart.
void _updateItem(String id, PendingDocument Function(PendingDocument item) update) {
final updated = _updateItemInMemory(id, update);
if (updated != null) {
_persistPendingDocument(updated);
}
}
/// Best-effort disk write - a Hive/storage failure is a lost "resume after
/// restart" affordance, not something that should break the in-progress
/// upload/sync flow itself.
void _persistPendingDocument(PendingDocument doc) {
try {
_storage.savePendingDocument(doc.toJson()).catchError((_) {});
} catch (_) {}
}
void _removePersistedPendingDocument(String id) {
try {
_storage.removePendingDocument(id).catchError((_) {});
} catch (_) {}
}
/// Same as [_updateItem] but doesn't touch disk - for transient, optimistic
/// UI states that shouldn't change what a resumed app restart does (see
/// `retrySync`).
PendingDocument? _updateItemInMemory(String id, PendingDocument Function(PendingDocument item) update) {
PendingDocument? updated;
state = [
for (final item in state)
if (item.id == id)
(updated = update(item))
else
item
];
return updated;
}
void addDocument(String imagePath, {double? latitude, double? longitude}) {
final id = DateTime.now().millisecondsSinceEpoch.toString();
final newDoc = PendingDocument(
id: id,
id: id,
imagePath: imagePath,
latitude: latitude,
longitude: longitude,
);
state = [newDoc, ...state];
_persistPendingDocument(newDoc);
_uploadAndProcess(id, imagePath, latitude: latitude, longitude: longitude);
}
@@ -74,7 +193,7 @@ class PendingDocumentsNotifier extends StateNotifier<List<PendingDocument>> {
Future<void> _uploadAndProcess(String id, String imagePath, {double? latitude, double? longitude}) async {
double? lat = latitude;
double? lng = longitude;
if (lat == null || lng == null) {
try {
final position = await _determinePosition();
@@ -82,13 +201,7 @@ class PendingDocumentsNotifier extends StateNotifier<List<PendingDocument>> {
lat = position.latitude;
lng = position.longitude;
if (mounted) {
state = [
for (final item in state)
if (item.id == id)
item.copyWith(latitude: lat, longitude: lng)
else
item
];
_updateItem(id, (item) => item.copyWith(latitude: lat, longitude: lng));
}
}
} catch (_) {
@@ -114,97 +227,88 @@ class PendingDocumentsNotifier extends StateNotifier<List<PendingDocument>> {
);
final doc = DocumentModel.fromJson(response.data['data']);
if (!mounted) return;
state = [
for (final item in state)
if (item.id == id)
item.copyWith(status: PendingDocumentStatus.processing, document: doc)
else
item
];
// Poll until the document is parsed on the server
bool isParsed = false;
DocumentModel? parsedDoc;
int retries = 0;
const maxRetries = 60; // 2 minutes max polling (60 retries * 2s)
while (!isParsed && retries < maxRetries && mounted) {
await Future.delayed(const Duration(seconds: 2));
if (!mounted) return;
try {
final getRes = await apiClient.client.get(AppConfig.fetchDocumentsEndpoint);
if (getRes.statusCode == 200 && getRes.data['data'] is List) {
final list = (getRes.data['data'] as List)
.map((e) => DocumentModel.fromJson(Map<String, dynamic>.from(e)))
.toList();
final found = list.firstWhere(
(element) => element.id == doc.id,
orElse: () => doc,
);
if (found != doc) {
isParsed = true;
parsedDoc = found;
}
}
} catch (_) {
// Ignore network glitch during polling
}
retries++;
}
if (!mounted) return;
_updateItem(id, (item) => item.copyWith(status: PendingDocumentStatus.processing, document: doc));
if (isParsed && parsedDoc != null) {
state = [
for (final item in state)
if (item.id == id)
item.copyWith(status: PendingDocumentStatus.success, document: parsedDoc)
else
item
];
} else {
state = [
for (final item in state)
if (item.id == id)
item.copyWith(
status: PendingDocumentStatus.error,
errorMessage: 'Gagal mengekstrak data dari dokumen (Timeout).',
)
else
item
];
}
await _pollUntilParsed(id, doc);
} catch (e) {
if (!mounted) return;
state = [
for (final item in state)
if (item.id == id)
item.copyWith(status: PendingDocumentStatus.error, errorMessage: e.toString())
else
item
];
_updateItem(id, (item) => item.copyWith(status: PendingDocumentStatus.error, errorMessage: ApiException.from(e).displayMessage));
}
}
/// Resumes polling for an item whose upload already completed (status
/// `processing`) in a previous app run — never re-uploads the image.
Future<void> _resumePolling(String id, DocumentModel doc) async {
try {
await _pollUntilParsed(id, doc);
} catch (e) {
if (!mounted) return;
_updateItem(id, (item) => item.copyWith(status: PendingDocumentStatus.error, errorMessage: ApiException.from(e).displayMessage));
}
}
Future<void> _pollUntilParsed(String id, DocumentModel doc) async {
final apiClient = _ref.read(apiClientProvider);
// Poll until the document is parsed on the server
bool isParsed = false;
DocumentModel? parsedDoc;
int retries = 0;
// Server's OCR pass can take up to 210s worst case (see
// v1/documents/upload/route.ts), so poll comfortably past that instead
// of giving up right as the backend might still be finishing.
const maxRetries = 130; // ~4.3 minutes max polling (130 retries * 2s)
while (!isParsed && retries < maxRetries && mounted) {
await Future.delayed(const Duration(seconds: 2));
if (!mounted) return;
try {
final getRes = await apiClient.client.get(AppConfig.fetchDocumentsEndpoint);
if (getRes.statusCode == 200 && getRes.data['data'] is List) {
final list = (getRes.data['data'] as List)
.map((e) => DocumentModel.fromJson(Map<String, dynamic>.from(e)))
.toList();
final found = list.firstWhere(
(element) => element.id == doc.id,
orElse: () => doc,
);
if (found != doc) {
isParsed = true;
parsedDoc = found;
}
}
} catch (_) {
// Ignore network glitch during polling
}
retries++;
}
if (!mounted) return;
if (isParsed && parsedDoc != null) {
_updateItem(id, (item) => item.copyWith(status: PendingDocumentStatus.success, document: parsedDoc));
} else {
_updateItem(id, (item) => item.copyWith(
status: PendingDocumentStatus.error,
errorMessage: 'Gagal mengekstrak data dari dokumen (Timeout).',
));
}
}
void removeDocument(String id) {
state = state.where((item) => item.id != id).toList();
_removePersistedPendingDocument(id);
}
/// Marks a pending item as failed to sync after an editor save, keeping the
/// finalized [doc] so [retrySync] can resend it without re-running OCR.
void markSyncFailed(String id, DocumentModel doc, String message) {
state = [
for (final item in state)
if (item.id == id)
item.copyWith(status: PendingDocumentStatus.syncFailed, document: doc, errorMessage: message)
else
item
];
_updateItem(id, (item) => item.copyWith(status: PendingDocumentStatus.syncFailed, document: doc, errorMessage: message));
}
/// Resends a previously-failed editor save. Unlike [retryUpload], this never
@@ -216,13 +320,10 @@ class PendingDocumentsNotifier extends StateNotifier<List<PendingDocument>> {
final doc = matches.first.document;
if (doc == null) return;
state = [
for (final item in state)
if (item.id == id)
item.copyWith(status: PendingDocumentStatus.uploading, errorMessage: null)
else
item
];
// Transient/optimistic only - if the app is killed right here, a restart
// should resend the PUT (still `syncFailed` on disk), not redo the whole
// upload, so this particular flip is never persisted.
_updateItemInMemory(id, (item) => item.copyWith(status: PendingDocumentStatus.uploading, errorMessage: null));
try {
final apiClient = _ref.read(apiClientProvider);
@@ -234,13 +335,10 @@ class PendingDocumentsNotifier extends StateNotifier<List<PendingDocument>> {
removeDocument(id);
} catch (e) {
if (!mounted) return;
state = [
for (final item in state)
if (item.id == id)
item.copyWith(status: PendingDocumentStatus.syncFailed, errorMessage: 'Gagal sinkronisasi ke server: ${e.toString()}')
else
item
];
_updateItem(id, (item) => item.copyWith(
status: PendingDocumentStatus.syncFailed,
errorMessage: 'Gagal sinkronisasi ke server: ${ApiException.from(e).displayMessage}',
));
}
}
@@ -248,16 +346,7 @@ class PendingDocumentsNotifier extends StateNotifier<List<PendingDocument>> {
final doc = state.firstWhere((item) => item.id == id);
final lat = doc.latitude;
final lng = doc.longitude;
state = [
for (final item in state)
if (item.id == id)
item.copyWith(
status: PendingDocumentStatus.uploading,
errorMessage: null,
)
else
item
];
_updateItem(id, (item) => item.copyWith(status: PendingDocumentStatus.uploading, errorMessage: null));
_uploadAndProcess(id, doc.imagePath, latitude: lat, longitude: lng);
}
}