From d20d44a1f3cab8db8f181ca5392222b5ef78a654 Mon Sep 17 00:00:00 2001 From: egecankomur Date: Sat, 10 Oct 2026 14:52:05 +0300 Subject: [PATCH] Simplify lab trial dispatch and fix per-jaw pricing --- lib/core/services/pricing_service.dart | 8 +- .../clinic/jobs/clinic_jobs_repository.dart | 6 +- .../lab/jobs/lab_job_detail_screen.dart | 88 ++++++++------- .../lab/jobs/lab_jobs_repository.dart | 48 ++++---- lib/models/job.dart | 25 ++++- test/pricing_workflow_test.dart | 103 ++++++++++++++++++ 6 files changed, 204 insertions(+), 74 deletions(-) create mode 100644 test/pricing_workflow_test.dart diff --git a/lib/core/services/pricing_service.dart b/lib/core/services/pricing_service.dart index 168911c..c05a855 100644 --- a/lib/core/services/pricing_service.dart +++ b/lib/core/services/pricing_service.dart @@ -38,7 +38,6 @@ class PricingService { int billableUnitsForTeeth(ProstheticType type, Iterable teeth) { final selected = teeth.toSet(); if (selected.isEmpty) return billableUnitsForType(type, 0); - if (type.family == ProstheticFamily.hareketli) return 1; final upper = { for (int i = 11; i <= 18; i++) i, @@ -49,6 +48,11 @@ class PricingService { for (int i = 41; i <= 48; i++) i, }; + if (type.family == ProstheticFamily.hareketli) { + return (selected.any(upper.contains) ? 1 : 0) + + (selected.any(lower.contains) ? 1 : 0); + } + var units = 0; final remaining = Set.from(selected); if (upper.every(selected.contains)) { @@ -67,7 +71,7 @@ class PricingService { ProstheticType.hareketli || ProstheticType.tamProtez || ProstheticType.parsiyel => - 'vaka', + 'çene', _ => 'diş', }; } diff --git a/lib/features/clinic/jobs/clinic_jobs_repository.dart b/lib/features/clinic/jobs/clinic_jobs_repository.dart index 5b2eff1..a0acec3 100644 --- a/lib/features/clinic/jobs/clinic_jobs_repository.dart +++ b/lib/features/clinic/jobs/clinic_jobs_repository.dart @@ -112,7 +112,11 @@ class ClinicJobsRepository { } Future approveAtClinic(String jobId, Job job, {String? note}) async { - final nextStep = job.nextStep; + if (job.status != JobStatus.inProgress || + job.location != JobLocation.atClinic) { + throw StateError('Yalnızca klinikteki üretim işleri onaylanabilir.'); + } + final nextStep = job.provaRequired ? job.currentStep : job.nextStep; if (nextStep == null) throw Exception('Bu aşamadan ileri gidilemez.'); final record = await _pb.collection('jobs').update(jobId, body: { diff --git a/lib/features/lab/jobs/lab_job_detail_screen.dart b/lib/features/lab/jobs/lab_job_detail_screen.dart index 3783695..aeb8088 100644 --- a/lib/features/lab/jobs/lab_job_detail_screen.dart +++ b/lib/features/lab/jobs/lab_job_detail_screen.dart @@ -189,15 +189,19 @@ class _LabJobDetailScreenState extends ConsumerState { } } - void _showHandToClinicSheet(Job job) { + void _showHandToClinicSheet(Job job, {required bool finish}) { _showAdaptive( context, _HandToClinicSheet( job: job, + finish: finish, onDone: (Job updated) { if (mounted) { - setState(() => _job = updated.copyWith( - clinicName: job.clinicName, labName: job.labName)); + setState(() { + _job = updated.copyWith( + clinicName: job.clinicName, labName: job.labName); + _historyFuture = JobHistoryService.instance.listForJob(job.id); + }); } }, ), @@ -525,20 +529,26 @@ class _LabJobDetailScreenState extends ConsumerState { backgroundColor: AppColors.success, ), ), - if (canSendToClinic) + if (canSendToClinic && job.provaRequired) ...[ FilledButton.icon( - onPressed: () => _showHandToClinicSheet(job), + onPressed: () => _showHandToClinicSheet(job, finish: false), icon: const Icon(Icons.send_outlined), - label: Text( - (job.isLastStep) - ? 'Son Prova - Teslime Gönder' - : 'Prova için Kliniğe Gönder', - ), + label: const Text('Provaya Gönder'), style: FilledButton.styleFrom( minimumSize: const Size.fromHeight(52), - backgroundColor: (job.isLastStep) - ? AppColors.success - : AppColors.inProgress, + backgroundColor: AppColors.inProgress, + ), + ), + const SizedBox(height: 12), + ], + if (canSendToClinic) + FilledButton.icon( + onPressed: () => _showHandToClinicSheet(job, finish: true), + icon: const Icon(Icons.check_circle_outline), + label: const Text('Bitime Gönder'), + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(52), + backgroundColor: AppColors.success, ), ), if (canCancelJobs && job.status == JobStatus.pending) ...[ @@ -674,8 +684,13 @@ class _LatestRevisionBanner extends StatelessWidget { // ── Hand to Clinic Sheet ───────────────────────────────────────────────────── class _HandToClinicSheet extends StatefulWidget { - const _HandToClinicSheet({required this.job, required this.onDone}); + const _HandToClinicSheet({ + required this.job, + required this.finish, + required this.onDone, + }); final Job job; + final bool finish; final void Function(Job updatedJob) onDone; @override @@ -685,6 +700,7 @@ class _HandToClinicSheet extends StatefulWidget { class _HandToClinicSheetState extends State<_HandToClinicSheet> { final _noteController = TextEditingController(); bool _sending = false; + String? _noteError; @override void dispose() { @@ -696,20 +712,8 @@ class _HandToClinicSheetState extends State<_HandToClinicSheet> { Widget build(BuildContext context) { final isDesktop = MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint; - final currentStep = widget.job.currentStep; - final isLast = widget.job.isLastStep; - final stepLabel = currentStep?.label ?? ''; - final requiresClinicApproval = currentStep?.requiresClinicApproval ?? true; - final continuesInLab = !widget.job.provaRequired && - currentStep == JobStep.olcuKontrol && - widget.job.nextStep == JobStep.cilaBitim; - final buttonLabel = isLast - ? (widget.job.provaRequired - ? 'Son Prova · Teslime Gönder' - : 'Teslime Gönder') - : requiresClinicApproval && !continuesInLab - ? '$stepLabel için Kliniğe Gönder' - : '$stepLabel tamamlandı, sonraki adıma geç'; + final isLast = widget.finish; + final buttonLabel = isLast ? 'Bitime Gönder' : 'Provaya Gönder'; final buttonColor = isLast ? AppColors.success : AppColors.inProgress; return Container( @@ -737,18 +741,17 @@ class _HandToClinicSheetState extends State<_HandToClinicSheet> { const SizedBox(height: 8), Text( isLast - ? 'İş teslim edilecek olarak işaretlenecek.' - : requiresClinicApproval && !continuesInLab - ? 'İş klinikteki prova veya onay için gönderilecek.' - : 'Bu iç adım tamamlanacak ve iş laboratuvarda ilerleyecek.', + ? 'Cila / bitim tamamlandıktan sonra seçin. İş teslim için gönderildi olarak işaretlenecek.' + : 'İş üretim aşamasında kalır. Klinik onayından sonra gerektiği kadar tekrar provaya gönderebilirsiniz.', style: const TextStyle(color: AppColors.textSecondary), ), const SizedBox(height: 16), TextField( controller: _noteController, - decoration: const InputDecoration( - labelText: 'Not (isteğe bağlı)', - hintText: 'Klinik için not ekleyin...', + decoration: InputDecoration( + labelText: 'Aşama açıklaması', + hintText: 'Yapılan işlemi ve klinik için açıklamanızı yazın...', + errorText: _noteError, ), maxLines: 3, ), @@ -757,6 +760,10 @@ class _HandToClinicSheetState extends State<_HandToClinicSheet> { onPressed: _sending ? null : () async { + if (_noteController.text.trim().isEmpty) { + setState(() => _noteError = 'Aşama açıklaması yazın.'); + return; + } setState(() => _sending = true); final navigator = Navigator.of(context); final messenger = ScaffoldMessenger.of(context); @@ -765,20 +772,17 @@ class _HandToClinicSheetState extends State<_HandToClinicSheet> { await LabJobsRepository.instance.handToClinic( widget.job.id, widget.job, - note: _noteController.text.trim().isEmpty - ? null - : _noteController.text.trim(), + finish: widget.finish, + note: _noteController.text.trim(), ); + widget.onDone(updated); navigator.pop(); messenger.showSnackBar( SnackBar( content: Text(isLast ? 'İş teslim için gönderildi' - : requiresClinicApproval && !continuesInLab - ? 'Onay için kliniğe gönderildi' - : 'İş bir sonraki iç adıma geçirildi')), + : 'İş provaya gönderildi')), ); - if (context.mounted) widget.onDone(updated); } catch (e) { if (context.mounted) { setState(() => _sending = false); diff --git a/lib/features/lab/jobs/lab_jobs_repository.dart b/lib/features/lab/jobs/lab_jobs_repository.dart index a31a763..34662de 100644 --- a/lib/features/lab/jobs/lab_jobs_repository.dart +++ b/lib/features/lab/jobs/lab_jobs_repository.dart @@ -83,40 +83,34 @@ class LabJobsRepository { return job; } - Future handToClinic(String jobId, Job job, {String? note}) async { - final currentStep = job.currentStep; - if (currentStep == null) { - throw Exception('Geçerli bir iş adımı bulunamadı.'); + Future handToClinic(String jobId, Job job, + {required bool finish, required String note}) async { + if (note.trim().isEmpty) { + throw ArgumentError('Aşama açıklaması gereklidir.'); } - - final isFinal = currentStep == JobStep.cilaBitim; - final nextStep = job.nextStep; - final shouldStayAtLabAfterMeasure = !job.provaRequired && - currentStep == JobStep.olcuKontrol && - nextStep == JobStep.cilaBitim; - final patch = isFinal - ? {'status': 'sent', 'location': 'at_clinic'} - : currentStep.requiresClinicApproval && !shouldStayAtLabAfterMeasure - ? {'location': 'at_clinic'} - : { - 'current_step': nextStep?.value, - 'location': 'at_lab', - }; + final dispatchStep = job.labDispatchStep(finish: finish); + final steps = [...job.stepTemplate]; + if (!steps.contains(dispatchStep)) { + steps.insert(0, dispatchStep); + } + final patch = { + 'status': finish ? 'sent' : 'in_progress', + 'location': 'at_clinic', + 'current_step': dispatchStep.value, + 'workflow_steps': steps.map((step) => step.value).toList(), + }; final record = await _pb.collection('jobs').update(jobId, body: patch); final updated = Job.fromJson(record.toJson()); - unawaited(JobHistoryService.instance.append( + await JobHistoryService.instance.append( jobId: jobId, clinicTenantId: job.clinicTenantId, labTenantId: job.labTenantId, - action: (currentStep.requiresClinicApproval && - !shouldStayAtLabAfterMeasure) || - isFinal - ? JobHistoryAction.handedToClinic - : JobHistoryAction.stepCompleted, - step: currentStep, - note: note, - )); + action: JobHistoryAction.handedToClinic, + step: dispatchStep, + note: + '${finish ? "Bitime gönderildi" : "Provaya gönderildi"}: ${note.trim()}', + ); return updated; } diff --git a/lib/models/job.dart b/lib/models/job.dart index c0595f7..a77cac9 100644 --- a/lib/models/job.dart +++ b/lib/models/job.dart @@ -47,8 +47,8 @@ const visibleProstheticTypes = [ extension JobStatusExt on JobStatus { String get label => switch (this) { - JobStatus.pending => 'Bekliyor', - JobStatus.inProgress => 'İşlemde', + JobStatus.pending => 'Üretim aşamasında · Kabul bekliyor', + JobStatus.inProgress => 'Üretim aşamasında', JobStatus.sent => 'Gönderildi', JobStatus.delivered => 'Teslim Alındı', JobStatus.cancelled => 'İptal', @@ -556,6 +556,27 @@ class Job { bool get isLastStep => currentStep != null && currentStep == stepTemplate.last; + JobStep labDispatchStep({required bool finish}) { + if (status != JobStatus.inProgress || location != JobLocation.atLab) { + throw StateError('İş üretim aşamasında ve laboratuvarda olmalıdır.'); + } + if (finish) return JobStep.cilaBitim; + if (!provaRequired) { + throw StateError('Provasız iş provaya gönderilemez.'); + } + const trialSteps = { + JobStep.altYapiProva, + JobStep.ustYapiProva, + JobStep.mumProva, + JobStep.dislerProva, + JobStep.dayanakProva, + JobStep.kronProva, + }; + if (trialSteps.contains(currentStep)) return currentStep!; + return stepTemplate.where(trialSteps.contains).firstOrNull ?? + JobStep.altYapiProva; + } + /// The next step after currentStep in this job's template, or null if done. JobStep? get nextStep { if (currentStep == null) return stepTemplate.firstOrNull; diff --git a/test/pricing_workflow_test.dart b/test/pricing_workflow_test.dart new file mode 100644 index 0000000..dc92ea3 --- /dev/null +++ b/test/pricing_workflow_test.dart @@ -0,0 +1,103 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:dls_app/core/services/pricing_service.dart'; +import 'package:dls_app/models/job.dart'; +import 'package:dls_app/models/prosthetic_product.dart'; + +void main() { + final upper = [ + for (var tooth = 11; tooth <= 18; tooth++) tooth, + for (var tooth = 21; tooth <= 28; tooth++) tooth, + ]; + final lower = [ + for (var tooth = 31; tooth <= 38; tooth++) tooth, + for (var tooth = 41; tooth <= 48; tooth++) tooth, + ]; + + test('Each selected jaw costs one unit, including legacy removable types', + () { + for (final type in [ + ProstheticType.hareketli, + ProstheticType.tamProtez, + ProstheticType.parsiyel, + ProstheticType.sabit, + ]) { + for (final teeth in [ + upper, + lower, + [...upper, ...lower] + ]) { + final result = PricingService.instance.calculate( + product: const ProstheticProduct( + id: 'product', + labTenantId: 'lab', + name: 'Protez', + prostheticType: 'hareketli', + unitPrice: 5000, + ), + prostheticType: type, + memberCount: teeth.length, + teeth: teeth, + clinicTenantId: 'clinic', + discounts: [], + ); + expect(result.finalAmount, teeth.length == 32 ? 10000 : 5000); + } + } + }); + + test('Partial removable jaws count separately; fixed teeth stay per tooth', + () { + expect( + PricingService.instance + .billableUnitsForTeeth(ProstheticType.hareketli, [11, 12, 31]), + 2); + expect( + PricingService.instance + .billableUnitsForTeeth(ProstheticType.sabit, [11, 12, 31]), + 3); + }); + + Job job({bool provaRequired = true}) => Job( + id: 'job', + clinicTenantId: 'clinic', + labTenantId: 'lab', + patientCode: 'patient', + prostheticType: ProstheticType.sabit, + memberCount: 1, + status: JobStatus.inProgress, + dateCreated: DateTime(2026), + currentStep: JobStep.olcuKontrol, + location: JobLocation.atLab, + provaRequired: provaRequired, + ); + + test('Trials can repeat without automatically advancing to finishing', () { + final initial = job(); + final trial = initial.labDispatchStep(finish: false); + expect(trial, isNot(JobStep.olcuKontrol)); + expect(trial, isNot(JobStep.cilaBitim)); + final returned = initial.copyWith(currentStep: trial); + expect(returned.labDispatchStep(finish: false), trial); + expect(returned.labDispatchStep(finish: true), JobStep.cilaBitim); + }); + + test('No-trial jobs can finish but cannot be sent to trial', () { + expect(() => job(provaRequired: false).labDispatchStep(finish: false), + throwsStateError); + expect(job(provaRequired: false).labDispatchStep(finish: true), + JobStep.cilaBitim); + }); + + test('Jobs at clinic or already sent cannot be dispatched again', () { + expect( + () => job() + .copyWith(location: JobLocation.atClinic) + .labDispatchStep(finish: false), + throwsStateError); + expect( + () => job() + .copyWith(status: JobStatus.sent) + .labDispatchStep(finish: true), + throwsStateError); + }); +}