feat: add voucher discount functionality and update related calculations across orders

This commit is contained in:
Yoga Pangestu 2026-01-09 20:18:58 +07:00
parent c0fe92f0d2
commit 95a252dab7
13 changed files with 48 additions and 24 deletions

View File

@ -54,26 +54,29 @@ public function columns(): array
->data(
fn ($value, $row) => $row->items->map(fn ($item) => [
'name' => $item->orderable?->name,
'quantity' => formatCurrencyNumber($item->quantity, ''),
'quantity' => $item->quality && $item->quality !== 'Custom' ? $item->quality : formatCurrencyNumber($item->quantity, ''),
'unit_price' => formatCurrencyNumber($item->unit_price, 'Rp'),
'total_price' => formatCurrencyNumber($item->total_price, 'Rp'),
'has_quality' => $item->quality && $item->quality !== 'Custom',
])->toArray()
)
->outputFormat(
fn ($index, $value) => "
->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]'>{$value['quantity']} x {$value['unit_price']}</div>
".($value['has_quality']
? "<div class='text-gray-400 text-[12px]'>{$value['quantity']}</div>"
: "<div class='text-gray-400 text-[12px]'>{$value['quantity']} x {$value['unit_price']}</div>"
)."
<div class='text-gray-400 text-[12px]'>{$value['total_price']}</div>
</div>"
)
</div>
")
->flexCol(['class' => 'flex-col gap-3']),
Column::make('Ringkasan')
->label(function ($value) {
$subtotal = formatCurrencyNumber($value->subtotal, 'Rp');
$discount = formatCurrencyNumber($value->discount, 'Rp');
$discount = formatCurrencyNumber($value->discount + $value->voucher_discount, 'Rp');
$total = formatCurrencyNumber($value->total, 'Rp');
@ -151,6 +154,7 @@ public function builder(): Builder
'cogs',
'subtotal',
'discount',
'voucher_discount',
'total',
'orders.status',
'channel',

View File

@ -118,7 +118,7 @@ public function store(): array
// Wrap the entire operation in a database transaction
// Ensures atomicity — if any step fails, all changes are rolled back
try {
DB::transaction(function () use (&$order, $invoiceNumber, $cogs, $subtotal, $discount, $total, $items, $pointsEarned) {
DB::transaction(function () use (&$order, $invoiceNumber, $cogs, $subtotal, $manualDiscount, $voucherDiscount, $total, $items, $pointsEarned) {
$customer = $this->member_id ? Customer::find($this->member_id) : null;
$member = $customer?->user()->with(['membership' => fn ($q) => $q->lockForUpdate()])->first();
@ -130,7 +130,8 @@ public function store(): array
'invoice_number' => $invoiceNumber,
'cogs' => $cogs,
'subtotal' => $subtotal,
'discount' => $discount,
'discount' => $manualDiscount,
'voucher_discount' => $voucherDiscount,
'total' => $total,
'channel' => (int) $this->channel,
'status' => (int) $this->status,

View File

@ -187,7 +187,7 @@ public function render(): View
$totalIncome = (clone $orderQuery)->sum('total');
$totalCogs = (clone $orderQuery)->sum('cogs');
$totalDiscount = (clone $orderQuery)->sum('discount');
$totalDiscount = (clone $orderQuery)->sum(DB::raw('discount + COALESCE(voucher_discount, 0)'));
$expenseQuery = Expense::when(! empty($outletIds), fn ($q) => $q->whereIn('outlet_id', $outletIds))
->when($dateQuery || ($startDate && $endDate), $filterDate);
@ -389,7 +389,7 @@ public function render(): View
$ordersTrend = Order::query()
->when(! empty($outletIds), fn ($q) => $q->whereIn('outlet_id', $outletIds))
->where('created_at', '>=', now()->subDays(30)->startOfDay())
->select(DB::raw('DATE(created_at) as date'), DB::raw('SUM(total) as revenue'), DB::raw('SUM(total - cogs - discount) as gross_profit'), DB::raw('count(*) as count'))
->select(DB::raw('DATE(created_at) as date'), DB::raw('SUM(total) as revenue'), DB::raw('SUM(total - cogs - discount - COALESCE(voucher_discount,0)) as gross_profit'), DB::raw('count(*) as count'))
->groupBy('date')
->get()
->keyBy('date');

View File

@ -79,11 +79,11 @@ public function render(): View
$todayDiscount = Order::when(! empty($outletIds), fn ($q) => $q->whereIn('outlet_id', $outletIds))
->today()
->sum('discount');
->sum(DB::raw('discount + COALESCE(voucher_discount, 0)'));
$yesterdayDiscount = Order::when(! empty($outletIds), fn ($q) => $q->whereIn('outlet_id', $outletIds))
->yesterday()
->sum('discount');
->sum(DB::raw('discount + COALESCE(voucher_discount, 0)'));
$todayVoucher = Order::when(! empty($outletIds), fn ($q) => $q->whereIn('outlet_id', $outletIds))
->today()

View File

@ -53,7 +53,7 @@ public function getTopMemberStats($tierId): ?array
// Sum total
$subtotal = $orders->sum('subtotal');
$totalDiscount = $orders->sum('discount');
$totalDiscount = $orders->sum(fn ($o) => ($o->discount ?? 0) + ($o->voucher_discount ?? 0));
$grandTotal = $orders->sum('total');
return [

View File

@ -69,7 +69,7 @@ public function print(Order $order): void
outlet_name: $order->outlet?->name ?? '',
outlet_address: $order->outlet?->address ?? '',
subtotal: $order->subtotal,
discount: $order->discount,
discount: ($order->discount ?? 0) + ($order->voucher_discount ?? 0),
total: $order->total,
items: $order->items->map(function ($item) {
$item->name = $item->orderable?->name;

View File

@ -29,7 +29,7 @@ public function mount(Order $order): void
'date' => formatDateTimeLocalized($order->created_at),
'channel' => $order->channel->label(),
'subtotal' => formatCurrencyNumber($order->subtotal, 'Rp'),
'discount' => formatCurrencyNumber($order->discount, 'Rp'),
'discount' => formatCurrencyNumber(($order->discount ?? 0) + ($order->voucher_discount ?? 0), 'Rp'),
'total' => formatCurrencyNumber($order->total, 'Rp'),
];
@ -37,6 +37,7 @@ public function mount(Order $order): void
->map(fn (OrderItem $item) => [
'id' => $item->hash,
'name' => $item->orderable?->name,
'quality' => $item->quality,
'quantity' => $item->quantity,
'unit_price' => formatCurrencyNumber($item->unit_price, 'Rp'),
'total' => formatCurrencyNumber($item->quantity * $item->unit_price, 'Rp'),

View File

@ -28,6 +28,7 @@ protected function casts(): array
'cogs' => 'int',
'subtotal' => 'int',
'discount' => 'int',
'voucher_discount' => 'int',
'total' => 'int',
'channel' => OrderChannel::class,
'status' => OrderStatus::class,

View File

@ -36,6 +36,7 @@ public function definition(): array
'cogs' => $cogs,
'subtotal' => $subtotal,
'discount' => $discount,
'voucher_discount' => 0,
'total' => $total,
'channel' => $this->faker->randomElement(OrderChannel::cases()),
'status' => $this->faker->randomElement(OrderStatus::cases()),
@ -138,6 +139,7 @@ public function withAmounts(int $subtotal, ?int $discount = null, ?int $cogs = n
return [
'subtotal' => $subtotal,
'discount' => $discount,
'voucher_discount' => 0,
'total' => $total,
'cogs' => $cogs,
];
@ -152,6 +154,7 @@ public function withDiscount(int $discount): Factory
return [
'discount' => $discount,
'voucher_discount' => 0,
'total' => max(0, $total),
];
});
@ -164,6 +167,7 @@ public function withoutDiscount(): Factory
return [
'discount' => 0,
'voucher_discount' => 0,
'total' => $subtotal,
];
});

View File

@ -23,6 +23,7 @@ public function up(): void
$table->unsignedInteger('cogs');
$table->unsignedInteger('subtotal');
$table->unsignedInteger('discount');
$table->unsignedInteger('voucher_discount')->default(0);
$table->unsignedInteger('total');
$table->enum('channel', OrderChannel::values())->default(OrderChannel::OUTLET)->comment(OrderChannel::comment());
$table->enum('status', OrderStatus::values())->default(OrderStatus::PENDING)->comment(OrderStatus::comment());

View File

@ -336,7 +336,7 @@
<flux:description>Voucher diurutkan berdasarkan dengan diskon yang paling besar.
</flux:description>
<flux:select variant="listbox" placeholder="Pilih Voucher"
wire:model="form.voucher_id" searchable clearable>
wire:model.live.debounce.250ms="form.voucher_id" searchable clearable>
@foreach ($vouchers as $key => $name)
<flux:select.option value="{{ $key }}" key="{{ $key }}">
{{ $name }}

View File

@ -104,13 +104,15 @@
<h5 class="sm:hidden text-xs font-medium text-gray-500 uppercase dark:text-zinc-400">
Kuantitas
</h5>
<p class="text-gray-800 dark:text-zinc-100">{{ $item['quantity'] }}</p>
<p class="text-gray-800 dark:text-zinc-100">{{ $item['quality'] ?: $item['quantity'] }}
</p>
</div>
<div>
<h5 class="sm:hidden text-xs font-medium text-gray-500 uppercase dark:text-zinc-400">
Harga
</h5>
<p class="text-gray-800 dark:text-zinc-100">{{ $item['unit_price'] }}</p>
<p class="text-gray-800 dark:text-zinc-100">
{{ $item['quality'] ? '-' : $item['unit_price'] }}</p>
</div>
<div>
<h5 class="sm:hidden text-xs font-medium text-gray-500 uppercase dark:text-zinc-400">

View File

@ -328,18 +328,23 @@
$this->outlet->products()->attach($product->id, ['stock' => 10]);
$memberUser = User::factory()->create();
$memberUser->customer()->create([
$customer = $memberUser->customer()->create([
'name' => 'Voucher User',
'gender' => Gender::MALE->value,
]);
$memberUser->membership()->create(['tier_id' => $this->tier->id]);
$memberUser->membership()->create([
'tier_id' => $this->tier->id,
'total_spending' => 0,
'reward_points' => 0,
]);
$voucher = Voucher::factory()->create([
'type' => VoucherType::FIXED->value,
'discount_amount' => 20000,
'start_date' => now()->subDay()->format('Y-m-d'), // Make it active
]);
// Assign voucher to user
// Assign voucher to user and outlet
DB::table('user_voucher')->insert([
'user_id' => $memberUser->id,
'voucher_id' => $voucher->id,
@ -348,6 +353,8 @@
'updated_at' => now(),
]);
$voucher->outlets()->attach($this->outlet->id);
OrderItem::factory()->forProduct($product)->create([
'user_id' => $this->user->id,
'order_id' => null,
@ -360,13 +367,16 @@
->set('form.outlet_id', $this->outlet->id)
->set('form.member_id', $memberUser->customer->id)
->set('form.voucher_id', $voucher->id)
->set('form.payment_method', '1') // Cash
->call('save')
->assertHasNoErrors();
->assertHasNoErrors()
->assertRedirect();
$this->assertDatabaseHas('orders', [
'voucher_id' => $voucher->id,
'total' => 80000,
'discount' => 20000,
'discount' => 0, // Manual discount
'voucher_discount' => 20000, // Voucher discount
]);
// Check voucher used up