initial: bootstrap from BukidBountyApp base
This commit is contained in:
55
app/Http/Controllers/Market/ActivityController.php
Normal file
55
app/Http/Controllers/Market/ActivityController.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Market;
|
||||
|
||||
use App\Services\ActivityService;
|
||||
use Hypervel\Http\Request;
|
||||
use Hypervel\Support\Facades\Response;
|
||||
use App\Http\Controllers\Helpers\ResponseHelper;
|
||||
|
||||
class ActivityController
|
||||
{
|
||||
/**
|
||||
* Get recent activities.
|
||||
*
|
||||
* @param Request $request
|
||||
* @return \Psr\Http\Message\ResponseInterface
|
||||
*/
|
||||
public function getRecent(Request $request)
|
||||
{
|
||||
$limit = (int) $request->input('limit', 10);
|
||||
$service = new ActivityService();
|
||||
$activities = $service->getRecentActivities($limit);
|
||||
|
||||
return Response::json([
|
||||
'success' => true,
|
||||
'data' => $activities
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search activities.
|
||||
*
|
||||
* @param Request $request
|
||||
* @return \Psr\Http\Message\ResponseInterface
|
||||
*/
|
||||
public function search(Request $request)
|
||||
{
|
||||
$query = $request->input('q', '');
|
||||
$limit = (int) $request->input('limit', 20);
|
||||
$service = new ActivityService();
|
||||
|
||||
if (empty($query)) {
|
||||
$activities = $service->getRecentActivities($limit);
|
||||
} else {
|
||||
$activities = $service->searchActivities($query, $limit);
|
||||
}
|
||||
|
||||
return Response::json([
|
||||
'success' => true,
|
||||
'data' => $activities
|
||||
]);
|
||||
}
|
||||
}
|
||||
577
app/Http/Controllers/Market/BatchController.php
Normal file
577
app/Http/Controllers/Market/BatchController.php
Normal file
@@ -0,0 +1,577 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Market;
|
||||
|
||||
use App\Enums\UserActions;
|
||||
use App\Enums\UserTypes;
|
||||
use App\Http\Controllers\Helpers\Permissions\ProductPermissions;
|
||||
use App\Http\Controllers\Helpers\Permissions\UserPermissions;
|
||||
use App\Http\Controllers\Helpers\Permissions\UserTypeService;
|
||||
use App\Http\Controllers\Helpers\ResponseHelper;
|
||||
use App\Http\Controllers\Helpers\UserController;
|
||||
use App\Models\Market\CooperativeMember;
|
||||
use App\Models\Market\Organization;
|
||||
use App\Models\Market\Product;
|
||||
use App\Models\Market\Store;
|
||||
use App\Models\User;
|
||||
use Hypervel\Support\Str;
|
||||
use Hypervel\Http\Request;
|
||||
use Hypervel\Support\Facades\Auth;
|
||||
use Hypervel\Support\Facades\DB;
|
||||
use Hypervel\Support\Facades\Hash;
|
||||
use Hypervel\Support\Facades\Response;
|
||||
use Hypervel\Support\Facades\Validator;
|
||||
|
||||
class BatchController
|
||||
{
|
||||
/**
|
||||
* Batch create products.
|
||||
* Available for Ultimate, Super Operator, and Operator.
|
||||
*/
|
||||
public function batchCreateProducts(Request $request)
|
||||
{
|
||||
$user = Auth::user();
|
||||
if (!$user) return ResponseHelper::returnUnauthorized();
|
||||
|
||||
$acctType = $user->acct_type instanceof UserTypes ? $user->acct_type : UserTypes::tryFrom($user->acct_type);
|
||||
$isBig3 = in_array($acctType, [UserTypes::ULTIMATE, UserTypes::SUPER_OPERATOR, UserTypes::OPERATOR]);
|
||||
$isStoreOwner = $acctType === UserTypes::STORE_OWNER;
|
||||
|
||||
if (!$isBig3 && !$isStoreOwner) {
|
||||
return ResponseHelper::returnUnauthorized();
|
||||
}
|
||||
|
||||
$products = $request->input('products', []);
|
||||
if (empty($products)) {
|
||||
return ResponseHelper::returnError('No products provided');
|
||||
}
|
||||
|
||||
$targetStoreHash = $request->input('target_store_hash');
|
||||
$targetStore = null;
|
||||
if ($targetStoreHash) {
|
||||
$targetStore = Store::where('hashkey', $targetStoreHash)->first();
|
||||
if (!$targetStore) {
|
||||
return ResponseHelper::returnError('Target store not found');
|
||||
}
|
||||
}
|
||||
|
||||
// STORE_OWNER must operate against a store they actually own/manage.
|
||||
// Without that, batch-import has no destination and must be refused
|
||||
// up-front so the UI doesn't silently 401 partway through.
|
||||
if (!$isBig3) {
|
||||
if (!$targetStore) {
|
||||
return ResponseHelper::returnError('You must select one of your stores before importing products.', 422);
|
||||
}
|
||||
$ownsTarget = (int) $targetStore->owner_id === (int) $user->id
|
||||
|| (int) $targetStore->manager_id === (int) $user->id
|
||||
|| $targetStore->managers()->where('user_id', $user->id)->exists();
|
||||
if (!$ownsTarget) {
|
||||
return ResponseHelper::returnError('You can only import products into stores you own.', 403);
|
||||
}
|
||||
}
|
||||
|
||||
$results = [];
|
||||
$errors = [];
|
||||
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
foreach ($products as $index => $productData) {
|
||||
$source = $productData['source'] ?? 'new';
|
||||
|
||||
if ($source === 'existing') {
|
||||
if (!$targetStore) {
|
||||
$errors[] = 'Row ' . ($index + 1) . ': A target store is required to import an existing product.';
|
||||
continue;
|
||||
}
|
||||
|
||||
$validator = Validator::make($productData, [
|
||||
'product_hash' => 'required|string',
|
||||
'price' => 'nullable|numeric|min:0',
|
||||
'available' => 'nullable|numeric',
|
||||
'description' => 'nullable|string',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
$errors[] = 'Row ' . ($index + 1) . ': ' . implode(', ', $validator->errors()->all());
|
||||
continue;
|
||||
}
|
||||
|
||||
$existing = Product::where('hashkey', $productData['product_hash'])->first();
|
||||
if (!$existing) {
|
||||
$errors[] = 'Row ' . ($index + 1) . ': Selected global product not found.';
|
||||
continue;
|
||||
}
|
||||
|
||||
$alreadyAttached = $targetStore->products()->where('prd_items.id', $existing->id)->exists();
|
||||
if ($alreadyAttached) {
|
||||
$errors[] = 'Row ' . ($index + 1) . ": '{$existing->name}' is already in the target store.";
|
||||
continue;
|
||||
}
|
||||
|
||||
$price = $productData['price'] ?? null;
|
||||
$price = ($price === null || $price === '' || (float) $price <= 0)
|
||||
? (float) $existing->price
|
||||
: (float) $price;
|
||||
|
||||
$description = $productData['description'] ?? '';
|
||||
if (trim((string) $description) === '') {
|
||||
$description = (string) ($existing->description ?? '');
|
||||
}
|
||||
|
||||
$available = (int) ($productData['available'] ?? 0);
|
||||
|
||||
$targetStore->products()->attach($existing->id, [
|
||||
'available' => $available,
|
||||
'price' => $price,
|
||||
'description' => $description,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$results[] = $existing->hashkey;
|
||||
continue;
|
||||
}
|
||||
|
||||
$validator = Validator::make($productData, [
|
||||
'name' => 'required|string|max:255',
|
||||
'price' => 'required|numeric|min:0',
|
||||
'available' => 'required|numeric',
|
||||
'unitname' => 'required|string|max:100',
|
||||
'description' => 'nullable|string',
|
||||
'category' => 'nullable|string|max:255',
|
||||
'subcategory' => 'nullable|string|max:255',
|
||||
'barcode' => 'nullable|string|max:255',
|
||||
'photourl' => 'nullable|array',
|
||||
'photourl.*' => 'nullable|string',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
$errors[] = "Row " . ($index + 1) . ": " . implode(', ', $validator->errors()->all());
|
||||
continue;
|
||||
}
|
||||
|
||||
// Reject if a global product with this name already exists.
|
||||
// Owners should pick the existing one via the fuzzy-search
|
||||
// modal instead of creating a duplicate global entry.
|
||||
$duplicate = Product::whereRaw('LOWER(TRIM(name)) = ?', [strtolower(trim((string) $productData['name']))])
|
||||
->first();
|
||||
if ($duplicate) {
|
||||
$errors[] = "Row " . ($index + 1) . ": '{$productData['name']}' already exists globally. Use 'Pick existing' to import it instead.";
|
||||
continue;
|
||||
}
|
||||
|
||||
$product = Product::create([
|
||||
'name' => $productData['name'],
|
||||
'description' => $productData['description'] ?? '',
|
||||
'price' => $productData['price'],
|
||||
'unitname' => $productData['unitname'],
|
||||
'available' => $productData['available'],
|
||||
'barcode' => $productData['barcode'] ?? null,
|
||||
'category' => $productData['category'] ?? null,
|
||||
'subcategory' => $productData['subcategory'] ?? null,
|
||||
'photourl' => $productData['photourl'] ?? [],
|
||||
'created_by' => $user->id,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
if ($targetStore) {
|
||||
$targetStore->products()->attach($product->id, [
|
||||
'available' => (int) $productData['available'],
|
||||
'price' => (float) $productData['price'],
|
||||
'description' => $productData['description'] ?? '',
|
||||
'is_active' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
$results[] = $product->hashkey;
|
||||
}
|
||||
|
||||
if (!empty($errors)) {
|
||||
DB::rollBack();
|
||||
return Response::json(['success' => false, 'message' => 'Batch creation failed', 'errors' => $errors], 422);
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
return Response::json(['success' => true, 'count' => count($results), 'data' => $results]);
|
||||
} catch (\Throwable $th) {
|
||||
DB::rollBack();
|
||||
return ResponseHelper::returnError($th->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve the batch products Excel template as a file download.
|
||||
* Available for all authenticated users with batch module access.
|
||||
*/
|
||||
public function downloadProductTemplate()
|
||||
{
|
||||
$templatePath = BASE_PATH . '/resources/templates/batch-products-template.xlsx';
|
||||
|
||||
if (!file_exists($templatePath)) {
|
||||
return Response::json(['success' => false, 'message' => 'Template file not found.'], 404);
|
||||
}
|
||||
|
||||
$fileContent = file_get_contents($templatePath);
|
||||
$filename = 'bukidbounty-batch-products-template.xlsx';
|
||||
|
||||
return Response::make($fileContent, 200, [
|
||||
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
|
||||
'Content-Length' => strlen($fileContent),
|
||||
'Cache-Control' => 'no-cache, no-store, must-revalidate',
|
||||
'Pragma' => 'no-cache',
|
||||
'Expires' => '0',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch create stores.
|
||||
* Available for Ultimate, Super Operator, and Operator.
|
||||
*/
|
||||
public function batchCreateStores(Request $request)
|
||||
{
|
||||
$user = Auth::user();
|
||||
if (!$user) return ResponseHelper::returnUnauthorized();
|
||||
|
||||
$acctType = $user->acct_type instanceof UserTypes ? $user->acct_type : UserTypes::tryFrom($user->acct_type);
|
||||
$isBig3 = in_array($acctType, [UserTypes::ULTIMATE, UserTypes::SUPER_OPERATOR, UserTypes::OPERATOR]);
|
||||
|
||||
if (!$isBig3) {
|
||||
return ResponseHelper::returnUnauthorized();
|
||||
}
|
||||
|
||||
$stores = $request->input('stores', []);
|
||||
if (empty($stores)) {
|
||||
return ResponseHelper::returnError('No stores provided');
|
||||
}
|
||||
|
||||
$results = [];
|
||||
$errors = [];
|
||||
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
foreach ($stores as $index => $storeData) {
|
||||
$validator = Validator::make($storeData, [
|
||||
'name' => 'required|string|max:255',
|
||||
'description' => 'required|string',
|
||||
'address' => 'required|string',
|
||||
'category' => 'nullable|string|max:100',
|
||||
'subcategory' => 'nullable|string|max:100',
|
||||
'owner_hash' => 'nullable|string',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
$errors[] = "Row " . ($index + 1) . ": " . implode(', ', $validator->errors()->all());
|
||||
continue;
|
||||
}
|
||||
|
||||
$ownerId = null;
|
||||
if (!empty($storeData['owner_hash'])) {
|
||||
$ownerId = UserController::findUserIdByHash($storeData['owner_hash']);
|
||||
}
|
||||
|
||||
$storeCode = StoreController::generateStoreCode($storeData['category'] ?? 'General');
|
||||
|
||||
$store = Store::create([
|
||||
'storecode' => $storeCode,
|
||||
'name' => $storeData['name'],
|
||||
'description' => $storeData['description'],
|
||||
'address' => $storeData['address'],
|
||||
'category' => $storeData['category'] ?? 'General',
|
||||
'subcategory' => $storeData['subcategory'] ?? '',
|
||||
'owner_id' => $ownerId,
|
||||
'created_by' => $user->id,
|
||||
'is_active' => true,
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
$results[] = $store->hashkey;
|
||||
}
|
||||
|
||||
if (!empty($errors)) {
|
||||
DB::rollBack();
|
||||
return Response::json(['success' => false, 'message' => 'Batch creation failed', 'errors' => $errors], 422);
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
return Response::json(['success' => true, 'count' => count($results), 'data' => $results]);
|
||||
} catch (\Throwable $th) {
|
||||
DB::rollBack();
|
||||
return ResponseHelper::returnError($th->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch create users.
|
||||
* Available for Ultimate, Super Operator, and Operator.
|
||||
*/
|
||||
public function batchCreateUsers(Request $request)
|
||||
{
|
||||
$user = Auth::user();
|
||||
if (!$user) return ResponseHelper::returnUnauthorized();
|
||||
|
||||
$acctType = $user->acct_type instanceof UserTypes ? $user->acct_type : UserTypes::tryFrom($user->acct_type);
|
||||
$isBig3 = in_array($acctType, [UserTypes::ULTIMATE, UserTypes::SUPER_OPERATOR, UserTypes::OPERATOR]);
|
||||
|
||||
if (!$isBig3) {
|
||||
return ResponseHelper::returnUnauthorized();
|
||||
}
|
||||
|
||||
$usersData = $request->input('users', []);
|
||||
if (empty($usersData)) {
|
||||
return ResponseHelper::returnError('No users provided');
|
||||
}
|
||||
|
||||
$results = [];
|
||||
$errors = [];
|
||||
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
foreach ($usersData as $index => $data) {
|
||||
$validator = Validator::make($data, [
|
||||
'username' => 'required|string|max:255|unique:users,username',
|
||||
'name' => 'required|string|max:255',
|
||||
'mobile_number' => ['required', 'string', 'max:20', 'unique:users,mobile_number', 'regex:/^(09|\+639)\d{9}$/'],
|
||||
'password' => 'required|string|min:6',
|
||||
'type' => 'required|string',
|
||||
'parent_hash' => 'nullable|string',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
$errors[] = "Row " . ($index + 1) . ": " . implode(', ', $validator->errors()->all());
|
||||
continue;
|
||||
}
|
||||
|
||||
$usertypeEnum = UserTypes::tryFrom($data['type']);
|
||||
if (!$usertypeEnum) {
|
||||
$errors[] = "Row " . ($index + 1) . ": Invalid User Type";
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if creator is allowed to create this user type
|
||||
if ($acctType !== UserTypes::ULTIMATE) {
|
||||
$allowedTypes = UserTypeService::getAllowedUserTypes($acctType);
|
||||
if (!in_array($usertypeEnum, $allowedTypes)) {
|
||||
$errors[] = "Row " . ($index + 1) . ": You are not allowed to create user type '{$data['type']}'";
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$parentId = $user->id; // Default to creator
|
||||
if (!empty($data['parent_hash'])) {
|
||||
$parent = User::where('hashkey', $data['parent_hash'])->first();
|
||||
if ($parent) {
|
||||
$parentId = $parent->id;
|
||||
}
|
||||
}
|
||||
|
||||
$newUser = User::create([
|
||||
'username' => $data['username'],
|
||||
'name' => $data['name'],
|
||||
'mobile_number' => $data['mobile_number'],
|
||||
'password' => Hash::make($data['password']),
|
||||
'acct_type' => $data['type'],
|
||||
'parentuid' => $parentId,
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
$results[] = $newUser->hashkey;
|
||||
}
|
||||
|
||||
if (!empty($errors)) {
|
||||
DB::rollBack();
|
||||
return Response::json(['success' => false, 'message' => 'Batch creation failed', 'errors' => $errors], 422);
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
return Response::json(['success' => true, 'count' => count($results), 'data' => $results]);
|
||||
} catch (\Throwable $th) {
|
||||
DB::rollBack();
|
||||
return ResponseHelper::returnError($th->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch create cooperatives.
|
||||
* Available for Ultimate and Super Operator.
|
||||
*/
|
||||
public function batchCreateCooperatives(Request $request)
|
||||
{
|
||||
$user = Auth::user();
|
||||
if (!$user) return ResponseHelper::returnUnauthorized();
|
||||
|
||||
$acctType = $user->acct_type instanceof UserTypes ? $user->acct_type : UserTypes::tryFrom($user->acct_type);
|
||||
$isAllowed = in_array($acctType, [UserTypes::ULTIMATE, UserTypes::SUPER_OPERATOR]);
|
||||
|
||||
if (!$isAllowed) {
|
||||
return ResponseHelper::returnUnauthorized();
|
||||
}
|
||||
|
||||
$cooperatives = $request->input('cooperatives', []);
|
||||
if (empty($cooperatives)) {
|
||||
return ResponseHelper::returnError('No cooperatives provided');
|
||||
}
|
||||
|
||||
$results = [];
|
||||
$errors = [];
|
||||
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
foreach ($cooperatives as $index => $data) {
|
||||
$validator = Validator::make($data, [
|
||||
'name' => 'required|string|max:255',
|
||||
'address' => 'nullable|string',
|
||||
'registration_number' => 'nullable|string|max:255',
|
||||
'cin' => 'nullable|string|max:255',
|
||||
'tin' => 'nullable|string|max:255',
|
||||
'cooperative_type' => 'nullable|string|max:100',
|
||||
'cooperative_category' => 'nullable|string|max:100',
|
||||
'registration_date' => 'nullable|date',
|
||||
'contact_person' => 'nullable|string|max:255',
|
||||
'contact_number' => 'nullable|string|max:50',
|
||||
'contact_email' => 'nullable|email|max:255',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
$errors[] = "Row " . ($index + 1) . ": " . implode(', ', $validator->errors()->all());
|
||||
continue;
|
||||
}
|
||||
|
||||
$cooperative = new Organization([
|
||||
'hashkey' => Str::random(64),
|
||||
'name' => trim($data['name']),
|
||||
'type' => 'COOPERATIVE',
|
||||
'address' => trim($data['address'] ?? ''),
|
||||
'registration_number' => trim($data['registration_number'] ?? ''),
|
||||
'cin' => trim($data['cin'] ?? ''),
|
||||
'tin' => trim($data['tin'] ?? ''),
|
||||
'cooperative_type' => trim($data['cooperative_type'] ?? ''),
|
||||
'cooperative_category' => trim($data['cooperative_category'] ?? ''),
|
||||
'registration_date' => $data['registration_date'] ?? null,
|
||||
'contact_person' => trim($data['contact_person'] ?? ''),
|
||||
'contact_number' => trim($data['contact_number'] ?? ''),
|
||||
'contact_email' => trim($data['contact_email'] ?? ''),
|
||||
'is_active' => true,
|
||||
'created_by' => $user->id,
|
||||
]);
|
||||
|
||||
if (!$cooperative->save()) {
|
||||
$errors[] = "Row " . ($index + 1) . ": Failed to save cooperative";
|
||||
continue;
|
||||
}
|
||||
|
||||
$results[] = $cooperative->hashkey;
|
||||
}
|
||||
|
||||
if (!empty($errors)) {
|
||||
DB::rollBack();
|
||||
return Response::json(['success' => false, 'message' => 'Batch creation failed', 'errors' => $errors], 422);
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
return Response::json(['success' => true, 'count' => count($results), 'data' => $results]);
|
||||
} catch (\Throwable $th) {
|
||||
DB::rollBack();
|
||||
return ResponseHelper::returnError($th->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch add cooperative members with automatic user account creation.
|
||||
* Each row creates a new User and a corresponding CooperativeMember in one transaction.
|
||||
*/
|
||||
public function batchCreateCooperativeMembers(Request $request)
|
||||
{
|
||||
$user = Auth::user();
|
||||
if (!$user) return ResponseHelper::returnUnauthorized();
|
||||
|
||||
if (!UserPermissions::isActionPermitted($user->acct_type, UserActions::ManageOrganizations)) {
|
||||
return ResponseHelper::returnUnauthorized();
|
||||
}
|
||||
|
||||
$cooperativeHash = $request->input('cooperative_hash');
|
||||
$members = $request->input('members', []);
|
||||
|
||||
if (!$cooperativeHash) {
|
||||
return ResponseHelper::returnError('Cooperative hash is required');
|
||||
}
|
||||
if (empty($members)) {
|
||||
return ResponseHelper::returnError('No members provided');
|
||||
}
|
||||
|
||||
$cooperative = Organization::where('hashkey', $cooperativeHash)
|
||||
->where('type', 'COOPERATIVE')
|
||||
->first();
|
||||
|
||||
if (!$cooperative) {
|
||||
return ResponseHelper::returnError('Cooperative not found', 404);
|
||||
}
|
||||
|
||||
$parentUser = User::where('id', $cooperative->created_by)->first() ?? $user;
|
||||
|
||||
$results = [];
|
||||
$errors = [];
|
||||
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
foreach ($members as $index => $data) {
|
||||
$validator = Validator::make($data, [
|
||||
'username' => 'required|string|max:255|unique:users,username',
|
||||
'name' => 'required|string|max:255',
|
||||
'mobile_number' => ['required', 'string', 'max:20', 'unique:users,mobile_number', 'regex:/^(09|\+639)\d{9}$/'],
|
||||
'password' => 'required|string|min:6',
|
||||
'role' => 'nullable|string|max:50',
|
||||
'membership_type' => 'nullable|string|max:50',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
$errors[] = 'Row ' . ($index + 1) . ': ' . implode(', ', $validator->errors()->all());
|
||||
continue;
|
||||
}
|
||||
|
||||
$newUser = new User();
|
||||
$newUser->username = $data['username'];
|
||||
$newUser->name = $data['name'];
|
||||
$newUser->mobile_number = $data['mobile_number'];
|
||||
$newUser->password = Hash::make($data['password']);
|
||||
$newUser->parentuid = $parentUser->id;
|
||||
$newUser->acct_type = 'user';
|
||||
$newUser->active = true;
|
||||
$newUser->save();
|
||||
|
||||
$member = new CooperativeMember([
|
||||
'hashkey' => Str::random(64),
|
||||
'organization_id' => $cooperative->id,
|
||||
'user_id' => $newUser->id,
|
||||
'role' => $data['role'] ?? 'MEMBER',
|
||||
'membership_type' => $data['membership_type'] ?? null,
|
||||
'joined_at' => now(),
|
||||
'is_active' => true,
|
||||
'created_by' => $user->id,
|
||||
]);
|
||||
|
||||
if (!$member->save()) {
|
||||
$errors[] = 'Row ' . ($index + 1) . ': Failed to save membership';
|
||||
continue;
|
||||
}
|
||||
|
||||
$results[] = [
|
||||
'user_hashkey' => $newUser->hashkey,
|
||||
'member_hashkey' => $member->hashkey,
|
||||
];
|
||||
}
|
||||
|
||||
if (!empty($errors)) {
|
||||
DB::rollBack();
|
||||
return Response::json(['success' => false, 'message' => 'Batch creation failed', 'errors' => $errors], 422);
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
return Response::json(['success' => true, 'count' => count($results), 'data' => $results]);
|
||||
} catch (\Throwable $th) {
|
||||
DB::rollBack();
|
||||
return ResponseHelper::returnError($th->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
142
app/Http/Controllers/Market/CartController.php
Normal file
142
app/Http/Controllers/Market/CartController.php
Normal file
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Market;
|
||||
|
||||
use App\Models\Market\Cart;
|
||||
use App\Models\Market\CartItem;
|
||||
use App\Models\Market\Product;
|
||||
use Hypervel\Http\Request;
|
||||
use Hypervel\Support\Facades\Auth;
|
||||
use Hypervel\Support\Facades\Response;
|
||||
use Hypervel\Support\Str;
|
||||
|
||||
class CartController
|
||||
{
|
||||
public function getCart()
|
||||
{
|
||||
$user = Auth::user();
|
||||
if (!$user) {
|
||||
return Response::json(['error' => 'Unauthorized'], 401);
|
||||
}
|
||||
|
||||
$cart = Cart::firstOrCreate(['user_id' => $user->id]);
|
||||
|
||||
$items = $cart->items()->with('product')->get();
|
||||
|
||||
return Response::json([
|
||||
'success' => true,
|
||||
'cart' => $cart,
|
||||
'items' => $items,
|
||||
'total' => $items->sum(fn($item) => $item->price * $item->quantity)
|
||||
]);
|
||||
}
|
||||
|
||||
public function addItem(Request $request)
|
||||
{
|
||||
$user = Auth::user();
|
||||
if (!$user) {
|
||||
return Response::json(['error' => 'Unauthorized'], 401);
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'product_hash' => 'required|string',
|
||||
'quantity' => 'nullable|integer|min:1',
|
||||
]);
|
||||
|
||||
$product = Product::where('hashkey', $request->input('product_hash'))->first();
|
||||
if (!$product) {
|
||||
return Response::json(['error' => 'Product not found'], 404);
|
||||
}
|
||||
|
||||
$cart = Cart::firstOrCreate(['user_id' => $user->id]);
|
||||
|
||||
$item = $cart->items()->where('product_id', $product->id)->first();
|
||||
|
||||
if ($item) {
|
||||
$item->quantity += $request->input('quantity', 1);
|
||||
$item->save();
|
||||
} else {
|
||||
$cart->items()->create([
|
||||
'product_id' => $product->id,
|
||||
'quantity' => $request->input('quantity', 1),
|
||||
'price' => $product->price,
|
||||
'is_active' => true,
|
||||
'hashkey' => Str::uuid()->toString(),
|
||||
]);
|
||||
}
|
||||
|
||||
return Response::json(['success' => true, 'message' => 'Item added to cart']);
|
||||
}
|
||||
|
||||
public function updateItem(Request $request)
|
||||
{
|
||||
$user = Auth::user();
|
||||
if (!$user) {
|
||||
return Response::json(['error' => 'Unauthorized'], 401);
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'item_hash' => 'required|string',
|
||||
'quantity' => 'required|integer|min:1',
|
||||
]);
|
||||
|
||||
$item = CartItem::where('hashkey', $request->input('item_hash'))->first();
|
||||
if (!$item) {
|
||||
return Response::json(['error' => 'Item not found'], 404);
|
||||
}
|
||||
|
||||
// Verify cart ownership
|
||||
$cart = Cart::find($item->cart_id);
|
||||
if ($cart->user_id !== $user->id) {
|
||||
return Response::json(['error' => 'Forbidden'], 403);
|
||||
}
|
||||
|
||||
$item->quantity = $request->input('quantity');
|
||||
$item->save();
|
||||
|
||||
return Response::json(['success' => true, 'message' => 'Cart updated']);
|
||||
}
|
||||
|
||||
public function removeItem(Request $request)
|
||||
{
|
||||
$user = Auth::user();
|
||||
if (!$user) {
|
||||
return Response::json(['error' => 'Unauthorized'], 401);
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'item_hash' => 'required|string',
|
||||
]);
|
||||
|
||||
$item = CartItem::where('hashkey', $request->input('item_hash'))->first();
|
||||
if (!$item) {
|
||||
return Response::json(['error' => 'Item not found'], 404);
|
||||
}
|
||||
|
||||
$cart = Cart::find($item->cart_id);
|
||||
if ($cart->user_id !== $user->id) {
|
||||
return Response::json(['error' => 'Forbidden'], 403);
|
||||
}
|
||||
|
||||
$item->delete();
|
||||
|
||||
return Response::json(['success' => true, 'message' => 'Item removed from cart']);
|
||||
}
|
||||
|
||||
public function clearCart()
|
||||
{
|
||||
$user = Auth::user();
|
||||
if (!$user) {
|
||||
return Response::json(['error' => 'Unauthorized'], 401);
|
||||
}
|
||||
|
||||
$cart = Cart::where('user_id', $user->id)->first();
|
||||
if ($cart) {
|
||||
$cart->items()->delete();
|
||||
}
|
||||
|
||||
return Response::json(['success' => true, 'message' => 'Cart cleared']);
|
||||
}
|
||||
}
|
||||
470
app/Http/Controllers/Market/CooperativeController.php
Normal file
470
app/Http/Controllers/Market/CooperativeController.php
Normal file
@@ -0,0 +1,470 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Market;
|
||||
|
||||
use App\Http\Controllers\Helpers\ResponseHelper;
|
||||
use App\Models\Market\CooperativeMember;
|
||||
use App\Models\Market\Organization;
|
||||
use App\Models\User;
|
||||
use Hypervel\Http\Request;
|
||||
use Hypervel\Support\Facades\Auth;
|
||||
use Hypervel\Support\Facades\Hash;
|
||||
use Hypervel\Support\Facades\Response;
|
||||
use App\Http\Controllers\Helpers\Permissions\UserPermissions;
|
||||
use App\Enums\UserActions;
|
||||
use App\Enums\UserTypes;
|
||||
use Hypervel\Support\Str;
|
||||
|
||||
class CooperativeController
|
||||
{
|
||||
public function listCooperatives(Request $request)
|
||||
{
|
||||
if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::ViewOrganizations)) {
|
||||
return ResponseHelper::returnUnauthorized();
|
||||
}
|
||||
|
||||
$query = Organization::where('is_active', true)->where('type', 'COOPERATIVE');
|
||||
|
||||
$cooperatives = $query->withCount('members')->get();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $cooperatives
|
||||
]);
|
||||
}
|
||||
|
||||
public function getCooperative(Request $request)
|
||||
{
|
||||
if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::ViewOrganizations)) {
|
||||
return ResponseHelper::returnUnauthorized();
|
||||
}
|
||||
|
||||
$hashkey = $request->input('hashkey');
|
||||
if (!$hashkey) {
|
||||
return ResponseHelper::returnIncorrectDetails();
|
||||
}
|
||||
|
||||
$cooperative = Organization::where('hashkey', $hashkey)->with(['members.user.userInfo'])->first();
|
||||
|
||||
if (!$cooperative) {
|
||||
return ResponseHelper::returnError('Cooperative not found', 404);
|
||||
}
|
||||
|
||||
$currentUserMembership = null;
|
||||
$user = Auth::user();
|
||||
if ($user) {
|
||||
$currentUserMembership = CooperativeMember::where('organization_id', $cooperative->id)
|
||||
->where('user_id', $user->id)
|
||||
->first();
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $cooperative,
|
||||
'is_member' => $currentUserMembership !== null,
|
||||
'membership' => $currentUserMembership,
|
||||
]);
|
||||
}
|
||||
|
||||
public function createCooperative(Request $request)
|
||||
{
|
||||
if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::CreateOrganization)) {
|
||||
return ResponseHelper::returnUnauthorized();
|
||||
}
|
||||
|
||||
$name = $request->input('name');
|
||||
$address = $request->input('address', '');
|
||||
$registrationNumber = $request->input('registration_number', '');
|
||||
$cin = $request->input('cin', '');
|
||||
$tin = $request->input('tin', '');
|
||||
$cooperativeType = $request->input('cooperative_type', '');
|
||||
$cooperativeCategory = $request->input('cooperative_category', '');
|
||||
$registrationDate = $request->input('registration_date', null);
|
||||
$contactPerson = $request->input('contact_person', '');
|
||||
$contactNumber = $request->input('contact_number', '');
|
||||
$contactEmail = $request->input('contact_email', '');
|
||||
|
||||
if (empty(trim($name ?? ''))) {
|
||||
return ResponseHelper::returnError('Cooperative name is required');
|
||||
}
|
||||
|
||||
$cooperative = new Organization([
|
||||
'hashkey' => Str::random(64),
|
||||
'name' => trim($name),
|
||||
'type' => 'COOPERATIVE',
|
||||
'address' => trim($address ?? ''),
|
||||
'registration_number' => trim($registrationNumber),
|
||||
'cin' => trim($cin),
|
||||
'tin' => trim($tin),
|
||||
'cooperative_type' => trim($cooperativeType),
|
||||
'cooperative_category' => trim($cooperativeCategory),
|
||||
'registration_date' => $registrationDate,
|
||||
'contact_person' => trim($contactPerson),
|
||||
'contact_number' => trim($contactNumber),
|
||||
'contact_email' => trim($contactEmail),
|
||||
'is_active' => true,
|
||||
'created_by' => Auth::id(),
|
||||
]);
|
||||
|
||||
if ($cooperative->save()) {
|
||||
return ResponseHelper::returnSuccessResponse($cooperative, $cooperative->hashkey, 'Cooperative created successfully');
|
||||
}
|
||||
|
||||
return ResponseHelper::returnError('Failed to create cooperative');
|
||||
}
|
||||
|
||||
public function joinCooperative(Request $request)
|
||||
{
|
||||
$user = Auth::user();
|
||||
if (!$user) {
|
||||
return ResponseHelper::returnUnauthorized();
|
||||
}
|
||||
|
||||
$cooperativeHash = $request->input('cooperative_hash');
|
||||
if (!$cooperativeHash) {
|
||||
return ResponseHelper::returnIncorrectDetails();
|
||||
}
|
||||
|
||||
$cooperative = Organization::where('hashkey', $cooperativeHash)->first();
|
||||
if (!$cooperative) {
|
||||
return ResponseHelper::returnError('Cooperative not found', 404);
|
||||
}
|
||||
|
||||
// Check if already a member
|
||||
$existing = CooperativeMember::where('organization_id', $cooperative->id)
|
||||
->where('user_id', $user->id)
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
return ResponseHelper::returnError('Already a member of this cooperative');
|
||||
}
|
||||
|
||||
$memberFields = $request->only([
|
||||
'role', 'membership_type', 'membership_level', 'officer_position', 'officer_level',
|
||||
'concurrent_position', 'concurrent_level', 'cooperative_name_alt', 'cooperative_position', 'year_beginning',
|
||||
'priority_sector', 'common_bond', 'vulnerability_classifications',
|
||||
'philsys_id', 'sss_number', 'pagibig_number',
|
||||
'slp_track', 'slp_association_name', 'listahanan_id', 'fourtps_household_id',
|
||||
'tupad_category', 'tupad_insurance_beneficiary_name', 'tupad_insurance_beneficiary_relation',
|
||||
'preferred_occupation', 'nsrp_skills', 'employment_status', 'program_participation',
|
||||
]);
|
||||
|
||||
$member = new CooperativeMember(array_merge($memberFields, [
|
||||
'organization_id' => $cooperative->id,
|
||||
'user_id' => $user->id,
|
||||
'role' => $request->input('role', 'MEMBER'),
|
||||
'joined_at' => now(),
|
||||
'is_active' => true,
|
||||
]));
|
||||
|
||||
if ($member->save()) {
|
||||
// Sync with user settings
|
||||
$settings = $user->settings ?? [];
|
||||
$cooperatives = $settings['cooperatives'] ?? [];
|
||||
if (!in_array($cooperativeHash, $cooperatives)) {
|
||||
$cooperatives[] = $cooperativeHash;
|
||||
$settings['cooperatives'] = $cooperatives;
|
||||
$user->settings = $settings;
|
||||
$user->save();
|
||||
}
|
||||
return ResponseHelper::returnSuccessResponse($member, $member->hashkey, 'Successfully joined cooperative');
|
||||
}
|
||||
|
||||
return ResponseHelper::returnError('Failed to join cooperative');
|
||||
}
|
||||
|
||||
public function addMember(Request $request)
|
||||
{
|
||||
if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::ManageOrganizations)) {
|
||||
return ResponseHelper::returnUnauthorized();
|
||||
}
|
||||
|
||||
$cooperativeHash = $request->input('cooperative_hash');
|
||||
$userHash = $request->input('user_hash');
|
||||
|
||||
if (!$cooperativeHash || !$userHash) {
|
||||
return ResponseHelper::returnIncorrectDetails();
|
||||
}
|
||||
|
||||
$cooperative = Organization::where('hashkey', $cooperativeHash)->first();
|
||||
$targetUser = User::where('hashkey', $userHash)->first();
|
||||
|
||||
if (!$cooperative || !$targetUser) {
|
||||
return ResponseHelper::returnError('Cooperative or User not found', 404);
|
||||
}
|
||||
|
||||
$existing = CooperativeMember::where('organization_id', $cooperative->id)
|
||||
->where('user_id', $targetUser->id)
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
return ResponseHelper::returnError('User is already a member');
|
||||
}
|
||||
|
||||
$memberFields = $request->only([
|
||||
'role', 'membership_type', 'membership_level', 'officer_position', 'officer_level',
|
||||
'concurrent_position', 'concurrent_level', 'cooperative_name_alt', 'cooperative_position', 'year_beginning',
|
||||
'priority_sector', 'common_bond', 'vulnerability_classifications',
|
||||
'philsys_id', 'sss_number', 'pagibig_number',
|
||||
'slp_track', 'slp_association_name', 'listahanan_id', 'fourtps_household_id',
|
||||
'tupad_category', 'tupad_insurance_beneficiary_name', 'tupad_insurance_beneficiary_relation',
|
||||
'preferred_occupation', 'nsrp_skills', 'employment_status', 'program_participation',
|
||||
]);
|
||||
|
||||
$member = new CooperativeMember(array_merge($memberFields, [
|
||||
'organization_id' => $cooperative->id,
|
||||
'user_id' => $targetUser->id,
|
||||
'role' => $request->input('role', 'MEMBER'),
|
||||
'joined_at' => now(),
|
||||
'is_active' => true,
|
||||
]));
|
||||
|
||||
if ($member->save()) {
|
||||
return ResponseHelper::returnSuccessResponse($member, $member->hashkey, 'Member added to cooperative');
|
||||
}
|
||||
|
||||
return ResponseHelper::returnError('Failed to add member');
|
||||
}
|
||||
|
||||
public function updateMember(Request $request)
|
||||
{
|
||||
if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::ManageOrganizations)) {
|
||||
return ResponseHelper::returnUnauthorized();
|
||||
}
|
||||
|
||||
$memberHash = $request->input('member_hash');
|
||||
if (!$memberHash) {
|
||||
return ResponseHelper::returnIncorrectDetails();
|
||||
}
|
||||
|
||||
$member = CooperativeMember::where('hashkey', $memberHash)->first();
|
||||
if (!$member) {
|
||||
return ResponseHelper::returnError('Member record not found', 404);
|
||||
}
|
||||
|
||||
$memberFields = $request->only([
|
||||
'role', 'membership_type', 'membership_level', 'officer_position', 'officer_level',
|
||||
'concurrent_position', 'concurrent_level', 'cooperative_name_alt', 'cooperative_position', 'year_beginning',
|
||||
'is_active'
|
||||
]);
|
||||
|
||||
$member->fill($memberFields);
|
||||
|
||||
if ($member->save()) {
|
||||
return ResponseHelper::returnSuccessResponse($member, $member->hashkey, 'Membership details updated');
|
||||
}
|
||||
|
||||
return ResponseHelper::returnError('Failed to update membership details');
|
||||
}
|
||||
|
||||
public function registerMember(Request $request)
|
||||
{
|
||||
$user = Auth::user();
|
||||
if (!$user) {
|
||||
return ResponseHelper::returnUnauthorized();
|
||||
}
|
||||
|
||||
if (!UserPermissions::isActionPermitted($user->acct_type, UserActions::JoinCooperative)) {
|
||||
return ResponseHelper::returnUnauthorized();
|
||||
}
|
||||
|
||||
$cooperativeHash = $request->input('cooperative_hash');
|
||||
if (!$cooperativeHash) {
|
||||
return ResponseHelper::returnIncorrectDetails();
|
||||
}
|
||||
|
||||
$cooperative = Organization::where('hashkey', $cooperativeHash)->first();
|
||||
if (!$cooperative) {
|
||||
return ResponseHelper::returnError('Cooperative not found', 404);
|
||||
}
|
||||
|
||||
// Check if already a member
|
||||
$existing = CooperativeMember::where('organization_id', $cooperative->id)
|
||||
->where('user_id', $user->id)
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
return ResponseHelper::returnError('Already a member of this cooperative');
|
||||
}
|
||||
|
||||
$memberFields = $request->only([
|
||||
'role', 'membership_type', 'membership_level', 'officer_position', 'officer_level',
|
||||
'concurrent_position', 'concurrent_level', 'cooperative_name_alt', 'cooperative_position', 'year_beginning',
|
||||
'priority_sector', 'common_bond', 'vulnerability_classifications',
|
||||
'philsys_id', 'sss_number', 'pagibig_number',
|
||||
'slp_track', 'slp_association_name', 'listahanan_id', 'fourtps_household_id',
|
||||
'tupad_category', 'tupad_insurance_beneficiary_name', 'tupad_insurance_beneficiary_relation',
|
||||
'preferred_occupation', 'nsrp_skills', 'employment_status', 'program_participation',
|
||||
]);
|
||||
|
||||
$member = new CooperativeMember(array_merge($memberFields, [
|
||||
'hashkey' => Str::random(64),
|
||||
'organization_id' => $cooperative->id,
|
||||
'user_id' => $user->id,
|
||||
'role' => $request->input('role', 'MEMBER'),
|
||||
'joined_at' => now(),
|
||||
'is_active' => true,
|
||||
'created_by' => $user->id,
|
||||
]));
|
||||
|
||||
if ($member->save()) {
|
||||
// Sync with user settings
|
||||
$settings = $user->settings ?? [];
|
||||
$cooperatives = $settings['cooperatives'] ?? [];
|
||||
if (!is_array($cooperatives)) $cooperatives = [];
|
||||
|
||||
if (!in_array($cooperativeHash, $cooperatives)) {
|
||||
$cooperatives[] = $cooperativeHash;
|
||||
$settings['cooperatives'] = $cooperatives;
|
||||
$user->settings = $settings;
|
||||
$user->save();
|
||||
}
|
||||
// Upgrade a plain USER to COOP_MEMBER on cooperative registration.
|
||||
// Never downgrade a higher type (COORDINATOR, OPERATOR, etc.).
|
||||
if ($user->acct_type === UserTypes::USER) {
|
||||
$user->acct_type = UserTypes::COOP_MEMBER;
|
||||
$user->save();
|
||||
}
|
||||
return ResponseHelper::returnSuccessResponse($member, $member->hashkey, 'Successfully registered as a cooperative member');
|
||||
}
|
||||
|
||||
return ResponseHelper::returnError('Failed to register');
|
||||
}
|
||||
|
||||
public function publicCompleteMembership(Request $request)
|
||||
{
|
||||
$userHashkey = $request->input('user_hashkey');
|
||||
$coopHash = $request->input('cooperative_hash');
|
||||
|
||||
if (!$userHashkey || !$coopHash) {
|
||||
return ResponseHelper::returnIncorrectDetails();
|
||||
}
|
||||
|
||||
$user = User::where('hashkey', $userHashkey)->first();
|
||||
if (!$user) {
|
||||
return response()->json(['success' => false, 'message' => 'User not found'], 404);
|
||||
}
|
||||
|
||||
$cooperative = Organization::where('hashkey', $coopHash)
|
||||
->where('type', 'COOPERATIVE')
|
||||
->where('is_active', true)
|
||||
->first();
|
||||
|
||||
if (!$cooperative) {
|
||||
return response()->json(['success' => false, 'message' => 'Cooperative not found'], 404);
|
||||
}
|
||||
|
||||
$existing = CooperativeMember::where('organization_id', $cooperative->id)
|
||||
->where('user_id', $user->id)
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
return response()->json(['success' => false, 'message' => 'Already a member'], 409);
|
||||
}
|
||||
|
||||
$member = new CooperativeMember(array_merge(
|
||||
$request->only([
|
||||
'membership_type', 'membership_level', 'year_beginning',
|
||||
'officer_position', 'officer_level', 'concurrent_position', 'concurrent_level',
|
||||
'cooperative_position', 'cooperative_name_alt',
|
||||
'priority_sector', 'common_bond', 'vulnerability_classifications',
|
||||
'philsys_id', 'sss_number', 'pagibig_number',
|
||||
'slp_track', 'slp_association_name', 'listahanan_id', 'fourtps_household_id',
|
||||
'tupad_category', 'tupad_insurance_beneficiary_name', 'tupad_insurance_beneficiary_relation',
|
||||
'preferred_occupation', 'nsrp_skills', 'employment_status', 'program_participation',
|
||||
]),
|
||||
[
|
||||
'hashkey' => Str::random(64),
|
||||
'organization_id' => $cooperative->id,
|
||||
'user_id' => $user->id,
|
||||
'role' => 'MEMBER',
|
||||
'joined_at' => now(),
|
||||
'is_active' => true,
|
||||
'created_by' => $user->id,
|
||||
]
|
||||
));
|
||||
$member->save();
|
||||
|
||||
$settings = $user->settings ?? [];
|
||||
$settings['cooperatives'] = array_unique(array_merge($settings['cooperatives'] ?? [], [$coopHash]));
|
||||
$user->settings = $settings;
|
||||
$user->save();
|
||||
|
||||
return response()->json(['success' => true, 'message' => 'Membership application submitted successfully.']);
|
||||
}
|
||||
|
||||
public function publicGetCooperative(Request $request, string $hkey)
|
||||
{
|
||||
try {
|
||||
$cooperative = Organization::where('hashkey', $hkey)
|
||||
->where('type', 'COOPERATIVE')
|
||||
->where('is_active', true)
|
||||
->select(['id', 'hashkey', 'name', 'type', 'cooperative_type', 'cooperative_category', 'contact_person', 'contact_number', 'address'])
|
||||
->first();
|
||||
} catch (\Throwable $e) {
|
||||
return Response::json(['success' => false, 'message' => 'Service temporarily unavailable'], 500);
|
||||
}
|
||||
|
||||
if (!$cooperative) {
|
||||
return Response::json(['success' => false, 'message' => 'Cooperative not found'], 404);
|
||||
}
|
||||
|
||||
return Response::json(['success' => true, 'data' => $cooperative]);
|
||||
}
|
||||
|
||||
public function publicRegisterMember(Request $request)
|
||||
{
|
||||
$hkey = $request->input('cooperative_hash');
|
||||
if (!$hkey) {
|
||||
return ResponseHelper::returnIncorrectDetails();
|
||||
}
|
||||
|
||||
$cooperative = Organization::where('hashkey', $hkey)
|
||||
->where('type', 'COOPERATIVE')
|
||||
->where('is_active', true)
|
||||
->first();
|
||||
|
||||
if (!$cooperative) {
|
||||
return Response::json(['success' => false, 'message' => 'Cooperative not found'], 404);
|
||||
}
|
||||
|
||||
try {
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'username' => 'required|string|max:255|unique:users,username',
|
||||
'mobile_number' => ['required', 'string', 'max:20', 'unique:users,mobile_number', 'regex:/^(09|\+639)\d{9}$/'],
|
||||
'password' => 'required|string|min:6',
|
||||
]);
|
||||
} catch (\Hypervel\Validation\ValidationException $e) {
|
||||
return Response::json(['success' => false, 'errors' => $e->errors()], 422);
|
||||
}
|
||||
|
||||
$parentUser = User::where('id', $cooperative->created_by)->first()
|
||||
?? User::where('acct_type', 'COORDINATOR')->first()
|
||||
?? User::orderBy('id')->first();
|
||||
|
||||
if (!$parentUser) {
|
||||
return Response::json(['success' => false, 'message' => 'No valid parent user found'], 500);
|
||||
}
|
||||
|
||||
$user = new User();
|
||||
$user->username = $validated['username'];
|
||||
$user->name = $validated['name'];
|
||||
$user->mobile_number = $validated['mobile_number'];
|
||||
$user->password = Hash::make($validated['password']);
|
||||
$user->parentuid = $parentUser->id;
|
||||
$user->acct_type = 'user';
|
||||
$user->active = true;
|
||||
$user->save();
|
||||
|
||||
return Response::json([
|
||||
'success' => true,
|
||||
'user_hashkey' => $user->hashkey,
|
||||
'message' => 'Account created. Please complete your membership application.',
|
||||
], 201);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
257
app/Http/Controllers/Market/CooperativeDocumentController.php
Normal file
257
app/Http/Controllers/Market/CooperativeDocumentController.php
Normal file
@@ -0,0 +1,257 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Market;
|
||||
|
||||
use App\Http\Controllers\FilesMainController;
|
||||
use App\Http\Controllers\Helpers\ResponseHelper;
|
||||
use App\Models\Market\CooperativeDocument;
|
||||
use App\Models\Market\Organization;
|
||||
use App\Models\FileList;
|
||||
use Hypervel\Http\Request;
|
||||
use Hypervel\Support\Facades\Auth;
|
||||
use App\Http\Controllers\Helpers\Permissions\UserPermissions;
|
||||
use App\Enums\UserActions;
|
||||
use Hypervel\Support\Str;
|
||||
|
||||
class CooperativeDocumentController
|
||||
{
|
||||
public function listDocuments(Request $request)
|
||||
{
|
||||
$orgHash = $request->input('orgHash');
|
||||
if (!$orgHash) {
|
||||
return ResponseHelper::returnIncorrectDetails();
|
||||
}
|
||||
|
||||
$org = Organization::where('hashkey', $orgHash)->first();
|
||||
if (!$org) {
|
||||
return ResponseHelper::returnError('Organization not found', 404);
|
||||
}
|
||||
|
||||
// Get latest versions (where parent_hashkey IS NULL or it is the latest in its group)
|
||||
// For simplicity, we'll fetch all active docs and group them by parent_hashkey or hashkey
|
||||
$allDocs = CooperativeDocument::where('organization_id', $org->id)
|
||||
->where('is_active', true)
|
||||
->orderBy('version_number', 'desc')
|
||||
->get();
|
||||
|
||||
$fileHashes = $allDocs->pluck('file_hashkey')->filter()->unique()->toArray();
|
||||
$fileLists = FileList::whereIn('hashkey', $fileHashes)->with('fileContent')->get()->keyBy('hashkey');
|
||||
|
||||
$grouped = [];
|
||||
foreach ($allDocs as $doc) {
|
||||
$rootKey = $doc->parent_hashkey ?? $doc->hashkey;
|
||||
if (!isset($grouped[$rootKey])) {
|
||||
$grouped[$rootKey] = [];
|
||||
}
|
||||
$grouped[$rootKey][] = $doc;
|
||||
}
|
||||
|
||||
$data = [];
|
||||
foreach ($grouped as $rootKey => $versions) {
|
||||
$latest = $versions[0]; // ordered desc by version_number
|
||||
$fileList = $fileLists[$latest->file_hashkey] ?? null;
|
||||
if ($fileList) {
|
||||
$history = [];
|
||||
foreach ($versions as $v) {
|
||||
$vFileList = $fileLists[$v->file_hashkey] ?? null;
|
||||
if ($vFileList) {
|
||||
$history[] = [
|
||||
'hashkey' => $v->hashkey,
|
||||
'version' => $v->version_number,
|
||||
'name' => $vFileList->filename,
|
||||
'date' => $v->created_at ? $v->created_at->format('Y-m-d H:i') : null,
|
||||
'note' => $v->revision_note,
|
||||
'url' => $vFileList->resolvedUrl()
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$data[] = [
|
||||
'hashkey' => $latest->hashkey,
|
||||
'parent_hashkey' => $latest->parent_hashkey,
|
||||
'name' => $fileList->filename,
|
||||
'type' => $latest->document_type ?? 'Document',
|
||||
'date' => $latest->created_at ? $latest->created_at->toDateString() : null,
|
||||
'size' => $this->formatBytes($fileList->fileContent->size_in_bytes ?? 0),
|
||||
'url' => $fileList->resolvedUrl(),
|
||||
'version' => $latest->version_number,
|
||||
'note' => $latest->revision_note,
|
||||
'history' => $history
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $data
|
||||
]);
|
||||
}
|
||||
|
||||
public function uploadDocument(Request $request)
|
||||
{
|
||||
if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::ManageOrganizations)) {
|
||||
return ResponseHelper::returnUnauthorized();
|
||||
}
|
||||
|
||||
$orgHash = $request->input('orgHash');
|
||||
$docType = $request->input('type', 'OTHERS');
|
||||
|
||||
if (!$orgHash || !$request->hasFile('file')) {
|
||||
return ResponseHelper::returnIncorrectDetails();
|
||||
}
|
||||
|
||||
$org = Organization::where('hashkey', $orgHash)->first();
|
||||
if (!$org) {
|
||||
return ResponseHelper::returnError('Organization not found', 404);
|
||||
}
|
||||
|
||||
$file = $request->file('file');
|
||||
$filename = $file->getClientFilename();
|
||||
|
||||
$fileList = FilesMainController::uploadFileList(
|
||||
$file,
|
||||
$filename,
|
||||
$filename,
|
||||
'Cooperative Document for ' . $org->name,
|
||||
['type' => 'coop_document', 'org_id' => $org->id],
|
||||
'CooperativeDocuments',
|
||||
[],
|
||||
0,
|
||||
'cooperative_document',
|
||||
);
|
||||
|
||||
if (!$fileList || !isset($fileList->hashkey)) {
|
||||
return ResponseHelper::returnError('File upload failed');
|
||||
}
|
||||
|
||||
$doc = new CooperativeDocument([
|
||||
'hashkey' => (string) Str::uuid(),
|
||||
'organization_id' => $org->id,
|
||||
'file_hashkey' => $fileList->hashkey,
|
||||
'document_type' => $docType,
|
||||
'created_by' => Auth::id(),
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
if ($doc->save()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Document uploaded successfully',
|
||||
'data' => [
|
||||
'hashkey' => $doc->hashkey,
|
||||
'name' => $filename,
|
||||
'url' => $fileList->resolvedUrl()
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
return ResponseHelper::returnError('Failed to save document record');
|
||||
}
|
||||
|
||||
public function reviseDocument(Request $request)
|
||||
{
|
||||
if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::ManageOrganizations)) {
|
||||
return ResponseHelper::returnUnauthorized();
|
||||
}
|
||||
|
||||
$parentHash = $request->input('parentHash');
|
||||
$note = $request->input('note');
|
||||
|
||||
if (!$parentHash || !$request->hasFile('file')) {
|
||||
return ResponseHelper::returnIncorrectDetails();
|
||||
}
|
||||
|
||||
$parentDoc = CooperativeDocument::where('hashkey', $parentHash)->first();
|
||||
if (!$parentDoc) {
|
||||
return ResponseHelper::returnError('Original document not found', 404);
|
||||
}
|
||||
|
||||
// The real parent is either its own parent or it is the parent
|
||||
$rootHash = $parentDoc->parent_hashkey ?? $parentDoc->hashkey;
|
||||
|
||||
// Find highest version number
|
||||
$lastVersion = CooperativeDocument::where('hashkey', $rootHash)
|
||||
->orWhere('parent_hashkey', $rootHash)
|
||||
->max('version_number');
|
||||
|
||||
$file = $request->file('file');
|
||||
$filename = $file->getClientFilename();
|
||||
|
||||
$fileList = FilesMainController::uploadFileList(
|
||||
$file,
|
||||
$filename,
|
||||
$filename,
|
||||
'Revision of ' . $parentDoc->hashkey,
|
||||
['type' => 'coop_document_revision', 'parent_id' => $parentDoc->id],
|
||||
'CooperativeDocuments',
|
||||
[],
|
||||
0,
|
||||
'cooperative_document_revision',
|
||||
);
|
||||
|
||||
if (!$fileList) {
|
||||
return ResponseHelper::returnError('File upload failed');
|
||||
}
|
||||
|
||||
$doc = new CooperativeDocument([
|
||||
'hashkey' => (string) Str::uuid(),
|
||||
'parent_hashkey' => $rootHash,
|
||||
'version_number' => $lastVersion + 1,
|
||||
'organization_id' => $parentDoc->organization_id,
|
||||
'file_hashkey' => $fileList->hashkey,
|
||||
'document_type' => $parentDoc->document_type,
|
||||
'revision_note' => $note,
|
||||
'created_by' => Auth::id(),
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
if ($doc->save()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Revision uploaded successfully',
|
||||
'data' => $doc
|
||||
]);
|
||||
}
|
||||
|
||||
return ResponseHelper::returnError('Failed to save revision record');
|
||||
}
|
||||
|
||||
public function deleteDocument(Request $request)
|
||||
{
|
||||
if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::ManageOrganizations)) {
|
||||
return ResponseHelper::returnUnauthorized();
|
||||
}
|
||||
|
||||
$hashkey = $request->input('hashkey');
|
||||
if (!$hashkey) {
|
||||
return ResponseHelper::returnIncorrectDetails();
|
||||
}
|
||||
|
||||
$doc = CooperativeDocument::where('hashkey', $hashkey)->first();
|
||||
if (!$doc) {
|
||||
return ResponseHelper::returnError('Document not found', 404);
|
||||
}
|
||||
|
||||
// If it's a version, we might want to just deactivate that version.
|
||||
// If it's the root, we might want to deactivate all versions.
|
||||
// For now, let's just deactivate the specific one.
|
||||
$doc->is_active = false;
|
||||
if ($doc->save()) {
|
||||
return response()->json(['success' => true, 'message' => 'Document/Revision deleted']);
|
||||
}
|
||||
|
||||
return ResponseHelper::returnError('Failed to delete document');
|
||||
}
|
||||
|
||||
private function formatBytes($bytes, $precision = 1)
|
||||
{
|
||||
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
$bytes = max($bytes, 0);
|
||||
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
|
||||
$pow = min($pow, count($units) - 1);
|
||||
$bytes /= (1 << (10 * $pow));
|
||||
return round($bytes, $precision) . ' ' . $units[$pow];
|
||||
}
|
||||
}
|
||||
269
app/Http/Controllers/Market/CreditController.php
Normal file
269
app/Http/Controllers/Market/CreditController.php
Normal file
@@ -0,0 +1,269 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Market;
|
||||
|
||||
use App\Http\Controllers\Helpers\ResponseHelper;
|
||||
use App\Http\Controllers\Helpers\PaymentProcessor;
|
||||
use App\Http\Controllers\Helpers\QrphDecoder;
|
||||
use App\Models\SystemSetting;
|
||||
use App\Models\Accounting\MemberLedger;
|
||||
use App\Models\User;
|
||||
use App\Models\GlobalTransaction;
|
||||
use Hypervel\Http\Request;
|
||||
use Hypervel\Support\Facades\Auth;
|
||||
use Hypervel\Support\Facades\DB;
|
||||
use Hypervel\Support\Str;
|
||||
use App\Enums\Market\ProductTransactionType;
|
||||
use App\Enums\Market\TransactionFlow;
|
||||
|
||||
class CreditController
|
||||
{
|
||||
public function getWalletData(Request $request)
|
||||
{
|
||||
$user = User::find(Auth::id());
|
||||
if (!$user) return ResponseHelper::returnUnauthorized();
|
||||
|
||||
$history = MemberLedger::where('user_id', $user->id)
|
||||
->where('is_active', true)
|
||||
->orderBy('created_at', 'desc')
|
||||
->limit(20)
|
||||
->get();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'balance' => $user->total_balance,
|
||||
'credit' => $user->total_credit,
|
||||
'history' => $history
|
||||
]);
|
||||
}
|
||||
|
||||
public function topUp(Request $request)
|
||||
{
|
||||
// Check if Top Up is enabled globally
|
||||
if (!(\App\Models\SystemSetting::getValue('top_up_enabled', true))) {
|
||||
return ResponseHelper::returnError('Credit top-up is currently disabled by administrators.');
|
||||
}
|
||||
|
||||
$amount = (float) $request->input('amount');
|
||||
$method = $request->input('method', 'GCASH');
|
||||
|
||||
if ($amount <= 0) {
|
||||
return ResponseHelper::returnError('Amount must be greater than zero');
|
||||
}
|
||||
|
||||
$user = User::find(Auth::id());
|
||||
if (!$user) return ResponseHelper::returnUnauthorized();
|
||||
|
||||
// Start Transaction
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
// 1. Simulate Payment Success (in real life this would be a webhook/callback)
|
||||
$payment = PaymentProcessor::initiatePayment($amount, $method, $user->hashkey);
|
||||
|
||||
if (!$payment['success']) {
|
||||
throw new \Exception('Payment initiation failed');
|
||||
}
|
||||
|
||||
// 2. Update User Balance
|
||||
$user->total_balance += $amount;
|
||||
$user->save();
|
||||
|
||||
// 3. Record in MemberLedger
|
||||
$ledger = new MemberLedger([
|
||||
'hashkey' => Str::random(64),
|
||||
'user_id' => $user->id,
|
||||
'amount' => $amount,
|
||||
'transaction_type' => 'TOP_UP',
|
||||
'flow' => 'IN',
|
||||
'balance_after' => $user->total_balance,
|
||||
'description' => "Credit Top-up via {$method}",
|
||||
'reference_id' => $payment['transaction_id'],
|
||||
'created_by' => $user->id,
|
||||
'is_active' => true,
|
||||
]);
|
||||
$ledger->save();
|
||||
|
||||
// 4. Record in GlobalTransaction (for compatibility with existing reports)
|
||||
$globalTxn = new GlobalTransaction([
|
||||
'hashkey' => Str::random(64),
|
||||
'user_id' => $user->id,
|
||||
'amount' => $amount,
|
||||
'type' => ProductTransactionType::TOP_UP,
|
||||
'status' => 'COMPLETED',
|
||||
'description' => "Credit Top-up via {$method}",
|
||||
'flow' => TransactionFlow::INCOME,
|
||||
'created_by' => $user->id,
|
||||
]);
|
||||
$globalTxn->save();
|
||||
|
||||
DB::commit();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Top-up successful',
|
||||
'balance' => $user->total_balance,
|
||||
'transaction_id' => $payment['transaction_id']
|
||||
]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
return ResponseHelper::returnError('Top-up failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function transferCredit(Request $request)
|
||||
{
|
||||
$recipientHash = $request->input('recipient_hash');
|
||||
$amount = (float) $request->input('amount');
|
||||
|
||||
if ($amount <= 0) return ResponseHelper::returnError('Invalid amount');
|
||||
|
||||
$sender = User::find(Auth::id());
|
||||
if (!$sender) return ResponseHelper::returnUnauthorized();
|
||||
|
||||
if ($sender->total_balance < $amount) {
|
||||
return ResponseHelper::returnError('Insufficient balance');
|
||||
}
|
||||
|
||||
$recipient = User::where('hashkey', $recipientHash)->first();
|
||||
if (!$recipient) return ResponseHelper::returnError('Recipient not found');
|
||||
|
||||
if ($sender->id === $recipient->id) {
|
||||
return ResponseHelper::returnError('Cannot transfer to yourself');
|
||||
}
|
||||
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
// Deduct from sender
|
||||
$sender->total_balance -= $amount;
|
||||
$sender->save();
|
||||
|
||||
// Add to recipient
|
||||
$recipient->total_balance += $amount;
|
||||
$recipient->save();
|
||||
|
||||
$txnRef = Str::random(12);
|
||||
|
||||
// Record Ledger for Sender
|
||||
MemberLedger::create([
|
||||
'hashkey' => Str::random(64),
|
||||
'user_id' => $sender->id,
|
||||
'amount' => $amount,
|
||||
'transaction_type' => 'TRANSFER_OUT',
|
||||
'flow' => 'OUT',
|
||||
'balance_after' => $sender->total_balance,
|
||||
'description' => "Credit Transfer to {$recipient->fullname}",
|
||||
'reference_id' => $txnRef,
|
||||
'created_by' => $sender->id,
|
||||
]);
|
||||
|
||||
// Record Ledger for Recipient
|
||||
MemberLedger::create([
|
||||
'hashkey' => Str::random(64),
|
||||
'user_id' => $recipient->id,
|
||||
'amount' => $amount,
|
||||
'transaction_type' => 'TRANSFER_IN',
|
||||
'flow' => 'IN',
|
||||
'balance_after' => $recipient->total_balance,
|
||||
'description' => "Credit Transfer from {$sender->fullname}",
|
||||
'reference_id' => $txnRef,
|
||||
'created_by' => $sender->id,
|
||||
]);
|
||||
|
||||
DB::commit();
|
||||
return response()->json(['success' => true, 'message' => 'Transfer successful']);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
return ResponseHelper::returnError('Transfer failed');
|
||||
}
|
||||
}
|
||||
|
||||
public function searchUsers(Request $request)
|
||||
{
|
||||
$query = $request->input('q');
|
||||
if (empty($query)) return response()->json(['success' => true, 'data' => []]);
|
||||
|
||||
$users = User::where('fullname', 'like', "%{$query}%")
|
||||
->orWhere('name', 'like', "%{$query}%")
|
||||
->orWhere('mobile_number', 'like', "%{$query}%")
|
||||
->where('id', '!=', Auth::id())
|
||||
->limit(10)
|
||||
->get(['hashkey', 'fullname', 'name', 'mobile_number']);
|
||||
|
||||
return response()->json(['success' => true, 'data' => $users]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET the current QRPH payment code (available to all authenticated users for top-up display).
|
||||
*/
|
||||
public function getQrphCode(Request $request)
|
||||
{
|
||||
$raw = SystemSetting::getValue('qrph_payment_code', null);
|
||||
$imgHashkey = SystemSetting::getValue('qrph_payment_image_hashkey', null);
|
||||
|
||||
if (empty($raw)) {
|
||||
return response()->json(['success' => true, 'qrph' => null, 'decoded' => null, 'image_url' => null]);
|
||||
}
|
||||
|
||||
$decoded = QrphDecoder::decode($raw);
|
||||
$imageUrl = $imgHashkey ? "/RequestData/File/{$imgHashkey}" : null;
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'qrph' => $raw,
|
||||
'decoded' => $decoded,
|
||||
'image_url' => $imageUrl,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* SET the QRPH payment code — ULTIMATE only (enforced by route middleware).
|
||||
* Accepts optional image_hashkey from a prior /File/Upload/QrphPayment call.
|
||||
*/
|
||||
public function setQrphCode(Request $request)
|
||||
{
|
||||
$raw = trim($request->input('qrph_code', ''));
|
||||
$imgHashkey = trim($request->input('image_hashkey', ''));
|
||||
|
||||
if (empty($raw)) {
|
||||
SystemSetting::setValue('qrph_payment_code', '');
|
||||
SystemSetting::setValue('qrph_payment_image_hashkey', '');
|
||||
return response()->json(['success' => true, 'message' => 'QRPH code cleared.']);
|
||||
}
|
||||
|
||||
$decoded = QrphDecoder::decode($raw);
|
||||
|
||||
SystemSetting::setValue('qrph_payment_code', $raw);
|
||||
if (!empty($imgHashkey)) {
|
||||
SystemSetting::setValue('qrph_payment_image_hashkey', $imgHashkey);
|
||||
}
|
||||
|
||||
$imageUrl = $imgHashkey ? "/RequestData/File/{$imgHashkey}" : null;
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'QRPH code saved.',
|
||||
'decoded' => $decoded,
|
||||
'image_url' => $imageUrl,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a QRPH string without saving — for preview before saving.
|
||||
*/
|
||||
public function decodeQrph(Request $request)
|
||||
{
|
||||
$raw = trim($request->input('qrph_code', ''));
|
||||
if (empty($raw)) {
|
||||
return ResponseHelper::returnError('No QR string provided.');
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'decoded' => QrphDecoder::decode($raw),
|
||||
]);
|
||||
}
|
||||
}
|
||||
143
app/Http/Controllers/Market/FarmerController.php
Normal file
143
app/Http/Controllers/Market/FarmerController.php
Normal file
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Market;
|
||||
|
||||
use App\Http\Controllers\Helpers\ResponseHelper;
|
||||
use App\Models\Market\FarmerProfile;
|
||||
use App\Models\Market\Organization;
|
||||
use App\Models\User;
|
||||
use Hypervel\Http\Request;
|
||||
use Hypervel\Support\Facades\Auth;
|
||||
|
||||
use App\Http\Controllers\Helpers\Permissions\UserPermissions;
|
||||
use App\Enums\UserActions;
|
||||
|
||||
class FarmerController
|
||||
{
|
||||
public function registerFarmer(Request $request)
|
||||
{
|
||||
$currentUser = Auth::user();
|
||||
if (!$currentUser) {
|
||||
return ResponseHelper::returnUnauthorized();
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'user_hash' => 'nullable|string',
|
||||
'farm_name' => 'required|string|max:255',
|
||||
'farm_location' => 'nullable|string',
|
||||
'organization_hash' => 'nullable|string',
|
||||
'main_crops' => 'nullable|array',
|
||||
]);
|
||||
|
||||
// Determine the target user - if user_hash provided, use that user; otherwise use current user
|
||||
if (!empty($validated['user_hash'])) {
|
||||
$user = User::where('hashkey', $validated['user_hash'])->first();
|
||||
if (!$user) {
|
||||
return ResponseHelper::returnError('User not found', 404);
|
||||
}
|
||||
} else {
|
||||
$user = $currentUser;
|
||||
}
|
||||
|
||||
$organization = $validated['organization_hash'] ? Organization::where('hashkey', $validated['organization_hash'])->first() : null;
|
||||
|
||||
// Check if user already has a farmer profile
|
||||
$existingProfile = FarmerProfile::where('user_id', $user->id)->first();
|
||||
if ($existingProfile) {
|
||||
return ResponseHelper::returnError('User already has a farmer profile');
|
||||
}
|
||||
|
||||
$profile = new FarmerProfile([
|
||||
'user_id' => $user->id,
|
||||
'organization_id' => $organization?->id,
|
||||
'farm_name' => $validated['farm_name'],
|
||||
'farm_location' => $validated['farm_location'],
|
||||
'main_crops' => $validated['main_crops'],
|
||||
'verification_status' => 'VERIFIED',
|
||||
'created_by' => $currentUser->id,
|
||||
]);
|
||||
|
||||
if ($profile->save()) {
|
||||
return ResponseHelper::returnSuccessResponse($profile, $profile->hashkey, 'Farmer profile created and pending verification');
|
||||
}
|
||||
|
||||
return ResponseHelper::returnError('Failed to create farmer profile');
|
||||
}
|
||||
|
||||
public function listFarmers(Request $request)
|
||||
{
|
||||
if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::ViewFarmers)) {
|
||||
return ResponseHelper::returnUnauthorized();
|
||||
}
|
||||
|
||||
$farmers = FarmerProfile::with(['user', 'organization'])->orderBy('created_at', 'desc')->get();
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $farmers
|
||||
]);
|
||||
}
|
||||
|
||||
public function verifyFarmer(Request $request)
|
||||
{
|
||||
if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::VerifyFarmer)) {
|
||||
return ResponseHelper::returnUnauthorized();
|
||||
}
|
||||
|
||||
$hashkey = $request->input('target');
|
||||
$status = $request->input('status'); // VERIFIED, REJECTED
|
||||
|
||||
if (!$hashkey || !$status) {
|
||||
return ResponseHelper::returnIncorrectDetails();
|
||||
}
|
||||
|
||||
$profile = FarmerProfile::where('hashkey', $hashkey)->first();
|
||||
if (!$profile) {
|
||||
return ResponseHelper::returnError('Profile not found', 404);
|
||||
}
|
||||
|
||||
$profile->verification_status = $status;
|
||||
$profile->save();
|
||||
|
||||
return ResponseHelper::returnSuccessResponse($profile, $profile->hashkey, 'Farmer verification status updated');
|
||||
}
|
||||
|
||||
public function listOrganizations()
|
||||
{
|
||||
if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::ViewOrganizations)) {
|
||||
return ResponseHelper::returnUnauthorized();
|
||||
}
|
||||
|
||||
$orgs = Organization::where('is_active', true)->get();
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $orgs
|
||||
]);
|
||||
}
|
||||
|
||||
public function createOrganization(Request $request)
|
||||
{
|
||||
if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::CreateOrganization)) {
|
||||
return ResponseHelper::returnUnauthorized();
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'type' => 'required|string|in:COOPERATIVE,ASSOCIATION,COMPANY',
|
||||
'address' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$org = new Organization([
|
||||
'name' => $validated['name'],
|
||||
'type' => $validated['type'],
|
||||
'address' => $validated['address'],
|
||||
]);
|
||||
|
||||
if ($org->save()) {
|
||||
return ResponseHelper::returnSuccessResponse($org, $org->hashkey, 'Organization created');
|
||||
}
|
||||
|
||||
return ResponseHelper::returnError('Failed to create organization');
|
||||
}
|
||||
}
|
||||
178
app/Http/Controllers/Market/GovernanceController.php
Normal file
178
app/Http/Controllers/Market/GovernanceController.php
Normal file
@@ -0,0 +1,178 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Market;
|
||||
|
||||
use App\Http\Controllers\Helpers\ResponseHelper;
|
||||
use App\Models\Market\CooperativeResolution;
|
||||
use App\Models\Market\CooperativeVote;
|
||||
use App\Models\Market\Organization;
|
||||
use App\Models\Market\CooperativeMember;
|
||||
use Hypervel\Http\Request;
|
||||
use Hypervel\Support\Facades\Auth;
|
||||
use App\Http\Controllers\Helpers\Permissions\UserPermissions;
|
||||
use App\Enums\UserActions;
|
||||
use Hypervel\Support\Str;
|
||||
|
||||
class GovernanceController
|
||||
{
|
||||
public function listResolutions(Request $request)
|
||||
{
|
||||
if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::ViewResolutions)) {
|
||||
return ResponseHelper::returnUnauthorized();
|
||||
}
|
||||
|
||||
$orgHash = $request->input('org_hash');
|
||||
if (!$orgHash) {
|
||||
return ResponseHelper::returnIncorrectDetails();
|
||||
}
|
||||
|
||||
$org = Organization::where('hashkey', $orgHash)->first();
|
||||
if (!$org) {
|
||||
return ResponseHelper::returnError('Organization not found', 404);
|
||||
}
|
||||
|
||||
$resolutions = CooperativeResolution::where('organization_id', $org->id)
|
||||
->where('is_active', true)
|
||||
->withCount(['votes as yes_votes' => function($query) {
|
||||
$query->where('vote_cast', 'YES');
|
||||
}])
|
||||
->withCount(['votes as no_votes' => function($query) {
|
||||
$query->where('vote_cast', 'NO');
|
||||
}])
|
||||
->withCount(['votes as abstain_votes' => function($query) {
|
||||
$query->where('vote_cast', 'ABSTAIN');
|
||||
}])
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $resolutions
|
||||
]);
|
||||
}
|
||||
|
||||
public function createResolution(Request $request)
|
||||
{
|
||||
if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::CreateResolution)) {
|
||||
return ResponseHelper::returnUnauthorized();
|
||||
}
|
||||
|
||||
$orgHash = $request->input('org_hash');
|
||||
$title = $request->input('title');
|
||||
$description = $request->input('description');
|
||||
|
||||
if (!$orgHash || !$title) {
|
||||
return ResponseHelper::returnIncorrectDetails();
|
||||
}
|
||||
|
||||
$org = Organization::where('hashkey', $orgHash)->first();
|
||||
if (!$org) {
|
||||
return ResponseHelper::returnError('Organization not found', 404);
|
||||
}
|
||||
|
||||
$resolution = new CooperativeResolution([
|
||||
'hashkey' => Str::random(64),
|
||||
'organization_id' => $org->id,
|
||||
'title' => trim($title),
|
||||
'description' => trim($description ?? ''),
|
||||
'status' => 'PROPOSED',
|
||||
'created_by' => Auth::id(),
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
if ($resolution->save()) {
|
||||
return ResponseHelper::returnSuccessResponse($resolution, $resolution->hashkey, 'Resolution created successfully');
|
||||
}
|
||||
|
||||
return ResponseHelper::returnError('Failed to create resolution');
|
||||
}
|
||||
|
||||
public function castVote(Request $request)
|
||||
{
|
||||
if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::VoteResolution)) {
|
||||
return ResponseHelper::returnUnauthorized();
|
||||
}
|
||||
|
||||
$resolutionHash = $request->input('resolution_hash');
|
||||
$vote = $request->input('vote'); // YES, NO, ABSTAIN
|
||||
|
||||
if (!$resolutionHash || !in_array($vote, ['YES', 'NO', 'ABSTAIN'])) {
|
||||
return ResponseHelper::returnIncorrectDetails();
|
||||
}
|
||||
|
||||
$resolution = CooperativeResolution::where('hashkey', $resolutionHash)->first();
|
||||
if (!$resolution) {
|
||||
return ResponseHelper::returnError('Resolution not found', 404);
|
||||
}
|
||||
|
||||
// Check if user is a member of the organization
|
||||
$isMember = CooperativeMember::where('organization_id', $resolution->organization_id)
|
||||
->where('user_id', Auth::id())
|
||||
->where('is_active', true)
|
||||
->exists();
|
||||
|
||||
if (!$isMember) {
|
||||
return ResponseHelper::returnError('Only active members can vote on resolutions', 403);
|
||||
}
|
||||
|
||||
// Check for existing vote
|
||||
$existingVote = CooperativeVote::where('resolution_id', $resolution->id)
|
||||
->where('user_id', Auth::id())
|
||||
->first();
|
||||
|
||||
if ($existingVote) {
|
||||
$existingVote->vote_cast = $vote;
|
||||
$existingVote->updated_by = Auth::id();
|
||||
if ($existingVote->save()) {
|
||||
return ResponseHelper::returnSuccessResponse($existingVote, $existingVote->hashkey, 'Vote updated successfully');
|
||||
}
|
||||
} else {
|
||||
$newVote = new CooperativeVote([
|
||||
'hashkey' => Str::random(64),
|
||||
'resolution_id' => $resolution->id,
|
||||
'user_id' => Auth::id(),
|
||||
'vote_cast' => $vote,
|
||||
'created_by' => Auth::id(),
|
||||
'is_active' => true,
|
||||
]);
|
||||
if ($newVote->save()) {
|
||||
return ResponseHelper::returnSuccessResponse($newVote, $newVote->hashkey, 'Vote cast successfully');
|
||||
}
|
||||
}
|
||||
|
||||
return ResponseHelper::returnError('Failed to record vote');
|
||||
}
|
||||
|
||||
public function updateResolutionStatus(Request $request)
|
||||
{
|
||||
if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::ManageOrganizations)) {
|
||||
return ResponseHelper::returnUnauthorized();
|
||||
}
|
||||
|
||||
$resolutionHash = $request->input('resolution_hash');
|
||||
$status = $request->input('status'); // APPROVED, RESCINDED
|
||||
|
||||
if (!$resolutionHash || !in_array($status, ['APPROVED', 'RESCINDED', 'PROPOSED'])) {
|
||||
return ResponseHelper::returnIncorrectDetails();
|
||||
}
|
||||
|
||||
$resolution = CooperativeResolution::where('hashkey', $resolutionHash)->first();
|
||||
if (!$resolution) {
|
||||
return ResponseHelper::returnError('Resolution not found', 404);
|
||||
}
|
||||
|
||||
$resolution->status = $status;
|
||||
$resolution->updated_by = Auth::id();
|
||||
if ($status === 'APPROVED') {
|
||||
$resolution->date_approved = now();
|
||||
}
|
||||
|
||||
if ($resolution->save()) {
|
||||
return ResponseHelper::returnSuccessResponse($resolution, $resolution->hashkey, 'Resolution status updated');
|
||||
}
|
||||
|
||||
return ResponseHelper::returnError('Failed to update resolution status');
|
||||
}
|
||||
}
|
||||
472
app/Http/Controllers/Market/PerformanceController.php
Normal file
472
app/Http/Controllers/Market/PerformanceController.php
Normal file
@@ -0,0 +1,472 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Market;
|
||||
|
||||
use App\Enums\UserTypes;
|
||||
use App\Http\Controllers\Helpers\ResponseHelper;
|
||||
use App\Models\Market\PosSession;
|
||||
use App\Models\Market\PosTransaction;
|
||||
use App\Models\Market\Product;
|
||||
use App\Models\Market\Store;
|
||||
use App\Models\User;
|
||||
use Hyperf\Stringable\Str;
|
||||
use Hypervel\Http\Request;
|
||||
use Hypervel\Support\Facades\DB;
|
||||
use Hypervel\Support\Facades\Hash;
|
||||
use Hypervel\Support\Facades\Response;
|
||||
|
||||
/**
|
||||
* Performance / load-testing endpoints.
|
||||
*
|
||||
* These bypass session auth so a client machine can hit them with curl.
|
||||
* Auth is via header `X-Perf-Token` matched against env `PERF_API_TOKEN`.
|
||||
* If `PERF_API_TOKEN` is unset the endpoints are disabled (403 for all).
|
||||
*
|
||||
* All endpoints return timing metrics (ms) so the caller can chart how the
|
||||
* box behaves under different batch sizes.
|
||||
*/
|
||||
class PerformanceController
|
||||
{
|
||||
private const DEFAULT_LIMIT = 1000;
|
||||
|
||||
private function authorize(Request $request)
|
||||
{
|
||||
$expected = (string) env('PERF_API_TOKEN', '');
|
||||
if ($expected === '') {
|
||||
return ResponseHelper::returnError('Performance API is disabled (PERF_API_TOKEN not set)', 403);
|
||||
}
|
||||
$provided = (string) ($request->header('X-Perf-Token') ?? $request->input('token', ''));
|
||||
if (!hash_equals($expected, $provided)) {
|
||||
return ResponseHelper::returnError('Invalid perf token', 401);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private function actingUser(): ?User
|
||||
{
|
||||
$hash = (string) env('PERF_ACTOR_HASH', '');
|
||||
if ($hash !== '') {
|
||||
$u = User::where('hashkey', $hash)->first();
|
||||
if ($u) return $u;
|
||||
}
|
||||
return User::where('acct_type', UserTypes::ULTIMATE->value)
|
||||
->orderBy('id', 'asc')
|
||||
->first();
|
||||
}
|
||||
|
||||
private function clampCount($raw): int
|
||||
{
|
||||
$n = (int) $raw;
|
||||
if ($n < 1) $n = 1;
|
||||
if ($n > self::DEFAULT_LIMIT) $n = self::DEFAULT_LIMIT;
|
||||
return $n;
|
||||
}
|
||||
|
||||
private function ms(float $start): float
|
||||
{
|
||||
return round((microtime(true) - $start) * 1000, 3);
|
||||
}
|
||||
|
||||
public function ping(Request $request)
|
||||
{
|
||||
$deny = $this->authorize($request);
|
||||
if ($deny) return $deny;
|
||||
return Response::json([
|
||||
'success' => true,
|
||||
'ts' => now()->toIso8601String(),
|
||||
'php' => PHP_VERSION,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Core user seeder, returns ['hashes' => [...], 'ms' => float].
|
||||
*/
|
||||
private function _seedUsers(User $actor, int $count, string $type, ?int $parentId, string $prefix): array
|
||||
{
|
||||
$parentId = $parentId ?? $actor->id;
|
||||
$hashed = Hash::make('Perf12345!');
|
||||
$start = microtime(true);
|
||||
$created = [];
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
foreach (range(1, $count) as $i) {
|
||||
$suffix = Str::random(10);
|
||||
$u = User::create([
|
||||
'username' => "{$prefix}_{$suffix}",
|
||||
'name' => "Perf User {$i}",
|
||||
'mobile_number' => '09' . str_pad((string) random_int(0, 999999999), 9, '0', STR_PAD_LEFT),
|
||||
'password' => $hashed,
|
||||
'acct_type' => $type,
|
||||
'parentuid' => $parentId,
|
||||
'active' => true,
|
||||
]);
|
||||
$created[] = $u->hashkey;
|
||||
}
|
||||
DB::commit();
|
||||
} catch (\Throwable $e) {
|
||||
DB::rollBack();
|
||||
throw $e;
|
||||
}
|
||||
return ['hashes' => $created, 'ms' => $this->ms($start)];
|
||||
}
|
||||
|
||||
private function _seedStores(User $actor, int $count, string $category, ?int $ownerId, string $prefix): array
|
||||
{
|
||||
$start = microtime(true);
|
||||
$created = [];
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
foreach (range(1, $count) as $i) {
|
||||
$storeCode = StoreController::generateStoreCode($category);
|
||||
$s = Store::create([
|
||||
'storecode' => $storeCode,
|
||||
'name' => "{$prefix} " . Str::random(8),
|
||||
'description' => "Synthetic store for perf testing #{$i}",
|
||||
'address' => 'Perf Lane ' . random_int(1, 9999),
|
||||
'category' => $category,
|
||||
'subcategory' => '',
|
||||
'owner_id' => $ownerId,
|
||||
'created_by' => $actor->id,
|
||||
'is_active' => true,
|
||||
'status' => 'active',
|
||||
]);
|
||||
$created[] = $s->hashkey;
|
||||
}
|
||||
DB::commit();
|
||||
} catch (\Throwable $e) {
|
||||
DB::rollBack();
|
||||
throw $e;
|
||||
}
|
||||
return ['hashes' => $created, 'ms' => $this->ms($start)];
|
||||
}
|
||||
|
||||
private function _seedProducts(User $actor, int $count, ?Store $store, bool $attach, string $prefix): array
|
||||
{
|
||||
$start = microtime(true);
|
||||
$created = [];
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
foreach (range(1, $count) as $i) {
|
||||
$price = random_int(10, 5000);
|
||||
$available = random_int(10, 500);
|
||||
$p = Product::create([
|
||||
'name' => "{$prefix} " . Str::random(8),
|
||||
'description' => "Synthetic product for perf testing #{$i}",
|
||||
'price' => $price,
|
||||
'unitname' => 'pc',
|
||||
'available' => $available,
|
||||
'category' => 'Perf',
|
||||
'subcategory' => 'Synthetic',
|
||||
'created_by' => $actor->id,
|
||||
'is_active' => true,
|
||||
]);
|
||||
if ($store && $attach) {
|
||||
$store->products()->attach($p->id, [
|
||||
'available' => $available,
|
||||
'price' => $price,
|
||||
'description' => $p->description,
|
||||
'is_active' => true,
|
||||
]);
|
||||
}
|
||||
$created[] = $p->hashkey;
|
||||
}
|
||||
DB::commit();
|
||||
} catch (\Throwable $e) {
|
||||
DB::rollBack();
|
||||
throw $e;
|
||||
}
|
||||
return ['hashes' => $created, 'ms' => $this->ms($start)];
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/perf/seed/users
|
||||
* body: { count, type?, parent_hash?, prefix? }
|
||||
*/
|
||||
public function seedUsers(Request $request)
|
||||
{
|
||||
$deny = $this->authorize($request);
|
||||
if ($deny) return $deny;
|
||||
$actor = $this->actingUser();
|
||||
if (!$actor) return ResponseHelper::returnError('No actor user available', 500);
|
||||
|
||||
$count = $this->clampCount($request->input('count', 10));
|
||||
$type = (string) $request->input('type', UserTypes::USER->value);
|
||||
$prefix = (string) $request->input('prefix', 'perf');
|
||||
if (!UserTypes::tryFrom($type)) {
|
||||
return ResponseHelper::returnError("Invalid user type '{$type}'", 422);
|
||||
}
|
||||
$parentId = null;
|
||||
if ($p = $request->input('parent_hash')) {
|
||||
$parent = User::where('hashkey', (string) $p)->first();
|
||||
if ($parent) $parentId = $parent->id;
|
||||
}
|
||||
try {
|
||||
$r = $this->_seedUsers($actor, $count, $type, $parentId, $prefix);
|
||||
} catch (\Throwable $e) {
|
||||
return ResponseHelper::returnError($e->getMessage());
|
||||
}
|
||||
return Response::json([
|
||||
'success' => true,
|
||||
'count' => count($r['hashes']),
|
||||
'total_ms' => $r['ms'],
|
||||
'avg_ms' => round($r['ms'] / max(1, count($r['hashes'])), 3),
|
||||
'sample' => array_slice($r['hashes'], 0, 5),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/perf/seed/stores
|
||||
* body: { count, owner_hash?, category?, prefix? }
|
||||
*/
|
||||
public function seedStores(Request $request)
|
||||
{
|
||||
$deny = $this->authorize($request);
|
||||
if ($deny) return $deny;
|
||||
$actor = $this->actingUser();
|
||||
if (!$actor) return ResponseHelper::returnError('No actor user available', 500);
|
||||
|
||||
$count = $this->clampCount($request->input('count', 10));
|
||||
$category = (string) $request->input('category', 'General');
|
||||
$prefix = (string) $request->input('prefix', 'PerfStore');
|
||||
$ownerId = null;
|
||||
if ($h = $request->input('owner_hash')) {
|
||||
$owner = User::where('hashkey', (string) $h)->first();
|
||||
if ($owner) $ownerId = $owner->id;
|
||||
}
|
||||
try {
|
||||
$r = $this->_seedStores($actor, $count, $category, $ownerId, $prefix);
|
||||
} catch (\Throwable $e) {
|
||||
return ResponseHelper::returnError($e->getMessage());
|
||||
}
|
||||
return Response::json([
|
||||
'success' => true,
|
||||
'count' => count($r['hashes']),
|
||||
'total_ms' => $r['ms'],
|
||||
'avg_ms' => round($r['ms'] / max(1, count($r['hashes'])), 3),
|
||||
'sample' => array_slice($r['hashes'], 0, 5),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/perf/seed/products
|
||||
* body: { count, target_store_hash?, prefix?, attach_to_store? }
|
||||
*/
|
||||
public function seedProducts(Request $request)
|
||||
{
|
||||
$deny = $this->authorize($request);
|
||||
if ($deny) return $deny;
|
||||
$actor = $this->actingUser();
|
||||
if (!$actor) return ResponseHelper::returnError('No actor user available', 500);
|
||||
|
||||
$count = $this->clampCount($request->input('count', 10));
|
||||
$prefix = (string) $request->input('prefix', 'PerfProduct');
|
||||
$attach = (bool) $request->input('attach_to_store', true);
|
||||
$store = null;
|
||||
if ($h = $request->input('target_store_hash')) {
|
||||
$store = Store::where('hashkey', (string) $h)->first();
|
||||
if (!$store) return ResponseHelper::returnError('Target store not found', 404);
|
||||
}
|
||||
try {
|
||||
$r = $this->_seedProducts($actor, $count, $store, $attach, $prefix);
|
||||
} catch (\Throwable $e) {
|
||||
return ResponseHelper::returnError($e->getMessage());
|
||||
}
|
||||
return Response::json([
|
||||
'success' => true,
|
||||
'count' => count($r['hashes']),
|
||||
'total_ms' => $r['ms'],
|
||||
'avg_ms' => round($r['ms'] / max(1, count($r['hashes'])), 3),
|
||||
'attached_to_store' => $store?->hashkey,
|
||||
'sample' => array_slice($r['hashes'], 0, 5),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/perf/seed/batch
|
||||
* body: { users?, stores?, products?, prefix?, type?, category? }
|
||||
* Runs all three seeders sequentially with per-phase timings.
|
||||
*/
|
||||
public function seedBatch(Request $request)
|
||||
{
|
||||
$deny = $this->authorize($request);
|
||||
if ($deny) return $deny;
|
||||
$actor = $this->actingUser();
|
||||
if (!$actor) return ResponseHelper::returnError('No actor user available', 500);
|
||||
|
||||
$prefix = (string) $request->input('prefix', 'perf');
|
||||
$type = (string) $request->input('type', UserTypes::USER->value);
|
||||
if (!UserTypes::tryFrom($type)) {
|
||||
return ResponseHelper::returnError("Invalid user type '{$type}'", 422);
|
||||
}
|
||||
$category = (string) $request->input('category', 'General');
|
||||
|
||||
$users = max(0, (int) $request->input('users', 0));
|
||||
$stores = max(0, (int) $request->input('stores', 0));
|
||||
$products = max(0, (int) $request->input('products', 0));
|
||||
|
||||
$phases = [];
|
||||
$totalStart = microtime(true);
|
||||
|
||||
try {
|
||||
if ($users > 0) {
|
||||
$r = $this->_seedUsers($actor, $this->clampCount($users), $type, null, $prefix);
|
||||
$phases['users'] = ['count' => count($r['hashes']), 'ms' => $r['ms']];
|
||||
}
|
||||
if ($stores > 0) {
|
||||
$r = $this->_seedStores($actor, $this->clampCount($stores), $category, null, $prefix . 'Store');
|
||||
$phases['stores'] = ['count' => count($r['hashes']), 'ms' => $r['ms']];
|
||||
}
|
||||
if ($products > 0) {
|
||||
$r = $this->_seedProducts($actor, $this->clampCount($products), null, false, $prefix . 'Product');
|
||||
$phases['products'] = ['count' => count($r['hashes']), 'ms' => $r['ms']];
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return ResponseHelper::returnError($e->getMessage());
|
||||
}
|
||||
|
||||
return Response::json([
|
||||
'success' => true,
|
||||
'total_ms' => $this->ms($totalStart),
|
||||
'phases' => $phases,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/perf/pos/simulate
|
||||
* body: { store_hash, items?, cycles?, complete? }
|
||||
*
|
||||
* Runs end-to-end POS cycles entirely server-side: open session, add N
|
||||
* line items, optionally complete + archive. Returns per-phase timings.
|
||||
*/
|
||||
public function simulatePos(Request $request)
|
||||
{
|
||||
$deny = $this->authorize($request);
|
||||
if ($deny) return $deny;
|
||||
|
||||
$actor = $this->actingUser();
|
||||
if (!$actor) return ResponseHelper::returnError('No actor user available', 500);
|
||||
|
||||
$storeHash = (string) $request->input('store_hash', '');
|
||||
if ($storeHash === '') return ResponseHelper::returnError('store_hash is required', 422);
|
||||
|
||||
$store = Store::where('hashkey', $storeHash)->first();
|
||||
if (!$store) return ResponseHelper::returnError('Store not found', 404);
|
||||
|
||||
$items = max(1, min(200, (int) $request->input('items', 5)));
|
||||
$cycles = max(1, min(100, (int) $request->input('cycles', 1)));
|
||||
$complete = (bool) $request->input('complete', true);
|
||||
|
||||
// Pull a pool of products attached to this store; fall back to global
|
||||
// active products if the store has none yet.
|
||||
$productIds = DB::table('prd_str')
|
||||
->where('store_id', $store->id)
|
||||
->where('is_active', true)
|
||||
->pluck('product_id')
|
||||
->toArray();
|
||||
|
||||
if (empty($productIds)) {
|
||||
$productIds = Product::where('is_active', true)
|
||||
->limit(max(50, $items * 2))
|
||||
->pluck('id')
|
||||
->toArray();
|
||||
}
|
||||
|
||||
if (empty($productIds)) {
|
||||
return ResponseHelper::returnError('No products available to simulate sales', 422);
|
||||
}
|
||||
|
||||
$products = Product::whereIn('id', $productIds)->get(['id', 'hashkey', 'price'])->keyBy('id');
|
||||
|
||||
$cycleResults = [];
|
||||
$totalStart = microtime(true);
|
||||
|
||||
for ($c = 1; $c <= $cycles; $c++) {
|
||||
$cStart = microtime(true);
|
||||
|
||||
// 1. Open session
|
||||
$t = microtime(true);
|
||||
$session = PosSession::create([
|
||||
'access_key' => Str::random(32),
|
||||
'store_id' => $store->id,
|
||||
'customer_name' => 'Perf Customer ' . $c,
|
||||
'status' => 'active',
|
||||
'created_by' => $actor->id,
|
||||
]);
|
||||
$openMs = $this->ms($t);
|
||||
|
||||
// 2. Add items (raw inserts for speed, mirrors PosController::addItem)
|
||||
$t = microtime(true);
|
||||
$now = now();
|
||||
$rows = [];
|
||||
$total = 0;
|
||||
for ($i = 0; $i < $items; $i++) {
|
||||
$pid = $productIds[array_rand($productIds)];
|
||||
$product = $products[$pid] ?? null;
|
||||
if (!$product) continue;
|
||||
$qty = random_int(1, 5);
|
||||
$price = (int) $product->price;
|
||||
$line = $price * $qty;
|
||||
$total += $line;
|
||||
$rows[] = [
|
||||
'pos_session_id' => $session->id,
|
||||
'product_id' => $pid,
|
||||
'quantity' => $qty,
|
||||
'price_at_sale' => $price,
|
||||
'total_price' => $line,
|
||||
'hashkey' => Str::uuid()->toString() . Str::random(100),
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
'created_by' => $actor->id,
|
||||
];
|
||||
}
|
||||
if (!empty($rows)) {
|
||||
DB::table('pos_transactions')->insert($rows);
|
||||
}
|
||||
$addMs = $this->ms($t);
|
||||
|
||||
// 3. Optionally complete the session
|
||||
$completeMs = null;
|
||||
if ($complete) {
|
||||
$t = microtime(true);
|
||||
DB::table('pos_sessions')->where('id', $session->id)->update([
|
||||
'total_amount' => $total,
|
||||
'received_amount' => $total,
|
||||
'change_amount' => 0,
|
||||
'status' => 'completed',
|
||||
'payment_method' => 'cash',
|
||||
'updated_at' => $now,
|
||||
'updated_by' => $actor->id,
|
||||
]);
|
||||
$completeMs = $this->ms($t);
|
||||
}
|
||||
|
||||
$cycleResults[] = [
|
||||
'session_hash' => $session->hashkey,
|
||||
'items' => count($rows),
|
||||
'total' => $total,
|
||||
'open_ms' => $openMs,
|
||||
'add_items_ms' => $addMs,
|
||||
'complete_ms' => $completeMs,
|
||||
'cycle_ms' => $this->ms($cStart),
|
||||
];
|
||||
}
|
||||
|
||||
$totalMs = $this->ms($totalStart);
|
||||
$cycleMsValues = array_column($cycleResults, 'cycle_ms');
|
||||
$avg = $cycleMsValues ? array_sum($cycleMsValues) / count($cycleMsValues) : 0.0;
|
||||
|
||||
return Response::json([
|
||||
'success' => true,
|
||||
'store_hash' => $store->hashkey,
|
||||
'cycles' => $cycles,
|
||||
'items_per_cycle' => $items,
|
||||
'total_ms' => $totalMs,
|
||||
'avg_cycle_ms' => round($avg, 3),
|
||||
'min_cycle_ms' => $cycleMsValues ? min($cycleMsValues) : 0,
|
||||
'max_cycle_ms' => $cycleMsValues ? max($cycleMsValues) : 0,
|
||||
'detail' => $cycleResults,
|
||||
]);
|
||||
}
|
||||
}
|
||||
774
app/Http/Controllers/Market/PosController.php
Normal file
774
app/Http/Controllers/Market/PosController.php
Normal file
@@ -0,0 +1,774 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
}
|
||||
1140
app/Http/Controllers/Market/ProductController.php
Normal file
1140
app/Http/Controllers/Market/ProductController.php
Normal file
File diff suppressed because it is too large
Load Diff
190
app/Http/Controllers/Market/ProductPhotoSearchController.php
Normal file
190
app/Http/Controllers/Market/ProductPhotoSearchController.php
Normal file
@@ -0,0 +1,190 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Market;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\FilesMainController;
|
||||
use App\Http\Controllers\Helpers\Permissions\UserPermissions;
|
||||
use App\Enums\UserActions;
|
||||
use Hypervel\Http\Request;
|
||||
use Hypervel\Support\Facades\Auth;
|
||||
|
||||
class ProductPhotoSearchController extends Controller
|
||||
{
|
||||
private const DDG_BROWSER_HEADERS = [
|
||||
'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
||||
'Accept-Language: en-US,en;q=0.9',
|
||||
'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
'Referer: https://duckduckgo.com/',
|
||||
];
|
||||
|
||||
// Step 1: fetch the DDG search page to extract the vqd session token.
|
||||
// DDG requires this token to serve the image JSON endpoint.
|
||||
private static function getDdgVqd(string $query): ?string
|
||||
{
|
||||
$url = 'https://duckduckgo.com/?q=' . urlencode($query) . '&iax=images&ia=images';
|
||||
$ctx = stream_context_create(['http' => [
|
||||
'method' => 'GET',
|
||||
'header' => implode("\r\n", self::DDG_BROWSER_HEADERS),
|
||||
'timeout' => 10,
|
||||
]]);
|
||||
$html = @file_get_contents($url, false, $ctx);
|
||||
if (!$html) return null;
|
||||
// The vqd token appears in the page JS in several formats depending on
|
||||
// DDG's current build. Try the quoted forms first (vqd="4-xxx" /
|
||||
// vqd='4-xxx' / vqd:"4-xxx"), then fall back to the bare form.
|
||||
$patterns = [
|
||||
'/vqd=["\']([0-9a-zA-Z._\-]+)["\']/', // vqd="4-123..." or vqd='4-123...'
|
||||
'/vqd["\']?\s*[:=]\s*["\']([0-9a-zA-Z._\-]+)["\']/', // vqd:"4-123..."
|
||||
'/vqd=([0-9a-zA-Z._\-]+)/', // bare vqd=4-123...
|
||||
];
|
||||
foreach ($patterns as $pattern) {
|
||||
if (preg_match($pattern, $html, $m) && !empty($m[1])) {
|
||||
return $m[1];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// GET /api/products/photo-search?q=...&page=1
|
||||
public function search(Request $request)
|
||||
{
|
||||
if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::SearchStockPhotos)) {
|
||||
return response()->json(['error' => 'Unauthorized'], 403);
|
||||
}
|
||||
|
||||
$query = trim($request->input('q', ''));
|
||||
$page = max(1, (int) $request->input('page', 1));
|
||||
|
||||
if (!$query) {
|
||||
return response()->json(['error' => 'Query required'], 422);
|
||||
}
|
||||
|
||||
$vqd = self::getDdgVqd($query);
|
||||
if (!$vqd) {
|
||||
return response()->json(['error' => 'Could not reach image search service'], 502);
|
||||
}
|
||||
|
||||
// s = offset; DDG returns ~15 results per call; page 1 = s=0, page 2 = s=15, etc.
|
||||
$offset = ($page - 1) * 15;
|
||||
|
||||
$url = 'https://duckduckgo.com/i.js?' . http_build_query([
|
||||
'q' => $query,
|
||||
'vqd' => $vqd,
|
||||
'o' => 'json',
|
||||
'p' => '1',
|
||||
'f' => ',,,',
|
||||
'l' => 'us-en',
|
||||
's' => $offset,
|
||||
]);
|
||||
|
||||
$ctx = stream_context_create(['http' => [
|
||||
'method' => 'GET',
|
||||
'header' => implode("\r\n", self::DDG_BROWSER_HEADERS),
|
||||
'timeout' => 10,
|
||||
]]);
|
||||
|
||||
$raw = @file_get_contents($url, false, $ctx);
|
||||
if ($raw === false) {
|
||||
return response()->json(['error' => 'Failed to fetch image results'], 502);
|
||||
}
|
||||
|
||||
$data = json_decode($raw, true);
|
||||
$results = $data['results'] ?? [];
|
||||
|
||||
$photos = array_map(fn($r) => [
|
||||
'id' => md5($r['image']), // stable ID from image URL
|
||||
'thumb' => $r['thumbnail'], // DDG-proxied small thumb (safe to display)
|
||||
'src' => $r['image'], // actual source image URL (used for download)
|
||||
'title' => $r['title'] ?? '',
|
||||
], array_slice($results, 0, 15));
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'photos' => $photos,
|
||||
'page' => $page,
|
||||
'has_more' => count($results) >= 15,
|
||||
]);
|
||||
}
|
||||
|
||||
// POST /api/products/photo-download
|
||||
// body: { src: "https://..." } — the actual source image URL from DDG results
|
||||
public function download(Request $request)
|
||||
{
|
||||
if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::DownloadStockPhoto)) {
|
||||
return response()->json(['error' => 'Unauthorized'], 403);
|
||||
}
|
||||
|
||||
$src = $request->input('src', '');
|
||||
|
||||
// SSRF guard: must be http/https and must not target private/loopback IPs
|
||||
$parsed = parse_url($src);
|
||||
$scheme = $parsed['scheme'] ?? '';
|
||||
$host = strtolower($parsed['host'] ?? '');
|
||||
|
||||
if (!in_array($scheme, ['http', 'https']) || !$host) {
|
||||
return response()->json(['error' => 'Invalid URL'], 422);
|
||||
}
|
||||
|
||||
// Block private/loopback ranges
|
||||
if (preg_match('/^(localhost|127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|0\.0\.0\.0|::1)/i', $host)) {
|
||||
return response()->json(['error' => 'Forbidden URL'], 403);
|
||||
}
|
||||
|
||||
$ctx = stream_context_create(['http' => [
|
||||
'method' => 'GET',
|
||||
'header' => 'User-Agent: Mozilla/5.0' . "\r\n",
|
||||
'timeout' => 15,
|
||||
]]);
|
||||
$raw = @file_get_contents($src, false, $ctx);
|
||||
if ($raw === false || strlen($raw) < 500) {
|
||||
return response()->json(['error' => 'Failed to fetch image'], 502);
|
||||
}
|
||||
|
||||
// Resize to max 1280x720 using PHP GD (bundled — no Intervention Image needed)
|
||||
$srcImg = @imagecreatefromstring($raw);
|
||||
if (!$srcImg) {
|
||||
return response()->json(['error' => 'Invalid image data'], 422);
|
||||
}
|
||||
|
||||
$origW = imagesx($srcImg);
|
||||
$origH = imagesy($srcImg);
|
||||
$maxW = 1280;
|
||||
$maxH = 720;
|
||||
|
||||
$ratio = min($maxW / $origW, $maxH / $origH, 1.0); // never upscale
|
||||
$newW = (int) round($origW * $ratio);
|
||||
$newH = (int) round($origH * $ratio);
|
||||
|
||||
$dstImg = imagescale($srcImg, $newW, $newH, IMG_BILINEAR_FIXED);
|
||||
imagedestroy($srcImg);
|
||||
|
||||
ob_start();
|
||||
imagejpeg($dstImg, null, 85);
|
||||
$binary = ob_get_clean();
|
||||
imagedestroy($dstImg);
|
||||
|
||||
// Save via existing pipeline — binary string branch in uploadFileContent handles this
|
||||
$result = FilesMainController::uploadFileList(
|
||||
$binary,
|
||||
'stock-photo',
|
||||
'stock_photo_' . time() . '.jpg',
|
||||
'',
|
||||
[],
|
||||
'ProductMarket',
|
||||
[],
|
||||
0,
|
||||
'image/jpeg'
|
||||
);
|
||||
|
||||
if (!$result || empty($result->hashkey)) {
|
||||
return response()->json(['error' => 'Save failed'], 500);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'hashkey' => $result->hashkey,
|
||||
'url' => $result->resolvedUrl(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
163
app/Http/Controllers/Market/ShipmentController.php
Normal file
163
app/Http/Controllers/Market/ShipmentController.php
Normal file
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Market;
|
||||
|
||||
use App\Http\Controllers\Helpers\ResponseHelper;
|
||||
use App\Models\Market\Courier;
|
||||
use App\Models\Market\Shipment;
|
||||
use App\Models\Market\Store;
|
||||
use App\Models\Market\Customer;
|
||||
use App\Models\GlobalTransaction;
|
||||
use Hypervel\Http\Request;
|
||||
use Hypervel\Support\Facades\Auth;
|
||||
|
||||
use App\Http\Controllers\Helpers\Permissions\UserPermissions;
|
||||
use App\Enums\UserActions;
|
||||
|
||||
class ShipmentController
|
||||
{
|
||||
public function listShipments(Request $request)
|
||||
{
|
||||
if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::ViewShipments)) {
|
||||
return ResponseHelper::returnUnauthorized();
|
||||
}
|
||||
|
||||
$user = Auth::user();
|
||||
|
||||
$query = Shipment::with(['courier', 'transaction', 'store', 'customer']);
|
||||
|
||||
// filter by store if provided
|
||||
if ($storeHash = $request->input('store_hash')) {
|
||||
$store = Store::where('hashkey', $storeHash)->first();
|
||||
if ($store) {
|
||||
$query->where('store_id', $store->id);
|
||||
}
|
||||
}
|
||||
|
||||
// if not ultimate/admin, restrict to user's shipments
|
||||
// (This logic might need adjustment based on how roles are defined)
|
||||
// For now, let's just list all and allow filtering
|
||||
|
||||
$shipments = $query->orderBy('created_at', 'desc')->get();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $shipments
|
||||
]);
|
||||
}
|
||||
|
||||
public function createNewShipment(Request $request)
|
||||
{
|
||||
if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::CreateShipment)) {
|
||||
return ResponseHelper::returnUnauthorized();
|
||||
}
|
||||
|
||||
$user = Auth::user();
|
||||
|
||||
$validated = $request->validate([
|
||||
'transaction_hash' => 'required|string',
|
||||
'store_hash' => 'nullable|string',
|
||||
'customer_hash' => 'nullable|string',
|
||||
'courier_hash' => 'nullable|string',
|
||||
'origin_address' => 'nullable|string',
|
||||
'destination_address' => 'nullable|string',
|
||||
'shipping_fee' => 'nullable|numeric',
|
||||
'estimated_delivery_date' => 'nullable|date',
|
||||
]);
|
||||
|
||||
$transaction = GlobalTransaction::where('hashkey', $validated['transaction_hash'])->first();
|
||||
if (!$transaction) {
|
||||
return ResponseHelper::returnError('Transaction not found', 404);
|
||||
}
|
||||
|
||||
$store = $validated['store_hash'] ? Store::where('hashkey', $validated['store_hash'])->first() : null;
|
||||
$customer = $validated['customer_hash'] ? Customer::where('hashkey', $validated['customer_hash'])->first() : null;
|
||||
$courier = $validated['courier_hash'] ? Courier::where('hashkey', $validated['courier_hash'])->first() : null;
|
||||
|
||||
$shipment = new Shipment([
|
||||
'transaction_id' => $transaction->id,
|
||||
'store_id' => $store?->id,
|
||||
'customer_id' => $customer?->id,
|
||||
'courier_id' => $courier?->id,
|
||||
'origin_address' => $validated['origin_address'] ?? $store?->address,
|
||||
'destination_address' => $validated['destination_address'] ?? $customer?->address,
|
||||
'shipping_fee' => $validated['shipping_fee'] ?? 0,
|
||||
'estimated_delivery_date' => $validated['estimated_delivery_date'],
|
||||
'status' => 'PENDING',
|
||||
'created_by' => $user->id,
|
||||
]);
|
||||
|
||||
if ($shipment->save()) {
|
||||
return ResponseHelper::returnSuccessResponse($shipment, $shipment->hashkey, 'Shipment created successfully');
|
||||
}
|
||||
|
||||
return ResponseHelper::returnError('Failed to create shipment');
|
||||
}
|
||||
|
||||
public function updateShipmentStatus(Request $request)
|
||||
{
|
||||
if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::UpdateShipmentStatus)) {
|
||||
return ResponseHelper::returnUnauthorized();
|
||||
}
|
||||
|
||||
$hashkey = $request->input('target');
|
||||
$status = $request->input('status');
|
||||
|
||||
if (!$hashkey || !$status) {
|
||||
return ResponseHelper::returnIncorrectDetails();
|
||||
}
|
||||
|
||||
$shipment = Shipment::where('hashkey', $hashkey)->first();
|
||||
if (!$shipment) {
|
||||
return ResponseHelper::returnError('Shipment not found', 404);
|
||||
}
|
||||
|
||||
$shipment->status = $status;
|
||||
if ($status === 'DELIVERED') {
|
||||
$shipment->actual_delivery_date = now();
|
||||
}
|
||||
$shipment->save();
|
||||
|
||||
return ResponseHelper::returnSuccessResponse($shipment, $shipment->hashkey, 'Shipment status updated');
|
||||
}
|
||||
|
||||
public function listCouriers()
|
||||
{
|
||||
if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::ViewCouriers)) {
|
||||
return ResponseHelper::returnUnauthorized();
|
||||
}
|
||||
|
||||
$couriers = Courier::where('is_active', true)->get();
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $couriers
|
||||
]);
|
||||
}
|
||||
|
||||
public function createCourier(Request $request)
|
||||
{
|
||||
if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::CreateCourier)) {
|
||||
return ResponseHelper::returnUnauthorized();
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'contact_number' => 'nullable|string',
|
||||
'type' => 'required|string|in:INTERNAL,EXTERNAL',
|
||||
]);
|
||||
|
||||
$courier = new Courier([
|
||||
'name' => $validated['name'],
|
||||
'contact_number' => $validated['contact_number'],
|
||||
'type' => $validated['type'],
|
||||
]);
|
||||
|
||||
if ($courier->save()) {
|
||||
return ResponseHelper::returnSuccessResponse($courier, $courier->hashkey, 'Courier created');
|
||||
}
|
||||
|
||||
return ResponseHelper::returnError('Failed to create courier');
|
||||
}
|
||||
}
|
||||
1510
app/Http/Controllers/Market/StoreController.php
Normal file
1510
app/Http/Controllers/Market/StoreController.php
Normal file
File diff suppressed because it is too large
Load Diff
565
app/Http/Controllers/Market/UltimateController.php
Normal file
565
app/Http/Controllers/Market/UltimateController.php
Normal file
@@ -0,0 +1,565 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Market;
|
||||
|
||||
use App\Enums\UserActions;
|
||||
use App\Http\Controllers\Helpers\Permissions\UserPermissions;
|
||||
use App\Http\Controllers\Helpers\ResponseHelper;
|
||||
use App\Models\User;
|
||||
use App\Models\Market\Store;
|
||||
use App\Models\Market\Product;
|
||||
use App\Models\GlobalTransaction;
|
||||
use App\Models\FileContent;
|
||||
use App\Models\DbBackup;
|
||||
use Hyperf\Stringable\Str;
|
||||
|
||||
use Hypervel\Http\Request;
|
||||
use Hypervel\Support\Facades\Auth;
|
||||
use Hypervel\Support\Facades\DB;
|
||||
use Hypervel\Support\Facades\Redis;
|
||||
use Hypervel\Support\Facades\Response;
|
||||
|
||||
class UltimateController
|
||||
{
|
||||
/**
|
||||
* Common check for Ultimate access.
|
||||
*/
|
||||
private function checkAccess()
|
||||
{
|
||||
if (!UserPermissions::isActionPermitted(0, UserActions::UltimateConsole)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get system-wide statistics for the dashboard.
|
||||
*/
|
||||
public function getSystemStats()
|
||||
{
|
||||
if (!$this->checkAccess()) return ResponseHelper::returnUnauthorized();
|
||||
|
||||
$globalMessage = Redis::get('system:global_message');
|
||||
|
||||
$redisStatus = ['connected' => false, 'ping_ms' => null, 'used_memory_human' => null, 'version' => null, 'error' => null];
|
||||
try {
|
||||
$start = microtime(true);
|
||||
$pong = Redis::ping();
|
||||
$redisStatus['ping_ms'] = round((microtime(true) - $start) * 1000, 2);
|
||||
$redisStatus['connected'] = $pong === true || $pong === 'PONG' || $pong === '+PONG' || (is_string($pong) && stripos($pong, 'PONG') !== false);
|
||||
|
||||
$info = Redis::info();
|
||||
if (is_array($info)) {
|
||||
$flat = isset($info['Memory']) && is_array($info['Memory']) ? $info['Memory'] : $info;
|
||||
$redisStatus['used_memory_human'] = $flat['used_memory_human'] ?? null;
|
||||
$serverInfo = isset($info['Server']) && is_array($info['Server']) ? $info['Server'] : $info;
|
||||
$redisStatus['version'] = $serverInfo['redis_version'] ?? null;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$redisStatus['error'] = $e->getMessage();
|
||||
}
|
||||
|
||||
$stats = [
|
||||
'users' => User::count(),
|
||||
'active_users' => User::where('active', true)->count(),
|
||||
'stores' => Store::count(),
|
||||
'active_stores' => Store::where('is_active', true)->count(),
|
||||
'products' => Product::count(),
|
||||
'transactions' => GlobalTransaction::count(),
|
||||
'total_balance' => GlobalTransaction::sum('amount'),
|
||||
'php_version' => PHP_VERSION,
|
||||
'server_time' => date('Y-m-d H:i:s'),
|
||||
'maintenance_mode' => Redis::get('system:maintenance_mode') === 'true',
|
||||
'global_message' => $globalMessage ? json_decode($globalMessage, true) : null,
|
||||
'logs_count' => DB::table('logs')->count(),
|
||||
'table_logs_count' => DB::table('table_logs')->count(),
|
||||
'pos_sessions_count' => DB::table('pos_sessions')->count(),
|
||||
'cooperatives_count' => DB::table('organizations')->where('type', 'COOPERATIVE')->count(),
|
||||
'carts_count' => DB::table('carts')->count(),
|
||||
'farmer_profiles_count' => DB::table('farmer_profiles')->count(),
|
||||
'redis' => $redisStatus,
|
||||
];
|
||||
|
||||
return Response::json(['success' => true, 'data' => $stats]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a raw SQL query.
|
||||
*/
|
||||
public function runQuery(Request $request)
|
||||
{
|
||||
if (Auth::user()->acct_type !== \App\Enums\UserTypes::ULTIMATE || !UserPermissions::isActionPermitted(0, UserActions::UltimateQuery)) {
|
||||
return ResponseHelper::returnUnauthorized();
|
||||
}
|
||||
|
||||
$query = $request->input('query');
|
||||
if (empty($query)) return ResponseHelper::returnError('Query cannot be empty');
|
||||
|
||||
try {
|
||||
$queryLower = strtolower(trim($query));
|
||||
if (str_starts_with($queryLower, 'select') || str_starts_with($queryLower, 'show') || str_starts_with($queryLower, 'describe')) {
|
||||
$results = DB::select($query);
|
||||
return Response::json(['success' => true, 'data' => $results]);
|
||||
} else {
|
||||
$affected = DB::statement($query);
|
||||
return Response::json(['success' => true, 'affected' => $affected]);
|
||||
}
|
||||
} catch (\Throwable $th) {
|
||||
return ResponseHelper::returnError($th->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle maintenance mode system-wide.
|
||||
*/
|
||||
public function toggleMaintenance(Request $request)
|
||||
{
|
||||
if (!$this->checkAccess() || !UserPermissions::isActionPermitted(0, UserActions::UltimateMaintenance)) {
|
||||
return ResponseHelper::returnUnauthorized();
|
||||
}
|
||||
|
||||
$enabled = (bool) $request->input('enabled');
|
||||
Redis::set('system:maintenance_mode', $enabled ? 'true' : 'false');
|
||||
|
||||
return Response::json(['success' => true, 'maintenance_mode' => $enabled]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a global message / broadcast.
|
||||
*/
|
||||
public function sendGlobalMessage(Request $request)
|
||||
{
|
||||
if (!$this->checkAccess() || !UserPermissions::isActionPermitted(0, UserActions::UltimateGlobalMessage)) {
|
||||
return ResponseHelper::returnUnauthorized();
|
||||
}
|
||||
|
||||
$message = $request->input('message');
|
||||
$type = $request->input('type', 'info'); // info, success, warning, danger
|
||||
|
||||
if (empty($message)) {
|
||||
Redis::del('system:global_message');
|
||||
return Response::json(['success' => true, 'message' => 'Global message cleared']);
|
||||
}
|
||||
|
||||
Redis::set('system:global_message', json_encode([
|
||||
'text' => $message,
|
||||
'type' => $type,
|
||||
'timestamp' => time()
|
||||
]));
|
||||
|
||||
return Response::json(['success' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush / Truncate specific tables.
|
||||
*/
|
||||
public function flushData(Request $request)
|
||||
{
|
||||
if (!$this->checkAccess() || !UserPermissions::isActionPermitted(0, UserActions::UltimateFlush)) {
|
||||
return ResponseHelper::returnUnauthorized();
|
||||
}
|
||||
|
||||
$target = $request->input('target');
|
||||
|
||||
try {
|
||||
$affected = 0;
|
||||
switch ($target) {
|
||||
case 'transactions':
|
||||
$affected = GlobalTransaction::count();
|
||||
DB::statement('SET FOREIGN_KEY_CHECKS = 0');
|
||||
GlobalTransaction::truncate();
|
||||
DB::statement('SET FOREIGN_KEY_CHECKS = 1');
|
||||
break;
|
||||
case 'pos_sessions':
|
||||
$affected = DB::table('pos_sessions')->count();
|
||||
DB::table('pos_sessions')->truncate();
|
||||
break;
|
||||
case 'cache':
|
||||
Redis::flushDB();
|
||||
break;
|
||||
case 'stores':
|
||||
$affected = DB::table('str')->count();
|
||||
DB::statement('SET FOREIGN_KEY_CHECKS = 0');
|
||||
DB::table('prd_str')->truncate();
|
||||
DB::table('store_managers')->truncate();
|
||||
DB::table('str')->truncate();
|
||||
DB::statement('SET FOREIGN_KEY_CHECKS = 1');
|
||||
break;
|
||||
case 'products':
|
||||
$affected = DB::table('prd_items')->count();
|
||||
DB::statement('SET FOREIGN_KEY_CHECKS = 0');
|
||||
DB::table('prd_str')->truncate();
|
||||
DB::table('prd_items')->truncate();
|
||||
DB::statement('SET FOREIGN_KEY_CHECKS = 1');
|
||||
break;
|
||||
case 'cooperatives':
|
||||
$affected = DB::table('organizations')->where('type', 'COOPERATIVE')->count();
|
||||
DB::statement('SET FOREIGN_KEY_CHECKS = 0');
|
||||
DB::table('cooperative_votes')->truncate();
|
||||
DB::table('cooperative_resolutions')->truncate();
|
||||
DB::table('cooperative_documents')->truncate();
|
||||
DB::table('cooperative_members')->truncate();
|
||||
DB::table('organizations')->where('type', 'COOPERATIVE')->delete();
|
||||
DB::statement('SET FOREIGN_KEY_CHECKS = 1');
|
||||
break;
|
||||
case 'carts':
|
||||
$affected = DB::table('carts')->count();
|
||||
DB::statement('SET FOREIGN_KEY_CHECKS = 0');
|
||||
DB::table('cart_items')->truncate();
|
||||
DB::table('carts')->truncate();
|
||||
DB::statement('SET FOREIGN_KEY_CHECKS = 1');
|
||||
break;
|
||||
case 'farmer_profiles':
|
||||
$affected = DB::table('farmer_profiles')->count();
|
||||
DB::statement('SET FOREIGN_KEY_CHECKS = 0');
|
||||
DB::table('farmer_profiles')->truncate();
|
||||
DB::statement('SET FOREIGN_KEY_CHECKS = 1');
|
||||
break;
|
||||
default:
|
||||
return ResponseHelper::returnError('Invalid flush target');
|
||||
}
|
||||
return Response::json(['success' => true, 'message' => "Flushed $target successfully", 'affected' => $affected]);
|
||||
} catch (\Throwable $th) {
|
||||
return ResponseHelper::returnError($th->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a test notification for a specific user.
|
||||
*/
|
||||
public function testNotification(Request $request)
|
||||
{
|
||||
if (!$this->checkAccess()) return ResponseHelper::returnUnauthorized();
|
||||
|
||||
$userHash = $request->input('user_hash');
|
||||
$user = User::where('hashkey', $userHash)->first();
|
||||
if (!$user) return ResponseHelper::returnError('User not found');
|
||||
|
||||
// Setting exec_command which SSEController picks up to notify client
|
||||
$user->exec_command = 'toast:success:Test Notification from Ultimate Console: ' . date('H:i:s');
|
||||
$user->save();
|
||||
|
||||
return Response::json(['success' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch management for various entities.
|
||||
*/
|
||||
public function batchManage(Request $request)
|
||||
{
|
||||
if (!$this->checkAccess() || !UserPermissions::isActionPermitted(0, UserActions::UltimateBatch)) {
|
||||
return ResponseHelper::returnUnauthorized();
|
||||
}
|
||||
|
||||
$action = $request->input('action');
|
||||
$ids = $request->input('ids', []);
|
||||
$data = $request->input('data', []);
|
||||
|
||||
if (empty($ids) && !in_array($action, ['cleanup_sessions'])) {
|
||||
return ResponseHelper::returnError('No IDs provided');
|
||||
}
|
||||
|
||||
try {
|
||||
switch($action) {
|
||||
case 'activate_users':
|
||||
User::whereIn('id', $ids)->update(['active' => true]);
|
||||
break;
|
||||
case 'deactivate_users':
|
||||
User::whereIn('id', $ids)->update(['active' => false]);
|
||||
break;
|
||||
case 'cleanup_sessions':
|
||||
DB::table('pos_sessions')->where('status', 'VOIDED')->delete();
|
||||
break;
|
||||
case 'mass_transfer_points':
|
||||
$amount = (float)($data['amount'] ?? 0);
|
||||
if ($amount <= 0) return ResponseHelper::returnError('Invalid amount');
|
||||
|
||||
foreach ($ids as $id) {
|
||||
GlobalTransaction::create([
|
||||
'user_id' => $id,
|
||||
'amount' => $amount,
|
||||
'type' => 'REWARD',
|
||||
'description' => 'Mass points adjustment via Ultimate Console',
|
||||
'is_active' => true,
|
||||
]);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
return ResponseHelper::returnError('Invalid batch action');
|
||||
}
|
||||
return Response::json(['success' => true]);
|
||||
} catch (\Throwable $th) {
|
||||
return ResponseHelper::returnError($th->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a system command (Artisan wrapper).
|
||||
*/
|
||||
public function runCommand(Request $request)
|
||||
{
|
||||
if (!$this->checkAccess()) return ResponseHelper::returnUnauthorized();
|
||||
|
||||
$command = $request->input('command');
|
||||
if (empty($command)) return ResponseHelper::returnError('Command cannot be empty');
|
||||
|
||||
// Normalize command: strip 'php artisan ' if present
|
||||
$command = preg_replace('/^php artisan\s+/', '', trim($command));
|
||||
|
||||
// Mapping for user-friendly commands
|
||||
if ($command === 'reset-app all users') {
|
||||
$command = 'app:reset-users';
|
||||
}
|
||||
|
||||
if ($command === 'db seed') {
|
||||
$command = 'db:seed';
|
||||
}
|
||||
|
||||
// For security, only allow specific commands
|
||||
$allowedCommands = [
|
||||
'cache:clear', 'view:clear', 'config:clear', 'route:clear',
|
||||
'migrate', 'migrate:rollback', 'migrate:fresh',
|
||||
'db:seed', 'app:reset-users', 'optimize', 'optimize:clear'
|
||||
];
|
||||
|
||||
$baseCommand = explode(' ', trim($command))[0];
|
||||
|
||||
if (!in_array($baseCommand, $allowedCommands)) {
|
||||
return ResponseHelper::returnError("Command '{$baseCommand}' not allowed for security reasons.");
|
||||
}
|
||||
|
||||
try {
|
||||
// In Hyperf, running commands from HTTP request context is tricky.
|
||||
// We'll use shell_exec in this local environment demo as a fallback.
|
||||
$output = shell_exec("php artisan $command 2>&1");
|
||||
return Response::json(['success' => true, 'output' => $output]);
|
||||
} catch (\Throwable $th) {
|
||||
return ResponseHelper::returnError($th->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `php artisan migrate --force` (non-interactive).
|
||||
*/
|
||||
public function runMigrate(Request $request)
|
||||
{
|
||||
if (!$this->checkAccess()) return ResponseHelper::returnUnauthorized();
|
||||
|
||||
try {
|
||||
$output = shell_exec('cd ' . escapeshellarg(BASE_PATH) . ' && php artisan migrate --force 2>&1');
|
||||
return Response::json(['success' => true, 'output' => $output]);
|
||||
} catch (\Throwable $th) {
|
||||
return ResponseHelper::returnError($th->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a full database backup.
|
||||
* Puts system in maintenance mode during the process.
|
||||
*/
|
||||
public function downloadBackup()
|
||||
{
|
||||
if (!$this->checkAccess()) return ResponseHelper::returnUnauthorized();
|
||||
|
||||
// 1. Enable maintenance mode & Notify
|
||||
Redis::set('system:maintenance_mode', 'true');
|
||||
Redis::set('system:global_message', json_encode(['text' => 'System backup in progress. Transactions temporarily disabled.', 'type' => 'warning']));
|
||||
|
||||
try {
|
||||
$filename = 'backup_' . date('Y-m-d_H-i-s') . '.sql';
|
||||
$path = BASE_PATH . '/storage/app/backups/' . $filename;
|
||||
|
||||
if (!is_dir(dirname($path))) {
|
||||
mkdir(dirname($path), 0755, true);
|
||||
}
|
||||
|
||||
$dbHost = env('DB_HOST', '127.0.0.1');
|
||||
$dbPort = env('DB_PORT', '3306');
|
||||
$dbName = env('DB_DATABASE', 'bukid');
|
||||
$dbUser = env('DB_USERNAME', 'root');
|
||||
$dbPass = env('DB_PASSWORD', '');
|
||||
|
||||
$dump = new \Ifsnop\Mysqldump\Mysqldump(
|
||||
"mysql:host={$dbHost};port={$dbPort};dbname={$dbName}",
|
||||
$dbUser,
|
||||
$dbPass,
|
||||
[
|
||||
'add-drop-table' => true,
|
||||
'exclude-tables' => ['db_backups'] // Exclude the backups table
|
||||
]
|
||||
);
|
||||
|
||||
$dump->start($path);
|
||||
|
||||
if (!file_exists($path) || filesize($path) === 0) {
|
||||
throw new \Exception('Backup file was not created or is empty.');
|
||||
}
|
||||
|
||||
// Compress into 7z Ultra
|
||||
$sevenZFilename = 'backup_' . date('Y-m-d_H-i-s') . '.7z';
|
||||
$sevenZPath = BASE_PATH . '/storage/app/backups/' . $sevenZFilename;
|
||||
|
||||
// -mx=9 for Ultra compression
|
||||
$path_escaped = escapeshellarg($path);
|
||||
$sevenZPath_escaped = escapeshellarg($sevenZPath);
|
||||
$command = "7z a -t7z -m0=lzma2 -mx=9 {$sevenZPath_escaped} {$path_escaped} 2>&1";
|
||||
shell_exec($command);
|
||||
|
||||
if (!file_exists($sevenZPath)) {
|
||||
throw new \Exception('Failed to create 7z archive.');
|
||||
}
|
||||
|
||||
// Save to database
|
||||
$fileContentRaw = file_get_contents($sevenZPath);
|
||||
$fileHash = hash('sha256', $fileContentRaw);
|
||||
|
||||
$fileContent = new FileContent();
|
||||
$fileContent->filehash = $fileHash;
|
||||
$fileContent->titlename = $sevenZFilename;
|
||||
$fileContent->description = 'System database backup';
|
||||
$fileContent->size_in_bytes = filesize($sevenZPath);
|
||||
$fileContent->content = base64_encode($fileContentRaw);
|
||||
$fileContent->mimetype = 'application/x-7z-compressed';
|
||||
$fileContent->created_by = Auth::id();
|
||||
$fileContent->updated_by = Auth::id();
|
||||
$fileContent->save();
|
||||
|
||||
$dbBackup = new DbBackup();
|
||||
$dbBackup->file_content_hashkey = $fileContent->hashkey;
|
||||
$dbBackup->filename = $sevenZFilename;
|
||||
$dbBackup->size_in_bytes = filesize($sevenZPath);
|
||||
$dbBackup->created_by = Auth::id();
|
||||
$dbBackup->updated_by = Auth::id();
|
||||
$dbBackup->save();
|
||||
|
||||
// Clean up the temporary files from filesystem
|
||||
@unlink($path);
|
||||
@unlink($sevenZPath);
|
||||
|
||||
// 2. Disable maintenance mode & Clear Notify
|
||||
Redis::set('system:maintenance_mode', 'false');
|
||||
Redis::del('system:global_message');
|
||||
|
||||
// 3. Return the binary content for download
|
||||
return Response::make($fileContentRaw, 200, [
|
||||
'Content-Type' => 'application/x-7z-compressed',
|
||||
'Content-Disposition' => 'attachment; filename="' . $sevenZFilename . '"',
|
||||
'Content-Length' => strlen($fileContentRaw),
|
||||
]);
|
||||
} catch (\Throwable $th) {
|
||||
Redis::set('system:maintenance_mode', 'false');
|
||||
return ResponseHelper::returnError($th->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recently created backups.
|
||||
*/
|
||||
public function getBackups()
|
||||
{
|
||||
if (!$this->checkAccess()) return ResponseHelper::returnUnauthorized();
|
||||
|
||||
$backups = DbBackup::with(['creator'])
|
||||
->orderBy('created_at', 'desc')
|
||||
->limit(50)
|
||||
->get();
|
||||
|
||||
return Response::json(['success' => true, 'data' => $backups]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a specific backup.
|
||||
*/
|
||||
public function renameBackup(Request $request)
|
||||
{
|
||||
if (!$this->checkAccess()) return ResponseHelper::returnUnauthorized();
|
||||
|
||||
$hash = $request->input('hash');
|
||||
$newName = $request->input('name');
|
||||
|
||||
if (empty($newName)) return ResponseHelper::returnError('Name cannot be empty');
|
||||
|
||||
$backup = DbBackup::where('hashkey', $hash)->first();
|
||||
if (!$backup) return ResponseHelper::returnError('Backup not found');
|
||||
|
||||
$backup->name = $newName;
|
||||
$backup->save();
|
||||
|
||||
return Response::json(['success' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a specific backup.
|
||||
*/
|
||||
public function deleteBackup(Request $request)
|
||||
{
|
||||
if (!$this->checkAccess()) return ResponseHelper::returnUnauthorized();
|
||||
|
||||
$hash = $request->input('hash');
|
||||
$backup = DbBackup::where('hashkey', $hash)->first();
|
||||
if (!$backup) return ResponseHelper::returnError('Backup not found');
|
||||
|
||||
// Delete associated file content
|
||||
FileContent::where('hashkey', $backup->file_content_hashkey)->delete();
|
||||
|
||||
// Delete backup record
|
||||
$backup->delete();
|
||||
|
||||
return Response::json(['success' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a specific backup from the database.
|
||||
*/
|
||||
public function downloadBackupByHash(Request $request)
|
||||
{
|
||||
if (!$this->checkAccess()) return ResponseHelper::returnUnauthorized();
|
||||
|
||||
$hash = $request->input('hash');
|
||||
$backup = DbBackup::where('hashkey', $hash)->first();
|
||||
if (!$backup) return ResponseHelper::returnError('Backup not found');
|
||||
|
||||
$fileContent = FileContent::where('hashkey', $backup->file_content_hashkey)->first();
|
||||
if (!$fileContent) return ResponseHelper::returnError('File content not found');
|
||||
|
||||
$content = base64_decode($fileContent->content);
|
||||
|
||||
return Response::make($content, 200, [
|
||||
'Content-Type' => $fileContent->mimetype,
|
||||
'Content-Disposition' => 'attachment; filename="' . $backup->filename . '"',
|
||||
'Content-Length' => strlen($content),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get system-wide logs from file and database.
|
||||
*/
|
||||
public function getSystemLogs(Request $request)
|
||||
{
|
||||
if (!$this->checkAccess()) return ResponseHelper::returnUnauthorized();
|
||||
|
||||
$type = $request->input('type', 'database');
|
||||
|
||||
if ($type === 'file') {
|
||||
$logPath = BASE_PATH . '/storage/logs/hypervel.log';
|
||||
if (!file_exists($logPath)) {
|
||||
return Response::json(['success' => true, 'data' => 'No file logs found.']);
|
||||
}
|
||||
$logs = shell_exec("tail -n 1000 " . escapeshellarg($logPath));
|
||||
return Response::json(['success' => true, 'data' => $logs]);
|
||||
}
|
||||
|
||||
// Database logs (audit)
|
||||
if ($type === 'audit') {
|
||||
$logs = DB::table('table_logs')->orderBy('id', 'desc')->limit(500)->get();
|
||||
return Response::json(['success' => true, 'data' => $logs]);
|
||||
}
|
||||
|
||||
// Database logs (system)
|
||||
$logs = DB::table('logs')->orderBy('uid', 'desc')->limit(500)->get();
|
||||
return Response::json(['success' => true, 'data' => $logs]);
|
||||
}
|
||||
}
|
||||
169
app/Http/Controllers/Market/UserInfoController.php
Normal file
169
app/Http/Controllers/Market/UserInfoController.php
Normal file
@@ -0,0 +1,169 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Market;
|
||||
|
||||
use App\Http\Controllers\Helpers\ResponseHelper;
|
||||
use App\Models\Market\UserInfo;
|
||||
use App\Models\User;
|
||||
use Hypervel\Http\Request;
|
||||
use Hypervel\Support\Facades\Auth;
|
||||
use App\Http\Controllers\Helpers\Permissions\UserPermissions;
|
||||
use App\Enums\UserActions;
|
||||
|
||||
class UserInfoController
|
||||
{
|
||||
public function getUserInfo(Request $request, string $hashkey)
|
||||
{
|
||||
$targetUser = User::where('hashkey', $hashkey)->first();
|
||||
if (!$targetUser) {
|
||||
return ResponseHelper::returnError('User not found', 404);
|
||||
}
|
||||
|
||||
$currentUser = Auth::user();
|
||||
if (!$currentUser) {
|
||||
return ResponseHelper::returnUnauthorized();
|
||||
}
|
||||
|
||||
// Check permission: can view self or has ViewUserInfo permission for others
|
||||
if ($currentUser->id !== $targetUser->id && !UserPermissions::isActionPermitted($currentUser->acct_type, UserActions::ViewUserInfo)) {
|
||||
return ResponseHelper::returnUnauthorized();
|
||||
}
|
||||
|
||||
$userInfo = $targetUser->userInfo;
|
||||
if (!$userInfo) {
|
||||
// Lazy create if it doesn't exist (should have been backfilled but just in case)
|
||||
$userInfo = UserInfo::create([
|
||||
'user_id' => $targetUser->id,
|
||||
'fullname' => $targetUser->fullname ?? $targetUser->name,
|
||||
'email' => $targetUser->email,
|
||||
'mobile' => $targetUser->mobile_number,
|
||||
'is_active' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $userInfo
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateUserInfo(Request $request, string $hashkey)
|
||||
{
|
||||
$targetUser = User::where('hashkey', $hashkey)->first();
|
||||
if (!$targetUser) {
|
||||
return ResponseHelper::returnError('User not found', 404);
|
||||
}
|
||||
|
||||
$currentUser = Auth::user();
|
||||
if (!$currentUser) {
|
||||
return ResponseHelper::returnUnauthorized();
|
||||
}
|
||||
|
||||
// Check permission: can manage self or has ManageUserInfo permission for others
|
||||
if ($currentUser->id !== $targetUser->id && !UserPermissions::isActionPermitted($currentUser->acct_type, UserActions::ManageUserInfo)) {
|
||||
return ResponseHelper::returnUnauthorized();
|
||||
}
|
||||
|
||||
$userInfo = $targetUser->userInfo;
|
||||
if (!$userInfo) {
|
||||
$userInfo = new UserInfo(['user_id' => $targetUser->id]);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'firstname' => 'nullable|string|max:255',
|
||||
'middlename' => 'nullable|string|max:255',
|
||||
'lastname' => 'nullable|string|max:255',
|
||||
'suffix' => 'nullable|string|max:50',
|
||||
'gender' => 'nullable|string|max:50',
|
||||
'dob' => 'nullable|date',
|
||||
'priority_sector' => 'nullable|string|max:255',
|
||||
'messenger_id' => 'nullable|string|max:255',
|
||||
'viber_number' => 'nullable|string|max:255',
|
||||
'tiktok_username' => 'nullable|string|max:255',
|
||||
'region' => 'nullable|string|max:255',
|
||||
'province' => 'nullable|string|max:255',
|
||||
'city' => 'nullable|string|max:255',
|
||||
'barangay' => 'nullable|string|max:255',
|
||||
'civil_status' => 'nullable|string|max:100',
|
||||
'children_count' => 'nullable|integer',
|
||||
'dependent_count' => 'nullable|integer',
|
||||
'education_level' => 'nullable|string|max:255',
|
||||
'course' => 'nullable|string|max:255',
|
||||
'school' => 'nullable|string|max:255',
|
||||
'year_last_attended' => 'nullable|string|max:50',
|
||||
'livelihood_source' => 'nullable|string|max:255',
|
||||
'last_company' => 'nullable|string|max:255',
|
||||
'employer_name' => 'nullable|string|max:255',
|
||||
'last_position' => 'nullable|string|max:255',
|
||||
'occupation' => 'nullable|string|max:255',
|
||||
'last_employment_year' => 'nullable|string|max:50',
|
||||
'monthly_income' => 'nullable|numeric',
|
||||
'tin' => 'nullable|string|max:100',
|
||||
'philhealth_id' => 'nullable|string|max:100',
|
||||
'gov_id' => 'nullable|string|max:100',
|
||||
'id_type' => 'nullable|string|max:100',
|
||||
'id_number' => 'nullable|string|max:100',
|
||||
'beneficiary_type' => 'nullable|string|max:100',
|
||||
'emergency_contact_name' => 'nullable|string|max:255',
|
||||
'emergency_contact_address' => 'nullable|string|max:255',
|
||||
'emergency_contact_phone' => 'nullable|string|max:50',
|
||||
'emergency_contact_relation' => 'nullable|string|max:100',
|
||||
'emergency_contact_user_id' => 'nullable|integer',
|
||||
'fullname' => 'nullable|string|max:255',
|
||||
'landline' => 'nullable|string|max:20',
|
||||
'mobile' => 'nullable|string|max:20',
|
||||
'email' => 'nullable|email|max:255',
|
||||
'alt_email' => 'nullable|email|max:255',
|
||||
'alt_landline' => 'nullable|string|max:20',
|
||||
'alt_mobile' => 'nullable|string|max:20',
|
||||
'facebook_url' => 'nullable|url|max:255',
|
||||
'bank_details' => 'nullable|array',
|
||||
'bank_account_no' => 'nullable|string|max:100',
|
||||
'addresses' => 'nullable|array',
|
||||
'other_details' => 'nullable|array',
|
||||
]);
|
||||
|
||||
// Logic to automatically populate emergency_contact_user_id if phone matches a registered user
|
||||
if (!empty($validated['emergency_contact_phone'])) {
|
||||
$matchedUser = User::where('mobile_number', $validated['emergency_contact_phone'])->first();
|
||||
if ($matchedUser) {
|
||||
$validated['emergency_contact_user_id'] = $matchedUser->id;
|
||||
}
|
||||
}
|
||||
|
||||
$userInfo->fill($validated);
|
||||
|
||||
if ($userInfo->save()) {
|
||||
// Also update core user fields if they match
|
||||
if (isset($validated['fullname'])) $targetUser->fullname = $validated['fullname'];
|
||||
if (isset($validated['email'])) $targetUser->email = $validated['email'];
|
||||
if (isset($validated['mobile'])) $targetUser->mobile_number = $validated['mobile'];
|
||||
$targetUser->save();
|
||||
|
||||
return ResponseHelper::returnSuccessResponse($userInfo, $userInfo->hashkey, 'User info updated');
|
||||
}
|
||||
|
||||
return ResponseHelper::returnError('Failed to update user info');
|
||||
}
|
||||
|
||||
public function searchEmergencyContact(Request $request)
|
||||
{
|
||||
$query = $request->input('q');
|
||||
if (empty($query)) {
|
||||
return response()->json(['success' => true, 'data' => []]);
|
||||
}
|
||||
|
||||
$users = User::where('name', 'like', "%$query%")
|
||||
->orWhere('fullname', 'like', "%$query%")
|
||||
->orWhere('mobile_number', 'like', "%$query%")
|
||||
->limit(10)
|
||||
->get(['id', 'name', 'fullname', 'mobile_number', 'hashkey']);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $users
|
||||
]);
|
||||
}
|
||||
}
|
||||
52
app/Http/Controllers/Market/UserSettingsController.php
Normal file
52
app/Http/Controllers/Market/UserSettingsController.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Market;
|
||||
|
||||
use App\Http\Controllers\Helpers\ResponseHelper;
|
||||
use App\Models\User;
|
||||
use Hypervel\Http\Request;
|
||||
use Hypervel\Support\Facades\Auth;
|
||||
|
||||
class UserSettingsController
|
||||
{
|
||||
/**
|
||||
* Get the current user's settings.
|
||||
*/
|
||||
public function getSettings()
|
||||
{
|
||||
$user = Auth::user();
|
||||
if (!$user) {
|
||||
return ResponseHelper::returnUnauthorized();
|
||||
}
|
||||
|
||||
return response()->json($user->settings ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the current user's settings.
|
||||
*/
|
||||
public function updateSettings(Request $request)
|
||||
{
|
||||
$user = Auth::user();
|
||||
if (!$user) {
|
||||
return ResponseHelper::returnUnauthorized();
|
||||
}
|
||||
|
||||
$newSettings = $request->all();
|
||||
$currentSettings = $user->settings ?? [];
|
||||
|
||||
// Merge new settings into current settings
|
||||
$updatedSettings = array_merge($currentSettings, $newSettings);
|
||||
|
||||
// Save to database
|
||||
$user->settings = $updatedSettings;
|
||||
$user->save();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'settings' => $user->settings
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user