POS System

This commit is contained in:
adeliaala 2025-05-25 10:36:03 +07:00
parent 1f6021249e
commit 339031e370
16 changed files with 561 additions and 226 deletions

View File

@ -98,7 +98,8 @@ class BranchController extends Controller
auth()->user()->update(['active_branch_id' => $branch->id]); auth()->user()->update(['active_branch_id' => $branch->id]);
// Store in session // 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); return redirect()->back()->with('success', 'Branch switched successfully to ' . $branch->name);
} }

View File

@ -21,7 +21,8 @@ class ProductDataTable extends DataTable
}) })
->addColumn('product_image', function ($data) { ->addColumn('product_image', function ($data) {
$url = Product::find($data->id)->getFirstMediaUrl('images'); $url = Product::find($data->id)->getFirstMediaUrl('images');
return '<img src="'.$url.'" class="product-img-thumb" alt="">'; return '<img src="'.$url.'" class="img-thumbnail" style="width: 50px; height: 50px; object-fit: cover;" alt="Product Image">';
}) })
->addColumn('product_name', function ($data) { ->addColumn('product_name', function ($data) {
return '<a href="'.route('products.show', $data->id).'">'.$data->product_name.'</a>'; return '<a href="'.route('products.show', $data->id).'">'.$data->product_name.'</a>';
@ -33,28 +34,64 @@ class ProductDataTable extends DataTable
} }
public function query(Product $model) public function query(Product $model)
{ {
$products = DB::table('products') // Subquery untuk total stock
->leftJoinSub( $stockSubquery = DB::table('product_batches')
DB::table('product_batches')
->select('product_id', DB::raw('SUM(qty) as total_quantity')) ->select('product_id', DB::raw('SUM(qty) as total_quantity'))
->groupBy('product_id'), ->groupBy('product_id');
'pb',
'products.id', // Subquery untuk batch pertama berdasarkan exp_date (FIFO)
'=', $fifoSubquery = DB::table('product_batches as pb')
'pb.product_id' ->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( ->select(
'products.id', 'products.id',
'products.product_name', 'products.product_name',
'products.product_code', 'products.product_code',
'products.product_unit', 'products.product_unit',
'products.category_id', 'products.category_id',
DB::raw('COALESCE(pb.total_quantity, 0) as product_quantity') 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; 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() public function html()
{ {
@ -93,10 +130,6 @@ class ProductDataTable extends DataTable
->title('Code') ->title('Code')
->className('text-center align-middle'), ->className('text-center align-middle'),
// Column::make('category.category_name')
// ->title('Category')
// ->className('text-center align-middle'),
Column::make('product_unit') Column::make('product_unit')
->title('Unit') ->title('Unit')
->className('text-center align-middle'), ->className('text-center align-middle'),
@ -105,6 +138,14 @@ class ProductDataTable extends DataTable
->title('Quantity') ->title('Quantity')
->className('text-center align-middle'), ->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') Column::computed('action')
->exportable(false) ->exportable(false)
->printable(false) ->printable(false)
@ -113,6 +154,7 @@ class ProductDataTable extends DataTable
]; ];
} }
/** /**
* Get filename for export. * Get filename for export.
* *

View File

@ -69,6 +69,20 @@ class Product extends Model implements HasMedia
/** /**
* Setter & Getter harga jual * 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) { public function setProductPriceAttribute($value) {
$this->attributes['product_price'] = $value !== null ? ($value * 100) : null; $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 * Hitung harga grosir jika kuantitas mencukupi
*/ */
public function getWholesalePrice($quantity) { // public function getWholesalePrice($quantity) {
if ($this->min_quantity_for_wholesale && $quantity >= $this->min_quantity_for_wholesale) { // if ($this->min_quantity_for_wholesale && $quantity >= $this->min_quantity_for_wholesale) {
$discount = $this->wholesale_discount_percentage / 100; // $discount = $this->wholesale_discount_percentage / 100;
return $this->product_price * (1 - $discount); // return $this->product_price * (1 - $discount);
} // }
return $this->product_price; // return $this->product_price;
} // }
/** /**
* Cek apakah kuantitas memenuhi harga grosir * Cek apakah kuantitas memenuhi harga grosir
*/ */
public function isWholesalePrice($quantity) { // public function isWholesalePrice($quantity) {
return $this->min_quantity_for_wholesale && $quantity >= $this->min_quantity_for_wholesale; // return $this->min_quantity_for_wholesale && $quantity >= $this->min_quantity_for_wholesale;
} // }
} }

View File

@ -49,11 +49,24 @@ class ProductController extends Controller
public function show(Product $product) public function show(Product $product)
{ {
abort_if(Gate::denies('show_products'), 403); abort_if(Gate::denies('show_products'), 403);
return view('product::products.show', compact('product')); // 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'));
}
public function edit(Product $product) public function edit(Product $product)

View File

@ -13,12 +13,16 @@
@section('content') @section('content')
<div class="container-fluid mb-4"> <div class="container-fluid mb-4">
<div class="row"> <div class="row">
<div class="col-lg-9"> <!-- Informasi Produk -->
<div class="card h-100"> <div class="col-lg-8">
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">Product Information</h5>
</div>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-bordered table-striped mb-0"> <table class="table table-bordered table-striped mb-0">
<tr> <tr>
<th>Product Code</th> <th width="30%">Product Code</th>
<td>{{ $product->product_code }}</td> <td>{{ $product->product_code }}</td>
</tr> </tr>
<tr> <tr>
@ -39,23 +43,63 @@
</tr> </tr>
<tr> <tr>
<th>Note</th> <th>Note</th>
<td>{{ $product->product_note }}</td> <td>{{ $product->product_note ?? '-' }}</td>
</tr> </tr>
</table> </table>
</div> </div>
</div> </div>
<!-- Batch Details -->
<div class="card">
<div class="card-header">
<h5 class="mb-0">Batch Details (Qty > 0)</h5>
</div>
<div class="table-responsive">
<table class="table table-bordered table-hover table-sm mb-0 text-center">
<thead class="table-light">
<tr>
<th>Batch Code</th>
<th>Harga Beli</th>
<th>Price</th>
<th>Stock</th>
<th>Expiry Date</th>
</tr>
</thead>
<tbody>
@forelse($product->batches as $batch)
<tr>
<td>{{ $batch->batch_code ?? '-' }}</td>
<td>Rp {{ number_format($batch->unit_price, 0, ',', '.') }}</td>
<td>Rp {{ number_format($batch->price, 0, ',', '.') }}</td>
<td>{{ $batch->qty }}</td>
<td>{{ $batch->exp_date ? \Carbon\Carbon::parse($batch->exp_date)->format('d M Y') : '-' }}</td>
</tr>
@empty
<tr>
<td colspan="4">No available batches.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div> </div>
<div class="col-lg-3"> <!-- Gambar Produk -->
<div class="col-lg-4">
<div class="card h-100"> <div class="card h-100">
<div class="card-body"> <div class="card-body">
<h5 class="card-title">Product Images</h5> <h5 class="card-title">Product Images</h5>
<div class="row"> <div class="row">
@foreach($product->getMedia('images') as $media) @forelse($product->getMedia('images') as $media)
<div class="col-md-6 mb-2"> <div class="col-md-6 mb-2">
<img src="{{ $media->getUrl() }}" alt="{{ $product->product_name }}" class="img-fluid"> <img src="{{ $media->getUrl() }}" alt="{{ $product->product_name }}" class="img-fluid rounded border shadow-sm" style="max-height: 120px; object-fit: cover;">
</div> </div>
@endforeach @empty
<div class="col-12">
<p class="text-muted">No images uploaded.</p>
</div>
@endforelse
</div> </div>
</div> </div>
</div> </div>
@ -63,6 +107,3 @@
</div> </div>
</div> </div>
@endsection @endsection

View File

@ -10,6 +10,7 @@ class Sale extends Model
use HasFactory; use HasFactory;
protected $fillable = [ protected $fillable = [
'branch_id', // ← TAMBAHKAN INI
'date', 'date',
'reference', 'reference',
'customer_id', 'customer_id',
@ -24,6 +25,7 @@ class Sale extends Model
'note' 'note'
]; ];
public function saleDetails() { public function saleDetails() {
return $this->hasMany(SaleDetails::class, 'sale_id', 'id'); return $this->hasMany(SaleDetails::class, 'sale_id', 'id');
} }
@ -60,4 +62,6 @@ class Sale extends Model
public function getDiscountAmountAttribute($value) { public function getDiscountAmountAttribute($value) {
return $value / 100; return $value / 100;
} }
} }

View File

@ -11,7 +11,11 @@ class SalePayment extends Model
use HasFactory; use HasFactory;
protected $guarded = []; protected $fillable = [
'branch_id', 'sale_id', 'amount', 'date', 'reference', 'payment_method', 'note'
];
protected $with = ['sale'];
public function sale() { public function sale() {
return $this->belongsTo(Sale::class, 'sale_id', 'id'); return $this->belongsTo(Sale::class, 'sale_id', 'id');

View File

@ -3,13 +3,11 @@
namespace Modules\Sale\Http\Controllers; namespace Modules\Sale\Http\Controllers;
use Gloudemans\Shoppingcart\Facades\Cart; use Gloudemans\Shoppingcart\Facades\Cart;
use Illuminate\Contracts\Support\Renderable;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Routing\Controller; use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Modules\People\Entities\Customer; use Modules\People\Entities\Customer;
use Modules\Product\Entities\Category; use Modules\Product\Entities\Category;
use Modules\Product\Entities\Product;
use Modules\Sale\Entities\Sale; use Modules\Sale\Entities\Sale;
use Modules\Sale\Entities\SaleDetails; use Modules\Sale\Entities\SaleDetails;
use Modules\Sale\Entities\SalePayment; use Modules\Sale\Entities\SalePayment;
@ -17,8 +15,8 @@ use Modules\Sale\Http\Requests\StorePosSaleRequest;
class PosController extends Controller class PosController extends Controller
{ {
public function index()
public function index() { {
Cart::instance('sale')->destroy(); Cart::instance('sale')->destroy();
$customers = Customer::all(); $customers = Customer::all();
@ -27,27 +25,86 @@ class PosController extends Controller
return view('sale::pos.index', compact('product_categories', 'customers')); 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) { 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; $due_amount = $request->total_amount - $request->paid_amount;
if ($due_amount == $request->total_amount) { $payment_status = match (true) {
$payment_status = 'Unpaid'; $due_amount == $request->total_amount => 'Unpaid',
} elseif ($due_amount > 0) { $due_amount > 0 => 'Partial',
$payment_status = 'Partial'; default => 'Paid',
} else { };
$payment_status = 'Paid';
} $discount_percentage = $request->discount_percentage ?? 0;
$tax_percentage = $request->tax_percentage ?? 0;
$sale = Sale::create([ $sale = Sale::create([
'date' => now()->format('Y-m-d'), 'date' => now()->format('Y-m-d'),
'branch_id' => $branch_id,
'reference' => 'PSL', 'reference' => 'PSL',
'customer_id' => $request->customer_id, 'customer_id' => $request->customer_id,
'customer_name' => $request->customer_id ? Customer::findOrFail($request->customer_id)->customer_name : 'Walk-in Customer', 'customer_name' => $request->customer_id
'tax_percentage' => $request->tax_percentage, ? Customer::findOrFail($request->customer_id)->customer_name
'discount_percentage' => $request->discount_percentage, : 'Walk-in Customer',
'shipping_amount' => $request->shipping_amount * 100, 'tax_percentage' => $tax_percentage,
'discount_percentage' => $discount_percentage,
'paid_amount' => $request->paid_amount * 100, 'paid_amount' => $request->paid_amount * 100,
'total_amount' => $request->total_amount * 100, 'total_amount' => $request->total_amount * 100,
'due_amount' => $due_amount * 100, 'due_amount' => $due_amount * 100,
@ -56,27 +113,25 @@ class PosController extends Controller
'note' => $request->note, 'note' => $request->note,
'tax_amount' => Cart::instance('sale')->tax() * 100, 'tax_amount' => Cart::instance('sale')->tax() * 100,
'discount_amount' => Cart::instance('sale')->discount() * 100, 'discount_amount' => Cart::instance('sale')->discount() * 100,
'user_id' => auth()->id(),
]); ]);
foreach (Cart::instance('sale')->content() as $cart_item) { foreach (Cart::instance('sale')->content() as $cart_item) {
$allocation = $this->allocateProductFromBatches($cart_item->id, $cart_item->qty);
SaleDetails::create([ SaleDetails::create([
'sale_id' => $sale->id, 'sale_id' => $sale->id,
'product_id' => $cart_item->id, 'product_id' => $cart_item->id,
'product_name' => $cart_item->name, 'product_name' => $cart_item->name,
'product_code' => $cart_item->options->code, 'product_code' => $cart_item->options->code,
'quantity' => $cart_item->qty, 'quantity' => $cart_item->qty,
'price' => $cart_item->price * 100, 'price' => $allocation['total_price'] * 100,
'unit_price' => $cart_item->options->unit_price * 100, 'unit_price' => $allocation['unit_price'] * 100,
'sub_total' => $cart_item->options->sub_total * 100, 'sub_total' => $cart_item->options->sub_total * 100,
'product_discount_amount' => $cart_item->options->product_discount * 100, 'product_discount_amount' => $cart_item->options->product_discount * 100,
'product_discount_type' => $cart_item->options->product_discount_type, 'product_discount_type' => $cart_item->options->product_discount_type,
'product_tax_amount' => $cart_item->options->product_tax * 100, '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(); Cart::instance('sale')->destroy();
@ -84,7 +139,7 @@ class PosController extends Controller
if ($sale->paid_amount > 0) { if ($sale->paid_amount > 0) {
SalePayment::create([ SalePayment::create([
'date' => now()->format('Y-m-d'), 'date' => now()->format('Y-m-d'),
'reference' => 'INV/'.$sale->reference, 'reference' => 'INV/' . $sale->reference,
'amount' => $sale->paid_amount, 'amount' => $sale->paid_amount,
'sale_id' => $sale->id, 'sale_id' => $sale->id,
'payment_method' => $request->payment_method 'payment_method' => $request->payment_method
@ -93,7 +148,6 @@ class PosController extends Controller
}); });
toast('POS Sale Created!', 'success'); toast('POS Sale Created!', 'success');
return redirect()->route('sales.index'); return redirect()->route('sales.index');
} }
} }

View File

@ -17,40 +17,73 @@ use Modules\Sale\Http\Requests\UpdateSaleRequest;
class SaleController extends Controller class SaleController extends Controller
{ {
public function index(SalesDataTable $dataTable)
public function index(SalesDataTable $dataTable) { {
abort_if(Gate::denies('access_sales'), 403); abort_if(Gate::denies('access_sales'), 403);
return $dataTable->render('sale::index'); return $dataTable->render('sale::index');
} }
public function create()
public function create() { {
abort_if(Gate::denies('create_sales'), 403); abort_if(Gate::denies('create_sales'), 403);
Cart::instance('sale')->destroy(); Cart::instance('sale')->destroy();
return view('sale::create'); 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')
DB::transaction(function () use ($request) { ->where('product_id', $product_id)
$due_amount = $request->total_amount - $request->paid_amount; ->where('branch_id', $branch_id)
->where('quantity', '>', 0)
->orderBy('created_at') // FIFO
->get();
if ($due_amount == $request->total_amount) { $allocation = [];
$payment_status = 'Unpaid'; $remaining = $required_qty;
} elseif ($due_amount > 0) {
$payment_status = 'Partial'; foreach ($batches as $batch) {
} else { if ($remaining <= 0) break;
$payment_status = 'Paid';
$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');
$customer_id = $request->customer_id ?? 1;
$sale = Sale::create([ $sale = Sale::create([
'branch_id' => session('branch_id'),
'date' => $request->date, 'date' => $request->date,
'reference' => $request->reference, 'reference' => $request->reference,
'customer_id' => $request->customer_id, 'customer_id' => $request->customer_id,
'customer_name' => Customer::findOrFail($request->customer_id)->customer_name, 'customer_name' => optional(Customer::find($request->customer_id))->customer_name,
'tax_percentage' => $request->tax_percentage, 'tax_percentage' => $request->tax_percentage,
'discount_percentage' => $request->discount_percentage, 'discount_percentage' => $request->discount_percentage,
'shipping_amount' => $request->shipping_amount * 100, 'shipping_amount' => $request->shipping_amount * 100,
@ -64,26 +97,27 @@ class SaleController extends Controller
'discount_amount' => Cart::instance('sale')->discount() * 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([ SaleDetails::create([
'sale_id' => $sale->id, 'sale_id' => $sale->id,
'product_id' => $cart_item->id, 'product_id' => $item->id,
'product_name' => $cart_item->name, 'product_name' => $item->name,
'product_code' => $cart_item->options->code, 'product_code' => $item->options->code,
'quantity' => $cart_item->qty, 'quantity' => $item->qty,
'price' => $cart_item->price * 100, 'price' => $item->price * 100,
'unit_price' => $cart_item->options->unit_price * 100, 'unit_price' => $item->options->unit_price * 100,
'sub_total' => $cart_item->options->sub_total * 100, 'sub_total' => $item->options->sub_total * 100,
'product_discount_amount' => $cart_item->options->product_discount * 100, 'product_discount_amount' => $item->options->product_discount * 100,
'product_discount_type' => $cart_item->options->product_discount_type, 'product_discount_type' => $item->options->product_discount_type,
'product_tax_amount' => $cart_item->options->product_tax * 100, 'product_tax_amount' => $item->options->product_tax * 100,
]); ]);
if ($request->status == 'Shipped' || $request->status == 'Completed') { if (in_array($request->status, ['Shipped', 'Completed'])) {
$product = Product::findOrFail($cart_item->id); $product = Product::find($item->id);
$product->update([ if ($product) {
'product_quantity' => $product->product_quantity - $cart_item->qty $product->decrement('product_quantity', $item->qty);
]); $this->allocateProductFromBatches($item->id, $item->qty);
}
} }
} }
@ -92,7 +126,7 @@ class SaleController extends Controller
if ($sale->paid_amount > 0) { if ($sale->paid_amount > 0) {
SalePayment::create([ SalePayment::create([
'date' => $request->date, 'date' => $request->date,
'reference' => 'INV/'.$sale->reference, 'reference' => 'INV/' . $sale->reference,
'amount' => $sale->paid_amount, 'amount' => $sale->paid_amount,
'sale_id' => $sale->id, 'sale_id' => $sale->id,
'payment_method' => $request->payment_method 'payment_method' => $request->payment_method
@ -101,44 +135,38 @@ class SaleController extends Controller
}); });
toast('Sale Created!', 'success'); toast('Sale Created!', 'success');
return redirect()->route('sales.index'); return redirect()->route('sales.index');
} }
public function show(Sale $sale)
public function show(Sale $sale) { {
abort_if(Gate::denies('show_sales'), 403); abort_if(Gate::denies('show_sales'), 403);
$customer = Customer::find($sale->customer_id);
$customer = Customer::findOrFail($sale->customer_id);
return view('sale::show', compact('sale', 'customer')); return view('sale::show', compact('sale', 'customer'));
} }
public function edit(Sale $sale)
public function edit(Sale $sale) { {
abort_if(Gate::denies('edit_sales'), 403); abort_if(Gate::denies('edit_sales'), 403);
$sale_details = $sale->saleDetails;
Cart::instance('sale')->destroy();
$cart = Cart::instance('sale'); $cart = Cart::instance('sale');
$cart->destroy();
foreach ($sale_details as $sale_detail) { foreach ($sale->saleDetails as $detail) {
$cart->add([ $cart->add([
'id' => $sale_detail->product_id, 'id' => $detail->product_id,
'name' => $sale_detail->product_name, 'name' => $detail->product_name,
'qty' => $sale_detail->quantity, 'qty' => $detail->quantity,
'price' => $sale_detail->price, 'price' => $detail->price,
'weight' => 1, 'weight' => 1,
'options' => [ 'options' => [
'product_discount' => $sale_detail->product_discount_amount, 'product_discount' => $detail->product_discount_amount,
'product_discount_type' => $sale_detail->product_discount_type, 'product_discount_type' => $detail->product_discount_type,
'sub_total' => $sale_detail->sub_total, 'sub_total' => $detail->sub_total,
'code' => $sale_detail->product_code, 'code' => $detail->product_code,
'stock' => Product::findOrFail($sale_detail->product_id)->product_quantity, 'stock' => optional(Product::find($detail->product_id))->product_quantity,
'product_tax' => $sale_detail->product_tax_amount, 'product_tax' => $detail->product_tax_amount,
'unit_price' => $sale_detail->unit_price 'unit_price' => $detail->unit_price
] ]
]); ]);
} }
@ -146,35 +174,28 @@ class SaleController extends Controller
return view('sale::edit', compact('sale')); 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) { DB::transaction(function () use ($request, $sale) {
$due_amount = $request->total_amount - $request->paid_amount; $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) { foreach ($sale->saleDetails as $detail) {
$payment_status = 'Unpaid'; if (in_array($sale->status, ['Shipped', 'Completed'])) {
} elseif ($due_amount > 0) { $product = Product::find($detail->product_id);
$payment_status = 'Partial'; if ($product) {
} else { $product->increment('product_quantity', $detail->quantity);
$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
]);
} }
$sale_detail->delete(); $detail->delete();
} }
$sale->update([ $sale->update([
'date' => $request->date, 'date' => $request->date,
'reference' => $request->reference, 'reference' => $request->reference,
'customer_id' => $request->customer_id, 'customer_id' => $request->customer_id,
'customer_name' => Customer::findOrFail($request->customer_id)->customer_name, 'customer_name' => optional(Customer::find($request->customer_id))->customer_name,
'tax_percentage' => $request->tax_percentage, 'tax_percentage' => $request->tax_percentage,
'discount_percentage' => $request->discount_percentage, 'discount_percentage' => $request->discount_percentage,
'shipping_amount' => $request->shipping_amount * 100, 'shipping_amount' => $request->shipping_amount * 100,
@ -188,26 +209,27 @@ class SaleController extends Controller
'discount_amount' => Cart::instance('sale')->discount() * 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([ SaleDetails::create([
'sale_id' => $sale->id, 'sale_id' => $sale->id,
'product_id' => $cart_item->id, 'product_id' => $item->id,
'product_name' => $cart_item->name, 'product_name' => $item->name,
'product_code' => $cart_item->options->code, 'product_code' => $item->options->code,
'quantity' => $cart_item->qty, 'quantity' => $item->qty,
'price' => $cart_item->price * 100, 'price' => $item->price * 100,
'unit_price' => $cart_item->options->unit_price * 100, 'unit_price' => $item->options->unit_price * 100,
'sub_total' => $cart_item->options->sub_total * 100, 'sub_total' => $item->options->sub_total * 100,
'product_discount_amount' => $cart_item->options->product_discount * 100, 'product_discount_amount' => $item->options->product_discount * 100,
'product_discount_type' => $cart_item->options->product_discount_type, 'product_discount_type' => $item->options->product_discount_type,
'product_tax_amount' => $cart_item->options->product_tax * 100, 'product_tax_amount' => $item->options->product_tax * 100,
]); ]);
if ($request->status == 'Shipped' || $request->status == 'Completed') { if (in_array($request->status, ['Shipped', 'Completed'])) {
$product = Product::findOrFail($cart_item->id); $product = Product::find($item->id);
$product->update([ if ($product) {
'product_quantity' => $product->product_quantity - $cart_item->qty $product->decrement('product_quantity', $item->qty);
]); $this->allocateProductFromBatches($item->id, $item->qty);
}
} }
} }
@ -215,18 +237,16 @@ class SaleController extends Controller
}); });
toast('Sale Updated!', 'info'); toast('Sale Updated!', 'info');
return redirect()->route('sales.index'); return redirect()->route('sales.index');
} }
public function destroy(Sale $sale)
public function destroy(Sale $sale) { {
abort_if(Gate::denies('delete_sales'), 403); abort_if(Gate::denies('delete_sales'), 403);
$sale->delete(); $sale->delete();
toast('Sale Deleted!', 'warning'); toast('Sale Deleted!', 'warning');
return redirect()->route('sales.index'); return redirect()->route('sales.index');
} }
} }

View File

@ -46,6 +46,7 @@ class SalePaymentsController extends Controller
DB::transaction(function () use ($request) { DB::transaction(function () use ($request) {
SalePayment::create([ SalePayment::create([
'branch_id' => session('branch_id'), // tambahkan ini
'date' => $request->date, 'date' => $request->date,
'reference' => $request->reference, 'reference' => $request->reference,
'amount' => $request->amount, 'amount' => $request->amount,

View File

@ -16,9 +16,9 @@ class StorePosSaleRequest extends FormRequest
{ {
return [ return [
'customer_id' => 'required|numeric', 'customer_id' => 'required|numeric',
'tax_percentage' => 'required|integer|min:0|max:100', //'tax_percentage' => 'required|integer|min:0|max:100',
'discount_percentage' => 'required|integer|min:0|max:100', //'discount_percentage' => 'required|integer|min:0|max:100',
'shipping_amount' => 'required|numeric', //'shipping_amount' => 'required|numeric',
'total_amount' => 'required|numeric', 'total_amount' => 'required|numeric',
'paid_amount' => 'required|numeric', 'paid_amount' => 'required|numeric',
'note' => 'nullable|string|max:1000' 'note' => 'nullable|string|max:1000'

View File

@ -4,6 +4,7 @@ namespace App\Livewire\Pos;
use Gloudemans\Shoppingcart\Facades\Cart; use Gloudemans\Shoppingcart\Facades\Cart;
use Livewire\Component; use Livewire\Component;
use App\Models\ProductBatch;
class Checkout extends Component class Checkout extends Component
{ {
@ -69,25 +70,28 @@ class Checkout extends Component
if ($exists->isNotEmpty()) { if ($exists->isNotEmpty()) {
session()->flash('message', 'Product exists in the cart!'); session()->flash('message', 'Product exists in the cart!');
return; return;
} }
$branchId = session('active_branch') ?? 1;
$qty = 1;
$fifoPrice = ProductBatch::getFifoBatchPrice($product['id'], $branchId, $qty);
$cart->add([ $cart->add([
'id' => $product['id'], 'id' => $product['id'],
'name' => $product['product_name'], 'name' => $product['product_name'],
'qty' => 1, 'qty' => $qty,
'price' => $this->calculate($product)['price'], 'price' => $fifoPrice,
'weight' => 1, 'weight' => 1,
'options' => [ 'options' => [
'product_discount' => 0.00, 'product_discount' => 0.00,
'product_discount_type' => 'fixed', 'product_discount_type' => 'fixed',
'sub_total' => $this->calculate($product)['sub_total'], 'sub_total' => $fifoPrice,
'code' => $product['product_code'], 'code' => $product['product_code'],
'stock' => $product['product_quantity'], 'stock' => $product['product_quantity'],
'unit' => $product['product_unit'], 'unit' => $product['product_unit'],
'product_tax' => $this->calculate($product)['product_tax'], 'product_tax' => 0,
'unit_price' => $this->calculate($product)['unit_price'] 'unit_price' => $fifoPrice
] ]
]); ]);

View File

@ -6,6 +6,7 @@ use Gloudemans\Shoppingcart\Facades\Cart;
use Illuminate\Support\Facades\Request; use Illuminate\Support\Facades\Request;
use Livewire\Component; use Livewire\Component;
use Modules\Product\Entities\Product; use Modules\Product\Entities\Product;
use App\Models\ProductBatch;
class ProductCart extends Component class ProductCart extends Component
{ {
@ -43,7 +44,7 @@ class ProductCart extends Component
foreach ($cart_items as $cart_item) { foreach ($cart_items as $cart_item) {
$this->check_quantity[$cart_item->id] = [$cart_item->options->stock]; $this->check_quantity[$cart_item->id] = [$cart_item->options->stock];
$this->quantity[$cart_item->id] = $cart_item->qty; $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; $this->discount_type[$cart_item->id] = $cart_item->options->product_discount_type;
if ($cart_item->options->product_discount_type == 'fixed') { if ($cart_item->options->product_discount_type == 'fixed') {
$this->item_discount[$cart_item->id] = $cart_item->options->product_discount; $this->item_discount[$cart_item->id] = $cart_item->options->product_discount;

View File

@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Modules\Product\Entities\Product; use Modules\Product\Entities\Product;
use App\Models\Branch;
class ProductBatch extends Model class ProductBatch extends Model
{ {
@ -15,6 +16,7 @@ class ProductBatch extends Model
'batch_code', 'batch_code',
'qty', 'qty',
'unit_price', 'unit_price',
'price',
'exp_date', 'exp_date',
'purchase_id', 'purchase_id',
'created_by', 'created_by',
@ -24,6 +26,7 @@ class ProductBatch extends Model
protected $casts = [ protected $casts = [
'exp_date' => 'date', 'exp_date' => 'date',
'unit_price' => 'decimal:2', 'unit_price' => 'decimal:2',
'price' => 'decimal:2',
]; ];
public function product(): BelongsTo public function product(): BelongsTo
@ -77,7 +80,8 @@ class ProductBatch extends Model
'batch_id' => $batch->id, 'batch_id' => $batch->id,
'qty' => $deductAmount, 'qty' => $deductAmount,
'unit_price' => $batch->unit_price, 'unit_price' => $batch->unit_price,
'exp_date' => $batch->exp_date 'price' => $batch->price,
'exp_date' => $batch->exp_date,
]; ];
$remainingQuantity -= $deductAmount; $remainingQuantity -= $deductAmount;
@ -108,10 +112,40 @@ class ProductBatch extends Model
} }
// Generate batch code if not provided // Generate batch code if not provided
if (!isset($data['batch_code'])) { if (empty($data['batch_code'])) {
$data['batch_code'] = self::generateBatchCode(); $data['batch_code'] = self::generateBatchCode();
} }
return self::create($data); return self::create($data);
} }
/**
* 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);
}
} }

View File

@ -1,3 +1,7 @@
@php
use Modules\Branch\Entities\Branch;
$branch = Branch::find(session('branch_id'));
@endphp
<button class="c-header-toggler c-class-toggler d-lg-none mfe-auto" type="button" data-target="#sidebar" data-class="c-sidebar-show"> <button class="c-header-toggler c-class-toggler d-lg-none mfe-auto" type="button" data-target="#sidebar" data-class="c-sidebar-show">
<i class="bi bi-list" style="font-size: 2rem;"></i> <i class="bi bi-list" style="font-size: 2rem;"></i>
</button> </button>
@ -8,6 +12,102 @@
<ul class="c-header-nav ml-auto"> <ul class="c-header-nav ml-auto">
</ul>
<ul class="c-header-nav ml-auto mr-4">
@can('access_branches')
<li class="c-header-nav-item mr-3">
<div class="dropdown">
<button class="btn btn-outline-primary btn-pill dropdown-toggle" type="button" id="branchDropdown" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<i class="bi bi-building mr-1"></i>
{{ $branch ? $branch->name : 'Pilih Cabang' }}
</button>
<div class="dropdown-menu" aria-labelledby="branchDropdown">
@foreach(Branch::all() as $b)
<a class="dropdown-item {{ session('branch_id') == $b->id ? 'active' : '' }}"
href="{{ route('branch.switch', $b->id) }}">
{{ $b->name }}
</a>
@endforeach
</div>
</div>
</li>
@endcan
@can('create_pos_sales')
<li class="c-header-nav-item mr-3">
<a class="btn btn-primary btn-pill {{ request()->routeIs('app.pos.index') ? 'disabled' : '' }}" href="{{ route('app.pos.index') }}">
<i class="bi bi-cart mr-1"></i> POS System
</a>
</li>
@endcan
@can('show_notifications')
<li class="c-header-nav-item dropdown d-md-down-none mr-2">
<a class="c-header-nav-link" data-toggle="dropdown" href="#" role="button" aria-haspopup="true" aria-expanded="false">
<i class="bi bi-bell" style="font-size: 20px;"></i>
<span class="badge badge-pill badge-danger">
@php
$low_quantity_products = \Modules\Product\Entities\Product::select('id', 'product_code', 'product_stock_alert')
->withSum('batches', 'qty', 'batches_sum_qty')
->having('batches_sum_qty', '<=', DB::raw('product_stock_alert'))
->get();
echo $low_quantity_products->count();
@endphp
</span>
</a>
<div class="dropdown-menu dropdown-menu-right dropdown-menu-lg pt-0">
<div class="dropdown-header bg-light">
<strong>{{ $low_quantity_products->count() }} Notifications</strong>
</div>
@forelse($low_quantity_products as $product)
<a class="dropdown-item" href="{{ route('products.show', $product->id) }}">
<i class="bi bi-hash mr-1 text-primary"></i> Product: "{{ $product->product_code }}" is low in quantity!
</a>
@empty
<a class="dropdown-item" href="#">
<i class="bi bi-app-indicator mr-2 text-danger"></i> No notifications available.
</a>
@endforelse
</div>
</li>
@endcan
<li class="c-header-nav-item dropdown">
<a class="c-header-nav-link" data-toggle="dropdown" href="#" role="button"
aria-haspopup="true" aria-expanded="false">
<div class="c-avatar mr-2">
<img class="c-avatar rounded-circle" src="{{ auth()->user()->getFirstMediaUrl('avatars') }}" alt="Profile Image">
</div>
<div class="d-flex flex-column">
<span class="font-weight-bold">{{ auth()->user()->name }}</span>
<span class="font-italic">Online <i class="bi bi-circle-fill text-success" style="font-size: 11px;"></i></span>
</div>
</a>
<div class="dropdown-menu dropdown-menu-right pt-0">
<div class="dropdown-header bg-light py-2"><strong>Account</strong></div>
<a class="dropdown-item" href="{{ route('profile.edit') }}">
<i class="mfe-2 bi bi-person" style="font-size: 1.2rem;"></i> Profile
</a>
<a class="dropdown-item" href="#" onclick="event.preventDefault(); document.getElementById('logout-form').submit();">
<i class="mfe-2 bi bi-box-arrow-left" style="font-size: 1.2rem;"></i> Logout
</a>
<form id="logout-form" action="{{ route('logout') }}" method="POST" class="d-none">
@csrf
</form>
</div>
</li>
</ul>
{{-- <button class="c-header-toggler c-class-toggler d-lg-none mfe-auto" type="button" data-target="#sidebar" data-class="c-sidebar-show">
<i class="bi bi-list" style="font-size: 2rem;"></i>
</button>
<button class="c-header-toggler c-class-toggler mfs-3 d-md-down-none" type="button" data-target="#sidebar" data-class="c-sidebar-lg-show" responsive="true">
<i class="bi bi-list" style="font-size: 2rem;"></i>
</button>
<ul class="c-header-nav ml-auto">
</ul> </ul>
<ul class="c-header-nav ml-auto mr-4"> <ul class="c-header-nav ml-auto mr-4">
@can('access_branches') @can('access_branches')
@ -91,4 +191,4 @@
</form> </form>
</div> </div>
</li> </li>
</ul> </ul> --}}

View File

@ -22,7 +22,9 @@
{{ $product->product_code }} {{ $product->product_code }}
</span> </span>
</div> </div>
<p class="card-text font-weight-bold">{{ format_currency($product->product_price) }}</p> <p class="card-text font-weight-bold">
{{ format_currency($product->fifo_price ?? 0) }}
</p>
</div> </div>
</div> </div>
</div> </div>