class DocumentModel { final String id; final String tanggal; final String noPo; final String noSo; final String noDo; final String kepadaYth; final String orderUntuk; final String alamat; final String platTruk; final String namaDriver; final String namaPenerima; final double? latitude; final double? longitude; final List items; final DateTime createdAt; final bool parsed; /// 'pending' | 'done' | 'failed', sourced from the backend's `parseStatus` /// (backend task 9.1, `document-mapper.ts`) - only populated by /// `GET /api/v1/documents/:id` (see `pending_documents_provider.dart`'s /// `_pollUntilParsed`, which polls that route instead of scanning the /// whole list). Null for responses that don't include it (e.g. the PUT /// route's own hand-built response, or cached Hive rows). final String? parseStatus; /// 'DO' or 'Product', sourced from the backend's persisted `scan_mode` /// column (see backend task 9.1, `document-mapper.ts`'s `docType`) - the /// real replacement for the `orderUntuk == 'PRODUCT SCAN'` sentinel that /// every call site used to check (editing that display field used to /// silently move a document between tabs). final String docType; /// True once the user has confirmed this document (`PUT /documents/:id`), /// sourced from the backend's `confirmed` column (backend task 10.1) - /// separates "OCR finished" (`parsed`) from "user confirmed," since a /// document only re-enters `GET /api/v1/documents` (and this field is only /// meaningfully `false`) once confirmed. Defaults to `true` for /// legacy/cached responses that predate the column - see /// docs/api-contract-map.md G11. final bool confirmed; /// Top-5 SKU candidates from the single classify+OCR pass the backend runs /// at upload time for Product Scan documents (backend task on gap G3, /// `document-mapper.ts`'s `productScan.possibleMatches`) - lets the product /// editor render immediately instead of re-running the GPU pipeline a /// second time on review. Empty for DO documents, or Product documents /// parsed before this existed (already-confirmed docs also lose it, since /// it's only needed during the initial review). final List> productScanMatches; /// OCR-extracted expiry date from that same single pass /// (`productScan.extractedExpiryDate`). Empty string when absent/not found. final String productScanExtractedExpiryDate; DocumentModel({ required this.id, required this.tanggal, required this.noPo, required this.noSo, required this.noDo, required this.kepadaYth, required this.orderUntuk, required this.alamat, required this.platTruk, required this.namaDriver, required this.namaPenerima, this.latitude, this.longitude, required this.items, required this.createdAt, this.parsed = false, this.parseStatus, this.docType = 'DO', this.confirmed = true, this.productScanMatches = const [], this.productScanExtractedExpiryDate = '', }); factory DocumentModel.fromJson(Map json) { var header = json['header'] ?? {}; var shipment = json['shipment'] ?? {}; var itemsList = json['items'] as List? ?? []; return DocumentModel( id: json['id'] ?? DateTime.now().millisecondsSinceEpoch.toString(), tanggal: header['tanggal']?.toString() ?? '', noPo: header['no_po']?.toString() ?? '', noSo: header['no_so']?.toString() ?? '', noDo: header['no_do']?.toString() ?? '', kepadaYth: shipment['kepada_yth']?.toString() ?? '', orderUntuk: shipment['order_untuk']?.toString() ?? '', alamat: shipment['alamat']?.toString() ?? '', platTruk: shipment['plat_truk']?.toString() ?? '', namaDriver: json['nama_driver']?.toString() ?? shipment['nama_driver']?.toString() ?? '', namaPenerima: json['nama_penerima']?.toString() ?? shipment['nama_penerima']?.toString() ?? '', latitude: json['latitude'] != null ? double.tryParse(json['latitude'].toString()) : null, longitude: json['longitude'] != null ? double.tryParse(json['longitude'].toString()) : null, items: itemsList.map((e) => DocumentItem.fromJson(Map.from(e))).toList(), createdAt: json['created_at'] != null ? DateTime.parse(json['created_at'].toString()) : (json['createdAt'] != null ? DateTime.parse(json['createdAt'].toString()) : DateTime.now()), parsed: json['parsed'] as bool? ?? false, parseStatus: json['parseStatus']?.toString(), // Prefer the backend's persisted docType (backend task 9.1). Fall back // to the legacy orderUntuk sentinel for responses/cached rows that // predate that column (e.g. the upload endpoint's fresh-upload stub, // or Hive entries saved before this field existed) - mirrors the same // fallback backend/pfm-web-app/src/utils/document-mapper.ts uses. docType: json['docType']?.toString() ?? (shipment['order_untuk'] == 'PRODUCT SCAN' ? 'Product' : 'DO'), confirmed: json['confirmed'] as bool? ?? true, productScanMatches: ((json['productScan'] as Map?)?['possibleMatches'] as List?) ?.map((e) => Map.from(e as Map)) .toList() ?? const [], productScanExtractedExpiryDate: (json['productScan'] as Map?)?['extractedExpiryDate']?.toString() ?? '', ); } /// Round-trips with [fromJson]. Used to persist a document to Hive, both for /// the confirmed-documents box and the in-flight pending-documents queue. Map toJson() { return { 'id': id, 'created_at': createdAt.toIso8601String(), 'latitude': latitude, 'longitude': longitude, 'nama_driver': namaDriver, 'nama_penerima': namaPenerima, 'parsed': parsed, 'docType': docType, 'header': { 'tanggal': tanggal, 'no_po': noPo, 'no_so': noSo, 'no_do': noDo, }, 'shipment': { 'kepada_yth': kepadaYth, 'order_untuk': orderUntuk, 'alamat': alamat, 'plat_truk': platTruk, }, 'items': items.map((e) => { 'nomor_sku': e.nomorSku, 'nama_barang': e.namaBarang, 'banyak': e.banyak, 'jumlah': e.jumlah, }).toList(), }; } /// Body for `PUT /api/v1/documents/:id`. Shared by the editor's initial save and /// the pending-queue's sync retry so both send an identical payload shape. Map toPutPayload() { return { 'tanggal': tanggal, 'noPo': noPo, 'noSo': noSo, 'noDo': noDo, 'kepadaYth': kepadaYth, 'orderUntuk': orderUntuk, 'alamat': alamat, 'platTruk': platTruk, 'namaDriver': namaDriver, 'namaPenerima': namaPenerima, 'latitude': latitude, 'longitude': longitude, 'items': items.map((e) => { 'nomor_sku': e.nomorSku, 'nama_barang': e.namaBarang, 'banyak': e.banyak, 'jumlah': e.jumlah, }).toList(), }; } } class DocumentItem { final String nomorSku; final String namaBarang; final String banyak; final String jumlah; DocumentItem({ required this.nomorSku, required this.namaBarang, required this.banyak, required this.jumlah, }); factory DocumentItem.fromJson(Map json) { return DocumentItem( nomorSku: json['nomor_sku']?.toString() ?? '', namaBarang: json['nama_barang']?.toString() ?? '', banyak: json['banyak']?.toString() ?? '', jumlah: json['jumlah']?.toString() ?? '', ); } }