1688 PO Automation Project

This commit is contained in:
Dillon Ngo
2025-12-30 16:09:16 +08:00
parent 9f65e881e3
commit a037a7c9ad
7 changed files with 469 additions and 39 deletions
@@ -0,0 +1,52 @@
<?php
namespace App\Classes\Modules\OpenAI\Services;
use Illuminate\Support\Facades\Http;
use App\Classes\Exceptions\MalformedRequestException;
use App\Classes\Exceptions\ConnectionErrorException;
class CreatesChatGPTResponseWithFiles
{
public function execute(string $userPrompt, array $fileIds)
{
try {
$fileContents = [];
foreach ($fileIds as $fileId) {
$fileContents[] = [
'type' => '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());
}
}
}
@@ -0,0 +1,49 @@
<?php
namespace App\Classes\Modules\OpenAI\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use App\Classes\Exceptions\MalformedRequestException;
use App\Classes\Exceptions\ConnectionErrorException;
class UploadsOpenAIFiles
{
public function execute(array $paths): array
{
try {
$ids = [];
foreach ($paths as $path) {
$response = Http::withHeaders([
'Authorization' => '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());
}
}
}
@@ -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);
}
}
@@ -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);
@@ -0,0 +1,198 @@
<?php
namespace App\Classes\Modules\Transactions\Processors;
use App\Classes\Modules\OpenAI\Services\UploadsOpenAIFiles;
use App\Classes\Modules\OpenAI\Services\CreatesChatGPTResponseWithFiles;
use App\Classes\Exceptions\ResourceNotFoundException;
use App\Classes\Modules\Bookings\Services\FetchesBooking;
use App\Classes\Modules\Transactions\Services\GeneratesTransactionBillNumber;
use App\Classes\Modules\Transactions\Processors\CreatePurchaseOrderTransactionProcessor;
use App\Classes\Modules\Transactions\DataTransferObjects\TransactionObject;
use App\Classes\ValueObjects\Constants\KVPKey;
use App\Classes\ValueObjects\Constants\ApprovalStatus;
use App\Classes\ValueObjects\Constants\PaymentMethodType;
use App\Classes\ValueObjects\Constants\TransactionType;
use App\Models\KeyValuePair;
use App\Models\Booking;
use Illuminate\Support\Facades\Log;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
class ETLPDFPurchaseOrderTransactionProcessor
{
/** @var FetchesBooking */
private $fetchesBooking;
/** @var GeneratesTransactionBillNumber */
private $generatesTransactionBillNumber;
/** @var CreatePurchaseOrderTransactionProcessor */
private $createPurchaseOrderTransactionProcessor;
/** @var UploadsOpenAIFiles */
private $uploadsOpenAIFiles;
/** @var CreatesChatGPTResponseWithFiles */
private $createsChatGPTResponseWithFiles;
/**
* ETLPDFPurchaseOrderTransactionProcessor constructor.
* @param FetchesBooking $fetchesBooking
* @param GeneratesTransactionBillNumber $generatesTransactionBillNumber
* @param CreatePurchaseOrderTransactionProcessor $createPurchaseOrderTransactionProcessor
* @param UploadsOpenAIFiles $uploadsOpenAIFiles
* @param CreatesChatGPTResponseWithFiles $createsChatGPTResponseWithFiles
*/
public function __construct(FetchesBooking $fetchesBooking, GeneratesTransactionBillNumber $generatesTransactionBillNumber, CreatePurchaseOrderTransactionProcessor $createPurchaseOrderTransactionProcessor, UploadsOpenAIFiles $uploadsOpenAIFiles,
CreatesChatGPTResponseWithFiles $createsChatGPTResponseWithFiles)
{
$this->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);
}
}
@@ -0,0 +1,138 @@
<template>
<div class="row" @keyup.enter="submitForm">
<div class="col">
<loading-component style="height: 200px; top: 0;" key="1" color="success" v-show="$store.getters.isLoading(section)"></loading-component>
<div class="row" v-show="!$store.getters.isLoading(section)">
<div class="col">
<div class="row">
<div class="col">
<file-input-component :validator="$v.parameters.files" v-model="parameters.files">
<template slot="label">
<div class="font-heading fs-11 text-primary all-caps">Import Purchase Order PDF</div>
</template>
</file-input-component>
</div>
</div>
<div class="row m-t-20 justify-content-center" v-if="parameters.files.length > 0">
<div class="col-auto">
<button type="button"
class="btn btn-sm btn-outline-complete rounded-0 mx-1"
@click="exportToCSV"
:disabled="isExporting || isImporting">
{{ isExporting ? 'Exporting...' : 'Export to CSV' }}
</button>
</div>
<div class="col-auto">
<button type="button"
class="btn btn-sm btn-outline-complete rounded-0 mx-1"
@click="importToSystem"
:disabled="isExporting || isImporting">
{{ isImporting ? 'Uploading...' : 'Continue Upload' }}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { required } from "vuelidate/lib/validators";
import formHandler from '../../../general/mixins/formHandler';
export default {
props: {
section: {
type: String,
required: true
},
bookingId: {
type: Number,
required: true
}
},
data(){
return {
isExporting: false,
isImporting: false,
parameters: {
files: [],
products: []
}
}
},
validations: {
parameters: {
files: {
required
}
}
},
methods: {
exportToCSV(){
this.parameters.products = [];
if (this.isExporting) return;
this.isExporting = true;
this.submit(this.route('api.transaction.po.etl', this.bookingId), 'post', this.section + 'PDFExport', true, false)
},
importToSystem() {
if (this.isImporting) return;
this.isImporting = true;
this.submit(route('api.transaction.po.import', this.bookingId), 'post', this.section + 'PDFImport', true, true);
},
escapeCSV(value) {
if (value === null || value === undefined) return '';
const str = value.toString();
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
return `"${str.replace(/"/g, '""')}"`;
}
return str;
},
successHandler(response, section){
if(this.section + 'PDFImport' === section){
this.updateList()
this.isImporting = false;
}
else if(this.section + 'PDFExport' === section){
try {
const BOM = '\uFEFF';
let csvContent = "stock code,description,quantity,unit price\r\n";
response.payload.forEach(item => {
const formattedRow = [
item.stockCode,
this.escapeCSV(item.description),
item.quantity,
item.unit_price
].join(',');
csvContent += formattedRow + "\r\n";
});
const blob = new Blob([BOM + csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.setAttribute("download", "order_details.csv");
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
this.parameters.products = response.payload;
} catch (e) {
console.error("Export failed:", e);
alert("Export failed. Please try again.");
} finally {
this.isExporting = false;
}
}
},
errorHandler(response, statusCode, section){
this.isExporting = false;
this.isImporting = false;
}
},
mixins: [formHandler]
}
</script>
@@ -119,7 +119,8 @@
</div>
<div class="row" v-if="useUploadPdfPo">
<div class="col">
<pdf-table-extractor-component :booking-id="data.id" :section="section"></pdf-table-extractor-component>
<!-- <pdf-table-extractor-component :booking-id="data.id" :section="section"></pdf-table-extractor-component> -->
<pdf-upload-1688-component :booking-id="data.id" :section="section"></pdf-upload-1688-component>
</div>
</div>
</div>