Files
BarangaySystem/app/Http/Controllers/Market/PosController.php
2026-06-06 18:43:00 +08:00

775 lines
29 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Http\Controllers\Market;
use App\Http\Controllers\Helpers\ResponseHelper;
use App\Models\Market\PosSession;
use App\Models\Market\PosTransaction;
use App\Models\Market\PosSessionArchive;
use App\Models\Market\Product;
use App\Models\Market\Store;
use Hypervel\Http\Request;
use Hypervel\Support\Facades\Auth;
use Hypervel\Support\Facades\Response;
use Hyperf\Stringable\Str;
use App\Models\Market\PosAccessKey;
use App\Models\Market\Customer;
use Hypervel\Support\Facades\DB;
use App\Http\Controllers\Helpers\Permissions\UserPermissions;
use App\Enums\UserActions;
use Hyperf\Coroutine\Coroutine;
use App\Http\Controllers\Helpers\CacheHelper;
class PosController
{
public function startSession(Request $request)
{
$validated = $request->validate([
'store_hash' => 'nullable|string',
'customer_name' => 'nullable|string|max:255',
'access_key' => 'nullable|string',
]);
$store = null;
$accessKeyObj = null;
if ($request->input('access_key')) {
$accessKeyObj = PosAccessKey::where('access_key', $request->input('access_key'))
->where('status', 'active')
->first();
if ($accessKeyObj) {
if ($accessKeyObj->isExpired()) {
$accessKeyObj->status = 'inactive';
$accessKeyObj->save();
return ResponseHelper::returnError('Access key has expired', 401);
}
$store = $accessKeyObj->store;
} elseif (!Auth::check()) {
return ResponseHelper::returnError('Invalid or inactive access key', 401);
}
}
if (!$store && !empty($validated['store_hash'])) {
$store = Store::where('hashkey', $validated['store_hash'])->first();
}
if (!$store) {
return ResponseHelper::returnError('No store found. Please open the POS from a store page or use a valid access key.', 422);
}
// Authorization check
if (!UserPermissions::isUserAllowedAccessToStore(Auth::user(), $store)) {
return ResponseHelper::returnError('You are not authorized to start a POS session for this store.', 403);
}
/** @var PosSession $session */
$session = PosSession::create([
'access_key' => $accessKeyObj ? $accessKeyObj->access_key : Str::random(32),
'store_id' => $store->id,
'customer_name' => $validated['customer_name'] ?? null,
'status' => 'active',
'created_by' => Auth::id(),
]);
if ($accessKeyObj) {
$accessKeyObj->last_used_at = now();
$accessKeyObj->save();
}
$this->archiveSession($session, 'Session initialized');
$this->invalidateSessionCache($session);
return ResponseHelper::returnSuccessResponse([
'hashkey' => $session->hashkey,
'access_key' => $session->access_key,
], $session->hashkey, 'POS Session started');
}
public function getSession(Request $request)
{
$hashkey = ResponseHelper::getTargetHash();
$accessKey = $request->input('access_key');
if (!$hashkey && !$accessKey) {
return ResponseHelper::returnError('No key provided', 400);
}
$session = null;
if ($hashkey) {
$q = $this->getBaseSessionQuery()->where('hashkey', $hashkey);
$session = CacheHelper::get_cache($q);
if (!$session) {
$session = $q->first();
if ($session) {
CacheHelper::set_cache($q, $session);
}
}
// If not a session hash, check if it's a store hash
if (!$session) {
$sq = Store::where('hashkey', $hashkey);
$store = CacheHelper::get_cache($sq);
if (!$store) {
$store = $sq->first();
if ($store) {
CacheHelper::set_cache($sq, $store);
}
}
if ($store) {
$q = $this->getBaseSessionQuery()
->where('store_id', $store->id)
->where('status', 'active')
->orderBy('id', 'desc');
$session = CacheHelper::get_cache($q);
if (!$session) {
$session = $q->first();
if ($session) {
CacheHelper::set_cache($q, $session);
}
}
}
}
}
// If still no session and we have an accessKey, try that (as fallback or primary if no hashkey)
if (!$session && $accessKey) {
$q = $this->getBaseSessionQuery()
->where('access_key', $accessKey)
->orderBy('id', 'desc');
$session = CacheHelper::get_cache($q);
if (!$session) {
$session = $q->first();
if ($session) {
CacheHelper::set_cache($q, $session);
}
}
}
if (!$session) {
return ResponseHelper::returnError('Session not found', 404);
}
// Authorization check
if (!UserPermissions::isUserAllowedAccessToStore(Auth::user(), $session->store_id)) {
return ResponseHelper::returnError('You are not authorized to access this POS session.', 403);
}
// Return the full session with all eager loaded relations
return ResponseHelper::returnSuccessResponse($session, $session->hashkey);
}
private function getBaseSessionQuery()
{
return PosSession::with([
'transactions.product' => function ($q) {
// Only fetch minimal columns needed for the POS to reduce serialization time
$q->select(['id', 'hashkey', 'name', 'price', 'photourl', 'unitname', 'category']);
},
'store'
]);
}
public function addItem(Request $request)
{
$validated = $request->validate([
'session_hash' => 'required|string',
'product_hash' => 'required|string',
'quantity' => 'required|integer|min:1',
'price' => 'nullable|numeric|min:0',
]);
$sq = PosSession::where('hashkey', $validated['session_hash']);
$session = CacheHelper::get_cache($sq);
if (!$session) {
$session = $sq->first();
if ($session) {
CacheHelper::set_cache($sq, $session);
}
}
$pq = Product::select(['id', 'hashkey', 'price', 'is_active', 'name'])->where('hashkey', $validated['product_hash']);
$product = CacheHelper::get_cache($pq);
if (!$product) {
$product = $pq->first();
if ($product) {
CacheHelper::set_cache($pq, $product);
}
}
if (!$session || !$product) {
return ResponseHelper::returnError('Session or Product not found', 404);
}
$sessionNeedsSave = false;
if ($session->status !== 'active') {
$session->status = 'active';
$sessionNeedsSave = true;
}
$price = (int) $product->price;
$isActive = $product->is_active;
if ($session->store_id) {
$psq = DB::table('prd_str')
->where('store_id', $session->store_id)
->where('product_id', $product->id)
->select('price', 'is_active');
$storeProduct = CacheHelper::get_cache($psq);
if (!$storeProduct) {
$storeProduct = $psq->first();
if ($storeProduct) {
CacheHelper::set_cache($psq, $storeProduct, [], 3600); // 1 hour
}
}
if ($storeProduct) {
if (isset($storeProduct->price)) {
$price = (int) $storeProduct->price;
}
if (isset($storeProduct->is_active)) {
$isActive = (bool) $storeProduct->is_active;
}
} else {
return ResponseHelper::returnError('Product not available in this store', 403);
}
}
if (!$isActive) {
return ResponseHelper::returnError('Product is currently inactive in this store', 403);
}
// Use custom price if provided, otherwise use calculated store/product price
if ($request->has('price')) {
$price = (int) $validated['price'];
}
// Update or create the transaction using raw DB for max speed
$existingTx = DB::table('pos_transactions')
->where('pos_session_id', $session->id)
->where('product_id', $product->id)
->first();
if ($existingTx) {
DB::table('pos_transactions')->where('id', $existingTx->id)->update([
'quantity' => $validated['quantity'],
'price_at_sale' => $price,
'total_price' => $price * $validated['quantity'],
'updated_at' => now(),
]);
} else {
DB::table('pos_transactions')->insert([
'pos_session_id' => $session->id,
'product_id' => $product->id,
'quantity' => $validated['quantity'],
'price_at_sale' => $price,
'total_price' => $price * $validated['quantity'],
'hashkey' => \Hyperf\Stringable\Str::uuid()->toString() . \Hyperf\Stringable\Str::random(100),
'created_at' => now(),
'updated_at' => now(),
'created_by' => Auth::id() ?? null,
]);
}
// Load specific columns to be fast, just like in getSession to reduce payload and memory
$session->load([
'transactions.product' => function ($q) {
$q->select(['id', 'hashkey', 'name', 'price', 'photourl', 'unitname', 'category']);
},
'store'
]);
$t_load = microtime(true);
// Update session totals in memory
$total = $session->transactions->where('is_void', false)->sum('total_price');
$updateData = [];
if ($session->total_amount !== (int) $total) {
$session->total_amount = (int) $total;
$updateData['total_amount'] = (int) $total;
}
if ($sessionNeedsSave) {
$updateData['status'] = $session->status;
}
// Use raw DB update to skip ModelSavingListener overhead while making sure we still record who updated it
if (!empty($updateData)) {
$updateData['updated_at'] = now();
$updateData['updated_by'] = Auth::id();
DB::table('pos_sessions')->where('id', $session->id)->update($updateData);
}
$t_db = microtime(true);
// Invalidate all possible session cache keys
$this->invalidateSessionCache($session);
// Archive the session using already loaded transaction data (deferred to background coroutine)
$this->archiveSession($session, 'Item added/updated: ' . $product->name, $session->transactions);
return ResponseHelper::returnSuccessResponse($session, $session->hashkey, 'Item added to session');
}
public function removeItem(Request $request)
{
$validated = $request->validate([
'session_hash' => 'required|string',
'transaction_id' => 'required|integer',
]);
$session = PosSession::where('hashkey', $validated['session_hash'])->first();
if (!$session) {
return ResponseHelper::returnError('Session not found', 404);
}
$transaction = PosTransaction::where('id', $validated['transaction_id'])
->where('pos_session_id', $session->id)
->first();
if ($transaction) {
$transaction->delete();
// Re-calculate and archive efficiently
// Load relations ONCE with only necessary columns
$session->load([
'transactions.product' => function ($q) {
$q->select(['id', 'hashkey', 'name', 'price', 'photourl', 'unitname', 'category']);
},
'store'
]);
$total = $session->transactions->where('is_void', false)->sum('total_price');
$session->total_amount = (int) $total;
DB::table('pos_sessions')->where('id', $session->id)->update([
'total_amount' => (int) $total,
'updated_at' => now(),
'updated_by' => Auth::id(),
]);
// Invalidate all possible session cache keys
$this->invalidateSessionCache($session);
$this->archiveSession($session, 'Item removed', $session->transactions);
} else {
$session->load([
'transactions.product' => function ($q) {
$q->select(['id', 'hashkey', 'name', 'price', 'photourl', 'unitname', 'category']);
},
'store'
]);
}
return ResponseHelper::returnSuccessResponse($session, $session->hashkey, 'Item removed from session');
}
public function completeSession(Request $request)
{
$validated = $request->validate([
'session_hash' => 'required|string',
'received_amount' => 'required|integer|min:0',
'payment_method' => 'required|string',
'payment_field' => 'nullable|string',
'customer_name' => 'nullable|string|max:255',
]);
$session = PosSession::where('hashkey', $validated['session_hash'])->first();
if (!$session) {
return ResponseHelper::returnError('Session not found', 404);
}
$session->received_amount = $validated['received_amount'];
$session->change_amount = $validated['received_amount'] - $session->total_amount;
$session->payment_method = $validated['payment_method'];
$session->payment_details = ['payment_field' => $validated['payment_field']];
if (!empty($validated['customer_name'])) {
$session->customer_name = $validated['customer_name'];
}
$session->status = 'completed';
$session->save();
// Invalidate cache
$this->invalidateSessionCache($session);
if (!empty($validated['customer_name'])) {
$customerName = trim($validated['customer_name']);
$customer = Customer::where('name', $customerName)
->where(function ($q) use ($session) {
$q->where('store_id', $session->store_id)
->orWhereNull('store_id');
})
->first();
if (!$customer) {
Customer::create([
'name' => $customerName,
'store_id' => $session->store_id,
'created_by' => Auth::id(),
]);
} else {
$customer->updated_at = now();
$customer->save();
}
}
$this->archiveSession($session, 'Session completed');
return ResponseHelper::returnSuccessResponse($session, $session->hashkey, 'Transaction completed');
}
public function syncOffline(Request $request)
{
$validated = $request->validate([
'transactions' => 'required|array',
'transactions.*.store_hash' => 'required|string',
'transactions.*.customer_name' => 'nullable|string',
'transactions.*.items' => 'required|array',
'transactions.*.total' => 'required|numeric',
'transactions.*.received' => 'required|numeric',
'transactions.*.method' => 'required|string',
'transactions.*.timestamp' => 'required|string',
'transactions.*.local_id' => 'nullable|integer',
]);
$syncedCount = 0;
$syncedIds = [];
$errors = [];
$affectedStoreIds = [];
foreach ($validated['transactions'] as $txn) {
try {
DB::beginTransaction();
$store = Store::where('hashkey', $txn['store_hash'])->first();
if (!$store) {
throw new \Exception('Store not found for hash: ' . $txn['store_hash']);
}
// Convert ISO 8601 timestamp to MySQL datetime format
$offlineTimestamp = date('Y-m-d H:i:s', strtotime($txn['timestamp']));
// Create the session
$session = new PosSession([
'store_id' => $store->id,
'customer_name' => $txn['customer_name'] ?? null,
'total_amount' => (int) $txn['total'],
'received_amount' => (int) $txn['received'],
'change_amount' => (int) ($txn['received'] - $txn['total']),
'payment_method' => $txn['method'],
'status' => 'completed',
'created_by' => Auth::id(),
'access_key' => 'synced-' . Str::random(32),
'hashkey' => Str::random(32) . '-' . Str::random(100),
]);
// Manually set timestamps to preserve offline time
$session->created_at = $offlineTimestamp;
$session->updated_at = $offlineTimestamp;
$session->save();
// Add Items
foreach ($txn['items'] as $item) {
$product = Product::where('hashkey', $item['product_hashkey'])->first();
if (!$product) continue;
DB::table('pos_transactions')->insert([
'pos_session_id' => $session->id,
'product_id' => $product->id,
'quantity' => $item['quantity'],
'price_at_sale' => (int) $item['price_at_sale'],
'total_price' => (int) ($item['price_at_sale'] * $item['quantity']),
'hashkey' => Str::uuid()->toString() . Str::random(100),
'created_at' => $offlineTimestamp,
'updated_at' => now(),
'created_by' => Auth::id(),
]);
}
// Handle Customer
if (!empty($txn['customer_name'])) {
$customerName = trim($txn['customer_name']);
$customer = Customer::where('name', $customerName)
->where(function ($q) use ($store) {
$q->where('store_id', $store->id)
->orWhereNull('store_id');
})
->first();
if (!$customer) {
Customer::create([
'name' => $customerName,
'store_id' => $store->id,
'created_by' => Auth::id(),
]);
}
}
$this->archiveSession($session, 'Offline synced transaction');
$this->invalidateSessionCache($session);
DB::commit();
$syncedCount++;
if (isset($txn['local_id'])) {
$syncedIds[] = $txn['local_id'];
}
if (!in_array($store->id, $affectedStoreIds)) {
$affectedStoreIds[] = $store->id;
}
} catch (\Exception $e) {
DB::rollBack();
$errors[] = $e->getMessage();
}
}
return ResponseHelper::returnSuccessResponse([
'synced_count' => $syncedCount,
'synced_ids' => $syncedIds,
'errors' => $errors
], 'sync_offline', "Synced $syncedCount transactions");
}
public function voidSession(Request $request)
{
$validated = $request->validate([
'session_hash' => 'required|string',
'remarks' => 'nullable|string',
]);
$session = PosSession::where('hashkey', $validated['session_hash'])->first();
if (!$session) {
return ResponseHelper::returnError('Session not found', 404);
}
if (!UserPermissions::isUserAllowedAccessToStore(Auth::user(), $session->store_id)) {
return ResponseHelper::returnError('You are not authorized to void this POS session.', 403);
}
$session->status = 'voided';
$session->is_void = true;
$session->save();
// Invalidate cache
$this->invalidateSessionCache($session);
$this->archiveSession($session, 'Session voided: ' . ($validated['remarks'] ?? 'No remarks'));
return ResponseHelper::returnSuccessResponse($session, $session->hashkey, 'Transaction voided');
}
public function getPosSessions(Request $request)
{
$validated = $request->validate([
'store_hash' => 'required|string',
'page' => 'nullable|integer|min:1',
'per_page' => 'nullable|integer|min:1|max:100',
]);
$store = Store::where('hashkey', $validated['store_hash'])->first();
if (!$store) {
return ResponseHelper::returnError('Store not found', 404);
}
// Authorization check
if (!UserPermissions::isUserAllowedAccessToStore(Auth::user(), $store)) {
return ResponseHelper::returnError('You are not authorized to view sessions for this store.', 403);
}
$page = (int) ($validated['page'] ?? 1);
$perPage = (int) ($validated['per_page'] ?? 10);
$offset = ($page - 1) * $perPage;
$query = PosSession::with(['transactions.product'])
->where('store_id', $store->id)
->where('status', '!=', 'active')
->orderBy('id', 'desc');
$totalCount = $query->count();
$sessions = $query->limit($perPage)->offset($offset)->get();
// Map sessions to include item count and simplify if needed
$sessions = $sessions->map(function ($session) {
return [
'hashkey' => $session->hashkey,
'status' => $session->status,
'total_amount' => $session->total_amount,
'customer_name' => $session->customer_name,
'payment_method' => $session->payment_method,
'items_count' => $session->transactions->count(),
'created_at' => $session->created_at,
'transactions' => $session->transactions,
];
});
return ResponseHelper::returnSuccessResponse([
'sessions' => $sessions,
'total_count' => $totalCount,
'page' => $page,
'per_page' => $perPage,
], $store->hashkey, 'POS sessions fetched');
}
public function getTodayStats(Request $request)
{
if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::ViewPosReports)) {
return ResponseHelper::returnUnauthorized();
}
$date = now()->format('Y-m-d');
$query = PosSession::where('status', 'completed')
->whereDate('created_at', $date);
$storeName = null;
$storePhoto = null;
// If store_hash is provided, filter by store
if ($request->input('store_hash')) {
$store = Store::where('hashkey', $request->input('store_hash'))->first();
if ($store) {
// Authorization check
if (!UserPermissions::isUserAllowedAccessToStore(Auth::user(), $store)) {
return ResponseHelper::returnError('You are not authorized to view reports for this store.', 403);
}
$query->where('store_id', $store->id);
$storeName = $store->name;
$storePhoto = $store->photourl;
}
}
$stats = CacheHelper::get_cache($query);
if ($stats) {
return ResponseHelper::returnSuccessResponse($stats, 'today_stats');
}
$stats = [
'count' => (int) $query->count(),
'total' => (int) $query->sum('total_amount'),
'store_name' => $storeName,
'store_photo' => $storePhoto,
];
CacheHelper::set_cache($query, $stats, [], 300); // 5 minutes
return ResponseHelper::returnSuccessResponse($stats, 'today_stats');
}
public function getCustomers(Request $request)
{
if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::ViewCustomers)) {
return ResponseHelper::returnUnauthorized();
}
$queryText = $request->input('query');
$storeHash = $request->input('store_hash');
$customerQuery = Customer::where('is_active', true);
if ($storeHash) {
$store = Store::where('hashkey', $storeHash)->first();
if ($store) {
// Authorization check
if (!UserPermissions::isUserAllowedAccessToStore(Auth::user(), $store)) {
return ResponseHelper::returnError('You are not authorized to view customers for this store.', 403);
}
$customerQuery->where(function ($q) use ($store) {
$q->where('store_id', $store->id)
->orWhereNull('store_id');
});
}
}
if ($queryText) {
$customerQuery->where('name', 'like', '%' . $queryText . '%');
}
$finalQuery = $customerQuery->orderBy('name', 'asc')->limit(10);
$customers = CacheHelper::get_cache($finalQuery);
if (!$customers) {
$customers = $finalQuery->get();
CacheHelper::set_cache($finalQuery, $customers, [], 3600); // 1 hour
}
return ResponseHelper::returnSuccessResponse($customers, 'customer_suggestions');
}
private function updateSessionTotals(PosSession $session)
{
$total = $session->transactions()->where('is_void', false)->sum('total_price');
$session->total_amount = (int) $total;
$session->save();
}
private function archiveSession(PosSession $session, string $remarks = '', $transactions = null)
{
// Serialize all data NOW before spawning coroutine to avoid context issues
$sessionData = $session->toArray();
$sessionId = $session->id;
$sessionHashkey = $session->hashkey;
$userId = Auth::id();
if ($transactions !== null) {
$transactionsData = $transactions->toArray();
} else {
$transactionsData = $session->transactions()->with('product')->get()->toArray();
}
// Defer the archive INSERT to a background coroutine so it doesn't block the response
Coroutine::create(function () use ($sessionId, $sessionHashkey, $sessionData, $transactionsData, $userId, $remarks) {
try {
PosSessionArchive::create([
'pos_session_id' => $sessionId,
'hashkey' => $sessionHashkey,
'session_snapshot' => $sessionData,
'transactions_snapshot' => $transactionsData,
'created_by' => $userId,
'remarks' => $remarks,
]);
} catch (\Throwable $e) {
// Silently fail — archive is non-critical audit data
}
});
}
private function invalidateSessionCache(PosSession $session)
{
// 1. Invalidate by hashkey (with relations)
CacheHelper::erase_cache($this->getBaseSessionQuery()->where('hashkey', $session->hashkey));
// 1b. Invalidate simple hashkey lookup (used in addItem)
CacheHelper::erase_cache(PosSession::where('hashkey', $session->hashkey));
// 2. Invalidate by store_id (last active session)
if ($session->store_id) {
CacheHelper::erase_cache($this->getBaseSessionQuery()
->where('store_id', $session->store_id)
->where('status', 'active')
->orderBy('id', 'desc'));
}
// 3. Invalidate by access_key
if ($session->access_key) {
CacheHelper::erase_cache($this->getBaseSessionQuery()
->where('access_key', $session->access_key)
->orderBy('id', 'desc'));
}
// 4. Invalidate today stats cache for the store
if ($session->store_id) {
$date = now()->format('Y-m-d');
$statsQuery = PosSession::where('status', 'completed')
->whereDate('created_at', $date)
->where('store_id', $session->store_id);
CacheHelper::erase_cache($statsQuery);
}
}
}