@extends('layouts.app')
@section('content')
@php
$normalizeUnitLabel = function ($unit) {
$unit = strtolower(trim((string) $unit));
return match ($unit) {
'm2', 'm²' => 'm²',
'meter', 'm' => 'meter',
'pcs', 'pc', 'piece', 'lembar' => 'lembar',
default => $unit !== '' ? $unit : 'lembar',
};
};
$normalizeUnitKey = function ($unit) {
$unit = strtolower(trim((string) $unit));
return match ($unit) {
'm2', 'm²' => 'm2',
'meter', 'm' => 'meter',
'pcs', 'pc', 'piece', 'lembar' => 'lembar',
default => $unit !== '' ? $unit : 'lembar',
};
};
$formatNumberClean = function ($value, $decimals = 2) {
$formatted = number_format((float) $value, $decimals, ',', '.');
$formatted = rtrim($formatted, '0');
$formatted = rtrim($formatted, ',');
return $formatted === '' ? '0' : $formatted;
};
$parseTwoDimensions = function ($text) {
$clean = strtolower(trim((string) $text));
if ($clean === '') {
return null;
}
$clean = preg_replace('/\s+/', '', $clean);
$clean = str_replace(['×', '*'], 'x', $clean);
if (!preg_match('/^(\d+(?:[.,]\d+)?)x(\d+(?:[.,]\d+)?)(cm|m)?$/', $clean, $matches)) {
return null;
}
$a = (float) str_replace(',', '.', $matches[1]);
$b = (float) str_replace(',', '.', $matches[2]);
$suffix = $matches[3] ?? '';
if ($suffix === 'cm') {
return [$a, $b, 'cm'];
}
if ($suffix === 'm') {
return [$a, $b, 'm'];
}
$unit = ($a >= 10 && $b >= 10) ? 'cm' : 'm';
return [$a, $b, $unit];
};
$resolveAreaValue = function ($sizeValue, $sizeDescription, $basePrice, $qty, $storedSubtotal) use ($parseTwoDimensions) {
$sizeValue = (float) $sizeValue;
$basePrice = (float) $basePrice;
$qty = max((float) $qty, 0);
$storedSubtotal = (float) $storedSubtotal;
if ($sizeValue > 0) {
return round($sizeValue, 4);
}
$dims = $parseTwoDimensions($sizeDescription);
if ($dims) {
[$a, $b, $parsedUnit] = $dims;
if ($parsedUnit === 'cm') {
return round(($a * $b) / 10000, 4);
}
return round($a * $b, 4);
}
if ($storedSubtotal > 0 && $basePrice > 0 && $qty > 0) {
return round($storedSubtotal / ($basePrice * $qty), 4);
}
return 1;
};
$resolveMeterValue = function ($sizeValue, $sizeDescription, $material, $basePrice, $qty, $storedSubtotal) use ($parseTwoDimensions) {
$sizeValue = (float) $sizeValue;
$basePrice = (float) $basePrice;
$qty = max((float) $qty, 0);
$storedSubtotal = (float) $storedSubtotal;
if ($sizeValue > 0) {
return round($sizeValue, 4);
}
$dims = $parseTwoDimensions($sizeDescription);
if ($dims) {
[$a, $b, $parsedUnit] = $dims;
$aCm = $parsedUnit === 'cm' ? $a : ($a * 100);
$bCm = $parsedUnit === 'cm' ? $b : ($b * 100);
$rollWidthCm = (float) ($material->roll_width_cm ?? 0);
$allowRotation = (bool) ($material->allow_rotation ?? true);
if ($rollWidthCm > 0) {
$candidates = [];
if ($aCm <= $rollWidthCm) {
$candidates[] = $bCm;
}
if ($allowRotation && $bCm <= $rollWidthCm) {
$candidates[] = $aCm;
}
if (!empty($candidates)) {
return round(min($candidates) / 100, 4);
}
return round(max($aCm, $bCm) / 100, 4);
}
return round(min($aCm, $bCm) / 100, 4);
}
if ($storedSubtotal > 0 && $basePrice > 0 && $qty > 0) {
return round($storedSubtotal / ($basePrice * $qty), 4);
}
return 1;
};
$resolveSizeValue = function ($sizeValue, $sizeDescription, $unit, $material = null, $basePrice = 0, $qty = 1, $storedSubtotal = 0) use (
$normalizeUnitKey,
$resolveAreaValue,
$resolveMeterValue
) {
$unitKey = $normalizeUnitKey($unit);
if ($unitKey === 'm2') {
return $resolveAreaValue($sizeValue, $sizeDescription, $basePrice, $qty, $storedSubtotal);
}
if ($unitKey === 'meter') {
return $resolveMeterValue($sizeValue, $sizeDescription, $material, $basePrice, $qty, $storedSubtotal);
}
return 1;
};
$applyRoundingOnly = function ($amount) {
$amount = (int) ceil(max((float) $amount, 0));
if ($amount <= 0) {
return 0;
}
$remainder = $amount % 1000;
if ($remainder === 0) {
return (float) $amount;
}
$base = $amount - $remainder;
return (float) ($base + ($remainder <= 500 ? 500 : 1000));
};
$applyMeterMinimumAndRounding = function ($amount) {
$amount = max((float) $amount, 0);
$minimumOrder = 10000;
if ($amount <= 0) {
return 0;
}
if ($amount < $minimumOrder) {
$amount = $minimumOrder;
}
$amount = (int) ceil($amount);
$remainder = $amount % 1000;
if ($remainder === 0) {
return (float) $amount;
}
$base = $amount - $remainder;
return (float) ($base + ($remainder <= 500 ? 500 : 1000));
};
$calculateDisplaySubtotal = function ($basePrice, $qty, $unit, $resolvedSizeValue, $storedSubtotal = 0) use ($normalizeUnitKey, $applyRoundingOnly, $applyMeterMinimumAndRounding) {
$unitKey = $normalizeUnitKey($unit);
$basePrice = max((float) $basePrice, 0);
$qty = max((float) $qty, 0);
$resolvedSizeValue = max((float) $resolvedSizeValue, 0);
$storedSubtotal = max((float) $storedSubtotal, 0);
// Wajib utamakan subtotal final yang tersimpan.
// Setelah ada minimal order + pembulatan, angka tampilan juga dipaksa rapi:
// minimal Rp 10.000 dan pembulatan ke atas Rp 1.000.
if ($storedSubtotal > 0) {
return in_array($unitKey, ['m2', 'meter'], true)
? round($applyMeterMinimumAndRounding($storedSubtotal), 2)
: round($applyRoundingOnly($storedSubtotal), 2);
}
if (in_array($unitKey, ['m2', 'meter'], true)) {
return round($applyMeterMinimumAndRounding($basePrice * $resolvedSizeValue * $qty), 2);
}
return round($applyRoundingOnly($basePrice * $qty), 2);
};
$rawItems = method_exists($order, 'items')
? ($order->relationLoaded('items') ? $order->items : $order->items()->with('material')->get())
: collect();
$displayItems = $rawItems->isNotEmpty()
? $rawItems->map(function ($item) use ($normalizeUnitLabel,
$normalizeUnitKey,
$resolveSizeValue,
$calculateDisplaySubtotal, $order) {
$sizeUnit = $item->size_unit ?? 'lembar';
$sizeUnitKey = $normalizeUnitKey($sizeUnit);
$basePrice = (float) ($item->base_price ?? 0);
$manualPrice = (float) ($item->manual_price ?? 0);
$hasManualPrice = $manualPrice > 0;
$qty = (float) ($item->qty ?? 1);
$storedSubtotal = (float) ($item->subtotal ?? $item->system_subtotal ?? 0);
$sisi = max((int) ($item->sisi ?? 1), 1);
// Sinkron dengan kasir/order:
// - Kalau subtotal final sudah tersimpan dari proses order/kasir, pakai angka itu.
// - Kalau data lama hanya menyimpan harga manual satuan di subtotal, hitung ulang: qty x manual x sisi.
// - Jangan jadikan harga manual sebagai grand total langsung.
$manualCalculatedSubtotal = $hasManualPrice
? ($manualPrice * max($qty, 0) * $sisi)
: 0;
if ($hasManualPrice && $manualCalculatedSubtotal > 0 && ($storedSubtotal <= 0 || abs($storedSubtotal - $manualPrice) < 0.01)) {
$storedSubtotal = $manualCalculatedSubtotal;
}
$resolvedSizeValue = $resolveSizeValue(
$item->size_value ?? 0,
$item->size_description ?? '',
$sizeUnit,
$item->material ?? null,
$basePrice,
$qty,
$storedSubtotal
);
$displaySubtotal = $calculateDisplaySubtotal(
$hasManualPrice ? $manualPrice : $basePrice,
$qty,
$sizeUnit,
$resolvedSizeValue,
$storedSubtotal
);
return [
'model' => $item,
'id' => $item->id ?? null,
'product_name' => $item->product_name ?? '-',
'material_name' => $item->material_name ?: ($item->material->name ?? null),
'material_category_name' => $item->material->category->name ?? null,
'file_name' => $item->file_name ?? '-',
'size_description' => $item->size_description ?? '-',
'size_value' => (float) ($item->size_value ?? 0),
'resolved_size_value' => $resolvedSizeValue,
'size_unit' => $sizeUnitKey,
'size_unit_label' => $normalizeUnitLabel($sizeUnit),
'qty' => $qty,
'sisi' => $sisi,
'base_price' => $basePrice,
'manual_price' => $manualPrice,
'is_manual_price' => $hasManualPrice,
'stored_subtotal' => $storedSubtotal,
'display_subtotal' => $displaySubtotal,
'finishing_note' => $item->finishing_note ?? null,
'file_url' => $item->file_url ?? $item->file_path_url ?? null,
'preview_image_path' => $item->preview_image_path ?? null,
'preview_image_url' => !empty($item->preview_image_path) ? route('orders.items.preview', [$order, $item]) : null,
'is_cancelled' => method_exists($item, 'isCancelled') ? $item->isCancelled() : (bool) ($item->is_cancelled ?? false),
'has_pending_cancellation' => method_exists($item, 'hasPendingCancellationRequest') ? $item->hasPendingCancellationRequest() : false,
'cancel_reason' => $item->cancel_reason ?? null,
'can_cancel' => method_exists($item, 'canBeCancelled') && auth()->check() && method_exists(auth()->user(), 'canRequestCancellations')
? ($item->canBeCancelled() && !$item->hasPendingCancellationRequest() && auth()->user()->canRequestCancellations())
: false,
];
})->values()
: collect([
(function () use (
$order,
$normalizeUnitLabel,
$normalizeUnitKey,
$resolveSizeValue,
$calculateDisplaySubtotal
) {
$sizeUnit = $order->size_unit ?? 'lembar';
$sizeUnitKey = $normalizeUnitKey($sizeUnit);
$basePrice = (float) ($order->base_price ?? 0);
$qty = (float) ($order->qty ?? 1);
$storedSubtotal = (float) ($order->total_price ?? 0);
$resolvedSizeValue = $resolveSizeValue(
$order->size_value ?? 0,
$order->size ?? '',
$sizeUnit,
null,
$basePrice,
$qty,
$storedSubtotal
);
$displaySubtotal = $calculateDisplaySubtotal(
$basePrice,
$qty,
$sizeUnit,
$resolvedSizeValue,
$storedSubtotal
);
return [
'model' => null,
'id' => null,
'product_name' => $order->product_name ?? '-',
'material_name' => $order->material_name ?? null,
'file_name' => $order->file_name ?? '-',
'size_description' => $order->size ?? '-',
'size_value' => (float) ($order->size_value ?? 0),
'resolved_size_value' => $resolvedSizeValue,
'size_unit' => $sizeUnitKey,
'size_unit_label' => $normalizeUnitLabel($sizeUnit),
'qty' => $qty,
'sisi' => (int) ($order->sisi ?? 1),
'base_price' => $basePrice,
'manual_price' => 0,
'is_manual_price' => false,
'stored_subtotal' => $storedSubtotal,
'display_subtotal' => $displaySubtotal,
'finishing_note' => $order->finishing_note ?? null,
'file_url' => null,
'preview_image_path' => null,
'preview_image_url' => null,
'is_cancelled' => false,
'has_pending_cancellation' => false,
'cancel_reason' => null,
'can_cancel' => false,
];
})(),
]);
$activeItems = $displayItems->where('is_cancelled', false)->values();
$activeItemsCount = $activeItems->count();
$cancelledItemsCount = $displayItems->where('is_cancelled', true)->count();
$displayGrandTotal = (float) $activeItems->sum(fn ($item) => (float) $item['display_subtotal']);
// Sinkron tampilan pembayaran dengan total final yang sudah kena minimal order + pembulatan.
// Jangan lagi pakai change_amount lama apa adanya, karena data lama bisa masih menyimpan
// kembalian dari total sebelum pembulatan.
$displayPaidAmount = (float) ($order->paid_amount ?? 0);
if ($displayPaidAmount <= 0 && isset($order->payments)) {
$displayPaidAmount = (float) $order->payments->sum('amount');
}
// Detail order harus mengikuti nilai final kasir.
// Jika order lama sudah telanjur dibayar lebih besar daripada hitungan ulang item,
// tampilkan grand total mengikuti pembayaran agar tidak muncul kembalian palsu.
if ($displayPaidAmount > $displayGrandTotal) {
$displayGrandTotal = $displayPaidAmount;
}
$displayChangeAmount = 0;
$currentUser = auth()->user();
$invoiceEditorRole = strtolower((string) ($currentUser->role ?? ''));
$canEditInvoiceItems = auth()->check() && (
$currentUser->can('process payments')
|| $currentUser->can('edit orders')
|| $currentUser->can('create orders')
|| $currentUser->can('view orders')
|| (method_exists($currentUser, 'isDeveloper') && $currentUser->isDeveloper())
|| in_array($invoiceEditorRole, ['developer', 'owner', 'admin', 'administrator', 'administrasi', 'kasir', 'designer'], true)
);
$userRoleName = strtolower((string) (
$currentUser->role_name
?? optional($currentUser->role)->name
?? optional($currentUser->roles->first() ?? null)->name
?? $currentUser->role
?? ''
));
$isAdminKasirUser = auth()->check() && (
in_array($invoiceEditorRole, ['admin', 'administrator', 'kasir', 'cashier'], true)
|| in_array($userRoleName, ['admin', 'administrator', 'kasir', 'cashier'], true)
|| (method_exists($currentUser, 'hasRole') && ($currentUser->hasRole('admin') || $currentUser->hasRole('administrator') || $currentUser->hasRole('kasir') || $currentUser->hasRole('cashier')))
|| (method_exists($currentUser, 'can') && $currentUser->can('process payments'))
|| (method_exists($currentUser, 'isKasir') && $currentUser->isKasir())
|| (method_exists($currentUser, 'isAdmin') && $currentUser->isAdmin())
|| (method_exists($currentUser, 'isDeveloper') && $currentUser->isDeveloper())
);
// Admin/kasir boleh cetak nota/invoice untuk semua status pembayaran:
// belum bayar, DP/partial, maupun lunas. Ini hanya membuka akses cetak,
// tidak mengubah pembayaran, status order, atau transaksi.
$canPrintAdminKasirNota = $isAdminKasirUser;
$printNotaRouteExists = \Illuminate\Support\Facades\Route::has('print.nota');
$invoicePrintRouteExists = \Illuminate\Support\Facades\Route::has('orders.invoice-print');
@endphp
{{ $order->cancel_reason }}
Dibatalkan oleh {{ $order->canceller->name ?? '-' }} pada {{ $order->cancelled_at?->format('d M Y H:i') }}
Menunggu approval pembatalan order dari supervisor
{{ $displayItem['material_name'] }} @if(!empty($displayItem['material_category_name'])) • {{ $displayItem['material_category_name'] }} @endif
@endifPreview Upload
Klik gambar untuk lihat ukuran besar.
Khusus merapikan nama barang agar sesuai PO. Qty, harga, subtotal, stok, dan status produksi tidak berubah.
{{ $cancelledItemsCount }} item dibatalkan
@endif @if($order->isPaid())Dokumen Order
Cetak dokumen administrasi dan kirimkan dokumen penagihan customer sesuai kebutuhan operasional.
Order Dibuat
{{ $order->created_at->format('d M Y H:i') }}
oleh {{ $order->designer->name ?? '-' }}
Pembayaran
{{ $order->paid_at?->format('d M Y H:i') }}
oleh {{ $order->cashier->name ?? '-' }}
SPK Dicetak
{{ $order->spk_printed_at?->format('d M Y H:i') }}
Produksi Dimulai
{{ $order->started_at->format('d M Y H:i') }}
oleh {{ $order->operator->name ?? '-' }}
Selesai
{{ $order->finished_at->format('d M Y H:i') }}
Diambil
{{ $order->picked_up_at->format('d M Y H:i') }}
Dibatalkan
{{ $order->cancelled_at?->format('d M Y H:i') }}
oleh {{ $order->canceller->name ?? '-' }}
@if($order->cancel_reason){{ $order->cancel_reason }}
@endif