From 339031e370f7ed27fda4a0c04e8238042250fb92 Mon Sep 17 00:00:00 2001 From: adeliaala Date: Sun, 25 May 2025 10:36:03 +0700 Subject: [PATCH] POS System --- .../Http/Controllers/BranchController.php | 3 +- .../Product/DataTables/ProductDataTable.php | 104 +++++-- Modules/Product/Entities/Product.php | 34 ++- .../Http/Controllers/ProductController.php | 21 +- .../Resources/views/products/show.blade.php | 63 +++- Modules/Sale/Entities/Sale.php | 4 + Modules/Sale/Entities/SalePayment.php | 6 +- .../Sale/Http/Controllers/PosController.php | 104 +++++-- .../Sale/Http/Controllers/SaleController.php | 274 ++++++++++-------- .../Controllers/SalePaymentsController.php | 1 + .../Http/Requests/StorePosSaleRequest.php | 6 +- app/Livewire/Pos/Checkout.php | 16 +- app/Livewire/ProductCart.php | 3 +- app/Models/ProductBatch.php | 42 ++- resources/views/layouts/header.blade.php | 102 ++++++- .../views/livewire/pos/product-list.blade.php | 4 +- 16 files changed, 561 insertions(+), 226 deletions(-) diff --git a/Modules/Branch/Http/Controllers/BranchController.php b/Modules/Branch/Http/Controllers/BranchController.php index 8af40292..8aa48d22 100644 --- a/Modules/Branch/Http/Controllers/BranchController.php +++ b/Modules/Branch/Http/Controllers/BranchController.php @@ -98,7 +98,8 @@ class BranchController extends Controller auth()->user()->update(['active_branch_id' => $branch->id]); // Store in session - session(['active_branch_id' => $branch->id]); + session(['branch_id' => $branch->id]); + return redirect()->back()->with('success', 'Branch switched successfully to ' . $branch->name); } diff --git a/Modules/Product/DataTables/ProductDataTable.php b/Modules/Product/DataTables/ProductDataTable.php index efd09d4e..169a36e8 100644 --- a/Modules/Product/DataTables/ProductDataTable.php +++ b/Modules/Product/DataTables/ProductDataTable.php @@ -21,7 +21,8 @@ class ProductDataTable extends DataTable }) ->addColumn('product_image', function ($data) { $url = Product::find($data->id)->getFirstMediaUrl('images'); - return ''; + return 'Product Image'; + }) ->addColumn('product_name', function ($data) { return ''.$data->product_name.''; @@ -33,28 +34,64 @@ class ProductDataTable extends DataTable } public function query(Product $model) - { - $products = DB::table('products') - ->leftJoinSub( - DB::table('product_batches') - ->select('product_id', DB::raw('SUM(qty) as total_quantity')) - ->groupBy('product_id'), - 'pb', - 'products.id', - '=', - 'pb.product_id' - ) - ->select( - 'products.id', - 'products.product_name', - 'products.product_code', - 'products.product_unit', - 'products.category_id', - DB::raw('COALESCE(pb.total_quantity, 0) as product_quantity') - ); +{ + // Subquery untuk total stock + $stockSubquery = DB::table('product_batches') + ->select('product_id', DB::raw('SUM(qty) as total_quantity')) + ->groupBy('product_id'); - return $products; - } + // Subquery untuk batch pertama berdasarkan exp_date (FIFO) + $fifoSubquery = DB::table('product_batches as pb') + ->select('pb.product_id', 'pb.unit_price', 'pb.exp_date') + ->whereRaw('pb.id = ( + SELECT id FROM product_batches + WHERE product_id = pb.product_id + ORDER BY exp_date ASC, id ASC + LIMIT 1 + )'); + + // Join dengan subqueries + $products = DB::table('products') + ->leftJoinSub($stockSubquery, 'stock', 'products.id', '=', 'stock.product_id') + ->leftJoinSub($fifoSubquery, 'fifo', 'products.id', '=', 'fifo.product_id') + ->select( + 'products.id', + 'products.product_name', + 'products.product_code', + 'products.product_unit', + 'products.category_id', + DB::raw('COALESCE(stock.total_quantity, 0) as product_quantity'), + DB::raw('fifo.unit_price as fifo_price'), + DB::raw('fifo.exp_date as fifo_exp_date') + ); + + return $products; +} + + + // public function query(Product $model) + // { + // $products = DB::table('products') + // ->leftJoinSub( + // DB::table('product_batches') + // ->select('product_id', DB::raw('SUM(qty) as total_quantity')) + // ->groupBy('product_id'), + // 'pb', + // 'products.id', + // '=', + // 'pb.product_id' + // ) + // ->select( + // 'products.id', + // 'products.product_name', + // 'products.product_code', + // 'products.product_unit', + // 'products.category_id', + // DB::raw('COALESCE(pb.total_quantity, 0) as product_quantity') + // ); + + // return $products; + // } public function html() { @@ -84,27 +121,31 @@ class ProductDataTable extends DataTable Column::make('product_image') ->title('Image') ->className('text-center align-middle'), - + Column::make('product_name') ->title('Name') ->className('text-center align-middle'), - + Column::make('product_code') ->title('Code') ->className('text-center align-middle'), - - // Column::make('category.category_name') - // ->title('Category') - // ->className('text-center align-middle'), - + Column::make('product_unit') ->title('Unit') ->className('text-center align-middle'), - + Column::make('total_quantity') ->title('Quantity') ->className('text-center align-middle'), - + + Column::make('fifo_price') + ->title('Batch Price') + ->className('text-center align-middle'), + + Column::make('fifo_exp_date') + ->title('Exp Date') + ->className('text-center align-middle'), + Column::computed('action') ->exportable(false) ->printable(false) @@ -112,6 +153,7 @@ class ProductDataTable extends DataTable ->addClass('text-center'), ]; } + /** * Get filename for export. diff --git a/Modules/Product/Entities/Product.php b/Modules/Product/Entities/Product.php index 8996b232..d56e5dab 100644 --- a/Modules/Product/Entities/Product.php +++ b/Modules/Product/Entities/Product.php @@ -69,6 +69,20 @@ class Product extends Model implements HasMedia /** * Setter & Getter harga jual */ + + public function getFifoPriceAttribute(): ?float +{ + try { + return \App\Models\ProductBatch::getFifoBatchPrice( + $this->id, + session('branch_id', 1), // pastikan session branch_id aktif + 1 // ambil harga per unit + ); + } catch (\Exception $e) { + return null; + } +} + public function setProductPriceAttribute($value) { $this->attributes['product_price'] = $value !== null ? ($value * 100) : null; } @@ -80,18 +94,18 @@ class Product extends Model implements HasMedia /** * Hitung harga grosir jika kuantitas mencukupi */ - public function getWholesalePrice($quantity) { - if ($this->min_quantity_for_wholesale && $quantity >= $this->min_quantity_for_wholesale) { - $discount = $this->wholesale_discount_percentage / 100; - return $this->product_price * (1 - $discount); - } - return $this->product_price; - } + // public function getWholesalePrice($quantity) { + // if ($this->min_quantity_for_wholesale && $quantity >= $this->min_quantity_for_wholesale) { + // $discount = $this->wholesale_discount_percentage / 100; + // return $this->product_price * (1 - $discount); + // } + // return $this->product_price; + // } /** * Cek apakah kuantitas memenuhi harga grosir */ - public function isWholesalePrice($quantity) { - return $this->min_quantity_for_wholesale && $quantity >= $this->min_quantity_for_wholesale; - } + // public function isWholesalePrice($quantity) { + // return $this->min_quantity_for_wholesale && $quantity >= $this->min_quantity_for_wholesale; + // } } diff --git a/Modules/Product/Http/Controllers/ProductController.php b/Modules/Product/Http/Controllers/ProductController.php index 4496ae5c..8b0b544a 100644 --- a/Modules/Product/Http/Controllers/ProductController.php +++ b/Modules/Product/Http/Controllers/ProductController.php @@ -49,11 +49,24 @@ class ProductController extends Controller public function show(Product $product) - { - abort_if(Gate::denies('show_products'), 403); +{ + abort_if(Gate::denies('show_products'), 403); + + // Load relasi yang dibutuhkan + $product->load([ + 'category', + 'batches' => function ($q) { + $q->where('qty', '>', 0) + ->orderBy('exp_date', 'asc'); // FIFO + } + ]); + + return view('product::products.show', compact('product')); +} + + + - return view('product::products.show', compact('product')); - } public function edit(Product $product) diff --git a/Modules/Product/Resources/views/products/show.blade.php b/Modules/Product/Resources/views/products/show.blade.php index f35994b3..bf8376d4 100644 --- a/Modules/Product/Resources/views/products/show.blade.php +++ b/Modules/Product/Resources/views/products/show.blade.php @@ -13,12 +13,16 @@ @section('content')
-
-
+ +
+
+
+
Product Information
+
- + @@ -39,23 +43,63 @@ - +
Product CodeProduct Code {{ $product->product_code }}
Note{{ $product->product_note }}{{ $product->product_note ?? '-' }}
+ + +
+
+
Batch Details (Qty > 0)
+
+
+ + + + + + + + + + + + @forelse($product->batches as $batch) + + + + + + + + @empty + + + + @endforelse + +
Batch CodeHarga BeliPriceStockExpiry Date
{{ $batch->batch_code ?? '-' }}Rp {{ number_format($batch->unit_price, 0, ',', '.') }}Rp {{ number_format($batch->price, 0, ',', '.') }}{{ $batch->qty }}{{ $batch->exp_date ? \Carbon\Carbon::parse($batch->exp_date)->format('d M Y') : '-' }}
No available batches.
+
+
-
+ +
Product Images
- @foreach($product->getMedia('images') as $media) + @forelse($product->getMedia('images') as $media)
- {{ $product->product_name }} + {{ $product->product_name }}
- @endforeach + @empty +
+

