Simplify lab trial dispatch and fix per-jaw pricing

This commit is contained in:
egecankomur
2026-10-10 14:52:05 +03:00
parent f44e07f378
commit d20d44a1f3
6 changed files with 204 additions and 74 deletions
+6 -2
View File
@@ -38,7 +38,6 @@ class PricingService {
int billableUnitsForTeeth(ProstheticType type, Iterable<int> teeth) { int billableUnitsForTeeth(ProstheticType type, Iterable<int> teeth) {
final selected = teeth.toSet(); final selected = teeth.toSet();
if (selected.isEmpty) return billableUnitsForType(type, 0); if (selected.isEmpty) return billableUnitsForType(type, 0);
if (type.family == ProstheticFamily.hareketli) return 1;
final upper = { final upper = {
for (int i = 11; i <= 18; i++) i, for (int i = 11; i <= 18; i++) i,
@@ -49,6 +48,11 @@ class PricingService {
for (int i = 41; i <= 48; i++) i, 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; var units = 0;
final remaining = Set<int>.from(selected); final remaining = Set<int>.from(selected);
if (upper.every(selected.contains)) { if (upper.every(selected.contains)) {
@@ -67,7 +71,7 @@ class PricingService {
ProstheticType.hareketli || ProstheticType.hareketli ||
ProstheticType.tamProtez || ProstheticType.tamProtez ||
ProstheticType.parsiyel => ProstheticType.parsiyel =>
'vaka', 'çene',
_ => 'diş', _ => 'diş',
}; };
} }
@@ -112,7 +112,11 @@ class ClinicJobsRepository {
} }
Future<Job> approveAtClinic(String jobId, Job job, {String? note}) async { Future<Job> 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.'); if (nextStep == null) throw Exception('Bu aşamadan ileri gidilemez.');
final record = await _pb.collection('jobs').update(jobId, body: { final record = await _pb.collection('jobs').update(jobId, body: {
@@ -189,15 +189,19 @@ class _LabJobDetailScreenState extends ConsumerState<LabJobDetailScreen> {
} }
} }
void _showHandToClinicSheet(Job job) { void _showHandToClinicSheet(Job job, {required bool finish}) {
_showAdaptive( _showAdaptive(
context, context,
_HandToClinicSheet( _HandToClinicSheet(
job: job, job: job,
finish: finish,
onDone: (Job updated) { onDone: (Job updated) {
if (mounted) { if (mounted) {
setState(() => _job = updated.copyWith( setState(() {
clinicName: job.clinicName, labName: job.labName)); _job = updated.copyWith(
clinicName: job.clinicName, labName: job.labName);
_historyFuture = JobHistoryService.instance.listForJob(job.id);
});
} }
}, },
), ),
@@ -525,20 +529,26 @@ class _LabJobDetailScreenState extends ConsumerState<LabJobDetailScreen> {
backgroundColor: AppColors.success, backgroundColor: AppColors.success,
), ),
), ),
if (canSendToClinic) if (canSendToClinic && job.provaRequired) ...[
FilledButton.icon( FilledButton.icon(
onPressed: () => _showHandToClinicSheet(job), onPressed: () => _showHandToClinicSheet(job, finish: false),
icon: const Icon(Icons.send_outlined), icon: const Icon(Icons.send_outlined),
label: Text( label: const Text('Provaya Gönder'),
(job.isLastStep)
? 'Son Prova - Teslime Gönder'
: 'Prova için Kliniğe Gönder',
),
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(52), minimumSize: const Size.fromHeight(52),
backgroundColor: (job.isLastStep) backgroundColor: AppColors.inProgress,
? AppColors.success ),
: 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) ...[ if (canCancelJobs && job.status == JobStatus.pending) ...[
@@ -674,8 +684,13 @@ class _LatestRevisionBanner extends StatelessWidget {
// ── Hand to Clinic Sheet ───────────────────────────────────────────────────── // ── Hand to Clinic Sheet ─────────────────────────────────────────────────────
class _HandToClinicSheet extends StatefulWidget { 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 Job job;
final bool finish;
final void Function(Job updatedJob) onDone; final void Function(Job updatedJob) onDone;
@override @override
@@ -685,6 +700,7 @@ class _HandToClinicSheet extends StatefulWidget {
class _HandToClinicSheetState extends State<_HandToClinicSheet> { class _HandToClinicSheetState extends State<_HandToClinicSheet> {
final _noteController = TextEditingController(); final _noteController = TextEditingController();
bool _sending = false; bool _sending = false;
String? _noteError;
@override @override
void dispose() { void dispose() {
@@ -696,20 +712,8 @@ class _HandToClinicSheetState extends State<_HandToClinicSheet> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isDesktop = final isDesktop =
MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint; MediaQuery.sizeOf(context).width > AppLayout.sidebarBreakpoint;
final currentStep = widget.job.currentStep; final isLast = widget.finish;
final isLast = widget.job.isLastStep; final buttonLabel = isLast ? 'Bitime Gönder' : 'Provaya Gönder';
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 buttonColor = isLast ? AppColors.success : AppColors.inProgress; final buttonColor = isLast ? AppColors.success : AppColors.inProgress;
return Container( return Container(
@@ -737,18 +741,17 @@ class _HandToClinicSheetState extends State<_HandToClinicSheet> {
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
isLast isLast
? 'İş teslim edilecek olarak işaretlenecek.' ? 'Cila / bitim tamamlandıktan sonra seçin. İş teslim için gönderildi olarak işaretlenecek.'
: requiresClinicApproval && !continuesInLab : 'İş üretim aşamasında kalır. Klinik onayından sonra gerektiği kadar tekrar provaya gönderebilirsiniz.',
? 'İş klinikteki prova veya onay için gönderilecek.'
: 'Bu iç adım tamamlanacak ve iş laboratuvarda ilerleyecek.',
style: const TextStyle(color: AppColors.textSecondary), style: const TextStyle(color: AppColors.textSecondary),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
TextField( TextField(
controller: _noteController, controller: _noteController,
decoration: const InputDecoration( decoration: InputDecoration(
labelText: 'Not (isteğe bağlı)', labelText: 'Aşama açıklaması',
hintText: 'Klinik için not ekleyin...', hintText: 'Yapılan işlemi ve klinik için açıklamanızı yazın...',
errorText: _noteError,
), ),
maxLines: 3, maxLines: 3,
), ),
@@ -757,6 +760,10 @@ class _HandToClinicSheetState extends State<_HandToClinicSheet> {
onPressed: _sending onPressed: _sending
? null ? null
: () async { : () async {
if (_noteController.text.trim().isEmpty) {
setState(() => _noteError = 'Aşama açıklaması yazın.');
return;
}
setState(() => _sending = true); setState(() => _sending = true);
final navigator = Navigator.of(context); final navigator = Navigator.of(context);
final messenger = ScaffoldMessenger.of(context); final messenger = ScaffoldMessenger.of(context);
@@ -765,20 +772,17 @@ class _HandToClinicSheetState extends State<_HandToClinicSheet> {
await LabJobsRepository.instance.handToClinic( await LabJobsRepository.instance.handToClinic(
widget.job.id, widget.job.id,
widget.job, widget.job,
note: _noteController.text.trim().isEmpty finish: widget.finish,
? null note: _noteController.text.trim(),
: _noteController.text.trim(),
); );
widget.onDone(updated);
navigator.pop(); navigator.pop();
messenger.showSnackBar( messenger.showSnackBar(
SnackBar( SnackBar(
content: Text(isLast content: Text(isLast
? 'İş teslim için gönderildi' ? 'İş teslim için gönderildi'
: requiresClinicApproval && !continuesInLab : 'İş provaya gönderildi')),
? 'Onay için kliniğe gönderildi'
: 'İş bir sonraki iç adıma geçirildi')),
); );
if (context.mounted) widget.onDone(updated);
} catch (e) { } catch (e) {
if (context.mounted) { if (context.mounted) {
setState(() => _sending = false); setState(() => _sending = false);
+21 -27
View File
@@ -83,40 +83,34 @@ class LabJobsRepository {
return job; return job;
} }
Future<Job> handToClinic(String jobId, Job job, {String? note}) async { Future<Job> handToClinic(String jobId, Job job,
final currentStep = job.currentStep; {required bool finish, required String note}) async {
if (currentStep == null) { if (note.trim().isEmpty) {
throw Exception('Geçerli bir iş adımı bulunamadı.'); throw ArgumentError('Aşama açıklaması gereklidir.');
} }
final dispatchStep = job.labDispatchStep(finish: finish);
final isFinal = currentStep == JobStep.cilaBitim; final steps = [...job.stepTemplate];
final nextStep = job.nextStep; if (!steps.contains(dispatchStep)) {
final shouldStayAtLabAfterMeasure = !job.provaRequired && steps.insert(0, dispatchStep);
currentStep == JobStep.olcuKontrol && }
nextStep == JobStep.cilaBitim; final patch = {
final patch = isFinal 'status': finish ? 'sent' : 'in_progress',
? {'status': 'sent', 'location': 'at_clinic'} 'location': 'at_clinic',
: currentStep.requiresClinicApproval && !shouldStayAtLabAfterMeasure 'current_step': dispatchStep.value,
? {'location': 'at_clinic'} 'workflow_steps': steps.map((step) => step.value).toList(),
: { };
'current_step': nextStep?.value,
'location': 'at_lab',
};
final record = await _pb.collection('jobs').update(jobId, body: patch); final record = await _pb.collection('jobs').update(jobId, body: patch);
final updated = Job.fromJson(record.toJson()); final updated = Job.fromJson(record.toJson());
unawaited(JobHistoryService.instance.append( await JobHistoryService.instance.append(
jobId: jobId, jobId: jobId,
clinicTenantId: job.clinicTenantId, clinicTenantId: job.clinicTenantId,
labTenantId: job.labTenantId, labTenantId: job.labTenantId,
action: (currentStep.requiresClinicApproval && action: JobHistoryAction.handedToClinic,
!shouldStayAtLabAfterMeasure) || step: dispatchStep,
isFinal note:
? JobHistoryAction.handedToClinic '${finish ? "Bitime gönderildi" : "Provaya gönderildi"}: ${note.trim()}',
: JobHistoryAction.stepCompleted, );
step: currentStep,
note: note,
));
return updated; return updated;
} }
+23 -2
View File
@@ -47,8 +47,8 @@ const visibleProstheticTypes = <ProstheticType>[
extension JobStatusExt on JobStatus { extension JobStatusExt on JobStatus {
String get label => switch (this) { String get label => switch (this) {
JobStatus.pending => 'Bekliyor', JobStatus.pending => 'Üretim aşamasında · Kabul bekliyor',
JobStatus.inProgress => 'İşlemde', JobStatus.inProgress => 'Üretim aşamasında',
JobStatus.sent => 'Gönderildi', JobStatus.sent => 'Gönderildi',
JobStatus.delivered => 'Teslim Alındı', JobStatus.delivered => 'Teslim Alındı',
JobStatus.cancelled => 'İptal', JobStatus.cancelled => 'İptal',
@@ -556,6 +556,27 @@ class Job {
bool get isLastStep => bool get isLastStep =>
currentStep != null && currentStep == stepTemplate.last; 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. /// The next step after currentStep in this job's template, or null if done.
JobStep? get nextStep { JobStep? get nextStep {
if (currentStep == null) return stepTemplate.firstOrNull; if (currentStep == null) return stepTemplate.firstOrNull;
+103
View File
@@ -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);
});
}