436 lines
17 KiB
PHP
436 lines
17 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers;
|
|
|
|
use App\Models\Product;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use App\Models\Stock;
|
|
use App\Models\Sale;
|
|
|
|
class ProductController extends Controller
|
|
{
|
|
public function index(Request $request)
|
|
{
|
|
// If user is karyawan, use karyawanIndex logic
|
|
if (auth()->user()->role !== 'admin') {
|
|
return $this->karyawanIndex($request);
|
|
}
|
|
|
|
$branches = \App\Models\Branch::all();
|
|
$selectedBranch = $request->input('branch', 'Total Gabungan');
|
|
|
|
$query = Product::with(['stocks', 'recipes.rawMaterial']);
|
|
|
|
if (auth()->user()->role !== 'admin') {
|
|
$query->where('branch', auth()->user()->branch ?? 'Cabang 1');
|
|
} elseif ($selectedBranch && $selectedBranch !== 'Total Gabungan') {
|
|
$query->where('branch', $selectedBranch);
|
|
}
|
|
|
|
if ($request->filled('search')) {
|
|
$query->where(function($q) use ($request) {
|
|
$q->where('name', 'like', '%' . $request->search . '%')
|
|
->orWhere('category', 'like', '%' . $request->search . '%');
|
|
});
|
|
}
|
|
|
|
$products = $query->get();
|
|
|
|
$selectedRawMaterialBranch = $request->input('raw_material_branch', 'Total Gabungan');
|
|
$rawMaterialsQuery = \App\Models\RawMaterial::query();
|
|
if ($selectedRawMaterialBranch && $selectedRawMaterialBranch !== 'Total Gabungan') {
|
|
$rawMaterialsQuery->where('branch', $selectedRawMaterialBranch);
|
|
}
|
|
$rawMaterials = $rawMaterialsQuery->get();
|
|
|
|
return view('produk', compact('products', 'branches', 'selectedBranch', 'rawMaterials', 'selectedRawMaterialBranch'));
|
|
}
|
|
|
|
public function store(Request $request)
|
|
{
|
|
$request->validate([
|
|
'name' => 'required|string|max:255',
|
|
'category' => 'required|in:makanan,minuman,snack',
|
|
'purchase_price' => 'required|integer',
|
|
'selling_price' => 'required|integer',
|
|
'image' => 'nullable|image|mimes:jpeg,png,jpg,gif,svg|max:2048',
|
|
]);
|
|
|
|
$data = $request->all();
|
|
$branch = auth()->user()->branch ?? 'Cabang 1';
|
|
$data['branch'] = $branch;
|
|
|
|
// Unique name check per branch
|
|
$exists = Product::whereRaw('LOWER(name) = ?', [strtolower($request->name)])
|
|
->where('branch', $branch)
|
|
->exists();
|
|
if ($exists) {
|
|
return back()->withInput()->withErrors(['duplicate_error' => 'Produk dengan nama "' . $request->name . '" sudah ada di ' . $branch . '.']);
|
|
}
|
|
|
|
if ($request->hasFile('image')) {
|
|
$file = $request->file('image');
|
|
$filename = time() . '_' . preg_replace('/[^a-zA-Z0-9._-]/', '', $file->getClientOriginalName());
|
|
$destination = public_path('images/produk');
|
|
if (!file_exists($destination)) {
|
|
mkdir($destination, 0775, true);
|
|
}
|
|
$file->move($destination, $filename);
|
|
$data['image'] = '/images/produk/' . $filename;
|
|
}
|
|
|
|
$product = Product::create($data);
|
|
|
|
// Save Recipes if any
|
|
if ($request->has('recipes') && is_array($request->recipes)) {
|
|
foreach ($request->recipes as $recipeData) {
|
|
if (isset($recipeData['raw_material_id']) && isset($recipeData['quantity_needed']) && $recipeData['quantity_needed'] !== '') {
|
|
// Parse quantity string to allow '300 ml', '0.5', '300'
|
|
$parsed = $this->parseQuantity($recipeData['quantity_needed']);
|
|
$rm = \App\Models\RawMaterial::find($recipeData['raw_material_id']);
|
|
$quantityToStore = $parsed['value'];
|
|
// If unit provided and differs from raw material unit, try to convert
|
|
if ($parsed['unit'] && $rm) {
|
|
$quantityToStore = $this->convertRecipeToRawMaterialUnit($parsed['value'], $parsed['unit'], $rm->unit);
|
|
}
|
|
|
|
\App\Models\ProductRecipe::create([
|
|
'product_id' => $product->id,
|
|
'raw_material_id' => $recipeData['raw_material_id'],
|
|
'quantity_needed' => $quantityToStore,
|
|
]);
|
|
}
|
|
}
|
|
|
|
// Recalculate stock based on raw materials
|
|
$this->recalculateProductsFromRawMaterials($product->id, $branch);
|
|
}
|
|
|
|
// Create initial stock for THIS branch only
|
|
\App\Models\Stock::create([
|
|
'product_id' => $product->id,
|
|
'branch' => $branch,
|
|
'quantity' => 0,
|
|
'unit' => 'Pcs',
|
|
]);
|
|
|
|
return back()->with('success', 'Produk Master berhasil ditambahkan!');
|
|
}
|
|
|
|
public function update(Request $request, $id)
|
|
{
|
|
$request->validate([
|
|
'name' => 'required|string|max:255',
|
|
'category' => 'required|in:makanan,minuman,snack',
|
|
'purchase_price' => 'required|integer',
|
|
'selling_price' => 'required|integer',
|
|
'image' => 'nullable|image|mimes:jpeg,png,jpg,gif,svg|max:2048',
|
|
]);
|
|
|
|
$product = Product::findOrFail($id);
|
|
$branch = auth()->user()->branch ?? 'Cabang 1';
|
|
|
|
// Only allow update if product belongs to user's branch
|
|
if ($product->branch !== $branch) {
|
|
return back()->with('error', 'Anda tidak memiliki akses untuk mengubah produk dari cabang lain.');
|
|
}
|
|
|
|
// Unique name check for update per branch
|
|
$exists = Product::whereRaw('LOWER(name) = ?', [strtolower($request->name)])
|
|
->where('branch', $branch)
|
|
->where('id', '!=', $id)
|
|
->exists();
|
|
if ($exists) {
|
|
return back()->withInput()->withErrors(['duplicate_error' => 'Produk dengan nama "' . $request->name . '" sudah ada di ' . $branch . '.']);
|
|
}
|
|
|
|
$data = $request->only(['name', 'category', 'purchase_price', 'selling_price']);
|
|
|
|
if ($request->hasFile('image')) {
|
|
// Delete old image if it exists in our public/images/produk directory
|
|
if ($product->image && str_contains($product->image, '/images/produk/')) {
|
|
$oldFile = public_path($product->image);
|
|
if (file_exists($oldFile)) {
|
|
unlink($oldFile);
|
|
}
|
|
}
|
|
// Also handle old images stored via storage symlink
|
|
if ($product->image && str_contains($product->image, '/storage/produk/')) {
|
|
$oldPath = str_replace('/storage/', '', $product->image);
|
|
Storage::disk('public')->delete($oldPath);
|
|
}
|
|
|
|
$file = $request->file('image');
|
|
$filename = time() . '_' . preg_replace('/[^a-zA-Z0-9._-]/', '', $file->getClientOriginalName());
|
|
$destination = public_path('images/produk');
|
|
if (!file_exists($destination)) {
|
|
mkdir($destination, 0775, true);
|
|
}
|
|
$file->move($destination, $filename);
|
|
$data['image'] = '/images/produk/' . $filename;
|
|
}
|
|
|
|
$product->update($data);
|
|
|
|
// Update Recipes
|
|
\App\Models\ProductRecipe::where('product_id', $product->id)->delete();
|
|
if ($request->has('recipes') && is_array($request->recipes)) {
|
|
foreach ($request->recipes as $recipeData) {
|
|
if (isset($recipeData['raw_material_id']) && isset($recipeData['quantity_needed']) && $recipeData['quantity_needed'] !== '') {
|
|
$parsed = $this->parseQuantity($recipeData['quantity_needed']);
|
|
$rm = \App\Models\RawMaterial::find($recipeData['raw_material_id']);
|
|
$quantityToStore = $parsed['value'];
|
|
if ($parsed['unit'] && $rm) {
|
|
$quantityToStore = $this->convertRecipeToRawMaterialUnit($parsed['value'], $parsed['unit'], $rm->unit);
|
|
}
|
|
|
|
\App\Models\ProductRecipe::create([
|
|
'product_id' => $product->id,
|
|
'raw_material_id' => $recipeData['raw_material_id'],
|
|
'quantity_needed' => $quantityToStore,
|
|
]);
|
|
}
|
|
}
|
|
|
|
// Recalculate stock based on raw materials
|
|
$this->recalculateProductsFromRawMaterials($product->id, $branch);
|
|
}
|
|
|
|
return back()->with('success', 'Produk berhasil diperbarui!');
|
|
}
|
|
|
|
public function destroy($id)
|
|
{
|
|
$product = Product::findOrFail($id);
|
|
$branch = auth()->user()->branch ?? 'Cabang 1';
|
|
|
|
// Only allow deletion if product belongs to user's branch
|
|
if ($product->branch !== $branch) {
|
|
return back()->with('error', 'Anda tidak memiliki akses untuk menghapus produk dari cabang lain.');
|
|
}
|
|
|
|
$product->delete();
|
|
return back()->with('success', 'Produk berhasil dihapus!');
|
|
}
|
|
|
|
// Karyawan: lihat produk dengan stok per cabang dan catat penjualan sederhana
|
|
public function karyawanIndex(Request $request)
|
|
{
|
|
$branch = auth()->user()->branch ?? 'Cabang 1';
|
|
$query = Product::with(['stocks' => function($q) use ($branch) {
|
|
$q->where('branch', $branch);
|
|
}, 'recipes.rawMaterial'])->where('branch', $branch);
|
|
|
|
if ($request->filled('search')) {
|
|
$query->where('name', 'like', '%' . $request->search . '%')
|
|
->orWhere('category', 'like', '%' . $request->search . '%');
|
|
}
|
|
|
|
$products = $query->get();
|
|
$rawMaterials = \App\Models\RawMaterial::where('branch', $branch)->get();
|
|
|
|
return view('produk_karyawan', compact('products', 'branch', 'rawMaterials'));
|
|
}
|
|
|
|
public function sell(Request $request, $id)
|
|
{
|
|
$request->validate([
|
|
'quantity' => 'required|integer|min:1'
|
|
]);
|
|
|
|
$branch = auth()->user()->branch ?? 'Cabang 1';
|
|
$qty = (int) $request->input('quantity');
|
|
|
|
$product = Product::with('recipes.rawMaterial')->findOrFail($id);
|
|
|
|
if ($product->recipes->count() > 0) {
|
|
// Check raw material stock first
|
|
foreach ($product->recipes as $recipe) {
|
|
$rm = $recipe->rawMaterial;
|
|
if (!$rm) continue;
|
|
$needed = $recipe->quantity_needed * $qty;
|
|
if ($rm->stock_quantity < $needed) {
|
|
return back()->withErrors(['stock' => 'Bahan baku tidak cukup untuk membuat ' . $qty . ' porsi.']);
|
|
}
|
|
}
|
|
|
|
// Deduct raw materials
|
|
foreach ($product->recipes as $recipe) {
|
|
$rm = $recipe->rawMaterial;
|
|
if ($rm) {
|
|
$rm->stock_quantity -= ($recipe->quantity_needed * $qty);
|
|
$rm->save();
|
|
// Optional: recalculate other products that depend on this raw material
|
|
$this->recalculateProductsFromRawMaterial($rm->id, $branch);
|
|
}
|
|
}
|
|
} else {
|
|
// Deduct from regular stock
|
|
$stock = Stock::where('product_id', $id)->where('branch', $branch)->first();
|
|
if (!$stock) {
|
|
return back()->withErrors(['stock' => 'Stok untuk produk ini tidak ditemukan pada cabang Anda.']);
|
|
}
|
|
|
|
if ($stock->quantity < $qty) {
|
|
return back()->withErrors(['stock' => 'Stok tidak cukup. Tersedia: ' . $stock->quantity]);
|
|
}
|
|
|
|
// Kurangi stok
|
|
$stock->quantity = $stock->quantity - $qty;
|
|
$stock->save();
|
|
}
|
|
|
|
// Catat penjualan sederhana
|
|
Sale::create([
|
|
'date' => now()->toDateString(),
|
|
'branch' => $branch,
|
|
'product_id' => $product->id,
|
|
'item' => $product->name,
|
|
'quantity' => $qty,
|
|
'total_price' => ($product->selling_price ?? 0) * $qty,
|
|
]);
|
|
|
|
return back()->with('success', 'Penjualan berhasil dicatat dan stok diperbarui.');
|
|
}
|
|
|
|
private function recalculateProductsFromRawMaterials($productId, $branch)
|
|
{
|
|
$product = \App\Models\Product::with('recipes.rawMaterial')->find($productId);
|
|
if (!$product || $product->recipes->isEmpty()) {
|
|
return;
|
|
}
|
|
|
|
$minStock = null;
|
|
foreach ($product->recipes as $recipe) {
|
|
$rm = $recipe->rawMaterial;
|
|
if (!$rm || $recipe->quantity_needed <= 0) {
|
|
continue;
|
|
}
|
|
$possibleAmount = floor($rm->stock_quantity / $recipe->quantity_needed);
|
|
if ($minStock === null || $possibleAmount < $minStock) {
|
|
$minStock = $possibleAmount;
|
|
}
|
|
}
|
|
|
|
if ($minStock !== null && $minStock >= 0) {
|
|
$stock = \App\Models\Stock::firstOrCreate(
|
|
['product_id' => $productId, 'branch' => $branch],
|
|
['quantity' => 0, 'unit' => 'Pcs']
|
|
);
|
|
$stock->quantity = $minStock;
|
|
$stock->save();
|
|
}
|
|
}
|
|
|
|
// Parse quantity strings used in recipe inputs (e.g. '300 ml', '0.5', '300')
|
|
private function parseQuantity($quantityStr)
|
|
{
|
|
$quantityStr = trim((string)$quantityStr);
|
|
if (preg_match('/^([\d.,]+)\s*(.*)$/', $quantityStr, $m)) {
|
|
$value = floatval(str_replace(',', '.', $m[1]));
|
|
$unit = strtolower(trim($m[2]));
|
|
return ['value' => $value, 'unit' => $unit];
|
|
}
|
|
return ['value' => floatval($quantityStr), 'unit' => ''];
|
|
}
|
|
|
|
// Convert a recipe quantity with unit into the raw material's unit if possible
|
|
private function convertRecipeToRawMaterialUnit($value, $fromUnit, $toUnit)
|
|
{
|
|
$from = strtolower(trim($fromUnit));
|
|
$to = strtolower(trim($toUnit));
|
|
|
|
// If same unit or no fromUnit, return value as-is
|
|
if (!$from || $from === $to) return $value;
|
|
|
|
// Basic conversions: liters <-> ml, kilogram <-> gram
|
|
$mapping = [
|
|
'l' => ['ml' => 1000, 'liter' => 1000],
|
|
'liter' => ['ml' => 1000, 'l' => 1],
|
|
'ml' => ['l' => 1/1000, 'liter' => 1/1000],
|
|
'kg' => ['g' => 1000, 'kilogram' => 1000],
|
|
'kilogram' => ['g' => 1000, 'kg' => 1],
|
|
'g' => ['kg' => 1/1000, 'gram' => 1/1000],
|
|
'gram' => ['kg' => 1/1000, 'g' => 1],
|
|
];
|
|
|
|
// Normalize tokens
|
|
$aliases = [
|
|
'grams' => 'g', 'gram' => 'g', 'gr' => 'g',
|
|
'milliliter' => 'ml', 'mililiter' => 'ml',
|
|
'litre' => 'l', 'liters' => 'l', 'liter' => 'l',
|
|
'kilograms' => 'kg', 'kilogram' => 'kg',
|
|
'pcs' => 'pcs', 'piece' => 'pcs', 'pc' => 'pcs',
|
|
'botol' => 'botol'
|
|
];
|
|
|
|
$fromNorm = $aliases[$from] ?? $from;
|
|
$toNorm = $aliases[$to] ?? $to;
|
|
|
|
// If one is pcs and other is numeric unit, do not convert automatically
|
|
if (($fromNorm === 'pcs' && $toNorm !== 'pcs') || ($toNorm === 'pcs' && $fromNorm !== 'pcs')) {
|
|
return $value;
|
|
}
|
|
|
|
if (isset($mapping[$fromNorm]) && isset($mapping[$fromNorm][$toNorm])) {
|
|
return $value * $mapping[$fromNorm][$toNorm];
|
|
}
|
|
|
|
// If direct mapping not found, try inverse
|
|
if (isset($mapping[$toNorm]) && isset($mapping[$toNorm][$fromNorm])) {
|
|
return $value / $mapping[$toNorm][$fromNorm];
|
|
}
|
|
|
|
return $value;
|
|
}
|
|
|
|
private function recalculateProductsFromRawMaterial($rawMaterialId, $branch)
|
|
{
|
|
$recipes = \App\Models\ProductRecipe::where('raw_material_id', $rawMaterialId)
|
|
->whereHas('product', function($q) use ($branch) {
|
|
$q->where('branch', $branch);
|
|
})->get();
|
|
foreach ($recipes as $recipe) {
|
|
$product = clone $recipe->product;
|
|
|
|
$minStock = null;
|
|
foreach ($product->recipes as $prodRecipe) {
|
|
$rm = $prodRecipe->rawMaterial;
|
|
$possibleAmount = floor($rm->stock_quantity / $prodRecipe->quantity_needed);
|
|
if ($minStock === null || $possibleAmount < $minStock) {
|
|
$minStock = $possibleAmount;
|
|
}
|
|
}
|
|
|
|
if ($minStock !== null) {
|
|
$stock = \App\Models\Stock::firstOrCreate(
|
|
['product_id' => $product->id, 'branch' => $branch],
|
|
['quantity' => 0, 'unit' => 'Pcs']
|
|
);
|
|
$stock->quantity = $minStock;
|
|
$stock->save();
|
|
}
|
|
}
|
|
}
|
|
public function updateStock(Request $request, $id)
|
|
{
|
|
$request->validate([
|
|
'quantity' => 'required|integer'
|
|
]);
|
|
|
|
$branch = auth()->user()->branch ?? 'Cabang 1';
|
|
|
|
$stock = Stock::where('product_id', $id)->where('branch', $branch)->first();
|
|
if (!$stock) {
|
|
return back()->withErrors(['stock' => 'Stok untuk produk ini tidak ditemukan pada cabang Anda.']);
|
|
}
|
|
|
|
$stock->quantity += (int)$request->input('quantity');
|
|
if ($stock->quantity < 0) $stock->quantity = 0;
|
|
$stock->save();
|
|
|
|
return back()->with('success', 'Stok berhasil diperbarui.');
|
|
}
|
|
}
|