From a037a7c9ad3a0ece23c9b0c44337ee20caca2437 Mon Sep 17 00:00:00 2001 From: Dillon Ngo Date: Tue, 30 Dec 2025 16:09:16 +0800 Subject: [PATCH] 1688 PO Automation Project --- .../CreatesChatGPTResponseWithFiles.php | 52 +++++ .../OpenAI/Services/UploadsOpenAIFiles.php | 49 +++++ .../ETLPurchaseOrderTransactionLogic.php | 35 ++-- .../ImportPurchaseOrderTransactionLogic.php | 33 +-- ...TLPDFPurchaseOrderTransactionProcessor.php | 198 ++++++++++++++++++ .../bookings/forms/PdfUpload1688Component.vue | 138 ++++++++++++ .../forms/PurchaseOrderFormComponent.vue | 3 +- 7 files changed, 469 insertions(+), 39 deletions(-) create mode 100644 app/Classes/Modules/OpenAI/Services/CreatesChatGPTResponseWithFiles.php create mode 100644 app/Classes/Modules/OpenAI/Services/UploadsOpenAIFiles.php create mode 100644 app/Classes/Modules/Transactions/Processors/ETLPDFPurchaseOrderTransactionProcessor.php create mode 100644 resources/assets/vue/components/bookings/forms/PdfUpload1688Component.vue diff --git a/app/Classes/Modules/OpenAI/Services/CreatesChatGPTResponseWithFiles.php b/app/Classes/Modules/OpenAI/Services/CreatesChatGPTResponseWithFiles.php new file mode 100644 index 00000000..44b52990 --- /dev/null +++ b/app/Classes/Modules/OpenAI/Services/CreatesChatGPTResponseWithFiles.php @@ -0,0 +1,52 @@ + 'input_file', + 'file_id' => $fileId, + ]; + } + + $inputs = array_merge([ + [ + 'type' => 'input_text', + 'text' => $userPrompt, + ] + ], $fileContents); + + $response = Http::withHeaders([ + 'Authorization' => 'Bearer ' . config('openai.api_key'), + 'Content-Type' => 'application/json', + ])->post(config('openai.base_url') . '/v1/responses', [ + 'model' => 'gpt-4.1-mini', + 'input' => [ + [ + 'role' => 'user', + 'content' => $inputs, + ], + ], + ]); + + return $response->json(); + } + catch (\Illuminate\Http\Client\ConnectionException $exception) { + $error = 'Failed to connect to ChatGPT API'; + throw new ConnectionErrorException($error, $exception->getMessage(), $userPrompt, $exception->getTraceAsString()); + } + catch (\Exception $exception) { + throw new MalformedRequestException('Unable to get correct response from ChatGPT API: ' . $exception->getMessage()); + } + } +} diff --git a/app/Classes/Modules/OpenAI/Services/UploadsOpenAIFiles.php b/app/Classes/Modules/OpenAI/Services/UploadsOpenAIFiles.php new file mode 100644 index 00000000..edfdcc1e --- /dev/null +++ b/app/Classes/Modules/OpenAI/Services/UploadsOpenAIFiles.php @@ -0,0 +1,49 @@ + 'Bearer ' . config('openai.api_key'), + ])->attach( + 'file', + fopen($path, 'r'), + basename($path) + )->post(config('openai.base_url') . '/v1/files', [ + 'purpose' => 'user_data', + ]); + + $data = $response->json(); + + if ($response->successful() && isset($data['id'])) { + $ids[] = $data['id']; + } else { + Log::error('File upload failed', [ + 'path' => $path, + 'response' => $data + ]); + } + } + + return $ids; + } + catch (\Illuminate\Http\Client\ConnectionException $exception) { + $error = 'Failed to connect to ChatGPT API'; + throw new ConnectionErrorException($error, $exception->getMessage(), $exception->getTraceAsString()); + } + catch (\Exception $exception) { + throw new MalformedRequestException('Unable to get correct response from ChatGPT API: ' . $exception->getMessage()); + } + } +} diff --git a/app/Classes/Modules/Transactions/ControllersLogic/ETLPurchaseOrderTransactionLogic.php b/app/Classes/Modules/Transactions/ControllersLogic/ETLPurchaseOrderTransactionLogic.php index 3fd2d7b0..a43895cc 100644 --- a/app/Classes/Modules/Transactions/ControllersLogic/ETLPurchaseOrderTransactionLogic.php +++ b/app/Classes/Modules/Transactions/ControllersLogic/ETLPurchaseOrderTransactionLogic.php @@ -5,11 +5,12 @@ namespace App\Classes\Modules\Transactions\ControllersLogic; use App\Classes\Exceptions\InternalServerErrorException; use App\Classes\General\Abstracts\AbstractControllerLogic; -use App\Classes\Modules\Transactions\Processors\ETLPurchaseOrderTransactionProcessor; +use App\Classes\Modules\Transactions\Processors\ETLPDFPurchaseOrderTransactionProcessor; use App\Classes\Modules\Transactions\Standards\Rules\CanETLPurchaseOrder; use Exception; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Log; class ETLPurchaseOrderTransactionLogic extends AbstractControllerLogic { @@ -28,18 +29,18 @@ class ETLPurchaseOrderTransactionLogic extends AbstractControllerLogic /** @var CanETLPurchaseOrder */ private $canETLPurchaseOrder; - /** @var ETLPurchaseOrderTransactionProcessor */ - private $etlPurchaseOrderTransactionProcessor; + /** @var ETLPDFPurchaseOrderTransactionProcessor */ + private $etlPDFPurchaseOrderTransactionProcessor; /** * ETLPurchaseOrderTransactionLogic constructor. * @param CanETLPurchaseOrder $canETLPurchaseOrder - * @param ETLPurchaseOrderTransactionProcessor $etlPurchaseOrderTransactionProcessor + * @param ETLPDFPurchaseOrderTransactionProcessor $etlPDFPurchaseOrderTransactionProcessor */ - public function __construct(CanETLPurchaseOrder $canETLPurchaseOrder, ETLPurchaseOrderTransactionProcessor $etlPurchaseOrderTransactionProcessor) + public function __construct(CanETLPurchaseOrder $canETLPurchaseOrder, ETLPDFPurchaseOrderTransactionProcessor $etlPDFPurchaseOrderTransactionProcessor) { $this->canETLPurchaseOrder = $canETLPurchaseOrder; - $this->etlPurchaseOrderTransactionProcessor = $etlPurchaseOrderTransactionProcessor; + $this->etlPDFPurchaseOrderTransactionProcessor = $etlPDFPurchaseOrderTransactionProcessor; } /** @@ -53,17 +54,19 @@ class ETLPurchaseOrderTransactionLogic extends AbstractControllerLogic { $this->canETLPurchaseOrder->passes(); - if ($request->has('products') && is_array($request->input('products'))) { - $products = $request->input('products'); - $productsMetadata = $request->input('productsMetadata'); - } + // if ($request->has('products') && is_array($request->input('products'))) { + // $products = $request->input('products'); + // $productsMetadata = $request->input('productsMetadata'); + // } - try{ - $products = $this->etlPurchaseOrderTransactionProcessor->execute($products, $productsMetadata); - } catch (\Exception $exception) { - throw new InternalServerErrorException('Something went wrong!'); - } + // try{ + // $products = $this->etlPDFPurchaseOrderTransactionProcessor->execute($products, $productsMetadata); + // } catch (\Exception $exception) { + // throw new InternalServerErrorException('Something went wrong!'); + // } - return $this->response($products); + $files = $request->input('files'); + $result = $this->etlPDFPurchaseOrderTransactionProcessor->execute($files); + return $this->response($result); } } diff --git a/app/Classes/Modules/Transactions/ControllersLogic/ImportPurchaseOrderTransactionLogic.php b/app/Classes/Modules/Transactions/ControllersLogic/ImportPurchaseOrderTransactionLogic.php index d1c855d8..15796728 100644 --- a/app/Classes/Modules/Transactions/ControllersLogic/ImportPurchaseOrderTransactionLogic.php +++ b/app/Classes/Modules/Transactions/ControllersLogic/ImportPurchaseOrderTransactionLogic.php @@ -9,7 +9,7 @@ use App\Classes\Modules\Bookings\Services\FetchesBooking; use App\Classes\Modules\Documents\DataTransferObjects\DocumentObject; use App\Classes\Modules\Transactions\DataTransferObjects\TransactionObject; use App\Classes\Modules\Transactions\Processors\CreatePurchaseOrderTransactionProcessor; -use App\Classes\Modules\Transactions\Processors\ETLPurchaseOrderTransactionProcessor; +use App\Classes\Modules\Transactions\Processors\ETLPDFPurchaseOrderTransactionProcessor; use App\Classes\Modules\Transactions\Services\GeneratesTransactionBillNumber; use App\Classes\Modules\Transactions\Standards\Rules\CanETLPurchaseOrder; use App\Classes\ValueObjects\Constants\ApprovalStatus; @@ -47,8 +47,8 @@ class ImportPurchaseOrderTransactionLogic extends AbstractControllerLogic /** @var CreatePurchaseOrderTransactionProcessor */ private $createPurchaseOrderTransactionProcessor; - /** @var ETLPurchaseOrderTransactionProcessor */ - private $etlPurchaseOrderTransactionProcessor; + /** @var ETLPDFPurchaseOrderTransactionProcessor */ + private $etlPDFPurchaseOrderTransactionProcessor; /** @var CanETLPurchaseOrder */ private $canETLPurchaseOrder; @@ -58,15 +58,15 @@ class ImportPurchaseOrderTransactionLogic extends AbstractControllerLogic * @param FetchesBooking $fetchesBooking * @param GeneratesTransactionBillNumber $generatesTransactionBillNumber * @param CreatePurchaseOrderTransactionProcessor $createPurchaseOrderTransactionProcessor - * @param ETLPurchaseOrderTransactionProcessor $etlPurchaseOrderTransactionProcessor + * @param ETLPDFPurchaseOrderTransactionProcessor $etlPDFPurchaseOrderTransactionProcessor * @param CanETLPurchaseOrder $canETLPurchaseOrder */ - public function __construct(FetchesBooking $fetchesBooking, GeneratesTransactionBillNumber $generatesTransactionBillNumber, CreatePurchaseOrderTransactionProcessor $createPurchaseOrderTransactionProcessor, ETLPurchaseOrderTransactionProcessor $etlPurchaseOrderTransactionProcessor, CanETLPurchaseOrder $canETLPurchaseOrder) + public function __construct(FetchesBooking $fetchesBooking, GeneratesTransactionBillNumber $generatesTransactionBillNumber, CreatePurchaseOrderTransactionProcessor $createPurchaseOrderTransactionProcessor, ETLPDFPurchaseOrderTransactionProcessor $etlPDFPurchaseOrderTransactionProcessor, CanETLPurchaseOrder $canETLPurchaseOrder) { $this->fetchesBooking = $fetchesBooking; $this->generatesTransactionBillNumber = $generatesTransactionBillNumber; $this->createPurchaseOrderTransactionProcessor = $createPurchaseOrderTransactionProcessor; - $this->etlPurchaseOrderTransactionProcessor = $etlPurchaseOrderTransactionProcessor; + $this->etlPDFPurchaseOrderTransactionProcessor = $etlPDFPurchaseOrderTransactionProcessor; $this->canETLPurchaseOrder = $canETLPurchaseOrder; } @@ -83,24 +83,13 @@ class ImportPurchaseOrderTransactionLogic extends AbstractControllerLogic $this->canETLPurchaseOrder->passes(); $products = $request->input('products'); - $productsMetadata = $request->input('productsMetadata'); - $count = collect($products) - ->filter(function ($product) { - return isset($product['description']) && - preg_match('/\p{Han}+/u', $product['description']); - }) - ->count(); - - if($count > 10){ - ETLPurchaseOrderTransactionV2CommandJob::dispatch($products, $productsMetadata, $request->route('id')); - $this->notificationMessage = "Please refresh page in a few minutes"; - return $this->response([]); + if(empty($products)){ + $files = $request->input('files'); + $products = $this->etlPDFPurchaseOrderTransactionProcessor->execute($files); } - else{ - $products = $this->etlPurchaseOrderTransactionProcessor->execute($products, $productsMetadata); - } - } else { + } + else { $object = new DocumentObject('', $request->input('files'), '', ApprovalStatus::APPROVED, 'imports'); foreach ($object->getFiles() as $file) { $collection = Excel::toCollection(null, json_decode($file)->file_info->original->file, null, null, true); diff --git a/app/Classes/Modules/Transactions/Processors/ETLPDFPurchaseOrderTransactionProcessor.php b/app/Classes/Modules/Transactions/Processors/ETLPDFPurchaseOrderTransactionProcessor.php new file mode 100644 index 00000000..db9b91f5 --- /dev/null +++ b/app/Classes/Modules/Transactions/Processors/ETLPDFPurchaseOrderTransactionProcessor.php @@ -0,0 +1,198 @@ +fetchesBooking = $fetchesBooking; + $this->generatesTransactionBillNumber = $generatesTransactionBillNumber; + $this->createPurchaseOrderTransactionProcessor = $createPurchaseOrderTransactionProcessor; + $this->uploadsOpenAIFiles = $uploadsOpenAIFiles; + $this->createsChatGPTResponseWithFiles = $createsChatGPTResponseWithFiles; + } + + + /** + * @param UploadedFile[] $files + * @param int $bookingId + * @return array|null + */ + public function execute(array $files, ?int $bookingId = null): ?array + { + // Ensure temp directory exists + $tempDir = storage_path('app/documents'); + if (!file_exists($tempDir)) { + mkdir($tempDir, 0755, true); + } + + $tempPaths = []; + + try { + foreach ($files as $index => $fileBase64) { + // Strip prefix if present + if (preg_match('/^data:application\/pdf;base64,/', $fileBase64)) { + $fileBase64 = preg_replace('/^data:application\/pdf;base64,/', '', $fileBase64); + } + + // Decode Base64 + $fileContent = base64_decode($fileBase64); + if ($fileContent === false) { + Log::warning("Invalid Base64 for file index $index"); + continue; + } + + // Ensure temp directory exists + $tempDir = storage_path('app/documents'); + if (!file_exists($tempDir)) { + mkdir($tempDir, 0755, true); + } + + // Generate unique temp file path + $fileName = uniqid("file_{$index}_") . '.pdf'; + $filePath = $tempDir . '/' . $fileName; + + // Save file to disk + file_put_contents($filePath, $fileContent); + + // Track temp path + $tempPaths[] = $filePath; + } + + Log::info('tempPaths: ' . json_encode($tempPaths)); + + // Upload to OpenAI + $fileIds = $this->uploadsOpenAIFiles->execute($tempPaths); + + $kvp = KeyValuePair::where( + 'key', + KVPKey::CHATGPT_PROMPT_PREFIX . 'EXTRACT_1' + )->first(); + + if (! $kvp) { + throw new ResourceNotFoundException('AI Setup incomplete!'); + } + + $userPrompt = $kvp->value; + $response = $this->createsChatGPTResponseWithFiles->execute($userPrompt, $fileIds); + + // Extract the text content from the first output message + $textContent = $response['output'][0]['content'][0]['text'] ?? '[]'; + + // Remove ```json fences if present + if (preg_match('/```json\s*(.*?)\s*```/s', $textContent, $matches)) { + $jsonString = $matches[1]; + } else { + $jsonString = $textContent; + } + + // Decode JSON + $data = json_decode($jsonString, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + Log::error('Failed to decode OpenAI JSON response', [ + 'error' => json_last_error_msg(), + 'textContent' => $textContent + ]); + $data = []; + } + + // Optional: convert keys to snake_case + $data = array_map(function($item) { + $newItem = []; + foreach ($item as $key => $value) { + $newKey = strtolower(str_replace(' ', '_', $key)); + $newItem[$newKey] = $value; + } + return $newItem; + }, $data); + + if($bookingId){ + /** @var Booking $booking */ + $booking = $this->fetchesBooking->execute(['id' => $bookingId]); + + $billNumber = $this->generatesTransactionBillNumber->execute('PO-'); + + $total = collect($data)->sum(function($product){ + return $product['quantity'] * floatval(str_replace(',', '', $product['unit_price'])); + }); + + $object = new TransactionObject($billNumber, TransactionType::PURCHASE_ORDER, $booking->company->id, 1, + 1, PaymentMethodType::CASH, + $total, $total, $booking->fix_currency_id, $booking->fix_currency_id, + 1, 0, 0, null, ApprovalStatus::PENDING_SUBMISSION, $data); + + + $transaction = $this->createPurchaseOrderTransactionProcessor->execute($booking, $object); + } + return $data; + + } catch (\Exception $e) { + Log::error('Error processing PDF files with ChatGPT', [ + 'message' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + throw $e; + } finally { + // Clean up + foreach ($tempPaths as $path) { + if (file_exists($path)) { + unlink($path); + } + } + } + } + + function convertKeysToSnakeCase(array $items): array { + return array_map(function($item) { + $newItem = []; + foreach ($item as $key => $value) { + $newKey = strtolower(str_replace(' ', '_', $key)); + $newItem[$newKey] = $value; + } + return $newItem; + }, $items); + } +} diff --git a/resources/assets/vue/components/bookings/forms/PdfUpload1688Component.vue b/resources/assets/vue/components/bookings/forms/PdfUpload1688Component.vue new file mode 100644 index 00000000..a0bbeab3 --- /dev/null +++ b/resources/assets/vue/components/bookings/forms/PdfUpload1688Component.vue @@ -0,0 +1,138 @@ + + + diff --git a/resources/assets/vue/components/bookings/forms/PurchaseOrderFormComponent.vue b/resources/assets/vue/components/bookings/forms/PurchaseOrderFormComponent.vue index b44eb6a8..91da5118 100644 --- a/resources/assets/vue/components/bookings/forms/PurchaseOrderFormComponent.vue +++ b/resources/assets/vue/components/bookings/forms/PurchaseOrderFormComponent.vue @@ -119,7 +119,8 @@
- + +