Simplify lab trial dispatch and fix per-jaw pricing
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user