From 989f4b87b9749e2a5c9ebd52529b968fb9212a95 Mon Sep 17 00:00:00 2001 From: admin Date: Thu, 30 Apr 2026 02:55:29 +0800 Subject: [PATCH] feat(backend): complete Module 12 (partner stores) partner_stores, store_inventories, store_purchases, store_sales tables. Promotes qr_code_batches.target_store_id and qr_codes.assigned_to_store_id to real FKs. StoreOperations service handles wholesale issuance (generates fresh batch -> codes go allocated to store -> inventory tops up -> StorePurchase recorded) and resident sales (codes flip allocated -> active to a household, commission computed at the store's rate). Admin endpoints: store CRUD, issue-inventory, record-sale. 160 feature tests passing. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../V1/Admin/AdminPartnerStoreController.php | 147 ++++++++++++++++++ app/Http/Resources/PartnerStoreResource.php | 29 ++++ app/Models/PartnerStore.php | 74 +++++++++ app/Models/StoreInventory.php | 27 ++++ app/Models/StorePurchase.php | 36 +++++ app/Models/StoreSale.php | 38 +++++ app/Services/Store/StoreOperations.php | 125 +++++++++++++++ database/factories/PartnerStoreFactory.php | 29 ++++ ..._10_100000_create_partner_stores_table.php | 75 +++++++++ ...1_add_store_fk_to_qr_codes_and_batches.php | 29 ++++ routes/api.php | 13 ++ .../Feature/Api/V1/Store/PartnerStoreTest.php | 119 ++++++++++++++ 12 files changed, 741 insertions(+) create mode 100644 app/Http/Controllers/Api/V1/Admin/AdminPartnerStoreController.php create mode 100644 app/Http/Resources/PartnerStoreResource.php create mode 100644 app/Models/PartnerStore.php create mode 100644 app/Models/StoreInventory.php create mode 100644 app/Models/StorePurchase.php create mode 100644 app/Models/StoreSale.php create mode 100644 app/Services/Store/StoreOperations.php create mode 100644 database/factories/PartnerStoreFactory.php create mode 100644 database/migrations/2026_05_10_100000_create_partner_stores_table.php create mode 100644 database/migrations/2026_05_10_100001_add_store_fk_to_qr_codes_and_batches.php create mode 100644 tests/Feature/Api/V1/Store/PartnerStoreTest.php diff --git a/app/Http/Controllers/Api/V1/Admin/AdminPartnerStoreController.php b/app/Http/Controllers/Api/V1/Admin/AdminPartnerStoreController.php new file mode 100644 index 0000000..1d6618d --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/AdminPartnerStoreController.php @@ -0,0 +1,147 @@ +validate([ + 'status' => ['nullable', 'in:pending_kyc,active,suspended'], + 'q' => ['nullable', 'string', 'max:100'], + 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'], + ]); + $perPage = (int) $request->input('per_page', 25); + + $stores = PartnerStore::query() + ->with(['owner', 'barangay', 'inventory']) + ->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status'))) + ->when($request->filled('q'), fn ($q) => $q->where('business_name', 'like', '%'.$request->string('q').'%')) + ->orderBy('business_name') + ->paginate($perPage); + + return $this->ok( + PartnerStoreResource::collection($stores), + null, + [ + 'page' => $stores->currentPage(), + 'per_page' => $stores->perPage(), + 'total' => $stores->total(), + 'last_page' => $stores->lastPage(), + ], + ); + } + + public function store(Request $request): JsonResponse + { + $data = $request->validate([ + 'owner_user_id' => ['required', 'integer', 'exists:users,id'], + 'business_name' => ['required', 'string', 'max:191'], + 'business_permit_number' => ['nullable', 'string', 'max:64'], + 'address_line' => ['nullable', 'string', 'max:255'], + 'barangay_id' => ['nullable', 'integer', 'exists:barangays,id'], + 'lat' => ['nullable', 'numeric', 'between:-90,90'], + 'lng' => ['nullable', 'numeric', 'between:-180,180'], + 'commission_rate_percent' => ['nullable', 'integer', 'min:0', 'max:50'], + 'status' => ['nullable', Rule::in(['pending_kyc', 'active', 'suspended'])], + ]); + + $payload = collect($data)->except(['lat', 'lng'])->all(); + if (isset($data['lat'], $data['lng'])) { + $payload['coordinates'] = new Point((float) $data['lat'], (float) $data['lng'], 4326); + } + + $store = PartnerStore::create($payload); + + return $this->created( + new PartnerStoreResource($store->load(['owner', 'barangay'])), + 'Store created', + ); + } + + public function show(PartnerStore $store): JsonResponse + { + $store->load(['owner', 'barangay', 'inventory']); + + return $this->ok(new PartnerStoreResource($store)); + } + + public function update(Request $request, PartnerStore $store): JsonResponse + { + $data = $request->validate([ + 'business_name' => ['sometimes', 'required', 'string', 'max:191'], + 'business_permit_number' => ['sometimes', 'nullable', 'string', 'max:64'], + 'commission_rate_percent' => ['sometimes', 'integer', 'min:0', 'max:50'], + 'status' => ['sometimes', Rule::in(['pending_kyc', 'active', 'suspended'])], + ]); + $store->update($data); + + return $this->ok(new PartnerStoreResource($store->fresh()->load('inventory')), 'Store updated'); + } + + public function issueInventory(Request $request, PartnerStore $store): JsonResponse + { + $data = $request->validate([ + 'quantity' => ['required', 'integer', 'min:1', 'max:50000'], + 'wholesale_price_centavos' => ['required', 'integer', 'min:0'], + ]); + + if ($store->status !== PartnerStore::STATUS_ACTIVE) { + return $this->fail('Store must be active to receive inventory', null, 422); + } + + $purchase = $this->ops->issueWholesale( + $store, + (int) $data['quantity'], + (int) $data['wholesale_price_centavos'], + ); + + return $this->created([ + 'purchase_id' => $purchase->id, + 'batch_number' => $purchase->batch->batch_number, + 'quantity' => $purchase->quantity, + 'inventory_balance' => $store->fresh()->load('inventory')->inventory?->current_code_balance ?? 0, + ], 'Inventory issued'); + } + + public function recordSale(Request $request, PartnerStore $store): JsonResponse + { + $data = $request->validate([ + 'household_id' => ['required', 'integer', 'exists:households,id'], + 'quantity' => ['required', 'integer', 'min:1', 'max:1000'], + 'retail_price_per_code_centavos' => ['required', 'integer', 'min:0'], + ]); + + try { + $household = Household::findOrFail($data['household_id']); + $sale = $this->ops->sellToHousehold( + $store, + $household, + (int) $data['quantity'], + (int) $data['retail_price_per_code_centavos'], + ); + } catch (\DomainException $e) { + return $this->fail($e->getMessage(), null, 422); + } + + return $this->created([ + 'sale_id' => $sale->id, + 'quantity' => $sale->quantity, + 'retail_price_centavos' => $sale->retail_price_centavos, + 'commission_centavos' => $sale->commission_centavos, + ], 'Sale recorded'); + } +} diff --git a/app/Http/Resources/PartnerStoreResource.php b/app/Http/Resources/PartnerStoreResource.php new file mode 100644 index 0000000..0cbdc70 --- /dev/null +++ b/app/Http/Resources/PartnerStoreResource.php @@ -0,0 +1,29 @@ + $this->uuid, + 'business_name' => $this->business_name, + 'business_permit_number' => $this->business_permit_number, + 'address_line' => $this->address_line, + 'coordinates' => $this->coordinates ? [ + 'lat' => $this->coordinates->latitude, + 'lng' => $this->coordinates->longitude, + ] : null, + 'commission_rate_percent' => $this->commission_rate_percent, + 'status' => $this->status, + 'inventory_balance' => $this->whenLoaded('inventory', fn () => $this->inventory?->current_code_balance ?? 0), + 'owner' => UserResource::make($this->whenLoaded('owner')), + 'barangay' => BarangayResource::make($this->whenLoaded('barangay')), + 'created_at' => $this->created_at?->toIso8601String(), + ]; + } +} diff --git a/app/Models/PartnerStore.php b/app/Models/PartnerStore.php new file mode 100644 index 0000000..24a05a6 --- /dev/null +++ b/app/Models/PartnerStore.php @@ -0,0 +1,74 @@ + Point::class, + 'operating_hours' => 'array', + 'commission_rate_percent' => 'integer', + ]; + } + + public function getRouteKeyName(): string + { + return 'uuid'; + } + + protected static function booted(): void + { + static::creating(function (self $s): void { + if (empty($s->uuid)) $s->uuid = (string) Str::uuid(); + }); + } + + public function owner(): BelongsTo + { + return $this->belongsTo(User::class, 'owner_user_id'); + } + + public function barangay(): BelongsTo + { + return $this->belongsTo(Barangay::class); + } + + public function inventory(): HasOne + { + return $this->hasOne(StoreInventory::class, 'store_id'); + } + + public function purchases(): HasMany + { + return $this->hasMany(StorePurchase::class, 'store_id'); + } + + public function sales(): HasMany + { + return $this->hasMany(StoreSale::class, 'store_id'); + } +} diff --git a/app/Models/StoreInventory.php b/app/Models/StoreInventory.php new file mode 100644 index 0000000..c51881d --- /dev/null +++ b/app/Models/StoreInventory.php @@ -0,0 +1,27 @@ + 'integer', + 'last_updated_at' => 'datetime', + ]; + } + + public function store(): BelongsTo + { + return $this->belongsTo(PartnerStore::class, 'store_id'); + } +} diff --git a/app/Models/StorePurchase.php b/app/Models/StorePurchase.php new file mode 100644 index 0000000..f2801c8 --- /dev/null +++ b/app/Models/StorePurchase.php @@ -0,0 +1,36 @@ + 'datetime', + 'quantity' => 'integer', + 'wholesale_price_centavos' => 'integer', + ]; + } + + public function store(): BelongsTo + { + return $this->belongsTo(PartnerStore::class, 'store_id'); + } + + public function batch(): BelongsTo + { + return $this->belongsTo(QrCodeBatch::class, 'batch_id'); + } +} diff --git a/app/Models/StoreSale.php b/app/Models/StoreSale.php new file mode 100644 index 0000000..449342c --- /dev/null +++ b/app/Models/StoreSale.php @@ -0,0 +1,38 @@ + 'datetime', + 'quantity' => 'integer', + 'retail_price_centavos' => 'integer', + 'commission_centavos' => 'integer', + ]; + } + + public function store(): BelongsTo + { + return $this->belongsTo(PartnerStore::class, 'store_id'); + } + + public function household(): BelongsTo + { + return $this->belongsTo(Household::class); + } +} diff --git a/app/Services/Store/StoreOperations.php b/app/Services/Store/StoreOperations.php new file mode 100644 index 0000000..90b4670 --- /dev/null +++ b/app/Services/Store/StoreOperations.php @@ -0,0 +1,125 @@ +batches->generate( + quantity: $quantity, + purpose: QrCodeBatch::PURPOSE_STORE, + targetArea: null, + targetStoreId: $store->id, + createdBy: null, + notes: "Wholesale to store {$store->business_name}", + ); + + $now = now(); + QrCode::query() + ->where('batch_id', $batch->id) + ->where('status', Unassigned::$name) + ->update([ + 'status' => Allocated::$name, + 'assigned_to_store_id' => $store->id, + 'allocated_at' => $now, + 'updated_at' => $now, + ]); + + $purchase = StorePurchase::create([ + 'store_id' => $store->id, + 'batch_id' => $batch->id, + 'quantity' => $quantity, + 'wholesale_price_centavos' => $wholesalePriceCentavos, + 'paid_at' => $paidAt ?? $now, + ]); + + $inventory = StoreInventory::firstOrCreate(['store_id' => $store->id], ['current_code_balance' => 0]); + $inventory->forceFill([ + 'current_code_balance' => $inventory->current_code_balance + $quantity, + 'last_updated_at' => $now, + ])->save(); + + return $purchase; + }); + } + + /** + * Sell N codes from a store's inventory to a resident's household. + * Codes transition allocated -> active and reassign household. + */ + public function sellToHousehold( + PartnerStore $store, + Household $household, + int $quantity, + int $retailPricePerCodeCentavos, + ): StoreSale { + return DB::transaction(function () use ($store, $household, $quantity, $retailPricePerCodeCentavos) { + $codeIds = QrCode::query() + ->where('assigned_to_store_id', $store->id) + ->where('status', Allocated::$name) + ->orderBy('id') + ->limit($quantity) + ->lockForUpdate() + ->pluck('id'); + + if ($codeIds->count() < $quantity) { + throw new \DomainException("Store has only {$codeIds->count()} codes available"); + } + + $now = now(); + QrCode::whereIn('id', $codeIds)->update([ + 'status' => Active::$name, + 'assigned_to_household_id' => $household->id, + 'assigned_to_store_id' => null, + 'activated_at' => $now, + 'updated_at' => $now, + ]); + + $totalRetail = $retailPricePerCodeCentavos * $quantity; + $commission = (int) round($totalRetail * ($store->commission_rate_percent / 100)); + + $sale = StoreSale::create([ + 'store_id' => $store->id, + 'household_id' => $household->id, + 'quantity' => $quantity, + 'retail_price_centavos' => $totalRetail, + 'commission_centavos' => $commission, + 'sold_at' => $now, + ]); + + $inventory = StoreInventory::firstOrCreate(['store_id' => $store->id], ['current_code_balance' => 0]); + $inventory->forceFill([ + 'current_code_balance' => max(0, $inventory->current_code_balance - $quantity), + 'last_updated_at' => $now, + ])->save(); + + return $sale; + }); + } +} diff --git a/database/factories/PartnerStoreFactory.php b/database/factories/PartnerStoreFactory.php new file mode 100644 index 0000000..a2fbc2c --- /dev/null +++ b/database/factories/PartnerStoreFactory.php @@ -0,0 +1,29 @@ + + */ +class PartnerStoreFactory extends Factory +{ + protected $model = PartnerStore::class; + + public function definition(): array + { + return [ + 'uuid' => (string) Str::uuid(), + 'owner_user_id' => User::factory()->state(['role' => 'store_partner'])->create()->id, + 'business_name' => fake()->company().' Store', + 'business_permit_number' => 'BP-'.strtoupper(Str::random(8)), + 'address_line' => fake()->streetAddress(), + 'commission_rate_percent' => 10, + 'status' => 'active', + ]; + } +} diff --git a/database/migrations/2026_05_10_100000_create_partner_stores_table.php b/database/migrations/2026_05_10_100000_create_partner_stores_table.php new file mode 100644 index 0000000..1ae86ee --- /dev/null +++ b/database/migrations/2026_05_10_100000_create_partner_stores_table.php @@ -0,0 +1,75 @@ +id(); + $table->uuid('uuid')->unique(); + $table->foreignId('owner_user_id')->constrained('users')->cascadeOnDelete(); + $table->string('business_name', 191); + $table->string('business_permit_number', 64)->nullable(); + $table->string('address_line', 255)->nullable(); + $table->foreignId('barangay_id')->nullable()->constrained('barangays')->nullOnDelete(); + $table->geometry('coordinates', subtype: 'point', srid: 4326)->nullable(); + $table->json('operating_hours')->nullable(); + $table->unsignedTinyInteger('commission_rate_percent')->default(10); + $table->enum('status', ['pending_kyc', 'active', 'suspended']) + ->default('pending_kyc') + ->index(); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['barangay_id', 'status']); + }); + + Schema::create('store_inventories', function (Blueprint $table) { + $table->id(); + $table->foreignId('store_id')->unique()->constrained('partner_stores')->cascadeOnDelete(); + $table->unsignedInteger('current_code_balance')->default(0); + $table->timestamp('last_updated_at')->nullable(); + $table->timestamps(); + }); + + Schema::create('store_purchases', function (Blueprint $table) { + $table->id(); + $table->foreignId('store_id')->constrained('partner_stores')->cascadeOnDelete(); + $table->foreignId('batch_id')->constrained('qr_code_batches')->cascadeOnDelete(); + $table->unsignedInteger('quantity'); + $table->unsignedBigInteger('wholesale_price_centavos'); + $table->timestamp('paid_at')->nullable(); + $table->unsignedBigInteger('payment_id')->nullable()->index(); + $table->timestamps(); + + $table->index(['store_id', 'paid_at']); + }); + + Schema::create('store_sales', function (Blueprint $table) { + $table->id(); + $table->foreignId('store_id')->constrained('partner_stores')->cascadeOnDelete(); + $table->foreignId('household_id')->nullable()->constrained('households')->nullOnDelete(); + $table->unsignedInteger('quantity'); + $table->unsignedBigInteger('retail_price_centavos'); + $table->unsignedBigInteger('commission_centavos'); + $table->unsignedBigInteger('payment_id')->nullable()->index(); + $table->timestamp('sold_at'); + $table->timestamps(); + + $table->index(['store_id', 'sold_at']); + $table->index(['household_id', 'sold_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('store_sales'); + Schema::dropIfExists('store_purchases'); + Schema::dropIfExists('store_inventories'); + Schema::dropIfExists('partner_stores'); + } +}; diff --git a/database/migrations/2026_05_10_100001_add_store_fk_to_qr_codes_and_batches.php b/database/migrations/2026_05_10_100001_add_store_fk_to_qr_codes_and_batches.php new file mode 100644 index 0000000..569d7bc --- /dev/null +++ b/database/migrations/2026_05_10_100001_add_store_fk_to_qr_codes_and_batches.php @@ -0,0 +1,29 @@ +foreign('target_store_id')->references('id')->on('partner_stores')->nullOnDelete(); + }); + + Schema::table('qr_codes', function (Blueprint $table) { + $table->foreign('assigned_to_store_id')->references('id')->on('partner_stores')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('qr_code_batches', function (Blueprint $table) { + $table->dropForeign(['target_store_id']); + }); + Schema::table('qr_codes', function (Blueprint $table) { + $table->dropForeign(['assigned_to_store_id']); + }); + } +}; diff --git a/routes/api.php b/routes/api.php index 6d23fc2..bf9149f 100644 --- a/routes/api.php +++ b/routes/api.php @@ -3,6 +3,7 @@ use App\Http\Controllers\Api\V1\Admin\AdminDropOffPointController; use App\Http\Controllers\Api\V1\Admin\AdminDumpsiteController; use App\Http\Controllers\Api\V1\Admin\AdminHouseholdController; +use App\Http\Controllers\Api\V1\Admin\AdminPartnerStoreController; use App\Http\Controllers\Api\V1\Admin\AdminQrBatchController; use App\Http\Controllers\Api\V1\Admin\AdminQrCodeController; use App\Http\Controllers\Api\V1\Admin\AdminRouteController; @@ -190,6 +191,18 @@ Route::prefix('admin/teams') Route::delete('/{team}', [AdminTeamController::class, 'destroy'])->name('destroy'); }); +Route::prefix('admin/partner-stores') + ->name('api.v1.admin.partner-stores.') + ->middleware(['auth:sanctum', 'role:admin']) + ->group(function () { + Route::get('/', [AdminPartnerStoreController::class, 'index'])->name('index'); + Route::post('/', [AdminPartnerStoreController::class, 'store'])->name('store'); + Route::get('/{store}', [AdminPartnerStoreController::class, 'show'])->name('show'); + Route::patch('/{store}', [AdminPartnerStoreController::class, 'update'])->name('update'); + Route::post('/{store}/issue-inventory', [AdminPartnerStoreController::class, 'issueInventory'])->name('issue-inventory'); + Route::post('/{store}/sales', [AdminPartnerStoreController::class, 'recordSale'])->name('sales'); + }); + Route::prefix('admin/trips') ->name('api.v1.admin.trips.') ->middleware(['auth:sanctum', 'role:admin']) diff --git a/tests/Feature/Api/V1/Store/PartnerStoreTest.php b/tests/Feature/Api/V1/Store/PartnerStoreTest.php new file mode 100644 index 0000000..001bcfb --- /dev/null +++ b/tests/Feature/Api/V1/Store/PartnerStoreTest.php @@ -0,0 +1,119 @@ +seed(RoleSeeder::class); + $this->admin = User::factory()->create(['role' => User::ROLE_ADMIN, 'status' => 'active']); + } + + public function test_admin_can_create_partner_store(): void + { + Sanctum::actingAs($this->admin); + $owner = User::factory()->create(['role' => User::ROLE_STORE_PARTNER]); + + $response = $this->postJson('/api/v1/admin/partner-stores', [ + 'owner_user_id' => $owner->id, + 'business_name' => 'Diliman Grocery', + 'commission_rate_percent' => 15, + 'status' => 'active', + ]); + + $response->assertCreated() + ->assertJsonPath('data.business_name', 'Diliman Grocery') + ->assertJsonPath('data.commission_rate_percent', 15); + } + + public function test_admin_can_issue_inventory_to_store(): void + { + Sanctum::actingAs($this->admin); + $store = PartnerStore::factory()->create(['status' => 'active']); + + $response = $this->postJson("/api/v1/admin/partner-stores/{$store->uuid}/issue-inventory", [ + 'quantity' => 50, + 'wholesale_price_centavos' => 250000, + ]); + + $response->assertCreated() + ->assertJsonPath('data.quantity', 50) + ->assertJsonPath('data.inventory_balance', 50); + + $allocated = QrCode::where('assigned_to_store_id', $store->id) + ->where('status', 'allocated')->count(); + $this->assertSame(50, $allocated); + } + + public function test_inactive_store_cannot_receive_inventory(): void + { + Sanctum::actingAs($this->admin); + $store = PartnerStore::factory()->create(['status' => 'pending_kyc']); + + $response = $this->postJson("/api/v1/admin/partner-stores/{$store->uuid}/issue-inventory", [ + 'quantity' => 10, 'wholesale_price_centavos' => 50000, + ]); + + $response->assertStatus(422); + } + + public function test_admin_can_record_sale_and_codes_activate(): void + { + Sanctum::actingAs($this->admin); + $store = PartnerStore::factory()->create(['status' => 'active', 'commission_rate_percent' => 10]); + app(StoreOperations::class)->issueWholesale($store, 20, 100000); + + $household = Household::factory()->create(); + + $response = $this->postJson("/api/v1/admin/partner-stores/{$store->uuid}/sales", [ + 'household_id' => $household->id, + 'quantity' => 5, + 'retail_price_per_code_centavos' => 1000, + ]); + + $response->assertCreated() + ->assertJsonPath('data.quantity', 5) + ->assertJsonPath('data.retail_price_centavos', 5000) + ->assertJsonPath('data.commission_centavos', 500); + + $active = QrCode::where('assigned_to_household_id', $household->id) + ->where('status', 'active')->count(); + $this->assertSame(5, $active); + + $balance = StoreInventory::where('store_id', $store->id)->value('current_code_balance'); + $this->assertSame(15, (int) $balance); + } + + public function test_oversold_request_fails(): void + { + Sanctum::actingAs($this->admin); + $store = PartnerStore::factory()->create(['status' => 'active']); + app(StoreOperations::class)->issueWholesale($store, 5, 25000); + $household = Household::factory()->create(); + + $response = $this->postJson("/api/v1/admin/partner-stores/{$store->uuid}/sales", [ + 'household_id' => $household->id, + 'quantity' => 10, + 'retail_price_per_code_centavos' => 1000, + ]); + + $response->assertStatus(422); + } +}