Some checks are pending
Scan for leaked secrets using Kingfisher / kingfisher-secrets-scan (push) Waiting to run
Gemini PR Review / Gemini PR Review (pull_request) Waiting to run
Scan for leaked secrets using Kingfisher / kingfisher-secrets-scan (pull_request) Waiting to run
Laravel Larastan / larastan (pull_request) Waiting to run
Laravel Pint / pint (pull_request) Waiting to run
483 lines
18 KiB
PHP
483 lines
18 KiB
PHP
<?php
|
|
|
|
namespace App\Livewire;
|
|
|
|
use App\Services\ChatbotService;
|
|
use App\Services\GeminiChatbotService;
|
|
use Livewire\Component;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
class ChatBot extends Component
|
|
{
|
|
// ── Panel state ───────────────────────────────────────────────────────────
|
|
public bool $isOpen = false;
|
|
|
|
/**
|
|
* 'select' → mode-picker screen shown first
|
|
* 'basic' → structured query UI (production or invoice report)
|
|
* 'advanced' → free-text natural-language UI (Gemini-powered)
|
|
*/
|
|
public string $mode = 'select';
|
|
|
|
// ── Basic mode — shared ───────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Which report the user picked inside basic mode.
|
|
* '' → report-type picker shown
|
|
* 'production' → production form
|
|
* 'invoice' → invoice type-lookup form
|
|
* 'invoice_status' → invoice scan-status form
|
|
*/
|
|
public string $reportType = '';
|
|
|
|
public array $plants = [];
|
|
|
|
// ── Basic mode — Production report ───────────────────────────────────────
|
|
public string $result = '';
|
|
public bool $hasResult = false;
|
|
|
|
public ?int $selectedPlantId = null;
|
|
public ?int $selectedLineId = null;
|
|
public string $dateFrom = '';
|
|
public string $dateTo = '';
|
|
public array $lines = [];
|
|
|
|
// ── Basic mode — Invoice report (type lookup) ─────────────────────────────
|
|
public ?int $invoicePlantId = null;
|
|
public string $invoiceItemCode = '';
|
|
public string $invoiceResult = '';
|
|
public bool $hasInvoiceResult = false;
|
|
|
|
// ── Basic mode — Invoice status (scan status) ─────────────────────────────
|
|
public string $invoiceNumber = '';
|
|
public string $invoiceStatusResult = ''; // kept for simple error strings
|
|
public bool $hasInvoiceStatusResult = false;
|
|
|
|
/**
|
|
* Structured result from ChatbotService::getInvoiceData().
|
|
* Shape: type, message, invoice_number, total, scanned, not_scanned, unscanned_serials[]
|
|
*/
|
|
public array $invoiceStatusData = [];
|
|
|
|
/** Controls whether all unscanned serials are shown (vs the first 10). */
|
|
public bool $showAllUnscanned = false;
|
|
|
|
// ── Advanced mode ─────────────────────────────────────────────────────────
|
|
public string $advancedQuestion = '';
|
|
public string $advancedResult = '';
|
|
public bool $hasAdvancedResult = false;
|
|
public bool $isAdvancedLoading = false;
|
|
|
|
/**
|
|
* Conversation history shown in advanced mode.
|
|
* Each entry: ['role' => 'user'|'assistant', 'content' => '…']
|
|
*
|
|
* The full history is passed to GeminiChatbotService on every turn so
|
|
* Gemini can resolve follow-up messages (e.g. answering a clarification
|
|
* question) in context.
|
|
*/
|
|
public array $chatHistory = [];
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
public function mount(): void
|
|
{
|
|
$this->plants = DB::table('plants')
|
|
->whereNull('deleted_at')
|
|
->orderBy('name')
|
|
->get(['id', 'name'])
|
|
->toArray();
|
|
|
|
$this->dateFrom = now()->startOfMonth()->format('Y-m-d');
|
|
$this->dateTo = now()->format('Y-m-d');
|
|
}
|
|
|
|
// ── Mode switching ────────────────────────────────────────────────────────
|
|
|
|
public function setMode(string $mode): void
|
|
{
|
|
$this->mode = $mode;
|
|
}
|
|
|
|
public function setReportType(string $type): void
|
|
{
|
|
$this->reportType = $type;
|
|
|
|
// Clear previous results when switching report type
|
|
$this->result = '';
|
|
$this->hasResult = false;
|
|
$this->invoiceResult = '';
|
|
$this->hasInvoiceResult = false;
|
|
$this->invoiceStatusResult = '';
|
|
$this->hasInvoiceStatusResult = false;
|
|
$this->invoiceStatusData = [];
|
|
$this->showAllUnscanned = false;
|
|
}
|
|
|
|
// ── Basic mode — Production helpers ──────────────────────────────────────
|
|
|
|
public function updatedSelectedPlantId(): void
|
|
{
|
|
$this->selectedLineId = null;
|
|
$this->lines = [];
|
|
$this->result = '';
|
|
$this->hasResult = false;
|
|
|
|
if ($this->selectedPlantId) {
|
|
$this->lines = DB::table('lines')
|
|
->whereNull('deleted_at')
|
|
->where('plant_id', $this->selectedPlantId)
|
|
->orderBy('name')
|
|
->get(['id', 'name'])
|
|
->toArray();
|
|
}
|
|
}
|
|
|
|
public function updatedSelectedLineId(): void
|
|
{
|
|
$this->result = '';
|
|
$this->hasResult = false;
|
|
}
|
|
|
|
public function fetchProduction(): void
|
|
{
|
|
if (! $this->selectedPlantId) {
|
|
$this->result = 'Please select a plant.';
|
|
$this->hasResult = true;
|
|
return;
|
|
}
|
|
|
|
$query = DB::table('production_quantities')
|
|
->whereNull('deleted_at')
|
|
->where('plant_id', $this->selectedPlantId)
|
|
->whereDate('created_at', '>=', $this->dateFrom)
|
|
->whereDate('created_at', '<=', $this->dateTo);
|
|
|
|
if ($this->selectedLineId) {
|
|
$query->where('line_id', $this->selectedLineId);
|
|
}
|
|
|
|
$count = $query->count();
|
|
|
|
$plantName = collect($this->plants)
|
|
->firstWhere('id', $this->selectedPlantId)?->name ?? 'Unknown Plant';
|
|
|
|
$lineName = $this->selectedLineId
|
|
? (collect($this->lines)->firstWhere('id', $this->selectedLineId)?->name ?? 'Unknown Line')
|
|
: 'All Lines';
|
|
|
|
$from = \Carbon\Carbon::parse($this->dateFrom)->format('d M Y');
|
|
$to = \Carbon\Carbon::parse($this->dateTo)->format('d M Y');
|
|
|
|
$this->result = "Production count for {$plantName} / {$lineName} from {$from} to {$to}: {$count} records.";
|
|
$this->hasResult = true;
|
|
}
|
|
|
|
// ── Basic mode — Invoice report (type lookup) ─────────────────────────────
|
|
|
|
public function updatedInvoicePlantId(): void
|
|
{
|
|
$this->invoiceResult = '';
|
|
$this->hasInvoiceResult = false;
|
|
}
|
|
|
|
public function updatedInvoiceItemCode(): void
|
|
{
|
|
$this->invoiceResult = '';
|
|
$this->hasInvoiceResult = false;
|
|
}
|
|
|
|
public function fetchInvoiceReport(): void
|
|
{
|
|
if (! $this->invoicePlantId) {
|
|
$this->invoiceResult = 'Please select a plant.';
|
|
$this->hasInvoiceResult = true;
|
|
return;
|
|
}
|
|
|
|
$itemCode = trim($this->invoiceItemCode);
|
|
|
|
if ($itemCode === '') {
|
|
$this->invoiceResult = 'Please enter an item code.';
|
|
$this->hasInvoiceResult = true;
|
|
return;
|
|
}
|
|
|
|
$plantName = collect($this->plants)
|
|
->firstWhere('id', $this->invoicePlantId)?->name ?? 'Unknown Plant';
|
|
|
|
try {
|
|
$rows = DB::select("
|
|
WITH plant_item AS (
|
|
SELECT ? AS user_plant,
|
|
? AS user_item_code
|
|
),
|
|
t1 AS (
|
|
SELECT
|
|
plants.id AS plant_id,
|
|
plants.name AS plant_name,
|
|
ARRAY_AGG(items.code) AS item_codes
|
|
FROM plants
|
|
LEFT JOIN items ON plants.id = items.plant_id
|
|
GROUP BY plants.id, plants.name
|
|
),
|
|
t2 AS (
|
|
SELECT
|
|
t1.plant_id,
|
|
t1.plant_name,
|
|
CASE
|
|
WHEN plant_item.user_item_code = ANY(t1.item_codes) THEN 1
|
|
ELSE 0
|
|
END AS exists_flag
|
|
FROM t1
|
|
CROSS JOIN plant_item
|
|
WHERE t1.plant_name = plant_item.user_plant
|
|
),
|
|
t3 AS (
|
|
SELECT t2.plant_id, t2.plant_name, t2.exists_flag,
|
|
plant_item.user_item_code
|
|
FROM t2
|
|
LEFT JOIN plant_item ON plant_item.user_plant = t2.plant_name
|
|
),
|
|
t4 AS (
|
|
SELECT items.id AS item_id,
|
|
t3.plant_id, t3.plant_name, t3.exists_flag, t3.user_item_code
|
|
FROM t3
|
|
LEFT JOIN items
|
|
ON t3.plant_id = items.plant_id
|
|
AND t3.user_item_code = items.code
|
|
)
|
|
SELECT
|
|
t4.item_id,
|
|
t4.plant_id,
|
|
t4.plant_name,
|
|
t4.exists_flag,
|
|
t4.user_item_code,
|
|
COALESCE(sticker_masters.material_type, 0) AS material_type,
|
|
CASE
|
|
WHEN sticker_masters.item_id IS NULL
|
|
THEN 'no match found'
|
|
WHEN COALESCE(sticker_masters.material_type, 0) = 0
|
|
THEN 'serial invoice'
|
|
ELSE 'material invoice'
|
|
END AS invoice_description
|
|
FROM t4
|
|
LEFT JOIN sticker_masters
|
|
ON sticker_masters.plant_id = t4.plant_id
|
|
AND sticker_masters.item_id = t4.item_id
|
|
", [$plantName, $itemCode]);
|
|
|
|
} catch (\Exception $e) {
|
|
Log::error('ChatBot: invoice report query failed', [
|
|
'plant' => $plantName,
|
|
'item_code' => $itemCode,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
|
|
$this->invoiceResult = "Sorry, I couldn't fetch data. Please try again or contact support.";
|
|
$this->hasInvoiceResult = true;
|
|
return;
|
|
}
|
|
|
|
if (empty($rows)) {
|
|
$this->invoiceResult = "No data found for plant \"{$plantName}\". Please verify the plant selection.";
|
|
$this->hasInvoiceResult = true;
|
|
return;
|
|
}
|
|
|
|
$row = $rows[0];
|
|
|
|
if ((int) $row->exists_flag === 0) {
|
|
$this->invoiceResult = 'Provided item code does not exist in the item table.';
|
|
} else {
|
|
switch ($row->invoice_description) {
|
|
case 'no match found':
|
|
$this->invoiceResult = "Item not found in sticker master for the plant {$row->plant_name}.";
|
|
break;
|
|
case 'serial invoice':
|
|
$this->invoiceResult = 'It is a serial invoice item.';
|
|
break;
|
|
case 'material invoice':
|
|
$this->invoiceResult = 'It is a material invoice item.';
|
|
break;
|
|
default:
|
|
$this->invoiceResult = 'Unexpected result. Please contact support.';
|
|
}
|
|
}
|
|
|
|
$this->hasInvoiceResult = true;
|
|
}
|
|
|
|
// ── Basic mode — Invoice status (scan status) ─────────────────────────────
|
|
|
|
public function updatedInvoiceNumber(): void
|
|
{
|
|
$this->invoiceStatusResult = '';
|
|
$this->hasInvoiceStatusResult = false;
|
|
$this->invoiceStatusData = [];
|
|
$this->showAllUnscanned = false;
|
|
}
|
|
|
|
/**
|
|
* Looks up how many serials within an invoice have been scanned / not scanned.
|
|
* Stores structured data in $invoiceStatusData so the blade can render
|
|
* a "show more" serial-number list without dumping 70+ serials in one blob.
|
|
*/
|
|
public function fetchInvoiceStatus(): void
|
|
{
|
|
$invoiceNumber = trim(preg_replace('/\s+/', '', $this->invoiceNumber));
|
|
|
|
if (empty($invoiceNumber)) {
|
|
$this->invoiceStatusResult = 'Please enter a valid invoice number.';
|
|
$this->invoiceStatusData = [];
|
|
$this->showAllUnscanned = false;
|
|
$this->hasInvoiceStatusResult = true;
|
|
return;
|
|
}
|
|
|
|
try {
|
|
/** @var \App\Services\ChatbotService $service */
|
|
$service = app(\App\Services\ChatbotService::class);
|
|
$data = $service->getInvoiceData($invoiceNumber);
|
|
} catch (\Throwable $e) {
|
|
Log::error('ChatBot: invoice status fetch failed', [
|
|
'invoice' => $invoiceNumber,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
$data = [
|
|
'type' => 'error',
|
|
'message' => "Sorry, I couldn't fetch data for invoice {$invoiceNumber}. "
|
|
. 'Please try again or contact support.',
|
|
'invoice_number' => $invoiceNumber,
|
|
'total' => 0,
|
|
'scanned' => 0,
|
|
'not_scanned' => 0,
|
|
'unscanned_serials' => [],
|
|
];
|
|
}
|
|
|
|
$this->invoiceStatusData = $data;
|
|
$this->invoiceStatusResult = $data['message']; // fallback plain-text copy
|
|
$this->showAllUnscanned = false;
|
|
$this->hasInvoiceStatusResult = true;
|
|
}
|
|
|
|
/**
|
|
* Toggles the "show all / show less" state for unscanned serial numbers
|
|
* in the Basic → Invoice Status result card.
|
|
*/
|
|
public function toggleShowAllUnscanned(): void
|
|
{
|
|
$this->showAllUnscanned = ! $this->showAllUnscanned;
|
|
}
|
|
|
|
// ── Advanced mode (Gemini-powered) ────────────────────────────────────────
|
|
|
|
/**
|
|
* Handles a free-text user message in advanced mode.
|
|
*
|
|
* Steps:
|
|
* 1. Appends the user message to chatHistory immediately (UI feedback).
|
|
* 2. Calls GeminiChatbotService with the full prior history for context.
|
|
* 3. Gemini classifies the intent, extracts params, and either:
|
|
* a) runs the appropriate DB query and returns the result, or
|
|
* b) returns a clarification question if intent is ambiguous.
|
|
* 4. Appends the assistant reply to chatHistory.
|
|
*/
|
|
public function askAdvanced(): void
|
|
{
|
|
$question = trim($this->advancedQuestion);
|
|
|
|
if (empty($question)) {
|
|
return;
|
|
}
|
|
|
|
// Show the user's message in the chat bubble immediately
|
|
$this->chatHistory[] = [
|
|
'role' => 'user',
|
|
'content' => $question,
|
|
];
|
|
|
|
$this->advancedQuestion = '';
|
|
$this->isAdvancedLoading = true;
|
|
|
|
try {
|
|
/** @var GeminiChatbotService $gemini */
|
|
$gemini = app(GeminiChatbotService::class);
|
|
|
|
// Pass history *without* the turn we just appended — the new user
|
|
// message is passed separately so Gemini sees it as the latest turn.
|
|
$priorHistory = array_slice($this->chatHistory, 0, -1);
|
|
$answer = $gemini->processMessage($priorHistory, $question);
|
|
|
|
} catch (\Throwable $e) {
|
|
Log::error('ChatBot: advanced ask failed', [
|
|
'error' => $e->getMessage(),
|
|
'trace' => $e->getTraceAsString(),
|
|
]);
|
|
$answer = 'Sorry, something went wrong. Please try again.';
|
|
}
|
|
|
|
$this->chatHistory[] = [
|
|
'role' => 'assistant',
|
|
'content' => $answer,
|
|
];
|
|
|
|
$this->isAdvancedLoading = false;
|
|
}
|
|
|
|
public function clearAdvancedChat(): void
|
|
{
|
|
$this->chatHistory = [];
|
|
$this->advancedQuestion = '';
|
|
$this->isAdvancedLoading = false;
|
|
}
|
|
|
|
// ── Panel controls ────────────────────────────────────────────────────────
|
|
|
|
public function toggleChat(): void
|
|
{
|
|
$this->isOpen = ! $this->isOpen;
|
|
}
|
|
|
|
public function resetForm(): void
|
|
{
|
|
// Basic mode — shared
|
|
$this->reportType = '';
|
|
|
|
// Basic mode — production
|
|
$this->selectedPlantId = null;
|
|
$this->selectedLineId = null;
|
|
$this->lines = [];
|
|
$this->result = '';
|
|
$this->hasResult = false;
|
|
$this->dateFrom = now()->startOfMonth()->format('Y-m-d');
|
|
$this->dateTo = now()->format('Y-m-d');
|
|
|
|
// Basic mode — invoice type lookup
|
|
$this->invoicePlantId = null;
|
|
$this->invoiceItemCode = '';
|
|
$this->invoiceResult = '';
|
|
$this->hasInvoiceResult = false;
|
|
|
|
// Basic mode — invoice scan status
|
|
$this->invoiceNumber = '';
|
|
$this->invoiceStatusResult = '';
|
|
$this->hasInvoiceStatusResult = false;
|
|
$this->invoiceStatusData = [];
|
|
$this->showAllUnscanned = false;
|
|
|
|
// Advanced mode
|
|
$this->clearAdvancedChat();
|
|
|
|
// Go back to mode selector
|
|
$this->mode = 'select';
|
|
}
|
|
|
|
public function render()
|
|
{
|
|
return view('livewire.chat-bot');
|
|
}
|
|
}
|