feat: add is_affiliate field to order management, including validation, database migration, and UI updates

This commit is contained in:
Yoga Pangestu 2026-06-18 00:57:47 +07:00
parent 191743632b
commit 1af4af70fa
9 changed files with 77 additions and 7 deletions

View File

@ -33,6 +33,7 @@ public function rules(): array
'channel' => ['required', Rule::enum(OrderChannel::class)],
'price_type' => ['required', Rule::enum(PriceType::class)],
'payment_type' => ['required', Rule::enum(PaymentType::class)],
'is_affiliate' => ['nullable', 'boolean'],
'tiktok_order_id' => [Rule::requiredIf(fn () => $this->channel === OrderChannel::TIKTOK->value), 'string', 'max:100'],
'shopee_order_id' => [Rule::requiredIf(fn () => $this->channel === OrderChannel::SHOPEE->value), 'string', 'max:100'],
'discount' => ['nullable', 'integer', 'min:0'],
@ -63,6 +64,7 @@ public function attributes(): array
'channel' => 'channel',
'price_type' => 'tipe harga',
'payment_type' => 'tipe pembayaran',
'is_affiliate' => 'afiliasi',
'tiktok_order_id' => 'ID pesanan TikTok Shop',
'shopee_order_id' => 'ID pesanan Shopee',
'discount' => 'diskon',

View File

@ -39,6 +39,7 @@ protected function casts(): array
'channel' => OrderChannel::class,
'price_type' => PriceType::class,
'payment_type' => PaymentType::class,
'is_affiliate' => 'boolean',
'status' => OrderStatus::class,
'subtotal' => 'integer',
'discount' => 'integer',

View File

@ -329,6 +329,7 @@ public function create(array $validated, User $user): Order
'channel' => $channel,
'price_type' => $priceType,
'payment_type' => PaymentType::from($validated['payment_type']),
'is_affiliate' => $validated['is_affiliate'] ?? false,
'status' => OrderStatus::PENDING,
'tiktok_order_id' => $validated['tiktok_order_id'] ?? null,
'shopee_order_id' => $validated['shopee_order_id'] ?? null,
@ -339,6 +340,7 @@ public function create(array $validated, User $user): Order
$channel,
$totalAmount,
$this->lineItemsForSnapshot($draftItems),
$validated['is_affiliate'] ?? false,
),
'total_amount' => $totalAmount,
'notes' => $validated['notes'] ?? null,
@ -417,6 +419,8 @@ public function update(Order $order, array $validated): void
$order->marketing_id = $validated['marketing_id'] ?? null;
$order->channel = $channel;
$order->price_type = $priceType;
$order->payment_type = PaymentType::from($validated['payment_type']);
$order->is_affiliate = $validated['is_affiliate'] ?? false;
$order->tiktok_order_id = $validated['tiktok_order_id'] ?? null;
$order->shopee_order_id = $validated['shopee_order_id'] ?? null;
$order->subtotal = $subtotal;
@ -425,6 +429,7 @@ public function update(Order $order, array $validated): void
$channel,
$totalAmount,
$this->lineItemsForSnapshot($lineItems),
$validated['is_affiliate'] ?? false,
);
$order->total_amount = $totalAmount;
$order->notes = $validated['notes'] ?? null;

View File

