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); } } }