feat: Implement dynamic product filtering by outlet, enforce purchase total integrity, add concurrency locks for stock updates, and improve item management.

This commit is contained in:
Yoga Pangestu 2025-12-16 19:34:34 +07:00
parent 7495578b57
commit 14e79054c0
8 changed files with 79 additions and 18 deletions

View File

@ -87,14 +87,30 @@ public function store(): void
$purchaseItems = $this->loadPurchaseItems();
DB::transaction(function () use ($purchaseItems) {
$purchase = Purchase::create($this->prepareSavedData());
// Recalculate total to ensure data integrity
$calculatedTotal = $purchaseItems->sum(function ($item) {
return $item->unit_price * $item->quantity;
});
// Override the total input with the calculated one to prevent mismatches
$this->total = formatCurrencyNumber($calculatedTotal);
DB::transaction(function () use ($purchaseItems, $calculatedTotal) {
$data = $this->prepareSavedData();
$data['total'] = $calculatedTotal; // Use strict calculated total
$purchase = Purchase::create($data);
$purchase->load('outlet');
$this->uploadMedia($this->image, $purchase, 'image');
foreach ($purchaseItems as $item) {
// Ensure item integrity before linking
if ($item->quantity <= 0 || $item->unit_price <= 0) {
continue; // Or throw exception
}
$item->update(['purchase_id' => $purchase->id]);
$this->increaseOutletStock($purchase->outlet, $item);
@ -106,8 +122,15 @@ public function update(): void
{
$this->validate();
DB::transaction(function () {
$this->purchase->update($this->prepareSavedData());
// Ensure total calculation integrity from existing items
$calculatedTotal = $this->purchase->items()->sum(DB::raw('unit_price * quantity'));
$this->total = formatCurrencyNumber($calculatedTotal);
DB::transaction(function () use ($calculatedTotal) {
$data = $this->prepareSavedData();
$data['total'] = $calculatedTotal; // Enforce calculated total
$this->purchase->update($data);
$this->syncMedia($this->image, $this->purchase, 'image');
$this->uploadMedia($this->image, $this->purchase, 'image');

View File

@ -40,11 +40,11 @@ public function mount(): void
{
$this->outlets = auth()->user()->outlets->pluck('name', 'id')->toArray();
$this->perfumes = Perfume::orderBy('name')->pluck('name', 'id')->toArray();
$this->perfumes = Perfume::whereHas('outlets', fn ($query) => $query->where('outlets.id', $this->form->outlet_id))->orderBy('name')->pluck('name', 'id')->toArray();
$this->bottles = Bottle::orderBy('size')->pluck('name', 'id')->toArray();
$this->bottles = Bottle::whereHas('outlets', fn ($query) => $query->where('outlets.id', $this->form->outlet_id))->orderBy('size')->pluck('name', 'id')->toArray();
$this->products = Product::orderBy('name')->pluck('name', 'id')->toArray();
$this->products = Product::whereHas('outlets', fn ($query) => $query->where('outlets.id', $this->form->outlet_id))->orderBy('name')->pluck('name', 'id')->toArray();
$this->purchaseItems = $this->loadPurchaseItems();
@ -70,6 +70,13 @@ public function save(): void
$this->redirectRoute('studio.manage.purchase.index');
}
public function updatedFormOutletId($value): void
{
$this->perfumes = Perfume::whereHas('outlets', fn ($query) => $query->where('outlets.id', $value))->orderBy('name')->pluck('name', 'id')->toArray();
$this->bottles = Bottle::whereHas('outlets', fn ($query) => $query->where('outlets.id', $value))->orderBy('size')->pluck('name', 'id')->toArray();
$this->products = Product::whereHas('outlets', fn ($query) => $query->where('outlets.id', $value))->orderBy('name')->pluck('name', 'id')->toArray();
}
public function render(): View
{
return view('livewire.studio.manage.purchase.form', [

View File

@ -11,6 +11,7 @@
use App\Traits\Purchase\WithUpdateStock;
use Flux\Flux;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Title;
use Livewire\Component;
@ -23,11 +24,13 @@ public function delete(Purchase $purchase): void
{
$this->canOrAbort('delete purchase');
foreach ($purchase->items as $item) {
$this->decreaseOutletStock($purchase->outlet, $item);
}
DB::transaction(function () use ($purchase) {
foreach ($purchase->items as $item) {
$this->decreaseOutletStock($purchase->outlet, $item);
}
$purchase->delete();
$purchase->delete();
});
$this->dispatch('refreshDatatable');

View File

@ -84,7 +84,7 @@ public function addItem(string $type): void
'total_price' => $costPrice * $quantity,
]);
$this->purchaseItems->push($item);
$this->purchaseItems->prepend($item);
$this->toast(Str::ucfirst($type).' ditambahkan ke keranjang.', 'Berhasil');
}

View File

@ -27,11 +27,25 @@ public function closeModal()
public function updateItem(): void
{
$quantity = parseRupiahToInt($this->form->quantity_edit);
if ($quantity <= 0) {
$this->toast('Jumlah item tidak valid.', 'Gagal', 'danger');
return;
}
$item = PurchaseItem::find($this->form->item_id);
if (! $item) {
$this->toast('Item tidak found.', 'Gagal', 'danger');
return;
}
$item->update([
'quantity' => parseRupiahToInt($this->form->quantity_edit),
'total_price' => (int) $item->purchasable->cost_price * (int) parseRupiahToInt($this->form->quantity_edit),
'quantity' => $quantity,
'total_price' => (int) $item->purchasable->cost_price * $quantity,
]);
$item->refresh();

View File

@ -27,7 +27,12 @@ public function increaseOutletStock($outlet, $item): void
return;
}
$existing = $outlet->{$relation}()->withPivot('stock')->where("{$relation}.id", $item->purchasable_id)->first();
// Lock the row to prevent race conditions
$existing = $outlet->{$relation}()
->where("{$relation}.id", $item->purchasable_id)
->lockForUpdate()
->withPivot('stock')
->first();
if ($existing) {
$previousStock = $existing->pivot->stock ?? 0;
@ -76,7 +81,11 @@ public function decreaseOutletStock($outlet, $item): void
return;
}
$existing = $outlet->{$relation}()->withPivot('stock')->where("{$relation}.id", $item->purchasable_id)->first();
$existing = $outlet->{$relation}()
->where("{$relation}.id", $item->purchasable_id)
->lockForUpdate()
->withPivot('stock')
->first();
if ($existing) {
$currentStock = $existing->pivot->stock ?? 0;
@ -84,6 +93,11 @@ public function decreaseOutletStock($outlet, $item): void
$outlet->{$relation}()->updateExistingPivot($item->purchasable_id, [
'stock' => $newStock,
]);
} else {
// Handle case where stock doesn't exist but we are decreasing?
// Should not happen in normal flow, but good to handle.
$currentStock = 0;
$newStock = 0;
}
StockActivityLogService::log(

View File

@ -15,7 +15,7 @@
<div class="grid grid-cols-1 md:grid-cols-4 gap-6">
<flux:field>
<flux:label>Outlet <span class="text-red-500 ms-1">*</span></flux:label>
<flux:select variant="listbox" searchable placeholder="Pilih Outlet" wire:model="form.outlet_id">
<flux:select variant="listbox" searchable placeholder="Pilih Outlet" wire:model.live="form.outlet_id">
@foreach ($outlets as $key => $value)
<flux:select.option value="{{ $key }}">
{{ $value }}

View File

@ -50,7 +50,7 @@
<flux:field>
<flux:label>Outlet <span class="text-red-500 ms-1">*</span></flux:label>
<flux:select variant="listbox" searchable placeholder="Pilih Outlet"
wire:model="form.outlet_id">
wire:model.live="form.outlet_id">
@foreach ($outlets as $key => $value)
<flux:select.option value="{{ $key }}">{{ $value }}
</flux:select.option>