@ -84,7 +84,7 @@ public function updateMarketplace(array $validated): void
* @param list<array{quantity: int, subtotal: int}> $lineItems
* @return array<string, mixed>|null
*/
public function buildOrderSnapshot(OrderChannel $channel, int $totalAmount, array $lineItems): ?array
public function buildOrderSnapshot(OrderChannel $channel, int $totalAmount, array $lineItems, bool $isAffiliate = false): ?array
{
$feeSnapshot = $this->feeRulesForChannel($channel);
@ -92,6 +92,10 @@ public function buildOrderSnapshot(OrderChannel $channel, int $totalAmount, arra
return null;
}
if (! $isAffiliate) {
$feeSnapshot['fees'] = $this->excludeAffiliateFee($feeSnapshot['fees'], $channel);
}
$calculation = app(MarketplaceFeeCalculator::class)->calculate(
$feeSnapshot['fees'],
$totalAmount,
@ -100,6 +104,7 @@ public function buildOrderSnapshot(OrderChannel $channel, int $totalAmount, arra
return [
'platform' => $feeSnapshot['platform'],
'is_affiliate' => $isAffiliate,
'fees' => $feeSnapshot['fees'],
'base_amount' => $totalAmount,
'results' => $calculation['results'],
@ -108,6 +113,29 @@ public function buildOrderSnapshot(OrderChannel $channel, int $totalAmount, arra
];
}
/**
* @param array<string, array{scope: string, value_type: string, value: float}> $fees
* @return array<string, array{scope: string, value_type: string, value: float}>
*/
private function excludeAffiliateFee(array $fees, OrderChannel $channel): array
{
$affiliateKey = match ($channel) {
OrderChannel::TIKTOK => 'tiktok_shop_affiliate',
OrderChannel::SHOPEE => 'ams_commission_fee',
default => null,
};
if ($affiliateKey !== null && isset($fees[$affiliateKey])) {
$fees[$affiliateKey] = [
'scope' => $fees[$affiliateKey]['scope'],
'value_type' => $fees[$affiliateKey]['value_type'],
'value' => 0,
];
}
return $fees;
}
/**
* @return array{platform: string, fees: array<string, array{scope: string, value_type: string, value: float}>}|null
*/

View File

@ -23,6 +23,7 @@ public function up(): void
$table->enum('price_type', array_column(PriceType::cases(), 'value'));
$table->enum('status', array_column(OrderStatus::cases(), 'value'))->default(OrderStatus::PENDING->value);
$table->enum('payment_type', array_column(PaymentType::cases(), 'value'))->default(PaymentType::CASH->value);
$table->boolean('is_affiliate')->default(false);
$table->string('tiktok_order_id', 100)->nullable();
$table->string('shopee_order_id', 100)->nullable();
$table->unsignedBigInteger('subtotal');

View File

@ -104,6 +104,9 @@ function statusVariant(status: string): 'default' | 'secondary' | 'destructive'
<Badge v-if="order.shopee_order_id" variant="outline">
ID Pesanan: {{ order.shopee_order_id }}
</Badge>
<Badge v-if="order.is_affiliate" variant="outline">
Afiliasi
</Badge>
</div>
<div class="text-muted-foreground space-y-1 text-sm">
<p v-if="order.customer">
@ -114,15 +117,15 @@ function statusVariant(status: string): 'default' | 'secondary' | 'destructive'
</div>
<div class="flex flex-wrap gap-x-4 gap-y-1 text-sm">
<span>Tipe Harga <strong class="text-foreground">{{ order.price_type_label
}}</strong></span>
}}</strong></span>
<span>Pembayaran <strong class="text-foreground">{{ order.payment_type_label
}}</strong></span>
}}</strong></span>
<span>Subtotal <strong class="text-foreground">{{ order.subtotal_formatted
}}</strong></span>
}}</strong></span>
<span>Diskon <strong class="text-foreground">{{ order.discount_formatted
}}</strong></span>
}}</strong></span>
<span>Total <strong class="text-primary">{{ order.total_amount_formatted
}}</strong></span>
}}</strong></span>
</div>
<p v-if="order.notes" class="text-muted-foreground text-sm">
{{ order.notes }}

View File

