feat: add stock transfer management functionality

- Implemented stock transfer creation and management with Livewire components.
- Created StockTransfer and StockTransferItem models with necessary relationships.
- Added migration files for stock_transfers and stock_transfer_items tables.
- Developed traits for handling stock transfer items (add, delete, update).
- Integrated stock transfer logging in StockActivityLogService.
- Updated ViewServiceProvider to include stock transfer permissions and routes.
- Created frontend views for stock transfer form and index with appropriate UI components.
- Added role permissions for stock transfer actions in RolePermissionSeeder.
This commit is contained in:
Yoga Pangestu 2026-01-09 21:41:49 +07:00
parent 156eca433c
commit dffc9f9e72
19 changed files with 1281 additions and 0 deletions

View File

@ -0,0 +1,78 @@
<?php
namespace App\Livewire\Datatable\Manage;
use App\Models\StockTransfer;
use App\Traits\Datatable\WithAppendColumn;
use App\Traits\Datatable\WithConfiguration;
use App\Traits\Datatable\WithPrependColumn;
use Illuminate\Database\Eloquent\Builder;
use Rappasoft\LaravelLivewireTables\DataTableComponent;
use Rappasoft\LaravelLivewireTables\Views\Column;
use Rappasoft\LaravelLivewireTables\Views\Columns\ArrayColumn;
class StockTransfersTable extends DataTableComponent
{
use WithAppendColumn, WithConfiguration, WithPrependColumn;
protected $model = StockTransfer::class;
protected string $sortColumn = 'transfer_date';
protected string $sortDirection = 'desc';
public function columns(): array
{
return [
Column::make('Outlet Asal', 'sourceOutlet.name')->searchable(),
Column::make('Outlet Tujuan', 'destinationOutlet.name')->searchable(),
Column::make('Tanggal Transfer', 'transfer_date')
->format(fn ($value) => formatDateLocalized($value))
->searchable()
->sortable(),
Column::make('Catatan', 'note')->searchable(),
ArrayColumn::make('Item')
->data(
fn ($value, $row) => $row->items->map(fn ($item) => [
'name' => $item->transferable?->name,
'quantity' => formatCurrencyNumber($item->quantity, ''),
])->toArray()
)
->outputFormat(
fn ($index, $value) => "
<div class='text-[13px] leading-tight mb-1'>
<div class='font-bold text-gray-800 dark:text-white'>{$value['name']}</div>
<div class='text-gray-400 text-[12px]'>Qty: {$value['quantity']}</div>
</div>"
)
->flexCol(['class' => 'flex-col gap-3']),
Column::make('Aksi')
->label(function ($row) {
$actions = '';
if (auth()->user()->can('delete stock transfer')) {
$actions .= view('components.actions.table.delete', [
'id' => $row->hash,
])->render();
}
return $actions;
})
->html()
->hideIf(auth()->user()->cannot('update stock transfer')),
];
}
public function builder(): Builder
{
return StockTransfer::select('stock_transfers.id', 'source_outlet_id', 'destination_outlet_id', 'transfer_date', 'note')
->with(['sourceOutlet', 'destinationOutlet', 'items', 'items.transferable'])
->whereIn('source_outlet_id', auth()->user()->outlets->pluck('id')->toArray())
->orWhereIn('destination_outlet_id', auth()->user()->outlets->pluck('id')->toArray());
}
}

View File

@ -0,0 +1,129 @@
<?php
namespace App\Livewire\Forms\Studio\Manage;
use App\Models\StockTransfer;
use App\Traits\Media\WithMediaHandler;
use App\Traits\StockTransfer\WithStockTransferItems;
use App\Traits\StockTransfer\WithStockTransferStock;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Livewire\Form;
class StockTransferForm extends Form
{
use WithMediaHandler, WithStockTransferItems, WithStockTransferStock;
public ?StockTransfer $stockTransfer = null;
public ?string $note = null;
public string $transfer_date = '';
public string $source_outlet_id = '';
public string $destination_outlet_id = '';
public array $image = [];
public string $perfume_id = '';
public string $quantity_perfume = '';
public string $product_id = '';
public string $quantity_product = '';
public string $bottle_id = '';
public string $quantity_bottle = '';
public string $item_id = '';
public string $quantity_edit = '';
public function rules(): array
{
return [
'note' => ['nullable', 'string', 'max:100'],
'transfer_date' => ['required', 'date', 'before_or_equal:today'],
'source_outlet_id' => ['required', Rule::exists('outlets', 'id')],
'destination_outlet_id' => ['required', Rule::exists('outlets', 'id'), 'different:source_outlet_id'],
'image' => ['nullable', 'array', 'max:1'],
];
}
public function validationAttributes(): array
{
return [
'note' => 'catatan',
'transfer_date' => 'tanggal transfer',
'source_outlet_id' => 'outlet asal',
'destination_outlet_id' => 'outlet tujuan',
'image' => 'gambar',
];
}
public function setStockTransfer(StockTransfer $stockTransfer): void
{
$this->stockTransfer = $stockTransfer;
$this->note = $stockTransfer->note;
$this->transfer_date = $stockTransfer->transfer_date;
$this->source_outlet_id = $stockTransfer->source_outlet_id;
$this->destination_outlet_id = $stockTransfer->destination_outlet_id;
$this->image = $this->mapMediaCollection($stockTransfer->getMedia('image'));
}
public function store(): void
{
$this->validate();
$stockTransferItems = $this->loadStockTransferItems();
DB::transaction(function () use ($stockTransferItems) {
$data = $this->prepareSavedData();
$stockTransfer = StockTransfer::create($data);
$stockTransfer->load(['sourceOutlet', 'destinationOutlet']);
$this->uploadMedia($this->image, $stockTransfer, 'image');
foreach ($stockTransferItems as $item) {
if ($item->quantity <= 0) {
continue;
}
$item->update(['stock_transfer_id' => $stockTransfer->id]);
$this->transferStockBetweenOutlets($stockTransfer->sourceOutlet, $stockTransfer->destinationOutlet, $item);
}
});
}
public function update(): void
{
$this->validate();
DB::transaction(function () {
$data = $this->prepareSavedData();
$this->stockTransfer->update($data);
$this->syncMedia($this->image, $this->stockTransfer, 'image');
$this->uploadMedia($this->image, $this->stockTransfer, 'image');
});
}
private function prepareSavedData(): array
{
return [
'note' => $this->note,
'source_outlet_id' => $this->source_outlet_id,
'destination_outlet_id' => $this->destination_outlet_id,
'transfer_date' => $this->transfer_date,
];
}
}

