feat: split 'reject' price type into 'reject_capital' and 'reject_selling', update related components and database migration

This commit is contained in:
Yoga Pangestu 2026-08-16 14:27:19 +07:00
parent 0947cf3737
commit 0998c81803
15 changed files with 148 additions and 62 deletions

View File

@ -300,7 +300,7 @@ ## Enums
| `PayrollPeriodStatus` | open, closed | payroll_periods.status |
| `PayrollStatus` | unpaid, paid, cancelled | payrolls.status |
| `Permission` | — | Permission action names |
| `PriceType` | retail, wholesale, capital | product_prices.type, orders.price_type |
| `PriceType` | retail, wholesale, capital, reject_capital, reject_selling | product_prices.type, orders.price_type |
| `ProductStatus` | active, draft, inactive, pending, rejected | products.status |
| `ProductStockQuality` | good, reject | order_items.stock_quality, restocks.stock_type, stok_opname_items.stock_quality |
| `RawMaterialUnit` | kg, meter, yard | raw_materials.unit |

View File

@ -108,7 +108,7 @@ public function fix(): array
private function resolvePriceType(string $orderPriceType, string $stockQuality): string
{
if ($stockQuality === ProductStockQuality::REJECT->value) {
return PriceType::REJECT->value;
return PriceType::REJECT_SELLING->value;
}
$map = [

View File

@ -16,7 +16,8 @@ enum PriceType: string
case TIKTOK = 'tiktok';
case SHOPEE = 'shopee';
case CAPITAL = 'capital';
case REJECT = 'reject';
case REJECT_CAPITAL = 'reject_capital';
case REJECT_SELLING = 'reject_selling';
public function label(): string
{
@ -29,7 +30,8 @@ public function label(): string
self::TIKTOK => 'TikTok',
self::SHOPEE => 'Shopee',
self::CAPITAL => 'Modal',
self::REJECT => 'Reject',
self::REJECT_CAPITAL => 'Reject Modal',
self::REJECT_SELLING => 'Reject Jual',
};
}
}

View File

@ -30,7 +30,7 @@ public function rules(): array
return [
'stock_type' => ['sometimes', 'required', Rule::in(ProductStockQuality::values())],
'channel' => ['sometimes', 'required', Rule::in(OrderChannel::values())],
'price_type' => ['sometimes', 'required', Rule::in(array_diff(PriceType::values(), [PriceType::CAPITAL->value]))],
'price_type' => ['sometimes', 'required', Rule::in(array_diff(PriceType::values(), [PriceType::CAPITAL->value, PriceType::REJECT_CAPITAL->value]))],
'payment_type' => ['sometimes', 'required', Rule::in(PaymentType::values())],
'customer_id' => ['nullable', 'integer', Rule::exists('customers', 'id')],
'marketing_id' => ['nullable', 'integer', Rule::exists('users', 'id')],

View File

@ -188,7 +188,7 @@ public function destroy(Restock $restock): bool
private function buildItemRows(array $items, string $stockType, $now, int &$total): array
{
$priceType = $stockType === ProductStockQuality::REJECT->value
? PriceType::REJECT
? PriceType::REJECT_CAPITAL
: PriceType::CAPITAL;
$variantIds = collect($items)->pluck('product_variant_id')->unique()->all();

View File

@ -33,6 +33,7 @@ class TransactionService
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(
@ -355,7 +356,7 @@ public function updateStatus(Order $order, string $status): Order
private function buildItemRows(array $items, string $stockType, string $priceType, $now, int &$subtotal, int &$totalCost): array
{
$resolvedPriceType = $stockType === ProductStockQuality::REJECT->value
? PriceType::REJECT
? PriceType::REJECT_SELLING
: (self::SELLING_PRICE_MAP[$priceType] ?? PriceType::RETAIL);
$variantIds = collect($items)->pluck('product_variant_id')->unique()->all();
@ -375,9 +376,13 @@ private function buildItemRows(array $items, string $stockType, string $priceTyp
return [$variant->id => $price?->price ?? 0];
});
$capitalPrices = $variants->mapWithKeys(function (ProductVariant $variant) {
$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 === PriceType::CAPITAL);
->first(fn ($p) => $p->type === $capitalPriceType);
return [$variant->id => $price?->price ?? 0];
});

View File

@ -41,11 +41,11 @@ public function getForRestock(): array
if ($allVariantIds !== []) {
$mediaByVariant = Media::query()
->whereIn('mediable_id', $allVariantIds)
->where('mediable_type', ProductVariant::class)
->whereIn('model_id', $allVariantIds)
->where('model_type', ProductVariant::class)
->where('collection_name', 'images')
->get()
->groupBy('mediable_id');
->groupBy('model_id');
} else {
$mediaByVariant = collect();
}
@ -62,7 +62,7 @@ public function getForRestock(): array
$variant->capital_price = $capitalPrice?->price ?? 0;
$rejectPrice = $variant->productPrices
->first(fn ($price) => $price->type === PriceType::REJECT);
->first(fn ($price) => $price->type === PriceType::REJECT_CAPITAL);
$variant->reject_price = $rejectPrice?->price ?? 0;
});
})->toArray();
@ -84,11 +84,11 @@ public function getForTransaction(): array
if ($allVariantIds !== []) {
$mediaByVariant = Media::query()
->whereIn('mediable_id', $allVariantIds)
->where('mediable_type', ProductVariant::class)
->whereIn('model_id', $allVariantIds)
->where('model_type', ProductVariant::class)
->where('collection_name', 'images')
->get()
->groupBy('mediable_id');
->groupBy('model_id');
} else {
$mediaByVariant = collect();
}

