diff --git a/admin/app/Enums/ShippingMethodForEnum.php b/admin/app/Enums/ShippingMethodForEnum.php new file mode 100644 index 00000000..4c5d7fb6 --- /dev/null +++ b/admin/app/Enums/ShippingMethodForEnum.php @@ -0,0 +1,34 @@ + 'Customer', + self::PARTNER => 'Partner', + self::EMPLOYEE => 'Employee', + }; + } + + public function color(): string + { + return match ($this) { + self::CUSTOMER => 'success', + self::PARTNER => 'info', + self::EMPLOYEE => 'warning', + }; + } +} diff --git a/admin/app/Filament/Resources/ShippingCityResource.php b/admin/app/Filament/Resources/ShippingCityResource.php new file mode 100644 index 00000000..142da2b1 --- /dev/null +++ b/admin/app/Filament/Resources/ShippingCityResource.php @@ -0,0 +1,160 @@ +components([ + Select::make('shipping_method_id') + ->relationship('shippingMethod', 'name') + ->required() + ->searchable() + ->preload() + ->native(false) + ->columnSpanFull() + ->hintIcon('heroicon-o-information-circle') + ->hintIconTooltip('The shipping method this city config applies to.'), + Select::make('province_id') + ->relationship('province', 'name') + ->searchable() + ->preload() + ->nullable() + ->native(false) + ->hintIcon('heroicon-o-information-circle') + ->hintIconTooltip('Set a province for a province-wide rule. Leave empty if targeting a specific city.'), + Select::make('city_id') + ->relationship('city', 'name') + ->searchable() + ->preload() + ->nullable() + ->native(false) + ->hintIcon('heroicon-o-information-circle') + ->hintIconTooltip('Set a city for city-specific rules. City rules take priority over province rules. At least one of city or province must be set.'), + TextInput::make('amount') + ->numeric() + ->nullable() + ->prefix('تومان') + ->hintIcon('heroicon-o-information-circle') + ->hintIconTooltip('Shipping cost for this location. Leave empty for postpaid. Set 0 for free shipping.'), + Toggle::make('pay_on_delivery') + ->hintIcon('heroicon-o-information-circle') + ->hintIconTooltip('When on, the customer pays at delivery — no online payment required.'), + TextInput::make('sending_days') + ->nullable() + ->placeholder('e.g. 1,2,3,4,5') + ->hintIcon('heroicon-o-information-circle') + ->hintIconTooltip('Days this method ships for this location (comma-separated day numbers). Leave empty for every day.'), + TextInput::make('delay') + ->numeric() + ->nullable() + ->suffix('days') + ->hintIcon('heroicon-o-information-circle') + ->hintIconTooltip('Extra delivery delay in days for this location (e.g. 1 for next-day).'), + Textarea::make('description') + ->nullable() + ->rows(2) + ->columnSpanFull() + ->hintIcon('heroicon-o-information-circle') + ->hintIconTooltip('Human-readable delivery details shown to the customer (e.g. "Delivered within 72 hours").'), + DateTimePicker::make('disable_from') + ->nullable() + ->hintIcon('heroicon-o-information-circle') + ->hintIconTooltip('Start of a period when this method is unavailable in this location.'), + DateTimePicker::make('disable_to') + ->nullable() + ->hintIcon('heroicon-o-information-circle') + ->hintIconTooltip('End of the unavailability period.'), + Toggle::make('status') + ->default(true) + ->hintIcon('heroicon-o-information-circle') + ->hintIconTooltip('When off, this entry is ignored at checkout.'), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + TextColumn::make('shippingMethod.name') + ->label('Method') + ->searchable() + ->sortable(), + TextColumn::make('province.name') + ->label('Province') + ->placeholder('—'), + TextColumn::make('city.name') + ->label('City') + ->placeholder('—') + ->searchable(), + TextColumn::make('amount') + ->money() + ->placeholder('Postpaid'), + IconColumn::make('pay_on_delivery') + ->boolean(), + TextColumn::make('delay') + ->suffix(' d') + ->placeholder('—'), + IconColumn::make('status') + ->boolean(), + TextColumn::make('created_at') + ->dateTime() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->filters([ + // + ]) + ->recordActions([ + EditAction::make(), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } + + public static function getRelations(): array + { + return []; + } + + public static function getPages(): array + { + return [ + 'index' => ListShippingCities::route('/'), + 'create' => CreateShippingCity::route('/create'), + 'edit' => EditShippingCity::route('/{record}/edit'), + ]; + } +} diff --git a/admin/app/Filament/Resources/ShippingCityResource/Pages/CreateShippingCity.php b/admin/app/Filament/Resources/ShippingCityResource/Pages/CreateShippingCity.php new file mode 100644 index 00000000..eba8486d --- /dev/null +++ b/admin/app/Filament/Resources/ShippingCityResource/Pages/CreateShippingCity.php @@ -0,0 +1,18 @@ +getResource()::getUrl('index'); + } +} diff --git a/admin/app/Filament/Resources/ShippingCityResource/Pages/EditShippingCity.php b/admin/app/Filament/Resources/ShippingCityResource/Pages/EditShippingCity.php new file mode 100644 index 00000000..4ebf5a95 --- /dev/null +++ b/admin/app/Filament/Resources/ShippingCityResource/Pages/EditShippingCity.php @@ -0,0 +1,26 @@ +getResource()::getUrl('index'); + } + + protected function getHeaderActions(): array + { + return [ + DeleteAction::make(), + ]; + } +} diff --git a/admin/app/Filament/Resources/ShippingCityResource/Pages/ListShippingCities.php b/admin/app/Filament/Resources/ShippingCityResource/Pages/ListShippingCities.php new file mode 100644 index 00000000..0387d73e --- /dev/null +++ b/admin/app/Filament/Resources/ShippingCityResource/Pages/ListShippingCities.php @@ -0,0 +1,23 @@ +components([ + TextInput::make('name') + ->required() + ->maxLength(255) + ->hintIcon('heroicon-o-information-circle') + ->hintIconTooltip('The shipping carrier or line name (e.g. Post, Express, Same-day Courier).'), + TextInput::make('cost') + ->required() + ->numeric() + ->prefix('تومان') + ->hintIcon('heroicon-o-information-circle') + ->hintIconTooltip('Base shipping cost for this line. Use 0 for free shipping.'), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + TextColumn::make('name') + ->searchable() + ->sortable(), + TextColumn::make('cost') + ->money() + ->sortable(), + TextColumn::make('created_at') + ->dateTime() + ->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('updated_at') + ->dateTime() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->filters([ + // + ]) + ->recordActions([ + EditAction::make(), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } + + public static function getRelations(): array + { + return []; + } + + public static function getPages(): array + { + return [ + 'index' => ListShippingLines::route('/'), + 'create' => CreateShippingLine::route('/create'), + 'edit' => EditShippingLine::route('/{record}/edit'), + ]; + } +} diff --git a/admin/app/Filament/Resources/ShippingLineResource/Pages/CreateShippingLine.php b/admin/app/Filament/Resources/ShippingLineResource/Pages/CreateShippingLine.php new file mode 100644 index 00000000..610dda5d --- /dev/null +++ b/admin/app/Filament/Resources/ShippingLineResource/Pages/CreateShippingLine.php @@ -0,0 +1,18 @@ +getResource()::getUrl('index'); + } +} diff --git a/admin/app/Filament/Resources/ShippingLineResource/Pages/EditShippingLine.php b/admin/app/Filament/Resources/ShippingLineResource/Pages/EditShippingLine.php new file mode 100644 index 00000000..9a5c4527 --- /dev/null +++ b/admin/app/Filament/Resources/ShippingLineResource/Pages/EditShippingLine.php @@ -0,0 +1,26 @@ +getResource()::getUrl('index'); + } + + protected function getHeaderActions(): array + { + return [ + DeleteAction::make(), + ]; + } +} diff --git a/admin/app/Filament/Resources/ShippingLineResource/Pages/ListShippingLines.php b/admin/app/Filament/Resources/ShippingLineResource/Pages/ListShippingLines.php new file mode 100644 index 00000000..2c26fd46 --- /dev/null +++ b/admin/app/Filament/Resources/ShippingLineResource/Pages/ListShippingLines.php @@ -0,0 +1,23 @@ +components([ + Select::make('shipping_line_id') + ->relationship('shippingLine', 'name') + ->required() + ->searchable() + ->preload() + ->native(false) + ->hintIcon('heroicon-o-information-circle') + ->hintIconTooltip('The carrier this method belongs to (e.g. Post Office, Tap30).'), + TextInput::make('name') + ->required() + ->maxLength(255) + ->hintIcon('heroicon-o-information-circle') + ->hintIconTooltip('The service name shown to admins (e.g. "Standard Post", "Overnight Express").'), + TextInput::make('type') + ->nullable() + ->maxLength(100) + ->hintIcon('heroicon-o-information-circle') + ->hintIconTooltip('Service category, e.g. Express, Economy, Special.'), + TextInput::make('min_count') + ->numeric() + ->nullable() + ->hintIcon('heroicon-o-information-circle') + ->hintIconTooltip('Minimum item quantity required to use this method. Leave empty for no minimum.'), + TextInput::make('min_amount') + ->numeric() + ->nullable() + ->prefix('تومان') + ->hintIcon('heroicon-o-information-circle') + ->hintIconTooltip('Minimum order amount required to use this method. Leave empty for no minimum.'), + Select::make('for') + ->required() + ->options(ShippingMethodForEnum::options()) + ->default(ShippingMethodForEnum::CUSTOMER->value) + ->native(false) + ->hintIcon('heroicon-o-information-circle') + ->hintIconTooltip('Who can use this method — Customer, Partner, or Employee.'), + DateTimePicker::make('disable_from') + ->nullable() + ->hintIcon('heroicon-o-information-circle') + ->hintIconTooltip('Start of the period when this method is unavailable (e.g. holiday blackout).'), + DateTimePicker::make('disable_to') + ->nullable() + ->hintIcon('heroicon-o-information-circle') + ->hintIconTooltip('End of the unavailability period.'), + Toggle::make('status') + ->default(true) + ->hintIcon('heroicon-o-information-circle') + ->hintIconTooltip('When off, this method is hidden from all users.'), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + TextColumn::make('shippingLine.name') + ->label('Carrier') + ->sortable() + ->searchable(), + TextColumn::make('name') + ->searchable() + ->sortable(), + TextColumn::make('type') + ->placeholder('—'), + TextColumn::make('for') + ->getStateUsing(fn (ShippingMethod $record): string => $record->for->label()) + ->color(fn (ShippingMethod $record): string => $record->for->color()), + TextColumn::make('min_amount') + ->money() + ->placeholder('No minimum'), + IconColumn::make('status') + ->boolean(), + TextColumn::make('created_at') + ->dateTime() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->filters([ + // + ]) + ->recordActions([ + EditAction::make(), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } + + public static function getRelations(): array + { + return []; + } + + public static function getPages(): array + { + return [ + 'index' => ListShippingMethods::route('/'), + 'create' => CreateShippingMethod::route('/create'), + 'edit' => EditShippingMethod::route('/{record}/edit'), + ]; + } +} diff --git a/admin/app/Filament/Resources/ShippingMethodResource/Pages/CreateShippingMethod.php b/admin/app/Filament/Resources/ShippingMethodResource/Pages/CreateShippingMethod.php new file mode 100644 index 00000000..32e553df --- /dev/null +++ b/admin/app/Filament/Resources/ShippingMethodResource/Pages/CreateShippingMethod.php @@ -0,0 +1,18 @@ +getResource()::getUrl('index'); + } +} diff --git a/admin/app/Filament/Resources/ShippingMethodResource/Pages/EditShippingMethod.php b/admin/app/Filament/Resources/ShippingMethodResource/Pages/EditShippingMethod.php new file mode 100644 index 00000000..8f473639 --- /dev/null +++ b/admin/app/Filament/Resources/ShippingMethodResource/Pages/EditShippingMethod.php @@ -0,0 +1,26 @@ +getResource()::getUrl('index'); + } + + protected function getHeaderActions(): array + { + return [ + DeleteAction::make(), + ]; + } +} diff --git a/admin/app/Filament/Resources/ShippingMethodResource/Pages/ListShippingMethods.php b/admin/app/Filament/Resources/ShippingMethodResource/Pages/ListShippingMethods.php new file mode 100644 index 00000000..5566798e --- /dev/null +++ b/admin/app/Filament/Resources/ShippingMethodResource/Pages/ListShippingMethods.php @@ -0,0 +1,23 @@ + 'boolean', + 'status' => 'boolean', + 'disable_from' => 'datetime', + 'disable_to' => 'datetime', + ]; + + public function shippingMethod(): BelongsTo + { + return $this->belongsTo(ShippingMethod::class); + } + + public function province(): BelongsTo + { + return $this->belongsTo(Province::class); + } + + public function city(): BelongsTo + { + return $this->belongsTo(City::class); + } +} diff --git a/admin/app/Models/ShippingLine.php b/admin/app/Models/ShippingLine.php new file mode 100644 index 00000000..e0f8d952 --- /dev/null +++ b/admin/app/Models/ShippingLine.php @@ -0,0 +1,31 @@ + $shippingMethods + */ +class ShippingLine extends Model +{ + use HasFactory; + + protected $fillable = [ + 'name', + 'cost', + ]; + + public function shippingMethods(): HasMany + { + return $this->hasMany(ShippingMethod::class); + } +} diff --git a/admin/app/Models/ShippingMethod.php b/admin/app/Models/ShippingMethod.php new file mode 100644 index 00000000..06c58579 --- /dev/null +++ b/admin/app/Models/ShippingMethod.php @@ -0,0 +1,61 @@ + $shippingCities + */ +class ShippingMethod extends Model +{ + use HasFactory; + + protected $fillable = [ + 'shipping_line_id', + 'name', + 'type', + 'min_count', + 'min_amount', + 'for', + 'disable_from', + 'disable_to', + 'status', + ]; + + protected $casts = [ + 'for' => ShippingMethodForEnum::class, + 'status' => 'boolean', + 'disable_from' => 'datetime', + 'disable_to' => 'datetime', + ]; + + public function shippingLine(): BelongsTo + { + return $this->belongsTo(ShippingLine::class); + } + + public function shippingCities(): HasMany + { + return $this->hasMany(ShippingCity::class); + } +} diff --git a/admin/database/factories/ShippingCityFactory.php b/admin/database/factories/ShippingCityFactory.php new file mode 100644 index 00000000..67906cfb --- /dev/null +++ b/admin/database/factories/ShippingCityFactory.php @@ -0,0 +1,36 @@ + + */ +class ShippingCityFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'shipping_method_id' => ShippingMethod::factory(), + 'province_id' => null, + 'city_id' => City::inRandomOrder()->first()?->id, + 'pay_on_delivery' => fake()->boolean(20), + 'amount' => fake()->optional(0.8)->numberBetween(0, 200000), + 'sending_days' => fake()->optional()->randomElement(['1,2,3,4,5', '6,7', '1,3,5']), + 'disable_from' => null, + 'disable_to' => null, + 'delay' => fake()->optional()->numberBetween(0, 5), + 'description' => fake()->optional()->sentence(), + 'status' => true, + ]; + } +} diff --git a/admin/database/factories/ShippingLineFactory.php b/admin/database/factories/ShippingLineFactory.php new file mode 100644 index 00000000..b90dab65 --- /dev/null +++ b/admin/database/factories/ShippingLineFactory.php @@ -0,0 +1,25 @@ + + */ +class ShippingLineFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'name' => fake()->words(2, true), + 'cost' => fake()->numberBetween(0, 500000), + ]; + } +} diff --git a/admin/database/factories/ShippingMethodFactory.php b/admin/database/factories/ShippingMethodFactory.php new file mode 100644 index 00000000..d905661d --- /dev/null +++ b/admin/database/factories/ShippingMethodFactory.php @@ -0,0 +1,34 @@ + + */ +class ShippingMethodFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'shipping_line_id' => ShippingLine::factory(), + 'name' => fake()->words(3, true), + 'type' => fake()->optional()->randomElement(['Express', 'Special', 'Economy', 'Standard']), + 'min_count' => fake()->optional()->numberBetween(1, 10), + 'min_amount' => fake()->optional()->numberBetween(100000, 1000000), + 'for' => fake()->randomElement(ShippingMethodForEnum::cases()), + 'disable_from' => null, + 'disable_to' => null, + 'status' => true, + ]; + } +} diff --git a/admin/database/migrations/2026_06_20_000010_create_shipping_lines_table.php b/admin/database/migrations/2026_06_20_000010_create_shipping_lines_table.php new file mode 100644 index 00000000..1efbbff0 --- /dev/null +++ b/admin/database/migrations/2026_06_20_000010_create_shipping_lines_table.php @@ -0,0 +1,25 @@ +id(); + $table->string('name'); + $table->unsignedInteger('cost'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('shipping_lines'); + } +}; diff --git a/admin/database/migrations/2026_06_20_000011_create_shipping_methods_table.php b/admin/database/migrations/2026_06_20_000011_create_shipping_methods_table.php new file mode 100644 index 00000000..afdeaeca --- /dev/null +++ b/admin/database/migrations/2026_06_20_000011_create_shipping_methods_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignIdFor(ShippingLine::class)->constrained()->restrictOnDelete(); + $table->string('name'); + $table->string('type')->nullable(); + $table->unsignedInteger('min_count')->nullable(); + $table->unsignedInteger('min_amount')->nullable(); + $table->unsignedTinyInteger('for')->default(ShippingMethodForEnum::CUSTOMER->value); + $table->dateTime('disable_from')->nullable(); + $table->dateTime('disable_to')->nullable(); + $table->boolean('status')->default(true); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('shipping_methods'); + } +}; diff --git a/admin/database/migrations/2026_06_20_000012_create_shipping_cities_table.php b/admin/database/migrations/2026_06_20_000012_create_shipping_cities_table.php new file mode 100644 index 00000000..884eebaa --- /dev/null +++ b/admin/database/migrations/2026_06_20_000012_create_shipping_cities_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignIdFor(ShippingMethod::class)->constrained()->cascadeOnDelete(); + $table->foreignIdFor(Province::class)->nullable()->constrained()->nullOnDelete(); + $table->foreignIdFor(City::class)->nullable()->constrained()->nullOnDelete(); + $table->boolean('pay_on_delivery')->default(false); + $table->unsignedInteger('amount')->nullable(); + $table->string('sending_days')->nullable(); + $table->dateTime('disable_from')->nullable(); + $table->dateTime('disable_to')->nullable(); + $table->unsignedInteger('delay')->nullable(); + $table->text('description')->nullable(); + $table->boolean('status')->default(true); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('shipping_cities'); + } +}; diff --git a/admin/database/seeders/ShippingCitySeeder.php b/admin/database/seeders/ShippingCitySeeder.php new file mode 100644 index 00000000..aeeadd98 --- /dev/null +++ b/admin/database/seeders/ShippingCitySeeder.php @@ -0,0 +1,18 @@ +each->delete(); + + ShippingCity::factory()->count(20)->create(); + } +} diff --git a/admin/database/seeders/ShippingLineSeeder.php b/admin/database/seeders/ShippingLineSeeder.php new file mode 100644 index 00000000..161bc518 --- /dev/null +++ b/admin/database/seeders/ShippingLineSeeder.php @@ -0,0 +1,18 @@ +each->delete(); + + ShippingLine::factory()->count(20)->create(); + } +} diff --git a/admin/database/seeders/ShippingMethodSeeder.php b/admin/database/seeders/ShippingMethodSeeder.php new file mode 100644 index 00000000..26bd1484 --- /dev/null +++ b/admin/database/seeders/ShippingMethodSeeder.php @@ -0,0 +1,18 @@ +each->delete(); + + ShippingMethod::factory()->count(20)->create(); + } +} diff --git a/admin/database/seeders/TestSeeder.php b/admin/database/seeders/TestSeeder.php index 2596b2e6..3a992851 100644 --- a/admin/database/seeders/TestSeeder.php +++ b/admin/database/seeders/TestSeeder.php @@ -24,6 +24,9 @@ public function run(): void FaqSeeder::class, ReviewSeeder::class, WishlistSeeder::class, + ShippingLineSeeder::class, + ShippingMethodSeeder::class, + ShippingCitySeeder::class, ]); } } diff --git a/admin/docs/IMPLEMENTATION.md b/admin/docs/IMPLEMENTATION.md index 452c554c..93d3715c 100644 --- a/admin/docs/IMPLEMENTATION.md +++ b/admin/docs/IMPLEMENTATION.md @@ -83,9 +83,9 @@ Depend mostly on Images only. ## Phase 3 - Inventory & logistics (Orders prerequisites) - [ ] Warehouses (unblocks variety `warehouse_id`) -- [ ] Shipping Lines -- [ ] Shipping Methods -- [ ] Shipping Cities +- [x] Shipping Lines (carrier table; `name` + `cost`) +- [x] Shipping Methods (`shipping_line_id` FK to Shipping Lines; service tier with rules) +- [x] Shipping Cities (per-city cost and availability overrides per shipping method) - [ ] Guarantee Names - [x] `attribute_variety` pivot (done — moved up from Phase 3) - [ ] Variety extensions: `variety_serials`, `variety_details` diff --git a/admin/docs/SHIPPING_GUIDE.md b/admin/docs/SHIPPING_GUIDE.md new file mode 100644 index 00000000..c14aabdb --- /dev/null +++ b/admin/docs/SHIPPING_GUIDE.md @@ -0,0 +1,189 @@ +# ShopFlow Shipping & Logistics Guide + +A complete reference for the shipping system: how the tables relate, how to configure a new carrier, and how the frontend should resolve shipping costs at checkout. + +--- + +## Overview + +The shipping system has three levels: + +``` +shipping_lines (Carrier / Company) + └── shipping_methods (Service tier with rules) + └── shipping_cities (Per-city cost & availability) +``` + +| Level | Table | Purpose | +|---|---|---| +| 1 | `shipping_lines` | The carrier company (e.g. Post, Express Courier) | +| 2 | `shipping_methods` | A specific service offered by that carrier, with order rules | +| 3 | `shipping_cities` | Per-city cost override, schedule, and availability for a method | + +--- + +## Table Reference + +### `shipping_lines` + +The top-level carrier entity. Minimal by design. + +| Column | Type | Description | +|---|---|---| +| `id` | PK | — | +| `name` | string | Carrier name shown in admin (e.g. "Post Office", "Same-day Courier") | +| `cost` | unsigned int | Base cost in Toman. Can be overridden per city in `shipping_cities` | + +--- + +### `shipping_methods` + +A service tier belonging to a carrier. Defines who can use it, minimum order requirements, and blackout periods. + +| Column | Type | Description | +|---|---|---| +| `id` | PK | — | +| `shipping_line_id` | FK → `shipping_lines` | The carrier this method belongs to | +| `name` | string | Service name (e.g. "Standard Post", "Overnight Express") | +| `type` | string | Service category (e.g. Express, Special, Economy) | +| `min_count` | unsigned int (nullable) | Minimum item quantity required to use this method | +| `min_amount` | unsigned int (nullable) | Minimum order amount required to use this method | +| `for` | `ShippingMethodForEnum` (int) | Audience: `CUSTOMER=10` (default), `PARTNER=20`, `EMPLOYEE=30` | +| `disable_from` | datetime (nullable) | Start of unavailability window | +| `disable_to` | datetime (nullable) | End of unavailability window | +| `status` | boolean | `true` = active (default), `false` = hidden from all users | + +--- + +### `shipping_cities` + +Per-city (or per-province) configuration for a shipping method. Overrides cost and availability at a geographic level. + +| Column | Type | Description | +|---|---|---| +| `id` | PK | — | +| `shipping_method_id` | FK → `shipping_methods` | Which method this config applies to | +| `province_id` | FK → `provinces` (nullable) | Province-level rule. One of province or city must be set. | +| `city_id` | FK → `cities` (nullable) | City-level rule. Takes priority over province-level. | +| `pay_on_delivery` | boolean | If true, no online payment needed — paid on delivery | +| `amount` | unsigned int (nullable) | Shipping cost for this city. Null = postpaid. 0 = free. | +| `sending_days` | string (nullable) | Days this method ships: single day, even/odd, or a pattern | +| `disable_from` | datetime (nullable) | Start of city-specific unavailability | +| `disable_to` | datetime (nullable) | End of city-specific unavailability | +| `delay` | unsigned int (nullable) | Extra delivery delay in days for this city | +| `description` | text (nullable) | Human-readable schedule detail (e.g. "up to 72 hours", "18:00–22:00 today") | +| `status` | boolean | Active / inactive for this city | + +--- + +## Relationships + +``` +ShippingLine + hasMany ShippingMethod + +ShippingMethod + belongsTo ShippingLine + hasMany ShippingCity + +ShippingCity + belongsTo ShippingMethod + belongsTo Province (nullable) + belongsTo City (nullable) +``` + +--- + +## Step-by-step: Add a New Carrier + +**Example: Add "Tap30 Express" delivery** + +### Step 1 — Create the Shipping Line + +In admin → Logistics → Shipping Lines → Create: + +| Field | Value | +|---|---| +| Name | Tap30 Express | +| Cost | 50,000 | + +### Step 2 — Create Shipping Methods for that Line + +In admin → Logistics → Shipping Methods → Create: + +| Field | Value | +|---|---| +| Shipping Line | Tap30 Express | +| Name | Tap30 – Same Day | +| Type | Express | +| Min Amount | 200,000 | +| For | Customer | +| Status | Active | + +Repeat for other tiers (e.g. "Tap30 – Scheduled" for next-day). + +### Step 3 — Configure Per-city Availability + +In admin → Logistics → Shipping Cities → Create: + +| Field | Value | +|---|---| +| Shipping Method | Tap30 – Same Day | +| City | Tehran | +| Amount | 45,000 | +| Pay on Delivery | No | +| Sending Days | 1,2,3,4,5 (Saturday–Wednesday) | +| Delay | 0 | +| Description | Delivered within 2 hours | +| Status | Active | + +--- + +## Frontend: Resolving Shipping at Checkout + +At checkout the frontend receives the user's city. The resolution steps: + +``` +1. Load all active ShippingCities for that city_id + → also load ShippingCities for the user's province_id (fallback) + +2. For each ShippingCity, load its ShippingMethod + → check method.status = active + → check method.min_count ≤ cart item count + → check method.min_amount ≤ cart total + → check method.disable_from / disable_to (not in blackout) + +3. Check ShippingCity + → status = active + → disable_from / disable_to (not in blackout) + → sending_days includes today + +4. Resolve cost: + → if pay_on_delivery: show "Pay on delivery" + → if amount = 0: show "Free" + → if amount is null: show "Postpaid (calculated on delivery)" + → otherwise: show amount (in Toman) + +5. Present the filtered list to the user +``` + +**Priority rule:** city-level `shipping_cities` records take priority over province-level records for the same method. + +--- + +## Implementation Status + +| Table | Status | Notes | +|---|---|---| +| `shipping_lines` | ✅ Done | Migration, model, factory, Filament resource, tests | +| `shipping_methods` | ✅ Done | Migration, model, factory, Filament resource, tests. `ShippingMethodForEnum` for `for` field. | +| `shipping_cities` | ✅ Done | Migration, model, factory, Filament resource, tests. Cascade-deletes when method is deleted. | + +--- + +## Important Notes + +- A `shipping_city` entry can target a **province** (applies to all cities in that province) or a specific **city**. Use city-level entries to override province defaults. +- `amount = 0` means free shipping — do not confuse with `null` (postpaid). +- `sending_days` format is not yet standardised — define the pattern constants in your frontend or a config file before using this field. +- The `for` field on `shipping_methods` allows restricting methods to partners or employees (e.g. internal logistics) without exposing them to customers. diff --git a/admin/docs/ShoFlow db doc.md b/admin/docs/ShoFlow db doc.md index 703090e5..775100dc 100644 --- a/admin/docs/ShoFlow db doc.md +++ b/admin/docs/ShoFlow db doc.md @@ -643,20 +643,23 @@ Used to store products. # shipping\_lines -* A separate line must be specified for each shipping method. -* The `name` column specifies the name. -* The `cost` column specifies the cost. +Represents a shipping carrier or company (e.g. Post Office, Express Courier, Same-day Delivery). Acts as the top level of the shipping hierarchy: `shipping_lines → shipping_methods → shipping_cities`. + +* `name`: The carrier name shown to admins (e.g. "Post", "Express Courier"). +* `cost`: Base cost for this carrier. Can be overridden per city in `shipping_cities`. # shipping\_methods -* For storing product shipping methods. -* The `name` column specifies the shipping method name, such as Post, Snap, etc. -* The `type` column specifies the shipping method type, such as Express, Special, etc. -* The `min_count` column specifies the minimum purchase quantity for shipping via this method. -* The `min_amount` column specifies the minimum purchase amount for shipping via this method. -* The `for` column specifies if this shipping method is for a partner, customer, or employee. -* The `disable_from` and `disable_to` columns specify the period when shipping is not available. -* The `status` column specifies whether the shipping method is active or inactive. +A specific service tier offered by a shipping carrier. References `shipping_lines` and is further scoped per city via `shipping_cities`. + +* `shipping_line_id`: The carrier this method belongs to (FK to `shipping_lines`). Added during ShopFlow implementation — not in the original doc. +* `name`: The service name, e.g. "Standard Post", "Overnight Express". +* `type`: Service type, e.g. Express, Special, Economy. +* `min_count`: Minimum purchase quantity required to use this method. +* `min_amount`: Minimum purchase amount required to use this method. +* `for`: Audience — `CUSTOMER=10` (default), `PARTNER=20`, `EMPLOYEE=30`. Implemented as `ShippingMethodForEnum`. +* `disable_from` / `disable_to`: Period during which this method is unavailable. +* `status`: Active or inactive. # sliders diff --git a/admin/tests/Feature/Filament/Resource/ShippingCityResourceTest.php b/admin/tests/Feature/Filament/Resource/ShippingCityResourceTest.php new file mode 100644 index 00000000..6470036a --- /dev/null +++ b/admin/tests/Feature/Filament/Resource/ShippingCityResourceTest.php @@ -0,0 +1,93 @@ +assertOk(); +}); + +it('can list shipping cities in the table.', function () { + $cities = ShippingCity::factory()->count(5)->create(); + + livewire(ShippingCityResource\Pages\ListShippingCities::class) + ->assertCanSeeTableRecords($cities); +}); + +it('can render edit page.', function () { + $city = ShippingCity::factory()->create(); + + get(ShippingCityResource::getUrl('edit', [ + 'record' => $city, + ]))->assertOk(); +}); + +it('can create shipping city.', function () { + $method = ShippingMethod::factory()->create(); + + livewire(ShippingCityResource\Pages\CreateShippingCity::class) + ->fillForm([ + 'shipping_method_id' => $method->id, + 'amount' => 50000, + 'pay_on_delivery' => false, + 'status' => true, + ]) + ->call('create') + ->assertHasNoFormErrors(); + + $this->assertDatabaseHas(ShippingCity::class, [ + 'shipping_method_id' => $method->id, + 'amount' => 50000, + ]); +}); + +it('can update shipping city.', function () { + $city = ShippingCity::factory()->create(); + + livewire(ShippingCityResource\Pages\EditShippingCity::class, [ + 'record' => $city->getRouteKey(), + ]) + ->fillForm([ + 'shipping_method_id' => $city->shipping_method_id, + 'amount' => 75000, + 'pay_on_delivery' => false, + 'status' => false, + ]) + ->call('save') + ->assertHasNoFormErrors(); + + expect($city->refresh()) + ->amount->toBe(75000) + ->status->toBeFalse(); +}); + +it('can delete shipping city.', function () { + $city = ShippingCity::factory()->create(); + + livewire(ShippingCityResource\Pages\EditShippingCity::class, [ + 'record' => $city->getRouteKey(), + ]) + ->callAction(DeleteAction::class); + + $this->assertModelMissing($city); +}); + +it('cascades delete when shipping method is deleted.', function () { + $city = ShippingCity::factory()->create(); + $method = $city->shippingMethod; + + $method->delete(); + + $this->assertModelMissing($city); +}); diff --git a/admin/tests/Feature/Filament/Resource/ShippingLineResourceTest.php b/admin/tests/Feature/Filament/Resource/ShippingLineResourceTest.php new file mode 100644 index 00000000..83f8bcbb --- /dev/null +++ b/admin/tests/Feature/Filament/Resource/ShippingLineResourceTest.php @@ -0,0 +1,80 @@ +assertOk(); +}); + +it('can list shipping lines in the table.', function () { + $lines = ShippingLine::factory()->count(5)->create(); + + livewire(ShippingLineResource\Pages\ListShippingLines::class) + ->assertCanSeeTableRecords($lines); +}); + +it('can render edit page.', function () { + $line = ShippingLine::factory()->create(); + + get(ShippingLineResource::getUrl('edit', [ + 'record' => $line, + ]))->assertOk(); +}); + +it('can create shipping line.', function () { + $new = ShippingLine::factory()->make(); + + livewire(ShippingLineResource\Pages\CreateShippingLine::class) + ->fillForm([ + 'name' => $new->name, + 'cost' => $new->cost, + ]) + ->call('create') + ->assertHasNoFormErrors(); + + $this->assertDatabaseHas(ShippingLine::class, [ + 'name' => $new->name, + 'cost' => $new->cost, + ]); +}); + +it('can update shipping line.', function () { + $line = ShippingLine::factory()->create(); + $new = ShippingLine::factory()->make(); + + livewire(ShippingLineResource\Pages\EditShippingLine::class, [ + 'record' => $line->getRouteKey(), + ]) + ->fillForm([ + 'name' => $new->name, + 'cost' => $new->cost, + ]) + ->call('save') + ->assertHasNoFormErrors(); + + expect($line->refresh()) + ->name->toBe($new->name) + ->cost->toBe($new->cost); +}); + +it('can delete shipping line.', function () { + $line = ShippingLine::factory()->create(); + + livewire(ShippingLineResource\Pages\EditShippingLine::class, [ + 'record' => $line->getRouteKey(), + ]) + ->callAction(DeleteAction::class); + + $this->assertModelMissing($line); +}); diff --git a/admin/tests/Feature/Filament/Resource/ShippingMethodResourceTest.php b/admin/tests/Feature/Filament/Resource/ShippingMethodResourceTest.php new file mode 100644 index 00000000..a0ba43e5 --- /dev/null +++ b/admin/tests/Feature/Filament/Resource/ShippingMethodResourceTest.php @@ -0,0 +1,86 @@ +assertOk(); +}); + +it('can list shipping methods in the table.', function () { + $methods = ShippingMethod::factory()->count(5)->create(); + + livewire(ShippingMethodResource\Pages\ListShippingMethods::class) + ->assertCanSeeTableRecords($methods); +}); + +it('can render edit page.', function () { + $method = ShippingMethod::factory()->create(); + + get(ShippingMethodResource::getUrl('edit', [ + 'record' => $method, + ]))->assertOk(); +}); + +it('can create shipping method.', function () { + $line = ShippingLine::factory()->create(); + + livewire(ShippingMethodResource\Pages\CreateShippingMethod::class) + ->fillForm([ + 'shipping_line_id' => $line->id, + 'name' => 'Standard Post', + 'type' => 'Economy', + 'for' => ShippingMethodForEnum::CUSTOMER->value, + 'status' => true, + ]) + ->call('create') + ->assertHasNoFormErrors(); + + $this->assertDatabaseHas(ShippingMethod::class, [ + 'name' => 'Standard Post', + 'shipping_line_id' => $line->id, + ]); +}); + +it('can update shipping method.', function () { + $method = ShippingMethod::factory()->create(); + $new = ShippingMethod::factory()->make(['shipping_line_id' => $method->shipping_line_id]); + + livewire(ShippingMethodResource\Pages\EditShippingMethod::class, [ + 'record' => $method->getRouteKey(), + ]) + ->fillForm([ + 'shipping_line_id' => $new->shipping_line_id, + 'name' => $new->name, + 'for' => ShippingMethodForEnum::CUSTOMER->value, + 'status' => $new->status, + ]) + ->call('save') + ->assertHasNoFormErrors(); + + expect($method->refresh()) + ->name->toBe($new->name); +}); + +it('can delete shipping method.', function () { + $method = ShippingMethod::factory()->create(); + + livewire(ShippingMethodResource\Pages\EditShippingMethod::class, [ + 'record' => $method->getRouteKey(), + ]) + ->callAction(DeleteAction::class); + + $this->assertModelMissing($method); +});