diff --git a/app/Enums/ProductStockQuality.php b/app/Enums/ProductStockQuality.php new file mode 100644 index 0000000..d9b8252 --- /dev/null +++ b/app/Enums/ProductStockQuality.php @@ -0,0 +1,29 @@ + 'Bagus', + self::REJECT => 'Reject', + }; + } + + public function stockColumn(): string + { + return match ($this) { + self::GOOD => 'stock', + self::REJECT => 'reject_stock', + }; + } +} diff --git a/app/Enums/RawMaterialUnit.php b/app/Enums/RawMaterialUnit.php index 62b049e..eb8a912 100644 --- a/app/Enums/RawMaterialUnit.php +++ b/app/Enums/RawMaterialUnit.php @@ -18,6 +18,10 @@ enum RawMaterialUnit: string private const MIN_STOCK_KILOGRAM = 2.0; + private const CM_PER_YARD = 91.44; + + private const CM_PER_METER = 100.0; + public function label(): string { return match ($this) { @@ -52,4 +56,54 @@ public function minStock(): float self::KILOGRAM => self::MIN_STOCK_KILOGRAM, }; } + + public function usesLengthUnit(): bool + { + return match ($this) { + self::YARD, self::METER => true, + self::KILOGRAM => false, + }; + } + + public function cmPerUnit(): ?float + { + return match ($this) { + self::YARD => self::CM_PER_YARD, + self::METER => self::CM_PER_METER, + self::KILOGRAM => null, + }; + } + + public function toCm(float $value): ?float + { + $cmPerUnit = $this->cmPerUnit(); + + if ($cmPerUnit === null) { + return null; + } + + return $value * $cmPerUnit; + } + + public function fromCm(float $cm): float + { + $cmPerUnit = $this->cmPerUnit(); + + if ($cmPerUnit === null || $cmPerUnit <= 0) { + return $cm; + } + + return $cm / $cmPerUnit; + } + + public function pricePerCm(int $pricePerUnit): ?float + { + $cmPerUnit = $this->cmPerUnit(); + + if ($cmPerUnit === null || $cmPerUnit <= 0) { + return null; + } + + return $pricePerUnit / $cmPerUnit; + } } diff --git a/app/Http/Controllers/Admin/Manage/CuttingController.php b/app/Http/Controllers/Admin/Manage/CuttingController.php index 3114381..38c8331 100644 --- a/app/Http/Controllers/Admin/Manage/CuttingController.php +++ b/app/Http/Controllers/Admin/Manage/CuttingController.php @@ -97,6 +97,7 @@ public function transitionStatus(CuttingStatusTransitionRequest $request, Cuttin $request->validated('reason'), $request->validated('verification_note'), $request->validated('results'), + $request->validated('result_prices'), ); $message = match ($status) { diff --git a/app/Http/Controllers/Admin/Manage/OrderDraftItemController.php b/app/Http/Controllers/Admin/Manage/OrderDraftItemController.php index fb8b6af..74581da 100644 --- a/app/Http/Controllers/Admin/Manage/OrderDraftItemController.php +++ b/app/Http/Controllers/Admin/Manage/OrderDraftItemController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Admin\Manage; +use App\Enums\ProductStockQuality; use App\Http\Controllers\Controller; use App\Http\Requests\Admin\Manage\OrderDraftItemRequest; use App\Http\Requests\Admin\Manage\OrderDraftResyncPricesRequest; @@ -25,7 +26,11 @@ public function store(OrderDraftItemRequest $request): JsonResponse public function destroy(Request $request, ProductVariant $productVariant): JsonResponse { - $this->orderService->removeDraftItem($request->user(), $productVariant); + $stockQuality = ProductStockQuality::from( + $request->query('stock_quality', ProductStockQuality::GOOD->value), + ); + + $this->orderService->removeDraftItem($request->user(), $productVariant, $stockQuality); return response()->json(['ok' => true]); } diff --git a/app/Http/Controllers/Admin/Master/ProductController.php b/app/Http/Controllers/Admin/Master/ProductController.php index c988571..7586040 100644 --- a/app/Http/Controllers/Admin/Master/ProductController.php +++ b/app/Http/Controllers/Admin/Master/ProductController.php @@ -2,7 +2,6 @@ namespace App\Http\Controllers\Admin\Master; -use App\Enums\PriceType; use App\Http\Controllers\Concerns\FlashesEntityMessage; use App\Http\Controllers\Concerns\ParsesDataTableQuery; use App\Http\Controllers\Controller; @@ -47,7 +46,6 @@ public function create(): Response { return Inertia::render('admin/master/products/Create', [ 'categories' => $this->productService->categoryOptions(), - 'priceTypes' => PriceType::selectOptions(), ]); } @@ -65,7 +63,7 @@ public function edit(Product $product): Response $product->load([ 'categories', 'variants' => fn ($query) => $query - ->with(['prices' => fn ($query) => $query->orderBy('type'), 'media']) + ->with('media') ->orderBy('created_at'), ]); @@ -75,7 +73,6 @@ public function edit(Product $product): Response return Inertia::render('admin/master/products/Edit', [ 'categories' => $this->productService->categoryOptions(), - 'priceTypes' => PriceType::selectOptions(), 'product' => $product, ]); } diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php index 97bbe62..59e5bf8 100644 --- a/app/Http/Controllers/HomeController.php +++ b/app/Http/Controllers/HomeController.php @@ -5,6 +5,7 @@ use App\Models\Category; use App\Models\Product; use App\Models\SystemConfiguration; +use App\Services\Manage\CuttingResultPriceResolver; use App\Settings\SocialMediaSettings; use App\Settings\SystemSettings; use App\Support\Media\MediaPresenter; @@ -13,6 +14,10 @@ class HomeController extends Controller { + public function __construct( + private readonly CuttingResultPriceResolver $cuttingResultPriceResolver, + ) {} + public function index(): Response { $categories = Category::whereHas('products')->get(['id', 'name', 'slug']); @@ -21,7 +26,7 @@ public function index(): Response ->with([ 'categories', 'variants' => fn ($query) => $query - ->with(['prices' => fn ($query) => $query->orderBy('type'), 'media']) + ->with('media') ->orderBy('created_at'), ]) ->get(); @@ -29,6 +34,18 @@ public function index(): Response $products = $products->map(function ($product) { $product->variants->each(function ($variant) { $variant->setAttribute('images', MediaPresenter::collection($variant, 'images')); + $variant->setAttribute( + 'prices', + collect($this->cuttingResultPriceResolver->latestPricesForVariant($variant->id)) + ->map(fn ($price) => [ + 'type' => $price->price_type->value, + 'type_label' => $price->price_type->label(), + 'price' => (int) $price->price, + 'price_formatted' => $price->price_formatted, + ]) + ->values() + ->all(), + ); }); return $product; diff --git a/app/Http/Requests/Admin/Manage/CuttingRequest.php b/app/Http/Requests/Admin/Manage/CuttingRequest.php index 61e75ed..a9be074 100644 --- a/app/Http/Requests/Admin/Manage/CuttingRequest.php +++ b/app/Http/Requests/Admin/Manage/CuttingRequest.php @@ -32,8 +32,8 @@ public function rules(): array 'distinct', Rule::exists('raw_material_prices', 'id')->whereNull('deleted_at'), ], - 'materials.*.material_usage' => ['required', 'numeric', 'decimal:0,4', 'gt:0'], - 'materials.*.remaining_material' => ['required', 'numeric', 'decimal:0,4', 'gte:0'], + 'materials.*.material_usage' => ['required', 'numeric', 'decimal:0,2', 'gt:0'], + 'materials.*.remaining_material' => ['required', 'numeric', 'decimal:0,2', 'gte:0'], 'results' => ['required', 'array', 'min:1'], 'results.*.product_variant_id' => [ diff --git a/app/Http/Requests/Admin/Manage/CuttingStatusTransitionRequest.php b/app/Http/Requests/Admin/Manage/CuttingStatusTransitionRequest.php index 5173137..d5436fd 100644 --- a/app/Http/Requests/Admin/Manage/CuttingStatusTransitionRequest.php +++ b/app/Http/Requests/Admin/Manage/CuttingStatusTransitionRequest.php @@ -4,6 +4,7 @@ use App\Enums\CuttingStatus; use App\Enums\Permission; +use App\Enums\PriceType; use App\Models\Cutting; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -41,6 +42,11 @@ public function rules(): array 'results.*.product_variant_id' => ['required_with:results', 'integer', 'exists:product_variants,id'], 'results.*.warehouse_stock' => ['required_with:results', 'integer', 'min:0'], 'results.*.cutting_reject' => ['required_with:results', 'integer', 'min:0'], + 'result_prices' => ['nullable', 'array'], + 'result_prices.*.product_variant_id' => ['required_with:result_prices', 'integer', 'exists:product_variants,id'], + 'result_prices.*.prices' => ['required_with:result_prices', 'array', 'min:1'], + 'result_prices.*.prices.*.type' => ['required', Rule::enum(PriceType::class)], + 'result_prices.*.prices.*.price' => ['required', 'integer', 'gt:0'], ]; } @@ -75,22 +81,41 @@ public function withValidator(Validator $validator): void $validator->errors()->add('reason', 'Alasan penolakan wajib diisi.'); } - if ($status === CuttingStatus::VERIFIED && $this->has('results')) { - $cuttingResults = $cutting->results->keyBy('product_variant_id'); - foreach ($this->input('results', []) as $index => $item) { - $variantId = $item['product_variant_id'] ?? 0; - $warehouseStock = (int) ($item['warehouse_stock'] ?? 0); - $cuttingReject = (int) ($item['cutting_reject'] ?? 0); + if ($status === CuttingStatus::VERIFIED) { + if ($this->has('results')) { + $cuttingResults = $cutting->results->keyBy('product_variant_id'); + foreach ($this->input('results', []) as $index => $item) { + $variantId = $item['product_variant_id'] ?? 0; + $warehouseStock = (int) ($item['warehouse_stock'] ?? 0); + $cuttingReject = (int) ($item['cutting_reject'] ?? 0); - $originalResult = $cuttingResults->get($variantId); - if ($originalResult === null) { - $validator->errors()->add("results.{$index}.product_variant_id", 'Varian produk tidak ditemukan pada cutting ini.'); + $originalResult = $cuttingResults->get($variantId); + if ($originalResult === null) { + $validator->errors()->add("results.{$index}.product_variant_id", 'Varian produk tidak ditemukan pada cutting ini.'); - continue; + continue; + } + + if (($warehouseStock + $cuttingReject) !== (int) $originalResult->cutting_result) { + $validator->errors()->add("results.{$index}.warehouse_stock", "Total jumlah (diterima + reject) harus sama dengan hasil cutting asli ({$originalResult->cutting_result} pcs)."); + } } + } - if (($warehouseStock + $cuttingReject) !== (int) $originalResult->cutting_result) { - $validator->errors()->add("results.{$index}.warehouse_stock", "Total jumlah (diterima + reject) harus sama dengan hasil cutting asli ({$originalResult->cutting_result} pcs)."); + if (! $this->has('result_prices') || $this->input('result_prices') === []) { + $validator->errors()->add('result_prices', 'Harga jual wajib diisi saat verifikasi.'); + } else { + $variantIds = $cutting->results->pluck('product_variant_id')->all(); + $submittedVariantIds = collect($this->input('result_prices', [])) + ->pluck('product_variant_id') + ->map(fn ($id) => (int) $id) + ->all(); + + foreach ($variantIds as $variantId) { + if (! in_array($variantId, $submittedVariantIds, true)) { + $validator->errors()->add('result_prices', 'Harga jual wajib diisi untuk semua varian hasil cutting.'); + break; + } } } } diff --git a/app/Http/Requests/Admin/Manage/OrderDraftItemRequest.php b/app/Http/Requests/Admin/Manage/OrderDraftItemRequest.php index 015d51a..fbc6a5f 100644 --- a/app/Http/Requests/Admin/Manage/OrderDraftItemRequest.php +++ b/app/Http/Requests/Admin/Manage/OrderDraftItemRequest.php @@ -4,6 +4,7 @@ use App\Enums\Permission; use App\Enums\PriceType; +use App\Enums\ProductStockQuality; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -27,6 +28,7 @@ public function rules(): array ], 'quantity' => ['required', 'integer', 'min:1'], 'price_type' => ['required', Rule::enum(PriceType::class)], + 'stock_quality' => ['required', Rule::enum(ProductStockQuality::class)], ]; } } diff --git a/app/Http/Requests/Admin/Manage/OrderRequest.php b/app/Http/Requests/Admin/Manage/OrderRequest.php index 1fa824c..9095ca0 100644 --- a/app/Http/Requests/Admin/Manage/OrderRequest.php +++ b/app/Http/Requests/Admin/Manage/OrderRequest.php @@ -6,6 +6,7 @@ use App\Enums\PaymentType; use App\Enums\Permission; use App\Enums\PriceType; +use App\Enums\ProductStockQuality; use App\Models\Order; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -49,6 +50,7 @@ public function rules(): array Rule::exists('product_variants', 'id')->whereNull('deleted_at'), ]; $rules['items.*.quantity'] = ['required', 'integer', 'min:1']; + $rules['items.*.stock_quality'] = ['required', Rule::enum(ProductStockQuality::class)]; } return $rules; @@ -74,6 +76,7 @@ public function attributes(): array 'items' => 'produk', 'items.*.product_variant_id' => 'varian produk', 'items.*.quantity' => 'jumlah', + 'items.*.stock_quality' => 'kualitas stok', ]; } diff --git a/app/Http/Requests/Admin/Master/ProductRequest.php b/app/Http/Requests/Admin/Master/ProductRequest.php index fd1add2..319a37d 100644 --- a/app/Http/Requests/Admin/Master/ProductRequest.php +++ b/app/Http/Requests/Admin/Master/ProductRequest.php @@ -3,7 +3,6 @@ namespace App\Http\Requests\Admin\Master; use App\Enums\Permission; -use App\Enums\PriceType; use App\Http\Requests\Concerns\ValidatesMediaUploads; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -43,9 +42,6 @@ public function rules(): array ], 'variants.*.name' => ['required', 'string', 'max:200'], 'variants.*.stock' => ['required', 'integer', 'min:0'], - 'variants.*.prices' => ['required', 'array', 'min:1'], - 'variants.*.prices.*.type' => ['required', Rule::enum(PriceType::class)], - 'variants.*.prices.*.price' => ['required', 'integer', 'gt:0'], ...$this->variantImageRules(), ]; } @@ -63,9 +59,6 @@ public function attributes(): array 'variants' => 'varian', 'variants.*.name' => 'nama varian', 'variants.*.stock' => 'stok', - 'variants.*.prices' => 'harga', - 'variants.*.prices.*.type' => 'tipe harga', - 'variants.*.prices.*.price' => 'harga', ...$this->variantImageAttributes('variants', 'foto varian'), ]; } diff --git a/app/Models/Cutting.php b/app/Models/Cutting.php index bec35f5..3324ea0 100644 --- a/app/Models/Cutting.php +++ b/app/Models/Cutting.php @@ -30,6 +30,8 @@ protected function casts(): array { return [ 'status' => CuttingStatus::class, + 'total_material_cost' => 'integer', + 'cost_per_unit' => 'integer', ]; } @@ -48,6 +50,11 @@ public function results(): HasMany return $this->hasMany(CuttingResult::class); } + public function resultPrices(): HasMany + { + return $this->hasMany(CuttingResultPrice::class); + } + public function createdAtFormatted(): Attribute { return Attribute::make( diff --git a/app/Models/CuttingMaterial.php b/app/Models/CuttingMaterial.php index 9bcc9a5..d96c3bb 100644 --- a/app/Models/CuttingMaterial.php +++ b/app/Models/CuttingMaterial.php @@ -15,9 +15,14 @@ #[Appends([ 'material_usage_formatted', 'material_usage_input', + 'material_usage_cm_formatted', + 'material_usage_cm_input', 'remaining_material_formatted', 'remaining_material_input', + 'remaining_material_cm_formatted', + 'remaining_material_cm_input', 'unit_abbreviation', + 'uses_length_unit', ])] class CuttingMaterial extends Model { @@ -28,8 +33,8 @@ class CuttingMaterial extends Model protected function casts(): array { return [ - 'material_usage' => 'decimal:4', - 'remaining_material' => 'decimal:4', + 'material_usage' => 'decimal:2', + 'remaining_material' => 'decimal:2', ]; } @@ -78,15 +83,127 @@ public function unitAbbreviation(): Attribute ); } + public function usesLengthUnit(): Attribute + { + return Attribute::make( + get: fn () => $this->rawMaterialPrice?->rawMaterial?->unit?->usesLengthUnit() ?? false, + ); + } + + public function materialUsageCmFormatted(): Attribute + { + return Attribute::make( + get: fn () => $this->formatCmQuantity($this->material_usage), + ); + } + + public function materialUsageCmInput(): Attribute + { + return Attribute::make( + get: fn () => $this->formatCmQuantityInput($this->material_usage), + ); + } + + public function remainingMaterialCmFormatted(): Attribute + { + return Attribute::make( + get: fn () => $this->formatCmQuantity($this->remaining_material), + ); + } + + public function remainingMaterialCmInput(): Attribute + { + return Attribute::make( + get: fn () => $this->formatCmQuantityInput($this->remaining_material), + ); + } + + public function materialUsageInCm(): ?float + { + $unit = $this->rawMaterialPrice?->rawMaterial?->unit; + + if ($unit === null) { + return null; + } + + return $unit->toCm((float) $this->material_usage); + } + + public function remainingMaterialInCm(): ?float + { + $unit = $this->rawMaterialPrice?->rawMaterial?->unit; + + if ($unit === null) { + return null; + } + + return $unit->toCm((float) $this->remaining_material); + } + + public function materialCost(): int + { + $price = $this->rawMaterialPrice; + + if ($price === null) { + return 0; + } + + return (int) round((float) $this->material_usage * (int) $price->price); + } + + private function formatCmQuantity(float|string|null $value): ?string + { + $unit = $this->rawMaterialPrice?->rawMaterial?->unit; + + if ($unit === null) { + return null; + } + + if (! $unit->usesLengthUnit()) { + return $this->formatQuantity($value); + } + + $cm = $unit->toCm((float) $value); + + if ($cm === null) { + return null; + } + + $formatted = rtrim(rtrim(number_format($cm, 2, ',', '.'), '0'), ','); + + return "{$formatted} cm"; + } + + private function formatCmQuantityInput(float|string|null $value): ?string + { + $unit = $this->rawMaterialPrice?->rawMaterial?->unit; + + if ($unit === null) { + return null; + } + + if (! $unit->usesLengthUnit()) { + return $this->formatQuantityInput($value); + } + + $cm = $unit->toCm((float) $value); + + if ($cm === null) { + return null; + } + + return rtrim(rtrim(number_format($cm, 2, '.', ''), '0'), '.'); + } + private function formatQuantity(float|string|null $value): string { - $formatted = rtrim(rtrim(number_format((float) $value, 4, ',', '.'), '0'), ','); + $formatted = rtrim(rtrim(number_format((float) $value, 2, ',', '.'), '0'), ','); return "{$formatted} {$this->unitAbbreviation}"; } private function formatQuantityInput(float|string|null $value): string { - return rtrim(rtrim(number_format((float) $value, 4, '.', ''), '0'), '.'); + return rtrim(rtrim(number_format((float) $value, 2, '.', ''), '0'), '.'); } } diff --git a/app/Models/ProductPrice.php b/app/Models/CuttingResultPrice.php similarity index 61% rename from app/Models/ProductPrice.php rename to app/Models/CuttingResultPrice.php index 63bc7a7..e787926 100644 --- a/app/Models/ProductPrice.php +++ b/app/Models/CuttingResultPrice.php @@ -12,8 +12,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; #[Guarded(['id'])] -#[Appends(['price_formatted', 'price_input', 'type_label'])] -class ProductPrice extends Model +#[Appends(['price_formatted', 'cost_per_unit_formatted', 'type_label'])] +class CuttingResultPrice extends Model { use HasFactory; use InteractsWithActivityLog; @@ -21,14 +21,20 @@ class ProductPrice extends Model protected function casts(): array { return [ - 'type' => PriceType::class, + 'price_type' => PriceType::class, 'price' => 'integer', + 'cost_per_unit' => 'integer', ]; } - public function variant(): BelongsTo + public function cutting(): BelongsTo { - return $this->belongsTo(ProductVariant::class, 'variant_id'); + return $this->belongsTo(Cutting::class); + } + + public function productVariant(): BelongsTo + { + return $this->belongsTo(ProductVariant::class); } public function priceFormatted(): Attribute @@ -38,17 +44,17 @@ public function priceFormatted(): Attribute ); } - public function priceInput(): Attribute + public function costPerUnitFormatted(): Attribute { return Attribute::make( - get: fn () => (string) $this->price, + get: fn () => 'Rp '.number_format($this->cost_per_unit, 0, ',', '.'), ); } public function typeLabel(): Attribute { return Attribute::make( - get: fn () => $this->type->label(), + get: fn () => $this->price_type->label(), ); } } diff --git a/app/Models/OrderItem.php b/app/Models/OrderItem.php index cc9a5d2..99c199f 100644 --- a/app/Models/OrderItem.php +++ b/app/Models/OrderItem.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Enums\ProductStockQuality; use App\Models\Concerns\InteractsWithActivityLog; use Illuminate\Database\Eloquent\Attributes\Appends; use Illuminate\Database\Eloquent\Attributes\Guarded; @@ -27,6 +28,7 @@ class OrderItem extends Model protected function casts(): array { return [ + 'stock_quality' => ProductStockQuality::class, 'quantity' => 'integer', 'unit_price' => 'integer', 'subtotal' => 'integer', diff --git a/app/Models/ProductVariant.php b/app/Models/ProductVariant.php index 1e7fe9b..1c8e5ce 100644 --- a/app/Models/ProductVariant.php +++ b/app/Models/ProductVariant.php @@ -26,6 +26,7 @@ protected function casts(): array { return [ 'stock' => 'integer', + 'reject_stock' => 'integer', ]; } @@ -39,9 +40,9 @@ public function orderItems(): HasMany return $this->hasMany(OrderItem::class); } - public function prices(): HasMany + public function cuttingResultPrices(): HasMany { - return $this->hasMany(ProductPrice::class, 'variant_id'); + return $this->hasMany(CuttingResultPrice::class); } public function product(): BelongsTo diff --git a/app/Services/Manage/CuttingResultPriceResolver.php b/app/Services/Manage/CuttingResultPriceResolver.php new file mode 100644 index 0000000..03e4443 --- /dev/null +++ b/app/Services/Manage/CuttingResultPriceResolver.php @@ -0,0 +1,40 @@ +where('product_variant_id', $productVariantId) + ->where('price_type', $priceType) + ->whereHas('cutting', fn ($query) => $query->where('status', CuttingStatus::VERIFIED)) + ->join('cuttings', 'cutting_result_prices.cutting_id', '=', 'cuttings.id') + ->orderByDesc('cuttings.created_at') + ->select('cutting_result_prices.*') + ->first(); + } + + /** + * @return list + */ + public function latestPricesForVariant(int $productVariantId): array + { + $prices = []; + + foreach (PriceType::cases() as $priceType) { + $price = $this->resolve($productVariantId, $priceType); + + if ($price !== null) { + $prices[] = $price; + } + } + + return $prices; + } +} diff --git a/app/Services/Manage/CuttingService.php b/app/Services/Manage/CuttingService.php index eddb800..71376e8 100644 --- a/app/Services/Manage/CuttingService.php +++ b/app/Services/Manage/CuttingService.php @@ -6,6 +6,7 @@ use App\Models\Cutting; use App\Models\CuttingMaterial; use App\Models\CuttingResult; +use App\Models\CuttingResultPrice; use App\Models\Product; use App\Models\ProductVariant; use App\Models\RawMaterial; @@ -66,6 +67,7 @@ public function paginateForIndex(array $tableQuery, User $user): LengthAwarePagi $cutting->setAttribute('available_actions', $actions); $cutting->setAttribute('is_editable', $cutting->status->isEditable()); + $this->appendCostPreview($cutting); return $cutting; }); @@ -122,6 +124,7 @@ public function getCompletedCuttings(User $user): Collection $cutting->setAttribute('available_actions', $actions); $cutting->setAttribute('is_editable', $cutting->status->isEditable()); + $this->appendCostPreview($cutting); }); } @@ -359,7 +362,8 @@ public function transitionStatus( User $user, ?string $reason = null, ?string $verificationNote = null, - ?array $results = null + ?array $results = null, + ?array $resultPrices = null, ): void { if (! $cutting->status->canTransitionTo($status)) { throw ValidationException::withMessages([ @@ -367,11 +371,13 @@ public function transitionStatus( ]); } - DB::transaction(function () use ($cutting, $status, $verificationNote, $results, $user, $reason): void { - $cutting->load(['materials', 'results']); + DB::transaction(function () use ($cutting, $status, $verificationNote, $results, $resultPrices, $user, $reason): void { + $cutting->load(['materials.rawMaterialPrice', 'results']); if ($status === CuttingStatus::COMPLETED) { $this->applyRemainingMaterialStock($cutting); + $cutting->total_material_cost = $this->calculateTotalMaterialCost($cutting); + $cutting->cost_per_unit = $this->calculateCostPerUnit($cutting); } if ($status === CuttingStatus::IN_PROGRESS) { @@ -396,6 +402,7 @@ public function transitionStatus( $cutting->load('results'); } $this->applyProductStockOnVerify($cutting); + $this->storeResultPrices($cutting, $resultPrices ?? []); if ($verificationNote !== null && trim($verificationNote) !== '') { $cutting->rejection()->create([ @@ -442,7 +449,9 @@ private function buildMaterials(array $materials): array { return collect($materials) ->map(function (array $itemData, int $index) { - $price = RawMaterialPrice::query()->find($itemData['raw_material_price_id']); + $price = RawMaterialPrice::query() + ->with('rawMaterial') + ->find($itemData['raw_material_price_id']); if ($price === null) { throw ValidationException::withMessages([ @@ -450,8 +459,17 @@ private function buildMaterials(array $materials): array ]); } - $materialUsage = (float) $itemData['material_usage']; - $remainingMaterial = (float) $itemData['remaining_material']; + $unit = $price->rawMaterial?->unit; + $usageInput = round((float) $itemData['material_usage'], 2); + $remainingInput = round((float) $itemData['remaining_material'], 2); + + if ($unit !== null && $unit->usesLengthUnit()) { + $materialUsage = round($unit->fromCm($usageInput), 2); + $remainingMaterial = round($unit->fromCm($remainingInput), 2); + } else { + $materialUsage = $usageInput; + $remainingMaterial = $remainingInput; + } if ($materialUsage <= 0) { throw ValidationException::withMessages([ @@ -594,26 +612,34 @@ private function reverseMaterialStock(Cutting $cutting): void private function applyProductStockOnVerify(Cutting $cutting): void { foreach ($cutting->results as $result) { - if ($result->warehouse_stock < 1) { - continue; + if ($result->warehouse_stock > 0) { + ProductVariant::query() + ->whereKey($result->product_variant_id) + ->increment('stock', $result->warehouse_stock); } - ProductVariant::query() - ->whereKey($result->product_variant_id) - ->increment('stock', $result->warehouse_stock); + if ($result->cutting_reject > 0) { + ProductVariant::query() + ->whereKey($result->product_variant_id) + ->increment('reject_stock', $result->cutting_reject); + } } } private function reverseProductStock(Cutting $cutting): void { foreach ($cutting->results as $result) { - if ($result->warehouse_stock < 1) { - continue; + if ($result->warehouse_stock > 0) { + ProductVariant::query() + ->whereKey($result->product_variant_id) + ->decrement('stock', $result->warehouse_stock); } - ProductVariant::query() - ->whereKey($result->product_variant_id) - ->decrement('stock', $result->warehouse_stock); + if ($result->cutting_reject > 0) { + ProductVariant::query() + ->whereKey($result->product_variant_id) + ->decrement('reject_stock', $result->cutting_reject); + } } } @@ -643,4 +669,55 @@ private function applySorting(Builder $query, string $sort, string $direction): $query->latest(); } + + private function appendCostPreview(Cutting $cutting): void + { + if (! in_array($cutting->status, [CuttingStatus::COMPLETED, CuttingStatus::VERIFIED], true)) { + return; + } + + $totalMaterialCost = $cutting->total_material_cost ?? $this->calculateTotalMaterialCost($cutting); + $costPerUnit = $cutting->cost_per_unit ?? $this->calculateCostPerUnit($cutting); + + $cutting->setAttribute('total_material_cost', $totalMaterialCost); + $cutting->setAttribute('total_material_cost_formatted', 'Rp '.number_format($totalMaterialCost, 0, ',', '.')); + $cutting->setAttribute('estimated_cost_per_unit', $costPerUnit); + $cutting->setAttribute('estimated_cost_per_unit_formatted', 'Rp '.number_format($costPerUnit, 0, ',', '.')); + } + + public function calculateTotalMaterialCost(Cutting $cutting): int + { + return (int) $cutting->materials->sum(fn (CuttingMaterial $material) => $material->materialCost()); + } + + public function calculateCostPerUnit(Cutting $cutting): int + { + $totalPieces = (int) $cutting->results->sum('cutting_result'); + + if ($totalPieces <= 0) { + return 0; + } + + return (int) round($this->calculateTotalMaterialCost($cutting) / $totalPieces); + } + + /** + * @param list}> $resultPrices + */ + private function storeResultPrices(Cutting $cutting, array $resultPrices): void + { + $costPerUnit = (int) ($cutting->cost_per_unit ?? $this->calculateCostPerUnit($cutting)); + + foreach ($resultPrices as $resultData) { + foreach ($resultData['prices'] as $priceData) { + CuttingResultPrice::query()->create([ + 'cutting_id' => $cutting->id, + 'product_variant_id' => $resultData['product_variant_id'], + 'price_type' => $priceData['type'], + 'price' => (int) $priceData['price'], + 'cost_per_unit' => $costPerUnit, + ]); + } + } + } } diff --git a/app/Services/Manage/OrderService.php b/app/Services/Manage/OrderService.php index df7d0b8..f305b86 100644 --- a/app/Services/Manage/OrderService.php +++ b/app/Services/Manage/OrderService.php @@ -6,11 +6,11 @@ use App\Enums\OrderStatus; use App\Enums\PaymentType; use App\Enums\PriceType; +use App\Enums\ProductStockQuality; use App\Models\Customer; use App\Models\Order; use App\Models\OrderItem; use App\Models\Product; -use App\Models\ProductPrice; use App\Models\ProductVariant; use App\Models\User; use App\Services\Finance\CashService; @@ -30,6 +30,7 @@ public function __construct( private readonly MarketplaceService $marketplaceService, private readonly PushNotificationService $pushNotificationService, private readonly CashService $cashService, + private readonly CuttingResultPriceResolver $cuttingResultPriceResolver, ) {} /** @@ -138,7 +139,7 @@ public function catalogItems(?Order $order = null, ?User $user = null): Collecti return Product::query() ->with([ 'variants' => fn ($query) => $query - ->with(['prices' => fn ($query) => $query->orderBy('type'), 'media']) + ->with('media') ->orderBy('created_at'), ]) ->where(function (Builder $query) use ($orderVariantIds): void { @@ -159,6 +160,10 @@ public function catalogItems(?Order $order = null, ?User $user = null): Collecti 'images', MediaPresenter::collection($variant, 'images'), ); + $variant->setAttribute( + 'prices', + $this->presentVariantPrices($variant->id), + ); }); }); } @@ -167,7 +172,6 @@ public function findForEdit(Order $order): Order { $order->load([ 'items.productVariant.product:id,name', - 'items.productVariant.prices', 'items.productVariant.media', ]); @@ -181,6 +185,10 @@ public function findForEdit(Order $order): Order 'images', MediaPresenter::collection($variant, 'images'), ); + $variant->setAttribute( + 'prices', + $this->presentVariantPrices($variant->id), + ); } }); @@ -210,32 +218,26 @@ public function draftItemsForUser(User $user): array public function syncDraftItem(array $validated, User $user): array { $priceType = PriceType::from($validated['price_type']); + $stockQuality = ProductStockQuality::from($validated['stock_quality']); $variant = ProductVariant::query()->findOrFail($validated['product_variant_id']); - $price = ProductPrice::query() - ->where('variant_id', $variant->id) - ->where('type', $priceType) - ->first(); - - if ($price === null) { - throw ValidationException::withMessages([ - 'product_variant_id' => 'Harga untuk tipe harga ini belum diatur.', - ]); - } + $unitPrice = $this->resolveUnitPrice($variant->id, $priceType); $quantity = (int) $validated['quantity']; - if ($quantity > $variant->stock) { + $availableStock = $this->availableStock($variant, $stockQuality); + + if ($quantity > $availableStock) { throw ValidationException::withMessages([ - 'quantity' => "Stok tidak mencukupi. Stok saat ini: {$variant->stock} pcs.", + 'quantity' => "Stok {$stockQuality->label()} tidak mencukupi. Stok saat ini: {$availableStock} pcs.", ]); } - $unitPrice = (int) $price->price; $subtotal = $unitPrice * $quantity; $item = OrderItem::query()->updateOrCreate( [ 'user_id' => $user->id, 'product_variant_id' => $variant->id, + 'stock_quality' => $stockQuality->value, 'order_id' => null, ], [ @@ -253,12 +255,13 @@ public function syncDraftItem(array $validated, User $user): array return $this->presentDraftItem($item); } - public function removeDraftItem(User $user, ProductVariant $productVariant): void + public function removeDraftItem(User $user, ProductVariant $productVariant, ProductStockQuality $stockQuality): void { OrderItem::query() ->whereNull('order_id') ->where('user_id', $user->id) ->where('product_variant_id', $productVariant->id) + ->where('stock_quality', $stockQuality->value) ->delete(); } @@ -277,10 +280,7 @@ public function resyncDraftPrices(User $user, string $priceTypeValue): array ->get(); foreach ($items as $item) { - $price = ProductPrice::query() - ->where('variant_id', $item->product_variant_id) - ->where('type', $priceType) - ->first(); + $price = $this->cuttingResultPriceResolver->resolve($item->product_variant_id, $priceType); if ($price === null) { $item->delete(); @@ -367,9 +367,13 @@ public function create(array $validated, User $user): Order 'items' => 'Varian produk tidak ditemukan.', ]); } - if ($variant->stock < $item->quantity) { + + $stockQuality = $item->stock_quality ?? ProductStockQuality::GOOD; + $availableStock = $this->availableStock($variant, $stockQuality); + + if ($availableStock < $item->quantity) { throw ValidationException::withMessages([ - 'items' => "Stok produk {$variant->product->name} ({$variant->name}) tidak mencukupi. Stok saat ini: {$variant->stock} pcs.", + 'items' => "Stok {$stockQuality->label()} produk {$variant->product->name} ({$variant->name}) tidak mencukupi. Stok saat ini: {$availableStock} pcs.", ]); } $item->order_id = $order->id; @@ -446,9 +450,13 @@ public function update(Order $order, array $validated): void 'items' => 'Varian produk tidak ditemukan.', ]); } - if ($variant->stock < $itemData['quantity']) { + + $stockQuality = ProductStockQuality::from($itemData['stock_quality']); + $availableStock = $this->availableStock($variant, $stockQuality); + + if ($availableStock < $itemData['quantity']) { throw ValidationException::withMessages([ - 'items' => "Stok produk {$variant->product->name} ({$variant->name}) tidak mencukupi. Stok saat ini: {$variant->stock} pcs.", + 'items' => "Stok {$stockQuality->label()} produk {$variant->product->name} ({$variant->name}) tidak mencukupi. Stok saat ini: {$availableStock} pcs.", ]); } $orderItem = $order->items()->create($itemData); @@ -529,8 +537,8 @@ public function transitionStatus(Order $order, OrderStatus $status): void } /** - * @param list $items - * @return list + * @param list $items + * @return list */ private function buildLineItems(array $items, PriceType $priceType): array { @@ -544,18 +552,10 @@ private function buildLineItems(array $items, PriceType $priceType): array ]); } - $price = ProductPrice::query() - ->where('variant_id', $variant->id) - ->where('type', $priceType) - ->first(); - - if ($price === null) { - throw ValidationException::withMessages([ - "items.{$index}.product_variant_id" => 'Harga untuk tipe harga ini belum diatur.', - ]); - } + $unitPrice = $this->resolveUnitPrice($variant->id, $priceType, "items.{$index}.product_variant_id"); $quantity = (int) $itemData['quantity']; + $stockQuality = ProductStockQuality::from($itemData['stock_quality'] ?? ProductStockQuality::GOOD->value); if ($quantity < 1) { throw ValidationException::withMessages([ @@ -563,11 +563,11 @@ private function buildLineItems(array $items, PriceType $priceType): array ]); } - $unitPrice = (int) $price->price; $subtotal = $unitPrice * $quantity; return [ 'product_variant_id' => $variant->id, + 'stock_quality' => $stockQuality->value, 'quantity' => $quantity, 'unit_price' => $unitPrice, 'subtotal' => $subtotal, @@ -619,6 +619,8 @@ private function presentDraftItem(OrderItem $item): array 'product_variant_id' => $item->product_variant_id, 'product_name' => $variant?->product?->name ?? '', 'variant_name' => $variant?->name ?? '', + 'stock_quality' => ($item->stock_quality ?? ProductStockQuality::GOOD)->value, + 'stock_quality_label' => ($item->stock_quality ?? ProductStockQuality::GOOD)->label(), 'quantity' => (string) $item->quantity, 'unit_price' => $item->unit_price, 'images' => $variant ? MediaPresenter::collection($variant, 'images') : [], @@ -631,18 +633,12 @@ private function presentDraftItem(OrderItem $item): array private function applyDraftPrices(EloquentCollection $items, PriceType $priceType): void { foreach ($items as $index => $item) { - $price = ProductPrice::query() - ->where('variant_id', $item->product_variant_id) - ->where('type', $priceType) - ->first(); + $unitPrice = $this->resolveUnitPrice( + $item->product_variant_id, + $priceType, + "items.{$index}.product_variant_id", + ); - if ($price === null) { - throw ValidationException::withMessages([ - "items.{$index}.product_variant_id" => 'Harga untuk tipe harga ini belum diatur.', - ]); - } - - $unitPrice = (int) $price->price; $item->unit_price = $unitPrice; $item->subtotal = $unitPrice * $item->quantity; $item->save(); @@ -651,16 +647,25 @@ private function applyDraftPrices(EloquentCollection $items, PriceType $priceTyp private function decrementStock(OrderItem $item): void { + $stockQuality = $item->stock_quality ?? ProductStockQuality::GOOD; + ProductVariant::query() ->whereKey($item->product_variant_id) - ->decrement('stock', $item->quantity); + ->decrement($stockQuality->stockColumn(), $item->quantity); } private function incrementStock(OrderItem $item): void { + $stockQuality = $item->stock_quality ?? ProductStockQuality::GOOD; + ProductVariant::query() ->whereKey($item->product_variant_id) - ->increment('stock', $item->quantity); + ->increment($stockQuality->stockColumn(), $item->quantity); + } + + private function availableStock(ProductVariant $variant, ProductStockQuality $stockQuality): int + { + return (int) $variant->{$stockQuality->stockColumn()}; } /** @@ -688,4 +693,44 @@ private function applySorting(Builder $query, string $sort, string $direction): $query->latest(); } + + private function resolveUnitPrice(int $variantId, PriceType $priceType, ?string $errorKey = null): int + { + $price = $this->cuttingResultPriceResolver->resolve($variantId, $priceType); + + if ($price === null) { + throw ValidationException::withMessages([ + $errorKey ?? 'product_variant_id' => 'Harga untuk tipe harga ini belum diatur. Verifikasi cutting terlebih dahulu.', + ]); + } + + return (int) $price->price; + } + + /** + * @return list + */ + private function presentVariantPrices(int $variantId): array + { + return collect($this->cuttingResultPriceResolver->latestPricesForVariant($variantId)) + ->map(fn ($price) => [ + 'type' => $price->price_type->value, + 'type_label' => $price->price_type->label(), + 'price' => (int) $price->price, + 'price_formatted' => $price->price_formatted, + 'price_input' => (string) $price->price, + 'cost_per_unit' => (int) $price->cost_per_unit, + 'cost_per_unit_formatted' => $price->cost_per_unit_formatted, + ]) + ->values() + ->all(); + } } diff --git a/app/Services/Master/ProductService.php b/app/Services/Master/ProductService.php index 360dd77..9b56b3e 100644 --- a/app/Services/Master/ProductService.php +++ b/app/Services/Master/ProductService.php @@ -4,8 +4,8 @@ use App\Models\Category; use App\Models\Product; -use App\Models\ProductPrice; use App\Models\ProductVariant; +use App\Services\Manage\CuttingResultPriceResolver; use App\Services\Media\MediaService; use App\Support\Media\MediaPresenter; use Illuminate\Contracts\Pagination\LengthAwarePaginator; @@ -18,6 +18,7 @@ class ProductService public function __construct( private readonly MediaService $mediaService, + private readonly CuttingResultPriceResolver $cuttingResultPriceResolver, ) {} /** @@ -119,12 +120,12 @@ public function paginateForIndex(array $tableQuery, string $isActive, string $ca ->with([ 'categories', 'variants' => fn ($query) => $query - ->with(['prices' => fn ($query) => $query->orderBy('type'), 'media']) + ->with('media') ->orderBy('created_at'), ]) ->when($tableQuery['search'] !== '', function (Builder $query) use ($tableQuery): void { $search = $tableQuery['search']; - $query->where(function (Builder $query) use ($search): void { + $query->where(function (Builder $query) use ($search) { $query->where('name', 'like', "%{$search}%") ->orWhere('slug', 'like', "%{$search}%") ->orWhere('description', 'like', "%{$search}%") @@ -152,6 +153,10 @@ public function paginateForIndex(array $tableQuery, string $isActive, string $ca 'images', MediaPresenter::collection($variant, 'images'), ); + $variant->setAttribute( + 'prices', + $this->presentVariantPrices($variant->id), + ); }); return $product; @@ -225,7 +230,6 @@ public function update(Product $product, array $validated): void $variant->name = $variantData['name']; $variant->stock = $variantData['stock']; $variant->save(); - $this->syncVariantPrices($variant, $variantData['prices']); $this->syncVariantImages($variant, $variantData, $index); continue; @@ -275,7 +279,6 @@ private function createVariant(Product $product, array $variantData, int $index) 'stock' => $variantData['stock'], ]); - $this->syncVariantPrices($variant, $variantData['prices']); $this->syncVariantImages($variant, $variantData, $index); return $variant; @@ -298,20 +301,29 @@ private function syncVariantImages(ProductVariant $variant, array $variantData, } /** - * @param list $prices + * @return list */ - private function syncVariantPrices(ProductVariant $variant, array $prices): void + private function presentVariantPrices(int $variantId): array { - foreach ($prices as $priceData) { - ProductPrice::updateOrCreate( - [ - 'variant_id' => $variant->id, - 'type' => $priceData['type'], - ], - [ - 'price' => $priceData['price'], - ], - ); - } + return collect($this->cuttingResultPriceResolver->latestPricesForVariant($variantId)) + ->map(fn ($price) => [ + 'type' => $price->price_type->value, + 'type_label' => $price->price_type->label(), + 'price' => (int) $price->price, + 'price_formatted' => $price->price_formatted, + 'price_input' => (string) $price->price, + 'cost_per_unit' => (int) $price->cost_per_unit, + 'cost_per_unit_formatted' => $price->cost_per_unit_formatted, + ]) + ->values() + ->all(); } } diff --git a/app/Support/ActivityLog/ModelLabel.php b/app/Support/ActivityLog/ModelLabel.php index 6c98234..2f9cf58 100644 --- a/app/Support/ActivityLog/ModelLabel.php +++ b/app/Support/ActivityLog/ModelLabel.php @@ -10,6 +10,7 @@ use App\Models\Cutting; use App\Models\CuttingMaterial; use App\Models\CuttingResult; +use App\Models\CuttingResultPrice; use App\Models\Employee; use App\Models\EmployeeAdvance; use App\Models\Expense; @@ -20,7 +21,6 @@ use App\Models\PayrollAdjustment; use App\Models\PayrollPeriod; use App\Models\Product; -use App\Models\ProductPrice; use App\Models\ProductVariant; use App\Models\Purchase; use App\Models\PurchaseItem; @@ -46,6 +46,7 @@ class ModelLabel Cutting::class => 'Cutting', CuttingMaterial::class => 'Bahan Cutting', CuttingResult::class => 'Hasil Cutting', + CuttingResultPrice::class => 'Harga Hasil Cutting', Employee::class => 'Pegawai', EmployeeAdvance::class => 'Kasbon', Expense::class => 'Pengeluaran', @@ -56,7 +57,6 @@ class ModelLabel PayrollAdjustment::class => 'Penyesuaian Gaji', PayrollPeriod::class => 'Periode Gaji', Product::class => 'Produk', - ProductPrice::class => 'Harga Produk', ProductVariant::class => 'Varian Produk', Purchase::class => 'Belanja', PurchaseItem::class => 'Item Belanja', diff --git a/database/factories/OrderItemFactory.php b/database/factories/OrderItemFactory.php index 1a6a723..ee9552d 100644 --- a/database/factories/OrderItemFactory.php +++ b/database/factories/OrderItemFactory.php @@ -2,6 +2,7 @@ namespace Database\Factories; +use App\Enums\ProductStockQuality; use App\Models\Order; use App\Models\OrderItem; use App\Models\ProductVariant; @@ -20,6 +21,7 @@ public function definition(): array return [ 'order_id' => Order::factory(), 'product_variant_id' => ProductVariant::factory(), + 'stock_quality' => ProductStockQuality::GOOD->value, 'quantity' => $quantity, 'unit_price' => $unitPrice, 'subtotal' => $quantity * $unitPrice, diff --git a/database/factories/ProductPriceFactory.php b/database/factories/ProductPriceFactory.php deleted file mode 100644 index 9a0b209..0000000 --- a/database/factories/ProductPriceFactory.php +++ /dev/null @@ -1,23 +0,0 @@ - - */ -class ProductPriceFactory extends Factory -{ - public function definition(): array - { - return [ - 'variant_id' => ProductVariant::factory(), - 'type' => fake()->randomElement(PriceType::cases())->value, - 'price' => fake()->numberBetween(25_000, 500_000), - ]; - } -} diff --git a/database/factories/ProductVariantFactory.php b/database/factories/ProductVariantFactory.php index 8578d63..a69179a 100644 --- a/database/factories/ProductVariantFactory.php +++ b/database/factories/ProductVariantFactory.php @@ -17,6 +17,7 @@ public function definition(): array 'product_id' => Product::factory(), 'name' => fake()->randomElement(['S', 'M', 'L', 'XL', 'All Size']), 'stock' => fake()->numberBetween(0, 100), + 'reject_stock' => fake()->numberBetween(0, 20), ]; } } diff --git a/database/migrations/2026_06_10_100003_create_product_variants_table.php b/database/migrations/2026_06_10_100003_create_product_variants_table.php index fe7eeab..a618c92 100644 --- a/database/migrations/2026_06_10_100003_create_product_variants_table.php +++ b/database/migrations/2026_06_10_100003_create_product_variants_table.php @@ -15,6 +15,7 @@ public function up(): void $table->string('name', 200); $table->unsignedInteger('stock')->default(0); + $table->unsignedInteger('reject_stock')->default(0); $table->timestamp('created_at')->useCurrent(); $table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate(); diff --git a/database/migrations/2026_06_10_100004_create_product_prices_table.php b/database/migrations/2026_06_10_100004_create_product_prices_table.php deleted file mode 100644 index 0fc482b..0000000 --- a/database/migrations/2026_06_10_100004_create_product_prices_table.php +++ /dev/null @@ -1,29 +0,0 @@ -id(); - - $table->foreignId('variant_id')->constrained('product_variants')->cascadeOnDelete(); - - $table->string('type', 20); - $table->unsignedInteger('price'); - - $table->timestamp('created_at')->useCurrent(); - $table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate(); - $table->softDeletes(); - }); - } - - public function down(): void - { - Schema::dropIfExists('product_prices'); - } -}; diff --git a/database/migrations/2026_06_12_100002_create_order_items_table.php b/database/migrations/2026_06_12_100002_create_order_items_table.php index 217af1b..5641dfb 100644 --- a/database/migrations/2026_06_12_100002_create_order_items_table.php +++ b/database/migrations/2026_06_12_100002_create_order_items_table.php @@ -1,5 +1,6 @@ foreignId('user_id')->nullable()->constrained()->cascadeOnDelete(); $table->foreignId('product_variant_id')->constrained()->restrictOnDelete(); + $table->enum('stock_quality', array_column(ProductStockQuality::cases(), 'value'))->default(ProductStockQuality::GOOD->value); $table->unsignedInteger('quantity'); $table->unsignedBigInteger('unit_price'); $table->unsignedBigInteger('subtotal'); diff --git a/database/migrations/2026_06_12_110001_create_cuttings_table.php b/database/migrations/2026_06_12_110001_create_cuttings_table.php index b834cc7..3557b4d 100644 --- a/database/migrations/2026_06_12_110001_create_cuttings_table.php +++ b/database/migrations/2026_06_12_110001_create_cuttings_table.php @@ -1,6 +1,7 @@ enum('status', array_column(CuttingStatus::cases(), 'value'))->default(CuttingStatus::IN_PROGRESS->value); $table->string('description', 100)->nullable(); + $table->unsignedBigInteger('total_material_cost')->nullable(); + $table->unsignedBigInteger('cost_per_unit')->nullable(); + $table->foreignId('created_by_id')->constrained('users')->restrictOnDelete(); $table->timestamp('created_at')->useCurrent(); @@ -22,10 +26,27 @@ public function up(): void $table->softDeletes(); }); + + Schema::create('cutting_result_prices', function (Blueprint $table) { + $table->id(); + + $table->foreignId('cutting_id')->constrained()->cascadeOnDelete(); + $table->foreignId('product_variant_id')->constrained()->restrictOnDelete(); + + $table->enum('price_type', array_column(PriceType::cases(), 'value')); + $table->unsignedBigInteger('price'); + $table->unsignedBigInteger('cost_per_unit'); + + $table->timestamp('created_at')->useCurrent(); + $table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate(); + + $table->unique(['cutting_id', 'product_variant_id', 'price_type'], 'cutting_result_prices_unique'); + }); } public function down(): void { + Schema::dropIfExists('cutting_result_prices'); Schema::dropIfExists('cuttings'); } }; diff --git a/database/migrations/2026_06_12_110002_create_cutting_materials_table.php b/database/migrations/2026_06_12_110002_create_cutting_materials_table.php index 8580716..d7e85ce 100644 --- a/database/migrations/2026_06_12_110002_create_cutting_materials_table.php +++ b/database/migrations/2026_06_12_110002_create_cutting_materials_table.php @@ -14,8 +14,8 @@ public function up(): void $table->foreignId('cutting_id')->constrained()->cascadeOnDelete(); $table->foreignId('raw_material_price_id')->constrained()->restrictOnDelete(); - $table->decimal('material_usage', 18, 4); - $table->decimal('remaining_material', 18, 4); + $table->decimal('material_usage', 18, 2); + $table->decimal('remaining_material', 18, 2); $table->timestamp('created_at')->useCurrent(); $table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate(); diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 60ed8f9..d0a01d8 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -18,7 +18,6 @@ public function run(): void CategorySeeder::class, ProductSeeder::class, ProductVariantSeeder::class, - ProductPriceSeeder::class, RawMaterialSeeder::class, RawMaterialPriceSeeder::class, SupplierSeeder::class, diff --git a/database/seeders/ProductPriceSeeder.php b/database/seeders/ProductPriceSeeder.php deleted file mode 100644 index 28b1d40..0000000 --- a/database/seeders/ProductPriceSeeder.php +++ /dev/null @@ -1,28 +0,0 @@ -whereDoesntHave('prices') - ->each(function (ProductVariant $variant): void { - $basePrice = fake()->numberBetween(50_000, 250_000); - - foreach (PriceType::cases() as $type) { - ProductPrice::factory()->create([ - 'variant_id' => $variant->id, - 'type' => $type->value, - 'price' => $basePrice + fake()->numberBetween(0, 50_000), - ]); - } - }); - } -} diff --git a/database/seeders/ProductSeeder.php b/database/seeders/ProductSeeder.php index 064184f..2896d3e 100644 --- a/database/seeders/ProductSeeder.php +++ b/database/seeders/ProductSeeder.php @@ -2,10 +2,8 @@ namespace Database\Seeders; -use App\Enums\PriceType; use App\Models\Category; use App\Models\Product; -use App\Models\ProductPrice; use App\Models\ProductVariant; use Illuminate\Database\Seeder; use Illuminate\Support\Facades\DB; @@ -25,10 +23,10 @@ public function run(): void 'description' => 'Daster batik motif parang dengan bahan katun nyaman untuk pemakaian sehari-hari.', 'category_slugs' => ['daster'], 'variants' => [ - ['name' => 'S', 'stock' => 25, 'base_price' => 65000], - ['name' => 'M', 'stock' => 40, 'base_price' => 65000], - ['name' => 'L', 'stock' => 35, 'base_price' => 65000], - ['name' => 'XL', 'stock' => 20, 'base_price' => 70000], + ['name' => 'S', 'stock' => 25], + ['name' => 'M', 'stock' => 40], + ['name' => 'L', 'stock' => 35], + ['name' => 'XL', 'stock' => 20], ], ], [ @@ -36,10 +34,10 @@ public function run(): void 'description' => 'Setelan atasan dan celana batik dengan potongan modern untuk acara formal maupun kasual.', 'category_slugs' => ['setelan-celana'], 'variants' => [ - ['name' => 'S', 'stock' => 15, 'base_price' => 185000], - ['name' => 'M', 'stock' => 20, 'base_price' => 185000], - ['name' => 'L', 'stock' => 18, 'base_price' => 185000], - ['name' => 'XL', 'stock' => 12, 'base_price' => 195000], + ['name' => 'S', 'stock' => 15], + ['name' => 'M', 'stock' => 20], + ['name' => 'L', 'stock' => 18], + ['name' => 'XL', 'stock' => 12], ], ], [ @@ -47,9 +45,9 @@ public function run(): void 'description' => 'Blouse batik lengan panjang dengan detail kerah mandarin.', 'category_slugs' => ['atasan'], 'variants' => [ - ['name' => 'S', 'stock' => 30, 'base_price' => 95000], - ['name' => 'M', 'stock' => 35, 'base_price' => 95000], - ['name' => 'L', 'stock' => 28, 'base_price' => 95000], + ['name' => 'S', 'stock' => 30], + ['name' => 'M', 'stock' => 35], + ['name' => 'L', 'stock' => 28], ], ], [ @@ -57,7 +55,7 @@ public function run(): void 'description' => 'Rok lilit batik dengan motif klasik, mudah disesuaikan dengan berbagai ukuran.', 'category_slugs' => ['bawahan'], 'variants' => [ - ['name' => 'All Size', 'stock' => 50, 'base_price' => 85000], + ['name' => 'All Size', 'stock' => 50], ], ], [ @@ -65,9 +63,9 @@ public function run(): void 'description' => 'Daster busui dengan akses bukaan menyusui praktis dan bahan adem.', 'category_slugs' => ['busui', 'daster'], 'variants' => [ - ['name' => 'M', 'stock' => 22, 'base_price' => 78000], - ['name' => 'L', 'stock' => 26, 'base_price' => 78000], - ['name' => 'XL', 'stock' => 18, 'base_price' => 82000], + ['name' => 'M', 'stock' => 22], + ['name' => 'L', 'stock' => 26], + ['name' => 'XL', 'stock' => 18], ], ], ]; @@ -88,36 +86,13 @@ public function run(): void ); foreach ($productData['variants'] as $variantData) { - $variant = ProductVariant::factory()->create([ + ProductVariant::factory()->create([ 'product_id' => $product->id, 'name' => $variantData['name'], 'stock' => $variantData['stock'], ]); - - $this->seedVariantPrices($variant, $variantData['base_price']); } } }); } - - private function seedVariantPrices(ProductVariant $variant, int $basePrice): void - { - $multipliers = [ - PriceType::DISTRIBUTOR->value => 1.00, - PriceType::AGENT->value => 1.05, - PriceType::SUB_AGENT->value => 1.10, - PriceType::GROSIR->value => 1.15, - PriceType::ECER->value => 1.35, - PriceType::TIKTOK->value => 1.40, - PriceType::SHOPEE->value => 1.40, - ]; - - foreach ($multipliers as $type => $multiplier) { - ProductPrice::factory()->create([ - 'variant_id' => $variant->id, - 'type' => $type, - 'price' => (int) round($basePrice * $multiplier), - ]); - } - } } diff --git a/database/settings/2026_06_18_100000_create_hr_settings.php b/database/settings/2026_06_18_100000_create_hr_settings.php index ed121d5..ad4e83e 100644 --- a/database/settings/2026_06_18_100000_create_hr_settings.php +++ b/database/settings/2026_06_18_100000_create_hr_settings.php @@ -9,6 +9,7 @@ public function up(): void { $this->migrator->inGroup('hr', function (SettingsBlueprint $blueprint): void { $blueprint->add('scheduled_check_in_time', '08:00'); + $blueprint->add('scheduled_check_out_time', '17:00'); $blueprint->add('late_penalty_amount', 0); $blueprint->add('absent_penalty_amount', 0); }); diff --git a/database/settings/2026_06_18_100001_add_scheduled_check_in_time_to_hr_settings.php b/database/settings/2026_06_18_100001_add_scheduled_check_in_time_to_hr_settings.php deleted file mode 100644 index b5a9a08..0000000 --- a/database/settings/2026_06_18_100001_add_scheduled_check_in_time_to_hr_settings.php +++ /dev/null @@ -1,14 +0,0 @@ -migrator->inGroup('hr', function (SettingsBlueprint $blueprint): void { - $blueprint->add('scheduled_check_in_time', '08:00'); - }); - } -}; diff --git a/database/settings/2026_06_18_100002_add_scheduled_check_out_time_to_hr_settings.php b/database/settings/2026_06_18_100002_add_scheduled_check_out_time_to_hr_settings.php deleted file mode 100644 index 9b6bd83..0000000 --- a/database/settings/2026_06_18_100002_add_scheduled_check_out_time_to_hr_settings.php +++ /dev/null @@ -1,14 +0,0 @@ -migrator->inGroup('hr', function (SettingsBlueprint $blueprint): void { - $blueprint->add('scheduled_check_out_time', '17:00'); - }); - } -}; diff --git a/resources/js/components/admin/manage/cuttings/CuttingGroupedTable.vue b/resources/js/components/admin/manage/cuttings/CuttingGroupedTable.vue index c8f1fda..c0accb9 100644 --- a/resources/js/components/admin/manage/cuttings/CuttingGroupedTable.vue +++ b/resources/js/components/admin/manage/cuttings/CuttingGroupedTable.vue @@ -225,7 +225,7 @@ function getGroupedResults(results: any[]): GroupedCuttingResults[] { Varian Hasil - Gudang + Bagus Reject diff --git a/resources/js/components/admin/manage/cuttings/CuttingPosForm.vue b/resources/js/components/admin/manage/cuttings/CuttingPosForm.vue index 3f46bd7..0c52c41 100644 --- a/resources/js/components/admin/manage/cuttings/CuttingPosForm.vue +++ b/resources/js/components/admin/manage/cuttings/CuttingPosForm.vue @@ -29,6 +29,7 @@ import { Textarea } from '@/components/ui/textarea'; import { getFirstCoverImage } from '@/lib/catalog-cover'; import { FIELD_LIMITS } from '@/lib/field-limits'; import { formErrors } from '@/lib/form'; +import { formatRupiah } from '@/lib/rupiah'; import type { CuttingMaterialCartItem, CuttingProductCatalogItem, @@ -36,6 +37,72 @@ import type { CuttingResultCartItem, } from '@/types/cutting'; +function usesLengthUnit(unit: string): boolean { + return unit === 'yard' || unit === 'meter'; +} + +const CM_PER_YARD = 91.44; +const CM_PER_METER = 100; + +function findCatalogPrice(priceId: number) { + for (const rawMaterial of props.rawMaterialCatalog) { + const price = rawMaterial.prices.find((item) => item.id === priceId); + + if (price) { + return { + price: price.price, + unit: rawMaterial.unit, + }; + } + } + + return null; +} + +function usageInNativeUnit(usageInput: number, unit: string): number { + if (unit === 'yard') { + return usageInput / CM_PER_YARD; + } + + if (unit === 'meter') { + return usageInput / CM_PER_METER; + } + + return usageInput; +} + +const totalMaterialCost = computed(() => + materialCart.value.reduce((sum, item) => { + const catalogPrice = findCatalogPrice(item.raw_material_price_id); + + if (!catalogPrice) { + return sum; + } + + const usage = usageInNativeUnit( + Number(item.material_usage) || 0, + catalogPrice.unit, + ); + + return sum + Math.round(usage * catalogPrice.price); + }, 0), +); + +const totalResultPieces = computed(() => + resultCart.value.reduce( + (sum, item) => sum + (Number(item.cutting_result) || 0), + 0, + ), +); + +const estimatedCostPerUnit = computed(() => { + if (totalResultPieces.value <= 0) { + return 0; + } + + return Math.round(totalMaterialCost.value / totalResultPieces.value); +}); + const props = defineProps<{ rawMaterialCatalog: CuttingRawMaterialCatalogItem[]; productCatalog: CuttingProductCatalogItem[]; @@ -157,9 +224,11 @@ function addMaterial( raw_material_price_id: price.id, raw_material_name: rawMaterial.name, variant: price.variant, + unit: rawMaterial.unit, + uses_length_unit: usesLengthUnit(rawMaterial.unit), unit_abbreviation: rawMaterial.unit_abbreviation, stock_input: price.stock_input, - material_usage: '1', + material_usage: usesLengthUnit(rawMaterial.unit) ? '100' : '1', remaining_material: '0', images: price.images ?? [], }); @@ -263,7 +332,7 @@ function submit() { if (warehouse + reject !== total) { toast.error( - `Hasil cutting ${item.product_name} - ${item.variant_name} harus sama dengan stok gudang + reject.`, + `Hasil cutting ${item.product_name} - ${item.variant_name} harus sama dengan stok bagus + reject.`, ); return; @@ -656,9 +725,7 @@ function submit() {
- Pemakaian ({{ - item.unit_abbreviation - }}) + Pemakaian ({{ item.uses_length_unit ? 'cm' : item.unit_abbreviation }}) - Sisa ({{ - item.unit_abbreviation - }}) + Sisa ({{ item.uses_length_unit ? 'cm' : item.unit_abbreviation }}) GudangBagus 0" + class="rounded-lg border bg-muted/40 p-3 text-sm" + > +

Estimasi Harga Modal

+

+ Total bahan baku: + {{ formatRupiah(totalMaterialCost) }} +

+

+ {{ formatRupiah(estimatedCostPerUnit) }} / pcs +

+

+ Tambahkan hasil produk untuk estimasi per pcs. +

+
+