refactor(purchase): merubah total alur belanja, yang awalnya langsung masuk ke stok yang ada di outlet, sekarang masuk ke gudang terleih dahulu

This commit is contained in:
Yoga Pangestu 2026-01-01 15:26:05 +07:00
parent 8144b8bace
commit a2efc0f349
11 changed files with 181 additions and 77 deletions

View File

@ -25,7 +25,7 @@ class PurchasesTable extends DataTableComponent
public function columns(): array
{
return [
Column::make('Outlet', 'outlet.name')->searchable(),
Column::make('Gudang', 'warehouse.name')->searchable(),
Column::make('Nomor Invoice', 'invoice_number')->searchable(),
@ -73,14 +73,13 @@ public function columns(): array
return $actions;
})
->html()
->hideIf(auth()->user()->cannot('update purchase')),
->hideIf(auth()->user()->cannot('delete purchase')),
];
}
public function builder(): Builder
{
return Purchase::select('purchases.id', 'outlet_id', 'invoice_number', 'purchase_date', 'total')
->with(['outlet', 'items', 'items.purchasable'])
->whereIn('outlet_id', auth()->user()->outlets->pluck('id')->toArray());
return Purchase::select('purchases.id', 'warehouse_id', 'invoice_number', 'purchase_date', 'total')
->with(['warehouse', 'items', 'items.purchasable']);
}
}

View File

@ -6,14 +6,14 @@
use App\Rules\UnsignedInteger;
use App\Traits\Media\WithMediaHandler;
use App\Traits\Purchase\WithPurchaseItems;
use App\Traits\Purchase\WithUpdateStock;
use App\Traits\Purchase\WithWarehouseStock;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Livewire\Form;
class PurchaseForm extends Form
{
use WithMediaHandler, WithPurchaseItems, WithUpdateStock;
use WithMediaHandler, WithPurchaseItems, WithWarehouseStock;
public ?Purchase $purchase = null;
@ -25,7 +25,7 @@ class PurchaseForm extends Form
public string $total = '';
public string $outlet_id = '';
public string $warehouse_id = '';
public array $image = [];
@ -51,7 +51,7 @@ public function rules(): array
'invoice_number' => ['nullable', 'string', 'max:50'],
'note' => ['nullable', 'string', 'max:100'],
'purchase_date' => ['required', 'date', 'before_or_equal:today'],
'outlet_id' => ['required', Rule::exists('outlets', 'id')],
'warehouse_id' => ['required', Rule::exists('warehouses', 'id')],
'total' => ['required', new UnsignedInteger],
'image' => ['nullable', 'array', 'max:1'],
];
@ -63,7 +63,7 @@ public function validationAttributes(): array
'invoice_number' => 'nomor invoice',
'note' => 'catatan',
'purchase_date' => 'tanggal belanja',
'outlet_id' => 'outlet',
'warehouse_id' => 'gudang',
'image' => 'gambar',
];
}
@ -75,7 +75,7 @@ public function setPurchase(Purchase $purchase): void
$this->invoice_number = $purchase->invoice_number;
$this->note = $purchase->note;
$this->purchase_date = $purchase->purchase_date;
$this->outlet_id = $purchase->outlet_id;
$this->warehouse_id = $purchase->warehouse_id;
$this->total = $purchase->total;
$this->image = $this->mapMediaCollection($purchase->getMedia('image'));
@ -101,7 +101,7 @@ public function store(): void
$purchase = Purchase::create($data);
$purchase->load('outlet');
$purchase->load('warehouse');
$this->uploadMedia($this->image, $purchase, 'image');
@ -113,36 +113,17 @@ public function store(): void
$item->update(['purchase_id' => $purchase->id]);
$this->increaseOutletStock($purchase->outlet, $item);
$this->increaseWarehouseStock($purchase->warehouse, $item);
}
});
}
public function update(): void
{
$this->validate();
// 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');
});
}
private function prepareSavedData(): array
{
return [
'invoice_number' => $this->invoice_number,
'note' => $this->note,
'outlet_id' => $this->outlet_id,
'warehouse_id' => $this->warehouse_id,
'purchase_date' => $this->purchase_date,
'total' => parseRupiahToInt($this->total),
];

View File

@ -6,6 +6,7 @@
use App\Models\Bottle;
use App\Models\Perfume;
use App\Models\Product;
use App\Models\Warehouse as WarehouseModel;
use App\Traits\Authorization\WithAuthorization;
use App\Traits\Components\WithConfirmation;
use App\Traits\Components\WithToast;
@ -26,7 +27,7 @@ class Create extends Component
public PurchaseForm $form;
public array $outlets = [];
public array $warehouses = [];
public array $perfumes = [];
@ -38,13 +39,13 @@ class Create extends Component
public function mount(): void
{
$this->outlets = auth()->user()->outlets->pluck('name', 'id')->toArray();
$this->warehouses = WarehouseModel::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->perfumes = Perfume::orderBy('name')->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->bottles = Bottle::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->products = Product::orderBy('name')->pluck('name', 'id')->toArray();
$this->purchaseItems = $this->loadPurchaseItems();
@ -68,13 +69,6 @@ public function save(): void
], navigate: true);
}
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

