282 lines
10 KiB
PHP
282 lines
10 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\Models\StoreSettlement;
|
|
use App\Models\User;
|
|
use App\Notifications\CodesPurchased;
|
|
use App\Services\Qr\BatchGenerator;
|
|
use App\States\QrCode\Active;
|
|
use App\States\QrCode\Allocated;
|
|
use App\States\QrCode\Unassigned;
|
|
use App\Models\StoreInventoryAdjustment;
|
|
use App\States\QrCode\Voided;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Notification;
|
|
|
|
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,
|
|
$userId = null,
|
|
bool $isPrepaid = false,
|
|
): StorePurchase {
|
|
return DB::transaction(function () use ($store, $quantity, $wholesalePriceCentavos, $paidAt, $userId, $isPrepaid) {
|
|
$batch = $this->batches->generate(
|
|
quantity: $quantity,
|
|
purpose: QrCodeBatch::PURPOSE_STORE,
|
|
targetArea: null,
|
|
targetStoreId: $store->id,
|
|
createdBy: $userId,
|
|
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,
|
|
'is_prepaid' => $isPrepaid,
|
|
]);
|
|
|
|
$this->logAdjustment($store, StoreInventoryAdjustment::TYPE_PURCHASE, $quantity, null, "Wholesale purchase: Batch #{$batch->batch_number}" . ($isPrepaid ? ' (Prepaid)' : ''), $userId);
|
|
$this->updateBalance($store, $quantity, $isPrepaid);
|
|
|
|
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,
|
|
?string $serialNumber = null,
|
|
): StoreSale {
|
|
return DB::transaction(function () use ($store, $household, $quantity, $retailPricePerCodeCentavos, $serialNumber) {
|
|
$inventory = StoreInventory::where('store_id', $store->id)->first();
|
|
$prepaidAvailable = $inventory ? $inventory->prepaid_balance : 0;
|
|
$usePrepaid = $prepaidAvailable >= $quantity;
|
|
|
|
$query = QrCode::query()
|
|
->where('assigned_to_store_id', $store->id)
|
|
->where('status', Allocated::$name);
|
|
|
|
if ($serialNumber) {
|
|
$query->where('serial', $serialNumber);
|
|
}
|
|
|
|
$codeIds = $query->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,
|
|
'is_prepaid' => $usePrepaid,
|
|
]);
|
|
|
|
$this->logAdjustment($store, StoreInventoryAdjustment::TYPE_SALE, -$quantity, null, "Sold to Household #{$household->id}" . ($usePrepaid ? ' (Prepaid)' : ''), null);
|
|
$this->updateBalance($store, -$quantity, $usePrepaid);
|
|
|
|
// Notify the household head — fire after commit so the receiver
|
|
// sees the persisted state.
|
|
if ($household->head) {
|
|
Notification::send(
|
|
$household->head,
|
|
new CodesPurchased($quantity, $totalRetail, $store->business_name),
|
|
);
|
|
}
|
|
|
|
return $sale;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Mark a specific code as defective and adjust inventory.
|
|
* Generates an immediate replacement QR code for the store.
|
|
*/
|
|
public function reportDefective(PartnerStore $store, string $serial, string $reason, $userId = null): QrCode
|
|
{
|
|
return DB::transaction(function () use ($store, $serial, $reason, $userId) {
|
|
$qrCode = QrCode::where('serial', $serial)
|
|
->where('assigned_to_store_id', $store->id)
|
|
->where('status', Allocated::$name)
|
|
->lockForUpdate()
|
|
->first();
|
|
|
|
if (!$qrCode) {
|
|
throw new \DomainException("QR code {$serial} not found in store inventory or already used.");
|
|
}
|
|
|
|
// 1. Void the defective code
|
|
$qrCode->status->transitionTo(Voided::class);
|
|
$qrCode->forceFill([
|
|
'metadata' => array_merge($qrCode->metadata ?? [], [
|
|
'voided_at' => now()->toIso8601String(),
|
|
'voided_by_id' => $userId instanceof User ? $userId->id : $userId,
|
|
'void_reason' => $reason,
|
|
'source' => 'store_report',
|
|
]),
|
|
'assigned_to_store_id' => null,
|
|
])->save();
|
|
|
|
$this->logAdjustment($store, StoreInventoryAdjustment::TYPE_DEFECTIVE, -1, $qrCode->id, "Defective: {$reason}", $userId);
|
|
$this->updateBalance($store, -1);
|
|
|
|
// 2. Generate replacement
|
|
$batch = $this->batches->generate(
|
|
quantity: 1,
|
|
purpose: QrCodeBatch::PURPOSE_STORE,
|
|
targetStoreId: $store->id,
|
|
createdBy: $userId instanceof User ? $userId : User::find($userId),
|
|
notes: "Replacement for defective {$serial}",
|
|
);
|
|
|
|
$replacement = QrCode::where('batch_id', $batch->id)->first();
|
|
$replacement->update([
|
|
'status' => Allocated::$name,
|
|
'assigned_to_store_id' => $store->id,
|
|
'allocated_at' => now(),
|
|
'replacement_for_id' => $qrCode->id,
|
|
]);
|
|
|
|
$this->logAdjustment($store, StoreInventoryAdjustment::TYPE_MANUAL, 1, $replacement->id, "Replacement for {$serial}", $userId);
|
|
$this->updateBalance($store, 1);
|
|
|
|
return $replacement;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Record a financial settlement (payment) from a store.
|
|
*/
|
|
public function recordPayment(
|
|
PartnerStore $store,
|
|
int $amountCentavos,
|
|
string $method,
|
|
?string $reference = null,
|
|
?string $notes = null,
|
|
$userId = null
|
|
): StoreSettlement {
|
|
return StoreSettlement::create([
|
|
'store_id' => $store->id,
|
|
'amount_centavos' => $amountCentavos,
|
|
'payment_method' => $method,
|
|
'reference_number' => $reference,
|
|
'notes' => $notes,
|
|
'recorded_by_user_id' => $userId instanceof User ? $userId->id : $userId,
|
|
'settled_at' => now(),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Calculate the current outstanding balance for the store (Consignment model).
|
|
* Balance = (Total Retail - Total Commission) - Total Settled
|
|
*/
|
|
public function calculateBalanceDue(PartnerStore $store): int
|
|
{
|
|
$netPayable = $store->sales()
|
|
->where('is_prepaid', false)
|
|
->sum(DB::raw('retail_price_centavos - commission_centavos'));
|
|
|
|
$totalPaid = $store->settlements()->sum('amount_centavos');
|
|
|
|
return max(0, (int) $netPayable - (int) $totalPaid);
|
|
}
|
|
|
|
/**
|
|
* Manual inventory adjustment by admin.
|
|
*/
|
|
public function manualAdjustment(PartnerStore $store, int $quantity, string $reason, $userId): void
|
|
{
|
|
DB::transaction(function () use ($store, $quantity, $reason, $userId) {
|
|
$this->logAdjustment($store, StoreInventoryAdjustment::TYPE_MANUAL, $quantity, null, $reason, $userId);
|
|
$this->updateBalance($store, $quantity);
|
|
});
|
|
}
|
|
|
|
private function logAdjustment(PartnerStore $store, string $type, int $quantity, ?int $qrCodeId, ?string $reason, $userId): void
|
|
{
|
|
StoreInventoryAdjustment::create([
|
|
'store_id' => $store->id,
|
|
'type' => $type,
|
|
'quantity' => $quantity,
|
|
'qr_code_id' => $qrCodeId,
|
|
'reason' => $reason,
|
|
'adjusted_by_user_id' => $userId instanceof \App\Models\User ? $userId->id : $userId,
|
|
]);
|
|
}
|
|
|
|
private function updateBalance(PartnerStore $store, int $delta, bool $isPrepaid = false): void
|
|
{
|
|
$inventory = StoreInventory::firstOrCreate(['store_id' => $store->id], [
|
|
'current_code_balance' => 0,
|
|
'prepaid_balance' => 0
|
|
]);
|
|
|
|
if ($isPrepaid) {
|
|
$inventory->prepaid_balance = max(0, $inventory->prepaid_balance + $delta);
|
|
} else {
|
|
$inventory->current_code_balance = max(0, $inventory->current_code_balance + $delta);
|
|
}
|
|
|
|
$inventory->last_updated_at = now();
|
|
$inventory->save();
|
|
}
|
|
}
|