feat: Implement decimal precision for quantities and monetary values across feed purchases, delayed goods, and related calculations.

This commit is contained in:
Yoga Pangestu 2026-03-07 12:04:05 +07:00
parent 0a643efbb6
commit 48b182cd4b
4 changed files with 152 additions and 37 deletions

View File

@ -11,6 +11,7 @@
use App\Filament\Columns\TimestampColumns;
use App\Filament\Resources\Manage\Orders\Pages\ManageOrders;
use App\Models\Order;
use App\Models\Warehouse;
use BackedEnum;
use Filament\Actions\BulkActionGroup;
use Filament\Forms\Components\DatePicker;
@ -46,17 +47,39 @@ class OrderResource extends Resource
public static function form(Schema $schema): Schema
{
$recalculateTotal = function (callable $get, callable $set): void {
$qty = $get('egg_quantity') ?? 0;
$quantity = str_contains((string) $qty, ',')
? (float) str_replace(',', '.', str_replace('.', '', (string) $qty))
: (float) $qty;
$unmaskQty = function ($state) {
if (blank($state)) {
return 0;
}
$price = $get('unit_price') ?? 0;
$unitPrice = (int) str_replace('.', '', (string) $price);
if (is_int($state) || is_float($state)) {
return $state;
}
$discountRaw = $get('discount') ?? 0;
$discount = (int) str_replace('.', '', (string) $discountRaw);
return (float) str($state)
->replace(',', '.')
->toString();
};
$unmaskMoney = function ($state) {
if (blank($state)) {
return 0;
}
if (is_int($state) || is_float($state)) {
return $state;
}
return (float) str($state)
->replace('.', '')
->replace(',', '.')
->toString();
};
$recalculateTotal = function (callable $get, callable $set) use ($unmaskMoney, $unmaskQty): void {
$quantity = $unmaskQty($get('egg_quantity'));
$unitPrice = $unmaskMoney($get('unit_price'));
$discount = $unmaskMoney($get('discount'));
$subtotal = $quantity * $unitPrice;
$totalAmount = max(0, $subtotal - $discount);
@ -66,19 +89,32 @@ public static function form(Schema $schema): Schema
return $schema
->components([
DatePicker::make('order_date')
->label('Tanggal Order')
->default(now())
->required()
->native(false)
->displayFormat('l, d F Y'),
Grid::make(3)
->schema([
DatePicker::make('order_date')
->label('Tanggal Order')
->default(now())
->required()
->native(false)
->displayFormat('l, d F Y'),
Select::make('customer_id')
->label('Pelanggan')
->relationship('customer', 'name')
->searchable()
->preload()
->native(false),
Select::make('warehouse_id')
->label('Ambil Dari Gudang')
->relationship('warehouse', 'name')
->searchable()
->preload()
->native(false)
->required()
->default(fn () => Warehouse::first()?->id),
Select::make('customer_id')
->label('Pelanggan')
->relationship('customer', 'name')
->searchable()
->preload()
->native(false),
])
->columnSpanFull(),
Grid::make(3)
->schema([
@ -93,9 +129,8 @@ public static function form(Schema $schema): Schema
$recalculateTotal($get, $set);
})
->currencyMask(thousandSeparator: '.', decimalSeparator: ',', precision: 2)
->dehydrateStateUsing(fn ($state) => str_contains((string) $state, ',')
? (float) str_replace(',', '.', str_replace('.', '', (string) $state))
: (float) $state),
->formatStateUsing(fn ($state) => $state === null ? null : (float) $state)
->dehydrateStateUsing($unmaskQty),
TextInput::make('unit_price')
->label('Harga per Satuan')
@ -105,7 +140,7 @@ public static function form(Schema $schema): Schema
->required()
->live()
->currencyMask(thousandSeparator: '.', decimalSeparator: ',', precision: 0)
->dehydrateStateUsing(fn ($state) => (int) str()->replace('.', '', (string) ($state ?? 0)))
->dehydrateStateUsing($unmaskMoney)
->afterStateUpdated(function ($state, callable $get, callable $set) use ($recalculateTotal): void {
$recalculateTotal($get, $set);
}),
@ -123,6 +158,7 @@ public static function form(Schema $schema): Schema
TextInput::make('discount')
->label('Diskon')
->placeholder('0')
->autocomplete(false)
->default(0)
->prefix('Rp')
->live()
@ -130,7 +166,7 @@ public static function form(Schema $schema): Schema
$recalculateTotal($get, $set);
})
->currencyMask(thousandSeparator: '.', decimalSeparator: ',', precision: 0)
->dehydrateStateUsing(fn ($state) => (int) str()->replace('.', '', (string) ($state ?? 0))),
->dehydrateStateUsing($unmaskMoney),
TextInput::make('total_amount')
->label('Total')
@ -138,10 +174,8 @@ public static function form(Schema $schema): Schema
->readOnly()
->prefix('Rp')
->required()
->formatStateUsing(
fn ($state) => number_format((float) ($state ?? 0), 0, ',', '.')
)
->dehydrateStateUsing(fn ($state) => (float) str_replace('.', '', (string) ($state ?? 0))),
->formatStateUsing(fn ($state) => number_format(($state ?? 0), 0, ',', '.'))
->dehydrateStateUsing($unmaskMoney),
Textarea::make('notes')
->label('Catatan')
@ -166,10 +200,16 @@ public static function table(Table $table): Table
->searchable()
->sortable(),
TextColumn::make('warehouse.name')
->label('Gudang')
->searchable()
->sortable(),
TextColumn::make('egg_quantity')
->label('Jumlah')
->suffix(fn ($record) => ' '.$record->unit?->alias)
->numeric(decimalPlaces: 2, decimalSeparator: ',', thousandsSeparator: '.')
->getStateUsing(function ($record) {
return str_replace('.', ',', (float) $record->egg_quantity)." {$record->unit?->alias}";
})
->sortable(),
TextColumn::make('unit_price')

View File

@ -2,11 +2,15 @@
namespace App\Models;
use App\Observers\OrderObserver;
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
#[ObservedBy(OrderObserver::class)]
class Order extends Model
{
use HasFactory, SoftDeletes;
@ -19,8 +23,8 @@ protected function casts(): array
'order_date' => 'date',
'egg_quantity' => 'decimal:2',
'unit_price' => 'integer',
'total_amount' => 'decimal:2',
'discount' => 'decimal:2',
'total_amount' => 'integer',
'discount' => 'integer',
];
}
@ -33,4 +37,9 @@ public function unit(): BelongsTo
{
return $this->belongsTo(Unit::class);
}
public function warehouse(): BelongsTo
{
return $this->belongsTo(Warehouse::class);
}
}

View File

@ -0,0 +1,65 @@
<?php
namespace App\Observers;
use App\Models\Order;
use App\Models\WarehouseStock;
class OrderObserver
{
/**
* Handle the Order "created" event.
*/
public function created(Order $order): void
{
if ($order->warehouse_id) {
WarehouseStock::adjustStock(
$order->warehouse_id,
$order->unit_id,
false, // Assume sold eggs are not broken
-$order->egg_quantity
);
}
}
/**
* Handle the Order "updated" event.
*/
public function updated(Order $order): void
{
// Revert old stock if warehouse_id, unit_id, egg_quantity, or deleted_at changed
if ($order->isDirty(['warehouse_id', 'unit_id', 'egg_quantity'])) {
$oldWarehouseId = $order->getOriginal('warehouse_id');
$oldUnitId = $order->getOriginal('unit_id');
$oldQuantity = $order->getOriginal('egg_quantity');
if ($oldWarehouseId) {
WarehouseStock::adjustStock($oldWarehouseId, $oldUnitId, false, $oldQuantity);
}
if ($order->warehouse_id) {
WarehouseStock::adjustStock($order->warehouse_id, $order->unit_id, false, -$order->egg_quantity);
}
}
}
/**
* Handle the Order "deleted" event.
*/
public function deleted(Order $order): void
{
if (! $order->isForceDeleting() && $order->warehouse_id) {
WarehouseStock::adjustStock($order->warehouse_id, $order->unit_id, false, $order->egg_quantity);
}
}
/**
* Handle the Order "restored" event.
*/
public function restored(Order $order): void
{
if ($order->warehouse_id) {
WarehouseStock::adjustStock($order->warehouse_id, $order->unit_id, false, -$order->egg_quantity);
}
}
}

View File

@ -11,13 +11,14 @@ public function up(): void
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->foreignId('customer_id')->nullable()->constrained()->cascadeOnDelete();
$table->foreignId('warehouse_id')->nullable()->constrained()->nullOnDelete();
$table->foreignId('unit_id')->constrained()->cascadeOnDelete();
$table->decimal('egg_quantity', 10, 2);
$table->decimal('egg_quantity', 12, 2);
$table->unsignedInteger('unit_price');
$table->date('order_date');
$table->string('status', 20)->default('pending');
$table->decimal('total_amount', 15, 2);
$table->decimal('discount', 15, 2)->default(0);
$table->unsignedBigInteger('total_amount');
$table->unsignedBigInteger('discount')->default(0);
$table->text('notes')->nullable();
$table->timestamp('created_at')->useCurrent();
$table->timestamp('updated_at')->nullable()->useCurrentOnUpdate();