View File

@ -0,0 +1,130 @@
<?php
namespace App\Livewire\Studio\Manage\StockTransfer;
use App\Livewire\Forms\Studio\Manage\StockTransferForm;
use App\Models\Outlet;
use App\Traits\Authorization\WithAuthorization;
use App\Traits\Components\WithConfirmation;
use App\Traits\Components\WithToast;
use App\Traits\StockTransfer\WithStockTransferAddItem;
use App\Traits\StockTransfer\WithStockTransferDeleteItem;
use App\Traits\StockTransfer\WithStockTransferItems;
use App\Traits\StockTransfer\WithStockTransferUpdateItem;
use App\Traits\Utilities\WithUpdatedData;
use Illuminate\Contracts\View\View;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Title('Tambah Transfer Stok Antar Outlet')]
class Create extends Component
{
use WithAuthorization, WithConfirmation, WithStockTransferAddItem, WithStockTransferDeleteItem, WithStockTransferItems, WithStockTransferUpdateItem, WithToast, WithUpdatedData;
public StockTransferForm $form;
public array $sourceOutlets = [];
public array $destinationOutlets = [];
public array $perfumes = [];
public array $products = [];
public array $bottles = [];
public $stockTransferItems;
public function mount(): void
{
$this->sourceOutlets = auth()->user()->outlets->pluck('name', 'id')->toArray();
$this->destinationOutlets = Outlet::orderBy('name')->pluck('name', 'id')->toArray();
$this->stockTransferItems = $this->loadStockTransferItems();
}
public function updated($property, $value): void
{
if ($property === 'form.source_outlet_id' || $property === 'form.destination_outlet_id') {
$this->loadCreateItems();
}
}
private function loadCreateItems(): void
{
$this->reset(['perfumes', 'products', 'bottles']);
$this->form->reset(
'perfume_id',
'quantity_perfume',
'product_id',
'quantity_product',
'bottle_id',
'quantity_bottle'
);
if (! $this->form->source_outlet_id || ! $this->form->destination_outlet_id) {
return;
}
$sourceOutlet = Outlet::find($this->form->source_outlet_id);
if (! $sourceOutlet) {
return;
}
$this->perfumes = $sourceOutlet->perfumes()
->wherePivot('stock', '>', 0)
->orderBy('name')
->get()
->pluck('name', 'id')
->toArray();
$this->products = $sourceOutlet->products()
->wherePivot('stock', '>', 0)
->orderBy('name')
->get()
->pluck('name', 'id')
->toArray();
$this->bottles = $sourceOutlet->bottles()
->wherePivot('stock', '>', 0)
->orderBy('name')
->get()
->pluck('name', 'id')
->toArray();
}
public function save(): void
{
$this->canOrAbort('create stock transfer');
if ($this->stockTransferItems->isEmpty()) {
$this->toast('Keranjang transfer stok tidak boleh kosong.', 'Gagal', 'danger');
return;
}
try {
$this->form->store();
} catch (ValidationException $e) {
throw $e;
} catch (\Exception $e) {
$this->toast($e->getMessage(), 'Gagal', 'danger');
return;
}
$this->redirectRoute('studio.manage.stock_transfer.index', [
'notification' => 'Transfer stok berhasil diproses.',
], navigate: true);
}
public function render(): View
{
return view('livewire.studio.manage.stock-transfer.form', [
'pageTitle' => 'Tambah Transfer Stok Antar Outlet',
]);
}
}

