From ef70ae9dc566f899f79348b8f90879c5d8454261 Mon Sep 17 00:00:00 2001 From: Bahman026 Date: Sat, 20 Jun 2026 15:59:25 +0330 Subject: [PATCH 1/3] feat: implement Shipping Lines and add logistics docs ShippingLines: - Migration, model, factory, ShippingLineSeeder (20 records in TestSeeder) - ShippingLineResource under new Logistics nav group - ShippingLineResourceTest: 5 tests Docs: - ShoFlow db doc: rewrite shipping_lines and shipping_methods sections; add shipping_line_id FK design decision to shipping_methods - IMPLEMENTATION.md: mark Shipping Lines done, clarify next steps - SHIPPING_GUIDE.md: new doc covering full 3-level hierarchy, table reference, step-by-step setup, and frontend checkout algorithm Co-authored-by: Cursor --- .../Resources/ShippingLineResource.php | 89 +++++++++ .../Pages/CreateShippingLine.php | 18 ++ .../Pages/EditShippingLine.php | 26 +++ .../Pages/ListShippingLines.php | 23 +++ admin/app/Models/ShippingLine.php | 23 +++ .../factories/ShippingLineFactory.php | 25 +++ ..._20_000010_create_shipping_lines_table.php | 25 +++ admin/database/seeders/ShippingLineSeeder.php | 18 ++ admin/database/seeders/TestSeeder.php | 1 + admin/docs/IMPLEMENTATION.md | 6 +- admin/docs/SHIPPING_GUIDE.md | 179 ++++++++++++++++++ admin/docs/ShoFlow db doc.md | 25 +-- .../Resource/ShippingLineResourceTest.php | 80 ++++++++ 13 files changed, 524 insertions(+), 14 deletions(-) create mode 100644 admin/app/Filament/Resources/ShippingLineResource.php create mode 100644 admin/app/Filament/Resources/ShippingLineResource/Pages/CreateShippingLine.php create mode 100644 admin/app/Filament/Resources/ShippingLineResource/Pages/EditShippingLine.php create mode 100644 admin/app/Filament/Resources/ShippingLineResource/Pages/ListShippingLines.php create mode 100644 admin/app/Models/ShippingLine.php create mode 100644 admin/database/factories/ShippingLineFactory.php create mode 100644 admin/database/migrations/2026_06_20_000010_create_shipping_lines_table.php create mode 100644 admin/database/seeders/ShippingLineSeeder.php create mode 100644 admin/docs/SHIPPING_GUIDE.md create mode 100644 admin/tests/Feature/Filament/Resource/ShippingLineResourceTest.php diff --git a/admin/app/Filament/Resources/ShippingLineResource.php b/admin/app/Filament/Resources/ShippingLineResource.php new file mode 100644 index 00000000..a9a53b97 --- /dev/null +++ b/admin/app/Filament/Resources/ShippingLineResource.php @@ -0,0 +1,89 @@ +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 @@ + + */ +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/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/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/TestSeeder.php b/admin/database/seeders/TestSeeder.php index 2596b2e6..b1d3a2f7 100644 --- a/admin/database/seeders/TestSeeder.php +++ b/admin/database/seeders/TestSeeder.php @@ -24,6 +24,7 @@ public function run(): void FaqSeeder::class, ReviewSeeder::class, WishlistSeeder::class, + ShippingLineSeeder::class, ]); } } diff --git a/admin/docs/IMPLEMENTATION.md b/admin/docs/IMPLEMENTATION.md index 452c554c..1b67322c 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`) +- [ ] Shipping Methods (`shipping_line_id` FK to Shipping Lines; service tier with rules) +- [ ] 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..f7e51d7b --- /dev/null +++ b/admin/docs/SHIPPING_GUIDE.md @@ -0,0 +1,179 @@ +# 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` | enum | Audience restriction: `customer`, `partner`, `employee` | +| `disable_from` | datetime (nullable) | Start of unavailability window | +| `disable_to` | datetime (nullable) | End of unavailability window | +| `status` | boolean | Active / inactive | + +--- + +### `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. + +--- + +## 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..fcecd448 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, partner, or employee. +* `disable_from` / `disable_to`: Period during which this method is unavailable. +* `status`: Active or inactive. # sliders 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); +}); From 9bd342c6c3cf244d8678be2f9864e0f538399422 Mon Sep 17 00:00:00 2001 From: Bahman026 Date: Sat, 20 Jun 2026 16:04:14 +0330 Subject: [PATCH 2/3] feat: implement Shipping Methods - ShippingMethodForEnum (CUSTOMER=10, PARTNER=20, EMPLOYEE=30) - Migration with shipping_line_id FK (restrictOnDelete), name, type, min_count, min_amount, for (enum), disable_from/to, status (bool) - ShippingMethod model, factory, ShippingMethodSeeder (20 records) - ShippingMethodResource under Logistics nav group with full form/table - ShippingMethodResourceTest: 5 tests - ShippingLine model: add shippingMethods() HasMany back-relation - Update ShoFlow db doc, IMPLEMENTATION.md, SHIPPING_GUIDE.md Co-authored-by: Cursor --- admin/app/Enums/ShippingMethodForEnum.php | 34 +++++ .../Resources/ShippingMethodResource.php | 141 ++++++++++++++++++ .../Pages/CreateShippingMethod.php | 18 +++ .../Pages/EditShippingMethod.php | 26 ++++ .../Pages/ListShippingMethods.php | 23 +++ admin/app/Models/ShippingLine.php | 8 + admin/app/Models/ShippingMethod.php | 53 +++++++ .../factories/ShippingMethodFactory.php | 34 +++++ ...0_000011_create_shipping_methods_table.php | 34 +++++ .../database/seeders/ShippingMethodSeeder.php | 18 +++ admin/database/seeders/TestSeeder.php | 1 + admin/docs/IMPLEMENTATION.md | 2 +- admin/docs/SHIPPING_GUIDE.md | 14 +- admin/docs/ShoFlow db doc.md | 2 +- .../Resource/ShippingMethodResourceTest.php | 86 +++++++++++ 15 files changed, 490 insertions(+), 4 deletions(-) create mode 100644 admin/app/Enums/ShippingMethodForEnum.php create mode 100644 admin/app/Filament/Resources/ShippingMethodResource.php create mode 100644 admin/app/Filament/Resources/ShippingMethodResource/Pages/CreateShippingMethod.php create mode 100644 admin/app/Filament/Resources/ShippingMethodResource/Pages/EditShippingMethod.php create mode 100644 admin/app/Filament/Resources/ShippingMethodResource/Pages/ListShippingMethods.php create mode 100644 admin/app/Models/ShippingMethod.php create mode 100644 admin/database/factories/ShippingMethodFactory.php create mode 100644 admin/database/migrations/2026_06_20_000011_create_shipping_methods_table.php create mode 100644 admin/database/seeders/ShippingMethodSeeder.php create mode 100644 admin/tests/Feature/Filament/Resource/ShippingMethodResourceTest.php 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/ShippingMethodResource.php b/admin/app/Filament/Resources/ShippingMethodResource.php new file mode 100644 index 00000000..0fab0ad7 --- /dev/null +++ b/admin/app/Filament/Resources/ShippingMethodResource.php @@ -0,0 +1,141 @@ +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 @@ + $shippingMethods */ class ShippingLine extends Model { @@ -20,4 +23,9 @@ class ShippingLine extends Model '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..e9721443 --- /dev/null +++ b/admin/app/Models/ShippingMethod.php @@ -0,0 +1,53 @@ + ShippingMethodForEnum::class, + 'status' => 'boolean', + 'disable_from' => 'datetime', + 'disable_to' => 'datetime', + ]; + + public function shippingLine(): BelongsTo + { + return $this->belongsTo(ShippingLine::class); + } +} 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_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/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 b1d3a2f7..d47ed5dc 100644 --- a/admin/database/seeders/TestSeeder.php +++ b/admin/database/seeders/TestSeeder.php @@ -25,6 +25,7 @@ public function run(): void ReviewSeeder::class, WishlistSeeder::class, ShippingLineSeeder::class, + ShippingMethodSeeder::class, ]); } } diff --git a/admin/docs/IMPLEMENTATION.md b/admin/docs/IMPLEMENTATION.md index 1b67322c..7ad4899e 100644 --- a/admin/docs/IMPLEMENTATION.md +++ b/admin/docs/IMPLEMENTATION.md @@ -84,7 +84,7 @@ Depend mostly on Images only. - [ ] Warehouses (unblocks variety `warehouse_id`) - [x] Shipping Lines (carrier table; `name` + `cost`) -- [ ] Shipping Methods (`shipping_line_id` FK to Shipping Lines; service tier with rules) +- [x] Shipping Methods (`shipping_line_id` FK to Shipping Lines; service tier with rules) - [ ] Shipping Cities (per-city cost and availability overrides per shipping method) - [ ] Guarantee Names - [x] `attribute_variety` pivot (done — moved up from Phase 3) diff --git a/admin/docs/SHIPPING_GUIDE.md b/admin/docs/SHIPPING_GUIDE.md index f7e51d7b..4af3d95b 100644 --- a/admin/docs/SHIPPING_GUIDE.md +++ b/admin/docs/SHIPPING_GUIDE.md @@ -48,10 +48,10 @@ A service tier belonging to a carrier. Defines who can use it, minimum order req | `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` | enum | Audience restriction: `customer`, `partner`, `employee` | +| `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 | Active / inactive | +| `status` | boolean | `true` = active (default), `false` = hidden from all users | --- @@ -171,6 +171,16 @@ At checkout the frontend receives the user's city. The resolution steps: --- +## 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` | ⏳ Pending | Next to implement (Phase 3) | + +--- + ## 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. diff --git a/admin/docs/ShoFlow db doc.md b/admin/docs/ShoFlow db doc.md index fcecd448..775100dc 100644 --- a/admin/docs/ShoFlow db doc.md +++ b/admin/docs/ShoFlow db doc.md @@ -657,7 +657,7 @@ A specific service tier offered by a shipping carrier. References `shipping_line * `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, partner, or employee. +* `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. 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); +}); From d25b0dcd433d9a1997c2310e9a92a937743c0747 Mon Sep 17 00:00:00 2001 From: Bahman026 Date: Sat, 20 Jun 2026 16:08:04 +0330 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20implement=20Shipping=20Cities=20?= =?UTF-8?q?=E2=80=94=20complete=20Logistics=20module?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Migration with shipping_method_id (cascade), province_id/city_id (nullOnDelete), pay_on_delivery, amount (null=postpaid, 0=free), sending_days, disable_from/to, delay, description, status - ShippingCity model with shippingMethod, province, city relations - ShippingMethod model: add shippingCities() HasMany back-relation - ShippingCityFactory, ShippingCitySeeder (20 records in TestSeeder) - ShippingCityResource under Logistics nav group (sort=3) - ShippingCityResourceTest: 6 tests including cascade delete - Update IMPLEMENTATION.md and SHIPPING_GUIDE.md Co-authored-by: Cursor --- .../Resources/ShippingCityResource.php | 160 ++++++++++++++++++ .../Pages/CreateShippingCity.php | 18 ++ .../Pages/EditShippingCity.php | 26 +++ .../Pages/ListShippingCities.php | 23 +++ admin/app/Models/ShippingCity.php | 68 ++++++++ admin/app/Models/ShippingMethod.php | 8 + .../factories/ShippingCityFactory.php | 36 ++++ ...20_000012_create_shipping_cities_table.php | 37 ++++ admin/database/seeders/ShippingCitySeeder.php | 18 ++ admin/database/seeders/TestSeeder.php | 1 + admin/docs/IMPLEMENTATION.md | 2 +- admin/docs/SHIPPING_GUIDE.md | 2 +- .../Resource/ShippingCityResourceTest.php | 93 ++++++++++ 13 files changed, 490 insertions(+), 2 deletions(-) create mode 100644 admin/app/Filament/Resources/ShippingCityResource.php create mode 100644 admin/app/Filament/Resources/ShippingCityResource/Pages/CreateShippingCity.php create mode 100644 admin/app/Filament/Resources/ShippingCityResource/Pages/EditShippingCity.php create mode 100644 admin/app/Filament/Resources/ShippingCityResource/Pages/ListShippingCities.php create mode 100644 admin/app/Models/ShippingCity.php create mode 100644 admin/database/factories/ShippingCityFactory.php create mode 100644 admin/database/migrations/2026_06_20_000012_create_shipping_cities_table.php create mode 100644 admin/database/seeders/ShippingCitySeeder.php create mode 100644 admin/tests/Feature/Filament/Resource/ShippingCityResourceTest.php 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 @@ + '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/ShippingMethod.php b/admin/app/Models/ShippingMethod.php index e9721443..06c58579 100644 --- a/admin/app/Models/ShippingMethod.php +++ b/admin/app/Models/ShippingMethod.php @@ -5,9 +5,11 @@ namespace App\Models; use App\Enums\ShippingMethodForEnum; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Support\Carbon; /** @@ -22,6 +24,7 @@ * @property Carbon|null $disable_to * @property bool $status * @property ShippingLine $shippingLine + * @property Collection $shippingCities */ class ShippingMethod extends Model { @@ -50,4 +53,9 @@ 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/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/TestSeeder.php b/admin/database/seeders/TestSeeder.php index d47ed5dc..3a992851 100644 --- a/admin/database/seeders/TestSeeder.php +++ b/admin/database/seeders/TestSeeder.php @@ -26,6 +26,7 @@ public function run(): void WishlistSeeder::class, ShippingLineSeeder::class, ShippingMethodSeeder::class, + ShippingCitySeeder::class, ]); } } diff --git a/admin/docs/IMPLEMENTATION.md b/admin/docs/IMPLEMENTATION.md index 7ad4899e..93d3715c 100644 --- a/admin/docs/IMPLEMENTATION.md +++ b/admin/docs/IMPLEMENTATION.md @@ -85,7 +85,7 @@ Depend mostly on Images only. - [ ] Warehouses (unblocks variety `warehouse_id`) - [x] Shipping Lines (carrier table; `name` + `cost`) - [x] Shipping Methods (`shipping_line_id` FK to Shipping Lines; service tier with rules) -- [ ] Shipping Cities (per-city cost and availability overrides per shipping method) +- [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 index 4af3d95b..c14aabdb 100644 --- a/admin/docs/SHIPPING_GUIDE.md +++ b/admin/docs/SHIPPING_GUIDE.md @@ -177,7 +177,7 @@ At checkout the frontend receives the user's city. The resolution steps: |---|---|---| | `shipping_lines` | ✅ Done | Migration, model, factory, Filament resource, tests | | `shipping_methods` | ✅ Done | Migration, model, factory, Filament resource, tests. `ShippingMethodForEnum` for `for` field. | -| `shipping_cities` | ⏳ Pending | Next to implement (Phase 3) | +| `shipping_cities` | ✅ Done | Migration, model, factory, Filament resource, tests. Cascade-deletes when method is deleted. | --- 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); +});