dstpabuaran.com/app/Services/Admin/Manage/TransactionService.php
Yoga Pangestu 72848d51d8 feat: add transaction management page with filtering and CRUD functionality
- Implemented TransactionIndex component for displaying transactions with pagination and filtering options.
- Created TransactionCardRow component for rendering individual transaction details.
- Added TransactionItemSubRow component for displaying detailed order items within a transaction.
- Integrated delete confirmation dialog for transaction deletion.
- Updated routes to include transaction management with appropriate permissions.
2026-08-04 10:37:28 +07:00

441 lines
18 KiB
PHP

<?php
namespace App\Services\Admin\Manage;
use App\Enums\OrderChannel;
use App\Enums\OrderStatus;
use App\Enums\PaymentType;
use App\Enums\PriceType;
use App\Enums\ProductStockQuality;
use App\Models\Customer;
use App\Models\Order;
use App\Models\OrderItem;
use App\Models\Product;
use App\Models\ProductVariant;
use App\Models\User;
use App\Services\Concerns\RegistersMedia;
use App\Services\NotificationService;
use App\Services\S3PresignedService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
class TransactionService
{
use RegistersMedia;
private const QUALITY_STOCK_MAP = [
ProductStockQuality::GOOD->value => 'stock',
ProductStockQuality::REJECT->value => 'reject_stock',
];
private const SELLING_PRICE_MAP = [
PriceType::DISTRIBUTOR->value => PriceType::DISTRIBUTOR,
PriceType::AGEN->value => PriceType::AGEN,
PriceType::SUB_AGEN->value => PriceType::SUB_AGEN,
PriceType::WHOLESALE->value => PriceType::WHOLESALE,
PriceType::RETAIL->value => PriceType::RETAIL,
PriceType::TIKTOK->value => PriceType::TIKTOK,
PriceType::SHOPEE->value => PriceType::SHOPEE,
];
public function __construct(
private S3PresignedService $s3Service = new S3PresignedService,
) {}
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
{
$paginator = Order::query()
->select('id', 'created_by_id', 'customer_id', 'marketing_id', 'order_number', 'channel', 'price_type', 'status', 'payment_type', 'subtotal', 'discount', 'nego_price', 'total_amount', 'cogs', 'notes', 'created_at')
->with([
'createdBy:id',
'createdBy.userProfile:id,user_id,full_name',
'customer:id,name',
'marketing:id',
'marketing.userProfile:id,user_id,full_name',
'orderItems:id,order_id,product_variant_id,stock_quality,quantity,unit_price,subtotal',
'orderItems.productVariant:id,product_id,name,stock,reject_stock,retail_stock',
'orderItems.productVariant.product:id,name',
])
->when($search, function ($q) use ($search) {
$q->whereHas('orderItems.productVariant.product', fn ($sq) => $sq->where('name', 'like', "%{$search}%"))
->orWhere('order_number', 'like', "%{$search}%")
->orWhere('notes', 'like', "%{$search}%");
})
->when($filters['status'] ?? null, fn ($q, $status) => $q->where('status', $status))
->when($filters['channel'] ?? null, fn ($q, $channel) => $q->where('channel', $channel))
->when($filters['payment_type'] ?? null, fn ($q, $paymentType) => $q->where('payment_type', $paymentType))
->when($filters['customer_id'] ?? null, fn ($q, $customerId) => $q->where('customer_id', $customerId))
->when($filters['marketing_id'] ?? null, fn ($q, $marketingId) => $q->where('marketing_id', $marketingId))
->when($filters['created_by_id'] ?? null, fn ($q, $createdById) => $q->where('created_by_id', $createdById))
->orderBy($sort, $direction)
->paginate($perPage);
$paginator->getCollection()->each(function (Order $order) {
$order->status_label = $order->status->label();
$order->payment_type_label = $order->payment_type->label();
$order->channel_label = $order->channel->label();
$order->price_type_label = $order->price_type->label();
$order->orderItems->each(function (OrderItem $item) {
if (! $item->productVariant) {
return;
}
$media = $item->productVariant->getFirstMedia('photos');
$item->productVariant->photo_url = $media
? $this->s3Service->getTemporaryUrl($media->file_name)
: null;
});
$order->profit = $order->total_amount - $order->cogs;
});
return $paginator;
}
public function getFilterOptions(): array
{
return [
'customers' => Customer::query()
->select('id', 'name')
->orderBy('name')
->get(),
'employees' => User::query()
->select('id')
->active()
->with('userProfile:id,user_id,full_name')
->orderBy('id')
->get()
->filter(fn (User $user) => $user->userProfile?->full_name)
->values()
->map(fn (User $user) => [
'id' => $user->id,
'name' => $user->userProfile->full_name,
]),
];
}
public function getForCreate(): array
{
return [
'products' => Product::query()
->select('id', 'name', 'status')
->with([
'productVariants:id,product_id,name,stock,reject_stock',
'productVariants.productPrices:id,variant_id,type,price',
])
->orderBy('name')
->get()
->each(function (Product $product) {
$product->productVariants->each(function (ProductVariant $variant) {
$media = $variant->getFirstMedia('photos');
$variant->photo_url = $media
? $this->s3Service->getTemporaryUrl($media->file_name)
: null;
$prices = $variant->productPrices->mapWithKeys(fn ($p) => [$p->type->value => $p->price]);
$variant->prices = $prices;
});
}),
'customers' => Customer::query()
->select('id', 'name')
->orderBy('name')
->get(),
'employees' => User::query()
->select('id')
->active()
->with('userProfile:id,user_id,full_name')
->orderBy('id')
->get()
->filter(fn (User $user) => $user->userProfile?->full_name)
->values(),
'channelOptions' => collect(OrderChannel::cases())->map(fn ($c) => ['value' => $c->value, 'label' => $c->label()])->values(),
'paymentTypeOptions' => collect(PaymentType::cases())->map(fn ($p) => ['value' => $p->value, 'label' => $p->label()])->values(),
'priceTypeOptions' => collect(PriceType::cases())->filter(fn ($p) => ! in_array($p, [PriceType::CAPITAL]))->map(fn ($p) => ['value' => $p->value, 'label' => $p->label()])->values(),
];
}
public function getForEdit(Order $order): array
{
$order->load('orderItems.productVariant.product');
$media = $order->getFirstMedia('photos');
return [
'id' => $order->id,
'order_number' => $order->order_number,
'stock_type' => $order->orderItems->first()?->stock_type?->value ?? ProductStockQuality::GOOD->value,
'channel' => $order->channel?->value ?? OrderChannel::STORE->value,
'price_type' => $order->price_type?->value ?? PriceType::RETAIL->value,
'payment_type' => $order->payment_type?->value ?? PaymentType::CASH->value,
'customer_id' => $order->customer_id,
'marketing_id' => $order->marketing_id,
'discount' => $order->discount,
'nego_price' => $order->nego_price,
'is_completed' => $order->status === OrderStatus::COMPLETED,
'is_affiliate' => $order->is_affiliate,
'tiktok_order_id' => $order->tiktok_order_id,
'shopee_order_id' => $order->shopee_order_id,
'notes' => $order->notes,
'photo_key' => $media?->file_name,
'photo_url' => $media
? $this->s3Service->getTemporaryUrl($media->file_name)
: null,
'items' => $order->orderItems->map(fn (OrderItem $item) => [
'id' => $item->id,
'product_variant_id' => $item->product_variant_id,
'quantity' => $item->quantity,
'unit_price' => $item->unit_price,
])->values(),
];
}
public function create(array $data): Order
{
return DB::transaction(function () use ($data) {
$now = now();
$stockType = $data['stock_type'] ?? ProductStockQuality::GOOD->value;
$priceType = $data['price_type'] ?? PriceType::RETAIL->value;
$channel = $data['channel'] ?? OrderChannel::STORE->value;
$paymentType = $data['payment_type'] ?? PaymentType::CASH->value;
$subtotal = 0;
$totalCost = 0;
$itemRows = $this->buildItemRows($data['items'], $stockType, $priceType, $now, $subtotal, $totalCost);
$discount = (int) ($data['discount'] ?? 0);
$negoPrice = ! empty($data['nego_price']) ? (int) $data['nego_price'] : null;
$totalAmount = $subtotal - $discount + ($negoPrice ?? 0);
$order = Order::create([
'created_by_id' => auth()->id(),
'customer_id' => $data['customer_id'] ?? null,
'marketing_id' => $data['marketing_id'] ?? null,
'order_number' => $this->generateOrderNumber(),
'channel' => $channel,
'price_type' => $priceType,
'status' => ($data['is_completed'] ?? false) ? OrderStatus::COMPLETED : OrderStatus::PENDING,
'payment_type' => $paymentType,
'is_affiliate' => $data['is_affiliate'] ?? false,
'tiktok_order_id' => $data['tiktok_order_id'] ?? null,
'shopee_order_id' => $data['shopee_order_id'] ?? null,
'subtotal' => $subtotal,
'discount' => $discount,
'nego_price' => $negoPrice,
'total_amount' => $totalAmount,
'cogs' => $totalCost,
'notes' => $data['notes'] ?? null,
]);
foreach ($itemRows as &$row) {
$row['order_id'] = $order->id;
}
DB::table('order_items')->insert($itemRows);
$this->applyStock($data['items'], $stockType, -1);
if ($paymentType !== PaymentType::CASH->value) {
$this->syncPhoto($order, $data);
}
NotificationService::notify(
roles: ['Owner', 'Developer', 'Admin Toko'],
title: 'Transaksi Baru',
body: 'Transaksi '.$order->order_number.' sebesar Rp '.number_format($totalAmount, 0, ',', '.').' berhasil dicatat oleh '.auth()->user()->full_name.'.',
url: route('admin.manage.transactions.index'),
);
return $order;
});
}
public function update(Order $order, array $data): Order
{
return DB::transaction(function () use ($order, $data) {
$order->load('orderItems');
$oldStockType = $order->orderItems->first()?->stock_type?->value ?? ProductStockQuality::GOOD->value;
$order->orderItems->each(function (OrderItem $item) use ($oldStockType) {
$this->adjustVariantStock($item->product_variant_id, $item->quantity, 1, $oldStockType);
});
$order->orderItems()->delete();
$now = now();
$stockType = $data['stock_type'] ?? ProductStockQuality::GOOD->value;
$priceType = $data['price_type'] ?? PriceType::RETAIL->value;
$subtotal = 0;
$totalCost = 0;
$itemRows = $this->buildItemRows($data['items'], $stockType, $priceType, $now, $subtotal, $totalCost);
$discount = (int) ($data['discount'] ?? 0);
$negoPrice = ! empty($data['nego_price']) ? (int) $data['nego_price'] : null;
$totalAmount = $subtotal - $discount + ($negoPrice ?? 0);
foreach ($itemRows as &$row) {
$row['order_id'] = $order->id;
}
DB::table('order_items')->insert($itemRows);
$order->update([
'customer_id' => $data['customer_id'] ?? null,
'marketing_id' => $data['marketing_id'] ?? null,
'channel' => $data['channel'] ?? $order->channel->value,
'price_type' => $priceType,
'status' => ($data['is_completed'] ?? false) ? OrderStatus::COMPLETED : OrderStatus::PENDING,
'payment_type' => $data['payment_type'] ?? $order->payment_type->value,
'is_affiliate' => $data['is_affiliate'] ?? false,
'tiktok_order_id' => $data['tiktok_order_id'] ?? null,
'shopee_order_id' => $data['shopee_order_id'] ?? null,
'subtotal' => $subtotal,
'discount' => $discount,
'nego_price' => $negoPrice,
'total_amount' => $totalAmount,
'cogs' => $totalCost,
'notes' => $data['notes'] ?? null,
]);
$this->applyStock($data['items'], $stockType, -1);
$paymentType = $data['payment_type'] ?? $order->payment_type->value;
if ($paymentType !== PaymentType::CASH->value) {
$this->syncPhoto($order, $data);
} else {
$order->clearMediaCollection('photos');
}
return $order;
});
}
public function delete(Order $order): bool
{
return DB::transaction(function () use ($order) {
$order->load('orderItems');
$stockType = $order->orderItems->first()?->stock_type?->value ?? ProductStockQuality::GOOD->value;
$order->orderItems->each(function (OrderItem $item) use ($stockType) {
$this->adjustVariantStock($item->product_variant_id, $item->quantity, 1, $stockType);
});
$order->orderItems()->delete();
$order->clearMediaCollection('photos');
$order->delete();
return true;
});
}
private function buildItemRows(array $items, string $stockType, string $priceType, $now, int &$subtotal, int &$totalCost): array
{
$resolvedPriceType = $stockType === ProductStockQuality::REJECT->value
? PriceType::REJECT
: (self::SELLING_PRICE_MAP[$priceType] ?? PriceType::RETAIL);
$variantIds = collect($items)->pluck('product_variant_id')->unique()->all();
$variants = ProductVariant::query()
->whereKey($variantIds)
->with('productPrices:id,variant_id,type,price')
->get();
$prices = $variants->mapWithKeys(function (ProductVariant $variant) use ($resolvedPriceType) {
$price = $variant->productPrices
->first(fn ($p) => $p->type === $resolvedPriceType);
return [$variant->id => $price?->price ?? 0];
});
$capitalPrices = $variants->mapWithKeys(function (ProductVariant $variant) {
$price = $variant->productPrices
->first(fn ($p) => $p->type === PriceType::CAPITAL);
return [$variant->id => $price?->price ?? 0];
});
return collect($items)->map(function ($item) use ($now, $prices, $capitalPrices, $stockType, &$subtotal, &$totalCost) {
$quantity = (int) $item['quantity'];
$unitPrice = (int) ($prices[$item['product_variant_id']] ?? 0);
$itemSubtotal = $unitPrice * $quantity;
$subtotal += $itemSubtotal;
$capitalPrice = (int) ($capitalPrices[$item['product_variant_id']] ?? 0);
$totalCost += $capitalPrice * $quantity;
return [
'order_id' => null,
'user_id' => auth()->id(),
'product_variant_id' => $item['product_variant_id'],
'stock_quality' => $stockType,
'quantity' => $quantity,
'unit_price' => $unitPrice,
'subtotal' => $itemSubtotal,
'created_at' => $now,
'updated_at' => $now,
];
})->toArray();
}
private function applyStock(array $items, string $stockType, int $sign): void
{
foreach ($items as $item) {
$this->adjustVariantStock($item['product_variant_id'], $item['quantity'], $sign, $stockType);
}
}
private function adjustVariantStock(int $variantId, int $quantity, int $sign, string $stockType): void
{
$field = self::QUALITY_STOCK_MAP[$stockType] ?? 'stock';
if ($sign > 0) {
ProductVariant::whereKey($variantId)->increment($field, $quantity);
} else {
ProductVariant::whereKey($variantId)->decrement($field, $quantity);
}
}
private function syncPhoto(Order $order, array $data): void
{
if (! array_key_exists('photo_key', $data)) {
return;
}
$currentKey = $order->getFirstMedia('photos')?->file_name;
if ($data['photo_key'] === $currentKey) {
return;
}
$order->clearMediaCollection('photos');
if (! empty($data['photo_key'])) {
$this->registerMedia(
model: $order,
s3Key: $data['photo_key'],
collectionName: 'photos',
orderColumn: 1,
);
}
}
private function generateOrderNumber(): string
{
$prefix = 'TRX';
$date = now()->format('ymd');
$lastOrder = Order::where('order_number', 'like', "{$prefix}{$date}%")
->orderByDesc('order_number')
->first();
if ($lastOrder) {
$lastSequence = (int) substr($lastOrder->order_number, -4);
$sequence = $lastSequence + 1;
} else {
$sequence = 1;
}
return $prefix.$date.str_pad($sequence, 4, '0', STR_PAD_LEFT);
}
}