View File

@ -0,0 +1,62 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
// 1. product_prices: rename 'reject' → 'reject_selling', then add 'reject_capital' rows
DB::table('product_prices')
->where('type', 'reject')
->update(['type' => 'reject_selling']);
// Insert reject_capital rows (price = 0) for each variant that now has reject_selling
$rejectSellingPrices = DB::table('product_prices')
->where('type', 'reject_selling')
->get();
foreach ($rejectSellingPrices as $row) {
DB::table('product_prices')->insert([
'variant_id' => $row->variant_id,
'type' => 'reject_capital',
'price' => 0,
'created_at' => $row->created_at,
'updated_at' => $row->updated_at,
]);
}
// 2. orders: rename 'reject' → 'reject_selling'
DB::table('orders')
->where('price_type', 'reject')
->update(['price_type' => 'reject_selling']);
// 3. Modify enum columns to reflect new values (MySQL only)
if (DB::getDriverName() === 'mysql') {
DB::statement("ALTER TABLE product_prices MODIFY COLUMN type ENUM('distributor','agent','sub_agent','wholesale','retail','tiktok','shopee','capital','reject_capital','reject_selling') NOT NULL");
DB::statement("ALTER TABLE orders MODIFY COLUMN price_type ENUM('distributor','agent','sub_agent','wholesale','retail','tiktok','shopee','capital','reject_capital','reject_selling') NOT NULL");
}
}
public function down(): void
{
// Reverse: rename back and clean up
DB::table('product_prices')
->where('type', 'reject_capital')
->delete();
DB::table('product_prices')
->where('type', 'reject_selling')
->update(['type' => 'reject']);
DB::table('orders')
->where('price_type', 'reject_selling')
->update(['price_type' => 'reject']);
if (DB::getDriverName() === 'mysql') {
DB::statement("ALTER TABLE product_prices MODIFY COLUMN type ENUM('distributor','agent','sub_agent','wholesale','retail','tiktok','shopee','capital','reject') NOT NULL");
DB::statement("ALTER TABLE orders MODIFY COLUMN price_type ENUM('distributor','agent','sub_agent','wholesale','retail','tiktok','shopee','capital','reject') NOT NULL");
}
}
};

