- Added existing_material_ids to PurchaseForEdit and RestockForEdit types. - Introduced RawMaterialVariant and ProductVariantForRestock types for better variant handling. - Updated PurchaseCreate and PurchaseEdit components to fetch and display raw material variants. - Enhanced RestockCreate and RestockEdit components to manage product variants dynamically. - Modified TransactionCreate and TransactionEdit components to support product variants. - Added routes for fetching active products and raw materials.
577 lines
25 KiB
PHP
577 lines
25 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Admin\Manage;
|
|
|
|
use App\Enums\CashTransactionType;
|
|
use App\Enums\OrderChannel;
|
|
use App\Enums\OrderStatus;
|
|
use App\Enums\PaymentType;
|
|
use App\Enums\PriceType;
|
|
use App\Enums\ProductStockQuality;
|
|
use App\Enums\Role;
|
|
use App\Models\Customer;
|
|
use App\Models\Order;
|
|
use App\Models\OrderItem;
|
|
use App\Models\ProductVariant;
|
|
use App\Models\User;
|
|
use App\Services\Concerns\HasStockAdjustment;
|
|
use App\Services\Concerns\HandlesCashTransactions;
|
|
use App\Services\Concerns\LogsFormHistory;
|
|
use App\Services\Concerns\RegistersMedia;
|
|
use App\Services\NotificationService;
|
|
use App\Services\S3PresignedService;
|
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Validation\ValidationException;
|
|
|
|
class TransactionService
|
|
{
|
|
use HasStockAdjustment, HandlesCashTransactions, LogsFormHistory, RegistersMedia;
|
|
|
|
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,
|
|
PriceType::REJECT_SELLING->value => PriceType::REJECT_SELLING,
|
|
];
|
|
|
|
public function __construct(
|
|
private S3PresignedService $s3Service,
|
|
) {}
|
|
|
|
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = [], ?User $user = null, ?int $highlight = null): LengthAwarePaginator
|
|
{
|
|
$itemsCountQuery = '(SELECT COUNT(*) FROM order_items WHERE order_items.order_id = orders.id AND order_items.deleted_at IS NULL)';
|
|
$totalQtyQuery = '(SELECT IFNULL(SUM(quantity), 0) FROM order_items WHERE order_items.order_id = orders.id AND order_items.deleted_at IS NULL)';
|
|
$productNamesQuery = '(SELECT GROUP_CONCAT(DISTINCT p.name ORDER BY p.name SEPARATOR \', \') FROM order_items oi JOIN product_variants pv ON pv.id = oi.product_variant_id JOIN products p ON p.id = pv.product_id WHERE oi.order_id = orders.id AND oi.deleted_at IS NULL)';
|
|
|
|
$paginator = Order::query()
|
|
->select(['id', 'created_by_id', 'customer_id', 'marketing_id', 'order_number', 'channel', 'price_type', 'status', 'payment_type', 'tiktok_order_id', 'shopee_order_id', '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',
|
|
])
|
|
->selectRaw("{$itemsCountQuery} as items_count")
|
|
->selectRaw("{$totalQtyQuery} as total_qty")
|
|
->selectRaw("{$productNamesQuery} as product_names")
|
|
->when($highlight, fn ($q) => $q->where('id', $highlight))
|
|
->when($user && $this->isMarketingUser($user), fn ($q) => $q->where('marketing_id', $user->id))
|
|
->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}%")
|
|
->orWhere('shopee_order_id', 'like', "%{$search}%")
|
|
->orWhere('tiktok_order_id', '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))
|
|
->when($filters['date_from'] ?? null, fn ($q, $dateFrom) => $q->whereDate('created_at', '>=', $dateFrom))
|
|
->when($filters['date_to'] ?? null, fn ($q, $dateTo) => $q->whereDate('created_at', '<=', $dateTo))
|
|
->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();
|
|
|
|
$orderMedia = $order->getFirstMedia('photos');
|
|
$order->photo_url = $orderMedia
|
|
? $this->s3Service->getTemporaryUrl($orderMedia->getPath())
|
|
: null;
|
|
$order->photo_conversion_url = $orderMedia
|
|
? ($orderMedia->getGeneratedConversions()->contains('thumb')
|
|
? $this->s3Service->getTemporaryUrl($orderMedia->getPath('thumb'))
|
|
: $this->s3Service->getTemporaryUrl($orderMedia->getPath()))
|
|
: null;
|
|
|
|
$order->profit = $order->total_amount - $order->cogs;
|
|
});
|
|
|
|
return $paginator;
|
|
}
|
|
|
|
public function getForEdit(Order $order): array
|
|
{
|
|
$order->load([
|
|
'orderItems:id,order_id,product_variant_id,stock_quality,quantity,unit_price',
|
|
'orderItems.productVariant:id,product_id,name',
|
|
]);
|
|
|
|
$stockType = $order->orderItems->first()?->stock_quality?->value ?? 'good';
|
|
|
|
$orderMedia = $order->getFirstMedia('photos');
|
|
|
|
return [
|
|
'id' => $order->id,
|
|
'order_number' => $order->order_number,
|
|
'stock_type' => $stockType,
|
|
'channel' => $order->channel->value,
|
|
'price_type' => $order->price_type->value,
|
|
'payment_type' => $order->payment_type->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,
|
|
'tiktok_order_id' => $order->tiktok_order_id,
|
|
'shopee_order_id' => $order->shopee_order_id,
|
|
'notes' => $order->notes,
|
|
'photo_key' => $orderMedia?->getCustomProperty('s3_key') ?? $orderMedia?->file_name,
|
|
'photo_url' => $orderMedia
|
|
? $this->s3Service->getTemporaryUrl($orderMedia->getPath())
|
|
: 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,
|
|
]),
|
|
'existing_product_ids' => $order->orderItems
|
|
->map(fn (OrderItem $item) => $item->productVariant?->product_id)
|
|
->filter()
|
|
->unique()
|
|
->values()
|
|
->all(),
|
|
];
|
|
}
|
|
|
|
public function getItems(Order $order): \Illuminate\Support\Collection
|
|
{
|
|
return $order->orderItems()
|
|
->select(['id', 'order_id', 'product_variant_id', 'stock_quality', 'quantity', 'unit_price', 'subtotal'])
|
|
->with(['productVariant:id,product_id,name', 'productVariant.product:id,name'])
|
|
->get()
|
|
->each(function (OrderItem $item) {
|
|
if (! $item->productVariant) {
|
|
return;
|
|
}
|
|
|
|
$media = $item->productVariant->getFirstMedia('images');
|
|
$item->productVariant->photo_url = $media
|
|
? $this->s3Service->getTemporaryUrl($media->getPath())
|
|
: null;
|
|
$item->productVariant->photo_conversion_url = $media
|
|
? ($media->getGeneratedConversions()->contains('thumb')
|
|
? $this->s3Service->getTemporaryUrl($media->getPath('thumb'))
|
|
: $this->s3Service->getTemporaryUrl($media->getPath()))
|
|
: null;
|
|
});
|
|
}
|
|
|
|
public function getFilterOptions(): array
|
|
{
|
|
return [
|
|
'statusOptions' => OrderStatus::toSelect(),
|
|
'channelOptions' => OrderChannel::toSelect(),
|
|
'paymentTypeOptions' => PaymentType::toSelect(),
|
|
'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 store(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);
|
|
|
|
if ($totalAmount <= 0) {
|
|
throw ValidationException::withMessages([
|
|
'discount' => 'Total harga tidak boleh nol atau kurang.',
|
|
]);
|
|
}
|
|
|
|
$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,
|
|
'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) {
|
|
$cashTransaction = $this->creditCash(
|
|
amount: $totalAmount,
|
|
description: 'Pembayaran tunai: '.$order->order_number,
|
|
type: CashTransactionType::TRANSACTION,
|
|
);
|
|
$order->update(['cash_transaction_id' => $cashTransaction->id]);
|
|
} else {
|
|
$this->syncPhoto($order, $data);
|
|
}
|
|
|
|
NotificationService::notify(
|
|
roles: [Role::OWNER, Role::DEVELOPER, Role::ADMIN_TOKO],
|
|
title: 'Transaksi Baru',
|
|
body: 'Transaksi '.$order->order_number.' sebesar Rp '.$order->formatted_total_amount.' berhasil dicatat oleh '.auth()->user()->full_name.'.',
|
|
url: route('admin.manage.transactions.index', ['highlight' => $order->id]),
|
|
);
|
|
|
|
$this->logCreated($order, 'Transaksi', $this->getOrderLogValues($order));
|
|
|
|
return $order;
|
|
});
|
|
}
|
|
|
|
public function update(Order $order, array $data): Order
|
|
{
|
|
$oldValues = $this->getOrderLogValues($order);
|
|
|
|
$order = DB::transaction(function () use ($order, $data) {
|
|
$order->load('orderItems');
|
|
|
|
$oldPaymentType = $order->payment_type;
|
|
$oldStockType = $order->orderItems->first()?->stock_quality?->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);
|
|
|
|
if ($totalAmount <= 0) {
|
|
throw ValidationException::withMessages([
|
|
'discount' => 'Total harga tidak boleh nol atau kurang.',
|
|
]);
|
|
}
|
|
|
|
foreach ($itemRows as &$row) {
|
|
$row['order_id'] = $order->id;
|
|
}
|
|
DB::table('order_items')->insert($itemRows);
|
|
|
|
$newPaymentType = $data['payment_type'] ?? $order->payment_type->value;
|
|
|
|
$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' => $newPaymentType,
|
|
'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);
|
|
|
|
if ($oldPaymentType === PaymentType::CASH && $newPaymentType !== PaymentType::CASH->value) {
|
|
if ($order->cash_transaction_id) {
|
|
$order->cashTransaction()->delete();
|
|
$order->update(['cash_transaction_id' => null]);
|
|
}
|
|
} elseif ($oldPaymentType !== PaymentType::CASH && $newPaymentType === PaymentType::CASH->value) {
|
|
$cashTransaction = $this->creditCash(
|
|
amount: $totalAmount,
|
|
description: 'Pembayaran tunai: '.$order->order_number,
|
|
type: CashTransactionType::TRANSACTION,
|
|
);
|
|
$order->update(['cash_transaction_id' => $cashTransaction->id]);
|
|
} elseif ($oldPaymentType === PaymentType::CASH && $newPaymentType === PaymentType::CASH->value && $order->cash_transaction_id) {
|
|
$cashTransaction = $order->cashTransaction;
|
|
$oldAmount = $cashTransaction->amount;
|
|
$difference = $totalAmount - $oldAmount;
|
|
|
|
if ($difference !== 0) {
|
|
$cashAccount = \App\Models\CashAccount::firstOrFail();
|
|
$newBalance = $cashAccount->balance + $difference;
|
|
$cashAccount->update(['balance' => $newBalance]);
|
|
|
|
$cashTransaction->update([
|
|
'amount' => $totalAmount,
|
|
'balance_after' => $newBalance,
|
|
]);
|
|
}
|
|
}
|
|
|
|
if ($newPaymentType === PaymentType::CASH->value) {
|
|
$order->clearMediaCollection('photos');
|
|
} else {
|
|
$this->syncPhoto($order, $data);
|
|
}
|
|
|
|
return $order;
|
|
});
|
|
|
|
NotificationService::notify(
|
|
roles: [Role::OWNER, Role::DEVELOPER, Role::ADMIN_TOKO],
|
|
title: 'Transaksi Diperbarui',
|
|
body: 'Transaksi '.$order->order_number.' sebesar '.$order->formatted_total_amount.' berhasil diperbarui oleh '.auth()->user()->full_name.'.',
|
|
url: route('admin.manage.transactions.index', ['highlight' => $order->id]),
|
|
);
|
|
|
|
$this->logUpdated($order, 'Transaksi', $oldValues, $this->getOrderLogValues($order));
|
|
|
|
return $order;
|
|
}
|
|
|
|
public function destroy(Order $order): bool
|
|
{
|
|
$oldValues = $this->getOrderLogValues($order);
|
|
|
|
$result = DB::transaction(function () use ($order) {
|
|
$order->load('orderItems');
|
|
|
|
if (! in_array($order->status, [OrderStatus::CANCELLED, OrderStatus::REFUNDED])) {
|
|
$stockType = $order->orderItems->first()?->stock_quality?->value ?? ProductStockQuality::GOOD->value;
|
|
|
|
$order->orderItems->each(function (OrderItem $item) use ($stockType) {
|
|
$this->adjustVariantStock($item->product_variant_id, $item->quantity, 1, $stockType);
|
|
});
|
|
}
|
|
|
|
if ($order->payment_type === PaymentType::CASH && $order->cash_transaction_id) {
|
|
$this->debitCash(
|
|
amount: $order->total_amount,
|
|
description: 'Pembatalan transaksi: '.$order->order_number,
|
|
type: CashTransactionType::EXPENSE,
|
|
);
|
|
}
|
|
|
|
$order->orderItems()->delete();
|
|
$order->delete();
|
|
|
|
return true;
|
|
});
|
|
|
|
NotificationService::notify(
|
|
roles: [Role::OWNER, Role::DEVELOPER, Role::ADMIN_TOKO],
|
|
title: 'Transaksi Dihapus',
|
|
body: 'Transaksi '.$order->order_number.' berhasil dihapus oleh '.auth()->user()->full_name.'.',
|
|
url: route('admin.manage.transactions.index'),
|
|
);
|
|
|
|
$this->logDeleted($order, 'Transaksi', $oldValues);
|
|
|
|
return $result;
|
|
}
|
|
|
|
public function updateStatus(Order $order, string $status): Order
|
|
{
|
|
$oldValues = $this->getOrderLogValues($order);
|
|
$oldStatus = $order->status->value;
|
|
|
|
$order->update(['status' => $status]);
|
|
|
|
if (in_array($status, [OrderStatus::CANCELLED->value, OrderStatus::REFUNDED->value]) && ! in_array($oldStatus, [OrderStatus::CANCELLED->value, OrderStatus::REFUNDED->value])) {
|
|
$order->load('orderItems');
|
|
$stockType = $order->orderItems->first()?->stock_quality?->value ?? ProductStockQuality::GOOD->value;
|
|
|
|
$order->orderItems->each(function (OrderItem $item) use ($stockType) {
|
|
$this->adjustVariantStock($item->product_variant_id, $item->quantity, 1, $stockType);
|
|
});
|
|
|
|
if ($order->payment_type === PaymentType::CASH && $order->cash_transaction_id) {
|
|
$this->debitCash(
|
|
amount: $order->total_amount,
|
|
description: 'Pembatalan transaksi: '.$order->order_number,
|
|
type: CashTransactionType::EXPENSE,
|
|
);
|
|
}
|
|
}
|
|
|
|
$this->logUpdated($order, 'Transaksi', $oldValues, $this->getOrderLogValues($order));
|
|
|
|
return $order;
|
|
}
|
|
|
|
private function buildItemRows(array $items, string $stockType, string $priceType, $now, int &$subtotal, int &$totalCost): array
|
|
{
|
|
$resolvedPriceType = $stockType === ProductStockQuality::REJECT->value
|
|
? PriceType::REJECT_SELLING
|
|
: (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', 'product:id,name'])
|
|
->get();
|
|
|
|
$variantLabels = $variants->mapWithKeys(fn (ProductVariant $v) => [
|
|
$v->id => $v->product->name.' - '.$v->name,
|
|
]);
|
|
|
|
$prices = $variants->mapWithKeys(function (ProductVariant $variant) use ($resolvedPriceType) {
|
|
$price = $variant->productPrices
|
|
->first(fn ($p) => $p->type === $resolvedPriceType);
|
|
|
|
return [$variant->id => $price?->price ?? 0];
|
|
});
|
|
|
|
$capitalPriceType = $stockType === ProductStockQuality::REJECT->value
|
|
? PriceType::REJECT_CAPITAL
|
|
: PriceType::CAPITAL;
|
|
|
|
$capitalPrices = $variants->mapWithKeys(function (ProductVariant $variant) use ($capitalPriceType) {
|
|
$price = $variant->productPrices
|
|
->first(fn ($p) => $p->type === $capitalPriceType);
|
|
|
|
return [$variant->id => $price?->price ?? 0];
|
|
});
|
|
|
|
return collect($items)->map(function ($item) use ($now, $prices, $capitalPrices, $variantLabels, $stockType, &$subtotal, &$totalCost) {
|
|
$quantity = (int) $item['quantity'];
|
|
$unitPrice = (int) ($prices[$item['product_variant_id']] ?? 0);
|
|
$label = $variantLabels[$item['product_variant_id']] ?? ' Produk';
|
|
|
|
if ($unitPrice <= 0) {
|
|
throw ValidationException::withMessages([
|
|
'items' => 'Harga untuk "'.$label.'" belum diatur.',
|
|
]);
|
|
}
|
|
|
|
$itemSubtotal = $unitPrice * $quantity;
|
|
$subtotal += $itemSubtotal;
|
|
|
|
$capitalPrice = (int) ($capitalPrices[$item['product_variant_id']] ?? 0);
|
|
|
|
if ($capitalPrice <= 0) {
|
|
throw ValidationException::withMessages([
|
|
'items' => 'Harga modal untuk "'.$label.'" belum diatur.',
|
|
]);
|
|
}
|
|
|
|
$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 generateOrderNumber(): string
|
|
{
|
|
$prefix = 'TRX';
|
|
$date = now()->format('ymd');
|
|
$lastOrder = Order::withTrashed()
|
|
->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);
|
|
}
|
|
|
|
private function isMarketingUser(User $user): bool
|
|
{
|
|
return $user->hasAnyRole([
|
|
Role::MARKETING_OFFLINE->value,
|
|
Role::MARKETING_ONLINE->value,
|
|
]);
|
|
}
|
|
|
|
private function getOrderLogValues(Order $order): array
|
|
{
|
|
$order->load(['customer:id,name', 'marketing:id', 'marketing.userProfile:id,user_id,full_name', 'orderItems.productVariant.product:id,name']);
|
|
|
|
return [
|
|
'No. Transaksi' => $order->order_number,
|
|
'Customer' => $order->customer?->name ?? '-',
|
|
'Marketing' => $order->marketing?->userProfile?->full_name ?? '-',
|
|
'Channel' => $order->channel?->label(),
|
|
'Tipe Harga' => $order->price_type?->label(),
|
|
'Status' => $order->status?->label(),
|
|
'Tipe Pembayaran' => $order->payment_type?->label(),
|
|
'Subtotal' => $this->formatCurrency($order->subtotal),
|
|
'Diskon' => $this->formatCurrency($order->discount),
|
|
'Harga Nego' => $order->nego_price ? $this->formatCurrency($order->nego_price) : null,
|
|
'Total' => $this->formatCurrency($order->total_amount),
|
|
'Catatan' => $order->notes,
|
|
'Item' => $order->orderItems->map(fn ($item) => [
|
|
'Nama' => $item->productVariant?->product?->name.' - '.$item->productVariant?->name ?? '-',
|
|
'Qty' => $item->quantity,
|
|
'Harga' => $this->formatCurrency($item->unit_price),
|
|
'Subtotal' => $this->formatCurrency($item->subtotal),
|
|
])->toArray(),
|
|
];
|
|
}
|
|
}
|