Files
Verde-Web/app/Services/Store/StoreOperations.php
admin 15d56d0e98 feat(backend): finish Module 13 sub-modules + flow fixes
Notifications: notification_preferences + Laravel notifications inbox.
SmsChannel adapter for our SmsService. RoutesByPreferences trait reads
per-user toggles. HouseholdApproved/Rejected, QrBalanceLow, and
CodesPurchased notifications wired in via auto-discovered listeners
or direct dispatch from controllers/StoreOperations.

Payments: payments table + PaymentDriver interface. ManualPaymentDriver
works out of the box; PayMongoDriver activates when
PAYMONGO_SECRET_KEY is set, falls back to manual otherwise. Resident
initiates code-purchase, admin can mark paid manually, webhook applies
real provider events. Fulfillment runs StoreOperations::sellToHousehold.

Live tracking (HTTP polling): truck_location_history (with SPATIAL
INDEX + 7-day retention plan). Driver POST /driver/trucks/{uuid}/location
writes history, updates trucks.last_known_coordinates, caches in Redis,
flags geofence-trigger when entering active trip dumpsite. Admin
GET /admin/live/trucks returns active truck positions. Reverb broadcast
deferred.

Flow corrections:
- QrAllocator now idempotent — re-approving a household no longer
  re-dispenses free codes.
- arrive-dumpsite enforces dumpsite geofence via ST_Contains; can be
  bypassed with override_geofence: true.

171 feature tests passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 14:01:38 +08:00

135 lines
4.8 KiB
PHP

<?php
namespace App\Services\Store;
use App\Models\Household;
use App\Models\PartnerStore;
use App\Models\QrCode;
use App\Models\QrCodeBatch;
use App\Models\StoreInventory;
use App\Models\StorePurchase;
use App\Models\StoreSale;
use App\Services\Qr\BatchGenerator;
use App\States\QrCode\Active;
use App\States\QrCode\Allocated;
use App\States\QrCode\Unassigned;
use Illuminate\Support\Facades\DB;
class StoreOperations
{
public function __construct(private readonly BatchGenerator $batches) {}
/**
* Issue a new wholesale batch to a store. Generates a fresh batch
* targeted at the store, marks codes as `allocated` to that store,
* tops up the store's inventory, and records a StorePurchase.
*/
public function issueWholesale(
PartnerStore $store,
int $quantity,
int $wholesalePriceCentavos,
?\DateTimeInterface $paidAt = null,
): StorePurchase {
return DB::transaction(function () use ($store, $quantity, $wholesalePriceCentavos, $paidAt) {
$batch = $this->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();
// Notify the household head — fire after commit so the receiver
// sees the persisted state.
if ($household->head) {
\Illuminate\Support\Facades\Notification::send(
$household->head,
new \App\Notifications\CodesPurchased($quantity, $totalRetail, $store->business_name),
);
}
return $sale;
});
}
}