View File

@ -0,0 +1,49 @@
<?php
namespace App\Livewire\Studio\Manage\StockTransfer;
use App\Models\StockTransfer;
use App\Traits\Authorization\WithAuthorization;
use App\Traits\Components\WithCloseModal;
use App\Traits\Components\WithConfirmation;
use App\Traits\Components\WithToast;
use App\Traits\Notification\WithSubscribeNotification;
use App\Traits\StockTransfer\WithStockTransferStock;
use App\Traits\Utilities\WithRequestNotification;
use Flux\Flux;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Title('Transfer Stok')]
class Index extends Component
{
use WithAuthorization, WithCloseModal, WithConfirmation, WithRequestNotification, WithStockTransferStock, WithSubscribeNotification, WithToast;
public function delete(StockTransfer $stockTransfer): void
{
$this->canOrAbort('delete stock transfer');
DB::transaction(function () use ($stockTransfer) {
foreach ($stockTransfer->items as $item) {
$this->reverseStockTransferBetweenOutlets($stockTransfer->sourceOutlet, $stockTransfer->destinationOutlet, $item);
}
$stockTransfer->delete();
});
$this->dispatch('refreshDatatable');
$this->toast('Transfer stok berhasil dihapus.');
Flux::modals()->close();
}
public function render(): View
{
return view('livewire.studio.manage.stock-transfer.index', [
'pageTitle' => 'Transfer Stok Antar Outlet',
]);
}
}

View File