View File

@ -10,21 +10,21 @@ export function NotificationPermissionPrompt() {
usePushNotification();
useEffect(() => {
if (!isSupported || hasRequested.current) {
return;
}
if (Notification.permission !== 'default') {
if (!isSupported || hasRequested.current || !vapidPublicKey) {
return;
}
hasRequested.current = true;
void requestPermission().then(async (result) => {
if (result === 'granted' && vapidPublicKey) {
await subscribe(vapidPublicKey);
}
});
if (Notification.permission === 'default') {
void requestPermission().then(async (result) => {
if (result === 'granted') {
await subscribe(vapidPublicKey);
}
});
} else if (Notification.permission === 'granted') {
void subscribe(vapidPublicKey);
}
}, [isSupported, requestPermission, subscribe, vapidPublicKey]);
return null;

View File

@ -68,7 +68,7 @@ type Props = {
priceTypeOptions: TransactionCreateData['priceTypeOptions'];
};
const SELLING_PRICE_TYPES = ['distributor', 'agent', 'sub_agent', 'wholesale', 'retail', 'tiktok', 'shopee'];
const SELLING_PRICE_TYPES = ['distributor', 'agent', 'sub_agent', 'wholesale', 'retail', 'tiktok', 'shopee', 'reject_selling'];
export default function TransactionCreate({
products,
@ -181,8 +181,8 @@ export default function TransactionCreate({
useEffect(() => {
if (stockType === 'reject') {
setPriceType('reject');
} else if (priceType === 'reject') {
setPriceType('reject_selling');
} else if (priceType === 'reject_selling') {
setPriceType('retail');
}
}, [stockType]);
@ -191,7 +191,7 @@ export default function TransactionCreate({
const availablePriceTypes = useMemo(() => {
if (stockType === 'reject') {
return priceTypeOptions.filter((o) => o.value === 'reject');
return priceTypeOptions.filter((o) => o.value === 'reject_selling');
}
return priceTypeOptions.filter((o) => SELLING_PRICE_TYPES.includes(o.value));
@ -206,7 +206,7 @@ export default function TransactionCreate({
}
if (stockType === 'reject') {
return variant.prices?.reject ?? 0;
return variant.prices?.reject_selling ?? 0;
}
return variant.prices?.[priceType] ?? 0;
@ -284,7 +284,7 @@ export default function TransactionCreate({
return {
stock_type: stockType,
channel,
price_type: stockType === 'reject' ? 'reject' : priceType,
price_type: stockType === 'reject' ? 'reject_selling' : priceType,
payment_type: paymentType,
customer_id: customerId,
marketing_id: marketingId,
@ -443,7 +443,7 @@ export default function TransactionCreate({
) : (
formatCurrency(
stockType === 'reject'
? (variant.prices?.reject ?? 0)
? (variant.prices?.reject_selling ?? 0)
: (variant.prices?.[priceType] ?? 0),
)
)}

View File

@ -67,7 +67,7 @@ type Props = {
priceTypeOptions: TransactionCreateData['priceTypeOptions'];
};
const SELLING_PRICE_TYPES = ['distributor', 'agent', 'sub_agent', 'wholesale', 'retail', 'tiktok', 'shopee'];
const SELLING_PRICE_TYPES = ['distributor', 'agent', 'sub_agent', 'wholesale', 'retail', 'tiktok', 'shopee', 'reject_selling'];
export default function TransactionEdit({
transaction,
@ -134,8 +134,8 @@ export default function TransactionEdit({
useEffect(() => {
if (stockType === 'reject') {
setPriceType('reject');
} else if (priceType === 'reject') {
setPriceType('reject_selling');
} else if (priceType === 'reject_selling') {
setPriceType('retail');
}
}, [stockType]);
@ -162,7 +162,7 @@ export default function TransactionEdit({
const availablePriceTypes = useMemo(() => {
if (stockType === 'reject') {
return priceTypeOptions.filter((o) => o.value === 'reject');
return priceTypeOptions.filter((o) => o.value === 'reject_selling');
}
return priceTypeOptions.filter((o) => SELLING_PRICE_TYPES.includes(o.value));
@ -177,7 +177,7 @@ export default function TransactionEdit({
}
if (stockType === 'reject') {
return variant.prices?.reject ?? 0;
return variant.prices?.reject_selling ?? 0;
}
return variant.prices?.[priceType] ?? 0;
@ -255,7 +255,7 @@ export default function TransactionEdit({
return {
stock_type: stockType,
channel,
price_type: stockType === 'reject' ? 'reject' : priceType,
price_type: stockType === 'reject' ? 'reject_selling' : priceType,
payment_type: paymentType,
customer_id: customerId,
marketing_id: marketingId,
@ -423,7 +423,7 @@ export default function TransactionEdit({
) : (
formatCurrency(
stockType === 'reject'
? (variant.prices?.reject ?? 0)
? (variant.prices?.reject_selling ?? 0)
: (variant.prices?.[priceType] ?? 0),
)
)}

View File

@ -43,7 +43,8 @@ const PRICE_TYPES = [
{ key: 'tiktok', label: 'TikTok' },
{ key: 'shopee', label: 'Shopee' },
{ key: 'capital', label: 'Modal' },
{ key: 'reject', label: 'Reject' },
{ key: 'reject_capital', label: 'Reject Modal' },
{ key: 'reject_selling', label: 'Reject Jual' },
];
function createEmptyPrices(): Array<{ type: string; price: number }> {

View File

@ -61,7 +61,8 @@ const PRICE_TYPES = [
{ key: 'tiktok', label: 'TikTok' },
{ key: 'shopee', label: 'Shopee' },
{ key: 'capital', label: 'Modal' },
{ key: 'reject', label: 'Reject' },
{ key: 'reject_capital', label: 'Reject Modal' },
{ key: 'reject_selling', label: 'Reject Jual' },
];
function createEmptyPrices(): Array<{ type: string; price: number }> {
@ -104,6 +105,13 @@ export default function ProductEdit({ product, categories }: Props) {
product.description ?? '',
);
function normalizePrices(serverPrices: Array<{ type: string; price: number }>): Array<{ type: string; price: number }> {
return PRICE_TYPES.map((pt) => {
const existing = serverPrices.find((p) => p.type === pt.key);
return { type: pt.key, price: existing?.price ?? 0 };
});
}
const serverVariants: VariantState[] = product.product_variants.map(
(v) => ({
id: v.id,
@ -116,7 +124,7 @@ export default function ProductEdit({ product, categories }: Props) {
url: v.photo_urls[i] ?? null,
})),
uploading: false,
prices: v.prices.length > 0 ? v.prices : createEmptyPrices(),
prices: normalizePrices(v.prices),
}),
);

View File

@ -35,7 +35,8 @@ const PRICE_TYPES = [
{ key: 'tiktok', label: 'TikTok' },
{ key: 'shopee', label: 'Shopee' },
{ key: 'capital', label: 'Modal' },
{ key: 'reject', label: 'Reject' },
{ key: 'reject_capital', label: 'Reject Modal' },
{ key: 'reject_selling', label: 'Reject Jual' },
];
export default function ProductVariantEdit({ variant }: Props) {
@ -53,14 +54,15 @@ export default function ProductVariantEdit({ variant }: Props) {
const [prices, setPrices] = useState<
Array<{ type: string; price: number }>
>(
variant.prices.length > 0
? variant.prices
: PRICE_TYPES.map((pt) => ({ type: pt.key, price: 0 })),
PRICE_TYPES.map((pt) => {
const existing = variant.prices.find((p) => p.type === pt.key);
return { type: pt.key, price: existing?.price ?? 0 };
}),
);
function updatePrice(priceIndex: number, value: number) {
function updatePrice(priceType: string, value: number) {
setPrices((prev) =>
prev.map((p, i) => (i === priceIndex ? { ...p, price: value } : p)),
prev.map((p) => (p.type === priceType ? { ...p, price: value } : p)),
);
}
@ -200,7 +202,9 @@ export default function ProductVariantEdit({ variant }: Props) {
<CardContent>
<div className="grid grid-cols-2 gap-4 md:grid-cols-3">
{PRICE_TYPES.map(
(priceType, priceIndex) => (
(priceType) => {
const priceEntry = prices.find((p) => p.type === priceType.key);
return (
<div
key={priceType.key}
className="grid gap-2"
@ -214,15 +218,13 @@ export default function ProductVariantEdit({ variant }: Props) {
</Label>
<RupiahInput
value={
prices[
priceIndex
]?.price ?? 0
priceEntry?.price ?? 0
}
onValueChange={(
val,
) =>
updatePrice(
priceIndex,
priceType.key,
val,
)
}
@ -230,12 +232,13 @@ export default function ProductVariantEdit({ variant }: Props) {
<InputError
message={
errors[
`prices.${priceIndex}.price`
`prices.${priceType.key}.price`
]
}
/>
</div>
),
);
},
)}
</div>
</CardContent>

View File

@ -45,7 +45,8 @@ function makeValidProductPayload(array $overrides = []): array
['type' => 'tiktok', 'price' => 16000],
['type' => 'shopee', 'price' => 16500],
['type' => 'capital', 'price' => 8000],
['type' => 'reject', 'price' => 5000],
['type' => 'reject_capital', 'price' => 5000],
['type' => 'reject_selling', 'price' => 6000],
],
],
],
@ -71,7 +72,8 @@ function makeValidSharedPricePayload(array $overrides = []): array
['type' => 'tiktok', 'price' => 16000],
['type' => 'shopee', 'price' => 16500],
['type' => 'capital', 'price' => 8000],
['type' => 'reject', 'price' => 5000],
['type' => 'reject_capital', 'price' => 5000],
['type' => 'reject_selling', 'price' => 6000],
],
'variants' => [
[
@ -97,7 +99,8 @@ function allPriceTypes(): array
['type' => 'tiktok', 'price' => 16000],
['type' => 'shopee', 'price' => 16500],
['type' => 'capital', 'price' => 8000],
['type' => 'reject', 'price' => 5000],
['type' => 'reject_capital', 'price' => 5000],
['type' => 'reject_selling', 'price' => 6000],
];
}
@ -1946,7 +1949,8 @@ function allPriceTypes(): array
['type' => 'tiktok', 'price' => 16000],
['type' => 'shopee', 'price' => 16500],
['type' => 'capital', 'price' => 8000],
['type' => 'reject', 'price' => 5000],
['type' => 'reject_capital', 'price' => 5000],
['type' => 'reject_selling', 'price' => 6000],
];
$response = $this->post(route('admin.master.products.store'), makeValidProductPayload([
@ -1965,9 +1969,10 @@ function allPriceTypes(): array
$variant = ProductVariant::where('name', 'Semua Harga')->first();
$dbPrices = ProductPrice::where('variant_id', $variant->id)->get();
expect($dbPrices)->toHaveCount(9);
expect($dbPrices)->toHaveCount(10);
expect($dbPrices->firstWhere('type', 'distributor')->price)->toBe(10000);
expect($dbPrices->firstWhere('type', 'reject')->price)->toBe(5000);
expect($dbPrices->firstWhere('type', 'reject_capital')->price)->toBe(5000);
expect($dbPrices->firstWhere('type', 'reject_selling')->price)->toBe(6000);
});
test('shared price applies to all variants', function () {