@ -27,7 +27,7 @@ public function delete(Purchase $purchase): void
DB::transaction(function () use ($purchase) {
foreach ($purchase->items as $item) {
$this->decreaseOutletStock($purchase->outlet, $item);
$this->decreaseWarehouseStock($purchase->warehouse, $item);
}
$purchase->delete();

View File

@ -38,9 +38,9 @@ public function yesterday(Builder $query): void
$query->whereDate('created_at', today()->subDay());
}
public function outlet(): BelongsTo
public function warehouse(): BelongsTo
{
return $this->belongsTo(Outlet::class);
return $this->belongsTo(Warehouse::class);
}
public function items(): HasMany

View File

@ -9,7 +9,7 @@
trait WithUpdateStock
{
public function increaseOutletStock($outlet, $item): void
public function increaseWarehouseStock($warehouse, $item): void
{
$quantity = $item->quantity;
@ -28,7 +28,7 @@ public function increaseOutletStock($outlet, $item): void
}
// Lock the row to prevent race conditions
$existing = $outlet->{$relation}()
$existing = $warehouse->{$relation}()
->where("{$relation}.id", $item->purchasable_id)
->lockForUpdate()
->withPivot('stock')
@ -36,22 +36,22 @@ public function increaseOutletStock($outlet, $item): void
if ($existing) {
$previousStock = $existing->pivot->stock ?? 0;
$outlet->{$relation}()->updateExistingPivot($item->purchasable_id, [
$warehouse->{$relation}()->updateExistingPivot($item->purchasable_id, [
'stock' => $previousStock + $quantity,
]);
} else {
$previousStock = 0;
$outlet->{$relation}()->attach($item->purchasable_id, [
$warehouse->{$relation}()->attach($item->purchasable_id, [
'stock' => $quantity,
]);
}
StockActivityLogService::log(
StockActivityLogService::logWarehouse(
logName: 'Belanja',
event: 'Menambah',
description: 'Stok '.$item->purchasable->name.' di '.$outlet->name.' bertambah '.formatCurrencyNumber($quantity),
description: 'Stok '.$item->purchasable->name.' di '.$warehouse->name.' bertambah '.formatCurrencyNumber($quantity),
performedOn: $item->purchasable,
outlet: $outlet,
warehouse: $warehouse,
quantityChange: $quantity,
previousStock: $previousStock,
newStock: $previousStock + $quantity,
@ -63,7 +63,7 @@ public function increaseOutletStock($outlet, $item): void
);
}
public function decreaseOutletStock($outlet, $item): void
public function decreaseWarehouseStock($warehouse, $item): void
{
$quantity = $item->quantity;
@ -81,31 +81,32 @@ public function decreaseOutletStock($outlet, $item): void
return;
}
$existing = $outlet->{$relation}()
$existing = $warehouse->{$relation}()
->where("{$relation}.id", $item->purchasable_id)
->lockForUpdate()
->withPivot('stock')
->first();
$currentStock = $existing?->pivot->stock ?? 0;
$newStock = max(0, $currentStock - $quantity);
if ($existing) {
$currentStock = $existing->pivot->stock ?? 0;
$newStock = max(0, $currentStock - $quantity);
$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;
if ($newStock === 0) {
$warehouse->{$relation}()->detach($item->purchasable_id);
} else {
$warehouse->{$relation}()->updateExistingPivot(
$item->purchasable_id,
['stock' => $newStock]
);
}
}
StockActivityLogService::log(
StockActivityLogService::logWarehouse(
logName: 'Belanja',
event: 'Mengurangi',
description: 'Stok '.$item->purchasable->name.' di '.$outlet->name.' berkurang '.$quantity,
description: 'Stok '.$item->purchasable->name.' di '.$warehouse->name.' berkurang '.$quantity,
performedOn: $item->purchasable,
outlet: $outlet,
warehouse: $warehouse,
quantityChange: $quantity,
previousStock: $currentStock,
newStock: $newStock,

View File

@ -0,0 +1,129 @@
<?php
namespace App\Traits\Purchase;
use App\Models\Bottle;
use App\Models\Perfume;
use App\Models\Product;
use App\Services\StockActivityLogService;
trait WithWarehouseStock
{
public function increaseWarehouseStock($warehouse, $item): void
{
$quantity = $item->quantity;
switch ($item->purchasable_type) {
case Perfume::class:
$relation = 'perfumes';
break;
case Product::class:
$relation = 'products';
break;
case Bottle::class:
$relation = 'bottles';
break;
default:
return;
}
// Lock the row to prevent race conditions
$existing = $warehouse->{$relation}()
->where("{$relation}.id", $item->purchasable_id)
->lockForUpdate()
->withPivot('stock')
->first();
if ($existing) {
$previousStock = $existing->pivot->stock ?? 0;
$warehouse->{$relation}()->updateExistingPivot($item->purchasable_id, [
'stock' => $previousStock + $quantity,
]);
} else {
$previousStock = 0;
$warehouse->{$relation}()->attach($item->purchasable_id, [
'stock' => $quantity,
]);
}
$purchase = $item->purchase ?? \App\Models\Purchase::find($item->purchase_id);
if ($purchase) {
StockActivityLogService::purchase(
purchase: $purchase,
product: $item->purchasable,
quantity: $quantity,
previousStock: $previousStock,
newStock: $previousStock + $quantity
);
} else {
StockActivityLogService::logWarehouse(
logName: StockActivityLogService::LOG_PURCHASE,
event: 'increase',
description: 'Stok '.$item->purchasable->name.' di gudang '.$warehouse->name.' bertambah '.formatCurrencyNumber($quantity),
performedOn: $item->purchasable,
warehouse: $warehouse,
quantityChange: $quantity,
previousStock: $previousStock,
newStock: $previousStock + $quantity,
extraProperties: [
'purchasable_id' => $item->purchasable_id,
'purchasable_type' => $item->purchasable_type,
'purchase_id' => $item->purchase_id ?? null,
]
);
}
}
public function decreaseWarehouseStock($warehouse, $item): void
{
$quantity = $item->quantity;
switch ($item->purchasable_type) {
case Perfume::class:
$relation = 'perfumes';
break;
case Product::class:
$relation = 'products';
break;
case Bottle::class:
$relation = 'bottles';
break;
default:
return;
}
$existing = $warehouse->{$relation}()
->where("{$relation}.id", $item->purchasable_id)
->lockForUpdate()
->withPivot('stock')
->first();
if ($existing) {
$currentStock = $existing->pivot->stock ?? 0;
$newStock = max(0, $currentStock - $quantity);
$warehouse->{$relation}()->updateExistingPivot($item->purchasable_id, [
'stock' => $newStock,
]);
} else {
$currentStock = 0;
$newStock = 0;
}
StockActivityLogService::logWarehouse(
logName: StockActivityLogService::LOG_PURCHASE,
event: 'decrease',
description: 'Stok '.$item->purchasable->name.' di gudang '.$warehouse->name.' berkurang '.$quantity,
performedOn: $item->purchasable,
warehouse: $warehouse,
quantityChange: -1 * abs($quantity),
previousStock: $currentStock,
newStock: $newStock,
extraProperties: [
'purchasable_id' => $item->purchasable_id,
'purchasable_type' => $item->purchasable_type,
'purchase_id' => $item->purchase_id,
]
);
}
}

View File

@ -13,7 +13,7 @@ public function up(): void
{
Schema::create('purchases', function (Blueprint $table) {
$table->id();
$table->foreignId('outlet_id')->constrained()->cascadeOnDelete();
$table->foreignId('warehouse_id')->constrained()->cascadeOnDelete();
$table->string('invoice_number', 50)->nullable();
$table->date('purchase_date');
$table->unsignedInteger('total');

View File

@ -48,15 +48,15 @@
</flux:field>
<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.live="form.outlet_id">
@foreach ($outlets as $key => $value)
<flux:label>Gudang <span class="text-red-500 ms-1">*</span></flux:label>
<flux:select variant="listbox" searchable placeholder="Pilih Gudang"
wire:model.live="form.warehouse_id">
@foreach ($warehouses as $key => $value)
<flux:select.option value="{{ $key }}">{{ $value }}
</flux:select.option>
@endforeach
</flux:select>
<flux:error name="form.outlet_id" />
<flux:error name="form.warehouse_id" />
</flux:field>
</div>
</flux:card>

View File

@ -17,7 +17,7 @@ class="text-sm">
<flux:callout icon="megaphone" color="blue" class="mb-6">
<flux:callout.heading>Informasi</flux:callout.heading>
<flux:callout.text>
Menghapus data belanja otomatis akan menambah stok.
Menghapus data belanja otomatis menghapus stok dari gudang.
</flux:callout.text>
</flux:callout>