store/app/Services/Manage/OrderService.php
Yoga Pangestu c2c5f12b5c
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
feat: add date filters and summary for order management
2026-08-05 00:30:41 +07:00

1010 lines
40 KiB
PHP

<?php
namespace App\Services\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\CachesQuery;
use App\Services\Concerns\RunsInTransaction;
use App\Services\Finance\CashService;
use App\Services\Media\MediaService;
use App\Services\System\PushNotificationService;
use App\Services\System\Setting\MarketplaceService;
use App\Support\Media\MediaPresenter;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Collection;
use Illuminate\Validation\ValidationException;
class OrderService
{
use CachesQuery, RunsInTransaction;
private const MAX_PHOTOS = 1;
public function __construct(
private readonly MarketplaceService $marketplaceService,
private readonly PushNotificationService $pushNotificationService,
private readonly CashService $cashService,
private readonly CuttingResultPriceResolver $cuttingResultPriceResolver,
private readonly MediaService $mediaService,
private readonly StockMutationService $stockMutationService,
) {}
public function defaultPriceType(OrderChannel $channel): ?PriceType
{
return match ($channel) {
OrderChannel::STORE => null,
OrderChannel::SHOPEE => PriceType::SHOPEE,
OrderChannel::TIKTOK => PriceType::TIKTOK,
};
}
public function stockColumn(ProductStockQuality $quality): string
{
return match ($quality) {
ProductStockQuality::GOOD => 'stock',
ProductStockQuality::REJECT => 'reject_stock',
ProductStockQuality::RETAIL => 'retail_stock',
};
}
public function paginateForIndex(array $tableQuery, User $user): LengthAwarePaginator
{
$query = Order::query()
->with([
'customer:id,name',
'createdBy.profile',
'items.productVariant.product:id,name',
'items.productVariant:id,product_id,name',
'items.productVariant.prices',
])
->when($user->hasAnyRole(['marketing-offline', 'marketing-online']), fn (Builder $query) => $query->where('marketing_id', $user->id))
->when($user->hasRole('cashier'), fn (Builder $query) => $query->where('created_by_id', $user->id))
->when($tableQuery['search'] !== '', function (Builder $query) use ($tableQuery): void {
$search = $tableQuery['search'];
$query->where(function (Builder $query) use ($search): void {
$query->where('order_number', 'like', "%{$search}%")
->orWhere('tiktok_order_id', 'like', "%{$search}%")
->orWhere('shopee_order_id', 'like', "%{$search}%")
->orWhere('notes', 'like', "%{$search}%")
->orWhereHas('customer', fn (Builder $query) => $query->where('name', 'like', "%{$search}%"))
->orWhereHas('items.productVariant', function (Builder $query) use ($search): void {
$query->where('name', 'like', "%{$search}%")
->orWhereHas('product', fn (Builder $query) => $query->where('name', 'like', "%{$search}%"));
});
});
})
->when(($tableQuery['channel'] ?? '') !== '', fn (Builder $query) => $query->where('channel', $tableQuery['channel']))
->when(($tableQuery['status'] ?? '') !== '', fn (Builder $query) => $query->where('status', $tableQuery['status']))
->when(($tableQuery['payment_type'] ?? '') !== '', fn (Builder $query) => $query->where('payment_type', $tableQuery['payment_type']))
->when(($tableQuery['date_from'] ?? '') !== '', fn (Builder $query) => $query->whereDate('created_at', '>=', $tableQuery['date_from']))
->when(($tableQuery['date_to'] ?? '') !== '', fn (Builder $query) => $query->whereDate('created_at', '<=', $tableQuery['date_to']));
$this->applySorting($query, $tableQuery['sort'], $tableQuery['direction']);
return $query
->paginate(25)
->withQueryString()
->through(function (Order $order) use ($user) {
$actions = collect($order->status->availableActions())
->filter(fn (array $action) => $user->can($action['permission']))
->values()
->all();
$order->setAttribute('available_actions', $actions);
$order->setAttribute('is_editable', $order->status->isEditable());
$order->setAttribute(
'marketplace_settings_snapshot',
$this->enrichMarketplaceSnapshot($order->marketplace_settings_snapshot, $order),
);
$this->appendNetAmount($order);
return $order;
});
}
public function summaryForIndex(array $tableQuery, User $user): array
{
$query = Order::query()
->when($user->hasAnyRole(['marketing-offline', 'marketing-online']), fn (Builder $query) => $query->where('marketing_id', $user->id))
->when($user->hasRole('cashier'), fn (Builder $query) => $query->where('created_by_id', $user->id))
->when($tableQuery['search'] !== '', function (Builder $query) use ($tableQuery): void {
$search = $tableQuery['search'];
$query->where(function (Builder $query) use ($search): void {
$query->where('order_number', 'like', "%{$search}%")
->orWhere('tiktok_order_id', 'like', "%{$search}%")
->orWhere('shopee_order_id', 'like', "%{$search}%")
->orWhere('notes', 'like', "%{$search}%")
->orWhereHas('customer', fn (Builder $query) => $query->where('name', 'like', "%{$search}%"))
->orWhereHas('items.productVariant', function (Builder $query) use ($search): void {
$query->where('name', 'like', "%{$search}%")
->orWhereHas('product', fn (Builder $query) => $query->where('name', 'like', "%{$search}%"));
});
});
})
->when(($tableQuery['channel'] ?? '') !== '', fn (Builder $query) => $query->where('channel', $tableQuery['channel']))
->when(($tableQuery['status'] ?? '') !== '', fn (Builder $query) => $query->where('status', $tableQuery['status']))
->when(($tableQuery['payment_type'] ?? '') !== '', fn (Builder $query) => $query->where('payment_type', $tableQuery['payment_type']))
->when(($tableQuery['date_from'] ?? '') !== '', fn (Builder $query) => $query->whereDate('created_at', '>=', $tableQuery['date_from']))
->when(($tableQuery['date_to'] ?? '') !== '', fn (Builder $query) => $query->whereDate('created_at', '<=', $tableQuery['date_to']));
$result = $query->selectRaw('
COUNT(*) as total_orders,
COALESCE(SUM(total_amount), 0) as total_amount,
COALESCE(SUM(subtotal), 0) as total_subtotal,
COALESCE(SUM(discount), 0) as total_discount
')->first();
return [
'total_orders' => (int) $result->total_orders,
'total_amount' => (int) $result->total_amount,
'total_amount_formatted' => 'Rp ' . number_format($result->total_amount, 0, ',', '.'),
'total_subtotal' => (int) $result->total_subtotal,
'total_subtotal_formatted' => 'Rp ' . number_format($result->total_subtotal, 0, ',', '.'),
'total_discount' => (int) $result->total_discount,
'total_discount_formatted' => 'Rp ' . number_format($result->total_discount, 0, ',', '.'),
];
}
public function customerOptions(): array
{
return Customer::query()
->orderBy('name')
->get(['id', 'name'])
->map(fn (Customer $customer) => [
'value' => $customer->id,
'label' => $customer->name,
])
->all();
}
public function marketingOptions(): array
{
return User::query()
->active()
->whereHas('roles', fn (Builder $roleQuery) => $roleQuery->whereIn('name', ['marketing-offline', 'marketing-online']))
->with('profile:user_id,full_name')
->orderBy('username')
->get(['id', 'username'])
->map(fn (User $user) => [
'value' => $user->id,
'label' => $user->profile?->full_name ?? $user->username,
])
->all();
}
public function storePriceTypeOptions(): array
{
return collect(PriceType::cases())
->reject(fn (PriceType $type) => in_array($type, [PriceType::HARGA_MODAL, PriceType::SHOPEE, PriceType::TIKTOK], true))
->map(fn (PriceType $type) => [
'value' => $type->value,
'label' => $type->label(),
])
->values()
->all();
}
public function catalogItems(?Order $order = null, ?User $user = null): Collection
{
$orderVariantIds = $order
? $order->items()->pluck('product_variant_id')->all()
: ($user ? $this->draftItemsQuery($user)->pluck('product_variant_id')->all() : []);
$products = Product::query()
->with([
'variants' => fn ($query) => $query
->with('media')
->where(fn ($q) => $q
->where('stock', '>', 0)
->orWhere('reject_stock', '>', 0)
->orWhere('retail_stock', '>', 0)
->orWhereIn('id', $orderVariantIds)
),
])
->where(function (Builder $query) use ($orderVariantIds): void {
$query->active();
if ($orderVariantIds !== []) {
$query->orWhereHas(
'variants',
fn (Builder $query) => $query->whereIn('id', $orderVariantIds),
);
}
})
->orderBy('name')
->get();
$allVariantIds = $products
->flatMap(fn (Product $product) => $product->variants->pluck('id'))
->all();
$allPricesByVariant = $this->cuttingResultPriceResolver->latestPricesForVariants($allVariantIds);
return $products->each(function (Product $product) use ($allPricesByVariant): void {
$product->variants->each(function (ProductVariant $variant) use ($allPricesByVariant): void {
$variant->setAttribute(
'images',
MediaPresenter::collection($variant, 'images'),
);
$variant->setAttribute(
'prices',
$this->presentVariantPricesFromCollection(
$variant->id,
$allPricesByVariant,
),
);
});
});
}
public function findForEdit(Order $order): Order
{
$order->load([
'items.productVariant.product:id,name',
'items.productVariant.media',
'media',
]);
$order->setAttribute(
'photos',
MediaPresenter::first($order, 'photos'),
);
$variantIds = $order->items
->filter(fn (OrderItem $item) => $item->productVariant !== null)
->map(fn (OrderItem $item) => $item->productVariant->id)
->all();
$allPricesByVariant = $this->cuttingResultPriceResolver->latestPricesForVariants($variantIds);
$order->items->each(function (OrderItem $item) use ($allPricesByVariant): void {
$variant = $item->productVariant;
if ($variant) {
$item->setAttribute('product_name', $variant->product?->name);
$item->setAttribute('variant_name', $variant->name);
$variant->setAttribute(
'images',
MediaPresenter::collection($variant, 'images'),
);
$variant->setAttribute(
'prices',
$this->presentVariantPricesFromCollection(
$variant->id,
$allPricesByVariant,
),
);
}
});
return $order;
}
public function findForShow(Order $order): Order
{
$order->load([
'customer:id,name,phone_number,address',
'createdBy.profile',
'marketing.profile',
'items.productVariant.product:id,name',
'items.productVariant.media',
'items.productVariant.prices',
'cashTransaction:id,amount,description,created_at',
'media',
]);
$order->setAttribute(
'photos',
MediaPresenter::first($order, 'photos'),
);
$order->items->each(function (OrderItem $item): void {
$variant = $item->productVariant;
if ($variant) {
$item->setAttribute('product_name', $variant->product?->name);
$item->setAttribute('variant_name', $variant->name);
$variant->setAttribute(
'images',
MediaPresenter::collection($variant, 'images'),
);
}
});
$availableActions = collect($order->status->availableActions())
->values()
->all();
$order->setAttribute('available_actions', $availableActions);
$order->setAttribute('is_editable', $order->status->isEditable());
$order->setAttribute(
'marketplace_settings_snapshot',
$this->enrichMarketplaceSnapshot($order->marketplace_settings_snapshot, $order),
);
$this->appendNetAmount($order);
return $order;
}
public function draftItemsForUser(User $user): array
{
return $this->draftItemsQuery($user)
->with([
'productVariant.product:id,name',
'productVariant.media',
])
->get()
->map(fn (OrderItem $item) => $this->presentDraftItem($item))
->values()
->all();
}
public function syncDraftItem(array $validated, User $user): array
{
// Force retail price type and stock quality for cashier role
$priceType = $user->hasRole('cashier')
? PriceType::RETAIL
: PriceType::from($validated['price_type']);
$stockQuality = $user->hasRole('cashier')
? ProductStockQuality::RETAIL
: ProductStockQuality::from($validated['stock_quality']);
$variant = ProductVariant::query()->findOrFail($validated['product_variant_id']);
$unitPrice = $this->resolveUnitPrice($variant->id, $priceType);
$quantity = (int) $validated['quantity'];
$availableStock = $this->availableStock($variant, $stockQuality);
if ($quantity > $availableStock) {
throw ValidationException::withMessages([
'quantity' => "Stok {$stockQuality->label()} tidak mencukupi. Stok saat ini: {$availableStock} pcs.",
]);
}
$subtotal = $unitPrice * $quantity;
$item = OrderItem::query()->updateOrCreate(
[
'user_id' => $user->id,
'product_variant_id' => $variant->id,
'stock_quality' => $stockQuality->value,
'order_id' => null,
],
[
'quantity' => $quantity,
'unit_price' => $unitPrice,
'subtotal' => $subtotal,
],
);
$item->load([
'productVariant.product:id,name',
'productVariant.media',
]);
return $this->presentDraftItem($item);
}
public function removeDraftItem(User $user, ProductVariant $productVariant, ProductStockQuality $stockQuality): void
{
OrderItem::query()
->whereNull('order_id')
->where('user_id', $user->id)
->where('product_variant_id', $productVariant->id)
->where('stock_quality', $stockQuality->value)
->delete();
}
public function resyncDraftPrices(User $user, string $priceTypeValue): array
{
// Force retail price type for cashier role
$priceType = $user->hasRole('cashier')
? PriceType::RETAIL
: PriceType::from($priceTypeValue);
$items = $this->draftItemsQuery($user)
->with([
'productVariant.product:id,name',
'productVariant.media',
])
->get();
foreach ($items as $item) {
$price = $this->cuttingResultPriceResolver->resolve($item->product_variant_id, $priceType);
if ($price === null) {
$item->delete();
continue;
}
$unitPrice = (int) $price->price;
$item->unit_price = $unitPrice;
$item->subtotal = $unitPrice * $item->quantity;
$item->save();
}
return $this->draftItemsForUser($user);
}
public function create(array $validated, User $user): Order
{
// Force cashier settings
if ($user->hasRole('cashier')) {
$validated['channel'] = 'store';
$validated['price_type'] = 'retail';
$validated['payment_type'] = $validated['payment_type'] ?? 'cash';
$validated['status'] = OrderStatus::COMPLETED->value;
unset($validated['customer_id']);
unset($validated['marketing_id']);
}
$order = $this->runInTransaction(
function () use ($validated, $user): Order {
$priceType = $this->resolvePriceType($validated['channel'], $validated['price_type']);
$draftItems = $this->draftItemsQuery($user)
->lockForUpdate()
->get();
if ($draftItems->isEmpty()) {
throw ValidationException::withMessages([
'items' => 'Tambahkan minimal satu produk ke keranjang.',
]);
}
$this->applyDraftPrices($draftItems, $priceType);
$subtotal = $draftItems->sum('subtotal');
$discount = (int) ($validated['discount'] ?? 0);
$negoPrice = isset($validated['nego_price']) && $validated['nego_price'] !== '' ? (int) $validated['nego_price'] : null;
$totalAmount = $negoPrice !== null ? max($negoPrice, 0) : max($subtotal - $discount, 0);
$channel = OrderChannel::from($validated['channel']);
$status = isset($validated['status'])
? OrderStatus::from($validated['status'])
: OrderStatus::PENDING;
$order = Order::create([
'customer_id' => $validated['customer_id'] ?? null,
'marketing_id' => $validated['marketing_id'] ?? null,
'channel' => $channel,
'price_type' => $priceType,
'payment_type' => PaymentType::from($validated['payment_type']),
'is_affiliate' => $validated['is_affiliate'] ?? false,
'status' => $status,
'tiktok_order_id' => $validated['tiktok_order_id'] ?? null,
'shopee_order_id' => $validated['shopee_order_id'] ?? null,
'created_by_id' => $user->id,
'subtotal' => $subtotal,
'discount' => $discount,
'nego_price' => $negoPrice,
'marketplace_settings_snapshot' => $this->marketplaceService->buildOrderSnapshot(
$channel,
$totalAmount,
$this->lineItemsForSnapshot($draftItems),
$validated['is_affiliate'] ?? false,
),
'total_amount' => $totalAmount,
'notes' => $validated['notes'] ?? null,
]);
if ($order->payment_type === PaymentType::CASH) {
$cashTransaction = $this->cashService->recordIncoming(
$order,
$totalAmount,
"Pembayaran pesanan {$order->order_number}",
$user,
);
$order->cash_transaction_id = $cashTransaction->id;
$order->save();
}
$this->syncPhotos($order, $validated);
foreach ($draftItems as $item) {
$variant = ProductVariant::query()->with('product')->lockForUpdate()->find($item->product_variant_id);
if ($variant === null) {
throw ValidationException::withMessages([
'items' => 'Varian produk tidak ditemukan.',
]);
}
$stockQuality = $item->stock_quality ?? ProductStockQuality::GOOD;
$availableStock = $this->availableStock($variant, $stockQuality);
if ($availableStock < $item->quantity) {
throw ValidationException::withMessages([
'items' => "Stok {$stockQuality->label()} produk {$variant->product->name} ({$variant->name}) tidak mencukupi. Stok saat ini: {$availableStock} pcs.",
]);
}
$item->order_id = $order->id;
$item->save();
$this->decrementStock($item);
}
return $order;
},
'Gagal membuat pesanan',
);
$this->pushNotificationService->sendToRoles(
'📦 Pesanan Baru',
"Pesanan baru {$order->order_number} senilai {$order->total_amount_formatted} telah dibuat oleh {$user->profile?->full_name}.",
['owner', 'developer', 'direktur'],
route('admin.manage.orders.index'),
);
$this->cacheForgetByPattern('manage:orders:*');
return $order;
}
public function update(Order $order, array $validated, User $user): void
{
$order->ensureEditable();
$this->runInTransaction(
function () use ($order, $validated): void {
$order->load('items');
foreach ($order->items as $item) {
$this->incrementStock($item);
}
$order->items()->delete();
$priceType = $this->resolvePriceType($validated['channel'], $validated['price_type']);
$lineItems = $this->buildLineItems($validated['items'], $priceType);
$subtotal = array_sum(array_column($lineItems, 'subtotal'));
$discount = (int) ($validated['discount'] ?? 0);
$negoPrice = isset($validated['nego_price']) && $validated['nego_price'] !== '' ? (int) $validated['nego_price'] : null;
$totalAmount = $negoPrice !== null ? max($negoPrice, 0) : max($subtotal - $discount, 0);
$channel = OrderChannel::from($validated['channel']);
$status = $order->status;
if (isset($validated['status'])) {
$newStatus = OrderStatus::from($validated['status']);
if ($order->status->canTransitionTo($newStatus) || $newStatus === $order->status) {
$status = $newStatus;
}
}
$order->update([
'customer_id' => $validated['customer_id'] ?? null,
'marketing_id' => $validated['marketing_id'] ?? null,
'channel' => $channel,
'price_type' => $priceType,
'payment_type' => PaymentType::from($validated['payment_type']),
'is_affiliate' => $validated['is_affiliate'] ?? false,
'tiktok_order_id' => $validated['tiktok_order_id'] ?? null,
'shopee_order_id' => $validated['shopee_order_id'] ?? null,
'subtotal' => $subtotal,
'discount' => $discount,
'nego_price' => $negoPrice,
'marketplace_settings_snapshot' => $this->marketplaceService->buildOrderSnapshot(
$channel,
$totalAmount,
$this->lineItemsForSnapshot($lineItems),
$validated['is_affiliate'] ?? false,
),
'total_amount' => $totalAmount,
'notes' => $validated['notes'] ?? null,
'status' => $status,
]);
$this->syncPhotos($order, $validated);
foreach ($lineItems as $itemData) {
$variant = ProductVariant::query()->with('product')->lockForUpdate()->find($itemData['product_variant_id']);
if ($variant === null) {
throw ValidationException::withMessages([
'items' => 'Varian produk tidak ditemukan.',
]);
}
$stockQuality = ProductStockQuality::from($itemData['stock_quality']);
$availableStock = $this->availableStock($variant, $stockQuality);
if ($availableStock < $itemData['quantity']) {
throw ValidationException::withMessages([
'items' => "Stok {$stockQuality->label()} produk {$variant->product->name} ({$variant->name}) tidak mencukupi. Stok saat ini: {$availableStock} pcs.",
]);
}
$orderItem = $order->items()->create($itemData);
$this->decrementStock($orderItem);
}
},
'Gagal memperbarui pesanan',
);
$this->pushNotificationService->sendToRoles(
'✏️ Pesanan Diperbarui',
"Pesanan {$order->order_number} senilai {$order->total_amount_formatted} telah diperbarui oleh {$user->profile?->full_name}.",
['owner', 'developer', 'direktur'],
route('admin.manage.orders.index'),
);
$this->cacheForgetByPattern('manage:orders:*');
}
public function delete(Order $order, User $user): void
{
$orderNumber = $order->order_number;
$totalAmount = $order->total_amount;
$this->runInTransaction(
function () use ($order): void {
$order->load('items');
if ($order->status->isEditable()) {
foreach ($order->items as $item) {
$this->incrementStock($item);
}
}
if ($order->cashTransaction) {
$this->cashService->deleteReferencedTransaction($order->cashTransaction);
}
$order->items()->delete();
$order->delete();
},
'Gagal menghapus pesanan',
);
$this->pushNotificationService->sendToRoles(
'🗑️ Pesanan Dihapus',
"Pesanan {$orderNumber} senilai {$order->total_amount_formatted} telah dihapus oleh {$user->profile?->full_name}.",
['owner', 'developer', 'direktur'],
route('admin.manage.orders.index'),
);
$this->cacheForgetByPattern('manage:orders:*');
}
public function transitionStatus(Order $order, OrderStatus $status, User $user): void
{
if (! $order->status->canTransitionTo($status)) {
throw ValidationException::withMessages([
'status' => 'Status pesanan tidak dapat diubah.',
]);
}
$this->runInTransaction(
function () use ($order, $status): void {
if ($status === OrderStatus::CANCELLED) {
$order->load('items');
foreach ($order->items as $item) {
$this->incrementStock($item);
}
if ($order->cashTransaction) {
$this->cashService->deleteReferencedTransaction($order->cashTransaction);
$order->cash_transaction_id = null;
}
}
$order->status = $status;
$order->save();
},
'Gagal mengubah status pesanan',
);
$this->pushNotificationService->sendToRoles(
'📦 Status Pesanan Diubah',
"Pesanan {$order->order_number} diubah statusnya menjadi {$status->label()} oleh {$user->profile?->full_name}.",
['owner', 'developer', 'direktur'],
route('admin.manage.orders.index'),
);
$this->cacheForgetByPattern('manage:orders:*');
}
private function buildLineItems(array $items, PriceType $priceType): array
{
return collect($items)
->map(function (array $itemData, int $index) use ($priceType) {
$variant = ProductVariant::query()->find($itemData['product_variant_id']);
if ($variant === null) {
throw ValidationException::withMessages([
"items.{$index}.product_variant_id" => 'Varian produk tidak ditemukan.',
]);
}
$unitPrice = $this->resolveUnitPrice($variant->id, $priceType, "items.{$index}.product_variant_id");
$quantity = (int) $itemData['quantity'];
$stockQuality = ProductStockQuality::from($itemData['stock_quality'] ?? ProductStockQuality::GOOD->value);
if ($quantity < 1) {
throw ValidationException::withMessages([
"items.{$index}.quantity" => 'Jumlah minimal 1 pcs.',
]);
}
$subtotal = $unitPrice * $quantity;
return [
'product_variant_id' => $variant->id,
'stock_quality' => $stockQuality->value,
'quantity' => $quantity,
'unit_price' => $unitPrice,
'subtotal' => $subtotal,
];
})
->all();
}
private function resolvePriceType(string $channel, string $priceType): PriceType
{
$channelEnum = OrderChannel::from($channel);
$priceTypeEnum = PriceType::from($priceType);
$defaultPriceType = $this->defaultPriceType($channelEnum);
if ($defaultPriceType !== null && $priceTypeEnum !== $defaultPriceType) {
throw ValidationException::withMessages([
'price_type' => 'Tipe harga tidak sesuai dengan channel pesanan.',
]);
}
if ($channelEnum === OrderChannel::STORE && in_array($priceTypeEnum, [PriceType::SHOPEE, PriceType::TIKTOK], true)) {
throw ValidationException::withMessages([
'price_type' => 'Tipe harga marketplace tidak dapat digunakan untuk channel toko.',
]);
}
return $priceTypeEnum;
}
private function draftItemsQuery(User $user): Builder
{
return OrderItem::query()
->whereNull('order_id')
->where('user_id', $user->id);
}
private function presentDraftItem(OrderItem $item): array
{
$variant = $item->productVariant;
return [
'product_variant_id' => $item->product_variant_id,
'product_name' => $variant?->product?->name ?? '',
'variant_name' => $variant?->name ?? '',
'stock_quality' => ($item->stock_quality ?? ProductStockQuality::GOOD)->value,
'stock_quality_label' => ($item->stock_quality ?? ProductStockQuality::GOOD)->label(),
'quantity' => (string) $item->quantity,
'unit_price' => $item->unit_price,
'images' => $variant ? MediaPresenter::collection($variant, 'images') : [],
];
}
private function applyDraftPrices(EloquentCollection $items, PriceType $priceType): void
{
foreach ($items as $index => $item) {
$unitPrice = $this->resolveUnitPrice(
$item->product_variant_id,
$priceType,
"items.{$index}.product_variant_id",
);
$item->unit_price = $unitPrice;
$item->subtotal = $unitPrice * $item->quantity;
$item->save();
}
}
private function decrementStock(OrderItem $item): void
{
$stockQuality = $item->stock_quality ?? ProductStockQuality::GOOD;
$column = $this->stockColumn($stockQuality);
$variant = ProductVariant::query()->findOrFail($item->product_variant_id);
$stockBefore = (int) $variant->{$column};
$variant->decrement($column, $item->quantity);
$this->stockMutationService->record(
stockable: $variant,
type: 'out',
quantity: -$item->quantity,
stockBefore: $stockBefore,
stockAfter: $stockBefore - $item->quantity,
stockQuality: $stockQuality->value,
source: $item->order,
description: $item->order ? "Pesanan #{$item->order->order_number}" : null,
);
}
private function incrementStock(OrderItem $item): void
{
$stockQuality = $item->stock_quality ?? ProductStockQuality::GOOD;
$column = $this->stockColumn($stockQuality);
$variant = ProductVariant::query()->findOrFail($item->product_variant_id);
$stockBefore = (int) $variant->{$column};
$variant->increment($column, $item->quantity);
$this->stockMutationService->record(
stockable: $variant,
type: 'in',
quantity: $item->quantity,
stockBefore: $stockBefore,
stockAfter: $stockBefore + $item->quantity,
stockQuality: $stockQuality->value,
source: $item->order,
description: $item->order ? "Pesanan #{$item->order->order_number} (batal)" : null,
);
}
private function availableStock(ProductVariant $variant, ProductStockQuality $stockQuality): int
{
return (int) $variant->{$this->stockColumn($stockQuality)};
}
private function syncPhotos(Order $order, array $validated): void
{
$paymentType = $validated['payment_type'] ?? null;
$requiresPhoto = in_array($paymentType, ['qris', 'transfer'], true);
$this->mediaService->syncCollection(
$order,
'photos',
$validated['photos'] ?? null,
$validated['remove_media_ids'] ?? null,
self::MAX_PHOTOS,
required: $requiresPhoto,
errorKey: 'photos',
s3Keys: $validated['s3_keys'] ?? null,
);
}
private function lineItemsForSnapshot(EloquentCollection|array $items): array
{
return collect($items)
->map(fn (OrderItem|array $item) => [
'quantity' => (int) (is_array($item) ? $item['quantity'] : $item->quantity),
'subtotal' => (int) (is_array($item) ? $item['subtotal'] : $item->subtotal),
])
->values()
->all();
}
private function applySorting(Builder $query, string $sort, string $direction): void
{
if (in_array($sort, ['created_at', 'total_amount', 'discount', 'subtotal', 'order_number'], true)) {
$query->orderBy($sort, $direction);
return;
}
$query->latest();
}
private function resolveUnitPrice(int $variantId, PriceType $priceType, ?string $errorKey = null): int
{
$price = $this->cuttingResultPriceResolver->resolve($variantId, $priceType);
if ($price === null) {
throw ValidationException::withMessages([
$errorKey ?? 'product_variant_id' => 'Harga untuk tipe harga ini belum diatur. Verifikasi cutting terlebih dahulu.',
]);
}
return (int) $price->price;
}
private function presentVariantPrices(int $variantId): array
{
return collect($this->cuttingResultPriceResolver->latestPricesForVariant($variantId))
->map(fn ($price) => [
'type' => $price->price_type->value,
'type_label' => $price->price_type->label(),
'price' => (int) $price->price,
'price_formatted' => $price->price_formatted,
'price_input' => (string) $price->price,
'cost_per_unit' => (int) $price->cost_per_unit,
'cost_per_unit_formatted' => $price->cost_per_unit_formatted,
])
->values()
->all();
}
private function presentVariantPricesFromCollection(int $variantId, Collection $allPricesByVariant): array
{
return $allPricesByVariant
->get($variantId, collect())
->map(fn ($price) => [
'type' => is_array($price) ? $price['price_type'] : $price->price_type->value,
'type_label' => PriceType::from(is_array($price) ? $price['price_type'] : $price->price_type->value)->label(),
'price' => (int) (is_array($price) ? $price['price'] : $price->price),
'price_formatted' => is_array($price) ? $price['price_formatted'] : $price->price_formatted,
'price_input' => (string) (is_array($price) ? $price['price'] : $price->price),
'cost_per_unit' => (int) (is_array($price) ? ($price['cost_per_unit'] ?? $price['price']) : $price->cost_per_unit),
'cost_per_unit_formatted' => is_array($price) ? ($price['cost_per_unit_formatted'] ?? $price['price_formatted']) : $price->cost_per_unit_formatted,
])
->values()
->all();
}
private function enrichMarketplaceSnapshot(?array $snapshot, Order $order): ?array
{
if ($snapshot === null) {
return null;
}
$totalCostPrice = 0;
foreach ($order->items as $item) {
$variant = $item->productVariant;
if ($variant) {
$hargaModal = $variant->prices
->first(fn ($price) => $price->type === PriceType::HARGA_MODAL)
?->price ?? 0;
$totalCostPrice += $hargaModal * $item->quantity;
}
}
$netAmount = max(0, ($snapshot['net_amount'] ?? 0) - $totalCostPrice);
$snapshot['base_amount_formatted'] ??= 'Rp '.number_format($snapshot['base_amount'] ?? 0, 0, ',', '.');
$snapshot['total_fee_amount_formatted'] ??= 'Rp '.number_format($snapshot['total_fee_amount'] ?? 0, 0, ',', '.');
$snapshot['total_cost_price'] = $totalCostPrice;
$snapshot['total_cost_price_formatted'] = 'Rp '.number_format($totalCostPrice, 0, ',', '.');
$snapshot['net_amount'] = $netAmount;
$snapshot['net_amount_formatted'] = 'Rp '.number_format($netAmount, 0, ',', '.');
return $snapshot;
}
private function appendNetAmount(Order $order): void
{
$totalCostPrice = 0;
foreach ($order->items as $item) {
$variant = $item->productVariant;
if ($variant) {
$hargaModal = $variant->prices
->first(fn ($price) => $price->type === PriceType::HARGA_MODAL)
?->price ?? 0;
$totalCostPrice += $hargaModal * $item->quantity;
}
}
$baseAmount = $order->total_amount;
$netAmount = max(0, $baseAmount - $totalCostPrice);
if ($order->marketplace_settings_snapshot !== null) {
$snapshot = $order->marketplace_settings_snapshot;
$netAmount = $snapshot['net_amount'] ?? 0;
}
$order->setAttribute('total_cost_price', $totalCostPrice);
$order->setAttribute('total_cost_price_formatted', 'Rp '.number_format($totalCostPrice, 0, ',', '.'));
$order->setAttribute('net_amount', $netAmount);
$order->setAttribute('net_amount_formatted', 'Rp '.number_format($netAmount, 0, ',', '.'));
}
}