No images uploaded.

+
+ @endforelse
@@ -63,6 +107,3 @@
@endsection - - - diff --git a/Modules/Sale/Entities/Sale.php b/Modules/Sale/Entities/Sale.php index 4cbb3539..41960e7f 100644 --- a/Modules/Sale/Entities/Sale.php +++ b/Modules/Sale/Entities/Sale.php @@ -10,6 +10,7 @@ class Sale extends Model use HasFactory; protected $fillable = [ + 'branch_id', // ← TAMBAHKAN INI 'date', 'reference', 'customer_id', @@ -23,6 +24,7 @@ class Sale extends Model 'payment_method', 'note' ]; + public function saleDetails() { return $this->hasMany(SaleDetails::class, 'sale_id', 'id'); @@ -60,4 +62,6 @@ class Sale extends Model public function getDiscountAmountAttribute($value) { return $value / 100; } + + } diff --git a/Modules/Sale/Entities/SalePayment.php b/Modules/Sale/Entities/SalePayment.php index 6f11956b..7ed187b7 100644 --- a/Modules/Sale/Entities/SalePayment.php +++ b/Modules/Sale/Entities/SalePayment.php @@ -11,7 +11,11 @@ class SalePayment extends Model use HasFactory; - protected $guarded = []; + protected $fillable = [ + 'branch_id', 'sale_id', 'amount', 'date', 'reference', 'payment_method', 'note' + ]; + + protected $with = ['sale']; public function sale() { return $this->belongsTo(Sale::class, 'sale_id', 'id'); diff --git a/Modules/Sale/Http/Controllers/PosController.php b/Modules/Sale/Http/Controllers/PosController.php index d144cd0e..e17643a9 100644 --- a/Modules/Sale/Http/Controllers/PosController.php +++ b/Modules/Sale/Http/Controllers/PosController.php @@ -3,13 +3,11 @@ namespace Modules\Sale\Http\Controllers; use Gloudemans\Shoppingcart\Facades\Cart; -use Illuminate\Contracts\Support\Renderable; use Illuminate\Http\Request; use Illuminate\Routing\Controller; use Illuminate\Support\Facades\DB; use Modules\People\Entities\Customer; use Modules\Product\Entities\Category; -use Modules\Product\Entities\Product; use Modules\Sale\Entities\Sale; use Modules\Sale\Entities\SaleDetails; use Modules\Sale\Entities\SalePayment; @@ -17,8 +15,8 @@ use Modules\Sale\Http\Requests\StorePosSaleRequest; class PosController extends Controller { - - public function index() { + public function index() + { Cart::instance('sale')->destroy(); $customers = Customer::all(); @@ -27,27 +25,86 @@ class PosController extends Controller return view('sale::pos.index', compact('product_categories', 'customers')); } + private function allocateProductFromBatches($product_id, $required_qty) + { + $branch_id = session('branch_id'); - public function store(StorePosSaleRequest $request) { + $batches = DB::table('product_batches') + ->where('product_id', $product_id) + ->where('branch_id', $branch_id) + ->where('qty', '>', 0) + ->orderBy('created_at') // FIFO + ->get(); + + $allocation = []; + $remaining = $required_qty; + $first_price = null; + $total_allocated_price = 0; + + foreach ($batches as $batch) { + if ($remaining <= 0) break; + + $used_qty = min($batch->qty, $remaining); + + if ($first_price === null) { + $first_price = $batch->price; + } + + $allocation[] = [ + 'batch_id' => $batch->id, + 'qty' => $used_qty, + 'unit_price' => $batch->price + ]; + + DB::table('product_batches') + ->where('id', $batch->id) + ->decrement('qty', $used_qty); + + $total_allocated_price += $used_qty * $batch->price; + $remaining -= $used_qty; + } + + if ($remaining > 0) { + throw new \Exception("Stok produk tidak cukup di cabang saat ini."); + } + + return [ + 'allocation' => $allocation, + 'unit_price' => $first_price, + 'total_price' => $total_allocated_price + ]; + } + + public function store(StorePosSaleRequest $request) + { DB::transaction(function () use ($request) { + $branch_id = session('branch_id'); + + if (!$branch_id) { + throw new \Exception("Cabang belum dipilih. Silakan pilih cabang terlebih dahulu."); + } + $due_amount = $request->total_amount - $request->paid_amount; - if ($due_amount == $request->total_amount) { - $payment_status = 'Unpaid'; - } elseif ($due_amount > 0) { - $payment_status = 'Partial'; - } else { - $payment_status = 'Paid'; - } + $payment_status = match (true) { + $due_amount == $request->total_amount => 'Unpaid', + $due_amount > 0 => 'Partial', + default => 'Paid', + }; + + $discount_percentage = $request->discount_percentage ?? 0; + $tax_percentage = $request->tax_percentage ?? 0; $sale = Sale::create([ 'date' => now()->format('Y-m-d'), + 'branch_id' => $branch_id, 'reference' => 'PSL', 'customer_id' => $request->customer_id, - 'customer_name' => $request->customer_id ? Customer::findOrFail($request->customer_id)->customer_name : 'Walk-in Customer', - 'tax_percentage' => $request->tax_percentage, - 'discount_percentage' => $request->discount_percentage, - 'shipping_amount' => $request->shipping_amount * 100, + 'customer_name' => $request->customer_id + ? Customer::findOrFail($request->customer_id)->customer_name + : 'Walk-in Customer', + 'tax_percentage' => $tax_percentage, + 'discount_percentage' => $discount_percentage, 'paid_amount' => $request->paid_amount * 100, 'total_amount' => $request->total_amount * 100, 'due_amount' => $due_amount * 100, @@ -56,27 +113,25 @@ class PosController extends Controller 'note' => $request->note, 'tax_amount' => Cart::instance('sale')->tax() * 100, 'discount_amount' => Cart::instance('sale')->discount() * 100, + 'user_id' => auth()->id(), ]); foreach (Cart::instance('sale')->content() as $cart_item) { + $allocation = $this->allocateProductFromBatches($cart_item->id, $cart_item->qty); + SaleDetails::create([ 'sale_id' => $sale->id, 'product_id' => $cart_item->id, 'product_name' => $cart_item->name, 'product_code' => $cart_item->options->code, 'quantity' => $cart_item->qty, - 'price' => $cart_item->price * 100, - 'unit_price' => $cart_item->options->unit_price * 100, + 'price' => $allocation['total_price'] * 100, + 'unit_price' => $allocation['unit_price'] * 100, 'sub_total' => $cart_item->options->sub_total * 100, 'product_discount_amount' => $cart_item->options->product_discount * 100, 'product_discount_type' => $cart_item->options->product_discount_type, 'product_tax_amount' => $cart_item->options->product_tax * 100, ]); - - $product = Product::findOrFail($cart_item->id); - $product->update([ - 'product_quantity' => $product->product_quantity - $cart_item->qty - ]); } Cart::instance('sale')->destroy(); @@ -84,7 +139,7 @@ class PosController extends Controller if ($sale->paid_amount > 0) { SalePayment::create([ 'date' => now()->format('Y-m-d'), - 'reference' => 'INV/'.$sale->reference, + 'reference' => 'INV/' . $sale->reference, 'amount' => $sale->paid_amount, 'sale_id' => $sale->id, 'payment_method' => $request->payment_method @@ -93,7 +148,6 @@ class PosController extends Controller }); toast('POS Sale Created!', 'success'); - return redirect()->route('sales.index'); } } diff --git a/Modules/Sale/Http/Controllers/SaleController.php b/Modules/Sale/Http/Controllers/SaleController.php index 27292632..fa0c5c04 100644 --- a/Modules/Sale/Http/Controllers/SaleController.php +++ b/Modules/Sale/Http/Controllers/SaleController.php @@ -17,73 +17,107 @@ use Modules\Sale\Http\Requests\UpdateSaleRequest; class SaleController extends Controller { - - public function index(SalesDataTable $dataTable) { + public function index(SalesDataTable $dataTable) + { abort_if(Gate::denies('access_sales'), 403); - return $dataTable->render('sale::index'); } - - public function create() { + public function create() + { abort_if(Gate::denies('create_sales'), 403); - Cart::instance('sale')->destroy(); - return view('sale::create'); } + private function allocateProductFromBatches($product_id, $required_qty) + { + $branch_id = session('branch_id'); - public function store(StoreSaleRequest $request) { + $batches = DB::table('product_batches') + ->where('product_id', $product_id) + ->where('branch_id', $branch_id) + ->where('quantity', '>', 0) + ->orderBy('created_at') // FIFO + ->get(); + + $allocation = []; + $remaining = $required_qty; + + foreach ($batches as $batch) { + if ($remaining <= 0) break; + + $used_qty = min($batch->quantity, $remaining); + $allocation[] = [ + 'batch_id' => $batch->id, + 'quantity' => $used_qty, + 'unit_price' => $batch->unit_price + ]; + + // Kurangi jumlah di batch + DB::table('product_batches') + ->where('id', $batch->id) + ->decrement('quantity', $used_qty); + + $remaining -= $used_qty; + } + + if ($remaining > 0) { + throw new \Exception("Stok produk tidak cukup di cabang saat ini."); + } + + return $allocation; + } + + public function store(StoreSaleRequest $request) + { DB::transaction(function () use ($request) { $due_amount = $request->total_amount - $request->paid_amount; + $payment_status = $due_amount == $request->total_amount ? 'Unpaid' : + ($due_amount > 0 ? 'Partial' : 'Paid'); - if ($due_amount == $request->total_amount) { - $payment_status = 'Unpaid'; - } elseif ($due_amount > 0) { - $payment_status = 'Partial'; - } else { - $payment_status = 'Paid'; - } + $customer_id = $request->customer_id ?? 1; $sale = Sale::create([ - 'date' => $request->date, - 'reference' => $request->reference, - 'customer_id' => $request->customer_id, - 'customer_name' => Customer::findOrFail($request->customer_id)->customer_name, - 'tax_percentage' => $request->tax_percentage, + 'branch_id' => session('branch_id'), + 'date' => $request->date, + 'reference' => $request->reference, + 'customer_id' => $request->customer_id, + 'customer_name' => optional(Customer::find($request->customer_id))->customer_name, + 'tax_percentage' => $request->tax_percentage, 'discount_percentage' => $request->discount_percentage, - 'shipping_amount' => $request->shipping_amount * 100, - 'paid_amount' => $request->paid_amount * 100, - 'total_amount' => $request->total_amount * 100, - 'due_amount' => $due_amount * 100, - 'payment_status' => $payment_status, - 'payment_method' => $request->payment_method, - 'note' => $request->note, - 'tax_amount' => Cart::instance('sale')->tax() * 100, - 'discount_amount' => Cart::instance('sale')->discount() * 100, + 'shipping_amount' => $request->shipping_amount * 100, + 'paid_amount' => $request->paid_amount * 100, + 'total_amount' => $request->total_amount * 100, + 'due_amount' => $due_amount * 100, + 'payment_status' => $payment_status, + 'payment_method' => $request->payment_method, + 'note' => $request->note, + 'tax_amount' => Cart::instance('sale')->tax() * 100, + 'discount_amount' => Cart::instance('sale')->discount() * 100, ]); - foreach (Cart::instance('sale')->content() as $cart_item) { + foreach (Cart::instance('sale')->content() as $item) { SaleDetails::create([ - 'sale_id' => $sale->id, - 'product_id' => $cart_item->id, - 'product_name' => $cart_item->name, - 'product_code' => $cart_item->options->code, - 'quantity' => $cart_item->qty, - 'price' => $cart_item->price * 100, - 'unit_price' => $cart_item->options->unit_price * 100, - 'sub_total' => $cart_item->options->sub_total * 100, - 'product_discount_amount' => $cart_item->options->product_discount * 100, - 'product_discount_type' => $cart_item->options->product_discount_type, - 'product_tax_amount' => $cart_item->options->product_tax * 100, + 'sale_id' => $sale->id, + 'product_id' => $item->id, + 'product_name' => $item->name, + 'product_code' => $item->options->code, + 'quantity' => $item->qty, + 'price' => $item->price * 100, + 'unit_price' => $item->options->unit_price * 100, + 'sub_total' => $item->options->sub_total * 100, + 'product_discount_amount' => $item->options->product_discount * 100, + 'product_discount_type' => $item->options->product_discount_type, + 'product_tax_amount' => $item->options->product_tax * 100, ]); - if ($request->status == 'Shipped' || $request->status == 'Completed') { - $product = Product::findOrFail($cart_item->id); - $product->update([ - 'product_quantity' => $product->product_quantity - $cart_item->qty - ]); + if (in_array($request->status, ['Shipped', 'Completed'])) { + $product = Product::find($item->id); + if ($product) { + $product->decrement('product_quantity', $item->qty); + $this->allocateProductFromBatches($item->id, $item->qty); + } } } @@ -91,54 +125,48 @@ class SaleController extends Controller if ($sale->paid_amount > 0) { SalePayment::create([ - 'date' => $request->date, - 'reference' => 'INV/'.$sale->reference, - 'amount' => $sale->paid_amount, - 'sale_id' => $sale->id, + 'date' => $request->date, + 'reference' => 'INV/' . $sale->reference, + 'amount' => $sale->paid_amount, + 'sale_id' => $sale->id, 'payment_method' => $request->payment_method ]); } }); toast('Sale Created!', 'success'); - return redirect()->route('sales.index'); } - - public function show(Sale $sale) { + public function show(Sale $sale) + { abort_if(Gate::denies('show_sales'), 403); - - $customer = Customer::findOrFail($sale->customer_id); - + $customer = Customer::find($sale->customer_id); return view('sale::show', compact('sale', 'customer')); } - - public function edit(Sale $sale) { + public function edit(Sale $sale) + { abort_if(Gate::denies('edit_sales'), 403); - $sale_details = $sale->saleDetails; - - Cart::instance('sale')->destroy(); - $cart = Cart::instance('sale'); + $cart->destroy(); - foreach ($sale_details as $sale_detail) { + foreach ($sale->saleDetails as $detail) { $cart->add([ - 'id' => $sale_detail->product_id, - 'name' => $sale_detail->product_name, - 'qty' => $sale_detail->quantity, - 'price' => $sale_detail->price, + 'id' => $detail->product_id, + 'name' => $detail->product_name, + 'qty' => $detail->quantity, + 'price' => $detail->price, 'weight' => 1, 'options' => [ - 'product_discount' => $sale_detail->product_discount_amount, - 'product_discount_type' => $sale_detail->product_discount_type, - 'sub_total' => $sale_detail->sub_total, - 'code' => $sale_detail->product_code, - 'stock' => Product::findOrFail($sale_detail->product_id)->product_quantity, - 'product_tax' => $sale_detail->product_tax_amount, - 'unit_price' => $sale_detail->unit_price + 'product_discount' => $detail->product_discount_amount, + 'product_discount_type' => $detail->product_discount_type, + 'sub_total' => $detail->sub_total, + 'code' => $detail->product_code, + 'stock' => optional(Product::find($detail->product_id))->product_quantity, + 'product_tax' => $detail->product_tax_amount, + 'unit_price' => $detail->unit_price ] ]); } @@ -146,68 +174,62 @@ class SaleController extends Controller return view('sale::edit', compact('sale')); } - - public function update(UpdateSaleRequest $request, Sale $sale) { + public function update(UpdateSaleRequest $request, Sale $sale) + { DB::transaction(function () use ($request, $sale) { - $due_amount = $request->total_amount - $request->paid_amount; + $payment_status = $due_amount == $request->total_amount ? 'Unpaid' : + ($due_amount > 0 ? 'Partial' : 'Paid'); - if ($due_amount == $request->total_amount) { - $payment_status = 'Unpaid'; - } elseif ($due_amount > 0) { - $payment_status = 'Partial'; - } else { - $payment_status = 'Paid'; - } - - foreach ($sale->saleDetails as $sale_detail) { - if ($sale->status == 'Shipped' || $sale->status == 'Completed') { - $product = Product::findOrFail($sale_detail->product_id); - $product->update([ - 'product_quantity' => $product->product_quantity + $sale_detail->quantity - ]); + foreach ($sale->saleDetails as $detail) { + if (in_array($sale->status, ['Shipped', 'Completed'])) { + $product = Product::find($detail->product_id); + if ($product) { + $product->increment('product_quantity', $detail->quantity); + } } - $sale_detail->delete(); + $detail->delete(); } $sale->update([ - 'date' => $request->date, - 'reference' => $request->reference, - 'customer_id' => $request->customer_id, - 'customer_name' => Customer::findOrFail($request->customer_id)->customer_name, - 'tax_percentage' => $request->tax_percentage, + 'date' => $request->date, + 'reference' => $request->reference, + 'customer_id' => $request->customer_id, + 'customer_name' => optional(Customer::find($request->customer_id))->customer_name, + 'tax_percentage' => $request->tax_percentage, 'discount_percentage' => $request->discount_percentage, - 'shipping_amount' => $request->shipping_amount * 100, - 'paid_amount' => $request->paid_amount * 100, - 'total_amount' => $request->total_amount * 100, - 'due_amount' => $due_amount * 100, - 'payment_status' => $payment_status, - 'payment_method' => $request->payment_method, - 'note' => $request->note, - 'tax_amount' => Cart::instance('sale')->tax() * 100, - 'discount_amount' => Cart::instance('sale')->discount() * 100, + 'shipping_amount' => $request->shipping_amount * 100, + 'paid_amount' => $request->paid_amount * 100, + 'total_amount' => $request->total_amount * 100, + 'due_amount' => $due_amount * 100, + 'payment_status' => $payment_status, + 'payment_method' => $request->payment_method, + 'note' => $request->note, + 'tax_amount' => Cart::instance('sale')->tax() * 100, + 'discount_amount' => Cart::instance('sale')->discount() * 100, ]); - foreach (Cart::instance('sale')->content() as $cart_item) { + foreach (Cart::instance('sale')->content() as $item) { SaleDetails::create([ - 'sale_id' => $sale->id, - 'product_id' => $cart_item->id, - 'product_name' => $cart_item->name, - 'product_code' => $cart_item->options->code, - 'quantity' => $cart_item->qty, - 'price' => $cart_item->price * 100, - 'unit_price' => $cart_item->options->unit_price * 100, - 'sub_total' => $cart_item->options->sub_total * 100, - 'product_discount_amount' => $cart_item->options->product_discount * 100, - 'product_discount_type' => $cart_item->options->product_discount_type, - 'product_tax_amount' => $cart_item->options->product_tax * 100, + 'sale_id' => $sale->id, + 'product_id' => $item->id, + 'product_name' => $item->name, + 'product_code' => $item->options->code, + 'quantity' => $item->qty, + 'price' => $item->price * 100, + 'unit_price' => $item->options->unit_price * 100, + 'sub_total' => $item->options->sub_total * 100, + 'product_discount_amount' => $item->options->product_discount * 100, + 'product_discount_type' => $item->options->product_discount_type, + 'product_tax_amount' => $item->options->product_tax * 100, ]); - if ($request->status == 'Shipped' || $request->status == 'Completed') { - $product = Product::findOrFail($cart_item->id); - $product->update([ - 'product_quantity' => $product->product_quantity - $cart_item->qty - ]); + if (in_array($request->status, ['Shipped', 'Completed'])) { + $product = Product::find($item->id); + if ($product) { + $product->decrement('product_quantity', $item->qty); + $this->allocateProductFromBatches($item->id, $item->qty); + } } } @@ -215,18 +237,16 @@ class SaleController extends Controller }); toast('Sale Updated!', 'info'); - return redirect()->route('sales.index'); } - - public function destroy(Sale $sale) { + public function destroy(Sale $sale) + { abort_if(Gate::denies('delete_sales'), 403); $sale->delete(); toast('Sale Deleted!', 'warning'); - return redirect()->route('sales.index'); } } diff --git a/Modules/Sale/Http/Controllers/SalePaymentsController.php b/Modules/Sale/Http/Controllers/SalePaymentsController.php index a0fc034f..dd1a0cba 100644 --- a/Modules/Sale/Http/Controllers/SalePaymentsController.php +++ b/Modules/Sale/Http/Controllers/SalePaymentsController.php @@ -46,6 +46,7 @@ class SalePaymentsController extends Controller DB::transaction(function () use ($request) { SalePayment::create([ + 'branch_id' => session('branch_id'), // tambahkan ini 'date' => $request->date, 'reference' => $request->reference, 'amount' => $request->amount, diff --git a/Modules/Sale/Http/Requests/StorePosSaleRequest.php b/Modules/Sale/Http/Requests/StorePosSaleRequest.php index 6b584c12..a8a3f800 100644 --- a/Modules/Sale/Http/Requests/StorePosSaleRequest.php +++ b/Modules/Sale/Http/Requests/StorePosSaleRequest.php @@ -16,9 +16,9 @@ class StorePosSaleRequest extends FormRequest { return [ 'customer_id' => 'required|numeric', - 'tax_percentage' => 'required|integer|min:0|max:100', - 'discount_percentage' => 'required|integer|min:0|max:100', - 'shipping_amount' => 'required|numeric', + //'tax_percentage' => 'required|integer|min:0|max:100', + //'discount_percentage' => 'required|integer|min:0|max:100', + //'shipping_amount' => 'required|numeric', 'total_amount' => 'required|numeric', 'paid_amount' => 'required|numeric', 'note' => 'nullable|string|max:1000' diff --git a/app/Livewire/Pos/Checkout.php b/app/Livewire/Pos/Checkout.php index 03142f15..1a3c8015 100644 --- a/app/Livewire/Pos/Checkout.php +++ b/app/Livewire/Pos/Checkout.php @@ -4,6 +4,7 @@ namespace App\Livewire\Pos; use Gloudemans\Shoppingcart\Facades\Cart; use Livewire\Component; +use App\Models\ProductBatch; class Checkout extends Component { @@ -69,25 +70,28 @@ class Checkout extends Component if ($exists->isNotEmpty()) { session()->flash('message', 'Product exists in the cart!'); - return; } + $branchId = session('active_branch') ?? 1; + $qty = 1; + $fifoPrice = ProductBatch::getFifoBatchPrice($product['id'], $branchId, $qty); + $cart->add([ 'id' => $product['id'], 'name' => $product['product_name'], - 'qty' => 1, - 'price' => $this->calculate($product)['price'], + 'qty' => $qty, + 'price' => $fifoPrice, 'weight' => 1, 'options' => [ 'product_discount' => 0.00, 'product_discount_type' => 'fixed', - 'sub_total' => $this->calculate($product)['sub_total'], + 'sub_total' => $fifoPrice, 'code' => $product['product_code'], 'stock' => $product['product_quantity'], 'unit' => $product['product_unit'], - 'product_tax' => $this->calculate($product)['product_tax'], - 'unit_price' => $this->calculate($product)['unit_price'] + 'product_tax' => 0, + 'unit_price' => $fifoPrice ] ]); diff --git a/app/Livewire/ProductCart.php b/app/Livewire/ProductCart.php index be85c766..ac5b8b4e 100644 --- a/app/Livewire/ProductCart.php +++ b/app/Livewire/ProductCart.php @@ -6,6 +6,7 @@ use Gloudemans\Shoppingcart\Facades\Cart; use Illuminate\Support\Facades\Request; use Livewire\Component; use Modules\Product\Entities\Product; +use App\Models\ProductBatch; class ProductCart extends Component { @@ -43,7 +44,7 @@ class ProductCart extends Component foreach ($cart_items as $cart_item) { $this->check_quantity[$cart_item->id] = [$cart_item->options->stock]; $this->quantity[$cart_item->id] = $cart_item->qty; - $this->unit_price[$cart_item->id] = $cart_item->price; + $this->unit_price[$cart_item->id] = $cart_item->unit_price; $this->discount_type[$cart_item->id] = $cart_item->options->product_discount_type; if ($cart_item->options->product_discount_type == 'fixed') { $this->item_discount[$cart_item->id] = $cart_item->options->product_discount; diff --git a/app/Models/ProductBatch.php b/app/Models/ProductBatch.php index d4dfd539..5aa9215c 100644 --- a/app/Models/ProductBatch.php +++ b/app/Models/ProductBatch.php @@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Support\Str; use Modules\Product\Entities\Product; +use App\Models\Branch; class ProductBatch extends Model { @@ -15,6 +16,7 @@ class ProductBatch extends Model 'batch_code', 'qty', 'unit_price', + 'price', 'exp_date', 'purchase_id', 'created_by', @@ -24,6 +26,7 @@ class ProductBatch extends Model protected $casts = [ 'exp_date' => 'date', 'unit_price' => 'decimal:2', + 'price' => 'decimal:2', ]; public function product(): BelongsTo @@ -77,7 +80,8 @@ class ProductBatch extends Model 'batch_id' => $batch->id, 'qty' => $deductAmount, 'unit_price' => $batch->unit_price, - 'exp_date' => $batch->exp_date + 'price' => $batch->price, + 'exp_date' => $batch->exp_date, ]; $remainingQuantity -= $deductAmount; @@ -100,7 +104,7 @@ class ProductBatch extends Model $data['exp_date'] = $data['expired_date']; unset($data['expired_date']); } - + // Rename purchase_price to unit_price if it exists if (isset($data['purchase_price'])) { $data['unit_price'] = $data['purchase_price']; @@ -108,10 +112,40 @@ class ProductBatch extends Model } // Generate batch code if not provided - if (!isset($data['batch_code'])) { + if (empty($data['batch_code'])) { $data['batch_code'] = self::generateBatchCode(); } return self::create($data); } -} \ No newline at end of file + + /** + * Get FIFO batch unit price (average from earliest batches) + */ + public static function getFifoBatchPrice(int $productId, int $branchId, int $qty = 1): float + { + $batches = self::where('product_id', $productId) + ->where('branch_id', $branchId) + ->where('qty', '>', 0) + ->orderBy('exp_date', 'asc') + ->orderBy('created_at', 'asc') + ->get(); + + $remaining = $qty; + $totalPrice = 0; + + foreach ($batches as $batch) { + if ($remaining <= 0) break; + + $take = min($remaining, $batch->qty); + $totalPrice += $take * $batch->unit_price; + $remaining -= $take; + } + + if ($remaining > 0) { + throw new \Exception('Stok tidak cukup untuk menghitung harga FIFO'); + } + + return round($totalPrice / $qty, 2); + } +} diff --git a/resources/views/layouts/header.blade.php b/resources/views/layouts/header.blade.php index de457f5f..2ccae5ef 100644 --- a/resources/views/layouts/header.blade.php +++ b/resources/views/layouts/header.blade.php @@ -1,3 +1,7 @@ +@php + use Modules\Branch\Entities\Branch; + $branch = Branch::find(session('branch_id')); +@endphp @@ -8,6 +12,102 @@
    +
+ + +{{-- + + + +
    +
    @can('access_branches') @@ -91,4 +191,4 @@
- + --}} diff --git a/resources/views/livewire/pos/product-list.blade.php b/resources/views/livewire/pos/product-list.blade.php index f665d6dc..25d6d087 100644 --- a/resources/views/livewire/pos/product-list.blade.php +++ b/resources/views/livewire/pos/product-list.blade.php @@ -22,7 +22,9 @@ {{ $product->product_code }}
-

{{ format_currency($product->product_price) }}

+

+ {{ format_currency($product->fifo_price ?? 0) }} +