@ -0,0 +1,55 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
use Veelasky\LaravelHashId\Eloquent\HashableId;
class StockTransfer extends Model implements HasMedia
{
use HasFactory, HashableId, InteractsWithMedia, SoftDeletes;
protected $guarded = ['id'];
protected function casts(): array
{
return [
'transfer_date' => 'date',
];
}
#[Scope]
public function today(Builder $query): void
{
$query->whereDate('created_at', today());
}
#[Scope]
public function yesterday(Builder $query): void
{
$query->whereDate('created_at', today()->subDay());
}
public function sourceOutlet(): BelongsTo
{
return $this->belongsTo(Outlet::class, 'source_outlet_id');
}
public function destinationOutlet(): BelongsTo
{
return $this->belongsTo(Outlet::class, 'destination_outlet_id');
}
public function items(): HasMany
{
return $this->hasMany(StockTransferItem::class);
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Database\Eloquent\SoftDeletes;
use Veelasky\LaravelHashId\Eloquent\HashableId;
class StockTransferItem extends Model
{
use HashableId, SoftDeletes;
protected $guarded = ['id'];
protected function casts(): array
{
return [
'quantity' => 'int',
];
}
#[Scope]
public function currentUser(Builder $query): void
{
$query->where('user_id', auth()->id());
}
public function stockTransfer(): BelongsTo
{
return $this->belongsTo(StockTransfer::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function transferable(): MorphTo
{
return $this->morphTo();
}
}

View File

@ -188,6 +188,13 @@ public function boot(): void
'match' => 'studio.manage.restock.*', 'match' => 'studio.manage.restock.*',
'can' => 'view restock', 'can' => 'view restock',
], ],
[
'label' => 'Transfer Stok',
'icon' => 'clipboard-document-list',
'route' => 'studio.manage.stock_transfer.index',
'match' => 'studio.manage.stock_transfer.*',
'can' => 'view stock transfer',
],
[ [
'label' => 'Stock Opname', 'label' => 'Stock Opname',
'icon' => 'clipboard-document-check', 'icon' => 'clipboard-document-check',

View File

@ -7,6 +7,7 @@
use App\Models\Purchase; use App\Models\Purchase;
use App\Models\Restock; use App\Models\Restock;
use App\Models\StockOpname; use App\Models\StockOpname;
use App\Models\StockTransfer;
use App\Models\Warehouse; use App\Models\Warehouse;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@ -20,6 +21,8 @@ class StockActivityLogService
public const LOG_STOCK_OPNAME = 'stock_opname'; public const LOG_STOCK_OPNAME = 'stock_opname';
public const LOG_TRANSFER = 'transfer';
/** /**
* Log purchase activity (Incoming to Warehouse). * Log purchase activity (Incoming to Warehouse).
*/ */
@ -159,6 +162,57 @@ public static function stockOpname(
); );
} }
/**
* Log transfer activity (Outlet to Outlet).
* This logs two events: Out from Source Outlet, In to Destination Outlet.
*/
public static function transfer(
StockTransfer $stockTransfer,
Model $product,
int $quantity,
int $sourcePreviousStock,
int $sourceNewStock,
int $destinationPreviousStock,
int $destinationNewStock,
?Model $causedBy = null
): void {
// Log Source Outlet Out
self::logOutlet(
logName: self::LOG_TRANSFER,
event: 'decrease',
description: "Stock Transfer #{$stockTransfer->hash_id}: Moved {$quantity} stock from Source to Destination Outlet.",
performedOn: $product,
outlet: $stockTransfer->sourceOutlet,
quantityChange: -1 * abs($quantity),
previousStock: $sourcePreviousStock,
newStock: $sourceNewStock,
extraProperties: [
'stock_transfer_id' => $stockTransfer->id,
'stock_transfer_hash' => $stockTransfer->hash_id,
'destination_outlet_id' => $stockTransfer->destination_outlet_id,
],
causedBy: $causedBy
);
// Log Destination Outlet In
self::logOutlet(
logName: self::LOG_TRANSFER,
event: 'increase',
description: "Stock Transfer #{$stockTransfer->hash_id}: Received {$quantity} stock from Source Outlet.",
performedOn: $product,
outlet: $stockTransfer->destinationOutlet,
quantityChange: abs($quantity),
previousStock: $destinationPreviousStock,
newStock: $destinationNewStock,
extraProperties: [
'stock_transfer_id' => $stockTransfer->id,
'stock_transfer_hash' => $stockTransfer->hash_id,
'source_outlet_id' => $stockTransfer->source_outlet_id,
],
causedBy: $causedBy
);
}
/** /**
* Log stock related activity for Outlet. * Log stock related activity for Outlet.
*/ */

View File

@ -0,0 +1,112 @@
<?php
namespace App\Traits\StockTransfer;
use App\Models\Bottle;
use App\Models\Outlet;
use App\Models\Perfume;
use App\Models\Product;
use App\Models\StockTransferItem;
trait WithStockTransferAddItem
{
public function addItem(string $type): void
{
$this->canOrAbort('create stock transfer');
if (! $this->form->source_outlet_id) {
$this->toast('Silakan pilih outlet asal terlebih dahulu.', 'Gagal', 'warning');
return;
}
$sourceOutlet = Outlet::find($this->form->source_outlet_id);
if (! $sourceOutlet) {
$this->toast('Outlet asal tidak ditemukan.', 'Gagal', 'danger');
return;
}
$data = match ($type) {
'parfum' => [
'transferable_type' => Perfume::class,
'transferable_id' => $this->form->perfume_id,
'quantity' => parseRupiahToInt($this->form->quantity_perfume),
],
'produk' => [
'transferable_type' => Product::class,
'transferable_id' => $this->form->product_id,
'quantity' => parseRupiahToInt($this->form->quantity_product),
],
'botol' => [
'transferable_type' => Bottle::class,
'transferable_id' => $this->form->bottle_id,
'quantity' => parseRupiahToInt($this->form->quantity_bottle),
],
default => null,
};
if (! $data || ! $data['transferable_id'] || $data['quantity'] <= 0) {
$this->toast('Data item tidak valid.', 'Gagal', 'danger');
return;
}
// Validate stock availability
$relationName = match ($type) {
'parfum' => 'perfumes',
'produk' => 'products',
'botol' => 'bottles',
default => null,
};
if ($relationName) {
$sourceStock = $sourceOutlet->{$relationName}()
->where($data['transferable_type']::make()->getTable().'.id', $data['transferable_id'])
->first()
?->pivot
?->stock ?? 0;
// Is item already in cart?
$existing = StockTransferItem::where([
'user_id' => auth()->id(),
'transferable_id' => $data['transferable_id'],
'transferable_type' => $data['transferable_type'],
])->whereNull('stock_transfer_id')->first();
$currentCartQty = $existing ? $existing->quantity : 0;
$requestedQty = $data['quantity'];
if (($currentCartQty + $requestedQty) > $sourceStock) {
$this->toast("Stok tidak mencukupi. Stok di outlet asal: {$sourceStock}. Sudah di keranjang: {$currentCartQty}.", 'Gagal', 'danger');
return;
}
if ($existing) {
$existing->increment('quantity', $requestedQty);
} else {
StockTransferItem::create(array_merge($data, ['user_id' => auth()->id()]));
}
}
$this->resetFormItem($type);
$this->stockTransferItems = $this->loadStockTransferItems();
$this->toast('Item berhasil ditambahkan ke keranjang.');
}
private function resetFormItem(string $type): void
{
if ($type === 'parfum') {
$this->form->perfume_id = '';
$this->form->quantity_perfume = '';
} elseif ($type === 'produk') {
$this->form->product_id = '';
$this->form->quantity_product = '';
} elseif ($type === 'botol') {
$this->form->bottle_id = '';
$this->form->quantity_bottle = '';
}
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace App\Traits\StockTransfer;
use App\Models\StockTransferItem;
trait WithStockTransferDeleteItem
{
public function deleteItem(StockTransferItem $item): void
{
$this->canOrAbort('create stock transfer');
$item->delete();
$this->stockTransferItems = $this->loadStockTransferItems();
$this->toast('Item berhasil dihapus dari keranjang.');
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Traits\StockTransfer;
use App\Models\StockTransferItem;
use Illuminate\Database\Eloquent\Collection;
trait WithStockTransferItems
{
public function loadStockTransferItems(): Collection
{
return $this->stockTransferItems = StockTransferItem::query()
->currentUser()
->where('user_id', auth()->id())
->whereNull('stock_transfer_id')
->latest()
->get();
}
}

View File

@ -0,0 +1,169 @@
<?php
namespace App\Traits\StockTransfer;
use App\Models\Bottle;
use App\Models\Perfume;
use App\Models\Product;
use App\Models\StockTransfer;
use App\Services\StockActivityLogService;
trait WithStockTransferStock
{
public function transferStockBetweenOutlets($sourceOutlet, $destinationOutlet, $item): void
{
$quantity = $item->quantity;
switch ($item->transferable_type) {
case Perfume::class:
$relation = 'perfumes';
break;
case Product::class:
$relation = 'products';
break;
case Bottle::class:
$relation = 'bottles';
break;
default:
return;
}
// Decrease source outlet stock
$sourceItem = $sourceOutlet->{$relation}()
->where("{$relation}.id", $item->transferable_id)
->lockForUpdate()
->withPivot('stock')
->first();
$sourcePreviousStock = 0;
if ($sourceItem) {
$sourcePreviousStock = $sourceItem->pivot->stock ?? 0;
}
if ($sourcePreviousStock < $quantity) {
throw new \Exception("Stok {$item->transferable->name} di outlet asal tidak mencukupi. Tersisa {$sourcePreviousStock}, diminta {$quantity}.");
}
$sourceNewStock = $sourcePreviousStock - $quantity;
$sourceOutlet->{$relation}()->updateExistingPivot($item->transferable_id, [
'stock' => $sourceNewStock,
]);
// Increase destination outlet stock
$destinationItem = $destinationOutlet->{$relation}()
->where("{$relation}.id", $item->transferable_id)
->lockForUpdate()
->withPivot('stock')
->first();
if ($destinationItem) {
$destinationPreviousStock = $destinationItem->pivot->stock ?? 0;
$destinationOutlet->{$relation}()->updateExistingPivot($item->transferable_id, [
'stock' => $destinationPreviousStock + $quantity,
]);
} else {
$destinationPreviousStock = 0;
$destinationOutlet->{$relation}()->attach($item->transferable_id, [
'stock' => $quantity,
]);
}
$destinationNewStock = $destinationPreviousStock + $quantity;
$stockTransfer = $item->stockTransfer ?? StockTransfer::find($item->stock_transfer_id);
if ($stockTransfer) {
StockActivityLogService::transfer(
stockTransfer: $stockTransfer,
product: $item->transferable,
quantity: $quantity,
sourcePreviousStock: $sourcePreviousStock,
sourceNewStock: $sourceNewStock,
destinationPreviousStock: $destinationPreviousStock,
destinationNewStock: $destinationNewStock
);
}
}
public function reverseStockTransferBetweenOutlets($sourceOutlet, $destinationOutlet, $item): void
{
$quantity = $item->quantity;
switch ($item->transferable_type) {
case Perfume::class:
$relation = 'perfumes';
break;
case Product::class:
$relation = 'products';
break;
case Bottle::class:
$relation = 'bottles';
break;
default:
return;
}
// Return items to source outlet
$sourcePivot = $sourceOutlet->{$relation}()
->where("{$relation}.id", $item->transferable_id)
->first();
$currentSourceStock = $sourcePivot?->pivot?->stock ?? 0;
$newSourceStock = $currentSourceStock + $quantity;
if ($sourcePivot) {
$sourceOutlet->{$relation}()->updateExistingPivot($item->transferable_id, [
'stock' => $newSourceStock,
]);
} else {
$sourceOutlet->{$relation}()->attach($item->transferable_id, [
'stock' => $quantity,
]);
}
$stockTransfer = $item->stockTransfer ?? StockTransfer::find($item->stock_transfer_id);
if ($stockTransfer) {
StockActivityLogService::logOutlet(
logName: StockActivityLogService::LOG_TRANSFER,
event: 'increase',
description: "Stock Transfer #{$stockTransfer->hash_id} Deleted: Returned {$quantity} stock to source outlet.",
performedOn: $item->transferable,
outlet: $sourceOutlet,
quantityChange: $quantity,
previousStock: $currentSourceStock,
newStock: $newSourceStock,
extraProperties: ['stock_transfer_id' => $stockTransfer->id]
);
}
// Remove items from destination outlet
$destinationPivot = $destinationOutlet->{$relation}()
->where("{$relation}.id", $item->transferable_id)
->first();
$currentDestinationStock = $destinationPivot?->pivot?->stock ?? 0;
$newDestinationStock = max(0, $currentDestinationStock - $quantity);
if ($destinationPivot) {
$destinationOutlet->{$relation}()->updateExistingPivot($item->transferable_id, [
'stock' => $newDestinationStock,
]);
}
if ($stockTransfer) {
StockActivityLogService::logOutlet(
logName: StockActivityLogService::LOG_TRANSFER,
event: 'decrease',
description: "Stock Transfer #{$stockTransfer->hash_id} Deleted: Removed {$quantity} stock from destination outlet.",
performedOn: $item->transferable,
outlet: $destinationOutlet,
quantityChange: -1 * abs($quantity),
previousStock: $currentDestinationStock,
newStock: $newDestinationStock,
extraProperties: ['stock_transfer_id' => $stockTransfer->id]
);
}
}
}

View File

@ -0,0 +1,84 @@
<?php
namespace App\Traits\StockTransfer;
use App\Models\Bottle;
use App\Models\Outlet;
use App\Models\Perfume;
use App\Models\Product;
use App\Models\StockTransferItem;
use Livewire\Attributes\On;
trait WithStockTransferUpdateItem
{
public string $modalTitle = '';
public function updateItem(): void
{
$this->canOrAbort('create stock transfer');
if (! $this->form->source_outlet_id) {
$this->toast('Silakan pilih outlet asal terlebih dahulu.', 'Gagal', 'warning');
return;
}
$sourceOutlet = Outlet::find($this->form->source_outlet_id);
if (! $sourceOutlet) {
$this->toast('Outlet asal tidak ditemukan.', 'Gagal', 'danger');
return;
}
$item = StockTransferItem::find($this->form->item_id);
if ($item) {
$newQuantity = parseRupiahToInt($this->form->quantity_edit);
if ($newQuantity <= 0) {
$this->toast('Jumlah harus lebih dari 0.', 'Gagal', 'danger');
return;
}
$relationName = match ($item->transferable_type) {
Perfume::class => 'perfumes',
Product::class => 'products',
Bottle::class => 'bottles',
default => null,
};
if ($relationName) {
$sourceStock = $sourceOutlet->{$relationName}()
->where($item->transferable_type::make()->getTable().'.id', $item->transferable_id)
->first()
?->pivot
?->stock ?? 0;
if ($newQuantity > $sourceStock) {
$this->toast("Stok tidak mencukupi (Update). Stok di outlet asal: {$sourceStock}.", 'Gagal', 'danger');
return;
}
}
$item->update([
'quantity' => $newQuantity,
]);
}
$this->stockTransferItems = $this->loadStockTransferItems();
$this->toast('Item berhasil diperbarui.');
$this->dispatch('modal:close', 'form-modal');
}
#[On('modal:open')]
public function openModal($item): void
{
$item = StockTransferItem::find($item['item']);
$this->form->item_id = $item->id;
$this->form->quantity_edit = formatCurrencyNumber($item->quantity, '');
$this->modalTitle = $item->transferable->name;
}
}

View File

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('stock_transfers', function (Blueprint $table) {
$table->id();
$table->foreignId('source_outlet_id')->constrained('outlets')->cascadeOnDelete();
$table->foreignId('destination_outlet_id')->constrained('outlets')->cascadeOnDelete();
$table->date('transfer_date');
$table->string('note', 100)->nullable();
$table->timestamp('created_at')->useCurrent();
$table->timestamp('updated_at')->nullable()->useCurrentOnUpdate();
$table->softDeletes();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('stock_transfers');
}
};

View File

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('stock_transfer_items', function (Blueprint $table) {
$table->id();
$table->foreignId('stock_transfer_id')->nullable()->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->morphs('transferable');
$table->unsignedInteger('quantity');
$table->timestamp('created_at')->useCurrent();
$table->timestamp('updated_at')->nullable()->useCurrentOnUpdate();
$table->softDeletes();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('stock_transfer_items');
}
};

View File

@ -139,6 +139,11 @@ public function run(): void
'update restock', 'update restock',
'delete restock', 'delete restock',
'view stock transfer',
'create stock transfer',
'update stock transfer',
'delete stock transfer',
'view reward', 'view reward',
'create reward', 'create reward',
'update reward', 'update reward',
@ -399,6 +404,11 @@ public function run(): void
'update restock', 'update restock',
'delete restock', 'delete restock',
'view stock transfer',
'create stock transfer',
'update stock transfer',
'delete stock transfer',
'view reward', 'view reward',
'create reward', 'create reward',
'update reward', 'update reward',

View File

@ -0,0 +1,213 @@
<flux:main>
<div class="flex justify-between items-center">
<div>
<flux:heading size="xl">{{ $pageTitle }}</flux:heading>
</div>
<div>
<flux:button href="{{ route('studio.manage.stock_transfer.index') }}" wire:navigate class="text-sm">
Kembali
</flux:button>
</div>
</div>
<div class="mt-6">
<flux:callout icon="megaphone" color="blue" class="mb-6">
<flux:callout.heading>Informasi Transfer Stok</flux:callout.heading>
<flux:callout.text>
Proses transfer stok akan **mengurangi** stok di Outlet Asal dan **menambah** stok di Outlet Tujuan yang dipilih.
</flux:callout.text>
</flux:callout>
<div class="flex flex-col lg:flex-row gap-4">
<div class="w-full lg:w-3/4 space-y-4">
<flux:card class="space-y-6 p-6">
<flux:input label="Catatan" placeholder="Transfer stok antar outlet" wire:model="form.note"
autocomplete="off" />
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<flux:field>
<flux:label>Tanggal Transfer <span class="text-red-500 ms-1">*</span></flux:label>
<flux:date-picker with-today wire:model="form.transfer_date" placeholder="Pilih Tanggal"
autocomplete="off" locale="id-ID" selectable-header />
<flux:error name="form.transfer_date" />
</flux:field>
<flux:field>
<flux:label>Outlet Asal <span class="text-red-500 ms-1">*</span></flux:label>
<flux:select variant="listbox" searchable placeholder="Pilih Outlet Asal"
wire:model.live="form.source_outlet_id">
@foreach ($sourceOutlets as $key => $value)
<flux:select.option value="{{ $key }}">{{ $value }}
</flux:select.option>
@endforeach
</flux:select>
<flux:error name="form.source_outlet_id" />
</flux:field>
<flux:field>
<flux:label>Outlet Tujuan <span class="text-red-500 ms-1">*</span></flux:label>
<flux:select variant="listbox" searchable placeholder="Pilih Outlet Tujuan"
wire:model.live="form.destination_outlet_id">
@foreach ($destinationOutlets as $key => $value)
<flux:select.option value="{{ $key }}">{{ $value }}
</flux:select.option>
@endforeach
</flux:select>
<flux:error name="form.destination_outlet_id" />
</flux:field>
</div>
</flux:card>
@if ($form->source_outlet_id && $form->destination_outlet_id)
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<flux:card class="space-y-6 p-6">
<flux:select label="Parfum" variant="listbox" searchable placeholder="Cari parfum..."
wire:model="form.perfume_id">
@foreach ($perfumes as $key => $perfume)
<flux:select.option value="{{ $key }}">{{ $perfume }}
</flux:select.option>
@endforeach
</flux:select>
<flux:input label="Kuantitas" placeholder="500" wire:model="form.quantity_perfume"
autocomplete="off" x-mask:dynamic="$money($input, ',')" />
<div class="flex justify-end">
<flux:button variant="primary" color="zinc" wire:click="addItem('parfum')">
Tambah
</flux:button>
</div>
</flux:card>
<flux:card class="space-y-6 p-6">
<flux:select label="Produk" variant="listbox" searchable placeholder="Cari produk..."
wire:model="form.product_id">
@foreach ($products as $key => $product)
<flux:select.option value="{{ $key }}">{{ $product }}
</flux:select.option>
@endforeach
</flux:select>
<flux:input label="Kuantitas" placeholder="10" wire:model="form.quantity_product"
autocomplete="off" x-mask:dynamic="$money($input, ',')" />
<div class="flex justify-end">
<flux:button variant="primary" color="zinc" wire:click="addItem('produk')">
Tambah
</flux:button>
</div>
</flux:card>
<flux:card class="space-y-6 p-6">
<flux:select label="Botol" variant="listbox" searchable placeholder="Cari botol..."
wire:model="form.bottle_id">
@foreach ($bottles as $key => $bottle)
<flux:select.option value="{{ $key }}">{{ $bottle }}
</flux:select.option>
@endforeach
</flux:select>
<flux:input label="Kuantitas" placeholder="50" wire:model="form.quantity_bottle"
autocomplete="off" x-mask:dynamic="$money($input, ',')" />
<div class="flex justify-end">
<flux:button variant="primary" color="zinc" wire:click="addItem('botol')">
Tambah
</flux:button>
</div>
</flux:card>
</div>
@else
<flux:card class="p-6 text-center text-zinc-500 italic">
Silakan pilih Outlet Asal dan Outlet Tujuan terlebih dahulu untuk menampilkan daftar item.
</flux:card>
@endif
</div>
<div class="w-full lg:w-1/3 space-y-4">
<flux:card class="space-y-6 p-6">
<div class="space-y-3">
<h3 class="text-sm font-medium">Gambar</h3>
<div class="dropzone-wrapper">
<livewire:dropzone wire:model="form.image" :rules="['image', 'mimes:png,jpeg', 'max:10420']" :max-files="1"
:key="'image'" :files="$form->image" />
@error('form.image')
<div class="mt-3 text-sm font-medium text-red-500 dark:text-red-400">
{{ $message }}
</div>
@enderror
</div>
</div>
</flux:card>
<flux:card class="space-y-6 p-6">
<h3 class="text-sm font-medium">Keranjang Transfer Stok</h3>
<div class="divide-y text-sm">
<flux:table>
<flux:table.columns>
<flux:table.column>Item</flux:table.column>
<flux:table.column>Qty</flux:table.column>
<flux:table.column>Aksi</flux:table.column>
</flux:table.columns>
<flux:table.rows>
@forelse($stockTransferItems as $item)
<flux:table.row>
<flux:table.cell>
<flux:heading>
<div>{{ $item->transferable->name }}</div>
</flux:heading>
</flux:table.cell>
<flux:table.cell>
{{ formatCurrencyNumber($item->quantity, '') }}
</flux:table.cell>
<flux:table.cell class="space-x-2">
<flux:modal.trigger name="form-modal">
<flux:tooltip content="Ubah">
<flux:button variant="primary" color="yellow" icon="pencil-square"
size="sm"
wire:click="$dispatch('modal:open', {'item': '{{ $item->id }}'})">
</flux:button>
</flux:tooltip>
</flux:modal.trigger>
<flux:button variant="danger" icon="trash" size="sm"
wire:click="deleteItem('{{ $item->id }}')">
</flux:button>
</flux:table.cell>
</flux:table.row>
@empty
<flux:table.row>
<flux:table.cell colspan="3" class="text-center">
<span class="text-sm text-zinc-500 italic">Keranjang Kosong...</span>
</flux:table.cell>
</flux:table.row>
@endforelse
</flux:table.rows>
</flux:table>
</div>
</flux:card>
</div>
</div>
<div class="flex justify-start mt-4">
<flux:button variant="primary" class="sm:w-auto cursor-pointer" wire:click="save">
Simpan
</flux:button>
</div>
</div>
<flux:modal name="form-modal" class="w-[95%] max-w-sm md:max-w-xl mx-auto" @close="closeModal('form-modal')">
<div class="p-4 space-y-6">
<flux:heading size="lg">Ubah {{ $modalTitle }}</flux:heading>
<flux:input label="Kuantitas" placeholder="Masukkan kuantitas" wire:model="form.quantity_edit"
autocomplete="off" x-mask:dynamic="$money($input, ',')" />
<div class="flex">
<flux:spacer />
<flux:button variant="primary" color="zinc" class="sm:w-auto cursor-pointer"
wire:click="updateItem">
Perbarui
</flux:button>
</div>
</div>
</flux:modal>
</flux:main>

View File

@ -0,0 +1,35 @@
<flux:main>
<div class="flex justify-between items-center">
<div>
<flux:heading size="xl">{{ $pageTitle }}</flux:heading>
</div>
@can('create stock transfer')
<div>
<flux:button href="{{ route('studio.manage.stock_transfer.create') }}" variant="primary" wire:navigate
class="text-sm">
Tambah
</flux:button>
</div>
@endcan
</div>
<div class="mt-6">
<flux:callout icon="megaphone" color="blue" class="mb-6">
<flux:callout.heading>Informasi</flux:callout.heading>
<flux:callout.text>
Menghapus data transfer stok otomatis mengembalikan stok ke outlet asal.
</flux:callout.text>
</flux:callout>
<livewire:datatable.manage.stock-transfers-table />
</div>
@include('components.modals.confirmation', [
'modalName' => 'delete-confirmation',
'modalTitle' => 'Apakah Anda yakin?',
'modalMessage' => 'Data transfer stok akan dihapus dari riwayat.',
'buttonVariant' => 'primary',
'buttonColor' => 'danger',
'buttonText' => 'Ya, Hapus',
])
</flux:main>

View File

@ -42,6 +42,8 @@
use App\Livewire\Studio\Manage\StockOpname\Index as StockOpnameIndex; use App\Livewire\Studio\Manage\StockOpname\Index as StockOpnameIndex;
use App\Livewire\Studio\Manage\StockOpname\Manage as StockOpnameManage; use App\Livewire\Studio\Manage\StockOpname\Manage as StockOpnameManage;
use App\Livewire\Studio\Manage\StockOpname\Show as StockOpnameShow; use App\Livewire\Studio\Manage\StockOpname\Show as StockOpnameShow;
use App\Livewire\Studio\Manage\StockTransfer\Create as StockTransferCreate;
use App\Livewire\Studio\Manage\StockTransfer\Index as StockTransferIndex;
use App\Livewire\Studio\Master\Category as CategoryComponent; use App\Livewire\Studio\Master\Category as CategoryComponent;
use App\Livewire\Studio\Master\Formula as FormulaComponent; use App\Livewire\Studio\Master\Formula as FormulaComponent;
use App\Livewire\Studio\Master\Outlet\Create as OutletCreate; use App\Livewire\Studio\Master\Outlet\Create as OutletCreate;
@ -172,6 +174,11 @@
Route::get('/create', RestockCreate::class)->name('create')->middleware('can:create restock'); Route::get('/create', RestockCreate::class)->name('create')->middleware('can:create restock');
}); });
Route::prefix('stock-transfers')->name('stock_transfer.')->group(function () {
Route::get('/', StockTransferIndex::class)->name('index')->middleware('can:view stock transfer');
Route::get('/create', StockTransferCreate::class)->name('create')->middleware('can:create stock transfer');
});
Route::prefix('stock-opname')->name('stock_opname.')->group(function () { Route::prefix('stock-opname')->name('stock_opname.')->group(function () {
Route::get('/', StockOpnameIndex::class)->name('index')->middleware('can:view stock opname'); Route::get('/', StockOpnameIndex::class)->name('index')->middleware('can:view stock opname');
Route::get('/{stockOpname}/show', StockOpnameShow::class)->name('show')->middleware('can:show stock opname'); Route::get('/{stockOpname}/show', StockOpnameShow::class)->name('show')->middleware('can:show stock opname');