feat: enhance cutting management by adding result prices handling, including validation and calculations for total material cost and cost per unit
This commit is contained in:
parent
e45307a328
commit
2db6dfece9
29
app/Enums/ProductStockQuality.php
Normal file
29
app/Enums/ProductStockQuality.php
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
use App\Traits\ProvidesEnumOptions;
|
||||||
|
|
||||||
|
enum ProductStockQuality: string
|
||||||
|
{
|
||||||
|
use ProvidesEnumOptions;
|
||||||
|
|
||||||
|
case GOOD = 'good';
|
||||||
|
case REJECT = 'reject';
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::GOOD => 'Bagus',
|
||||||
|
self::REJECT => 'Reject',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function stockColumn(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::GOOD => 'stock',
|
||||||
|
self::REJECT => 'reject_stock',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -18,6 +18,10 @@ enum RawMaterialUnit: string
|
|||||||
|
|
||||||
private const MIN_STOCK_KILOGRAM = 2.0;
|
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
|
public function label(): string
|
||||||
{
|
{
|
||||||
return match ($this) {
|
return match ($this) {
|
||||||
@ -52,4 +56,54 @@ public function minStock(): float
|
|||||||
self::KILOGRAM => self::MIN_STOCK_KILOGRAM,
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -97,6 +97,7 @@ public function transitionStatus(CuttingStatusTransitionRequest $request, Cuttin
|
|||||||
$request->validated('reason'),
|
$request->validated('reason'),
|
||||||
$request->validated('verification_note'),
|
$request->validated('verification_note'),
|
||||||
$request->validated('results'),
|
$request->validated('results'),
|
||||||
|
$request->validated('result_prices'),
|
||||||
);
|
);
|
||||||
|
|
||||||
$message = match ($status) {
|
$message = match ($status) {
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Admin\Manage;
|
namespace App\Http\Controllers\Admin\Manage;
|
||||||
|
|
||||||
|
use App\Enums\ProductStockQuality;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\Admin\Manage\OrderDraftItemRequest;
|
use App\Http\Requests\Admin\Manage\OrderDraftItemRequest;
|
||||||
use App\Http\Requests\Admin\Manage\OrderDraftResyncPricesRequest;
|
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
|
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]);
|
return response()->json(['ok' => true]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Admin\Master;
|
namespace App\Http\Controllers\Admin\Master;
|
||||||
|
|
||||||
use App\Enums\PriceType;
|
|
||||||
use App\Http\Controllers\Concerns\FlashesEntityMessage;
|
use App\Http\Controllers\Concerns\FlashesEntityMessage;
|
||||||
use App\Http\Controllers\Concerns\ParsesDataTableQuery;
|
use App\Http\Controllers\Concerns\ParsesDataTableQuery;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
@ -47,7 +46,6 @@ public function create(): Response
|
|||||||
{
|
{
|
||||||
return Inertia::render('admin/master/products/Create', [
|
return Inertia::render('admin/master/products/Create', [
|
||||||
'categories' => $this->productService->categoryOptions(),
|
'categories' => $this->productService->categoryOptions(),
|
||||||
'priceTypes' => PriceType::selectOptions(),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -65,7 +63,7 @@ public function edit(Product $product): Response
|
|||||||
$product->load([
|
$product->load([
|
||||||
'categories',
|
'categories',
|
||||||
'variants' => fn ($query) => $query
|
'variants' => fn ($query) => $query
|
||||||
->with(['prices' => fn ($query) => $query->orderBy('type'), 'media'])
|
->with('media')
|
||||||
->orderBy('created_at'),
|
->orderBy('created_at'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -75,7 +73,6 @@ public function edit(Product $product): Response
|
|||||||
|
|
||||||
return Inertia::render('admin/master/products/Edit', [
|
return Inertia::render('admin/master/products/Edit', [
|
||||||
'categories' => $this->productService->categoryOptions(),
|
'categories' => $this->productService->categoryOptions(),
|
||||||
'priceTypes' => PriceType::selectOptions(),
|
|
||||||
'product' => $product,
|
'product' => $product,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
use App\Models\Category;
|
use App\Models\Category;
|
||||||
use App\Models\Product;
|
use App\Models\Product;
|
||||||
use App\Models\SystemConfiguration;
|
use App\Models\SystemConfiguration;
|
||||||
|
use App\Services\Manage\CuttingResultPriceResolver;
|
||||||
use App\Settings\SocialMediaSettings;
|
use App\Settings\SocialMediaSettings;
|
||||||
use App\Settings\SystemSettings;
|
use App\Settings\SystemSettings;
|
||||||
use App\Support\Media\MediaPresenter;
|
use App\Support\Media\MediaPresenter;
|
||||||
@ -13,6 +14,10 @@
|
|||||||
|
|
||||||
class HomeController extends Controller
|
class HomeController extends Controller
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly CuttingResultPriceResolver $cuttingResultPriceResolver,
|
||||||
|
) {}
|
||||||
|
|
||||||
public function index(): Response
|
public function index(): Response
|
||||||
{
|
{
|
||||||
$categories = Category::whereHas('products')->get(['id', 'name', 'slug']);
|
$categories = Category::whereHas('products')->get(['id', 'name', 'slug']);
|
||||||
@ -21,7 +26,7 @@ public function index(): Response
|
|||||||
->with([
|
->with([
|
||||||
'categories',
|
'categories',
|
||||||
'variants' => fn ($query) => $query
|
'variants' => fn ($query) => $query
|
||||||
->with(['prices' => fn ($query) => $query->orderBy('type'), 'media'])
|
->with('media')
|
||||||
->orderBy('created_at'),
|
->orderBy('created_at'),
|
||||||
])
|
])
|
||||||
->get();
|
->get();
|
||||||
@ -29,6 +34,18 @@ public function index(): Response
|
|||||||
$products = $products->map(function ($product) {
|
$products = $products->map(function ($product) {
|
||||||
$product->variants->each(function ($variant) {
|
$product->variants->each(function ($variant) {
|
||||||
$variant->setAttribute('images', MediaPresenter::collection($variant, 'images'));
|
$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;
|
return $product;
|
||||||
|
|||||||
@ -32,8 +32,8 @@ public function rules(): array
|
|||||||
'distinct',
|
'distinct',
|
||||||
Rule::exists('raw_material_prices', 'id')->whereNull('deleted_at'),
|
Rule::exists('raw_material_prices', 'id')->whereNull('deleted_at'),
|
||||||
],
|
],
|
||||||
'materials.*.material_usage' => ['required', 'numeric', 'decimal:0,4', 'gt:0'],
|
'materials.*.material_usage' => ['required', 'numeric', 'decimal:0,2', 'gt:0'],
|
||||||
'materials.*.remaining_material' => ['required', 'numeric', 'decimal:0,4', 'gte:0'],
|
'materials.*.remaining_material' => ['required', 'numeric', 'decimal:0,2', 'gte:0'],
|
||||||
|
|
||||||
'results' => ['required', 'array', 'min:1'],
|
'results' => ['required', 'array', 'min:1'],
|
||||||
'results.*.product_variant_id' => [
|
'results.*.product_variant_id' => [
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
use App\Enums\CuttingStatus;
|
use App\Enums\CuttingStatus;
|
||||||
use App\Enums\Permission;
|
use App\Enums\Permission;
|
||||||
|
use App\Enums\PriceType;
|
||||||
use App\Models\Cutting;
|
use App\Models\Cutting;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use Illuminate\Validation\Rule;
|
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.*.product_variant_id' => ['required_with:results', 'integer', 'exists:product_variants,id'],
|
||||||
'results.*.warehouse_stock' => ['required_with:results', 'integer', 'min:0'],
|
'results.*.warehouse_stock' => ['required_with:results', 'integer', 'min:0'],
|
||||||
'results.*.cutting_reject' => ['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.');
|
$validator->errors()->add('reason', 'Alasan penolakan wajib diisi.');
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($status === CuttingStatus::VERIFIED && $this->has('results')) {
|
if ($status === CuttingStatus::VERIFIED) {
|
||||||
$cuttingResults = $cutting->results->keyBy('product_variant_id');
|
if ($this->has('results')) {
|
||||||
foreach ($this->input('results', []) as $index => $item) {
|
$cuttingResults = $cutting->results->keyBy('product_variant_id');
|
||||||
$variantId = $item['product_variant_id'] ?? 0;
|
foreach ($this->input('results', []) as $index => $item) {
|
||||||
$warehouseStock = (int) ($item['warehouse_stock'] ?? 0);
|
$variantId = $item['product_variant_id'] ?? 0;
|
||||||
$cuttingReject = (int) ($item['cutting_reject'] ?? 0);
|
$warehouseStock = (int) ($item['warehouse_stock'] ?? 0);
|
||||||
|
$cuttingReject = (int) ($item['cutting_reject'] ?? 0);
|
||||||
|
|
||||||
$originalResult = $cuttingResults->get($variantId);
|
$originalResult = $cuttingResults->get($variantId);
|
||||||
if ($originalResult === null) {
|
if ($originalResult === null) {
|
||||||
$validator->errors()->add("results.{$index}.product_variant_id", 'Varian produk tidak ditemukan pada cutting ini.');
|
$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) {
|
if (! $this->has('result_prices') || $this->input('result_prices') === []) {
|
||||||
$validator->errors()->add("results.{$index}.warehouse_stock", "Total jumlah (diterima + reject) harus sama dengan hasil cutting asli ({$originalResult->cutting_result} pcs).");
|
$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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
use App\Enums\Permission;
|
use App\Enums\Permission;
|
||||||
use App\Enums\PriceType;
|
use App\Enums\PriceType;
|
||||||
|
use App\Enums\ProductStockQuality;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
@ -27,6 +28,7 @@ public function rules(): array
|
|||||||
],
|
],
|
||||||
'quantity' => ['required', 'integer', 'min:1'],
|
'quantity' => ['required', 'integer', 'min:1'],
|
||||||
'price_type' => ['required', Rule::enum(PriceType::class)],
|
'price_type' => ['required', Rule::enum(PriceType::class)],
|
||||||
|
'stock_quality' => ['required', Rule::enum(ProductStockQuality::class)],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
use App\Enums\PaymentType;
|
use App\Enums\PaymentType;
|
||||||
use App\Enums\Permission;
|
use App\Enums\Permission;
|
||||||
use App\Enums\PriceType;
|
use App\Enums\PriceType;
|
||||||
|
use App\Enums\ProductStockQuality;
|
||||||
use App\Models\Order;
|
use App\Models\Order;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
@ -49,6 +50,7 @@ public function rules(): array
|
|||||||
Rule::exists('product_variants', 'id')->whereNull('deleted_at'),
|
Rule::exists('product_variants', 'id')->whereNull('deleted_at'),
|
||||||
];
|
];
|
||||||
$rules['items.*.quantity'] = ['required', 'integer', 'min:1'];
|
$rules['items.*.quantity'] = ['required', 'integer', 'min:1'];
|
||||||
|
$rules['items.*.stock_quality'] = ['required', Rule::enum(ProductStockQuality::class)];
|
||||||
}
|
}
|
||||||
|
|
||||||
return $rules;
|
return $rules;
|
||||||
@ -74,6 +76,7 @@ public function attributes(): array
|
|||||||
'items' => 'produk',
|
'items' => 'produk',
|
||||||
'items.*.product_variant_id' => 'varian produk',
|
'items.*.product_variant_id' => 'varian produk',
|
||||||
'items.*.quantity' => 'jumlah',
|
'items.*.quantity' => 'jumlah',
|
||||||
|
'items.*.stock_quality' => 'kualitas stok',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,6 @@
|
|||||||
namespace App\Http\Requests\Admin\Master;
|
namespace App\Http\Requests\Admin\Master;
|
||||||
|
|
||||||
use App\Enums\Permission;
|
use App\Enums\Permission;
|
||||||
use App\Enums\PriceType;
|
|
||||||
use App\Http\Requests\Concerns\ValidatesMediaUploads;
|
use App\Http\Requests\Concerns\ValidatesMediaUploads;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
@ -43,9 +42,6 @@ public function rules(): array
|
|||||||
],
|
],
|
||||||
'variants.*.name' => ['required', 'string', 'max:200'],
|
'variants.*.name' => ['required', 'string', 'max:200'],
|
||||||
'variants.*.stock' => ['required', 'integer', 'min:0'],
|
'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(),
|
...$this->variantImageRules(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@ -63,9 +59,6 @@ public function attributes(): array
|
|||||||
'variants' => 'varian',
|
'variants' => 'varian',
|
||||||
'variants.*.name' => 'nama varian',
|
'variants.*.name' => 'nama varian',
|
||||||
'variants.*.stock' => 'stok',
|
'variants.*.stock' => 'stok',
|
||||||
'variants.*.prices' => 'harga',
|
|
||||||
'variants.*.prices.*.type' => 'tipe harga',
|
|
||||||
'variants.*.prices.*.price' => 'harga',
|
|
||||||
...$this->variantImageAttributes('variants', 'foto varian'),
|
...$this->variantImageAttributes('variants', 'foto varian'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -30,6 +30,8 @@ protected function casts(): array
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'status' => CuttingStatus::class,
|
'status' => CuttingStatus::class,
|
||||||
|
'total_material_cost' => 'integer',
|
||||||
|
'cost_per_unit' => 'integer',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -48,6 +50,11 @@ public function results(): HasMany
|
|||||||
return $this->hasMany(CuttingResult::class);
|
return $this->hasMany(CuttingResult::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function resultPrices(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(CuttingResultPrice::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function createdAtFormatted(): Attribute
|
public function createdAtFormatted(): Attribute
|
||||||
{
|
{
|
||||||
return Attribute::make(
|
return Attribute::make(
|
||||||
|
|||||||
@ -15,9 +15,14 @@
|
|||||||
#[Appends([
|
#[Appends([
|
||||||
'material_usage_formatted',
|
'material_usage_formatted',
|
||||||
'material_usage_input',
|
'material_usage_input',
|
||||||
|
'material_usage_cm_formatted',
|
||||||
|
'material_usage_cm_input',
|
||||||
'remaining_material_formatted',
|
'remaining_material_formatted',
|
||||||
'remaining_material_input',
|
'remaining_material_input',
|
||||||
|
'remaining_material_cm_formatted',
|
||||||
|
'remaining_material_cm_input',
|
||||||
'unit_abbreviation',
|
'unit_abbreviation',
|
||||||
|
'uses_length_unit',
|
||||||
])]
|
])]
|
||||||
class CuttingMaterial extends Model
|
class CuttingMaterial extends Model
|
||||||
{
|
{
|
||||||
@ -28,8 +33,8 @@ class CuttingMaterial extends Model
|
|||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'material_usage' => 'decimal:4',
|
'material_usage' => 'decimal:2',
|
||||||
'remaining_material' => 'decimal:4',
|
'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
|
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}";
|
return "{$formatted} {$this->unitAbbreviation}";
|
||||||
}
|
}
|
||||||
|
|
||||||
private function formatQuantityInput(float|string|null $value): string
|
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'), '.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,8 +12,8 @@
|
|||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
#[Guarded(['id'])]
|
#[Guarded(['id'])]
|
||||||
#[Appends(['price_formatted', 'price_input', 'type_label'])]
|
#[Appends(['price_formatted', 'cost_per_unit_formatted', 'type_label'])]
|
||||||
class ProductPrice extends Model
|
class CuttingResultPrice extends Model
|
||||||
{
|
{
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
use InteractsWithActivityLog;
|
use InteractsWithActivityLog;
|
||||||
@ -21,14 +21,20 @@ class ProductPrice extends Model
|
|||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'type' => PriceType::class,
|
'price_type' => PriceType::class,
|
||||||
'price' => 'integer',
|
'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
|
public function priceFormatted(): Attribute
|
||||||
@ -38,17 +44,17 @@ public function priceFormatted(): Attribute
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function priceInput(): Attribute
|
public function costPerUnitFormatted(): Attribute
|
||||||
{
|
{
|
||||||
return Attribute::make(
|
return Attribute::make(
|
||||||
get: fn () => (string) $this->price,
|
get: fn () => 'Rp '.number_format($this->cost_per_unit, 0, ',', '.'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function typeLabel(): Attribute
|
public function typeLabel(): Attribute
|
||||||
{
|
{
|
||||||
return Attribute::make(
|
return Attribute::make(
|
||||||
get: fn () => $this->type->label(),
|
get: fn () => $this->price_type->label(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Enums\ProductStockQuality;
|
||||||
use App\Models\Concerns\InteractsWithActivityLog;
|
use App\Models\Concerns\InteractsWithActivityLog;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Appends;
|
use Illuminate\Database\Eloquent\Attributes\Appends;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Guarded;
|
use Illuminate\Database\Eloquent\Attributes\Guarded;
|
||||||
@ -27,6 +28,7 @@ class OrderItem extends Model
|
|||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
'stock_quality' => ProductStockQuality::class,
|
||||||
'quantity' => 'integer',
|
'quantity' => 'integer',
|
||||||
'unit_price' => 'integer',
|
'unit_price' => 'integer',
|
||||||
'subtotal' => 'integer',
|
'subtotal' => 'integer',
|
||||||
|
|||||||
@ -26,6 +26,7 @@ protected function casts(): array
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'stock' => 'integer',
|
'stock' => 'integer',
|
||||||
|
'reject_stock' => 'integer',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -39,9 +40,9 @@ public function orderItems(): HasMany
|
|||||||
return $this->hasMany(OrderItem::class);
|
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
|
public function product(): BelongsTo
|
||||||
|
|||||||
40
app/Services/Manage/CuttingResultPriceResolver.php
Normal file
40
app/Services/Manage/CuttingResultPriceResolver.php
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Manage;
|
||||||
|
|
||||||
|
use App\Enums\CuttingStatus;
|
||||||
|
use App\Enums\PriceType;
|
||||||
|
use App\Models\CuttingResultPrice;
|
||||||
|
|
||||||
|
class CuttingResultPriceResolver
|
||||||
|
{
|
||||||
|
public function resolve(int $productVariantId, PriceType $priceType): ?CuttingResultPrice
|
||||||
|
{
|
||||||
|
return CuttingResultPrice::query()
|
||||||
|
->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<CuttingResultPrice>
|
||||||
|
*/
|
||||||
|
public function latestPricesForVariant(int $productVariantId): array
|
||||||
|
{
|
||||||
|
$prices = [];
|
||||||
|
|
||||||
|
foreach (PriceType::cases() as $priceType) {
|
||||||
|
$price = $this->resolve($productVariantId, $priceType);
|
||||||
|
|
||||||
|
if ($price !== null) {
|
||||||
|
$prices[] = $price;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $prices;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,6 +6,7 @@
|
|||||||
use App\Models\Cutting;
|
use App\Models\Cutting;
|
||||||
use App\Models\CuttingMaterial;
|
use App\Models\CuttingMaterial;
|
||||||
use App\Models\CuttingResult;
|
use App\Models\CuttingResult;
|
||||||
|
use App\Models\CuttingResultPrice;
|
||||||
use App\Models\Product;
|
use App\Models\Product;
|
||||||
use App\Models\ProductVariant;
|
use App\Models\ProductVariant;
|
||||||
use App\Models\RawMaterial;
|
use App\Models\RawMaterial;
|
||||||
@ -66,6 +67,7 @@ public function paginateForIndex(array $tableQuery, User $user): LengthAwarePagi
|
|||||||
|
|
||||||
$cutting->setAttribute('available_actions', $actions);
|
$cutting->setAttribute('available_actions', $actions);
|
||||||
$cutting->setAttribute('is_editable', $cutting->status->isEditable());
|
$cutting->setAttribute('is_editable', $cutting->status->isEditable());
|
||||||
|
$this->appendCostPreview($cutting);
|
||||||
|
|
||||||
return $cutting;
|
return $cutting;
|
||||||
});
|
});
|
||||||
@ -122,6 +124,7 @@ public function getCompletedCuttings(User $user): Collection
|
|||||||
|
|
||||||
$cutting->setAttribute('available_actions', $actions);
|
$cutting->setAttribute('available_actions', $actions);
|
||||||
$cutting->setAttribute('is_editable', $cutting->status->isEditable());
|
$cutting->setAttribute('is_editable', $cutting->status->isEditable());
|
||||||
|
$this->appendCostPreview($cutting);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -359,7 +362,8 @@ public function transitionStatus(
|
|||||||
User $user,
|
User $user,
|
||||||
?string $reason = null,
|
?string $reason = null,
|
||||||
?string $verificationNote = null,
|
?string $verificationNote = null,
|
||||||
?array $results = null
|
?array $results = null,
|
||||||
|
?array $resultPrices = null,
|
||||||
): void {
|
): void {
|
||||||
if (! $cutting->status->canTransitionTo($status)) {
|
if (! $cutting->status->canTransitionTo($status)) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
@ -367,11 +371,13 @@ public function transitionStatus(
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
DB::transaction(function () use ($cutting, $status, $verificationNote, $results, $user, $reason): void {
|
DB::transaction(function () use ($cutting, $status, $verificationNote, $results, $resultPrices, $user, $reason): void {
|
||||||
$cutting->load(['materials', 'results']);
|
$cutting->load(['materials.rawMaterialPrice', 'results']);
|
||||||
|
|
||||||
if ($status === CuttingStatus::COMPLETED) {
|
if ($status === CuttingStatus::COMPLETED) {
|
||||||
$this->applyRemainingMaterialStock($cutting);
|
$this->applyRemainingMaterialStock($cutting);
|
||||||
|
$cutting->total_material_cost = $this->calculateTotalMaterialCost($cutting);
|
||||||
|
$cutting->cost_per_unit = $this->calculateCostPerUnit($cutting);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($status === CuttingStatus::IN_PROGRESS) {
|
if ($status === CuttingStatus::IN_PROGRESS) {
|
||||||
@ -396,6 +402,7 @@ public function transitionStatus(
|
|||||||
$cutting->load('results');
|
$cutting->load('results');
|
||||||
}
|
}
|
||||||
$this->applyProductStockOnVerify($cutting);
|
$this->applyProductStockOnVerify($cutting);
|
||||||
|
$this->storeResultPrices($cutting, $resultPrices ?? []);
|
||||||
|
|
||||||
if ($verificationNote !== null && trim($verificationNote) !== '') {
|
if ($verificationNote !== null && trim($verificationNote) !== '') {
|
||||||
$cutting->rejection()->create([
|
$cutting->rejection()->create([
|
||||||
@ -442,7 +449,9 @@ private function buildMaterials(array $materials): array
|
|||||||
{
|
{
|
||||||
return collect($materials)
|
return collect($materials)
|
||||||
->map(function (array $itemData, int $index) {
|
->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) {
|
if ($price === null) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
@ -450,8 +459,17 @@ private function buildMaterials(array $materials): array
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$materialUsage = (float) $itemData['material_usage'];
|
$unit = $price->rawMaterial?->unit;
|
||||||
$remainingMaterial = (float) $itemData['remaining_material'];
|
$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) {
|
if ($materialUsage <= 0) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
@ -594,26 +612,34 @@ private function reverseMaterialStock(Cutting $cutting): void
|
|||||||
private function applyProductStockOnVerify(Cutting $cutting): void
|
private function applyProductStockOnVerify(Cutting $cutting): void
|
||||||
{
|
{
|
||||||
foreach ($cutting->results as $result) {
|
foreach ($cutting->results as $result) {
|
||||||
if ($result->warehouse_stock < 1) {
|
if ($result->warehouse_stock > 0) {
|
||||||
continue;
|
ProductVariant::query()
|
||||||
|
->whereKey($result->product_variant_id)
|
||||||
|
->increment('stock', $result->warehouse_stock);
|
||||||
}
|
}
|
||||||
|
|
||||||
ProductVariant::query()
|
if ($result->cutting_reject > 0) {
|
||||||
->whereKey($result->product_variant_id)
|
ProductVariant::query()
|
||||||
->increment('stock', $result->warehouse_stock);
|
->whereKey($result->product_variant_id)
|
||||||
|
->increment('reject_stock', $result->cutting_reject);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function reverseProductStock(Cutting $cutting): void
|
private function reverseProductStock(Cutting $cutting): void
|
||||||
{
|
{
|
||||||
foreach ($cutting->results as $result) {
|
foreach ($cutting->results as $result) {
|
||||||
if ($result->warehouse_stock < 1) {
|
if ($result->warehouse_stock > 0) {
|
||||||
continue;
|
ProductVariant::query()
|
||||||
|
->whereKey($result->product_variant_id)
|
||||||
|
->decrement('stock', $result->warehouse_stock);
|
||||||
}
|
}
|
||||||
|
|
||||||
ProductVariant::query()
|
if ($result->cutting_reject > 0) {
|
||||||
->whereKey($result->product_variant_id)
|
ProductVariant::query()
|
||||||
->decrement('stock', $result->warehouse_stock);
|
->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();
|
$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<array{product_variant_id: int, prices: list<array{type: string, price: int}>}> $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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,11 +6,11 @@
|
|||||||
use App\Enums\OrderStatus;
|
use App\Enums\OrderStatus;
|
||||||
use App\Enums\PaymentType;
|
use App\Enums\PaymentType;
|
||||||
use App\Enums\PriceType;
|
use App\Enums\PriceType;
|
||||||
|
use App\Enums\ProductStockQuality;
|
||||||
use App\Models\Customer;
|
use App\Models\Customer;
|
||||||
use App\Models\Order;
|
use App\Models\Order;
|
||||||
use App\Models\OrderItem;
|
use App\Models\OrderItem;
|
||||||
use App\Models\Product;
|
use App\Models\Product;
|
||||||
use App\Models\ProductPrice;
|
|
||||||
use App\Models\ProductVariant;
|
use App\Models\ProductVariant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Finance\CashService;
|
use App\Services\Finance\CashService;
|
||||||
@ -30,6 +30,7 @@ public function __construct(
|
|||||||
private readonly MarketplaceService $marketplaceService,
|
private readonly MarketplaceService $marketplaceService,
|
||||||
private readonly PushNotificationService $pushNotificationService,
|
private readonly PushNotificationService $pushNotificationService,
|
||||||
private readonly CashService $cashService,
|
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()
|
return Product::query()
|
||||||
->with([
|
->with([
|
||||||
'variants' => fn ($query) => $query
|
'variants' => fn ($query) => $query
|
||||||
->with(['prices' => fn ($query) => $query->orderBy('type'), 'media'])
|
->with('media')
|
||||||
->orderBy('created_at'),
|
->orderBy('created_at'),
|
||||||
])
|
])
|
||||||
->where(function (Builder $query) use ($orderVariantIds): void {
|
->where(function (Builder $query) use ($orderVariantIds): void {
|
||||||
@ -159,6 +160,10 @@ public function catalogItems(?Order $order = null, ?User $user = null): Collecti
|
|||||||
'images',
|
'images',
|
||||||
MediaPresenter::collection($variant, 'images'),
|
MediaPresenter::collection($variant, 'images'),
|
||||||
);
|
);
|
||||||
|
$variant->setAttribute(
|
||||||
|
'prices',
|
||||||
|
$this->presentVariantPrices($variant->id),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -167,7 +172,6 @@ public function findForEdit(Order $order): Order
|
|||||||
{
|
{
|
||||||
$order->load([
|
$order->load([
|
||||||
'items.productVariant.product:id,name',
|
'items.productVariant.product:id,name',
|
||||||
'items.productVariant.prices',
|
|
||||||
'items.productVariant.media',
|
'items.productVariant.media',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -181,6 +185,10 @@ public function findForEdit(Order $order): Order
|
|||||||
'images',
|
'images',
|
||||||
MediaPresenter::collection($variant, '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
|
public function syncDraftItem(array $validated, User $user): array
|
||||||
{
|
{
|
||||||
$priceType = PriceType::from($validated['price_type']);
|
$priceType = PriceType::from($validated['price_type']);
|
||||||
|
$stockQuality = ProductStockQuality::from($validated['stock_quality']);
|
||||||
$variant = ProductVariant::query()->findOrFail($validated['product_variant_id']);
|
$variant = ProductVariant::query()->findOrFail($validated['product_variant_id']);
|
||||||
$price = ProductPrice::query()
|
$unitPrice = $this->resolveUnitPrice($variant->id, $priceType);
|
||||||
->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.',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$quantity = (int) $validated['quantity'];
|
$quantity = (int) $validated['quantity'];
|
||||||
if ($quantity > $variant->stock) {
|
$availableStock = $this->availableStock($variant, $stockQuality);
|
||||||
|
|
||||||
|
if ($quantity > $availableStock) {
|
||||||
throw ValidationException::withMessages([
|
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;
|
$subtotal = $unitPrice * $quantity;
|
||||||
|
|
||||||
$item = OrderItem::query()->updateOrCreate(
|
$item = OrderItem::query()->updateOrCreate(
|
||||||
[
|
[
|
||||||
'user_id' => $user->id,
|
'user_id' => $user->id,
|
||||||
'product_variant_id' => $variant->id,
|
'product_variant_id' => $variant->id,
|
||||||
|
'stock_quality' => $stockQuality->value,
|
||||||
'order_id' => null,
|
'order_id' => null,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
@ -253,12 +255,13 @@ public function syncDraftItem(array $validated, User $user): array
|
|||||||
return $this->presentDraftItem($item);
|
return $this->presentDraftItem($item);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function removeDraftItem(User $user, ProductVariant $productVariant): void
|
public function removeDraftItem(User $user, ProductVariant $productVariant, ProductStockQuality $stockQuality): void
|
||||||
{
|
{
|
||||||
OrderItem::query()
|
OrderItem::query()
|
||||||
->whereNull('order_id')
|
->whereNull('order_id')
|
||||||
->where('user_id', $user->id)
|
->where('user_id', $user->id)
|
||||||
->where('product_variant_id', $productVariant->id)
|
->where('product_variant_id', $productVariant->id)
|
||||||
|
->where('stock_quality', $stockQuality->value)
|
||||||
->delete();
|
->delete();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -277,10 +280,7 @@ public function resyncDraftPrices(User $user, string $priceTypeValue): array
|
|||||||
->get();
|
->get();
|
||||||
|
|
||||||
foreach ($items as $item) {
|
foreach ($items as $item) {
|
||||||
$price = ProductPrice::query()
|
$price = $this->cuttingResultPriceResolver->resolve($item->product_variant_id, $priceType);
|
||||||
->where('variant_id', $item->product_variant_id)
|
|
||||||
->where('type', $priceType)
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if ($price === null) {
|
if ($price === null) {
|
||||||
$item->delete();
|
$item->delete();
|
||||||
@ -367,9 +367,13 @@ public function create(array $validated, User $user): Order
|
|||||||
'items' => 'Varian produk tidak ditemukan.',
|
'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([
|
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;
|
$item->order_id = $order->id;
|
||||||
@ -446,9 +450,13 @@ public function update(Order $order, array $validated): void
|
|||||||
'items' => 'Varian produk tidak ditemukan.',
|
'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([
|
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);
|
$orderItem = $order->items()->create($itemData);
|
||||||
@ -529,8 +537,8 @@ public function transitionStatus(Order $order, OrderStatus $status): void
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param list<array{product_variant_id: int, quantity: int|string}> $items
|
* @param list<array{product_variant_id: int, quantity: int|string, stock_quality?: string}> $items
|
||||||
* @return list<array{product_variant_id: int, quantity: int, unit_price: int, subtotal: int}>
|
* @return list<array{product_variant_id: int, stock_quality: string, quantity: int, unit_price: int, subtotal: int}>
|
||||||
*/
|
*/
|
||||||
private function buildLineItems(array $items, PriceType $priceType): array
|
private function buildLineItems(array $items, PriceType $priceType): array
|
||||||
{
|
{
|
||||||
@ -544,18 +552,10 @@ private function buildLineItems(array $items, PriceType $priceType): array
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$price = ProductPrice::query()
|
$unitPrice = $this->resolveUnitPrice($variant->id, $priceType, "items.{$index}.product_variant_id");
|
||||||
->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.',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$quantity = (int) $itemData['quantity'];
|
$quantity = (int) $itemData['quantity'];
|
||||||
|
$stockQuality = ProductStockQuality::from($itemData['stock_quality'] ?? ProductStockQuality::GOOD->value);
|
||||||
|
|
||||||
if ($quantity < 1) {
|
if ($quantity < 1) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
@ -563,11 +563,11 @@ private function buildLineItems(array $items, PriceType $priceType): array
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$unitPrice = (int) $price->price;
|
|
||||||
$subtotal = $unitPrice * $quantity;
|
$subtotal = $unitPrice * $quantity;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'product_variant_id' => $variant->id,
|
'product_variant_id' => $variant->id,
|
||||||
|
'stock_quality' => $stockQuality->value,
|
||||||
'quantity' => $quantity,
|
'quantity' => $quantity,
|
||||||
'unit_price' => $unitPrice,
|
'unit_price' => $unitPrice,
|
||||||
'subtotal' => $subtotal,
|
'subtotal' => $subtotal,
|
||||||
@ -619,6 +619,8 @@ private function presentDraftItem(OrderItem $item): array
|
|||||||
'product_variant_id' => $item->product_variant_id,
|
'product_variant_id' => $item->product_variant_id,
|
||||||
'product_name' => $variant?->product?->name ?? '',
|
'product_name' => $variant?->product?->name ?? '',
|
||||||
'variant_name' => $variant?->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,
|
'quantity' => (string) $item->quantity,
|
||||||
'unit_price' => $item->unit_price,
|
'unit_price' => $item->unit_price,
|
||||||
'images' => $variant ? MediaPresenter::collection($variant, 'images') : [],
|
'images' => $variant ? MediaPresenter::collection($variant, 'images') : [],
|
||||||
@ -631,18 +633,12 @@ private function presentDraftItem(OrderItem $item): array
|
|||||||
private function applyDraftPrices(EloquentCollection $items, PriceType $priceType): void
|
private function applyDraftPrices(EloquentCollection $items, PriceType $priceType): void
|
||||||
{
|
{
|
||||||
foreach ($items as $index => $item) {
|
foreach ($items as $index => $item) {
|
||||||
$price = ProductPrice::query()
|
$unitPrice = $this->resolveUnitPrice(
|
||||||
->where('variant_id', $item->product_variant_id)
|
$item->product_variant_id,
|
||||||
->where('type', $priceType)
|
$priceType,
|
||||||
->first();
|
"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->unit_price = $unitPrice;
|
||||||
$item->subtotal = $unitPrice * $item->quantity;
|
$item->subtotal = $unitPrice * $item->quantity;
|
||||||
$item->save();
|
$item->save();
|
||||||
@ -651,16 +647,25 @@ private function applyDraftPrices(EloquentCollection $items, PriceType $priceTyp
|
|||||||
|
|
||||||
private function decrementStock(OrderItem $item): void
|
private function decrementStock(OrderItem $item): void
|
||||||
{
|
{
|
||||||
|
$stockQuality = $item->stock_quality ?? ProductStockQuality::GOOD;
|
||||||
|
|
||||||
ProductVariant::query()
|
ProductVariant::query()
|
||||||
->whereKey($item->product_variant_id)
|
->whereKey($item->product_variant_id)
|
||||||
->decrement('stock', $item->quantity);
|
->decrement($stockQuality->stockColumn(), $item->quantity);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function incrementStock(OrderItem $item): void
|
private function incrementStock(OrderItem $item): void
|
||||||
{
|
{
|
||||||
|
$stockQuality = $item->stock_quality ?? ProductStockQuality::GOOD;
|
||||||
|
|
||||||
ProductVariant::query()
|
ProductVariant::query()
|
||||||
->whereKey($item->product_variant_id)
|
->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();
|
$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<array{
|
||||||
|
* type: string,
|
||||||
|
* type_label: string,
|
||||||
|
* price: int,
|
||||||
|
* price_formatted: string,
|
||||||
|
* price_input: string,
|
||||||
|
* cost_per_unit: int,
|
||||||
|
* cost_per_unit_formatted: string,
|
||||||
|
* }>
|
||||||
|
*/
|
||||||
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,8 +4,8 @@
|
|||||||
|
|
||||||
use App\Models\Category;
|
use App\Models\Category;
|
||||||
use App\Models\Product;
|
use App\Models\Product;
|
||||||
use App\Models\ProductPrice;
|
|
||||||
use App\Models\ProductVariant;
|
use App\Models\ProductVariant;
|
||||||
|
use App\Services\Manage\CuttingResultPriceResolver;
|
||||||
use App\Services\Media\MediaService;
|
use App\Services\Media\MediaService;
|
||||||
use App\Support\Media\MediaPresenter;
|
use App\Support\Media\MediaPresenter;
|
||||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
@ -18,6 +18,7 @@ class ProductService
|
|||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly MediaService $mediaService,
|
private readonly MediaService $mediaService,
|
||||||
|
private readonly CuttingResultPriceResolver $cuttingResultPriceResolver,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -119,12 +120,12 @@ public function paginateForIndex(array $tableQuery, string $isActive, string $ca
|
|||||||
->with([
|
->with([
|
||||||
'categories',
|
'categories',
|
||||||
'variants' => fn ($query) => $query
|
'variants' => fn ($query) => $query
|
||||||
->with(['prices' => fn ($query) => $query->orderBy('type'), 'media'])
|
->with('media')
|
||||||
->orderBy('created_at'),
|
->orderBy('created_at'),
|
||||||
])
|
])
|
||||||
->when($tableQuery['search'] !== '', function (Builder $query) use ($tableQuery): void {
|
->when($tableQuery['search'] !== '', function (Builder $query) use ($tableQuery): void {
|
||||||
$search = $tableQuery['search'];
|
$search = $tableQuery['search'];
|
||||||
$query->where(function (Builder $query) use ($search): void {
|
$query->where(function (Builder $query) use ($search) {
|
||||||
$query->where('name', 'like', "%{$search}%")
|
$query->where('name', 'like', "%{$search}%")
|
||||||
->orWhere('slug', 'like', "%{$search}%")
|
->orWhere('slug', 'like', "%{$search}%")
|
||||||
->orWhere('description', 'like', "%{$search}%")
|
->orWhere('description', 'like', "%{$search}%")
|
||||||
@ -152,6 +153,10 @@ public function paginateForIndex(array $tableQuery, string $isActive, string $ca
|
|||||||
'images',
|
'images',
|
||||||
MediaPresenter::collection($variant, 'images'),
|
MediaPresenter::collection($variant, 'images'),
|
||||||
);
|
);
|
||||||
|
$variant->setAttribute(
|
||||||
|
'prices',
|
||||||
|
$this->presentVariantPrices($variant->id),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
return $product;
|
return $product;
|
||||||
@ -225,7 +230,6 @@ public function update(Product $product, array $validated): void
|
|||||||
$variant->name = $variantData['name'];
|
$variant->name = $variantData['name'];
|
||||||
$variant->stock = $variantData['stock'];
|
$variant->stock = $variantData['stock'];
|
||||||
$variant->save();
|
$variant->save();
|
||||||
$this->syncVariantPrices($variant, $variantData['prices']);
|
|
||||||
$this->syncVariantImages($variant, $variantData, $index);
|
$this->syncVariantImages($variant, $variantData, $index);
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
@ -275,7 +279,6 @@ private function createVariant(Product $product, array $variantData, int $index)
|
|||||||
'stock' => $variantData['stock'],
|
'stock' => $variantData['stock'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->syncVariantPrices($variant, $variantData['prices']);
|
|
||||||
$this->syncVariantImages($variant, $variantData, $index);
|
$this->syncVariantImages($variant, $variantData, $index);
|
||||||
|
|
||||||
return $variant;
|
return $variant;
|
||||||
@ -298,20 +301,29 @@ private function syncVariantImages(ProductVariant $variant, array $variantData,
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param list<array{type: string, price: int}> $prices
|
* @return list<array{
|
||||||
|
* type: string,
|
||||||
|
* type_label: string,
|
||||||
|
* price: int,
|
||||||
|
* price_formatted: string,
|
||||||
|
* price_input: string,
|
||||||
|
* cost_per_unit: int,
|
||||||
|
* cost_per_unit_formatted: string,
|
||||||
|
* }>
|
||||||
*/
|
*/
|
||||||
private function syncVariantPrices(ProductVariant $variant, array $prices): void
|
private function presentVariantPrices(int $variantId): array
|
||||||
{
|
{
|
||||||
foreach ($prices as $priceData) {
|
return collect($this->cuttingResultPriceResolver->latestPricesForVariant($variantId))
|
||||||
ProductPrice::updateOrCreate(
|
->map(fn ($price) => [
|
||||||
[
|
'type' => $price->price_type->value,
|
||||||
'variant_id' => $variant->id,
|
'type_label' => $price->price_type->label(),
|
||||||
'type' => $priceData['type'],
|
'price' => (int) $price->price,
|
||||||
],
|
'price_formatted' => $price->price_formatted,
|
||||||
[
|
'price_input' => (string) $price->price,
|
||||||
'price' => $priceData['price'],
|
'cost_per_unit' => (int) $price->cost_per_unit,
|
||||||
],
|
'cost_per_unit_formatted' => $price->cost_per_unit_formatted,
|
||||||
);
|
])
|
||||||
}
|
->values()
|
||||||
|
->all();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,7 @@
|
|||||||
use App\Models\Cutting;
|
use App\Models\Cutting;
|
||||||
use App\Models\CuttingMaterial;
|
use App\Models\CuttingMaterial;
|
||||||
use App\Models\CuttingResult;
|
use App\Models\CuttingResult;
|
||||||
|
use App\Models\CuttingResultPrice;
|
||||||
use App\Models\Employee;
|
use App\Models\Employee;
|
||||||
use App\Models\EmployeeAdvance;
|
use App\Models\EmployeeAdvance;
|
||||||
use App\Models\Expense;
|
use App\Models\Expense;
|
||||||
@ -20,7 +21,6 @@
|
|||||||
use App\Models\PayrollAdjustment;
|
use App\Models\PayrollAdjustment;
|
||||||
use App\Models\PayrollPeriod;
|
use App\Models\PayrollPeriod;
|
||||||
use App\Models\Product;
|
use App\Models\Product;
|
||||||
use App\Models\ProductPrice;
|
|
||||||
use App\Models\ProductVariant;
|
use App\Models\ProductVariant;
|
||||||
use App\Models\Purchase;
|
use App\Models\Purchase;
|
||||||
use App\Models\PurchaseItem;
|
use App\Models\PurchaseItem;
|
||||||
@ -46,6 +46,7 @@ class ModelLabel
|
|||||||
Cutting::class => 'Cutting',
|
Cutting::class => 'Cutting',
|
||||||
CuttingMaterial::class => 'Bahan Cutting',
|
CuttingMaterial::class => 'Bahan Cutting',
|
||||||
CuttingResult::class => 'Hasil Cutting',
|
CuttingResult::class => 'Hasil Cutting',
|
||||||
|
CuttingResultPrice::class => 'Harga Hasil Cutting',
|
||||||
Employee::class => 'Pegawai',
|
Employee::class => 'Pegawai',
|
||||||
EmployeeAdvance::class => 'Kasbon',
|
EmployeeAdvance::class => 'Kasbon',
|
||||||
Expense::class => 'Pengeluaran',
|
Expense::class => 'Pengeluaran',
|
||||||
@ -56,7 +57,6 @@ class ModelLabel
|
|||||||
PayrollAdjustment::class => 'Penyesuaian Gaji',
|
PayrollAdjustment::class => 'Penyesuaian Gaji',
|
||||||
PayrollPeriod::class => 'Periode Gaji',
|
PayrollPeriod::class => 'Periode Gaji',
|
||||||
Product::class => 'Produk',
|
Product::class => 'Produk',
|
||||||
ProductPrice::class => 'Harga Produk',
|
|
||||||
ProductVariant::class => 'Varian Produk',
|
ProductVariant::class => 'Varian Produk',
|
||||||
Purchase::class => 'Belanja',
|
Purchase::class => 'Belanja',
|
||||||
PurchaseItem::class => 'Item Belanja',
|
PurchaseItem::class => 'Item Belanja',
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace Database\Factories;
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Enums\ProductStockQuality;
|
||||||
use App\Models\Order;
|
use App\Models\Order;
|
||||||
use App\Models\OrderItem;
|
use App\Models\OrderItem;
|
||||||
use App\Models\ProductVariant;
|
use App\Models\ProductVariant;
|
||||||
@ -20,6 +21,7 @@ public function definition(): array
|
|||||||
return [
|
return [
|
||||||
'order_id' => Order::factory(),
|
'order_id' => Order::factory(),
|
||||||
'product_variant_id' => ProductVariant::factory(),
|
'product_variant_id' => ProductVariant::factory(),
|
||||||
|
'stock_quality' => ProductStockQuality::GOOD->value,
|
||||||
'quantity' => $quantity,
|
'quantity' => $quantity,
|
||||||
'unit_price' => $unitPrice,
|
'unit_price' => $unitPrice,
|
||||||
'subtotal' => $quantity * $unitPrice,
|
'subtotal' => $quantity * $unitPrice,
|
||||||
|
|||||||
@ -1,23 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace Database\Factories;
|
|
||||||
|
|
||||||
use App\Enums\PriceType;
|
|
||||||
use App\Models\ProductPrice;
|
|
||||||
use App\Models\ProductVariant;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @extends Factory<ProductPrice>
|
|
||||||
*/
|
|
||||||
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),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -17,6 +17,7 @@ public function definition(): array
|
|||||||
'product_id' => Product::factory(),
|
'product_id' => Product::factory(),
|
||||||
'name' => fake()->randomElement(['S', 'M', 'L', 'XL', 'All Size']),
|
'name' => fake()->randomElement(['S', 'M', 'L', 'XL', 'All Size']),
|
||||||
'stock' => fake()->numberBetween(0, 100),
|
'stock' => fake()->numberBetween(0, 100),
|
||||||
|
'reject_stock' => fake()->numberBetween(0, 20),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,6 +15,7 @@ public function up(): void
|
|||||||
|
|
||||||
$table->string('name', 200);
|
$table->string('name', 200);
|
||||||
$table->unsignedInteger('stock')->default(0);
|
$table->unsignedInteger('stock')->default(0);
|
||||||
|
$table->unsignedInteger('reject_stock')->default(0);
|
||||||
|
|
||||||
$table->timestamp('created_at')->useCurrent();
|
$table->timestamp('created_at')->useCurrent();
|
||||||
$table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate();
|
$table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate();
|
||||||
|
|||||||
@ -1,29 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
Schema::create('product_prices', function (Blueprint $table) {
|
|
||||||
$table->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');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,5 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Enums\ProductStockQuality;
|
||||||
use Illuminate\Database\Migrations\Migration;
|
use Illuminate\Database\Migrations\Migration;
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
use Illuminate\Support\Facades\Schema;
|
use Illuminate\Support\Facades\Schema;
|
||||||
@ -15,6 +16,7 @@ public function up(): void
|
|||||||
$table->foreignId('user_id')->nullable()->constrained()->cascadeOnDelete();
|
$table->foreignId('user_id')->nullable()->constrained()->cascadeOnDelete();
|
||||||
$table->foreignId('product_variant_id')->constrained()->restrictOnDelete();
|
$table->foreignId('product_variant_id')->constrained()->restrictOnDelete();
|
||||||
|
|
||||||
|
$table->enum('stock_quality', array_column(ProductStockQuality::cases(), 'value'))->default(ProductStockQuality::GOOD->value);
|
||||||
$table->unsignedInteger('quantity');
|
$table->unsignedInteger('quantity');
|
||||||
$table->unsignedBigInteger('unit_price');
|
$table->unsignedBigInteger('unit_price');
|
||||||
$table->unsignedBigInteger('subtotal');
|
$table->unsignedBigInteger('subtotal');
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Enums\CuttingStatus;
|
use App\Enums\CuttingStatus;
|
||||||
|
use App\Enums\PriceType;
|
||||||
use Illuminate\Database\Migrations\Migration;
|
use Illuminate\Database\Migrations\Migration;
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
use Illuminate\Support\Facades\Schema;
|
use Illuminate\Support\Facades\Schema;
|
||||||
@ -15,6 +16,9 @@ public function up(): void
|
|||||||
$table->enum('status', array_column(CuttingStatus::cases(), 'value'))->default(CuttingStatus::IN_PROGRESS->value);
|
$table->enum('status', array_column(CuttingStatus::cases(), 'value'))->default(CuttingStatus::IN_PROGRESS->value);
|
||||||
$table->string('description', 100)->nullable();
|
$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->foreignId('created_by_id')->constrained('users')->restrictOnDelete();
|
||||||
|
|
||||||
$table->timestamp('created_at')->useCurrent();
|
$table->timestamp('created_at')->useCurrent();
|
||||||
@ -22,10 +26,27 @@ public function up(): void
|
|||||||
$table->softDeletes();
|
$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
|
public function down(): void
|
||||||
{
|
{
|
||||||
|
Schema::dropIfExists('cutting_result_prices');
|
||||||
Schema::dropIfExists('cuttings');
|
Schema::dropIfExists('cuttings');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@ -14,8 +14,8 @@ public function up(): void
|
|||||||
$table->foreignId('cutting_id')->constrained()->cascadeOnDelete();
|
$table->foreignId('cutting_id')->constrained()->cascadeOnDelete();
|
||||||
$table->foreignId('raw_material_price_id')->constrained()->restrictOnDelete();
|
$table->foreignId('raw_material_price_id')->constrained()->restrictOnDelete();
|
||||||
|
|
||||||
$table->decimal('material_usage', 18, 4);
|
$table->decimal('material_usage', 18, 2);
|
||||||
$table->decimal('remaining_material', 18, 4);
|
$table->decimal('remaining_material', 18, 2);
|
||||||
|
|
||||||
$table->timestamp('created_at')->useCurrent();
|
$table->timestamp('created_at')->useCurrent();
|
||||||
$table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate();
|
$table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate();
|
||||||
|
|||||||
@ -18,7 +18,6 @@ public function run(): void
|
|||||||
CategorySeeder::class,
|
CategorySeeder::class,
|
||||||
ProductSeeder::class,
|
ProductSeeder::class,
|
||||||
ProductVariantSeeder::class,
|
ProductVariantSeeder::class,
|
||||||
ProductPriceSeeder::class,
|
|
||||||
RawMaterialSeeder::class,
|
RawMaterialSeeder::class,
|
||||||
RawMaterialPriceSeeder::class,
|
RawMaterialPriceSeeder::class,
|
||||||
SupplierSeeder::class,
|
SupplierSeeder::class,
|
||||||
|
|||||||
@ -1,28 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace Database\Seeders;
|
|
||||||
|
|
||||||
use App\Enums\PriceType;
|
|
||||||
use App\Models\ProductPrice;
|
|
||||||
use App\Models\ProductVariant;
|
|
||||||
use Illuminate\Database\Seeder;
|
|
||||||
|
|
||||||
class ProductPriceSeeder extends Seeder
|
|
||||||
{
|
|
||||||
public function run(): void
|
|
||||||
{
|
|
||||||
ProductVariant::query()
|
|
||||||
->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),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -2,10 +2,8 @@
|
|||||||
|
|
||||||
namespace Database\Seeders;
|
namespace Database\Seeders;
|
||||||
|
|
||||||
use App\Enums\PriceType;
|
|
||||||
use App\Models\Category;
|
use App\Models\Category;
|
||||||
use App\Models\Product;
|
use App\Models\Product;
|
||||||
use App\Models\ProductPrice;
|
|
||||||
use App\Models\ProductVariant;
|
use App\Models\ProductVariant;
|
||||||
use Illuminate\Database\Seeder;
|
use Illuminate\Database\Seeder;
|
||||||
use Illuminate\Support\Facades\DB;
|
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.',
|
'description' => 'Daster batik motif parang dengan bahan katun nyaman untuk pemakaian sehari-hari.',
|
||||||
'category_slugs' => ['daster'],
|
'category_slugs' => ['daster'],
|
||||||
'variants' => [
|
'variants' => [
|
||||||
['name' => 'S', 'stock' => 25, 'base_price' => 65000],
|
['name' => 'S', 'stock' => 25],
|
||||||
['name' => 'M', 'stock' => 40, 'base_price' => 65000],
|
['name' => 'M', 'stock' => 40],
|
||||||
['name' => 'L', 'stock' => 35, 'base_price' => 65000],
|
['name' => 'L', 'stock' => 35],
|
||||||
['name' => 'XL', 'stock' => 20, 'base_price' => 70000],
|
['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.',
|
'description' => 'Setelan atasan dan celana batik dengan potongan modern untuk acara formal maupun kasual.',
|
||||||
'category_slugs' => ['setelan-celana'],
|
'category_slugs' => ['setelan-celana'],
|
||||||
'variants' => [
|
'variants' => [
|
||||||
['name' => 'S', 'stock' => 15, 'base_price' => 185000],
|
['name' => 'S', 'stock' => 15],
|
||||||
['name' => 'M', 'stock' => 20, 'base_price' => 185000],
|
['name' => 'M', 'stock' => 20],
|
||||||
['name' => 'L', 'stock' => 18, 'base_price' => 185000],
|
['name' => 'L', 'stock' => 18],
|
||||||
['name' => 'XL', 'stock' => 12, 'base_price' => 195000],
|
['name' => 'XL', 'stock' => 12],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
@ -47,9 +45,9 @@ public function run(): void
|
|||||||
'description' => 'Blouse batik lengan panjang dengan detail kerah mandarin.',
|
'description' => 'Blouse batik lengan panjang dengan detail kerah mandarin.',
|
||||||
'category_slugs' => ['atasan'],
|
'category_slugs' => ['atasan'],
|
||||||
'variants' => [
|
'variants' => [
|
||||||
['name' => 'S', 'stock' => 30, 'base_price' => 95000],
|
['name' => 'S', 'stock' => 30],
|
||||||
['name' => 'M', 'stock' => 35, 'base_price' => 95000],
|
['name' => 'M', 'stock' => 35],
|
||||||
['name' => 'L', 'stock' => 28, 'base_price' => 95000],
|
['name' => 'L', 'stock' => 28],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
@ -57,7 +55,7 @@ public function run(): void
|
|||||||
'description' => 'Rok lilit batik dengan motif klasik, mudah disesuaikan dengan berbagai ukuran.',
|
'description' => 'Rok lilit batik dengan motif klasik, mudah disesuaikan dengan berbagai ukuran.',
|
||||||
'category_slugs' => ['bawahan'],
|
'category_slugs' => ['bawahan'],
|
||||||
'variants' => [
|
'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.',
|
'description' => 'Daster busui dengan akses bukaan menyusui praktis dan bahan adem.',
|
||||||
'category_slugs' => ['busui', 'daster'],
|
'category_slugs' => ['busui', 'daster'],
|
||||||
'variants' => [
|
'variants' => [
|
||||||
['name' => 'M', 'stock' => 22, 'base_price' => 78000],
|
['name' => 'M', 'stock' => 22],
|
||||||
['name' => 'L', 'stock' => 26, 'base_price' => 78000],
|
['name' => 'L', 'stock' => 26],
|
||||||
['name' => 'XL', 'stock' => 18, 'base_price' => 82000],
|
['name' => 'XL', 'stock' => 18],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
@ -88,36 +86,13 @@ public function run(): void
|
|||||||
);
|
);
|
||||||
|
|
||||||
foreach ($productData['variants'] as $variantData) {
|
foreach ($productData['variants'] as $variantData) {
|
||||||
$variant = ProductVariant::factory()->create([
|
ProductVariant::factory()->create([
|
||||||
'product_id' => $product->id,
|
'product_id' => $product->id,
|
||||||
'name' => $variantData['name'],
|
'name' => $variantData['name'],
|
||||||
'stock' => $variantData['stock'],
|
'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),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,7 @@ public function up(): void
|
|||||||
{
|
{
|
||||||
$this->migrator->inGroup('hr', function (SettingsBlueprint $blueprint): void {
|
$this->migrator->inGroup('hr', function (SettingsBlueprint $blueprint): void {
|
||||||
$blueprint->add('scheduled_check_in_time', '08:00');
|
$blueprint->add('scheduled_check_in_time', '08:00');
|
||||||
|
$blueprint->add('scheduled_check_out_time', '17:00');
|
||||||
$blueprint->add('late_penalty_amount', 0);
|
$blueprint->add('late_penalty_amount', 0);
|
||||||
$blueprint->add('absent_penalty_amount', 0);
|
$blueprint->add('absent_penalty_amount', 0);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,14 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use Spatie\LaravelSettings\Migrations\SettingsBlueprint;
|
|
||||||
use Spatie\LaravelSettings\Migrations\SettingsMigration;
|
|
||||||
|
|
||||||
return new class extends SettingsMigration
|
|
||||||
{
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
$this->migrator->inGroup('hr', function (SettingsBlueprint $blueprint): void {
|
|
||||||
$blueprint->add('scheduled_check_in_time', '08:00');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,14 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use Spatie\LaravelSettings\Migrations\SettingsBlueprint;
|
|
||||||
use Spatie\LaravelSettings\Migrations\SettingsMigration;
|
|
||||||
|
|
||||||
return new class extends SettingsMigration
|
|
||||||
{
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
$this->migrator->inGroup('hr', function (SettingsBlueprint $blueprint): void {
|
|
||||||
$blueprint->add('scheduled_check_out_time', '17:00');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -225,7 +225,7 @@ function getGroupedResults(results: any[]): GroupedCuttingResults[] {
|
|||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Varian</TableHead>
|
<TableHead>Varian</TableHead>
|
||||||
<TableHead>Hasil</TableHead>
|
<TableHead>Hasil</TableHead>
|
||||||
<TableHead>Gudang</TableHead>
|
<TableHead>Bagus</TableHead>
|
||||||
<TableHead>Reject</TableHead>
|
<TableHead>Reject</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
|
|||||||
@ -29,6 +29,7 @@ import { Textarea } from '@/components/ui/textarea';
|
|||||||
import { getFirstCoverImage } from '@/lib/catalog-cover';
|
import { getFirstCoverImage } from '@/lib/catalog-cover';
|
||||||
import { FIELD_LIMITS } from '@/lib/field-limits';
|
import { FIELD_LIMITS } from '@/lib/field-limits';
|
||||||
import { formErrors } from '@/lib/form';
|
import { formErrors } from '@/lib/form';
|
||||||
|
import { formatRupiah } from '@/lib/rupiah';
|
||||||
import type {
|
import type {
|
||||||
CuttingMaterialCartItem,
|
CuttingMaterialCartItem,
|
||||||
CuttingProductCatalogItem,
|
CuttingProductCatalogItem,
|
||||||
@ -36,6 +37,72 @@ import type {
|
|||||||
CuttingResultCartItem,
|
CuttingResultCartItem,
|
||||||
} from '@/types/cutting';
|
} 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<{
|
const props = defineProps<{
|
||||||
rawMaterialCatalog: CuttingRawMaterialCatalogItem[];
|
rawMaterialCatalog: CuttingRawMaterialCatalogItem[];
|
||||||
productCatalog: CuttingProductCatalogItem[];
|
productCatalog: CuttingProductCatalogItem[];
|
||||||
@ -157,9 +224,11 @@ function addMaterial(
|
|||||||
raw_material_price_id: price.id,
|
raw_material_price_id: price.id,
|
||||||
raw_material_name: rawMaterial.name,
|
raw_material_name: rawMaterial.name,
|
||||||
variant: price.variant,
|
variant: price.variant,
|
||||||
|
unit: rawMaterial.unit,
|
||||||
|
uses_length_unit: usesLengthUnit(rawMaterial.unit),
|
||||||
unit_abbreviation: rawMaterial.unit_abbreviation,
|
unit_abbreviation: rawMaterial.unit_abbreviation,
|
||||||
stock_input: price.stock_input,
|
stock_input: price.stock_input,
|
||||||
material_usage: '1',
|
material_usage: usesLengthUnit(rawMaterial.unit) ? '100' : '1',
|
||||||
remaining_material: '0',
|
remaining_material: '0',
|
||||||
images: price.images ?? [],
|
images: price.images ?? [],
|
||||||
});
|
});
|
||||||
@ -263,7 +332,7 @@ function submit() {
|
|||||||
|
|
||||||
if (warehouse + reject !== total) {
|
if (warehouse + reject !== total) {
|
||||||
toast.error(
|
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;
|
return;
|
||||||
@ -656,9 +725,7 @@ function submit() {
|
|||||||
<div class="grid grid-cols-2 gap-2">
|
<div class="grid grid-cols-2 gap-2">
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel class="text-xs">
|
<FieldLabel class="text-xs">
|
||||||
Pemakaian ({{
|
Pemakaian ({{ item.uses_length_unit ? 'cm' : item.unit_abbreviation }})
|
||||||
item.unit_abbreviation
|
|
||||||
}})
|
|
||||||
</FieldLabel>
|
</FieldLabel>
|
||||||
<DecimalInput
|
<DecimalInput
|
||||||
v-model="
|
v-model="
|
||||||
@ -669,9 +736,7 @@ function submit() {
|
|||||||
</Field>
|
</Field>
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel class="text-xs">
|
<FieldLabel class="text-xs">
|
||||||
Sisa ({{
|
Sisa ({{ item.uses_length_unit ? 'cm' : item.unit_abbreviation }})
|
||||||
item.unit_abbreviation
|
|
||||||
}})
|
|
||||||
</FieldLabel>
|
</FieldLabel>
|
||||||
<DecimalInput
|
<DecimalInput
|
||||||
v-model="
|
v-model="
|
||||||
@ -747,7 +812,7 @@ function submit() {
|
|||||||
</Field>
|
</Field>
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel class="text-xs"
|
<FieldLabel class="text-xs"
|
||||||
>Gudang</FieldLabel
|
>Bagus</FieldLabel
|
||||||
>
|
>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
v-model="
|
v-model="
|
||||||
@ -775,6 +840,29 @@ function submit() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="materialCart.length > 0"
|
||||||
|
class="rounded-lg border bg-muted/40 p-3 text-sm"
|
||||||
|
>
|
||||||
|
<p class="font-medium">Estimasi Harga Modal</p>
|
||||||
|
<p class="text-muted-foreground">
|
||||||
|
Total bahan baku:
|
||||||
|
{{ formatRupiah(totalMaterialCost) }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-if="totalResultPieces > 0"
|
||||||
|
class="font-semibold tabular-nums"
|
||||||
|
>
|
||||||
|
{{ formatRupiah(estimatedCostPerUnit) }} / pcs
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-else
|
||||||
|
class="text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
Tambahkan hasil produk untuk estimasi per pcs.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
|
|||||||
@ -21,6 +21,8 @@ import {
|
|||||||
FieldSet,
|
FieldSet,
|
||||||
} from '@/components/ui/field';
|
} from '@/components/ui/field';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@ -28,7 +30,12 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@/components/ui/tooltip';
|
} from '@/components/ui/tooltip';
|
||||||
import { useCan } from '@/composables/useCan';
|
import { useCan } from '@/composables/useCan';
|
||||||
import { FIELD_LIMITS } from '@/lib/field-limits';
|
import { RupiahInput } from '@/components/form/rupiah-input';
|
||||||
|
import { parseRupiah } from '@/lib/rupiah';
|
||||||
|
import {
|
||||||
|
PRICE_TYPES,
|
||||||
|
PRICE_TYPE_LABELS,
|
||||||
|
} from '@/types/product';
|
||||||
import type { CuttingListItem, CuttingStatusAction } from '@/types/cutting';
|
import type { CuttingListItem, CuttingStatusAction } from '@/types/cutting';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@ -43,6 +50,7 @@ const statusConfirmOpen = ref(false);
|
|||||||
const statusProcessing = ref(false);
|
const statusProcessing = ref(false);
|
||||||
const rejectDialogOpen = ref(false);
|
const rejectDialogOpen = ref(false);
|
||||||
const verifyDialogOpen = ref(false);
|
const verifyDialogOpen = ref(false);
|
||||||
|
const allMatches = ref(true);
|
||||||
const pendingAction = ref<CuttingStatusAction | null>(null);
|
const pendingAction = ref<CuttingStatusAction | null>(null);
|
||||||
|
|
||||||
const rejectForm = useForm({
|
const rejectForm = useForm({
|
||||||
@ -50,6 +58,10 @@ const rejectForm = useForm({
|
|||||||
reason: '',
|
reason: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function buildEmptyPrices(): Record<string, string> {
|
||||||
|
return Object.fromEntries(PRICE_TYPES.map((type) => [type, '']));
|
||||||
|
}
|
||||||
|
|
||||||
const verifyForm = useForm({
|
const verifyForm = useForm({
|
||||||
status: 'verified',
|
status: 'verified',
|
||||||
verification_note: '',
|
verification_note: '',
|
||||||
@ -59,11 +71,19 @@ const verifyForm = useForm({
|
|||||||
cutting_result: res.cutting_result,
|
cutting_result: res.cutting_result,
|
||||||
warehouse_stock: res.warehouse_stock,
|
warehouse_stock: res.warehouse_stock,
|
||||||
cutting_reject: res.cutting_reject,
|
cutting_reject: res.cutting_reject,
|
||||||
|
original_warehouse_stock: res.warehouse_stock,
|
||||||
|
original_cutting_reject: res.cutting_reject,
|
||||||
|
prices: buildEmptyPrices(),
|
||||||
})),
|
})),
|
||||||
|
result_prices: [] as Array<{
|
||||||
|
product_variant_id: number;
|
||||||
|
prices: Array<{ type: string; price: number }>;
|
||||||
|
}>,
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(verifyDialogOpen, (isOpen) => {
|
watch(verifyDialogOpen, (isOpen) => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
|
allMatches.value = true;
|
||||||
verifyForm.verification_note = '';
|
verifyForm.verification_note = '';
|
||||||
verifyForm.results = props.cutting.results.map(res => ({
|
verifyForm.results = props.cutting.results.map(res => ({
|
||||||
product_variant_id: res.product_variant?.id || 0,
|
product_variant_id: res.product_variant?.id || 0,
|
||||||
@ -71,11 +91,60 @@ watch(verifyDialogOpen, (isOpen) => {
|
|||||||
cutting_result: res.cutting_result,
|
cutting_result: res.cutting_result,
|
||||||
warehouse_stock: res.warehouse_stock,
|
warehouse_stock: res.warehouse_stock,
|
||||||
cutting_reject: res.cutting_reject,
|
cutting_reject: res.cutting_reject,
|
||||||
|
original_warehouse_stock: res.warehouse_stock,
|
||||||
|
original_cutting_reject: res.cutting_reject,
|
||||||
|
prices: buildEmptyPrices(),
|
||||||
}));
|
}));
|
||||||
|
verifyForm.result_prices = [];
|
||||||
verifyForm.clearErrors();
|
verifyForm.clearErrors();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
watch(allMatches, (matches) => {
|
||||||
|
if (!matches) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
verifyForm.results = verifyForm.results.map((result) => ({
|
||||||
|
...result,
|
||||||
|
warehouse_stock: result.original_warehouse_stock,
|
||||||
|
cutting_reject: result.original_cutting_reject,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
function setAllMatches(value: boolean) {
|
||||||
|
allMatches.value = value;
|
||||||
|
|
||||||
|
if (value) {
|
||||||
|
verifyForm.results = verifyForm.results.map((result) => ({
|
||||||
|
...result,
|
||||||
|
warehouse_stock: result.original_warehouse_stock,
|
||||||
|
cutting_reject: result.original_cutting_reject,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function setResultPrice(resultIndex: number, type: string, value: string) {
|
||||||
|
const result = verifyForm.results[resultIndex];
|
||||||
|
if (!result) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.prices = {
|
||||||
|
...result.prices,
|
||||||
|
[type]: value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildResultPricesPayload() {
|
||||||
|
return verifyForm.results.map((result) => ({
|
||||||
|
product_variant_id: result.product_variant_id,
|
||||||
|
prices: PRICE_TYPES.map((type) => ({
|
||||||
|
type,
|
||||||
|
price: Number.parseInt(parseRupiah(result.prices[type] ?? ''), 10) || 0,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
const availableActions = computed(() => props.cutting.available_actions ?? []);
|
const availableActions = computed(() => props.cutting.available_actions ?? []);
|
||||||
|
|
||||||
const canEdit = computed(
|
const canEdit = computed(
|
||||||
@ -95,7 +164,7 @@ function statusConfirmDescription(action: CuttingStatusAction): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (action.status === 'verified') {
|
if (action.status === 'verified') {
|
||||||
return 'Hasil cutting akan diverifikasi dan stok produk gudang akan ditambahkan.';
|
return 'Hasil cutting akan diverifikasi. Stok bagus dan reject akan ditambahkan ke produk.';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action.status === 'in_progress') {
|
if (action.status === 'in_progress') {
|
||||||
@ -125,7 +194,18 @@ function openStatusConfirm(action: CuttingStatusAction) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function submitVerify() {
|
function submitVerify() {
|
||||||
verifyForm.post(`/admin/manage/cuttings/${props.cutting.id}/status`, {
|
verifyForm
|
||||||
|
.transform((data) => ({
|
||||||
|
status: data.status,
|
||||||
|
verification_note: data.verification_note,
|
||||||
|
results: data.results.map(({ product_variant_id, warehouse_stock, cutting_reject }) => ({
|
||||||
|
product_variant_id,
|
||||||
|
warehouse_stock,
|
||||||
|
cutting_reject,
|
||||||
|
})),
|
||||||
|
result_prices: buildResultPricesPayload(),
|
||||||
|
}))
|
||||||
|
.post(`/admin/manage/cuttings/${props.cutting.id}/status`, {
|
||||||
preserveScroll: true,
|
preserveScroll: true,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
verifyDialogOpen.value = false;
|
verifyDialogOpen.value = false;
|
||||||
@ -375,7 +455,7 @@ function actionIcon(status: string) {
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog v-model:open="verifyDialogOpen">
|
<Dialog v-model:open="verifyDialogOpen">
|
||||||
<DialogContent class="sm:max-w-lg">
|
<DialogContent class="sm:max-w-2xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Verifikasi Hasil Cutting</DialogTitle>
|
<DialogTitle>Verifikasi Hasil Cutting</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@ -383,11 +463,35 @@ function actionIcon(status: string) {
|
|||||||
<form @submit.prevent="submitVerify">
|
<form @submit.prevent="submitVerify">
|
||||||
<div class="space-y-4 max-h-[60vh] overflow-y-auto scrollbar-thin px-1 py-1">
|
<div class="space-y-4 max-h-[60vh] overflow-y-auto scrollbar-thin px-1 py-1">
|
||||||
<p class="text-sm text-muted-foreground">
|
<p class="text-sm text-muted-foreground">
|
||||||
Silakan verifikasi jumlah produk yang diterima di toko. Jika terdapat perbedaan/selisih, jumlah produk masuk gudang akan disesuaikan secara otomatis dan selisihnya akan dicatat sebagai reject.
|
Verifikasi jumlah produk yang diterima di toko dan tentukan harga jual per varian.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between rounded-lg border p-3">
|
||||||
|
<Label for="all-matches" class="text-sm font-medium">
|
||||||
|
Semua sesuai dengan data cutting
|
||||||
|
</Label>
|
||||||
|
<Switch
|
||||||
|
id="all-matches"
|
||||||
|
:model-value="allMatches"
|
||||||
|
@update:model-value="setAllMatches"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="cutting.estimated_cost_per_unit_formatted"
|
||||||
|
class="rounded-lg border bg-muted/40 p-3 text-sm"
|
||||||
|
>
|
||||||
|
<p class="font-medium">Harga Modal Batch</p>
|
||||||
|
<p class="text-muted-foreground">
|
||||||
|
Total bahan baku: {{ cutting.total_material_cost_formatted ?? '-' }}
|
||||||
|
</p>
|
||||||
|
<p class="font-semibold tabular-nums">
|
||||||
|
{{ cutting.estimated_cost_per_unit_formatted }} / pcs
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div v-for="(result, index) in verifyForm.results" :key="result.product_variant_id" class="p-3 border rounded-lg space-y-2">
|
<div v-for="(result, index) in verifyForm.results" :key="result.product_variant_id" class="p-3 border rounded-lg space-y-3">
|
||||||
<div class="font-medium text-sm">
|
<div class="font-medium text-sm">
|
||||||
{{ result.name }}
|
{{ result.name }}
|
||||||
</div>
|
</div>
|
||||||
@ -397,27 +501,67 @@ function actionIcon(status: string) {
|
|||||||
<span class="font-semibold">{{ result.cutting_result }} pcs</span>
|
<span class="font-semibold">{{ result.cutting_result }} pcs</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label :for="`received-${index}`" class="text-muted-foreground block mb-0.5">Diterima:</label>
|
<span class="text-muted-foreground block mb-0.5">Data cutting:</span>
|
||||||
<Input
|
<span class="font-medium tabular-nums">
|
||||||
:id="`received-${index}`"
|
{{ result.original_warehouse_stock }} bagus ·
|
||||||
type="number"
|
{{ result.original_cutting_reject }} reject
|
||||||
v-model.number="result.warehouse_stock"
|
</span>
|
||||||
min="0"
|
|
||||||
:max="result.cutting_result"
|
|
||||||
class="h-8 w-20 px-2 text-xs"
|
|
||||||
@input="result.cutting_reject = result.cutting_result - result.warehouse_stock"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="text-muted-foreground block">Selisih (Reject):</span>
|
<span class="text-muted-foreground block">Stok Reject:</span>
|
||||||
<Badge variant="secondary" class="font-semibold">
|
<Badge variant="secondary" class="font-semibold">
|
||||||
{{ result.cutting_reject }} pcs
|
{{ result.cutting_reject }} pcs
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-2 items-end text-xs">
|
||||||
|
<div>
|
||||||
|
<label :for="`good-${index}`" class="text-muted-foreground block mb-0.5">Stok Bagus (diterima):</label>
|
||||||
|
<Input
|
||||||
|
:id="`good-${index}`"
|
||||||
|
type="number"
|
||||||
|
v-model.number="result.warehouse_stock"
|
||||||
|
min="0"
|
||||||
|
:max="result.cutting_result"
|
||||||
|
class="h-8 w-full px-2 text-xs"
|
||||||
|
:disabled="allMatches"
|
||||||
|
@input="result.cutting_reject = result.cutting_result - result.warehouse_stock"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label :for="`reject-${index}`" class="text-muted-foreground block mb-0.5">Stok Reject (diterima):</label>
|
||||||
|
<Input
|
||||||
|
:id="`reject-${index}`"
|
||||||
|
type="number"
|
||||||
|
v-model.number="result.cutting_reject"
|
||||||
|
min="0"
|
||||||
|
:max="result.cutting_result"
|
||||||
|
class="h-8 w-full px-2 text-xs"
|
||||||
|
:disabled="allMatches"
|
||||||
|
@input="result.warehouse_stock = result.cutting_result - result.cutting_reject"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-2 sm:grid-cols-2">
|
||||||
|
<Field v-for="type in PRICE_TYPES" :key="`${result.product_variant_id}-${type}`">
|
||||||
|
<FieldLabel class="text-xs" :for="`price-${index}-${type}`">
|
||||||
|
{{ PRICE_TYPE_LABELS[type] }}
|
||||||
|
</FieldLabel>
|
||||||
|
<RupiahInput
|
||||||
|
:id="`price-${index}-${type}`"
|
||||||
|
:model-value="result.prices[type]"
|
||||||
|
@update:model-value="setResultPrice(index, type, $event)"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<FieldError
|
||||||
|
:errors="verifyForm.errors.result_prices ? [verifyForm.errors.result_prices] : []"
|
||||||
|
/>
|
||||||
|
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel for="verification-note">Catatan Verifikasi</FieldLabel>
|
<FieldLabel for="verification-note">Catatan Verifikasi</FieldLabel>
|
||||||
<Textarea
|
<Textarea
|
||||||
|
|||||||
@ -43,7 +43,7 @@ import { getFirstCoverImage } from '@/lib/catalog-cover';
|
|||||||
import { formErrors } from '@/lib/form';
|
import { formErrors } from '@/lib/form';
|
||||||
import { formatRupiah, parseRupiah } from '@/lib/rupiah';
|
import { formatRupiah, parseRupiah } from '@/lib/rupiah';
|
||||||
import type { Auth } from '@/types/auth';
|
import type { Auth } from '@/types/auth';
|
||||||
import type { EnumOption, OrderCartItem, OrderCatalogItem, SelectOption } from '@/types/order';
|
import { STOCK_QUALITY_OPTIONS, type EnumOption, type OrderCartItem, type OrderCatalogItem, type SelectOption } from '@/types/order';
|
||||||
import type { ProductPriceItem, ProductVariantItem } from '@/types/product';
|
import type { ProductPriceItem, ProductVariantItem } from '@/types/product';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@ -88,6 +88,7 @@ const isMarketingUser = computed(() =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
const search = ref('');
|
const search = ref('');
|
||||||
|
const selectedStockQuality = ref<'good' | 'reject'>('good');
|
||||||
const cart = ref<OrderCartItem[]>([]);
|
const cart = ref<OrderCartItem[]>([]);
|
||||||
const customerFormOpen = ref(false);
|
const customerFormOpen = ref(false);
|
||||||
const printAfterSave = ref(false);
|
const printAfterSave = ref(false);
|
||||||
@ -136,7 +137,10 @@ function populateForm() {
|
|||||||
form.discount = props.initialData.discount;
|
form.discount = props.initialData.discount;
|
||||||
form.shipping_cost = String(props.initialData.shipping_cost);
|
form.shipping_cost = String(props.initialData.shipping_cost);
|
||||||
form.notes = props.initialData.notes;
|
form.notes = props.initialData.notes;
|
||||||
cart.value = props.initialData.items.map((item) => ({ ...item }));
|
cart.value = props.initialData.items.map((item) => ({
|
||||||
|
...item,
|
||||||
|
stock_quality: item.stock_quality ?? 'good',
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@ -201,7 +205,9 @@ watch(
|
|||||||
|
|
||||||
function upsertCartItem(item: OrderCartItem) {
|
function upsertCartItem(item: OrderCartItem) {
|
||||||
const index = cart.value.findIndex(
|
const index = cart.value.findIndex(
|
||||||
(cartItem) => cartItem.product_variant_id === item.product_variant_id,
|
(cartItem) =>
|
||||||
|
cartItem.product_variant_id === item.product_variant_id
|
||||||
|
&& cartItem.stock_quality === item.stock_quality,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (index === -1) {
|
if (index === -1) {
|
||||||
@ -213,6 +219,10 @@ function upsertCartItem(item: OrderCartItem) {
|
|||||||
cart.value[index] = { ...item };
|
cart.value[index] = { ...item };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function availableStockForVariant(variant: ProductVariantItem, stockQuality: string): number {
|
||||||
|
return stockQuality === 'reject' ? variant.reject_stock : variant.stock;
|
||||||
|
}
|
||||||
|
|
||||||
const filteredCatalog = computed(() => {
|
const filteredCatalog = computed(() => {
|
||||||
const keyword = search.value.trim().toLowerCase();
|
const keyword = search.value.trim().toLowerCase();
|
||||||
|
|
||||||
@ -238,12 +248,20 @@ function getVariantPrice(variant: ProductVariantItem): ProductPriceItem | undefi
|
|||||||
return variant.prices.find((price) => price.type === form.price_type);
|
return variant.prices.find((price) => price.type === form.price_type);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCartItem(variantId: number): OrderCartItem | undefined {
|
function getCartItem(variantId: number, stockQuality = selectedStockQuality.value): OrderCartItem | undefined {
|
||||||
return cart.value.find((item) => item.product_variant_id === variantId);
|
return cart.value.find(
|
||||||
|
(item) =>
|
||||||
|
item.product_variant_id === variantId
|
||||||
|
&& item.stock_quality === stockQuality,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function decreaseVariantQty(variantId: number) {
|
async function decreaseVariantQty(variantId: number) {
|
||||||
const index = cart.value.findIndex((item) => item.product_variant_id === variantId);
|
const index = cart.value.findIndex(
|
||||||
|
(item) =>
|
||||||
|
item.product_variant_id === variantId
|
||||||
|
&& item.stock_quality === selectedStockQuality.value,
|
||||||
|
);
|
||||||
|
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
await adjustQuantity(index, -1);
|
await adjustQuantity(index, -1);
|
||||||
@ -256,13 +274,14 @@ function lineSubtotal(item: OrderCartItem): number {
|
|||||||
return quantity * item.unit_price;
|
return quantity * item.unit_price;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function syncDraftItem(variantId: number, quantity: number) {
|
async function syncDraftItem(variantId: number, quantity: number, stockQuality = selectedStockQuality.value) {
|
||||||
const { item } = await apiFetch<{ item: OrderCartItem }>('/admin/manage/orders/draft-items', {
|
const { item } = await apiFetch<{ item: OrderCartItem }>('/admin/manage/orders/draft-items', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
product_variant_id: variantId,
|
product_variant_id: variantId,
|
||||||
quantity,
|
quantity,
|
||||||
price_type: form.price_type,
|
price_type: form.price_type,
|
||||||
|
stock_quality: stockQuality,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -278,14 +297,31 @@ async function addToCart(product: OrderCatalogItem, variant: ProductVariantItem)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const stockQuality = selectedStockQuality.value;
|
||||||
|
const availableStock = availableStockForVariant(variant, stockQuality);
|
||||||
|
|
||||||
|
if (availableStock < 1) {
|
||||||
|
toast.error(`Stok ${stockQuality === 'reject' ? 'reject' : 'bagus'} tidak tersedia.`);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const existing = cart.value.find(
|
const existing = cart.value.find(
|
||||||
(item) => item.product_variant_id === variant.id,
|
(item) =>
|
||||||
|
item.product_variant_id === variant.id
|
||||||
|
&& item.stock_quality === stockQuality,
|
||||||
);
|
);
|
||||||
const nextQty = existing ? (Number(existing.quantity) || 0) + 1 : 1;
|
const nextQty = existing ? (Number(existing.quantity) || 0) + 1 : 1;
|
||||||
|
|
||||||
|
if (nextQty > availableStock) {
|
||||||
|
toast.error(`Stok ${stockQuality === 'reject' ? 'reject' : 'bagus'} tidak mencukupi.`);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (isCreateMode.value) {
|
if (isCreateMode.value) {
|
||||||
try {
|
try {
|
||||||
await syncDraftItem(variant.id, nextQty);
|
await syncDraftItem(variant.id, nextQty, stockQuality);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(error instanceof Error ? error.message : 'Gagal menyimpan item ke keranjang.');
|
toast.error(error instanceof Error ? error.message : 'Gagal menyimpan item ke keranjang.');
|
||||||
}
|
}
|
||||||
@ -303,6 +339,8 @@ async function addToCart(product: OrderCatalogItem, variant: ProductVariantItem)
|
|||||||
product_variant_id: variant.id,
|
product_variant_id: variant.id,
|
||||||
product_name: product.name,
|
product_name: product.name,
|
||||||
variant_name: variant.name,
|
variant_name: variant.name,
|
||||||
|
stock_quality: stockQuality,
|
||||||
|
stock_quality_label: STOCK_QUALITY_OPTIONS.find((option) => option.value === stockQuality)?.label,
|
||||||
quantity: '1',
|
quantity: '1',
|
||||||
unit_price: Number(price.price_input),
|
unit_price: Number(price.price_input),
|
||||||
images: variant.images ?? [],
|
images: variant.images ?? [],
|
||||||
@ -314,9 +352,12 @@ async function removeFromCart(index: number) {
|
|||||||
|
|
||||||
if (isCreateMode.value) {
|
if (isCreateMode.value) {
|
||||||
try {
|
try {
|
||||||
await apiFetch(`/admin/manage/orders/draft-items/${item.product_variant_id}`, {
|
await apiFetch(
|
||||||
method: 'DELETE',
|
`/admin/manage/orders/draft-items/${item.product_variant_id}?stock_quality=${item.stock_quality}`,
|
||||||
});
|
{
|
||||||
|
method: 'DELETE',
|
||||||
|
},
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(error instanceof Error ? error.message : 'Gagal menghapus item dari keranjang.');
|
toast.error(error instanceof Error ? error.message : 'Gagal menghapus item dari keranjang.');
|
||||||
|
|
||||||
@ -339,7 +380,7 @@ async function adjustQuantity(index: number, delta: number) {
|
|||||||
|
|
||||||
if (isCreateMode.value) {
|
if (isCreateMode.value) {
|
||||||
try {
|
try {
|
||||||
await syncDraftItem(item.product_variant_id, nextQty);
|
await syncDraftItem(item.product_variant_id, nextQty, item.stock_quality);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(error instanceof Error ? error.message : 'Gagal memperbarui jumlah item.');
|
toast.error(error instanceof Error ? error.message : 'Gagal memperbarui jumlah item.');
|
||||||
}
|
}
|
||||||
@ -365,7 +406,7 @@ async function syncCartItemQuantity(index: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await syncDraftItem(item.product_variant_id, nextQty);
|
await syncDraftItem(item.product_variant_id, nextQty, item.stock_quality);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(error instanceof Error ? error.message : 'Gagal memperbarui jumlah item.');
|
toast.error(error instanceof Error ? error.message : 'Gagal memperbarui jumlah item.');
|
||||||
}
|
}
|
||||||
@ -416,6 +457,7 @@ function buildFormData(): FormData {
|
|||||||
if (props.method === 'put') {
|
if (props.method === 'put') {
|
||||||
cart.value.forEach((item, index) => {
|
cart.value.forEach((item, index) => {
|
||||||
formData.append(`items[${index}][product_variant_id]`, String(item.product_variant_id));
|
formData.append(`items[${index}][product_variant_id]`, String(item.product_variant_id));
|
||||||
|
formData.append(`items[${index}][stock_quality]`, item.stock_quality);
|
||||||
formData.append(`items[${index}][quantity]`, item.quantity);
|
formData.append(`items[${index}][quantity]`, item.quantity);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -449,6 +491,26 @@ function submit() {
|
|||||||
<CardTitle class="text-base">Pilih Produk</CardTitle>
|
<CardTitle class="text-base">Pilih Produk</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="space-y-4">
|
<CardContent class="space-y-4">
|
||||||
|
<Field>
|
||||||
|
<FieldLabel for="stock_quality">Kualitas Stok</FieldLabel>
|
||||||
|
<Select v-model="selectedStockQuality">
|
||||||
|
<SelectTrigger id="stock_quality" class="w-full">
|
||||||
|
<SelectValue placeholder="Pilih kualitas stok" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectItem
|
||||||
|
v-for="option in STOCK_QUALITY_OPTIONS"
|
||||||
|
:key="option.value"
|
||||||
|
:value="option.value"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<Search class="absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
|
<Search class="absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input v-model="search" placeholder="Cari produk..." class="pl-9" />
|
<Input v-model="search" placeholder="Cari produk..." class="pl-9" />
|
||||||
@ -483,7 +545,9 @@ function submit() {
|
|||||||
{{ variant.name }}
|
{{ variant.name }}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs text-muted-foreground">
|
<p class="text-xs text-muted-foreground">
|
||||||
<span class="tabular-nums">{{ variant.stock }} pcs</span>
|
<span class="tabular-nums">Bagus: {{ variant.stock }}</span>
|
||||||
|
<span class="mx-1">·</span>
|
||||||
|
<span class="tabular-nums">Reject: {{ variant.reject_stock ?? 0 }}</span>
|
||||||
<span class="mx-1">·</span>
|
<span class="mx-1">·</span>
|
||||||
<span v-if="getVariantPrice(variant)" class="tabular-nums">
|
<span v-if="getVariantPrice(variant)" class="tabular-nums">
|
||||||
{{ getVariantPrice(variant)!.price_formatted }}
|
{{ getVariantPrice(variant)!.price_formatted }}
|
||||||
@ -677,8 +741,11 @@ function submit() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="scrollbar-thin max-h-80 space-y-3 overflow-y-auto overscroll-y-contain">
|
<div v-else class="scrollbar-thin max-h-80 space-y-3 overflow-y-auto overscroll-y-contain">
|
||||||
<div v-for="(item, index) in cart" :key="item.product_variant_id"
|
<div
|
||||||
class="rounded-lg border p-3">
|
v-for="(item, index) in cart"
|
||||||
|
:key="`${item.product_variant_id}-${item.stock_quality}`"
|
||||||
|
class="rounded-lg border p-3"
|
||||||
|
>
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<div class="min-w-0 flex-1 space-y-2">
|
<div class="min-w-0 flex-1 space-y-2">
|
||||||
<div class="flex items-start justify-between gap-2">
|
<div class="flex items-start justify-between gap-2">
|
||||||
@ -688,6 +755,8 @@ function submit() {
|
|||||||
</p>
|
</p>
|
||||||
<p class="truncate text-xs text-muted-foreground">
|
<p class="truncate text-xs text-muted-foreground">
|
||||||
{{ item.variant_name }}
|
{{ item.variant_name }}
|
||||||
|
·
|
||||||
|
{{ item.stock_quality_label ?? (item.stock_quality === 'reject' ? 'Reject' : 'Bagus') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button type="button" variant="ghost" size="icon"
|
<Button type="button" variant="ghost" size="icon"
|
||||||
|
|||||||
@ -1,11 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useForm } from '@inertiajs/vue3';
|
import { useForm } from '@inertiajs/vue3';
|
||||||
import { Copy, Plus, Save, Trash2 } from '@lucide/vue';
|
import { Plus, Save, Trash2 } from '@lucide/vue';
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { toast } from 'vue-sonner';
|
import { toast } from 'vue-sonner';
|
||||||
import { MultipleImageUploadField } from '@/components/form/image-upload-field';
|
import { MultipleImageUploadField } from '@/components/form/image-upload-field';
|
||||||
import { NumberInput } from '@/components/form/number-input';
|
import { NumberInput } from '@/components/form/number-input';
|
||||||
import { RupiahInput } from '@/components/form/rupiah-input';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import {
|
import {
|
||||||
@ -19,12 +18,7 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { FIELD_LIMITS } from '@/lib/field-limits';
|
import { FIELD_LIMITS } from '@/lib/field-limits';
|
||||||
import { formErrors } from '@/lib/form';
|
import { formErrors } from '@/lib/form';
|
||||||
import { parseRupiah } from '@/lib/rupiah';
|
|
||||||
import { appendMediaToFormData, createMediaUploadState } from '@/types/media';
|
import { appendMediaToFormData, createMediaUploadState } from '@/types/media';
|
||||||
import {
|
|
||||||
PRICE_TYPES,
|
|
||||||
PRICE_TYPE_LABELS,
|
|
||||||
} from '@/types/product';
|
|
||||||
import type { CategoryOption, ProductVariantFormItem } from '@/types/product';
|
import type { CategoryOption, ProductVariantFormItem } from '@/types/product';
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
@ -38,7 +32,6 @@ const props = withDefaults(
|
|||||||
id?: number;
|
id?: number;
|
||||||
name?: string;
|
name?: string;
|
||||||
stock?: number | string;
|
stock?: number | string;
|
||||||
prices?: Record<string, string>;
|
|
||||||
images?: Array<{ id: number; url: string; thumb_url: string }>;
|
images?: Array<{ id: number; url: string; thumb_url: string }>;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
@ -56,16 +49,11 @@ function createClientId(): string {
|
|||||||
return `variant-${crypto.randomUUID()}`;
|
return `variant-${crypto.randomUUID()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildEmptyPrices(): Record<string, string> {
|
|
||||||
return Object.fromEntries(PRICE_TYPES.map((type) => [type, '']));
|
|
||||||
}
|
|
||||||
|
|
||||||
function createEmptyVariant(): ProductVariantFormItem {
|
function createEmptyVariant(): ProductVariantFormItem {
|
||||||
return {
|
return {
|
||||||
client_id: createClientId(),
|
client_id: createClientId(),
|
||||||
name: '',
|
name: '',
|
||||||
stock: '0',
|
stock: '0',
|
||||||
prices: buildEmptyPrices(),
|
|
||||||
media: createMediaUploadState(),
|
media: createMediaUploadState(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -80,16 +68,11 @@ function buildInitialVariants(): ProductVariantFormItem[] {
|
|||||||
id: variant.id,
|
id: variant.id,
|
||||||
name: variant.name ?? '',
|
name: variant.name ?? '',
|
||||||
stock: variant.stock != null ? String(variant.stock) : '0',
|
stock: variant.stock != null ? String(variant.stock) : '0',
|
||||||
prices: {
|
|
||||||
...buildEmptyPrices(),
|
|
||||||
...(variant.prices ?? {}),
|
|
||||||
},
|
|
||||||
media: createMediaUploadState(variant.images ?? []),
|
media: createMediaUploadState(variant.images ?? []),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
const variants = ref<ProductVariantFormItem[]>(buildInitialVariants());
|
const variants = ref<ProductVariantFormItem[]>(buildInitialVariants());
|
||||||
const useSamePrices = ref(variants.value.length <= 1 || allVariantsHaveSamePrices(variants.value));
|
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
name: props.initialData?.name ?? '',
|
name: props.initialData?.name ?? '',
|
||||||
@ -97,18 +80,6 @@ const form = useForm({
|
|||||||
category_ids: props.initialData?.category_ids ?? [],
|
category_ids: props.initialData?.category_ids ?? [],
|
||||||
});
|
});
|
||||||
|
|
||||||
function allVariantsHaveSamePrices(items: ProductVariantFormItem[]): boolean {
|
|
||||||
if (items.length <= 1) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const first = items[0].prices;
|
|
||||||
|
|
||||||
return items.every((variant) =>
|
|
||||||
PRICE_TYPES.every((type) => variant.prices[type].trim() === first[type].trim()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleCategory(categoryId: number, checked: boolean) {
|
function toggleCategory(categoryId: number, checked: boolean) {
|
||||||
if (checked) {
|
if (checked) {
|
||||||
if (!form.category_ids.includes(categoryId)) {
|
if (!form.category_ids.includes(categoryId)) {
|
||||||
@ -126,13 +97,7 @@ function isCategoryChecked(categoryId: number): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function addVariant() {
|
function addVariant() {
|
||||||
const newVariant = createEmptyVariant();
|
variants.value = [...variants.value, createEmptyVariant()];
|
||||||
|
|
||||||
if (useSamePrices.value && variants.value[0]) {
|
|
||||||
newVariant.prices = { ...variants.value[0].prices };
|
|
||||||
}
|
|
||||||
|
|
||||||
variants.value = [...variants.value, newVariant];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeVariant(clientId: string) {
|
function removeVariant(clientId: string) {
|
||||||
@ -149,48 +114,6 @@ function setVariantField(clientId: string, key: 'name' | 'stock', value: string)
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setVariantPrice(clientId: string, type: string, value: string) {
|
|
||||||
variants.value = variants.value.map((variant) =>
|
|
||||||
variant.client_id === clientId
|
|
||||||
? { ...variant, prices: { ...variant.prices, [type]: value } }
|
|
||||||
: variant,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setSharedPrice(type: string, value: string) {
|
|
||||||
variants.value = variants.value.map((variant) => ({
|
|
||||||
...variant,
|
|
||||||
prices: { ...variant.prices, [type]: value },
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleUseSamePrices(checked: boolean) {
|
|
||||||
useSamePrices.value = checked;
|
|
||||||
|
|
||||||
if (!checked || !variants.value[0]) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sourcePrices = { ...variants.value[0].prices };
|
|
||||||
variants.value = variants.value.map((variant) => ({
|
|
||||||
...variant,
|
|
||||||
prices: { ...sourcePrices },
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyPricesToAllVariants(sourceClientId: string) {
|
|
||||||
const source = variants.value.find((variant) => variant.client_id === sourceClientId);
|
|
||||||
|
|
||||||
if (!source) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
variants.value = variants.value.map((variant) => ({
|
|
||||||
...variant,
|
|
||||||
prices: { ...source.prices },
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildFormData(): FormData {
|
function buildFormData(): FormData {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
|
|
||||||
@ -213,14 +136,6 @@ function buildFormData(): FormData {
|
|||||||
formData.append(`variants[${index}][name]`, variant.name.trim());
|
formData.append(`variants[${index}][name]`, variant.name.trim());
|
||||||
formData.append(`variants[${index}][stock]`, String(Number.parseInt(variant.stock, 10) || 0));
|
formData.append(`variants[${index}][stock]`, String(Number.parseInt(variant.stock, 10) || 0));
|
||||||
|
|
||||||
PRICE_TYPES.forEach((type, priceIndex) => {
|
|
||||||
formData.append(`variants[${index}][prices][${priceIndex}][type]`, type);
|
|
||||||
formData.append(
|
|
||||||
`variants[${index}][prices][${priceIndex}][price]`,
|
|
||||||
String(Number.parseInt(parseRupiah(variant.prices[type]), 10) || 0),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
appendMediaToFormData(formData, `variants[${index}]`, variant.media);
|
appendMediaToFormData(formData, `variants[${index}]`, variant.media);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -238,21 +153,7 @@ function variantError(clientId: string, field: string): string | undefined {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
return formError(`variants.${index}.${field}`)
|
return formError(`variants.${index}.${field}`);
|
||||||
?? formError(`variants.${index}.prices`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function variantPriceError(clientId: string, type: string): string | undefined {
|
|
||||||
const index = variants.value.findIndex((variant) => variant.client_id === clientId);
|
|
||||||
|
|
||||||
if (index === -1) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const priceIndex = PRICE_TYPES.indexOf(type as typeof PRICE_TYPES[number]);
|
|
||||||
|
|
||||||
return formError(`variants.${index}.prices.${priceIndex}.price`)
|
|
||||||
?? formError(`variants.${index}.prices.${priceIndex}.type`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const categoryError = computed(() => form.errors.category_ids);
|
const categoryError = computed(() => form.errors.category_ids);
|
||||||
@ -318,43 +219,10 @@ function submit() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card v-if="variants.length > 1">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Harga Bersama</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<FieldGroup>
|
|
||||||
<label class="mb-4 flex cursor-pointer items-center gap-2">
|
|
||||||
<input type="checkbox" class="size-4 rounded border-input" :checked="useSamePrices"
|
|
||||||
@change="toggleUseSamePrices(($event.target as HTMLInputElement).checked)">
|
|
||||||
<span class="text-sm">Gunakan harga yang sama untuk semua varian</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<FieldSet v-if="useSamePrices && variants[0]"
|
|
||||||
class="grid gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
|
||||||
<Field v-for="type in PRICE_TYPES" :key="type">
|
|
||||||
<FieldLabel :for="`shared_price_${type}`" required>
|
|
||||||
{{ PRICE_TYPE_LABELS[type] }}
|
|
||||||
</FieldLabel>
|
|
||||||
<RupiahInput :id="`shared_price_${type}`" :model-value="variants[0].prices[type]"
|
|
||||||
@update:model-value="setSharedPrice(type, $event)" />
|
|
||||||
<FieldError
|
|
||||||
:errors="variantPriceError(variants[0].client_id, type) ? [variantPriceError(variants[0].client_id, type)!] : []" />
|
|
||||||
</Field>
|
|
||||||
</FieldSet>
|
|
||||||
</FieldGroup>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card v-for="(variant, index) in variants" :key="variant.client_id">
|
<Card v-for="(variant, index) in variants" :key="variant.client_id">
|
||||||
<CardHeader class="flex flex-row items-start justify-between gap-4">
|
<CardHeader class="flex flex-row items-start justify-between gap-4">
|
||||||
<CardTitle>Varian {{ index + 1 }}</CardTitle>
|
<CardTitle>Varian {{ index + 1 }}</CardTitle>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Button v-if="variants.length > 1 && !useSamePrices" type="button" variant="outline" size="sm"
|
|
||||||
@click="applyPricesToAllVariants(variant.client_id)">
|
|
||||||
<Copy class="size-4" />
|
|
||||||
Terapkan Harga ke Semua
|
|
||||||
</Button>
|
|
||||||
<Button v-if="variants.length > 1" type="button" variant="outline" size="icon"
|
<Button v-if="variants.length > 1" type="button" variant="outline" size="icon"
|
||||||
class="text-destructive hover:text-destructive size-8"
|
class="text-destructive hover:text-destructive size-8"
|
||||||
@click="removeVariant(variant.client_id)">
|
@click="removeVariant(variant.client_id)">
|
||||||
@ -386,19 +254,9 @@ function submit() {
|
|||||||
</Field>
|
</Field>
|
||||||
</FieldSet>
|
</FieldSet>
|
||||||
|
|
||||||
<FieldSet v-if="!useSamePrices || variants.length === 1"
|
<p class="text-xs text-muted-foreground">
|
||||||
class="grid gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
Harga jual diatur saat verifikasi proses cutting.
|
||||||
<Field v-for="type in PRICE_TYPES" :key="`${variant.client_id}_${type}`">
|
</p>
|
||||||
<FieldLabel :for="`price_${variant.client_id}_${type}`" required>
|
|
||||||
{{ PRICE_TYPE_LABELS[type] }}
|
|
||||||
</FieldLabel>
|
|
||||||
<RupiahInput :id="`price_${variant.client_id}_${type}`"
|
|
||||||
:model-value="variant.prices[type]"
|
|
||||||
@update:model-value="setVariantPrice(variant.client_id, type, $event)" />
|
|
||||||
<FieldError
|
|
||||||
:errors="variantPriceError(variant.client_id, type) ? [variantPriceError(variant.client_id, type)!] : []" />
|
|
||||||
</Field>
|
|
||||||
</FieldSet>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<MultipleImageUploadField :id="`variant_images_${variant.client_id}`"
|
<MultipleImageUploadField :id="`variant_images_${variant.client_id}`"
|
||||||
|
|||||||
@ -110,13 +110,14 @@ function rowNumber(index: number): number {
|
|||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Varian</TableHead>
|
<TableHead>Varian</TableHead>
|
||||||
<TableHead>Foto</TableHead>
|
<TableHead>Foto</TableHead>
|
||||||
<TableHead>Stok</TableHead>
|
<TableHead>Stok Bagus</TableHead>
|
||||||
<TableHead>Harga</TableHead>
|
<TableHead>Stok Reject</TableHead>
|
||||||
|
<TableHead>Harga Cutting Terakhir</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
<TableRow v-if="!product.variants.length" :key="`${product.id}-empty`">
|
<TableRow v-if="!product.variants.length" :key="`${product.id}-empty`">
|
||||||
<TableCell colspan="4" class="text-muted-foreground">
|
<TableCell colspan="5" class="text-muted-foreground">
|
||||||
Belum ada varian
|
Belum ada varian
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@ -130,6 +131,9 @@ function rowNumber(index: number): number {
|
|||||||
<TableCell class="tabular-nums">
|
<TableCell class="tabular-nums">
|
||||||
{{ formatStock(variant.stock) }}
|
{{ formatStock(variant.stock) }}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell class="tabular-nums">
|
||||||
|
{{ formatStock(variant.reject_stock ?? 0) }}
|
||||||
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div v-if="variant.prices.length" class="space-y-0.5 text-xs">
|
<div v-if="variant.prices.length" class="space-y-0.5 text-xs">
|
||||||
<div v-for="type in PRICE_TYPES" :key="type">
|
<div v-for="type in PRICE_TYPES" :key="type">
|
||||||
@ -146,7 +150,7 @@ function rowNumber(index: number): number {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span v-else>-</span>
|
<span v-else class="text-xs text-muted-foreground">Belum ada harga cutting</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
|||||||
@ -17,18 +17,33 @@ const props = defineProps<{
|
|||||||
productCatalog: CuttingProductCatalogItem[];
|
productCatalog: CuttingProductCatalogItem[];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
function usesLengthUnit(unit?: string): boolean {
|
||||||
|
return unit === 'yard' || unit === 'meter';
|
||||||
|
}
|
||||||
|
|
||||||
const initialData = computed(() => ({
|
const initialData = computed(() => ({
|
||||||
description: props.cutting.description ?? '',
|
description: props.cutting.description ?? '',
|
||||||
materials: props.cutting.materials.map((item) => ({
|
materials: props.cutting.materials.map((item) => {
|
||||||
raw_material_price_id: item.raw_material_price_id,
|
const unit = item.raw_material_price?.raw_material?.unit ?? 'kilogram';
|
||||||
raw_material_name: item.raw_material_price?.raw_material?.name ?? '',
|
const usesCm = usesLengthUnit(unit);
|
||||||
variant: item.raw_material_price?.variant ?? '',
|
|
||||||
unit_abbreviation: item.raw_material_price?.raw_material?.unit_abbreviation ?? '',
|
return {
|
||||||
stock_input: item.raw_material_price?.stock_input ?? '',
|
raw_material_price_id: item.raw_material_price_id,
|
||||||
material_usage: item.material_usage_input,
|
raw_material_name: item.raw_material_price?.raw_material?.name ?? '',
|
||||||
remaining_material: item.remaining_material_input,
|
variant: item.raw_material_price?.variant ?? '',
|
||||||
images: item.raw_material_price?.images ?? [],
|
unit,
|
||||||
})),
|
uses_length_unit: usesCm,
|
||||||
|
unit_abbreviation: item.raw_material_price?.raw_material?.unit_abbreviation ?? '',
|
||||||
|
stock_input: item.raw_material_price?.stock_input ?? '',
|
||||||
|
material_usage: usesCm
|
||||||
|
? (item.material_usage_cm_input ?? item.material_usage_input)
|
||||||
|
: item.material_usage_input,
|
||||||
|
remaining_material: usesCm
|
||||||
|
? (item.remaining_material_cm_input ?? item.remaining_material_input)
|
||||||
|
: item.remaining_material_input,
|
||||||
|
images: item.raw_material_price?.images ?? [],
|
||||||
|
};
|
||||||
|
}),
|
||||||
results: props.cutting.results.map((item) => ({
|
results: props.cutting.results.map((item) => ({
|
||||||
product_variant_id: item.product_variant_id,
|
product_variant_id: item.product_variant_id,
|
||||||
product_name: item.product_variant?.product?.name ?? '',
|
product_name: item.product_variant?.product?.name ?? '',
|
||||||
|
|||||||
@ -35,6 +35,8 @@ const initialData = computed(() => ({
|
|||||||
product_variant_id: item.product_variant_id,
|
product_variant_id: item.product_variant_id,
|
||||||
product_name: item.product_name,
|
product_name: item.product_name,
|
||||||
variant_name: item.variant_name,
|
variant_name: item.variant_name,
|
||||||
|
stock_quality: item.stock_quality ?? 'good',
|
||||||
|
stock_quality_label: item.stock_quality === 'reject' ? 'Reject' : 'Bagus',
|
||||||
quantity: item.quantity_input,
|
quantity: item.quantity_input,
|
||||||
unit_price: item.unit_price,
|
unit_price: item.unit_price,
|
||||||
images: item.product_variant?.images ?? [],
|
images: item.product_variant?.images ?? [],
|
||||||
|
|||||||
@ -4,11 +4,10 @@ import { ArrowLeft } from '@lucide/vue';
|
|||||||
import ProductForm from '@/components/admin/master/products/ProductForm.vue';
|
import ProductForm from '@/components/admin/master/products/ProductForm.vue';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import AdminLayout from '@/layouts/AdminLayout.vue';
|
import AdminLayout from '@/layouts/AdminLayout.vue';
|
||||||
import type { CategoryOption, EnumOption } from '@/types/product';
|
import type { CategoryOption } from '@/types/product';
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
categories: CategoryOption[];
|
categories: CategoryOption[];
|
||||||
priceTypes: EnumOption[];
|
|
||||||
}>();
|
}>();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@ -5,18 +5,11 @@ import { computed } from 'vue';
|
|||||||
import ProductForm from '@/components/admin/master/products/ProductForm.vue';
|
import ProductForm from '@/components/admin/master/products/ProductForm.vue';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import AdminLayout from '@/layouts/AdminLayout.vue';
|
import AdminLayout from '@/layouts/AdminLayout.vue';
|
||||||
import {
|
import type { CategoryOption, ProductListItem } from '@/types/product';
|
||||||
PRICE_TYPES
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
} from '@/types/product';
|
|
||||||
import type { CategoryOption, EnumOption, ProductListItem } from '@/types/product';
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
product: ProductListItem & { description?: string | null };
|
product: ProductListItem & { description?: string | null };
|
||||||
categories: CategoryOption[];
|
categories: CategoryOption[];
|
||||||
priceTypes: EnumOption[];
|
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const initialData = computed(() => ({
|
const initialData = computed(() => ({
|
||||||
@ -28,18 +21,11 @@ const initialData = computed(() => ({
|
|||||||
name: variant.name,
|
name: variant.name,
|
||||||
stock: variant.stock,
|
stock: variant.stock,
|
||||||
images: variant.images ?? [],
|
images: variant.images ?? [],
|
||||||
prices: Object.fromEntries(
|
|
||||||
PRICE_TYPES.map((type) => [
|
|
||||||
type,
|
|
||||||
variant.prices.find((price) => price.type === type)?.price_input ?? '',
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
||||||
<Head title="Ubah Produk" />
|
<Head title="Ubah Produk" />
|
||||||
|
|
||||||
<AdminLayout>
|
<AdminLayout>
|
||||||
|
|||||||
@ -47,6 +47,10 @@ export type CuttingListItem = {
|
|||||||
is_editable: boolean;
|
is_editable: boolean;
|
||||||
available_actions: CuttingStatusAction[];
|
available_actions: CuttingStatusAction[];
|
||||||
created_at_formatted: string;
|
created_at_formatted: string;
|
||||||
|
total_material_cost?: number;
|
||||||
|
total_material_cost_formatted?: string;
|
||||||
|
estimated_cost_per_unit?: number;
|
||||||
|
estimated_cost_per_unit_formatted?: string;
|
||||||
created_by?: {
|
created_by?: {
|
||||||
username: string;
|
username: string;
|
||||||
profile?: {
|
profile?: {
|
||||||
@ -69,6 +73,8 @@ export type CuttingMaterialCartItem = {
|
|||||||
raw_material_price_id: number;
|
raw_material_price_id: number;
|
||||||
raw_material_name: string;
|
raw_material_name: string;
|
||||||
variant: string;
|
variant: string;
|
||||||
|
unit: string;
|
||||||
|
uses_length_unit: boolean;
|
||||||
unit_abbreviation: string;
|
unit_abbreviation: string;
|
||||||
stock_input: string;
|
stock_input: string;
|
||||||
material_usage: string;
|
material_usage: string;
|
||||||
@ -95,14 +101,16 @@ export type CuttingEditItem = {
|
|||||||
raw_material_price_id: number;
|
raw_material_price_id: number;
|
||||||
material_usage_input: string;
|
material_usage_input: string;
|
||||||
remaining_material_input: string;
|
remaining_material_input: string;
|
||||||
|
material_usage_cm_input?: string | null;
|
||||||
|
remaining_material_cm_input?: string | null;
|
||||||
raw_material_price?: {
|
raw_material_price?: {
|
||||||
variant: string;
|
variant: string;
|
||||||
|
stock_input?: string;
|
||||||
images?: MediaItem[];
|
images?: MediaItem[];
|
||||||
raw_material?: {
|
raw_material?: {
|
||||||
name: string;
|
name: string;
|
||||||
unit?: {
|
unit?: string;
|
||||||
abbreviation?: string;
|
unit_abbreviation?: string;
|
||||||
};
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
}>;
|
}>;
|
||||||
|
|||||||
@ -74,11 +74,18 @@ export type OrderCartItem = {
|
|||||||
product_variant_id: number;
|
product_variant_id: number;
|
||||||
product_name: string;
|
product_name: string;
|
||||||
variant_name: string;
|
variant_name: string;
|
||||||
|
stock_quality: string;
|
||||||
|
stock_quality_label?: string;
|
||||||
quantity: string;
|
quantity: string;
|
||||||
unit_price: number;
|
unit_price: number;
|
||||||
images?: MediaItem[];
|
images?: MediaItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const STOCK_QUALITY_OPTIONS = [
|
||||||
|
{ value: 'good', label: 'Bagus' },
|
||||||
|
{ value: 'reject', label: 'Reject' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
export type OrderEditItem = {
|
export type OrderEditItem = {
|
||||||
id: number;
|
id: number;
|
||||||
order_number: string;
|
order_number: string;
|
||||||
@ -99,6 +106,7 @@ export type OrderEditItem = {
|
|||||||
product_name: string;
|
product_name: string;
|
||||||
variant_name: string;
|
variant_name: string;
|
||||||
quantity_input: string;
|
quantity_input: string;
|
||||||
|
stock_quality: string;
|
||||||
unit_price: number;
|
unit_price: number;
|
||||||
product_variant?: {
|
product_variant?: {
|
||||||
images?: MediaItem[];
|
images?: MediaItem[];
|
||||||
|
|||||||
@ -20,6 +20,8 @@ export type ProductPriceItem = {
|
|||||||
price: number;
|
price: number;
|
||||||
price_formatted: string;
|
price_formatted: string;
|
||||||
price_input: string;
|
price_input: string;
|
||||||
|
cost_per_unit?: number;
|
||||||
|
cost_per_unit_formatted?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ProductCategoryItem = {
|
export type ProductCategoryItem = {
|
||||||
@ -31,6 +33,7 @@ export type ProductVariantItem = {
|
|||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
stock: number;
|
stock: number;
|
||||||
|
reject_stock: number;
|
||||||
prices: ProductPriceItem[];
|
prices: ProductPriceItem[];
|
||||||
images?: MediaItem[];
|
images?: MediaItem[];
|
||||||
};
|
};
|
||||||
@ -48,7 +51,6 @@ export type ProductVariantFormItem = {
|
|||||||
id?: number;
|
id?: number;
|
||||||
name: string;
|
name: string;
|
||||||
stock: string;
|
stock: string;
|
||||||
prices: Record<string, string>;
|
|
||||||
media: MediaUploadState;
|
media: MediaUploadState;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -60,10 +62,6 @@ export type ProductFormData = {
|
|||||||
id?: number;
|
id?: number;
|
||||||
name: string;
|
name: string;
|
||||||
stock: number;
|
stock: number;
|
||||||
prices: Array<{
|
|
||||||
type: string;
|
|
||||||
price: number;
|
|
||||||
}>;
|
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user