@ -32,6 +32,7 @@ import {
SelectValue,
} from '@/components/ui/select';
import { Separator } from '@/components/ui/separator';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import { OrderChannel } from '@/constants/order-channel';
import { apiFetch } from '@/lib/api';
@ -55,6 +56,7 @@ const props = defineProps<{
channel: string;
price_type: string;
payment_type: string;
is_affiliate: boolean;
tiktok_order_id: string;
shopee_order_id: string;
discount: string;
@ -89,6 +91,7 @@ const form = useForm({
channel: 'store',
price_type: 'ecer',
payment_type: 'cash',
is_affiliate: false,
tiktok_order_id: '',
shopee_order_id: '',
discount: '',
@ -96,6 +99,7 @@ const form = useForm({
});
const isStoreChannel = computed(() => form.channel === 'store');
const isMarketplaceChannel = computed(() => form.channel === 'shopee' || form.channel === 'tiktok');
function populateForm() {
if (!props.initialData) {
@ -107,6 +111,7 @@ function populateForm() {
form.channel = props.initialData.channel;
form.price_type = props.initialData.price_type;
form.payment_type = props.initialData.payment_type;
form.is_affiliate = props.initialData.is_affiliate;
form.tiktok_order_id = props.initialData.tiktok_order_id;
form.shopee_order_id = props.initialData.shopee_order_id;
form.discount = props.initialData.discount;
@ -137,11 +142,17 @@ watch(
(channel) => {
if (channel === 'shopee') {
form.price_type = 'shopee';
form.payment_type = 'marketplace';
} else if (channel === 'tiktok') {
form.price_type = 'tiktok';
form.payment_type = 'marketplace';
} else if (!props.storePriceTypes.some((type) => type.value === form.price_type)) {
form.price_type = 'ecer';
}
if (channel === 'store') {
form.is_affiliate = false;
}
},
);
@ -360,6 +371,10 @@ function buildFormData(): FormData {
formData.append('price_type', form.price_type);
formData.append('payment_type', form.payment_type);
if (form.channel !== 'store') {
formData.append('is_affiliate', form.is_affiliate ? '1' : '0');
}
if (form.channel === 'tiktok' && form.tiktok_order_id) {
formData.append('tiktok_order_id', form.tiktok_order_id);
}
@ -546,7 +561,7 @@ function submit() {
<Field>
<FieldLabel for="payment_type" required>Tipe Pembayaran</FieldLabel>
<Select v-model="form.payment_type">
<Select v-model="form.payment_type" :disabled="isMarketplaceChannel">
<SelectTrigger id="payment_type" class="w-full">
<SelectValue placeholder="Pilih tipe pembayaran" />
</SelectTrigger>
@ -562,6 +577,18 @@ function submit() {
<FieldError :errors="formErrors(form, 'payment_type')" />
</Field>
<Field v-if="isMarketplaceChannel">
<div class="flex items-center gap-3">
<Switch id="is_affiliate" :model-value="form.is_affiliate"
@update:model-value="form.is_affiliate = $event" />
<FieldLabel for="is_affiliate" class="cursor-pointer">Pesanan Afiliasi</FieldLabel>
</div>
<p class="text-xs text-muted-foreground">
Aktifkan jika pesanan ini melalui afiliasi. Biaya afiliasi akan dikenakan sesuai
pengaturan marketplace.
</p>
</Field>
<Field>
<FieldLabel for="customer">Pelanggan</FieldLabel>
<Select v-model="form.customer_id">

View File

@ -23,6 +23,7 @@ const initialData = computed(() => ({
channel: props.order.channel,
price_type: props.order.price_type,
payment_type: props.order.payment_type,
is_affiliate: props.order.is_affiliate,
tiktok_order_id: props.order.tiktok_order_id ?? '',
shopee_order_id: props.order.shopee_order_id ?? '',
discount: String(props.order.discount),

View File

@ -43,6 +43,7 @@ export type OrderListItem = {
price_type_label: string;
payment_type: string;
payment_type_label: string;
is_affiliate: boolean;
status: string;
status_label: string;
is_editable: boolean;
@ -84,6 +85,7 @@ export type OrderEditItem = {
channel: string;
price_type: string;
payment_type: string;
is_affiliate: boolean;
status: string;
tiktok_order_id: string | null;
shopee_order_id: string | null;