326 lines
14 KiB
Dart
326 lines
14 KiB
Dart
import 'package:pocketbase/pocketbase.dart';
|
||
import '../../core/api/pocketbase_client.dart';
|
||
import '../../models/tenant.dart';
|
||
|
||
// ── Value objects ─────────────────────────────────────────────────────────────
|
||
|
||
const _monthLabels = ['Oca','Şub','Mar','Nis','May','Haz','Tem','Ağu','Eyl','Eki','Kas','Ara'];
|
||
|
||
class MonthlyCount {
|
||
const MonthlyCount({required this.year, required this.month, required this.count});
|
||
final int year, month, count;
|
||
String get label => _monthLabels[month - 1];
|
||
}
|
||
|
||
class MonthlyAmount {
|
||
const MonthlyAmount({required this.year, required this.month, required this.amount});
|
||
final int year, month;
|
||
final double amount;
|
||
String get label => _monthLabels[month - 1];
|
||
}
|
||
|
||
class CounterpartStat {
|
||
const CounterpartStat({required this.name, required this.jobCount, required this.pendingRevenue, required this.paidRevenue});
|
||
final String name;
|
||
final int jobCount;
|
||
final double pendingRevenue, paidRevenue;
|
||
double get totalRevenue => pendingRevenue + paidRevenue;
|
||
}
|
||
|
||
class ActivityItem {
|
||
const ActivityItem({required this.jobId, this.patientCode, required this.action, required this.createdAt, this.note});
|
||
final String jobId, action;
|
||
final String? patientCode, note;
|
||
final DateTime createdAt;
|
||
|
||
String get actionLabel => switch (action) {
|
||
'accepted' => 'İş kabul edildi',
|
||
'handed_to_clinic' => 'Provaya gönderildi',
|
||
'approved' => 'Onaylandı',
|
||
'revision_requested' => 'Revizyon istendi',
|
||
'delivered' => 'Teslim edildi',
|
||
'cancelled' => 'İptal edildi',
|
||
_ => action,
|
||
};
|
||
|
||
bool get isNegative => action == 'revision_requested' || action == 'cancelled';
|
||
bool get isPositive => action == 'delivered' || action == 'approved' || action == 'accepted';
|
||
}
|
||
|
||
// ── Aggregated metrics ────────────────────────────────────────────────────────
|
||
|
||
class ReportMetrics {
|
||
const ReportMetrics({
|
||
required this.activeJobs,
|
||
required this.completedThisMonth,
|
||
required this.overdueJobs,
|
||
required this.revisionRate,
|
||
required this.avgCompletionDays,
|
||
required this.totalRevenue,
|
||
required this.pendingRevenue,
|
||
required this.currency,
|
||
required this.jobsByStatus,
|
||
required this.monthlyCounts,
|
||
required this.monthlyRevenue,
|
||
required this.byProstheticType,
|
||
required this.counterpartStats,
|
||
required this.recentActivity,
|
||
});
|
||
|
||
final int activeJobs;
|
||
final int completedThisMonth;
|
||
final int overdueJobs;
|
||
final double revisionRate; // 0-100
|
||
final double avgCompletionDays;
|
||
final double totalRevenue;
|
||
final double pendingRevenue;
|
||
final String currency;
|
||
|
||
final Map<String, int> jobsByStatus;
|
||
final List<MonthlyCount> monthlyCounts;
|
||
final List<MonthlyAmount> monthlyRevenue;
|
||
final Map<String, int> byProstheticType;
|
||
final List<CounterpartStat> counterpartStats;
|
||
final List<ActivityItem> recentActivity;
|
||
}
|
||
|
||
// ── Repository ────────────────────────────────────────────────────────────────
|
||
|
||
class ReportsRepository {
|
||
ReportsRepository._();
|
||
static final instance = ReportsRepository._();
|
||
|
||
PocketBase get _pb => PocketBaseClient.instance.pb;
|
||
|
||
Future<ReportMetrics> load(String tenantId, TenantKind kind) async {
|
||
final jobFilter = kind == TenantKind.lab
|
||
? 'lab_tenant_id = "$tenantId"'
|
||
: 'clinic_tenant_id = "$tenantId"';
|
||
final historyFilter = kind == TenantKind.lab
|
||
? 'lab_tenant_id = "$tenantId"'
|
||
: 'clinic_tenant_id = "$tenantId"';
|
||
|
||
final results = await Future.wait([
|
||
_pb.collection('jobs').getList(
|
||
filter: jobFilter,
|
||
perPage: 500,
|
||
expand: kind == TenantKind.lab ? 'clinic_tenant_id' : 'lab_tenant_id',
|
||
fields: 'id,status,prosthetic_type,clinic_tenant_id,lab_tenant_id,created,updated,due_date,price,currency,expand',
|
||
).catchError((_) => ResultList<RecordModel>()),
|
||
_pb.collection('finance_entries').getList(
|
||
filter: 'tenant_id = "$tenantId"',
|
||
perPage: 300,
|
||
fields: 'id,amount,currency,status,created,counterparty_name',
|
||
).catchError((_) => ResultList<RecordModel>()),
|
||
_pb.collection('job_status_history').getList(
|
||
filter: historyFilter,
|
||
perPage: 100,
|
||
expand: 'job_id',
|
||
fields: 'id,action_type,created,note,job_id,expand',
|
||
).catchError((_) => ResultList<RecordModel>()),
|
||
]);
|
||
|
||
final jobRecords = (results[0] as ResultList<RecordModel>).items;
|
||
final financeRecords = (results[1] as ResultList<RecordModel>).items;
|
||
final historyRecords = (results[2] as ResultList<RecordModel>).items;
|
||
|
||
return _aggregate(tenantId, kind, jobRecords, financeRecords, historyRecords);
|
||
}
|
||
|
||
ReportMetrics _aggregate(
|
||
String tenantId,
|
||
TenantKind kind,
|
||
List<RecordModel> jobRecords,
|
||
List<RecordModel> financeRecords,
|
||
List<RecordModel> historyRecords,
|
||
) {
|
||
final now = DateTime.now();
|
||
final thisMonthStart = DateTime(now.year, now.month, 1);
|
||
|
||
// ── Parse jobs ────────────────────────────────────────────────────────────
|
||
String _s(Map<String, dynamic> j, String k) {
|
||
final v = j[k];
|
||
if (v == null || v == '') return '';
|
||
return v.toString();
|
||
}
|
||
|
||
final jobs = jobRecords.map((r) {
|
||
final j = r.toJson();
|
||
final exp = j['expand'] as Map<String, dynamic>?;
|
||
final cpKey = kind == TenantKind.lab ? 'clinic_tenant_id' : 'lab_tenant_id';
|
||
final cpExp = exp?[cpKey] as Map<String, dynamic>?;
|
||
return _RawJob(
|
||
id: _s(j, 'id'),
|
||
status: _s(j, 'status'),
|
||
prostheticType: _s(j, 'prosthetic_type'),
|
||
clinicTenantId: _s(j, 'clinic_tenant_id'),
|
||
labTenantId: _s(j, 'lab_tenant_id'),
|
||
created: _parseDate(j['created']),
|
||
updated: _parseDate(j['updated']),
|
||
dueDate: j['due_date'] != null && j['due_date'] != '' ? _parseDate(j['due_date']) : null,
|
||
currency: _s(j, 'currency').isNotEmpty ? _s(j, 'currency') : 'TRY',
|
||
counterpartName: cpExp?['company_name'] as String? ?? '',
|
||
);
|
||
}).toList();
|
||
|
||
// ── Parse finance ─────────────────────────────────────────────────────────
|
||
String topCurrency = 'TRY';
|
||
final financeList = financeRecords.map((r) {
|
||
final j = r.toJson();
|
||
final cur = (j['currency'] as String?) ?? 'TRY';
|
||
if (cur.isNotEmpty) topCurrency = cur;
|
||
return _RawFinance(
|
||
status: (j['status'] as String?) ?? '',
|
||
amount: (j['amount'] as num?)?.toDouble() ?? 0,
|
||
created: _parseDate(j['created']),
|
||
counterpartyName: j['counterparty_name'] as String?,
|
||
);
|
||
}).toList();
|
||
|
||
// ── Parse history ─────────────────────────────────────────────────────────
|
||
final activity = historyRecords.map((r) {
|
||
final j = r.toJson();
|
||
final exp = j['expand'] as Map<String, dynamic>?;
|
||
final jobExp = exp?['job_id'] as Map<String, dynamic>?;
|
||
return ActivityItem(
|
||
jobId: (j['job_id'] as String?) ?? '',
|
||
patientCode: jobExp?['patient_code'] as String?,
|
||
action: (j['action_type'] as String?) ?? '',
|
||
createdAt: _parseDate(j['created']),
|
||
note: j['note'] as String?,
|
||
);
|
||
}).toList()
|
||
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||
|
||
// ── KPI metrics ───────────────────────────────────────────────────────────
|
||
final activeJobs = jobs.where((j) => j.status == 'in_progress' || j.status == 'pending').length;
|
||
final completedThisMonth = jobs.where((j) => j.status == 'delivered' && j.updated.isAfter(thisMonthStart)).length;
|
||
final overdueJobs = jobs.where((j) =>
|
||
j.dueDate != null &&
|
||
j.dueDate!.isBefore(now) &&
|
||
(j.status == 'in_progress' || j.status == 'pending')).length;
|
||
|
||
final revisions = activity.where((a) => a.action == 'revision_requested').length;
|
||
final revisionRate = activity.isNotEmpty ? revisions / activity.length * 100 : 0.0;
|
||
|
||
final deliveredJobs = jobs.where((j) => j.status == 'delivered').toList();
|
||
final avgCompletionDays = deliveredJobs.isNotEmpty
|
||
? deliveredJobs
|
||
.fold<int>(0, (s, j) => s + j.updated.difference(j.created).inDays) /
|
||
deliveredJobs.length
|
||
: 0.0;
|
||
|
||
// ── Finance totals ────────────────────────────────────────────────────────
|
||
final totalRevenue = financeList.where((f) => f.status == 'paid').fold<double>(0, (s, f) => s + f.amount);
|
||
final pendingRevenue = financeList.where((f) => f.status == 'pending').fold<double>(0, (s, f) => s + f.amount);
|
||
|
||
// ── Job status distribution ───────────────────────────────────────────────
|
||
final Map<String, int> jobsByStatus = {};
|
||
for (final j in jobs) {
|
||
jobsByStatus[j.status] = (jobsByStatus[j.status] ?? 0) + 1;
|
||
}
|
||
|
||
// ── Monthly job counts (last 6 months) ────────────────────────────────────
|
||
final monthKeys = List.generate(6, (i) {
|
||
final d = DateTime(now.year, now.month - 5 + i, 1);
|
||
return '${d.year}-${d.month}';
|
||
});
|
||
final monthMap = {for (final k in monthKeys) k: 0};
|
||
for (final j in jobs) {
|
||
final key = '${j.created.year}-${j.created.month}';
|
||
if (monthMap.containsKey(key)) monthMap[key] = monthMap[key]! + 1;
|
||
}
|
||
final monthlyCounts = monthKeys.map((k) {
|
||
final parts = k.split('-');
|
||
return MonthlyCount(year: int.parse(parts[0]), month: int.parse(parts[1]), count: monthMap[k]!);
|
||
}).toList();
|
||
|
||
// ── Monthly revenue (last 6 months) ──────────────────────────────────────
|
||
final revMap = {for (final k in monthKeys) k: 0.0};
|
||
for (final f in financeList) {
|
||
if (f.status == 'paid') {
|
||
final key = '${f.created.year}-${f.created.month}';
|
||
if (revMap.containsKey(key)) revMap[key] = revMap[key]! + f.amount;
|
||
}
|
||
}
|
||
final monthlyRevenue = monthKeys.map((k) {
|
||
final parts = k.split('-');
|
||
return MonthlyAmount(year: int.parse(parts[0]), month: int.parse(parts[1]), amount: revMap[k]!);
|
||
}).toList();
|
||
|
||
// ── By prosthetic type ────────────────────────────────────────────────────
|
||
final Map<String, int> byType = {};
|
||
for (final j in jobs) {
|
||
if (j.prostheticType.isNotEmpty) {
|
||
byType[j.prostheticType] = (byType[j.prostheticType] ?? 0) + 1;
|
||
}
|
||
}
|
||
|
||
// ── By counterpart ────────────────────────────────────────────────────────
|
||
final Map<String, int> cpCount = {};
|
||
final Map<String, double> cpPending = {}, cpPaid = {};
|
||
for (final j in jobs) {
|
||
final name = j.counterpartName.isNotEmpty ? j.counterpartName : '—';
|
||
cpCount[name] = (cpCount[name] ?? 0) + 1;
|
||
}
|
||
for (final f in financeList) {
|
||
final name = f.counterpartyName ?? '—';
|
||
if (f.status == 'pending') cpPending[name] = (cpPending[name] ?? 0) + f.amount;
|
||
if (f.status == 'paid') cpPaid[name] = (cpPaid[name] ?? 0) + f.amount;
|
||
}
|
||
final counterparts = cpCount.entries
|
||
.map((e) => CounterpartStat(
|
||
name: e.key,
|
||
jobCount: e.value,
|
||
pendingRevenue: cpPending[e.key] ?? 0,
|
||
paidRevenue: cpPaid[e.key] ?? 0,
|
||
))
|
||
.toList()
|
||
..sort((a, b) => b.jobCount.compareTo(a.jobCount));
|
||
|
||
return ReportMetrics(
|
||
activeJobs: activeJobs,
|
||
completedThisMonth: completedThisMonth,
|
||
overdueJobs: overdueJobs,
|
||
revisionRate: revisionRate,
|
||
avgCompletionDays: avgCompletionDays,
|
||
totalRevenue: totalRevenue,
|
||
pendingRevenue: pendingRevenue,
|
||
currency: topCurrency,
|
||
jobsByStatus: jobsByStatus,
|
||
monthlyCounts: monthlyCounts,
|
||
monthlyRevenue: monthlyRevenue,
|
||
byProstheticType: byType,
|
||
counterpartStats: counterparts.take(5).toList(),
|
||
recentActivity: activity.take(30).toList(),
|
||
);
|
||
}
|
||
|
||
static DateTime _parseDate(dynamic v) {
|
||
if (v == null || v == '') return DateTime(2000);
|
||
return DateTime.tryParse(v.toString()) ?? DateTime(2000);
|
||
}
|
||
}
|
||
|
||
// ── Internal raw models ───────────────────────────────────────────────────────
|
||
|
||
class _RawJob {
|
||
const _RawJob({
|
||
required this.id, required this.status, required this.prostheticType,
|
||
required this.clinicTenantId, required this.labTenantId,
|
||
required this.created, required this.updated,
|
||
required this.currency, required this.counterpartName, this.dueDate,
|
||
});
|
||
final String id, status, prostheticType, clinicTenantId, labTenantId, currency, counterpartName;
|
||
final DateTime created, updated;
|
||
final DateTime? dueDate;
|
||
}
|
||
|
||
class _RawFinance {
|
||
const _RawFinance({required this.status, required this.amount, required this.created, this.counterpartyName});
|
||
final String status;
|
||
final double amount;
|
||
final DateTime created;
|
||
final String? counterpartyName;
|
||
}
|