1 line
2.9 MiB
Plaintext
1 line
2.9 MiB
Plaintext
{"repo": "ahmednour1234/bacco", "files": {".env.example": {"9a5ada4ab1ceb0de386bc1ce99c561f8869c09d7": {"t": 1785745165.6067455, "n": 2563, "h": "d7a9539a7e49cc60", "c": "APP_NAME=Laravel\nAPP_ENV=local\nAPP_KEY=\nAPP_DEBUG=true\nAPP_URL=http://localhost\n\nAPP_LOCALE=en\nAPP_FALLBACK_LOCALE=en\nAPP_FAKER_LOCALE=en_US\n\nAPP_MAINTENANCE_DRIVER=file\n# APP_MAINTENANCE_STORE=database\n\n# PHP_CLI_SERVER_WORKERS=4\n\nBCRYPT_ROUNDS=12\n\nLOG_CHANNEL=stack\nLOG_STACK=single\nLOG_DEPRECATIONS_CHANNEL=null\nLOG_LEVEL=debug\n\nDB_CONNECTION=sqlite\n# DB_HOST=127.0.0.1\n# DB_PORT=3306\n# DB_DATABASE=laravel\n# DB_USERNAME=root\n# DB_PASSWORD=\n\nSESSION_DRIVER=database\nSESSION_LIFETIME=120\nSESSION_ENCRYPT=false\nSESSION_PATH=/\nSESSION_DOMAIN=null\n\nBROADCAST_CONNECTION=log\nFILESYSTEM_DISK=local\nQUEUE_CONNECTION=database\n\nCACHE_STORE=database\n# CACHE_PREFIX=\n\nMEMCACHED_HOST=127.0.0.1\n\nREDIS_CLIENT=phpredis\nREDIS_HOST=127.0.0.1\nREDIS_PASSWORD=null\nREDIS_PORT=6379\n\nMAIL_MAILER=log\nMAIL_SCHEME=null\nMAIL_HOST=127.0.0.1\nMAIL_PORT=2525\nMAIL_USERNAME=null\nMAIL_PASSWORD=null\nMAIL_FROM_ADDRESS=\"hello@example.com\"\nMAIL_FROM_NAME=\"${APP_NAME}\"\n\nAWS_ACCESS_KEY_ID=\nAWS_SECRET_ACCESS_KEY=\nAWS_DEFAULT_REGION=us-east-1\nAWS_BUCKET=\nAWS_USE_PATH_STYLE_ENDPOINT=false\n\nAI_QUOTATION_BASE_URL=\nAI_QUOTATION_PARSE_ENDPOINT=parse\nAI_QUOTATION_API_KEY=\nAI_QUOTATION_TIMEOUT=120\n\nDEEPSEEK_API_KEY=\nDEEPSEEK_MODEL=deepseek-chat\nDEEPSEEK_VISION_MODEL=deepseek-vl2\n\n# Product Catalog Research module (DeepSeek client + research pipeline).\n# Never commit real secret values.\nDEEPSEEK_BASE_URL=https://api.deepseek.com\nDEEPSEEK_TIMEOUT=120\nDEEPSEEK_MAX_RETRIES=3\nDEEPSEEK_RATE_LIMIT=30\nCATALOG_RESEARCH_PROVIDER=deepseek\n# Leave as \"default\" so the standard queue worker processes research jobs.\nCATALOG_RESEARCH_QUEUE=default\nCATALOG_RESEARCH_BATCH_SIZE=5\nCATALOG_RESEARCH_MANUFACTURERS_BATCH=5\nCATALOG_RESEARCH_SERIES_BATCH=10\nCATALOG_RESEARCH_VARIANTS_BATCH=20\nCATALOG_RESEARCH_MAX_CONCURRENT_JOBS=3\nCATALOG_SOURCE_REFRESH_DAYS=180\n\n# Vision API — DeepSeek does NOT support images; use a vision-capable service.\n# Option 1 (recommended, free): Google Gemini — https://aistudio.google.com/apikey\nGEMINI_API_KEY=\nGEMINI_VISION_MODEL=gemini-2.0-flash\n\n# Option 2 (free): Groq — https://console.groq.com\nGROQ_API_KEY=\nGROQ_VISION_MODEL=meta-llama/llama-4-scout-17b-16e-instruct\n\n# Option 3: Generic OpenAI-compatible endpoint (OpenAI, OpenRouter, etc.)\nVISION_API_KEY=\nVISION_BASE_URL=https://openrouter.ai/api/v1\nVISION_MODEL=google/gemini-flash-1.5-8b\n\nVITE_APP_NAME=\"${APP_NAME}\"\n\n# Reading PDF BOQs. Without a key the app falls back to OCR.space's shared\n# demo key, which is rate-limited and will fail under real use.\n# Free key: https://ocr.space/ocrapi/freekey\nOCRSPACE_API_KEY=\n"}, "7d1f333677c84a7f32e280efa33dbaee165f8efe": {"t": 1785748347.919626, "n": 2563, "h": "d7a9539a7e49cc60", "c": "APP_NAME=Laravel\nAPP_ENV=local\nAPP_KEY=\nAPP_DEBUG=true\nAPP_URL=http://localhost\n\nAPP_LOCALE=en\nAPP_FALLBACK_LOCALE=en\nAPP_FAKER_LOCALE=en_US\n\nAPP_MAINTENANCE_DRIVER=file\n# APP_MAINTENANCE_STORE=database\n\n# PHP_CLI_SERVER_WORKERS=4\n\nBCRYPT_ROUNDS=12\n\nLOG_CHANNEL=stack\nLOG_STACK=single\nLOG_DEPRECATIONS_CHANNEL=null\nLOG_LEVEL=debug\n\nDB_CONNECTION=sqlite\n# DB_HOST=127.0.0.1\n# DB_PORT=3306\n# DB_DATABASE=laravel\n# DB_USERNAME=root\n# DB_PASSWORD=\n\nSESSION_DRIVER=database\nSESSION_LIFETIME=120\nSESSION_ENCRYPT=false\nSESSION_PATH=/\nSESSION_DOMAIN=null\n\nBROADCAST_CONNECTION=log\nFILESYSTEM_DISK=local\nQUEUE_CONNECTION=database\n\nCACHE_STORE=database\n# CACHE_PREFIX=\n\nMEMCACHED_HOST=127.0.0.1\n\nREDIS_CLIENT=phpredis\nREDIS_HOST=127.0.0.1\nREDIS_PASSWORD=null\nREDIS_PORT=6379\n\nMAIL_MAILER=log\nMAIL_SCHEME=null\nMAIL_HOST=127.0.0.1\nMAIL_PORT=2525\nMAIL_USERNAME=null\nMAIL_PASSWORD=null\nMAIL_FROM_ADDRESS=\"hello@example.com\"\nMAIL_FROM_NAME=\"${APP_NAME}\"\n\nAWS_ACCESS_KEY_ID=\nAWS_SECRET_ACCESS_KEY=\nAWS_DEFAULT_REGION=us-east-1\nAWS_BUCKET=\nAWS_USE_PATH_STYLE_ENDPOINT=false\n\nAI_QUOTATION_BASE_URL=\nAI_QUOTATION_PARSE_ENDPOINT=parse\nAI_QUOTATION_API_KEY=\nAI_QUOTATION_TIMEOUT=120\n\nDEEPSEEK_API_KEY=\nDEEPSEEK_MODEL=deepseek-chat\nDEEPSEEK_VISION_MODEL=deepseek-vl2\n\n# Product Catalog Research module (DeepSeek client + research pipeline).\n# Never commit real secret values.\nDEEPSEEK_BASE_URL=https://api.deepseek.com\nDEEPSEEK_TIMEOUT=120\nDEEPSEEK_MAX_RETRIES=3\nDEEPSEEK_RATE_LIMIT=30\nCATALOG_RESEARCH_PROVIDER=deepseek\n# Leave as \"default\" so the standard queue worker processes research jobs.\nCATALOG_RESEARCH_QUEUE=default\nCATALOG_RESEARCH_BATCH_SIZE=5\nCATALOG_RESEARCH_MANUFACTURERS_BATCH=5\nCATALOG_RESEARCH_SERIES_BATCH=10\nCATALOG_RESEARCH_VARIANTS_BATCH=20\nCATALOG_RESEARCH_MAX_CONCURRENT_JOBS=3\nCATALOG_SOURCE_REFRESH_DAYS=180\n\n# Vision API — DeepSeek does NOT support images; use a vision-capable service.\n# Option 1 (recommended, free): Google Gemini — https://aistudio.google.com/apikey\nGEMINI_API_KEY=\nGEMINI_VISION_MODEL=gemini-2.0-flash\n\n# Option 2 (free): Groq — https://console.groq.com\nGROQ_API_KEY=\nGROQ_VISION_MODEL=meta-llama/llama-4-scout-17b-16e-instruct\n\n# Option 3: Generic OpenAI-compatible endpoint (OpenAI, OpenRouter, etc.)\nVISION_API_KEY=\nVISION_BASE_URL=https://openrouter.ai/api/v1\nVISION_MODEL=google/gemini-flash-1.5-8b\n\nVITE_APP_NAME=\"${APP_NAME}\"\n\n# Reading PDF BOQs. Without a key the app falls back to OCR.space's shared\n# demo key, which is rate-limited and will fail under real use.\n# Free key: https://ocr.space/ocrapi/freekey\nOCRSPACE_API_KEY=\n"}, "380bc546449c8da0516a2cd357f65f6e6421dd90": {"t": 1785748349.023084, "n": 2497, "h": "66fd371031beb07d", "c": "APP_NAME=Laravel\nAPP_ENV=local\nAPP_KEY=\nAPP_DEBUG=true\nAPP_URL=http://localhost\n\nAPP_LOCALE=en\nAPP_FALLBACK_LOCALE=en\nAPP_FAKER_LOCALE=en_US\n\nAPP_MAINTENANCE_DRIVER=file\n# APP_MAINTENANCE_STORE=database\n\n# PHP_CLI_SERVER_WORKERS=4\n\nBCRYPT_ROUNDS=12\n\nLOG_CHANNEL=stack\nLOG_STACK=single\nLOG_DEPRECATIONS_CHANNEL=null\nLOG_LEVEL=debug\n\nDB_CONNECTION=sqlite\n# DB_HOST=127.0.0.1\n# DB_PORT=3306\n# DB_DATABASE=laravel\n# DB_USERNAME=root\n# DB_PASSWORD=\n\nSESSION_DRIVER=database\nSESSION_LIFETIME=120\nSESSION_ENCRYPT=false\nSESSION_PATH=/\nSESSION_DOMAIN=null\n\nBROADCAST_CONNECTION=log\nFILESYSTEM_DISK=local\nQUEUE_CONNECTION=database\n\nCACHE_STORE=database\n# CACHE_PREFIX=\n\nMEMCACHED_HOST=127.0.0.1\n\nREDIS_CLIENT=phpredis\nREDIS_HOST=127.0.0.1\nREDIS_PASSWORD=null\nREDIS_PORT=6379\n\nMAIL_MAILER=log\nMAIL_SCHEME=null\nMAIL_HOST=127.0.0.1\nMAIL_PORT=2525\nMAIL_USERNAME=null\nMAIL_PASSWORD=null\nMAIL_FROM_ADDRESS=\"hello@example.com\"\nMAIL_FROM_NAME=\"${APP_NAME}\"\n\nAWS_ACCESS_KEY_ID=\nAWS_SECRET_ACCESS_KEY=\nAWS_DEFAULT_REGION=us-east-1\nAWS_BUCKET=\nAWS_USE_PATH_STYLE_ENDPOINT=false\n\nAI_QUOTATION_BASE_URL=\nAI_QUOTATION_PARSE_ENDPOINT=parse\nAI_QUOTATION_API_KEY=\nAI_QUOTATION_TIMEOUT=120\n\nDEEPSEEK_API_KEY=\nDEEPSEEK_MODEL=deepseek-chat\nDEEPSEEK_VISION_MODEL=deepseek-vl2\n\n# Product Catalog Research module (DeepSeek client + research pipeline).\n# Never commit real secret values.\nDEEPSEEK_BASE_URL=https://api.deepseek.com\nDEEPSEEK_TIMEOUT=120\nDEEPSEEK_MAX_RETRIES=3\nDEEPSEEK_RATE_LIMIT=30\nCATALOG_RESEARCH_PROVIDER=deepseek\nCATALOG_RESEARCH_QUEUE=catalog-research\nCATALOG_RESEARCH_BATCH_SIZE=5\nCATALOG_RESEARCH_MANUFACTURERS_BATCH=5\nCATALOG_RESEARCH_SERIES_BATCH=10\nCATALOG_RESEARCH_VARIANTS_BATCH=20\nCATALOG_RESEARCH_MAX_CONCURRENT_JOBS=3\nCATALOG_SOURCE_REFRESH_DAYS=180\n\n# Vision API — DeepSeek does NOT support images; use a vision-capable service.\n# Option 1 (recommended, free): Google Gemini — https://aistudio.google.com/apikey\nGEMINI_API_KEY=\nGEMINI_VISION_MODEL=gemini-2.0-flash\n\n# Option 2 (free): Groq — https://console.groq.com\nGROQ_API_KEY=\nGROQ_VISION_MODEL=meta-llama/llama-4-scout-17b-16e-instruct\n\n# Option 3: Generic OpenAI-compatible endpoint (OpenAI, OpenRouter, etc.)\nVISION_API_KEY=\nVISION_BASE_URL=https://openrouter.ai/api/v1\nVISION_MODEL=google/gemini-flash-1.5-8b\n\nVITE_APP_NAME=\"${APP_NAME}\"\n\n# Reading PDF BOQs. Without a key the app falls back to OCR.space's shared\n# demo key, which is rate-limited and will fail under real use.\n# Free key: https://ocr.space/ocrapi/freekey\nOCRSPACE_API_KEY=\n"}, "89bda6cd3370f6bba078a12282b744e0ca8f1bd7": {"t": 1785748350.0862277, "n": 1995, "h": "404d602a591e64e9", "c": "APP_NAME=Laravel\nAPP_ENV=local\nAPP_KEY=\nAPP_DEBUG=true\nAPP_URL=http://localhost\n\nAPP_LOCALE=en\nAPP_FALLBACK_LOCALE=en\nAPP_FAKER_LOCALE=en_US\n\nAPP_MAINTENANCE_DRIVER=file\n# APP_MAINTENANCE_STORE=database\n\n# PHP_CLI_SERVER_WORKERS=4\n\nBCRYPT_ROUNDS=12\n\nLOG_CHANNEL=stack\nLOG_STACK=single\nLOG_DEPRECATIONS_CHANNEL=null\nLOG_LEVEL=debug\n\nDB_CONNECTION=sqlite\n# DB_HOST=127.0.0.1\n# DB_PORT=3306\n# DB_DATABASE=laravel\n# DB_USERNAME=root\n# DB_PASSWORD=\n\nSESSION_DRIVER=database\nSESSION_LIFETIME=120\nSESSION_ENCRYPT=false\nSESSION_PATH=/\nSESSION_DOMAIN=null\n\nBROADCAST_CONNECTION=log\nFILESYSTEM_DISK=local\nQUEUE_CONNECTION=database\n\nCACHE_STORE=database\n# CACHE_PREFIX=\n\nMEMCACHED_HOST=127.0.0.1\n\nREDIS_CLIENT=phpredis\nREDIS_HOST=127.0.0.1\nREDIS_PASSWORD=null\nREDIS_PORT=6379\n\nMAIL_MAILER=log\nMAIL_SCHEME=null\nMAIL_HOST=127.0.0.1\nMAIL_PORT=2525\nMAIL_USERNAME=null\nMAIL_PASSWORD=null\nMAIL_FROM_ADDRESS=\"hello@example.com\"\nMAIL_FROM_NAME=\"${APP_NAME}\"\n\nAWS_ACCESS_KEY_ID=\nAWS_SECRET_ACCESS_KEY=\nAWS_DEFAULT_REGION=us-east-1\nAWS_BUCKET=\nAWS_USE_PATH_STYLE_ENDPOINT=false\n\nAI_QUOTATION_BASE_URL=\nAI_QUOTATION_PARSE_ENDPOINT=parse\nAI_QUOTATION_API_KEY=\nAI_QUOTATION_TIMEOUT=120\n\nDEEPSEEK_API_KEY=\nDEEPSEEK_MODEL=deepseek-chat\nDEEPSEEK_VISION_MODEL=deepseek-vl2\n\n# Vision API — DeepSeek does NOT support images; use a vision-capable service.\n# Option 1 (recommended, free): Google Gemini — https://aistudio.google.com/apikey\nGEMINI_API_KEY=\nGEMINI_VISION_MODEL=gemini-2.0-flash\n\n# Option 2 (free): Groq — https://console.groq.com\nGROQ_API_KEY=\nGROQ_VISION_MODEL=meta-llama/llama-4-scout-17b-16e-instruct\n\n# Option 3: Generic OpenAI-compatible endpoint (OpenAI, OpenRouter, etc.)\nVISION_API_KEY=\nVISION_BASE_URL=https://openrouter.ai/api/v1\nVISION_MODEL=google/gemini-flash-1.5-8b\n\nVITE_APP_NAME=\"${APP_NAME}\"\n\n# Reading PDF BOQs. Without a key the app falls back to OCR.space's shared\n# demo key, which is rate-limited and will fail under real use.\n# Free key: https://ocr.space/ocrapi/freekey\nOCRSPACE_API_KEY=\n"}, "f01410ba86b70436dbde9e233e6611d10545c13c": {"t": 1785748352.2040315, "n": 1792, "h": "f0bad8792e745b8f", "c": "APP_NAME=Laravel\nAPP_ENV=local\nAPP_KEY=\nAPP_DEBUG=true\nAPP_URL=http://localhost\n\nAPP_LOCALE=en\nAPP_FALLBACK_LOCALE=en\nAPP_FAKER_LOCALE=en_US\n\nAPP_MAINTENANCE_DRIVER=file\n# APP_MAINTENANCE_STORE=database\n\n# PHP_CLI_SERVER_WORKERS=4\n\nBCRYPT_ROUNDS=12\n\nLOG_CHANNEL=stack\nLOG_STACK=single\nLOG_DEPRECATIONS_CHANNEL=null\nLOG_LEVEL=debug\n\nDB_CONNECTION=sqlite\n# DB_HOST=127.0.0.1\n# DB_PORT=3306\n# DB_DATABASE=laravel\n# DB_USERNAME=root\n# DB_PASSWORD=\n\nSESSION_DRIVER=database\nSESSION_LIFETIME=120\nSESSION_ENCRYPT=false\nSESSION_PATH=/\nSESSION_DOMAIN=null\n\nBROADCAST_CONNECTION=log\nFILESYSTEM_DISK=local\nQUEUE_CONNECTION=database\n\nCACHE_STORE=database\n# CACHE_PREFIX=\n\nMEMCACHED_HOST=127.0.0.1\n\nREDIS_CLIENT=phpredis\nREDIS_HOST=127.0.0.1\nREDIS_PASSWORD=null\nREDIS_PORT=6379\n\nMAIL_MAILER=log\nMAIL_SCHEME=null\nMAIL_HOST=127.0.0.1\nMAIL_PORT=2525\nMAIL_USERNAME=null\nMAIL_PASSWORD=null\nMAIL_FROM_ADDRESS=\"hello@example.com\"\nMAIL_FROM_NAME=\"${APP_NAME}\"\n\nAWS_ACCESS_KEY_ID=\nAWS_SECRET_ACCESS_KEY=\nAWS_DEFAULT_REGION=us-east-1\nAWS_BUCKET=\nAWS_USE_PATH_STYLE_ENDPOINT=false\n\nAI_QUOTATION_BASE_URL=\nAI_QUOTATION_PARSE_ENDPOINT=parse\nAI_QUOTATION_API_KEY=\nAI_QUOTATION_TIMEOUT=120\n\nDEEPSEEK_API_KEY=\nDEEPSEEK_MODEL=deepseek-chat\nDEEPSEEK_VISION_MODEL=deepseek-vl2\n\n# Vision API — DeepSeek does NOT support images; use a vision-capable service.\n# Option 1 (recommended, free): Google Gemini — https://aistudio.google.com/apikey\nGEMINI_API_KEY=\nGEMINI_VISION_MODEL=gemini-2.0-flash\n\n# Option 2 (free): Groq — https://console.groq.com\nGROQ_API_KEY=\nGROQ_VISION_MODEL=meta-llama/llama-4-scout-17b-16e-instruct\n\n# Option 3: Generic OpenAI-compatible endpoint (OpenAI, OpenRouter, etc.)\nVISION_API_KEY=\nVISION_BASE_URL=https://openrouter.ai/api/v1\nVISION_MODEL=google/gemini-flash-1.5-8b\n\nVITE_APP_NAME=\"${APP_NAME}\"\n"}, "769498c3a1c058b48ccadcb2840e8474a079cab2": {"t": 1785748353.2742786, "n": 1757, "h": "156c833b8b94afb4", "c": "APP_NAME=Laravel\nAPP_ENV=local\nAPP_KEY=\nAPP_DEBUG=true\nAPP_URL=http://localhost\n\nAPP_LOCALE=en\nAPP_FALLBACK_LOCALE=en\nAPP_FAKER_LOCALE=en_US\n\nAPP_MAINTENANCE_DRIVER=file\n# APP_MAINTENANCE_STORE=database\n\n# PHP_CLI_SERVER_WORKERS=4\n\nBCRYPT_ROUNDS=12\n\nLOG_CHANNEL=stack\nLOG_STACK=single\nLOG_DEPRECATIONS_CHANNEL=null\nLOG_LEVEL=debug\n\nDB_CONNECTION=sqlite\n# DB_HOST=127.0.0.1\n# DB_PORT=3306\n# DB_DATABASE=laravel\n# DB_USERNAME=root\n# DB_PASSWORD=\n\nSESSION_DRIVER=database\nSESSION_LIFETIME=120\nSESSION_ENCRYPT=false\nSESSION_PATH=/\nSESSION_DOMAIN=null\n\nBROADCAST_CONNECTION=log\nFILESYSTEM_DISK=local\nQUEUE_CONNECTION=database\n\nCACHE_STORE=database\n# CACHE_PREFIX=\n\nMEMCACHED_HOST=127.0.0.1\n\nREDIS_CLIENT=phpredis\nREDIS_HOST=127.0.0.1\nREDIS_PASSWORD=null\nREDIS_PORT=6379\n\nMAIL_MAILER=log\nMAIL_SCHEME=null\nMAIL_HOST=127.0.0.1\nMAIL_PORT=2525\nMAIL_USERNAME=null\nMAIL_PASSWORD=null\nMAIL_FROM_ADDRESS=\"hello@example.com\"\nMAIL_FROM_NAME=\"${APP_NAME}\"\n\nAWS_ACCESS_KEY_ID=\nAWS_SECRET_ACCESS_KEY=\nAWS_DEFAULT_REGION=us-east-1\nAWS_BUCKET=\nAWS_USE_PATH_STYLE_ENDPOINT=false\n\nAI_QUOTATION_BASE_URL=\nAI_QUOTATION_PARSE_ENDPOINT=parse\nAI_QUOTATION_API_KEY=\nAI_QUOTATION_TIMEOUT=120\n\nDEEPSEEK_API_KEY=\nDEEPSEEK_MODEL=deepseek-chat\n\n# Vision API — DeepSeek does NOT support images; use a vision-capable service.\n# Option 1 (recommended, free): Google Gemini — https://aistudio.google.com/apikey\nGEMINI_API_KEY=\nGEMINI_VISION_MODEL=gemini-2.0-flash\n\n# Option 2 (free): Groq — https://console.groq.com\nGROQ_API_KEY=\nGROQ_VISION_MODEL=meta-llama/llama-4-scout-17b-16e-instruct\n\n# Option 3: Generic OpenAI-compatible endpoint (OpenAI, OpenRouter, etc.)\nVISION_API_KEY=\nVISION_BASE_URL=https://openrouter.ai/api/v1\nVISION_MODEL=google/gemini-flash-1.5-8b\n\nVITE_APP_NAME=\"${APP_NAME}\"\n"}, "c9cfc223dc923400b78320c53bc3915186a077bd": {"t": 1785748354.3366458, "n": 1676, "h": "00e9d77c3bc9fed0", "c": "APP_NAME=Laravel\nAPP_ENV=local\nAPP_KEY=\nAPP_DEBUG=true\nAPP_URL=http://localhost\n\nAPP_LOCALE=en\nAPP_FALLBACK_LOCALE=en\nAPP_FAKER_LOCALE=en_US\n\nAPP_MAINTENANCE_DRIVER=file\n# APP_MAINTENANCE_STORE=database\n\n# PHP_CLI_SERVER_WORKERS=4\n\nBCRYPT_ROUNDS=12\n\nLOG_CHANNEL=stack\nLOG_STACK=single\nLOG_DEPRECATIONS_CHANNEL=null\nLOG_LEVEL=debug\n\nDB_CONNECTION=sqlite\n# DB_HOST=127.0.0.1\n# DB_PORT=3306\n# DB_DATABASE=laravel\n# DB_USERNAME=root\n# DB_PASSWORD=\n\nSESSION_DRIVER=database\nSESSION_LIFETIME=120\nSESSION_ENCRYPT=false\nSESSION_PATH=/\nSESSION_DOMAIN=null\n\nBROADCAST_CONNECTION=log\nFILESYSTEM_DISK=local\nQUEUE_CONNECTION=database\n\nCACHE_STORE=database\n# CACHE_PREFIX=\n\nMEMCACHED_HOST=127.0.0.1\n\nREDIS_CLIENT=phpredis\nREDIS_HOST=127.0.0.1\nREDIS_PASSWORD=null\nREDIS_PORT=6379\n\nMAIL_MAILER=log\nMAIL_SCHEME=null\nMAIL_HOST=127.0.0.1\nMAIL_PORT=2525\nMAIL_USERNAME=null\nMAIL_PASSWORD=null\nMAIL_FROM_ADDRESS=\"hello@example.com\"\nMAIL_FROM_NAME=\"${APP_NAME}\"\n\nAWS_ACCESS_KEY_ID=\nAWS_SECRET_ACCESS_KEY=\nAWS_DEFAULT_REGION=us-east-1\nAWS_BUCKET=\nAWS_USE_PATH_STYLE_ENDPOINT=false\n\nAI_QUOTATION_BASE_URL=\nAI_QUOTATION_PARSE_ENDPOINT=parse\nAI_QUOTATION_API_KEY=\nAI_QUOTATION_TIMEOUT=120\n\nDEEPSEEK_API_KEY=\nDEEPSEEK_MODEL=deepseek-chat\n\n# Vision API — DeepSeek does NOT support images; use any OpenAI-compatible vision service.\n# Free: OpenRouter (https://openrouter.ai) — set VISION_BASE_URL=https://openrouter.ai/api/v1\n# and VISION_MODEL=google/gemini-flash-1.5-8b (free tier)\n# OpenAI: set VISION_BASE_URL=https://api.openai.com/v1 and VISION_MODEL=gpt-4o-mini\nVISION_API_KEY=\nVISION_BASE_URL=https://openrouter.ai/api/v1\nVISION_MODEL=google/gemini-flash-1.5-8b\n\nVITE_APP_NAME=\"${APP_NAME}\"\n"}, "b65ff6622e72f65525fe55404e124bc72c97539d": {"t": 1785748355.3790066, "n": 1275, "h": "3e3bdc7bf4a03912", "c": "APP_NAME=Laravel\nAPP_ENV=local\nAPP_KEY=\nAPP_DEBUG=true\nAPP_URL=http://localhost\n\nAPP_LOCALE=en\nAPP_FALLBACK_LOCALE=en\nAPP_FAKER_LOCALE=en_US\n\nAPP_MAINTENANCE_DRIVER=file\n# APP_MAINTENANCE_STORE=database\n\n# PHP_CLI_SERVER_WORKERS=4\n\nBCRYPT_ROUNDS=12\n\nLOG_CHANNEL=stack\nLOG_STACK=single\nLOG_DEPRECATIONS_CHANNEL=null\nLOG_LEVEL=debug\n\nDB_CONNECTION=sqlite\n# DB_HOST=127.0.0.1\n# DB_PORT=3306\n# DB_DATABASE=laravel\n# DB_USERNAME=root\n# DB_PASSWORD=\n\nSESSION_DRIVER=database\nSESSION_LIFETIME=120\nSESSION_ENCRYPT=false\nSESSION_PATH=/\nSESSION_DOMAIN=null\n\nBROADCAST_CONNECTION=log\nFILESYSTEM_DISK=local\nQUEUE_CONNECTION=database\n\nCACHE_STORE=database\n# CACHE_PREFIX=\n\nMEMCACHED_HOST=127.0.0.1\n\nREDIS_CLIENT=phpredis\nREDIS_HOST=127.0.0.1\nREDIS_PASSWORD=null\nREDIS_PORT=6379\n\nMAIL_MAILER=log\nMAIL_SCHEME=null\nMAIL_HOST=127.0.0.1\nMAIL_PORT=2525\nMAIL_USERNAME=null\nMAIL_PASSWORD=null\nMAIL_FROM_ADDRESS=\"hello@example.com\"\nMAIL_FROM_NAME=\"${APP_NAME}\"\n\nAWS_ACCESS_KEY_ID=\nAWS_SECRET_ACCESS_KEY=\nAWS_DEFAULT_REGION=us-east-1\nAWS_BUCKET=\nAWS_USE_PATH_STYLE_ENDPOINT=false\n\nAI_QUOTATION_BASE_URL=\nAI_QUOTATION_PARSE_ENDPOINT=parse\nAI_QUOTATION_API_KEY=\nAI_QUOTATION_TIMEOUT=120\n\nDEEPSEEK_API_KEY=\nDEEPSEEK_MODEL=deepseek-chat\nDEEPSEEK_VISION_MODEL=deepseek-chat\n\nVITE_APP_NAME=\"${APP_NAME}\"\n"}, "a4318f8cc2271b05ba9016c1010c1b8f31161281": {"t": 1785748356.5027087, "n": 1191, "h": "61ca4e2987a765e3", "c": "APP_NAME=Laravel\nAPP_ENV=local\nAPP_KEY=\nAPP_DEBUG=true\nAPP_URL=http://localhost\n\nAPP_LOCALE=en\nAPP_FALLBACK_LOCALE=en\nAPP_FAKER_LOCALE=en_US\n\nAPP_MAINTENANCE_DRIVER=file\n# APP_MAINTENANCE_STORE=database\n\n# PHP_CLI_SERVER_WORKERS=4\n\nBCRYPT_ROUNDS=12\n\nLOG_CHANNEL=stack\nLOG_STACK=single\nLOG_DEPRECATIONS_CHANNEL=null\nLOG_LEVEL=debug\n\nDB_CONNECTION=sqlite\n# DB_HOST=127.0.0.1\n# DB_PORT=3306\n# DB_DATABASE=laravel\n# DB_USERNAME=root\n# DB_PASSWORD=\n\nSESSION_DRIVER=database\nSESSION_LIFETIME=120\nSESSION_ENCRYPT=false\nSESSION_PATH=/\nSESSION_DOMAIN=null\n\nBROADCAST_CONNECTION=log\nFILESYSTEM_DISK=local\nQUEUE_CONNECTION=database\n\nCACHE_STORE=database\n# CACHE_PREFIX=\n\nMEMCACHED_HOST=127.0.0.1\n\nREDIS_CLIENT=phpredis\nREDIS_HOST=127.0.0.1\nREDIS_PASSWORD=null\nREDIS_PORT=6379\n\nMAIL_MAILER=log\nMAIL_SCHEME=null\nMAIL_HOST=127.0.0.1\nMAIL_PORT=2525\nMAIL_USERNAME=null\nMAIL_PASSWORD=null\nMAIL_FROM_ADDRESS=\"hello@example.com\"\nMAIL_FROM_NAME=\"${APP_NAME}\"\n\nAWS_ACCESS_KEY_ID=\nAWS_SECRET_ACCESS_KEY=\nAWS_DEFAULT_REGION=us-east-1\nAWS_BUCKET=\nAWS_USE_PATH_STYLE_ENDPOINT=false\n\nAI_QUOTATION_BASE_URL=\nAI_QUOTATION_PARSE_ENDPOINT=parse\nAI_QUOTATION_API_KEY=\nAI_QUOTATION_TIMEOUT=120\n\nVITE_APP_NAME=\"${APP_NAME}\"\n"}}, "config/services.php": {"9a5ada4ab1ceb0de386bc1ce99c561f8869c09d7": {"t": 1785745761.2134774, "n": 2841, "h": "2a2d60233a0431c9", "c": "<?php\n\nreturn [\n\n /*\n |--------------------------------------------------------------------------\n | Third Party Services\n |--------------------------------------------------------------------------\n |\n | This file is for storing the credentials for third party services such\n | as Mailgun, Postmark, AWS and more. This file provides the de facto\n | location for this type of information, allowing packages to have\n | a conventional file to locate the various service credentials.\n |\n */\n\n 'postmark' => [\n 'key' => env('POSTMARK_API_KEY'),\n ],\n\n 'resend' => [\n 'key' => env('RESEND_API_KEY'),\n ],\n\n 'ses' => [\n 'key' => env('AWS_ACCESS_KEY_ID'),\n 'secret' => env('AWS_SECRET_ACCESS_KEY'),\n 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),\n ],\n\n 'slack' => [\n 'notifications' => [\n 'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),\n 'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),\n ],\n ],\n\n 'ai_quotation' => [\n 'base_url' => env('AI_QUOTATION_BASE_URL', ''),\n 'parse_endpoint' => env('AI_QUOTATION_PARSE_ENDPOINT', 'parse'),\n 'api_key' => env('AI_QUOTATION_API_KEY', ''),\n 'timeout' => env('AI_QUOTATION_TIMEOUT', 30),\n 'test_mode' => env('AI_QUOTATION_TEST_MODE', false),\n ],\n\n 'deepseek' => [\n 'key' => env('DEEPSEEK_API_KEY'),\n 'model' => env('DEEPSEEK_MODEL', 'deepseek-chat'),\n 'vision_model' => env('DEEPSEEK_VISION_MODEL', 'deepseek-vl2'),\n // Catalog research client settings (see config/catalog_research.php too).\n 'base_url' => env('DEEPSEEK_BASE_URL', 'https://api.deepseek.com'),\n 'timeout' => (int) env('DEEPSEEK_TIMEOUT', 120),\n 'max_retries' => (int) env('DEEPSEEK_MAX_RETRIES', 3),\n 'rate_limit' => (int) env('DEEPSEEK_RATE_LIMIT', 30), // requests / minute\n ],\n\n // Google Gemini — free tier at https://aistudio.google.com/apikey\n 'gemini' => [\n 'key' => env('GEMINI_API_KEY', ''),\n 'model' => env('GEMINI_VISION_MODEL', 'gemini-2.0-flash'),\n ],\n\n // Groq — free tier at https://console.groq.com (vision: llama-4 / llama-3.2)\n 'groq' => [\n 'key' => env('GROQ_API_KEY', ''),\n 'model' => env('GROQ_VISION_MODEL', 'meta-llama/llama-4-scout-17b-16e-instruct'),\n ],\n\n // Generic vision fallback (any OpenAI-compatible endpoint: OpenAI, OpenRouter, etc.)\n 'vision' => [\n 'key' => env('VISION_API_KEY', ''),\n 'base_url' => env('VISION_BASE_URL', 'https://openrouter.ai/api/v1'),\n 'model' => env('VISION_MODEL', 'google/gemini-flash-1.5-8b'),\n ],\n\n 'ocrspace' => [\n 'key' => env('OCRSPACE_API_KEY', 'helloworld'),\n ],\n\n];\n"}, "380bc546449c8da0516a2cd357f65f6e6421dd90": {"t": 1785748346.793319, "n": 2841, "h": "2a2d60233a0431c9", "c": "<?php\n\nreturn [\n\n /*\n |--------------------------------------------------------------------------\n | Third Party Services\n |--------------------------------------------------------------------------\n |\n | This file is for storing the credentials for third party services such\n | as Mailgun, Postmark, AWS and more. This file provides the de facto\n | location for this type of information, allowing packages to have\n | a conventional file to locate the various service credentials.\n |\n */\n\n 'postmark' => [\n 'key' => env('POSTMARK_API_KEY'),\n ],\n\n 'resend' => [\n 'key' => env('RESEND_API_KEY'),\n ],\n\n 'ses' => [\n 'key' => env('AWS_ACCESS_KEY_ID'),\n 'secret' => env('AWS_SECRET_ACCESS_KEY'),\n 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),\n ],\n\n 'slack' => [\n 'notifications' => [\n 'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),\n 'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),\n ],\n ],\n\n 'ai_quotation' => [\n 'base_url' => env('AI_QUOTATION_BASE_URL', ''),\n 'parse_endpoint' => env('AI_QUOTATION_PARSE_ENDPOINT', 'parse'),\n 'api_key' => env('AI_QUOTATION_API_KEY', ''),\n 'timeout' => env('AI_QUOTATION_TIMEOUT', 30),\n 'test_mode' => env('AI_QUOTATION_TEST_MODE', false),\n ],\n\n 'deepseek' => [\n 'key' => env('DEEPSEEK_API_KEY'),\n 'model' => env('DEEPSEEK_MODEL', 'deepseek-chat'),\n 'vision_model' => env('DEEPSEEK_VISION_MODEL', 'deepseek-vl2'),\n // Catalog research client settings (see config/catalog_research.php too).\n 'base_url' => env('DEEPSEEK_BASE_URL', 'https://api.deepseek.com'),\n 'timeout' => (int) env('DEEPSEEK_TIMEOUT', 120),\n 'max_retries' => (int) env('DEEPSEEK_MAX_RETRIES', 3),\n 'rate_limit' => (int) env('DEEPSEEK_RATE_LIMIT', 30), // requests / minute\n ],\n\n // Google Gemini — free tier at https://aistudio.google.com/apikey\n 'gemini' => [\n 'key' => env('GEMINI_API_KEY', ''),\n 'model' => env('GEMINI_VISION_MODEL', 'gemini-2.0-flash'),\n ],\n\n // Groq — free tier at https://console.groq.com (vision: llama-4 / llama-3.2)\n 'groq' => [\n 'key' => env('GROQ_API_KEY', ''),\n 'model' => env('GROQ_VISION_MODEL', 'meta-llama/llama-4-scout-17b-16e-instruct'),\n ],\n\n // Generic vision fallback (any OpenAI-compatible endpoint: OpenAI, OpenRouter, etc.)\n 'vision' => [\n 'key' => env('VISION_API_KEY', ''),\n 'base_url' => env('VISION_BASE_URL', 'https://openrouter.ai/api/v1'),\n 'model' => env('VISION_MODEL', 'google/gemini-flash-1.5-8b'),\n ],\n\n 'ocrspace' => [\n 'key' => env('OCRSPACE_API_KEY', 'helloworld'),\n ],\n\n];\n"}, "f01410ba86b70436dbde9e233e6611d10545c13c": {"t": 1785748347.9152079, "n": 2467, "h": "6643d4ee1ef4e8c9", "c": "<?php\n\nreturn [\n\n /*\n |--------------------------------------------------------------------------\n | Third Party Services\n |--------------------------------------------------------------------------\n |\n | This file is for storing the credentials for third party services such\n | as Mailgun, Postmark, AWS and more. This file provides the de facto\n | location for this type of information, allowing packages to have\n | a conventional file to locate the various service credentials.\n |\n */\n\n 'postmark' => [\n 'key' => env('POSTMARK_API_KEY'),\n ],\n\n 'resend' => [\n 'key' => env('RESEND_API_KEY'),\n ],\n\n 'ses' => [\n 'key' => env('AWS_ACCESS_KEY_ID'),\n 'secret' => env('AWS_SECRET_ACCESS_KEY'),\n 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),\n ],\n\n 'slack' => [\n 'notifications' => [\n 'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),\n 'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),\n ],\n ],\n\n 'ai_quotation' => [\n 'base_url' => env('AI_QUOTATION_BASE_URL', ''),\n 'parse_endpoint' => env('AI_QUOTATION_PARSE_ENDPOINT', 'parse'),\n 'api_key' => env('AI_QUOTATION_API_KEY', ''),\n 'timeout' => env('AI_QUOTATION_TIMEOUT', 30),\n 'test_mode' => env('AI_QUOTATION_TEST_MODE', false),\n ],\n\n 'deepseek' => [\n 'key' => env('DEEPSEEK_API_KEY'),\n 'model' => env('DEEPSEEK_MODEL', 'deepseek-chat'),\n 'vision_model' => env('DEEPSEEK_VISION_MODEL', 'deepseek-vl2'),\n ],\n\n // Google Gemini — free tier at https://aistudio.google.com/apikey\n 'gemini' => [\n 'key' => env('GEMINI_API_KEY', ''),\n 'model' => env('GEMINI_VISION_MODEL', 'gemini-2.0-flash'),\n ],\n\n // Groq — free tier at https://console.groq.com (vision: llama-4 / llama-3.2)\n 'groq' => [\n 'key' => env('GROQ_API_KEY', ''),\n 'model' => env('GROQ_VISION_MODEL', 'meta-llama/llama-4-scout-17b-16e-instruct'),\n ],\n\n // Generic vision fallback (any OpenAI-compatible endpoint: OpenAI, OpenRouter, etc.)\n 'vision' => [\n 'key' => env('VISION_API_KEY', ''),\n 'base_url' => env('VISION_BASE_URL', 'https://openrouter.ai/api/v1'),\n 'model' => env('VISION_MODEL', 'google/gemini-flash-1.5-8b'),\n ],\n\n 'ocrspace' => [\n 'key' => env('OCRSPACE_API_KEY', 'helloworld'),\n ],\n\n];\n"}, "769498c3a1c058b48ccadcb2840e8474a079cab2": {"t": 1785748349.1555865, "n": 2381, "h": "3c5c8d5a40f4b977", "c": "<?php\n\nreturn [\n\n /*\n |--------------------------------------------------------------------------\n | Third Party Services\n |--------------------------------------------------------------------------\n |\n | This file is for storing the credentials for third party services such\n | as Mailgun, Postmark, AWS and more. This file provides the de facto\n | location for this type of information, allowing packages to have\n | a conventional file to locate the various service credentials.\n |\n */\n\n 'postmark' => [\n 'key' => env('POSTMARK_API_KEY'),\n ],\n\n 'resend' => [\n 'key' => env('RESEND_API_KEY'),\n ],\n\n 'ses' => [\n 'key' => env('AWS_ACCESS_KEY_ID'),\n 'secret' => env('AWS_SECRET_ACCESS_KEY'),\n 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),\n ],\n\n 'slack' => [\n 'notifications' => [\n 'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),\n 'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),\n ],\n ],\n\n 'ai_quotation' => [\n 'base_url' => env('AI_QUOTATION_BASE_URL', ''),\n 'parse_endpoint' => env('AI_QUOTATION_PARSE_ENDPOINT', 'parse'),\n 'api_key' => env('AI_QUOTATION_API_KEY', ''),\n 'timeout' => env('AI_QUOTATION_TIMEOUT', 30),\n 'test_mode' => env('AI_QUOTATION_TEST_MODE', false),\n ],\n\n 'deepseek' => [\n 'key' => env('DEEPSEEK_API_KEY'),\n 'model' => env('DEEPSEEK_MODEL', 'deepseek-chat'),\n ],\n\n // Google Gemini — free tier at https://aistudio.google.com/apikey\n 'gemini' => [\n 'key' => env('GEMINI_API_KEY', ''),\n 'model' => env('GEMINI_VISION_MODEL', 'gemini-2.0-flash'),\n ],\n\n // Groq — free tier at https://console.groq.com (vision: llama-4 / llama-3.2)\n 'groq' => [\n 'key' => env('GROQ_API_KEY', ''),\n 'model' => env('GROQ_VISION_MODEL', 'meta-llama/llama-4-scout-17b-16e-instruct'),\n ],\n\n // Generic vision fallback (any OpenAI-compatible endpoint: OpenAI, OpenRouter, etc.)\n 'vision' => [\n 'key' => env('VISION_API_KEY', ''),\n 'base_url' => env('VISION_BASE_URL', 'https://openrouter.ai/api/v1'),\n 'model' => env('VISION_MODEL', 'google/gemini-flash-1.5-8b'),\n ],\n\n 'ocrspace' => [\n 'key' => env('OCRSPACE_API_KEY', 'helloworld'),\n ],\n\n];\n"}, "49f1993d53cb7be5eb63aa3417cccb277e75daa4": {"t": 1785748351.3533325, "n": 2286, "h": "a06cf01f5b22215e", "c": "<?php\n\nreturn [\n\n /*\n |--------------------------------------------------------------------------\n | Third Party Services\n |--------------------------------------------------------------------------\n |\n | This file is for storing the credentials for third party services such\n | as Mailgun, Postmark, AWS and more. This file provides the de facto\n | location for this type of information, allowing packages to have\n | a conventional file to locate the various service credentials.\n |\n */\n\n 'postmark' => [\n 'key' => env('POSTMARK_API_KEY'),\n ],\n\n 'resend' => [\n 'key' => env('RESEND_API_KEY'),\n ],\n\n 'ses' => [\n 'key' => env('AWS_ACCESS_KEY_ID'),\n 'secret' => env('AWS_SECRET_ACCESS_KEY'),\n 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),\n ],\n\n 'slack' => [\n 'notifications' => [\n 'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),\n 'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),\n ],\n ],\n\n 'ai_quotation' => [\n 'base_url' => env('AI_QUOTATION_BASE_URL', ''),\n 'parse_endpoint' => env('AI_QUOTATION_PARSE_ENDPOINT', 'parse'),\n 'api_key' => env('AI_QUOTATION_API_KEY', ''),\n 'timeout' => env('AI_QUOTATION_TIMEOUT', 30),\n 'test_mode' => env('AI_QUOTATION_TEST_MODE', false),\n ],\n\n 'deepseek' => [\n 'key' => env('DEEPSEEK_API_KEY'),\n 'model' => env('DEEPSEEK_MODEL', 'deepseek-chat'),\n ],\n\n // Google Gemini (recommended — free tier available at https://ai.google.dev)\n // Set GEMINI_API_KEY in .env and image processing will use Gemini automatically.\n 'gemini' => [\n 'key' => env('GEMINI_API_KEY', ''),\n 'model' => env('GEMINI_VISION_MODEL', 'gemini-2.0-flash'),\n ],\n\n // Generic vision fallback (any OpenAI-compatible endpoint: OpenAI, OpenRouter, etc.)\n // Only used when GEMINI_API_KEY is not set.\n 'vision' => [\n 'key' => env('VISION_API_KEY', ''),\n 'base_url' => env('VISION_BASE_URL', 'https://openrouter.ai/api/v1'),\n 'model' => env('VISION_MODEL', 'google/gemini-flash-1.5-8b'),\n ],\n\n 'ocrspace' => [\n 'key' => env('OCRSPACE_API_KEY', 'helloworld'),\n ],\n\n];\n"}, "c9cfc223dc923400b78320c53bc3915186a077bd": {"t": 1785748352.4341967, "n": 2181, "h": "72567b532e87f5b3", "c": "<?php\n\nreturn [\n\n /*\n |--------------------------------------------------------------------------\n | Third Party Services\n |--------------------------------------------------------------------------\n |\n | This file is for storing the credentials for third party services such\n | as Mailgun, Postmark, AWS and more. This file provides the de facto\n | location for this type of information, allowing packages to have\n | a conventional file to locate the various service credentials.\n |\n */\n\n 'postmark' => [\n 'key' => env('POSTMARK_API_KEY'),\n ],\n\n 'resend' => [\n 'key' => env('RESEND_API_KEY'),\n ],\n\n 'ses' => [\n 'key' => env('AWS_ACCESS_KEY_ID'),\n 'secret' => env('AWS_SECRET_ACCESS_KEY'),\n 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),\n ],\n\n 'slack' => [\n 'notifications' => [\n 'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),\n 'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),\n ],\n ],\n\n 'ai_quotation' => [\n 'base_url' => env('AI_QUOTATION_BASE_URL', ''),\n 'parse_endpoint' => env('AI_QUOTATION_PARSE_ENDPOINT', 'parse'),\n 'api_key' => env('AI_QUOTATION_API_KEY', ''),\n 'timeout' => env('AI_QUOTATION_TIMEOUT', 30),\n 'test_mode' => env('AI_QUOTATION_TEST_MODE', false),\n ],\n\n 'deepseek' => [\n 'key' => env('DEEPSEEK_API_KEY'),\n 'model' => env('DEEPSEEK_MODEL', 'deepseek-chat'),\n ],\n\n // Vision-capable model (any OpenAI-compatible endpoint: OpenAI, OpenRouter, Gemini, etc.)\n // DeepSeek does NOT support image inputs — use a vision API here.\n // Free option: OpenRouter (https://openrouter.ai) with google/gemini-flash-1.5-8b\n // Cheap option: OpenAI gpt-4o-mini | Google Gemini Flash (https://ai.google.dev)\n 'vision' => [\n 'key' => env('VISION_API_KEY', ''),\n 'base_url' => env('VISION_BASE_URL', 'https://openrouter.ai/api/v1'),\n 'model' => env('VISION_MODEL', 'google/gemini-flash-1.5-8b'),\n ],\n\n 'ocrspace' => [\n 'key' => env('OCRSPACE_API_KEY', 'helloworld'),\n ],\n\n];\n"}, "b65ff6622e72f65525fe55404e124bc72c97539d": {"t": 1785748354.2153206, "n": 1701, "h": "7e822ca1a1601242", "c": "<?php\n\nreturn [\n\n /*\n |--------------------------------------------------------------------------\n | Third Party Services\n |--------------------------------------------------------------------------\n |\n | This file is for storing the credentials for third party services such\n | as Mailgun, Postmark, AWS and more. This file provides the de facto\n | location for this type of information, allowing packages to have\n | a conventional file to locate the various service credentials.\n |\n */\n\n 'postmark' => [\n 'key' => env('POSTMARK_API_KEY'),\n ],\n\n 'resend' => [\n 'key' => env('RESEND_API_KEY'),\n ],\n\n 'ses' => [\n 'key' => env('AWS_ACCESS_KEY_ID'),\n 'secret' => env('AWS_SECRET_ACCESS_KEY'),\n 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),\n ],\n\n 'slack' => [\n 'notifications' => [\n 'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),\n 'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),\n ],\n ],\n\n 'ai_quotation' => [\n 'base_url' => env('AI_QUOTATION_BASE_URL', ''),\n 'parse_endpoint' => env('AI_QUOTATION_PARSE_ENDPOINT', 'parse'),\n 'api_key' => env('AI_QUOTATION_API_KEY', ''),\n 'timeout' => env('AI_QUOTATION_TIMEOUT', 30),\n 'test_mode' => env('AI_QUOTATION_TEST_MODE', false),\n ],\n\n 'deepseek' => [\n 'key' => env('DEEPSEEK_API_KEY'),\n 'model' => env('DEEPSEEK_MODEL', 'deepseek-chat'),\n 'vision_model' => env('DEEPSEEK_VISION_MODEL', 'deepseek-chat'),\n ],\n\n 'ocrspace' => [\n 'key' => env('OCRSPACE_API_KEY', 'helloworld'),\n ],\n\n];\n"}, "ed11fb1768acfde3b4f940ad9398269f2353fd64": {"t": 1785748355.2859316, "n": 1527, "h": "98dca374fd90c796", "c": "<?php\n\nreturn [\n\n /*\n |--------------------------------------------------------------------------\n | Third Party Services\n |--------------------------------------------------------------------------\n |\n | This file is for storing the credentials for third party services such\n | as Mailgun, Postmark, AWS and more. This file provides the de facto\n | location for this type of information, allowing packages to have\n | a conventional file to locate the various service credentials.\n |\n */\n\n 'postmark' => [\n 'key' => env('POSTMARK_API_KEY'),\n ],\n\n 'resend' => [\n 'key' => env('RESEND_API_KEY'),\n ],\n\n 'ses' => [\n 'key' => env('AWS_ACCESS_KEY_ID'),\n 'secret' => env('AWS_SECRET_ACCESS_KEY'),\n 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),\n ],\n\n 'slack' => [\n 'notifications' => [\n 'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),\n 'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),\n ],\n ],\n\n 'ai_quotation' => [\n 'base_url' => env('AI_QUOTATION_BASE_URL', ''),\n 'parse_endpoint' => env('AI_QUOTATION_PARSE_ENDPOINT', 'parse'),\n 'api_key' => env('AI_QUOTATION_API_KEY', ''),\n 'timeout' => env('AI_QUOTATION_TIMEOUT', 30),\n 'test_mode' => env('AI_QUOTATION_TEST_MODE', false),\n ],\n\n 'gemini' => [\n 'key' => env('GEMINI_API_KEY'),\n 'model' => env('GEMINI_MODEL', 'gemini-2.5-flash'),\n ],\n\n];\n"}, "591e116059c37741acf2d7111a75250d1714307e": {"t": 1785748356.378487, "n": 1528, "h": "0062ac2bde78fa26", "c": "<?php\n\nreturn [\n\n /*\n |--------------------------------------------------------------------------\n | Third Party Services\n |--------------------------------------------------------------------------\n |\n | This file is for storing the credentials for third party services such\n | as Mailgun, Postmark, AWS and more. This file provides the de facto\n | location for this type of information, allowing packages to have\n | a conventional file to locate the various service credentials.\n |\n */\n\n 'postmark' => [\n 'key' => env('POSTMARK_API_KEY'),\n ],\n\n 'resend' => [\n 'key' => env('RESEND_API_KEY'),\n ],\n\n 'ses' => [\n 'key' => env('AWS_ACCESS_KEY_ID'),\n 'secret' => env('AWS_SECRET_ACCESS_KEY'),\n 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),\n ],\n\n 'slack' => [\n 'notifications' => [\n 'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),\n 'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),\n ],\n ],\n\n 'ai_quotation' => [\n 'base_url' => env('AI_QUOTATION_BASE_URL', ''),\n 'parse_endpoint' => env('AI_QUOTATION_PARSE_ENDPOINT', 'parse'),\n 'api_key' => env('AI_QUOTATION_API_KEY', ''),\n 'timeout' => env('AI_QUOTATION_TIMEOUT', 180),\n 'test_mode' => env('AI_QUOTATION_TEST_MODE', false),\n ],\n\n 'gemini' => [\n 'key' => env('GEMINI_API_KEY'),\n 'model' => env('GEMINI_MODEL', 'gemini-2.5-flash'),\n ],\n\n];\n"}, "6fad901dd50024591679a24a2b8c93da95f47220": {"t": 1785748357.478145, "n": 1528, "h": "966cc75cd166c4f5", "c": "<?php\n\nreturn [\n\n /*\n |--------------------------------------------------------------------------\n | Third Party Services\n |--------------------------------------------------------------------------\n |\n | This file is for storing the credentials for third party services such\n | as Mailgun, Postmark, AWS and more. This file provides the de facto\n | location for this type of information, allowing packages to have\n | a conventional file to locate the various service credentials.\n |\n */\n\n 'postmark' => [\n 'key' => env('POSTMARK_API_KEY'),\n ],\n\n 'resend' => [\n 'key' => env('RESEND_API_KEY'),\n ],\n\n 'ses' => [\n 'key' => env('AWS_ACCESS_KEY_ID'),\n 'secret' => env('AWS_SECRET_ACCESS_KEY'),\n 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),\n ],\n\n 'slack' => [\n 'notifications' => [\n 'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),\n 'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),\n ],\n ],\n\n 'ai_quotation' => [\n 'base_url' => env('AI_QUOTATION_BASE_URL', ''),\n 'parse_endpoint' => env('AI_QUOTATION_PARSE_ENDPOINT', 'parse'),\n 'api_key' => env('AI_QUOTATION_API_KEY', ''),\n 'timeout' => env('AI_QUOTATION_TIMEOUT', 180),\n 'test_mode' => env('AI_QUOTATION_TEST_MODE', false),\n ],\n\n 'gemini' => [\n 'key' => env('GEMINI_API_KEY'),\n 'model' => env('GEMINI_MODEL', 'gemini-2.0-flash'),\n ],\n\n];\n"}, "d18974ac98ccde45831fafe18562ddf73e6c0c6c": {"t": 1785748358.5549593, "n": 1528, "h": "0062ac2bde78fa26", "c": "<?php\n\nreturn [\n\n /*\n |--------------------------------------------------------------------------\n | Third Party Services\n |--------------------------------------------------------------------------\n |\n | This file is for storing the credentials for third party services such\n | as Mailgun, Postmark, AWS and more. This file provides the de facto\n | location for this type of information, allowing packages to have\n | a conventional file to locate the various service credentials.\n |\n */\n\n 'postmark' => [\n 'key' => env('POSTMARK_API_KEY'),\n ],\n\n 'resend' => [\n 'key' => env('RESEND_API_KEY'),\n ],\n\n 'ses' => [\n 'key' => env('AWS_ACCESS_KEY_ID'),\n 'secret' => env('AWS_SECRET_ACCESS_KEY'),\n 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),\n ],\n\n 'slack' => [\n 'notifications' => [\n 'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),\n 'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),\n ],\n ],\n\n 'ai_quotation' => [\n 'base_url' => env('AI_QUOTATION_BASE_URL', ''),\n 'parse_endpoint' => env('AI_QUOTATION_PARSE_ENDPOINT', 'parse'),\n 'api_key' => env('AI_QUOTATION_API_KEY', ''),\n 'timeout' => env('AI_QUOTATION_TIMEOUT', 180),\n 'test_mode' => env('AI_QUOTATION_TEST_MODE', false),\n ],\n\n 'gemini' => [\n 'key' => env('GEMINI_API_KEY'),\n 'model' => env('GEMINI_MODEL', 'gemini-2.5-flash'),\n ],\n\n];\n"}, "53c8f6e7f81d8dfdc9642ff895a779f1b4cd7a67": {"t": 1785748359.634313, "n": 1461, "h": "890856ae61e5f558", "c": "<?php\n\nreturn [\n\n /*\n |--------------------------------------------------------------------------\n | Third Party Services\n |--------------------------------------------------------------------------\n |\n | This file is for storing the credentials for third party services such\n | as Mailgun, Postmark, AWS and more. This file provides the de facto\n | location for this type of information, allowing packages to have\n | a conventional file to locate the various service credentials.\n |\n */\n\n 'postmark' => [\n 'key' => env('POSTMARK_API_KEY'),\n ],\n\n 'resend' => [\n 'key' => env('RESEND_API_KEY'),\n ],\n\n 'ses' => [\n 'key' => env('AWS_ACCESS_KEY_ID'),\n 'secret' => env('AWS_SECRET_ACCESS_KEY'),\n 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),\n ],\n\n 'slack' => [\n 'notifications' => [\n 'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),\n 'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),\n ],\n ],\n\n 'ai_quotation' => [\n 'base_url' => env('AI_QUOTATION_BASE_URL', ''),\n 'parse_endpoint' => env('AI_QUOTATION_PARSE_ENDPOINT', 'parse'),\n 'api_key' => env('AI_QUOTATION_API_KEY', ''),\n 'timeout' => env('AI_QUOTATION_TIMEOUT', 120),\n ],\n\n 'gemini' => [\n 'key' => env('GEMINI_API_KEY'),\n 'model' => env('GEMINI_MODEL', 'gemini-2.5-flash'),\n ],\n\n];\n"}, "a4318f8cc2271b05ba9016c1010c1b8f31161281": {"t": 1785748360.7212021, "n": 1333, "h": "545fa668f99b2f5f", "c": "<?php\n\nreturn [\n\n /*\n |--------------------------------------------------------------------------\n | Third Party Services\n |--------------------------------------------------------------------------\n |\n | This file is for storing the credentials for third party services such\n | as Mailgun, Postmark, AWS and more. This file provides the de facto\n | location for this type of information, allowing packages to have\n | a conventional file to locate the various service credentials.\n |\n */\n\n 'postmark' => [\n 'key' => env('POSTMARK_API_KEY'),\n ],\n\n 'resend' => [\n 'key' => env('RESEND_API_KEY'),\n ],\n\n 'ses' => [\n 'key' => env('AWS_ACCESS_KEY_ID'),\n 'secret' => env('AWS_SECRET_ACCESS_KEY'),\n 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),\n ],\n\n 'slack' => [\n 'notifications' => [\n 'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),\n 'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),\n ],\n ],\n\n 'ai_quotation' => [\n 'base_url' => env('AI_QUOTATION_BASE_URL', ''),\n 'parse_endpoint' => env('AI_QUOTATION_PARSE_ENDPOINT', 'parse'),\n 'api_key' => env('AI_QUOTATION_API_KEY', ''),\n 'timeout' => env('AI_QUOTATION_TIMEOUT', 120),\n ],\n\n];\n"}}, "app/Services/QuotationAiService.php": {"9a5ada4ab1ceb0de386bc1ce99c561f8869c09d7": {"t": 1785745771.025775, "n": 101270, "h": "ebfa7324f0f51e4a", "c": "<?php\n\nnamespace App\\Services;\n\nuse Illuminate\\Http\\Client\\ConnectionException;\nuse Illuminate\\Http\\UploadedFile;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\nuse PhpOffice\\PhpSpreadsheet\\Cell\\Coordinate;\nuse PhpOffice\\PhpSpreadsheet\\IOFactory;\n\nclass QuotationAiService\n{\n /** Increment this whenever BOQ parsing logic changes to invalidate old caches. */\n private const PARSER_VERSION = 10;\n private const LARGE_BOQ_VERIFICATION_THRESHOLD = 2000;\n private const LARGE_BOQ_VERIFICATION_CHUNK_SIZE = 120;\n\n /**\n * Characters of document text sent per AI call.\n *\n * Above this the document is split and parsed over several calls. Sized well\n * under the model's context so the prompt, the slice, and a full JSON answer\n * for every row in it all fit — the binding limit is the *output* budget, not\n * the input, since each input row produces a JSON object.\n */\n private const TEXT_CHUNK_CHARS = 60000;\n\n /**\n * A workbook with more sheets than this is split regardless of its size.\n *\n * Multiple tabs almost always mean multiple disciplines (electrical, HVAC,\n * civil…). Sending them in one call makes the model summarise across\n * sections and silently drop rows, so each slice gets a narrower scope.\n */\n private const MAX_SHEETS_PER_CALL = 2;\n\n /** A PDF longer than this is split regardless of its extracted text length. */\n private const MAX_PDF_PAGES_PER_CALL = 5;\n\n /**\n * Largest PDF we will hand to the parser (12 MB).\n *\n * smalot/pdfparser decompresses every stream into memory with no ceiling of\n * its own, and overrunning the limit there is a fatal error that kills the\n * worker rather than an exception we can recover from.\n */\n private const MAX_PDF_BYTES = 12582912;\n\n /**\n * Memory the parser is assumed to need, as a multiple of the file size.\n *\n * Decompressed content streams plus the object graph run well past the raw\n * bytes; 8x is conservative enough to catch the cases that were dying.\n */\n private const PDF_MEMORY_FACTOR = 8;\n\n /** Returned by extractPdfText when it stopped for memory, not for lack of text. */\n private const PDF_LOW_MEMORY = \"\\0__pdf_low_memory__\";\n\n private string $baseUrl;\n private string $parseEndpoint;\n private string $apiKey;\n private int $timeout;\n private bool $testMode;\n private BoqCleaningService $boqCleaner;\n\n /** Page count of the most recently parsed PDF; 0 when unknown. */\n private int $lastPdfPageCount = 0;\n\n /**\n * Optional progress reporter for chunked parsing, called as ($part, $total).\n *\n * Set by the queue job so the polling UI can show real progress on a large\n * BOQ instead of a spinner that appears frozen for many minutes.\n *\n * @var (callable(int, int): void)|null\n */\n private $onChunkProgress = null;\n\n /** @param callable(int, int): void $callback */\n public function onChunkProgress(callable $callback): self\n {\n $this->onChunkProgress = $callback;\n return $this;\n }\n\n /**\n * Optional per-chunk item sink, called as ($items, $part, $total).\n *\n * Lets the caller persist each slice's rows the moment they arrive instead\n * of waiting for the whole document. On a large BOQ this is the difference\n * between a table that fills in progressively and one that stays empty for\n * many minutes.\n *\n * @var (callable(array, int, int): void)|null\n */\n private $onChunkItems = null;\n\n /** @param callable(array, int, int): void $callback */\n public function onChunkItems(callable $callback): self\n {\n $this->onChunkItems = $callback;\n return $this;\n }\n\n /**\n * Report a non-chunk stage (local parse, handing off to AI) to the caller.\n *\n * A spreadsheet is read locally first and only sent to the AI when that\n * fails, so a large file can spend minutes before any chunk exists. Without\n * these the UI shows a generic spinner for that whole period.\n */\n private function reportStage(string $message): void\n {\n if ($this->onStage !== null) {\n ($this->onStage)($message);\n }\n }\n\n /**\n * Report a workbook's shape from grids that are already in memory.\n *\n * Deliberately takes the parsed grids rather than a path: re-reading a large\n * spreadsheet just to count its sheets would double the slowest step of the\n * whole extraction.\n *\n * @param array<int, array{name: string, grid: array}> $sheets\n */\n private function reportWorkbookShape(array $sheets): void\n {\n if ($this->onStage === null || empty($sheets)) {\n return;\n }\n\n $rows = 0;\n foreach ($sheets as $sheet) {\n $rows += count($sheet['grid']);\n }\n\n $count = count($sheets);\n\n $this->reportStage($count > 1\n ? \"Found {$count} sheets, {$rows} rows in total. Reading…\"\n : \"Found {$rows} rows. Reading…\");\n }\n\n /** @var (callable(string): void)|null */\n private $onStage = null;\n\n /** @param callable(string): void $callback */\n public function onStage(callable $callback): self\n {\n $this->onStage = $callback;\n return $this;\n }\n\n public function __construct(BoqCleaningService $boqCleaner)\n {\n $this->boqCleaner = $boqCleaner;\n $this->baseUrl = rtrim((string) config('services.ai_quotation.base_url', ''), '/');\n $this->parseEndpoint = ltrim((string) config('services.ai_quotation.parse_endpoint', 'parse'), '/');\n $this->apiKey = (string) config('services.ai_quotation.api_key', '');\n $this->timeout = (int) config('services.ai_quotation.timeout', 300);\n $this->testMode = (bool) config('services.ai_quotation.test_mode', false);\n }\n\n /**\n * Give the running script enough head-room to outlast the AI HTTP call.\n *\n * The web request / queue worker must not be killed before the HTTP timeout\n * completes, otherwise the user sees a generic \"timeout or unavailable\"\n * error even though the AI may have responded. We allow the HTTP timeout\n * plus a 120s margin for connecting, response parsing and DB writes.\n */\n private function extendExecutionTime(): void\n {\n // Ignored on some SAPIs (e.g. when running under fastcgi with a hard\n // limit), but harmless to attempt.\n @set_time_limit($this->timeout + 120);\n }\n\n public function parseBoq(UploadedFile|string $file, array $context = []): array\n {\n if ($this->testMode) {\n return $this->mockResponse();\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n\n if (! is_file($absPath)) {\n return $this->failure('File not found or not readable.');\n }\n\n $fileHash = hash_file('sha256', $absPath);\n // Bump PARSER_VERSION whenever the extraction logic changes so that\n // previously-uploaded files are re-parsed instead of returning a stale\n // cached result produced by the old code.\n $cacheKey = 'boq_analysis_v' . self::PARSER_VERSION . '_' . $fileHash;\n $forceRefresh = (bool) ($context['force_refresh'] ?? false);\n\n if (! $forceRefresh && ($cached = Cache::get($cacheKey)) !== null) {\n return $cached;\n }\n\n // Clear stale cached result when force-refreshing\n if ($forceRefresh) {\n Cache::forget($cacheKey);\n }\n\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n // Read Excel locally first. Fall back to AI if local parse failed,\n // found 0 items, OR could not read one of the sheets (so multi-sheet\n // files never silently drop a sheet the local parser didn't understand).\n $this->reportStage('Reading the spreadsheet…');\n\n $result = $this->parseSpreadsheetDirect($absPath);\n\n $hasSkippedSheets = ! empty($result['skipped_sheets']);\n\n if (! $result['success'] || empty($result['items']) || $hasSkippedSheets) {\n // The local pass could not handle this file on its own. Say so:\n // otherwise the UI sits on a generic spinner through the local\n // parse AND the whole AI parse with nothing to show for either.\n $this->reportStage($hasSkippedSheets\n ? 'Some sheets need AI to read. Sending to AI…'\n : 'Spreadsheet needs AI to read. Sending to AI…');\n\n $deepSeekResult = $this->parseBoqWithDeepSeek($file, $context);\n // AI sees every sheet at once, so prefer its result whenever it\n // actually returned items (covers the skipped-sheet case too).\n if ($deepSeekResult['success'] && ! empty($deepSeekResult['items'])) {\n // Keep the larger of the two item sets so we never lose the\n // rows the local parser already extracted correctly.\n if (count($deepSeekResult['items']) >= count($result['items'] ?? [])) {\n $result = $deepSeekResult;\n }\n } elseif (! $result['success']) {\n $result = $deepSeekResult;\n }\n // else: keep local result if AI also failed/returned nothing\n }\n } elseif (in_array($ext, ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif'], true)) {\n $result = $this->parseBoqWithDeepSeek($file, $context);\n } else {\n return $this->failure('Unsupported file type. Please upload Excel, CSV, PDF, or image file.');\n }\n\n if ($result['success'] && ! empty($result['items'])) {\n $cleaning = $this->boqCleaner->process($result['items']);\n $result['items'] = $cleaning['accepted'] ?? $result['items'];\n $result['rejected'] = array_merge($result['rejected'] ?? [], $cleaning['rejected'] ?? []);\n }\n\n if ($result['success'] && count($result['items'] ?? []) > self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n $result = $this->verifyLargeBoqItemsWithAi($result);\n }\n\n // Only cache when we actually extracted items; empty results should be re-tried.\n if ($result['success'] && ! empty($result['items'])) {\n Cache::put($cacheKey, $result, now()->addDays(30));\n }\n\n return $result;\n }\n\n private function resolveFile(UploadedFile|string $file): array\n {\n if ($file instanceof UploadedFile) {\n return [(string) $file->getRealPath(), strtolower($file->getClientOriginalExtension())];\n }\n\n $absPath = is_file($file) ? $file : storage_path('app/' . ltrim($file, '/'));\n return [$absPath, strtolower(pathinfo($absPath, PATHINFO_EXTENSION))];\n }\n\n private function parseSpreadsheetDirect(string $absPath): array\n {\n ini_set('memory_limit', '2048M');\n\n // 180s was not enough for a real BOQ: an 11 200-row workbook was killed\n // mid-parse, and because set_time_limit() kills the process outright the\n // job reported a bare failure with nothing in the log to explain it.\n // This runs on the queue, where wall time is cheap.\n set_time_limit(1800);\n\n try {\n $sheets = $this->spreadsheetToGrids($absPath);\n\n if (empty($sheets)) {\n return $this->failure('Could not read the Excel file.');\n }\n\n $this->reportWorkbookShape($sheets);\n\n $items = [];\n $rejected = [];\n $skippedSheets = []; // sheets we could not parse locally (header undetected)\n\n foreach ($sheets as $sheet) {\n $grid = $sheet['grid'];\n if (count($grid) < 2) {\n continue;\n }\n\n // Skip dedicated summary / cost-abstract sheets entirely. Their rows\n // are per-discipline totals (Structural Concrete, Electrical, …) and\n // grand totals, not procurable products.\n if ($this->isSummarySheet($sheet['name'])) {\n Log::info('QuotationAiService: skipped summary sheet.', ['sheet' => $sheet['name']]);\n continue;\n }\n\n $header = $this->detectHeader($grid);\n\n if ($header === null || ! isset($header['map']['description'])) {\n Log::warning('QuotationAiService: header not detected in sheet.', ['sheet' => $sheet['name']]);\n $skippedSheets[] = $sheet['name'];\n continue;\n }\n\n $map = $header['map'];\n $start = $header['row'] + 1;\n\n for ($r = $start; $r < count($grid); $r++) {\n $row = $grid[$r];\n $description = $this->cell($row, $map['description'] ?? null);\n $itemNo = $this->cell($row, $map['item_code'] ?? null);\n $unit = $this->cell($row, $map['unit'] ?? null);\n $quantity = $this->number($this->cell($row, $map['quantity'] ?? null));\n $unitPrice = $this->number($this->cell($row, $map['unit_price'] ?? null));\n $totalPrice = $this->number($this->cell($row, $map['total_price'] ?? null));\n\n if ($description === '') {\n continue;\n }\n\n if (! isset($map['quantity'])) {\n $quantity = 1.0;\n }\n\n // Skip floor breakdown sub-rows: Ref. is \"-\" meaning it's a per-floor\n // quantity split of the parent item, not a standalone product.\n if ($itemNo === '-' || $this->isFloorBreakdownRow($itemNo, $description)) {\n continue;\n }\n\n if ($this->isTotalOrHeaderRow($description)) {\n continue;\n }\n\n if ($quantity === null || $quantity <= 0) {\n $rejected[] = $this->rejectedRow($description, $unit, $quantity, 'Missing quantity', $itemNo, $sheet['name']);\n continue;\n }\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $supply = $this->boqCleaner->filterItem($description, $unitPrice, $quantity);\n\n if (! ($supply['keep'] ?? false)) {\n $rejected[] = $this->rejectedRow(\n $description,\n $unit,\n $quantity,\n (string) ($supply['rejection_reason'] ?? 'Rejected by cleaner'),\n $itemNo,\n $sheet['name']\n );\n continue;\n }\n\n $finalDescription = (string) ($supply['description'] ?? $description);\n\n $items[] = [\n 'description' => $finalDescription,\n 'quantity' => $quantity,\n 'unit' => $unit,\n 'category' => $sheet['name'],\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($finalDescription),\n 'unit_price' => $unitPrice,\n 'confidence' => 0.95,\n 'needs_review' => false,\n 'raw_data' => [\n 'sheet' => $sheet['name'],\n 'row_number' => $r + 1,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'cleaned_description' => $finalDescription,\n 'extraction_type' => (string) ($supply['extraction_type'] ?? 'supply_only'),\n ],\n 'ai_extracted' => true,\n ];\n }\n }\n\n if (empty($items)) {\n return $this->failure('No BOQ supply items could be extracted from the Excel file.');\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'skipped_sheets' => $skippedSheets,\n ];\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: parseSpreadsheetDirect failed.', [\n 'path' => $absPath,\n 'message' => $e->getMessage(),\n 'trace' => $e->getTraceAsString(),\n ]);\n\n return $this->failure('Failed to read the Excel file: ' . $e->getMessage());\n }\n }\n\n private function spreadsheetToGrids(string $absPath): array\n {\n $ext = strtolower(pathinfo($absPath, PATHINFO_EXTENSION));\n\n if ($ext === 'csv') {\n $rows = [];\n $handle = fopen($absPath, 'rb');\n if ($handle !== false) {\n while (($row = fgetcsv($handle)) !== false) {\n $rows[] = $this->trimTrailingEmptyCells($row);\n }\n fclose($handle);\n }\n return [['name' => 'Sheet1', 'grid' => $rows]];\n }\n\n $reader = IOFactory::createReader($ext === 'xls' ? 'Xls' : 'Xlsx');\n $reader->setReadDataOnly(true);\n $spreadsheet = $reader->load($absPath);\n $sheets = [];\n\n foreach ($spreadsheet->getAllSheets() as $sheet) {\n $highestRow = $sheet->getHighestDataRow();\n $highestColumn = $sheet->getHighestDataColumn();\n $highestColumnIndex = Coordinate::columnIndexFromString($highestColumn);\n\n // BOQ data is normally in first 8-20 columns.\n // Files with heavy formatting/images can report 300+ columns — limit to 30.\n $maxColumnIndex = min($highestColumnIndex, 30);\n\n $grid = [];\n for ($row = 1; $row <= $highestRow; $row++) {\n $rowData = [];\n $hasValue = false;\n\n for ($col = 1; $col <= $maxColumnIndex; $col++) {\n $value = $sheet->getCell([$col, $row])->getValue();\n $value = $this->cellValueToString($value);\n $value = trim($value);\n if ($value !== null && $value !== '') {\n $hasValue = true;\n }\n $rowData[] = $value;\n }\n\n $rowData = $this->trimTrailingEmptyCells($rowData);\n if ($hasValue) {\n $grid[] = $rowData;\n }\n }\n\n if (! empty($grid)) {\n $sheets[] = ['name' => $sheet->getTitle(), 'grid' => $grid];\n }\n }\n\n $spreadsheet->disconnectWorksheets();\n unset($spreadsheet);\n\n return $sheets;\n }\n\n private function detectHeader(array $grid): ?array\n {\n $keywords = [\n 'item_code' => ['رقم البند', 'item no', 'item number', 'ref.', 'ref', 'item', 'no.', 'no', '#', 'كود'],\n 'description' => ['وص٠البند', 'scope of works', 'scope of work', 'scope', 'item description', 'description of works', 'description', 'بالعربية', 'الوصÙ', 'البند', 'بيان'],\n 'unit' => ['Ø§Ù„ÙˆØØ¯Ø©', 'unit', 'uom', 'u/m'],\n 'quantity' => ['الكمية', 'qty', 'quantity', 'q.ty'],\n 'unit_price' => ['سعر Ø§Ù„ÙˆØØ¯Ø©', 'u/price', 'unit price', 'unit rate', 'rate', 'price'],\n 'total_price' => ['السعر الإجمالي', 'total amount', 'total price', 'amount', 'الإجمالي'],\n ];\n\n $best = null;\n $bestScore = 0;\n\n for ($r = 0; $r < min(30, count($grid)); $r++) {\n $map = [];\n $score = 0;\n\n foreach ($grid[$r] as $c => $value) {\n $cell = mb_strtolower(trim((string) $value));\n if ($cell === '') {\n continue;\n }\n\n foreach ($keywords as $field => $list) {\n foreach ($list as $kw) {\n if (str_contains($cell, mb_strtolower($kw))) {\n if (! isset($map[$field])) {\n $map[$field] = $c;\n $score++;\n }\n break 2;\n }\n }\n }\n }\n\n if ($score > $bestScore) {\n $bestScore = $score;\n $best = ['row' => $r, 'map' => $map];\n }\n }\n\n if ($bestScore >= 3) {\n return $best;\n }\n\n // Allow simpler BOQ sheets that only define a description column plus one\n // other BOQ-related column, e.g. Item + Description or Description + Qty.\n if ($bestScore === 2 && isset($best['map']['description']) && (\n isset($best['map']['item_code']) ||\n isset($best['map']['quantity']) ||\n isset($best['map']['unit'])\n )) {\n return $best;\n }\n\n return null;\n }\n\n private function cell(array $row, ?int $index): string\n {\n if ($index === null) {\n return '';\n }\n return trim((string) ($row[$index] ?? ''));\n }\n\n private function number(string $value): ?float\n {\n $value = trim(str_replace([',', 'SAR', 'ر.س'], '', $value));\n if ($value === '' || ! is_numeric($value)) {\n return null;\n }\n return (float) $value;\n }\n\n private function trimTrailingEmptyCells(array $row): array\n {\n while (! empty($row) && trim((string) end($row)) === '') {\n array_pop($row);\n }\n return array_values($row);\n }\n\n private function isTotalOrHeaderRow(string $description): bool\n {\n $d = mb_strtolower(trim($description));\n\n if ($d === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $d)) {\n return true;\n }\n\n // Section totals / summaries\n if (preg_match('/(اجمالي|إجمالي|المجموع|grand\\s*total|sub\\s*total|total\\s*amount|total\\s*price)/iu', $d)) {\n return true;\n }\n\n // Bare \"Total\" lines, optionally with a currency suffix, e.g. \"Total\",\n // \"Total (SAR)\", \"Total cost\", \"Total (SAR) unit cost\".\n if (preg_match('/^total\\b[\\s\\(\\):a-z]*$/iu', $d)) {\n return true;\n }\n\n // Pure floor / level labels used as section headers\n if (preg_match('/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor)\\s*$/iu', $d)) {\n return true;\n }\n\n // Division / section header lines (e.g. \"Division - 02: Wood Works\")\n if (preg_match('/^division\\s*([-:]|\\d)/iu', $d)) {\n return true;\n }\n\n return false;\n }\n\n /**\n * Detect dedicated summary / cost-abstract sheets by their name. These hold\n * per-discipline rollups and grand totals (e.g. a \"Summary (Dry Cost)\" tab),\n * never procurable supply items, so they are skipped wholesale.\n */\n private function isSummarySheet(string $name): bool\n {\n $n = mb_strtolower(trim($name));\n\n if ($n === '') {\n return false;\n }\n\n return (bool) preg_match(\n '/\\b(summary|abstract|cost\\s*summary|grand\\s*summary|recap(?:itulation)?|bill\\s*summary|boq\\s*summary|dry\\s*cost)\\b|ملخص|الملخص|الملخّص|ملخّص|اجمالي|إجمالي/iu',\n $n\n );\n }\n\n private function isFloorBreakdownRow(string $itemNo, string $description): bool\n {\n // A row whose Ref column is empty or a dash is a sub-breakdown row (per-floor split).\n $ref = trim($itemNo);\n if ($ref === '' || $ref === '-') {\n // Confirm by checking the description is a location/floor label\n $d = mb_strtolower(trim($description));\n return (bool) preg_match(\n '/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor|\\d+f)\\s*$/iu',\n $d\n );\n }\n return false;\n }\n\n private function rejectedRow(string $description, string $unit, ?float $quantity, string $reason, string $itemNo = '', string $sheet = ''): array\n {\n return [\n 'description' => $description,\n 'quantity' => $quantity ?? 0,\n 'unit' => $unit,\n 'category' => $sheet,\n 'brand' => '',\n 'status' => 'rejected',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 0.0,\n 'raw_data' => [\n 'sheet' => $sheet,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'extraction_type' => 'rejected',\n 'rejection_reason' => $reason,\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function parseBoqWithDeepSeek(UploadedFile|string $file, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured. Please set DEEPSEEK_API_KEY in .env file.');\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n $mime = $this->mimeForExtension($ext);\n\n try {\n // ── Spreadsheets ──────────────────────────────────────────────────\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n $text = $this->spreadsheetToCompactCsvText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not convert spreadsheet to readable text.');\n }\n\n $text = $this->sanitizeUtf8($text);\n\n // A large BOQ exceeds what one request can carry, so send it in\n // sequential slices and merge. Anything that fits goes as-is.\n //\n // Size alone is not a good enough signal: a workbook with several\n // sheets is structurally a big BOQ even when the text is compact,\n // and asking the model to hold every discipline at once is where\n // rows start getting dropped. So split on either signal.\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS || $sheetCount > self::MAX_SHEETS_PER_CALL) {\n Log::info('QuotationAiService: spreadsheet routed to chunked parsing.', [\n 'chars' => mb_strlen($text),\n 'sheets' => $sheetCount,\n ]);\n return $this->parseTextInChunks($text, 'BOQ spreadsheet converted to CSV', $apiKey, $context);\n }\n\n $userContent = \"BOQ spreadsheet converted to CSV:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── PDF ───────────────────────────────────────────────────────────\n if ($ext === 'pdf') {\n $extracted = $this->extractPdfText($absPath);\n\n if ($extracted === self::PDF_LOW_MEMORY) {\n return $this->failure('The server was low on memory while reading this PDF. Please try again in a moment, or convert it to Excel or CSV.');\n }\n\n if ($extracted !== null && mb_strlen(trim($extracted)) > 50) {\n $extracted = $this->sanitizeUtf8($extracted);\n\n // Split on either signal — long text, or many pages. A BOQ\n // spanning several pages is a multi-section document even if\n // each page is sparse.\n if (mb_strlen($extracted) > self::TEXT_CHUNK_CHARS\n || $this->lastPdfPageCount > self::MAX_PDF_PAGES_PER_CALL) {\n Log::info('QuotationAiService: PDF routed to chunked parsing.', [\n 'chars' => mb_strlen($extracted),\n 'pages' => $this->lastPdfPageCount,\n ]);\n return $this->parseTextInChunks($extracted, 'BOQ PDF extracted text', $apiKey, $context, 'application/pdf');\n }\n\n $userContent = \"BOQ PDF extracted text:\\n\\n\" . $extracted . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'application/pdf');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // No text yet — smalot threw its \"$obj\" bug and pdftotext is not\n // installed. Last resort before giving up: OCR.space, a hosted\n // reader that takes the PDF and returns its text. Needs no local\n // binary and no vision key, and still feeds DeepSeek afterwards.\n $ocrText = $this->extractPdfTextViaOcrSpace($absPath);\n\n if ($ocrText !== null && mb_strlen(trim($ocrText)) > 50) {\n $ocrText = $this->sanitizeUtf8($ocrText);\n $userContent = \"BOQ PDF extracted text:\\n\\n\" . $ocrText . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'application/pdf');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // Every reader failed. Which one to blame changes the advice: a\n // missing OCR key is ours to fix and no amount of re-uploading\n // helps, so telling the user to convert the file would send them\n // chasing a problem they cannot solve.\n Log::error('QuotationAiService: every PDF reader failed.', [\n 'file' => basename($absPath),\n 'bytes' => @filesize($absPath) ?: 0,\n 'pages' => $this->lastPdfPageCount,\n 'pdftotext' => $this->pdftotextPath() !== null,\n 'ocr_key_set' => ((string) config('services.ocrspace.key', '')) !== ''\n && config('services.ocrspace.key') !== 'helloworld',\n ]);\n\n return $this->failure('Could not read this PDF. It may be a scanned image, or use a format our reader cannot open. Please convert it to Excel or CSV and upload again.');\n }\n\n // -- Word documents (docx) ---------------------------------------------------\n if (in_array($ext, ['docx', 'doc'], true)) {\n $text = $this->extractDocxText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not extract text from the Word document. Please convert it to Excel, CSV, or PDF.');\n }\n $text = $this->sanitizeUtf8($text);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS) {\n return $this->parseTextInChunks($text, 'BOQ Word document extracted text', $apiKey, $context);\n }\n\n $userContent = \"BOQ Word document extracted text:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── Images ──────────────────────────────────────────────────────────────\n // DeepSeek cannot receive images. A vision-capable AI processes the image\n // directly (no OCR) — configure GROQ_API_KEY or GEMINI_API_KEY in .env.\n $imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif', 'heic', 'heif'];\n if (in_array($ext, $imageExts, true)) {\n $bytes = (string) file_get_contents($absPath);\n return $this->callDeepSeekVision($bytes, $mime, $context);\n }\n\n return $this->failure('Unsupported file type. Please upload an Excel, CSV, PDF, or image file.');\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: DeepSeek fallback failed.', ['message' => $e->getMessage()]);\n return $this->failure('An error occurred while processing the file with AI: ' . $e->getMessage());\n }\n }\n\n private function deepSeekModel(): string\n {\n return (string) config('services.deepseek.model', 'deepseek-chat');\n }\n\n /**\n * Remove or replace any non-UTF-8 / malformed byte sequences so json_encode never fails.\n */\n private function sanitizeUtf8(string $text): string\n {\n // Replace invalid byte sequences with the UTF-8 replacement character\n $clean = mb_convert_encoding($text, 'UTF-8', 'UTF-8');\n // Strip any remaining non-printable control characters (except tab/newline/CR)\n $clean = (string) preg_replace('/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]/u', '', $clean);\n return $clean;\n }\n\n /**\n * Extract plain text from a Word docx file using ZipArchive (no external library needed).\n * Reads word/document.xml and strips XML tags, preserving paragraph and table structure.\n */\n private function extractDocxText(string $absPath): ?string\n {\n try {\n $zip = new \\ZipArchive();\n if ($zip->open($absPath) !== true) {\n return null;\n }\n\n $xml = $zip->getFromName('word/document.xml');\n $zip->close();\n\n if ($xml === false || $xml === '') {\n return null;\n }\n\n // Add newlines before paragraph/table-row/table-cell tags so structure is preserved\n $xml = preg_replace('/<w:p[ >]/', \"\\n<w:p>\", $xml) ?? $xml;\n $xml = preg_replace('/<w:tr[ >]/', \"\\n<w:tr>\", $xml) ?? $xml;\n $xml = preg_replace('/<\\/w:tc>/', \"\\t\", $xml) ?? $xml;\n\n $text = strip_tags($xml);\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n return trim($text) !== '' ? trim($text) : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: DOCX parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Extract plain text from a PDF using smalot/pdfparser (pure PHP, no binary required).\n */\n /**\n * The process memory limit in bytes, or 0 when unlimited.\n *\n * memory_limit is a shorthand string (\"128M\", \"1G\", \"-1\"), so it has to be\n * expanded before it can be compared against anything.\n */\n private function memoryLimitBytes(): int\n {\n $raw = trim((string) ini_get('memory_limit'));\n\n if ($raw === '' || $raw === '-1') {\n return 0;\n }\n\n $value = (int) $raw;\n\n return match (strtolower(substr($raw, -1))) {\n 'g' => $value * 1024 * 1024 * 1024,\n 'm' => $value * 1024 * 1024,\n 'k' => $value * 1024,\n default => $value,\n };\n }\n\n private function extractPdfText(string $absPath): ?string\n {\n // The parser loads and decompresses the whole document before any of our\n // chunking gets a look at it, and it does so with no memory ceiling of\n // its own. A big PDF exhausts the limit inside gzuncompress() — a fatal\n // error, not an exception, so the catch below cannot help and the worker\n // dies outright. Refuse the file first instead.\n $size = @filesize($absPath) ?: 0;\n\n if ($size > self::MAX_PDF_BYTES) {\n Log::warning('QuotationAiService: PDF too large to parse in-process.', [\n 'bytes' => $size,\n 'limit' => self::MAX_PDF_BYTES,\n ]);\n\n return null;\n }\n\n // Headroom check: parsing routinely needs several times the file size\n // once streams are decompressed. If that would not fit in what is left,\n // stop here rather than taking the worker down with us.\n $limit = $this->memoryLimitBytes();\n\n if ($limit > 0) {\n // Try to reclaim memory a previous job left behind before deciding\n // there is not enough. Without this, a worker sitting near its limit\n // after a big job could refuse a tiny PDF for no real reason.\n gc_collect_cycles();\n\n $available = $limit - memory_get_usage(true);\n\n if ($available < $size * self::PDF_MEMORY_FACTOR) {\n Log::warning('QuotationAiService: not enough memory left to parse this PDF.', [\n 'bytes' => $size,\n 'available' => $available,\n 'needed' => $size * self::PDF_MEMORY_FACTOR,\n ]);\n\n // A distinct sentinel, so the caller can tell \"no memory\" apart\n // from \"no text\" and show the right message instead of blaming\n // the file for being scanned.\n return self::PDF_LOW_MEMORY;\n }\n }\n\n try {\n $parser = new \\Smalot\\PdfParser\\Parser();\n $pdf = $parser->parseFile($absPath);\n\n // Recorded for the chunking decision: a long PDF is structurally a\n // big BOQ even when its extracted text is short.\n try {\n $this->lastPdfPageCount = count($pdf->getPages());\n } catch (\\Throwable) {\n $this->lastPdfPageCount = 0;\n }\n\n $text = $pdf->getText();\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n return $text !== '' ? $text : null;\n } catch (\\Throwable $e) {\n // Class, file and line, not just the message — a bare message left\n // the actual cause of a PDF failure guessable at best.\n Log::warning('QuotationAiService: PDF parsing failed.', [\n 'exception' => $e::class,\n 'error' => $e->getMessage(),\n 'at' => $e->getFile() . ':' . $e->getLine(),\n ]);\n\n // smalot/pdfparser throws \"Invalid object reference for $obj.\" on\n // some perfectly valid PDFs — a known bug in the library, not a\n // problem with the file. Fall back to the pdftotext binary, which\n // reads those documents fine.\n return $this->extractPdfTextViaBinary($absPath);\n }\n }\n\n /**\n * Extract PDF text through the OCR.space API.\n *\n * The fallback of last resort when smalot throws and pdftotext is not on the\n * server. OCR.space is a hosted reader: the file is uploaded and its text\n * comes back, so it needs nothing installed locally. The free tier caps\n * uploads at 1 MB and 3 pages — enough for a typical BOQ, and skipped with a\n * clear log line when the file is over that, rather than a wasted call.\n */\n private function extractPdfTextViaOcrSpace(string $absPath): ?string\n {\n $apiKey = (string) config('services.ocrspace.key', '');\n\n if ($apiKey === '') {\n return null;\n }\n\n // The free tier caps the FILE at 3 MB (the 1 MB figure is the per-page\n // limit). Capping at 1 MB here turned readable BOQs away without trying.\n $size = @filesize($absPath) ?: 0;\n if ($size > 3 * 1024 * 1024) {\n Log::warning('QuotationAiService: PDF too large for the OCR fallback.', [\n 'bytes' => $size,\n 'limit' => 3 * 1024 * 1024,\n ]);\n return null;\n }\n\n // 'helloworld' is OCR.space's public demo key, and it is the config\n // default. It is rate-limited to near-nothing and starts rejecting real\n // traffic almost immediately, which surfaces to the user as the generic\n // \"could not read this PDF\" — indistinguishable from a genuinely\n // unreadable file. Say so plainly instead.\n if ($apiKey === 'helloworld') {\n Log::warning('QuotationAiService: OCR is using the shared demo key; PDF reading will fail under any real use. Set OCRSPACE_API_KEY in .env (free at https://ocr.space/ocrapi/freekey).');\n }\n\n try {\n $response = Http::timeout(120)\n ->attach('file', (string) file_get_contents($absPath), basename($absPath))\n ->post('https://api.ocr.space/parse/image', [\n 'apikey' => $apiKey,\n 'filetype' => 'PDF',\n 'language' => 'eng',\n 'isTable' => 'true', // BOQs are tabular; keep columns\n 'OCREngine' => '2',\n 'scale' => 'true',\n ]);\n\n if (! $response->successful()) {\n Log::warning('QuotationAiService: OCR.space request failed.', [\n 'status' => $response->status(),\n ]);\n return null;\n }\n\n $json = $response->json();\n\n // OCR.space reports its own errors in the body with a 200 status.\n if (($json['IsErroredOnProcessing'] ?? false) === true) {\n Log::warning('QuotationAiService: OCR.space could not process the PDF.', [\n 'message' => $json['ErrorMessage'] ?? 'unknown',\n ]);\n return null;\n }\n\n $text = '';\n foreach ($json['ParsedResults'] ?? [] as $page) {\n $text .= ($page['ParsedText'] ?? '') . \"\\n\";\n }\n\n $text = trim((string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text));\n\n if ($text !== '') {\n Log::info('QuotationAiService: recovered PDF text via OCR.space.', [\n 'path' => basename($absPath),\n 'chars' => mb_strlen($text),\n ]);\n }\n\n return $text !== '' ? $text : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: OCR.space fallback threw.', [\n 'error' => $e->getMessage(),\n ]);\n return null;\n }\n }\n\n /**\n * Locate the pdftotext binary.\n *\n * On shared hosting it is rarely on the system PATH, so a user-installed\n * copy — dropped in ~/bin, ~/.local/bin, or the project's vendor/bin — has\n * to be found explicitly. An escaped, quoted absolute path is returned so it\n * is safe to interpolate into the command.\n *\n * PDFTOTEXT_PATH in .env wins, for a copy installed somewhere non-standard.\n */\n private function pdftotextPath(): ?string\n {\n $configured = trim((string) env('PDFTOTEXT_PATH', ''));\n\n $candidates = array_filter([\n $configured !== '' ? $configured : null,\n ($home = getenv('HOME')) ? $home . '/bin/pdftotext' : null,\n $home ? $home . '/.local/bin/pdftotext' : null,\n base_path('vendor/bin/pdftotext'),\n '/usr/bin/pdftotext',\n '/usr/local/bin/pdftotext',\n ]);\n\n foreach ($candidates as $path) {\n if (is_file($path) && is_executable($path)) {\n return escapeshellarg($path);\n }\n }\n\n // Fall back to a bare name, in case it is on PATH after all.\n $onPath = @shell_exec('command -v pdftotext 2>/dev/null');\n\n return is_string($onPath) && trim($onPath) !== '' ? 'pdftotext' : null;\n }\n\n private function extractPdfTextViaBinary(string $absPath): ?string\n {\n // shell_exec is commonly disabled on shared hosting; bail cleanly so the\n // caller falls through to the vision path or a clear error.\n if (! function_exists('shell_exec')\n || in_array('shell_exec', array_map('trim', explode(',', (string) ini_get('disable_functions'))), true)) {\n return null;\n }\n\n $binary = $this->pdftotextPath();\n\n if ($binary === null) {\n Log::warning('QuotationAiService: pdftotext binary not found.', [\n 'looked_in' => 'PATH, PDFTOTEXT_PATH, ~/bin, ~/.local/bin, vendor/bin',\n ]);\n return null;\n }\n\n // `-layout` keeps columns roughly aligned, which matters for a BOQ; the\n // trailing \"-\" writes to stdout instead of a file.\n $command = sprintf('%s -layout -enc UTF-8 %s - 2>/dev/null', $binary, escapeshellarg($absPath));\n\n $output = @shell_exec($command);\n\n if (! is_string($output) || trim($output) === '') {\n Log::warning('QuotationAiService: pdftotext fallback produced no text.', [\n 'path' => basename($absPath),\n ]);\n return null;\n }\n\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $output);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n\n if ($text !== '') {\n Log::info('QuotationAiService: recovered PDF text via pdftotext fallback.', [\n 'path' => basename($absPath),\n 'chars' => mb_strlen($text),\n ]);\n }\n\n return $text !== '' ? $text : null;\n }\n\n /**\n * Send a vision request to a vision-capable AI model.\n * DeepSeek's commercial API (api.deepseek.com) does NOT support image inputs — any model.\n * Priority order (first configured key wins):\n * 1. GROQ_API_KEY → Groq llama-4-scout (free: https://console.groq.com)\n * 2. GEMINI_API_KEY → Google Gemini Flash (free: https://aistudio.google.com/apikey)\n * 3. VISION_API_KEY → any OpenAI-compatible endpoint (OpenAI, OpenRouter …)\n */\n private function callDeepSeekVision(string $bytes, string $mime, array $context): array\n {\n // ── 1. Groq (free, reliable vision) ─────────────────────────────────────\n if ((string) config('services.groq.key', '') !== '') {\n $visionKey = (string) config('services.groq.key');\n $visionBaseUrl = 'https://api.groq.com/openai/v1';\n $visionModel = (string) config('services.groq.model', 'meta-llama/llama-4-scout-17b-16e-instruct');\n }\n // ── 2. Google Gemini ─────────────────────────────────────────────────────\n elseif ((string) config('services.gemini.key', '') !== '') {\n $visionKey = (string) config('services.gemini.key');\n $visionBaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai';\n $visionModel = (string) config('services.gemini.model', 'gemini-2.0-flash');\n }\n // ── 3. Generic VISION_API_KEY (OpenAI, OpenRouter, …) ───────────────────\n elseif ((string) config('services.vision.key', '') !== '') {\n $visionKey = (string) config('services.vision.key');\n $visionBaseUrl = rtrim((string) config('services.vision.base_url', 'https://openrouter.ai/api/v1'), '/');\n $visionModel = (string) config('services.vision.model', 'google/gemini-flash-1.5-8b');\n }\n else {\n return $this->failure(\n 'Image processing is not available. DeepSeek\\'s API does not support images. ' .\n 'Get a free key at https://console.groq.com and add GROQ_API_KEY to your .env file.'\n );\n }\n\n $dataUrl = \"data:{$mime};base64,\" . base64_encode($bytes);\n $prompt = $this->buildDeepSeekPrompt($context, $mime);\n\n $userContent = [\n ['type' => 'text', 'text' => $prompt],\n ['type' => 'image_url', 'image_url' => ['url' => $dataUrl]],\n ];\n\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders([\n 'Authorization' => 'Bearer ' . $visionKey,\n 'HTTP-Referer' => config('app.url', 'https://qimta.sa'),\n 'X-Title' => 'Qimta Platform',\n ])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post($visionBaseUrl . '/chat/completions', [\n 'model' => $visionModel,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 16384,\n 'temperature' => 0,\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('Vision API connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: Vision API HTTP error.', [\n 'model' => $visionModel,\n 'base_url' => $visionBaseUrl,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n return $this->failure(\"Vision API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n return $this->parseDeepSeekJsonResponse((string) $response->json('choices.0.message.content'));\n }\n\n private function callDeepSeekChat(string $userContent, string $key, string $model): array\n {\n // Ensure the PHP script outlives the HTTP call (+ a margin for parsing the\n // response and writing to the DB) so the worker/web request is not killed\n // mid-flight, which surfaces to users as a generic \"timeout or unavailable\".\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders(['Authorization' => 'Bearer ' . $key])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 32768,\n 'temperature' => 0,\n 'response_format' => ['type' => 'json_object'],\n 'user' => 'Qimta_Platform',\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('DeepSeek connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: DeepSeek HTTP error.', [\n 'model' => $model,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n if ($response->status() === 401) {\n return $this->failure('DeepSeek API key is invalid or unauthorised. Please update DEEPSEEK_API_KEY in .env.', false);\n }\n return $this->failure(\"DeepSeek API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n $text = (string) $response->json('choices.0.message.content');\n return $this->parseDeepSeekJsonResponse($text);\n }\n\n /**\n * Parse a document too large for one AI call by sending it in slices.\n *\n * A 20 000-row BOQ is several megabytes of text — far beyond what a single\n * request can carry, and beyond what the model can answer without hitting its\n * output token limit. Previously the text was simply cut at 180 000 chars and\n * the remainder discarded: the parse \"succeeded\" while silently dropping most\n * of the file. Now the text is split and each slice parsed on its own call,\n * with the items merged.\n *\n * Splitting happens on line boundaries so a BOQ row is never cut in half.\n * Slices run sequentially rather than pooled: DeepSeek rate-limits hard on\n * concurrent large prompts, and this already runs on the queue where wall\n * time is cheap.\n *\n * A slice that fails does not fail the document — its rows are lost, but the\n * rest are kept and the shortfall is logged. Total failure is reported only\n * when every slice fails.\n *\n * @param string $label How the payload is introduced to the model.\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n /**\n * Split a spreadsheet into the slices the AI will be asked to read.\n *\n * Exposed so the work can be spread over several queue jobs instead of one\n * job walking every slice in sequence. Returns an empty array when the file\n * is small enough to go in a single call, or cannot be read.\n *\n * @return array<int, string>\n */\n public function chunkSpreadsheet(string $absPath): array\n {\n ini_set('memory_limit', '2048M');\n set_time_limit(1800);\n\n $text = $this->spreadsheetToCompactCsvText($absPath);\n\n if ($text === null || trim($text) === '') {\n return [];\n }\n\n $text = $this->sanitizeUtf8($text);\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) <= self::TEXT_CHUNK_CHARS && $sheetCount <= self::MAX_SHEETS_PER_CALL) {\n return [];\n }\n\n $chunks = $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n\n // Free the source text: the slices are a second full copy of it, and on\n // a large workbook that doubling — on top of PhpSpreadsheet's grids —\n // is what exhausted memory here.\n unset($text);\n\n Log::info('QuotationAiService: chunked spreadsheet.', [\n 'chunks' => count($chunks),\n 'peak_mb' => round(memory_get_peak_usage(true) / 1048576),\n ]);\n\n return $chunks;\n }\n\n /**\n * Split a spreadsheet straight to disk, one file per slice.\n *\n * chunkSpreadsheet() returns every slice as an array, which is a second\n * full copy of the document in memory on top of PhpSpreadsheet's grids.\n * This hands each slice to $write and drops it immediately, so peak memory\n * is one slice rather than the whole file twice.\n *\n * @param callable(int, string): void $write ($part, $chunk)\n * @return int number of slices written; 0 when the file needs no split\n */\n public function chunkSpreadsheetToDisk(string $absPath, callable $write): int\n {\n ini_set('memory_limit', '2048M');\n set_time_limit(1800);\n\n $text = $this->spreadsheetToCompactCsvText($absPath);\n\n if ($text === null || trim($text) === '') {\n return 0;\n }\n\n $text = $this->sanitizeUtf8($text);\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) <= self::TEXT_CHUNK_CHARS && $sheetCount <= self::MAX_SHEETS_PER_CALL) {\n return 0;\n }\n\n $part = 0;\n foreach ($this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS) as $chunk) {\n $write(++$part, $chunk);\n }\n\n unset($text);\n\n Log::info('QuotationAiService: chunked spreadsheet to disk.', [\n 'chunks' => $part,\n 'peak_mb' => round(memory_get_peak_usage(true) / 1048576),\n ]);\n\n return $part;\n }\n\n /**\n * Parse one slice produced by chunkSpreadsheet().\n *\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n public function parseChunk(string $chunk, int $part, int $total, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured.');\n }\n\n // Same framing parseTextInChunks() uses, so a slice is never mistaken\n // for a complete BOQ and totals are not inferred from a fragment.\n $userContent = \"BOQ spreadsheet converted to CSV (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, 'text/plain');\n\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n private function parseTextInChunks(\n string $text,\n string $label,\n string $apiKey,\n array $context,\n string $mime = 'text/plain',\n ): array {\n $chunks = $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n $total = count($chunks);\n\n Log::info('QuotationAiService: large document split for parsing.', [\n 'chars' => mb_strlen($text),\n 'chunks' => $total,\n ]);\n\n // Announce the split before the first slice is sent, so the UI can show\n // how many parts there are instead of sitting on a generic message for\n // however long the first AI call takes.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)(0, $total);\n }\n\n $items = [];\n $rejected = [];\n $failed = 0;\n $lastError = null;\n\n foreach ($chunks as $index => $chunk) {\n $part = $index + 1;\n\n // Report progress so a 28-slice parse does not look frozen to the user.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)($part, $total);\n }\n\n // Tell the model this is a fragment, so it does not try to reconcile\n // totals or treat a mid-document slice as a complete BOQ.\n $userContent = \"{$label} (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, $mime);\n\n $result = $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n\n if (! ($result['success'] ?? false)) {\n $failed++;\n $lastError = $result['error'] ?? 'Chunk parsing failed.';\n Log::warning('QuotationAiService: chunk failed, continuing.', [\n 'part' => $part,\n 'of' => $total,\n 'error' => $lastError,\n ]);\n continue;\n }\n\n foreach ($result['items'] as $item) {\n $items[] = $item;\n }\n foreach ($result['rejected'] ?? [] as $row) {\n $rejected[] = $row;\n }\n\n // Hand this slice's rows straight to the caller so they can be shown\n // while the remaining slices are still being parsed.\n if ($this->onChunkItems !== null && $result['items'] !== []) {\n ($this->onChunkItems)($result['items'], $part, $total);\n }\n }\n\n // Every slice failed — there is nothing to show, so report the failure.\n if ($failed === $total) {\n return $this->failure($lastError ?? 'Could not parse the document.');\n }\n\n if ($failed > 0) {\n Log::error('QuotationAiService: document parsed with missing parts.', [\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n 'items_kept' => count($items),\n ]);\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'service_unavailable' => false,\n 'partial' => $failed > 0,\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n ];\n }\n\n /**\n * Split text into slices of at most $limit characters, breaking only at\n * newlines so no BOQ row is ever cut in half.\n *\n * A single line longer than the limit (rare — a pathological cell) is passed\n * through oversized rather than severed mid-row.\n *\n * @return array<int, string>\n */\n private function splitOnLineBoundaries(string $text, int $limit): array\n {\n $chunks = [];\n $current = '';\n\n // Sheet a chunk starts in the middle of. Repeated at the top of the next\n // chunk so the model always knows which sheet the rows belong to — the\n // header would otherwise be stranded in the previous slice, and rows\n // would arrive with no discipline context.\n $currentSheet = null;\n\n foreach (explode(\"\\n\", $text) as $line) {\n $isSheetHeader = str_starts_with($line, 'Sheet: ');\n\n // Start a new chunk at every sheet boundary. Sheets are usually\n // separate disciplines, and the whole point of splitting a multi-tab\n // workbook is to give the model one discipline at a time — a size-only\n // split would leave a compact 3-tab file as a single chunk, silently\n // undoing the sheet gate that routed it here.\n if ($isSheetHeader && $current !== '') {\n $chunks[] = $current;\n $current = $line;\n $currentSheet = $line;\n continue;\n }\n\n if ($isSheetHeader) {\n $currentSheet = $line;\n }\n\n $candidate = $current === '' ? $line : $current . \"\\n\" . $line;\n\n if (mb_strlen($candidate) > $limit && $current !== '') {\n $chunks[] = $current;\n\n // Carry the sheet header over, unless this line is itself one.\n $current = ($currentSheet !== null && ! str_starts_with($line, 'Sheet: '))\n ? $currentSheet . \" (continued)\\n\" . $line\n : $line;\n continue;\n }\n\n $current = $candidate;\n }\n\n if (trim($current) !== '') {\n $chunks[] = $current;\n }\n\n return $chunks;\n }\n\n /**\n * Shared JSON response parser used by both chat and vision paths.\n */\n private function parseDeepSeekJsonResponse(string $text): array\n {\n if ($text === '') {\n return $this->failure('DeepSeek returned an empty response.');\n }\n\n // Strip markdown code fences if the model wrapped the JSON.\n if (preg_match('/```(?:json)?\\s*([\\s\\S]*?)\\s*```/i', $text, $m)) {\n $text = $m[1];\n }\n // Narrow to the outermost JSON object/array.\n if (preg_match('/[\\{\\[][\\s\\S]*[\\}\\]]/s', $text, $m)) {\n $text = $m[0];\n }\n $text = trim($text);\n\n $decoded = json_decode($text, true);\n\n // The response can be truncated when the model hits the token limit,\n // leaving an unterminated JSON string/object. Try to repair it before\n // giving up so we can still salvage the items extracted so far.\n if (! is_array($decoded)) {\n $repaired = $this->repairTruncatedJson($text);\n if ($repaired !== null) {\n $decoded = json_decode($repaired, true);\n }\n }\n\n if (! is_array($decoded)) {\n Log::error('QuotationAiService: DeepSeek non-JSON response.', ['text' => mb_substr($text, 0, 1000)]);\n return $this->failure('DeepSeek returned a non-JSON response. The file may be too large — try splitting it into smaller files.');\n }\n\n $rawItems = $decoded['items'] ?? (array_is_list($decoded) ? $decoded : []);\n $items = [];\n $rejected = [];\n\n foreach ($rawItems as $raw) {\n if (! is_array($raw)) {\n continue;\n }\n $item = $this->normaliseAiItem($raw);\n if ($item === null) {\n continue;\n }\n $check = $this->boqCleaner->filterItem((string) $item['description']);\n if ($check['keep'] ?? false) {\n $items[] = $item;\n } else {\n $item['status'] = 'rejected';\n $item['raw_data']['rejection_reason'] = $check['rejection_reason'] ?? 'Rejected by cleaner';\n $rejected[] = $item;\n }\n }\n\n if (empty($items)) {\n Log::info('QuotationAiService: DeepSeek returned 0 accepted items.', [\n 'raw_items_count' => count($rawItems),\n 'rejected_count' => count($rejected),\n 'first_rejected' => ! empty($rejected) ? ($rejected[0]['description'] ?? '') : '',\n ]);\n }\n\n return ['success' => true, 'items' => $items, 'rejected' => $rejected, 'error' => null];\n }\n\n /**\n * Best-effort repair of a truncated JSON string produced when the model hits\n * its token limit mid-output. Drops any incomplete trailing object, then\n * closes the open string/array/object brackets so the result is valid JSON.\n *\n * Returns the repaired JSON string, or null if it cannot be salvaged.\n */\n private function repairTruncatedJson(string $text): ?string\n {\n if ($text === '') {\n return null;\n }\n\n // Walk the string tracking structural state, ignoring everything inside\n // string literals (and their escapes). Remember the last position where\n // we were back at the top of the \"items\" array between complete elements,\n // so we can cut a half-written trailing object.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($text);\n $lastSafeCut = null; // index just after a completed array element\n\n for ($i = 0; $i < $len; $i++) {\n $ch = $text[$i];\n\n if ($inString) {\n if ($escaped) {\n $escaped = false;\n } elseif ($ch === '\\\\') {\n $escaped = true;\n } elseif ($ch === '\"') {\n $inString = false;\n }\n continue;\n }\n\n if ($ch === '\"') {\n $inString = true;\n } elseif ($ch === '{' || $ch === '[') {\n $stack[] = $ch;\n } elseif ($ch === '}' || $ch === ']') {\n array_pop($stack);\n } elseif ($ch === ',' && ! empty($stack) && end($stack) === '[') {\n // Separator directly inside an array: a clean boundary between\n // elements (the items array, whatever its nesting depth).\n $lastSafeCut = $i;\n }\n }\n\n // If we are still inside a string, the last element is incomplete — fall\n // back to the last clean array boundary if we have one.\n $candidate = $text;\n if ($inString && $lastSafeCut !== null) {\n $candidate = substr($text, 0, $lastSafeCut);\n } else {\n // Drop a dangling partial object after the last completed element.\n $candidate = rtrim($candidate);\n if ($lastSafeCut !== null && substr(rtrim($candidate), -1) !== '}' && substr(rtrim($candidate), -1) !== ']') {\n $candidate = substr($text, 0, $lastSafeCut);\n }\n }\n\n // Recompute the open brackets for the (possibly trimmed) candidate and\n // append the matching closers in reverse order.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($candidate);\n for ($i = 0; $i < $len; $i++) {\n $ch = $candidate[$i];\n if ($inString) {\n if ($escaped) { $escaped = false; }\n elseif ($ch === '\\\\') { $escaped = true; }\n elseif ($ch === '\"') { $inString = false; }\n continue;\n }\n if ($ch === '\"') { $inString = true; }\n elseif ($ch === '{' || $ch === '[') { $stack[] = $ch; }\n elseif ($ch === '}' || $ch === ']') { array_pop($stack); }\n }\n\n $candidate = rtrim($candidate);\n // Remove a trailing comma left after cutting an element.\n $candidate = rtrim($candidate, ',');\n\n $closers = '';\n for ($i = count($stack) - 1; $i >= 0; $i--) {\n $closers .= ($stack[$i] === '{') ? '}' : ']';\n }\n\n $repaired = $candidate . $closers;\n\n // Validate before returning.\n return is_array(json_decode($repaired, true)) ? $repaired : null;\n }\n\n private function spreadsheetToCompactCsvText(string $absPath): ?string\n {\n $sheets = $this->spreadsheetToGrids($absPath);\n if (empty($sheets)) {\n return null;\n }\n\n $output = '';\n $kept = 0;\n $dropped = 0;\n $nonSupply = 0;\n $duplicates = 0;\n\n /** @var array<string, true> Fingerprints of rows already emitted. */\n $seenRows = [];\n\n foreach ($sheets as $sheet) {\n // Don't feed summary / cost-abstract sheets to the AI either.\n if ($this->isSummarySheet($sheet['name'])) {\n continue;\n }\n $sheetRows = '';\n foreach ($sheet['grid'] as $row) {\n $row = $this->trimTrailingEmptyCells($row);\n\n if ($this->isNoiseRow($row)) {\n $dropped++;\n continue;\n }\n\n // Drop rows that are definitely not procurable before they cost\n // a single token: section totals, discipline headings,\n // preliminaries, labour-only lines. The same classifier already\n // runs on the AI's output, but by then the tokens are spent — on\n // an 11 200-row BOQ that is most of the bill.\n if ($this->isNonSupplyRow($row)) {\n $dropped++;\n $nonSupply++;\n continue;\n }\n\n // Byte-identical rows tell the AI nothing new. Only exact\n // duplicates are collapsed — description, quantity, unit and all\n // — so nothing is lost: two rows differing in quantity are two\n // different line items and both are sent.\n $fingerprint = $this->rowFingerprint($row);\n\n if (isset($seenRows[$fingerprint])) {\n $duplicates++;\n $dropped++;\n continue;\n }\n $seenRows[$fingerprint] = true;\n\n $sheetRows .= implode(',', array_map([$this, 'csvEscape'], $this->stripBoilerplate($row))) . \"\\n\";\n $kept++;\n }\n\n // A sheet whose rows were all noise contributes nothing but its own\n // header, which would just be another thing for the model to explain.\n if ($sheetRows === '') {\n continue;\n }\n\n $output .= 'Sheet: ' . $sheet['name'] . \"\\n\" . $sheetRows . \"\\n\";\n }\n\n $blank = $dropped - $nonSupply - $duplicates;\n\n Log::info('QuotationAiService: filtered rows before sending to AI.', [\n 'kept' => $kept,\n 'blank' => $blank,\n 'non_supply' => $nonSupply,\n 'duplicates' => $duplicates,\n ]);\n\n if ($dropped > 0) {\n $parts = [];\n if ($blank > 0) { $parts[] = \"{$blank} blank\"; }\n if ($nonSupply > 0) { $parts[] = \"{$nonSupply} non-supply\"; }\n if ($duplicates > 0) { $parts[] = \"{$duplicates} duplicate\"; }\n\n $this->reportStage('Skipped ' . implode(', ', $parts) . \" rows. Sending {$kept}…\");\n }\n\n return $output;\n }\n\n /**\n * True when a row carries no information worth sending to the AI.\n *\n * A real BOQ is mostly formatting: spacer rows, stray borders, a lone\n * section number, a leftover cell from a merged block. They survive\n * trimTrailingEmptyCells() because they are not *entirely* empty, then cost\n * tokens, dilute the prompt, and inflate the number of parts a large file is\n * split into.\n *\n * Deliberately conservative — a row is dropped only when nothing in it could\n * name a product. Anything with letters is kept, because a description is\n * the one field an item cannot do without.\n *\n * @param array<int, mixed> $row\n */\n /**\n * True when a row is definitely not a procurable supply item.\n *\n * Runs BoqCleaningService over the row's longest text cell — the one most\n * likely to be the description — so headings, totals, preliminaries and\n * labour-only lines never reach the AI. The same classifier already vets the\n * AI's *output*; doing it here as well means we stop paying to have those\n * rows read in the first place.\n *\n * Conservative by construction: only rows the classifier positively rejects\n * are dropped. Anything it is unsure about goes to the AI, which is the\n * better judge — a wrongly dropped row is invisible, a wrongly kept one only\n * costs tokens.\n *\n * @param array<int, mixed> $row\n */\n /**\n * Stable identity for a row, used to collapse exact duplicates.\n *\n * Whitespace and case are normalised so \"DN50 PIPE\" and \"dn50 pipe \" are one\n * row, but nothing else is: a differing quantity or unit produces a\n * different fingerprint and both rows are sent.\n *\n * @param array<int, mixed> $row\n */\n private function rowFingerprint(array $row): string\n {\n $parts = array_map(\n fn($cell) => mb_strtolower(preg_replace('/\\s+/u', ' ', trim((string) $cell)) ?? ''),\n $row,\n );\n\n // Drop a leading BOQ sequence number. Otherwise \"1,Pipe,m,100\" and\n // \"2,Pipe,m,100\" fingerprint differently and the dedup never fires —\n // which is exactly the shape every real BOQ has.\n if (isset($parts[0]) && $parts[0] !== '' && preg_match('/^[\\d.]+$/', $parts[0])) {\n array_shift($parts);\n }\n\n return md5(implode('|', $parts));\n }\n\n /**\n * Strip contractual boilerplate from a row's description cell.\n *\n * \"as per drawings\", \"complete with all necessary accessories\" and friends\n * repeat on nearly every line of a real BOQ and carry no information the\n * pricing engine can use — but they are a large share of the tokens.\n *\n * Only the longest text cell is touched, and only when the result is still a\n * usable description: if stripping would leave too little to identify the\n * product, the original is kept. A shorter-but-wrong description is far\n * worse than a verbose one.\n *\n * @param array<int, mixed> $row\n * @return array<int, mixed>\n */\n private function stripBoilerplate(array $row): array\n {\n $index = null;\n $longest = '';\n\n foreach ($row as $i => $cell) {\n $text = trim((string) $cell);\n if (preg_match('/\\p{L}/u', $text) && mb_strlen($text) > mb_strlen($longest)) {\n $longest = $text;\n $index = $i;\n }\n }\n\n // Nothing worth cleaning, or too short to risk it.\n if ($index === null || mb_strlen($longest) < 40) {\n return $row;\n }\n\n try {\n $cleaned = $this->boqCleaner->cleanDescription($longest);\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: boilerplate strip failed, keeping row as-is.', [\n 'error' => $e->getMessage(),\n ]);\n return $row;\n }\n\n // Guard against an over-eager pattern eating the product name: keep the\n // original unless a recognisable description survives.\n if (mb_strlen($cleaned) < 12 || mb_strlen($cleaned) < mb_strlen($longest) * 0.3) {\n return $row;\n }\n\n $row[$index] = $cleaned;\n\n return $row;\n }\n\n private function isNonSupplyRow(array $row): bool\n {\n $description = '';\n foreach ($row as $cell) {\n $text = trim((string) $cell);\n // Longest cell containing letters — numbers are qty/rate columns.\n if (preg_match('/\\p{L}/u', $text) && mb_strlen($text) > mb_strlen($description)) {\n $description = $text;\n }\n }\n\n // No text at all: leave it to isNoiseRow()/the AI rather than guessing.\n if ($description === '' || mb_strlen($description) < 3) {\n return false;\n }\n\n try {\n $verdict = $this->boqCleaner->filterItem($description);\n } catch (\\Throwable $e) {\n // A classifier failure must never silently drop a real product.\n Log::warning('QuotationAiService: pre-filter classifier failed, keeping row.', [\n 'error' => $e->getMessage(),\n ]);\n return false;\n }\n\n return ! ($verdict['keep'] ?? true);\n }\n\n private function isNoiseRow(array $row): bool\n {\n if (empty($row)) {\n return true;\n }\n\n $joined = trim(implode('', array_map(fn($c) => trim((string) $c), $row)));\n\n // Nothing but separators/whitespace.\n if ($joined === '') {\n return true;\n }\n\n // Ruled lines and decorative fills (\"-----\", \"====\", \"___\").\n if (preg_match('/^[\\-_=.*\\s|]+$/u', $joined)) {\n return true;\n }\n\n // Any letter — Latin or Arabic — means this could be a description.\n if (preg_match('/\\p{L}/u', $joined)) {\n return false;\n }\n\n // No letters at all: numbers only. A single number is a stray row\n // number or a dangling total; two or more could be qty/rate/amount\n // belonging to a real line, so keep those for the AI to judge.\n $numericCells = 0;\n foreach ($row as $cell) {\n if (trim((string) $cell) !== '') {\n $numericCells++;\n }\n }\n\n return $numericCells <= 1;\n }\n\n /**\n * Safely convert a PhpSpreadsheet cell value to a string.\n *\n * Cells may contain RichText or, in malformed/heavily-formatted files,\n * embedded objects such as Worksheet\\Drawing that have no __toString().\n * Casting those directly throws \"Object ... could not be converted to string\".\n */\n private function cellValueToString(mixed $value): string\n {\n if ($value === null) {\n return '';\n }\n\n if ($value instanceof \\PhpOffice\\PhpSpreadsheet\\RichText\\RichText) {\n return $value->getPlainText();\n }\n\n if (is_object($value)) {\n // Only stringify objects that explicitly support it; ignore the rest\n // (e.g. Worksheet\\Drawing) instead of crashing.\n return method_exists($value, '__toString') ? (string) $value : '';\n }\n\n if (is_array($value)) {\n return '';\n }\n\n return (string) $value;\n }\n\n private function csvEscape(mixed $value): string\n {\n $value = $this->cellValueToString($value);\n return str_contains($value, ',') || str_contains($value, '\"') || str_contains($value, \"\\n\")\n ? '\"' . str_replace('\"', '\"\"', $value) . '\"'\n : $value;\n }\n\n private function verifyLargeBoqItemsWithAi(array $result): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n Log::warning('QuotationAiService: large BOQ verification skipped, AI key is not configured.');\n return $result;\n }\n\n $items = array_values(array_filter($result['items'] ?? [], 'is_array'));\n if (count($items) <= self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n return $result;\n }\n\n Log::info('QuotationAiService: starting second-pass physical item verification.', [\n 'items_count' => count($items),\n 'threshold' => self::LARGE_BOQ_VERIFICATION_THRESHOLD,\n ]);\n\n $verifiedItems = [];\n $rejectedItems = $result['rejected'] ?? [];\n\n foreach (array_chunk($items, self::LARGE_BOQ_VERIFICATION_CHUNK_SIZE) as $chunkIndex => $chunk) {\n $payload = array_map(fn(array $item) => [\n 'description' => (string) ($item['description'] ?? ''),\n 'quantity' => $item['quantity'] ?? null,\n 'unit' => (string) ($item['unit'] ?? ''),\n 'unit_price' => $item['unit_price'] ?? null,\n 'category' => (string) ($item['category'] ?? ''),\n 'brand' => (string) ($item['brand'] ?? ''),\n ], $chunk);\n\n $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);\n if (! is_string($json)) {\n Log::warning('QuotationAiService: large BOQ verification chunk JSON encode failed.', [\n 'chunk' => $chunkIndex + 1,\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n $prompt = <<<PROMPT\nYou are doing a SECOND PASS verification on a BOQ extraction result.\n\nThe first pass may hallucinate non-products when the BOQ has thousands of rows.\nYour job is to keep ONLY physical, tangible, procurable supply products.\n\nRules:\n- Keep items only if a supplier can actually deliver them as materials/equipment.\n- Reject headings, trade names, systems, scopes, prose, specifications-only rows, totals, labor, installation, testing, commissioning, supervision, drawings, insurance, preliminaries, mobilisation, site works, and vague fragments.\n- Do not invent new products.\n- Do not add items that are not in the input.\n- You may clean a product description, but keep the original quantity, unit, unit_price, category, and brand when available.\n\nInput JSON array:\n{$json}\n\nReturn ONLY valid JSON in this shape:\n{\"items\":[{\"description\":\"\",\"quantity\":1,\"unit\":\"\",\"unit_price\":null,\"category\":\"\",\"brand\":\"\",\"confidence\":0.95}]}\nPROMPT;\n\n try {\n $chunkResult = $this->callDeepSeekChat($prompt, $apiKey, $this->deepSeekModel());\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: large BOQ verification chunk threw an exception.', [\n 'chunk' => $chunkIndex + 1,\n 'message' => $e->getMessage(),\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n if ($chunkResult['success'] ?? false) {\n array_push($verifiedItems, ...($chunkResult['items'] ?? []));\n $rejectedItems = array_merge($rejectedItems, $chunkResult['rejected'] ?? []);\n continue;\n }\n\n Log::warning('QuotationAiService: large BOQ verification chunk failed, keeping original chunk.', [\n 'chunk' => $chunkIndex + 1,\n 'error' => $chunkResult['error'] ?? null,\n ]);\n array_push($verifiedItems, ...$chunk);\n }\n\n if (empty($verifiedItems)) {\n Log::warning('QuotationAiService: second-pass verification returned no items, keeping first-pass result.', [\n 'original_items_count' => count($items),\n ]);\n return $result;\n }\n\n $result['items'] = $verifiedItems;\n $result['rejected'] = $rejectedItems;\n $result['large_boq_verified'] = true;\n\n Log::info('QuotationAiService: second-pass physical item verification completed.', [\n 'before' => count($items),\n 'after' => count($verifiedItems),\n ]);\n\n return $result;\n }\n\n private function normaliseAiItem(array $raw): ?array\n {\n if (! empty($raw['rejected'])) {\n return null;\n }\n\n $description = trim((string) ($raw['cleaned_description'] ?? $raw['product_name'] ?? $raw['description'] ?? ''));\n if ($description === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $description)) {\n return null;\n }\n\n $quantity = is_numeric($raw['quantity'] ?? null) ? (float) $raw['quantity'] : 1;\n $unitPrice = is_numeric($raw['unit_price'] ?? null) ? (float) $raw['unit_price'] : null;\n $totalPrice = is_numeric($raw['total_price'] ?? null) ? (float) $raw['total_price'] : null;\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $specs = is_array($raw['specifications'] ?? null) ? $raw['specifications'] : [];\n\n return [\n 'description' => $description,\n 'quantity' => $quantity,\n 'unit' => (string) ($raw['unit'] ?? ''),\n // Clamped to the column width. The model routinely returns a whole\n // description here — \"Cocktail Unit Ice well adjacent to chopping\n // area with waste chute…\" — and a value over 100 chars made the\n // INSERT fail with SQLSTATE[22001], which silently cost the whole\n // chunk its rows and left the reuse tables empty.\n 'category' => mb_substr((string) ($raw['core_product'] ?? $raw['category'] ?? ''), 0, 100),\n 'brand' => mb_substr((string) ($specs['brand'] ?? $raw['brand'] ?? ''), 0, 100),\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($description),\n 'unit_price' => $unitPrice,\n 'confidence' => is_numeric($raw['confidence'] ?? null) ? (float) $raw['confidence'] : null,\n 'needs_review' => ! empty($raw['needs_review']),\n 'raw_data' => [\n 'source_item_no' => (string) ($raw['source_item_no'] ?? ''),\n 'original_description' => (string) ($raw['original_description'] ?? $description),\n 'cleaned_description' => $description,\n 'core_product' => (string) ($raw['core_product'] ?? ''),\n 'specifications' => $specs,\n 'extraction_type' => (string) ($raw['extraction_type'] ?? 'supply_only'),\n 'note' => (string) ($raw['note'] ?? $raw['notes'] ?? ''),\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function buildSystemInstruction(): string\n {\n return <<<'TXT'\nYou are a strict BOQ Supply Product Extraction Engine.\nExtract ONLY concrete, tangible, physical products that a supplier can box up and deliver to site — a specific item you could point at and say \"this is the thing being bought\" (e.g. \"Wall mounted LED luminaire 36W\", \"PVC pipe 110mm\", \"Single phase distribution board 12-way\", \"Fire extinguisher CO2 6kg\", \"CCTV dome camera 4MP\").\n\nABSOLUTELY REJECT abstract concepts, disciplines, categories, section names, and trade headings. These are NOT products even though they appear as rows:\n- Bare discipline/trade words: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", \"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\".\n- Summary / total / abstract / \"dry cost\" rows of any kind.\n- Anything that names a system, scope, or category instead of one buyable item.\n\nRule of thumb: if you cannot name a unit and physically hand the item over (a piece, a box, a length, a unit of equipment), REJECT it. A word that describes a field of work, a system, or a section of the BOQ is NEVER a product.\nReject totals, headings, general requirements, installation-only, testing, commissioning, supervision, mobilization, civil works, labor, and vague rows.\nFor Supply & Install rows, keep only the supply product and remove install/labor wording.\nReturn only valid JSON: {\"items\":[{\"source_item_no\":null,\"original_description\":\"\",\"cleaned_description\":\"\",\"core_product\":\"\",\"specifications\":{},\"quantity\":0,\"unit\":\"\",\"unit_price\":0,\"total_price\":0,\"extraction_type\":\"supply_only\",\"needs_review\":false,\"note\":\"\",\"rejected\":false,\"rejection_reason\":null,\"confidence\":0.95}]}\nTXT;\n }\n\n private function buildDeepSeekPrompt(array $context = [], string $mime = ''): string\n {\n $projectName = (string) ($context['project_name'] ?? '');\n $projectStatus = (string) ($context['project_status'] ?? '');\n $contextLine = $projectName !== ''\n ? \"Project: {$projectName}\" . ($projectStatus !== '' ? \" ({$projectStatus})\" : '')\n : '';\n $sourceHint = str_starts_with($mime, 'image/')\n ? 'The input is an image of a BOQ table. OCR every cell carefully — read the quantity/count column (العدد, الكمية, Qty) and extract the EXACT numeric value from each row.'\n : 'The input is a BOQ document/spreadsheet.';\n\n return trim(<<<PROMPT\nYou are a procurement specialist. Your task is to extract ONLY tangible, physical supply products from this Bill of Quantities (BOQ).\n\n{$contextLine}\n{$sourceHint}\n\nCRITICAL — MERGE WRAPPED / SPLIT ROWS INTO ONE ITEM:\nBOQ tables extracted from PDF often break ONE item's long description across several\nlines/rows. These continuation lines repeat the SAME quantity, unit and category, and\ntheir text is a grammatical continuation of the previous line (it starts mid-sentence,\nlowercase, or with \"the/and/-\", or is a spec/instruction clause). You MUST reconstruct\nthe original single item:\n- Treat consecutive rows that share the SAME quantity + unit + category as ONE product\n when the later rows are clearly continuation text of the first, NOT new products.\n- Emit ONE item with the merged description and the price counted ONCE. Do NOT repeat\n the same quantity×price for every wrapped line — that multiplies the total wrongly.\n- Example: rows \"The asphalt flooring is to be supplied\" / \"installed in accordance\" /\n \"standard road specifications...\" all with 7,100 m2 are ONE asphalt item, not 3+.\n\nCRITICAL — UNDERSTAND INTENT, DO NOT COPY ROWS LITERALLY:\nYour job is to output WHAT A SUPPLIER MUST ACTUALLY DELIVER, not a copy of the row text.\nRead each description for MEANING and decide how many real, separately-buyable products it\nimplies. One BOQ row can imply SEVERAL products, or a spread of rows can imply ONE product.\n- SPLIT a row into multiple items when it lists distinct products that a supplier would\n buy/price separately — even when joined by commas, \"with\", \"and\", \"including\", \"c/w\",\n \"consisting of\", \"comprising\", or \":\" then a list.\n Example: \"Fire pump set consisting of: fire pump, electric motor, control panel, diesel\n engine\" → FOUR items: (1) fire pump, (2) electric motor, (3) control panel, (4) diesel\n engine — each with its own quantity and priced on its own.\n Example: \"Wash basin complete with mixer tap and bottle trap\" → THREE items: wash basin,\n mixer tap, bottle trap.\n- Give each split product the correct quantity implied by the text (usually the parent\n quantity, unless the text states a different count for a sub-item). Each product is\n priced individually; the line total becomes the SUM of its products.\n- DO NOT split when the extra words are just specifications, dimensions, colours, ratings,\n standards, or a single product's integral parts. \"Steel door 900x2100 with vision panel\n and SS ironmongery\" is usually ONE door product — vision panel and ironmongery are part\n of that door, not separately-bought items. Use judgement: would a supplier issue a\n separate price line for it? If no, keep it as one.\n- Balanced approach: split only when genuinely separate products are present; when in doubt\n about whether something is a separate product vs. an accessory/spec of the main one, keep\n it as a single item. Do not invent products that are not in the text.\n\nSTRICT RULES — include ONLY items that are ALL of the following:\n1. A specific, concrete, tangible product that can be boxed up by a supplier and delivered to site — something you can point at and say \"this exact thing is being bought\" (e.g. \"LED luminaire 36W\", \"PVC pipe 110mm\", \"Distribution board 12-way\", \"CO2 fire extinguisher 6kg\").\n2. Has a real quantity (not 0 or \"varies\" with no meaning).\n3. Has enough description to identify what the product is.\n4. Is an actual PRODUCT, not a sentence, instruction, or specification clause. REJECT rows that are prose such as \"the supervising engineer's instructions\", \"The price includes all materials, labor\", \"Measurement will be based on the geometric cubic meter\", \"necessary steps to complete the work\", \"soil-contacting elements\", \"in compliance\", \"everything necessary\". These are description fragments, not buyable items.\n\nNEVER extract abstract concepts, disciplines, trades, systems, or section/category names — they are NOT products even when they appear as their own row:\n- Bare discipline/trade/system words such as: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", and their Arabic equivalents (\"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\", \"تشطيبات\").\n- Test: if the word names a FIELD OF WORK or a SYSTEM rather than one buyable item with a unit (pcs, m, set, kg…), REJECT it.\n\nEXCLUDE all of the following (do NOT include them):\n- Labor, installation, or workmanship items (e.g. \"install\", \"fix\", \"erect\", \"dismantle\", \"painting\", \"cleaning service\")\n- Project management, overhead, or profit items\n- Insurance, bonds, liability items (e.g. \"bodily injury\", \"third party liability\", \"CAR insurance\")\n- Shop drawings, as-built drawings, method statements, documentation\n- Preliminary items, mobilisation, demobilisation, provisional sums\n- Housekeeping, site clearance, waste removal\n- Floor/level labels (e.g. \"GF Floor\", \"Level 3\") — these are headers, not products\n- Pure dimension specs without a product noun (e.g. \"Dia 32 mm\", \"25 mm\") — reject unless a product name is present\n- Security services, night shifts, rentals, payments\n- Any item whose description is too vague or fragmentary to identify a specific product\n\nEmit ONE JSON item PER real product. A single source row may therefore produce several\nitems (when it lists multiple products) or several rows may collapse into one item (when\nthey are wrapped fragments of the same product). The \"items\" array is your final delivery\nlist, not a row-by-row transcript.\n\nFor each valid physical product return JSON with:\n- description: clean product name only (strip \"supply and install\", \"supply and fix\", installation clauses, floor/spec suffixes). For a split product, use just that product's own name.\n- quantity: the EXACT numeric value from the quantity/count column (العدد, الكمية, Qty, Quantity, No.). NEVER default to 1 unless the quantity column is truly absent or empty.\n- unit: unit of measure (pcs, m, m2, m3, set, no, kg, etc.)\n- category: product category\n- brand: brand/manufacturer if mentioned, else \"\"\n- engineering_required: true if the item needs engineering design before procurement (panel boards, chillers, generators, fire suppression systems, BMS, pumps, AHUs, switchgear, transformers), else false\n\nReturn ONLY a valid JSON object: {\"items\": [...]}. No explanations, no markdown, no extra text.\nPROMPT);\n }\n\n private function mockResponse(): array\n {\n return [\n 'success' => true,\n 'error' => null,\n 'items' => [[\n 'description' => 'Sample BOQ Item',\n 'quantity' => 1,\n 'unit' => 'No',\n 'category' => 'Sample',\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 1,\n 'raw_data' => [],\n 'ai_extracted' => true,\n ]],\n 'rejected' => [],\n ];\n }\n\n private function failure(string $message, bool $serviceUnavailable = false): array\n {\n return [\n 'success' => false,\n 'items' => [],\n 'rejected' => [],\n 'error' => $this->userFacingError($message, $serviceUnavailable),\n 'service_unavailable' => $serviceUnavailable,\n ];\n }\n\n /**\n * Translate any internal error into a neutral, user-safe message.\n *\n * The user must never see the name of the underlying AI/processing engine\n * (DeepSeek, Groq, Gemini, Vision, etc.) or configuration hints like API\n * keys. The detailed message is kept in the logs for debugging only.\n */\n private function userFacingError(string $message, bool $serviceUnavailable = false): string\n {\n // Detect any reference to an AI provider / engine / API config so we can\n // swap it for a generic, branded-neutral message.\n $sensitive = '/deepseek|groq|gemini|vision|openai|openrouter|api[\\s_-]?key|\\.env|non-?json|token limit/i';\n\n if (preg_match($sensitive, $message)) {\n Log::warning('QuotationAiService: suppressed technical error from user.', ['internal' => $message]);\n\n return $serviceUnavailable\n ? 'The file could not be processed right now. Please try again in a moment.'\n : 'We could not read this file automatically. Please make sure it is a clear BOQ (Excel, CSV, PDF, or image) and try again, or add the items manually.';\n }\n\n return $message;\n }\n\n private function mimeForExtension(string $ext): string\n {\n return match (strtolower($ext)) {\n 'pdf' => 'application/pdf',\n 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n 'xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12',\n 'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',\n 'xls' => 'application/vnd.ms-excel',\n 'csv' => 'text/csv',\n 'jpg', 'jpeg' => 'image/jpeg',\n 'png' => 'image/png',\n 'gif' => 'image/gif',\n 'bmp' => 'image/bmp',\n 'webp' => 'image/webp',\n 'tiff', 'tif' => 'image/tiff',\n 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'doc' => 'application/msword',\n default => 'application/octet-stream',\n };\n }\n}\n"}, "89bda6cd3370f6bba078a12282b744e0ca8f1bd7": {"t": 1785748347.7905595, "n": 101270, "h": "ebfa7324f0f51e4a", "c": "<?php\n\nnamespace App\\Services;\n\nuse Illuminate\\Http\\Client\\ConnectionException;\nuse Illuminate\\Http\\UploadedFile;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\nuse PhpOffice\\PhpSpreadsheet\\Cell\\Coordinate;\nuse PhpOffice\\PhpSpreadsheet\\IOFactory;\n\nclass QuotationAiService\n{\n /** Increment this whenever BOQ parsing logic changes to invalidate old caches. */\n private const PARSER_VERSION = 10;\n private const LARGE_BOQ_VERIFICATION_THRESHOLD = 2000;\n private const LARGE_BOQ_VERIFICATION_CHUNK_SIZE = 120;\n\n /**\n * Characters of document text sent per AI call.\n *\n * Above this the document is split and parsed over several calls. Sized well\n * under the model's context so the prompt, the slice, and a full JSON answer\n * for every row in it all fit — the binding limit is the *output* budget, not\n * the input, since each input row produces a JSON object.\n */\n private const TEXT_CHUNK_CHARS = 60000;\n\n /**\n * A workbook with more sheets than this is split regardless of its size.\n *\n * Multiple tabs almost always mean multiple disciplines (electrical, HVAC,\n * civil…). Sending them in one call makes the model summarise across\n * sections and silently drop rows, so each slice gets a narrower scope.\n */\n private const MAX_SHEETS_PER_CALL = 2;\n\n /** A PDF longer than this is split regardless of its extracted text length. */\n private const MAX_PDF_PAGES_PER_CALL = 5;\n\n /**\n * Largest PDF we will hand to the parser (12 MB).\n *\n * smalot/pdfparser decompresses every stream into memory with no ceiling of\n * its own, and overrunning the limit there is a fatal error that kills the\n * worker rather than an exception we can recover from.\n */\n private const MAX_PDF_BYTES = 12582912;\n\n /**\n * Memory the parser is assumed to need, as a multiple of the file size.\n *\n * Decompressed content streams plus the object graph run well past the raw\n * bytes; 8x is conservative enough to catch the cases that were dying.\n */\n private const PDF_MEMORY_FACTOR = 8;\n\n /** Returned by extractPdfText when it stopped for memory, not for lack of text. */\n private const PDF_LOW_MEMORY = \"\\0__pdf_low_memory__\";\n\n private string $baseUrl;\n private string $parseEndpoint;\n private string $apiKey;\n private int $timeout;\n private bool $testMode;\n private BoqCleaningService $boqCleaner;\n\n /** Page count of the most recently parsed PDF; 0 when unknown. */\n private int $lastPdfPageCount = 0;\n\n /**\n * Optional progress reporter for chunked parsing, called as ($part, $total).\n *\n * Set by the queue job so the polling UI can show real progress on a large\n * BOQ instead of a spinner that appears frozen for many minutes.\n *\n * @var (callable(int, int): void)|null\n */\n private $onChunkProgress = null;\n\n /** @param callable(int, int): void $callback */\n public function onChunkProgress(callable $callback): self\n {\n $this->onChunkProgress = $callback;\n return $this;\n }\n\n /**\n * Optional per-chunk item sink, called as ($items, $part, $total).\n *\n * Lets the caller persist each slice's rows the moment they arrive instead\n * of waiting for the whole document. On a large BOQ this is the difference\n * between a table that fills in progressively and one that stays empty for\n * many minutes.\n *\n * @var (callable(array, int, int): void)|null\n */\n private $onChunkItems = null;\n\n /** @param callable(array, int, int): void $callback */\n public function onChunkItems(callable $callback): self\n {\n $this->onChunkItems = $callback;\n return $this;\n }\n\n /**\n * Report a non-chunk stage (local parse, handing off to AI) to the caller.\n *\n * A spreadsheet is read locally first and only sent to the AI when that\n * fails, so a large file can spend minutes before any chunk exists. Without\n * these the UI shows a generic spinner for that whole period.\n */\n private function reportStage(string $message): void\n {\n if ($this->onStage !== null) {\n ($this->onStage)($message);\n }\n }\n\n /**\n * Report a workbook's shape from grids that are already in memory.\n *\n * Deliberately takes the parsed grids rather than a path: re-reading a large\n * spreadsheet just to count its sheets would double the slowest step of the\n * whole extraction.\n *\n * @param array<int, array{name: string, grid: array}> $sheets\n */\n private function reportWorkbookShape(array $sheets): void\n {\n if ($this->onStage === null || empty($sheets)) {\n return;\n }\n\n $rows = 0;\n foreach ($sheets as $sheet) {\n $rows += count($sheet['grid']);\n }\n\n $count = count($sheets);\n\n $this->reportStage($count > 1\n ? \"Found {$count} sheets, {$rows} rows in total. Reading…\"\n : \"Found {$rows} rows. Reading…\");\n }\n\n /** @var (callable(string): void)|null */\n private $onStage = null;\n\n /** @param callable(string): void $callback */\n public function onStage(callable $callback): self\n {\n $this->onStage = $callback;\n return $this;\n }\n\n public function __construct(BoqCleaningService $boqCleaner)\n {\n $this->boqCleaner = $boqCleaner;\n $this->baseUrl = rtrim((string) config('services.ai_quotation.base_url', ''), '/');\n $this->parseEndpoint = ltrim((string) config('services.ai_quotation.parse_endpoint', 'parse'), '/');\n $this->apiKey = (string) config('services.ai_quotation.api_key', '');\n $this->timeout = (int) config('services.ai_quotation.timeout', 300);\n $this->testMode = (bool) config('services.ai_quotation.test_mode', false);\n }\n\n /**\n * Give the running script enough head-room to outlast the AI HTTP call.\n *\n * The web request / queue worker must not be killed before the HTTP timeout\n * completes, otherwise the user sees a generic \"timeout or unavailable\"\n * error even though the AI may have responded. We allow the HTTP timeout\n * plus a 120s margin for connecting, response parsing and DB writes.\n */\n private function extendExecutionTime(): void\n {\n // Ignored on some SAPIs (e.g. when running under fastcgi with a hard\n // limit), but harmless to attempt.\n @set_time_limit($this->timeout + 120);\n }\n\n public function parseBoq(UploadedFile|string $file, array $context = []): array\n {\n if ($this->testMode) {\n return $this->mockResponse();\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n\n if (! is_file($absPath)) {\n return $this->failure('File not found or not readable.');\n }\n\n $fileHash = hash_file('sha256', $absPath);\n // Bump PARSER_VERSION whenever the extraction logic changes so that\n // previously-uploaded files are re-parsed instead of returning a stale\n // cached result produced by the old code.\n $cacheKey = 'boq_analysis_v' . self::PARSER_VERSION . '_' . $fileHash;\n $forceRefresh = (bool) ($context['force_refresh'] ?? false);\n\n if (! $forceRefresh && ($cached = Cache::get($cacheKey)) !== null) {\n return $cached;\n }\n\n // Clear stale cached result when force-refreshing\n if ($forceRefresh) {\n Cache::forget($cacheKey);\n }\n\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n // Read Excel locally first. Fall back to AI if local parse failed,\n // found 0 items, OR could not read one of the sheets (so multi-sheet\n // files never silently drop a sheet the local parser didn't understand).\n $this->reportStage('Reading the spreadsheet…');\n\n $result = $this->parseSpreadsheetDirect($absPath);\n\n $hasSkippedSheets = ! empty($result['skipped_sheets']);\n\n if (! $result['success'] || empty($result['items']) || $hasSkippedSheets) {\n // The local pass could not handle this file on its own. Say so:\n // otherwise the UI sits on a generic spinner through the local\n // parse AND the whole AI parse with nothing to show for either.\n $this->reportStage($hasSkippedSheets\n ? 'Some sheets need AI to read. Sending to AI…'\n : 'Spreadsheet needs AI to read. Sending to AI…');\n\n $deepSeekResult = $this->parseBoqWithDeepSeek($file, $context);\n // AI sees every sheet at once, so prefer its result whenever it\n // actually returned items (covers the skipped-sheet case too).\n if ($deepSeekResult['success'] && ! empty($deepSeekResult['items'])) {\n // Keep the larger of the two item sets so we never lose the\n // rows the local parser already extracted correctly.\n if (count($deepSeekResult['items']) >= count($result['items'] ?? [])) {\n $result = $deepSeekResult;\n }\n } elseif (! $result['success']) {\n $result = $deepSeekResult;\n }\n // else: keep local result if AI also failed/returned nothing\n }\n } elseif (in_array($ext, ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif'], true)) {\n $result = $this->parseBoqWithDeepSeek($file, $context);\n } else {\n return $this->failure('Unsupported file type. Please upload Excel, CSV, PDF, or image file.');\n }\n\n if ($result['success'] && ! empty($result['items'])) {\n $cleaning = $this->boqCleaner->process($result['items']);\n $result['items'] = $cleaning['accepted'] ?? $result['items'];\n $result['rejected'] = array_merge($result['rejected'] ?? [], $cleaning['rejected'] ?? []);\n }\n\n if ($result['success'] && count($result['items'] ?? []) > self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n $result = $this->verifyLargeBoqItemsWithAi($result);\n }\n\n // Only cache when we actually extracted items; empty results should be re-tried.\n if ($result['success'] && ! empty($result['items'])) {\n Cache::put($cacheKey, $result, now()->addDays(30));\n }\n\n return $result;\n }\n\n private function resolveFile(UploadedFile|string $file): array\n {\n if ($file instanceof UploadedFile) {\n return [(string) $file->getRealPath(), strtolower($file->getClientOriginalExtension())];\n }\n\n $absPath = is_file($file) ? $file : storage_path('app/' . ltrim($file, '/'));\n return [$absPath, strtolower(pathinfo($absPath, PATHINFO_EXTENSION))];\n }\n\n private function parseSpreadsheetDirect(string $absPath): array\n {\n ini_set('memory_limit', '2048M');\n\n // 180s was not enough for a real BOQ: an 11 200-row workbook was killed\n // mid-parse, and because set_time_limit() kills the process outright the\n // job reported a bare failure with nothing in the log to explain it.\n // This runs on the queue, where wall time is cheap.\n set_time_limit(1800);\n\n try {\n $sheets = $this->spreadsheetToGrids($absPath);\n\n if (empty($sheets)) {\n return $this->failure('Could not read the Excel file.');\n }\n\n $this->reportWorkbookShape($sheets);\n\n $items = [];\n $rejected = [];\n $skippedSheets = []; // sheets we could not parse locally (header undetected)\n\n foreach ($sheets as $sheet) {\n $grid = $sheet['grid'];\n if (count($grid) < 2) {\n continue;\n }\n\n // Skip dedicated summary / cost-abstract sheets entirely. Their rows\n // are per-discipline totals (Structural Concrete, Electrical, …) and\n // grand totals, not procurable products.\n if ($this->isSummarySheet($sheet['name'])) {\n Log::info('QuotationAiService: skipped summary sheet.', ['sheet' => $sheet['name']]);\n continue;\n }\n\n $header = $this->detectHeader($grid);\n\n if ($header === null || ! isset($header['map']['description'])) {\n Log::warning('QuotationAiService: header not detected in sheet.', ['sheet' => $sheet['name']]);\n $skippedSheets[] = $sheet['name'];\n continue;\n }\n\n $map = $header['map'];\n $start = $header['row'] + 1;\n\n for ($r = $start; $r < count($grid); $r++) {\n $row = $grid[$r];\n $description = $this->cell($row, $map['description'] ?? null);\n $itemNo = $this->cell($row, $map['item_code'] ?? null);\n $unit = $this->cell($row, $map['unit'] ?? null);\n $quantity = $this->number($this->cell($row, $map['quantity'] ?? null));\n $unitPrice = $this->number($this->cell($row, $map['unit_price'] ?? null));\n $totalPrice = $this->number($this->cell($row, $map['total_price'] ?? null));\n\n if ($description === '') {\n continue;\n }\n\n if (! isset($map['quantity'])) {\n $quantity = 1.0;\n }\n\n // Skip floor breakdown sub-rows: Ref. is \"-\" meaning it's a per-floor\n // quantity split of the parent item, not a standalone product.\n if ($itemNo === '-' || $this->isFloorBreakdownRow($itemNo, $description)) {\n continue;\n }\n\n if ($this->isTotalOrHeaderRow($description)) {\n continue;\n }\n\n if ($quantity === null || $quantity <= 0) {\n $rejected[] = $this->rejectedRow($description, $unit, $quantity, 'Missing quantity', $itemNo, $sheet['name']);\n continue;\n }\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $supply = $this->boqCleaner->filterItem($description, $unitPrice, $quantity);\n\n if (! ($supply['keep'] ?? false)) {\n $rejected[] = $this->rejectedRow(\n $description,\n $unit,\n $quantity,\n (string) ($supply['rejection_reason'] ?? 'Rejected by cleaner'),\n $itemNo,\n $sheet['name']\n );\n continue;\n }\n\n $finalDescription = (string) ($supply['description'] ?? $description);\n\n $items[] = [\n 'description' => $finalDescription,\n 'quantity' => $quantity,\n 'unit' => $unit,\n 'category' => $sheet['name'],\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($finalDescription),\n 'unit_price' => $unitPrice,\n 'confidence' => 0.95,\n 'needs_review' => false,\n 'raw_data' => [\n 'sheet' => $sheet['name'],\n 'row_number' => $r + 1,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'cleaned_description' => $finalDescription,\n 'extraction_type' => (string) ($supply['extraction_type'] ?? 'supply_only'),\n ],\n 'ai_extracted' => true,\n ];\n }\n }\n\n if (empty($items)) {\n return $this->failure('No BOQ supply items could be extracted from the Excel file.');\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'skipped_sheets' => $skippedSheets,\n ];\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: parseSpreadsheetDirect failed.', [\n 'path' => $absPath,\n 'message' => $e->getMessage(),\n 'trace' => $e->getTraceAsString(),\n ]);\n\n return $this->failure('Failed to read the Excel file: ' . $e->getMessage());\n }\n }\n\n private function spreadsheetToGrids(string $absPath): array\n {\n $ext = strtolower(pathinfo($absPath, PATHINFO_EXTENSION));\n\n if ($ext === 'csv') {\n $rows = [];\n $handle = fopen($absPath, 'rb');\n if ($handle !== false) {\n while (($row = fgetcsv($handle)) !== false) {\n $rows[] = $this->trimTrailingEmptyCells($row);\n }\n fclose($handle);\n }\n return [['name' => 'Sheet1', 'grid' => $rows]];\n }\n\n $reader = IOFactory::createReader($ext === 'xls' ? 'Xls' : 'Xlsx');\n $reader->setReadDataOnly(true);\n $spreadsheet = $reader->load($absPath);\n $sheets = [];\n\n foreach ($spreadsheet->getAllSheets() as $sheet) {\n $highestRow = $sheet->getHighestDataRow();\n $highestColumn = $sheet->getHighestDataColumn();\n $highestColumnIndex = Coordinate::columnIndexFromString($highestColumn);\n\n // BOQ data is normally in first 8-20 columns.\n // Files with heavy formatting/images can report 300+ columns — limit to 30.\n $maxColumnIndex = min($highestColumnIndex, 30);\n\n $grid = [];\n for ($row = 1; $row <= $highestRow; $row++) {\n $rowData = [];\n $hasValue = false;\n\n for ($col = 1; $col <= $maxColumnIndex; $col++) {\n $value = $sheet->getCell([$col, $row])->getValue();\n $value = $this->cellValueToString($value);\n $value = trim($value);\n if ($value !== null && $value !== '') {\n $hasValue = true;\n }\n $rowData[] = $value;\n }\n\n $rowData = $this->trimTrailingEmptyCells($rowData);\n if ($hasValue) {\n $grid[] = $rowData;\n }\n }\n\n if (! empty($grid)) {\n $sheets[] = ['name' => $sheet->getTitle(), 'grid' => $grid];\n }\n }\n\n $spreadsheet->disconnectWorksheets();\n unset($spreadsheet);\n\n return $sheets;\n }\n\n private function detectHeader(array $grid): ?array\n {\n $keywords = [\n 'item_code' => ['رقم البند', 'item no', 'item number', 'ref.', 'ref', 'item', 'no.', 'no', '#', 'كود'],\n 'description' => ['وص٠البند', 'scope of works', 'scope of work', 'scope', 'item description', 'description of works', 'description', 'بالعربية', 'الوصÙ', 'البند', 'بيان'],\n 'unit' => ['Ø§Ù„ÙˆØØ¯Ø©', 'unit', 'uom', 'u/m'],\n 'quantity' => ['الكمية', 'qty', 'quantity', 'q.ty'],\n 'unit_price' => ['سعر Ø§Ù„ÙˆØØ¯Ø©', 'u/price', 'unit price', 'unit rate', 'rate', 'price'],\n 'total_price' => ['السعر الإجمالي', 'total amount', 'total price', 'amount', 'الإجمالي'],\n ];\n\n $best = null;\n $bestScore = 0;\n\n for ($r = 0; $r < min(30, count($grid)); $r++) {\n $map = [];\n $score = 0;\n\n foreach ($grid[$r] as $c => $value) {\n $cell = mb_strtolower(trim((string) $value));\n if ($cell === '') {\n continue;\n }\n\n foreach ($keywords as $field => $list) {\n foreach ($list as $kw) {\n if (str_contains($cell, mb_strtolower($kw))) {\n if (! isset($map[$field])) {\n $map[$field] = $c;\n $score++;\n }\n break 2;\n }\n }\n }\n }\n\n if ($score > $bestScore) {\n $bestScore = $score;\n $best = ['row' => $r, 'map' => $map];\n }\n }\n\n if ($bestScore >= 3) {\n return $best;\n }\n\n // Allow simpler BOQ sheets that only define a description column plus one\n // other BOQ-related column, e.g. Item + Description or Description + Qty.\n if ($bestScore === 2 && isset($best['map']['description']) && (\n isset($best['map']['item_code']) ||\n isset($best['map']['quantity']) ||\n isset($best['map']['unit'])\n )) {\n return $best;\n }\n\n return null;\n }\n\n private function cell(array $row, ?int $index): string\n {\n if ($index === null) {\n return '';\n }\n return trim((string) ($row[$index] ?? ''));\n }\n\n private function number(string $value): ?float\n {\n $value = trim(str_replace([',', 'SAR', 'ر.س'], '', $value));\n if ($value === '' || ! is_numeric($value)) {\n return null;\n }\n return (float) $value;\n }\n\n private function trimTrailingEmptyCells(array $row): array\n {\n while (! empty($row) && trim((string) end($row)) === '') {\n array_pop($row);\n }\n return array_values($row);\n }\n\n private function isTotalOrHeaderRow(string $description): bool\n {\n $d = mb_strtolower(trim($description));\n\n if ($d === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $d)) {\n return true;\n }\n\n // Section totals / summaries\n if (preg_match('/(اجمالي|إجمالي|المجموع|grand\\s*total|sub\\s*total|total\\s*amount|total\\s*price)/iu', $d)) {\n return true;\n }\n\n // Bare \"Total\" lines, optionally with a currency suffix, e.g. \"Total\",\n // \"Total (SAR)\", \"Total cost\", \"Total (SAR) unit cost\".\n if (preg_match('/^total\\b[\\s\\(\\):a-z]*$/iu', $d)) {\n return true;\n }\n\n // Pure floor / level labels used as section headers\n if (preg_match('/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor)\\s*$/iu', $d)) {\n return true;\n }\n\n // Division / section header lines (e.g. \"Division - 02: Wood Works\")\n if (preg_match('/^division\\s*([-:]|\\d)/iu', $d)) {\n return true;\n }\n\n return false;\n }\n\n /**\n * Detect dedicated summary / cost-abstract sheets by their name. These hold\n * per-discipline rollups and grand totals (e.g. a \"Summary (Dry Cost)\" tab),\n * never procurable supply items, so they are skipped wholesale.\n */\n private function isSummarySheet(string $name): bool\n {\n $n = mb_strtolower(trim($name));\n\n if ($n === '') {\n return false;\n }\n\n return (bool) preg_match(\n '/\\b(summary|abstract|cost\\s*summary|grand\\s*summary|recap(?:itulation)?|bill\\s*summary|boq\\s*summary|dry\\s*cost)\\b|ملخص|الملخص|الملخّص|ملخّص|اجمالي|إجمالي/iu',\n $n\n );\n }\n\n private function isFloorBreakdownRow(string $itemNo, string $description): bool\n {\n // A row whose Ref column is empty or a dash is a sub-breakdown row (per-floor split).\n $ref = trim($itemNo);\n if ($ref === '' || $ref === '-') {\n // Confirm by checking the description is a location/floor label\n $d = mb_strtolower(trim($description));\n return (bool) preg_match(\n '/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor|\\d+f)\\s*$/iu',\n $d\n );\n }\n return false;\n }\n\n private function rejectedRow(string $description, string $unit, ?float $quantity, string $reason, string $itemNo = '', string $sheet = ''): array\n {\n return [\n 'description' => $description,\n 'quantity' => $quantity ?? 0,\n 'unit' => $unit,\n 'category' => $sheet,\n 'brand' => '',\n 'status' => 'rejected',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 0.0,\n 'raw_data' => [\n 'sheet' => $sheet,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'extraction_type' => 'rejected',\n 'rejection_reason' => $reason,\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function parseBoqWithDeepSeek(UploadedFile|string $file, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured. Please set DEEPSEEK_API_KEY in .env file.');\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n $mime = $this->mimeForExtension($ext);\n\n try {\n // ── Spreadsheets ──────────────────────────────────────────────────\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n $text = $this->spreadsheetToCompactCsvText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not convert spreadsheet to readable text.');\n }\n\n $text = $this->sanitizeUtf8($text);\n\n // A large BOQ exceeds what one request can carry, so send it in\n // sequential slices and merge. Anything that fits goes as-is.\n //\n // Size alone is not a good enough signal: a workbook with several\n // sheets is structurally a big BOQ even when the text is compact,\n // and asking the model to hold every discipline at once is where\n // rows start getting dropped. So split on either signal.\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS || $sheetCount > self::MAX_SHEETS_PER_CALL) {\n Log::info('QuotationAiService: spreadsheet routed to chunked parsing.', [\n 'chars' => mb_strlen($text),\n 'sheets' => $sheetCount,\n ]);\n return $this->parseTextInChunks($text, 'BOQ spreadsheet converted to CSV', $apiKey, $context);\n }\n\n $userContent = \"BOQ spreadsheet converted to CSV:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── PDF ───────────────────────────────────────────────────────────\n if ($ext === 'pdf') {\n $extracted = $this->extractPdfText($absPath);\n\n if ($extracted === self::PDF_LOW_MEMORY) {\n return $this->failure('The server was low on memory while reading this PDF. Please try again in a moment, or convert it to Excel or CSV.');\n }\n\n if ($extracted !== null && mb_strlen(trim($extracted)) > 50) {\n $extracted = $this->sanitizeUtf8($extracted);\n\n // Split on either signal — long text, or many pages. A BOQ\n // spanning several pages is a multi-section document even if\n // each page is sparse.\n if (mb_strlen($extracted) > self::TEXT_CHUNK_CHARS\n || $this->lastPdfPageCount > self::MAX_PDF_PAGES_PER_CALL) {\n Log::info('QuotationAiService: PDF routed to chunked parsing.', [\n 'chars' => mb_strlen($extracted),\n 'pages' => $this->lastPdfPageCount,\n ]);\n return $this->parseTextInChunks($extracted, 'BOQ PDF extracted text', $apiKey, $context, 'application/pdf');\n }\n\n $userContent = \"BOQ PDF extracted text:\\n\\n\" . $extracted . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'application/pdf');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // No text yet — smalot threw its \"$obj\" bug and pdftotext is not\n // installed. Last resort before giving up: OCR.space, a hosted\n // reader that takes the PDF and returns its text. Needs no local\n // binary and no vision key, and still feeds DeepSeek afterwards.\n $ocrText = $this->extractPdfTextViaOcrSpace($absPath);\n\n if ($ocrText !== null && mb_strlen(trim($ocrText)) > 50) {\n $ocrText = $this->sanitizeUtf8($ocrText);\n $userContent = \"BOQ PDF extracted text:\\n\\n\" . $ocrText . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'application/pdf');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // Every reader failed. Which one to blame changes the advice: a\n // missing OCR key is ours to fix and no amount of re-uploading\n // helps, so telling the user to convert the file would send them\n // chasing a problem they cannot solve.\n Log::error('QuotationAiService: every PDF reader failed.', [\n 'file' => basename($absPath),\n 'bytes' => @filesize($absPath) ?: 0,\n 'pages' => $this->lastPdfPageCount,\n 'pdftotext' => $this->pdftotextPath() !== null,\n 'ocr_key_set' => ((string) config('services.ocrspace.key', '')) !== ''\n && config('services.ocrspace.key') !== 'helloworld',\n ]);\n\n return $this->failure('Could not read this PDF. It may be a scanned image, or use a format our reader cannot open. Please convert it to Excel or CSV and upload again.');\n }\n\n // -- Word documents (docx) ---------------------------------------------------\n if (in_array($ext, ['docx', 'doc'], true)) {\n $text = $this->extractDocxText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not extract text from the Word document. Please convert it to Excel, CSV, or PDF.');\n }\n $text = $this->sanitizeUtf8($text);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS) {\n return $this->parseTextInChunks($text, 'BOQ Word document extracted text', $apiKey, $context);\n }\n\n $userContent = \"BOQ Word document extracted text:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── Images ──────────────────────────────────────────────────────────────\n // DeepSeek cannot receive images. A vision-capable AI processes the image\n // directly (no OCR) — configure GROQ_API_KEY or GEMINI_API_KEY in .env.\n $imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif', 'heic', 'heif'];\n if (in_array($ext, $imageExts, true)) {\n $bytes = (string) file_get_contents($absPath);\n return $this->callDeepSeekVision($bytes, $mime, $context);\n }\n\n return $this->failure('Unsupported file type. Please upload an Excel, CSV, PDF, or image file.');\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: DeepSeek fallback failed.', ['message' => $e->getMessage()]);\n return $this->failure('An error occurred while processing the file with AI: ' . $e->getMessage());\n }\n }\n\n private function deepSeekModel(): string\n {\n return (string) config('services.deepseek.model', 'deepseek-chat');\n }\n\n /**\n * Remove or replace any non-UTF-8 / malformed byte sequences so json_encode never fails.\n */\n private function sanitizeUtf8(string $text): string\n {\n // Replace invalid byte sequences with the UTF-8 replacement character\n $clean = mb_convert_encoding($text, 'UTF-8', 'UTF-8');\n // Strip any remaining non-printable control characters (except tab/newline/CR)\n $clean = (string) preg_replace('/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]/u', '', $clean);\n return $clean;\n }\n\n /**\n * Extract plain text from a Word docx file using ZipArchive (no external library needed).\n * Reads word/document.xml and strips XML tags, preserving paragraph and table structure.\n */\n private function extractDocxText(string $absPath): ?string\n {\n try {\n $zip = new \\ZipArchive();\n if ($zip->open($absPath) !== true) {\n return null;\n }\n\n $xml = $zip->getFromName('word/document.xml');\n $zip->close();\n\n if ($xml === false || $xml === '') {\n return null;\n }\n\n // Add newlines before paragraph/table-row/table-cell tags so structure is preserved\n $xml = preg_replace('/<w:p[ >]/', \"\\n<w:p>\", $xml) ?? $xml;\n $xml = preg_replace('/<w:tr[ >]/', \"\\n<w:tr>\", $xml) ?? $xml;\n $xml = preg_replace('/<\\/w:tc>/', \"\\t\", $xml) ?? $xml;\n\n $text = strip_tags($xml);\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n return trim($text) !== '' ? trim($text) : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: DOCX parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Extract plain text from a PDF using smalot/pdfparser (pure PHP, no binary required).\n */\n /**\n * The process memory limit in bytes, or 0 when unlimited.\n *\n * memory_limit is a shorthand string (\"128M\", \"1G\", \"-1\"), so it has to be\n * expanded before it can be compared against anything.\n */\n private function memoryLimitBytes(): int\n {\n $raw = trim((string) ini_get('memory_limit'));\n\n if ($raw === '' || $raw === '-1') {\n return 0;\n }\n\n $value = (int) $raw;\n\n return match (strtolower(substr($raw, -1))) {\n 'g' => $value * 1024 * 1024 * 1024,\n 'm' => $value * 1024 * 1024,\n 'k' => $value * 1024,\n default => $value,\n };\n }\n\n private function extractPdfText(string $absPath): ?string\n {\n // The parser loads and decompresses the whole document before any of our\n // chunking gets a look at it, and it does so with no memory ceiling of\n // its own. A big PDF exhausts the limit inside gzuncompress() — a fatal\n // error, not an exception, so the catch below cannot help and the worker\n // dies outright. Refuse the file first instead.\n $size = @filesize($absPath) ?: 0;\n\n if ($size > self::MAX_PDF_BYTES) {\n Log::warning('QuotationAiService: PDF too large to parse in-process.', [\n 'bytes' => $size,\n 'limit' => self::MAX_PDF_BYTES,\n ]);\n\n return null;\n }\n\n // Headroom check: parsing routinely needs several times the file size\n // once streams are decompressed. If that would not fit in what is left,\n // stop here rather than taking the worker down with us.\n $limit = $this->memoryLimitBytes();\n\n if ($limit > 0) {\n // Try to reclaim memory a previous job left behind before deciding\n // there is not enough. Without this, a worker sitting near its limit\n // after a big job could refuse a tiny PDF for no real reason.\n gc_collect_cycles();\n\n $available = $limit - memory_get_usage(true);\n\n if ($available < $size * self::PDF_MEMORY_FACTOR) {\n Log::warning('QuotationAiService: not enough memory left to parse this PDF.', [\n 'bytes' => $size,\n 'available' => $available,\n 'needed' => $size * self::PDF_MEMORY_FACTOR,\n ]);\n\n // A distinct sentinel, so the caller can tell \"no memory\" apart\n // from \"no text\" and show the right message instead of blaming\n // the file for being scanned.\n return self::PDF_LOW_MEMORY;\n }\n }\n\n try {\n $parser = new \\Smalot\\PdfParser\\Parser();\n $pdf = $parser->parseFile($absPath);\n\n // Recorded for the chunking decision: a long PDF is structurally a\n // big BOQ even when its extracted text is short.\n try {\n $this->lastPdfPageCount = count($pdf->getPages());\n } catch (\\Throwable) {\n $this->lastPdfPageCount = 0;\n }\n\n $text = $pdf->getText();\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n return $text !== '' ? $text : null;\n } catch (\\Throwable $e) {\n // Class, file and line, not just the message — a bare message left\n // the actual cause of a PDF failure guessable at best.\n Log::warning('QuotationAiService: PDF parsing failed.', [\n 'exception' => $e::class,\n 'error' => $e->getMessage(),\n 'at' => $e->getFile() . ':' . $e->getLine(),\n ]);\n\n // smalot/pdfparser throws \"Invalid object reference for $obj.\" on\n // some perfectly valid PDFs — a known bug in the library, not a\n // problem with the file. Fall back to the pdftotext binary, which\n // reads those documents fine.\n return $this->extractPdfTextViaBinary($absPath);\n }\n }\n\n /**\n * Extract PDF text through the OCR.space API.\n *\n * The fallback of last resort when smalot throws and pdftotext is not on the\n * server. OCR.space is a hosted reader: the file is uploaded and its text\n * comes back, so it needs nothing installed locally. The free tier caps\n * uploads at 1 MB and 3 pages — enough for a typical BOQ, and skipped with a\n * clear log line when the file is over that, rather than a wasted call.\n */\n private function extractPdfTextViaOcrSpace(string $absPath): ?string\n {\n $apiKey = (string) config('services.ocrspace.key', '');\n\n if ($apiKey === '') {\n return null;\n }\n\n // The free tier caps the FILE at 3 MB (the 1 MB figure is the per-page\n // limit). Capping at 1 MB here turned readable BOQs away without trying.\n $size = @filesize($absPath) ?: 0;\n if ($size > 3 * 1024 * 1024) {\n Log::warning('QuotationAiService: PDF too large for the OCR fallback.', [\n 'bytes' => $size,\n 'limit' => 3 * 1024 * 1024,\n ]);\n return null;\n }\n\n // 'helloworld' is OCR.space's public demo key, and it is the config\n // default. It is rate-limited to near-nothing and starts rejecting real\n // traffic almost immediately, which surfaces to the user as the generic\n // \"could not read this PDF\" — indistinguishable from a genuinely\n // unreadable file. Say so plainly instead.\n if ($apiKey === 'helloworld') {\n Log::warning('QuotationAiService: OCR is using the shared demo key; PDF reading will fail under any real use. Set OCRSPACE_API_KEY in .env (free at https://ocr.space/ocrapi/freekey).');\n }\n\n try {\n $response = Http::timeout(120)\n ->attach('file', (string) file_get_contents($absPath), basename($absPath))\n ->post('https://api.ocr.space/parse/image', [\n 'apikey' => $apiKey,\n 'filetype' => 'PDF',\n 'language' => 'eng',\n 'isTable' => 'true', // BOQs are tabular; keep columns\n 'OCREngine' => '2',\n 'scale' => 'true',\n ]);\n\n if (! $response->successful()) {\n Log::warning('QuotationAiService: OCR.space request failed.', [\n 'status' => $response->status(),\n ]);\n return null;\n }\n\n $json = $response->json();\n\n // OCR.space reports its own errors in the body with a 200 status.\n if (($json['IsErroredOnProcessing'] ?? false) === true) {\n Log::warning('QuotationAiService: OCR.space could not process the PDF.', [\n 'message' => $json['ErrorMessage'] ?? 'unknown',\n ]);\n return null;\n }\n\n $text = '';\n foreach ($json['ParsedResults'] ?? [] as $page) {\n $text .= ($page['ParsedText'] ?? '') . \"\\n\";\n }\n\n $text = trim((string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text));\n\n if ($text !== '') {\n Log::info('QuotationAiService: recovered PDF text via OCR.space.', [\n 'path' => basename($absPath),\n 'chars' => mb_strlen($text),\n ]);\n }\n\n return $text !== '' ? $text : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: OCR.space fallback threw.', [\n 'error' => $e->getMessage(),\n ]);\n return null;\n }\n }\n\n /**\n * Locate the pdftotext binary.\n *\n * On shared hosting it is rarely on the system PATH, so a user-installed\n * copy — dropped in ~/bin, ~/.local/bin, or the project's vendor/bin — has\n * to be found explicitly. An escaped, quoted absolute path is returned so it\n * is safe to interpolate into the command.\n *\n * PDFTOTEXT_PATH in .env wins, for a copy installed somewhere non-standard.\n */\n private function pdftotextPath(): ?string\n {\n $configured = trim((string) env('PDFTOTEXT_PATH', ''));\n\n $candidates = array_filter([\n $configured !== '' ? $configured : null,\n ($home = getenv('HOME')) ? $home . '/bin/pdftotext' : null,\n $home ? $home . '/.local/bin/pdftotext' : null,\n base_path('vendor/bin/pdftotext'),\n '/usr/bin/pdftotext',\n '/usr/local/bin/pdftotext',\n ]);\n\n foreach ($candidates as $path) {\n if (is_file($path) && is_executable($path)) {\n return escapeshellarg($path);\n }\n }\n\n // Fall back to a bare name, in case it is on PATH after all.\n $onPath = @shell_exec('command -v pdftotext 2>/dev/null');\n\n return is_string($onPath) && trim($onPath) !== '' ? 'pdftotext' : null;\n }\n\n private function extractPdfTextViaBinary(string $absPath): ?string\n {\n // shell_exec is commonly disabled on shared hosting; bail cleanly so the\n // caller falls through to the vision path or a clear error.\n if (! function_exists('shell_exec')\n || in_array('shell_exec', array_map('trim', explode(',', (string) ini_get('disable_functions'))), true)) {\n return null;\n }\n\n $binary = $this->pdftotextPath();\n\n if ($binary === null) {\n Log::warning('QuotationAiService: pdftotext binary not found.', [\n 'looked_in' => 'PATH, PDFTOTEXT_PATH, ~/bin, ~/.local/bin, vendor/bin',\n ]);\n return null;\n }\n\n // `-layout` keeps columns roughly aligned, which matters for a BOQ; the\n // trailing \"-\" writes to stdout instead of a file.\n $command = sprintf('%s -layout -enc UTF-8 %s - 2>/dev/null', $binary, escapeshellarg($absPath));\n\n $output = @shell_exec($command);\n\n if (! is_string($output) || trim($output) === '') {\n Log::warning('QuotationAiService: pdftotext fallback produced no text.', [\n 'path' => basename($absPath),\n ]);\n return null;\n }\n\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $output);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n\n if ($text !== '') {\n Log::info('QuotationAiService: recovered PDF text via pdftotext fallback.', [\n 'path' => basename($absPath),\n 'chars' => mb_strlen($text),\n ]);\n }\n\n return $text !== '' ? $text : null;\n }\n\n /**\n * Send a vision request to a vision-capable AI model.\n * DeepSeek's commercial API (api.deepseek.com) does NOT support image inputs — any model.\n * Priority order (first configured key wins):\n * 1. GROQ_API_KEY → Groq llama-4-scout (free: https://console.groq.com)\n * 2. GEMINI_API_KEY → Google Gemini Flash (free: https://aistudio.google.com/apikey)\n * 3. VISION_API_KEY → any OpenAI-compatible endpoint (OpenAI, OpenRouter …)\n */\n private function callDeepSeekVision(string $bytes, string $mime, array $context): array\n {\n // ── 1. Groq (free, reliable vision) ─────────────────────────────────────\n if ((string) config('services.groq.key', '') !== '') {\n $visionKey = (string) config('services.groq.key');\n $visionBaseUrl = 'https://api.groq.com/openai/v1';\n $visionModel = (string) config('services.groq.model', 'meta-llama/llama-4-scout-17b-16e-instruct');\n }\n // ── 2. Google Gemini ─────────────────────────────────────────────────────\n elseif ((string) config('services.gemini.key', '') !== '') {\n $visionKey = (string) config('services.gemini.key');\n $visionBaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai';\n $visionModel = (string) config('services.gemini.model', 'gemini-2.0-flash');\n }\n // ── 3. Generic VISION_API_KEY (OpenAI, OpenRouter, …) ───────────────────\n elseif ((string) config('services.vision.key', '') !== '') {\n $visionKey = (string) config('services.vision.key');\n $visionBaseUrl = rtrim((string) config('services.vision.base_url', 'https://openrouter.ai/api/v1'), '/');\n $visionModel = (string) config('services.vision.model', 'google/gemini-flash-1.5-8b');\n }\n else {\n return $this->failure(\n 'Image processing is not available. DeepSeek\\'s API does not support images. ' .\n 'Get a free key at https://console.groq.com and add GROQ_API_KEY to your .env file.'\n );\n }\n\n $dataUrl = \"data:{$mime};base64,\" . base64_encode($bytes);\n $prompt = $this->buildDeepSeekPrompt($context, $mime);\n\n $userContent = [\n ['type' => 'text', 'text' => $prompt],\n ['type' => 'image_url', 'image_url' => ['url' => $dataUrl]],\n ];\n\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders([\n 'Authorization' => 'Bearer ' . $visionKey,\n 'HTTP-Referer' => config('app.url', 'https://qimta.sa'),\n 'X-Title' => 'Qimta Platform',\n ])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post($visionBaseUrl . '/chat/completions', [\n 'model' => $visionModel,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 16384,\n 'temperature' => 0,\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('Vision API connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: Vision API HTTP error.', [\n 'model' => $visionModel,\n 'base_url' => $visionBaseUrl,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n return $this->failure(\"Vision API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n return $this->parseDeepSeekJsonResponse((string) $response->json('choices.0.message.content'));\n }\n\n private function callDeepSeekChat(string $userContent, string $key, string $model): array\n {\n // Ensure the PHP script outlives the HTTP call (+ a margin for parsing the\n // response and writing to the DB) so the worker/web request is not killed\n // mid-flight, which surfaces to users as a generic \"timeout or unavailable\".\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders(['Authorization' => 'Bearer ' . $key])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 32768,\n 'temperature' => 0,\n 'response_format' => ['type' => 'json_object'],\n 'user' => 'Qimta_Platform',\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('DeepSeek connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: DeepSeek HTTP error.', [\n 'model' => $model,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n if ($response->status() === 401) {\n return $this->failure('DeepSeek API key is invalid or unauthorised. Please update DEEPSEEK_API_KEY in .env.', false);\n }\n return $this->failure(\"DeepSeek API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n $text = (string) $response->json('choices.0.message.content');\n return $this->parseDeepSeekJsonResponse($text);\n }\n\n /**\n * Parse a document too large for one AI call by sending it in slices.\n *\n * A 20 000-row BOQ is several megabytes of text — far beyond what a single\n * request can carry, and beyond what the model can answer without hitting its\n * output token limit. Previously the text was simply cut at 180 000 chars and\n * the remainder discarded: the parse \"succeeded\" while silently dropping most\n * of the file. Now the text is split and each slice parsed on its own call,\n * with the items merged.\n *\n * Splitting happens on line boundaries so a BOQ row is never cut in half.\n * Slices run sequentially rather than pooled: DeepSeek rate-limits hard on\n * concurrent large prompts, and this already runs on the queue where wall\n * time is cheap.\n *\n * A slice that fails does not fail the document — its rows are lost, but the\n * rest are kept and the shortfall is logged. Total failure is reported only\n * when every slice fails.\n *\n * @param string $label How the payload is introduced to the model.\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n /**\n * Split a spreadsheet into the slices the AI will be asked to read.\n *\n * Exposed so the work can be spread over several queue jobs instead of one\n * job walking every slice in sequence. Returns an empty array when the file\n * is small enough to go in a single call, or cannot be read.\n *\n * @return array<int, string>\n */\n public function chunkSpreadsheet(string $absPath): array\n {\n ini_set('memory_limit', '2048M');\n set_time_limit(1800);\n\n $text = $this->spreadsheetToCompactCsvText($absPath);\n\n if ($text === null || trim($text) === '') {\n return [];\n }\n\n $text = $this->sanitizeUtf8($text);\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) <= self::TEXT_CHUNK_CHARS && $sheetCount <= self::MAX_SHEETS_PER_CALL) {\n return [];\n }\n\n $chunks = $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n\n // Free the source text: the slices are a second full copy of it, and on\n // a large workbook that doubling — on top of PhpSpreadsheet's grids —\n // is what exhausted memory here.\n unset($text);\n\n Log::info('QuotationAiService: chunked spreadsheet.', [\n 'chunks' => count($chunks),\n 'peak_mb' => round(memory_get_peak_usage(true) / 1048576),\n ]);\n\n return $chunks;\n }\n\n /**\n * Split a spreadsheet straight to disk, one file per slice.\n *\n * chunkSpreadsheet() returns every slice as an array, which is a second\n * full copy of the document in memory on top of PhpSpreadsheet's grids.\n * This hands each slice to $write and drops it immediately, so peak memory\n * is one slice rather than the whole file twice.\n *\n * @param callable(int, string): void $write ($part, $chunk)\n * @return int number of slices written; 0 when the file needs no split\n */\n public function chunkSpreadsheetToDisk(string $absPath, callable $write): int\n {\n ini_set('memory_limit', '2048M');\n set_time_limit(1800);\n\n $text = $this->spreadsheetToCompactCsvText($absPath);\n\n if ($text === null || trim($text) === '') {\n return 0;\n }\n\n $text = $this->sanitizeUtf8($text);\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) <= self::TEXT_CHUNK_CHARS && $sheetCount <= self::MAX_SHEETS_PER_CALL) {\n return 0;\n }\n\n $part = 0;\n foreach ($this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS) as $chunk) {\n $write(++$part, $chunk);\n }\n\n unset($text);\n\n Log::info('QuotationAiService: chunked spreadsheet to disk.', [\n 'chunks' => $part,\n 'peak_mb' => round(memory_get_peak_usage(true) / 1048576),\n ]);\n\n return $part;\n }\n\n /**\n * Parse one slice produced by chunkSpreadsheet().\n *\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n public function parseChunk(string $chunk, int $part, int $total, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured.');\n }\n\n // Same framing parseTextInChunks() uses, so a slice is never mistaken\n // for a complete BOQ and totals are not inferred from a fragment.\n $userContent = \"BOQ spreadsheet converted to CSV (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, 'text/plain');\n\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n private function parseTextInChunks(\n string $text,\n string $label,\n string $apiKey,\n array $context,\n string $mime = 'text/plain',\n ): array {\n $chunks = $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n $total = count($chunks);\n\n Log::info('QuotationAiService: large document split for parsing.', [\n 'chars' => mb_strlen($text),\n 'chunks' => $total,\n ]);\n\n // Announce the split before the first slice is sent, so the UI can show\n // how many parts there are instead of sitting on a generic message for\n // however long the first AI call takes.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)(0, $total);\n }\n\n $items = [];\n $rejected = [];\n $failed = 0;\n $lastError = null;\n\n foreach ($chunks as $index => $chunk) {\n $part = $index + 1;\n\n // Report progress so a 28-slice parse does not look frozen to the user.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)($part, $total);\n }\n\n // Tell the model this is a fragment, so it does not try to reconcile\n // totals or treat a mid-document slice as a complete BOQ.\n $userContent = \"{$label} (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, $mime);\n\n $result = $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n\n if (! ($result['success'] ?? false)) {\n $failed++;\n $lastError = $result['error'] ?? 'Chunk parsing failed.';\n Log::warning('QuotationAiService: chunk failed, continuing.', [\n 'part' => $part,\n 'of' => $total,\n 'error' => $lastError,\n ]);\n continue;\n }\n\n foreach ($result['items'] as $item) {\n $items[] = $item;\n }\n foreach ($result['rejected'] ?? [] as $row) {\n $rejected[] = $row;\n }\n\n // Hand this slice's rows straight to the caller so they can be shown\n // while the remaining slices are still being parsed.\n if ($this->onChunkItems !== null && $result['items'] !== []) {\n ($this->onChunkItems)($result['items'], $part, $total);\n }\n }\n\n // Every slice failed — there is nothing to show, so report the failure.\n if ($failed === $total) {\n return $this->failure($lastError ?? 'Could not parse the document.');\n }\n\n if ($failed > 0) {\n Log::error('QuotationAiService: document parsed with missing parts.', [\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n 'items_kept' => count($items),\n ]);\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'service_unavailable' => false,\n 'partial' => $failed > 0,\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n ];\n }\n\n /**\n * Split text into slices of at most $limit characters, breaking only at\n * newlines so no BOQ row is ever cut in half.\n *\n * A single line longer than the limit (rare — a pathological cell) is passed\n * through oversized rather than severed mid-row.\n *\n * @return array<int, string>\n */\n private function splitOnLineBoundaries(string $text, int $limit): array\n {\n $chunks = [];\n $current = '';\n\n // Sheet a chunk starts in the middle of. Repeated at the top of the next\n // chunk so the model always knows which sheet the rows belong to — the\n // header would otherwise be stranded in the previous slice, and rows\n // would arrive with no discipline context.\n $currentSheet = null;\n\n foreach (explode(\"\\n\", $text) as $line) {\n $isSheetHeader = str_starts_with($line, 'Sheet: ');\n\n // Start a new chunk at every sheet boundary. Sheets are usually\n // separate disciplines, and the whole point of splitting a multi-tab\n // workbook is to give the model one discipline at a time — a size-only\n // split would leave a compact 3-tab file as a single chunk, silently\n // undoing the sheet gate that routed it here.\n if ($isSheetHeader && $current !== '') {\n $chunks[] = $current;\n $current = $line;\n $currentSheet = $line;\n continue;\n }\n\n if ($isSheetHeader) {\n $currentSheet = $line;\n }\n\n $candidate = $current === '' ? $line : $current . \"\\n\" . $line;\n\n if (mb_strlen($candidate) > $limit && $current !== '') {\n $chunks[] = $current;\n\n // Carry the sheet header over, unless this line is itself one.\n $current = ($currentSheet !== null && ! str_starts_with($line, 'Sheet: '))\n ? $currentSheet . \" (continued)\\n\" . $line\n : $line;\n continue;\n }\n\n $current = $candidate;\n }\n\n if (trim($current) !== '') {\n $chunks[] = $current;\n }\n\n return $chunks;\n }\n\n /**\n * Shared JSON response parser used by both chat and vision paths.\n */\n private function parseDeepSeekJsonResponse(string $text): array\n {\n if ($text === '') {\n return $this->failure('DeepSeek returned an empty response.');\n }\n\n // Strip markdown code fences if the model wrapped the JSON.\n if (preg_match('/```(?:json)?\\s*([\\s\\S]*?)\\s*```/i', $text, $m)) {\n $text = $m[1];\n }\n // Narrow to the outermost JSON object/array.\n if (preg_match('/[\\{\\[][\\s\\S]*[\\}\\]]/s', $text, $m)) {\n $text = $m[0];\n }\n $text = trim($text);\n\n $decoded = json_decode($text, true);\n\n // The response can be truncated when the model hits the token limit,\n // leaving an unterminated JSON string/object. Try to repair it before\n // giving up so we can still salvage the items extracted so far.\n if (! is_array($decoded)) {\n $repaired = $this->repairTruncatedJson($text);\n if ($repaired !== null) {\n $decoded = json_decode($repaired, true);\n }\n }\n\n if (! is_array($decoded)) {\n Log::error('QuotationAiService: DeepSeek non-JSON response.', ['text' => mb_substr($text, 0, 1000)]);\n return $this->failure('DeepSeek returned a non-JSON response. The file may be too large — try splitting it into smaller files.');\n }\n\n $rawItems = $decoded['items'] ?? (array_is_list($decoded) ? $decoded : []);\n $items = [];\n $rejected = [];\n\n foreach ($rawItems as $raw) {\n if (! is_array($raw)) {\n continue;\n }\n $item = $this->normaliseAiItem($raw);\n if ($item === null) {\n continue;\n }\n $check = $this->boqCleaner->filterItem((string) $item['description']);\n if ($check['keep'] ?? false) {\n $items[] = $item;\n } else {\n $item['status'] = 'rejected';\n $item['raw_data']['rejection_reason'] = $check['rejection_reason'] ?? 'Rejected by cleaner';\n $rejected[] = $item;\n }\n }\n\n if (empty($items)) {\n Log::info('QuotationAiService: DeepSeek returned 0 accepted items.', [\n 'raw_items_count' => count($rawItems),\n 'rejected_count' => count($rejected),\n 'first_rejected' => ! empty($rejected) ? ($rejected[0]['description'] ?? '') : '',\n ]);\n }\n\n return ['success' => true, 'items' => $items, 'rejected' => $rejected, 'error' => null];\n }\n\n /**\n * Best-effort repair of a truncated JSON string produced when the model hits\n * its token limit mid-output. Drops any incomplete trailing object, then\n * closes the open string/array/object brackets so the result is valid JSON.\n *\n * Returns the repaired JSON string, or null if it cannot be salvaged.\n */\n private function repairTruncatedJson(string $text): ?string\n {\n if ($text === '') {\n return null;\n }\n\n // Walk the string tracking structural state, ignoring everything inside\n // string literals (and their escapes). Remember the last position where\n // we were back at the top of the \"items\" array between complete elements,\n // so we can cut a half-written trailing object.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($text);\n $lastSafeCut = null; // index just after a completed array element\n\n for ($i = 0; $i < $len; $i++) {\n $ch = $text[$i];\n\n if ($inString) {\n if ($escaped) {\n $escaped = false;\n } elseif ($ch === '\\\\') {\n $escaped = true;\n } elseif ($ch === '\"') {\n $inString = false;\n }\n continue;\n }\n\n if ($ch === '\"') {\n $inString = true;\n } elseif ($ch === '{' || $ch === '[') {\n $stack[] = $ch;\n } elseif ($ch === '}' || $ch === ']') {\n array_pop($stack);\n } elseif ($ch === ',' && ! empty($stack) && end($stack) === '[') {\n // Separator directly inside an array: a clean boundary between\n // elements (the items array, whatever its nesting depth).\n $lastSafeCut = $i;\n }\n }\n\n // If we are still inside a string, the last element is incomplete — fall\n // back to the last clean array boundary if we have one.\n $candidate = $text;\n if ($inString && $lastSafeCut !== null) {\n $candidate = substr($text, 0, $lastSafeCut);\n } else {\n // Drop a dangling partial object after the last completed element.\n $candidate = rtrim($candidate);\n if ($lastSafeCut !== null && substr(rtrim($candidate), -1) !== '}' && substr(rtrim($candidate), -1) !== ']') {\n $candidate = substr($text, 0, $lastSafeCut);\n }\n }\n\n // Recompute the open brackets for the (possibly trimmed) candidate and\n // append the matching closers in reverse order.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($candidate);\n for ($i = 0; $i < $len; $i++) {\n $ch = $candidate[$i];\n if ($inString) {\n if ($escaped) { $escaped = false; }\n elseif ($ch === '\\\\') { $escaped = true; }\n elseif ($ch === '\"') { $inString = false; }\n continue;\n }\n if ($ch === '\"') { $inString = true; }\n elseif ($ch === '{' || $ch === '[') { $stack[] = $ch; }\n elseif ($ch === '}' || $ch === ']') { array_pop($stack); }\n }\n\n $candidate = rtrim($candidate);\n // Remove a trailing comma left after cutting an element.\n $candidate = rtrim($candidate, ',');\n\n $closers = '';\n for ($i = count($stack) - 1; $i >= 0; $i--) {\n $closers .= ($stack[$i] === '{') ? '}' : ']';\n }\n\n $repaired = $candidate . $closers;\n\n // Validate before returning.\n return is_array(json_decode($repaired, true)) ? $repaired : null;\n }\n\n private function spreadsheetToCompactCsvText(string $absPath): ?string\n {\n $sheets = $this->spreadsheetToGrids($absPath);\n if (empty($sheets)) {\n return null;\n }\n\n $output = '';\n $kept = 0;\n $dropped = 0;\n $nonSupply = 0;\n $duplicates = 0;\n\n /** @var array<string, true> Fingerprints of rows already emitted. */\n $seenRows = [];\n\n foreach ($sheets as $sheet) {\n // Don't feed summary / cost-abstract sheets to the AI either.\n if ($this->isSummarySheet($sheet['name'])) {\n continue;\n }\n $sheetRows = '';\n foreach ($sheet['grid'] as $row) {\n $row = $this->trimTrailingEmptyCells($row);\n\n if ($this->isNoiseRow($row)) {\n $dropped++;\n continue;\n }\n\n // Drop rows that are definitely not procurable before they cost\n // a single token: section totals, discipline headings,\n // preliminaries, labour-only lines. The same classifier already\n // runs on the AI's output, but by then the tokens are spent — on\n // an 11 200-row BOQ that is most of the bill.\n if ($this->isNonSupplyRow($row)) {\n $dropped++;\n $nonSupply++;\n continue;\n }\n\n // Byte-identical rows tell the AI nothing new. Only exact\n // duplicates are collapsed — description, quantity, unit and all\n // — so nothing is lost: two rows differing in quantity are two\n // different line items and both are sent.\n $fingerprint = $this->rowFingerprint($row);\n\n if (isset($seenRows[$fingerprint])) {\n $duplicates++;\n $dropped++;\n continue;\n }\n $seenRows[$fingerprint] = true;\n\n $sheetRows .= implode(',', array_map([$this, 'csvEscape'], $this->stripBoilerplate($row))) . \"\\n\";\n $kept++;\n }\n\n // A sheet whose rows were all noise contributes nothing but its own\n // header, which would just be another thing for the model to explain.\n if ($sheetRows === '') {\n continue;\n }\n\n $output .= 'Sheet: ' . $sheet['name'] . \"\\n\" . $sheetRows . \"\\n\";\n }\n\n $blank = $dropped - $nonSupply - $duplicates;\n\n Log::info('QuotationAiService: filtered rows before sending to AI.', [\n 'kept' => $kept,\n 'blank' => $blank,\n 'non_supply' => $nonSupply,\n 'duplicates' => $duplicates,\n ]);\n\n if ($dropped > 0) {\n $parts = [];\n if ($blank > 0) { $parts[] = \"{$blank} blank\"; }\n if ($nonSupply > 0) { $parts[] = \"{$nonSupply} non-supply\"; }\n if ($duplicates > 0) { $parts[] = \"{$duplicates} duplicate\"; }\n\n $this->reportStage('Skipped ' . implode(', ', $parts) . \" rows. Sending {$kept}…\");\n }\n\n return $output;\n }\n\n /**\n * True when a row carries no information worth sending to the AI.\n *\n * A real BOQ is mostly formatting: spacer rows, stray borders, a lone\n * section number, a leftover cell from a merged block. They survive\n * trimTrailingEmptyCells() because they are not *entirely* empty, then cost\n * tokens, dilute the prompt, and inflate the number of parts a large file is\n * split into.\n *\n * Deliberately conservative — a row is dropped only when nothing in it could\n * name a product. Anything with letters is kept, because a description is\n * the one field an item cannot do without.\n *\n * @param array<int, mixed> $row\n */\n /**\n * True when a row is definitely not a procurable supply item.\n *\n * Runs BoqCleaningService over the row's longest text cell — the one most\n * likely to be the description — so headings, totals, preliminaries and\n * labour-only lines never reach the AI. The same classifier already vets the\n * AI's *output*; doing it here as well means we stop paying to have those\n * rows read in the first place.\n *\n * Conservative by construction: only rows the classifier positively rejects\n * are dropped. Anything it is unsure about goes to the AI, which is the\n * better judge — a wrongly dropped row is invisible, a wrongly kept one only\n * costs tokens.\n *\n * @param array<int, mixed> $row\n */\n /**\n * Stable identity for a row, used to collapse exact duplicates.\n *\n * Whitespace and case are normalised so \"DN50 PIPE\" and \"dn50 pipe \" are one\n * row, but nothing else is: a differing quantity or unit produces a\n * different fingerprint and both rows are sent.\n *\n * @param array<int, mixed> $row\n */\n private function rowFingerprint(array $row): string\n {\n $parts = array_map(\n fn($cell) => mb_strtolower(preg_replace('/\\s+/u', ' ', trim((string) $cell)) ?? ''),\n $row,\n );\n\n // Drop a leading BOQ sequence number. Otherwise \"1,Pipe,m,100\" and\n // \"2,Pipe,m,100\" fingerprint differently and the dedup never fires —\n // which is exactly the shape every real BOQ has.\n if (isset($parts[0]) && $parts[0] !== '' && preg_match('/^[\\d.]+$/', $parts[0])) {\n array_shift($parts);\n }\n\n return md5(implode('|', $parts));\n }\n\n /**\n * Strip contractual boilerplate from a row's description cell.\n *\n * \"as per drawings\", \"complete with all necessary accessories\" and friends\n * repeat on nearly every line of a real BOQ and carry no information the\n * pricing engine can use — but they are a large share of the tokens.\n *\n * Only the longest text cell is touched, and only when the result is still a\n * usable description: if stripping would leave too little to identify the\n * product, the original is kept. A shorter-but-wrong description is far\n * worse than a verbose one.\n *\n * @param array<int, mixed> $row\n * @return array<int, mixed>\n */\n private function stripBoilerplate(array $row): array\n {\n $index = null;\n $longest = '';\n\n foreach ($row as $i => $cell) {\n $text = trim((string) $cell);\n if (preg_match('/\\p{L}/u', $text) && mb_strlen($text) > mb_strlen($longest)) {\n $longest = $text;\n $index = $i;\n }\n }\n\n // Nothing worth cleaning, or too short to risk it.\n if ($index === null || mb_strlen($longest) < 40) {\n return $row;\n }\n\n try {\n $cleaned = $this->boqCleaner->cleanDescription($longest);\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: boilerplate strip failed, keeping row as-is.', [\n 'error' => $e->getMessage(),\n ]);\n return $row;\n }\n\n // Guard against an over-eager pattern eating the product name: keep the\n // original unless a recognisable description survives.\n if (mb_strlen($cleaned) < 12 || mb_strlen($cleaned) < mb_strlen($longest) * 0.3) {\n return $row;\n }\n\n $row[$index] = $cleaned;\n\n return $row;\n }\n\n private function isNonSupplyRow(array $row): bool\n {\n $description = '';\n foreach ($row as $cell) {\n $text = trim((string) $cell);\n // Longest cell containing letters — numbers are qty/rate columns.\n if (preg_match('/\\p{L}/u', $text) && mb_strlen($text) > mb_strlen($description)) {\n $description = $text;\n }\n }\n\n // No text at all: leave it to isNoiseRow()/the AI rather than guessing.\n if ($description === '' || mb_strlen($description) < 3) {\n return false;\n }\n\n try {\n $verdict = $this->boqCleaner->filterItem($description);\n } catch (\\Throwable $e) {\n // A classifier failure must never silently drop a real product.\n Log::warning('QuotationAiService: pre-filter classifier failed, keeping row.', [\n 'error' => $e->getMessage(),\n ]);\n return false;\n }\n\n return ! ($verdict['keep'] ?? true);\n }\n\n private function isNoiseRow(array $row): bool\n {\n if (empty($row)) {\n return true;\n }\n\n $joined = trim(implode('', array_map(fn($c) => trim((string) $c), $row)));\n\n // Nothing but separators/whitespace.\n if ($joined === '') {\n return true;\n }\n\n // Ruled lines and decorative fills (\"-----\", \"====\", \"___\").\n if (preg_match('/^[\\-_=.*\\s|]+$/u', $joined)) {\n return true;\n }\n\n // Any letter — Latin or Arabic — means this could be a description.\n if (preg_match('/\\p{L}/u', $joined)) {\n return false;\n }\n\n // No letters at all: numbers only. A single number is a stray row\n // number or a dangling total; two or more could be qty/rate/amount\n // belonging to a real line, so keep those for the AI to judge.\n $numericCells = 0;\n foreach ($row as $cell) {\n if (trim((string) $cell) !== '') {\n $numericCells++;\n }\n }\n\n return $numericCells <= 1;\n }\n\n /**\n * Safely convert a PhpSpreadsheet cell value to a string.\n *\n * Cells may contain RichText or, in malformed/heavily-formatted files,\n * embedded objects such as Worksheet\\Drawing that have no __toString().\n * Casting those directly throws \"Object ... could not be converted to string\".\n */\n private function cellValueToString(mixed $value): string\n {\n if ($value === null) {\n return '';\n }\n\n if ($value instanceof \\PhpOffice\\PhpSpreadsheet\\RichText\\RichText) {\n return $value->getPlainText();\n }\n\n if (is_object($value)) {\n // Only stringify objects that explicitly support it; ignore the rest\n // (e.g. Worksheet\\Drawing) instead of crashing.\n return method_exists($value, '__toString') ? (string) $value : '';\n }\n\n if (is_array($value)) {\n return '';\n }\n\n return (string) $value;\n }\n\n private function csvEscape(mixed $value): string\n {\n $value = $this->cellValueToString($value);\n return str_contains($value, ',') || str_contains($value, '\"') || str_contains($value, \"\\n\")\n ? '\"' . str_replace('\"', '\"\"', $value) . '\"'\n : $value;\n }\n\n private function verifyLargeBoqItemsWithAi(array $result): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n Log::warning('QuotationAiService: large BOQ verification skipped, AI key is not configured.');\n return $result;\n }\n\n $items = array_values(array_filter($result['items'] ?? [], 'is_array'));\n if (count($items) <= self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n return $result;\n }\n\n Log::info('QuotationAiService: starting second-pass physical item verification.', [\n 'items_count' => count($items),\n 'threshold' => self::LARGE_BOQ_VERIFICATION_THRESHOLD,\n ]);\n\n $verifiedItems = [];\n $rejectedItems = $result['rejected'] ?? [];\n\n foreach (array_chunk($items, self::LARGE_BOQ_VERIFICATION_CHUNK_SIZE) as $chunkIndex => $chunk) {\n $payload = array_map(fn(array $item) => [\n 'description' => (string) ($item['description'] ?? ''),\n 'quantity' => $item['quantity'] ?? null,\n 'unit' => (string) ($item['unit'] ?? ''),\n 'unit_price' => $item['unit_price'] ?? null,\n 'category' => (string) ($item['category'] ?? ''),\n 'brand' => (string) ($item['brand'] ?? ''),\n ], $chunk);\n\n $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);\n if (! is_string($json)) {\n Log::warning('QuotationAiService: large BOQ verification chunk JSON encode failed.', [\n 'chunk' => $chunkIndex + 1,\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n $prompt = <<<PROMPT\nYou are doing a SECOND PASS verification on a BOQ extraction result.\n\nThe first pass may hallucinate non-products when the BOQ has thousands of rows.\nYour job is to keep ONLY physical, tangible, procurable supply products.\n\nRules:\n- Keep items only if a supplier can actually deliver them as materials/equipment.\n- Reject headings, trade names, systems, scopes, prose, specifications-only rows, totals, labor, installation, testing, commissioning, supervision, drawings, insurance, preliminaries, mobilisation, site works, and vague fragments.\n- Do not invent new products.\n- Do not add items that are not in the input.\n- You may clean a product description, but keep the original quantity, unit, unit_price, category, and brand when available.\n\nInput JSON array:\n{$json}\n\nReturn ONLY valid JSON in this shape:\n{\"items\":[{\"description\":\"\",\"quantity\":1,\"unit\":\"\",\"unit_price\":null,\"category\":\"\",\"brand\":\"\",\"confidence\":0.95}]}\nPROMPT;\n\n try {\n $chunkResult = $this->callDeepSeekChat($prompt, $apiKey, $this->deepSeekModel());\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: large BOQ verification chunk threw an exception.', [\n 'chunk' => $chunkIndex + 1,\n 'message' => $e->getMessage(),\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n if ($chunkResult['success'] ?? false) {\n array_push($verifiedItems, ...($chunkResult['items'] ?? []));\n $rejectedItems = array_merge($rejectedItems, $chunkResult['rejected'] ?? []);\n continue;\n }\n\n Log::warning('QuotationAiService: large BOQ verification chunk failed, keeping original chunk.', [\n 'chunk' => $chunkIndex + 1,\n 'error' => $chunkResult['error'] ?? null,\n ]);\n array_push($verifiedItems, ...$chunk);\n }\n\n if (empty($verifiedItems)) {\n Log::warning('QuotationAiService: second-pass verification returned no items, keeping first-pass result.', [\n 'original_items_count' => count($items),\n ]);\n return $result;\n }\n\n $result['items'] = $verifiedItems;\n $result['rejected'] = $rejectedItems;\n $result['large_boq_verified'] = true;\n\n Log::info('QuotationAiService: second-pass physical item verification completed.', [\n 'before' => count($items),\n 'after' => count($verifiedItems),\n ]);\n\n return $result;\n }\n\n private function normaliseAiItem(array $raw): ?array\n {\n if (! empty($raw['rejected'])) {\n return null;\n }\n\n $description = trim((string) ($raw['cleaned_description'] ?? $raw['product_name'] ?? $raw['description'] ?? ''));\n if ($description === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $description)) {\n return null;\n }\n\n $quantity = is_numeric($raw['quantity'] ?? null) ? (float) $raw['quantity'] : 1;\n $unitPrice = is_numeric($raw['unit_price'] ?? null) ? (float) $raw['unit_price'] : null;\n $totalPrice = is_numeric($raw['total_price'] ?? null) ? (float) $raw['total_price'] : null;\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $specs = is_array($raw['specifications'] ?? null) ? $raw['specifications'] : [];\n\n return [\n 'description' => $description,\n 'quantity' => $quantity,\n 'unit' => (string) ($raw['unit'] ?? ''),\n // Clamped to the column width. The model routinely returns a whole\n // description here — \"Cocktail Unit Ice well adjacent to chopping\n // area with waste chute…\" — and a value over 100 chars made the\n // INSERT fail with SQLSTATE[22001], which silently cost the whole\n // chunk its rows and left the reuse tables empty.\n 'category' => mb_substr((string) ($raw['core_product'] ?? $raw['category'] ?? ''), 0, 100),\n 'brand' => mb_substr((string) ($specs['brand'] ?? $raw['brand'] ?? ''), 0, 100),\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($description),\n 'unit_price' => $unitPrice,\n 'confidence' => is_numeric($raw['confidence'] ?? null) ? (float) $raw['confidence'] : null,\n 'needs_review' => ! empty($raw['needs_review']),\n 'raw_data' => [\n 'source_item_no' => (string) ($raw['source_item_no'] ?? ''),\n 'original_description' => (string) ($raw['original_description'] ?? $description),\n 'cleaned_description' => $description,\n 'core_product' => (string) ($raw['core_product'] ?? ''),\n 'specifications' => $specs,\n 'extraction_type' => (string) ($raw['extraction_type'] ?? 'supply_only'),\n 'note' => (string) ($raw['note'] ?? $raw['notes'] ?? ''),\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function buildSystemInstruction(): string\n {\n return <<<'TXT'\nYou are a strict BOQ Supply Product Extraction Engine.\nExtract ONLY concrete, tangible, physical products that a supplier can box up and deliver to site — a specific item you could point at and say \"this is the thing being bought\" (e.g. \"Wall mounted LED luminaire 36W\", \"PVC pipe 110mm\", \"Single phase distribution board 12-way\", \"Fire extinguisher CO2 6kg\", \"CCTV dome camera 4MP\").\n\nABSOLUTELY REJECT abstract concepts, disciplines, categories, section names, and trade headings. These are NOT products even though they appear as rows:\n- Bare discipline/trade words: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", \"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\".\n- Summary / total / abstract / \"dry cost\" rows of any kind.\n- Anything that names a system, scope, or category instead of one buyable item.\n\nRule of thumb: if you cannot name a unit and physically hand the item over (a piece, a box, a length, a unit of equipment), REJECT it. A word that describes a field of work, a system, or a section of the BOQ is NEVER a product.\nReject totals, headings, general requirements, installation-only, testing, commissioning, supervision, mobilization, civil works, labor, and vague rows.\nFor Supply & Install rows, keep only the supply product and remove install/labor wording.\nReturn only valid JSON: {\"items\":[{\"source_item_no\":null,\"original_description\":\"\",\"cleaned_description\":\"\",\"core_product\":\"\",\"specifications\":{},\"quantity\":0,\"unit\":\"\",\"unit_price\":0,\"total_price\":0,\"extraction_type\":\"supply_only\",\"needs_review\":false,\"note\":\"\",\"rejected\":false,\"rejection_reason\":null,\"confidence\":0.95}]}\nTXT;\n }\n\n private function buildDeepSeekPrompt(array $context = [], string $mime = ''): string\n {\n $projectName = (string) ($context['project_name'] ?? '');\n $projectStatus = (string) ($context['project_status'] ?? '');\n $contextLine = $projectName !== ''\n ? \"Project: {$projectName}\" . ($projectStatus !== '' ? \" ({$projectStatus})\" : '')\n : '';\n $sourceHint = str_starts_with($mime, 'image/')\n ? 'The input is an image of a BOQ table. OCR every cell carefully — read the quantity/count column (العدد, الكمية, Qty) and extract the EXACT numeric value from each row.'\n : 'The input is a BOQ document/spreadsheet.';\n\n return trim(<<<PROMPT\nYou are a procurement specialist. Your task is to extract ONLY tangible, physical supply products from this Bill of Quantities (BOQ).\n\n{$contextLine}\n{$sourceHint}\n\nCRITICAL — MERGE WRAPPED / SPLIT ROWS INTO ONE ITEM:\nBOQ tables extracted from PDF often break ONE item's long description across several\nlines/rows. These continuation lines repeat the SAME quantity, unit and category, and\ntheir text is a grammatical continuation of the previous line (it starts mid-sentence,\nlowercase, or with \"the/and/-\", or is a spec/instruction clause). You MUST reconstruct\nthe original single item:\n- Treat consecutive rows that share the SAME quantity + unit + category as ONE product\n when the later rows are clearly continuation text of the first, NOT new products.\n- Emit ONE item with the merged description and the price counted ONCE. Do NOT repeat\n the same quantity×price for every wrapped line — that multiplies the total wrongly.\n- Example: rows \"The asphalt flooring is to be supplied\" / \"installed in accordance\" /\n \"standard road specifications...\" all with 7,100 m2 are ONE asphalt item, not 3+.\n\nCRITICAL — UNDERSTAND INTENT, DO NOT COPY ROWS LITERALLY:\nYour job is to output WHAT A SUPPLIER MUST ACTUALLY DELIVER, not a copy of the row text.\nRead each description for MEANING and decide how many real, separately-buyable products it\nimplies. One BOQ row can imply SEVERAL products, or a spread of rows can imply ONE product.\n- SPLIT a row into multiple items when it lists distinct products that a supplier would\n buy/price separately — even when joined by commas, \"with\", \"and\", \"including\", \"c/w\",\n \"consisting of\", \"comprising\", or \":\" then a list.\n Example: \"Fire pump set consisting of: fire pump, electric motor, control panel, diesel\n engine\" → FOUR items: (1) fire pump, (2) electric motor, (3) control panel, (4) diesel\n engine — each with its own quantity and priced on its own.\n Example: \"Wash basin complete with mixer tap and bottle trap\" → THREE items: wash basin,\n mixer tap, bottle trap.\n- Give each split product the correct quantity implied by the text (usually the parent\n quantity, unless the text states a different count for a sub-item). Each product is\n priced individually; the line total becomes the SUM of its products.\n- DO NOT split when the extra words are just specifications, dimensions, colours, ratings,\n standards, or a single product's integral parts. \"Steel door 900x2100 with vision panel\n and SS ironmongery\" is usually ONE door product — vision panel and ironmongery are part\n of that door, not separately-bought items. Use judgement: would a supplier issue a\n separate price line for it? If no, keep it as one.\n- Balanced approach: split only when genuinely separate products are present; when in doubt\n about whether something is a separate product vs. an accessory/spec of the main one, keep\n it as a single item. Do not invent products that are not in the text.\n\nSTRICT RULES — include ONLY items that are ALL of the following:\n1. A specific, concrete, tangible product that can be boxed up by a supplier and delivered to site — something you can point at and say \"this exact thing is being bought\" (e.g. \"LED luminaire 36W\", \"PVC pipe 110mm\", \"Distribution board 12-way\", \"CO2 fire extinguisher 6kg\").\n2. Has a real quantity (not 0 or \"varies\" with no meaning).\n3. Has enough description to identify what the product is.\n4. Is an actual PRODUCT, not a sentence, instruction, or specification clause. REJECT rows that are prose such as \"the supervising engineer's instructions\", \"The price includes all materials, labor\", \"Measurement will be based on the geometric cubic meter\", \"necessary steps to complete the work\", \"soil-contacting elements\", \"in compliance\", \"everything necessary\". These are description fragments, not buyable items.\n\nNEVER extract abstract concepts, disciplines, trades, systems, or section/category names — they are NOT products even when they appear as their own row:\n- Bare discipline/trade/system words such as: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", and their Arabic equivalents (\"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\", \"تشطيبات\").\n- Test: if the word names a FIELD OF WORK or a SYSTEM rather than one buyable item with a unit (pcs, m, set, kg…), REJECT it.\n\nEXCLUDE all of the following (do NOT include them):\n- Labor, installation, or workmanship items (e.g. \"install\", \"fix\", \"erect\", \"dismantle\", \"painting\", \"cleaning service\")\n- Project management, overhead, or profit items\n- Insurance, bonds, liability items (e.g. \"bodily injury\", \"third party liability\", \"CAR insurance\")\n- Shop drawings, as-built drawings, method statements, documentation\n- Preliminary items, mobilisation, demobilisation, provisional sums\n- Housekeeping, site clearance, waste removal\n- Floor/level labels (e.g. \"GF Floor\", \"Level 3\") — these are headers, not products\n- Pure dimension specs without a product noun (e.g. \"Dia 32 mm\", \"25 mm\") — reject unless a product name is present\n- Security services, night shifts, rentals, payments\n- Any item whose description is too vague or fragmentary to identify a specific product\n\nEmit ONE JSON item PER real product. A single source row may therefore produce several\nitems (when it lists multiple products) or several rows may collapse into one item (when\nthey are wrapped fragments of the same product). The \"items\" array is your final delivery\nlist, not a row-by-row transcript.\n\nFor each valid physical product return JSON with:\n- description: clean product name only (strip \"supply and install\", \"supply and fix\", installation clauses, floor/spec suffixes). For a split product, use just that product's own name.\n- quantity: the EXACT numeric value from the quantity/count column (العدد, الكمية, Qty, Quantity, No.). NEVER default to 1 unless the quantity column is truly absent or empty.\n- unit: unit of measure (pcs, m, m2, m3, set, no, kg, etc.)\n- category: product category\n- brand: brand/manufacturer if mentioned, else \"\"\n- engineering_required: true if the item needs engineering design before procurement (panel boards, chillers, generators, fire suppression systems, BMS, pumps, AHUs, switchgear, transformers), else false\n\nReturn ONLY a valid JSON object: {\"items\": [...]}. No explanations, no markdown, no extra text.\nPROMPT);\n }\n\n private function mockResponse(): array\n {\n return [\n 'success' => true,\n 'error' => null,\n 'items' => [[\n 'description' => 'Sample BOQ Item',\n 'quantity' => 1,\n 'unit' => 'No',\n 'category' => 'Sample',\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 1,\n 'raw_data' => [],\n 'ai_extracted' => true,\n ]],\n 'rejected' => [],\n ];\n }\n\n private function failure(string $message, bool $serviceUnavailable = false): array\n {\n return [\n 'success' => false,\n 'items' => [],\n 'rejected' => [],\n 'error' => $this->userFacingError($message, $serviceUnavailable),\n 'service_unavailable' => $serviceUnavailable,\n ];\n }\n\n /**\n * Translate any internal error into a neutral, user-safe message.\n *\n * The user must never see the name of the underlying AI/processing engine\n * (DeepSeek, Groq, Gemini, Vision, etc.) or configuration hints like API\n * keys. The detailed message is kept in the logs for debugging only.\n */\n private function userFacingError(string $message, bool $serviceUnavailable = false): string\n {\n // Detect any reference to an AI provider / engine / API config so we can\n // swap it for a generic, branded-neutral message.\n $sensitive = '/deepseek|groq|gemini|vision|openai|openrouter|api[\\s_-]?key|\\.env|non-?json|token limit/i';\n\n if (preg_match($sensitive, $message)) {\n Log::warning('QuotationAiService: suppressed technical error from user.', ['internal' => $message]);\n\n return $serviceUnavailable\n ? 'The file could not be processed right now. Please try again in a moment.'\n : 'We could not read this file automatically. Please make sure it is a clear BOQ (Excel, CSV, PDF, or image) and try again, or add the items manually.';\n }\n\n return $message;\n }\n\n private function mimeForExtension(string $ext): string\n {\n return match (strtolower($ext)) {\n 'pdf' => 'application/pdf',\n 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n 'xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12',\n 'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',\n 'xls' => 'application/vnd.ms-excel',\n 'csv' => 'text/csv',\n 'jpg', 'jpeg' => 'image/jpeg',\n 'png' => 'image/png',\n 'gif' => 'image/gif',\n 'bmp' => 'image/bmp',\n 'webp' => 'image/webp',\n 'tiff', 'tif' => 'image/tiff',\n 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'doc' => 'application/msword',\n default => 'application/octet-stream',\n };\n }\n}\n"}, "31eba737716b48e69f3ed688aeaf7bc864a34c87": {"t": 1785748349.2719965, "n": 99727, "h": "e90fe0f56938737a", "c": "<?php\n\nnamespace App\\Services;\n\nuse Illuminate\\Http\\Client\\ConnectionException;\nuse Illuminate\\Http\\UploadedFile;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\nuse PhpOffice\\PhpSpreadsheet\\Cell\\Coordinate;\nuse PhpOffice\\PhpSpreadsheet\\IOFactory;\n\nclass QuotationAiService\n{\n /** Increment this whenever BOQ parsing logic changes to invalidate old caches. */\n private const PARSER_VERSION = 10;\n private const LARGE_BOQ_VERIFICATION_THRESHOLD = 2000;\n private const LARGE_BOQ_VERIFICATION_CHUNK_SIZE = 120;\n\n /**\n * Characters of document text sent per AI call.\n *\n * Above this the document is split and parsed over several calls. Sized well\n * under the model's context so the prompt, the slice, and a full JSON answer\n * for every row in it all fit — the binding limit is the *output* budget, not\n * the input, since each input row produces a JSON object.\n */\n private const TEXT_CHUNK_CHARS = 60000;\n\n /**\n * A workbook with more sheets than this is split regardless of its size.\n *\n * Multiple tabs almost always mean multiple disciplines (electrical, HVAC,\n * civil…). Sending them in one call makes the model summarise across\n * sections and silently drop rows, so each slice gets a narrower scope.\n */\n private const MAX_SHEETS_PER_CALL = 2;\n\n /** A PDF longer than this is split regardless of its extracted text length. */\n private const MAX_PDF_PAGES_PER_CALL = 5;\n\n /**\n * Largest PDF we will hand to the parser (12 MB).\n *\n * smalot/pdfparser decompresses every stream into memory with no ceiling of\n * its own, and overrunning the limit there is a fatal error that kills the\n * worker rather than an exception we can recover from.\n */\n private const MAX_PDF_BYTES = 12582912;\n\n /**\n * Memory the parser is assumed to need, as a multiple of the file size.\n *\n * Decompressed content streams plus the object graph run well past the raw\n * bytes; 8x is conservative enough to catch the cases that were dying.\n */\n private const PDF_MEMORY_FACTOR = 8;\n\n /** Returned by extractPdfText when it stopped for memory, not for lack of text. */\n private const PDF_LOW_MEMORY = \"\\0__pdf_low_memory__\";\n\n private string $baseUrl;\n private string $parseEndpoint;\n private string $apiKey;\n private int $timeout;\n private bool $testMode;\n private BoqCleaningService $boqCleaner;\n\n /** Page count of the most recently parsed PDF; 0 when unknown. */\n private int $lastPdfPageCount = 0;\n\n /**\n * Optional progress reporter for chunked parsing, called as ($part, $total).\n *\n * Set by the queue job so the polling UI can show real progress on a large\n * BOQ instead of a spinner that appears frozen for many minutes.\n *\n * @var (callable(int, int): void)|null\n */\n private $onChunkProgress = null;\n\n /** @param callable(int, int): void $callback */\n public function onChunkProgress(callable $callback): self\n {\n $this->onChunkProgress = $callback;\n return $this;\n }\n\n /**\n * Optional per-chunk item sink, called as ($items, $part, $total).\n *\n * Lets the caller persist each slice's rows the moment they arrive instead\n * of waiting for the whole document. On a large BOQ this is the difference\n * between a table that fills in progressively and one that stays empty for\n * many minutes.\n *\n * @var (callable(array, int, int): void)|null\n */\n private $onChunkItems = null;\n\n /** @param callable(array, int, int): void $callback */\n public function onChunkItems(callable $callback): self\n {\n $this->onChunkItems = $callback;\n return $this;\n }\n\n /**\n * Report a non-chunk stage (local parse, handing off to AI) to the caller.\n *\n * A spreadsheet is read locally first and only sent to the AI when that\n * fails, so a large file can spend minutes before any chunk exists. Without\n * these the UI shows a generic spinner for that whole period.\n */\n private function reportStage(string $message): void\n {\n if ($this->onStage !== null) {\n ($this->onStage)($message);\n }\n }\n\n /**\n * Report a workbook's shape from grids that are already in memory.\n *\n * Deliberately takes the parsed grids rather than a path: re-reading a large\n * spreadsheet just to count its sheets would double the slowest step of the\n * whole extraction.\n *\n * @param array<int, array{name: string, grid: array}> $sheets\n */\n private function reportWorkbookShape(array $sheets): void\n {\n if ($this->onStage === null || empty($sheets)) {\n return;\n }\n\n $rows = 0;\n foreach ($sheets as $sheet) {\n $rows += count($sheet['grid']);\n }\n\n $count = count($sheets);\n\n $this->reportStage($count > 1\n ? \"Found {$count} sheets, {$rows} rows in total. Reading…\"\n : \"Found {$rows} rows. Reading…\");\n }\n\n /** @var (callable(string): void)|null */\n private $onStage = null;\n\n /** @param callable(string): void $callback */\n public function onStage(callable $callback): self\n {\n $this->onStage = $callback;\n return $this;\n }\n\n public function __construct(BoqCleaningService $boqCleaner)\n {\n $this->boqCleaner = $boqCleaner;\n $this->baseUrl = rtrim((string) config('services.ai_quotation.base_url', ''), '/');\n $this->parseEndpoint = ltrim((string) config('services.ai_quotation.parse_endpoint', 'parse'), '/');\n $this->apiKey = (string) config('services.ai_quotation.api_key', '');\n $this->timeout = (int) config('services.ai_quotation.timeout', 300);\n $this->testMode = (bool) config('services.ai_quotation.test_mode', false);\n }\n\n /**\n * Give the running script enough head-room to outlast the AI HTTP call.\n *\n * The web request / queue worker must not be killed before the HTTP timeout\n * completes, otherwise the user sees a generic \"timeout or unavailable\"\n * error even though the AI may have responded. We allow the HTTP timeout\n * plus a 120s margin for connecting, response parsing and DB writes.\n */\n private function extendExecutionTime(): void\n {\n // Ignored on some SAPIs (e.g. when running under fastcgi with a hard\n // limit), but harmless to attempt.\n @set_time_limit($this->timeout + 120);\n }\n\n public function parseBoq(UploadedFile|string $file, array $context = []): array\n {\n if ($this->testMode) {\n return $this->mockResponse();\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n\n if (! is_file($absPath)) {\n return $this->failure('File not found or not readable.');\n }\n\n $fileHash = hash_file('sha256', $absPath);\n // Bump PARSER_VERSION whenever the extraction logic changes so that\n // previously-uploaded files are re-parsed instead of returning a stale\n // cached result produced by the old code.\n $cacheKey = 'boq_analysis_v' . self::PARSER_VERSION . '_' . $fileHash;\n $forceRefresh = (bool) ($context['force_refresh'] ?? false);\n\n if (! $forceRefresh && ($cached = Cache::get($cacheKey)) !== null) {\n return $cached;\n }\n\n // Clear stale cached result when force-refreshing\n if ($forceRefresh) {\n Cache::forget($cacheKey);\n }\n\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n // Read Excel locally first. Fall back to AI if local parse failed,\n // found 0 items, OR could not read one of the sheets (so multi-sheet\n // files never silently drop a sheet the local parser didn't understand).\n $this->reportStage('Reading the spreadsheet…');\n\n $result = $this->parseSpreadsheetDirect($absPath);\n\n $hasSkippedSheets = ! empty($result['skipped_sheets']);\n\n if (! $result['success'] || empty($result['items']) || $hasSkippedSheets) {\n // The local pass could not handle this file on its own. Say so:\n // otherwise the UI sits on a generic spinner through the local\n // parse AND the whole AI parse with nothing to show for either.\n $this->reportStage($hasSkippedSheets\n ? 'Some sheets need AI to read. Sending to AI…'\n : 'Spreadsheet needs AI to read. Sending to AI…');\n\n $deepSeekResult = $this->parseBoqWithDeepSeek($file, $context);\n // AI sees every sheet at once, so prefer its result whenever it\n // actually returned items (covers the skipped-sheet case too).\n if ($deepSeekResult['success'] && ! empty($deepSeekResult['items'])) {\n // Keep the larger of the two item sets so we never lose the\n // rows the local parser already extracted correctly.\n if (count($deepSeekResult['items']) >= count($result['items'] ?? [])) {\n $result = $deepSeekResult;\n }\n } elseif (! $result['success']) {\n $result = $deepSeekResult;\n }\n // else: keep local result if AI also failed/returned nothing\n }\n } elseif (in_array($ext, ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif'], true)) {\n $result = $this->parseBoqWithDeepSeek($file, $context);\n } else {\n return $this->failure('Unsupported file type. Please upload Excel, CSV, PDF, or image file.');\n }\n\n if ($result['success'] && ! empty($result['items'])) {\n $cleaning = $this->boqCleaner->process($result['items']);\n $result['items'] = $cleaning['accepted'] ?? $result['items'];\n $result['rejected'] = array_merge($result['rejected'] ?? [], $cleaning['rejected'] ?? []);\n }\n\n if ($result['success'] && count($result['items'] ?? []) > self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n $result = $this->verifyLargeBoqItemsWithAi($result);\n }\n\n // Only cache when we actually extracted items; empty results should be re-tried.\n if ($result['success'] && ! empty($result['items'])) {\n Cache::put($cacheKey, $result, now()->addDays(30));\n }\n\n return $result;\n }\n\n private function resolveFile(UploadedFile|string $file): array\n {\n if ($file instanceof UploadedFile) {\n return [(string) $file->getRealPath(), strtolower($file->getClientOriginalExtension())];\n }\n\n $absPath = is_file($file) ? $file : storage_path('app/' . ltrim($file, '/'));\n return [$absPath, strtolower(pathinfo($absPath, PATHINFO_EXTENSION))];\n }\n\n private function parseSpreadsheetDirect(string $absPath): array\n {\n ini_set('memory_limit', '2048M');\n\n // 180s was not enough for a real BOQ: an 11 200-row workbook was killed\n // mid-parse, and because set_time_limit() kills the process outright the\n // job reported a bare failure with nothing in the log to explain it.\n // This runs on the queue, where wall time is cheap.\n set_time_limit(1800);\n\n try {\n $sheets = $this->spreadsheetToGrids($absPath);\n\n if (empty($sheets)) {\n return $this->failure('Could not read the Excel file.');\n }\n\n $this->reportWorkbookShape($sheets);\n\n $items = [];\n $rejected = [];\n $skippedSheets = []; // sheets we could not parse locally (header undetected)\n\n foreach ($sheets as $sheet) {\n $grid = $sheet['grid'];\n if (count($grid) < 2) {\n continue;\n }\n\n // Skip dedicated summary / cost-abstract sheets entirely. Their rows\n // are per-discipline totals (Structural Concrete, Electrical, …) and\n // grand totals, not procurable products.\n if ($this->isSummarySheet($sheet['name'])) {\n Log::info('QuotationAiService: skipped summary sheet.', ['sheet' => $sheet['name']]);\n continue;\n }\n\n $header = $this->detectHeader($grid);\n\n if ($header === null || ! isset($header['map']['description'])) {\n Log::warning('QuotationAiService: header not detected in sheet.', ['sheet' => $sheet['name']]);\n $skippedSheets[] = $sheet['name'];\n continue;\n }\n\n $map = $header['map'];\n $start = $header['row'] + 1;\n\n for ($r = $start; $r < count($grid); $r++) {\n $row = $grid[$r];\n $description = $this->cell($row, $map['description'] ?? null);\n $itemNo = $this->cell($row, $map['item_code'] ?? null);\n $unit = $this->cell($row, $map['unit'] ?? null);\n $quantity = $this->number($this->cell($row, $map['quantity'] ?? null));\n $unitPrice = $this->number($this->cell($row, $map['unit_price'] ?? null));\n $totalPrice = $this->number($this->cell($row, $map['total_price'] ?? null));\n\n if ($description === '') {\n continue;\n }\n\n if (! isset($map['quantity'])) {\n $quantity = 1.0;\n }\n\n // Skip floor breakdown sub-rows: Ref. is \"-\" meaning it's a per-floor\n // quantity split of the parent item, not a standalone product.\n if ($itemNo === '-' || $this->isFloorBreakdownRow($itemNo, $description)) {\n continue;\n }\n\n if ($this->isTotalOrHeaderRow($description)) {\n continue;\n }\n\n if ($quantity === null || $quantity <= 0) {\n $rejected[] = $this->rejectedRow($description, $unit, $quantity, 'Missing quantity', $itemNo, $sheet['name']);\n continue;\n }\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $supply = $this->boqCleaner->filterItem($description, $unitPrice, $quantity);\n\n if (! ($supply['keep'] ?? false)) {\n $rejected[] = $this->rejectedRow(\n $description,\n $unit,\n $quantity,\n (string) ($supply['rejection_reason'] ?? 'Rejected by cleaner'),\n $itemNo,\n $sheet['name']\n );\n continue;\n }\n\n $finalDescription = (string) ($supply['description'] ?? $description);\n\n $items[] = [\n 'description' => $finalDescription,\n 'quantity' => $quantity,\n 'unit' => $unit,\n 'category' => $sheet['name'],\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($finalDescription),\n 'unit_price' => $unitPrice,\n 'confidence' => 0.95,\n 'needs_review' => false,\n 'raw_data' => [\n 'sheet' => $sheet['name'],\n 'row_number' => $r + 1,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'cleaned_description' => $finalDescription,\n 'extraction_type' => (string) ($supply['extraction_type'] ?? 'supply_only'),\n ],\n 'ai_extracted' => true,\n ];\n }\n }\n\n if (empty($items)) {\n return $this->failure('No BOQ supply items could be extracted from the Excel file.');\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'skipped_sheets' => $skippedSheets,\n ];\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: parseSpreadsheetDirect failed.', [\n 'path' => $absPath,\n 'message' => $e->getMessage(),\n 'trace' => $e->getTraceAsString(),\n ]);\n\n return $this->failure('Failed to read the Excel file: ' . $e->getMessage());\n }\n }\n\n private function spreadsheetToGrids(string $absPath): array\n {\n $ext = strtolower(pathinfo($absPath, PATHINFO_EXTENSION));\n\n if ($ext === 'csv') {\n $rows = [];\n $handle = fopen($absPath, 'rb');\n if ($handle !== false) {\n while (($row = fgetcsv($handle)) !== false) {\n $rows[] = $this->trimTrailingEmptyCells($row);\n }\n fclose($handle);\n }\n return [['name' => 'Sheet1', 'grid' => $rows]];\n }\n\n $reader = IOFactory::createReader($ext === 'xls' ? 'Xls' : 'Xlsx');\n $reader->setReadDataOnly(true);\n $spreadsheet = $reader->load($absPath);\n $sheets = [];\n\n foreach ($spreadsheet->getAllSheets() as $sheet) {\n $highestRow = $sheet->getHighestDataRow();\n $highestColumn = $sheet->getHighestDataColumn();\n $highestColumnIndex = Coordinate::columnIndexFromString($highestColumn);\n\n // BOQ data is normally in first 8-20 columns.\n // Files with heavy formatting/images can report 300+ columns — limit to 30.\n $maxColumnIndex = min($highestColumnIndex, 30);\n\n $grid = [];\n for ($row = 1; $row <= $highestRow; $row++) {\n $rowData = [];\n $hasValue = false;\n\n for ($col = 1; $col <= $maxColumnIndex; $col++) {\n $value = $sheet->getCell([$col, $row])->getValue();\n $value = $this->cellValueToString($value);\n $value = trim($value);\n if ($value !== null && $value !== '') {\n $hasValue = true;\n }\n $rowData[] = $value;\n }\n\n $rowData = $this->trimTrailingEmptyCells($rowData);\n if ($hasValue) {\n $grid[] = $rowData;\n }\n }\n\n if (! empty($grid)) {\n $sheets[] = ['name' => $sheet->getTitle(), 'grid' => $grid];\n }\n }\n\n $spreadsheet->disconnectWorksheets();\n unset($spreadsheet);\n\n return $sheets;\n }\n\n private function detectHeader(array $grid): ?array\n {\n $keywords = [\n 'item_code' => ['رقم البند', 'item no', 'item number', 'ref.', 'ref', 'item', 'no.', 'no', '#', 'كود'],\n 'description' => ['وص٠البند', 'scope of works', 'scope of work', 'scope', 'item description', 'description of works', 'description', 'بالعربية', 'الوصÙ', 'البند', 'بيان'],\n 'unit' => ['Ø§Ù„ÙˆØØ¯Ø©', 'unit', 'uom', 'u/m'],\n 'quantity' => ['الكمية', 'qty', 'quantity', 'q.ty'],\n 'unit_price' => ['سعر Ø§Ù„ÙˆØØ¯Ø©', 'u/price', 'unit price', 'unit rate', 'rate', 'price'],\n 'total_price' => ['السعر الإجمالي', 'total amount', 'total price', 'amount', 'الإجمالي'],\n ];\n\n $best = null;\n $bestScore = 0;\n\n for ($r = 0; $r < min(30, count($grid)); $r++) {\n $map = [];\n $score = 0;\n\n foreach ($grid[$r] as $c => $value) {\n $cell = mb_strtolower(trim((string) $value));\n if ($cell === '') {\n continue;\n }\n\n foreach ($keywords as $field => $list) {\n foreach ($list as $kw) {\n if (str_contains($cell, mb_strtolower($kw))) {\n if (! isset($map[$field])) {\n $map[$field] = $c;\n $score++;\n }\n break 2;\n }\n }\n }\n }\n\n if ($score > $bestScore) {\n $bestScore = $score;\n $best = ['row' => $r, 'map' => $map];\n }\n }\n\n if ($bestScore >= 3) {\n return $best;\n }\n\n // Allow simpler BOQ sheets that only define a description column plus one\n // other BOQ-related column, e.g. Item + Description or Description + Qty.\n if ($bestScore === 2 && isset($best['map']['description']) && (\n isset($best['map']['item_code']) ||\n isset($best['map']['quantity']) ||\n isset($best['map']['unit'])\n )) {\n return $best;\n }\n\n return null;\n }\n\n private function cell(array $row, ?int $index): string\n {\n if ($index === null) {\n return '';\n }\n return trim((string) ($row[$index] ?? ''));\n }\n\n private function number(string $value): ?float\n {\n $value = trim(str_replace([',', 'SAR', 'ر.س'], '', $value));\n if ($value === '' || ! is_numeric($value)) {\n return null;\n }\n return (float) $value;\n }\n\n private function trimTrailingEmptyCells(array $row): array\n {\n while (! empty($row) && trim((string) end($row)) === '') {\n array_pop($row);\n }\n return array_values($row);\n }\n\n private function isTotalOrHeaderRow(string $description): bool\n {\n $d = mb_strtolower(trim($description));\n\n if ($d === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $d)) {\n return true;\n }\n\n // Section totals / summaries\n if (preg_match('/(اجمالي|إجمالي|المجموع|grand\\s*total|sub\\s*total|total\\s*amount|total\\s*price)/iu', $d)) {\n return true;\n }\n\n // Bare \"Total\" lines, optionally with a currency suffix, e.g. \"Total\",\n // \"Total (SAR)\", \"Total cost\", \"Total (SAR) unit cost\".\n if (preg_match('/^total\\b[\\s\\(\\):a-z]*$/iu', $d)) {\n return true;\n }\n\n // Pure floor / level labels used as section headers\n if (preg_match('/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor)\\s*$/iu', $d)) {\n return true;\n }\n\n // Division / section header lines (e.g. \"Division - 02: Wood Works\")\n if (preg_match('/^division\\s*([-:]|\\d)/iu', $d)) {\n return true;\n }\n\n return false;\n }\n\n /**\n * Detect dedicated summary / cost-abstract sheets by their name. These hold\n * per-discipline rollups and grand totals (e.g. a \"Summary (Dry Cost)\" tab),\n * never procurable supply items, so they are skipped wholesale.\n */\n private function isSummarySheet(string $name): bool\n {\n $n = mb_strtolower(trim($name));\n\n if ($n === '') {\n return false;\n }\n\n return (bool) preg_match(\n '/\\b(summary|abstract|cost\\s*summary|grand\\s*summary|recap(?:itulation)?|bill\\s*summary|boq\\s*summary|dry\\s*cost)\\b|ملخص|الملخص|الملخّص|ملخّص|اجمالي|إجمالي/iu',\n $n\n );\n }\n\n private function isFloorBreakdownRow(string $itemNo, string $description): bool\n {\n // A row whose Ref column is empty or a dash is a sub-breakdown row (per-floor split).\n $ref = trim($itemNo);\n if ($ref === '' || $ref === '-') {\n // Confirm by checking the description is a location/floor label\n $d = mb_strtolower(trim($description));\n return (bool) preg_match(\n '/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor|\\d+f)\\s*$/iu',\n $d\n );\n }\n return false;\n }\n\n private function rejectedRow(string $description, string $unit, ?float $quantity, string $reason, string $itemNo = '', string $sheet = ''): array\n {\n return [\n 'description' => $description,\n 'quantity' => $quantity ?? 0,\n 'unit' => $unit,\n 'category' => $sheet,\n 'brand' => '',\n 'status' => 'rejected',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 0.0,\n 'raw_data' => [\n 'sheet' => $sheet,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'extraction_type' => 'rejected',\n 'rejection_reason' => $reason,\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function parseBoqWithDeepSeek(UploadedFile|string $file, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured. Please set DEEPSEEK_API_KEY in .env file.');\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n $mime = $this->mimeForExtension($ext);\n\n try {\n // ── Spreadsheets ──────────────────────────────────────────────────\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n $text = $this->spreadsheetToCompactCsvText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not convert spreadsheet to readable text.');\n }\n\n $text = $this->sanitizeUtf8($text);\n\n // A large BOQ exceeds what one request can carry, so send it in\n // sequential slices and merge. Anything that fits goes as-is.\n //\n // Size alone is not a good enough signal: a workbook with several\n // sheets is structurally a big BOQ even when the text is compact,\n // and asking the model to hold every discipline at once is where\n // rows start getting dropped. So split on either signal.\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS || $sheetCount > self::MAX_SHEETS_PER_CALL) {\n Log::info('QuotationAiService: spreadsheet routed to chunked parsing.', [\n 'chars' => mb_strlen($text),\n 'sheets' => $sheetCount,\n ]);\n return $this->parseTextInChunks($text, 'BOQ spreadsheet converted to CSV', $apiKey, $context);\n }\n\n $userContent = \"BOQ spreadsheet converted to CSV:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── PDF ───────────────────────────────────────────────────────────\n if ($ext === 'pdf') {\n $extracted = $this->extractPdfText($absPath);\n\n if ($extracted === self::PDF_LOW_MEMORY) {\n return $this->failure('The server was low on memory while reading this PDF. Please try again in a moment, or convert it to Excel or CSV.');\n }\n\n if ($extracted !== null && mb_strlen(trim($extracted)) > 50) {\n $extracted = $this->sanitizeUtf8($extracted);\n\n // Split on either signal — long text, or many pages. A BOQ\n // spanning several pages is a multi-section document even if\n // each page is sparse.\n if (mb_strlen($extracted) > self::TEXT_CHUNK_CHARS\n || $this->lastPdfPageCount > self::MAX_PDF_PAGES_PER_CALL) {\n Log::info('QuotationAiService: PDF routed to chunked parsing.', [\n 'chars' => mb_strlen($extracted),\n 'pages' => $this->lastPdfPageCount,\n ]);\n return $this->parseTextInChunks($extracted, 'BOQ PDF extracted text', $apiKey, $context, 'application/pdf');\n }\n\n $userContent = \"BOQ PDF extracted text:\\n\\n\" . $extracted . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'application/pdf');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // No text yet — smalot threw its \"$obj\" bug and pdftotext is not\n // installed. Last resort before giving up: OCR.space, a hosted\n // reader that takes the PDF and returns its text. Needs no local\n // binary and no vision key, and still feeds DeepSeek afterwards.\n $ocrText = $this->extractPdfTextViaOcrSpace($absPath);\n\n if ($ocrText !== null && mb_strlen(trim($ocrText)) > 50) {\n $ocrText = $this->sanitizeUtf8($ocrText);\n $userContent = \"BOQ PDF extracted text:\\n\\n\" . $ocrText . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'application/pdf');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n return $this->failure('Could not read this PDF. It may be a scanned image, or use a format our reader cannot open. Please convert it to Excel or CSV and upload again.');\n }\n\n // -- Word documents (docx) ---------------------------------------------------\n if (in_array($ext, ['docx', 'doc'], true)) {\n $text = $this->extractDocxText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not extract text from the Word document. Please convert it to Excel, CSV, or PDF.');\n }\n $text = $this->sanitizeUtf8($text);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS) {\n return $this->parseTextInChunks($text, 'BOQ Word document extracted text', $apiKey, $context);\n }\n\n $userContent = \"BOQ Word document extracted text:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── Images ──────────────────────────────────────────────────────────────\n // DeepSeek cannot receive images. A vision-capable AI processes the image\n // directly (no OCR) — configure GROQ_API_KEY or GEMINI_API_KEY in .env.\n $imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif', 'heic', 'heif'];\n if (in_array($ext, $imageExts, true)) {\n $bytes = (string) file_get_contents($absPath);\n return $this->callDeepSeekVision($bytes, $mime, $context);\n }\n\n return $this->failure('Unsupported file type. Please upload an Excel, CSV, PDF, or image file.');\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: DeepSeek fallback failed.', ['message' => $e->getMessage()]);\n return $this->failure('An error occurred while processing the file with AI: ' . $e->getMessage());\n }\n }\n\n private function deepSeekModel(): string\n {\n return (string) config('services.deepseek.model', 'deepseek-chat');\n }\n\n /**\n * Remove or replace any non-UTF-8 / malformed byte sequences so json_encode never fails.\n */\n private function sanitizeUtf8(string $text): string\n {\n // Replace invalid byte sequences with the UTF-8 replacement character\n $clean = mb_convert_encoding($text, 'UTF-8', 'UTF-8');\n // Strip any remaining non-printable control characters (except tab/newline/CR)\n $clean = (string) preg_replace('/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]/u', '', $clean);\n return $clean;\n }\n\n /**\n * Extract plain text from a Word docx file using ZipArchive (no external library needed).\n * Reads word/document.xml and strips XML tags, preserving paragraph and table structure.\n */\n private function extractDocxText(string $absPath): ?string\n {\n try {\n $zip = new \\ZipArchive();\n if ($zip->open($absPath) !== true) {\n return null;\n }\n\n $xml = $zip->getFromName('word/document.xml');\n $zip->close();\n\n if ($xml === false || $xml === '') {\n return null;\n }\n\n // Add newlines before paragraph/table-row/table-cell tags so structure is preserved\n $xml = preg_replace('/<w:p[ >]/', \"\\n<w:p>\", $xml) ?? $xml;\n $xml = preg_replace('/<w:tr[ >]/', \"\\n<w:tr>\", $xml) ?? $xml;\n $xml = preg_replace('/<\\/w:tc>/', \"\\t\", $xml) ?? $xml;\n\n $text = strip_tags($xml);\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n return trim($text) !== '' ? trim($text) : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: DOCX parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Extract plain text from a PDF using smalot/pdfparser (pure PHP, no binary required).\n */\n /**\n * The process memory limit in bytes, or 0 when unlimited.\n *\n * memory_limit is a shorthand string (\"128M\", \"1G\", \"-1\"), so it has to be\n * expanded before it can be compared against anything.\n */\n private function memoryLimitBytes(): int\n {\n $raw = trim((string) ini_get('memory_limit'));\n\n if ($raw === '' || $raw === '-1') {\n return 0;\n }\n\n $value = (int) $raw;\n\n return match (strtolower(substr($raw, -1))) {\n 'g' => $value * 1024 * 1024 * 1024,\n 'm' => $value * 1024 * 1024,\n 'k' => $value * 1024,\n default => $value,\n };\n }\n\n private function extractPdfText(string $absPath): ?string\n {\n // The parser loads and decompresses the whole document before any of our\n // chunking gets a look at it, and it does so with no memory ceiling of\n // its own. A big PDF exhausts the limit inside gzuncompress() — a fatal\n // error, not an exception, so the catch below cannot help and the worker\n // dies outright. Refuse the file first instead.\n $size = @filesize($absPath) ?: 0;\n\n if ($size > self::MAX_PDF_BYTES) {\n Log::warning('QuotationAiService: PDF too large to parse in-process.', [\n 'bytes' => $size,\n 'limit' => self::MAX_PDF_BYTES,\n ]);\n\n return null;\n }\n\n // Headroom check: parsing routinely needs several times the file size\n // once streams are decompressed. If that would not fit in what is left,\n // stop here rather than taking the worker down with us.\n $limit = $this->memoryLimitBytes();\n\n if ($limit > 0) {\n // Try to reclaim memory a previous job left behind before deciding\n // there is not enough. Without this, a worker sitting near its limit\n // after a big job could refuse a tiny PDF for no real reason.\n gc_collect_cycles();\n\n $available = $limit - memory_get_usage(true);\n\n if ($available < $size * self::PDF_MEMORY_FACTOR) {\n Log::warning('QuotationAiService: not enough memory left to parse this PDF.', [\n 'bytes' => $size,\n 'available' => $available,\n 'needed' => $size * self::PDF_MEMORY_FACTOR,\n ]);\n\n // A distinct sentinel, so the caller can tell \"no memory\" apart\n // from \"no text\" and show the right message instead of blaming\n // the file for being scanned.\n return self::PDF_LOW_MEMORY;\n }\n }\n\n try {\n $parser = new \\Smalot\\PdfParser\\Parser();\n $pdf = $parser->parseFile($absPath);\n\n // Recorded for the chunking decision: a long PDF is structurally a\n // big BOQ even when its extracted text is short.\n try {\n $this->lastPdfPageCount = count($pdf->getPages());\n } catch (\\Throwable) {\n $this->lastPdfPageCount = 0;\n }\n\n $text = $pdf->getText();\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n return $text !== '' ? $text : null;\n } catch (\\Throwable $e) {\n // Class, file and line, not just the message — a bare message left\n // the actual cause of a PDF failure guessable at best.\n Log::warning('QuotationAiService: PDF parsing failed.', [\n 'exception' => $e::class,\n 'error' => $e->getMessage(),\n 'at' => $e->getFile() . ':' . $e->getLine(),\n ]);\n\n // smalot/pdfparser throws \"Invalid object reference for $obj.\" on\n // some perfectly valid PDFs — a known bug in the library, not a\n // problem with the file. Fall back to the pdftotext binary, which\n // reads those documents fine.\n return $this->extractPdfTextViaBinary($absPath);\n }\n }\n\n /**\n * Extract PDF text through the OCR.space API.\n *\n * The fallback of last resort when smalot throws and pdftotext is not on the\n * server. OCR.space is a hosted reader: the file is uploaded and its text\n * comes back, so it needs nothing installed locally. The free tier caps\n * uploads at 1 MB and 3 pages — enough for a typical BOQ, and skipped with a\n * clear log line when the file is over that, rather than a wasted call.\n */\n private function extractPdfTextViaOcrSpace(string $absPath): ?string\n {\n $apiKey = (string) config('services.ocrspace.key', '');\n\n if ($apiKey === '') {\n return null;\n }\n\n // Free tier hard limit. Above it the call is rejected, so do not make it.\n $size = @filesize($absPath) ?: 0;\n if ($size > 1024 * 1024) {\n Log::warning('QuotationAiService: PDF too large for the OCR fallback.', [\n 'bytes' => $size,\n 'limit' => 1024 * 1024,\n ]);\n return null;\n }\n\n try {\n $response = Http::timeout(120)\n ->attach('file', (string) file_get_contents($absPath), basename($absPath))\n ->post('https://api.ocr.space/parse/image', [\n 'apikey' => $apiKey,\n 'filetype' => 'PDF',\n 'language' => 'eng',\n 'isTable' => 'true', // BOQs are tabular; keep columns\n 'OCREngine' => '2',\n 'scale' => 'true',\n ]);\n\n if (! $response->successful()) {\n Log::warning('QuotationAiService: OCR.space request failed.', [\n 'status' => $response->status(),\n ]);\n return null;\n }\n\n $json = $response->json();\n\n // OCR.space reports its own errors in the body with a 200 status.\n if (($json['IsErroredOnProcessing'] ?? false) === true) {\n Log::warning('QuotationAiService: OCR.space could not process the PDF.', [\n 'message' => $json['ErrorMessage'] ?? 'unknown',\n ]);\n return null;\n }\n\n $text = '';\n foreach ($json['ParsedResults'] ?? [] as $page) {\n $text .= ($page['ParsedText'] ?? '') . \"\\n\";\n }\n\n $text = trim((string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text));\n\n if ($text !== '') {\n Log::info('QuotationAiService: recovered PDF text via OCR.space.', [\n 'path' => basename($absPath),\n 'chars' => mb_strlen($text),\n ]);\n }\n\n return $text !== '' ? $text : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: OCR.space fallback threw.', [\n 'error' => $e->getMessage(),\n ]);\n return null;\n }\n }\n\n /**\n * Locate the pdftotext binary.\n *\n * On shared hosting it is rarely on the system PATH, so a user-installed\n * copy — dropped in ~/bin, ~/.local/bin, or the project's vendor/bin — has\n * to be found explicitly. An escaped, quoted absolute path is returned so it\n * is safe to interpolate into the command.\n *\n * PDFTOTEXT_PATH in .env wins, for a copy installed somewhere non-standard.\n */\n private function pdftotextPath(): ?string\n {\n $configured = trim((string) env('PDFTOTEXT_PATH', ''));\n\n $candidates = array_filter([\n $configured !== '' ? $configured : null,\n ($home = getenv('HOME')) ? $home . '/bin/pdftotext' : null,\n $home ? $home . '/.local/bin/pdftotext' : null,\n base_path('vendor/bin/pdftotext'),\n '/usr/bin/pdftotext',\n '/usr/local/bin/pdftotext',\n ]);\n\n foreach ($candidates as $path) {\n if (is_file($path) && is_executable($path)) {\n return escapeshellarg($path);\n }\n }\n\n // Fall back to a bare name, in case it is on PATH after all.\n $onPath = @shell_exec('command -v pdftotext 2>/dev/null');\n\n return is_string($onPath) && trim($onPath) !== '' ? 'pdftotext' : null;\n }\n\n private function extractPdfTextViaBinary(string $absPath): ?string\n {\n // shell_exec is commonly disabled on shared hosting; bail cleanly so the\n // caller falls through to the vision path or a clear error.\n if (! function_exists('shell_exec')\n || in_array('shell_exec', array_map('trim', explode(',', (string) ini_get('disable_functions'))), true)) {\n return null;\n }\n\n $binary = $this->pdftotextPath();\n\n if ($binary === null) {\n Log::warning('QuotationAiService: pdftotext binary not found.', [\n 'looked_in' => 'PATH, PDFTOTEXT_PATH, ~/bin, ~/.local/bin, vendor/bin',\n ]);\n return null;\n }\n\n // `-layout` keeps columns roughly aligned, which matters for a BOQ; the\n // trailing \"-\" writes to stdout instead of a file.\n $command = sprintf('%s -layout -enc UTF-8 %s - 2>/dev/null', $binary, escapeshellarg($absPath));\n\n $output = @shell_exec($command);\n\n if (! is_string($output) || trim($output) === '') {\n Log::warning('QuotationAiService: pdftotext fallback produced no text.', [\n 'path' => basename($absPath),\n ]);\n return null;\n }\n\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $output);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n\n if ($text !== '') {\n Log::info('QuotationAiService: recovered PDF text via pdftotext fallback.', [\n 'path' => basename($absPath),\n 'chars' => mb_strlen($text),\n ]);\n }\n\n return $text !== '' ? $text : null;\n }\n\n /**\n * Send a vision request to a vision-capable AI model.\n * DeepSeek's commercial API (api.deepseek.com) does NOT support image inputs — any model.\n * Priority order (first configured key wins):\n * 1. GROQ_API_KEY → Groq llama-4-scout (free: https://console.groq.com)\n * 2. GEMINI_API_KEY → Google Gemini Flash (free: https://aistudio.google.com/apikey)\n * 3. VISION_API_KEY → any OpenAI-compatible endpoint (OpenAI, OpenRouter …)\n */\n private function callDeepSeekVision(string $bytes, string $mime, array $context): array\n {\n // ── 1. Groq (free, reliable vision) ─────────────────────────────────────\n if ((string) config('services.groq.key', '') !== '') {\n $visionKey = (string) config('services.groq.key');\n $visionBaseUrl = 'https://api.groq.com/openai/v1';\n $visionModel = (string) config('services.groq.model', 'meta-llama/llama-4-scout-17b-16e-instruct');\n }\n // ── 2. Google Gemini ─────────────────────────────────────────────────────\n elseif ((string) config('services.gemini.key', '') !== '') {\n $visionKey = (string) config('services.gemini.key');\n $visionBaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai';\n $visionModel = (string) config('services.gemini.model', 'gemini-2.0-flash');\n }\n // ── 3. Generic VISION_API_KEY (OpenAI, OpenRouter, …) ───────────────────\n elseif ((string) config('services.vision.key', '') !== '') {\n $visionKey = (string) config('services.vision.key');\n $visionBaseUrl = rtrim((string) config('services.vision.base_url', 'https://openrouter.ai/api/v1'), '/');\n $visionModel = (string) config('services.vision.model', 'google/gemini-flash-1.5-8b');\n }\n else {\n return $this->failure(\n 'Image processing is not available. DeepSeek\\'s API does not support images. ' .\n 'Get a free key at https://console.groq.com and add GROQ_API_KEY to your .env file.'\n );\n }\n\n $dataUrl = \"data:{$mime};base64,\" . base64_encode($bytes);\n $prompt = $this->buildDeepSeekPrompt($context, $mime);\n\n $userContent = [\n ['type' => 'text', 'text' => $prompt],\n ['type' => 'image_url', 'image_url' => ['url' => $dataUrl]],\n ];\n\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders([\n 'Authorization' => 'Bearer ' . $visionKey,\n 'HTTP-Referer' => config('app.url', 'https://qimta.sa'),\n 'X-Title' => 'Qimta Platform',\n ])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post($visionBaseUrl . '/chat/completions', [\n 'model' => $visionModel,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 16384,\n 'temperature' => 0,\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('Vision API connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: Vision API HTTP error.', [\n 'model' => $visionModel,\n 'base_url' => $visionBaseUrl,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n return $this->failure(\"Vision API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n return $this->parseDeepSeekJsonResponse((string) $response->json('choices.0.message.content'));\n }\n\n private function callDeepSeekChat(string $userContent, string $key, string $model): array\n {\n // Ensure the PHP script outlives the HTTP call (+ a margin for parsing the\n // response and writing to the DB) so the worker/web request is not killed\n // mid-flight, which surfaces to users as a generic \"timeout or unavailable\".\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders(['Authorization' => 'Bearer ' . $key])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 32768,\n 'temperature' => 0,\n 'response_format' => ['type' => 'json_object'],\n 'user' => 'Qimta_Platform',\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('DeepSeek connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: DeepSeek HTTP error.', [\n 'model' => $model,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n if ($response->status() === 401) {\n return $this->failure('DeepSeek API key is invalid or unauthorised. Please update DEEPSEEK_API_KEY in .env.', false);\n }\n return $this->failure(\"DeepSeek API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n $text = (string) $response->json('choices.0.message.content');\n return $this->parseDeepSeekJsonResponse($text);\n }\n\n /**\n * Parse a document too large for one AI call by sending it in slices.\n *\n * A 20 000-row BOQ is several megabytes of text — far beyond what a single\n * request can carry, and beyond what the model can answer without hitting its\n * output token limit. Previously the text was simply cut at 180 000 chars and\n * the remainder discarded: the parse \"succeeded\" while silently dropping most\n * of the file. Now the text is split and each slice parsed on its own call,\n * with the items merged.\n *\n * Splitting happens on line boundaries so a BOQ row is never cut in half.\n * Slices run sequentially rather than pooled: DeepSeek rate-limits hard on\n * concurrent large prompts, and this already runs on the queue where wall\n * time is cheap.\n *\n * A slice that fails does not fail the document — its rows are lost, but the\n * rest are kept and the shortfall is logged. Total failure is reported only\n * when every slice fails.\n *\n * @param string $label How the payload is introduced to the model.\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n /**\n * Split a spreadsheet into the slices the AI will be asked to read.\n *\n * Exposed so the work can be spread over several queue jobs instead of one\n * job walking every slice in sequence. Returns an empty array when the file\n * is small enough to go in a single call, or cannot be read.\n *\n * @return array<int, string>\n */\n public function chunkSpreadsheet(string $absPath): array\n {\n ini_set('memory_limit', '2048M');\n set_time_limit(1800);\n\n $text = $this->spreadsheetToCompactCsvText($absPath);\n\n if ($text === null || trim($text) === '') {\n return [];\n }\n\n $text = $this->sanitizeUtf8($text);\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) <= self::TEXT_CHUNK_CHARS && $sheetCount <= self::MAX_SHEETS_PER_CALL) {\n return [];\n }\n\n $chunks = $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n\n // Free the source text: the slices are a second full copy of it, and on\n // a large workbook that doubling — on top of PhpSpreadsheet's grids —\n // is what exhausted memory here.\n unset($text);\n\n Log::info('QuotationAiService: chunked spreadsheet.', [\n 'chunks' => count($chunks),\n 'peak_mb' => round(memory_get_peak_usage(true) / 1048576),\n ]);\n\n return $chunks;\n }\n\n /**\n * Split a spreadsheet straight to disk, one file per slice.\n *\n * chunkSpreadsheet() returns every slice as an array, which is a second\n * full copy of the document in memory on top of PhpSpreadsheet's grids.\n * This hands each slice to $write and drops it immediately, so peak memory\n * is one slice rather than the whole file twice.\n *\n * @param callable(int, string): void $write ($part, $chunk)\n * @return int number of slices written; 0 when the file needs no split\n */\n public function chunkSpreadsheetToDisk(string $absPath, callable $write): int\n {\n ini_set('memory_limit', '2048M');\n set_time_limit(1800);\n\n $text = $this->spreadsheetToCompactCsvText($absPath);\n\n if ($text === null || trim($text) === '') {\n return 0;\n }\n\n $text = $this->sanitizeUtf8($text);\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) <= self::TEXT_CHUNK_CHARS && $sheetCount <= self::MAX_SHEETS_PER_CALL) {\n return 0;\n }\n\n $part = 0;\n foreach ($this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS) as $chunk) {\n $write(++$part, $chunk);\n }\n\n unset($text);\n\n Log::info('QuotationAiService: chunked spreadsheet to disk.', [\n 'chunks' => $part,\n 'peak_mb' => round(memory_get_peak_usage(true) / 1048576),\n ]);\n\n return $part;\n }\n\n /**\n * Parse one slice produced by chunkSpreadsheet().\n *\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n public function parseChunk(string $chunk, int $part, int $total, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured.');\n }\n\n // Same framing parseTextInChunks() uses, so a slice is never mistaken\n // for a complete BOQ and totals are not inferred from a fragment.\n $userContent = \"BOQ spreadsheet converted to CSV (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, 'text/plain');\n\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n private function parseTextInChunks(\n string $text,\n string $label,\n string $apiKey,\n array $context,\n string $mime = 'text/plain',\n ): array {\n $chunks = $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n $total = count($chunks);\n\n Log::info('QuotationAiService: large document split for parsing.', [\n 'chars' => mb_strlen($text),\n 'chunks' => $total,\n ]);\n\n // Announce the split before the first slice is sent, so the UI can show\n // how many parts there are instead of sitting on a generic message for\n // however long the first AI call takes.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)(0, $total);\n }\n\n $items = [];\n $rejected = [];\n $failed = 0;\n $lastError = null;\n\n foreach ($chunks as $index => $chunk) {\n $part = $index + 1;\n\n // Report progress so a 28-slice parse does not look frozen to the user.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)($part, $total);\n }\n\n // Tell the model this is a fragment, so it does not try to reconcile\n // totals or treat a mid-document slice as a complete BOQ.\n $userContent = \"{$label} (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, $mime);\n\n $result = $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n\n if (! ($result['success'] ?? false)) {\n $failed++;\n $lastError = $result['error'] ?? 'Chunk parsing failed.';\n Log::warning('QuotationAiService: chunk failed, continuing.', [\n 'part' => $part,\n 'of' => $total,\n 'error' => $lastError,\n ]);\n continue;\n }\n\n foreach ($result['items'] as $item) {\n $items[] = $item;\n }\n foreach ($result['rejected'] ?? [] as $row) {\n $rejected[] = $row;\n }\n\n // Hand this slice's rows straight to the caller so they can be shown\n // while the remaining slices are still being parsed.\n if ($this->onChunkItems !== null && $result['items'] !== []) {\n ($this->onChunkItems)($result['items'], $part, $total);\n }\n }\n\n // Every slice failed — there is nothing to show, so report the failure.\n if ($failed === $total) {\n return $this->failure($lastError ?? 'Could not parse the document.');\n }\n\n if ($failed > 0) {\n Log::error('QuotationAiService: document parsed with missing parts.', [\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n 'items_kept' => count($items),\n ]);\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'service_unavailable' => false,\n 'partial' => $failed > 0,\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n ];\n }\n\n /**\n * Split text into slices of at most $limit characters, breaking only at\n * newlines so no BOQ row is ever cut in half.\n *\n * A single line longer than the limit (rare — a pathological cell) is passed\n * through oversized rather than severed mid-row.\n *\n * @return array<int, string>\n */\n private function splitOnLineBoundaries(string $text, int $limit): array\n {\n $chunks = [];\n $current = '';\n\n // Sheet a chunk starts in the middle of. Repeated at the top of the next\n // chunk so the model always knows which sheet the rows belong to — the\n // header would otherwise be stranded in the previous slice, and rows\n // would arrive with no discipline context.\n $currentSheet = null;\n\n foreach (explode(\"\\n\", $text) as $line) {\n $isSheetHeader = str_starts_with($line, 'Sheet: ');\n\n // Start a new chunk at every sheet boundary. Sheets are usually\n // separate disciplines, and the whole point of splitting a multi-tab\n // workbook is to give the model one discipline at a time — a size-only\n // split would leave a compact 3-tab file as a single chunk, silently\n // undoing the sheet gate that routed it here.\n if ($isSheetHeader && $current !== '') {\n $chunks[] = $current;\n $current = $line;\n $currentSheet = $line;\n continue;\n }\n\n if ($isSheetHeader) {\n $currentSheet = $line;\n }\n\n $candidate = $current === '' ? $line : $current . \"\\n\" . $line;\n\n if (mb_strlen($candidate) > $limit && $current !== '') {\n $chunks[] = $current;\n\n // Carry the sheet header over, unless this line is itself one.\n $current = ($currentSheet !== null && ! str_starts_with($line, 'Sheet: '))\n ? $currentSheet . \" (continued)\\n\" . $line\n : $line;\n continue;\n }\n\n $current = $candidate;\n }\n\n if (trim($current) !== '') {\n $chunks[] = $current;\n }\n\n return $chunks;\n }\n\n /**\n * Shared JSON response parser used by both chat and vision paths.\n */\n private function parseDeepSeekJsonResponse(string $text): array\n {\n if ($text === '') {\n return $this->failure('DeepSeek returned an empty response.');\n }\n\n // Strip markdown code fences if the model wrapped the JSON.\n if (preg_match('/```(?:json)?\\s*([\\s\\S]*?)\\s*```/i', $text, $m)) {\n $text = $m[1];\n }\n // Narrow to the outermost JSON object/array.\n if (preg_match('/[\\{\\[][\\s\\S]*[\\}\\]]/s', $text, $m)) {\n $text = $m[0];\n }\n $text = trim($text);\n\n $decoded = json_decode($text, true);\n\n // The response can be truncated when the model hits the token limit,\n // leaving an unterminated JSON string/object. Try to repair it before\n // giving up so we can still salvage the items extracted so far.\n if (! is_array($decoded)) {\n $repaired = $this->repairTruncatedJson($text);\n if ($repaired !== null) {\n $decoded = json_decode($repaired, true);\n }\n }\n\n if (! is_array($decoded)) {\n Log::error('QuotationAiService: DeepSeek non-JSON response.', ['text' => mb_substr($text, 0, 1000)]);\n return $this->failure('DeepSeek returned a non-JSON response. The file may be too large — try splitting it into smaller files.');\n }\n\n $rawItems = $decoded['items'] ?? (array_is_list($decoded) ? $decoded : []);\n $items = [];\n $rejected = [];\n\n foreach ($rawItems as $raw) {\n if (! is_array($raw)) {\n continue;\n }\n $item = $this->normaliseAiItem($raw);\n if ($item === null) {\n continue;\n }\n $check = $this->boqCleaner->filterItem((string) $item['description']);\n if ($check['keep'] ?? false) {\n $items[] = $item;\n } else {\n $item['status'] = 'rejected';\n $item['raw_data']['rejection_reason'] = $check['rejection_reason'] ?? 'Rejected by cleaner';\n $rejected[] = $item;\n }\n }\n\n if (empty($items)) {\n Log::info('QuotationAiService: DeepSeek returned 0 accepted items.', [\n 'raw_items_count' => count($rawItems),\n 'rejected_count' => count($rejected),\n 'first_rejected' => ! empty($rejected) ? ($rejected[0]['description'] ?? '') : '',\n ]);\n }\n\n return ['success' => true, 'items' => $items, 'rejected' => $rejected, 'error' => null];\n }\n\n /**\n * Best-effort repair of a truncated JSON string produced when the model hits\n * its token limit mid-output. Drops any incomplete trailing object, then\n * closes the open string/array/object brackets so the result is valid JSON.\n *\n * Returns the repaired JSON string, or null if it cannot be salvaged.\n */\n private function repairTruncatedJson(string $text): ?string\n {\n if ($text === '') {\n return null;\n }\n\n // Walk the string tracking structural state, ignoring everything inside\n // string literals (and their escapes). Remember the last position where\n // we were back at the top of the \"items\" array between complete elements,\n // so we can cut a half-written trailing object.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($text);\n $lastSafeCut = null; // index just after a completed array element\n\n for ($i = 0; $i < $len; $i++) {\n $ch = $text[$i];\n\n if ($inString) {\n if ($escaped) {\n $escaped = false;\n } elseif ($ch === '\\\\') {\n $escaped = true;\n } elseif ($ch === '\"') {\n $inString = false;\n }\n continue;\n }\n\n if ($ch === '\"') {\n $inString = true;\n } elseif ($ch === '{' || $ch === '[') {\n $stack[] = $ch;\n } elseif ($ch === '}' || $ch === ']') {\n array_pop($stack);\n } elseif ($ch === ',' && ! empty($stack) && end($stack) === '[') {\n // Separator directly inside an array: a clean boundary between\n // elements (the items array, whatever its nesting depth).\n $lastSafeCut = $i;\n }\n }\n\n // If we are still inside a string, the last element is incomplete — fall\n // back to the last clean array boundary if we have one.\n $candidate = $text;\n if ($inString && $lastSafeCut !== null) {\n $candidate = substr($text, 0, $lastSafeCut);\n } else {\n // Drop a dangling partial object after the last completed element.\n $candidate = rtrim($candidate);\n if ($lastSafeCut !== null && substr(rtrim($candidate), -1) !== '}' && substr(rtrim($candidate), -1) !== ']') {\n $candidate = substr($text, 0, $lastSafeCut);\n }\n }\n\n // Recompute the open brackets for the (possibly trimmed) candidate and\n // append the matching closers in reverse order.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($candidate);\n for ($i = 0; $i < $len; $i++) {\n $ch = $candidate[$i];\n if ($inString) {\n if ($escaped) { $escaped = false; }\n elseif ($ch === '\\\\') { $escaped = true; }\n elseif ($ch === '\"') { $inString = false; }\n continue;\n }\n if ($ch === '\"') { $inString = true; }\n elseif ($ch === '{' || $ch === '[') { $stack[] = $ch; }\n elseif ($ch === '}' || $ch === ']') { array_pop($stack); }\n }\n\n $candidate = rtrim($candidate);\n // Remove a trailing comma left after cutting an element.\n $candidate = rtrim($candidate, ',');\n\n $closers = '';\n for ($i = count($stack) - 1; $i >= 0; $i--) {\n $closers .= ($stack[$i] === '{') ? '}' : ']';\n }\n\n $repaired = $candidate . $closers;\n\n // Validate before returning.\n return is_array(json_decode($repaired, true)) ? $repaired : null;\n }\n\n private function spreadsheetToCompactCsvText(string $absPath): ?string\n {\n $sheets = $this->spreadsheetToGrids($absPath);\n if (empty($sheets)) {\n return null;\n }\n\n $output = '';\n $kept = 0;\n $dropped = 0;\n $nonSupply = 0;\n $duplicates = 0;\n\n /** @var array<string, true> Fingerprints of rows already emitted. */\n $seenRows = [];\n\n foreach ($sheets as $sheet) {\n // Don't feed summary / cost-abstract sheets to the AI either.\n if ($this->isSummarySheet($sheet['name'])) {\n continue;\n }\n $sheetRows = '';\n foreach ($sheet['grid'] as $row) {\n $row = $this->trimTrailingEmptyCells($row);\n\n if ($this->isNoiseRow($row)) {\n $dropped++;\n continue;\n }\n\n // Drop rows that are definitely not procurable before they cost\n // a single token: section totals, discipline headings,\n // preliminaries, labour-only lines. The same classifier already\n // runs on the AI's output, but by then the tokens are spent — on\n // an 11 200-row BOQ that is most of the bill.\n if ($this->isNonSupplyRow($row)) {\n $dropped++;\n $nonSupply++;\n continue;\n }\n\n // Byte-identical rows tell the AI nothing new. Only exact\n // duplicates are collapsed — description, quantity, unit and all\n // — so nothing is lost: two rows differing in quantity are two\n // different line items and both are sent.\n $fingerprint = $this->rowFingerprint($row);\n\n if (isset($seenRows[$fingerprint])) {\n $duplicates++;\n $dropped++;\n continue;\n }\n $seenRows[$fingerprint] = true;\n\n $sheetRows .= implode(',', array_map([$this, 'csvEscape'], $this->stripBoilerplate($row))) . \"\\n\";\n $kept++;\n }\n\n // A sheet whose rows were all noise contributes nothing but its own\n // header, which would just be another thing for the model to explain.\n if ($sheetRows === '') {\n continue;\n }\n\n $output .= 'Sheet: ' . $sheet['name'] . \"\\n\" . $sheetRows . \"\\n\";\n }\n\n $blank = $dropped - $nonSupply - $duplicates;\n\n Log::info('QuotationAiService: filtered rows before sending to AI.', [\n 'kept' => $kept,\n 'blank' => $blank,\n 'non_supply' => $nonSupply,\n 'duplicates' => $duplicates,\n ]);\n\n if ($dropped > 0) {\n $parts = [];\n if ($blank > 0) { $parts[] = \"{$blank} blank\"; }\n if ($nonSupply > 0) { $parts[] = \"{$nonSupply} non-supply\"; }\n if ($duplicates > 0) { $parts[] = \"{$duplicates} duplicate\"; }\n\n $this->reportStage('Skipped ' . implode(', ', $parts) . \" rows. Sending {$kept}…\");\n }\n\n return $output;\n }\n\n /**\n * True when a row carries no information worth sending to the AI.\n *\n * A real BOQ is mostly formatting: spacer rows, stray borders, a lone\n * section number, a leftover cell from a merged block. They survive\n * trimTrailingEmptyCells() because they are not *entirely* empty, then cost\n * tokens, dilute the prompt, and inflate the number of parts a large file is\n * split into.\n *\n * Deliberately conservative — a row is dropped only when nothing in it could\n * name a product. Anything with letters is kept, because a description is\n * the one field an item cannot do without.\n *\n * @param array<int, mixed> $row\n */\n /**\n * True when a row is definitely not a procurable supply item.\n *\n * Runs BoqCleaningService over the row's longest text cell — the one most\n * likely to be the description — so headings, totals, preliminaries and\n * labour-only lines never reach the AI. The same classifier already vets the\n * AI's *output*; doing it here as well means we stop paying to have those\n * rows read in the first place.\n *\n * Conservative by construction: only rows the classifier positively rejects\n * are dropped. Anything it is unsure about goes to the AI, which is the\n * better judge — a wrongly dropped row is invisible, a wrongly kept one only\n * costs tokens.\n *\n * @param array<int, mixed> $row\n */\n /**\n * Stable identity for a row, used to collapse exact duplicates.\n *\n * Whitespace and case are normalised so \"DN50 PIPE\" and \"dn50 pipe \" are one\n * row, but nothing else is: a differing quantity or unit produces a\n * different fingerprint and both rows are sent.\n *\n * @param array<int, mixed> $row\n */\n private function rowFingerprint(array $row): string\n {\n $parts = array_map(\n fn($cell) => mb_strtolower(preg_replace('/\\s+/u', ' ', trim((string) $cell)) ?? ''),\n $row,\n );\n\n // Drop a leading BOQ sequence number. Otherwise \"1,Pipe,m,100\" and\n // \"2,Pipe,m,100\" fingerprint differently and the dedup never fires —\n // which is exactly the shape every real BOQ has.\n if (isset($parts[0]) && $parts[0] !== '' && preg_match('/^[\\d.]+$/', $parts[0])) {\n array_shift($parts);\n }\n\n return md5(implode('|', $parts));\n }\n\n /**\n * Strip contractual boilerplate from a row's description cell.\n *\n * \"as per drawings\", \"complete with all necessary accessories\" and friends\n * repeat on nearly every line of a real BOQ and carry no information the\n * pricing engine can use — but they are a large share of the tokens.\n *\n * Only the longest text cell is touched, and only when the result is still a\n * usable description: if stripping would leave too little to identify the\n * product, the original is kept. A shorter-but-wrong description is far\n * worse than a verbose one.\n *\n * @param array<int, mixed> $row\n * @return array<int, mixed>\n */\n private function stripBoilerplate(array $row): array\n {\n $index = null;\n $longest = '';\n\n foreach ($row as $i => $cell) {\n $text = trim((string) $cell);\n if (preg_match('/\\p{L}/u', $text) && mb_strlen($text) > mb_strlen($longest)) {\n $longest = $text;\n $index = $i;\n }\n }\n\n // Nothing worth cleaning, or too short to risk it.\n if ($index === null || mb_strlen($longest) < 40) {\n return $row;\n }\n\n try {\n $cleaned = $this->boqCleaner->cleanDescription($longest);\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: boilerplate strip failed, keeping row as-is.', [\n 'error' => $e->getMessage(),\n ]);\n return $row;\n }\n\n // Guard against an over-eager pattern eating the product name: keep the\n // original unless a recognisable description survives.\n if (mb_strlen($cleaned) < 12 || mb_strlen($cleaned) < mb_strlen($longest) * 0.3) {\n return $row;\n }\n\n $row[$index] = $cleaned;\n\n return $row;\n }\n\n private function isNonSupplyRow(array $row): bool\n {\n $description = '';\n foreach ($row as $cell) {\n $text = trim((string) $cell);\n // Longest cell containing letters — numbers are qty/rate columns.\n if (preg_match('/\\p{L}/u', $text) && mb_strlen($text) > mb_strlen($description)) {\n $description = $text;\n }\n }\n\n // No text at all: leave it to isNoiseRow()/the AI rather than guessing.\n if ($description === '' || mb_strlen($description) < 3) {\n return false;\n }\n\n try {\n $verdict = $this->boqCleaner->filterItem($description);\n } catch (\\Throwable $e) {\n // A classifier failure must never silently drop a real product.\n Log::warning('QuotationAiService: pre-filter classifier failed, keeping row.', [\n 'error' => $e->getMessage(),\n ]);\n return false;\n }\n\n return ! ($verdict['keep'] ?? true);\n }\n\n private function isNoiseRow(array $row): bool\n {\n if (empty($row)) {\n return true;\n }\n\n $joined = trim(implode('', array_map(fn($c) => trim((string) $c), $row)));\n\n // Nothing but separators/whitespace.\n if ($joined === '') {\n return true;\n }\n\n // Ruled lines and decorative fills (\"-----\", \"====\", \"___\").\n if (preg_match('/^[\\-_=.*\\s|]+$/u', $joined)) {\n return true;\n }\n\n // Any letter — Latin or Arabic — means this could be a description.\n if (preg_match('/\\p{L}/u', $joined)) {\n return false;\n }\n\n // No letters at all: numbers only. A single number is a stray row\n // number or a dangling total; two or more could be qty/rate/amount\n // belonging to a real line, so keep those for the AI to judge.\n $numericCells = 0;\n foreach ($row as $cell) {\n if (trim((string) $cell) !== '') {\n $numericCells++;\n }\n }\n\n return $numericCells <= 1;\n }\n\n /**\n * Safely convert a PhpSpreadsheet cell value to a string.\n *\n * Cells may contain RichText or, in malformed/heavily-formatted files,\n * embedded objects such as Worksheet\\Drawing that have no __toString().\n * Casting those directly throws \"Object ... could not be converted to string\".\n */\n private function cellValueToString(mixed $value): string\n {\n if ($value === null) {\n return '';\n }\n\n if ($value instanceof \\PhpOffice\\PhpSpreadsheet\\RichText\\RichText) {\n return $value->getPlainText();\n }\n\n if (is_object($value)) {\n // Only stringify objects that explicitly support it; ignore the rest\n // (e.g. Worksheet\\Drawing) instead of crashing.\n return method_exists($value, '__toString') ? (string) $value : '';\n }\n\n if (is_array($value)) {\n return '';\n }\n\n return (string) $value;\n }\n\n private function csvEscape(mixed $value): string\n {\n $value = $this->cellValueToString($value);\n return str_contains($value, ',') || str_contains($value, '\"') || str_contains($value, \"\\n\")\n ? '\"' . str_replace('\"', '\"\"', $value) . '\"'\n : $value;\n }\n\n private function verifyLargeBoqItemsWithAi(array $result): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n Log::warning('QuotationAiService: large BOQ verification skipped, AI key is not configured.');\n return $result;\n }\n\n $items = array_values(array_filter($result['items'] ?? [], 'is_array'));\n if (count($items) <= self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n return $result;\n }\n\n Log::info('QuotationAiService: starting second-pass physical item verification.', [\n 'items_count' => count($items),\n 'threshold' => self::LARGE_BOQ_VERIFICATION_THRESHOLD,\n ]);\n\n $verifiedItems = [];\n $rejectedItems = $result['rejected'] ?? [];\n\n foreach (array_chunk($items, self::LARGE_BOQ_VERIFICATION_CHUNK_SIZE) as $chunkIndex => $chunk) {\n $payload = array_map(fn(array $item) => [\n 'description' => (string) ($item['description'] ?? ''),\n 'quantity' => $item['quantity'] ?? null,\n 'unit' => (string) ($item['unit'] ?? ''),\n 'unit_price' => $item['unit_price'] ?? null,\n 'category' => (string) ($item['category'] ?? ''),\n 'brand' => (string) ($item['brand'] ?? ''),\n ], $chunk);\n\n $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);\n if (! is_string($json)) {\n Log::warning('QuotationAiService: large BOQ verification chunk JSON encode failed.', [\n 'chunk' => $chunkIndex + 1,\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n $prompt = <<<PROMPT\nYou are doing a SECOND PASS verification on a BOQ extraction result.\n\nThe first pass may hallucinate non-products when the BOQ has thousands of rows.\nYour job is to keep ONLY physical, tangible, procurable supply products.\n\nRules:\n- Keep items only if a supplier can actually deliver them as materials/equipment.\n- Reject headings, trade names, systems, scopes, prose, specifications-only rows, totals, labor, installation, testing, commissioning, supervision, drawings, insurance, preliminaries, mobilisation, site works, and vague fragments.\n- Do not invent new products.\n- Do not add items that are not in the input.\n- You may clean a product description, but keep the original quantity, unit, unit_price, category, and brand when available.\n\nInput JSON array:\n{$json}\n\nReturn ONLY valid JSON in this shape:\n{\"items\":[{\"description\":\"\",\"quantity\":1,\"unit\":\"\",\"unit_price\":null,\"category\":\"\",\"brand\":\"\",\"confidence\":0.95}]}\nPROMPT;\n\n try {\n $chunkResult = $this->callDeepSeekChat($prompt, $apiKey, $this->deepSeekModel());\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: large BOQ verification chunk threw an exception.', [\n 'chunk' => $chunkIndex + 1,\n 'message' => $e->getMessage(),\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n if ($chunkResult['success'] ?? false) {\n array_push($verifiedItems, ...($chunkResult['items'] ?? []));\n $rejectedItems = array_merge($rejectedItems, $chunkResult['rejected'] ?? []);\n continue;\n }\n\n Log::warning('QuotationAiService: large BOQ verification chunk failed, keeping original chunk.', [\n 'chunk' => $chunkIndex + 1,\n 'error' => $chunkResult['error'] ?? null,\n ]);\n array_push($verifiedItems, ...$chunk);\n }\n\n if (empty($verifiedItems)) {\n Log::warning('QuotationAiService: second-pass verification returned no items, keeping first-pass result.', [\n 'original_items_count' => count($items),\n ]);\n return $result;\n }\n\n $result['items'] = $verifiedItems;\n $result['rejected'] = $rejectedItems;\n $result['large_boq_verified'] = true;\n\n Log::info('QuotationAiService: second-pass physical item verification completed.', [\n 'before' => count($items),\n 'after' => count($verifiedItems),\n ]);\n\n return $result;\n }\n\n private function normaliseAiItem(array $raw): ?array\n {\n if (! empty($raw['rejected'])) {\n return null;\n }\n\n $description = trim((string) ($raw['cleaned_description'] ?? $raw['product_name'] ?? $raw['description'] ?? ''));\n if ($description === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $description)) {\n return null;\n }\n\n $quantity = is_numeric($raw['quantity'] ?? null) ? (float) $raw['quantity'] : 1;\n $unitPrice = is_numeric($raw['unit_price'] ?? null) ? (float) $raw['unit_price'] : null;\n $totalPrice = is_numeric($raw['total_price'] ?? null) ? (float) $raw['total_price'] : null;\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $specs = is_array($raw['specifications'] ?? null) ? $raw['specifications'] : [];\n\n return [\n 'description' => $description,\n 'quantity' => $quantity,\n 'unit' => (string) ($raw['unit'] ?? ''),\n // Clamped to the column width. The model routinely returns a whole\n // description here — \"Cocktail Unit Ice well adjacent to chopping\n // area with waste chute…\" — and a value over 100 chars made the\n // INSERT fail with SQLSTATE[22001], which silently cost the whole\n // chunk its rows and left the reuse tables empty.\n 'category' => mb_substr((string) ($raw['core_product'] ?? $raw['category'] ?? ''), 0, 100),\n 'brand' => mb_substr((string) ($specs['brand'] ?? $raw['brand'] ?? ''), 0, 100),\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($description),\n 'unit_price' => $unitPrice,\n 'confidence' => is_numeric($raw['confidence'] ?? null) ? (float) $raw['confidence'] : null,\n 'needs_review' => ! empty($raw['needs_review']),\n 'raw_data' => [\n 'source_item_no' => (string) ($raw['source_item_no'] ?? ''),\n 'original_description' => (string) ($raw['original_description'] ?? $description),\n 'cleaned_description' => $description,\n 'core_product' => (string) ($raw['core_product'] ?? ''),\n 'specifications' => $specs,\n 'extraction_type' => (string) ($raw['extraction_type'] ?? 'supply_only'),\n 'note' => (string) ($raw['note'] ?? $raw['notes'] ?? ''),\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function buildSystemInstruction(): string\n {\n return <<<'TXT'\nYou are a strict BOQ Supply Product Extraction Engine.\nExtract ONLY concrete, tangible, physical products that a supplier can box up and deliver to site — a specific item you could point at and say \"this is the thing being bought\" (e.g. \"Wall mounted LED luminaire 36W\", \"PVC pipe 110mm\", \"Single phase distribution board 12-way\", \"Fire extinguisher CO2 6kg\", \"CCTV dome camera 4MP\").\n\nABSOLUTELY REJECT abstract concepts, disciplines, categories, section names, and trade headings. These are NOT products even though they appear as rows:\n- Bare discipline/trade words: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", \"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\".\n- Summary / total / abstract / \"dry cost\" rows of any kind.\n- Anything that names a system, scope, or category instead of one buyable item.\n\nRule of thumb: if you cannot name a unit and physically hand the item over (a piece, a box, a length, a unit of equipment), REJECT it. A word that describes a field of work, a system, or a section of the BOQ is NEVER a product.\nReject totals, headings, general requirements, installation-only, testing, commissioning, supervision, mobilization, civil works, labor, and vague rows.\nFor Supply & Install rows, keep only the supply product and remove install/labor wording.\nReturn only valid JSON: {\"items\":[{\"source_item_no\":null,\"original_description\":\"\",\"cleaned_description\":\"\",\"core_product\":\"\",\"specifications\":{},\"quantity\":0,\"unit\":\"\",\"unit_price\":0,\"total_price\":0,\"extraction_type\":\"supply_only\",\"needs_review\":false,\"note\":\"\",\"rejected\":false,\"rejection_reason\":null,\"confidence\":0.95}]}\nTXT;\n }\n\n private function buildDeepSeekPrompt(array $context = [], string $mime = ''): string\n {\n $projectName = (string) ($context['project_name'] ?? '');\n $projectStatus = (string) ($context['project_status'] ?? '');\n $contextLine = $projectName !== ''\n ? \"Project: {$projectName}\" . ($projectStatus !== '' ? \" ({$projectStatus})\" : '')\n : '';\n $sourceHint = str_starts_with($mime, 'image/')\n ? 'The input is an image of a BOQ table. OCR every cell carefully — read the quantity/count column (العدد, الكمية, Qty) and extract the EXACT numeric value from each row.'\n : 'The input is a BOQ document/spreadsheet.';\n\n return trim(<<<PROMPT\nYou are a procurement specialist. Your task is to extract ONLY tangible, physical supply products from this Bill of Quantities (BOQ).\n\n{$contextLine}\n{$sourceHint}\n\nCRITICAL — MERGE WRAPPED / SPLIT ROWS INTO ONE ITEM:\nBOQ tables extracted from PDF often break ONE item's long description across several\nlines/rows. These continuation lines repeat the SAME quantity, unit and category, and\ntheir text is a grammatical continuation of the previous line (it starts mid-sentence,\nlowercase, or with \"the/and/-\", or is a spec/instruction clause). You MUST reconstruct\nthe original single item:\n- Treat consecutive rows that share the SAME quantity + unit + category as ONE product\n when the later rows are clearly continuation text of the first, NOT new products.\n- Emit ONE item with the merged description and the price counted ONCE. Do NOT repeat\n the same quantity×price for every wrapped line — that multiplies the total wrongly.\n- Example: rows \"The asphalt flooring is to be supplied\" / \"installed in accordance\" /\n \"standard road specifications...\" all with 7,100 m2 are ONE asphalt item, not 3+.\n\nCRITICAL — UNDERSTAND INTENT, DO NOT COPY ROWS LITERALLY:\nYour job is to output WHAT A SUPPLIER MUST ACTUALLY DELIVER, not a copy of the row text.\nRead each description for MEANING and decide how many real, separately-buyable products it\nimplies. One BOQ row can imply SEVERAL products, or a spread of rows can imply ONE product.\n- SPLIT a row into multiple items when it lists distinct products that a supplier would\n buy/price separately — even when joined by commas, \"with\", \"and\", \"including\", \"c/w\",\n \"consisting of\", \"comprising\", or \":\" then a list.\n Example: \"Fire pump set consisting of: fire pump, electric motor, control panel, diesel\n engine\" → FOUR items: (1) fire pump, (2) electric motor, (3) control panel, (4) diesel\n engine — each with its own quantity and priced on its own.\n Example: \"Wash basin complete with mixer tap and bottle trap\" → THREE items: wash basin,\n mixer tap, bottle trap.\n- Give each split product the correct quantity implied by the text (usually the parent\n quantity, unless the text states a different count for a sub-item). Each product is\n priced individually; the line total becomes the SUM of its products.\n- DO NOT split when the extra words are just specifications, dimensions, colours, ratings,\n standards, or a single product's integral parts. \"Steel door 900x2100 with vision panel\n and SS ironmongery\" is usually ONE door product — vision panel and ironmongery are part\n of that door, not separately-bought items. Use judgement: would a supplier issue a\n separate price line for it? If no, keep it as one.\n- Balanced approach: split only when genuinely separate products are present; when in doubt\n about whether something is a separate product vs. an accessory/spec of the main one, keep\n it as a single item. Do not invent products that are not in the text.\n\nSTRICT RULES — include ONLY items that are ALL of the following:\n1. A specific, concrete, tangible product that can be boxed up by a supplier and delivered to site — something you can point at and say \"this exact thing is being bought\" (e.g. \"LED luminaire 36W\", \"PVC pipe 110mm\", \"Distribution board 12-way\", \"CO2 fire extinguisher 6kg\").\n2. Has a real quantity (not 0 or \"varies\" with no meaning).\n3. Has enough description to identify what the product is.\n4. Is an actual PRODUCT, not a sentence, instruction, or specification clause. REJECT rows that are prose such as \"the supervising engineer's instructions\", \"The price includes all materials, labor\", \"Measurement will be based on the geometric cubic meter\", \"necessary steps to complete the work\", \"soil-contacting elements\", \"in compliance\", \"everything necessary\". These are description fragments, not buyable items.\n\nNEVER extract abstract concepts, disciplines, trades, systems, or section/category names — they are NOT products even when they appear as their own row:\n- Bare discipline/trade/system words such as: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", and their Arabic equivalents (\"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\", \"تشطيبات\").\n- Test: if the word names a FIELD OF WORK or a SYSTEM rather than one buyable item with a unit (pcs, m, set, kg…), REJECT it.\n\nEXCLUDE all of the following (do NOT include them):\n- Labor, installation, or workmanship items (e.g. \"install\", \"fix\", \"erect\", \"dismantle\", \"painting\", \"cleaning service\")\n- Project management, overhead, or profit items\n- Insurance, bonds, liability items (e.g. \"bodily injury\", \"third party liability\", \"CAR insurance\")\n- Shop drawings, as-built drawings, method statements, documentation\n- Preliminary items, mobilisation, demobilisation, provisional sums\n- Housekeeping, site clearance, waste removal\n- Floor/level labels (e.g. \"GF Floor\", \"Level 3\") — these are headers, not products\n- Pure dimension specs without a product noun (e.g. \"Dia 32 mm\", \"25 mm\") — reject unless a product name is present\n- Security services, night shifts, rentals, payments\n- Any item whose description is too vague or fragmentary to identify a specific product\n\nEmit ONE JSON item PER real product. A single source row may therefore produce several\nitems (when it lists multiple products) or several rows may collapse into one item (when\nthey are wrapped fragments of the same product). The \"items\" array is your final delivery\nlist, not a row-by-row transcript.\n\nFor each valid physical product return JSON with:\n- description: clean product name only (strip \"supply and install\", \"supply and fix\", installation clauses, floor/spec suffixes). For a split product, use just that product's own name.\n- quantity: the EXACT numeric value from the quantity/count column (العدد, الكمية, Qty, Quantity, No.). NEVER default to 1 unless the quantity column is truly absent or empty.\n- unit: unit of measure (pcs, m, m2, m3, set, no, kg, etc.)\n- category: product category\n- brand: brand/manufacturer if mentioned, else \"\"\n- engineering_required: true if the item needs engineering design before procurement (panel boards, chillers, generators, fire suppression systems, BMS, pumps, AHUs, switchgear, transformers), else false\n\nReturn ONLY a valid JSON object: {\"items\": [...]}. No explanations, no markdown, no extra text.\nPROMPT);\n }\n\n private function mockResponse(): array\n {\n return [\n 'success' => true,\n 'error' => null,\n 'items' => [[\n 'description' => 'Sample BOQ Item',\n 'quantity' => 1,\n 'unit' => 'No',\n 'category' => 'Sample',\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 1,\n 'raw_data' => [],\n 'ai_extracted' => true,\n ]],\n 'rejected' => [],\n ];\n }\n\n private function failure(string $message, bool $serviceUnavailable = false): array\n {\n return [\n 'success' => false,\n 'items' => [],\n 'rejected' => [],\n 'error' => $this->userFacingError($message, $serviceUnavailable),\n 'service_unavailable' => $serviceUnavailable,\n ];\n }\n\n /**\n * Translate any internal error into a neutral, user-safe message.\n *\n * The user must never see the name of the underlying AI/processing engine\n * (DeepSeek, Groq, Gemini, Vision, etc.) or configuration hints like API\n * keys. The detailed message is kept in the logs for debugging only.\n */\n private function userFacingError(string $message, bool $serviceUnavailable = false): string\n {\n // Detect any reference to an AI provider / engine / API config so we can\n // swap it for a generic, branded-neutral message.\n $sensitive = '/deepseek|groq|gemini|vision|openai|openrouter|api[\\s_-]?key|\\.env|non-?json|token limit/i';\n\n if (preg_match($sensitive, $message)) {\n Log::warning('QuotationAiService: suppressed technical error from user.', ['internal' => $message]);\n\n return $serviceUnavailable\n ? 'The file could not be processed right now. Please try again in a moment.'\n : 'We could not read this file automatically. Please make sure it is a clear BOQ (Excel, CSV, PDF, or image) and try again, or add the items manually.';\n }\n\n return $message;\n }\n\n private function mimeForExtension(string $ext): string\n {\n return match (strtolower($ext)) {\n 'pdf' => 'application/pdf',\n 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n 'xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12',\n 'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',\n 'xls' => 'application/vnd.ms-excel',\n 'csv' => 'text/csv',\n 'jpg', 'jpeg' => 'image/jpeg',\n 'png' => 'image/png',\n 'gif' => 'image/gif',\n 'bmp' => 'image/bmp',\n 'webp' => 'image/webp',\n 'tiff', 'tif' => 'image/tiff',\n 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'doc' => 'application/msword',\n default => 'application/octet-stream',\n };\n }\n}\n"}, "2105116bd66b3ba99b4e577e0957008ffcfdec2e": {"t": 1785748350.7343705, "n": 99311, "h": "af0161c408563c8f", "c": "<?php\n\nnamespace App\\Services;\n\nuse Illuminate\\Http\\Client\\ConnectionException;\nuse Illuminate\\Http\\UploadedFile;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\nuse PhpOffice\\PhpSpreadsheet\\Cell\\Coordinate;\nuse PhpOffice\\PhpSpreadsheet\\IOFactory;\n\nclass QuotationAiService\n{\n /** Increment this whenever BOQ parsing logic changes to invalidate old caches. */\n private const PARSER_VERSION = 10;\n private const LARGE_BOQ_VERIFICATION_THRESHOLD = 2000;\n private const LARGE_BOQ_VERIFICATION_CHUNK_SIZE = 120;\n\n /**\n * Characters of document text sent per AI call.\n *\n * Above this the document is split and parsed over several calls. Sized well\n * under the model's context so the prompt, the slice, and a full JSON answer\n * for every row in it all fit — the binding limit is the *output* budget, not\n * the input, since each input row produces a JSON object.\n */\n private const TEXT_CHUNK_CHARS = 60000;\n\n /**\n * A workbook with more sheets than this is split regardless of its size.\n *\n * Multiple tabs almost always mean multiple disciplines (electrical, HVAC,\n * civil…). Sending them in one call makes the model summarise across\n * sections and silently drop rows, so each slice gets a narrower scope.\n */\n private const MAX_SHEETS_PER_CALL = 2;\n\n /** A PDF longer than this is split regardless of its extracted text length. */\n private const MAX_PDF_PAGES_PER_CALL = 5;\n\n /**\n * Largest PDF we will hand to the parser (12 MB).\n *\n * smalot/pdfparser decompresses every stream into memory with no ceiling of\n * its own, and overrunning the limit there is a fatal error that kills the\n * worker rather than an exception we can recover from.\n */\n private const MAX_PDF_BYTES = 12582912;\n\n /**\n * Memory the parser is assumed to need, as a multiple of the file size.\n *\n * Decompressed content streams plus the object graph run well past the raw\n * bytes; 8x is conservative enough to catch the cases that were dying.\n */\n private const PDF_MEMORY_FACTOR = 8;\n\n /** Returned by extractPdfText when it stopped for memory, not for lack of text. */\n private const PDF_LOW_MEMORY = \"\\0__pdf_low_memory__\";\n\n private string $baseUrl;\n private string $parseEndpoint;\n private string $apiKey;\n private int $timeout;\n private bool $testMode;\n private BoqCleaningService $boqCleaner;\n\n /** Page count of the most recently parsed PDF; 0 when unknown. */\n private int $lastPdfPageCount = 0;\n\n /**\n * Optional progress reporter for chunked parsing, called as ($part, $total).\n *\n * Set by the queue job so the polling UI can show real progress on a large\n * BOQ instead of a spinner that appears frozen for many minutes.\n *\n * @var (callable(int, int): void)|null\n */\n private $onChunkProgress = null;\n\n /** @param callable(int, int): void $callback */\n public function onChunkProgress(callable $callback): self\n {\n $this->onChunkProgress = $callback;\n return $this;\n }\n\n /**\n * Optional per-chunk item sink, called as ($items, $part, $total).\n *\n * Lets the caller persist each slice's rows the moment they arrive instead\n * of waiting for the whole document. On a large BOQ this is the difference\n * between a table that fills in progressively and one that stays empty for\n * many minutes.\n *\n * @var (callable(array, int, int): void)|null\n */\n private $onChunkItems = null;\n\n /** @param callable(array, int, int): void $callback */\n public function onChunkItems(callable $callback): self\n {\n $this->onChunkItems = $callback;\n return $this;\n }\n\n /**\n * Report a non-chunk stage (local parse, handing off to AI) to the caller.\n *\n * A spreadsheet is read locally first and only sent to the AI when that\n * fails, so a large file can spend minutes before any chunk exists. Without\n * these the UI shows a generic spinner for that whole period.\n */\n private function reportStage(string $message): void\n {\n if ($this->onStage !== null) {\n ($this->onStage)($message);\n }\n }\n\n /**\n * Report a workbook's shape from grids that are already in memory.\n *\n * Deliberately takes the parsed grids rather than a path: re-reading a large\n * spreadsheet just to count its sheets would double the slowest step of the\n * whole extraction.\n *\n * @param array<int, array{name: string, grid: array}> $sheets\n */\n private function reportWorkbookShape(array $sheets): void\n {\n if ($this->onStage === null || empty($sheets)) {\n return;\n }\n\n $rows = 0;\n foreach ($sheets as $sheet) {\n $rows += count($sheet['grid']);\n }\n\n $count = count($sheets);\n\n $this->reportStage($count > 1\n ? \"Found {$count} sheets, {$rows} rows in total. Reading…\"\n : \"Found {$rows} rows. Reading…\");\n }\n\n /** @var (callable(string): void)|null */\n private $onStage = null;\n\n /** @param callable(string): void $callback */\n public function onStage(callable $callback): self\n {\n $this->onStage = $callback;\n return $this;\n }\n\n public function __construct(BoqCleaningService $boqCleaner)\n {\n $this->boqCleaner = $boqCleaner;\n $this->baseUrl = rtrim((string) config('services.ai_quotation.base_url', ''), '/');\n $this->parseEndpoint = ltrim((string) config('services.ai_quotation.parse_endpoint', 'parse'), '/');\n $this->apiKey = (string) config('services.ai_quotation.api_key', '');\n $this->timeout = (int) config('services.ai_quotation.timeout', 300);\n $this->testMode = (bool) config('services.ai_quotation.test_mode', false);\n }\n\n /**\n * Give the running script enough head-room to outlast the AI HTTP call.\n *\n * The web request / queue worker must not be killed before the HTTP timeout\n * completes, otherwise the user sees a generic \"timeout or unavailable\"\n * error even though the AI may have responded. We allow the HTTP timeout\n * plus a 120s margin for connecting, response parsing and DB writes.\n */\n private function extendExecutionTime(): void\n {\n // Ignored on some SAPIs (e.g. when running under fastcgi with a hard\n // limit), but harmless to attempt.\n @set_time_limit($this->timeout + 120);\n }\n\n public function parseBoq(UploadedFile|string $file, array $context = []): array\n {\n if ($this->testMode) {\n return $this->mockResponse();\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n\n if (! is_file($absPath)) {\n return $this->failure('File not found or not readable.');\n }\n\n $fileHash = hash_file('sha256', $absPath);\n // Bump PARSER_VERSION whenever the extraction logic changes so that\n // previously-uploaded files are re-parsed instead of returning a stale\n // cached result produced by the old code.\n $cacheKey = 'boq_analysis_v' . self::PARSER_VERSION . '_' . $fileHash;\n $forceRefresh = (bool) ($context['force_refresh'] ?? false);\n\n if (! $forceRefresh && ($cached = Cache::get($cacheKey)) !== null) {\n return $cached;\n }\n\n // Clear stale cached result when force-refreshing\n if ($forceRefresh) {\n Cache::forget($cacheKey);\n }\n\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n // Read Excel locally first. Fall back to AI if local parse failed,\n // found 0 items, OR could not read one of the sheets (so multi-sheet\n // files never silently drop a sheet the local parser didn't understand).\n $this->reportStage('Reading the spreadsheet…');\n\n $result = $this->parseSpreadsheetDirect($absPath);\n\n $hasSkippedSheets = ! empty($result['skipped_sheets']);\n\n if (! $result['success'] || empty($result['items']) || $hasSkippedSheets) {\n // The local pass could not handle this file on its own. Say so:\n // otherwise the UI sits on a generic spinner through the local\n // parse AND the whole AI parse with nothing to show for either.\n $this->reportStage($hasSkippedSheets\n ? 'Some sheets need AI to read. Sending to AI…'\n : 'Spreadsheet needs AI to read. Sending to AI…');\n\n $deepSeekResult = $this->parseBoqWithDeepSeek($file, $context);\n // AI sees every sheet at once, so prefer its result whenever it\n // actually returned items (covers the skipped-sheet case too).\n if ($deepSeekResult['success'] && ! empty($deepSeekResult['items'])) {\n // Keep the larger of the two item sets so we never lose the\n // rows the local parser already extracted correctly.\n if (count($deepSeekResult['items']) >= count($result['items'] ?? [])) {\n $result = $deepSeekResult;\n }\n } elseif (! $result['success']) {\n $result = $deepSeekResult;\n }\n // else: keep local result if AI also failed/returned nothing\n }\n } elseif (in_array($ext, ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif'], true)) {\n $result = $this->parseBoqWithDeepSeek($file, $context);\n } else {\n return $this->failure('Unsupported file type. Please upload Excel, CSV, PDF, or image file.');\n }\n\n if ($result['success'] && ! empty($result['items'])) {\n $cleaning = $this->boqCleaner->process($result['items']);\n $result['items'] = $cleaning['accepted'] ?? $result['items'];\n $result['rejected'] = array_merge($result['rejected'] ?? [], $cleaning['rejected'] ?? []);\n }\n\n if ($result['success'] && count($result['items'] ?? []) > self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n $result = $this->verifyLargeBoqItemsWithAi($result);\n }\n\n // Only cache when we actually extracted items; empty results should be re-tried.\n if ($result['success'] && ! empty($result['items'])) {\n Cache::put($cacheKey, $result, now()->addDays(30));\n }\n\n return $result;\n }\n\n private function resolveFile(UploadedFile|string $file): array\n {\n if ($file instanceof UploadedFile) {\n return [(string) $file->getRealPath(), strtolower($file->getClientOriginalExtension())];\n }\n\n $absPath = is_file($file) ? $file : storage_path('app/' . ltrim($file, '/'));\n return [$absPath, strtolower(pathinfo($absPath, PATHINFO_EXTENSION))];\n }\n\n private function parseSpreadsheetDirect(string $absPath): array\n {\n ini_set('memory_limit', '2048M');\n\n // 180s was not enough for a real BOQ: an 11 200-row workbook was killed\n // mid-parse, and because set_time_limit() kills the process outright the\n // job reported a bare failure with nothing in the log to explain it.\n // This runs on the queue, where wall time is cheap.\n set_time_limit(1800);\n\n try {\n $sheets = $this->spreadsheetToGrids($absPath);\n\n if (empty($sheets)) {\n return $this->failure('Could not read the Excel file.');\n }\n\n $this->reportWorkbookShape($sheets);\n\n $items = [];\n $rejected = [];\n $skippedSheets = []; // sheets we could not parse locally (header undetected)\n\n foreach ($sheets as $sheet) {\n $grid = $sheet['grid'];\n if (count($grid) < 2) {\n continue;\n }\n\n // Skip dedicated summary / cost-abstract sheets entirely. Their rows\n // are per-discipline totals (Structural Concrete, Electrical, …) and\n // grand totals, not procurable products.\n if ($this->isSummarySheet($sheet['name'])) {\n Log::info('QuotationAiService: skipped summary sheet.', ['sheet' => $sheet['name']]);\n continue;\n }\n\n $header = $this->detectHeader($grid);\n\n if ($header === null || ! isset($header['map']['description'])) {\n Log::warning('QuotationAiService: header not detected in sheet.', ['sheet' => $sheet['name']]);\n $skippedSheets[] = $sheet['name'];\n continue;\n }\n\n $map = $header['map'];\n $start = $header['row'] + 1;\n\n for ($r = $start; $r < count($grid); $r++) {\n $row = $grid[$r];\n $description = $this->cell($row, $map['description'] ?? null);\n $itemNo = $this->cell($row, $map['item_code'] ?? null);\n $unit = $this->cell($row, $map['unit'] ?? null);\n $quantity = $this->number($this->cell($row, $map['quantity'] ?? null));\n $unitPrice = $this->number($this->cell($row, $map['unit_price'] ?? null));\n $totalPrice = $this->number($this->cell($row, $map['total_price'] ?? null));\n\n if ($description === '') {\n continue;\n }\n\n if (! isset($map['quantity'])) {\n $quantity = 1.0;\n }\n\n // Skip floor breakdown sub-rows: Ref. is \"-\" meaning it's a per-floor\n // quantity split of the parent item, not a standalone product.\n if ($itemNo === '-' || $this->isFloorBreakdownRow($itemNo, $description)) {\n continue;\n }\n\n if ($this->isTotalOrHeaderRow($description)) {\n continue;\n }\n\n if ($quantity === null || $quantity <= 0) {\n $rejected[] = $this->rejectedRow($description, $unit, $quantity, 'Missing quantity', $itemNo, $sheet['name']);\n continue;\n }\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $supply = $this->boqCleaner->filterItem($description, $unitPrice, $quantity);\n\n if (! ($supply['keep'] ?? false)) {\n $rejected[] = $this->rejectedRow(\n $description,\n $unit,\n $quantity,\n (string) ($supply['rejection_reason'] ?? 'Rejected by cleaner'),\n $itemNo,\n $sheet['name']\n );\n continue;\n }\n\n $finalDescription = (string) ($supply['description'] ?? $description);\n\n $items[] = [\n 'description' => $finalDescription,\n 'quantity' => $quantity,\n 'unit' => $unit,\n 'category' => $sheet['name'],\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($finalDescription),\n 'unit_price' => $unitPrice,\n 'confidence' => 0.95,\n 'needs_review' => false,\n 'raw_data' => [\n 'sheet' => $sheet['name'],\n 'row_number' => $r + 1,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'cleaned_description' => $finalDescription,\n 'extraction_type' => (string) ($supply['extraction_type'] ?? 'supply_only'),\n ],\n 'ai_extracted' => true,\n ];\n }\n }\n\n if (empty($items)) {\n return $this->failure('No BOQ supply items could be extracted from the Excel file.');\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'skipped_sheets' => $skippedSheets,\n ];\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: parseSpreadsheetDirect failed.', [\n 'path' => $absPath,\n 'message' => $e->getMessage(),\n 'trace' => $e->getTraceAsString(),\n ]);\n\n return $this->failure('Failed to read the Excel file: ' . $e->getMessage());\n }\n }\n\n private function spreadsheetToGrids(string $absPath): array\n {\n $ext = strtolower(pathinfo($absPath, PATHINFO_EXTENSION));\n\n if ($ext === 'csv') {\n $rows = [];\n $handle = fopen($absPath, 'rb');\n if ($handle !== false) {\n while (($row = fgetcsv($handle)) !== false) {\n $rows[] = $this->trimTrailingEmptyCells($row);\n }\n fclose($handle);\n }\n return [['name' => 'Sheet1', 'grid' => $rows]];\n }\n\n $reader = IOFactory::createReader($ext === 'xls' ? 'Xls' : 'Xlsx');\n $reader->setReadDataOnly(true);\n $spreadsheet = $reader->load($absPath);\n $sheets = [];\n\n foreach ($spreadsheet->getAllSheets() as $sheet) {\n $highestRow = $sheet->getHighestDataRow();\n $highestColumn = $sheet->getHighestDataColumn();\n $highestColumnIndex = Coordinate::columnIndexFromString($highestColumn);\n\n // BOQ data is normally in first 8-20 columns.\n // Files with heavy formatting/images can report 300+ columns — limit to 30.\n $maxColumnIndex = min($highestColumnIndex, 30);\n\n $grid = [];\n for ($row = 1; $row <= $highestRow; $row++) {\n $rowData = [];\n $hasValue = false;\n\n for ($col = 1; $col <= $maxColumnIndex; $col++) {\n $value = $sheet->getCell([$col, $row])->getValue();\n $value = $this->cellValueToString($value);\n $value = trim($value);\n if ($value !== null && $value !== '') {\n $hasValue = true;\n }\n $rowData[] = $value;\n }\n\n $rowData = $this->trimTrailingEmptyCells($rowData);\n if ($hasValue) {\n $grid[] = $rowData;\n }\n }\n\n if (! empty($grid)) {\n $sheets[] = ['name' => $sheet->getTitle(), 'grid' => $grid];\n }\n }\n\n $spreadsheet->disconnectWorksheets();\n unset($spreadsheet);\n\n return $sheets;\n }\n\n private function detectHeader(array $grid): ?array\n {\n $keywords = [\n 'item_code' => ['رقم البند', 'item no', 'item number', 'ref.', 'ref', 'item', 'no.', 'no', '#', 'كود'],\n 'description' => ['وص٠البند', 'scope of works', 'scope of work', 'scope', 'item description', 'description of works', 'description', 'بالعربية', 'الوصÙ', 'البند', 'بيان'],\n 'unit' => ['Ø§Ù„ÙˆØØ¯Ø©', 'unit', 'uom', 'u/m'],\n 'quantity' => ['الكمية', 'qty', 'quantity', 'q.ty'],\n 'unit_price' => ['سعر Ø§Ù„ÙˆØØ¯Ø©', 'u/price', 'unit price', 'unit rate', 'rate', 'price'],\n 'total_price' => ['السعر الإجمالي', 'total amount', 'total price', 'amount', 'الإجمالي'],\n ];\n\n $best = null;\n $bestScore = 0;\n\n for ($r = 0; $r < min(30, count($grid)); $r++) {\n $map = [];\n $score = 0;\n\n foreach ($grid[$r] as $c => $value) {\n $cell = mb_strtolower(trim((string) $value));\n if ($cell === '') {\n continue;\n }\n\n foreach ($keywords as $field => $list) {\n foreach ($list as $kw) {\n if (str_contains($cell, mb_strtolower($kw))) {\n if (! isset($map[$field])) {\n $map[$field] = $c;\n $score++;\n }\n break 2;\n }\n }\n }\n }\n\n if ($score > $bestScore) {\n $bestScore = $score;\n $best = ['row' => $r, 'map' => $map];\n }\n }\n\n if ($bestScore >= 3) {\n return $best;\n }\n\n // Allow simpler BOQ sheets that only define a description column plus one\n // other BOQ-related column, e.g. Item + Description or Description + Qty.\n if ($bestScore === 2 && isset($best['map']['description']) && (\n isset($best['map']['item_code']) ||\n isset($best['map']['quantity']) ||\n isset($best['map']['unit'])\n )) {\n return $best;\n }\n\n return null;\n }\n\n private function cell(array $row, ?int $index): string\n {\n if ($index === null) {\n return '';\n }\n return trim((string) ($row[$index] ?? ''));\n }\n\n private function number(string $value): ?float\n {\n $value = trim(str_replace([',', 'SAR', 'ر.س'], '', $value));\n if ($value === '' || ! is_numeric($value)) {\n return null;\n }\n return (float) $value;\n }\n\n private function trimTrailingEmptyCells(array $row): array\n {\n while (! empty($row) && trim((string) end($row)) === '') {\n array_pop($row);\n }\n return array_values($row);\n }\n\n private function isTotalOrHeaderRow(string $description): bool\n {\n $d = mb_strtolower(trim($description));\n\n if ($d === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $d)) {\n return true;\n }\n\n // Section totals / summaries\n if (preg_match('/(اجمالي|إجمالي|المجموع|grand\\s*total|sub\\s*total|total\\s*amount|total\\s*price)/iu', $d)) {\n return true;\n }\n\n // Bare \"Total\" lines, optionally with a currency suffix, e.g. \"Total\",\n // \"Total (SAR)\", \"Total cost\", \"Total (SAR) unit cost\".\n if (preg_match('/^total\\b[\\s\\(\\):a-z]*$/iu', $d)) {\n return true;\n }\n\n // Pure floor / level labels used as section headers\n if (preg_match('/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor)\\s*$/iu', $d)) {\n return true;\n }\n\n // Division / section header lines (e.g. \"Division - 02: Wood Works\")\n if (preg_match('/^division\\s*([-:]|\\d)/iu', $d)) {\n return true;\n }\n\n return false;\n }\n\n /**\n * Detect dedicated summary / cost-abstract sheets by their name. These hold\n * per-discipline rollups and grand totals (e.g. a \"Summary (Dry Cost)\" tab),\n * never procurable supply items, so they are skipped wholesale.\n */\n private function isSummarySheet(string $name): bool\n {\n $n = mb_strtolower(trim($name));\n\n if ($n === '') {\n return false;\n }\n\n return (bool) preg_match(\n '/\\b(summary|abstract|cost\\s*summary|grand\\s*summary|recap(?:itulation)?|bill\\s*summary|boq\\s*summary|dry\\s*cost)\\b|ملخص|الملخص|الملخّص|ملخّص|اجمالي|إجمالي/iu',\n $n\n );\n }\n\n private function isFloorBreakdownRow(string $itemNo, string $description): bool\n {\n // A row whose Ref column is empty or a dash is a sub-breakdown row (per-floor split).\n $ref = trim($itemNo);\n if ($ref === '' || $ref === '-') {\n // Confirm by checking the description is a location/floor label\n $d = mb_strtolower(trim($description));\n return (bool) preg_match(\n '/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor|\\d+f)\\s*$/iu',\n $d\n );\n }\n return false;\n }\n\n private function rejectedRow(string $description, string $unit, ?float $quantity, string $reason, string $itemNo = '', string $sheet = ''): array\n {\n return [\n 'description' => $description,\n 'quantity' => $quantity ?? 0,\n 'unit' => $unit,\n 'category' => $sheet,\n 'brand' => '',\n 'status' => 'rejected',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 0.0,\n 'raw_data' => [\n 'sheet' => $sheet,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'extraction_type' => 'rejected',\n 'rejection_reason' => $reason,\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function parseBoqWithDeepSeek(UploadedFile|string $file, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured. Please set DEEPSEEK_API_KEY in .env file.');\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n $mime = $this->mimeForExtension($ext);\n\n try {\n // ── Spreadsheets ──────────────────────────────────────────────────\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n $text = $this->spreadsheetToCompactCsvText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not convert spreadsheet to readable text.');\n }\n\n $text = $this->sanitizeUtf8($text);\n\n // A large BOQ exceeds what one request can carry, so send it in\n // sequential slices and merge. Anything that fits goes as-is.\n //\n // Size alone is not a good enough signal: a workbook with several\n // sheets is structurally a big BOQ even when the text is compact,\n // and asking the model to hold every discipline at once is where\n // rows start getting dropped. So split on either signal.\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS || $sheetCount > self::MAX_SHEETS_PER_CALL) {\n Log::info('QuotationAiService: spreadsheet routed to chunked parsing.', [\n 'chars' => mb_strlen($text),\n 'sheets' => $sheetCount,\n ]);\n return $this->parseTextInChunks($text, 'BOQ spreadsheet converted to CSV', $apiKey, $context);\n }\n\n $userContent = \"BOQ spreadsheet converted to CSV:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── PDF ───────────────────────────────────────────────────────────\n if ($ext === 'pdf') {\n $extracted = $this->extractPdfText($absPath);\n\n if ($extracted === self::PDF_LOW_MEMORY) {\n return $this->failure('The server was low on memory while reading this PDF. Please try again in a moment, or convert it to Excel or CSV.');\n }\n\n if ($extracted !== null && mb_strlen(trim($extracted)) > 50) {\n $extracted = $this->sanitizeUtf8($extracted);\n\n // Split on either signal — long text, or many pages. A BOQ\n // spanning several pages is a multi-section document even if\n // each page is sparse.\n if (mb_strlen($extracted) > self::TEXT_CHUNK_CHARS\n || $this->lastPdfPageCount > self::MAX_PDF_PAGES_PER_CALL) {\n Log::info('QuotationAiService: PDF routed to chunked parsing.', [\n 'chars' => mb_strlen($extracted),\n 'pages' => $this->lastPdfPageCount,\n ]);\n return $this->parseTextInChunks($extracted, 'BOQ PDF extracted text', $apiKey, $context, 'application/pdf');\n }\n\n $userContent = \"BOQ PDF extracted text:\\n\\n\" . $extracted . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'application/pdf');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // No text yet — smalot threw its \"$obj\" bug and pdftotext is not\n // installed. Last resort before giving up: OCR.space, a hosted\n // reader that takes the PDF and returns its text. Needs no local\n // binary and no vision key, and still feeds DeepSeek afterwards.\n $ocrText = $this->extractPdfTextViaOcrSpace($absPath);\n\n if ($ocrText !== null && mb_strlen(trim($ocrText)) > 50) {\n $ocrText = $this->sanitizeUtf8($ocrText);\n $userContent = \"BOQ PDF extracted text:\\n\\n\" . $ocrText . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'application/pdf');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n return $this->failure('Could not read this PDF. It may be a scanned image, or use a format our reader cannot open. Please convert it to Excel or CSV and upload again.');\n }\n\n // -- Word documents (docx) ---------------------------------------------------\n if (in_array($ext, ['docx', 'doc'], true)) {\n $text = $this->extractDocxText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not extract text from the Word document. Please convert it to Excel, CSV, or PDF.');\n }\n $text = $this->sanitizeUtf8($text);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS) {\n return $this->parseTextInChunks($text, 'BOQ Word document extracted text', $apiKey, $context);\n }\n\n $userContent = \"BOQ Word document extracted text:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── Images ──────────────────────────────────────────────────────────────\n // DeepSeek cannot receive images. A vision-capable AI processes the image\n // directly (no OCR) — configure GROQ_API_KEY or GEMINI_API_KEY in .env.\n $imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif', 'heic', 'heif'];\n if (in_array($ext, $imageExts, true)) {\n $bytes = (string) file_get_contents($absPath);\n return $this->callDeepSeekVision($bytes, $mime, $context);\n }\n\n return $this->failure('Unsupported file type. Please upload an Excel, CSV, PDF, or image file.');\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: DeepSeek fallback failed.', ['message' => $e->getMessage()]);\n return $this->failure('An error occurred while processing the file with AI: ' . $e->getMessage());\n }\n }\n\n private function deepSeekModel(): string\n {\n return (string) config('services.deepseek.model', 'deepseek-chat');\n }\n\n /**\n * Remove or replace any non-UTF-8 / malformed byte sequences so json_encode never fails.\n */\n private function sanitizeUtf8(string $text): string\n {\n // Replace invalid byte sequences with the UTF-8 replacement character\n $clean = mb_convert_encoding($text, 'UTF-8', 'UTF-8');\n // Strip any remaining non-printable control characters (except tab/newline/CR)\n $clean = (string) preg_replace('/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]/u', '', $clean);\n return $clean;\n }\n\n /**\n * Extract plain text from a Word docx file using ZipArchive (no external library needed).\n * Reads word/document.xml and strips XML tags, preserving paragraph and table structure.\n */\n private function extractDocxText(string $absPath): ?string\n {\n try {\n $zip = new \\ZipArchive();\n if ($zip->open($absPath) !== true) {\n return null;\n }\n\n $xml = $zip->getFromName('word/document.xml');\n $zip->close();\n\n if ($xml === false || $xml === '') {\n return null;\n }\n\n // Add newlines before paragraph/table-row/table-cell tags so structure is preserved\n $xml = preg_replace('/<w:p[ >]/', \"\\n<w:p>\", $xml) ?? $xml;\n $xml = preg_replace('/<w:tr[ >]/', \"\\n<w:tr>\", $xml) ?? $xml;\n $xml = preg_replace('/<\\/w:tc>/', \"\\t\", $xml) ?? $xml;\n\n $text = strip_tags($xml);\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n return trim($text) !== '' ? trim($text) : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: DOCX parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Extract plain text from a PDF using smalot/pdfparser (pure PHP, no binary required).\n */\n /**\n * The process memory limit in bytes, or 0 when unlimited.\n *\n * memory_limit is a shorthand string (\"128M\", \"1G\", \"-1\"), so it has to be\n * expanded before it can be compared against anything.\n */\n private function memoryLimitBytes(): int\n {\n $raw = trim((string) ini_get('memory_limit'));\n\n if ($raw === '' || $raw === '-1') {\n return 0;\n }\n\n $value = (int) $raw;\n\n return match (strtolower(substr($raw, -1))) {\n 'g' => $value * 1024 * 1024 * 1024,\n 'm' => $value * 1024 * 1024,\n 'k' => $value * 1024,\n default => $value,\n };\n }\n\n private function extractPdfText(string $absPath): ?string\n {\n // The parser loads and decompresses the whole document before any of our\n // chunking gets a look at it, and it does so with no memory ceiling of\n // its own. A big PDF exhausts the limit inside gzuncompress() — a fatal\n // error, not an exception, so the catch below cannot help and the worker\n // dies outright. Refuse the file first instead.\n $size = @filesize($absPath) ?: 0;\n\n if ($size > self::MAX_PDF_BYTES) {\n Log::warning('QuotationAiService: PDF too large to parse in-process.', [\n 'bytes' => $size,\n 'limit' => self::MAX_PDF_BYTES,\n ]);\n\n return null;\n }\n\n // Headroom check: parsing routinely needs several times the file size\n // once streams are decompressed. If that would not fit in what is left,\n // stop here rather than taking the worker down with us.\n $limit = $this->memoryLimitBytes();\n\n if ($limit > 0) {\n // Try to reclaim memory a previous job left behind before deciding\n // there is not enough. Without this, a worker sitting near its limit\n // after a big job could refuse a tiny PDF for no real reason.\n gc_collect_cycles();\n\n $available = $limit - memory_get_usage(true);\n\n if ($available < $size * self::PDF_MEMORY_FACTOR) {\n Log::warning('QuotationAiService: not enough memory left to parse this PDF.', [\n 'bytes' => $size,\n 'available' => $available,\n 'needed' => $size * self::PDF_MEMORY_FACTOR,\n ]);\n\n // A distinct sentinel, so the caller can tell \"no memory\" apart\n // from \"no text\" and show the right message instead of blaming\n // the file for being scanned.\n return self::PDF_LOW_MEMORY;\n }\n }\n\n try {\n $parser = new \\Smalot\\PdfParser\\Parser();\n $pdf = $parser->parseFile($absPath);\n\n // Recorded for the chunking decision: a long PDF is structurally a\n // big BOQ even when its extracted text is short.\n try {\n $this->lastPdfPageCount = count($pdf->getPages());\n } catch (\\Throwable) {\n $this->lastPdfPageCount = 0;\n }\n\n $text = $pdf->getText();\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n return $text !== '' ? $text : null;\n } catch (\\Throwable $e) {\n // Class, file and line, not just the message — a bare message left\n // the actual cause of a PDF failure guessable at best.\n Log::warning('QuotationAiService: PDF parsing failed.', [\n 'exception' => $e::class,\n 'error' => $e->getMessage(),\n 'at' => $e->getFile() . ':' . $e->getLine(),\n ]);\n\n // smalot/pdfparser throws \"Invalid object reference for $obj.\" on\n // some perfectly valid PDFs — a known bug in the library, not a\n // problem with the file. Fall back to the pdftotext binary, which\n // reads those documents fine.\n return $this->extractPdfTextViaBinary($absPath);\n }\n }\n\n /**\n * Extract PDF text through the OCR.space API.\n *\n * The fallback of last resort when smalot throws and pdftotext is not on the\n * server. OCR.space is a hosted reader: the file is uploaded and its text\n * comes back, so it needs nothing installed locally. The free tier caps\n * uploads at 1 MB and 3 pages — enough for a typical BOQ, and skipped with a\n * clear log line when the file is over that, rather than a wasted call.\n */\n private function extractPdfTextViaOcrSpace(string $absPath): ?string\n {\n $apiKey = (string) config('services.ocrspace.key', '');\n\n if ($apiKey === '') {\n return null;\n }\n\n // Free tier hard limit. Above it the call is rejected, so do not make it.\n $size = @filesize($absPath) ?: 0;\n if ($size > 1024 * 1024) {\n Log::warning('QuotationAiService: PDF too large for the OCR fallback.', [\n 'bytes' => $size,\n 'limit' => 1024 * 1024,\n ]);\n return null;\n }\n\n try {\n $response = Http::timeout(120)\n ->attach('file', (string) file_get_contents($absPath), basename($absPath))\n ->post('https://api.ocr.space/parse/image', [\n 'apikey' => $apiKey,\n 'filetype' => 'PDF',\n 'language' => 'eng',\n 'isTable' => 'true', // BOQs are tabular; keep columns\n 'OCREngine' => '2',\n 'scale' => 'true',\n ]);\n\n if (! $response->successful()) {\n Log::warning('QuotationAiService: OCR.space request failed.', [\n 'status' => $response->status(),\n ]);\n return null;\n }\n\n $json = $response->json();\n\n // OCR.space reports its own errors in the body with a 200 status.\n if (($json['IsErroredOnProcessing'] ?? false) === true) {\n Log::warning('QuotationAiService: OCR.space could not process the PDF.', [\n 'message' => $json['ErrorMessage'] ?? 'unknown',\n ]);\n return null;\n }\n\n $text = '';\n foreach ($json['ParsedResults'] ?? [] as $page) {\n $text .= ($page['ParsedText'] ?? '') . \"\\n\";\n }\n\n $text = trim((string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text));\n\n if ($text !== '') {\n Log::info('QuotationAiService: recovered PDF text via OCR.space.', [\n 'path' => basename($absPath),\n 'chars' => mb_strlen($text),\n ]);\n }\n\n return $text !== '' ? $text : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: OCR.space fallback threw.', [\n 'error' => $e->getMessage(),\n ]);\n return null;\n }\n }\n\n /**\n * Locate the pdftotext binary.\n *\n * On shared hosting it is rarely on the system PATH, so a user-installed\n * copy — dropped in ~/bin, ~/.local/bin, or the project's vendor/bin — has\n * to be found explicitly. An escaped, quoted absolute path is returned so it\n * is safe to interpolate into the command.\n *\n * PDFTOTEXT_PATH in .env wins, for a copy installed somewhere non-standard.\n */\n private function pdftotextPath(): ?string\n {\n $configured = trim((string) env('PDFTOTEXT_PATH', ''));\n\n $candidates = array_filter([\n $configured !== '' ? $configured : null,\n ($home = getenv('HOME')) ? $home . '/bin/pdftotext' : null,\n $home ? $home . '/.local/bin/pdftotext' : null,\n base_path('vendor/bin/pdftotext'),\n '/usr/bin/pdftotext',\n '/usr/local/bin/pdftotext',\n ]);\n\n foreach ($candidates as $path) {\n if (is_file($path) && is_executable($path)) {\n return escapeshellarg($path);\n }\n }\n\n // Fall back to a bare name, in case it is on PATH after all.\n $onPath = @shell_exec('command -v pdftotext 2>/dev/null');\n\n return is_string($onPath) && trim($onPath) !== '' ? 'pdftotext' : null;\n }\n\n private function extractPdfTextViaBinary(string $absPath): ?string\n {\n // shell_exec is commonly disabled on shared hosting; bail cleanly so the\n // caller falls through to the vision path or a clear error.\n if (! function_exists('shell_exec')\n || in_array('shell_exec', array_map('trim', explode(',', (string) ini_get('disable_functions'))), true)) {\n return null;\n }\n\n $binary = $this->pdftotextPath();\n\n if ($binary === null) {\n Log::warning('QuotationAiService: pdftotext binary not found.', [\n 'looked_in' => 'PATH, PDFTOTEXT_PATH, ~/bin, ~/.local/bin, vendor/bin',\n ]);\n return null;\n }\n\n // `-layout` keeps columns roughly aligned, which matters for a BOQ; the\n // trailing \"-\" writes to stdout instead of a file.\n $command = sprintf('%s -layout -enc UTF-8 %s - 2>/dev/null', $binary, escapeshellarg($absPath));\n\n $output = @shell_exec($command);\n\n if (! is_string($output) || trim($output) === '') {\n Log::warning('QuotationAiService: pdftotext fallback produced no text.', [\n 'path' => basename($absPath),\n ]);\n return null;\n }\n\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $output);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n\n if ($text !== '') {\n Log::info('QuotationAiService: recovered PDF text via pdftotext fallback.', [\n 'path' => basename($absPath),\n 'chars' => mb_strlen($text),\n ]);\n }\n\n return $text !== '' ? $text : null;\n }\n\n /**\n * Send a vision request to a vision-capable AI model.\n * DeepSeek's commercial API (api.deepseek.com) does NOT support image inputs — any model.\n * Priority order (first configured key wins):\n * 1. GROQ_API_KEY → Groq llama-4-scout (free: https://console.groq.com)\n * 2. GEMINI_API_KEY → Google Gemini Flash (free: https://aistudio.google.com/apikey)\n * 3. VISION_API_KEY → any OpenAI-compatible endpoint (OpenAI, OpenRouter …)\n */\n private function callDeepSeekVision(string $bytes, string $mime, array $context): array\n {\n // ── 1. Groq (free, reliable vision) ─────────────────────────────────────\n if ((string) config('services.groq.key', '') !== '') {\n $visionKey = (string) config('services.groq.key');\n $visionBaseUrl = 'https://api.groq.com/openai/v1';\n $visionModel = (string) config('services.groq.model', 'meta-llama/llama-4-scout-17b-16e-instruct');\n }\n // ── 2. Google Gemini ─────────────────────────────────────────────────────\n elseif ((string) config('services.gemini.key', '') !== '') {\n $visionKey = (string) config('services.gemini.key');\n $visionBaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai';\n $visionModel = (string) config('services.gemini.model', 'gemini-2.0-flash');\n }\n // ── 3. Generic VISION_API_KEY (OpenAI, OpenRouter, …) ───────────────────\n elseif ((string) config('services.vision.key', '') !== '') {\n $visionKey = (string) config('services.vision.key');\n $visionBaseUrl = rtrim((string) config('services.vision.base_url', 'https://openrouter.ai/api/v1'), '/');\n $visionModel = (string) config('services.vision.model', 'google/gemini-flash-1.5-8b');\n }\n else {\n return $this->failure(\n 'Image processing is not available. DeepSeek\\'s API does not support images. ' .\n 'Get a free key at https://console.groq.com and add GROQ_API_KEY to your .env file.'\n );\n }\n\n $dataUrl = \"data:{$mime};base64,\" . base64_encode($bytes);\n $prompt = $this->buildDeepSeekPrompt($context, $mime);\n\n $userContent = [\n ['type' => 'text', 'text' => $prompt],\n ['type' => 'image_url', 'image_url' => ['url' => $dataUrl]],\n ];\n\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders([\n 'Authorization' => 'Bearer ' . $visionKey,\n 'HTTP-Referer' => config('app.url', 'https://qimta.sa'),\n 'X-Title' => 'Qimta Platform',\n ])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post($visionBaseUrl . '/chat/completions', [\n 'model' => $visionModel,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 16384,\n 'temperature' => 0,\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('Vision API connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: Vision API HTTP error.', [\n 'model' => $visionModel,\n 'base_url' => $visionBaseUrl,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n return $this->failure(\"Vision API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n return $this->parseDeepSeekJsonResponse((string) $response->json('choices.0.message.content'));\n }\n\n private function callDeepSeekChat(string $userContent, string $key, string $model): array\n {\n // Ensure the PHP script outlives the HTTP call (+ a margin for parsing the\n // response and writing to the DB) so the worker/web request is not killed\n // mid-flight, which surfaces to users as a generic \"timeout or unavailable\".\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders(['Authorization' => 'Bearer ' . $key])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 32768,\n 'temperature' => 0,\n 'response_format' => ['type' => 'json_object'],\n 'user' => 'Qimta_Platform',\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('DeepSeek connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: DeepSeek HTTP error.', [\n 'model' => $model,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n if ($response->status() === 401) {\n return $this->failure('DeepSeek API key is invalid or unauthorised. Please update DEEPSEEK_API_KEY in .env.', false);\n }\n return $this->failure(\"DeepSeek API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n $text = (string) $response->json('choices.0.message.content');\n return $this->parseDeepSeekJsonResponse($text);\n }\n\n /**\n * Parse a document too large for one AI call by sending it in slices.\n *\n * A 20 000-row BOQ is several megabytes of text — far beyond what a single\n * request can carry, and beyond what the model can answer without hitting its\n * output token limit. Previously the text was simply cut at 180 000 chars and\n * the remainder discarded: the parse \"succeeded\" while silently dropping most\n * of the file. Now the text is split and each slice parsed on its own call,\n * with the items merged.\n *\n * Splitting happens on line boundaries so a BOQ row is never cut in half.\n * Slices run sequentially rather than pooled: DeepSeek rate-limits hard on\n * concurrent large prompts, and this already runs on the queue where wall\n * time is cheap.\n *\n * A slice that fails does not fail the document — its rows are lost, but the\n * rest are kept and the shortfall is logged. Total failure is reported only\n * when every slice fails.\n *\n * @param string $label How the payload is introduced to the model.\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n /**\n * Split a spreadsheet into the slices the AI will be asked to read.\n *\n * Exposed so the work can be spread over several queue jobs instead of one\n * job walking every slice in sequence. Returns an empty array when the file\n * is small enough to go in a single call, or cannot be read.\n *\n * @return array<int, string>\n */\n public function chunkSpreadsheet(string $absPath): array\n {\n ini_set('memory_limit', '2048M');\n set_time_limit(1800);\n\n $text = $this->spreadsheetToCompactCsvText($absPath);\n\n if ($text === null || trim($text) === '') {\n return [];\n }\n\n $text = $this->sanitizeUtf8($text);\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) <= self::TEXT_CHUNK_CHARS && $sheetCount <= self::MAX_SHEETS_PER_CALL) {\n return [];\n }\n\n $chunks = $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n\n // Free the source text: the slices are a second full copy of it, and on\n // a large workbook that doubling — on top of PhpSpreadsheet's grids —\n // is what exhausted memory here.\n unset($text);\n\n Log::info('QuotationAiService: chunked spreadsheet.', [\n 'chunks' => count($chunks),\n 'peak_mb' => round(memory_get_peak_usage(true) / 1048576),\n ]);\n\n return $chunks;\n }\n\n /**\n * Split a spreadsheet straight to disk, one file per slice.\n *\n * chunkSpreadsheet() returns every slice as an array, which is a second\n * full copy of the document in memory on top of PhpSpreadsheet's grids.\n * This hands each slice to $write and drops it immediately, so peak memory\n * is one slice rather than the whole file twice.\n *\n * @param callable(int, string): void $write ($part, $chunk)\n * @return int number of slices written; 0 when the file needs no split\n */\n public function chunkSpreadsheetToDisk(string $absPath, callable $write): int\n {\n ini_set('memory_limit', '2048M');\n set_time_limit(1800);\n\n $text = $this->spreadsheetToCompactCsvText($absPath);\n\n if ($text === null || trim($text) === '') {\n return 0;\n }\n\n $text = $this->sanitizeUtf8($text);\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) <= self::TEXT_CHUNK_CHARS && $sheetCount <= self::MAX_SHEETS_PER_CALL) {\n return 0;\n }\n\n $part = 0;\n foreach ($this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS) as $chunk) {\n $write(++$part, $chunk);\n }\n\n unset($text);\n\n Log::info('QuotationAiService: chunked spreadsheet to disk.', [\n 'chunks' => $part,\n 'peak_mb' => round(memory_get_peak_usage(true) / 1048576),\n ]);\n\n return $part;\n }\n\n /**\n * Parse one slice produced by chunkSpreadsheet().\n *\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n public function parseChunk(string $chunk, int $part, int $total, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured.');\n }\n\n // Same framing parseTextInChunks() uses, so a slice is never mistaken\n // for a complete BOQ and totals are not inferred from a fragment.\n $userContent = \"BOQ spreadsheet converted to CSV (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, 'text/plain');\n\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n private function parseTextInChunks(\n string $text,\n string $label,\n string $apiKey,\n array $context,\n string $mime = 'text/plain',\n ): array {\n $chunks = $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n $total = count($chunks);\n\n Log::info('QuotationAiService: large document split for parsing.', [\n 'chars' => mb_strlen($text),\n 'chunks' => $total,\n ]);\n\n // Announce the split before the first slice is sent, so the UI can show\n // how many parts there are instead of sitting on a generic message for\n // however long the first AI call takes.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)(0, $total);\n }\n\n $items = [];\n $rejected = [];\n $failed = 0;\n $lastError = null;\n\n foreach ($chunks as $index => $chunk) {\n $part = $index + 1;\n\n // Report progress so a 28-slice parse does not look frozen to the user.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)($part, $total);\n }\n\n // Tell the model this is a fragment, so it does not try to reconcile\n // totals or treat a mid-document slice as a complete BOQ.\n $userContent = \"{$label} (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, $mime);\n\n $result = $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n\n if (! ($result['success'] ?? false)) {\n $failed++;\n $lastError = $result['error'] ?? 'Chunk parsing failed.';\n Log::warning('QuotationAiService: chunk failed, continuing.', [\n 'part' => $part,\n 'of' => $total,\n 'error' => $lastError,\n ]);\n continue;\n }\n\n foreach ($result['items'] as $item) {\n $items[] = $item;\n }\n foreach ($result['rejected'] ?? [] as $row) {\n $rejected[] = $row;\n }\n\n // Hand this slice's rows straight to the caller so they can be shown\n // while the remaining slices are still being parsed.\n if ($this->onChunkItems !== null && $result['items'] !== []) {\n ($this->onChunkItems)($result['items'], $part, $total);\n }\n }\n\n // Every slice failed — there is nothing to show, so report the failure.\n if ($failed === $total) {\n return $this->failure($lastError ?? 'Could not parse the document.');\n }\n\n if ($failed > 0) {\n Log::error('QuotationAiService: document parsed with missing parts.', [\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n 'items_kept' => count($items),\n ]);\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'service_unavailable' => false,\n 'partial' => $failed > 0,\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n ];\n }\n\n /**\n * Split text into slices of at most $limit characters, breaking only at\n * newlines so no BOQ row is ever cut in half.\n *\n * A single line longer than the limit (rare — a pathological cell) is passed\n * through oversized rather than severed mid-row.\n *\n * @return array<int, string>\n */\n private function splitOnLineBoundaries(string $text, int $limit): array\n {\n $chunks = [];\n $current = '';\n\n // Sheet a chunk starts in the middle of. Repeated at the top of the next\n // chunk so the model always knows which sheet the rows belong to — the\n // header would otherwise be stranded in the previous slice, and rows\n // would arrive with no discipline context.\n $currentSheet = null;\n\n foreach (explode(\"\\n\", $text) as $line) {\n $isSheetHeader = str_starts_with($line, 'Sheet: ');\n\n // Start a new chunk at every sheet boundary. Sheets are usually\n // separate disciplines, and the whole point of splitting a multi-tab\n // workbook is to give the model one discipline at a time — a size-only\n // split would leave a compact 3-tab file as a single chunk, silently\n // undoing the sheet gate that routed it here.\n if ($isSheetHeader && $current !== '') {\n $chunks[] = $current;\n $current = $line;\n $currentSheet = $line;\n continue;\n }\n\n if ($isSheetHeader) {\n $currentSheet = $line;\n }\n\n $candidate = $current === '' ? $line : $current . \"\\n\" . $line;\n\n if (mb_strlen($candidate) > $limit && $current !== '') {\n $chunks[] = $current;\n\n // Carry the sheet header over, unless this line is itself one.\n $current = ($currentSheet !== null && ! str_starts_with($line, 'Sheet: '))\n ? $currentSheet . \" (continued)\\n\" . $line\n : $line;\n continue;\n }\n\n $current = $candidate;\n }\n\n if (trim($current) !== '') {\n $chunks[] = $current;\n }\n\n return $chunks;\n }\n\n /**\n * Shared JSON response parser used by both chat and vision paths.\n */\n private function parseDeepSeekJsonResponse(string $text): array\n {\n if ($text === '') {\n return $this->failure('DeepSeek returned an empty response.');\n }\n\n // Strip markdown code fences if the model wrapped the JSON.\n if (preg_match('/```(?:json)?\\s*([\\s\\S]*?)\\s*```/i', $text, $m)) {\n $text = $m[1];\n }\n // Narrow to the outermost JSON object/array.\n if (preg_match('/[\\{\\[][\\s\\S]*[\\}\\]]/s', $text, $m)) {\n $text = $m[0];\n }\n $text = trim($text);\n\n $decoded = json_decode($text, true);\n\n // The response can be truncated when the model hits the token limit,\n // leaving an unterminated JSON string/object. Try to repair it before\n // giving up so we can still salvage the items extracted so far.\n if (! is_array($decoded)) {\n $repaired = $this->repairTruncatedJson($text);\n if ($repaired !== null) {\n $decoded = json_decode($repaired, true);\n }\n }\n\n if (! is_array($decoded)) {\n Log::error('QuotationAiService: DeepSeek non-JSON response.', ['text' => mb_substr($text, 0, 1000)]);\n return $this->failure('DeepSeek returned a non-JSON response. The file may be too large — try splitting it into smaller files.');\n }\n\n $rawItems = $decoded['items'] ?? (array_is_list($decoded) ? $decoded : []);\n $items = [];\n $rejected = [];\n\n foreach ($rawItems as $raw) {\n if (! is_array($raw)) {\n continue;\n }\n $item = $this->normaliseAiItem($raw);\n if ($item === null) {\n continue;\n }\n $check = $this->boqCleaner->filterItem((string) $item['description']);\n if ($check['keep'] ?? false) {\n $items[] = $item;\n } else {\n $item['status'] = 'rejected';\n $item['raw_data']['rejection_reason'] = $check['rejection_reason'] ?? 'Rejected by cleaner';\n $rejected[] = $item;\n }\n }\n\n if (empty($items)) {\n Log::info('QuotationAiService: DeepSeek returned 0 accepted items.', [\n 'raw_items_count' => count($rawItems),\n 'rejected_count' => count($rejected),\n 'first_rejected' => ! empty($rejected) ? ($rejected[0]['description'] ?? '') : '',\n ]);\n }\n\n return ['success' => true, 'items' => $items, 'rejected' => $rejected, 'error' => null];\n }\n\n /**\n * Best-effort repair of a truncated JSON string produced when the model hits\n * its token limit mid-output. Drops any incomplete trailing object, then\n * closes the open string/array/object brackets so the result is valid JSON.\n *\n * Returns the repaired JSON string, or null if it cannot be salvaged.\n */\n private function repairTruncatedJson(string $text): ?string\n {\n if ($text === '') {\n return null;\n }\n\n // Walk the string tracking structural state, ignoring everything inside\n // string literals (and their escapes). Remember the last position where\n // we were back at the top of the \"items\" array between complete elements,\n // so we can cut a half-written trailing object.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($text);\n $lastSafeCut = null; // index just after a completed array element\n\n for ($i = 0; $i < $len; $i++) {\n $ch = $text[$i];\n\n if ($inString) {\n if ($escaped) {\n $escaped = false;\n } elseif ($ch === '\\\\') {\n $escaped = true;\n } elseif ($ch === '\"') {\n $inString = false;\n }\n continue;\n }\n\n if ($ch === '\"') {\n $inString = true;\n } elseif ($ch === '{' || $ch === '[') {\n $stack[] = $ch;\n } elseif ($ch === '}' || $ch === ']') {\n array_pop($stack);\n } elseif ($ch === ',' && ! empty($stack) && end($stack) === '[') {\n // Separator directly inside an array: a clean boundary between\n // elements (the items array, whatever its nesting depth).\n $lastSafeCut = $i;\n }\n }\n\n // If we are still inside a string, the last element is incomplete — fall\n // back to the last clean array boundary if we have one.\n $candidate = $text;\n if ($inString && $lastSafeCut !== null) {\n $candidate = substr($text, 0, $lastSafeCut);\n } else {\n // Drop a dangling partial object after the last completed element.\n $candidate = rtrim($candidate);\n if ($lastSafeCut !== null && substr(rtrim($candidate), -1) !== '}' && substr(rtrim($candidate), -1) !== ']') {\n $candidate = substr($text, 0, $lastSafeCut);\n }\n }\n\n // Recompute the open brackets for the (possibly trimmed) candidate and\n // append the matching closers in reverse order.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($candidate);\n for ($i = 0; $i < $len; $i++) {\n $ch = $candidate[$i];\n if ($inString) {\n if ($escaped) { $escaped = false; }\n elseif ($ch === '\\\\') { $escaped = true; }\n elseif ($ch === '\"') { $inString = false; }\n continue;\n }\n if ($ch === '\"') { $inString = true; }\n elseif ($ch === '{' || $ch === '[') { $stack[] = $ch; }\n elseif ($ch === '}' || $ch === ']') { array_pop($stack); }\n }\n\n $candidate = rtrim($candidate);\n // Remove a trailing comma left after cutting an element.\n $candidate = rtrim($candidate, ',');\n\n $closers = '';\n for ($i = count($stack) - 1; $i >= 0; $i--) {\n $closers .= ($stack[$i] === '{') ? '}' : ']';\n }\n\n $repaired = $candidate . $closers;\n\n // Validate before returning.\n return is_array(json_decode($repaired, true)) ? $repaired : null;\n }\n\n private function spreadsheetToCompactCsvText(string $absPath): ?string\n {\n $sheets = $this->spreadsheetToGrids($absPath);\n if (empty($sheets)) {\n return null;\n }\n\n $output = '';\n $kept = 0;\n $dropped = 0;\n $nonSupply = 0;\n $duplicates = 0;\n\n /** @var array<string, true> Fingerprints of rows already emitted. */\n $seenRows = [];\n\n foreach ($sheets as $sheet) {\n // Don't feed summary / cost-abstract sheets to the AI either.\n if ($this->isSummarySheet($sheet['name'])) {\n continue;\n }\n $sheetRows = '';\n foreach ($sheet['grid'] as $row) {\n $row = $this->trimTrailingEmptyCells($row);\n\n if ($this->isNoiseRow($row)) {\n $dropped++;\n continue;\n }\n\n // Drop rows that are definitely not procurable before they cost\n // a single token: section totals, discipline headings,\n // preliminaries, labour-only lines. The same classifier already\n // runs on the AI's output, but by then the tokens are spent — on\n // an 11 200-row BOQ that is most of the bill.\n if ($this->isNonSupplyRow($row)) {\n $dropped++;\n $nonSupply++;\n continue;\n }\n\n // Byte-identical rows tell the AI nothing new. Only exact\n // duplicates are collapsed — description, quantity, unit and all\n // — so nothing is lost: two rows differing in quantity are two\n // different line items and both are sent.\n $fingerprint = $this->rowFingerprint($row);\n\n if (isset($seenRows[$fingerprint])) {\n $duplicates++;\n $dropped++;\n continue;\n }\n $seenRows[$fingerprint] = true;\n\n $sheetRows .= implode(',', array_map([$this, 'csvEscape'], $this->stripBoilerplate($row))) . \"\\n\";\n $kept++;\n }\n\n // A sheet whose rows were all noise contributes nothing but its own\n // header, which would just be another thing for the model to explain.\n if ($sheetRows === '') {\n continue;\n }\n\n $output .= 'Sheet: ' . $sheet['name'] . \"\\n\" . $sheetRows . \"\\n\";\n }\n\n $blank = $dropped - $nonSupply - $duplicates;\n\n Log::info('QuotationAiService: filtered rows before sending to AI.', [\n 'kept' => $kept,\n 'blank' => $blank,\n 'non_supply' => $nonSupply,\n 'duplicates' => $duplicates,\n ]);\n\n if ($dropped > 0) {\n $parts = [];\n if ($blank > 0) { $parts[] = \"{$blank} blank\"; }\n if ($nonSupply > 0) { $parts[] = \"{$nonSupply} non-supply\"; }\n if ($duplicates > 0) { $parts[] = \"{$duplicates} duplicate\"; }\n\n $this->reportStage('Skipped ' . implode(', ', $parts) . \" rows. Sending {$kept}…\");\n }\n\n return $output;\n }\n\n /**\n * True when a row carries no information worth sending to the AI.\n *\n * A real BOQ is mostly formatting: spacer rows, stray borders, a lone\n * section number, a leftover cell from a merged block. They survive\n * trimTrailingEmptyCells() because they are not *entirely* empty, then cost\n * tokens, dilute the prompt, and inflate the number of parts a large file is\n * split into.\n *\n * Deliberately conservative — a row is dropped only when nothing in it could\n * name a product. Anything with letters is kept, because a description is\n * the one field an item cannot do without.\n *\n * @param array<int, mixed> $row\n */\n /**\n * True when a row is definitely not a procurable supply item.\n *\n * Runs BoqCleaningService over the row's longest text cell — the one most\n * likely to be the description — so headings, totals, preliminaries and\n * labour-only lines never reach the AI. The same classifier already vets the\n * AI's *output*; doing it here as well means we stop paying to have those\n * rows read in the first place.\n *\n * Conservative by construction: only rows the classifier positively rejects\n * are dropped. Anything it is unsure about goes to the AI, which is the\n * better judge — a wrongly dropped row is invisible, a wrongly kept one only\n * costs tokens.\n *\n * @param array<int, mixed> $row\n */\n /**\n * Stable identity for a row, used to collapse exact duplicates.\n *\n * Whitespace and case are normalised so \"DN50 PIPE\" and \"dn50 pipe \" are one\n * row, but nothing else is: a differing quantity or unit produces a\n * different fingerprint and both rows are sent.\n *\n * @param array<int, mixed> $row\n */\n private function rowFingerprint(array $row): string\n {\n $parts = array_map(\n fn($cell) => mb_strtolower(preg_replace('/\\s+/u', ' ', trim((string) $cell)) ?? ''),\n $row,\n );\n\n // Drop a leading BOQ sequence number. Otherwise \"1,Pipe,m,100\" and\n // \"2,Pipe,m,100\" fingerprint differently and the dedup never fires —\n // which is exactly the shape every real BOQ has.\n if (isset($parts[0]) && $parts[0] !== '' && preg_match('/^[\\d.]+$/', $parts[0])) {\n array_shift($parts);\n }\n\n return md5(implode('|', $parts));\n }\n\n /**\n * Strip contractual boilerplate from a row's description cell.\n *\n * \"as per drawings\", \"complete with all necessary accessories\" and friends\n * repeat on nearly every line of a real BOQ and carry no information the\n * pricing engine can use — but they are a large share of the tokens.\n *\n * Only the longest text cell is touched, and only when the result is still a\n * usable description: if stripping would leave too little to identify the\n * product, the original is kept. A shorter-but-wrong description is far\n * worse than a verbose one.\n *\n * @param array<int, mixed> $row\n * @return array<int, mixed>\n */\n private function stripBoilerplate(array $row): array\n {\n $index = null;\n $longest = '';\n\n foreach ($row as $i => $cell) {\n $text = trim((string) $cell);\n if (preg_match('/\\p{L}/u', $text) && mb_strlen($text) > mb_strlen($longest)) {\n $longest = $text;\n $index = $i;\n }\n }\n\n // Nothing worth cleaning, or too short to risk it.\n if ($index === null || mb_strlen($longest) < 40) {\n return $row;\n }\n\n try {\n $cleaned = $this->boqCleaner->cleanDescription($longest);\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: boilerplate strip failed, keeping row as-is.', [\n 'error' => $e->getMessage(),\n ]);\n return $row;\n }\n\n // Guard against an over-eager pattern eating the product name: keep the\n // original unless a recognisable description survives.\n if (mb_strlen($cleaned) < 12 || mb_strlen($cleaned) < mb_strlen($longest) * 0.3) {\n return $row;\n }\n\n $row[$index] = $cleaned;\n\n return $row;\n }\n\n private function isNonSupplyRow(array $row): bool\n {\n $description = '';\n foreach ($row as $cell) {\n $text = trim((string) $cell);\n // Longest cell containing letters — numbers are qty/rate columns.\n if (preg_match('/\\p{L}/u', $text) && mb_strlen($text) > mb_strlen($description)) {\n $description = $text;\n }\n }\n\n // No text at all: leave it to isNoiseRow()/the AI rather than guessing.\n if ($description === '' || mb_strlen($description) < 3) {\n return false;\n }\n\n try {\n $verdict = $this->boqCleaner->filterItem($description);\n } catch (\\Throwable $e) {\n // A classifier failure must never silently drop a real product.\n Log::warning('QuotationAiService: pre-filter classifier failed, keeping row.', [\n 'error' => $e->getMessage(),\n ]);\n return false;\n }\n\n return ! ($verdict['keep'] ?? true);\n }\n\n private function isNoiseRow(array $row): bool\n {\n if (empty($row)) {\n return true;\n }\n\n $joined = trim(implode('', array_map(fn($c) => trim((string) $c), $row)));\n\n // Nothing but separators/whitespace.\n if ($joined === '') {\n return true;\n }\n\n // Ruled lines and decorative fills (\"-----\", \"====\", \"___\").\n if (preg_match('/^[\\-_=.*\\s|]+$/u', $joined)) {\n return true;\n }\n\n // Any letter — Latin or Arabic — means this could be a description.\n if (preg_match('/\\p{L}/u', $joined)) {\n return false;\n }\n\n // No letters at all: numbers only. A single number is a stray row\n // number or a dangling total; two or more could be qty/rate/amount\n // belonging to a real line, so keep those for the AI to judge.\n $numericCells = 0;\n foreach ($row as $cell) {\n if (trim((string) $cell) !== '') {\n $numericCells++;\n }\n }\n\n return $numericCells <= 1;\n }\n\n /**\n * Safely convert a PhpSpreadsheet cell value to a string.\n *\n * Cells may contain RichText or, in malformed/heavily-formatted files,\n * embedded objects such as Worksheet\\Drawing that have no __toString().\n * Casting those directly throws \"Object ... could not be converted to string\".\n */\n private function cellValueToString(mixed $value): string\n {\n if ($value === null) {\n return '';\n }\n\n if ($value instanceof \\PhpOffice\\PhpSpreadsheet\\RichText\\RichText) {\n return $value->getPlainText();\n }\n\n if (is_object($value)) {\n // Only stringify objects that explicitly support it; ignore the rest\n // (e.g. Worksheet\\Drawing) instead of crashing.\n return method_exists($value, '__toString') ? (string) $value : '';\n }\n\n if (is_array($value)) {\n return '';\n }\n\n return (string) $value;\n }\n\n private function csvEscape(mixed $value): string\n {\n $value = $this->cellValueToString($value);\n return str_contains($value, ',') || str_contains($value, '\"') || str_contains($value, \"\\n\")\n ? '\"' . str_replace('\"', '\"\"', $value) . '\"'\n : $value;\n }\n\n private function verifyLargeBoqItemsWithAi(array $result): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n Log::warning('QuotationAiService: large BOQ verification skipped, AI key is not configured.');\n return $result;\n }\n\n $items = array_values(array_filter($result['items'] ?? [], 'is_array'));\n if (count($items) <= self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n return $result;\n }\n\n Log::info('QuotationAiService: starting second-pass physical item verification.', [\n 'items_count' => count($items),\n 'threshold' => self::LARGE_BOQ_VERIFICATION_THRESHOLD,\n ]);\n\n $verifiedItems = [];\n $rejectedItems = $result['rejected'] ?? [];\n\n foreach (array_chunk($items, self::LARGE_BOQ_VERIFICATION_CHUNK_SIZE) as $chunkIndex => $chunk) {\n $payload = array_map(fn(array $item) => [\n 'description' => (string) ($item['description'] ?? ''),\n 'quantity' => $item['quantity'] ?? null,\n 'unit' => (string) ($item['unit'] ?? ''),\n 'unit_price' => $item['unit_price'] ?? null,\n 'category' => (string) ($item['category'] ?? ''),\n 'brand' => (string) ($item['brand'] ?? ''),\n ], $chunk);\n\n $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);\n if (! is_string($json)) {\n Log::warning('QuotationAiService: large BOQ verification chunk JSON encode failed.', [\n 'chunk' => $chunkIndex + 1,\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n $prompt = <<<PROMPT\nYou are doing a SECOND PASS verification on a BOQ extraction result.\n\nThe first pass may hallucinate non-products when the BOQ has thousands of rows.\nYour job is to keep ONLY physical, tangible, procurable supply products.\n\nRules:\n- Keep items only if a supplier can actually deliver them as materials/equipment.\n- Reject headings, trade names, systems, scopes, prose, specifications-only rows, totals, labor, installation, testing, commissioning, supervision, drawings, insurance, preliminaries, mobilisation, site works, and vague fragments.\n- Do not invent new products.\n- Do not add items that are not in the input.\n- You may clean a product description, but keep the original quantity, unit, unit_price, category, and brand when available.\n\nInput JSON array:\n{$json}\n\nReturn ONLY valid JSON in this shape:\n{\"items\":[{\"description\":\"\",\"quantity\":1,\"unit\":\"\",\"unit_price\":null,\"category\":\"\",\"brand\":\"\",\"confidence\":0.95}]}\nPROMPT;\n\n try {\n $chunkResult = $this->callDeepSeekChat($prompt, $apiKey, $this->deepSeekModel());\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: large BOQ verification chunk threw an exception.', [\n 'chunk' => $chunkIndex + 1,\n 'message' => $e->getMessage(),\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n if ($chunkResult['success'] ?? false) {\n array_push($verifiedItems, ...($chunkResult['items'] ?? []));\n $rejectedItems = array_merge($rejectedItems, $chunkResult['rejected'] ?? []);\n continue;\n }\n\n Log::warning('QuotationAiService: large BOQ verification chunk failed, keeping original chunk.', [\n 'chunk' => $chunkIndex + 1,\n 'error' => $chunkResult['error'] ?? null,\n ]);\n array_push($verifiedItems, ...$chunk);\n }\n\n if (empty($verifiedItems)) {\n Log::warning('QuotationAiService: second-pass verification returned no items, keeping first-pass result.', [\n 'original_items_count' => count($items),\n ]);\n return $result;\n }\n\n $result['items'] = $verifiedItems;\n $result['rejected'] = $rejectedItems;\n $result['large_boq_verified'] = true;\n\n Log::info('QuotationAiService: second-pass physical item verification completed.', [\n 'before' => count($items),\n 'after' => count($verifiedItems),\n ]);\n\n return $result;\n }\n\n private function normaliseAiItem(array $raw): ?array\n {\n if (! empty($raw['rejected'])) {\n return null;\n }\n\n $description = trim((string) ($raw['cleaned_description'] ?? $raw['product_name'] ?? $raw['description'] ?? ''));\n if ($description === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $description)) {\n return null;\n }\n\n $quantity = is_numeric($raw['quantity'] ?? null) ? (float) $raw['quantity'] : 1;\n $unitPrice = is_numeric($raw['unit_price'] ?? null) ? (float) $raw['unit_price'] : null;\n $totalPrice = is_numeric($raw['total_price'] ?? null) ? (float) $raw['total_price'] : null;\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $specs = is_array($raw['specifications'] ?? null) ? $raw['specifications'] : [];\n\n return [\n 'description' => $description,\n 'quantity' => $quantity,\n 'unit' => (string) ($raw['unit'] ?? ''),\n 'category' => (string) ($raw['core_product'] ?? $raw['category'] ?? ''),\n 'brand' => (string) ($specs['brand'] ?? $raw['brand'] ?? ''),\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($description),\n 'unit_price' => $unitPrice,\n 'confidence' => is_numeric($raw['confidence'] ?? null) ? (float) $raw['confidence'] : null,\n 'needs_review' => ! empty($raw['needs_review']),\n 'raw_data' => [\n 'source_item_no' => (string) ($raw['source_item_no'] ?? ''),\n 'original_description' => (string) ($raw['original_description'] ?? $description),\n 'cleaned_description' => $description,\n 'core_product' => (string) ($raw['core_product'] ?? ''),\n 'specifications' => $specs,\n 'extraction_type' => (string) ($raw['extraction_type'] ?? 'supply_only'),\n 'note' => (string) ($raw['note'] ?? $raw['notes'] ?? ''),\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function buildSystemInstruction(): string\n {\n return <<<'TXT'\nYou are a strict BOQ Supply Product Extraction Engine.\nExtract ONLY concrete, tangible, physical products that a supplier can box up and deliver to site — a specific item you could point at and say \"this is the thing being bought\" (e.g. \"Wall mounted LED luminaire 36W\", \"PVC pipe 110mm\", \"Single phase distribution board 12-way\", \"Fire extinguisher CO2 6kg\", \"CCTV dome camera 4MP\").\n\nABSOLUTELY REJECT abstract concepts, disciplines, categories, section names, and trade headings. These are NOT products even though they appear as rows:\n- Bare discipline/trade words: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", \"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\".\n- Summary / total / abstract / \"dry cost\" rows of any kind.\n- Anything that names a system, scope, or category instead of one buyable item.\n\nRule of thumb: if you cannot name a unit and physically hand the item over (a piece, a box, a length, a unit of equipment), REJECT it. A word that describes a field of work, a system, or a section of the BOQ is NEVER a product.\nReject totals, headings, general requirements, installation-only, testing, commissioning, supervision, mobilization, civil works, labor, and vague rows.\nFor Supply & Install rows, keep only the supply product and remove install/labor wording.\nReturn only valid JSON: {\"items\":[{\"source_item_no\":null,\"original_description\":\"\",\"cleaned_description\":\"\",\"core_product\":\"\",\"specifications\":{},\"quantity\":0,\"unit\":\"\",\"unit_price\":0,\"total_price\":0,\"extraction_type\":\"supply_only\",\"needs_review\":false,\"note\":\"\",\"rejected\":false,\"rejection_reason\":null,\"confidence\":0.95}]}\nTXT;\n }\n\n private function buildDeepSeekPrompt(array $context = [], string $mime = ''): string\n {\n $projectName = (string) ($context['project_name'] ?? '');\n $projectStatus = (string) ($context['project_status'] ?? '');\n $contextLine = $projectName !== ''\n ? \"Project: {$projectName}\" . ($projectStatus !== '' ? \" ({$projectStatus})\" : '')\n : '';\n $sourceHint = str_starts_with($mime, 'image/')\n ? 'The input is an image of a BOQ table. OCR every cell carefully — read the quantity/count column (العدد, الكمية, Qty) and extract the EXACT numeric value from each row.'\n : 'The input is a BOQ document/spreadsheet.';\n\n return trim(<<<PROMPT\nYou are a procurement specialist. Your task is to extract ONLY tangible, physical supply products from this Bill of Quantities (BOQ).\n\n{$contextLine}\n{$sourceHint}\n\nCRITICAL — MERGE WRAPPED / SPLIT ROWS INTO ONE ITEM:\nBOQ tables extracted from PDF often break ONE item's long description across several\nlines/rows. These continuation lines repeat the SAME quantity, unit and category, and\ntheir text is a grammatical continuation of the previous line (it starts mid-sentence,\nlowercase, or with \"the/and/-\", or is a spec/instruction clause). You MUST reconstruct\nthe original single item:\n- Treat consecutive rows that share the SAME quantity + unit + category as ONE product\n when the later rows are clearly continuation text of the first, NOT new products.\n- Emit ONE item with the merged description and the price counted ONCE. Do NOT repeat\n the same quantity×price for every wrapped line — that multiplies the total wrongly.\n- Example: rows \"The asphalt flooring is to be supplied\" / \"installed in accordance\" /\n \"standard road specifications...\" all with 7,100 m2 are ONE asphalt item, not 3+.\n\nCRITICAL — UNDERSTAND INTENT, DO NOT COPY ROWS LITERALLY:\nYour job is to output WHAT A SUPPLIER MUST ACTUALLY DELIVER, not a copy of the row text.\nRead each description for MEANING and decide how many real, separately-buyable products it\nimplies. One BOQ row can imply SEVERAL products, or a spread of rows can imply ONE product.\n- SPLIT a row into multiple items when it lists distinct products that a supplier would\n buy/price separately — even when joined by commas, \"with\", \"and\", \"including\", \"c/w\",\n \"consisting of\", \"comprising\", or \":\" then a list.\n Example: \"Fire pump set consisting of: fire pump, electric motor, control panel, diesel\n engine\" → FOUR items: (1) fire pump, (2) electric motor, (3) control panel, (4) diesel\n engine — each with its own quantity and priced on its own.\n Example: \"Wash basin complete with mixer tap and bottle trap\" → THREE items: wash basin,\n mixer tap, bottle trap.\n- Give each split product the correct quantity implied by the text (usually the parent\n quantity, unless the text states a different count for a sub-item). Each product is\n priced individually; the line total becomes the SUM of its products.\n- DO NOT split when the extra words are just specifications, dimensions, colours, ratings,\n standards, or a single product's integral parts. \"Steel door 900x2100 with vision panel\n and SS ironmongery\" is usually ONE door product — vision panel and ironmongery are part\n of that door, not separately-bought items. Use judgement: would a supplier issue a\n separate price line for it? If no, keep it as one.\n- Balanced approach: split only when genuinely separate products are present; when in doubt\n about whether something is a separate product vs. an accessory/spec of the main one, keep\n it as a single item. Do not invent products that are not in the text.\n\nSTRICT RULES — include ONLY items that are ALL of the following:\n1. A specific, concrete, tangible product that can be boxed up by a supplier and delivered to site — something you can point at and say \"this exact thing is being bought\" (e.g. \"LED luminaire 36W\", \"PVC pipe 110mm\", \"Distribution board 12-way\", \"CO2 fire extinguisher 6kg\").\n2. Has a real quantity (not 0 or \"varies\" with no meaning).\n3. Has enough description to identify what the product is.\n4. Is an actual PRODUCT, not a sentence, instruction, or specification clause. REJECT rows that are prose such as \"the supervising engineer's instructions\", \"The price includes all materials, labor\", \"Measurement will be based on the geometric cubic meter\", \"necessary steps to complete the work\", \"soil-contacting elements\", \"in compliance\", \"everything necessary\". These are description fragments, not buyable items.\n\nNEVER extract abstract concepts, disciplines, trades, systems, or section/category names — they are NOT products even when they appear as their own row:\n- Bare discipline/trade/system words such as: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", and their Arabic equivalents (\"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\", \"تشطيبات\").\n- Test: if the word names a FIELD OF WORK or a SYSTEM rather than one buyable item with a unit (pcs, m, set, kg…), REJECT it.\n\nEXCLUDE all of the following (do NOT include them):\n- Labor, installation, or workmanship items (e.g. \"install\", \"fix\", \"erect\", \"dismantle\", \"painting\", \"cleaning service\")\n- Project management, overhead, or profit items\n- Insurance, bonds, liability items (e.g. \"bodily injury\", \"third party liability\", \"CAR insurance\")\n- Shop drawings, as-built drawings, method statements, documentation\n- Preliminary items, mobilisation, demobilisation, provisional sums\n- Housekeeping, site clearance, waste removal\n- Floor/level labels (e.g. \"GF Floor\", \"Level 3\") — these are headers, not products\n- Pure dimension specs without a product noun (e.g. \"Dia 32 mm\", \"25 mm\") — reject unless a product name is present\n- Security services, night shifts, rentals, payments\n- Any item whose description is too vague or fragmentary to identify a specific product\n\nEmit ONE JSON item PER real product. A single source row may therefore produce several\nitems (when it lists multiple products) or several rows may collapse into one item (when\nthey are wrapped fragments of the same product). The \"items\" array is your final delivery\nlist, not a row-by-row transcript.\n\nFor each valid physical product return JSON with:\n- description: clean product name only (strip \"supply and install\", \"supply and fix\", installation clauses, floor/spec suffixes). For a split product, use just that product's own name.\n- quantity: the EXACT numeric value from the quantity/count column (العدد, الكمية, Qty, Quantity, No.). NEVER default to 1 unless the quantity column is truly absent or empty.\n- unit: unit of measure (pcs, m, m2, m3, set, no, kg, etc.)\n- category: product category\n- brand: brand/manufacturer if mentioned, else \"\"\n- engineering_required: true if the item needs engineering design before procurement (panel boards, chillers, generators, fire suppression systems, BMS, pumps, AHUs, switchgear, transformers), else false\n\nReturn ONLY a valid JSON object: {\"items\": [...]}. No explanations, no markdown, no extra text.\nPROMPT);\n }\n\n private function mockResponse(): array\n {\n return [\n 'success' => true,\n 'error' => null,\n 'items' => [[\n 'description' => 'Sample BOQ Item',\n 'quantity' => 1,\n 'unit' => 'No',\n 'category' => 'Sample',\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 1,\n 'raw_data' => [],\n 'ai_extracted' => true,\n ]],\n 'rejected' => [],\n ];\n }\n\n private function failure(string $message, bool $serviceUnavailable = false): array\n {\n return [\n 'success' => false,\n 'items' => [],\n 'rejected' => [],\n 'error' => $this->userFacingError($message, $serviceUnavailable),\n 'service_unavailable' => $serviceUnavailable,\n ];\n }\n\n /**\n * Translate any internal error into a neutral, user-safe message.\n *\n * The user must never see the name of the underlying AI/processing engine\n * (DeepSeek, Groq, Gemini, Vision, etc.) or configuration hints like API\n * keys. The detailed message is kept in the logs for debugging only.\n */\n private function userFacingError(string $message, bool $serviceUnavailable = false): string\n {\n // Detect any reference to an AI provider / engine / API config so we can\n // swap it for a generic, branded-neutral message.\n $sensitive = '/deepseek|groq|gemini|vision|openai|openrouter|api[\\s_-]?key|\\.env|non-?json|token limit/i';\n\n if (preg_match($sensitive, $message)) {\n Log::warning('QuotationAiService: suppressed technical error from user.', ['internal' => $message]);\n\n return $serviceUnavailable\n ? 'The file could not be processed right now. Please try again in a moment.'\n : 'We could not read this file automatically. Please make sure it is a clear BOQ (Excel, CSV, PDF, or image) and try again, or add the items manually.';\n }\n\n return $message;\n }\n\n private function mimeForExtension(string $ext): string\n {\n return match (strtolower($ext)) {\n 'pdf' => 'application/pdf',\n 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n 'xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12',\n 'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',\n 'xls' => 'application/vnd.ms-excel',\n 'csv' => 'text/csv',\n 'jpg', 'jpeg' => 'image/jpeg',\n 'png' => 'image/png',\n 'gif' => 'image/gif',\n 'bmp' => 'image/bmp',\n 'webp' => 'image/webp',\n 'tiff', 'tif' => 'image/tiff',\n 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'doc' => 'application/msword',\n default => 'application/octet-stream',\n };\n }\n}\n"}, "53d2a8cbc1f70002ebb7c26e22ace78a5f0a004c": {"t": 1785748352.2062125, "n": 95737, "h": "7223ddf7507e700d", "c": "<?php\n\nnamespace App\\Services;\n\nuse Illuminate\\Http\\Client\\ConnectionException;\nuse Illuminate\\Http\\UploadedFile;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\nuse PhpOffice\\PhpSpreadsheet\\Cell\\Coordinate;\nuse PhpOffice\\PhpSpreadsheet\\IOFactory;\n\nclass QuotationAiService\n{\n /** Increment this whenever BOQ parsing logic changes to invalidate old caches. */\n private const PARSER_VERSION = 10;\n private const LARGE_BOQ_VERIFICATION_THRESHOLD = 2000;\n private const LARGE_BOQ_VERIFICATION_CHUNK_SIZE = 120;\n\n /**\n * Characters of document text sent per AI call.\n *\n * Above this the document is split and parsed over several calls. Sized well\n * under the model's context so the prompt, the slice, and a full JSON answer\n * for every row in it all fit — the binding limit is the *output* budget, not\n * the input, since each input row produces a JSON object.\n */\n private const TEXT_CHUNK_CHARS = 60000;\n\n /**\n * A workbook with more sheets than this is split regardless of its size.\n *\n * Multiple tabs almost always mean multiple disciplines (electrical, HVAC,\n * civil…). Sending them in one call makes the model summarise across\n * sections and silently drop rows, so each slice gets a narrower scope.\n */\n private const MAX_SHEETS_PER_CALL = 2;\n\n /** A PDF longer than this is split regardless of its extracted text length. */\n private const MAX_PDF_PAGES_PER_CALL = 5;\n\n /**\n * Largest PDF we will hand to the parser (12 MB).\n *\n * smalot/pdfparser decompresses every stream into memory with no ceiling of\n * its own, and overrunning the limit there is a fatal error that kills the\n * worker rather than an exception we can recover from.\n */\n private const MAX_PDF_BYTES = 12582912;\n\n /**\n * Memory the parser is assumed to need, as a multiple of the file size.\n *\n * Decompressed content streams plus the object graph run well past the raw\n * bytes; 8x is conservative enough to catch the cases that were dying.\n */\n private const PDF_MEMORY_FACTOR = 8;\n\n /** Returned by extractPdfText when it stopped for memory, not for lack of text. */\n private const PDF_LOW_MEMORY = \"\\0__pdf_low_memory__\";\n\n private string $baseUrl;\n private string $parseEndpoint;\n private string $apiKey;\n private int $timeout;\n private bool $testMode;\n private BoqCleaningService $boqCleaner;\n\n /** Page count of the most recently parsed PDF; 0 when unknown. */\n private int $lastPdfPageCount = 0;\n\n /**\n * Optional progress reporter for chunked parsing, called as ($part, $total).\n *\n * Set by the queue job so the polling UI can show real progress on a large\n * BOQ instead of a spinner that appears frozen for many minutes.\n *\n * @var (callable(int, int): void)|null\n */\n private $onChunkProgress = null;\n\n /** @param callable(int, int): void $callback */\n public function onChunkProgress(callable $callback): self\n {\n $this->onChunkProgress = $callback;\n return $this;\n }\n\n /**\n * Optional per-chunk item sink, called as ($items, $part, $total).\n *\n * Lets the caller persist each slice's rows the moment they arrive instead\n * of waiting for the whole document. On a large BOQ this is the difference\n * between a table that fills in progressively and one that stays empty for\n * many minutes.\n *\n * @var (callable(array, int, int): void)|null\n */\n private $onChunkItems = null;\n\n /** @param callable(array, int, int): void $callback */\n public function onChunkItems(callable $callback): self\n {\n $this->onChunkItems = $callback;\n return $this;\n }\n\n /**\n * Report a non-chunk stage (local parse, handing off to AI) to the caller.\n *\n * A spreadsheet is read locally first and only sent to the AI when that\n * fails, so a large file can spend minutes before any chunk exists. Without\n * these the UI shows a generic spinner for that whole period.\n */\n private function reportStage(string $message): void\n {\n if ($this->onStage !== null) {\n ($this->onStage)($message);\n }\n }\n\n /**\n * Report a workbook's shape from grids that are already in memory.\n *\n * Deliberately takes the parsed grids rather than a path: re-reading a large\n * spreadsheet just to count its sheets would double the slowest step of the\n * whole extraction.\n *\n * @param array<int, array{name: string, grid: array}> $sheets\n */\n private function reportWorkbookShape(array $sheets): void\n {\n if ($this->onStage === null || empty($sheets)) {\n return;\n }\n\n $rows = 0;\n foreach ($sheets as $sheet) {\n $rows += count($sheet['grid']);\n }\n\n $count = count($sheets);\n\n $this->reportStage($count > 1\n ? \"Found {$count} sheets, {$rows} rows in total. Reading…\"\n : \"Found {$rows} rows. Reading…\");\n }\n\n /** @var (callable(string): void)|null */\n private $onStage = null;\n\n /** @param callable(string): void $callback */\n public function onStage(callable $callback): self\n {\n $this->onStage = $callback;\n return $this;\n }\n\n public function __construct(BoqCleaningService $boqCleaner)\n {\n $this->boqCleaner = $boqCleaner;\n $this->baseUrl = rtrim((string) config('services.ai_quotation.base_url', ''), '/');\n $this->parseEndpoint = ltrim((string) config('services.ai_quotation.parse_endpoint', 'parse'), '/');\n $this->apiKey = (string) config('services.ai_quotation.api_key', '');\n $this->timeout = (int) config('services.ai_quotation.timeout', 300);\n $this->testMode = (bool) config('services.ai_quotation.test_mode', false);\n }\n\n /**\n * Give the running script enough head-room to outlast the AI HTTP call.\n *\n * The web request / queue worker must not be killed before the HTTP timeout\n * completes, otherwise the user sees a generic \"timeout or unavailable\"\n * error even though the AI may have responded. We allow the HTTP timeout\n * plus a 120s margin for connecting, response parsing and DB writes.\n */\n private function extendExecutionTime(): void\n {\n // Ignored on some SAPIs (e.g. when running under fastcgi with a hard\n // limit), but harmless to attempt.\n @set_time_limit($this->timeout + 120);\n }\n\n public function parseBoq(UploadedFile|string $file, array $context = []): array\n {\n if ($this->testMode) {\n return $this->mockResponse();\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n\n if (! is_file($absPath)) {\n return $this->failure('File not found or not readable.');\n }\n\n $fileHash = hash_file('sha256', $absPath);\n // Bump PARSER_VERSION whenever the extraction logic changes so that\n // previously-uploaded files are re-parsed instead of returning a stale\n // cached result produced by the old code.\n $cacheKey = 'boq_analysis_v' . self::PARSER_VERSION . '_' . $fileHash;\n $forceRefresh = (bool) ($context['force_refresh'] ?? false);\n\n if (! $forceRefresh && ($cached = Cache::get($cacheKey)) !== null) {\n return $cached;\n }\n\n // Clear stale cached result when force-refreshing\n if ($forceRefresh) {\n Cache::forget($cacheKey);\n }\n\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n // Read Excel locally first. Fall back to AI if local parse failed,\n // found 0 items, OR could not read one of the sheets (so multi-sheet\n // files never silently drop a sheet the local parser didn't understand).\n $this->reportStage('Reading the spreadsheet…');\n\n $result = $this->parseSpreadsheetDirect($absPath);\n\n $hasSkippedSheets = ! empty($result['skipped_sheets']);\n\n if (! $result['success'] || empty($result['items']) || $hasSkippedSheets) {\n // The local pass could not handle this file on its own. Say so:\n // otherwise the UI sits on a generic spinner through the local\n // parse AND the whole AI parse with nothing to show for either.\n $this->reportStage($hasSkippedSheets\n ? 'Some sheets need AI to read. Sending to AI…'\n : 'Spreadsheet needs AI to read. Sending to AI…');\n\n $deepSeekResult = $this->parseBoqWithDeepSeek($file, $context);\n // AI sees every sheet at once, so prefer its result whenever it\n // actually returned items (covers the skipped-sheet case too).\n if ($deepSeekResult['success'] && ! empty($deepSeekResult['items'])) {\n // Keep the larger of the two item sets so we never lose the\n // rows the local parser already extracted correctly.\n if (count($deepSeekResult['items']) >= count($result['items'] ?? [])) {\n $result = $deepSeekResult;\n }\n } elseif (! $result['success']) {\n $result = $deepSeekResult;\n }\n // else: keep local result if AI also failed/returned nothing\n }\n } elseif (in_array($ext, ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif'], true)) {\n $result = $this->parseBoqWithDeepSeek($file, $context);\n } else {\n return $this->failure('Unsupported file type. Please upload Excel, CSV, PDF, or image file.');\n }\n\n if ($result['success'] && ! empty($result['items'])) {\n $cleaning = $this->boqCleaner->process($result['items']);\n $result['items'] = $cleaning['accepted'] ?? $result['items'];\n $result['rejected'] = array_merge($result['rejected'] ?? [], $cleaning['rejected'] ?? []);\n }\n\n if ($result['success'] && count($result['items'] ?? []) > self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n $result = $this->verifyLargeBoqItemsWithAi($result);\n }\n\n // Only cache when we actually extracted items; empty results should be re-tried.\n if ($result['success'] && ! empty($result['items'])) {\n Cache::put($cacheKey, $result, now()->addDays(30));\n }\n\n return $result;\n }\n\n private function resolveFile(UploadedFile|string $file): array\n {\n if ($file instanceof UploadedFile) {\n return [(string) $file->getRealPath(), strtolower($file->getClientOriginalExtension())];\n }\n\n $absPath = is_file($file) ? $file : storage_path('app/' . ltrim($file, '/'));\n return [$absPath, strtolower(pathinfo($absPath, PATHINFO_EXTENSION))];\n }\n\n private function parseSpreadsheetDirect(string $absPath): array\n {\n ini_set('memory_limit', '2048M');\n\n // 180s was not enough for a real BOQ: an 11 200-row workbook was killed\n // mid-parse, and because set_time_limit() kills the process outright the\n // job reported a bare failure with nothing in the log to explain it.\n // This runs on the queue, where wall time is cheap.\n set_time_limit(1800);\n\n try {\n $sheets = $this->spreadsheetToGrids($absPath);\n\n if (empty($sheets)) {\n return $this->failure('Could not read the Excel file.');\n }\n\n $this->reportWorkbookShape($sheets);\n\n $items = [];\n $rejected = [];\n $skippedSheets = []; // sheets we could not parse locally (header undetected)\n\n foreach ($sheets as $sheet) {\n $grid = $sheet['grid'];\n if (count($grid) < 2) {\n continue;\n }\n\n // Skip dedicated summary / cost-abstract sheets entirely. Their rows\n // are per-discipline totals (Structural Concrete, Electrical, …) and\n // grand totals, not procurable products.\n if ($this->isSummarySheet($sheet['name'])) {\n Log::info('QuotationAiService: skipped summary sheet.', ['sheet' => $sheet['name']]);\n continue;\n }\n\n $header = $this->detectHeader($grid);\n\n if ($header === null || ! isset($header['map']['description'])) {\n Log::warning('QuotationAiService: header not detected in sheet.', ['sheet' => $sheet['name']]);\n $skippedSheets[] = $sheet['name'];\n continue;\n }\n\n $map = $header['map'];\n $start = $header['row'] + 1;\n\n for ($r = $start; $r < count($grid); $r++) {\n $row = $grid[$r];\n $description = $this->cell($row, $map['description'] ?? null);\n $itemNo = $this->cell($row, $map['item_code'] ?? null);\n $unit = $this->cell($row, $map['unit'] ?? null);\n $quantity = $this->number($this->cell($row, $map['quantity'] ?? null));\n $unitPrice = $this->number($this->cell($row, $map['unit_price'] ?? null));\n $totalPrice = $this->number($this->cell($row, $map['total_price'] ?? null));\n\n if ($description === '') {\n continue;\n }\n\n if (! isset($map['quantity'])) {\n $quantity = 1.0;\n }\n\n // Skip floor breakdown sub-rows: Ref. is \"-\" meaning it's a per-floor\n // quantity split of the parent item, not a standalone product.\n if ($itemNo === '-' || $this->isFloorBreakdownRow($itemNo, $description)) {\n continue;\n }\n\n if ($this->isTotalOrHeaderRow($description)) {\n continue;\n }\n\n if ($quantity === null || $quantity <= 0) {\n $rejected[] = $this->rejectedRow($description, $unit, $quantity, 'Missing quantity', $itemNo, $sheet['name']);\n continue;\n }\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $supply = $this->boqCleaner->filterItem($description, $unitPrice, $quantity);\n\n if (! ($supply['keep'] ?? false)) {\n $rejected[] = $this->rejectedRow(\n $description,\n $unit,\n $quantity,\n (string) ($supply['rejection_reason'] ?? 'Rejected by cleaner'),\n $itemNo,\n $sheet['name']\n );\n continue;\n }\n\n $finalDescription = (string) ($supply['description'] ?? $description);\n\n $items[] = [\n 'description' => $finalDescription,\n 'quantity' => $quantity,\n 'unit' => $unit,\n 'category' => $sheet['name'],\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($finalDescription),\n 'unit_price' => $unitPrice,\n 'confidence' => 0.95,\n 'needs_review' => false,\n 'raw_data' => [\n 'sheet' => $sheet['name'],\n 'row_number' => $r + 1,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'cleaned_description' => $finalDescription,\n 'extraction_type' => (string) ($supply['extraction_type'] ?? 'supply_only'),\n ],\n 'ai_extracted' => true,\n ];\n }\n }\n\n if (empty($items)) {\n return $this->failure('No BOQ supply items could be extracted from the Excel file.');\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'skipped_sheets' => $skippedSheets,\n ];\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: parseSpreadsheetDirect failed.', [\n 'path' => $absPath,\n 'message' => $e->getMessage(),\n 'trace' => $e->getTraceAsString(),\n ]);\n\n return $this->failure('Failed to read the Excel file: ' . $e->getMessage());\n }\n }\n\n private function spreadsheetToGrids(string $absPath): array\n {\n $ext = strtolower(pathinfo($absPath, PATHINFO_EXTENSION));\n\n if ($ext === 'csv') {\n $rows = [];\n $handle = fopen($absPath, 'rb');\n if ($handle !== false) {\n while (($row = fgetcsv($handle)) !== false) {\n $rows[] = $this->trimTrailingEmptyCells($row);\n }\n fclose($handle);\n }\n return [['name' => 'Sheet1', 'grid' => $rows]];\n }\n\n $reader = IOFactory::createReader($ext === 'xls' ? 'Xls' : 'Xlsx');\n $reader->setReadDataOnly(true);\n $spreadsheet = $reader->load($absPath);\n $sheets = [];\n\n foreach ($spreadsheet->getAllSheets() as $sheet) {\n $highestRow = $sheet->getHighestDataRow();\n $highestColumn = $sheet->getHighestDataColumn();\n $highestColumnIndex = Coordinate::columnIndexFromString($highestColumn);\n\n // BOQ data is normally in first 8-20 columns.\n // Files with heavy formatting/images can report 300+ columns — limit to 30.\n $maxColumnIndex = min($highestColumnIndex, 30);\n\n $grid = [];\n for ($row = 1; $row <= $highestRow; $row++) {\n $rowData = [];\n $hasValue = false;\n\n for ($col = 1; $col <= $maxColumnIndex; $col++) {\n $value = $sheet->getCell([$col, $row])->getValue();\n $value = $this->cellValueToString($value);\n $value = trim($value);\n if ($value !== null && $value !== '') {\n $hasValue = true;\n }\n $rowData[] = $value;\n }\n\n $rowData = $this->trimTrailingEmptyCells($rowData);\n if ($hasValue) {\n $grid[] = $rowData;\n }\n }\n\n if (! empty($grid)) {\n $sheets[] = ['name' => $sheet->getTitle(), 'grid' => $grid];\n }\n }\n\n $spreadsheet->disconnectWorksheets();\n unset($spreadsheet);\n\n return $sheets;\n }\n\n private function detectHeader(array $grid): ?array\n {\n $keywords = [\n 'item_code' => ['رقم البند', 'item no', 'item number', 'ref.', 'ref', 'item', 'no.', 'no', '#', 'كود'],\n 'description' => ['وص٠البند', 'scope of works', 'scope of work', 'scope', 'item description', 'description of works', 'description', 'بالعربية', 'الوصÙ', 'البند', 'بيان'],\n 'unit' => ['Ø§Ù„ÙˆØØ¯Ø©', 'unit', 'uom', 'u/m'],\n 'quantity' => ['الكمية', 'qty', 'quantity', 'q.ty'],\n 'unit_price' => ['سعر Ø§Ù„ÙˆØØ¯Ø©', 'u/price', 'unit price', 'unit rate', 'rate', 'price'],\n 'total_price' => ['السعر الإجمالي', 'total amount', 'total price', 'amount', 'الإجمالي'],\n ];\n\n $best = null;\n $bestScore = 0;\n\n for ($r = 0; $r < min(30, count($grid)); $r++) {\n $map = [];\n $score = 0;\n\n foreach ($grid[$r] as $c => $value) {\n $cell = mb_strtolower(trim((string) $value));\n if ($cell === '') {\n continue;\n }\n\n foreach ($keywords as $field => $list) {\n foreach ($list as $kw) {\n if (str_contains($cell, mb_strtolower($kw))) {\n if (! isset($map[$field])) {\n $map[$field] = $c;\n $score++;\n }\n break 2;\n }\n }\n }\n }\n\n if ($score > $bestScore) {\n $bestScore = $score;\n $best = ['row' => $r, 'map' => $map];\n }\n }\n\n if ($bestScore >= 3) {\n return $best;\n }\n\n // Allow simpler BOQ sheets that only define a description column plus one\n // other BOQ-related column, e.g. Item + Description or Description + Qty.\n if ($bestScore === 2 && isset($best['map']['description']) && (\n isset($best['map']['item_code']) ||\n isset($best['map']['quantity']) ||\n isset($best['map']['unit'])\n )) {\n return $best;\n }\n\n return null;\n }\n\n private function cell(array $row, ?int $index): string\n {\n if ($index === null) {\n return '';\n }\n return trim((string) ($row[$index] ?? ''));\n }\n\n private function number(string $value): ?float\n {\n $value = trim(str_replace([',', 'SAR', 'ر.س'], '', $value));\n if ($value === '' || ! is_numeric($value)) {\n return null;\n }\n return (float) $value;\n }\n\n private function trimTrailingEmptyCells(array $row): array\n {\n while (! empty($row) && trim((string) end($row)) === '') {\n array_pop($row);\n }\n return array_values($row);\n }\n\n private function isTotalOrHeaderRow(string $description): bool\n {\n $d = mb_strtolower(trim($description));\n\n if ($d === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $d)) {\n return true;\n }\n\n // Section totals / summaries\n if (preg_match('/(اجمالي|إجمالي|المجموع|grand\\s*total|sub\\s*total|total\\s*amount|total\\s*price)/iu', $d)) {\n return true;\n }\n\n // Bare \"Total\" lines, optionally with a currency suffix, e.g. \"Total\",\n // \"Total (SAR)\", \"Total cost\", \"Total (SAR) unit cost\".\n if (preg_match('/^total\\b[\\s\\(\\):a-z]*$/iu', $d)) {\n return true;\n }\n\n // Pure floor / level labels used as section headers\n if (preg_match('/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor)\\s*$/iu', $d)) {\n return true;\n }\n\n // Division / section header lines (e.g. \"Division - 02: Wood Works\")\n if (preg_match('/^division\\s*([-:]|\\d)/iu', $d)) {\n return true;\n }\n\n return false;\n }\n\n /**\n * Detect dedicated summary / cost-abstract sheets by their name. These hold\n * per-discipline rollups and grand totals (e.g. a \"Summary (Dry Cost)\" tab),\n * never procurable supply items, so they are skipped wholesale.\n */\n private function isSummarySheet(string $name): bool\n {\n $n = mb_strtolower(trim($name));\n\n if ($n === '') {\n return false;\n }\n\n return (bool) preg_match(\n '/\\b(summary|abstract|cost\\s*summary|grand\\s*summary|recap(?:itulation)?|bill\\s*summary|boq\\s*summary|dry\\s*cost)\\b|ملخص|الملخص|الملخّص|ملخّص|اجمالي|إجمالي/iu',\n $n\n );\n }\n\n private function isFloorBreakdownRow(string $itemNo, string $description): bool\n {\n // A row whose Ref column is empty or a dash is a sub-breakdown row (per-floor split).\n $ref = trim($itemNo);\n if ($ref === '' || $ref === '-') {\n // Confirm by checking the description is a location/floor label\n $d = mb_strtolower(trim($description));\n return (bool) preg_match(\n '/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor|\\d+f)\\s*$/iu',\n $d\n );\n }\n return false;\n }\n\n private function rejectedRow(string $description, string $unit, ?float $quantity, string $reason, string $itemNo = '', string $sheet = ''): array\n {\n return [\n 'description' => $description,\n 'quantity' => $quantity ?? 0,\n 'unit' => $unit,\n 'category' => $sheet,\n 'brand' => '',\n 'status' => 'rejected',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 0.0,\n 'raw_data' => [\n 'sheet' => $sheet,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'extraction_type' => 'rejected',\n 'rejection_reason' => $reason,\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function parseBoqWithDeepSeek(UploadedFile|string $file, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured. Please set DEEPSEEK_API_KEY in .env file.');\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n $mime = $this->mimeForExtension($ext);\n\n try {\n // ── Spreadsheets ──────────────────────────────────────────────────\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n $text = $this->spreadsheetToCompactCsvText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not convert spreadsheet to readable text.');\n }\n\n $text = $this->sanitizeUtf8($text);\n\n // A large BOQ exceeds what one request can carry, so send it in\n // sequential slices and merge. Anything that fits goes as-is.\n //\n // Size alone is not a good enough signal: a workbook with several\n // sheets is structurally a big BOQ even when the text is compact,\n // and asking the model to hold every discipline at once is where\n // rows start getting dropped. So split on either signal.\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS || $sheetCount > self::MAX_SHEETS_PER_CALL) {\n Log::info('QuotationAiService: spreadsheet routed to chunked parsing.', [\n 'chars' => mb_strlen($text),\n 'sheets' => $sheetCount,\n ]);\n return $this->parseTextInChunks($text, 'BOQ spreadsheet converted to CSV', $apiKey, $context);\n }\n\n $userContent = \"BOQ spreadsheet converted to CSV:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── PDF ───────────────────────────────────────────────────────────\n if ($ext === 'pdf') {\n $extracted = $this->extractPdfText($absPath);\n\n if ($extracted === self::PDF_LOW_MEMORY) {\n return $this->failure('The server was low on memory while reading this PDF. Please try again in a moment, or convert it to Excel or CSV.');\n }\n\n if ($extracted !== null && mb_strlen(trim($extracted)) > 50) {\n $extracted = $this->sanitizeUtf8($extracted);\n\n // Split on either signal — long text, or many pages. A BOQ\n // spanning several pages is a multi-section document even if\n // each page is sparse.\n if (mb_strlen($extracted) > self::TEXT_CHUNK_CHARS\n || $this->lastPdfPageCount > self::MAX_PDF_PAGES_PER_CALL) {\n Log::info('QuotationAiService: PDF routed to chunked parsing.', [\n 'chars' => mb_strlen($extracted),\n 'pages' => $this->lastPdfPageCount,\n ]);\n return $this->parseTextInChunks($extracted, 'BOQ PDF extracted text', $apiKey, $context, 'application/pdf');\n }\n\n $userContent = \"BOQ PDF extracted text:\\n\\n\" . $extracted . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'application/pdf');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // No text — smalot threw and pdftotext was unavailable. We do not\n // route PDFs to a vision model (that path needs a Gemini key we\n // are not using), so ask for an Excel/CSV instead.\n return $this->failure('Could not read this PDF. It may be a scanned image, or use a format our reader cannot open. Please convert it to Excel or CSV and upload again.');\n }\n\n // -- Word documents (docx) ---------------------------------------------------\n if (in_array($ext, ['docx', 'doc'], true)) {\n $text = $this->extractDocxText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not extract text from the Word document. Please convert it to Excel, CSV, or PDF.');\n }\n $text = $this->sanitizeUtf8($text);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS) {\n return $this->parseTextInChunks($text, 'BOQ Word document extracted text', $apiKey, $context);\n }\n\n $userContent = \"BOQ Word document extracted text:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── Images ──────────────────────────────────────────────────────────────\n // DeepSeek cannot receive images. A vision-capable AI processes the image\n // directly (no OCR) — configure GROQ_API_KEY or GEMINI_API_KEY in .env.\n $imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif', 'heic', 'heif'];\n if (in_array($ext, $imageExts, true)) {\n $bytes = (string) file_get_contents($absPath);\n return $this->callDeepSeekVision($bytes, $mime, $context);\n }\n\n return $this->failure('Unsupported file type. Please upload an Excel, CSV, PDF, or image file.');\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: DeepSeek fallback failed.', ['message' => $e->getMessage()]);\n return $this->failure('An error occurred while processing the file with AI: ' . $e->getMessage());\n }\n }\n\n private function deepSeekModel(): string\n {\n return (string) config('services.deepseek.model', 'deepseek-chat');\n }\n\n /**\n * Remove or replace any non-UTF-8 / malformed byte sequences so json_encode never fails.\n */\n private function sanitizeUtf8(string $text): string\n {\n // Replace invalid byte sequences with the UTF-8 replacement character\n $clean = mb_convert_encoding($text, 'UTF-8', 'UTF-8');\n // Strip any remaining non-printable control characters (except tab/newline/CR)\n $clean = (string) preg_replace('/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]/u', '', $clean);\n return $clean;\n }\n\n /**\n * Extract plain text from a Word docx file using ZipArchive (no external library needed).\n * Reads word/document.xml and strips XML tags, preserving paragraph and table structure.\n */\n private function extractDocxText(string $absPath): ?string\n {\n try {\n $zip = new \\ZipArchive();\n if ($zip->open($absPath) !== true) {\n return null;\n }\n\n $xml = $zip->getFromName('word/document.xml');\n $zip->close();\n\n if ($xml === false || $xml === '') {\n return null;\n }\n\n // Add newlines before paragraph/table-row/table-cell tags so structure is preserved\n $xml = preg_replace('/<w:p[ >]/', \"\\n<w:p>\", $xml) ?? $xml;\n $xml = preg_replace('/<w:tr[ >]/', \"\\n<w:tr>\", $xml) ?? $xml;\n $xml = preg_replace('/<\\/w:tc>/', \"\\t\", $xml) ?? $xml;\n\n $text = strip_tags($xml);\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n return trim($text) !== '' ? trim($text) : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: DOCX parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Extract plain text from a PDF using smalot/pdfparser (pure PHP, no binary required).\n */\n /**\n * The process memory limit in bytes, or 0 when unlimited.\n *\n * memory_limit is a shorthand string (\"128M\", \"1G\", \"-1\"), so it has to be\n * expanded before it can be compared against anything.\n */\n private function memoryLimitBytes(): int\n {\n $raw = trim((string) ini_get('memory_limit'));\n\n if ($raw === '' || $raw === '-1') {\n return 0;\n }\n\n $value = (int) $raw;\n\n return match (strtolower(substr($raw, -1))) {\n 'g' => $value * 1024 * 1024 * 1024,\n 'm' => $value * 1024 * 1024,\n 'k' => $value * 1024,\n default => $value,\n };\n }\n\n private function extractPdfText(string $absPath): ?string\n {\n // The parser loads and decompresses the whole document before any of our\n // chunking gets a look at it, and it does so with no memory ceiling of\n // its own. A big PDF exhausts the limit inside gzuncompress() — a fatal\n // error, not an exception, so the catch below cannot help and the worker\n // dies outright. Refuse the file first instead.\n $size = @filesize($absPath) ?: 0;\n\n if ($size > self::MAX_PDF_BYTES) {\n Log::warning('QuotationAiService: PDF too large to parse in-process.', [\n 'bytes' => $size,\n 'limit' => self::MAX_PDF_BYTES,\n ]);\n\n return null;\n }\n\n // Headroom check: parsing routinely needs several times the file size\n // once streams are decompressed. If that would not fit in what is left,\n // stop here rather than taking the worker down with us.\n $limit = $this->memoryLimitBytes();\n\n if ($limit > 0) {\n // Try to reclaim memory a previous job left behind before deciding\n // there is not enough. Without this, a worker sitting near its limit\n // after a big job could refuse a tiny PDF for no real reason.\n gc_collect_cycles();\n\n $available = $limit - memory_get_usage(true);\n\n if ($available < $size * self::PDF_MEMORY_FACTOR) {\n Log::warning('QuotationAiService: not enough memory left to parse this PDF.', [\n 'bytes' => $size,\n 'available' => $available,\n 'needed' => $size * self::PDF_MEMORY_FACTOR,\n ]);\n\n // A distinct sentinel, so the caller can tell \"no memory\" apart\n // from \"no text\" and show the right message instead of blaming\n // the file for being scanned.\n return self::PDF_LOW_MEMORY;\n }\n }\n\n try {\n $parser = new \\Smalot\\PdfParser\\Parser();\n $pdf = $parser->parseFile($absPath);\n\n // Recorded for the chunking decision: a long PDF is structurally a\n // big BOQ even when its extracted text is short.\n try {\n $this->lastPdfPageCount = count($pdf->getPages());\n } catch (\\Throwable) {\n $this->lastPdfPageCount = 0;\n }\n\n $text = $pdf->getText();\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n return $text !== '' ? $text : null;\n } catch (\\Throwable $e) {\n // Class, file and line, not just the message — a bare message left\n // the actual cause of a PDF failure guessable at best.\n Log::warning('QuotationAiService: PDF parsing failed.', [\n 'exception' => $e::class,\n 'error' => $e->getMessage(),\n 'at' => $e->getFile() . ':' . $e->getLine(),\n ]);\n\n // smalot/pdfparser throws \"Invalid object reference for $obj.\" on\n // some perfectly valid PDFs — a known bug in the library, not a\n // problem with the file. Fall back to the pdftotext binary, which\n // reads those documents fine.\n return $this->extractPdfTextViaBinary($absPath);\n }\n }\n\n /**\n * Locate the pdftotext binary.\n *\n * On shared hosting it is rarely on the system PATH, so a user-installed\n * copy — dropped in ~/bin, ~/.local/bin, or the project's vendor/bin — has\n * to be found explicitly. An escaped, quoted absolute path is returned so it\n * is safe to interpolate into the command.\n *\n * PDFTOTEXT_PATH in .env wins, for a copy installed somewhere non-standard.\n */\n private function pdftotextPath(): ?string\n {\n $configured = trim((string) env('PDFTOTEXT_PATH', ''));\n\n $candidates = array_filter([\n $configured !== '' ? $configured : null,\n ($home = getenv('HOME')) ? $home . '/bin/pdftotext' : null,\n $home ? $home . '/.local/bin/pdftotext' : null,\n base_path('vendor/bin/pdftotext'),\n '/usr/bin/pdftotext',\n '/usr/local/bin/pdftotext',\n ]);\n\n foreach ($candidates as $path) {\n if (is_file($path) && is_executable($path)) {\n return escapeshellarg($path);\n }\n }\n\n // Fall back to a bare name, in case it is on PATH after all.\n $onPath = @shell_exec('command -v pdftotext 2>/dev/null');\n\n return is_string($onPath) && trim($onPath) !== '' ? 'pdftotext' : null;\n }\n\n private function extractPdfTextViaBinary(string $absPath): ?string\n {\n // shell_exec is commonly disabled on shared hosting; bail cleanly so the\n // caller falls through to the vision path or a clear error.\n if (! function_exists('shell_exec')\n || in_array('shell_exec', array_map('trim', explode(',', (string) ini_get('disable_functions'))), true)) {\n return null;\n }\n\n $binary = $this->pdftotextPath();\n\n if ($binary === null) {\n Log::warning('QuotationAiService: pdftotext binary not found.', [\n 'looked_in' => 'PATH, PDFTOTEXT_PATH, ~/bin, ~/.local/bin, vendor/bin',\n ]);\n return null;\n }\n\n // `-layout` keeps columns roughly aligned, which matters for a BOQ; the\n // trailing \"-\" writes to stdout instead of a file.\n $command = sprintf('%s -layout -enc UTF-8 %s - 2>/dev/null', $binary, escapeshellarg($absPath));\n\n $output = @shell_exec($command);\n\n if (! is_string($output) || trim($output) === '') {\n Log::warning('QuotationAiService: pdftotext fallback produced no text.', [\n 'path' => basename($absPath),\n ]);\n return null;\n }\n\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $output);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n\n if ($text !== '') {\n Log::info('QuotationAiService: recovered PDF text via pdftotext fallback.', [\n 'path' => basename($absPath),\n 'chars' => mb_strlen($text),\n ]);\n }\n\n return $text !== '' ? $text : null;\n }\n\n /**\n * Send a vision request to a vision-capable AI model.\n * DeepSeek's commercial API (api.deepseek.com) does NOT support image inputs — any model.\n * Priority order (first configured key wins):\n * 1. GROQ_API_KEY → Groq llama-4-scout (free: https://console.groq.com)\n * 2. GEMINI_API_KEY → Google Gemini Flash (free: https://aistudio.google.com/apikey)\n * 3. VISION_API_KEY → any OpenAI-compatible endpoint (OpenAI, OpenRouter …)\n */\n private function callDeepSeekVision(string $bytes, string $mime, array $context): array\n {\n // ── 1. Groq (free, reliable vision) ─────────────────────────────────────\n if ((string) config('services.groq.key', '') !== '') {\n $visionKey = (string) config('services.groq.key');\n $visionBaseUrl = 'https://api.groq.com/openai/v1';\n $visionModel = (string) config('services.groq.model', 'meta-llama/llama-4-scout-17b-16e-instruct');\n }\n // ── 2. Google Gemini ─────────────────────────────────────────────────────\n elseif ((string) config('services.gemini.key', '') !== '') {\n $visionKey = (string) config('services.gemini.key');\n $visionBaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai';\n $visionModel = (string) config('services.gemini.model', 'gemini-2.0-flash');\n }\n // ── 3. Generic VISION_API_KEY (OpenAI, OpenRouter, …) ───────────────────\n elseif ((string) config('services.vision.key', '') !== '') {\n $visionKey = (string) config('services.vision.key');\n $visionBaseUrl = rtrim((string) config('services.vision.base_url', 'https://openrouter.ai/api/v1'), '/');\n $visionModel = (string) config('services.vision.model', 'google/gemini-flash-1.5-8b');\n }\n else {\n return $this->failure(\n 'Image processing is not available. DeepSeek\\'s API does not support images. ' .\n 'Get a free key at https://console.groq.com and add GROQ_API_KEY to your .env file.'\n );\n }\n\n $dataUrl = \"data:{$mime};base64,\" . base64_encode($bytes);\n $prompt = $this->buildDeepSeekPrompt($context, $mime);\n\n $userContent = [\n ['type' => 'text', 'text' => $prompt],\n ['type' => 'image_url', 'image_url' => ['url' => $dataUrl]],\n ];\n\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders([\n 'Authorization' => 'Bearer ' . $visionKey,\n 'HTTP-Referer' => config('app.url', 'https://qimta.sa'),\n 'X-Title' => 'Qimta Platform',\n ])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post($visionBaseUrl . '/chat/completions', [\n 'model' => $visionModel,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 16384,\n 'temperature' => 0,\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('Vision API connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: Vision API HTTP error.', [\n 'model' => $visionModel,\n 'base_url' => $visionBaseUrl,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n return $this->failure(\"Vision API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n return $this->parseDeepSeekJsonResponse((string) $response->json('choices.0.message.content'));\n }\n\n private function callDeepSeekChat(string $userContent, string $key, string $model): array\n {\n // Ensure the PHP script outlives the HTTP call (+ a margin for parsing the\n // response and writing to the DB) so the worker/web request is not killed\n // mid-flight, which surfaces to users as a generic \"timeout or unavailable\".\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders(['Authorization' => 'Bearer ' . $key])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 32768,\n 'temperature' => 0,\n 'response_format' => ['type' => 'json_object'],\n 'user' => 'Qimta_Platform',\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('DeepSeek connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: DeepSeek HTTP error.', [\n 'model' => $model,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n if ($response->status() === 401) {\n return $this->failure('DeepSeek API key is invalid or unauthorised. Please update DEEPSEEK_API_KEY in .env.', false);\n }\n return $this->failure(\"DeepSeek API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n $text = (string) $response->json('choices.0.message.content');\n return $this->parseDeepSeekJsonResponse($text);\n }\n\n /**\n * Parse a document too large for one AI call by sending it in slices.\n *\n * A 20 000-row BOQ is several megabytes of text — far beyond what a single\n * request can carry, and beyond what the model can answer without hitting its\n * output token limit. Previously the text was simply cut at 180 000 chars and\n * the remainder discarded: the parse \"succeeded\" while silently dropping most\n * of the file. Now the text is split and each slice parsed on its own call,\n * with the items merged.\n *\n * Splitting happens on line boundaries so a BOQ row is never cut in half.\n * Slices run sequentially rather than pooled: DeepSeek rate-limits hard on\n * concurrent large prompts, and this already runs on the queue where wall\n * time is cheap.\n *\n * A slice that fails does not fail the document — its rows are lost, but the\n * rest are kept and the shortfall is logged. Total failure is reported only\n * when every slice fails.\n *\n * @param string $label How the payload is introduced to the model.\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n /**\n * Split a spreadsheet into the slices the AI will be asked to read.\n *\n * Exposed so the work can be spread over several queue jobs instead of one\n * job walking every slice in sequence. Returns an empty array when the file\n * is small enough to go in a single call, or cannot be read.\n *\n * @return array<int, string>\n */\n public function chunkSpreadsheet(string $absPath): array\n {\n ini_set('memory_limit', '2048M');\n set_time_limit(1800);\n\n $text = $this->spreadsheetToCompactCsvText($absPath);\n\n if ($text === null || trim($text) === '') {\n return [];\n }\n\n $text = $this->sanitizeUtf8($text);\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) <= self::TEXT_CHUNK_CHARS && $sheetCount <= self::MAX_SHEETS_PER_CALL) {\n return [];\n }\n\n $chunks = $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n\n // Free the source text: the slices are a second full copy of it, and on\n // a large workbook that doubling — on top of PhpSpreadsheet's grids —\n // is what exhausted memory here.\n unset($text);\n\n Log::info('QuotationAiService: chunked spreadsheet.', [\n 'chunks' => count($chunks),\n 'peak_mb' => round(memory_get_peak_usage(true) / 1048576),\n ]);\n\n return $chunks;\n }\n\n /**\n * Split a spreadsheet straight to disk, one file per slice.\n *\n * chunkSpreadsheet() returns every slice as an array, which is a second\n * full copy of the document in memory on top of PhpSpreadsheet's grids.\n * This hands each slice to $write and drops it immediately, so peak memory\n * is one slice rather than the whole file twice.\n *\n * @param callable(int, string): void $write ($part, $chunk)\n * @return int number of slices written; 0 when the file needs no split\n */\n public function chunkSpreadsheetToDisk(string $absPath, callable $write): int\n {\n ini_set('memory_limit', '2048M');\n set_time_limit(1800);\n\n $text = $this->spreadsheetToCompactCsvText($absPath);\n\n if ($text === null || trim($text) === '') {\n return 0;\n }\n\n $text = $this->sanitizeUtf8($text);\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) <= self::TEXT_CHUNK_CHARS && $sheetCount <= self::MAX_SHEETS_PER_CALL) {\n return 0;\n }\n\n $part = 0;\n foreach ($this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS) as $chunk) {\n $write(++$part, $chunk);\n }\n\n unset($text);\n\n Log::info('QuotationAiService: chunked spreadsheet to disk.', [\n 'chunks' => $part,\n 'peak_mb' => round(memory_get_peak_usage(true) / 1048576),\n ]);\n\n return $part;\n }\n\n /**\n * Parse one slice produced by chunkSpreadsheet().\n *\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n public function parseChunk(string $chunk, int $part, int $total, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured.');\n }\n\n // Same framing parseTextInChunks() uses, so a slice is never mistaken\n // for a complete BOQ and totals are not inferred from a fragment.\n $userContent = \"BOQ spreadsheet converted to CSV (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, 'text/plain');\n\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n private function parseTextInChunks(\n string $text,\n string $label,\n string $apiKey,\n array $context,\n string $mime = 'text/plain',\n ): array {\n $chunks = $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n $total = count($chunks);\n\n Log::info('QuotationAiService: large document split for parsing.', [\n 'chars' => mb_strlen($text),\n 'chunks' => $total,\n ]);\n\n // Announce the split before the first slice is sent, so the UI can show\n // how many parts there are instead of sitting on a generic message for\n // however long the first AI call takes.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)(0, $total);\n }\n\n $items = [];\n $rejected = [];\n $failed = 0;\n $lastError = null;\n\n foreach ($chunks as $index => $chunk) {\n $part = $index + 1;\n\n // Report progress so a 28-slice parse does not look frozen to the user.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)($part, $total);\n }\n\n // Tell the model this is a fragment, so it does not try to reconcile\n // totals or treat a mid-document slice as a complete BOQ.\n $userContent = \"{$label} (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, $mime);\n\n $result = $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n\n if (! ($result['success'] ?? false)) {\n $failed++;\n $lastError = $result['error'] ?? 'Chunk parsing failed.';\n Log::warning('QuotationAiService: chunk failed, continuing.', [\n 'part' => $part,\n 'of' => $total,\n 'error' => $lastError,\n ]);\n continue;\n }\n\n foreach ($result['items'] as $item) {\n $items[] = $item;\n }\n foreach ($result['rejected'] ?? [] as $row) {\n $rejected[] = $row;\n }\n\n // Hand this slice's rows straight to the caller so they can be shown\n // while the remaining slices are still being parsed.\n if ($this->onChunkItems !== null && $result['items'] !== []) {\n ($this->onChunkItems)($result['items'], $part, $total);\n }\n }\n\n // Every slice failed — there is nothing to show, so report the failure.\n if ($failed === $total) {\n return $this->failure($lastError ?? 'Could not parse the document.');\n }\n\n if ($failed > 0) {\n Log::error('QuotationAiService: document parsed with missing parts.', [\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n 'items_kept' => count($items),\n ]);\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'service_unavailable' => false,\n 'partial' => $failed > 0,\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n ];\n }\n\n /**\n * Split text into slices of at most $limit characters, breaking only at\n * newlines so no BOQ row is ever cut in half.\n *\n * A single line longer than the limit (rare — a pathological cell) is passed\n * through oversized rather than severed mid-row.\n *\n * @return array<int, string>\n */\n private function splitOnLineBoundaries(string $text, int $limit): array\n {\n $chunks = [];\n $current = '';\n\n // Sheet a chunk starts in the middle of. Repeated at the top of the next\n // chunk so the model always knows which sheet the rows belong to — the\n // header would otherwise be stranded in the previous slice, and rows\n // would arrive with no discipline context.\n $currentSheet = null;\n\n foreach (explode(\"\\n\", $text) as $line) {\n $isSheetHeader = str_starts_with($line, 'Sheet: ');\n\n // Start a new chunk at every sheet boundary. Sheets are usually\n // separate disciplines, and the whole point of splitting a multi-tab\n // workbook is to give the model one discipline at a time — a size-only\n // split would leave a compact 3-tab file as a single chunk, silently\n // undoing the sheet gate that routed it here.\n if ($isSheetHeader && $current !== '') {\n $chunks[] = $current;\n $current = $line;\n $currentSheet = $line;\n continue;\n }\n\n if ($isSheetHeader) {\n $currentSheet = $line;\n }\n\n $candidate = $current === '' ? $line : $current . \"\\n\" . $line;\n\n if (mb_strlen($candidate) > $limit && $current !== '') {\n $chunks[] = $current;\n\n // Carry the sheet header over, unless this line is itself one.\n $current = ($currentSheet !== null && ! str_starts_with($line, 'Sheet: '))\n ? $currentSheet . \" (continued)\\n\" . $line\n : $line;\n continue;\n }\n\n $current = $candidate;\n }\n\n if (trim($current) !== '') {\n $chunks[] = $current;\n }\n\n return $chunks;\n }\n\n /**\n * Shared JSON response parser used by both chat and vision paths.\n */\n private function parseDeepSeekJsonResponse(string $text): array\n {\n if ($text === '') {\n return $this->failure('DeepSeek returned an empty response.');\n }\n\n // Strip markdown code fences if the model wrapped the JSON.\n if (preg_match('/```(?:json)?\\s*([\\s\\S]*?)\\s*```/i', $text, $m)) {\n $text = $m[1];\n }\n // Narrow to the outermost JSON object/array.\n if (preg_match('/[\\{\\[][\\s\\S]*[\\}\\]]/s', $text, $m)) {\n $text = $m[0];\n }\n $text = trim($text);\n\n $decoded = json_decode($text, true);\n\n // The response can be truncated when the model hits the token limit,\n // leaving an unterminated JSON string/object. Try to repair it before\n // giving up so we can still salvage the items extracted so far.\n if (! is_array($decoded)) {\n $repaired = $this->repairTruncatedJson($text);\n if ($repaired !== null) {\n $decoded = json_decode($repaired, true);\n }\n }\n\n if (! is_array($decoded)) {\n Log::error('QuotationAiService: DeepSeek non-JSON response.', ['text' => mb_substr($text, 0, 1000)]);\n return $this->failure('DeepSeek returned a non-JSON response. The file may be too large — try splitting it into smaller files.');\n }\n\n $rawItems = $decoded['items'] ?? (array_is_list($decoded) ? $decoded : []);\n $items = [];\n $rejected = [];\n\n foreach ($rawItems as $raw) {\n if (! is_array($raw)) {\n continue;\n }\n $item = $this->normaliseAiItem($raw);\n if ($item === null) {\n continue;\n }\n $check = $this->boqCleaner->filterItem((string) $item['description']);\n if ($check['keep'] ?? false) {\n $items[] = $item;\n } else {\n $item['status'] = 'rejected';\n $item['raw_data']['rejection_reason'] = $check['rejection_reason'] ?? 'Rejected by cleaner';\n $rejected[] = $item;\n }\n }\n\n if (empty($items)) {\n Log::info('QuotationAiService: DeepSeek returned 0 accepted items.', [\n 'raw_items_count' => count($rawItems),\n 'rejected_count' => count($rejected),\n 'first_rejected' => ! empty($rejected) ? ($rejected[0]['description'] ?? '') : '',\n ]);\n }\n\n return ['success' => true, 'items' => $items, 'rejected' => $rejected, 'error' => null];\n }\n\n /**\n * Best-effort repair of a truncated JSON string produced when the model hits\n * its token limit mid-output. Drops any incomplete trailing object, then\n * closes the open string/array/object brackets so the result is valid JSON.\n *\n * Returns the repaired JSON string, or null if it cannot be salvaged.\n */\n private function repairTruncatedJson(string $text): ?string\n {\n if ($text === '') {\n return null;\n }\n\n // Walk the string tracking structural state, ignoring everything inside\n // string literals (and their escapes). Remember the last position where\n // we were back at the top of the \"items\" array between complete elements,\n // so we can cut a half-written trailing object.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($text);\n $lastSafeCut = null; // index just after a completed array element\n\n for ($i = 0; $i < $len; $i++) {\n $ch = $text[$i];\n\n if ($inString) {\n if ($escaped) {\n $escaped = false;\n } elseif ($ch === '\\\\') {\n $escaped = true;\n } elseif ($ch === '\"') {\n $inString = false;\n }\n continue;\n }\n\n if ($ch === '\"') {\n $inString = true;\n } elseif ($ch === '{' || $ch === '[') {\n $stack[] = $ch;\n } elseif ($ch === '}' || $ch === ']') {\n array_pop($stack);\n } elseif ($ch === ',' && ! empty($stack) && end($stack) === '[') {\n // Separator directly inside an array: a clean boundary between\n // elements (the items array, whatever its nesting depth).\n $lastSafeCut = $i;\n }\n }\n\n // If we are still inside a string, the last element is incomplete — fall\n // back to the last clean array boundary if we have one.\n $candidate = $text;\n if ($inString && $lastSafeCut !== null) {\n $candidate = substr($text, 0, $lastSafeCut);\n } else {\n // Drop a dangling partial object after the last completed element.\n $candidate = rtrim($candidate);\n if ($lastSafeCut !== null && substr(rtrim($candidate), -1) !== '}' && substr(rtrim($candidate), -1) !== ']') {\n $candidate = substr($text, 0, $lastSafeCut);\n }\n }\n\n // Recompute the open brackets for the (possibly trimmed) candidate and\n // append the matching closers in reverse order.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($candidate);\n for ($i = 0; $i < $len; $i++) {\n $ch = $candidate[$i];\n if ($inString) {\n if ($escaped) { $escaped = false; }\n elseif ($ch === '\\\\') { $escaped = true; }\n elseif ($ch === '\"') { $inString = false; }\n continue;\n }\n if ($ch === '\"') { $inString = true; }\n elseif ($ch === '{' || $ch === '[') { $stack[] = $ch; }\n elseif ($ch === '}' || $ch === ']') { array_pop($stack); }\n }\n\n $candidate = rtrim($candidate);\n // Remove a trailing comma left after cutting an element.\n $candidate = rtrim($candidate, ',');\n\n $closers = '';\n for ($i = count($stack) - 1; $i >= 0; $i--) {\n $closers .= ($stack[$i] === '{') ? '}' : ']';\n }\n\n $repaired = $candidate . $closers;\n\n // Validate before returning.\n return is_array(json_decode($repaired, true)) ? $repaired : null;\n }\n\n private function spreadsheetToCompactCsvText(string $absPath): ?string\n {\n $sheets = $this->spreadsheetToGrids($absPath);\n if (empty($sheets)) {\n return null;\n }\n\n $output = '';\n $kept = 0;\n $dropped = 0;\n $nonSupply = 0;\n $duplicates = 0;\n\n /** @var array<string, true> Fingerprints of rows already emitted. */\n $seenRows = [];\n\n foreach ($sheets as $sheet) {\n // Don't feed summary / cost-abstract sheets to the AI either.\n if ($this->isSummarySheet($sheet['name'])) {\n continue;\n }\n $sheetRows = '';\n foreach ($sheet['grid'] as $row) {\n $row = $this->trimTrailingEmptyCells($row);\n\n if ($this->isNoiseRow($row)) {\n $dropped++;\n continue;\n }\n\n // Drop rows that are definitely not procurable before they cost\n // a single token: section totals, discipline headings,\n // preliminaries, labour-only lines. The same classifier already\n // runs on the AI's output, but by then the tokens are spent — on\n // an 11 200-row BOQ that is most of the bill.\n if ($this->isNonSupplyRow($row)) {\n $dropped++;\n $nonSupply++;\n continue;\n }\n\n // Byte-identical rows tell the AI nothing new. Only exact\n // duplicates are collapsed — description, quantity, unit and all\n // — so nothing is lost: two rows differing in quantity are two\n // different line items and both are sent.\n $fingerprint = $this->rowFingerprint($row);\n\n if (isset($seenRows[$fingerprint])) {\n $duplicates++;\n $dropped++;\n continue;\n }\n $seenRows[$fingerprint] = true;\n\n $sheetRows .= implode(',', array_map([$this, 'csvEscape'], $this->stripBoilerplate($row))) . \"\\n\";\n $kept++;\n }\n\n // A sheet whose rows were all noise contributes nothing but its own\n // header, which would just be another thing for the model to explain.\n if ($sheetRows === '') {\n continue;\n }\n\n $output .= 'Sheet: ' . $sheet['name'] . \"\\n\" . $sheetRows . \"\\n\";\n }\n\n $blank = $dropped - $nonSupply - $duplicates;\n\n Log::info('QuotationAiService: filtered rows before sending to AI.', [\n 'kept' => $kept,\n 'blank' => $blank,\n 'non_supply' => $nonSupply,\n 'duplicates' => $duplicates,\n ]);\n\n if ($dropped > 0) {\n $parts = [];\n if ($blank > 0) { $parts[] = \"{$blank} blank\"; }\n if ($nonSupply > 0) { $parts[] = \"{$nonSupply} non-supply\"; }\n if ($duplicates > 0) { $parts[] = \"{$duplicates} duplicate\"; }\n\n $this->reportStage('Skipped ' . implode(', ', $parts) . \" rows. Sending {$kept}…\");\n }\n\n return $output;\n }\n\n /**\n * True when a row carries no information worth sending to the AI.\n *\n * A real BOQ is mostly formatting: spacer rows, stray borders, a lone\n * section number, a leftover cell from a merged block. They survive\n * trimTrailingEmptyCells() because they are not *entirely* empty, then cost\n * tokens, dilute the prompt, and inflate the number of parts a large file is\n * split into.\n *\n * Deliberately conservative — a row is dropped only when nothing in it could\n * name a product. Anything with letters is kept, because a description is\n * the one field an item cannot do without.\n *\n * @param array<int, mixed> $row\n */\n /**\n * True when a row is definitely not a procurable supply item.\n *\n * Runs BoqCleaningService over the row's longest text cell — the one most\n * likely to be the description — so headings, totals, preliminaries and\n * labour-only lines never reach the AI. The same classifier already vets the\n * AI's *output*; doing it here as well means we stop paying to have those\n * rows read in the first place.\n *\n * Conservative by construction: only rows the classifier positively rejects\n * are dropped. Anything it is unsure about goes to the AI, which is the\n * better judge — a wrongly dropped row is invisible, a wrongly kept one only\n * costs tokens.\n *\n * @param array<int, mixed> $row\n */\n /**\n * Stable identity for a row, used to collapse exact duplicates.\n *\n * Whitespace and case are normalised so \"DN50 PIPE\" and \"dn50 pipe \" are one\n * row, but nothing else is: a differing quantity or unit produces a\n * different fingerprint and both rows are sent.\n *\n * @param array<int, mixed> $row\n */\n private function rowFingerprint(array $row): string\n {\n $parts = array_map(\n fn($cell) => mb_strtolower(preg_replace('/\\s+/u', ' ', trim((string) $cell)) ?? ''),\n $row,\n );\n\n // Drop a leading BOQ sequence number. Otherwise \"1,Pipe,m,100\" and\n // \"2,Pipe,m,100\" fingerprint differently and the dedup never fires —\n // which is exactly the shape every real BOQ has.\n if (isset($parts[0]) && $parts[0] !== '' && preg_match('/^[\\d.]+$/', $parts[0])) {\n array_shift($parts);\n }\n\n return md5(implode('|', $parts));\n }\n\n /**\n * Strip contractual boilerplate from a row's description cell.\n *\n * \"as per drawings\", \"complete with all necessary accessories\" and friends\n * repeat on nearly every line of a real BOQ and carry no information the\n * pricing engine can use — but they are a large share of the tokens.\n *\n * Only the longest text cell is touched, and only when the result is still a\n * usable description: if stripping would leave too little to identify the\n * product, the original is kept. A shorter-but-wrong description is far\n * worse than a verbose one.\n *\n * @param array<int, mixed> $row\n * @return array<int, mixed>\n */\n private function stripBoilerplate(array $row): array\n {\n $index = null;\n $longest = '';\n\n foreach ($row as $i => $cell) {\n $text = trim((string) $cell);\n if (preg_match('/\\p{L}/u', $text) && mb_strlen($text) > mb_strlen($longest)) {\n $longest = $text;\n $index = $i;\n }\n }\n\n // Nothing worth cleaning, or too short to risk it.\n if ($index === null || mb_strlen($longest) < 40) {\n return $row;\n }\n\n try {\n $cleaned = $this->boqCleaner->cleanDescription($longest);\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: boilerplate strip failed, keeping row as-is.', [\n 'error' => $e->getMessage(),\n ]);\n return $row;\n }\n\n // Guard against an over-eager pattern eating the product name: keep the\n // original unless a recognisable description survives.\n if (mb_strlen($cleaned) < 12 || mb_strlen($cleaned) < mb_strlen($longest) * 0.3) {\n return $row;\n }\n\n $row[$index] = $cleaned;\n\n return $row;\n }\n\n private function isNonSupplyRow(array $row): bool\n {\n $description = '';\n foreach ($row as $cell) {\n $text = trim((string) $cell);\n // Longest cell containing letters — numbers are qty/rate columns.\n if (preg_match('/\\p{L}/u', $text) && mb_strlen($text) > mb_strlen($description)) {\n $description = $text;\n }\n }\n\n // No text at all: leave it to isNoiseRow()/the AI rather than guessing.\n if ($description === '' || mb_strlen($description) < 3) {\n return false;\n }\n\n try {\n $verdict = $this->boqCleaner->filterItem($description);\n } catch (\\Throwable $e) {\n // A classifier failure must never silently drop a real product.\n Log::warning('QuotationAiService: pre-filter classifier failed, keeping row.', [\n 'error' => $e->getMessage(),\n ]);\n return false;\n }\n\n return ! ($verdict['keep'] ?? true);\n }\n\n private function isNoiseRow(array $row): bool\n {\n if (empty($row)) {\n return true;\n }\n\n $joined = trim(implode('', array_map(fn($c) => trim((string) $c), $row)));\n\n // Nothing but separators/whitespace.\n if ($joined === '') {\n return true;\n }\n\n // Ruled lines and decorative fills (\"-----\", \"====\", \"___\").\n if (preg_match('/^[\\-_=.*\\s|]+$/u', $joined)) {\n return true;\n }\n\n // Any letter — Latin or Arabic — means this could be a description.\n if (preg_match('/\\p{L}/u', $joined)) {\n return false;\n }\n\n // No letters at all: numbers only. A single number is a stray row\n // number or a dangling total; two or more could be qty/rate/amount\n // belonging to a real line, so keep those for the AI to judge.\n $numericCells = 0;\n foreach ($row as $cell) {\n if (trim((string) $cell) !== '') {\n $numericCells++;\n }\n }\n\n return $numericCells <= 1;\n }\n\n /**\n * Safely convert a PhpSpreadsheet cell value to a string.\n *\n * Cells may contain RichText or, in malformed/heavily-formatted files,\n * embedded objects such as Worksheet\\Drawing that have no __toString().\n * Casting those directly throws \"Object ... could not be converted to string\".\n */\n private function cellValueToString(mixed $value): string\n {\n if ($value === null) {\n return '';\n }\n\n if ($value instanceof \\PhpOffice\\PhpSpreadsheet\\RichText\\RichText) {\n return $value->getPlainText();\n }\n\n if (is_object($value)) {\n // Only stringify objects that explicitly support it; ignore the rest\n // (e.g. Worksheet\\Drawing) instead of crashing.\n return method_exists($value, '__toString') ? (string) $value : '';\n }\n\n if (is_array($value)) {\n return '';\n }\n\n return (string) $value;\n }\n\n private function csvEscape(mixed $value): string\n {\n $value = $this->cellValueToString($value);\n return str_contains($value, ',') || str_contains($value, '\"') || str_contains($value, \"\\n\")\n ? '\"' . str_replace('\"', '\"\"', $value) . '\"'\n : $value;\n }\n\n private function verifyLargeBoqItemsWithAi(array $result): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n Log::warning('QuotationAiService: large BOQ verification skipped, AI key is not configured.');\n return $result;\n }\n\n $items = array_values(array_filter($result['items'] ?? [], 'is_array'));\n if (count($items) <= self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n return $result;\n }\n\n Log::info('QuotationAiService: starting second-pass physical item verification.', [\n 'items_count' => count($items),\n 'threshold' => self::LARGE_BOQ_VERIFICATION_THRESHOLD,\n ]);\n\n $verifiedItems = [];\n $rejectedItems = $result['rejected'] ?? [];\n\n foreach (array_chunk($items, self::LARGE_BOQ_VERIFICATION_CHUNK_SIZE) as $chunkIndex => $chunk) {\n $payload = array_map(fn(array $item) => [\n 'description' => (string) ($item['description'] ?? ''),\n 'quantity' => $item['quantity'] ?? null,\n 'unit' => (string) ($item['unit'] ?? ''),\n 'unit_price' => $item['unit_price'] ?? null,\n 'category' => (string) ($item['category'] ?? ''),\n 'brand' => (string) ($item['brand'] ?? ''),\n ], $chunk);\n\n $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);\n if (! is_string($json)) {\n Log::warning('QuotationAiService: large BOQ verification chunk JSON encode failed.', [\n 'chunk' => $chunkIndex + 1,\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n $prompt = <<<PROMPT\nYou are doing a SECOND PASS verification on a BOQ extraction result.\n\nThe first pass may hallucinate non-products when the BOQ has thousands of rows.\nYour job is to keep ONLY physical, tangible, procurable supply products.\n\nRules:\n- Keep items only if a supplier can actually deliver them as materials/equipment.\n- Reject headings, trade names, systems, scopes, prose, specifications-only rows, totals, labor, installation, testing, commissioning, supervision, drawings, insurance, preliminaries, mobilisation, site works, and vague fragments.\n- Do not invent new products.\n- Do not add items that are not in the input.\n- You may clean a product description, but keep the original quantity, unit, unit_price, category, and brand when available.\n\nInput JSON array:\n{$json}\n\nReturn ONLY valid JSON in this shape:\n{\"items\":[{\"description\":\"\",\"quantity\":1,\"unit\":\"\",\"unit_price\":null,\"category\":\"\",\"brand\":\"\",\"confidence\":0.95}]}\nPROMPT;\n\n try {\n $chunkResult = $this->callDeepSeekChat($prompt, $apiKey, $this->deepSeekModel());\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: large BOQ verification chunk threw an exception.', [\n 'chunk' => $chunkIndex + 1,\n 'message' => $e->getMessage(),\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n if ($chunkResult['success'] ?? false) {\n array_push($verifiedItems, ...($chunkResult['items'] ?? []));\n $rejectedItems = array_merge($rejectedItems, $chunkResult['rejected'] ?? []);\n continue;\n }\n\n Log::warning('QuotationAiService: large BOQ verification chunk failed, keeping original chunk.', [\n 'chunk' => $chunkIndex + 1,\n 'error' => $chunkResult['error'] ?? null,\n ]);\n array_push($verifiedItems, ...$chunk);\n }\n\n if (empty($verifiedItems)) {\n Log::warning('QuotationAiService: second-pass verification returned no items, keeping first-pass result.', [\n 'original_items_count' => count($items),\n ]);\n return $result;\n }\n\n $result['items'] = $verifiedItems;\n $result['rejected'] = $rejectedItems;\n $result['large_boq_verified'] = true;\n\n Log::info('QuotationAiService: second-pass physical item verification completed.', [\n 'before' => count($items),\n 'after' => count($verifiedItems),\n ]);\n\n return $result;\n }\n\n private function normaliseAiItem(array $raw): ?array\n {\n if (! empty($raw['rejected'])) {\n return null;\n }\n\n $description = trim((string) ($raw['cleaned_description'] ?? $raw['product_name'] ?? $raw['description'] ?? ''));\n if ($description === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $description)) {\n return null;\n }\n\n $quantity = is_numeric($raw['quantity'] ?? null) ? (float) $raw['quantity'] : 1;\n $unitPrice = is_numeric($raw['unit_price'] ?? null) ? (float) $raw['unit_price'] : null;\n $totalPrice = is_numeric($raw['total_price'] ?? null) ? (float) $raw['total_price'] : null;\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $specs = is_array($raw['specifications'] ?? null) ? $raw['specifications'] : [];\n\n return [\n 'description' => $description,\n 'quantity' => $quantity,\n 'unit' => (string) ($raw['unit'] ?? ''),\n 'category' => (string) ($raw['core_product'] ?? $raw['category'] ?? ''),\n 'brand' => (string) ($specs['brand'] ?? $raw['brand'] ?? ''),\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($description),\n 'unit_price' => $unitPrice,\n 'confidence' => is_numeric($raw['confidence'] ?? null) ? (float) $raw['confidence'] : null,\n 'needs_review' => ! empty($raw['needs_review']),\n 'raw_data' => [\n 'source_item_no' => (string) ($raw['source_item_no'] ?? ''),\n 'original_description' => (string) ($raw['original_description'] ?? $description),\n 'cleaned_description' => $description,\n 'core_product' => (string) ($raw['core_product'] ?? ''),\n 'specifications' => $specs,\n 'extraction_type' => (string) ($raw['extraction_type'] ?? 'supply_only'),\n 'note' => (string) ($raw['note'] ?? $raw['notes'] ?? ''),\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function buildSystemInstruction(): string\n {\n return <<<'TXT'\nYou are a strict BOQ Supply Product Extraction Engine.\nExtract ONLY concrete, tangible, physical products that a supplier can box up and deliver to site — a specific item you could point at and say \"this is the thing being bought\" (e.g. \"Wall mounted LED luminaire 36W\", \"PVC pipe 110mm\", \"Single phase distribution board 12-way\", \"Fire extinguisher CO2 6kg\", \"CCTV dome camera 4MP\").\n\nABSOLUTELY REJECT abstract concepts, disciplines, categories, section names, and trade headings. These are NOT products even though they appear as rows:\n- Bare discipline/trade words: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", \"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\".\n- Summary / total / abstract / \"dry cost\" rows of any kind.\n- Anything that names a system, scope, or category instead of one buyable item.\n\nRule of thumb: if you cannot name a unit and physically hand the item over (a piece, a box, a length, a unit of equipment), REJECT it. A word that describes a field of work, a system, or a section of the BOQ is NEVER a product.\nReject totals, headings, general requirements, installation-only, testing, commissioning, supervision, mobilization, civil works, labor, and vague rows.\nFor Supply & Install rows, keep only the supply product and remove install/labor wording.\nReturn only valid JSON: {\"items\":[{\"source_item_no\":null,\"original_description\":\"\",\"cleaned_description\":\"\",\"core_product\":\"\",\"specifications\":{},\"quantity\":0,\"unit\":\"\",\"unit_price\":0,\"total_price\":0,\"extraction_type\":\"supply_only\",\"needs_review\":false,\"note\":\"\",\"rejected\":false,\"rejection_reason\":null,\"confidence\":0.95}]}\nTXT;\n }\n\n private function buildDeepSeekPrompt(array $context = [], string $mime = ''): string\n {\n $projectName = (string) ($context['project_name'] ?? '');\n $projectStatus = (string) ($context['project_status'] ?? '');\n $contextLine = $projectName !== ''\n ? \"Project: {$projectName}\" . ($projectStatus !== '' ? \" ({$projectStatus})\" : '')\n : '';\n $sourceHint = str_starts_with($mime, 'image/')\n ? 'The input is an image of a BOQ table. OCR every cell carefully — read the quantity/count column (العدد, الكمية, Qty) and extract the EXACT numeric value from each row.'\n : 'The input is a BOQ document/spreadsheet.';\n\n return trim(<<<PROMPT\nYou are a procurement specialist. Your task is to extract ONLY tangible, physical supply products from this Bill of Quantities (BOQ).\n\n{$contextLine}\n{$sourceHint}\n\nCRITICAL — MERGE WRAPPED / SPLIT ROWS INTO ONE ITEM:\nBOQ tables extracted from PDF often break ONE item's long description across several\nlines/rows. These continuation lines repeat the SAME quantity, unit and category, and\ntheir text is a grammatical continuation of the previous line (it starts mid-sentence,\nlowercase, or with \"the/and/-\", or is a spec/instruction clause). You MUST reconstruct\nthe original single item:\n- Treat consecutive rows that share the SAME quantity + unit + category as ONE product\n when the later rows are clearly continuation text of the first, NOT new products.\n- Emit ONE item with the merged description and the price counted ONCE. Do NOT repeat\n the same quantity×price for every wrapped line — that multiplies the total wrongly.\n- Example: rows \"The asphalt flooring is to be supplied\" / \"installed in accordance\" /\n \"standard road specifications...\" all with 7,100 m2 are ONE asphalt item, not 3+.\n\nCRITICAL — UNDERSTAND INTENT, DO NOT COPY ROWS LITERALLY:\nYour job is to output WHAT A SUPPLIER MUST ACTUALLY DELIVER, not a copy of the row text.\nRead each description for MEANING and decide how many real, separately-buyable products it\nimplies. One BOQ row can imply SEVERAL products, or a spread of rows can imply ONE product.\n- SPLIT a row into multiple items when it lists distinct products that a supplier would\n buy/price separately — even when joined by commas, \"with\", \"and\", \"including\", \"c/w\",\n \"consisting of\", \"comprising\", or \":\" then a list.\n Example: \"Fire pump set consisting of: fire pump, electric motor, control panel, diesel\n engine\" → FOUR items: (1) fire pump, (2) electric motor, (3) control panel, (4) diesel\n engine — each with its own quantity and priced on its own.\n Example: \"Wash basin complete with mixer tap and bottle trap\" → THREE items: wash basin,\n mixer tap, bottle trap.\n- Give each split product the correct quantity implied by the text (usually the parent\n quantity, unless the text states a different count for a sub-item). Each product is\n priced individually; the line total becomes the SUM of its products.\n- DO NOT split when the extra words are just specifications, dimensions, colours, ratings,\n standards, or a single product's integral parts. \"Steel door 900x2100 with vision panel\n and SS ironmongery\" is usually ONE door product — vision panel and ironmongery are part\n of that door, not separately-bought items. Use judgement: would a supplier issue a\n separate price line for it? If no, keep it as one.\n- Balanced approach: split only when genuinely separate products are present; when in doubt\n about whether something is a separate product vs. an accessory/spec of the main one, keep\n it as a single item. Do not invent products that are not in the text.\n\nSTRICT RULES — include ONLY items that are ALL of the following:\n1. A specific, concrete, tangible product that can be boxed up by a supplier and delivered to site — something you can point at and say \"this exact thing is being bought\" (e.g. \"LED luminaire 36W\", \"PVC pipe 110mm\", \"Distribution board 12-way\", \"CO2 fire extinguisher 6kg\").\n2. Has a real quantity (not 0 or \"varies\" with no meaning).\n3. Has enough description to identify what the product is.\n4. Is an actual PRODUCT, not a sentence, instruction, or specification clause. REJECT rows that are prose such as \"the supervising engineer's instructions\", \"The price includes all materials, labor\", \"Measurement will be based on the geometric cubic meter\", \"necessary steps to complete the work\", \"soil-contacting elements\", \"in compliance\", \"everything necessary\". These are description fragments, not buyable items.\n\nNEVER extract abstract concepts, disciplines, trades, systems, or section/category names — they are NOT products even when they appear as their own row:\n- Bare discipline/trade/system words such as: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", and their Arabic equivalents (\"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\", \"تشطيبات\").\n- Test: if the word names a FIELD OF WORK or a SYSTEM rather than one buyable item with a unit (pcs, m, set, kg…), REJECT it.\n\nEXCLUDE all of the following (do NOT include them):\n- Labor, installation, or workmanship items (e.g. \"install\", \"fix\", \"erect\", \"dismantle\", \"painting\", \"cleaning service\")\n- Project management, overhead, or profit items\n- Insurance, bonds, liability items (e.g. \"bodily injury\", \"third party liability\", \"CAR insurance\")\n- Shop drawings, as-built drawings, method statements, documentation\n- Preliminary items, mobilisation, demobilisation, provisional sums\n- Housekeeping, site clearance, waste removal\n- Floor/level labels (e.g. \"GF Floor\", \"Level 3\") — these are headers, not products\n- Pure dimension specs without a product noun (e.g. \"Dia 32 mm\", \"25 mm\") — reject unless a product name is present\n- Security services, night shifts, rentals, payments\n- Any item whose description is too vague or fragmentary to identify a specific product\n\nEmit ONE JSON item PER real product. A single source row may therefore produce several\nitems (when it lists multiple products) or several rows may collapse into one item (when\nthey are wrapped fragments of the same product). The \"items\" array is your final delivery\nlist, not a row-by-row transcript.\n\nFor each valid physical product return JSON with:\n- description: clean product name only (strip \"supply and install\", \"supply and fix\", installation clauses, floor/spec suffixes). For a split product, use just that product's own name.\n- quantity: the EXACT numeric value from the quantity/count column (العدد, الكمية, Qty, Quantity, No.). NEVER default to 1 unless the quantity column is truly absent or empty.\n- unit: unit of measure (pcs, m, m2, m3, set, no, kg, etc.)\n- category: product category\n- brand: brand/manufacturer if mentioned, else \"\"\n- engineering_required: true if the item needs engineering design before procurement (panel boards, chillers, generators, fire suppression systems, BMS, pumps, AHUs, switchgear, transformers), else false\n\nReturn ONLY a valid JSON object: {\"items\": [...]}. No explanations, no markdown, no extra text.\nPROMPT);\n }\n\n private function mockResponse(): array\n {\n return [\n 'success' => true,\n 'error' => null,\n 'items' => [[\n 'description' => 'Sample BOQ Item',\n 'quantity' => 1,\n 'unit' => 'No',\n 'category' => 'Sample',\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 1,\n 'raw_data' => [],\n 'ai_extracted' => true,\n ]],\n 'rejected' => [],\n ];\n }\n\n private function failure(string $message, bool $serviceUnavailable = false): array\n {\n return [\n 'success' => false,\n 'items' => [],\n 'rejected' => [],\n 'error' => $this->userFacingError($message, $serviceUnavailable),\n 'service_unavailable' => $serviceUnavailable,\n ];\n }\n\n /**\n * Translate any internal error into a neutral, user-safe message.\n *\n * The user must never see the name of the underlying AI/processing engine\n * (DeepSeek, Groq, Gemini, Vision, etc.) or configuration hints like API\n * keys. The detailed message is kept in the logs for debugging only.\n */\n private function userFacingError(string $message, bool $serviceUnavailable = false): string\n {\n // Detect any reference to an AI provider / engine / API config so we can\n // swap it for a generic, branded-neutral message.\n $sensitive = '/deepseek|groq|gemini|vision|openai|openrouter|api[\\s_-]?key|\\.env|non-?json|token limit/i';\n\n if (preg_match($sensitive, $message)) {\n Log::warning('QuotationAiService: suppressed technical error from user.', ['internal' => $message]);\n\n return $serviceUnavailable\n ? 'The file could not be processed right now. Please try again in a moment.'\n : 'We could not read this file automatically. Please make sure it is a clear BOQ (Excel, CSV, PDF, or image) and try again, or add the items manually.';\n }\n\n return $message;\n }\n\n private function mimeForExtension(string $ext): string\n {\n return match (strtolower($ext)) {\n 'pdf' => 'application/pdf',\n 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n 'xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12',\n 'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',\n 'xls' => 'application/vnd.ms-excel',\n 'csv' => 'text/csv',\n 'jpg', 'jpeg' => 'image/jpeg',\n 'png' => 'image/png',\n 'gif' => 'image/gif',\n 'bmp' => 'image/bmp',\n 'webp' => 'image/webp',\n 'tiff', 'tif' => 'image/tiff',\n 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'doc' => 'application/msword',\n default => 'application/octet-stream',\n };\n }\n}\n"}, "8baa8a9d7297d411c83559c12ddd3811451c0dbe": {"t": 1785748353.6509032, "n": 97476, "h": "2f66657b490f0820", "c": "<?php\n\nnamespace App\\Services;\n\nuse Illuminate\\Http\\Client\\ConnectionException;\nuse Illuminate\\Http\\UploadedFile;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\nuse PhpOffice\\PhpSpreadsheet\\Cell\\Coordinate;\nuse PhpOffice\\PhpSpreadsheet\\IOFactory;\n\nclass QuotationAiService\n{\n /** Increment this whenever BOQ parsing logic changes to invalidate old caches. */\n private const PARSER_VERSION = 10;\n private const LARGE_BOQ_VERIFICATION_THRESHOLD = 2000;\n private const LARGE_BOQ_VERIFICATION_CHUNK_SIZE = 120;\n\n /**\n * Characters of document text sent per AI call.\n *\n * Above this the document is split and parsed over several calls. Sized well\n * under the model's context so the prompt, the slice, and a full JSON answer\n * for every row in it all fit — the binding limit is the *output* budget, not\n * the input, since each input row produces a JSON object.\n */\n private const TEXT_CHUNK_CHARS = 60000;\n\n /**\n * A workbook with more sheets than this is split regardless of its size.\n *\n * Multiple tabs almost always mean multiple disciplines (electrical, HVAC,\n * civil…). Sending them in one call makes the model summarise across\n * sections and silently drop rows, so each slice gets a narrower scope.\n */\n private const MAX_SHEETS_PER_CALL = 2;\n\n /** A PDF longer than this is split regardless of its extracted text length. */\n private const MAX_PDF_PAGES_PER_CALL = 5;\n\n /**\n * Largest PDF we will hand to the parser (12 MB).\n *\n * smalot/pdfparser decompresses every stream into memory with no ceiling of\n * its own, and overrunning the limit there is a fatal error that kills the\n * worker rather than an exception we can recover from.\n */\n private const MAX_PDF_BYTES = 12582912;\n\n /**\n * Memory the parser is assumed to need, as a multiple of the file size.\n *\n * Decompressed content streams plus the object graph run well past the raw\n * bytes; 8x is conservative enough to catch the cases that were dying.\n */\n private const PDF_MEMORY_FACTOR = 8;\n\n /** Returned by extractPdfText when it stopped for memory, not for lack of text. */\n private const PDF_LOW_MEMORY = \"\\0__pdf_low_memory__\";\n\n private string $baseUrl;\n private string $parseEndpoint;\n private string $apiKey;\n private int $timeout;\n private bool $testMode;\n private BoqCleaningService $boqCleaner;\n\n /** Page count of the most recently parsed PDF; 0 when unknown. */\n private int $lastPdfPageCount = 0;\n\n /**\n * Optional progress reporter for chunked parsing, called as ($part, $total).\n *\n * Set by the queue job so the polling UI can show real progress on a large\n * BOQ instead of a spinner that appears frozen for many minutes.\n *\n * @var (callable(int, int): void)|null\n */\n private $onChunkProgress = null;\n\n /** @param callable(int, int): void $callback */\n public function onChunkProgress(callable $callback): self\n {\n $this->onChunkProgress = $callback;\n return $this;\n }\n\n /**\n * Optional per-chunk item sink, called as ($items, $part, $total).\n *\n * Lets the caller persist each slice's rows the moment they arrive instead\n * of waiting for the whole document. On a large BOQ this is the difference\n * between a table that fills in progressively and one that stays empty for\n * many minutes.\n *\n * @var (callable(array, int, int): void)|null\n */\n private $onChunkItems = null;\n\n /** @param callable(array, int, int): void $callback */\n public function onChunkItems(callable $callback): self\n {\n $this->onChunkItems = $callback;\n return $this;\n }\n\n /**\n * Report a non-chunk stage (local parse, handing off to AI) to the caller.\n *\n * A spreadsheet is read locally first and only sent to the AI when that\n * fails, so a large file can spend minutes before any chunk exists. Without\n * these the UI shows a generic spinner for that whole period.\n */\n private function reportStage(string $message): void\n {\n if ($this->onStage !== null) {\n ($this->onStage)($message);\n }\n }\n\n /**\n * Report a workbook's shape from grids that are already in memory.\n *\n * Deliberately takes the parsed grids rather than a path: re-reading a large\n * spreadsheet just to count its sheets would double the slowest step of the\n * whole extraction.\n *\n * @param array<int, array{name: string, grid: array}> $sheets\n */\n private function reportWorkbookShape(array $sheets): void\n {\n if ($this->onStage === null || empty($sheets)) {\n return;\n }\n\n $rows = 0;\n foreach ($sheets as $sheet) {\n $rows += count($sheet['grid']);\n }\n\n $count = count($sheets);\n\n $this->reportStage($count > 1\n ? \"Found {$count} sheets, {$rows} rows in total. Reading…\"\n : \"Found {$rows} rows. Reading…\");\n }\n\n /** @var (callable(string): void)|null */\n private $onStage = null;\n\n /** @param callable(string): void $callback */\n public function onStage(callable $callback): self\n {\n $this->onStage = $callback;\n return $this;\n }\n\n public function __construct(BoqCleaningService $boqCleaner)\n {\n $this->boqCleaner = $boqCleaner;\n $this->baseUrl = rtrim((string) config('services.ai_quotation.base_url', ''), '/');\n $this->parseEndpoint = ltrim((string) config('services.ai_quotation.parse_endpoint', 'parse'), '/');\n $this->apiKey = (string) config('services.ai_quotation.api_key', '');\n $this->timeout = (int) config('services.ai_quotation.timeout', 300);\n $this->testMode = (bool) config('services.ai_quotation.test_mode', false);\n }\n\n /**\n * Give the running script enough head-room to outlast the AI HTTP call.\n *\n * The web request / queue worker must not be killed before the HTTP timeout\n * completes, otherwise the user sees a generic \"timeout or unavailable\"\n * error even though the AI may have responded. We allow the HTTP timeout\n * plus a 120s margin for connecting, response parsing and DB writes.\n */\n private function extendExecutionTime(): void\n {\n // Ignored on some SAPIs (e.g. when running under fastcgi with a hard\n // limit), but harmless to attempt.\n @set_time_limit($this->timeout + 120);\n }\n\n public function parseBoq(UploadedFile|string $file, array $context = []): array\n {\n if ($this->testMode) {\n return $this->mockResponse();\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n\n if (! is_file($absPath)) {\n return $this->failure('File not found or not readable.');\n }\n\n $fileHash = hash_file('sha256', $absPath);\n // Bump PARSER_VERSION whenever the extraction logic changes so that\n // previously-uploaded files are re-parsed instead of returning a stale\n // cached result produced by the old code.\n $cacheKey = 'boq_analysis_v' . self::PARSER_VERSION . '_' . $fileHash;\n $forceRefresh = (bool) ($context['force_refresh'] ?? false);\n\n if (! $forceRefresh && ($cached = Cache::get($cacheKey)) !== null) {\n return $cached;\n }\n\n // Clear stale cached result when force-refreshing\n if ($forceRefresh) {\n Cache::forget($cacheKey);\n }\n\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n // Read Excel locally first. Fall back to AI if local parse failed,\n // found 0 items, OR could not read one of the sheets (so multi-sheet\n // files never silently drop a sheet the local parser didn't understand).\n $this->reportStage('Reading the spreadsheet…');\n\n $result = $this->parseSpreadsheetDirect($absPath);\n\n $hasSkippedSheets = ! empty($result['skipped_sheets']);\n\n if (! $result['success'] || empty($result['items']) || $hasSkippedSheets) {\n // The local pass could not handle this file on its own. Say so:\n // otherwise the UI sits on a generic spinner through the local\n // parse AND the whole AI parse with nothing to show for either.\n $this->reportStage($hasSkippedSheets\n ? 'Some sheets need AI to read. Sending to AI…'\n : 'Spreadsheet needs AI to read. Sending to AI…');\n\n $deepSeekResult = $this->parseBoqWithDeepSeek($file, $context);\n // AI sees every sheet at once, so prefer its result whenever it\n // actually returned items (covers the skipped-sheet case too).\n if ($deepSeekResult['success'] && ! empty($deepSeekResult['items'])) {\n // Keep the larger of the two item sets so we never lose the\n // rows the local parser already extracted correctly.\n if (count($deepSeekResult['items']) >= count($result['items'] ?? [])) {\n $result = $deepSeekResult;\n }\n } elseif (! $result['success']) {\n $result = $deepSeekResult;\n }\n // else: keep local result if AI also failed/returned nothing\n }\n } elseif (in_array($ext, ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif'], true)) {\n $result = $this->parseBoqWithDeepSeek($file, $context);\n } else {\n return $this->failure('Unsupported file type. Please upload Excel, CSV, PDF, or image file.');\n }\n\n if ($result['success'] && ! empty($result['items'])) {\n $cleaning = $this->boqCleaner->process($result['items']);\n $result['items'] = $cleaning['accepted'] ?? $result['items'];\n $result['rejected'] = array_merge($result['rejected'] ?? [], $cleaning['rejected'] ?? []);\n }\n\n if ($result['success'] && count($result['items'] ?? []) > self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n $result = $this->verifyLargeBoqItemsWithAi($result);\n }\n\n // Only cache when we actually extracted items; empty results should be re-tried.\n if ($result['success'] && ! empty($result['items'])) {\n Cache::put($cacheKey, $result, now()->addDays(30));\n }\n\n return $result;\n }\n\n private function resolveFile(UploadedFile|string $file): array\n {\n if ($file instanceof UploadedFile) {\n return [(string) $file->getRealPath(), strtolower($file->getClientOriginalExtension())];\n }\n\n $absPath = is_file($file) ? $file : storage_path('app/' . ltrim($file, '/'));\n return [$absPath, strtolower(pathinfo($absPath, PATHINFO_EXTENSION))];\n }\n\n private function parseSpreadsheetDirect(string $absPath): array\n {\n ini_set('memory_limit', '2048M');\n\n // 180s was not enough for a real BOQ: an 11 200-row workbook was killed\n // mid-parse, and because set_time_limit() kills the process outright the\n // job reported a bare failure with nothing in the log to explain it.\n // This runs on the queue, where wall time is cheap.\n set_time_limit(1800);\n\n try {\n $sheets = $this->spreadsheetToGrids($absPath);\n\n if (empty($sheets)) {\n return $this->failure('Could not read the Excel file.');\n }\n\n $this->reportWorkbookShape($sheets);\n\n $items = [];\n $rejected = [];\n $skippedSheets = []; // sheets we could not parse locally (header undetected)\n\n foreach ($sheets as $sheet) {\n $grid = $sheet['grid'];\n if (count($grid) < 2) {\n continue;\n }\n\n // Skip dedicated summary / cost-abstract sheets entirely. Their rows\n // are per-discipline totals (Structural Concrete, Electrical, …) and\n // grand totals, not procurable products.\n if ($this->isSummarySheet($sheet['name'])) {\n Log::info('QuotationAiService: skipped summary sheet.', ['sheet' => $sheet['name']]);\n continue;\n }\n\n $header = $this->detectHeader($grid);\n\n if ($header === null || ! isset($header['map']['description'])) {\n Log::warning('QuotationAiService: header not detected in sheet.', ['sheet' => $sheet['name']]);\n $skippedSheets[] = $sheet['name'];\n continue;\n }\n\n $map = $header['map'];\n $start = $header['row'] + 1;\n\n for ($r = $start; $r < count($grid); $r++) {\n $row = $grid[$r];\n $description = $this->cell($row, $map['description'] ?? null);\n $itemNo = $this->cell($row, $map['item_code'] ?? null);\n $unit = $this->cell($row, $map['unit'] ?? null);\n $quantity = $this->number($this->cell($row, $map['quantity'] ?? null));\n $unitPrice = $this->number($this->cell($row, $map['unit_price'] ?? null));\n $totalPrice = $this->number($this->cell($row, $map['total_price'] ?? null));\n\n if ($description === '') {\n continue;\n }\n\n if (! isset($map['quantity'])) {\n $quantity = 1.0;\n }\n\n // Skip floor breakdown sub-rows: Ref. is \"-\" meaning it's a per-floor\n // quantity split of the parent item, not a standalone product.\n if ($itemNo === '-' || $this->isFloorBreakdownRow($itemNo, $description)) {\n continue;\n }\n\n if ($this->isTotalOrHeaderRow($description)) {\n continue;\n }\n\n if ($quantity === null || $quantity <= 0) {\n $rejected[] = $this->rejectedRow($description, $unit, $quantity, 'Missing quantity', $itemNo, $sheet['name']);\n continue;\n }\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $supply = $this->boqCleaner->filterItem($description, $unitPrice, $quantity);\n\n if (! ($supply['keep'] ?? false)) {\n $rejected[] = $this->rejectedRow(\n $description,\n $unit,\n $quantity,\n (string) ($supply['rejection_reason'] ?? 'Rejected by cleaner'),\n $itemNo,\n $sheet['name']\n );\n continue;\n }\n\n $finalDescription = (string) ($supply['description'] ?? $description);\n\n $items[] = [\n 'description' => $finalDescription,\n 'quantity' => $quantity,\n 'unit' => $unit,\n 'category' => $sheet['name'],\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($finalDescription),\n 'unit_price' => $unitPrice,\n 'confidence' => 0.95,\n 'needs_review' => false,\n 'raw_data' => [\n 'sheet' => $sheet['name'],\n 'row_number' => $r + 1,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'cleaned_description' => $finalDescription,\n 'extraction_type' => (string) ($supply['extraction_type'] ?? 'supply_only'),\n ],\n 'ai_extracted' => true,\n ];\n }\n }\n\n if (empty($items)) {\n return $this->failure('No BOQ supply items could be extracted from the Excel file.');\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'skipped_sheets' => $skippedSheets,\n ];\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: parseSpreadsheetDirect failed.', [\n 'path' => $absPath,\n 'message' => $e->getMessage(),\n 'trace' => $e->getTraceAsString(),\n ]);\n\n return $this->failure('Failed to read the Excel file: ' . $e->getMessage());\n }\n }\n\n private function spreadsheetToGrids(string $absPath): array\n {\n $ext = strtolower(pathinfo($absPath, PATHINFO_EXTENSION));\n\n if ($ext === 'csv') {\n $rows = [];\n $handle = fopen($absPath, 'rb');\n if ($handle !== false) {\n while (($row = fgetcsv($handle)) !== false) {\n $rows[] = $this->trimTrailingEmptyCells($row);\n }\n fclose($handle);\n }\n return [['name' => 'Sheet1', 'grid' => $rows]];\n }\n\n $reader = IOFactory::createReader($ext === 'xls' ? 'Xls' : 'Xlsx');\n $reader->setReadDataOnly(true);\n $spreadsheet = $reader->load($absPath);\n $sheets = [];\n\n foreach ($spreadsheet->getAllSheets() as $sheet) {\n $highestRow = $sheet->getHighestDataRow();\n $highestColumn = $sheet->getHighestDataColumn();\n $highestColumnIndex = Coordinate::columnIndexFromString($highestColumn);\n\n // BOQ data is normally in first 8-20 columns.\n // Files with heavy formatting/images can report 300+ columns — limit to 30.\n $maxColumnIndex = min($highestColumnIndex, 30);\n\n $grid = [];\n for ($row = 1; $row <= $highestRow; $row++) {\n $rowData = [];\n $hasValue = false;\n\n for ($col = 1; $col <= $maxColumnIndex; $col++) {\n $value = $sheet->getCell([$col, $row])->getValue();\n $value = $this->cellValueToString($value);\n $value = trim($value);\n if ($value !== null && $value !== '') {\n $hasValue = true;\n }\n $rowData[] = $value;\n }\n\n $rowData = $this->trimTrailingEmptyCells($rowData);\n if ($hasValue) {\n $grid[] = $rowData;\n }\n }\n\n if (! empty($grid)) {\n $sheets[] = ['name' => $sheet->getTitle(), 'grid' => $grid];\n }\n }\n\n $spreadsheet->disconnectWorksheets();\n unset($spreadsheet);\n\n return $sheets;\n }\n\n private function detectHeader(array $grid): ?array\n {\n $keywords = [\n 'item_code' => ['رقم البند', 'item no', 'item number', 'ref.', 'ref', 'item', 'no.', 'no', '#', 'كود'],\n 'description' => ['وص٠البند', 'scope of works', 'scope of work', 'scope', 'item description', 'description of works', 'description', 'بالعربية', 'الوصÙ', 'البند', 'بيان'],\n 'unit' => ['Ø§Ù„ÙˆØØ¯Ø©', 'unit', 'uom', 'u/m'],\n 'quantity' => ['الكمية', 'qty', 'quantity', 'q.ty'],\n 'unit_price' => ['سعر Ø§Ù„ÙˆØØ¯Ø©', 'u/price', 'unit price', 'unit rate', 'rate', 'price'],\n 'total_price' => ['السعر الإجمالي', 'total amount', 'total price', 'amount', 'الإجمالي'],\n ];\n\n $best = null;\n $bestScore = 0;\n\n for ($r = 0; $r < min(30, count($grid)); $r++) {\n $map = [];\n $score = 0;\n\n foreach ($grid[$r] as $c => $value) {\n $cell = mb_strtolower(trim((string) $value));\n if ($cell === '') {\n continue;\n }\n\n foreach ($keywords as $field => $list) {\n foreach ($list as $kw) {\n if (str_contains($cell, mb_strtolower($kw))) {\n if (! isset($map[$field])) {\n $map[$field] = $c;\n $score++;\n }\n break 2;\n }\n }\n }\n }\n\n if ($score > $bestScore) {\n $bestScore = $score;\n $best = ['row' => $r, 'map' => $map];\n }\n }\n\n if ($bestScore >= 3) {\n return $best;\n }\n\n // Allow simpler BOQ sheets that only define a description column plus one\n // other BOQ-related column, e.g. Item + Description or Description + Qty.\n if ($bestScore === 2 && isset($best['map']['description']) && (\n isset($best['map']['item_code']) ||\n isset($best['map']['quantity']) ||\n isset($best['map']['unit'])\n )) {\n return $best;\n }\n\n return null;\n }\n\n private function cell(array $row, ?int $index): string\n {\n if ($index === null) {\n return '';\n }\n return trim((string) ($row[$index] ?? ''));\n }\n\n private function number(string $value): ?float\n {\n $value = trim(str_replace([',', 'SAR', 'ر.س'], '', $value));\n if ($value === '' || ! is_numeric($value)) {\n return null;\n }\n return (float) $value;\n }\n\n private function trimTrailingEmptyCells(array $row): array\n {\n while (! empty($row) && trim((string) end($row)) === '') {\n array_pop($row);\n }\n return array_values($row);\n }\n\n private function isTotalOrHeaderRow(string $description): bool\n {\n $d = mb_strtolower(trim($description));\n\n if ($d === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $d)) {\n return true;\n }\n\n // Section totals / summaries\n if (preg_match('/(اجمالي|إجمالي|المجموع|grand\\s*total|sub\\s*total|total\\s*amount|total\\s*price)/iu', $d)) {\n return true;\n }\n\n // Bare \"Total\" lines, optionally with a currency suffix, e.g. \"Total\",\n // \"Total (SAR)\", \"Total cost\", \"Total (SAR) unit cost\".\n if (preg_match('/^total\\b[\\s\\(\\):a-z]*$/iu', $d)) {\n return true;\n }\n\n // Pure floor / level labels used as section headers\n if (preg_match('/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor)\\s*$/iu', $d)) {\n return true;\n }\n\n // Division / section header lines (e.g. \"Division - 02: Wood Works\")\n if (preg_match('/^division\\s*([-:]|\\d)/iu', $d)) {\n return true;\n }\n\n return false;\n }\n\n /**\n * Detect dedicated summary / cost-abstract sheets by their name. These hold\n * per-discipline rollups and grand totals (e.g. a \"Summary (Dry Cost)\" tab),\n * never procurable supply items, so they are skipped wholesale.\n */\n private function isSummarySheet(string $name): bool\n {\n $n = mb_strtolower(trim($name));\n\n if ($n === '') {\n return false;\n }\n\n return (bool) preg_match(\n '/\\b(summary|abstract|cost\\s*summary|grand\\s*summary|recap(?:itulation)?|bill\\s*summary|boq\\s*summary|dry\\s*cost)\\b|ملخص|الملخص|الملخّص|ملخّص|اجمالي|إجمالي/iu',\n $n\n );\n }\n\n private function isFloorBreakdownRow(string $itemNo, string $description): bool\n {\n // A row whose Ref column is empty or a dash is a sub-breakdown row (per-floor split).\n $ref = trim($itemNo);\n if ($ref === '' || $ref === '-') {\n // Confirm by checking the description is a location/floor label\n $d = mb_strtolower(trim($description));\n return (bool) preg_match(\n '/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor|\\d+f)\\s*$/iu',\n $d\n );\n }\n return false;\n }\n\n private function rejectedRow(string $description, string $unit, ?float $quantity, string $reason, string $itemNo = '', string $sheet = ''): array\n {\n return [\n 'description' => $description,\n 'quantity' => $quantity ?? 0,\n 'unit' => $unit,\n 'category' => $sheet,\n 'brand' => '',\n 'status' => 'rejected',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 0.0,\n 'raw_data' => [\n 'sheet' => $sheet,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'extraction_type' => 'rejected',\n 'rejection_reason' => $reason,\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function parseBoqWithDeepSeek(UploadedFile|string $file, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured. Please set DEEPSEEK_API_KEY in .env file.');\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n $mime = $this->mimeForExtension($ext);\n\n try {\n // ── Spreadsheets ──────────────────────────────────────────────────\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n $text = $this->spreadsheetToCompactCsvText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not convert spreadsheet to readable text.');\n }\n\n $text = $this->sanitizeUtf8($text);\n\n // A large BOQ exceeds what one request can carry, so send it in\n // sequential slices and merge. Anything that fits goes as-is.\n //\n // Size alone is not a good enough signal: a workbook with several\n // sheets is structurally a big BOQ even when the text is compact,\n // and asking the model to hold every discipline at once is where\n // rows start getting dropped. So split on either signal.\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS || $sheetCount > self::MAX_SHEETS_PER_CALL) {\n Log::info('QuotationAiService: spreadsheet routed to chunked parsing.', [\n 'chars' => mb_strlen($text),\n 'sheets' => $sheetCount,\n ]);\n return $this->parseTextInChunks($text, 'BOQ spreadsheet converted to CSV', $apiKey, $context);\n }\n\n $userContent = \"BOQ spreadsheet converted to CSV:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── PDF ───────────────────────────────────────────────────────────\n if ($ext === 'pdf') {\n $extracted = $this->extractPdfText($absPath);\n\n if ($extracted === self::PDF_LOW_MEMORY) {\n return $this->failure('The server was low on memory while reading this PDF. Please try again in a moment, or convert it to Excel or CSV.');\n }\n\n if ($extracted !== null && mb_strlen(trim($extracted)) > 50) {\n $extracted = $this->sanitizeUtf8($extracted);\n\n // Split on either signal — long text, or many pages. A BOQ\n // spanning several pages is a multi-section document even if\n // each page is sparse.\n if (mb_strlen($extracted) > self::TEXT_CHUNK_CHARS\n || $this->lastPdfPageCount > self::MAX_PDF_PAGES_PER_CALL) {\n Log::info('QuotationAiService: PDF routed to chunked parsing.', [\n 'chars' => mb_strlen($extracted),\n 'pages' => $this->lastPdfPageCount,\n ]);\n return $this->parseTextInChunks($extracted, 'BOQ PDF extracted text', $apiKey, $context, 'application/pdf');\n }\n\n $userContent = \"BOQ PDF extracted text:\\n\\n\" . $extracted . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'application/pdf');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // No text — smalot threw and pdftotext was missing or empty.\n // Last resort: hand the PDF straight to a vision model, which\n // reads it as an image and needs no local binary at all. This is\n // what covers a server without pdftotext installed.\n if ($this->hasVisionKey()) {\n Log::info('QuotationAiService: PDF text extraction failed; trying vision.', [\n 'path' => basename($absPath),\n ]);\n\n $bytes = @file_get_contents($absPath);\n $vision = $bytes !== false\n ? $this->callDeepSeekVision($bytes, 'application/pdf', $context)\n : ['success' => false];\n\n if ($vision['success'] ?? false) {\n return $vision;\n }\n }\n\n return $this->failure('Could not read this PDF. It may be a scanned image, or use a format our reader cannot open. Please convert it to Excel or CSV and upload again.');\n }\n\n // -- Word documents (docx) ---------------------------------------------------\n if (in_array($ext, ['docx', 'doc'], true)) {\n $text = $this->extractDocxText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not extract text from the Word document. Please convert it to Excel, CSV, or PDF.');\n }\n $text = $this->sanitizeUtf8($text);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS) {\n return $this->parseTextInChunks($text, 'BOQ Word document extracted text', $apiKey, $context);\n }\n\n $userContent = \"BOQ Word document extracted text:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── Images ──────────────────────────────────────────────────────────────\n // DeepSeek cannot receive images. A vision-capable AI processes the image\n // directly (no OCR) — configure GROQ_API_KEY or GEMINI_API_KEY in .env.\n $imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif', 'heic', 'heif'];\n if (in_array($ext, $imageExts, true)) {\n $bytes = (string) file_get_contents($absPath);\n return $this->callDeepSeekVision($bytes, $mime, $context);\n }\n\n return $this->failure('Unsupported file type. Please upload an Excel, CSV, PDF, or image file.');\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: DeepSeek fallback failed.', ['message' => $e->getMessage()]);\n return $this->failure('An error occurred while processing the file with AI: ' . $e->getMessage());\n }\n }\n\n private function deepSeekModel(): string\n {\n return (string) config('services.deepseek.model', 'deepseek-chat');\n }\n\n /**\n * Remove or replace any non-UTF-8 / malformed byte sequences so json_encode never fails.\n */\n private function sanitizeUtf8(string $text): string\n {\n // Replace invalid byte sequences with the UTF-8 replacement character\n $clean = mb_convert_encoding($text, 'UTF-8', 'UTF-8');\n // Strip any remaining non-printable control characters (except tab/newline/CR)\n $clean = (string) preg_replace('/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]/u', '', $clean);\n return $clean;\n }\n\n /**\n * Extract plain text from a Word docx file using ZipArchive (no external library needed).\n * Reads word/document.xml and strips XML tags, preserving paragraph and table structure.\n */\n private function extractDocxText(string $absPath): ?string\n {\n try {\n $zip = new \\ZipArchive();\n if ($zip->open($absPath) !== true) {\n return null;\n }\n\n $xml = $zip->getFromName('word/document.xml');\n $zip->close();\n\n if ($xml === false || $xml === '') {\n return null;\n }\n\n // Add newlines before paragraph/table-row/table-cell tags so structure is preserved\n $xml = preg_replace('/<w:p[ >]/', \"\\n<w:p>\", $xml) ?? $xml;\n $xml = preg_replace('/<w:tr[ >]/', \"\\n<w:tr>\", $xml) ?? $xml;\n $xml = preg_replace('/<\\/w:tc>/', \"\\t\", $xml) ?? $xml;\n\n $text = strip_tags($xml);\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n return trim($text) !== '' ? trim($text) : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: DOCX parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Extract plain text from a PDF using smalot/pdfparser (pure PHP, no binary required).\n */\n /**\n * The process memory limit in bytes, or 0 when unlimited.\n *\n * memory_limit is a shorthand string (\"128M\", \"1G\", \"-1\"), so it has to be\n * expanded before it can be compared against anything.\n */\n private function memoryLimitBytes(): int\n {\n $raw = trim((string) ini_get('memory_limit'));\n\n if ($raw === '' || $raw === '-1') {\n return 0;\n }\n\n $value = (int) $raw;\n\n return match (strtolower(substr($raw, -1))) {\n 'g' => $value * 1024 * 1024 * 1024,\n 'm' => $value * 1024 * 1024,\n 'k' => $value * 1024,\n default => $value,\n };\n }\n\n private function extractPdfText(string $absPath): ?string\n {\n // The parser loads and decompresses the whole document before any of our\n // chunking gets a look at it, and it does so with no memory ceiling of\n // its own. A big PDF exhausts the limit inside gzuncompress() — a fatal\n // error, not an exception, so the catch below cannot help and the worker\n // dies outright. Refuse the file first instead.\n $size = @filesize($absPath) ?: 0;\n\n if ($size > self::MAX_PDF_BYTES) {\n Log::warning('QuotationAiService: PDF too large to parse in-process.', [\n 'bytes' => $size,\n 'limit' => self::MAX_PDF_BYTES,\n ]);\n\n return null;\n }\n\n // Headroom check: parsing routinely needs several times the file size\n // once streams are decompressed. If that would not fit in what is left,\n // stop here rather than taking the worker down with us.\n $limit = $this->memoryLimitBytes();\n\n if ($limit > 0) {\n // Try to reclaim memory a previous job left behind before deciding\n // there is not enough. Without this, a worker sitting near its limit\n // after a big job could refuse a tiny PDF for no real reason.\n gc_collect_cycles();\n\n $available = $limit - memory_get_usage(true);\n\n if ($available < $size * self::PDF_MEMORY_FACTOR) {\n Log::warning('QuotationAiService: not enough memory left to parse this PDF.', [\n 'bytes' => $size,\n 'available' => $available,\n 'needed' => $size * self::PDF_MEMORY_FACTOR,\n ]);\n\n // A distinct sentinel, so the caller can tell \"no memory\" apart\n // from \"no text\" and show the right message instead of blaming\n // the file for being scanned.\n return self::PDF_LOW_MEMORY;\n }\n }\n\n try {\n $parser = new \\Smalot\\PdfParser\\Parser();\n $pdf = $parser->parseFile($absPath);\n\n // Recorded for the chunking decision: a long PDF is structurally a\n // big BOQ even when its extracted text is short.\n try {\n $this->lastPdfPageCount = count($pdf->getPages());\n } catch (\\Throwable) {\n $this->lastPdfPageCount = 0;\n }\n\n $text = $pdf->getText();\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n return $text !== '' ? $text : null;\n } catch (\\Throwable $e) {\n // Class, file and line, not just the message — a bare message left\n // the actual cause of a PDF failure guessable at best.\n Log::warning('QuotationAiService: PDF parsing failed.', [\n 'exception' => $e::class,\n 'error' => $e->getMessage(),\n 'at' => $e->getFile() . ':' . $e->getLine(),\n ]);\n\n // smalot/pdfparser throws \"Invalid object reference for $obj.\" on\n // some perfectly valid PDFs — a known bug in the library, not a\n // problem with the file. Fall back to the pdftotext binary, which\n // reads those documents fine.\n return $this->extractPdfTextViaBinary($absPath);\n }\n }\n\n /**\n * Extract PDF text with the poppler `pdftotext` binary.\n *\n * A fallback for when smalot/pdfparser throws on a document it cannot handle\n * — most often \"Invalid object reference for $obj.\" pdftotext is a separate,\n * more robust implementation, so a PDF that breaks one usually reads on the\n * other. Returns null when the binary is absent or produces nothing, so the\n * caller drops to the vision path or a clear error.\n */\n /**\n * True when at least one vision provider is configured.\n *\n * Checked before falling back to vision, so a server with no vision key\n * gets a clear \"convert to Excel\" message rather than a failed API call.\n */\n private function hasVisionKey(): bool\n {\n // Gemini only. Groq's and OpenRouter's vision endpoints expect an image\n // in the data URL, not a raw PDF, so passing a PDF to them fails or is\n // silently ignored. Gemini accepts application/pdf directly.\n return ((string) config('services.gemini.key', '')) !== '';\n }\n\n /**\n * Locate the pdftotext binary.\n *\n * On shared hosting it is rarely on the system PATH, so a user-installed\n * copy — dropped in ~/bin, ~/.local/bin, or the project's vendor/bin — has\n * to be found explicitly. An escaped, quoted absolute path is returned so it\n * is safe to interpolate into the command.\n *\n * PDFTOTEXT_PATH in .env wins, for a copy installed somewhere non-standard.\n */\n private function pdftotextPath(): ?string\n {\n $configured = trim((string) env('PDFTOTEXT_PATH', ''));\n\n $candidates = array_filter([\n $configured !== '' ? $configured : null,\n ($home = getenv('HOME')) ? $home . '/bin/pdftotext' : null,\n $home ? $home . '/.local/bin/pdftotext' : null,\n base_path('vendor/bin/pdftotext'),\n '/usr/bin/pdftotext',\n '/usr/local/bin/pdftotext',\n ]);\n\n foreach ($candidates as $path) {\n if (is_file($path) && is_executable($path)) {\n return escapeshellarg($path);\n }\n }\n\n // Fall back to a bare name, in case it is on PATH after all.\n $onPath = @shell_exec('command -v pdftotext 2>/dev/null');\n\n return is_string($onPath) && trim($onPath) !== '' ? 'pdftotext' : null;\n }\n\n private function extractPdfTextViaBinary(string $absPath): ?string\n {\n // shell_exec is commonly disabled on shared hosting; bail cleanly so the\n // caller falls through to the vision path or a clear error.\n if (! function_exists('shell_exec')\n || in_array('shell_exec', array_map('trim', explode(',', (string) ini_get('disable_functions'))), true)) {\n return null;\n }\n\n $binary = $this->pdftotextPath();\n\n if ($binary === null) {\n Log::warning('QuotationAiService: pdftotext binary not found.', [\n 'looked_in' => 'PATH, PDFTOTEXT_PATH, ~/bin, ~/.local/bin, vendor/bin',\n ]);\n return null;\n }\n\n // `-layout` keeps columns roughly aligned, which matters for a BOQ; the\n // trailing \"-\" writes to stdout instead of a file.\n $command = sprintf('%s -layout -enc UTF-8 %s - 2>/dev/null', $binary, escapeshellarg($absPath));\n\n $output = @shell_exec($command);\n\n if (! is_string($output) || trim($output) === '') {\n Log::warning('QuotationAiService: pdftotext fallback produced no text.', [\n 'path' => basename($absPath),\n ]);\n return null;\n }\n\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $output);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n\n if ($text !== '') {\n Log::info('QuotationAiService: recovered PDF text via pdftotext fallback.', [\n 'path' => basename($absPath),\n 'chars' => mb_strlen($text),\n ]);\n }\n\n return $text !== '' ? $text : null;\n }\n\n /**\n * Send a vision request to a vision-capable AI model.\n * DeepSeek's commercial API (api.deepseek.com) does NOT support image inputs — any model.\n * Priority order (first configured key wins):\n * 1. GROQ_API_KEY → Groq llama-4-scout (free: https://console.groq.com)\n * 2. GEMINI_API_KEY → Google Gemini Flash (free: https://aistudio.google.com/apikey)\n * 3. VISION_API_KEY → any OpenAI-compatible endpoint (OpenAI, OpenRouter …)\n */\n private function callDeepSeekVision(string $bytes, string $mime, array $context): array\n {\n // ── 1. Groq (free, reliable vision) ─────────────────────────────────────\n if ((string) config('services.groq.key', '') !== '') {\n $visionKey = (string) config('services.groq.key');\n $visionBaseUrl = 'https://api.groq.com/openai/v1';\n $visionModel = (string) config('services.groq.model', 'meta-llama/llama-4-scout-17b-16e-instruct');\n }\n // ── 2. Google Gemini ─────────────────────────────────────────────────────\n elseif ((string) config('services.gemini.key', '') !== '') {\n $visionKey = (string) config('services.gemini.key');\n $visionBaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai';\n $visionModel = (string) config('services.gemini.model', 'gemini-2.0-flash');\n }\n // ── 3. Generic VISION_API_KEY (OpenAI, OpenRouter, …) ───────────────────\n elseif ((string) config('services.vision.key', '') !== '') {\n $visionKey = (string) config('services.vision.key');\n $visionBaseUrl = rtrim((string) config('services.vision.base_url', 'https://openrouter.ai/api/v1'), '/');\n $visionModel = (string) config('services.vision.model', 'google/gemini-flash-1.5-8b');\n }\n else {\n return $this->failure(\n 'Image processing is not available. DeepSeek\\'s API does not support images. ' .\n 'Get a free key at https://console.groq.com and add GROQ_API_KEY to your .env file.'\n );\n }\n\n $dataUrl = \"data:{$mime};base64,\" . base64_encode($bytes);\n $prompt = $this->buildDeepSeekPrompt($context, $mime);\n\n $userContent = [\n ['type' => 'text', 'text' => $prompt],\n ['type' => 'image_url', 'image_url' => ['url' => $dataUrl]],\n ];\n\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders([\n 'Authorization' => 'Bearer ' . $visionKey,\n 'HTTP-Referer' => config('app.url', 'https://qimta.sa'),\n 'X-Title' => 'Qimta Platform',\n ])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post($visionBaseUrl . '/chat/completions', [\n 'model' => $visionModel,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 16384,\n 'temperature' => 0,\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('Vision API connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: Vision API HTTP error.', [\n 'model' => $visionModel,\n 'base_url' => $visionBaseUrl,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n return $this->failure(\"Vision API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n return $this->parseDeepSeekJsonResponse((string) $response->json('choices.0.message.content'));\n }\n\n private function callDeepSeekChat(string $userContent, string $key, string $model): array\n {\n // Ensure the PHP script outlives the HTTP call (+ a margin for parsing the\n // response and writing to the DB) so the worker/web request is not killed\n // mid-flight, which surfaces to users as a generic \"timeout or unavailable\".\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders(['Authorization' => 'Bearer ' . $key])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 32768,\n 'temperature' => 0,\n 'response_format' => ['type' => 'json_object'],\n 'user' => 'Qimta_Platform',\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('DeepSeek connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: DeepSeek HTTP error.', [\n 'model' => $model,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n if ($response->status() === 401) {\n return $this->failure('DeepSeek API key is invalid or unauthorised. Please update DEEPSEEK_API_KEY in .env.', false);\n }\n return $this->failure(\"DeepSeek API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n $text = (string) $response->json('choices.0.message.content');\n return $this->parseDeepSeekJsonResponse($text);\n }\n\n /**\n * Parse a document too large for one AI call by sending it in slices.\n *\n * A 20 000-row BOQ is several megabytes of text — far beyond what a single\n * request can carry, and beyond what the model can answer without hitting its\n * output token limit. Previously the text was simply cut at 180 000 chars and\n * the remainder discarded: the parse \"succeeded\" while silently dropping most\n * of the file. Now the text is split and each slice parsed on its own call,\n * with the items merged.\n *\n * Splitting happens on line boundaries so a BOQ row is never cut in half.\n * Slices run sequentially rather than pooled: DeepSeek rate-limits hard on\n * concurrent large prompts, and this already runs on the queue where wall\n * time is cheap.\n *\n * A slice that fails does not fail the document — its rows are lost, but the\n * rest are kept and the shortfall is logged. Total failure is reported only\n * when every slice fails.\n *\n * @param string $label How the payload is introduced to the model.\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n /**\n * Split a spreadsheet into the slices the AI will be asked to read.\n *\n * Exposed so the work can be spread over several queue jobs instead of one\n * job walking every slice in sequence. Returns an empty array when the file\n * is small enough to go in a single call, or cannot be read.\n *\n * @return array<int, string>\n */\n public function chunkSpreadsheet(string $absPath): array\n {\n ini_set('memory_limit', '2048M');\n set_time_limit(1800);\n\n $text = $this->spreadsheetToCompactCsvText($absPath);\n\n if ($text === null || trim($text) === '') {\n return [];\n }\n\n $text = $this->sanitizeUtf8($text);\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) <= self::TEXT_CHUNK_CHARS && $sheetCount <= self::MAX_SHEETS_PER_CALL) {\n return [];\n }\n\n $chunks = $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n\n // Free the source text: the slices are a second full copy of it, and on\n // a large workbook that doubling — on top of PhpSpreadsheet's grids —\n // is what exhausted memory here.\n unset($text);\n\n Log::info('QuotationAiService: chunked spreadsheet.', [\n 'chunks' => count($chunks),\n 'peak_mb' => round(memory_get_peak_usage(true) / 1048576),\n ]);\n\n return $chunks;\n }\n\n /**\n * Split a spreadsheet straight to disk, one file per slice.\n *\n * chunkSpreadsheet() returns every slice as an array, which is a second\n * full copy of the document in memory on top of PhpSpreadsheet's grids.\n * This hands each slice to $write and drops it immediately, so peak memory\n * is one slice rather than the whole file twice.\n *\n * @param callable(int, string): void $write ($part, $chunk)\n * @return int number of slices written; 0 when the file needs no split\n */\n public function chunkSpreadsheetToDisk(string $absPath, callable $write): int\n {\n ini_set('memory_limit', '2048M');\n set_time_limit(1800);\n\n $text = $this->spreadsheetToCompactCsvText($absPath);\n\n if ($text === null || trim($text) === '') {\n return 0;\n }\n\n $text = $this->sanitizeUtf8($text);\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) <= self::TEXT_CHUNK_CHARS && $sheetCount <= self::MAX_SHEETS_PER_CALL) {\n return 0;\n }\n\n $part = 0;\n foreach ($this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS) as $chunk) {\n $write(++$part, $chunk);\n }\n\n unset($text);\n\n Log::info('QuotationAiService: chunked spreadsheet to disk.', [\n 'chunks' => $part,\n 'peak_mb' => round(memory_get_peak_usage(true) / 1048576),\n ]);\n\n return $part;\n }\n\n /**\n * Parse one slice produced by chunkSpreadsheet().\n *\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n public function parseChunk(string $chunk, int $part, int $total, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured.');\n }\n\n // Same framing parseTextInChunks() uses, so a slice is never mistaken\n // for a complete BOQ and totals are not inferred from a fragment.\n $userContent = \"BOQ spreadsheet converted to CSV (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, 'text/plain');\n\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n private function parseTextInChunks(\n string $text,\n string $label,\n string $apiKey,\n array $context,\n string $mime = 'text/plain',\n ): array {\n $chunks = $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n $total = count($chunks);\n\n Log::info('QuotationAiService: large document split for parsing.', [\n 'chars' => mb_strlen($text),\n 'chunks' => $total,\n ]);\n\n // Announce the split before the first slice is sent, so the UI can show\n // how many parts there are instead of sitting on a generic message for\n // however long the first AI call takes.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)(0, $total);\n }\n\n $items = [];\n $rejected = [];\n $failed = 0;\n $lastError = null;\n\n foreach ($chunks as $index => $chunk) {\n $part = $index + 1;\n\n // Report progress so a 28-slice parse does not look frozen to the user.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)($part, $total);\n }\n\n // Tell the model this is a fragment, so it does not try to reconcile\n // totals or treat a mid-document slice as a complete BOQ.\n $userContent = \"{$label} (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, $mime);\n\n $result = $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n\n if (! ($result['success'] ?? false)) {\n $failed++;\n $lastError = $result['error'] ?? 'Chunk parsing failed.';\n Log::warning('QuotationAiService: chunk failed, continuing.', [\n 'part' => $part,\n 'of' => $total,\n 'error' => $lastError,\n ]);\n continue;\n }\n\n foreach ($result['items'] as $item) {\n $items[] = $item;\n }\n foreach ($result['rejected'] ?? [] as $row) {\n $rejected[] = $row;\n }\n\n // Hand this slice's rows straight to the caller so they can be shown\n // while the remaining slices are still being parsed.\n if ($this->onChunkItems !== null && $result['items'] !== []) {\n ($this->onChunkItems)($result['items'], $part, $total);\n }\n }\n\n // Every slice failed — there is nothing to show, so report the failure.\n if ($failed === $total) {\n return $this->failure($lastError ?? 'Could not parse the document.');\n }\n\n if ($failed > 0) {\n Log::error('QuotationAiService: document parsed with missing parts.', [\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n 'items_kept' => count($items),\n ]);\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'service_unavailable' => false,\n 'partial' => $failed > 0,\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n ];\n }\n\n /**\n * Split text into slices of at most $limit characters, breaking only at\n * newlines so no BOQ row is ever cut in half.\n *\n * A single line longer than the limit (rare — a pathological cell) is passed\n * through oversized rather than severed mid-row.\n *\n * @return array<int, string>\n */\n private function splitOnLineBoundaries(string $text, int $limit): array\n {\n $chunks = [];\n $current = '';\n\n // Sheet a chunk starts in the middle of. Repeated at the top of the next\n // chunk so the model always knows which sheet the rows belong to — the\n // header would otherwise be stranded in the previous slice, and rows\n // would arrive with no discipline context.\n $currentSheet = null;\n\n foreach (explode(\"\\n\", $text) as $line) {\n $isSheetHeader = str_starts_with($line, 'Sheet: ');\n\n // Start a new chunk at every sheet boundary. Sheets are usually\n // separate disciplines, and the whole point of splitting a multi-tab\n // workbook is to give the model one discipline at a time — a size-only\n // split would leave a compact 3-tab file as a single chunk, silently\n // undoing the sheet gate that routed it here.\n if ($isSheetHeader && $current !== '') {\n $chunks[] = $current;\n $current = $line;\n $currentSheet = $line;\n continue;\n }\n\n if ($isSheetHeader) {\n $currentSheet = $line;\n }\n\n $candidate = $current === '' ? $line : $current . \"\\n\" . $line;\n\n if (mb_strlen($candidate) > $limit && $current !== '') {\n $chunks[] = $current;\n\n // Carry the sheet header over, unless this line is itself one.\n $current = ($currentSheet !== null && ! str_starts_with($line, 'Sheet: '))\n ? $currentSheet . \" (continued)\\n\" . $line\n : $line;\n continue;\n }\n\n $current = $candidate;\n }\n\n if (trim($current) !== '') {\n $chunks[] = $current;\n }\n\n return $chunks;\n }\n\n /**\n * Shared JSON response parser used by both chat and vision paths.\n */\n private function parseDeepSeekJsonResponse(string $text): array\n {\n if ($text === '') {\n return $this->failure('DeepSeek returned an empty response.');\n }\n\n // Strip markdown code fences if the model wrapped the JSON.\n if (preg_match('/```(?:json)?\\s*([\\s\\S]*?)\\s*```/i', $text, $m)) {\n $text = $m[1];\n }\n // Narrow to the outermost JSON object/array.\n if (preg_match('/[\\{\\[][\\s\\S]*[\\}\\]]/s', $text, $m)) {\n $text = $m[0];\n }\n $text = trim($text);\n\n $decoded = json_decode($text, true);\n\n // The response can be truncated when the model hits the token limit,\n // leaving an unterminated JSON string/object. Try to repair it before\n // giving up so we can still salvage the items extracted so far.\n if (! is_array($decoded)) {\n $repaired = $this->repairTruncatedJson($text);\n if ($repaired !== null) {\n $decoded = json_decode($repaired, true);\n }\n }\n\n if (! is_array($decoded)) {\n Log::error('QuotationAiService: DeepSeek non-JSON response.', ['text' => mb_substr($text, 0, 1000)]);\n return $this->failure('DeepSeek returned a non-JSON response. The file may be too large — try splitting it into smaller files.');\n }\n\n $rawItems = $decoded['items'] ?? (array_is_list($decoded) ? $decoded : []);\n $items = [];\n $rejected = [];\n\n foreach ($rawItems as $raw) {\n if (! is_array($raw)) {\n continue;\n }\n $item = $this->normaliseAiItem($raw);\n if ($item === null) {\n continue;\n }\n $check = $this->boqCleaner->filterItem((string) $item['description']);\n if ($check['keep'] ?? false) {\n $items[] = $item;\n } else {\n $item['status'] = 'rejected';\n $item['raw_data']['rejection_reason'] = $check['rejection_reason'] ?? 'Rejected by cleaner';\n $rejected[] = $item;\n }\n }\n\n if (empty($items)) {\n Log::info('QuotationAiService: DeepSeek returned 0 accepted items.', [\n 'raw_items_count' => count($rawItems),\n 'rejected_count' => count($rejected),\n 'first_rejected' => ! empty($rejected) ? ($rejected[0]['description'] ?? '') : '',\n ]);\n }\n\n return ['success' => true, 'items' => $items, 'rejected' => $rejected, 'error' => null];\n }\n\n /**\n * Best-effort repair of a truncated JSON string produced when the model hits\n * its token limit mid-output. Drops any incomplete trailing object, then\n * closes the open string/array/object brackets so the result is valid JSON.\n *\n * Returns the repaired JSON string, or null if it cannot be salvaged.\n */\n private function repairTruncatedJson(string $text): ?string\n {\n if ($text === '') {\n return null;\n }\n\n // Walk the string tracking structural state, ignoring everything inside\n // string literals (and their escapes). Remember the last position where\n // we were back at the top of the \"items\" array between complete elements,\n // so we can cut a half-written trailing object.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($text);\n $lastSafeCut = null; // index just after a completed array element\n\n for ($i = 0; $i < $len; $i++) {\n $ch = $text[$i];\n\n if ($inString) {\n if ($escaped) {\n $escaped = false;\n } elseif ($ch === '\\\\') {\n $escaped = true;\n } elseif ($ch === '\"') {\n $inString = false;\n }\n continue;\n }\n\n if ($ch === '\"') {\n $inString = true;\n } elseif ($ch === '{' || $ch === '[') {\n $stack[] = $ch;\n } elseif ($ch === '}' || $ch === ']') {\n array_pop($stack);\n } elseif ($ch === ',' && ! empty($stack) && end($stack) === '[') {\n // Separator directly inside an array: a clean boundary between\n // elements (the items array, whatever its nesting depth).\n $lastSafeCut = $i;\n }\n }\n\n // If we are still inside a string, the last element is incomplete — fall\n // back to the last clean array boundary if we have one.\n $candidate = $text;\n if ($inString && $lastSafeCut !== null) {\n $candidate = substr($text, 0, $lastSafeCut);\n } else {\n // Drop a dangling partial object after the last completed element.\n $candidate = rtrim($candidate);\n if ($lastSafeCut !== null && substr(rtrim($candidate), -1) !== '}' && substr(rtrim($candidate), -1) !== ']') {\n $candidate = substr($text, 0, $lastSafeCut);\n }\n }\n\n // Recompute the open brackets for the (possibly trimmed) candidate and\n // append the matching closers in reverse order.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($candidate);\n for ($i = 0; $i < $len; $i++) {\n $ch = $candidate[$i];\n if ($inString) {\n if ($escaped) { $escaped = false; }\n elseif ($ch === '\\\\') { $escaped = true; }\n elseif ($ch === '\"') { $inString = false; }\n continue;\n }\n if ($ch === '\"') { $inString = true; }\n elseif ($ch === '{' || $ch === '[') { $stack[] = $ch; }\n elseif ($ch === '}' || $ch === ']') { array_pop($stack); }\n }\n\n $candidate = rtrim($candidate);\n // Remove a trailing comma left after cutting an element.\n $candidate = rtrim($candidate, ',');\n\n $closers = '';\n for ($i = count($stack) - 1; $i >= 0; $i--) {\n $closers .= ($stack[$i] === '{') ? '}' : ']';\n }\n\n $repaired = $candidate . $closers;\n\n // Validate before returning.\n return is_array(json_decode($repaired, true)) ? $repaired : null;\n }\n\n private function spreadsheetToCompactCsvText(string $absPath): ?string\n {\n $sheets = $this->spreadsheetToGrids($absPath);\n if (empty($sheets)) {\n return null;\n }\n\n $output = '';\n $kept = 0;\n $dropped = 0;\n $nonSupply = 0;\n $duplicates = 0;\n\n /** @var array<string, true> Fingerprints of rows already emitted. */\n $seenRows = [];\n\n foreach ($sheets as $sheet) {\n // Don't feed summary / cost-abstract sheets to the AI either.\n if ($this->isSummarySheet($sheet['name'])) {\n continue;\n }\n $sheetRows = '';\n foreach ($sheet['grid'] as $row) {\n $row = $this->trimTrailingEmptyCells($row);\n\n if ($this->isNoiseRow($row)) {\n $dropped++;\n continue;\n }\n\n // Drop rows that are definitely not procurable before they cost\n // a single token: section totals, discipline headings,\n // preliminaries, labour-only lines. The same classifier already\n // runs on the AI's output, but by then the tokens are spent — on\n // an 11 200-row BOQ that is most of the bill.\n if ($this->isNonSupplyRow($row)) {\n $dropped++;\n $nonSupply++;\n continue;\n }\n\n // Byte-identical rows tell the AI nothing new. Only exact\n // duplicates are collapsed — description, quantity, unit and all\n // — so nothing is lost: two rows differing in quantity are two\n // different line items and both are sent.\n $fingerprint = $this->rowFingerprint($row);\n\n if (isset($seenRows[$fingerprint])) {\n $duplicates++;\n $dropped++;\n continue;\n }\n $seenRows[$fingerprint] = true;\n\n $sheetRows .= implode(',', array_map([$this, 'csvEscape'], $this->stripBoilerplate($row))) . \"\\n\";\n $kept++;\n }\n\n // A sheet whose rows were all noise contributes nothing but its own\n // header, which would just be another thing for the model to explain.\n if ($sheetRows === '') {\n continue;\n }\n\n $output .= 'Sheet: ' . $sheet['name'] . \"\\n\" . $sheetRows . \"\\n\";\n }\n\n $blank = $dropped - $nonSupply - $duplicates;\n\n Log::info('QuotationAiService: filtered rows before sending to AI.', [\n 'kept' => $kept,\n 'blank' => $blank,\n 'non_supply' => $nonSupply,\n 'duplicates' => $duplicates,\n ]);\n\n if ($dropped > 0) {\n $parts = [];\n if ($blank > 0) { $parts[] = \"{$blank} blank\"; }\n if ($nonSupply > 0) { $parts[] = \"{$nonSupply} non-supply\"; }\n if ($duplicates > 0) { $parts[] = \"{$duplicates} duplicate\"; }\n\n $this->reportStage('Skipped ' . implode(', ', $parts) . \" rows. Sending {$kept}…\");\n }\n\n return $output;\n }\n\n /**\n * True when a row carries no information worth sending to the AI.\n *\n * A real BOQ is mostly formatting: spacer rows, stray borders, a lone\n * section number, a leftover cell from a merged block. They survive\n * trimTrailingEmptyCells() because they are not *entirely* empty, then cost\n * tokens, dilute the prompt, and inflate the number of parts a large file is\n * split into.\n *\n * Deliberately conservative — a row is dropped only when nothing in it could\n * name a product. Anything with letters is kept, because a description is\n * the one field an item cannot do without.\n *\n * @param array<int, mixed> $row\n */\n /**\n * True when a row is definitely not a procurable supply item.\n *\n * Runs BoqCleaningService over the row's longest text cell — the one most\n * likely to be the description — so headings, totals, preliminaries and\n * labour-only lines never reach the AI. The same classifier already vets the\n * AI's *output*; doing it here as well means we stop paying to have those\n * rows read in the first place.\n *\n * Conservative by construction: only rows the classifier positively rejects\n * are dropped. Anything it is unsure about goes to the AI, which is the\n * better judge — a wrongly dropped row is invisible, a wrongly kept one only\n * costs tokens.\n *\n * @param array<int, mixed> $row\n */\n /**\n * Stable identity for a row, used to collapse exact duplicates.\n *\n * Whitespace and case are normalised so \"DN50 PIPE\" and \"dn50 pipe \" are one\n * row, but nothing else is: a differing quantity or unit produces a\n * different fingerprint and both rows are sent.\n *\n * @param array<int, mixed> $row\n */\n private function rowFingerprint(array $row): string\n {\n $parts = array_map(\n fn($cell) => mb_strtolower(preg_replace('/\\s+/u', ' ', trim((string) $cell)) ?? ''),\n $row,\n );\n\n // Drop a leading BOQ sequence number. Otherwise \"1,Pipe,m,100\" and\n // \"2,Pipe,m,100\" fingerprint differently and the dedup never fires —\n // which is exactly the shape every real BOQ has.\n if (isset($parts[0]) && $parts[0] !== '' && preg_match('/^[\\d.]+$/', $parts[0])) {\n array_shift($parts);\n }\n\n return md5(implode('|', $parts));\n }\n\n /**\n * Strip contractual boilerplate from a row's description cell.\n *\n * \"as per drawings\", \"complete with all necessary accessories\" and friends\n * repeat on nearly every line of a real BOQ and carry no information the\n * pricing engine can use — but they are a large share of the tokens.\n *\n * Only the longest text cell is touched, and only when the result is still a\n * usable description: if stripping would leave too little to identify the\n * product, the original is kept. A shorter-but-wrong description is far\n * worse than a verbose one.\n *\n * @param array<int, mixed> $row\n * @return array<int, mixed>\n */\n private function stripBoilerplate(array $row): array\n {\n $index = null;\n $longest = '';\n\n foreach ($row as $i => $cell) {\n $text = trim((string) $cell);\n if (preg_match('/\\p{L}/u', $text) && mb_strlen($text) > mb_strlen($longest)) {\n $longest = $text;\n $index = $i;\n }\n }\n\n // Nothing worth cleaning, or too short to risk it.\n if ($index === null || mb_strlen($longest) < 40) {\n return $row;\n }\n\n try {\n $cleaned = $this->boqCleaner->cleanDescription($longest);\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: boilerplate strip failed, keeping row as-is.', [\n 'error' => $e->getMessage(),\n ]);\n return $row;\n }\n\n // Guard against an over-eager pattern eating the product name: keep the\n // original unless a recognisable description survives.\n if (mb_strlen($cleaned) < 12 || mb_strlen($cleaned) < mb_strlen($longest) * 0.3) {\n return $row;\n }\n\n $row[$index] = $cleaned;\n\n return $row;\n }\n\n private function isNonSupplyRow(array $row): bool\n {\n $description = '';\n foreach ($row as $cell) {\n $text = trim((string) $cell);\n // Longest cell containing letters — numbers are qty/rate columns.\n if (preg_match('/\\p{L}/u', $text) && mb_strlen($text) > mb_strlen($description)) {\n $description = $text;\n }\n }\n\n // No text at all: leave it to isNoiseRow()/the AI rather than guessing.\n if ($description === '' || mb_strlen($description) < 3) {\n return false;\n }\n\n try {\n $verdict = $this->boqCleaner->filterItem($description);\n } catch (\\Throwable $e) {\n // A classifier failure must never silently drop a real product.\n Log::warning('QuotationAiService: pre-filter classifier failed, keeping row.', [\n 'error' => $e->getMessage(),\n ]);\n return false;\n }\n\n return ! ($verdict['keep'] ?? true);\n }\n\n private function isNoiseRow(array $row): bool\n {\n if (empty($row)) {\n return true;\n }\n\n $joined = trim(implode('', array_map(fn($c) => trim((string) $c), $row)));\n\n // Nothing but separators/whitespace.\n if ($joined === '') {\n return true;\n }\n\n // Ruled lines and decorative fills (\"-----\", \"====\", \"___\").\n if (preg_match('/^[\\-_=.*\\s|]+$/u', $joined)) {\n return true;\n }\n\n // Any letter — Latin or Arabic — means this could be a description.\n if (preg_match('/\\p{L}/u', $joined)) {\n return false;\n }\n\n // No letters at all: numbers only. A single number is a stray row\n // number or a dangling total; two or more could be qty/rate/amount\n // belonging to a real line, so keep those for the AI to judge.\n $numericCells = 0;\n foreach ($row as $cell) {\n if (trim((string) $cell) !== '') {\n $numericCells++;\n }\n }\n\n return $numericCells <= 1;\n }\n\n /**\n * Safely convert a PhpSpreadsheet cell value to a string.\n *\n * Cells may contain RichText or, in malformed/heavily-formatted files,\n * embedded objects such as Worksheet\\Drawing that have no __toString().\n * Casting those directly throws \"Object ... could not be converted to string\".\n */\n private function cellValueToString(mixed $value): string\n {\n if ($value === null) {\n return '';\n }\n\n if ($value instanceof \\PhpOffice\\PhpSpreadsheet\\RichText\\RichText) {\n return $value->getPlainText();\n }\n\n if (is_object($value)) {\n // Only stringify objects that explicitly support it; ignore the rest\n // (e.g. Worksheet\\Drawing) instead of crashing.\n return method_exists($value, '__toString') ? (string) $value : '';\n }\n\n if (is_array($value)) {\n return '';\n }\n\n return (string) $value;\n }\n\n private function csvEscape(mixed $value): string\n {\n $value = $this->cellValueToString($value);\n return str_contains($value, ',') || str_contains($value, '\"') || str_contains($value, \"\\n\")\n ? '\"' . str_replace('\"', '\"\"', $value) . '\"'\n : $value;\n }\n\n private function verifyLargeBoqItemsWithAi(array $result): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n Log::warning('QuotationAiService: large BOQ verification skipped, AI key is not configured.');\n return $result;\n }\n\n $items = array_values(array_filter($result['items'] ?? [], 'is_array'));\n if (count($items) <= self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n return $result;\n }\n\n Log::info('QuotationAiService: starting second-pass physical item verification.', [\n 'items_count' => count($items),\n 'threshold' => self::LARGE_BOQ_VERIFICATION_THRESHOLD,\n ]);\n\n $verifiedItems = [];\n $rejectedItems = $result['rejected'] ?? [];\n\n foreach (array_chunk($items, self::LARGE_BOQ_VERIFICATION_CHUNK_SIZE) as $chunkIndex => $chunk) {\n $payload = array_map(fn(array $item) => [\n 'description' => (string) ($item['description'] ?? ''),\n 'quantity' => $item['quantity'] ?? null,\n 'unit' => (string) ($item['unit'] ?? ''),\n 'unit_price' => $item['unit_price'] ?? null,\n 'category' => (string) ($item['category'] ?? ''),\n 'brand' => (string) ($item['brand'] ?? ''),\n ], $chunk);\n\n $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);\n if (! is_string($json)) {\n Log::warning('QuotationAiService: large BOQ verification chunk JSON encode failed.', [\n 'chunk' => $chunkIndex + 1,\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n $prompt = <<<PROMPT\nYou are doing a SECOND PASS verification on a BOQ extraction result.\n\nThe first pass may hallucinate non-products when the BOQ has thousands of rows.\nYour job is to keep ONLY physical, tangible, procurable supply products.\n\nRules:\n- Keep items only if a supplier can actually deliver them as materials/equipment.\n- Reject headings, trade names, systems, scopes, prose, specifications-only rows, totals, labor, installation, testing, commissioning, supervision, drawings, insurance, preliminaries, mobilisation, site works, and vague fragments.\n- Do not invent new products.\n- Do not add items that are not in the input.\n- You may clean a product description, but keep the original quantity, unit, unit_price, category, and brand when available.\n\nInput JSON array:\n{$json}\n\nReturn ONLY valid JSON in this shape:\n{\"items\":[{\"description\":\"\",\"quantity\":1,\"unit\":\"\",\"unit_price\":null,\"category\":\"\",\"brand\":\"\",\"confidence\":0.95}]}\nPROMPT;\n\n try {\n $chunkResult = $this->callDeepSeekChat($prompt, $apiKey, $this->deepSeekModel());\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: large BOQ verification chunk threw an exception.', [\n 'chunk' => $chunkIndex + 1,\n 'message' => $e->getMessage(),\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n if ($chunkResult['success'] ?? false) {\n array_push($verifiedItems, ...($chunkResult['items'] ?? []));\n $rejectedItems = array_merge($rejectedItems, $chunkResult['rejected'] ?? []);\n continue;\n }\n\n Log::warning('QuotationAiService: large BOQ verification chunk failed, keeping original chunk.', [\n 'chunk' => $chunkIndex + 1,\n 'error' => $chunkResult['error'] ?? null,\n ]);\n array_push($verifiedItems, ...$chunk);\n }\n\n if (empty($verifiedItems)) {\n Log::warning('QuotationAiService: second-pass verification returned no items, keeping first-pass result.', [\n 'original_items_count' => count($items),\n ]);\n return $result;\n }\n\n $result['items'] = $verifiedItems;\n $result['rejected'] = $rejectedItems;\n $result['large_boq_verified'] = true;\n\n Log::info('QuotationAiService: second-pass physical item verification completed.', [\n 'before' => count($items),\n 'after' => count($verifiedItems),\n ]);\n\n return $result;\n }\n\n private function normaliseAiItem(array $raw): ?array\n {\n if (! empty($raw['rejected'])) {\n return null;\n }\n\n $description = trim((string) ($raw['cleaned_description'] ?? $raw['product_name'] ?? $raw['description'] ?? ''));\n if ($description === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $description)) {\n return null;\n }\n\n $quantity = is_numeric($raw['quantity'] ?? null) ? (float) $raw['quantity'] : 1;\n $unitPrice = is_numeric($raw['unit_price'] ?? null) ? (float) $raw['unit_price'] : null;\n $totalPrice = is_numeric($raw['total_price'] ?? null) ? (float) $raw['total_price'] : null;\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $specs = is_array($raw['specifications'] ?? null) ? $raw['specifications'] : [];\n\n return [\n 'description' => $description,\n 'quantity' => $quantity,\n 'unit' => (string) ($raw['unit'] ?? ''),\n 'category' => (string) ($raw['core_product'] ?? $raw['category'] ?? ''),\n 'brand' => (string) ($specs['brand'] ?? $raw['brand'] ?? ''),\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($description),\n 'unit_price' => $unitPrice,\n 'confidence' => is_numeric($raw['confidence'] ?? null) ? (float) $raw['confidence'] : null,\n 'needs_review' => ! empty($raw['needs_review']),\n 'raw_data' => [\n 'source_item_no' => (string) ($raw['source_item_no'] ?? ''),\n 'original_description' => (string) ($raw['original_description'] ?? $description),\n 'cleaned_description' => $description,\n 'core_product' => (string) ($raw['core_product'] ?? ''),\n 'specifications' => $specs,\n 'extraction_type' => (string) ($raw['extraction_type'] ?? 'supply_only'),\n 'note' => (string) ($raw['note'] ?? $raw['notes'] ?? ''),\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function buildSystemInstruction(): string\n {\n return <<<'TXT'\nYou are a strict BOQ Supply Product Extraction Engine.\nExtract ONLY concrete, tangible, physical products that a supplier can box up and deliver to site — a specific item you could point at and say \"this is the thing being bought\" (e.g. \"Wall mounted LED luminaire 36W\", \"PVC pipe 110mm\", \"Single phase distribution board 12-way\", \"Fire extinguisher CO2 6kg\", \"CCTV dome camera 4MP\").\n\nABSOLUTELY REJECT abstract concepts, disciplines, categories, section names, and trade headings. These are NOT products even though they appear as rows:\n- Bare discipline/trade words: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", \"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\".\n- Summary / total / abstract / \"dry cost\" rows of any kind.\n- Anything that names a system, scope, or category instead of one buyable item.\n\nRule of thumb: if you cannot name a unit and physically hand the item over (a piece, a box, a length, a unit of equipment), REJECT it. A word that describes a field of work, a system, or a section of the BOQ is NEVER a product.\nReject totals, headings, general requirements, installation-only, testing, commissioning, supervision, mobilization, civil works, labor, and vague rows.\nFor Supply & Install rows, keep only the supply product and remove install/labor wording.\nReturn only valid JSON: {\"items\":[{\"source_item_no\":null,\"original_description\":\"\",\"cleaned_description\":\"\",\"core_product\":\"\",\"specifications\":{},\"quantity\":0,\"unit\":\"\",\"unit_price\":0,\"total_price\":0,\"extraction_type\":\"supply_only\",\"needs_review\":false,\"note\":\"\",\"rejected\":false,\"rejection_reason\":null,\"confidence\":0.95}]}\nTXT;\n }\n\n private function buildDeepSeekPrompt(array $context = [], string $mime = ''): string\n {\n $projectName = (string) ($context['project_name'] ?? '');\n $projectStatus = (string) ($context['project_status'] ?? '');\n $contextLine = $projectName !== ''\n ? \"Project: {$projectName}\" . ($projectStatus !== '' ? \" ({$projectStatus})\" : '')\n : '';\n $sourceHint = str_starts_with($mime, 'image/')\n ? 'The input is an image of a BOQ table. OCR every cell carefully — read the quantity/count column (العدد, الكمية, Qty) and extract the EXACT numeric value from each row.'\n : 'The input is a BOQ document/spreadsheet.';\n\n return trim(<<<PROMPT\nYou are a procurement specialist. Your task is to extract ONLY tangible, physical supply products from this Bill of Quantities (BOQ).\n\n{$contextLine}\n{$sourceHint}\n\nCRITICAL — MERGE WRAPPED / SPLIT ROWS INTO ONE ITEM:\nBOQ tables extracted from PDF often break ONE item's long description across several\nlines/rows. These continuation lines repeat the SAME quantity, unit and category, and\ntheir text is a grammatical continuation of the previous line (it starts mid-sentence,\nlowercase, or with \"the/and/-\", or is a spec/instruction clause). You MUST reconstruct\nthe original single item:\n- Treat consecutive rows that share the SAME quantity + unit + category as ONE product\n when the later rows are clearly continuation text of the first, NOT new products.\n- Emit ONE item with the merged description and the price counted ONCE. Do NOT repeat\n the same quantity×price for every wrapped line — that multiplies the total wrongly.\n- Example: rows \"The asphalt flooring is to be supplied\" / \"installed in accordance\" /\n \"standard road specifications...\" all with 7,100 m2 are ONE asphalt item, not 3+.\n\nCRITICAL — UNDERSTAND INTENT, DO NOT COPY ROWS LITERALLY:\nYour job is to output WHAT A SUPPLIER MUST ACTUALLY DELIVER, not a copy of the row text.\nRead each description for MEANING and decide how many real, separately-buyable products it\nimplies. One BOQ row can imply SEVERAL products, or a spread of rows can imply ONE product.\n- SPLIT a row into multiple items when it lists distinct products that a supplier would\n buy/price separately — even when joined by commas, \"with\", \"and\", \"including\", \"c/w\",\n \"consisting of\", \"comprising\", or \":\" then a list.\n Example: \"Fire pump set consisting of: fire pump, electric motor, control panel, diesel\n engine\" → FOUR items: (1) fire pump, (2) electric motor, (3) control panel, (4) diesel\n engine — each with its own quantity and priced on its own.\n Example: \"Wash basin complete with mixer tap and bottle trap\" → THREE items: wash basin,\n mixer tap, bottle trap.\n- Give each split product the correct quantity implied by the text (usually the parent\n quantity, unless the text states a different count for a sub-item). Each product is\n priced individually; the line total becomes the SUM of its products.\n- DO NOT split when the extra words are just specifications, dimensions, colours, ratings,\n standards, or a single product's integral parts. \"Steel door 900x2100 with vision panel\n and SS ironmongery\" is usually ONE door product — vision panel and ironmongery are part\n of that door, not separately-bought items. Use judgement: would a supplier issue a\n separate price line for it? If no, keep it as one.\n- Balanced approach: split only when genuinely separate products are present; when in doubt\n about whether something is a separate product vs. an accessory/spec of the main one, keep\n it as a single item. Do not invent products that are not in the text.\n\nSTRICT RULES — include ONLY items that are ALL of the following:\n1. A specific, concrete, tangible product that can be boxed up by a supplier and delivered to site — something you can point at and say \"this exact thing is being bought\" (e.g. \"LED luminaire 36W\", \"PVC pipe 110mm\", \"Distribution board 12-way\", \"CO2 fire extinguisher 6kg\").\n2. Has a real quantity (not 0 or \"varies\" with no meaning).\n3. Has enough description to identify what the product is.\n4. Is an actual PRODUCT, not a sentence, instruction, or specification clause. REJECT rows that are prose such as \"the supervising engineer's instructions\", \"The price includes all materials, labor\", \"Measurement will be based on the geometric cubic meter\", \"necessary steps to complete the work\", \"soil-contacting elements\", \"in compliance\", \"everything necessary\". These are description fragments, not buyable items.\n\nNEVER extract abstract concepts, disciplines, trades, systems, or section/category names — they are NOT products even when they appear as their own row:\n- Bare discipline/trade/system words such as: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", and their Arabic equivalents (\"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\", \"تشطيبات\").\n- Test: if the word names a FIELD OF WORK or a SYSTEM rather than one buyable item with a unit (pcs, m, set, kg…), REJECT it.\n\nEXCLUDE all of the following (do NOT include them):\n- Labor, installation, or workmanship items (e.g. \"install\", \"fix\", \"erect\", \"dismantle\", \"painting\", \"cleaning service\")\n- Project management, overhead, or profit items\n- Insurance, bonds, liability items (e.g. \"bodily injury\", \"third party liability\", \"CAR insurance\")\n- Shop drawings, as-built drawings, method statements, documentation\n- Preliminary items, mobilisation, demobilisation, provisional sums\n- Housekeeping, site clearance, waste removal\n- Floor/level labels (e.g. \"GF Floor\", \"Level 3\") — these are headers, not products\n- Pure dimension specs without a product noun (e.g. \"Dia 32 mm\", \"25 mm\") — reject unless a product name is present\n- Security services, night shifts, rentals, payments\n- Any item whose description is too vague or fragmentary to identify a specific product\n\nEmit ONE JSON item PER real product. A single source row may therefore produce several\nitems (when it lists multiple products) or several rows may collapse into one item (when\nthey are wrapped fragments of the same product). The \"items\" array is your final delivery\nlist, not a row-by-row transcript.\n\nFor each valid physical product return JSON with:\n- description: clean product name only (strip \"supply and install\", \"supply and fix\", installation clauses, floor/spec suffixes). For a split product, use just that product's own name.\n- quantity: the EXACT numeric value from the quantity/count column (العدد, الكمية, Qty, Quantity, No.). NEVER default to 1 unless the quantity column is truly absent or empty.\n- unit: unit of measure (pcs, m, m2, m3, set, no, kg, etc.)\n- category: product category\n- brand: brand/manufacturer if mentioned, else \"\"\n- engineering_required: true if the item needs engineering design before procurement (panel boards, chillers, generators, fire suppression systems, BMS, pumps, AHUs, switchgear, transformers), else false\n\nReturn ONLY a valid JSON object: {\"items\": [...]}. No explanations, no markdown, no extra text.\nPROMPT);\n }\n\n private function mockResponse(): array\n {\n return [\n 'success' => true,\n 'error' => null,\n 'items' => [[\n 'description' => 'Sample BOQ Item',\n 'quantity' => 1,\n 'unit' => 'No',\n 'category' => 'Sample',\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 1,\n 'raw_data' => [],\n 'ai_extracted' => true,\n ]],\n 'rejected' => [],\n ];\n }\n\n private function failure(string $message, bool $serviceUnavailable = false): array\n {\n return [\n 'success' => false,\n 'items' => [],\n 'rejected' => [],\n 'error' => $this->userFacingError($message, $serviceUnavailable),\n 'service_unavailable' => $serviceUnavailable,\n ];\n }\n\n /**\n * Translate any internal error into a neutral, user-safe message.\n *\n * The user must never see the name of the underlying AI/processing engine\n * (DeepSeek, Groq, Gemini, Vision, etc.) or configuration hints like API\n * keys. The detailed message is kept in the logs for debugging only.\n */\n private function userFacingError(string $message, bool $serviceUnavailable = false): string\n {\n // Detect any reference to an AI provider / engine / API config so we can\n // swap it for a generic, branded-neutral message.\n $sensitive = '/deepseek|groq|gemini|vision|openai|openrouter|api[\\s_-]?key|\\.env|non-?json|token limit/i';\n\n if (preg_match($sensitive, $message)) {\n Log::warning('QuotationAiService: suppressed technical error from user.', ['internal' => $message]);\n\n return $serviceUnavailable\n ? 'The file could not be processed right now. Please try again in a moment.'\n : 'We could not read this file automatically. Please make sure it is a clear BOQ (Excel, CSV, PDF, or image) and try again, or add the items manually.';\n }\n\n return $message;\n }\n\n private function mimeForExtension(string $ext): string\n {\n return match (strtolower($ext)) {\n 'pdf' => 'application/pdf',\n 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n 'xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12',\n 'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',\n 'xls' => 'application/vnd.ms-excel',\n 'csv' => 'text/csv',\n 'jpg', 'jpeg' => 'image/jpeg',\n 'png' => 'image/png',\n 'gif' => 'image/gif',\n 'bmp' => 'image/bmp',\n 'webp' => 'image/webp',\n 'tiff', 'tif' => 'image/tiff',\n 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'doc' => 'application/msword',\n default => 'application/octet-stream',\n };\n }\n}\n"}, "ca5e2a1409c26009f3d15783431a4e9be5c90092": {"t": 1785748355.111592, "n": 94360, "h": "57a4e017ceb58e5e", "c": "<?php\n\nnamespace App\\Services;\n\nuse Illuminate\\Http\\Client\\ConnectionException;\nuse Illuminate\\Http\\UploadedFile;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\nuse PhpOffice\\PhpSpreadsheet\\Cell\\Coordinate;\nuse PhpOffice\\PhpSpreadsheet\\IOFactory;\n\nclass QuotationAiService\n{\n /** Increment this whenever BOQ parsing logic changes to invalidate old caches. */\n private const PARSER_VERSION = 10;\n private const LARGE_BOQ_VERIFICATION_THRESHOLD = 2000;\n private const LARGE_BOQ_VERIFICATION_CHUNK_SIZE = 120;\n\n /**\n * Characters of document text sent per AI call.\n *\n * Above this the document is split and parsed over several calls. Sized well\n * under the model's context so the prompt, the slice, and a full JSON answer\n * for every row in it all fit — the binding limit is the *output* budget, not\n * the input, since each input row produces a JSON object.\n */\n private const TEXT_CHUNK_CHARS = 60000;\n\n /**\n * A workbook with more sheets than this is split regardless of its size.\n *\n * Multiple tabs almost always mean multiple disciplines (electrical, HVAC,\n * civil…). Sending them in one call makes the model summarise across\n * sections and silently drop rows, so each slice gets a narrower scope.\n */\n private const MAX_SHEETS_PER_CALL = 2;\n\n /** A PDF longer than this is split regardless of its extracted text length. */\n private const MAX_PDF_PAGES_PER_CALL = 5;\n\n /**\n * Largest PDF we will hand to the parser (12 MB).\n *\n * smalot/pdfparser decompresses every stream into memory with no ceiling of\n * its own, and overrunning the limit there is a fatal error that kills the\n * worker rather than an exception we can recover from.\n */\n private const MAX_PDF_BYTES = 12582912;\n\n /**\n * Memory the parser is assumed to need, as a multiple of the file size.\n *\n * Decompressed content streams plus the object graph run well past the raw\n * bytes; 8x is conservative enough to catch the cases that were dying.\n */\n private const PDF_MEMORY_FACTOR = 8;\n\n /** Returned by extractPdfText when it stopped for memory, not for lack of text. */\n private const PDF_LOW_MEMORY = \"\\0__pdf_low_memory__\";\n\n private string $baseUrl;\n private string $parseEndpoint;\n private string $apiKey;\n private int $timeout;\n private bool $testMode;\n private BoqCleaningService $boqCleaner;\n\n /** Page count of the most recently parsed PDF; 0 when unknown. */\n private int $lastPdfPageCount = 0;\n\n /**\n * Optional progress reporter for chunked parsing, called as ($part, $total).\n *\n * Set by the queue job so the polling UI can show real progress on a large\n * BOQ instead of a spinner that appears frozen for many minutes.\n *\n * @var (callable(int, int): void)|null\n */\n private $onChunkProgress = null;\n\n /** @param callable(int, int): void $callback */\n public function onChunkProgress(callable $callback): self\n {\n $this->onChunkProgress = $callback;\n return $this;\n }\n\n /**\n * Optional per-chunk item sink, called as ($items, $part, $total).\n *\n * Lets the caller persist each slice's rows the moment they arrive instead\n * of waiting for the whole document. On a large BOQ this is the difference\n * between a table that fills in progressively and one that stays empty for\n * many minutes.\n *\n * @var (callable(array, int, int): void)|null\n */\n private $onChunkItems = null;\n\n /** @param callable(array, int, int): void $callback */\n public function onChunkItems(callable $callback): self\n {\n $this->onChunkItems = $callback;\n return $this;\n }\n\n /**\n * Report a non-chunk stage (local parse, handing off to AI) to the caller.\n *\n * A spreadsheet is read locally first and only sent to the AI when that\n * fails, so a large file can spend minutes before any chunk exists. Without\n * these the UI shows a generic spinner for that whole period.\n */\n private function reportStage(string $message): void\n {\n if ($this->onStage !== null) {\n ($this->onStage)($message);\n }\n }\n\n /**\n * Report a workbook's shape from grids that are already in memory.\n *\n * Deliberately takes the parsed grids rather than a path: re-reading a large\n * spreadsheet just to count its sheets would double the slowest step of the\n * whole extraction.\n *\n * @param array<int, array{name: string, grid: array}> $sheets\n */\n private function reportWorkbookShape(array $sheets): void\n {\n if ($this->onStage === null || empty($sheets)) {\n return;\n }\n\n $rows = 0;\n foreach ($sheets as $sheet) {\n $rows += count($sheet['grid']);\n }\n\n $count = count($sheets);\n\n $this->reportStage($count > 1\n ? \"Found {$count} sheets, {$rows} rows in total. Reading…\"\n : \"Found {$rows} rows. Reading…\");\n }\n\n /** @var (callable(string): void)|null */\n private $onStage = null;\n\n /** @param callable(string): void $callback */\n public function onStage(callable $callback): self\n {\n $this->onStage = $callback;\n return $this;\n }\n\n public function __construct(BoqCleaningService $boqCleaner)\n {\n $this->boqCleaner = $boqCleaner;\n $this->baseUrl = rtrim((string) config('services.ai_quotation.base_url', ''), '/');\n $this->parseEndpoint = ltrim((string) config('services.ai_quotation.parse_endpoint', 'parse'), '/');\n $this->apiKey = (string) config('services.ai_quotation.api_key', '');\n $this->timeout = (int) config('services.ai_quotation.timeout', 300);\n $this->testMode = (bool) config('services.ai_quotation.test_mode', false);\n }\n\n /**\n * Give the running script enough head-room to outlast the AI HTTP call.\n *\n * The web request / queue worker must not be killed before the HTTP timeout\n * completes, otherwise the user sees a generic \"timeout or unavailable\"\n * error even though the AI may have responded. We allow the HTTP timeout\n * plus a 120s margin for connecting, response parsing and DB writes.\n */\n private function extendExecutionTime(): void\n {\n // Ignored on some SAPIs (e.g. when running under fastcgi with a hard\n // limit), but harmless to attempt.\n @set_time_limit($this->timeout + 120);\n }\n\n public function parseBoq(UploadedFile|string $file, array $context = []): array\n {\n if ($this->testMode) {\n return $this->mockResponse();\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n\n if (! is_file($absPath)) {\n return $this->failure('File not found or not readable.');\n }\n\n $fileHash = hash_file('sha256', $absPath);\n // Bump PARSER_VERSION whenever the extraction logic changes so that\n // previously-uploaded files are re-parsed instead of returning a stale\n // cached result produced by the old code.\n $cacheKey = 'boq_analysis_v' . self::PARSER_VERSION . '_' . $fileHash;\n $forceRefresh = (bool) ($context['force_refresh'] ?? false);\n\n if (! $forceRefresh && ($cached = Cache::get($cacheKey)) !== null) {\n return $cached;\n }\n\n // Clear stale cached result when force-refreshing\n if ($forceRefresh) {\n Cache::forget($cacheKey);\n }\n\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n // Read Excel locally first. Fall back to AI if local parse failed,\n // found 0 items, OR could not read one of the sheets (so multi-sheet\n // files never silently drop a sheet the local parser didn't understand).\n $this->reportStage('Reading the spreadsheet…');\n\n $result = $this->parseSpreadsheetDirect($absPath);\n\n $hasSkippedSheets = ! empty($result['skipped_sheets']);\n\n if (! $result['success'] || empty($result['items']) || $hasSkippedSheets) {\n // The local pass could not handle this file on its own. Say so:\n // otherwise the UI sits on a generic spinner through the local\n // parse AND the whole AI parse with nothing to show for either.\n $this->reportStage($hasSkippedSheets\n ? 'Some sheets need AI to read. Sending to AI…'\n : 'Spreadsheet needs AI to read. Sending to AI…');\n\n $deepSeekResult = $this->parseBoqWithDeepSeek($file, $context);\n // AI sees every sheet at once, so prefer its result whenever it\n // actually returned items (covers the skipped-sheet case too).\n if ($deepSeekResult['success'] && ! empty($deepSeekResult['items'])) {\n // Keep the larger of the two item sets so we never lose the\n // rows the local parser already extracted correctly.\n if (count($deepSeekResult['items']) >= count($result['items'] ?? [])) {\n $result = $deepSeekResult;\n }\n } elseif (! $result['success']) {\n $result = $deepSeekResult;\n }\n // else: keep local result if AI also failed/returned nothing\n }\n } elseif (in_array($ext, ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif'], true)) {\n $result = $this->parseBoqWithDeepSeek($file, $context);\n } else {\n return $this->failure('Unsupported file type. Please upload Excel, CSV, PDF, or image file.');\n }\n\n if ($result['success'] && ! empty($result['items'])) {\n $cleaning = $this->boqCleaner->process($result['items']);\n $result['items'] = $cleaning['accepted'] ?? $result['items'];\n $result['rejected'] = array_merge($result['rejected'] ?? [], $cleaning['rejected'] ?? []);\n }\n\n if ($result['success'] && count($result['items'] ?? []) > self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n $result = $this->verifyLargeBoqItemsWithAi($result);\n }\n\n // Only cache when we actually extracted items; empty results should be re-tried.\n if ($result['success'] && ! empty($result['items'])) {\n Cache::put($cacheKey, $result, now()->addDays(30));\n }\n\n return $result;\n }\n\n private function resolveFile(UploadedFile|string $file): array\n {\n if ($file instanceof UploadedFile) {\n return [(string) $file->getRealPath(), strtolower($file->getClientOriginalExtension())];\n }\n\n $absPath = is_file($file) ? $file : storage_path('app/' . ltrim($file, '/'));\n return [$absPath, strtolower(pathinfo($absPath, PATHINFO_EXTENSION))];\n }\n\n private function parseSpreadsheetDirect(string $absPath): array\n {\n ini_set('memory_limit', '2048M');\n\n // 180s was not enough for a real BOQ: an 11 200-row workbook was killed\n // mid-parse, and because set_time_limit() kills the process outright the\n // job reported a bare failure with nothing in the log to explain it.\n // This runs on the queue, where wall time is cheap.\n set_time_limit(1800);\n\n try {\n $sheets = $this->spreadsheetToGrids($absPath);\n\n if (empty($sheets)) {\n return $this->failure('Could not read the Excel file.');\n }\n\n $this->reportWorkbookShape($sheets);\n\n $items = [];\n $rejected = [];\n $skippedSheets = []; // sheets we could not parse locally (header undetected)\n\n foreach ($sheets as $sheet) {\n $grid = $sheet['grid'];\n if (count($grid) < 2) {\n continue;\n }\n\n // Skip dedicated summary / cost-abstract sheets entirely. Their rows\n // are per-discipline totals (Structural Concrete, Electrical, …) and\n // grand totals, not procurable products.\n if ($this->isSummarySheet($sheet['name'])) {\n Log::info('QuotationAiService: skipped summary sheet.', ['sheet' => $sheet['name']]);\n continue;\n }\n\n $header = $this->detectHeader($grid);\n\n if ($header === null || ! isset($header['map']['description'])) {\n Log::warning('QuotationAiService: header not detected in sheet.', ['sheet' => $sheet['name']]);\n $skippedSheets[] = $sheet['name'];\n continue;\n }\n\n $map = $header['map'];\n $start = $header['row'] + 1;\n\n for ($r = $start; $r < count($grid); $r++) {\n $row = $grid[$r];\n $description = $this->cell($row, $map['description'] ?? null);\n $itemNo = $this->cell($row, $map['item_code'] ?? null);\n $unit = $this->cell($row, $map['unit'] ?? null);\n $quantity = $this->number($this->cell($row, $map['quantity'] ?? null));\n $unitPrice = $this->number($this->cell($row, $map['unit_price'] ?? null));\n $totalPrice = $this->number($this->cell($row, $map['total_price'] ?? null));\n\n if ($description === '') {\n continue;\n }\n\n if (! isset($map['quantity'])) {\n $quantity = 1.0;\n }\n\n // Skip floor breakdown sub-rows: Ref. is \"-\" meaning it's a per-floor\n // quantity split of the parent item, not a standalone product.\n if ($itemNo === '-' || $this->isFloorBreakdownRow($itemNo, $description)) {\n continue;\n }\n\n if ($this->isTotalOrHeaderRow($description)) {\n continue;\n }\n\n if ($quantity === null || $quantity <= 0) {\n $rejected[] = $this->rejectedRow($description, $unit, $quantity, 'Missing quantity', $itemNo, $sheet['name']);\n continue;\n }\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $supply = $this->boqCleaner->filterItem($description, $unitPrice, $quantity);\n\n if (! ($supply['keep'] ?? false)) {\n $rejected[] = $this->rejectedRow(\n $description,\n $unit,\n $quantity,\n (string) ($supply['rejection_reason'] ?? 'Rejected by cleaner'),\n $itemNo,\n $sheet['name']\n );\n continue;\n }\n\n $finalDescription = (string) ($supply['description'] ?? $description);\n\n $items[] = [\n 'description' => $finalDescription,\n 'quantity' => $quantity,\n 'unit' => $unit,\n 'category' => $sheet['name'],\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($finalDescription),\n 'unit_price' => $unitPrice,\n 'confidence' => 0.95,\n 'needs_review' => false,\n 'raw_data' => [\n 'sheet' => $sheet['name'],\n 'row_number' => $r + 1,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'cleaned_description' => $finalDescription,\n 'extraction_type' => (string) ($supply['extraction_type'] ?? 'supply_only'),\n ],\n 'ai_extracted' => true,\n ];\n }\n }\n\n if (empty($items)) {\n return $this->failure('No BOQ supply items could be extracted from the Excel file.');\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'skipped_sheets' => $skippedSheets,\n ];\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: parseSpreadsheetDirect failed.', [\n 'path' => $absPath,\n 'message' => $e->getMessage(),\n 'trace' => $e->getTraceAsString(),\n ]);\n\n return $this->failure('Failed to read the Excel file: ' . $e->getMessage());\n }\n }\n\n private function spreadsheetToGrids(string $absPath): array\n {\n $ext = strtolower(pathinfo($absPath, PATHINFO_EXTENSION));\n\n if ($ext === 'csv') {\n $rows = [];\n $handle = fopen($absPath, 'rb');\n if ($handle !== false) {\n while (($row = fgetcsv($handle)) !== false) {\n $rows[] = $this->trimTrailingEmptyCells($row);\n }\n fclose($handle);\n }\n return [['name' => 'Sheet1', 'grid' => $rows]];\n }\n\n $reader = IOFactory::createReader($ext === 'xls' ? 'Xls' : 'Xlsx');\n $reader->setReadDataOnly(true);\n $spreadsheet = $reader->load($absPath);\n $sheets = [];\n\n foreach ($spreadsheet->getAllSheets() as $sheet) {\n $highestRow = $sheet->getHighestDataRow();\n $highestColumn = $sheet->getHighestDataColumn();\n $highestColumnIndex = Coordinate::columnIndexFromString($highestColumn);\n\n // BOQ data is normally in first 8-20 columns.\n // Files with heavy formatting/images can report 300+ columns — limit to 30.\n $maxColumnIndex = min($highestColumnIndex, 30);\n\n $grid = [];\n for ($row = 1; $row <= $highestRow; $row++) {\n $rowData = [];\n $hasValue = false;\n\n for ($col = 1; $col <= $maxColumnIndex; $col++) {\n $value = $sheet->getCell([$col, $row])->getValue();\n $value = $this->cellValueToString($value);\n $value = trim($value);\n if ($value !== null && $value !== '') {\n $hasValue = true;\n }\n $rowData[] = $value;\n }\n\n $rowData = $this->trimTrailingEmptyCells($rowData);\n if ($hasValue) {\n $grid[] = $rowData;\n }\n }\n\n if (! empty($grid)) {\n $sheets[] = ['name' => $sheet->getTitle(), 'grid' => $grid];\n }\n }\n\n $spreadsheet->disconnectWorksheets();\n unset($spreadsheet);\n\n return $sheets;\n }\n\n private function detectHeader(array $grid): ?array\n {\n $keywords = [\n 'item_code' => ['رقم البند', 'item no', 'item number', 'ref.', 'ref', 'item', 'no.', 'no', '#', 'كود'],\n 'description' => ['وص٠البند', 'scope of works', 'scope of work', 'scope', 'item description', 'description of works', 'description', 'بالعربية', 'الوصÙ', 'البند', 'بيان'],\n 'unit' => ['Ø§Ù„ÙˆØØ¯Ø©', 'unit', 'uom', 'u/m'],\n 'quantity' => ['الكمية', 'qty', 'quantity', 'q.ty'],\n 'unit_price' => ['سعر Ø§Ù„ÙˆØØ¯Ø©', 'u/price', 'unit price', 'unit rate', 'rate', 'price'],\n 'total_price' => ['السعر الإجمالي', 'total amount', 'total price', 'amount', 'الإجمالي'],\n ];\n\n $best = null;\n $bestScore = 0;\n\n for ($r = 0; $r < min(30, count($grid)); $r++) {\n $map = [];\n $score = 0;\n\n foreach ($grid[$r] as $c => $value) {\n $cell = mb_strtolower(trim((string) $value));\n if ($cell === '') {\n continue;\n }\n\n foreach ($keywords as $field => $list) {\n foreach ($list as $kw) {\n if (str_contains($cell, mb_strtolower($kw))) {\n if (! isset($map[$field])) {\n $map[$field] = $c;\n $score++;\n }\n break 2;\n }\n }\n }\n }\n\n if ($score > $bestScore) {\n $bestScore = $score;\n $best = ['row' => $r, 'map' => $map];\n }\n }\n\n if ($bestScore >= 3) {\n return $best;\n }\n\n // Allow simpler BOQ sheets that only define a description column plus one\n // other BOQ-related column, e.g. Item + Description or Description + Qty.\n if ($bestScore === 2 && isset($best['map']['description']) && (\n isset($best['map']['item_code']) ||\n isset($best['map']['quantity']) ||\n isset($best['map']['unit'])\n )) {\n return $best;\n }\n\n return null;\n }\n\n private function cell(array $row, ?int $index): string\n {\n if ($index === null) {\n return '';\n }\n return trim((string) ($row[$index] ?? ''));\n }\n\n private function number(string $value): ?float\n {\n $value = trim(str_replace([',', 'SAR', 'ر.س'], '', $value));\n if ($value === '' || ! is_numeric($value)) {\n return null;\n }\n return (float) $value;\n }\n\n private function trimTrailingEmptyCells(array $row): array\n {\n while (! empty($row) && trim((string) end($row)) === '') {\n array_pop($row);\n }\n return array_values($row);\n }\n\n private function isTotalOrHeaderRow(string $description): bool\n {\n $d = mb_strtolower(trim($description));\n\n if ($d === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $d)) {\n return true;\n }\n\n // Section totals / summaries\n if (preg_match('/(اجمالي|إجمالي|المجموع|grand\\s*total|sub\\s*total|total\\s*amount|total\\s*price)/iu', $d)) {\n return true;\n }\n\n // Bare \"Total\" lines, optionally with a currency suffix, e.g. \"Total\",\n // \"Total (SAR)\", \"Total cost\", \"Total (SAR) unit cost\".\n if (preg_match('/^total\\b[\\s\\(\\):a-z]*$/iu', $d)) {\n return true;\n }\n\n // Pure floor / level labels used as section headers\n if (preg_match('/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor)\\s*$/iu', $d)) {\n return true;\n }\n\n // Division / section header lines (e.g. \"Division - 02: Wood Works\")\n if (preg_match('/^division\\s*([-:]|\\d)/iu', $d)) {\n return true;\n }\n\n return false;\n }\n\n /**\n * Detect dedicated summary / cost-abstract sheets by their name. These hold\n * per-discipline rollups and grand totals (e.g. a \"Summary (Dry Cost)\" tab),\n * never procurable supply items, so they are skipped wholesale.\n */\n private function isSummarySheet(string $name): bool\n {\n $n = mb_strtolower(trim($name));\n\n if ($n === '') {\n return false;\n }\n\n return (bool) preg_match(\n '/\\b(summary|abstract|cost\\s*summary|grand\\s*summary|recap(?:itulation)?|bill\\s*summary|boq\\s*summary|dry\\s*cost)\\b|ملخص|الملخص|الملخّص|ملخّص|اجمالي|إجمالي/iu',\n $n\n );\n }\n\n private function isFloorBreakdownRow(string $itemNo, string $description): bool\n {\n // A row whose Ref column is empty or a dash is a sub-breakdown row (per-floor split).\n $ref = trim($itemNo);\n if ($ref === '' || $ref === '-') {\n // Confirm by checking the description is a location/floor label\n $d = mb_strtolower(trim($description));\n return (bool) preg_match(\n '/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor|\\d+f)\\s*$/iu',\n $d\n );\n }\n return false;\n }\n\n private function rejectedRow(string $description, string $unit, ?float $quantity, string $reason, string $itemNo = '', string $sheet = ''): array\n {\n return [\n 'description' => $description,\n 'quantity' => $quantity ?? 0,\n 'unit' => $unit,\n 'category' => $sheet,\n 'brand' => '',\n 'status' => 'rejected',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 0.0,\n 'raw_data' => [\n 'sheet' => $sheet,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'extraction_type' => 'rejected',\n 'rejection_reason' => $reason,\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function parseBoqWithDeepSeek(UploadedFile|string $file, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured. Please set DEEPSEEK_API_KEY in .env file.');\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n $mime = $this->mimeForExtension($ext);\n\n try {\n // ── Spreadsheets ──────────────────────────────────────────────────\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n $text = $this->spreadsheetToCompactCsvText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not convert spreadsheet to readable text.');\n }\n\n $text = $this->sanitizeUtf8($text);\n\n // A large BOQ exceeds what one request can carry, so send it in\n // sequential slices and merge. Anything that fits goes as-is.\n //\n // Size alone is not a good enough signal: a workbook with several\n // sheets is structurally a big BOQ even when the text is compact,\n // and asking the model to hold every discipline at once is where\n // rows start getting dropped. So split on either signal.\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS || $sheetCount > self::MAX_SHEETS_PER_CALL) {\n Log::info('QuotationAiService: spreadsheet routed to chunked parsing.', [\n 'chars' => mb_strlen($text),\n 'sheets' => $sheetCount,\n ]);\n return $this->parseTextInChunks($text, 'BOQ spreadsheet converted to CSV', $apiKey, $context);\n }\n\n $userContent = \"BOQ spreadsheet converted to CSV:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── PDF ───────────────────────────────────────────────────────────\n if ($ext === 'pdf') {\n $extracted = $this->extractPdfText($absPath);\n\n if ($extracted === self::PDF_LOW_MEMORY) {\n return $this->failure('The server was low on memory while reading this PDF. Please try again in a moment, or convert it to Excel or CSV.');\n }\n\n if ($extracted !== null && mb_strlen(trim($extracted)) > 50) {\n $extracted = $this->sanitizeUtf8($extracted);\n\n // Split on either signal — long text, or many pages. A BOQ\n // spanning several pages is a multi-section document even if\n // each page is sparse.\n if (mb_strlen($extracted) > self::TEXT_CHUNK_CHARS\n || $this->lastPdfPageCount > self::MAX_PDF_PAGES_PER_CALL) {\n Log::info('QuotationAiService: PDF routed to chunked parsing.', [\n 'chars' => mb_strlen($extracted),\n 'pages' => $this->lastPdfPageCount,\n ]);\n return $this->parseTextInChunks($extracted, 'BOQ PDF extracted text', $apiKey, $context, 'application/pdf');\n }\n\n $userContent = \"BOQ PDF extracted text:\\n\\n\" . $extracted . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'application/pdf');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n return $this->failure('Could not extract text from the PDF. Please make sure it is a text-based PDF (not a scanned image), or convert it to Excel or CSV.');\n }\n\n // -- Word documents (docx) ---------------------------------------------------\n if (in_array($ext, ['docx', 'doc'], true)) {\n $text = $this->extractDocxText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not extract text from the Word document. Please convert it to Excel, CSV, or PDF.');\n }\n $text = $this->sanitizeUtf8($text);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS) {\n return $this->parseTextInChunks($text, 'BOQ Word document extracted text', $apiKey, $context);\n }\n\n $userContent = \"BOQ Word document extracted text:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── Images ──────────────────────────────────────────────────────────────\n // DeepSeek cannot receive images. A vision-capable AI processes the image\n // directly (no OCR) — configure GROQ_API_KEY or GEMINI_API_KEY in .env.\n $imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif', 'heic', 'heif'];\n if (in_array($ext, $imageExts, true)) {\n $bytes = (string) file_get_contents($absPath);\n return $this->callDeepSeekVision($bytes, $mime, $context);\n }\n\n return $this->failure('Unsupported file type. Please upload an Excel, CSV, PDF, or image file.');\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: DeepSeek fallback failed.', ['message' => $e->getMessage()]);\n return $this->failure('An error occurred while processing the file with AI: ' . $e->getMessage());\n }\n }\n\n private function deepSeekModel(): string\n {\n return (string) config('services.deepseek.model', 'deepseek-chat');\n }\n\n /**\n * Remove or replace any non-UTF-8 / malformed byte sequences so json_encode never fails.\n */\n private function sanitizeUtf8(string $text): string\n {\n // Replace invalid byte sequences with the UTF-8 replacement character\n $clean = mb_convert_encoding($text, 'UTF-8', 'UTF-8');\n // Strip any remaining non-printable control characters (except tab/newline/CR)\n $clean = (string) preg_replace('/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]/u', '', $clean);\n return $clean;\n }\n\n /**\n * Extract plain text from a Word docx file using ZipArchive (no external library needed).\n * Reads word/document.xml and strips XML tags, preserving paragraph and table structure.\n */\n private function extractDocxText(string $absPath): ?string\n {\n try {\n $zip = new \\ZipArchive();\n if ($zip->open($absPath) !== true) {\n return null;\n }\n\n $xml = $zip->getFromName('word/document.xml');\n $zip->close();\n\n if ($xml === false || $xml === '') {\n return null;\n }\n\n // Add newlines before paragraph/table-row/table-cell tags so structure is preserved\n $xml = preg_replace('/<w:p[ >]/', \"\\n<w:p>\", $xml) ?? $xml;\n $xml = preg_replace('/<w:tr[ >]/', \"\\n<w:tr>\", $xml) ?? $xml;\n $xml = preg_replace('/<\\/w:tc>/', \"\\t\", $xml) ?? $xml;\n\n $text = strip_tags($xml);\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n return trim($text) !== '' ? trim($text) : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: DOCX parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Extract plain text from a PDF using smalot/pdfparser (pure PHP, no binary required).\n */\n /**\n * The process memory limit in bytes, or 0 when unlimited.\n *\n * memory_limit is a shorthand string (\"128M\", \"1G\", \"-1\"), so it has to be\n * expanded before it can be compared against anything.\n */\n private function memoryLimitBytes(): int\n {\n $raw = trim((string) ini_get('memory_limit'));\n\n if ($raw === '' || $raw === '-1') {\n return 0;\n }\n\n $value = (int) $raw;\n\n return match (strtolower(substr($raw, -1))) {\n 'g' => $value * 1024 * 1024 * 1024,\n 'm' => $value * 1024 * 1024,\n 'k' => $value * 1024,\n default => $value,\n };\n }\n\n private function extractPdfText(string $absPath): ?string\n {\n // The parser loads and decompresses the whole document before any of our\n // chunking gets a look at it, and it does so with no memory ceiling of\n // its own. A big PDF exhausts the limit inside gzuncompress() — a fatal\n // error, not an exception, so the catch below cannot help and the worker\n // dies outright. Refuse the file first instead.\n $size = @filesize($absPath) ?: 0;\n\n if ($size > self::MAX_PDF_BYTES) {\n Log::warning('QuotationAiService: PDF too large to parse in-process.', [\n 'bytes' => $size,\n 'limit' => self::MAX_PDF_BYTES,\n ]);\n\n return null;\n }\n\n // Headroom check: parsing routinely needs several times the file size\n // once streams are decompressed. If that would not fit in what is left,\n // stop here rather than taking the worker down with us.\n $limit = $this->memoryLimitBytes();\n\n if ($limit > 0) {\n // Try to reclaim memory a previous job left behind before deciding\n // there is not enough. Without this, a worker sitting near its limit\n // after a big job could refuse a tiny PDF for no real reason.\n gc_collect_cycles();\n\n $available = $limit - memory_get_usage(true);\n\n if ($available < $size * self::PDF_MEMORY_FACTOR) {\n Log::warning('QuotationAiService: not enough memory left to parse this PDF.', [\n 'bytes' => $size,\n 'available' => $available,\n 'needed' => $size * self::PDF_MEMORY_FACTOR,\n ]);\n\n // A distinct sentinel, so the caller can tell \"no memory\" apart\n // from \"no text\" and show the right message instead of blaming\n // the file for being scanned.\n return self::PDF_LOW_MEMORY;\n }\n }\n\n try {\n $parser = new \\Smalot\\PdfParser\\Parser();\n $pdf = $parser->parseFile($absPath);\n\n // Recorded for the chunking decision: a long PDF is structurally a\n // big BOQ even when its extracted text is short.\n try {\n $this->lastPdfPageCount = count($pdf->getPages());\n } catch (\\Throwable) {\n $this->lastPdfPageCount = 0;\n }\n\n $text = $pdf->getText();\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n return $text !== '' ? $text : null;\n } catch (\\Throwable $e) {\n // Class, file and line, not just the message — a bare message left\n // the actual cause of a PDF failure guessable at best.\n Log::warning('QuotationAiService: PDF parsing failed.', [\n 'exception' => $e::class,\n 'error' => $e->getMessage(),\n 'at' => $e->getFile() . ':' . $e->getLine(),\n ]);\n\n // smalot/pdfparser throws \"Invalid object reference for $obj.\" on\n // some perfectly valid PDFs — a known bug in the library, not a\n // problem with the file. Fall back to the pdftotext binary, which\n // reads those documents fine.\n return $this->extractPdfTextViaBinary($absPath);\n }\n }\n\n /**\n * Extract PDF text with the poppler `pdftotext` binary.\n *\n * A fallback for when smalot/pdfparser throws on a document it cannot handle\n * — most often \"Invalid object reference for $obj.\" pdftotext is a separate,\n * more robust implementation, so a PDF that breaks one usually reads on the\n * other. Returns null when the binary is absent or produces nothing, so the\n * caller drops to the vision path or a clear error.\n */\n private function extractPdfTextViaBinary(string $absPath): ?string\n {\n // shell_exec is commonly disabled on shared hosting; bail cleanly so the\n // caller falls through to the vision path or a clear error.\n if (! function_exists('shell_exec')\n || in_array('shell_exec', array_map('trim', explode(',', (string) ini_get('disable_functions'))), true)) {\n return null;\n }\n\n // `-layout` keeps columns roughly aligned, which matters for a BOQ; the\n // trailing \"-\" writes to stdout instead of a file.\n $command = sprintf('pdftotext -layout -enc UTF-8 %s - 2>/dev/null', escapeshellarg($absPath));\n\n $output = @shell_exec($command);\n\n if (! is_string($output) || trim($output) === '') {\n Log::warning('QuotationAiService: pdftotext fallback produced no text.', [\n 'path' => basename($absPath),\n ]);\n return null;\n }\n\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $output);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n\n if ($text !== '') {\n Log::info('QuotationAiService: recovered PDF text via pdftotext fallback.', [\n 'path' => basename($absPath),\n 'chars' => mb_strlen($text),\n ]);\n }\n\n return $text !== '' ? $text : null;\n }\n\n /**\n * Send a vision request to a vision-capable AI model.\n * DeepSeek's commercial API (api.deepseek.com) does NOT support image inputs — any model.\n * Priority order (first configured key wins):\n * 1. GROQ_API_KEY → Groq llama-4-scout (free: https://console.groq.com)\n * 2. GEMINI_API_KEY → Google Gemini Flash (free: https://aistudio.google.com/apikey)\n * 3. VISION_API_KEY → any OpenAI-compatible endpoint (OpenAI, OpenRouter …)\n */\n private function callDeepSeekVision(string $bytes, string $mime, array $context): array\n {\n // ── 1. Groq (free, reliable vision) ─────────────────────────────────────\n if ((string) config('services.groq.key', '') !== '') {\n $visionKey = (string) config('services.groq.key');\n $visionBaseUrl = 'https://api.groq.com/openai/v1';\n $visionModel = (string) config('services.groq.model', 'meta-llama/llama-4-scout-17b-16e-instruct');\n }\n // ── 2. Google Gemini ─────────────────────────────────────────────────────\n elseif ((string) config('services.gemini.key', '') !== '') {\n $visionKey = (string) config('services.gemini.key');\n $visionBaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai';\n $visionModel = (string) config('services.gemini.model', 'gemini-2.0-flash');\n }\n // ── 3. Generic VISION_API_KEY (OpenAI, OpenRouter, …) ───────────────────\n elseif ((string) config('services.vision.key', '') !== '') {\n $visionKey = (string) config('services.vision.key');\n $visionBaseUrl = rtrim((string) config('services.vision.base_url', 'https://openrouter.ai/api/v1'), '/');\n $visionModel = (string) config('services.vision.model', 'google/gemini-flash-1.5-8b');\n }\n else {\n return $this->failure(\n 'Image processing is not available. DeepSeek\\'s API does not support images. ' .\n 'Get a free key at https://console.groq.com and add GROQ_API_KEY to your .env file.'\n );\n }\n\n $dataUrl = \"data:{$mime};base64,\" . base64_encode($bytes);\n $prompt = $this->buildDeepSeekPrompt($context, $mime);\n\n $userContent = [\n ['type' => 'text', 'text' => $prompt],\n ['type' => 'image_url', 'image_url' => ['url' => $dataUrl]],\n ];\n\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders([\n 'Authorization' => 'Bearer ' . $visionKey,\n 'HTTP-Referer' => config('app.url', 'https://qimta.sa'),\n 'X-Title' => 'Qimta Platform',\n ])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post($visionBaseUrl . '/chat/completions', [\n 'model' => $visionModel,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 16384,\n 'temperature' => 0,\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('Vision API connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: Vision API HTTP error.', [\n 'model' => $visionModel,\n 'base_url' => $visionBaseUrl,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n return $this->failure(\"Vision API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n return $this->parseDeepSeekJsonResponse((string) $response->json('choices.0.message.content'));\n }\n\n private function callDeepSeekChat(string $userContent, string $key, string $model): array\n {\n // Ensure the PHP script outlives the HTTP call (+ a margin for parsing the\n // response and writing to the DB) so the worker/web request is not killed\n // mid-flight, which surfaces to users as a generic \"timeout or unavailable\".\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders(['Authorization' => 'Bearer ' . $key])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 32768,\n 'temperature' => 0,\n 'response_format' => ['type' => 'json_object'],\n 'user' => 'Qimta_Platform',\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('DeepSeek connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: DeepSeek HTTP error.', [\n 'model' => $model,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n if ($response->status() === 401) {\n return $this->failure('DeepSeek API key is invalid or unauthorised. Please update DEEPSEEK_API_KEY in .env.', false);\n }\n return $this->failure(\"DeepSeek API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n $text = (string) $response->json('choices.0.message.content');\n return $this->parseDeepSeekJsonResponse($text);\n }\n\n /**\n * Parse a document too large for one AI call by sending it in slices.\n *\n * A 20 000-row BOQ is several megabytes of text — far beyond what a single\n * request can carry, and beyond what the model can answer without hitting its\n * output token limit. Previously the text was simply cut at 180 000 chars and\n * the remainder discarded: the parse \"succeeded\" while silently dropping most\n * of the file. Now the text is split and each slice parsed on its own call,\n * with the items merged.\n *\n * Splitting happens on line boundaries so a BOQ row is never cut in half.\n * Slices run sequentially rather than pooled: DeepSeek rate-limits hard on\n * concurrent large prompts, and this already runs on the queue where wall\n * time is cheap.\n *\n * A slice that fails does not fail the document — its rows are lost, but the\n * rest are kept and the shortfall is logged. Total failure is reported only\n * when every slice fails.\n *\n * @param string $label How the payload is introduced to the model.\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n /**\n * Split a spreadsheet into the slices the AI will be asked to read.\n *\n * Exposed so the work can be spread over several queue jobs instead of one\n * job walking every slice in sequence. Returns an empty array when the file\n * is small enough to go in a single call, or cannot be read.\n *\n * @return array<int, string>\n */\n public function chunkSpreadsheet(string $absPath): array\n {\n ini_set('memory_limit', '2048M');\n set_time_limit(1800);\n\n $text = $this->spreadsheetToCompactCsvText($absPath);\n\n if ($text === null || trim($text) === '') {\n return [];\n }\n\n $text = $this->sanitizeUtf8($text);\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) <= self::TEXT_CHUNK_CHARS && $sheetCount <= self::MAX_SHEETS_PER_CALL) {\n return [];\n }\n\n $chunks = $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n\n // Free the source text: the slices are a second full copy of it, and on\n // a large workbook that doubling — on top of PhpSpreadsheet's grids —\n // is what exhausted memory here.\n unset($text);\n\n Log::info('QuotationAiService: chunked spreadsheet.', [\n 'chunks' => count($chunks),\n 'peak_mb' => round(memory_get_peak_usage(true) / 1048576),\n ]);\n\n return $chunks;\n }\n\n /**\n * Split a spreadsheet straight to disk, one file per slice.\n *\n * chunkSpreadsheet() returns every slice as an array, which is a second\n * full copy of the document in memory on top of PhpSpreadsheet's grids.\n * This hands each slice to $write and drops it immediately, so peak memory\n * is one slice rather than the whole file twice.\n *\n * @param callable(int, string): void $write ($part, $chunk)\n * @return int number of slices written; 0 when the file needs no split\n */\n public function chunkSpreadsheetToDisk(string $absPath, callable $write): int\n {\n ini_set('memory_limit', '2048M');\n set_time_limit(1800);\n\n $text = $this->spreadsheetToCompactCsvText($absPath);\n\n if ($text === null || trim($text) === '') {\n return 0;\n }\n\n $text = $this->sanitizeUtf8($text);\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) <= self::TEXT_CHUNK_CHARS && $sheetCount <= self::MAX_SHEETS_PER_CALL) {\n return 0;\n }\n\n $part = 0;\n foreach ($this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS) as $chunk) {\n $write(++$part, $chunk);\n }\n\n unset($text);\n\n Log::info('QuotationAiService: chunked spreadsheet to disk.', [\n 'chunks' => $part,\n 'peak_mb' => round(memory_get_peak_usage(true) / 1048576),\n ]);\n\n return $part;\n }\n\n /**\n * Parse one slice produced by chunkSpreadsheet().\n *\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n public function parseChunk(string $chunk, int $part, int $total, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured.');\n }\n\n // Same framing parseTextInChunks() uses, so a slice is never mistaken\n // for a complete BOQ and totals are not inferred from a fragment.\n $userContent = \"BOQ spreadsheet converted to CSV (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, 'text/plain');\n\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n private function parseTextInChunks(\n string $text,\n string $label,\n string $apiKey,\n array $context,\n string $mime = 'text/plain',\n ): array {\n $chunks = $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n $total = count($chunks);\n\n Log::info('QuotationAiService: large document split for parsing.', [\n 'chars' => mb_strlen($text),\n 'chunks' => $total,\n ]);\n\n // Announce the split before the first slice is sent, so the UI can show\n // how many parts there are instead of sitting on a generic message for\n // however long the first AI call takes.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)(0, $total);\n }\n\n $items = [];\n $rejected = [];\n $failed = 0;\n $lastError = null;\n\n foreach ($chunks as $index => $chunk) {\n $part = $index + 1;\n\n // Report progress so a 28-slice parse does not look frozen to the user.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)($part, $total);\n }\n\n // Tell the model this is a fragment, so it does not try to reconcile\n // totals or treat a mid-document slice as a complete BOQ.\n $userContent = \"{$label} (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, $mime);\n\n $result = $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n\n if (! ($result['success'] ?? false)) {\n $failed++;\n $lastError = $result['error'] ?? 'Chunk parsing failed.';\n Log::warning('QuotationAiService: chunk failed, continuing.', [\n 'part' => $part,\n 'of' => $total,\n 'error' => $lastError,\n ]);\n continue;\n }\n\n foreach ($result['items'] as $item) {\n $items[] = $item;\n }\n foreach ($result['rejected'] ?? [] as $row) {\n $rejected[] = $row;\n }\n\n // Hand this slice's rows straight to the caller so they can be shown\n // while the remaining slices are still being parsed.\n if ($this->onChunkItems !== null && $result['items'] !== []) {\n ($this->onChunkItems)($result['items'], $part, $total);\n }\n }\n\n // Every slice failed — there is nothing to show, so report the failure.\n if ($failed === $total) {\n return $this->failure($lastError ?? 'Could not parse the document.');\n }\n\n if ($failed > 0) {\n Log::error('QuotationAiService: document parsed with missing parts.', [\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n 'items_kept' => count($items),\n ]);\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'service_unavailable' => false,\n 'partial' => $failed > 0,\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n ];\n }\n\n /**\n * Split text into slices of at most $limit characters, breaking only at\n * newlines so no BOQ row is ever cut in half.\n *\n * A single line longer than the limit (rare — a pathological cell) is passed\n * through oversized rather than severed mid-row.\n *\n * @return array<int, string>\n */\n private function splitOnLineBoundaries(string $text, int $limit): array\n {\n $chunks = [];\n $current = '';\n\n // Sheet a chunk starts in the middle of. Repeated at the top of the next\n // chunk so the model always knows which sheet the rows belong to — the\n // header would otherwise be stranded in the previous slice, and rows\n // would arrive with no discipline context.\n $currentSheet = null;\n\n foreach (explode(\"\\n\", $text) as $line) {\n $isSheetHeader = str_starts_with($line, 'Sheet: ');\n\n // Start a new chunk at every sheet boundary. Sheets are usually\n // separate disciplines, and the whole point of splitting a multi-tab\n // workbook is to give the model one discipline at a time — a size-only\n // split would leave a compact 3-tab file as a single chunk, silently\n // undoing the sheet gate that routed it here.\n if ($isSheetHeader && $current !== '') {\n $chunks[] = $current;\n $current = $line;\n $currentSheet = $line;\n continue;\n }\n\n if ($isSheetHeader) {\n $currentSheet = $line;\n }\n\n $candidate = $current === '' ? $line : $current . \"\\n\" . $line;\n\n if (mb_strlen($candidate) > $limit && $current !== '') {\n $chunks[] = $current;\n\n // Carry the sheet header over, unless this line is itself one.\n $current = ($currentSheet !== null && ! str_starts_with($line, 'Sheet: '))\n ? $currentSheet . \" (continued)\\n\" . $line\n : $line;\n continue;\n }\n\n $current = $candidate;\n }\n\n if (trim($current) !== '') {\n $chunks[] = $current;\n }\n\n return $chunks;\n }\n\n /**\n * Shared JSON response parser used by both chat and vision paths.\n */\n private function parseDeepSeekJsonResponse(string $text): array\n {\n if ($text === '') {\n return $this->failure('DeepSeek returned an empty response.');\n }\n\n // Strip markdown code fences if the model wrapped the JSON.\n if (preg_match('/```(?:json)?\\s*([\\s\\S]*?)\\s*```/i', $text, $m)) {\n $text = $m[1];\n }\n // Narrow to the outermost JSON object/array.\n if (preg_match('/[\\{\\[][\\s\\S]*[\\}\\]]/s', $text, $m)) {\n $text = $m[0];\n }\n $text = trim($text);\n\n $decoded = json_decode($text, true);\n\n // The response can be truncated when the model hits the token limit,\n // leaving an unterminated JSON string/object. Try to repair it before\n // giving up so we can still salvage the items extracted so far.\n if (! is_array($decoded)) {\n $repaired = $this->repairTruncatedJson($text);\n if ($repaired !== null) {\n $decoded = json_decode($repaired, true);\n }\n }\n\n if (! is_array($decoded)) {\n Log::error('QuotationAiService: DeepSeek non-JSON response.', ['text' => mb_substr($text, 0, 1000)]);\n return $this->failure('DeepSeek returned a non-JSON response. The file may be too large — try splitting it into smaller files.');\n }\n\n $rawItems = $decoded['items'] ?? (array_is_list($decoded) ? $decoded : []);\n $items = [];\n $rejected = [];\n\n foreach ($rawItems as $raw) {\n if (! is_array($raw)) {\n continue;\n }\n $item = $this->normaliseAiItem($raw);\n if ($item === null) {\n continue;\n }\n $check = $this->boqCleaner->filterItem((string) $item['description']);\n if ($check['keep'] ?? false) {\n $items[] = $item;\n } else {\n $item['status'] = 'rejected';\n $item['raw_data']['rejection_reason'] = $check['rejection_reason'] ?? 'Rejected by cleaner';\n $rejected[] = $item;\n }\n }\n\n if (empty($items)) {\n Log::info('QuotationAiService: DeepSeek returned 0 accepted items.', [\n 'raw_items_count' => count($rawItems),\n 'rejected_count' => count($rejected),\n 'first_rejected' => ! empty($rejected) ? ($rejected[0]['description'] ?? '') : '',\n ]);\n }\n\n return ['success' => true, 'items' => $items, 'rejected' => $rejected, 'error' => null];\n }\n\n /**\n * Best-effort repair of a truncated JSON string produced when the model hits\n * its token limit mid-output. Drops any incomplete trailing object, then\n * closes the open string/array/object brackets so the result is valid JSON.\n *\n * Returns the repaired JSON string, or null if it cannot be salvaged.\n */\n private function repairTruncatedJson(string $text): ?string\n {\n if ($text === '') {\n return null;\n }\n\n // Walk the string tracking structural state, ignoring everything inside\n // string literals (and their escapes). Remember the last position where\n // we were back at the top of the \"items\" array between complete elements,\n // so we can cut a half-written trailing object.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($text);\n $lastSafeCut = null; // index just after a completed array element\n\n for ($i = 0; $i < $len; $i++) {\n $ch = $text[$i];\n\n if ($inString) {\n if ($escaped) {\n $escaped = false;\n } elseif ($ch === '\\\\') {\n $escaped = true;\n } elseif ($ch === '\"') {\n $inString = false;\n }\n continue;\n }\n\n if ($ch === '\"') {\n $inString = true;\n } elseif ($ch === '{' || $ch === '[') {\n $stack[] = $ch;\n } elseif ($ch === '}' || $ch === ']') {\n array_pop($stack);\n } elseif ($ch === ',' && ! empty($stack) && end($stack) === '[') {\n // Separator directly inside an array: a clean boundary between\n // elements (the items array, whatever its nesting depth).\n $lastSafeCut = $i;\n }\n }\n\n // If we are still inside a string, the last element is incomplete — fall\n // back to the last clean array boundary if we have one.\n $candidate = $text;\n if ($inString && $lastSafeCut !== null) {\n $candidate = substr($text, 0, $lastSafeCut);\n } else {\n // Drop a dangling partial object after the last completed element.\n $candidate = rtrim($candidate);\n if ($lastSafeCut !== null && substr(rtrim($candidate), -1) !== '}' && substr(rtrim($candidate), -1) !== ']') {\n $candidate = substr($text, 0, $lastSafeCut);\n }\n }\n\n // Recompute the open brackets for the (possibly trimmed) candidate and\n // append the matching closers in reverse order.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($candidate);\n for ($i = 0; $i < $len; $i++) {\n $ch = $candidate[$i];\n if ($inString) {\n if ($escaped) { $escaped = false; }\n elseif ($ch === '\\\\') { $escaped = true; }\n elseif ($ch === '\"') { $inString = false; }\n continue;\n }\n if ($ch === '\"') { $inString = true; }\n elseif ($ch === '{' || $ch === '[') { $stack[] = $ch; }\n elseif ($ch === '}' || $ch === ']') { array_pop($stack); }\n }\n\n $candidate = rtrim($candidate);\n // Remove a trailing comma left after cutting an element.\n $candidate = rtrim($candidate, ',');\n\n $closers = '';\n for ($i = count($stack) - 1; $i >= 0; $i--) {\n $closers .= ($stack[$i] === '{') ? '}' : ']';\n }\n\n $repaired = $candidate . $closers;\n\n // Validate before returning.\n return is_array(json_decode($repaired, true)) ? $repaired : null;\n }\n\n private function spreadsheetToCompactCsvText(string $absPath): ?string\n {\n $sheets = $this->spreadsheetToGrids($absPath);\n if (empty($sheets)) {\n return null;\n }\n\n $output = '';\n $kept = 0;\n $dropped = 0;\n $nonSupply = 0;\n $duplicates = 0;\n\n /** @var array<string, true> Fingerprints of rows already emitted. */\n $seenRows = [];\n\n foreach ($sheets as $sheet) {\n // Don't feed summary / cost-abstract sheets to the AI either.\n if ($this->isSummarySheet($sheet['name'])) {\n continue;\n }\n $sheetRows = '';\n foreach ($sheet['grid'] as $row) {\n $row = $this->trimTrailingEmptyCells($row);\n\n if ($this->isNoiseRow($row)) {\n $dropped++;\n continue;\n }\n\n // Drop rows that are definitely not procurable before they cost\n // a single token: section totals, discipline headings,\n // preliminaries, labour-only lines. The same classifier already\n // runs on the AI's output, but by then the tokens are spent — on\n // an 11 200-row BOQ that is most of the bill.\n if ($this->isNonSupplyRow($row)) {\n $dropped++;\n $nonSupply++;\n continue;\n }\n\n // Byte-identical rows tell the AI nothing new. Only exact\n // duplicates are collapsed — description, quantity, unit and all\n // — so nothing is lost: two rows differing in quantity are two\n // different line items and both are sent.\n $fingerprint = $this->rowFingerprint($row);\n\n if (isset($seenRows[$fingerprint])) {\n $duplicates++;\n $dropped++;\n continue;\n }\n $seenRows[$fingerprint] = true;\n\n $sheetRows .= implode(',', array_map([$this, 'csvEscape'], $this->stripBoilerplate($row))) . \"\\n\";\n $kept++;\n }\n\n // A sheet whose rows were all noise contributes nothing but its own\n // header, which would just be another thing for the model to explain.\n if ($sheetRows === '') {\n continue;\n }\n\n $output .= 'Sheet: ' . $sheet['name'] . \"\\n\" . $sheetRows . \"\\n\";\n }\n\n $blank = $dropped - $nonSupply - $duplicates;\n\n Log::info('QuotationAiService: filtered rows before sending to AI.', [\n 'kept' => $kept,\n 'blank' => $blank,\n 'non_supply' => $nonSupply,\n 'duplicates' => $duplicates,\n ]);\n\n if ($dropped > 0) {\n $parts = [];\n if ($blank > 0) { $parts[] = \"{$blank} blank\"; }\n if ($nonSupply > 0) { $parts[] = \"{$nonSupply} non-supply\"; }\n if ($duplicates > 0) { $parts[] = \"{$duplicates} duplicate\"; }\n\n $this->reportStage('Skipped ' . implode(', ', $parts) . \" rows. Sending {$kept}…\");\n }\n\n return $output;\n }\n\n /**\n * True when a row carries no information worth sending to the AI.\n *\n * A real BOQ is mostly formatting: spacer rows, stray borders, a lone\n * section number, a leftover cell from a merged block. They survive\n * trimTrailingEmptyCells() because they are not *entirely* empty, then cost\n * tokens, dilute the prompt, and inflate the number of parts a large file is\n * split into.\n *\n * Deliberately conservative — a row is dropped only when nothing in it could\n * name a product. Anything with letters is kept, because a description is\n * the one field an item cannot do without.\n *\n * @param array<int, mixed> $row\n */\n /**\n * True when a row is definitely not a procurable supply item.\n *\n * Runs BoqCleaningService over the row's longest text cell — the one most\n * likely to be the description — so headings, totals, preliminaries and\n * labour-only lines never reach the AI. The same classifier already vets the\n * AI's *output*; doing it here as well means we stop paying to have those\n * rows read in the first place.\n *\n * Conservative by construction: only rows the classifier positively rejects\n * are dropped. Anything it is unsure about goes to the AI, which is the\n * better judge — a wrongly dropped row is invisible, a wrongly kept one only\n * costs tokens.\n *\n * @param array<int, mixed> $row\n */\n /**\n * Stable identity for a row, used to collapse exact duplicates.\n *\n * Whitespace and case are normalised so \"DN50 PIPE\" and \"dn50 pipe \" are one\n * row, but nothing else is: a differing quantity or unit produces a\n * different fingerprint and both rows are sent.\n *\n * @param array<int, mixed> $row\n */\n private function rowFingerprint(array $row): string\n {\n $parts = array_map(\n fn($cell) => mb_strtolower(preg_replace('/\\s+/u', ' ', trim((string) $cell)) ?? ''),\n $row,\n );\n\n // Drop a leading BOQ sequence number. Otherwise \"1,Pipe,m,100\" and\n // \"2,Pipe,m,100\" fingerprint differently and the dedup never fires —\n // which is exactly the shape every real BOQ has.\n if (isset($parts[0]) && $parts[0] !== '' && preg_match('/^[\\d.]+$/', $parts[0])) {\n array_shift($parts);\n }\n\n return md5(implode('|', $parts));\n }\n\n /**\n * Strip contractual boilerplate from a row's description cell.\n *\n * \"as per drawings\", \"complete with all necessary accessories\" and friends\n * repeat on nearly every line of a real BOQ and carry no information the\n * pricing engine can use — but they are a large share of the tokens.\n *\n * Only the longest text cell is touched, and only when the result is still a\n * usable description: if stripping would leave too little to identify the\n * product, the original is kept. A shorter-but-wrong description is far\n * worse than a verbose one.\n *\n * @param array<int, mixed> $row\n * @return array<int, mixed>\n */\n private function stripBoilerplate(array $row): array\n {\n $index = null;\n $longest = '';\n\n foreach ($row as $i => $cell) {\n $text = trim((string) $cell);\n if (preg_match('/\\p{L}/u', $text) && mb_strlen($text) > mb_strlen($longest)) {\n $longest = $text;\n $index = $i;\n }\n }\n\n // Nothing worth cleaning, or too short to risk it.\n if ($index === null || mb_strlen($longest) < 40) {\n return $row;\n }\n\n try {\n $cleaned = $this->boqCleaner->cleanDescription($longest);\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: boilerplate strip failed, keeping row as-is.', [\n 'error' => $e->getMessage(),\n ]);\n return $row;\n }\n\n // Guard against an over-eager pattern eating the product name: keep the\n // original unless a recognisable description survives.\n if (mb_strlen($cleaned) < 12 || mb_strlen($cleaned) < mb_strlen($longest) * 0.3) {\n return $row;\n }\n\n $row[$index] = $cleaned;\n\n return $row;\n }\n\n private function isNonSupplyRow(array $row): bool\n {\n $description = '';\n foreach ($row as $cell) {\n $text = trim((string) $cell);\n // Longest cell containing letters — numbers are qty/rate columns.\n if (preg_match('/\\p{L}/u', $text) && mb_strlen($text) > mb_strlen($description)) {\n $description = $text;\n }\n }\n\n // No text at all: leave it to isNoiseRow()/the AI rather than guessing.\n if ($description === '' || mb_strlen($description) < 3) {\n return false;\n }\n\n try {\n $verdict = $this->boqCleaner->filterItem($description);\n } catch (\\Throwable $e) {\n // A classifier failure must never silently drop a real product.\n Log::warning('QuotationAiService: pre-filter classifier failed, keeping row.', [\n 'error' => $e->getMessage(),\n ]);\n return false;\n }\n\n return ! ($verdict['keep'] ?? true);\n }\n\n private function isNoiseRow(array $row): bool\n {\n if (empty($row)) {\n return true;\n }\n\n $joined = trim(implode('', array_map(fn($c) => trim((string) $c), $row)));\n\n // Nothing but separators/whitespace.\n if ($joined === '') {\n return true;\n }\n\n // Ruled lines and decorative fills (\"-----\", \"====\", \"___\").\n if (preg_match('/^[\\-_=.*\\s|]+$/u', $joined)) {\n return true;\n }\n\n // Any letter — Latin or Arabic — means this could be a description.\n if (preg_match('/\\p{L}/u', $joined)) {\n return false;\n }\n\n // No letters at all: numbers only. A single number is a stray row\n // number or a dangling total; two or more could be qty/rate/amount\n // belonging to a real line, so keep those for the AI to judge.\n $numericCells = 0;\n foreach ($row as $cell) {\n if (trim((string) $cell) !== '') {\n $numericCells++;\n }\n }\n\n return $numericCells <= 1;\n }\n\n /**\n * Safely convert a PhpSpreadsheet cell value to a string.\n *\n * Cells may contain RichText or, in malformed/heavily-formatted files,\n * embedded objects such as Worksheet\\Drawing that have no __toString().\n * Casting those directly throws \"Object ... could not be converted to string\".\n */\n private function cellValueToString(mixed $value): string\n {\n if ($value === null) {\n return '';\n }\n\n if ($value instanceof \\PhpOffice\\PhpSpreadsheet\\RichText\\RichText) {\n return $value->getPlainText();\n }\n\n if (is_object($value)) {\n // Only stringify objects that explicitly support it; ignore the rest\n // (e.g. Worksheet\\Drawing) instead of crashing.\n return method_exists($value, '__toString') ? (string) $value : '';\n }\n\n if (is_array($value)) {\n return '';\n }\n\n return (string) $value;\n }\n\n private function csvEscape(mixed $value): string\n {\n $value = $this->cellValueToString($value);\n return str_contains($value, ',') || str_contains($value, '\"') || str_contains($value, \"\\n\")\n ? '\"' . str_replace('\"', '\"\"', $value) . '\"'\n : $value;\n }\n\n private function verifyLargeBoqItemsWithAi(array $result): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n Log::warning('QuotationAiService: large BOQ verification skipped, AI key is not configured.');\n return $result;\n }\n\n $items = array_values(array_filter($result['items'] ?? [], 'is_array'));\n if (count($items) <= self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n return $result;\n }\n\n Log::info('QuotationAiService: starting second-pass physical item verification.', [\n 'items_count' => count($items),\n 'threshold' => self::LARGE_BOQ_VERIFICATION_THRESHOLD,\n ]);\n\n $verifiedItems = [];\n $rejectedItems = $result['rejected'] ?? [];\n\n foreach (array_chunk($items, self::LARGE_BOQ_VERIFICATION_CHUNK_SIZE) as $chunkIndex => $chunk) {\n $payload = array_map(fn(array $item) => [\n 'description' => (string) ($item['description'] ?? ''),\n 'quantity' => $item['quantity'] ?? null,\n 'unit' => (string) ($item['unit'] ?? ''),\n 'unit_price' => $item['unit_price'] ?? null,\n 'category' => (string) ($item['category'] ?? ''),\n 'brand' => (string) ($item['brand'] ?? ''),\n ], $chunk);\n\n $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);\n if (! is_string($json)) {\n Log::warning('QuotationAiService: large BOQ verification chunk JSON encode failed.', [\n 'chunk' => $chunkIndex + 1,\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n $prompt = <<<PROMPT\nYou are doing a SECOND PASS verification on a BOQ extraction result.\n\nThe first pass may hallucinate non-products when the BOQ has thousands of rows.\nYour job is to keep ONLY physical, tangible, procurable supply products.\n\nRules:\n- Keep items only if a supplier can actually deliver them as materials/equipment.\n- Reject headings, trade names, systems, scopes, prose, specifications-only rows, totals, labor, installation, testing, commissioning, supervision, drawings, insurance, preliminaries, mobilisation, site works, and vague fragments.\n- Do not invent new products.\n- Do not add items that are not in the input.\n- You may clean a product description, but keep the original quantity, unit, unit_price, category, and brand when available.\n\nInput JSON array:\n{$json}\n\nReturn ONLY valid JSON in this shape:\n{\"items\":[{\"description\":\"\",\"quantity\":1,\"unit\":\"\",\"unit_price\":null,\"category\":\"\",\"brand\":\"\",\"confidence\":0.95}]}\nPROMPT;\n\n try {\n $chunkResult = $this->callDeepSeekChat($prompt, $apiKey, $this->deepSeekModel());\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: large BOQ verification chunk threw an exception.', [\n 'chunk' => $chunkIndex + 1,\n 'message' => $e->getMessage(),\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n if ($chunkResult['success'] ?? false) {\n array_push($verifiedItems, ...($chunkResult['items'] ?? []));\n $rejectedItems = array_merge($rejectedItems, $chunkResult['rejected'] ?? []);\n continue;\n }\n\n Log::warning('QuotationAiService: large BOQ verification chunk failed, keeping original chunk.', [\n 'chunk' => $chunkIndex + 1,\n 'error' => $chunkResult['error'] ?? null,\n ]);\n array_push($verifiedItems, ...$chunk);\n }\n\n if (empty($verifiedItems)) {\n Log::warning('QuotationAiService: second-pass verification returned no items, keeping first-pass result.', [\n 'original_items_count' => count($items),\n ]);\n return $result;\n }\n\n $result['items'] = $verifiedItems;\n $result['rejected'] = $rejectedItems;\n $result['large_boq_verified'] = true;\n\n Log::info('QuotationAiService: second-pass physical item verification completed.', [\n 'before' => count($items),\n 'after' => count($verifiedItems),\n ]);\n\n return $result;\n }\n\n private function normaliseAiItem(array $raw): ?array\n {\n if (! empty($raw['rejected'])) {\n return null;\n }\n\n $description = trim((string) ($raw['cleaned_description'] ?? $raw['product_name'] ?? $raw['description'] ?? ''));\n if ($description === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $description)) {\n return null;\n }\n\n $quantity = is_numeric($raw['quantity'] ?? null) ? (float) $raw['quantity'] : 1;\n $unitPrice = is_numeric($raw['unit_price'] ?? null) ? (float) $raw['unit_price'] : null;\n $totalPrice = is_numeric($raw['total_price'] ?? null) ? (float) $raw['total_price'] : null;\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $specs = is_array($raw['specifications'] ?? null) ? $raw['specifications'] : [];\n\n return [\n 'description' => $description,\n 'quantity' => $quantity,\n 'unit' => (string) ($raw['unit'] ?? ''),\n 'category' => (string) ($raw['core_product'] ?? $raw['category'] ?? ''),\n 'brand' => (string) ($specs['brand'] ?? $raw['brand'] ?? ''),\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($description),\n 'unit_price' => $unitPrice,\n 'confidence' => is_numeric($raw['confidence'] ?? null) ? (float) $raw['confidence'] : null,\n 'needs_review' => ! empty($raw['needs_review']),\n 'raw_data' => [\n 'source_item_no' => (string) ($raw['source_item_no'] ?? ''),\n 'original_description' => (string) ($raw['original_description'] ?? $description),\n 'cleaned_description' => $description,\n 'core_product' => (string) ($raw['core_product'] ?? ''),\n 'specifications' => $specs,\n 'extraction_type' => (string) ($raw['extraction_type'] ?? 'supply_only'),\n 'note' => (string) ($raw['note'] ?? $raw['notes'] ?? ''),\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function buildSystemInstruction(): string\n {\n return <<<'TXT'\nYou are a strict BOQ Supply Product Extraction Engine.\nExtract ONLY concrete, tangible, physical products that a supplier can box up and deliver to site — a specific item you could point at and say \"this is the thing being bought\" (e.g. \"Wall mounted LED luminaire 36W\", \"PVC pipe 110mm\", \"Single phase distribution board 12-way\", \"Fire extinguisher CO2 6kg\", \"CCTV dome camera 4MP\").\n\nABSOLUTELY REJECT abstract concepts, disciplines, categories, section names, and trade headings. These are NOT products even though they appear as rows:\n- Bare discipline/trade words: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", \"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\".\n- Summary / total / abstract / \"dry cost\" rows of any kind.\n- Anything that names a system, scope, or category instead of one buyable item.\n\nRule of thumb: if you cannot name a unit and physically hand the item over (a piece, a box, a length, a unit of equipment), REJECT it. A word that describes a field of work, a system, or a section of the BOQ is NEVER a product.\nReject totals, headings, general requirements, installation-only, testing, commissioning, supervision, mobilization, civil works, labor, and vague rows.\nFor Supply & Install rows, keep only the supply product and remove install/labor wording.\nReturn only valid JSON: {\"items\":[{\"source_item_no\":null,\"original_description\":\"\",\"cleaned_description\":\"\",\"core_product\":\"\",\"specifications\":{},\"quantity\":0,\"unit\":\"\",\"unit_price\":0,\"total_price\":0,\"extraction_type\":\"supply_only\",\"needs_review\":false,\"note\":\"\",\"rejected\":false,\"rejection_reason\":null,\"confidence\":0.95}]}\nTXT;\n }\n\n private function buildDeepSeekPrompt(array $context = [], string $mime = ''): string\n {\n $projectName = (string) ($context['project_name'] ?? '');\n $projectStatus = (string) ($context['project_status'] ?? '');\n $contextLine = $projectName !== ''\n ? \"Project: {$projectName}\" . ($projectStatus !== '' ? \" ({$projectStatus})\" : '')\n : '';\n $sourceHint = str_starts_with($mime, 'image/')\n ? 'The input is an image of a BOQ table. OCR every cell carefully — read the quantity/count column (العدد, الكمية, Qty) and extract the EXACT numeric value from each row.'\n : 'The input is a BOQ document/spreadsheet.';\n\n return trim(<<<PROMPT\nYou are a procurement specialist. Your task is to extract ONLY tangible, physical supply products from this Bill of Quantities (BOQ).\n\n{$contextLine}\n{$sourceHint}\n\nCRITICAL — MERGE WRAPPED / SPLIT ROWS INTO ONE ITEM:\nBOQ tables extracted from PDF often break ONE item's long description across several\nlines/rows. These continuation lines repeat the SAME quantity, unit and category, and\ntheir text is a grammatical continuation of the previous line (it starts mid-sentence,\nlowercase, or with \"the/and/-\", or is a spec/instruction clause). You MUST reconstruct\nthe original single item:\n- Treat consecutive rows that share the SAME quantity + unit + category as ONE product\n when the later rows are clearly continuation text of the first, NOT new products.\n- Emit ONE item with the merged description and the price counted ONCE. Do NOT repeat\n the same quantity×price for every wrapped line — that multiplies the total wrongly.\n- Example: rows \"The asphalt flooring is to be supplied\" / \"installed in accordance\" /\n \"standard road specifications...\" all with 7,100 m2 are ONE asphalt item, not 3+.\n\nCRITICAL — UNDERSTAND INTENT, DO NOT COPY ROWS LITERALLY:\nYour job is to output WHAT A SUPPLIER MUST ACTUALLY DELIVER, not a copy of the row text.\nRead each description for MEANING and decide how many real, separately-buyable products it\nimplies. One BOQ row can imply SEVERAL products, or a spread of rows can imply ONE product.\n- SPLIT a row into multiple items when it lists distinct products that a supplier would\n buy/price separately — even when joined by commas, \"with\", \"and\", \"including\", \"c/w\",\n \"consisting of\", \"comprising\", or \":\" then a list.\n Example: \"Fire pump set consisting of: fire pump, electric motor, control panel, diesel\n engine\" → FOUR items: (1) fire pump, (2) electric motor, (3) control panel, (4) diesel\n engine — each with its own quantity and priced on its own.\n Example: \"Wash basin complete with mixer tap and bottle trap\" → THREE items: wash basin,\n mixer tap, bottle trap.\n- Give each split product the correct quantity implied by the text (usually the parent\n quantity, unless the text states a different count for a sub-item). Each product is\n priced individually; the line total becomes the SUM of its products.\n- DO NOT split when the extra words are just specifications, dimensions, colours, ratings,\n standards, or a single product's integral parts. \"Steel door 900x2100 with vision panel\n and SS ironmongery\" is usually ONE door product — vision panel and ironmongery are part\n of that door, not separately-bought items. Use judgement: would a supplier issue a\n separate price line for it? If no, keep it as one.\n- Balanced approach: split only when genuinely separate products are present; when in doubt\n about whether something is a separate product vs. an accessory/spec of the main one, keep\n it as a single item. Do not invent products that are not in the text.\n\nSTRICT RULES — include ONLY items that are ALL of the following:\n1. A specific, concrete, tangible product that can be boxed up by a supplier and delivered to site — something you can point at and say \"this exact thing is being bought\" (e.g. \"LED luminaire 36W\", \"PVC pipe 110mm\", \"Distribution board 12-way\", \"CO2 fire extinguisher 6kg\").\n2. Has a real quantity (not 0 or \"varies\" with no meaning).\n3. Has enough description to identify what the product is.\n4. Is an actual PRODUCT, not a sentence, instruction, or specification clause. REJECT rows that are prose such as \"the supervising engineer's instructions\", \"The price includes all materials, labor\", \"Measurement will be based on the geometric cubic meter\", \"necessary steps to complete the work\", \"soil-contacting elements\", \"in compliance\", \"everything necessary\". These are description fragments, not buyable items.\n\nNEVER extract abstract concepts, disciplines, trades, systems, or section/category names — they are NOT products even when they appear as their own row:\n- Bare discipline/trade/system words such as: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", and their Arabic equivalents (\"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\", \"تشطيبات\").\n- Test: if the word names a FIELD OF WORK or a SYSTEM rather than one buyable item with a unit (pcs, m, set, kg…), REJECT it.\n\nEXCLUDE all of the following (do NOT include them):\n- Labor, installation, or workmanship items (e.g. \"install\", \"fix\", \"erect\", \"dismantle\", \"painting\", \"cleaning service\")\n- Project management, overhead, or profit items\n- Insurance, bonds, liability items (e.g. \"bodily injury\", \"third party liability\", \"CAR insurance\")\n- Shop drawings, as-built drawings, method statements, documentation\n- Preliminary items, mobilisation, demobilisation, provisional sums\n- Housekeeping, site clearance, waste removal\n- Floor/level labels (e.g. \"GF Floor\", \"Level 3\") — these are headers, not products\n- Pure dimension specs without a product noun (e.g. \"Dia 32 mm\", \"25 mm\") — reject unless a product name is present\n- Security services, night shifts, rentals, payments\n- Any item whose description is too vague or fragmentary to identify a specific product\n\nEmit ONE JSON item PER real product. A single source row may therefore produce several\nitems (when it lists multiple products) or several rows may collapse into one item (when\nthey are wrapped fragments of the same product). The \"items\" array is your final delivery\nlist, not a row-by-row transcript.\n\nFor each valid physical product return JSON with:\n- description: clean product name only (strip \"supply and install\", \"supply and fix\", installation clauses, floor/spec suffixes). For a split product, use just that product's own name.\n- quantity: the EXACT numeric value from the quantity/count column (العدد, الكمية, Qty, Quantity, No.). NEVER default to 1 unless the quantity column is truly absent or empty.\n- unit: unit of measure (pcs, m, m2, m3, set, no, kg, etc.)\n- category: product category\n- brand: brand/manufacturer if mentioned, else \"\"\n- engineering_required: true if the item needs engineering design before procurement (panel boards, chillers, generators, fire suppression systems, BMS, pumps, AHUs, switchgear, transformers), else false\n\nReturn ONLY a valid JSON object: {\"items\": [...]}. No explanations, no markdown, no extra text.\nPROMPT);\n }\n\n private function mockResponse(): array\n {\n return [\n 'success' => true,\n 'error' => null,\n 'items' => [[\n 'description' => 'Sample BOQ Item',\n 'quantity' => 1,\n 'unit' => 'No',\n 'category' => 'Sample',\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 1,\n 'raw_data' => [],\n 'ai_extracted' => true,\n ]],\n 'rejected' => [],\n ];\n }\n\n private function failure(string $message, bool $serviceUnavailable = false): array\n {\n return [\n 'success' => false,\n 'items' => [],\n 'rejected' => [],\n 'error' => $this->userFacingError($message, $serviceUnavailable),\n 'service_unavailable' => $serviceUnavailable,\n ];\n }\n\n /**\n * Translate any internal error into a neutral, user-safe message.\n *\n * The user must never see the name of the underlying AI/processing engine\n * (DeepSeek, Groq, Gemini, Vision, etc.) or configuration hints like API\n * keys. The detailed message is kept in the logs for debugging only.\n */\n private function userFacingError(string $message, bool $serviceUnavailable = false): string\n {\n // Detect any reference to an AI provider / engine / API config so we can\n // swap it for a generic, branded-neutral message.\n $sensitive = '/deepseek|groq|gemini|vision|openai|openrouter|api[\\s_-]?key|\\.env|non-?json|token limit/i';\n\n if (preg_match($sensitive, $message)) {\n Log::warning('QuotationAiService: suppressed technical error from user.', ['internal' => $message]);\n\n return $serviceUnavailable\n ? 'The file could not be processed right now. Please try again in a moment.'\n : 'We could not read this file automatically. Please make sure it is a clear BOQ (Excel, CSV, PDF, or image) and try again, or add the items manually.';\n }\n\n return $message;\n }\n\n private function mimeForExtension(string $ext): string\n {\n return match (strtolower($ext)) {\n 'pdf' => 'application/pdf',\n 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n 'xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12',\n 'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',\n 'xls' => 'application/vnd.ms-excel',\n 'csv' => 'text/csv',\n 'jpg', 'jpeg' => 'image/jpeg',\n 'png' => 'image/png',\n 'gif' => 'image/gif',\n 'bmp' => 'image/bmp',\n 'webp' => 'image/webp',\n 'tiff', 'tif' => 'image/tiff',\n 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'doc' => 'application/msword',\n default => 'application/octet-stream',\n };\n }\n}\n"}, "de5c9196479848453b9b2e2038f1d1e85a95011c": {"t": 1785748356.5478766, "n": 92167, "h": "3e3722325446a5c4", "c": "<?php\n\nnamespace App\\Services;\n\nuse Illuminate\\Http\\Client\\ConnectionException;\nuse Illuminate\\Http\\UploadedFile;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\nuse PhpOffice\\PhpSpreadsheet\\Cell\\Coordinate;\nuse PhpOffice\\PhpSpreadsheet\\IOFactory;\n\nclass QuotationAiService\n{\n /** Increment this whenever BOQ parsing logic changes to invalidate old caches. */\n private const PARSER_VERSION = 10;\n private const LARGE_BOQ_VERIFICATION_THRESHOLD = 2000;\n private const LARGE_BOQ_VERIFICATION_CHUNK_SIZE = 120;\n\n /**\n * Characters of document text sent per AI call.\n *\n * Above this the document is split and parsed over several calls. Sized well\n * under the model's context so the prompt, the slice, and a full JSON answer\n * for every row in it all fit — the binding limit is the *output* budget, not\n * the input, since each input row produces a JSON object.\n */\n private const TEXT_CHUNK_CHARS = 60000;\n\n /**\n * A workbook with more sheets than this is split regardless of its size.\n *\n * Multiple tabs almost always mean multiple disciplines (electrical, HVAC,\n * civil…). Sending them in one call makes the model summarise across\n * sections and silently drop rows, so each slice gets a narrower scope.\n */\n private const MAX_SHEETS_PER_CALL = 2;\n\n /** A PDF longer than this is split regardless of its extracted text length. */\n private const MAX_PDF_PAGES_PER_CALL = 5;\n\n /**\n * Largest PDF we will hand to the parser (12 MB).\n *\n * smalot/pdfparser decompresses every stream into memory with no ceiling of\n * its own, and overrunning the limit there is a fatal error that kills the\n * worker rather than an exception we can recover from.\n */\n private const MAX_PDF_BYTES = 12582912;\n\n /**\n * Memory the parser is assumed to need, as a multiple of the file size.\n *\n * Decompressed content streams plus the object graph run well past the raw\n * bytes; 8x is conservative enough to catch the cases that were dying.\n */\n private const PDF_MEMORY_FACTOR = 8;\n\n /** Returned by extractPdfText when it stopped for memory, not for lack of text. */\n private const PDF_LOW_MEMORY = \"\\0__pdf_low_memory__\";\n\n private string $baseUrl;\n private string $parseEndpoint;\n private string $apiKey;\n private int $timeout;\n private bool $testMode;\n private BoqCleaningService $boqCleaner;\n\n /** Page count of the most recently parsed PDF; 0 when unknown. */\n private int $lastPdfPageCount = 0;\n\n /**\n * Optional progress reporter for chunked parsing, called as ($part, $total).\n *\n * Set by the queue job so the polling UI can show real progress on a large\n * BOQ instead of a spinner that appears frozen for many minutes.\n *\n * @var (callable(int, int): void)|null\n */\n private $onChunkProgress = null;\n\n /** @param callable(int, int): void $callback */\n public function onChunkProgress(callable $callback): self\n {\n $this->onChunkProgress = $callback;\n return $this;\n }\n\n /**\n * Optional per-chunk item sink, called as ($items, $part, $total).\n *\n * Lets the caller persist each slice's rows the moment they arrive instead\n * of waiting for the whole document. On a large BOQ this is the difference\n * between a table that fills in progressively and one that stays empty for\n * many minutes.\n *\n * @var (callable(array, int, int): void)|null\n */\n private $onChunkItems = null;\n\n /** @param callable(array, int, int): void $callback */\n public function onChunkItems(callable $callback): self\n {\n $this->onChunkItems = $callback;\n return $this;\n }\n\n /**\n * Report a non-chunk stage (local parse, handing off to AI) to the caller.\n *\n * A spreadsheet is read locally first and only sent to the AI when that\n * fails, so a large file can spend minutes before any chunk exists. Without\n * these the UI shows a generic spinner for that whole period.\n */\n private function reportStage(string $message): void\n {\n if ($this->onStage !== null) {\n ($this->onStage)($message);\n }\n }\n\n /**\n * Report a workbook's shape from grids that are already in memory.\n *\n * Deliberately takes the parsed grids rather than a path: re-reading a large\n * spreadsheet just to count its sheets would double the slowest step of the\n * whole extraction.\n *\n * @param array<int, array{name: string, grid: array}> $sheets\n */\n private function reportWorkbookShape(array $sheets): void\n {\n if ($this->onStage === null || empty($sheets)) {\n return;\n }\n\n $rows = 0;\n foreach ($sheets as $sheet) {\n $rows += count($sheet['grid']);\n }\n\n $count = count($sheets);\n\n $this->reportStage($count > 1\n ? \"Found {$count} sheets, {$rows} rows in total. Reading…\"\n : \"Found {$rows} rows. Reading…\");\n }\n\n /** @var (callable(string): void)|null */\n private $onStage = null;\n\n /** @param callable(string): void $callback */\n public function onStage(callable $callback): self\n {\n $this->onStage = $callback;\n return $this;\n }\n\n public function __construct(BoqCleaningService $boqCleaner)\n {\n $this->boqCleaner = $boqCleaner;\n $this->baseUrl = rtrim((string) config('services.ai_quotation.base_url', ''), '/');\n $this->parseEndpoint = ltrim((string) config('services.ai_quotation.parse_endpoint', 'parse'), '/');\n $this->apiKey = (string) config('services.ai_quotation.api_key', '');\n $this->timeout = (int) config('services.ai_quotation.timeout', 300);\n $this->testMode = (bool) config('services.ai_quotation.test_mode', false);\n }\n\n /**\n * Give the running script enough head-room to outlast the AI HTTP call.\n *\n * The web request / queue worker must not be killed before the HTTP timeout\n * completes, otherwise the user sees a generic \"timeout or unavailable\"\n * error even though the AI may have responded. We allow the HTTP timeout\n * plus a 120s margin for connecting, response parsing and DB writes.\n */\n private function extendExecutionTime(): void\n {\n // Ignored on some SAPIs (e.g. when running under fastcgi with a hard\n // limit), but harmless to attempt.\n @set_time_limit($this->timeout + 120);\n }\n\n public function parseBoq(UploadedFile|string $file, array $context = []): array\n {\n if ($this->testMode) {\n return $this->mockResponse();\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n\n if (! is_file($absPath)) {\n return $this->failure('File not found or not readable.');\n }\n\n $fileHash = hash_file('sha256', $absPath);\n // Bump PARSER_VERSION whenever the extraction logic changes so that\n // previously-uploaded files are re-parsed instead of returning a stale\n // cached result produced by the old code.\n $cacheKey = 'boq_analysis_v' . self::PARSER_VERSION . '_' . $fileHash;\n $forceRefresh = (bool) ($context['force_refresh'] ?? false);\n\n if (! $forceRefresh && ($cached = Cache::get($cacheKey)) !== null) {\n return $cached;\n }\n\n // Clear stale cached result when force-refreshing\n if ($forceRefresh) {\n Cache::forget($cacheKey);\n }\n\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n // Read Excel locally first. Fall back to AI if local parse failed,\n // found 0 items, OR could not read one of the sheets (so multi-sheet\n // files never silently drop a sheet the local parser didn't understand).\n $this->reportStage('Reading the spreadsheet…');\n\n $result = $this->parseSpreadsheetDirect($absPath);\n\n $hasSkippedSheets = ! empty($result['skipped_sheets']);\n\n if (! $result['success'] || empty($result['items']) || $hasSkippedSheets) {\n // The local pass could not handle this file on its own. Say so:\n // otherwise the UI sits on a generic spinner through the local\n // parse AND the whole AI parse with nothing to show for either.\n $this->reportStage($hasSkippedSheets\n ? 'Some sheets need AI to read. Sending to AI…'\n : 'Spreadsheet needs AI to read. Sending to AI…');\n\n $deepSeekResult = $this->parseBoqWithDeepSeek($file, $context);\n // AI sees every sheet at once, so prefer its result whenever it\n // actually returned items (covers the skipped-sheet case too).\n if ($deepSeekResult['success'] && ! empty($deepSeekResult['items'])) {\n // Keep the larger of the two item sets so we never lose the\n // rows the local parser already extracted correctly.\n if (count($deepSeekResult['items']) >= count($result['items'] ?? [])) {\n $result = $deepSeekResult;\n }\n } elseif (! $result['success']) {\n $result = $deepSeekResult;\n }\n // else: keep local result if AI also failed/returned nothing\n }\n } elseif (in_array($ext, ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif'], true)) {\n $result = $this->parseBoqWithDeepSeek($file, $context);\n } else {\n return $this->failure('Unsupported file type. Please upload Excel, CSV, PDF, or image file.');\n }\n\n if ($result['success'] && ! empty($result['items'])) {\n $cleaning = $this->boqCleaner->process($result['items']);\n $result['items'] = $cleaning['accepted'] ?? $result['items'];\n $result['rejected'] = array_merge($result['rejected'] ?? [], $cleaning['rejected'] ?? []);\n }\n\n if ($result['success'] && count($result['items'] ?? []) > self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n $result = $this->verifyLargeBoqItemsWithAi($result);\n }\n\n // Only cache when we actually extracted items; empty results should be re-tried.\n if ($result['success'] && ! empty($result['items'])) {\n Cache::put($cacheKey, $result, now()->addDays(30));\n }\n\n return $result;\n }\n\n private function resolveFile(UploadedFile|string $file): array\n {\n if ($file instanceof UploadedFile) {\n return [(string) $file->getRealPath(), strtolower($file->getClientOriginalExtension())];\n }\n\n $absPath = is_file($file) ? $file : storage_path('app/' . ltrim($file, '/'));\n return [$absPath, strtolower(pathinfo($absPath, PATHINFO_EXTENSION))];\n }\n\n private function parseSpreadsheetDirect(string $absPath): array\n {\n ini_set('memory_limit', '2048M');\n\n // 180s was not enough for a real BOQ: an 11 200-row workbook was killed\n // mid-parse, and because set_time_limit() kills the process outright the\n // job reported a bare failure with nothing in the log to explain it.\n // This runs on the queue, where wall time is cheap.\n set_time_limit(1800);\n\n try {\n $sheets = $this->spreadsheetToGrids($absPath);\n\n if (empty($sheets)) {\n return $this->failure('Could not read the Excel file.');\n }\n\n $this->reportWorkbookShape($sheets);\n\n $items = [];\n $rejected = [];\n $skippedSheets = []; // sheets we could not parse locally (header undetected)\n\n foreach ($sheets as $sheet) {\n $grid = $sheet['grid'];\n if (count($grid) < 2) {\n continue;\n }\n\n // Skip dedicated summary / cost-abstract sheets entirely. Their rows\n // are per-discipline totals (Structural Concrete, Electrical, …) and\n // grand totals, not procurable products.\n if ($this->isSummarySheet($sheet['name'])) {\n Log::info('QuotationAiService: skipped summary sheet.', ['sheet' => $sheet['name']]);\n continue;\n }\n\n $header = $this->detectHeader($grid);\n\n if ($header === null || ! isset($header['map']['description'])) {\n Log::warning('QuotationAiService: header not detected in sheet.', ['sheet' => $sheet['name']]);\n $skippedSheets[] = $sheet['name'];\n continue;\n }\n\n $map = $header['map'];\n $start = $header['row'] + 1;\n\n for ($r = $start; $r < count($grid); $r++) {\n $row = $grid[$r];\n $description = $this->cell($row, $map['description'] ?? null);\n $itemNo = $this->cell($row, $map['item_code'] ?? null);\n $unit = $this->cell($row, $map['unit'] ?? null);\n $quantity = $this->number($this->cell($row, $map['quantity'] ?? null));\n $unitPrice = $this->number($this->cell($row, $map['unit_price'] ?? null));\n $totalPrice = $this->number($this->cell($row, $map['total_price'] ?? null));\n\n if ($description === '') {\n continue;\n }\n\n if (! isset($map['quantity'])) {\n $quantity = 1.0;\n }\n\n // Skip floor breakdown sub-rows: Ref. is \"-\" meaning it's a per-floor\n // quantity split of the parent item, not a standalone product.\n if ($itemNo === '-' || $this->isFloorBreakdownRow($itemNo, $description)) {\n continue;\n }\n\n if ($this->isTotalOrHeaderRow($description)) {\n continue;\n }\n\n if ($quantity === null || $quantity <= 0) {\n $rejected[] = $this->rejectedRow($description, $unit, $quantity, 'Missing quantity', $itemNo, $sheet['name']);\n continue;\n }\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $supply = $this->boqCleaner->filterItem($description, $unitPrice, $quantity);\n\n if (! ($supply['keep'] ?? false)) {\n $rejected[] = $this->rejectedRow(\n $description,\n $unit,\n $quantity,\n (string) ($supply['rejection_reason'] ?? 'Rejected by cleaner'),\n $itemNo,\n $sheet['name']\n );\n continue;\n }\n\n $finalDescription = (string) ($supply['description'] ?? $description);\n\n $items[] = [\n 'description' => $finalDescription,\n 'quantity' => $quantity,\n 'unit' => $unit,\n 'category' => $sheet['name'],\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($finalDescription),\n 'unit_price' => $unitPrice,\n 'confidence' => 0.95,\n 'needs_review' => false,\n 'raw_data' => [\n 'sheet' => $sheet['name'],\n 'row_number' => $r + 1,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'cleaned_description' => $finalDescription,\n 'extraction_type' => (string) ($supply['extraction_type'] ?? 'supply_only'),\n ],\n 'ai_extracted' => true,\n ];\n }\n }\n\n if (empty($items)) {\n return $this->failure('No BOQ supply items could be extracted from the Excel file.');\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'skipped_sheets' => $skippedSheets,\n ];\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: parseSpreadsheetDirect failed.', [\n 'path' => $absPath,\n 'message' => $e->getMessage(),\n 'trace' => $e->getTraceAsString(),\n ]);\n\n return $this->failure('Failed to read the Excel file: ' . $e->getMessage());\n }\n }\n\n private function spreadsheetToGrids(string $absPath): array\n {\n $ext = strtolower(pathinfo($absPath, PATHINFO_EXTENSION));\n\n if ($ext === 'csv') {\n $rows = [];\n $handle = fopen($absPath, 'rb');\n if ($handle !== false) {\n while (($row = fgetcsv($handle)) !== false) {\n $rows[] = $this->trimTrailingEmptyCells($row);\n }\n fclose($handle);\n }\n return [['name' => 'Sheet1', 'grid' => $rows]];\n }\n\n $reader = IOFactory::createReader($ext === 'xls' ? 'Xls' : 'Xlsx');\n $reader->setReadDataOnly(true);\n $spreadsheet = $reader->load($absPath);\n $sheets = [];\n\n foreach ($spreadsheet->getAllSheets() as $sheet) {\n $highestRow = $sheet->getHighestDataRow();\n $highestColumn = $sheet->getHighestDataColumn();\n $highestColumnIndex = Coordinate::columnIndexFromString($highestColumn);\n\n // BOQ data is normally in first 8-20 columns.\n // Files with heavy formatting/images can report 300+ columns — limit to 30.\n $maxColumnIndex = min($highestColumnIndex, 30);\n\n $grid = [];\n for ($row = 1; $row <= $highestRow; $row++) {\n $rowData = [];\n $hasValue = false;\n\n for ($col = 1; $col <= $maxColumnIndex; $col++) {\n $value = $sheet->getCell([$col, $row])->getValue();\n $value = $this->cellValueToString($value);\n $value = trim($value);\n if ($value !== null && $value !== '') {\n $hasValue = true;\n }\n $rowData[] = $value;\n }\n\n $rowData = $this->trimTrailingEmptyCells($rowData);\n if ($hasValue) {\n $grid[] = $rowData;\n }\n }\n\n if (! empty($grid)) {\n $sheets[] = ['name' => $sheet->getTitle(), 'grid' => $grid];\n }\n }\n\n $spreadsheet->disconnectWorksheets();\n unset($spreadsheet);\n\n return $sheets;\n }\n\n private function detectHeader(array $grid): ?array\n {\n $keywords = [\n 'item_code' => ['رقم البند', 'item no', 'item number', 'ref.', 'ref', 'item', 'no.', 'no', '#', 'كود'],\n 'description' => ['وص٠البند', 'scope of works', 'scope of work', 'scope', 'item description', 'description of works', 'description', 'بالعربية', 'الوصÙ', 'البند', 'بيان'],\n 'unit' => ['Ø§Ù„ÙˆØØ¯Ø©', 'unit', 'uom', 'u/m'],\n 'quantity' => ['الكمية', 'qty', 'quantity', 'q.ty'],\n 'unit_price' => ['سعر Ø§Ù„ÙˆØØ¯Ø©', 'u/price', 'unit price', 'unit rate', 'rate', 'price'],\n 'total_price' => ['السعر الإجمالي', 'total amount', 'total price', 'amount', 'الإجمالي'],\n ];\n\n $best = null;\n $bestScore = 0;\n\n for ($r = 0; $r < min(30, count($grid)); $r++) {\n $map = [];\n $score = 0;\n\n foreach ($grid[$r] as $c => $value) {\n $cell = mb_strtolower(trim((string) $value));\n if ($cell === '') {\n continue;\n }\n\n foreach ($keywords as $field => $list) {\n foreach ($list as $kw) {\n if (str_contains($cell, mb_strtolower($kw))) {\n if (! isset($map[$field])) {\n $map[$field] = $c;\n $score++;\n }\n break 2;\n }\n }\n }\n }\n\n if ($score > $bestScore) {\n $bestScore = $score;\n $best = ['row' => $r, 'map' => $map];\n }\n }\n\n if ($bestScore >= 3) {\n return $best;\n }\n\n // Allow simpler BOQ sheets that only define a description column plus one\n // other BOQ-related column, e.g. Item + Description or Description + Qty.\n if ($bestScore === 2 && isset($best['map']['description']) && (\n isset($best['map']['item_code']) ||\n isset($best['map']['quantity']) ||\n isset($best['map']['unit'])\n )) {\n return $best;\n }\n\n return null;\n }\n\n private function cell(array $row, ?int $index): string\n {\n if ($index === null) {\n return '';\n }\n return trim((string) ($row[$index] ?? ''));\n }\n\n private function number(string $value): ?float\n {\n $value = trim(str_replace([',', 'SAR', 'ر.س'], '', $value));\n if ($value === '' || ! is_numeric($value)) {\n return null;\n }\n return (float) $value;\n }\n\n private function trimTrailingEmptyCells(array $row): array\n {\n while (! empty($row) && trim((string) end($row)) === '') {\n array_pop($row);\n }\n return array_values($row);\n }\n\n private function isTotalOrHeaderRow(string $description): bool\n {\n $d = mb_strtolower(trim($description));\n\n if ($d === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $d)) {\n return true;\n }\n\n // Section totals / summaries\n if (preg_match('/(اجمالي|إجمالي|المجموع|grand\\s*total|sub\\s*total|total\\s*amount|total\\s*price)/iu', $d)) {\n return true;\n }\n\n // Bare \"Total\" lines, optionally with a currency suffix, e.g. \"Total\",\n // \"Total (SAR)\", \"Total cost\", \"Total (SAR) unit cost\".\n if (preg_match('/^total\\b[\\s\\(\\):a-z]*$/iu', $d)) {\n return true;\n }\n\n // Pure floor / level labels used as section headers\n if (preg_match('/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor)\\s*$/iu', $d)) {\n return true;\n }\n\n // Division / section header lines (e.g. \"Division - 02: Wood Works\")\n if (preg_match('/^division\\s*([-:]|\\d)/iu', $d)) {\n return true;\n }\n\n return false;\n }\n\n /**\n * Detect dedicated summary / cost-abstract sheets by their name. These hold\n * per-discipline rollups and grand totals (e.g. a \"Summary (Dry Cost)\" tab),\n * never procurable supply items, so they are skipped wholesale.\n */\n private function isSummarySheet(string $name): bool\n {\n $n = mb_strtolower(trim($name));\n\n if ($n === '') {\n return false;\n }\n\n return (bool) preg_match(\n '/\\b(summary|abstract|cost\\s*summary|grand\\s*summary|recap(?:itulation)?|bill\\s*summary|boq\\s*summary|dry\\s*cost)\\b|ملخص|الملخص|الملخّص|ملخّص|اجمالي|إجمالي/iu',\n $n\n );\n }\n\n private function isFloorBreakdownRow(string $itemNo, string $description): bool\n {\n // A row whose Ref column is empty or a dash is a sub-breakdown row (per-floor split).\n $ref = trim($itemNo);\n if ($ref === '' || $ref === '-') {\n // Confirm by checking the description is a location/floor label\n $d = mb_strtolower(trim($description));\n return (bool) preg_match(\n '/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor|\\d+f)\\s*$/iu',\n $d\n );\n }\n return false;\n }\n\n private function rejectedRow(string $description, string $unit, ?float $quantity, string $reason, string $itemNo = '', string $sheet = ''): array\n {\n return [\n 'description' => $description,\n 'quantity' => $quantity ?? 0,\n 'unit' => $unit,\n 'category' => $sheet,\n 'brand' => '',\n 'status' => 'rejected',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 0.0,\n 'raw_data' => [\n 'sheet' => $sheet,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'extraction_type' => 'rejected',\n 'rejection_reason' => $reason,\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function parseBoqWithDeepSeek(UploadedFile|string $file, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured. Please set DEEPSEEK_API_KEY in .env file.');\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n $mime = $this->mimeForExtension($ext);\n\n try {\n // ── Spreadsheets ──────────────────────────────────────────────────\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n $text = $this->spreadsheetToCompactCsvText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not convert spreadsheet to readable text.');\n }\n\n $text = $this->sanitizeUtf8($text);\n\n // A large BOQ exceeds what one request can carry, so send it in\n // sequential slices and merge. Anything that fits goes as-is.\n //\n // Size alone is not a good enough signal: a workbook with several\n // sheets is structurally a big BOQ even when the text is compact,\n // and asking the model to hold every discipline at once is where\n // rows start getting dropped. So split on either signal.\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS || $sheetCount > self::MAX_SHEETS_PER_CALL) {\n Log::info('QuotationAiService: spreadsheet routed to chunked parsing.', [\n 'chars' => mb_strlen($text),\n 'sheets' => $sheetCount,\n ]);\n return $this->parseTextInChunks($text, 'BOQ spreadsheet converted to CSV', $apiKey, $context);\n }\n\n $userContent = \"BOQ spreadsheet converted to CSV:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── PDF ───────────────────────────────────────────────────────────\n if ($ext === 'pdf') {\n $extracted = $this->extractPdfText($absPath);\n\n if ($extracted === self::PDF_LOW_MEMORY) {\n return $this->failure('The server was low on memory while reading this PDF. Please try again in a moment, or convert it to Excel or CSV.');\n }\n\n if ($extracted !== null && mb_strlen(trim($extracted)) > 50) {\n $extracted = $this->sanitizeUtf8($extracted);\n\n // Split on either signal — long text, or many pages. A BOQ\n // spanning several pages is a multi-section document even if\n // each page is sparse.\n if (mb_strlen($extracted) > self::TEXT_CHUNK_CHARS\n || $this->lastPdfPageCount > self::MAX_PDF_PAGES_PER_CALL) {\n Log::info('QuotationAiService: PDF routed to chunked parsing.', [\n 'chars' => mb_strlen($extracted),\n 'pages' => $this->lastPdfPageCount,\n ]);\n return $this->parseTextInChunks($extracted, 'BOQ PDF extracted text', $apiKey, $context, 'application/pdf');\n }\n\n $userContent = \"BOQ PDF extracted text:\\n\\n\" . $extracted . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'application/pdf');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n return $this->failure('Could not extract text from the PDF. Please make sure it is a text-based PDF (not a scanned image), or convert it to Excel or CSV.');\n }\n\n // -- Word documents (docx) ---------------------------------------------------\n if (in_array($ext, ['docx', 'doc'], true)) {\n $text = $this->extractDocxText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not extract text from the Word document. Please convert it to Excel, CSV, or PDF.');\n }\n $text = $this->sanitizeUtf8($text);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS) {\n return $this->parseTextInChunks($text, 'BOQ Word document extracted text', $apiKey, $context);\n }\n\n $userContent = \"BOQ Word document extracted text:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── Images ──────────────────────────────────────────────────────────────\n // DeepSeek cannot receive images. A vision-capable AI processes the image\n // directly (no OCR) — configure GROQ_API_KEY or GEMINI_API_KEY in .env.\n $imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif', 'heic', 'heif'];\n if (in_array($ext, $imageExts, true)) {\n $bytes = (string) file_get_contents($absPath);\n return $this->callDeepSeekVision($bytes, $mime, $context);\n }\n\n return $this->failure('Unsupported file type. Please upload an Excel, CSV, PDF, or image file.');\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: DeepSeek fallback failed.', ['message' => $e->getMessage()]);\n return $this->failure('An error occurred while processing the file with AI: ' . $e->getMessage());\n }\n }\n\n private function deepSeekModel(): string\n {\n return (string) config('services.deepseek.model', 'deepseek-chat');\n }\n\n /**\n * Remove or replace any non-UTF-8 / malformed byte sequences so json_encode never fails.\n */\n private function sanitizeUtf8(string $text): string\n {\n // Replace invalid byte sequences with the UTF-8 replacement character\n $clean = mb_convert_encoding($text, 'UTF-8', 'UTF-8');\n // Strip any remaining non-printable control characters (except tab/newline/CR)\n $clean = (string) preg_replace('/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]/u', '', $clean);\n return $clean;\n }\n\n /**\n * Extract plain text from a Word docx file using ZipArchive (no external library needed).\n * Reads word/document.xml and strips XML tags, preserving paragraph and table structure.\n */\n private function extractDocxText(string $absPath): ?string\n {\n try {\n $zip = new \\ZipArchive();\n if ($zip->open($absPath) !== true) {\n return null;\n }\n\n $xml = $zip->getFromName('word/document.xml');\n $zip->close();\n\n if ($xml === false || $xml === '') {\n return null;\n }\n\n // Add newlines before paragraph/table-row/table-cell tags so structure is preserved\n $xml = preg_replace('/<w:p[ >]/', \"\\n<w:p>\", $xml) ?? $xml;\n $xml = preg_replace('/<w:tr[ >]/', \"\\n<w:tr>\", $xml) ?? $xml;\n $xml = preg_replace('/<\\/w:tc>/', \"\\t\", $xml) ?? $xml;\n\n $text = strip_tags($xml);\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n return trim($text) !== '' ? trim($text) : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: DOCX parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Extract plain text from a PDF using smalot/pdfparser (pure PHP, no binary required).\n */\n /**\n * The process memory limit in bytes, or 0 when unlimited.\n *\n * memory_limit is a shorthand string (\"128M\", \"1G\", \"-1\"), so it has to be\n * expanded before it can be compared against anything.\n */\n private function memoryLimitBytes(): int\n {\n $raw = trim((string) ini_get('memory_limit'));\n\n if ($raw === '' || $raw === '-1') {\n return 0;\n }\n\n $value = (int) $raw;\n\n return match (strtolower(substr($raw, -1))) {\n 'g' => $value * 1024 * 1024 * 1024,\n 'm' => $value * 1024 * 1024,\n 'k' => $value * 1024,\n default => $value,\n };\n }\n\n private function extractPdfText(string $absPath): ?string\n {\n // The parser loads and decompresses the whole document before any of our\n // chunking gets a look at it, and it does so with no memory ceiling of\n // its own. A big PDF exhausts the limit inside gzuncompress() — a fatal\n // error, not an exception, so the catch below cannot help and the worker\n // dies outright. Refuse the file first instead.\n $size = @filesize($absPath) ?: 0;\n\n if ($size > self::MAX_PDF_BYTES) {\n Log::warning('QuotationAiService: PDF too large to parse in-process.', [\n 'bytes' => $size,\n 'limit' => self::MAX_PDF_BYTES,\n ]);\n\n return null;\n }\n\n // Headroom check: parsing routinely needs several times the file size\n // once streams are decompressed. If that would not fit in what is left,\n // stop here rather than taking the worker down with us.\n $limit = $this->memoryLimitBytes();\n\n if ($limit > 0) {\n // Try to reclaim memory a previous job left behind before deciding\n // there is not enough. Without this, a worker sitting near its limit\n // after a big job could refuse a tiny PDF for no real reason.\n gc_collect_cycles();\n\n $available = $limit - memory_get_usage(true);\n\n if ($available < $size * self::PDF_MEMORY_FACTOR) {\n Log::warning('QuotationAiService: not enough memory left to parse this PDF.', [\n 'bytes' => $size,\n 'available' => $available,\n 'needed' => $size * self::PDF_MEMORY_FACTOR,\n ]);\n\n // A distinct sentinel, so the caller can tell \"no memory\" apart\n // from \"no text\" and show the right message instead of blaming\n // the file for being scanned.\n return self::PDF_LOW_MEMORY;\n }\n }\n\n try {\n $parser = new \\Smalot\\PdfParser\\Parser();\n $pdf = $parser->parseFile($absPath);\n\n // Recorded for the chunking decision: a long PDF is structurally a\n // big BOQ even when its extracted text is short.\n try {\n $this->lastPdfPageCount = count($pdf->getPages());\n } catch (\\Throwable) {\n $this->lastPdfPageCount = 0;\n }\n\n $text = $pdf->getText();\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n return $text !== '' ? $text : null;\n } catch (\\Throwable $e) {\n // Class, file and line, not just the message — a bare message left\n // the actual cause of a PDF failure guessable at best.\n Log::warning('QuotationAiService: PDF parsing failed.', [\n 'exception' => $e::class,\n 'error' => $e->getMessage(),\n 'at' => $e->getFile() . ':' . $e->getLine(),\n ]);\n return null;\n }\n }\n\n /**\n * Send a vision request to a vision-capable AI model.\n * DeepSeek's commercial API (api.deepseek.com) does NOT support image inputs — any model.\n * Priority order (first configured key wins):\n * 1. GROQ_API_KEY → Groq llama-4-scout (free: https://console.groq.com)\n * 2. GEMINI_API_KEY → Google Gemini Flash (free: https://aistudio.google.com/apikey)\n * 3. VISION_API_KEY → any OpenAI-compatible endpoint (OpenAI, OpenRouter …)\n */\n private function callDeepSeekVision(string $bytes, string $mime, array $context): array\n {\n // ── 1. Groq (free, reliable vision) ─────────────────────────────────────\n if ((string) config('services.groq.key', '') !== '') {\n $visionKey = (string) config('services.groq.key');\n $visionBaseUrl = 'https://api.groq.com/openai/v1';\n $visionModel = (string) config('services.groq.model', 'meta-llama/llama-4-scout-17b-16e-instruct');\n }\n // ── 2. Google Gemini ─────────────────────────────────────────────────────\n elseif ((string) config('services.gemini.key', '') !== '') {\n $visionKey = (string) config('services.gemini.key');\n $visionBaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai';\n $visionModel = (string) config('services.gemini.model', 'gemini-2.0-flash');\n }\n // ── 3. Generic VISION_API_KEY (OpenAI, OpenRouter, …) ───────────────────\n elseif ((string) config('services.vision.key', '') !== '') {\n $visionKey = (string) config('services.vision.key');\n $visionBaseUrl = rtrim((string) config('services.vision.base_url', 'https://openrouter.ai/api/v1'), '/');\n $visionModel = (string) config('services.vision.model', 'google/gemini-flash-1.5-8b');\n }\n else {\n return $this->failure(\n 'Image processing is not available. DeepSeek\\'s API does not support images. ' .\n 'Get a free key at https://console.groq.com and add GROQ_API_KEY to your .env file.'\n );\n }\n\n $dataUrl = \"data:{$mime};base64,\" . base64_encode($bytes);\n $prompt = $this->buildDeepSeekPrompt($context, $mime);\n\n $userContent = [\n ['type' => 'text', 'text' => $prompt],\n ['type' => 'image_url', 'image_url' => ['url' => $dataUrl]],\n ];\n\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders([\n 'Authorization' => 'Bearer ' . $visionKey,\n 'HTTP-Referer' => config('app.url', 'https://qimta.sa'),\n 'X-Title' => 'Qimta Platform',\n ])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post($visionBaseUrl . '/chat/completions', [\n 'model' => $visionModel,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 16384,\n 'temperature' => 0,\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('Vision API connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: Vision API HTTP error.', [\n 'model' => $visionModel,\n 'base_url' => $visionBaseUrl,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n return $this->failure(\"Vision API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n return $this->parseDeepSeekJsonResponse((string) $response->json('choices.0.message.content'));\n }\n\n private function callDeepSeekChat(string $userContent, string $key, string $model): array\n {\n // Ensure the PHP script outlives the HTTP call (+ a margin for parsing the\n // response and writing to the DB) so the worker/web request is not killed\n // mid-flight, which surfaces to users as a generic \"timeout or unavailable\".\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders(['Authorization' => 'Bearer ' . $key])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 32768,\n 'temperature' => 0,\n 'response_format' => ['type' => 'json_object'],\n 'user' => 'Qimta_Platform',\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('DeepSeek connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: DeepSeek HTTP error.', [\n 'model' => $model,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n if ($response->status() === 401) {\n return $this->failure('DeepSeek API key is invalid or unauthorised. Please update DEEPSEEK_API_KEY in .env.', false);\n }\n return $this->failure(\"DeepSeek API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n $text = (string) $response->json('choices.0.message.content');\n return $this->parseDeepSeekJsonResponse($text);\n }\n\n /**\n * Parse a document too large for one AI call by sending it in slices.\n *\n * A 20 000-row BOQ is several megabytes of text — far beyond what a single\n * request can carry, and beyond what the model can answer without hitting its\n * output token limit. Previously the text was simply cut at 180 000 chars and\n * the remainder discarded: the parse \"succeeded\" while silently dropping most\n * of the file. Now the text is split and each slice parsed on its own call,\n * with the items merged.\n *\n * Splitting happens on line boundaries so a BOQ row is never cut in half.\n * Slices run sequentially rather than pooled: DeepSeek rate-limits hard on\n * concurrent large prompts, and this already runs on the queue where wall\n * time is cheap.\n *\n * A slice that fails does not fail the document — its rows are lost, but the\n * rest are kept and the shortfall is logged. Total failure is reported only\n * when every slice fails.\n *\n * @param string $label How the payload is introduced to the model.\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n /**\n * Split a spreadsheet into the slices the AI will be asked to read.\n *\n * Exposed so the work can be spread over several queue jobs instead of one\n * job walking every slice in sequence. Returns an empty array when the file\n * is small enough to go in a single call, or cannot be read.\n *\n * @return array<int, string>\n */\n public function chunkSpreadsheet(string $absPath): array\n {\n ini_set('memory_limit', '2048M');\n set_time_limit(1800);\n\n $text = $this->spreadsheetToCompactCsvText($absPath);\n\n if ($text === null || trim($text) === '') {\n return [];\n }\n\n $text = $this->sanitizeUtf8($text);\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) <= self::TEXT_CHUNK_CHARS && $sheetCount <= self::MAX_SHEETS_PER_CALL) {\n return [];\n }\n\n $chunks = $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n\n // Free the source text: the slices are a second full copy of it, and on\n // a large workbook that doubling — on top of PhpSpreadsheet's grids —\n // is what exhausted memory here.\n unset($text);\n\n Log::info('QuotationAiService: chunked spreadsheet.', [\n 'chunks' => count($chunks),\n 'peak_mb' => round(memory_get_peak_usage(true) / 1048576),\n ]);\n\n return $chunks;\n }\n\n /**\n * Split a spreadsheet straight to disk, one file per slice.\n *\n * chunkSpreadsheet() returns every slice as an array, which is a second\n * full copy of the document in memory on top of PhpSpreadsheet's grids.\n * This hands each slice to $write and drops it immediately, so peak memory\n * is one slice rather than the whole file twice.\n *\n * @param callable(int, string): void $write ($part, $chunk)\n * @return int number of slices written; 0 when the file needs no split\n */\n public function chunkSpreadsheetToDisk(string $absPath, callable $write): int\n {\n ini_set('memory_limit', '2048M');\n set_time_limit(1800);\n\n $text = $this->spreadsheetToCompactCsvText($absPath);\n\n if ($text === null || trim($text) === '') {\n return 0;\n }\n\n $text = $this->sanitizeUtf8($text);\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) <= self::TEXT_CHUNK_CHARS && $sheetCount <= self::MAX_SHEETS_PER_CALL) {\n return 0;\n }\n\n $part = 0;\n foreach ($this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS) as $chunk) {\n $write(++$part, $chunk);\n }\n\n unset($text);\n\n Log::info('QuotationAiService: chunked spreadsheet to disk.', [\n 'chunks' => $part,\n 'peak_mb' => round(memory_get_peak_usage(true) / 1048576),\n ]);\n\n return $part;\n }\n\n /**\n * Parse one slice produced by chunkSpreadsheet().\n *\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n public function parseChunk(string $chunk, int $part, int $total, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured.');\n }\n\n // Same framing parseTextInChunks() uses, so a slice is never mistaken\n // for a complete BOQ and totals are not inferred from a fragment.\n $userContent = \"BOQ spreadsheet converted to CSV (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, 'text/plain');\n\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n private function parseTextInChunks(\n string $text,\n string $label,\n string $apiKey,\n array $context,\n string $mime = 'text/plain',\n ): array {\n $chunks = $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n $total = count($chunks);\n\n Log::info('QuotationAiService: large document split for parsing.', [\n 'chars' => mb_strlen($text),\n 'chunks' => $total,\n ]);\n\n // Announce the split before the first slice is sent, so the UI can show\n // how many parts there are instead of sitting on a generic message for\n // however long the first AI call takes.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)(0, $total);\n }\n\n $items = [];\n $rejected = [];\n $failed = 0;\n $lastError = null;\n\n foreach ($chunks as $index => $chunk) {\n $part = $index + 1;\n\n // Report progress so a 28-slice parse does not look frozen to the user.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)($part, $total);\n }\n\n // Tell the model this is a fragment, so it does not try to reconcile\n // totals or treat a mid-document slice as a complete BOQ.\n $userContent = \"{$label} (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, $mime);\n\n $result = $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n\n if (! ($result['success'] ?? false)) {\n $failed++;\n $lastError = $result['error'] ?? 'Chunk parsing failed.';\n Log::warning('QuotationAiService: chunk failed, continuing.', [\n 'part' => $part,\n 'of' => $total,\n 'error' => $lastError,\n ]);\n continue;\n }\n\n foreach ($result['items'] as $item) {\n $items[] = $item;\n }\n foreach ($result['rejected'] ?? [] as $row) {\n $rejected[] = $row;\n }\n\n // Hand this slice's rows straight to the caller so they can be shown\n // while the remaining slices are still being parsed.\n if ($this->onChunkItems !== null && $result['items'] !== []) {\n ($this->onChunkItems)($result['items'], $part, $total);\n }\n }\n\n // Every slice failed — there is nothing to show, so report the failure.\n if ($failed === $total) {\n return $this->failure($lastError ?? 'Could not parse the document.');\n }\n\n if ($failed > 0) {\n Log::error('QuotationAiService: document parsed with missing parts.', [\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n 'items_kept' => count($items),\n ]);\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'service_unavailable' => false,\n 'partial' => $failed > 0,\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n ];\n }\n\n /**\n * Split text into slices of at most $limit characters, breaking only at\n * newlines so no BOQ row is ever cut in half.\n *\n * A single line longer than the limit (rare — a pathological cell) is passed\n * through oversized rather than severed mid-row.\n *\n * @return array<int, string>\n */\n private function splitOnLineBoundaries(string $text, int $limit): array\n {\n $chunks = [];\n $current = '';\n\n // Sheet a chunk starts in the middle of. Repeated at the top of the next\n // chunk so the model always knows which sheet the rows belong to — the\n // header would otherwise be stranded in the previous slice, and rows\n // would arrive with no discipline context.\n $currentSheet = null;\n\n foreach (explode(\"\\n\", $text) as $line) {\n $isSheetHeader = str_starts_with($line, 'Sheet: ');\n\n // Start a new chunk at every sheet boundary. Sheets are usually\n // separate disciplines, and the whole point of splitting a multi-tab\n // workbook is to give the model one discipline at a time — a size-only\n // split would leave a compact 3-tab file as a single chunk, silently\n // undoing the sheet gate that routed it here.\n if ($isSheetHeader && $current !== '') {\n $chunks[] = $current;\n $current = $line;\n $currentSheet = $line;\n continue;\n }\n\n if ($isSheetHeader) {\n $currentSheet = $line;\n }\n\n $candidate = $current === '' ? $line : $current . \"\\n\" . $line;\n\n if (mb_strlen($candidate) > $limit && $current !== '') {\n $chunks[] = $current;\n\n // Carry the sheet header over, unless this line is itself one.\n $current = ($currentSheet !== null && ! str_starts_with($line, 'Sheet: '))\n ? $currentSheet . \" (continued)\\n\" . $line\n : $line;\n continue;\n }\n\n $current = $candidate;\n }\n\n if (trim($current) !== '') {\n $chunks[] = $current;\n }\n\n return $chunks;\n }\n\n /**\n * Shared JSON response parser used by both chat and vision paths.\n */\n private function parseDeepSeekJsonResponse(string $text): array\n {\n if ($text === '') {\n return $this->failure('DeepSeek returned an empty response.');\n }\n\n // Strip markdown code fences if the model wrapped the JSON.\n if (preg_match('/```(?:json)?\\s*([\\s\\S]*?)\\s*```/i', $text, $m)) {\n $text = $m[1];\n }\n // Narrow to the outermost JSON object/array.\n if (preg_match('/[\\{\\[][\\s\\S]*[\\}\\]]/s', $text, $m)) {\n $text = $m[0];\n }\n $text = trim($text);\n\n $decoded = json_decode($text, true);\n\n // The response can be truncated when the model hits the token limit,\n // leaving an unterminated JSON string/object. Try to repair it before\n // giving up so we can still salvage the items extracted so far.\n if (! is_array($decoded)) {\n $repaired = $this->repairTruncatedJson($text);\n if ($repaired !== null) {\n $decoded = json_decode($repaired, true);\n }\n }\n\n if (! is_array($decoded)) {\n Log::error('QuotationAiService: DeepSeek non-JSON response.', ['text' => mb_substr($text, 0, 1000)]);\n return $this->failure('DeepSeek returned a non-JSON response. The file may be too large — try splitting it into smaller files.');\n }\n\n $rawItems = $decoded['items'] ?? (array_is_list($decoded) ? $decoded : []);\n $items = [];\n $rejected = [];\n\n foreach ($rawItems as $raw) {\n if (! is_array($raw)) {\n continue;\n }\n $item = $this->normaliseAiItem($raw);\n if ($item === null) {\n continue;\n }\n $check = $this->boqCleaner->filterItem((string) $item['description']);\n if ($check['keep'] ?? false) {\n $items[] = $item;\n } else {\n $item['status'] = 'rejected';\n $item['raw_data']['rejection_reason'] = $check['rejection_reason'] ?? 'Rejected by cleaner';\n $rejected[] = $item;\n }\n }\n\n if (empty($items)) {\n Log::info('QuotationAiService: DeepSeek returned 0 accepted items.', [\n 'raw_items_count' => count($rawItems),\n 'rejected_count' => count($rejected),\n 'first_rejected' => ! empty($rejected) ? ($rejected[0]['description'] ?? '') : '',\n ]);\n }\n\n return ['success' => true, 'items' => $items, 'rejected' => $rejected, 'error' => null];\n }\n\n /**\n * Best-effort repair of a truncated JSON string produced when the model hits\n * its token limit mid-output. Drops any incomplete trailing object, then\n * closes the open string/array/object brackets so the result is valid JSON.\n *\n * Returns the repaired JSON string, or null if it cannot be salvaged.\n */\n private function repairTruncatedJson(string $text): ?string\n {\n if ($text === '') {\n return null;\n }\n\n // Walk the string tracking structural state, ignoring everything inside\n // string literals (and their escapes). Remember the last position where\n // we were back at the top of the \"items\" array between complete elements,\n // so we can cut a half-written trailing object.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($text);\n $lastSafeCut = null; // index just after a completed array element\n\n for ($i = 0; $i < $len; $i++) {\n $ch = $text[$i];\n\n if ($inString) {\n if ($escaped) {\n $escaped = false;\n } elseif ($ch === '\\\\') {\n $escaped = true;\n } elseif ($ch === '\"') {\n $inString = false;\n }\n continue;\n }\n\n if ($ch === '\"') {\n $inString = true;\n } elseif ($ch === '{' || $ch === '[') {\n $stack[] = $ch;\n } elseif ($ch === '}' || $ch === ']') {\n array_pop($stack);\n } elseif ($ch === ',' && ! empty($stack) && end($stack) === '[') {\n // Separator directly inside an array: a clean boundary between\n // elements (the items array, whatever its nesting depth).\n $lastSafeCut = $i;\n }\n }\n\n // If we are still inside a string, the last element is incomplete — fall\n // back to the last clean array boundary if we have one.\n $candidate = $text;\n if ($inString && $lastSafeCut !== null) {\n $candidate = substr($text, 0, $lastSafeCut);\n } else {\n // Drop a dangling partial object after the last completed element.\n $candidate = rtrim($candidate);\n if ($lastSafeCut !== null && substr(rtrim($candidate), -1) !== '}' && substr(rtrim($candidate), -1) !== ']') {\n $candidate = substr($text, 0, $lastSafeCut);\n }\n }\n\n // Recompute the open brackets for the (possibly trimmed) candidate and\n // append the matching closers in reverse order.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($candidate);\n for ($i = 0; $i < $len; $i++) {\n $ch = $candidate[$i];\n if ($inString) {\n if ($escaped) { $escaped = false; }\n elseif ($ch === '\\\\') { $escaped = true; }\n elseif ($ch === '\"') { $inString = false; }\n continue;\n }\n if ($ch === '\"') { $inString = true; }\n elseif ($ch === '{' || $ch === '[') { $stack[] = $ch; }\n elseif ($ch === '}' || $ch === ']') { array_pop($stack); }\n }\n\n $candidate = rtrim($candidate);\n // Remove a trailing comma left after cutting an element.\n $candidate = rtrim($candidate, ',');\n\n $closers = '';\n for ($i = count($stack) - 1; $i >= 0; $i--) {\n $closers .= ($stack[$i] === '{') ? '}' : ']';\n }\n\n $repaired = $candidate . $closers;\n\n // Validate before returning.\n return is_array(json_decode($repaired, true)) ? $repaired : null;\n }\n\n private function spreadsheetToCompactCsvText(string $absPath): ?string\n {\n $sheets = $this->spreadsheetToGrids($absPath);\n if (empty($sheets)) {\n return null;\n }\n\n $output = '';\n $kept = 0;\n $dropped = 0;\n $nonSupply = 0;\n $duplicates = 0;\n\n /** @var array<string, true> Fingerprints of rows already emitted. */\n $seenRows = [];\n\n foreach ($sheets as $sheet) {\n // Don't feed summary / cost-abstract sheets to the AI either.\n if ($this->isSummarySheet($sheet['name'])) {\n continue;\n }\n $sheetRows = '';\n foreach ($sheet['grid'] as $row) {\n $row = $this->trimTrailingEmptyCells($row);\n\n if ($this->isNoiseRow($row)) {\n $dropped++;\n continue;\n }\n\n // Drop rows that are definitely not procurable before they cost\n // a single token: section totals, discipline headings,\n // preliminaries, labour-only lines. The same classifier already\n // runs on the AI's output, but by then the tokens are spent — on\n // an 11 200-row BOQ that is most of the bill.\n if ($this->isNonSupplyRow($row)) {\n $dropped++;\n $nonSupply++;\n continue;\n }\n\n // Byte-identical rows tell the AI nothing new. Only exact\n // duplicates are collapsed — description, quantity, unit and all\n // — so nothing is lost: two rows differing in quantity are two\n // different line items and both are sent.\n $fingerprint = $this->rowFingerprint($row);\n\n if (isset($seenRows[$fingerprint])) {\n $duplicates++;\n $dropped++;\n continue;\n }\n $seenRows[$fingerprint] = true;\n\n $sheetRows .= implode(',', array_map([$this, 'csvEscape'], $this->stripBoilerplate($row))) . \"\\n\";\n $kept++;\n }\n\n // A sheet whose rows were all noise contributes nothing but its own\n // header, which would just be another thing for the model to explain.\n if ($sheetRows === '') {\n continue;\n }\n\n $output .= 'Sheet: ' . $sheet['name'] . \"\\n\" . $sheetRows . \"\\n\";\n }\n\n $blank = $dropped - $nonSupply - $duplicates;\n\n Log::info('QuotationAiService: filtered rows before sending to AI.', [\n 'kept' => $kept,\n 'blank' => $blank,\n 'non_supply' => $nonSupply,\n 'duplicates' => $duplicates,\n ]);\n\n if ($dropped > 0) {\n $parts = [];\n if ($blank > 0) { $parts[] = \"{$blank} blank\"; }\n if ($nonSupply > 0) { $parts[] = \"{$nonSupply} non-supply\"; }\n if ($duplicates > 0) { $parts[] = \"{$duplicates} duplicate\"; }\n\n $this->reportStage('Skipped ' . implode(', ', $parts) . \" rows. Sending {$kept}…\");\n }\n\n return $output;\n }\n\n /**\n * True when a row carries no information worth sending to the AI.\n *\n * A real BOQ is mostly formatting: spacer rows, stray borders, a lone\n * section number, a leftover cell from a merged block. They survive\n * trimTrailingEmptyCells() because they are not *entirely* empty, then cost\n * tokens, dilute the prompt, and inflate the number of parts a large file is\n * split into.\n *\n * Deliberately conservative — a row is dropped only when nothing in it could\n * name a product. Anything with letters is kept, because a description is\n * the one field an item cannot do without.\n *\n * @param array<int, mixed> $row\n */\n /**\n * True when a row is definitely not a procurable supply item.\n *\n * Runs BoqCleaningService over the row's longest text cell — the one most\n * likely to be the description — so headings, totals, preliminaries and\n * labour-only lines never reach the AI. The same classifier already vets the\n * AI's *output*; doing it here as well means we stop paying to have those\n * rows read in the first place.\n *\n * Conservative by construction: only rows the classifier positively rejects\n * are dropped. Anything it is unsure about goes to the AI, which is the\n * better judge — a wrongly dropped row is invisible, a wrongly kept one only\n * costs tokens.\n *\n * @param array<int, mixed> $row\n */\n /**\n * Stable identity for a row, used to collapse exact duplicates.\n *\n * Whitespace and case are normalised so \"DN50 PIPE\" and \"dn50 pipe \" are one\n * row, but nothing else is: a differing quantity or unit produces a\n * different fingerprint and both rows are sent.\n *\n * @param array<int, mixed> $row\n */\n private function rowFingerprint(array $row): string\n {\n $parts = array_map(\n fn($cell) => mb_strtolower(preg_replace('/\\s+/u', ' ', trim((string) $cell)) ?? ''),\n $row,\n );\n\n // Drop a leading BOQ sequence number. Otherwise \"1,Pipe,m,100\" and\n // \"2,Pipe,m,100\" fingerprint differently and the dedup never fires —\n // which is exactly the shape every real BOQ has.\n if (isset($parts[0]) && $parts[0] !== '' && preg_match('/^[\\d.]+$/', $parts[0])) {\n array_shift($parts);\n }\n\n return md5(implode('|', $parts));\n }\n\n /**\n * Strip contractual boilerplate from a row's description cell.\n *\n * \"as per drawings\", \"complete with all necessary accessories\" and friends\n * repeat on nearly every line of a real BOQ and carry no information the\n * pricing engine can use — but they are a large share of the tokens.\n *\n * Only the longest text cell is touched, and only when the result is still a\n * usable description: if stripping would leave too little to identify the\n * product, the original is kept. A shorter-but-wrong description is far\n * worse than a verbose one.\n *\n * @param array<int, mixed> $row\n * @return array<int, mixed>\n */\n private function stripBoilerplate(array $row): array\n {\n $index = null;\n $longest = '';\n\n foreach ($row as $i => $cell) {\n $text = trim((string) $cell);\n if (preg_match('/\\p{L}/u', $text) && mb_strlen($text) > mb_strlen($longest)) {\n $longest = $text;\n $index = $i;\n }\n }\n\n // Nothing worth cleaning, or too short to risk it.\n if ($index === null || mb_strlen($longest) < 40) {\n return $row;\n }\n\n try {\n $cleaned = $this->boqCleaner->cleanDescription($longest);\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: boilerplate strip failed, keeping row as-is.', [\n 'error' => $e->getMessage(),\n ]);\n return $row;\n }\n\n // Guard against an over-eager pattern eating the product name: keep the\n // original unless a recognisable description survives.\n if (mb_strlen($cleaned) < 12 || mb_strlen($cleaned) < mb_strlen($longest) * 0.3) {\n return $row;\n }\n\n $row[$index] = $cleaned;\n\n return $row;\n }\n\n private function isNonSupplyRow(array $row): bool\n {\n $description = '';\n foreach ($row as $cell) {\n $text = trim((string) $cell);\n // Longest cell containing letters — numbers are qty/rate columns.\n if (preg_match('/\\p{L}/u', $text) && mb_strlen($text) > mb_strlen($description)) {\n $description = $text;\n }\n }\n\n // No text at all: leave it to isNoiseRow()/the AI rather than guessing.\n if ($description === '' || mb_strlen($description) < 3) {\n return false;\n }\n\n try {\n $verdict = $this->boqCleaner->filterItem($description);\n } catch (\\Throwable $e) {\n // A classifier failure must never silently drop a real product.\n Log::warning('QuotationAiService: pre-filter classifier failed, keeping row.', [\n 'error' => $e->getMessage(),\n ]);\n return false;\n }\n\n return ! ($verdict['keep'] ?? true);\n }\n\n private function isNoiseRow(array $row): bool\n {\n if (empty($row)) {\n return true;\n }\n\n $joined = trim(implode('', array_map(fn($c) => trim((string) $c), $row)));\n\n // Nothing but separators/whitespace.\n if ($joined === '') {\n return true;\n }\n\n // Ruled lines and decorative fills (\"-----\", \"====\", \"___\").\n if (preg_match('/^[\\-_=.*\\s|]+$/u', $joined)) {\n return true;\n }\n\n // Any letter — Latin or Arabic — means this could be a description.\n if (preg_match('/\\p{L}/u', $joined)) {\n return false;\n }\n\n // No letters at all: numbers only. A single number is a stray row\n // number or a dangling total; two or more could be qty/rate/amount\n // belonging to a real line, so keep those for the AI to judge.\n $numericCells = 0;\n foreach ($row as $cell) {\n if (trim((string) $cell) !== '') {\n $numericCells++;\n }\n }\n\n return $numericCells <= 1;\n }\n\n /**\n * Safely convert a PhpSpreadsheet cell value to a string.\n *\n * Cells may contain RichText or, in malformed/heavily-formatted files,\n * embedded objects such as Worksheet\\Drawing that have no __toString().\n * Casting those directly throws \"Object ... could not be converted to string\".\n */\n private function cellValueToString(mixed $value): string\n {\n if ($value === null) {\n return '';\n }\n\n if ($value instanceof \\PhpOffice\\PhpSpreadsheet\\RichText\\RichText) {\n return $value->getPlainText();\n }\n\n if (is_object($value)) {\n // Only stringify objects that explicitly support it; ignore the rest\n // (e.g. Worksheet\\Drawing) instead of crashing.\n return method_exists($value, '__toString') ? (string) $value : '';\n }\n\n if (is_array($value)) {\n return '';\n }\n\n return (string) $value;\n }\n\n private function csvEscape(mixed $value): string\n {\n $value = $this->cellValueToString($value);\n return str_contains($value, ',') || str_contains($value, '\"') || str_contains($value, \"\\n\")\n ? '\"' . str_replace('\"', '\"\"', $value) . '\"'\n : $value;\n }\n\n private function verifyLargeBoqItemsWithAi(array $result): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n Log::warning('QuotationAiService: large BOQ verification skipped, AI key is not configured.');\n return $result;\n }\n\n $items = array_values(array_filter($result['items'] ?? [], 'is_array'));\n if (count($items) <= self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n return $result;\n }\n\n Log::info('QuotationAiService: starting second-pass physical item verification.', [\n 'items_count' => count($items),\n 'threshold' => self::LARGE_BOQ_VERIFICATION_THRESHOLD,\n ]);\n\n $verifiedItems = [];\n $rejectedItems = $result['rejected'] ?? [];\n\n foreach (array_chunk($items, self::LARGE_BOQ_VERIFICATION_CHUNK_SIZE) as $chunkIndex => $chunk) {\n $payload = array_map(fn(array $item) => [\n 'description' => (string) ($item['description'] ?? ''),\n 'quantity' => $item['quantity'] ?? null,\n 'unit' => (string) ($item['unit'] ?? ''),\n 'unit_price' => $item['unit_price'] ?? null,\n 'category' => (string) ($item['category'] ?? ''),\n 'brand' => (string) ($item['brand'] ?? ''),\n ], $chunk);\n\n $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);\n if (! is_string($json)) {\n Log::warning('QuotationAiService: large BOQ verification chunk JSON encode failed.', [\n 'chunk' => $chunkIndex + 1,\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n $prompt = <<<PROMPT\nYou are doing a SECOND PASS verification on a BOQ extraction result.\n\nThe first pass may hallucinate non-products when the BOQ has thousands of rows.\nYour job is to keep ONLY physical, tangible, procurable supply products.\n\nRules:\n- Keep items only if a supplier can actually deliver them as materials/equipment.\n- Reject headings, trade names, systems, scopes, prose, specifications-only rows, totals, labor, installation, testing, commissioning, supervision, drawings, insurance, preliminaries, mobilisation, site works, and vague fragments.\n- Do not invent new products.\n- Do not add items that are not in the input.\n- You may clean a product description, but keep the original quantity, unit, unit_price, category, and brand when available.\n\nInput JSON array:\n{$json}\n\nReturn ONLY valid JSON in this shape:\n{\"items\":[{\"description\":\"\",\"quantity\":1,\"unit\":\"\",\"unit_price\":null,\"category\":\"\",\"brand\":\"\",\"confidence\":0.95}]}\nPROMPT;\n\n try {\n $chunkResult = $this->callDeepSeekChat($prompt, $apiKey, $this->deepSeekModel());\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: large BOQ verification chunk threw an exception.', [\n 'chunk' => $chunkIndex + 1,\n 'message' => $e->getMessage(),\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n if ($chunkResult['success'] ?? false) {\n array_push($verifiedItems, ...($chunkResult['items'] ?? []));\n $rejectedItems = array_merge($rejectedItems, $chunkResult['rejected'] ?? []);\n continue;\n }\n\n Log::warning('QuotationAiService: large BOQ verification chunk failed, keeping original chunk.', [\n 'chunk' => $chunkIndex + 1,\n 'error' => $chunkResult['error'] ?? null,\n ]);\n array_push($verifiedItems, ...$chunk);\n }\n\n if (empty($verifiedItems)) {\n Log::warning('QuotationAiService: second-pass verification returned no items, keeping first-pass result.', [\n 'original_items_count' => count($items),\n ]);\n return $result;\n }\n\n $result['items'] = $verifiedItems;\n $result['rejected'] = $rejectedItems;\n $result['large_boq_verified'] = true;\n\n Log::info('QuotationAiService: second-pass physical item verification completed.', [\n 'before' => count($items),\n 'after' => count($verifiedItems),\n ]);\n\n return $result;\n }\n\n private function normaliseAiItem(array $raw): ?array\n {\n if (! empty($raw['rejected'])) {\n return null;\n }\n\n $description = trim((string) ($raw['cleaned_description'] ?? $raw['product_name'] ?? $raw['description'] ?? ''));\n if ($description === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $description)) {\n return null;\n }\n\n $quantity = is_numeric($raw['quantity'] ?? null) ? (float) $raw['quantity'] : 1;\n $unitPrice = is_numeric($raw['unit_price'] ?? null) ? (float) $raw['unit_price'] : null;\n $totalPrice = is_numeric($raw['total_price'] ?? null) ? (float) $raw['total_price'] : null;\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $specs = is_array($raw['specifications'] ?? null) ? $raw['specifications'] : [];\n\n return [\n 'description' => $description,\n 'quantity' => $quantity,\n 'unit' => (string) ($raw['unit'] ?? ''),\n 'category' => (string) ($raw['core_product'] ?? $raw['category'] ?? ''),\n 'brand' => (string) ($specs['brand'] ?? $raw['brand'] ?? ''),\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($description),\n 'unit_price' => $unitPrice,\n 'confidence' => is_numeric($raw['confidence'] ?? null) ? (float) $raw['confidence'] : null,\n 'needs_review' => ! empty($raw['needs_review']),\n 'raw_data' => [\n 'source_item_no' => (string) ($raw['source_item_no'] ?? ''),\n 'original_description' => (string) ($raw['original_description'] ?? $description),\n 'cleaned_description' => $description,\n 'core_product' => (string) ($raw['core_product'] ?? ''),\n 'specifications' => $specs,\n 'extraction_type' => (string) ($raw['extraction_type'] ?? 'supply_only'),\n 'note' => (string) ($raw['note'] ?? $raw['notes'] ?? ''),\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function buildSystemInstruction(): string\n {\n return <<<'TXT'\nYou are a strict BOQ Supply Product Extraction Engine.\nExtract ONLY concrete, tangible, physical products that a supplier can box up and deliver to site — a specific item you could point at and say \"this is the thing being bought\" (e.g. \"Wall mounted LED luminaire 36W\", \"PVC pipe 110mm\", \"Single phase distribution board 12-way\", \"Fire extinguisher CO2 6kg\", \"CCTV dome camera 4MP\").\n\nABSOLUTELY REJECT abstract concepts, disciplines, categories, section names, and trade headings. These are NOT products even though they appear as rows:\n- Bare discipline/trade words: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", \"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\".\n- Summary / total / abstract / \"dry cost\" rows of any kind.\n- Anything that names a system, scope, or category instead of one buyable item.\n\nRule of thumb: if you cannot name a unit and physically hand the item over (a piece, a box, a length, a unit of equipment), REJECT it. A word that describes a field of work, a system, or a section of the BOQ is NEVER a product.\nReject totals, headings, general requirements, installation-only, testing, commissioning, supervision, mobilization, civil works, labor, and vague rows.\nFor Supply & Install rows, keep only the supply product and remove install/labor wording.\nReturn only valid JSON: {\"items\":[{\"source_item_no\":null,\"original_description\":\"\",\"cleaned_description\":\"\",\"core_product\":\"\",\"specifications\":{},\"quantity\":0,\"unit\":\"\",\"unit_price\":0,\"total_price\":0,\"extraction_type\":\"supply_only\",\"needs_review\":false,\"note\":\"\",\"rejected\":false,\"rejection_reason\":null,\"confidence\":0.95}]}\nTXT;\n }\n\n private function buildDeepSeekPrompt(array $context = [], string $mime = ''): string\n {\n $projectName = (string) ($context['project_name'] ?? '');\n $projectStatus = (string) ($context['project_status'] ?? '');\n $contextLine = $projectName !== ''\n ? \"Project: {$projectName}\" . ($projectStatus !== '' ? \" ({$projectStatus})\" : '')\n : '';\n $sourceHint = str_starts_with($mime, 'image/')\n ? 'The input is an image of a BOQ table. OCR every cell carefully — read the quantity/count column (العدد, الكمية, Qty) and extract the EXACT numeric value from each row.'\n : 'The input is a BOQ document/spreadsheet.';\n\n return trim(<<<PROMPT\nYou are a procurement specialist. Your task is to extract ONLY tangible, physical supply products from this Bill of Quantities (BOQ).\n\n{$contextLine}\n{$sourceHint}\n\nCRITICAL — MERGE WRAPPED / SPLIT ROWS INTO ONE ITEM:\nBOQ tables extracted from PDF often break ONE item's long description across several\nlines/rows. These continuation lines repeat the SAME quantity, unit and category, and\ntheir text is a grammatical continuation of the previous line (it starts mid-sentence,\nlowercase, or with \"the/and/-\", or is a spec/instruction clause). You MUST reconstruct\nthe original single item:\n- Treat consecutive rows that share the SAME quantity + unit + category as ONE product\n when the later rows are clearly continuation text of the first, NOT new products.\n- Emit ONE item with the merged description and the price counted ONCE. Do NOT repeat\n the same quantity×price for every wrapped line — that multiplies the total wrongly.\n- Example: rows \"The asphalt flooring is to be supplied\" / \"installed in accordance\" /\n \"standard road specifications...\" all with 7,100 m2 are ONE asphalt item, not 3+.\n\nCRITICAL — UNDERSTAND INTENT, DO NOT COPY ROWS LITERALLY:\nYour job is to output WHAT A SUPPLIER MUST ACTUALLY DELIVER, not a copy of the row text.\nRead each description for MEANING and decide how many real, separately-buyable products it\nimplies. One BOQ row can imply SEVERAL products, or a spread of rows can imply ONE product.\n- SPLIT a row into multiple items when it lists distinct products that a supplier would\n buy/price separately — even when joined by commas, \"with\", \"and\", \"including\", \"c/w\",\n \"consisting of\", \"comprising\", or \":\" then a list.\n Example: \"Fire pump set consisting of: fire pump, electric motor, control panel, diesel\n engine\" → FOUR items: (1) fire pump, (2) electric motor, (3) control panel, (4) diesel\n engine — each with its own quantity and priced on its own.\n Example: \"Wash basin complete with mixer tap and bottle trap\" → THREE items: wash basin,\n mixer tap, bottle trap.\n- Give each split product the correct quantity implied by the text (usually the parent\n quantity, unless the text states a different count for a sub-item). Each product is\n priced individually; the line total becomes the SUM of its products.\n- DO NOT split when the extra words are just specifications, dimensions, colours, ratings,\n standards, or a single product's integral parts. \"Steel door 900x2100 with vision panel\n and SS ironmongery\" is usually ONE door product — vision panel and ironmongery are part\n of that door, not separately-bought items. Use judgement: would a supplier issue a\n separate price line for it? If no, keep it as one.\n- Balanced approach: split only when genuinely separate products are present; when in doubt\n about whether something is a separate product vs. an accessory/spec of the main one, keep\n it as a single item. Do not invent products that are not in the text.\n\nSTRICT RULES — include ONLY items that are ALL of the following:\n1. A specific, concrete, tangible product that can be boxed up by a supplier and delivered to site — something you can point at and say \"this exact thing is being bought\" (e.g. \"LED luminaire 36W\", \"PVC pipe 110mm\", \"Distribution board 12-way\", \"CO2 fire extinguisher 6kg\").\n2. Has a real quantity (not 0 or \"varies\" with no meaning).\n3. Has enough description to identify what the product is.\n4. Is an actual PRODUCT, not a sentence, instruction, or specification clause. REJECT rows that are prose such as \"the supervising engineer's instructions\", \"The price includes all materials, labor\", \"Measurement will be based on the geometric cubic meter\", \"necessary steps to complete the work\", \"soil-contacting elements\", \"in compliance\", \"everything necessary\". These are description fragments, not buyable items.\n\nNEVER extract abstract concepts, disciplines, trades, systems, or section/category names — they are NOT products even when they appear as their own row:\n- Bare discipline/trade/system words such as: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", and their Arabic equivalents (\"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\", \"تشطيبات\").\n- Test: if the word names a FIELD OF WORK or a SYSTEM rather than one buyable item with a unit (pcs, m, set, kg…), REJECT it.\n\nEXCLUDE all of the following (do NOT include them):\n- Labor, installation, or workmanship items (e.g. \"install\", \"fix\", \"erect\", \"dismantle\", \"painting\", \"cleaning service\")\n- Project management, overhead, or profit items\n- Insurance, bonds, liability items (e.g. \"bodily injury\", \"third party liability\", \"CAR insurance\")\n- Shop drawings, as-built drawings, method statements, documentation\n- Preliminary items, mobilisation, demobilisation, provisional sums\n- Housekeeping, site clearance, waste removal\n- Floor/level labels (e.g. \"GF Floor\", \"Level 3\") — these are headers, not products\n- Pure dimension specs without a product noun (e.g. \"Dia 32 mm\", \"25 mm\") — reject unless a product name is present\n- Security services, night shifts, rentals, payments\n- Any item whose description is too vague or fragmentary to identify a specific product\n\nEmit ONE JSON item PER real product. A single source row may therefore produce several\nitems (when it lists multiple products) or several rows may collapse into one item (when\nthey are wrapped fragments of the same product). The \"items\" array is your final delivery\nlist, not a row-by-row transcript.\n\nFor each valid physical product return JSON with:\n- description: clean product name only (strip \"supply and install\", \"supply and fix\", installation clauses, floor/spec suffixes). For a split product, use just that product's own name.\n- quantity: the EXACT numeric value from the quantity/count column (العدد, الكمية, Qty, Quantity, No.). NEVER default to 1 unless the quantity column is truly absent or empty.\n- unit: unit of measure (pcs, m, m2, m3, set, no, kg, etc.)\n- category: product category\n- brand: brand/manufacturer if mentioned, else \"\"\n- engineering_required: true if the item needs engineering design before procurement (panel boards, chillers, generators, fire suppression systems, BMS, pumps, AHUs, switchgear, transformers), else false\n\nReturn ONLY a valid JSON object: {\"items\": [...]}. No explanations, no markdown, no extra text.\nPROMPT);\n }\n\n private function mockResponse(): array\n {\n return [\n 'success' => true,\n 'error' => null,\n 'items' => [[\n 'description' => 'Sample BOQ Item',\n 'quantity' => 1,\n 'unit' => 'No',\n 'category' => 'Sample',\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 1,\n 'raw_data' => [],\n 'ai_extracted' => true,\n ]],\n 'rejected' => [],\n ];\n }\n\n private function failure(string $message, bool $serviceUnavailable = false): array\n {\n return [\n 'success' => false,\n 'items' => [],\n 'rejected' => [],\n 'error' => $this->userFacingError($message, $serviceUnavailable),\n 'service_unavailable' => $serviceUnavailable,\n ];\n }\n\n /**\n * Translate any internal error into a neutral, user-safe message.\n *\n * The user must never see the name of the underlying AI/processing engine\n * (DeepSeek, Groq, Gemini, Vision, etc.) or configuration hints like API\n * keys. The detailed message is kept in the logs for debugging only.\n */\n private function userFacingError(string $message, bool $serviceUnavailable = false): string\n {\n // Detect any reference to an AI provider / engine / API config so we can\n // swap it for a generic, branded-neutral message.\n $sensitive = '/deepseek|groq|gemini|vision|openai|openrouter|api[\\s_-]?key|\\.env|non-?json|token limit/i';\n\n if (preg_match($sensitive, $message)) {\n Log::warning('QuotationAiService: suppressed technical error from user.', ['internal' => $message]);\n\n return $serviceUnavailable\n ? 'The file could not be processed right now. Please try again in a moment.'\n : 'We could not read this file automatically. Please make sure it is a clear BOQ (Excel, CSV, PDF, or image) and try again, or add the items manually.';\n }\n\n return $message;\n }\n\n private function mimeForExtension(string $ext): string\n {\n return match (strtolower($ext)) {\n 'pdf' => 'application/pdf',\n 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n 'xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12',\n 'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',\n 'xls' => 'application/vnd.ms-excel',\n 'csv' => 'text/csv',\n 'jpg', 'jpeg' => 'image/jpeg',\n 'png' => 'image/png',\n 'gif' => 'image/gif',\n 'bmp' => 'image/bmp',\n 'webp' => 'image/webp',\n 'tiff', 'tif' => 'image/tiff',\n 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'doc' => 'application/msword',\n default => 'application/octet-stream',\n };\n }\n}\n"}, "ea6c7a169184892a12edda05c08c8a5b44d31c5d": {"t": 1785748357.9901447, "n": 90992, "h": "0153db197ab21b93", "c": "<?php\n\nnamespace App\\Services;\n\nuse Illuminate\\Http\\Client\\ConnectionException;\nuse Illuminate\\Http\\UploadedFile;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\nuse PhpOffice\\PhpSpreadsheet\\Cell\\Coordinate;\nuse PhpOffice\\PhpSpreadsheet\\IOFactory;\n\nclass QuotationAiService\n{\n /** Increment this whenever BOQ parsing logic changes to invalidate old caches. */\n private const PARSER_VERSION = 10;\n private const LARGE_BOQ_VERIFICATION_THRESHOLD = 2000;\n private const LARGE_BOQ_VERIFICATION_CHUNK_SIZE = 120;\n\n /**\n * Characters of document text sent per AI call.\n *\n * Above this the document is split and parsed over several calls. Sized well\n * under the model's context so the prompt, the slice, and a full JSON answer\n * for every row in it all fit — the binding limit is the *output* budget, not\n * the input, since each input row produces a JSON object.\n */\n private const TEXT_CHUNK_CHARS = 60000;\n\n /**\n * A workbook with more sheets than this is split regardless of its size.\n *\n * Multiple tabs almost always mean multiple disciplines (electrical, HVAC,\n * civil…). Sending them in one call makes the model summarise across\n * sections and silently drop rows, so each slice gets a narrower scope.\n */\n private const MAX_SHEETS_PER_CALL = 2;\n\n /** A PDF longer than this is split regardless of its extracted text length. */\n private const MAX_PDF_PAGES_PER_CALL = 5;\n\n /**\n * Largest PDF we will hand to the parser (12 MB).\n *\n * smalot/pdfparser decompresses every stream into memory with no ceiling of\n * its own, and overrunning the limit there is a fatal error that kills the\n * worker rather than an exception we can recover from.\n */\n private const MAX_PDF_BYTES = 12582912;\n\n /**\n * Memory the parser is assumed to need, as a multiple of the file size.\n *\n * Decompressed content streams plus the object graph run well past the raw\n * bytes; 8x is conservative enough to catch the cases that were dying.\n */\n private const PDF_MEMORY_FACTOR = 8;\n\n private string $baseUrl;\n private string $parseEndpoint;\n private string $apiKey;\n private int $timeout;\n private bool $testMode;\n private BoqCleaningService $boqCleaner;\n\n /** Page count of the most recently parsed PDF; 0 when unknown. */\n private int $lastPdfPageCount = 0;\n\n /**\n * Optional progress reporter for chunked parsing, called as ($part, $total).\n *\n * Set by the queue job so the polling UI can show real progress on a large\n * BOQ instead of a spinner that appears frozen for many minutes.\n *\n * @var (callable(int, int): void)|null\n */\n private $onChunkProgress = null;\n\n /** @param callable(int, int): void $callback */\n public function onChunkProgress(callable $callback): self\n {\n $this->onChunkProgress = $callback;\n return $this;\n }\n\n /**\n * Optional per-chunk item sink, called as ($items, $part, $total).\n *\n * Lets the caller persist each slice's rows the moment they arrive instead\n * of waiting for the whole document. On a large BOQ this is the difference\n * between a table that fills in progressively and one that stays empty for\n * many minutes.\n *\n * @var (callable(array, int, int): void)|null\n */\n private $onChunkItems = null;\n\n /** @param callable(array, int, int): void $callback */\n public function onChunkItems(callable $callback): self\n {\n $this->onChunkItems = $callback;\n return $this;\n }\n\n /**\n * Report a non-chunk stage (local parse, handing off to AI) to the caller.\n *\n * A spreadsheet is read locally first and only sent to the AI when that\n * fails, so a large file can spend minutes before any chunk exists. Without\n * these the UI shows a generic spinner for that whole period.\n */\n private function reportStage(string $message): void\n {\n if ($this->onStage !== null) {\n ($this->onStage)($message);\n }\n }\n\n /**\n * Report a workbook's shape from grids that are already in memory.\n *\n * Deliberately takes the parsed grids rather than a path: re-reading a large\n * spreadsheet just to count its sheets would double the slowest step of the\n * whole extraction.\n *\n * @param array<int, array{name: string, grid: array}> $sheets\n */\n private function reportWorkbookShape(array $sheets): void\n {\n if ($this->onStage === null || empty($sheets)) {\n return;\n }\n\n $rows = 0;\n foreach ($sheets as $sheet) {\n $rows += count($sheet['grid']);\n }\n\n $count = count($sheets);\n\n $this->reportStage($count > 1\n ? \"Found {$count} sheets, {$rows} rows in total. Reading…\"\n : \"Found {$rows} rows. Reading…\");\n }\n\n /** @var (callable(string): void)|null */\n private $onStage = null;\n\n /** @param callable(string): void $callback */\n public function onStage(callable $callback): self\n {\n $this->onStage = $callback;\n return $this;\n }\n\n public function __construct(BoqCleaningService $boqCleaner)\n {\n $this->boqCleaner = $boqCleaner;\n $this->baseUrl = rtrim((string) config('services.ai_quotation.base_url', ''), '/');\n $this->parseEndpoint = ltrim((string) config('services.ai_quotation.parse_endpoint', 'parse'), '/');\n $this->apiKey = (string) config('services.ai_quotation.api_key', '');\n $this->timeout = (int) config('services.ai_quotation.timeout', 300);\n $this->testMode = (bool) config('services.ai_quotation.test_mode', false);\n }\n\n /**\n * Give the running script enough head-room to outlast the AI HTTP call.\n *\n * The web request / queue worker must not be killed before the HTTP timeout\n * completes, otherwise the user sees a generic \"timeout or unavailable\"\n * error even though the AI may have responded. We allow the HTTP timeout\n * plus a 120s margin for connecting, response parsing and DB writes.\n */\n private function extendExecutionTime(): void\n {\n // Ignored on some SAPIs (e.g. when running under fastcgi with a hard\n // limit), but harmless to attempt.\n @set_time_limit($this->timeout + 120);\n }\n\n public function parseBoq(UploadedFile|string $file, array $context = []): array\n {\n if ($this->testMode) {\n return $this->mockResponse();\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n\n if (! is_file($absPath)) {\n return $this->failure('File not found or not readable.');\n }\n\n $fileHash = hash_file('sha256', $absPath);\n // Bump PARSER_VERSION whenever the extraction logic changes so that\n // previously-uploaded files are re-parsed instead of returning a stale\n // cached result produced by the old code.\n $cacheKey = 'boq_analysis_v' . self::PARSER_VERSION . '_' . $fileHash;\n $forceRefresh = (bool) ($context['force_refresh'] ?? false);\n\n if (! $forceRefresh && ($cached = Cache::get($cacheKey)) !== null) {\n return $cached;\n }\n\n // Clear stale cached result when force-refreshing\n if ($forceRefresh) {\n Cache::forget($cacheKey);\n }\n\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n // Read Excel locally first. Fall back to AI if local parse failed,\n // found 0 items, OR could not read one of the sheets (so multi-sheet\n // files never silently drop a sheet the local parser didn't understand).\n $this->reportStage('Reading the spreadsheet…');\n\n $result = $this->parseSpreadsheetDirect($absPath);\n\n $hasSkippedSheets = ! empty($result['skipped_sheets']);\n\n if (! $result['success'] || empty($result['items']) || $hasSkippedSheets) {\n // The local pass could not handle this file on its own. Say so:\n // otherwise the UI sits on a generic spinner through the local\n // parse AND the whole AI parse with nothing to show for either.\n $this->reportStage($hasSkippedSheets\n ? 'Some sheets need AI to read. Sending to AI…'\n : 'Spreadsheet needs AI to read. Sending to AI…');\n\n $deepSeekResult = $this->parseBoqWithDeepSeek($file, $context);\n // AI sees every sheet at once, so prefer its result whenever it\n // actually returned items (covers the skipped-sheet case too).\n if ($deepSeekResult['success'] && ! empty($deepSeekResult['items'])) {\n // Keep the larger of the two item sets so we never lose the\n // rows the local parser already extracted correctly.\n if (count($deepSeekResult['items']) >= count($result['items'] ?? [])) {\n $result = $deepSeekResult;\n }\n } elseif (! $result['success']) {\n $result = $deepSeekResult;\n }\n // else: keep local result if AI also failed/returned nothing\n }\n } elseif (in_array($ext, ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif'], true)) {\n $result = $this->parseBoqWithDeepSeek($file, $context);\n } else {\n return $this->failure('Unsupported file type. Please upload Excel, CSV, PDF, or image file.');\n }\n\n if ($result['success'] && ! empty($result['items'])) {\n $cleaning = $this->boqCleaner->process($result['items']);\n $result['items'] = $cleaning['accepted'] ?? $result['items'];\n $result['rejected'] = array_merge($result['rejected'] ?? [], $cleaning['rejected'] ?? []);\n }\n\n if ($result['success'] && count($result['items'] ?? []) > self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n $result = $this->verifyLargeBoqItemsWithAi($result);\n }\n\n // Only cache when we actually extracted items; empty results should be re-tried.\n if ($result['success'] && ! empty($result['items'])) {\n Cache::put($cacheKey, $result, now()->addDays(30));\n }\n\n return $result;\n }\n\n private function resolveFile(UploadedFile|string $file): array\n {\n if ($file instanceof UploadedFile) {\n return [(string) $file->getRealPath(), strtolower($file->getClientOriginalExtension())];\n }\n\n $absPath = is_file($file) ? $file : storage_path('app/' . ltrim($file, '/'));\n return [$absPath, strtolower(pathinfo($absPath, PATHINFO_EXTENSION))];\n }\n\n private function parseSpreadsheetDirect(string $absPath): array\n {\n ini_set('memory_limit', '2048M');\n\n // 180s was not enough for a real BOQ: an 11 200-row workbook was killed\n // mid-parse, and because set_time_limit() kills the process outright the\n // job reported a bare failure with nothing in the log to explain it.\n // This runs on the queue, where wall time is cheap.\n set_time_limit(1800);\n\n try {\n $sheets = $this->spreadsheetToGrids($absPath);\n\n if (empty($sheets)) {\n return $this->failure('Could not read the Excel file.');\n }\n\n $this->reportWorkbookShape($sheets);\n\n $items = [];\n $rejected = [];\n $skippedSheets = []; // sheets we could not parse locally (header undetected)\n\n foreach ($sheets as $sheet) {\n $grid = $sheet['grid'];\n if (count($grid) < 2) {\n continue;\n }\n\n // Skip dedicated summary / cost-abstract sheets entirely. Their rows\n // are per-discipline totals (Structural Concrete, Electrical, …) and\n // grand totals, not procurable products.\n if ($this->isSummarySheet($sheet['name'])) {\n Log::info('QuotationAiService: skipped summary sheet.', ['sheet' => $sheet['name']]);\n continue;\n }\n\n $header = $this->detectHeader($grid);\n\n if ($header === null || ! isset($header['map']['description'])) {\n Log::warning('QuotationAiService: header not detected in sheet.', ['sheet' => $sheet['name']]);\n $skippedSheets[] = $sheet['name'];\n continue;\n }\n\n $map = $header['map'];\n $start = $header['row'] + 1;\n\n for ($r = $start; $r < count($grid); $r++) {\n $row = $grid[$r];\n $description = $this->cell($row, $map['description'] ?? null);\n $itemNo = $this->cell($row, $map['item_code'] ?? null);\n $unit = $this->cell($row, $map['unit'] ?? null);\n $quantity = $this->number($this->cell($row, $map['quantity'] ?? null));\n $unitPrice = $this->number($this->cell($row, $map['unit_price'] ?? null));\n $totalPrice = $this->number($this->cell($row, $map['total_price'] ?? null));\n\n if ($description === '') {\n continue;\n }\n\n if (! isset($map['quantity'])) {\n $quantity = 1.0;\n }\n\n // Skip floor breakdown sub-rows: Ref. is \"-\" meaning it's a per-floor\n // quantity split of the parent item, not a standalone product.\n if ($itemNo === '-' || $this->isFloorBreakdownRow($itemNo, $description)) {\n continue;\n }\n\n if ($this->isTotalOrHeaderRow($description)) {\n continue;\n }\n\n if ($quantity === null || $quantity <= 0) {\n $rejected[] = $this->rejectedRow($description, $unit, $quantity, 'Missing quantity', $itemNo, $sheet['name']);\n continue;\n }\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $supply = $this->boqCleaner->filterItem($description, $unitPrice, $quantity);\n\n if (! ($supply['keep'] ?? false)) {\n $rejected[] = $this->rejectedRow(\n $description,\n $unit,\n $quantity,\n (string) ($supply['rejection_reason'] ?? 'Rejected by cleaner'),\n $itemNo,\n $sheet['name']\n );\n continue;\n }\n\n $finalDescription = (string) ($supply['description'] ?? $description);\n\n $items[] = [\n 'description' => $finalDescription,\n 'quantity' => $quantity,\n 'unit' => $unit,\n 'category' => $sheet['name'],\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($finalDescription),\n 'unit_price' => $unitPrice,\n 'confidence' => 0.95,\n 'needs_review' => false,\n 'raw_data' => [\n 'sheet' => $sheet['name'],\n 'row_number' => $r + 1,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'cleaned_description' => $finalDescription,\n 'extraction_type' => (string) ($supply['extraction_type'] ?? 'supply_only'),\n ],\n 'ai_extracted' => true,\n ];\n }\n }\n\n if (empty($items)) {\n return $this->failure('No BOQ supply items could be extracted from the Excel file.');\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'skipped_sheets' => $skippedSheets,\n ];\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: parseSpreadsheetDirect failed.', [\n 'path' => $absPath,\n 'message' => $e->getMessage(),\n 'trace' => $e->getTraceAsString(),\n ]);\n\n return $this->failure('Failed to read the Excel file: ' . $e->getMessage());\n }\n }\n\n private function spreadsheetToGrids(string $absPath): array\n {\n $ext = strtolower(pathinfo($absPath, PATHINFO_EXTENSION));\n\n if ($ext === 'csv') {\n $rows = [];\n $handle = fopen($absPath, 'rb');\n if ($handle !== false) {\n while (($row = fgetcsv($handle)) !== false) {\n $rows[] = $this->trimTrailingEmptyCells($row);\n }\n fclose($handle);\n }\n return [['name' => 'Sheet1', 'grid' => $rows]];\n }\n\n $reader = IOFactory::createReader($ext === 'xls' ? 'Xls' : 'Xlsx');\n $reader->setReadDataOnly(true);\n $spreadsheet = $reader->load($absPath);\n $sheets = [];\n\n foreach ($spreadsheet->getAllSheets() as $sheet) {\n $highestRow = $sheet->getHighestDataRow();\n $highestColumn = $sheet->getHighestDataColumn();\n $highestColumnIndex = Coordinate::columnIndexFromString($highestColumn);\n\n // BOQ data is normally in first 8-20 columns.\n // Files with heavy formatting/images can report 300+ columns — limit to 30.\n $maxColumnIndex = min($highestColumnIndex, 30);\n\n $grid = [];\n for ($row = 1; $row <= $highestRow; $row++) {\n $rowData = [];\n $hasValue = false;\n\n for ($col = 1; $col <= $maxColumnIndex; $col++) {\n $value = $sheet->getCell([$col, $row])->getValue();\n $value = $this->cellValueToString($value);\n $value = trim($value);\n if ($value !== null && $value !== '') {\n $hasValue = true;\n }\n $rowData[] = $value;\n }\n\n $rowData = $this->trimTrailingEmptyCells($rowData);\n if ($hasValue) {\n $grid[] = $rowData;\n }\n }\n\n if (! empty($grid)) {\n $sheets[] = ['name' => $sheet->getTitle(), 'grid' => $grid];\n }\n }\n\n $spreadsheet->disconnectWorksheets();\n unset($spreadsheet);\n\n return $sheets;\n }\n\n private function detectHeader(array $grid): ?array\n {\n $keywords = [\n 'item_code' => ['رقم البند', 'item no', 'item number', 'ref.', 'ref', 'item', 'no.', 'no', '#', 'كود'],\n 'description' => ['وص٠البند', 'scope of works', 'scope of work', 'scope', 'item description', 'description of works', 'description', 'بالعربية', 'الوصÙ', 'البند', 'بيان'],\n 'unit' => ['Ø§Ù„ÙˆØØ¯Ø©', 'unit', 'uom', 'u/m'],\n 'quantity' => ['الكمية', 'qty', 'quantity', 'q.ty'],\n 'unit_price' => ['سعر Ø§Ù„ÙˆØØ¯Ø©', 'u/price', 'unit price', 'unit rate', 'rate', 'price'],\n 'total_price' => ['السعر الإجمالي', 'total amount', 'total price', 'amount', 'الإجمالي'],\n ];\n\n $best = null;\n $bestScore = 0;\n\n for ($r = 0; $r < min(30, count($grid)); $r++) {\n $map = [];\n $score = 0;\n\n foreach ($grid[$r] as $c => $value) {\n $cell = mb_strtolower(trim((string) $value));\n if ($cell === '') {\n continue;\n }\n\n foreach ($keywords as $field => $list) {\n foreach ($list as $kw) {\n if (str_contains($cell, mb_strtolower($kw))) {\n if (! isset($map[$field])) {\n $map[$field] = $c;\n $score++;\n }\n break 2;\n }\n }\n }\n }\n\n if ($score > $bestScore) {\n $bestScore = $score;\n $best = ['row' => $r, 'map' => $map];\n }\n }\n\n if ($bestScore >= 3) {\n return $best;\n }\n\n // Allow simpler BOQ sheets that only define a description column plus one\n // other BOQ-related column, e.g. Item + Description or Description + Qty.\n if ($bestScore === 2 && isset($best['map']['description']) && (\n isset($best['map']['item_code']) ||\n isset($best['map']['quantity']) ||\n isset($best['map']['unit'])\n )) {\n return $best;\n }\n\n return null;\n }\n\n private function cell(array $row, ?int $index): string\n {\n if ($index === null) {\n return '';\n }\n return trim((string) ($row[$index] ?? ''));\n }\n\n private function number(string $value): ?float\n {\n $value = trim(str_replace([',', 'SAR', 'ر.س'], '', $value));\n if ($value === '' || ! is_numeric($value)) {\n return null;\n }\n return (float) $value;\n }\n\n private function trimTrailingEmptyCells(array $row): array\n {\n while (! empty($row) && trim((string) end($row)) === '') {\n array_pop($row);\n }\n return array_values($row);\n }\n\n private function isTotalOrHeaderRow(string $description): bool\n {\n $d = mb_strtolower(trim($description));\n\n if ($d === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $d)) {\n return true;\n }\n\n // Section totals / summaries\n if (preg_match('/(اجمالي|إجمالي|المجموع|grand\\s*total|sub\\s*total|total\\s*amount|total\\s*price)/iu', $d)) {\n return true;\n }\n\n // Bare \"Total\" lines, optionally with a currency suffix, e.g. \"Total\",\n // \"Total (SAR)\", \"Total cost\", \"Total (SAR) unit cost\".\n if (preg_match('/^total\\b[\\s\\(\\):a-z]*$/iu', $d)) {\n return true;\n }\n\n // Pure floor / level labels used as section headers\n if (preg_match('/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor)\\s*$/iu', $d)) {\n return true;\n }\n\n // Division / section header lines (e.g. \"Division - 02: Wood Works\")\n if (preg_match('/^division\\s*([-:]|\\d)/iu', $d)) {\n return true;\n }\n\n return false;\n }\n\n /**\n * Detect dedicated summary / cost-abstract sheets by their name. These hold\n * per-discipline rollups and grand totals (e.g. a \"Summary (Dry Cost)\" tab),\n * never procurable supply items, so they are skipped wholesale.\n */\n private function isSummarySheet(string $name): bool\n {\n $n = mb_strtolower(trim($name));\n\n if ($n === '') {\n return false;\n }\n\n return (bool) preg_match(\n '/\\b(summary|abstract|cost\\s*summary|grand\\s*summary|recap(?:itulation)?|bill\\s*summary|boq\\s*summary|dry\\s*cost)\\b|ملخص|الملخص|الملخّص|ملخّص|اجمالي|إجمالي/iu',\n $n\n );\n }\n\n private function isFloorBreakdownRow(string $itemNo, string $description): bool\n {\n // A row whose Ref column is empty or a dash is a sub-breakdown row (per-floor split).\n $ref = trim($itemNo);\n if ($ref === '' || $ref === '-') {\n // Confirm by checking the description is a location/floor label\n $d = mb_strtolower(trim($description));\n return (bool) preg_match(\n '/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor|\\d+f)\\s*$/iu',\n $d\n );\n }\n return false;\n }\n\n private function rejectedRow(string $description, string $unit, ?float $quantity, string $reason, string $itemNo = '', string $sheet = ''): array\n {\n return [\n 'description' => $description,\n 'quantity' => $quantity ?? 0,\n 'unit' => $unit,\n 'category' => $sheet,\n 'brand' => '',\n 'status' => 'rejected',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 0.0,\n 'raw_data' => [\n 'sheet' => $sheet,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'extraction_type' => 'rejected',\n 'rejection_reason' => $reason,\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function parseBoqWithDeepSeek(UploadedFile|string $file, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured. Please set DEEPSEEK_API_KEY in .env file.');\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n $mime = $this->mimeForExtension($ext);\n\n try {\n // ── Spreadsheets ──────────────────────────────────────────────────\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n $text = $this->spreadsheetToCompactCsvText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not convert spreadsheet to readable text.');\n }\n\n $text = $this->sanitizeUtf8($text);\n\n // A large BOQ exceeds what one request can carry, so send it in\n // sequential slices and merge. Anything that fits goes as-is.\n //\n // Size alone is not a good enough signal: a workbook with several\n // sheets is structurally a big BOQ even when the text is compact,\n // and asking the model to hold every discipline at once is where\n // rows start getting dropped. So split on either signal.\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS || $sheetCount > self::MAX_SHEETS_PER_CALL) {\n Log::info('QuotationAiService: spreadsheet routed to chunked parsing.', [\n 'chars' => mb_strlen($text),\n 'sheets' => $sheetCount,\n ]);\n return $this->parseTextInChunks($text, 'BOQ spreadsheet converted to CSV', $apiKey, $context);\n }\n\n $userContent = \"BOQ spreadsheet converted to CSV:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── PDF ───────────────────────────────────────────────────────────\n if ($ext === 'pdf') {\n $extracted = $this->extractPdfText($absPath);\n if ($extracted !== null && mb_strlen(trim($extracted)) > 50) {\n $extracted = $this->sanitizeUtf8($extracted);\n\n // Split on either signal — long text, or many pages. A BOQ\n // spanning several pages is a multi-section document even if\n // each page is sparse.\n if (mb_strlen($extracted) > self::TEXT_CHUNK_CHARS\n || $this->lastPdfPageCount > self::MAX_PDF_PAGES_PER_CALL) {\n Log::info('QuotationAiService: PDF routed to chunked parsing.', [\n 'chars' => mb_strlen($extracted),\n 'pages' => $this->lastPdfPageCount,\n ]);\n return $this->parseTextInChunks($extracted, 'BOQ PDF extracted text', $apiKey, $context, 'application/pdf');\n }\n\n $userContent = \"BOQ PDF extracted text:\\n\\n\" . $extracted . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'application/pdf');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n return $this->failure('Could not extract text from the PDF. Please make sure it is a text-based PDF (not a scanned image), or convert it to Excel or CSV.');\n }\n\n // -- Word documents (docx) ---------------------------------------------------\n if (in_array($ext, ['docx', 'doc'], true)) {\n $text = $this->extractDocxText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not extract text from the Word document. Please convert it to Excel, CSV, or PDF.');\n }\n $text = $this->sanitizeUtf8($text);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS) {\n return $this->parseTextInChunks($text, 'BOQ Word document extracted text', $apiKey, $context);\n }\n\n $userContent = \"BOQ Word document extracted text:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── Images ──────────────────────────────────────────────────────────────\n // DeepSeek cannot receive images. A vision-capable AI processes the image\n // directly (no OCR) — configure GROQ_API_KEY or GEMINI_API_KEY in .env.\n $imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif', 'heic', 'heif'];\n if (in_array($ext, $imageExts, true)) {\n $bytes = (string) file_get_contents($absPath);\n return $this->callDeepSeekVision($bytes, $mime, $context);\n }\n\n return $this->failure('Unsupported file type. Please upload an Excel, CSV, PDF, or image file.');\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: DeepSeek fallback failed.', ['message' => $e->getMessage()]);\n return $this->failure('An error occurred while processing the file with AI: ' . $e->getMessage());\n }\n }\n\n private function deepSeekModel(): string\n {\n return (string) config('services.deepseek.model', 'deepseek-chat');\n }\n\n /**\n * Remove or replace any non-UTF-8 / malformed byte sequences so json_encode never fails.\n */\n private function sanitizeUtf8(string $text): string\n {\n // Replace invalid byte sequences with the UTF-8 replacement character\n $clean = mb_convert_encoding($text, 'UTF-8', 'UTF-8');\n // Strip any remaining non-printable control characters (except tab/newline/CR)\n $clean = (string) preg_replace('/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]/u', '', $clean);\n return $clean;\n }\n\n /**\n * Extract plain text from a Word docx file using ZipArchive (no external library needed).\n * Reads word/document.xml and strips XML tags, preserving paragraph and table structure.\n */\n private function extractDocxText(string $absPath): ?string\n {\n try {\n $zip = new \\ZipArchive();\n if ($zip->open($absPath) !== true) {\n return null;\n }\n\n $xml = $zip->getFromName('word/document.xml');\n $zip->close();\n\n if ($xml === false || $xml === '') {\n return null;\n }\n\n // Add newlines before paragraph/table-row/table-cell tags so structure is preserved\n $xml = preg_replace('/<w:p[ >]/', \"\\n<w:p>\", $xml) ?? $xml;\n $xml = preg_replace('/<w:tr[ >]/', \"\\n<w:tr>\", $xml) ?? $xml;\n $xml = preg_replace('/<\\/w:tc>/', \"\\t\", $xml) ?? $xml;\n\n $text = strip_tags($xml);\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n return trim($text) !== '' ? trim($text) : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: DOCX parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Extract plain text from a PDF using smalot/pdfparser (pure PHP, no binary required).\n */\n /**\n * The process memory limit in bytes, or 0 when unlimited.\n *\n * memory_limit is a shorthand string (\"128M\", \"1G\", \"-1\"), so it has to be\n * expanded before it can be compared against anything.\n */\n private function memoryLimitBytes(): int\n {\n $raw = trim((string) ini_get('memory_limit'));\n\n if ($raw === '' || $raw === '-1') {\n return 0;\n }\n\n $value = (int) $raw;\n\n return match (strtolower(substr($raw, -1))) {\n 'g' => $value * 1024 * 1024 * 1024,\n 'm' => $value * 1024 * 1024,\n 'k' => $value * 1024,\n default => $value,\n };\n }\n\n private function extractPdfText(string $absPath): ?string\n {\n // The parser loads and decompresses the whole document before any of our\n // chunking gets a look at it, and it does so with no memory ceiling of\n // its own. A big PDF exhausts the limit inside gzuncompress() — a fatal\n // error, not an exception, so the catch below cannot help and the worker\n // dies outright. Refuse the file first instead.\n $size = @filesize($absPath) ?: 0;\n\n if ($size > self::MAX_PDF_BYTES) {\n Log::warning('QuotationAiService: PDF too large to parse in-process.', [\n 'bytes' => $size,\n 'limit' => self::MAX_PDF_BYTES,\n ]);\n\n return null;\n }\n\n // Headroom check: parsing routinely needs several times the file size\n // once streams are decompressed. If that would not fit in what is left,\n // stop here rather than taking the worker down with us.\n $limit = $this->memoryLimitBytes();\n\n if ($limit > 0) {\n $available = $limit - memory_get_usage(true);\n\n if ($available < $size * self::PDF_MEMORY_FACTOR) {\n Log::warning('QuotationAiService: not enough memory left to parse this PDF.', [\n 'bytes' => $size,\n 'available' => $available,\n 'needed' => $size * self::PDF_MEMORY_FACTOR,\n ]);\n\n return null;\n }\n }\n\n try {\n $parser = new \\Smalot\\PdfParser\\Parser();\n $pdf = $parser->parseFile($absPath);\n\n // Recorded for the chunking decision: a long PDF is structurally a\n // big BOQ even when its extracted text is short.\n try {\n $this->lastPdfPageCount = count($pdf->getPages());\n } catch (\\Throwable) {\n $this->lastPdfPageCount = 0;\n }\n\n $text = $pdf->getText();\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n return $text !== '' ? $text : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: PDF parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Send a vision request to a vision-capable AI model.\n * DeepSeek's commercial API (api.deepseek.com) does NOT support image inputs — any model.\n * Priority order (first configured key wins):\n * 1. GROQ_API_KEY → Groq llama-4-scout (free: https://console.groq.com)\n * 2. GEMINI_API_KEY → Google Gemini Flash (free: https://aistudio.google.com/apikey)\n * 3. VISION_API_KEY → any OpenAI-compatible endpoint (OpenAI, OpenRouter …)\n */\n private function callDeepSeekVision(string $bytes, string $mime, array $context): array\n {\n // ── 1. Groq (free, reliable vision) ─────────────────────────────────────\n if ((string) config('services.groq.key', '') !== '') {\n $visionKey = (string) config('services.groq.key');\n $visionBaseUrl = 'https://api.groq.com/openai/v1';\n $visionModel = (string) config('services.groq.model', 'meta-llama/llama-4-scout-17b-16e-instruct');\n }\n // ── 2. Google Gemini ─────────────────────────────────────────────────────\n elseif ((string) config('services.gemini.key', '') !== '') {\n $visionKey = (string) config('services.gemini.key');\n $visionBaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai';\n $visionModel = (string) config('services.gemini.model', 'gemini-2.0-flash');\n }\n // ── 3. Generic VISION_API_KEY (OpenAI, OpenRouter, …) ───────────────────\n elseif ((string) config('services.vision.key', '') !== '') {\n $visionKey = (string) config('services.vision.key');\n $visionBaseUrl = rtrim((string) config('services.vision.base_url', 'https://openrouter.ai/api/v1'), '/');\n $visionModel = (string) config('services.vision.model', 'google/gemini-flash-1.5-8b');\n }\n else {\n return $this->failure(\n 'Image processing is not available. DeepSeek\\'s API does not support images. ' .\n 'Get a free key at https://console.groq.com and add GROQ_API_KEY to your .env file.'\n );\n }\n\n $dataUrl = \"data:{$mime};base64,\" . base64_encode($bytes);\n $prompt = $this->buildDeepSeekPrompt($context, $mime);\n\n $userContent = [\n ['type' => 'text', 'text' => $prompt],\n ['type' => 'image_url', 'image_url' => ['url' => $dataUrl]],\n ];\n\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders([\n 'Authorization' => 'Bearer ' . $visionKey,\n 'HTTP-Referer' => config('app.url', 'https://qimta.sa'),\n 'X-Title' => 'Qimta Platform',\n ])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post($visionBaseUrl . '/chat/completions', [\n 'model' => $visionModel,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 16384,\n 'temperature' => 0,\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('Vision API connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: Vision API HTTP error.', [\n 'model' => $visionModel,\n 'base_url' => $visionBaseUrl,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n return $this->failure(\"Vision API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n return $this->parseDeepSeekJsonResponse((string) $response->json('choices.0.message.content'));\n }\n\n private function callDeepSeekChat(string $userContent, string $key, string $model): array\n {\n // Ensure the PHP script outlives the HTTP call (+ a margin for parsing the\n // response and writing to the DB) so the worker/web request is not killed\n // mid-flight, which surfaces to users as a generic \"timeout or unavailable\".\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders(['Authorization' => 'Bearer ' . $key])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 32768,\n 'temperature' => 0,\n 'response_format' => ['type' => 'json_object'],\n 'user' => 'Qimta_Platform',\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('DeepSeek connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: DeepSeek HTTP error.', [\n 'model' => $model,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n if ($response->status() === 401) {\n return $this->failure('DeepSeek API key is invalid or unauthorised. Please update DEEPSEEK_API_KEY in .env.', false);\n }\n return $this->failure(\"DeepSeek API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n $text = (string) $response->json('choices.0.message.content');\n return $this->parseDeepSeekJsonResponse($text);\n }\n\n /**\n * Parse a document too large for one AI call by sending it in slices.\n *\n * A 20 000-row BOQ is several megabytes of text — far beyond what a single\n * request can carry, and beyond what the model can answer without hitting its\n * output token limit. Previously the text was simply cut at 180 000 chars and\n * the remainder discarded: the parse \"succeeded\" while silently dropping most\n * of the file. Now the text is split and each slice parsed on its own call,\n * with the items merged.\n *\n * Splitting happens on line boundaries so a BOQ row is never cut in half.\n * Slices run sequentially rather than pooled: DeepSeek rate-limits hard on\n * concurrent large prompts, and this already runs on the queue where wall\n * time is cheap.\n *\n * A slice that fails does not fail the document — its rows are lost, but the\n * rest are kept and the shortfall is logged. Total failure is reported only\n * when every slice fails.\n *\n * @param string $label How the payload is introduced to the model.\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n /**\n * Split a spreadsheet into the slices the AI will be asked to read.\n *\n * Exposed so the work can be spread over several queue jobs instead of one\n * job walking every slice in sequence. Returns an empty array when the file\n * is small enough to go in a single call, or cannot be read.\n *\n * @return array<int, string>\n */\n public function chunkSpreadsheet(string $absPath): array\n {\n ini_set('memory_limit', '2048M');\n set_time_limit(1800);\n\n $text = $this->spreadsheetToCompactCsvText($absPath);\n\n if ($text === null || trim($text) === '') {\n return [];\n }\n\n $text = $this->sanitizeUtf8($text);\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) <= self::TEXT_CHUNK_CHARS && $sheetCount <= self::MAX_SHEETS_PER_CALL) {\n return [];\n }\n\n $chunks = $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n\n // Free the source text: the slices are a second full copy of it, and on\n // a large workbook that doubling — on top of PhpSpreadsheet's grids —\n // is what exhausted memory here.\n unset($text);\n\n Log::info('QuotationAiService: chunked spreadsheet.', [\n 'chunks' => count($chunks),\n 'peak_mb' => round(memory_get_peak_usage(true) / 1048576),\n ]);\n\n return $chunks;\n }\n\n /**\n * Split a spreadsheet straight to disk, one file per slice.\n *\n * chunkSpreadsheet() returns every slice as an array, which is a second\n * full copy of the document in memory on top of PhpSpreadsheet's grids.\n * This hands each slice to $write and drops it immediately, so peak memory\n * is one slice rather than the whole file twice.\n *\n * @param callable(int, string): void $write ($part, $chunk)\n * @return int number of slices written; 0 when the file needs no split\n */\n public function chunkSpreadsheetToDisk(string $absPath, callable $write): int\n {\n ini_set('memory_limit', '2048M');\n set_time_limit(1800);\n\n $text = $this->spreadsheetToCompactCsvText($absPath);\n\n if ($text === null || trim($text) === '') {\n return 0;\n }\n\n $text = $this->sanitizeUtf8($text);\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) <= self::TEXT_CHUNK_CHARS && $sheetCount <= self::MAX_SHEETS_PER_CALL) {\n return 0;\n }\n\n $part = 0;\n foreach ($this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS) as $chunk) {\n $write(++$part, $chunk);\n }\n\n unset($text);\n\n Log::info('QuotationAiService: chunked spreadsheet to disk.', [\n 'chunks' => $part,\n 'peak_mb' => round(memory_get_peak_usage(true) / 1048576),\n ]);\n\n return $part;\n }\n\n /**\n * Parse one slice produced by chunkSpreadsheet().\n *\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n public function parseChunk(string $chunk, int $part, int $total, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured.');\n }\n\n // Same framing parseTextInChunks() uses, so a slice is never mistaken\n // for a complete BOQ and totals are not inferred from a fragment.\n $userContent = \"BOQ spreadsheet converted to CSV (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, 'text/plain');\n\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n private function parseTextInChunks(\n string $text,\n string $label,\n string $apiKey,\n array $context,\n string $mime = 'text/plain',\n ): array {\n $chunks = $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n $total = count($chunks);\n\n Log::info('QuotationAiService: large document split for parsing.', [\n 'chars' => mb_strlen($text),\n 'chunks' => $total,\n ]);\n\n // Announce the split before the first slice is sent, so the UI can show\n // how many parts there are instead of sitting on a generic message for\n // however long the first AI call takes.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)(0, $total);\n }\n\n $items = [];\n $rejected = [];\n $failed = 0;\n $lastError = null;\n\n foreach ($chunks as $index => $chunk) {\n $part = $index + 1;\n\n // Report progress so a 28-slice parse does not look frozen to the user.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)($part, $total);\n }\n\n // Tell the model this is a fragment, so it does not try to reconcile\n // totals or treat a mid-document slice as a complete BOQ.\n $userContent = \"{$label} (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, $mime);\n\n $result = $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n\n if (! ($result['success'] ?? false)) {\n $failed++;\n $lastError = $result['error'] ?? 'Chunk parsing failed.';\n Log::warning('QuotationAiService: chunk failed, continuing.', [\n 'part' => $part,\n 'of' => $total,\n 'error' => $lastError,\n ]);\n continue;\n }\n\n foreach ($result['items'] as $item) {\n $items[] = $item;\n }\n foreach ($result['rejected'] ?? [] as $row) {\n $rejected[] = $row;\n }\n\n // Hand this slice's rows straight to the caller so they can be shown\n // while the remaining slices are still being parsed.\n if ($this->onChunkItems !== null && $result['items'] !== []) {\n ($this->onChunkItems)($result['items'], $part, $total);\n }\n }\n\n // Every slice failed — there is nothing to show, so report the failure.\n if ($failed === $total) {\n return $this->failure($lastError ?? 'Could not parse the document.');\n }\n\n if ($failed > 0) {\n Log::error('QuotationAiService: document parsed with missing parts.', [\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n 'items_kept' => count($items),\n ]);\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'service_unavailable' => false,\n 'partial' => $failed > 0,\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n ];\n }\n\n /**\n * Split text into slices of at most $limit characters, breaking only at\n * newlines so no BOQ row is ever cut in half.\n *\n * A single line longer than the limit (rare — a pathological cell) is passed\n * through oversized rather than severed mid-row.\n *\n * @return array<int, string>\n */\n private function splitOnLineBoundaries(string $text, int $limit): array\n {\n $chunks = [];\n $current = '';\n\n // Sheet a chunk starts in the middle of. Repeated at the top of the next\n // chunk so the model always knows which sheet the rows belong to — the\n // header would otherwise be stranded in the previous slice, and rows\n // would arrive with no discipline context.\n $currentSheet = null;\n\n foreach (explode(\"\\n\", $text) as $line) {\n $isSheetHeader = str_starts_with($line, 'Sheet: ');\n\n // Start a new chunk at every sheet boundary. Sheets are usually\n // separate disciplines, and the whole point of splitting a multi-tab\n // workbook is to give the model one discipline at a time — a size-only\n // split would leave a compact 3-tab file as a single chunk, silently\n // undoing the sheet gate that routed it here.\n if ($isSheetHeader && $current !== '') {\n $chunks[] = $current;\n $current = $line;\n $currentSheet = $line;\n continue;\n }\n\n if ($isSheetHeader) {\n $currentSheet = $line;\n }\n\n $candidate = $current === '' ? $line : $current . \"\\n\" . $line;\n\n if (mb_strlen($candidate) > $limit && $current !== '') {\n $chunks[] = $current;\n\n // Carry the sheet header over, unless this line is itself one.\n $current = ($currentSheet !== null && ! str_starts_with($line, 'Sheet: '))\n ? $currentSheet . \" (continued)\\n\" . $line\n : $line;\n continue;\n }\n\n $current = $candidate;\n }\n\n if (trim($current) !== '') {\n $chunks[] = $current;\n }\n\n return $chunks;\n }\n\n /**\n * Shared JSON response parser used by both chat and vision paths.\n */\n private function parseDeepSeekJsonResponse(string $text): array\n {\n if ($text === '') {\n return $this->failure('DeepSeek returned an empty response.');\n }\n\n // Strip markdown code fences if the model wrapped the JSON.\n if (preg_match('/```(?:json)?\\s*([\\s\\S]*?)\\s*```/i', $text, $m)) {\n $text = $m[1];\n }\n // Narrow to the outermost JSON object/array.\n if (preg_match('/[\\{\\[][\\s\\S]*[\\}\\]]/s', $text, $m)) {\n $text = $m[0];\n }\n $text = trim($text);\n\n $decoded = json_decode($text, true);\n\n // The response can be truncated when the model hits the token limit,\n // leaving an unterminated JSON string/object. Try to repair it before\n // giving up so we can still salvage the items extracted so far.\n if (! is_array($decoded)) {\n $repaired = $this->repairTruncatedJson($text);\n if ($repaired !== null) {\n $decoded = json_decode($repaired, true);\n }\n }\n\n if (! is_array($decoded)) {\n Log::error('QuotationAiService: DeepSeek non-JSON response.', ['text' => mb_substr($text, 0, 1000)]);\n return $this->failure('DeepSeek returned a non-JSON response. The file may be too large — try splitting it into smaller files.');\n }\n\n $rawItems = $decoded['items'] ?? (array_is_list($decoded) ? $decoded : []);\n $items = [];\n $rejected = [];\n\n foreach ($rawItems as $raw) {\n if (! is_array($raw)) {\n continue;\n }\n $item = $this->normaliseAiItem($raw);\n if ($item === null) {\n continue;\n }\n $check = $this->boqCleaner->filterItem((string) $item['description']);\n if ($check['keep'] ?? false) {\n $items[] = $item;\n } else {\n $item['status'] = 'rejected';\n $item['raw_data']['rejection_reason'] = $check['rejection_reason'] ?? 'Rejected by cleaner';\n $rejected[] = $item;\n }\n }\n\n if (empty($items)) {\n Log::info('QuotationAiService: DeepSeek returned 0 accepted items.', [\n 'raw_items_count' => count($rawItems),\n 'rejected_count' => count($rejected),\n 'first_rejected' => ! empty($rejected) ? ($rejected[0]['description'] ?? '') : '',\n ]);\n }\n\n return ['success' => true, 'items' => $items, 'rejected' => $rejected, 'error' => null];\n }\n\n /**\n * Best-effort repair of a truncated JSON string produced when the model hits\n * its token limit mid-output. Drops any incomplete trailing object, then\n * closes the open string/array/object brackets so the result is valid JSON.\n *\n * Returns the repaired JSON string, or null if it cannot be salvaged.\n */\n private function repairTruncatedJson(string $text): ?string\n {\n if ($text === '') {\n return null;\n }\n\n // Walk the string tracking structural state, ignoring everything inside\n // string literals (and their escapes). Remember the last position where\n // we were back at the top of the \"items\" array between complete elements,\n // so we can cut a half-written trailing object.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($text);\n $lastSafeCut = null; // index just after a completed array element\n\n for ($i = 0; $i < $len; $i++) {\n $ch = $text[$i];\n\n if ($inString) {\n if ($escaped) {\n $escaped = false;\n } elseif ($ch === '\\\\') {\n $escaped = true;\n } elseif ($ch === '\"') {\n $inString = false;\n }\n continue;\n }\n\n if ($ch === '\"') {\n $inString = true;\n } elseif ($ch === '{' || $ch === '[') {\n $stack[] = $ch;\n } elseif ($ch === '}' || $ch === ']') {\n array_pop($stack);\n } elseif ($ch === ',' && ! empty($stack) && end($stack) === '[') {\n // Separator directly inside an array: a clean boundary between\n // elements (the items array, whatever its nesting depth).\n $lastSafeCut = $i;\n }\n }\n\n // If we are still inside a string, the last element is incomplete — fall\n // back to the last clean array boundary if we have one.\n $candidate = $text;\n if ($inString && $lastSafeCut !== null) {\n $candidate = substr($text, 0, $lastSafeCut);\n } else {\n // Drop a dangling partial object after the last completed element.\n $candidate = rtrim($candidate);\n if ($lastSafeCut !== null && substr(rtrim($candidate), -1) !== '}' && substr(rtrim($candidate), -1) !== ']') {\n $candidate = substr($text, 0, $lastSafeCut);\n }\n }\n\n // Recompute the open brackets for the (possibly trimmed) candidate and\n // append the matching closers in reverse order.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($candidate);\n for ($i = 0; $i < $len; $i++) {\n $ch = $candidate[$i];\n if ($inString) {\n if ($escaped) { $escaped = false; }\n elseif ($ch === '\\\\') { $escaped = true; }\n elseif ($ch === '\"') { $inString = false; }\n continue;\n }\n if ($ch === '\"') { $inString = true; }\n elseif ($ch === '{' || $ch === '[') { $stack[] = $ch; }\n elseif ($ch === '}' || $ch === ']') { array_pop($stack); }\n }\n\n $candidate = rtrim($candidate);\n // Remove a trailing comma left after cutting an element.\n $candidate = rtrim($candidate, ',');\n\n $closers = '';\n for ($i = count($stack) - 1; $i >= 0; $i--) {\n $closers .= ($stack[$i] === '{') ? '}' : ']';\n }\n\n $repaired = $candidate . $closers;\n\n // Validate before returning.\n return is_array(json_decode($repaired, true)) ? $repaired : null;\n }\n\n private function spreadsheetToCompactCsvText(string $absPath): ?string\n {\n $sheets = $this->spreadsheetToGrids($absPath);\n if (empty($sheets)) {\n return null;\n }\n\n $output = '';\n $kept = 0;\n $dropped = 0;\n $nonSupply = 0;\n $duplicates = 0;\n\n /** @var array<string, true> Fingerprints of rows already emitted. */\n $seenRows = [];\n\n foreach ($sheets as $sheet) {\n // Don't feed summary / cost-abstract sheets to the AI either.\n if ($this->isSummarySheet($sheet['name'])) {\n continue;\n }\n $sheetRows = '';\n foreach ($sheet['grid'] as $row) {\n $row = $this->trimTrailingEmptyCells($row);\n\n if ($this->isNoiseRow($row)) {\n $dropped++;\n continue;\n }\n\n // Drop rows that are definitely not procurable before they cost\n // a single token: section totals, discipline headings,\n // preliminaries, labour-only lines. The same classifier already\n // runs on the AI's output, but by then the tokens are spent — on\n // an 11 200-row BOQ that is most of the bill.\n if ($this->isNonSupplyRow($row)) {\n $dropped++;\n $nonSupply++;\n continue;\n }\n\n // Byte-identical rows tell the AI nothing new. Only exact\n // duplicates are collapsed — description, quantity, unit and all\n // — so nothing is lost: two rows differing in quantity are two\n // different line items and both are sent.\n $fingerprint = $this->rowFingerprint($row);\n\n if (isset($seenRows[$fingerprint])) {\n $duplicates++;\n $dropped++;\n continue;\n }\n $seenRows[$fingerprint] = true;\n\n $sheetRows .= implode(',', array_map([$this, 'csvEscape'], $this->stripBoilerplate($row))) . \"\\n\";\n $kept++;\n }\n\n // A sheet whose rows were all noise contributes nothing but its own\n // header, which would just be another thing for the model to explain.\n if ($sheetRows === '') {\n continue;\n }\n\n $output .= 'Sheet: ' . $sheet['name'] . \"\\n\" . $sheetRows . \"\\n\";\n }\n\n $blank = $dropped - $nonSupply - $duplicates;\n\n Log::info('QuotationAiService: filtered rows before sending to AI.', [\n 'kept' => $kept,\n 'blank' => $blank,\n 'non_supply' => $nonSupply,\n 'duplicates' => $duplicates,\n ]);\n\n if ($dropped > 0) {\n $parts = [];\n if ($blank > 0) { $parts[] = \"{$blank} blank\"; }\n if ($nonSupply > 0) { $parts[] = \"{$nonSupply} non-supply\"; }\n if ($duplicates > 0) { $parts[] = \"{$duplicates} duplicate\"; }\n\n $this->reportStage('Skipped ' . implode(', ', $parts) . \" rows. Sending {$kept}…\");\n }\n\n return $output;\n }\n\n /**\n * True when a row carries no information worth sending to the AI.\n *\n * A real BOQ is mostly formatting: spacer rows, stray borders, a lone\n * section number, a leftover cell from a merged block. They survive\n * trimTrailingEmptyCells() because they are not *entirely* empty, then cost\n * tokens, dilute the prompt, and inflate the number of parts a large file is\n * split into.\n *\n * Deliberately conservative — a row is dropped only when nothing in it could\n * name a product. Anything with letters is kept, because a description is\n * the one field an item cannot do without.\n *\n * @param array<int, mixed> $row\n */\n /**\n * True when a row is definitely not a procurable supply item.\n *\n * Runs BoqCleaningService over the row's longest text cell — the one most\n * likely to be the description — so headings, totals, preliminaries and\n * labour-only lines never reach the AI. The same classifier already vets the\n * AI's *output*; doing it here as well means we stop paying to have those\n * rows read in the first place.\n *\n * Conservative by construction: only rows the classifier positively rejects\n * are dropped. Anything it is unsure about goes to the AI, which is the\n * better judge — a wrongly dropped row is invisible, a wrongly kept one only\n * costs tokens.\n *\n * @param array<int, mixed> $row\n */\n /**\n * Stable identity for a row, used to collapse exact duplicates.\n *\n * Whitespace and case are normalised so \"DN50 PIPE\" and \"dn50 pipe \" are one\n * row, but nothing else is: a differing quantity or unit produces a\n * different fingerprint and both rows are sent.\n *\n * @param array<int, mixed> $row\n */\n private function rowFingerprint(array $row): string\n {\n $parts = array_map(\n fn($cell) => mb_strtolower(preg_replace('/\\s+/u', ' ', trim((string) $cell)) ?? ''),\n $row,\n );\n\n // Drop a leading BOQ sequence number. Otherwise \"1,Pipe,m,100\" and\n // \"2,Pipe,m,100\" fingerprint differently and the dedup never fires —\n // which is exactly the shape every real BOQ has.\n if (isset($parts[0]) && $parts[0] !== '' && preg_match('/^[\\d.]+$/', $parts[0])) {\n array_shift($parts);\n }\n\n return md5(implode('|', $parts));\n }\n\n /**\n * Strip contractual boilerplate from a row's description cell.\n *\n * \"as per drawings\", \"complete with all necessary accessories\" and friends\n * repeat on nearly every line of a real BOQ and carry no information the\n * pricing engine can use — but they are a large share of the tokens.\n *\n * Only the longest text cell is touched, and only when the result is still a\n * usable description: if stripping would leave too little to identify the\n * product, the original is kept. A shorter-but-wrong description is far\n * worse than a verbose one.\n *\n * @param array<int, mixed> $row\n * @return array<int, mixed>\n */\n private function stripBoilerplate(array $row): array\n {\n $index = null;\n $longest = '';\n\n foreach ($row as $i => $cell) {\n $text = trim((string) $cell);\n if (preg_match('/\\p{L}/u', $text) && mb_strlen($text) > mb_strlen($longest)) {\n $longest = $text;\n $index = $i;\n }\n }\n\n // Nothing worth cleaning, or too short to risk it.\n if ($index === null || mb_strlen($longest) < 40) {\n return $row;\n }\n\n try {\n $cleaned = $this->boqCleaner->cleanDescription($longest);\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: boilerplate strip failed, keeping row as-is.', [\n 'error' => $e->getMessage(),\n ]);\n return $row;\n }\n\n // Guard against an over-eager pattern eating the product name: keep the\n // original unless a recognisable description survives.\n if (mb_strlen($cleaned) < 12 || mb_strlen($cleaned) < mb_strlen($longest) * 0.3) {\n return $row;\n }\n\n $row[$index] = $cleaned;\n\n return $row;\n }\n\n private function isNonSupplyRow(array $row): bool\n {\n $description = '';\n foreach ($row as $cell) {\n $text = trim((string) $cell);\n // Longest cell containing letters — numbers are qty/rate columns.\n if (preg_match('/\\p{L}/u', $text) && mb_strlen($text) > mb_strlen($description)) {\n $description = $text;\n }\n }\n\n // No text at all: leave it to isNoiseRow()/the AI rather than guessing.\n if ($description === '' || mb_strlen($description) < 3) {\n return false;\n }\n\n try {\n $verdict = $this->boqCleaner->filterItem($description);\n } catch (\\Throwable $e) {\n // A classifier failure must never silently drop a real product.\n Log::warning('QuotationAiService: pre-filter classifier failed, keeping row.', [\n 'error' => $e->getMessage(),\n ]);\n return false;\n }\n\n return ! ($verdict['keep'] ?? true);\n }\n\n private function isNoiseRow(array $row): bool\n {\n if (empty($row)) {\n return true;\n }\n\n $joined = trim(implode('', array_map(fn($c) => trim((string) $c), $row)));\n\n // Nothing but separators/whitespace.\n if ($joined === '') {\n return true;\n }\n\n // Ruled lines and decorative fills (\"-----\", \"====\", \"___\").\n if (preg_match('/^[\\-_=.*\\s|]+$/u', $joined)) {\n return true;\n }\n\n // Any letter — Latin or Arabic — means this could be a description.\n if (preg_match('/\\p{L}/u', $joined)) {\n return false;\n }\n\n // No letters at all: numbers only. A single number is a stray row\n // number or a dangling total; two or more could be qty/rate/amount\n // belonging to a real line, so keep those for the AI to judge.\n $numericCells = 0;\n foreach ($row as $cell) {\n if (trim((string) $cell) !== '') {\n $numericCells++;\n }\n }\n\n return $numericCells <= 1;\n }\n\n /**\n * Safely convert a PhpSpreadsheet cell value to a string.\n *\n * Cells may contain RichText or, in malformed/heavily-formatted files,\n * embedded objects such as Worksheet\\Drawing that have no __toString().\n * Casting those directly throws \"Object ... could not be converted to string\".\n */\n private function cellValueToString(mixed $value): string\n {\n if ($value === null) {\n return '';\n }\n\n if ($value instanceof \\PhpOffice\\PhpSpreadsheet\\RichText\\RichText) {\n return $value->getPlainText();\n }\n\n if (is_object($value)) {\n // Only stringify objects that explicitly support it; ignore the rest\n // (e.g. Worksheet\\Drawing) instead of crashing.\n return method_exists($value, '__toString') ? (string) $value : '';\n }\n\n if (is_array($value)) {\n return '';\n }\n\n return (string) $value;\n }\n\n private function csvEscape(mixed $value): string\n {\n $value = $this->cellValueToString($value);\n return str_contains($value, ',') || str_contains($value, '\"') || str_contains($value, \"\\n\")\n ? '\"' . str_replace('\"', '\"\"', $value) . '\"'\n : $value;\n }\n\n private function verifyLargeBoqItemsWithAi(array $result): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n Log::warning('QuotationAiService: large BOQ verification skipped, AI key is not configured.');\n return $result;\n }\n\n $items = array_values(array_filter($result['items'] ?? [], 'is_array'));\n if (count($items) <= self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n return $result;\n }\n\n Log::info('QuotationAiService: starting second-pass physical item verification.', [\n 'items_count' => count($items),\n 'threshold' => self::LARGE_BOQ_VERIFICATION_THRESHOLD,\n ]);\n\n $verifiedItems = [];\n $rejectedItems = $result['rejected'] ?? [];\n\n foreach (array_chunk($items, self::LARGE_BOQ_VERIFICATION_CHUNK_SIZE) as $chunkIndex => $chunk) {\n $payload = array_map(fn(array $item) => [\n 'description' => (string) ($item['description'] ?? ''),\n 'quantity' => $item['quantity'] ?? null,\n 'unit' => (string) ($item['unit'] ?? ''),\n 'unit_price' => $item['unit_price'] ?? null,\n 'category' => (string) ($item['category'] ?? ''),\n 'brand' => (string) ($item['brand'] ?? ''),\n ], $chunk);\n\n $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);\n if (! is_string($json)) {\n Log::warning('QuotationAiService: large BOQ verification chunk JSON encode failed.', [\n 'chunk' => $chunkIndex + 1,\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n $prompt = <<<PROMPT\nYou are doing a SECOND PASS verification on a BOQ extraction result.\n\nThe first pass may hallucinate non-products when the BOQ has thousands of rows.\nYour job is to keep ONLY physical, tangible, procurable supply products.\n\nRules:\n- Keep items only if a supplier can actually deliver them as materials/equipment.\n- Reject headings, trade names, systems, scopes, prose, specifications-only rows, totals, labor, installation, testing, commissioning, supervision, drawings, insurance, preliminaries, mobilisation, site works, and vague fragments.\n- Do not invent new products.\n- Do not add items that are not in the input.\n- You may clean a product description, but keep the original quantity, unit, unit_price, category, and brand when available.\n\nInput JSON array:\n{$json}\n\nReturn ONLY valid JSON in this shape:\n{\"items\":[{\"description\":\"\",\"quantity\":1,\"unit\":\"\",\"unit_price\":null,\"category\":\"\",\"brand\":\"\",\"confidence\":0.95}]}\nPROMPT;\n\n try {\n $chunkResult = $this->callDeepSeekChat($prompt, $apiKey, $this->deepSeekModel());\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: large BOQ verification chunk threw an exception.', [\n 'chunk' => $chunkIndex + 1,\n 'message' => $e->getMessage(),\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n if ($chunkResult['success'] ?? false) {\n array_push($verifiedItems, ...($chunkResult['items'] ?? []));\n $rejectedItems = array_merge($rejectedItems, $chunkResult['rejected'] ?? []);\n continue;\n }\n\n Log::warning('QuotationAiService: large BOQ verification chunk failed, keeping original chunk.', [\n 'chunk' => $chunkIndex + 1,\n 'error' => $chunkResult['error'] ?? null,\n ]);\n array_push($verifiedItems, ...$chunk);\n }\n\n if (empty($verifiedItems)) {\n Log::warning('QuotationAiService: second-pass verification returned no items, keeping first-pass result.', [\n 'original_items_count' => count($items),\n ]);\n return $result;\n }\n\n $result['items'] = $verifiedItems;\n $result['rejected'] = $rejectedItems;\n $result['large_boq_verified'] = true;\n\n Log::info('QuotationAiService: second-pass physical item verification completed.', [\n 'before' => count($items),\n 'after' => count($verifiedItems),\n ]);\n\n return $result;\n }\n\n private function normaliseAiItem(array $raw): ?array\n {\n if (! empty($raw['rejected'])) {\n return null;\n }\n\n $description = trim((string) ($raw['cleaned_description'] ?? $raw['product_name'] ?? $raw['description'] ?? ''));\n if ($description === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $description)) {\n return null;\n }\n\n $quantity = is_numeric($raw['quantity'] ?? null) ? (float) $raw['quantity'] : 1;\n $unitPrice = is_numeric($raw['unit_price'] ?? null) ? (float) $raw['unit_price'] : null;\n $totalPrice = is_numeric($raw['total_price'] ?? null) ? (float) $raw['total_price'] : null;\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $specs = is_array($raw['specifications'] ?? null) ? $raw['specifications'] : [];\n\n return [\n 'description' => $description,\n 'quantity' => $quantity,\n 'unit' => (string) ($raw['unit'] ?? ''),\n 'category' => (string) ($raw['core_product'] ?? $raw['category'] ?? ''),\n 'brand' => (string) ($specs['brand'] ?? $raw['brand'] ?? ''),\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($description),\n 'unit_price' => $unitPrice,\n 'confidence' => is_numeric($raw['confidence'] ?? null) ? (float) $raw['confidence'] : null,\n 'needs_review' => ! empty($raw['needs_review']),\n 'raw_data' => [\n 'source_item_no' => (string) ($raw['source_item_no'] ?? ''),\n 'original_description' => (string) ($raw['original_description'] ?? $description),\n 'cleaned_description' => $description,\n 'core_product' => (string) ($raw['core_product'] ?? ''),\n 'specifications' => $specs,\n 'extraction_type' => (string) ($raw['extraction_type'] ?? 'supply_only'),\n 'note' => (string) ($raw['note'] ?? $raw['notes'] ?? ''),\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function buildSystemInstruction(): string\n {\n return <<<'TXT'\nYou are a strict BOQ Supply Product Extraction Engine.\nExtract ONLY concrete, tangible, physical products that a supplier can box up and deliver to site — a specific item you could point at and say \"this is the thing being bought\" (e.g. \"Wall mounted LED luminaire 36W\", \"PVC pipe 110mm\", \"Single phase distribution board 12-way\", \"Fire extinguisher CO2 6kg\", \"CCTV dome camera 4MP\").\n\nABSOLUTELY REJECT abstract concepts, disciplines, categories, section names, and trade headings. These are NOT products even though they appear as rows:\n- Bare discipline/trade words: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", \"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\".\n- Summary / total / abstract / \"dry cost\" rows of any kind.\n- Anything that names a system, scope, or category instead of one buyable item.\n\nRule of thumb: if you cannot name a unit and physically hand the item over (a piece, a box, a length, a unit of equipment), REJECT it. A word that describes a field of work, a system, or a section of the BOQ is NEVER a product.\nReject totals, headings, general requirements, installation-only, testing, commissioning, supervision, mobilization, civil works, labor, and vague rows.\nFor Supply & Install rows, keep only the supply product and remove install/labor wording.\nReturn only valid JSON: {\"items\":[{\"source_item_no\":null,\"original_description\":\"\",\"cleaned_description\":\"\",\"core_product\":\"\",\"specifications\":{},\"quantity\":0,\"unit\":\"\",\"unit_price\":0,\"total_price\":0,\"extraction_type\":\"supply_only\",\"needs_review\":false,\"note\":\"\",\"rejected\":false,\"rejection_reason\":null,\"confidence\":0.95}]}\nTXT;\n }\n\n private function buildDeepSeekPrompt(array $context = [], string $mime = ''): string\n {\n $projectName = (string) ($context['project_name'] ?? '');\n $projectStatus = (string) ($context['project_status'] ?? '');\n $contextLine = $projectName !== ''\n ? \"Project: {$projectName}\" . ($projectStatus !== '' ? \" ({$projectStatus})\" : '')\n : '';\n $sourceHint = str_starts_with($mime, 'image/')\n ? 'The input is an image of a BOQ table. OCR every cell carefully — read the quantity/count column (العدد, الكمية, Qty) and extract the EXACT numeric value from each row.'\n : 'The input is a BOQ document/spreadsheet.';\n\n return trim(<<<PROMPT\nYou are a procurement specialist. Your task is to extract ONLY tangible, physical supply products from this Bill of Quantities (BOQ).\n\n{$contextLine}\n{$sourceHint}\n\nCRITICAL — MERGE WRAPPED / SPLIT ROWS INTO ONE ITEM:\nBOQ tables extracted from PDF often break ONE item's long description across several\nlines/rows. These continuation lines repeat the SAME quantity, unit and category, and\ntheir text is a grammatical continuation of the previous line (it starts mid-sentence,\nlowercase, or with \"the/and/-\", or is a spec/instruction clause). You MUST reconstruct\nthe original single item:\n- Treat consecutive rows that share the SAME quantity + unit + category as ONE product\n when the later rows are clearly continuation text of the first, NOT new products.\n- Emit ONE item with the merged description and the price counted ONCE. Do NOT repeat\n the same quantity×price for every wrapped line — that multiplies the total wrongly.\n- Example: rows \"The asphalt flooring is to be supplied\" / \"installed in accordance\" /\n \"standard road specifications...\" all with 7,100 m2 are ONE asphalt item, not 3+.\n\nCRITICAL — UNDERSTAND INTENT, DO NOT COPY ROWS LITERALLY:\nYour job is to output WHAT A SUPPLIER MUST ACTUALLY DELIVER, not a copy of the row text.\nRead each description for MEANING and decide how many real, separately-buyable products it\nimplies. One BOQ row can imply SEVERAL products, or a spread of rows can imply ONE product.\n- SPLIT a row into multiple items when it lists distinct products that a supplier would\n buy/price separately — even when joined by commas, \"with\", \"and\", \"including\", \"c/w\",\n \"consisting of\", \"comprising\", or \":\" then a list.\n Example: \"Fire pump set consisting of: fire pump, electric motor, control panel, diesel\n engine\" → FOUR items: (1) fire pump, (2) electric motor, (3) control panel, (4) diesel\n engine — each with its own quantity and priced on its own.\n Example: \"Wash basin complete with mixer tap and bottle trap\" → THREE items: wash basin,\n mixer tap, bottle trap.\n- Give each split product the correct quantity implied by the text (usually the parent\n quantity, unless the text states a different count for a sub-item). Each product is\n priced individually; the line total becomes the SUM of its products.\n- DO NOT split when the extra words are just specifications, dimensions, colours, ratings,\n standards, or a single product's integral parts. \"Steel door 900x2100 with vision panel\n and SS ironmongery\" is usually ONE door product — vision panel and ironmongery are part\n of that door, not separately-bought items. Use judgement: would a supplier issue a\n separate price line for it? If no, keep it as one.\n- Balanced approach: split only when genuinely separate products are present; when in doubt\n about whether something is a separate product vs. an accessory/spec of the main one, keep\n it as a single item. Do not invent products that are not in the text.\n\nSTRICT RULES — include ONLY items that are ALL of the following:\n1. A specific, concrete, tangible product that can be boxed up by a supplier and delivered to site — something you can point at and say \"this exact thing is being bought\" (e.g. \"LED luminaire 36W\", \"PVC pipe 110mm\", \"Distribution board 12-way\", \"CO2 fire extinguisher 6kg\").\n2. Has a real quantity (not 0 or \"varies\" with no meaning).\n3. Has enough description to identify what the product is.\n4. Is an actual PRODUCT, not a sentence, instruction, or specification clause. REJECT rows that are prose such as \"the supervising engineer's instructions\", \"The price includes all materials, labor\", \"Measurement will be based on the geometric cubic meter\", \"necessary steps to complete the work\", \"soil-contacting elements\", \"in compliance\", \"everything necessary\". These are description fragments, not buyable items.\n\nNEVER extract abstract concepts, disciplines, trades, systems, or section/category names — they are NOT products even when they appear as their own row:\n- Bare discipline/trade/system words such as: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", and their Arabic equivalents (\"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\", \"تشطيبات\").\n- Test: if the word names a FIELD OF WORK or a SYSTEM rather than one buyable item with a unit (pcs, m, set, kg…), REJECT it.\n\nEXCLUDE all of the following (do NOT include them):\n- Labor, installation, or workmanship items (e.g. \"install\", \"fix\", \"erect\", \"dismantle\", \"painting\", \"cleaning service\")\n- Project management, overhead, or profit items\n- Insurance, bonds, liability items (e.g. \"bodily injury\", \"third party liability\", \"CAR insurance\")\n- Shop drawings, as-built drawings, method statements, documentation\n- Preliminary items, mobilisation, demobilisation, provisional sums\n- Housekeeping, site clearance, waste removal\n- Floor/level labels (e.g. \"GF Floor\", \"Level 3\") — these are headers, not products\n- Pure dimension specs without a product noun (e.g. \"Dia 32 mm\", \"25 mm\") — reject unless a product name is present\n- Security services, night shifts, rentals, payments\n- Any item whose description is too vague or fragmentary to identify a specific product\n\nEmit ONE JSON item PER real product. A single source row may therefore produce several\nitems (when it lists multiple products) or several rows may collapse into one item (when\nthey are wrapped fragments of the same product). The \"items\" array is your final delivery\nlist, not a row-by-row transcript.\n\nFor each valid physical product return JSON with:\n- description: clean product name only (strip \"supply and install\", \"supply and fix\", installation clauses, floor/spec suffixes). For a split product, use just that product's own name.\n- quantity: the EXACT numeric value from the quantity/count column (العدد, الكمية, Qty, Quantity, No.). NEVER default to 1 unless the quantity column is truly absent or empty.\n- unit: unit of measure (pcs, m, m2, m3, set, no, kg, etc.)\n- category: product category\n- brand: brand/manufacturer if mentioned, else \"\"\n- engineering_required: true if the item needs engineering design before procurement (panel boards, chillers, generators, fire suppression systems, BMS, pumps, AHUs, switchgear, transformers), else false\n\nReturn ONLY a valid JSON object: {\"items\": [...]}. No explanations, no markdown, no extra text.\nPROMPT);\n }\n\n private function mockResponse(): array\n {\n return [\n 'success' => true,\n 'error' => null,\n 'items' => [[\n 'description' => 'Sample BOQ Item',\n 'quantity' => 1,\n 'unit' => 'No',\n 'category' => 'Sample',\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 1,\n 'raw_data' => [],\n 'ai_extracted' => true,\n ]],\n 'rejected' => [],\n ];\n }\n\n private function failure(string $message, bool $serviceUnavailable = false): array\n {\n return [\n 'success' => false,\n 'items' => [],\n 'rejected' => [],\n 'error' => $this->userFacingError($message, $serviceUnavailable),\n 'service_unavailable' => $serviceUnavailable,\n ];\n }\n\n /**\n * Translate any internal error into a neutral, user-safe message.\n *\n * The user must never see the name of the underlying AI/processing engine\n * (DeepSeek, Groq, Gemini, Vision, etc.) or configuration hints like API\n * keys. The detailed message is kept in the logs for debugging only.\n */\n private function userFacingError(string $message, bool $serviceUnavailable = false): string\n {\n // Detect any reference to an AI provider / engine / API config so we can\n // swap it for a generic, branded-neutral message.\n $sensitive = '/deepseek|groq|gemini|vision|openai|openrouter|api[\\s_-]?key|\\.env|non-?json|token limit/i';\n\n if (preg_match($sensitive, $message)) {\n Log::warning('QuotationAiService: suppressed technical error from user.', ['internal' => $message]);\n\n return $serviceUnavailable\n ? 'The file could not be processed right now. Please try again in a moment.'\n : 'We could not read this file automatically. Please make sure it is a clear BOQ (Excel, CSV, PDF, or image) and try again, or add the items manually.';\n }\n\n return $message;\n }\n\n private function mimeForExtension(string $ext): string\n {\n return match (strtolower($ext)) {\n 'pdf' => 'application/pdf',\n 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n 'xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12',\n 'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',\n 'xls' => 'application/vnd.ms-excel',\n 'csv' => 'text/csv',\n 'jpg', 'jpeg' => 'image/jpeg',\n 'png' => 'image/png',\n 'gif' => 'image/gif',\n 'bmp' => 'image/bmp',\n 'webp' => 'image/webp',\n 'tiff', 'tif' => 'image/tiff',\n 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'doc' => 'application/msword',\n default => 'application/octet-stream',\n };\n }\n}\n"}, "fc70438baa6c12056bb832b607cd810344f72c3b": {"t": 1785748359.4702814, "n": 90996, "h": "7c23871a81273d01", "c": "<?php\n\nnamespace App\\Services;\n\nuse Illuminate\\Http\\Client\\ConnectionException;\nuse Illuminate\\Http\\UploadedFile;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\nuse PhpOffice\\PhpSpreadsheet\\Cell\\Coordinate;\nuse PhpOffice\\PhpSpreadsheet\\IOFactory;\n\nclass QuotationAiService\n{\n /** Increment this whenever BOQ parsing logic changes to invalidate old caches. */\n private const PARSER_VERSION = 10;\n private const LARGE_BOQ_VERIFICATION_THRESHOLD = 2000;\n private const LARGE_BOQ_VERIFICATION_CHUNK_SIZE = 120;\n\n /**\n * Characters of document text sent per AI call.\n *\n * Above this the document is split and parsed over several calls. Sized well\n * under the model's context so the prompt, the slice, and a full JSON answer\n * for every row in it all fit — the binding limit is the *output* budget, not\n * the input, since each input row produces a JSON object.\n */\n private const TEXT_CHUNK_CHARS = 60000;\n\n /**\n * A workbook with more sheets than this is split regardless of its size.\n *\n * Multiple tabs almost always mean multiple disciplines (electrical, HVAC,\n * civil…). Sending them in one call makes the model summarise across\n * sections and silently drop rows, so each slice gets a narrower scope.\n */\n private const MAX_SHEETS_PER_CALL = 2;\n\n /** A PDF longer than this is split regardless of its extracted text length. */\n private const MAX_PDF_PAGES_PER_CALL = 5;\n\n /**\n * Largest PDF we will hand to the parser (12 MB).\n *\n * smalot/pdfparser decompresses every stream into memory with no ceiling of\n * its own, and overrunning the limit there is a fatal error that kills the\n * worker rather than an exception we can recover from.\n */\n private const MAX_PDF_BYTES = 12582912;\n\n /**\n * Memory the parser is assumed to need, as a multiple of the file size.\n *\n * Decompressed content streams plus the object graph run well past the raw\n * bytes; 8x is conservative enough to catch the cases that were dying.\n */\n private const PDF_MEMORY_FACTOR = 8;\n\n private string $baseUrl;\n private string $parseEndpoint;\n private string $apiKey;\n private int $timeout;\n private bool $testMode;\n private BoqCleaningService $boqCleaner;\n\n /** Page count of the most recently parsed PDF; 0 when unknown. */\n private int $lastPdfPageCount = 0;\n\n /**\n * Optional progress reporter for chunked parsing, called as ($part, $total).\n *\n * Set by the queue job so the polling UI can show real progress on a large\n * BOQ instead of a spinner that appears frozen for many minutes.\n *\n * @var (callable(int, int): void)|null\n */\n private $onChunkProgress = null;\n\n /** @param callable(int, int): void $callback */\n public function onChunkProgress(callable $callback): self\n {\n $this->onChunkProgress = $callback;\n return $this;\n }\n\n /**\n * Optional per-chunk item sink, called as ($items, $part, $total).\n *\n * Lets the caller persist each slice's rows the moment they arrive instead\n * of waiting for the whole document. On a large BOQ this is the difference\n * between a table that fills in progressively and one that stays empty for\n * many minutes.\n *\n * @var (callable(array, int, int): void)|null\n */\n private $onChunkItems = null;\n\n /** @param callable(array, int, int): void $callback */\n public function onChunkItems(callable $callback): self\n {\n $this->onChunkItems = $callback;\n return $this;\n }\n\n /**\n * Report a non-chunk stage (local parse, handing off to AI) to the caller.\n *\n * A spreadsheet is read locally first and only sent to the AI when that\n * fails, so a large file can spend minutes before any chunk exists. Without\n * these the UI shows a generic spinner for that whole period.\n */\n private function reportStage(string $message): void\n {\n if ($this->onStage !== null) {\n ($this->onStage)($message);\n }\n }\n\n /**\n * Report a workbook's shape from grids that are already in memory.\n *\n * Deliberately takes the parsed grids rather than a path: re-reading a large\n * spreadsheet just to count its sheets would double the slowest step of the\n * whole extraction.\n *\n * @param array<int, array{name: string, grid: array}> $sheets\n */\n private function reportWorkbookShape(array $sheets): void\n {\n if ($this->onStage === null || empty($sheets)) {\n return;\n }\n\n $rows = 0;\n foreach ($sheets as $sheet) {\n $rows += count($sheet['grid']);\n }\n\n $count = count($sheets);\n\n $this->reportStage($count > 1\n ? \"Found {$count} sheets, {$rows} rows in total. Reading…\"\n : \"Found {$rows} rows. Reading…\");\n }\n\n /** @var (callable(string): void)|null */\n private $onStage = null;\n\n /** @param callable(string): void $callback */\n public function onStage(callable $callback): self\n {\n $this->onStage = $callback;\n return $this;\n }\n\n public function __construct(BoqCleaningService $boqCleaner)\n {\n $this->boqCleaner = $boqCleaner;\n $this->baseUrl = rtrim((string) config('services.ai_quotation.base_url', ''), '/');\n $this->parseEndpoint = ltrim((string) config('services.ai_quotation.parse_endpoint', 'parse'), '/');\n $this->apiKey = (string) config('services.ai_quotation.api_key', '');\n $this->timeout = (int) config('services.ai_quotation.timeout', 300);\n $this->testMode = (bool) config('services.ai_quotation.test_mode', false);\n }\n\n /**\n * Give the running script enough head-room to outlast the AI HTTP call.\n *\n * The web request / queue worker must not be killed before the HTTP timeout\n * completes, otherwise the user sees a generic \"timeout or unavailable\"\n * error even though the AI may have responded. We allow the HTTP timeout\n * plus a 120s margin for connecting, response parsing and DB writes.\n */\n private function extendExecutionTime(): void\n {\n // Ignored on some SAPIs (e.g. when running under fastcgi with a hard\n // limit), but harmless to attempt.\n @set_time_limit($this->timeout + 120);\n }\n\n public function parseBoq(UploadedFile|string $file, array $context = []): array\n {\n if ($this->testMode) {\n return $this->mockResponse();\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n\n if (! is_file($absPath)) {\n return $this->failure('File not found or not readable.');\n }\n\n $fileHash = hash_file('sha256', $absPath);\n // Bump PARSER_VERSION whenever the extraction logic changes so that\n // previously-uploaded files are re-parsed instead of returning a stale\n // cached result produced by the old code.\n $cacheKey = 'boq_analysis_v' . self::PARSER_VERSION . '_' . $fileHash;\n $forceRefresh = (bool) ($context['force_refresh'] ?? false);\n\n if (! $forceRefresh && ($cached = Cache::get($cacheKey)) !== null) {\n return $cached;\n }\n\n // Clear stale cached result when force-refreshing\n if ($forceRefresh) {\n Cache::forget($cacheKey);\n }\n\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n // Read Excel locally first. Fall back to AI if local parse failed,\n // found 0 items, OR could not read one of the sheets (so multi-sheet\n // files never silently drop a sheet the local parser didn't understand).\n $this->reportStage('Reading the spreadsheet…');\n\n $result = $this->parseSpreadsheetDirect($absPath);\n\n $hasSkippedSheets = ! empty($result['skipped_sheets']);\n\n if (! $result['success'] || empty($result['items']) || $hasSkippedSheets) {\n // The local pass could not handle this file on its own. Say so:\n // otherwise the UI sits on a generic spinner through the local\n // parse AND the whole AI parse with nothing to show for either.\n $this->reportStage($hasSkippedSheets\n ? 'Some sheets need AI to read. Sending to AI…'\n : 'Spreadsheet needs AI to read. Sending to AI…');\n\n $deepSeekResult = $this->parseBoqWithDeepSeek($file, $context);\n // AI sees every sheet at once, so prefer its result whenever it\n // actually returned items (covers the skipped-sheet case too).\n if ($deepSeekResult['success'] && ! empty($deepSeekResult['items'])) {\n // Keep the larger of the two item sets so we never lose the\n // rows the local parser already extracted correctly.\n if (count($deepSeekResult['items']) >= count($result['items'] ?? [])) {\n $result = $deepSeekResult;\n }\n } elseif (! $result['success']) {\n $result = $deepSeekResult;\n }\n // else: keep local result if AI also failed/returned nothing\n }\n } elseif (in_array($ext, ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif'], true)) {\n $result = $this->parseBoqWithDeepSeek($file, $context);\n } else {\n return $this->failure('Unsupported file type. Please upload Excel, CSV, PDF, or image file.');\n }\n\n if ($result['success'] && ! empty($result['items'])) {\n $cleaning = $this->boqCleaner->process($result['items']);\n $result['items'] = $cleaning['accepted'] ?? $result['items'];\n $result['rejected'] = array_merge($result['rejected'] ?? [], $cleaning['rejected'] ?? []);\n }\n\n if ($result['success'] && count($result['items'] ?? []) > self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n $result = $this->verifyLargeBoqItemsWithAi($result);\n }\n\n // Only cache when we actually extracted items; empty results should be re-tried.\n if ($result['success'] && ! empty($result['items'])) {\n Cache::put($cacheKey, $result, now()->addDays(30));\n }\n\n return $result;\n }\n\n private function resolveFile(UploadedFile|string $file): array\n {\n if ($file instanceof UploadedFile) {\n return [(string) $file->getRealPath(), strtolower($file->getClientOriginalExtension())];\n }\n\n $absPath = is_file($file) ? $file : storage_path('app/' . ltrim($file, '/'));\n return [$absPath, strtolower(pathinfo($absPath, PATHINFO_EXTENSION))];\n }\n\n private function parseSpreadsheetDirect(string $absPath): array\n {\n ini_set('memory_limit', '2048M');\n\n // 180s was not enough for a real BOQ: an 11 200-row workbook was killed\n // mid-parse, and because set_time_limit() kills the process outright the\n // job reported a bare failure with nothing in the log to explain it.\n // This runs on the queue, where wall time is cheap.\n set_time_limit(1800);\n\n try {\n $sheets = $this->spreadsheetToGrids($absPath);\n\n if (empty($sheets)) {\n return $this->failure('Could not read the Excel file.');\n }\n\n $this->reportWorkbookShape($sheets);\n\n $items = [];\n $rejected = [];\n $skippedSheets = []; // sheets we could not parse locally (header undetected)\n\n foreach ($sheets as $sheet) {\n $grid = $sheet['grid'];\n if (count($grid) < 2) {\n continue;\n }\n\n // Skip dedicated summary / cost-abstract sheets entirely. Their rows\n // are per-discipline totals (Structural Concrete, Electrical, …) and\n // grand totals, not procurable products.\n if ($this->isSummarySheet($sheet['name'])) {\n Log::info('QuotationAiService: skipped summary sheet.', ['sheet' => $sheet['name']]);\n continue;\n }\n\n $header = $this->detectHeader($grid);\n\n if ($header === null || ! isset($header['map']['description'])) {\n Log::warning('QuotationAiService: header not detected in sheet.', ['sheet' => $sheet['name']]);\n $skippedSheets[] = $sheet['name'];\n continue;\n }\n\n $map = $header['map'];\n $start = $header['row'] + 1;\n\n for ($r = $start; $r < count($grid); $r++) {\n $row = $grid[$r];\n $description = $this->cell($row, $map['description'] ?? null);\n $itemNo = $this->cell($row, $map['item_code'] ?? null);\n $unit = $this->cell($row, $map['unit'] ?? null);\n $quantity = $this->number($this->cell($row, $map['quantity'] ?? null));\n $unitPrice = $this->number($this->cell($row, $map['unit_price'] ?? null));\n $totalPrice = $this->number($this->cell($row, $map['total_price'] ?? null));\n\n if ($description === '') {\n continue;\n }\n\n if (! isset($map['quantity'])) {\n $quantity = 1.0;\n }\n\n // Skip floor breakdown sub-rows: Ref. is \"-\" meaning it's a per-floor\n // quantity split of the parent item, not a standalone product.\n if ($itemNo === '-' || $this->isFloorBreakdownRow($itemNo, $description)) {\n continue;\n }\n\n if ($this->isTotalOrHeaderRow($description)) {\n continue;\n }\n\n if ($quantity === null || $quantity <= 0) {\n $rejected[] = $this->rejectedRow($description, $unit, $quantity, 'Missing quantity', $itemNo, $sheet['name']);\n continue;\n }\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $supply = $this->boqCleaner->filterItem($description, $unitPrice, $quantity);\n\n if (! ($supply['keep'] ?? false)) {\n $rejected[] = $this->rejectedRow(\n $description,\n $unit,\n $quantity,\n (string) ($supply['rejection_reason'] ?? 'Rejected by cleaner'),\n $itemNo,\n $sheet['name']\n );\n continue;\n }\n\n $finalDescription = (string) ($supply['description'] ?? $description);\n\n $items[] = [\n 'description' => $finalDescription,\n 'quantity' => $quantity,\n 'unit' => $unit,\n 'category' => $sheet['name'],\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($finalDescription),\n 'unit_price' => $unitPrice,\n 'confidence' => 0.95,\n 'needs_review' => false,\n 'raw_data' => [\n 'sheet' => $sheet['name'],\n 'row_number' => $r + 1,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'cleaned_description' => $finalDescription,\n 'extraction_type' => (string) ($supply['extraction_type'] ?? 'supply_only'),\n ],\n 'ai_extracted' => true,\n ];\n }\n }\n\n if (empty($items)) {\n return $this->failure('No BOQ supply items could be extracted from the Excel file.');\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'skipped_sheets' => $skippedSheets,\n ];\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: parseSpreadsheetDirect failed.', [\n 'path' => $absPath,\n 'message' => $e->getMessage(),\n 'trace' => $e->getTraceAsString(),\n ]);\n\n return $this->failure('Failed to read the Excel file: ' . $e->getMessage());\n }\n }\n\n private function spreadsheetToGrids(string $absPath): array\n {\n $ext = strtolower(pathinfo($absPath, PATHINFO_EXTENSION));\n\n if ($ext === 'csv') {\n $rows = [];\n $handle = fopen($absPath, 'rb');\n if ($handle !== false) {\n while (($row = fgetcsv($handle)) !== false) {\n $rows[] = $this->trimTrailingEmptyCells($row);\n }\n fclose($handle);\n }\n return [['name' => 'Sheet1', 'grid' => $rows]];\n }\n\n $reader = IOFactory::createReader($ext === 'xls' ? 'Xls' : 'Xlsx');\n $reader->setReadDataOnly(true);\n $spreadsheet = $reader->load($absPath);\n $sheets = [];\n\n foreach ($spreadsheet->getAllSheets() as $sheet) {\n $highestRow = $sheet->getHighestDataRow();\n $highestColumn = $sheet->getHighestDataColumn();\n $highestColumnIndex = Coordinate::columnIndexFromString($highestColumn);\n\n // BOQ data is normally in first 8-20 columns.\n // Files with heavy formatting/images can report 300+ columns — limit to 30.\n $maxColumnIndex = min($highestColumnIndex, 30);\n\n $grid = [];\n for ($row = 1; $row <= $highestRow; $row++) {\n $rowData = [];\n $hasValue = false;\n\n for ($col = 1; $col <= $maxColumnIndex; $col++) {\n $value = $sheet->getCell([$col, $row])->getValue();\n $value = $this->cellValueToString($value);\n $value = trim($value);\n if ($value !== null && $value !== '') {\n $hasValue = true;\n }\n $rowData[] = $value;\n }\n\n $rowData = $this->trimTrailingEmptyCells($rowData);\n if ($hasValue) {\n $grid[] = $rowData;\n }\n }\n\n if (! empty($grid)) {\n $sheets[] = ['name' => $sheet->getTitle(), 'grid' => $grid];\n }\n }\n\n $spreadsheet->disconnectWorksheets();\n unset($spreadsheet);\n\n return $sheets;\n }\n\n private function detectHeader(array $grid): ?array\n {\n $keywords = [\n 'item_code' => ['رقم البند', 'item no', 'item number', 'ref.', 'ref', 'item', 'no.', 'no', '#', 'كود'],\n 'description' => ['وص٠البند', 'scope of works', 'scope of work', 'scope', 'item description', 'description of works', 'description', 'بالعربية', 'الوصÙ', 'البند', 'بيان'],\n 'unit' => ['Ø§Ù„ÙˆØØ¯Ø©', 'unit', 'uom', 'u/m'],\n 'quantity' => ['الكمية', 'qty', 'quantity', 'q.ty'],\n 'unit_price' => ['سعر Ø§Ù„ÙˆØØ¯Ø©', 'u/price', 'unit price', 'unit rate', 'rate', 'price'],\n 'total_price' => ['السعر الإجمالي', 'total amount', 'total price', 'amount', 'الإجمالي'],\n ];\n\n $best = null;\n $bestScore = 0;\n\n for ($r = 0; $r < min(30, count($grid)); $r++) {\n $map = [];\n $score = 0;\n\n foreach ($grid[$r] as $c => $value) {\n $cell = mb_strtolower(trim((string) $value));\n if ($cell === '') {\n continue;\n }\n\n foreach ($keywords as $field => $list) {\n foreach ($list as $kw) {\n if (str_contains($cell, mb_strtolower($kw))) {\n if (! isset($map[$field])) {\n $map[$field] = $c;\n $score++;\n }\n break 2;\n }\n }\n }\n }\n\n if ($score > $bestScore) {\n $bestScore = $score;\n $best = ['row' => $r, 'map' => $map];\n }\n }\n\n if ($bestScore >= 3) {\n return $best;\n }\n\n // Allow simpler BOQ sheets that only define a description column plus one\n // other BOQ-related column, e.g. Item + Description or Description + Qty.\n if ($bestScore === 2 && isset($best['map']['description']) && (\n isset($best['map']['item_code']) ||\n isset($best['map']['quantity']) ||\n isset($best['map']['unit'])\n )) {\n return $best;\n }\n\n return null;\n }\n\n private function cell(array $row, ?int $index): string\n {\n if ($index === null) {\n return '';\n }\n return trim((string) ($row[$index] ?? ''));\n }\n\n private function number(string $value): ?float\n {\n $value = trim(str_replace([',', 'SAR', 'ر.س'], '', $value));\n if ($value === '' || ! is_numeric($value)) {\n return null;\n }\n return (float) $value;\n }\n\n private function trimTrailingEmptyCells(array $row): array\n {\n while (! empty($row) && trim((string) end($row)) === '') {\n array_pop($row);\n }\n return array_values($row);\n }\n\n private function isTotalOrHeaderRow(string $description): bool\n {\n $d = mb_strtolower(trim($description));\n\n if ($d === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $d)) {\n return true;\n }\n\n // Section totals / summaries\n if (preg_match('/(اجمالي|إجمالي|المجموع|grand\\s*total|sub\\s*total|total\\s*amount|total\\s*price)/iu', $d)) {\n return true;\n }\n\n // Bare \"Total\" lines, optionally with a currency suffix, e.g. \"Total\",\n // \"Total (SAR)\", \"Total cost\", \"Total (SAR) unit cost\".\n if (preg_match('/^total\\b[\\s\\(\\):a-z]*$/iu', $d)) {\n return true;\n }\n\n // Pure floor / level labels used as section headers\n if (preg_match('/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor)\\s*$/iu', $d)) {\n return true;\n }\n\n // Division / section header lines (e.g. \"Division - 02: Wood Works\")\n if (preg_match('/^division\\s*([-:]|\\d)/iu', $d)) {\n return true;\n }\n\n return false;\n }\n\n /**\n * Detect dedicated summary / cost-abstract sheets by their name. These hold\n * per-discipline rollups and grand totals (e.g. a \"Summary (Dry Cost)\" tab),\n * never procurable supply items, so they are skipped wholesale.\n */\n private function isSummarySheet(string $name): bool\n {\n $n = mb_strtolower(trim($name));\n\n if ($n === '') {\n return false;\n }\n\n return (bool) preg_match(\n '/\\b(summary|abstract|cost\\s*summary|grand\\s*summary|recap(?:itulation)?|bill\\s*summary|boq\\s*summary|dry\\s*cost)\\b|ملخص|الملخص|الملخّص|ملخّص|اجمالي|إجمالي/iu',\n $n\n );\n }\n\n private function isFloorBreakdownRow(string $itemNo, string $description): bool\n {\n // A row whose Ref column is empty or a dash is a sub-breakdown row (per-floor split).\n $ref = trim($itemNo);\n if ($ref === '' || $ref === '-') {\n // Confirm by checking the description is a location/floor label\n $d = mb_strtolower(trim($description));\n return (bool) preg_match(\n '/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor|\\d+f)\\s*$/iu',\n $d\n );\n }\n return false;\n }\n\n private function rejectedRow(string $description, string $unit, ?float $quantity, string $reason, string $itemNo = '', string $sheet = ''): array\n {\n return [\n 'description' => $description,\n 'quantity' => $quantity ?? 0,\n 'unit' => $unit,\n 'category' => $sheet,\n 'brand' => '',\n 'status' => 'rejected',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 0.0,\n 'raw_data' => [\n 'sheet' => $sheet,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'extraction_type' => 'rejected',\n 'rejection_reason' => $reason,\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function parseBoqWithDeepSeek(UploadedFile|string $file, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured. Please set DEEPSEEK_API_KEY in .env file.');\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n $mime = $this->mimeForExtension($ext);\n\n try {\n // ── Spreadsheets ──────────────────────────────────────────────────\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n $text = $this->spreadsheetToCompactCsvText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not convert spreadsheet to readable text.');\n }\n\n $text = $this->sanitizeUtf8($text);\n\n // A large BOQ exceeds what one request can carry, so send it in\n // sequential slices and merge. Anything that fits goes as-is.\n //\n // Size alone is not a good enough signal: a workbook with several\n // sheets is structurally a big BOQ even when the text is compact,\n // and asking the model to hold every discipline at once is where\n // rows start getting dropped. So split on either signal.\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS || $sheetCount > self::MAX_SHEETS_PER_CALL) {\n Log::info('QuotationAiService: spreadsheet routed to chunked parsing.', [\n 'chars' => mb_strlen($text),\n 'sheets' => $sheetCount,\n ]);\n return $this->parseTextInChunks($text, 'BOQ spreadsheet converted to CSV', $apiKey, $context);\n }\n\n $userContent = \"BOQ spreadsheet converted to CSV:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── PDF ───────────────────────────────────────────────────────────\n if ($ext === 'pdf') {\n $extracted = $this->extractPdfText($absPath);\n if ($extracted !== null && mb_strlen(trim($extracted)) > 50) {\n $extracted = $this->sanitizeUtf8($extracted);\n\n // Split on either signal — long text, or many pages. A BOQ\n // spanning several pages is a multi-section document even if\n // each page is sparse.\n if (mb_strlen($extracted) > self::TEXT_CHUNK_CHARS\n || $this->lastPdfPageCount > self::MAX_PDF_PAGES_PER_CALL) {\n Log::info('QuotationAiService: PDF routed to chunked parsing.', [\n 'chars' => mb_strlen($extracted),\n 'pages' => $this->lastPdfPageCount,\n ]);\n return $this->parseTextInChunks($extracted, 'BOQ PDF extracted text', $apiKey, $context, 'application/pdf');\n }\n\n $userContent = \"BOQ PDF extracted text:\\n\\n\" . $extracted . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'application/pdf');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n return $this->failure('Could not extract text from the PDF. Please make sure it is a text-based PDF (not a scanned image), or convert it to Excel or CSV.');\n }\n\n // -- Word documents (docx) ---------------------------------------------------\n if (in_array($ext, ['docx', 'doc'], true)) {\n $text = $this->extractDocxText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not extract text from the Word document. Please convert it to Excel, CSV, or PDF.');\n }\n $text = $this->sanitizeUtf8($text);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS) {\n return $this->parseTextInChunks($text, 'BOQ Word document extracted text', $apiKey, $context);\n }\n\n $userContent = \"BOQ Word document extracted text:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── Images ──────────────────────────────────────────────────────────────\n // DeepSeek cannot receive images. A vision-capable AI processes the image\n // directly (no OCR) — configure GROQ_API_KEY or GEMINI_API_KEY in .env.\n $imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif', 'heic', 'heif'];\n if (in_array($ext, $imageExts, true)) {\n $bytes = (string) file_get_contents($absPath);\n return $this->callDeepSeekVision($bytes, $mime, $context);\n }\n\n return $this->failure('Unsupported file type. Please upload an Excel, CSV, PDF, or image file.');\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: DeepSeek fallback failed.', ['message' => $e->getMessage()]);\n return $this->failure('An error occurred while processing the file with AI: ' . $e->getMessage());\n }\n }\n\n private function deepSeekModel(): string\n {\n return (string) config('services.deepseek.model', 'deepseek-chat');\n }\n\n /**\n * Remove or replace any non-UTF-8 / malformed byte sequences so json_encode never fails.\n */\n private function sanitizeUtf8(string $text): string\n {\n // Replace invalid byte sequences with the UTF-8 replacement character\n $clean = mb_convert_encoding($text, 'UTF-8', 'UTF-8');\n // Strip any remaining non-printable control characters (except tab/newline/CR)\n $clean = (string) preg_replace('/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]/u', '', $clean);\n return $clean;\n }\n\n /**\n * Extract plain text from a Word docx file using ZipArchive (no external library needed).\n * Reads word/document.xml and strips XML tags, preserving paragraph and table structure.\n */\n private function extractDocxText(string $absPath): ?string\n {\n try {\n $zip = new \\ZipArchive();\n if ($zip->open($absPath) !== true) {\n return null;\n }\n\n $xml = $zip->getFromName('word/document.xml');\n $zip->close();\n\n if ($xml === false || $xml === '') {\n return null;\n }\n\n // Add newlines before paragraph/table-row/table-cell tags so structure is preserved\n $xml = preg_replace('/<w:p[ >]/', \"\\n<w:p>\", $xml) ?? $xml;\n $xml = preg_replace('/<w:tr[ >]/', \"\\n<w:tr>\", $xml) ?? $xml;\n $xml = preg_replace('/<\\/w:tc>/', \"\\t\", $xml) ?? $xml;\n\n $text = strip_tags($xml);\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n return trim($text) !== '' ? trim($text) : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: DOCX parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Extract plain text from a PDF using smalot/pdfparser (pure PHP, no binary required).\n */\n /**\n * The process memory limit in bytes, or 0 when unlimited.\n *\n * memory_limit is a shorthand string (\"128M\", \"1G\", \"-1\"), so it has to be\n * expanded before it can be compared against anything.\n */\n private function memoryLimitBytes(): int\n {\n $raw = trim((string) ini_get('memory_limit'));\n\n if ($raw === '' || $raw === '-1') {\n return 0;\n }\n\n $value = (int) $raw;\n\n return match (strtolower(substr($raw, -1))) {\n 'g' => $value * 1024 * 1024 * 1024,\n 'm' => $value * 1024 * 1024,\n 'k' => $value * 1024,\n default => $value,\n };\n }\n\n private function extractPdfText(string $absPath): ?string\n {\n // The parser loads and decompresses the whole document before any of our\n // chunking gets a look at it, and it does so with no memory ceiling of\n // its own. A big PDF exhausts the limit inside gzuncompress() — a fatal\n // error, not an exception, so the catch below cannot help and the worker\n // dies outright. Refuse the file first instead.\n $size = @filesize($absPath) ?: 0;\n\n if ($size > self::MAX_PDF_BYTES) {\n Log::warning('QuotationAiService: PDF too large to parse in-process.', [\n 'bytes' => $size,\n 'limit' => self::MAX_PDF_BYTES,\n ]);\n\n return null;\n }\n\n // Headroom check: parsing routinely needs several times the file size\n // once streams are decompressed. If that would not fit in what is left,\n // stop here rather than taking the worker down with us.\n $limit = $this->memoryLimitBytes();\n\n if ($limit > 0) {\n $available = $limit - memory_get_usage(true);\n\n if ($available < $size * self::PDF_MEMORY_FACTOR) {\n Log::warning('QuotationAiService: not enough memory left to parse this PDF.', [\n 'bytes' => $size,\n 'available' => $available,\n 'needed' => $size * self::PDF_MEMORY_FACTOR,\n ]);\n\n return null;\n }\n }\n\n try {\n $parser = new \\Smalot\\PdfParser\\Parser();\n $pdf = $parser->parseFile($absPath);\n\n // Recorded for the chunking decision: a long PDF is structurally a\n // big BOQ even when its extracted text is short.\n try {\n $this->lastPdfPageCount = count($pdf->getPages());\n } catch (\\Throwable) {\n $this->lastPdfPageCount = 0;\n }\n\n $text = $pdf->getText();\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n return $text !== '' ? $text : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: PDF parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Send a vision request to a vision-capable AI model.\n * DeepSeek's commercial API (api.deepseek.com) does NOT support image inputs — any model.\n * Priority order (first configured key wins):\n * 1. GROQ_API_KEY → Groq llama-4-scout (free: https://console.groq.com)\n * 2. GEMINI_API_KEY → Google Gemini Flash (free: https://aistudio.google.com/apikey)\n * 3. VISION_API_KEY → any OpenAI-compatible endpoint (OpenAI, OpenRouter …)\n */\n private function callDeepSeekVision(string $bytes, string $mime, array $context): array\n {\n // ── 1. Groq (free, reliable vision) ─────────────────────────────────────\n if ((string) config('services.groq.key', '') !== '') {\n $visionKey = (string) config('services.groq.key');\n $visionBaseUrl = 'https://api.groq.com/openai/v1';\n $visionModel = (string) config('services.groq.model', 'meta-llama/llama-4-scout-17b-16e-instruct');\n }\n // ── 2. Google Gemini ─────────────────────────────────────────────────────\n elseif ((string) config('services.gemini.key', '') !== '') {\n $visionKey = (string) config('services.gemini.key');\n $visionBaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai';\n $visionModel = (string) config('services.gemini.model', 'gemini-2.0-flash');\n }\n // ── 3. Generic VISION_API_KEY (OpenAI, OpenRouter, …) ───────────────────\n elseif ((string) config('services.vision.key', '') !== '') {\n $visionKey = (string) config('services.vision.key');\n $visionBaseUrl = rtrim((string) config('services.vision.base_url', 'https://openrouter.ai/api/v1'), '/');\n $visionModel = (string) config('services.vision.model', 'google/gemini-flash-1.5-8b');\n }\n else {\n return $this->failure(\n 'Image processing is not available. DeepSeek\\'s API does not support images. ' .\n 'Get a free key at https://console.groq.com and add GROQ_API_KEY to your .env file.'\n );\n }\n\n $dataUrl = \"data:{$mime};base64,\" . base64_encode($bytes);\n $prompt = $this->buildDeepSeekPrompt($context, $mime);\n\n $userContent = [\n ['type' => 'text', 'text' => $prompt],\n ['type' => 'image_url', 'image_url' => ['url' => $dataUrl]],\n ];\n\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders([\n 'Authorization' => 'Bearer ' . $visionKey,\n 'HTTP-Referer' => config('app.url', 'https://qimta.sa'),\n 'X-Title' => 'Qimta Platform',\n ])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post($visionBaseUrl . '/chat/completions', [\n 'model' => $visionModel,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 16384,\n 'temperature' => 0.2,\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('Vision API connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: Vision API HTTP error.', [\n 'model' => $visionModel,\n 'base_url' => $visionBaseUrl,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n return $this->failure(\"Vision API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n return $this->parseDeepSeekJsonResponse((string) $response->json('choices.0.message.content'));\n }\n\n private function callDeepSeekChat(string $userContent, string $key, string $model): array\n {\n // Ensure the PHP script outlives the HTTP call (+ a margin for parsing the\n // response and writing to the DB) so the worker/web request is not killed\n // mid-flight, which surfaces to users as a generic \"timeout or unavailable\".\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders(['Authorization' => 'Bearer ' . $key])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 32768,\n 'temperature' => 0.1,\n 'response_format' => ['type' => 'json_object'],\n 'user' => 'Qimta_Platform',\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('DeepSeek connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: DeepSeek HTTP error.', [\n 'model' => $model,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n if ($response->status() === 401) {\n return $this->failure('DeepSeek API key is invalid or unauthorised. Please update DEEPSEEK_API_KEY in .env.', false);\n }\n return $this->failure(\"DeepSeek API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n $text = (string) $response->json('choices.0.message.content');\n return $this->parseDeepSeekJsonResponse($text);\n }\n\n /**\n * Parse a document too large for one AI call by sending it in slices.\n *\n * A 20 000-row BOQ is several megabytes of text — far beyond what a single\n * request can carry, and beyond what the model can answer without hitting its\n * output token limit. Previously the text was simply cut at 180 000 chars and\n * the remainder discarded: the parse \"succeeded\" while silently dropping most\n * of the file. Now the text is split and each slice parsed on its own call,\n * with the items merged.\n *\n * Splitting happens on line boundaries so a BOQ row is never cut in half.\n * Slices run sequentially rather than pooled: DeepSeek rate-limits hard on\n * concurrent large prompts, and this already runs on the queue where wall\n * time is cheap.\n *\n * A slice that fails does not fail the document — its rows are lost, but the\n * rest are kept and the shortfall is logged. Total failure is reported only\n * when every slice fails.\n *\n * @param string $label How the payload is introduced to the model.\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n /**\n * Split a spreadsheet into the slices the AI will be asked to read.\n *\n * Exposed so the work can be spread over several queue jobs instead of one\n * job walking every slice in sequence. Returns an empty array when the file\n * is small enough to go in a single call, or cannot be read.\n *\n * @return array<int, string>\n */\n public function chunkSpreadsheet(string $absPath): array\n {\n ini_set('memory_limit', '2048M');\n set_time_limit(1800);\n\n $text = $this->spreadsheetToCompactCsvText($absPath);\n\n if ($text === null || trim($text) === '') {\n return [];\n }\n\n $text = $this->sanitizeUtf8($text);\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) <= self::TEXT_CHUNK_CHARS && $sheetCount <= self::MAX_SHEETS_PER_CALL) {\n return [];\n }\n\n $chunks = $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n\n // Free the source text: the slices are a second full copy of it, and on\n // a large workbook that doubling — on top of PhpSpreadsheet's grids —\n // is what exhausted memory here.\n unset($text);\n\n Log::info('QuotationAiService: chunked spreadsheet.', [\n 'chunks' => count($chunks),\n 'peak_mb' => round(memory_get_peak_usage(true) / 1048576),\n ]);\n\n return $chunks;\n }\n\n /**\n * Split a spreadsheet straight to disk, one file per slice.\n *\n * chunkSpreadsheet() returns every slice as an array, which is a second\n * full copy of the document in memory on top of PhpSpreadsheet's grids.\n * This hands each slice to $write and drops it immediately, so peak memory\n * is one slice rather than the whole file twice.\n *\n * @param callable(int, string): void $write ($part, $chunk)\n * @return int number of slices written; 0 when the file needs no split\n */\n public function chunkSpreadsheetToDisk(string $absPath, callable $write): int\n {\n ini_set('memory_limit', '2048M');\n set_time_limit(1800);\n\n $text = $this->spreadsheetToCompactCsvText($absPath);\n\n if ($text === null || trim($text) === '') {\n return 0;\n }\n\n $text = $this->sanitizeUtf8($text);\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) <= self::TEXT_CHUNK_CHARS && $sheetCount <= self::MAX_SHEETS_PER_CALL) {\n return 0;\n }\n\n $part = 0;\n foreach ($this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS) as $chunk) {\n $write(++$part, $chunk);\n }\n\n unset($text);\n\n Log::info('QuotationAiService: chunked spreadsheet to disk.', [\n 'chunks' => $part,\n 'peak_mb' => round(memory_get_peak_usage(true) / 1048576),\n ]);\n\n return $part;\n }\n\n /**\n * Parse one slice produced by chunkSpreadsheet().\n *\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n public function parseChunk(string $chunk, int $part, int $total, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured.');\n }\n\n // Same framing parseTextInChunks() uses, so a slice is never mistaken\n // for a complete BOQ and totals are not inferred from a fragment.\n $userContent = \"BOQ spreadsheet converted to CSV (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, 'text/plain');\n\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n private function parseTextInChunks(\n string $text,\n string $label,\n string $apiKey,\n array $context,\n string $mime = 'text/plain',\n ): array {\n $chunks = $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n $total = count($chunks);\n\n Log::info('QuotationAiService: large document split for parsing.', [\n 'chars' => mb_strlen($text),\n 'chunks' => $total,\n ]);\n\n // Announce the split before the first slice is sent, so the UI can show\n // how many parts there are instead of sitting on a generic message for\n // however long the first AI call takes.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)(0, $total);\n }\n\n $items = [];\n $rejected = [];\n $failed = 0;\n $lastError = null;\n\n foreach ($chunks as $index => $chunk) {\n $part = $index + 1;\n\n // Report progress so a 28-slice parse does not look frozen to the user.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)($part, $total);\n }\n\n // Tell the model this is a fragment, so it does not try to reconcile\n // totals or treat a mid-document slice as a complete BOQ.\n $userContent = \"{$label} (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, $mime);\n\n $result = $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n\n if (! ($result['success'] ?? false)) {\n $failed++;\n $lastError = $result['error'] ?? 'Chunk parsing failed.';\n Log::warning('QuotationAiService: chunk failed, continuing.', [\n 'part' => $part,\n 'of' => $total,\n 'error' => $lastError,\n ]);\n continue;\n }\n\n foreach ($result['items'] as $item) {\n $items[] = $item;\n }\n foreach ($result['rejected'] ?? [] as $row) {\n $rejected[] = $row;\n }\n\n // Hand this slice's rows straight to the caller so they can be shown\n // while the remaining slices are still being parsed.\n if ($this->onChunkItems !== null && $result['items'] !== []) {\n ($this->onChunkItems)($result['items'], $part, $total);\n }\n }\n\n // Every slice failed — there is nothing to show, so report the failure.\n if ($failed === $total) {\n return $this->failure($lastError ?? 'Could not parse the document.');\n }\n\n if ($failed > 0) {\n Log::error('QuotationAiService: document parsed with missing parts.', [\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n 'items_kept' => count($items),\n ]);\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'service_unavailable' => false,\n 'partial' => $failed > 0,\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n ];\n }\n\n /**\n * Split text into slices of at most $limit characters, breaking only at\n * newlines so no BOQ row is ever cut in half.\n *\n * A single line longer than the limit (rare — a pathological cell) is passed\n * through oversized rather than severed mid-row.\n *\n * @return array<int, string>\n */\n private function splitOnLineBoundaries(string $text, int $limit): array\n {\n $chunks = [];\n $current = '';\n\n // Sheet a chunk starts in the middle of. Repeated at the top of the next\n // chunk so the model always knows which sheet the rows belong to — the\n // header would otherwise be stranded in the previous slice, and rows\n // would arrive with no discipline context.\n $currentSheet = null;\n\n foreach (explode(\"\\n\", $text) as $line) {\n $isSheetHeader = str_starts_with($line, 'Sheet: ');\n\n // Start a new chunk at every sheet boundary. Sheets are usually\n // separate disciplines, and the whole point of splitting a multi-tab\n // workbook is to give the model one discipline at a time — a size-only\n // split would leave a compact 3-tab file as a single chunk, silently\n // undoing the sheet gate that routed it here.\n if ($isSheetHeader && $current !== '') {\n $chunks[] = $current;\n $current = $line;\n $currentSheet = $line;\n continue;\n }\n\n if ($isSheetHeader) {\n $currentSheet = $line;\n }\n\n $candidate = $current === '' ? $line : $current . \"\\n\" . $line;\n\n if (mb_strlen($candidate) > $limit && $current !== '') {\n $chunks[] = $current;\n\n // Carry the sheet header over, unless this line is itself one.\n $current = ($currentSheet !== null && ! str_starts_with($line, 'Sheet: '))\n ? $currentSheet . \" (continued)\\n\" . $line\n : $line;\n continue;\n }\n\n $current = $candidate;\n }\n\n if (trim($current) !== '') {\n $chunks[] = $current;\n }\n\n return $chunks;\n }\n\n /**\n * Shared JSON response parser used by both chat and vision paths.\n */\n private function parseDeepSeekJsonResponse(string $text): array\n {\n if ($text === '') {\n return $this->failure('DeepSeek returned an empty response.');\n }\n\n // Strip markdown code fences if the model wrapped the JSON.\n if (preg_match('/```(?:json)?\\s*([\\s\\S]*?)\\s*```/i', $text, $m)) {\n $text = $m[1];\n }\n // Narrow to the outermost JSON object/array.\n if (preg_match('/[\\{\\[][\\s\\S]*[\\}\\]]/s', $text, $m)) {\n $text = $m[0];\n }\n $text = trim($text);\n\n $decoded = json_decode($text, true);\n\n // The response can be truncated when the model hits the token limit,\n // leaving an unterminated JSON string/object. Try to repair it before\n // giving up so we can still salvage the items extracted so far.\n if (! is_array($decoded)) {\n $repaired = $this->repairTruncatedJson($text);\n if ($repaired !== null) {\n $decoded = json_decode($repaired, true);\n }\n }\n\n if (! is_array($decoded)) {\n Log::error('QuotationAiService: DeepSeek non-JSON response.', ['text' => mb_substr($text, 0, 1000)]);\n return $this->failure('DeepSeek returned a non-JSON response. The file may be too large — try splitting it into smaller files.');\n }\n\n $rawItems = $decoded['items'] ?? (array_is_list($decoded) ? $decoded : []);\n $items = [];\n $rejected = [];\n\n foreach ($rawItems as $raw) {\n if (! is_array($raw)) {\n continue;\n }\n $item = $this->normaliseAiItem($raw);\n if ($item === null) {\n continue;\n }\n $check = $this->boqCleaner->filterItem((string) $item['description']);\n if ($check['keep'] ?? false) {\n $items[] = $item;\n } else {\n $item['status'] = 'rejected';\n $item['raw_data']['rejection_reason'] = $check['rejection_reason'] ?? 'Rejected by cleaner';\n $rejected[] = $item;\n }\n }\n\n if (empty($items)) {\n Log::info('QuotationAiService: DeepSeek returned 0 accepted items.', [\n 'raw_items_count' => count($rawItems),\n 'rejected_count' => count($rejected),\n 'first_rejected' => ! empty($rejected) ? ($rejected[0]['description'] ?? '') : '',\n ]);\n }\n\n return ['success' => true, 'items' => $items, 'rejected' => $rejected, 'error' => null];\n }\n\n /**\n * Best-effort repair of a truncated JSON string produced when the model hits\n * its token limit mid-output. Drops any incomplete trailing object, then\n * closes the open string/array/object brackets so the result is valid JSON.\n *\n * Returns the repaired JSON string, or null if it cannot be salvaged.\n */\n private function repairTruncatedJson(string $text): ?string\n {\n if ($text === '') {\n return null;\n }\n\n // Walk the string tracking structural state, ignoring everything inside\n // string literals (and their escapes). Remember the last position where\n // we were back at the top of the \"items\" array between complete elements,\n // so we can cut a half-written trailing object.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($text);\n $lastSafeCut = null; // index just after a completed array element\n\n for ($i = 0; $i < $len; $i++) {\n $ch = $text[$i];\n\n if ($inString) {\n if ($escaped) {\n $escaped = false;\n } elseif ($ch === '\\\\') {\n $escaped = true;\n } elseif ($ch === '\"') {\n $inString = false;\n }\n continue;\n }\n\n if ($ch === '\"') {\n $inString = true;\n } elseif ($ch === '{' || $ch === '[') {\n $stack[] = $ch;\n } elseif ($ch === '}' || $ch === ']') {\n array_pop($stack);\n } elseif ($ch === ',' && ! empty($stack) && end($stack) === '[') {\n // Separator directly inside an array: a clean boundary between\n // elements (the items array, whatever its nesting depth).\n $lastSafeCut = $i;\n }\n }\n\n // If we are still inside a string, the last element is incomplete — fall\n // back to the last clean array boundary if we have one.\n $candidate = $text;\n if ($inString && $lastSafeCut !== null) {\n $candidate = substr($text, 0, $lastSafeCut);\n } else {\n // Drop a dangling partial object after the last completed element.\n $candidate = rtrim($candidate);\n if ($lastSafeCut !== null && substr(rtrim($candidate), -1) !== '}' && substr(rtrim($candidate), -1) !== ']') {\n $candidate = substr($text, 0, $lastSafeCut);\n }\n }\n\n // Recompute the open brackets for the (possibly trimmed) candidate and\n // append the matching closers in reverse order.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($candidate);\n for ($i = 0; $i < $len; $i++) {\n $ch = $candidate[$i];\n if ($inString) {\n if ($escaped) { $escaped = false; }\n elseif ($ch === '\\\\') { $escaped = true; }\n elseif ($ch === '\"') { $inString = false; }\n continue;\n }\n if ($ch === '\"') { $inString = true; }\n elseif ($ch === '{' || $ch === '[') { $stack[] = $ch; }\n elseif ($ch === '}' || $ch === ']') { array_pop($stack); }\n }\n\n $candidate = rtrim($candidate);\n // Remove a trailing comma left after cutting an element.\n $candidate = rtrim($candidate, ',');\n\n $closers = '';\n for ($i = count($stack) - 1; $i >= 0; $i--) {\n $closers .= ($stack[$i] === '{') ? '}' : ']';\n }\n\n $repaired = $candidate . $closers;\n\n // Validate before returning.\n return is_array(json_decode($repaired, true)) ? $repaired : null;\n }\n\n private function spreadsheetToCompactCsvText(string $absPath): ?string\n {\n $sheets = $this->spreadsheetToGrids($absPath);\n if (empty($sheets)) {\n return null;\n }\n\n $output = '';\n $kept = 0;\n $dropped = 0;\n $nonSupply = 0;\n $duplicates = 0;\n\n /** @var array<string, true> Fingerprints of rows already emitted. */\n $seenRows = [];\n\n foreach ($sheets as $sheet) {\n // Don't feed summary / cost-abstract sheets to the AI either.\n if ($this->isSummarySheet($sheet['name'])) {\n continue;\n }\n $sheetRows = '';\n foreach ($sheet['grid'] as $row) {\n $row = $this->trimTrailingEmptyCells($row);\n\n if ($this->isNoiseRow($row)) {\n $dropped++;\n continue;\n }\n\n // Drop rows that are definitely not procurable before they cost\n // a single token: section totals, discipline headings,\n // preliminaries, labour-only lines. The same classifier already\n // runs on the AI's output, but by then the tokens are spent — on\n // an 11 200-row BOQ that is most of the bill.\n if ($this->isNonSupplyRow($row)) {\n $dropped++;\n $nonSupply++;\n continue;\n }\n\n // Byte-identical rows tell the AI nothing new. Only exact\n // duplicates are collapsed — description, quantity, unit and all\n // — so nothing is lost: two rows differing in quantity are two\n // different line items and both are sent.\n $fingerprint = $this->rowFingerprint($row);\n\n if (isset($seenRows[$fingerprint])) {\n $duplicates++;\n $dropped++;\n continue;\n }\n $seenRows[$fingerprint] = true;\n\n $sheetRows .= implode(',', array_map([$this, 'csvEscape'], $this->stripBoilerplate($row))) . \"\\n\";\n $kept++;\n }\n\n // A sheet whose rows were all noise contributes nothing but its own\n // header, which would just be another thing for the model to explain.\n if ($sheetRows === '') {\n continue;\n }\n\n $output .= 'Sheet: ' . $sheet['name'] . \"\\n\" . $sheetRows . \"\\n\";\n }\n\n $blank = $dropped - $nonSupply - $duplicates;\n\n Log::info('QuotationAiService: filtered rows before sending to AI.', [\n 'kept' => $kept,\n 'blank' => $blank,\n 'non_supply' => $nonSupply,\n 'duplicates' => $duplicates,\n ]);\n\n if ($dropped > 0) {\n $parts = [];\n if ($blank > 0) { $parts[] = \"{$blank} blank\"; }\n if ($nonSupply > 0) { $parts[] = \"{$nonSupply} non-supply\"; }\n if ($duplicates > 0) { $parts[] = \"{$duplicates} duplicate\"; }\n\n $this->reportStage('Skipped ' . implode(', ', $parts) . \" rows. Sending {$kept}…\");\n }\n\n return $output;\n }\n\n /**\n * True when a row carries no information worth sending to the AI.\n *\n * A real BOQ is mostly formatting: spacer rows, stray borders, a lone\n * section number, a leftover cell from a merged block. They survive\n * trimTrailingEmptyCells() because they are not *entirely* empty, then cost\n * tokens, dilute the prompt, and inflate the number of parts a large file is\n * split into.\n *\n * Deliberately conservative — a row is dropped only when nothing in it could\n * name a product. Anything with letters is kept, because a description is\n * the one field an item cannot do without.\n *\n * @param array<int, mixed> $row\n */\n /**\n * True when a row is definitely not a procurable supply item.\n *\n * Runs BoqCleaningService over the row's longest text cell — the one most\n * likely to be the description — so headings, totals, preliminaries and\n * labour-only lines never reach the AI. The same classifier already vets the\n * AI's *output*; doing it here as well means we stop paying to have those\n * rows read in the first place.\n *\n * Conservative by construction: only rows the classifier positively rejects\n * are dropped. Anything it is unsure about goes to the AI, which is the\n * better judge — a wrongly dropped row is invisible, a wrongly kept one only\n * costs tokens.\n *\n * @param array<int, mixed> $row\n */\n /**\n * Stable identity for a row, used to collapse exact duplicates.\n *\n * Whitespace and case are normalised so \"DN50 PIPE\" and \"dn50 pipe \" are one\n * row, but nothing else is: a differing quantity or unit produces a\n * different fingerprint and both rows are sent.\n *\n * @param array<int, mixed> $row\n */\n private function rowFingerprint(array $row): string\n {\n $parts = array_map(\n fn($cell) => mb_strtolower(preg_replace('/\\s+/u', ' ', trim((string) $cell)) ?? ''),\n $row,\n );\n\n // Drop a leading BOQ sequence number. Otherwise \"1,Pipe,m,100\" and\n // \"2,Pipe,m,100\" fingerprint differently and the dedup never fires —\n // which is exactly the shape every real BOQ has.\n if (isset($parts[0]) && $parts[0] !== '' && preg_match('/^[\\d.]+$/', $parts[0])) {\n array_shift($parts);\n }\n\n return md5(implode('|', $parts));\n }\n\n /**\n * Strip contractual boilerplate from a row's description cell.\n *\n * \"as per drawings\", \"complete with all necessary accessories\" and friends\n * repeat on nearly every line of a real BOQ and carry no information the\n * pricing engine can use — but they are a large share of the tokens.\n *\n * Only the longest text cell is touched, and only when the result is still a\n * usable description: if stripping would leave too little to identify the\n * product, the original is kept. A shorter-but-wrong description is far\n * worse than a verbose one.\n *\n * @param array<int, mixed> $row\n * @return array<int, mixed>\n */\n private function stripBoilerplate(array $row): array\n {\n $index = null;\n $longest = '';\n\n foreach ($row as $i => $cell) {\n $text = trim((string) $cell);\n if (preg_match('/\\p{L}/u', $text) && mb_strlen($text) > mb_strlen($longest)) {\n $longest = $text;\n $index = $i;\n }\n }\n\n // Nothing worth cleaning, or too short to risk it.\n if ($index === null || mb_strlen($longest) < 40) {\n return $row;\n }\n\n try {\n $cleaned = $this->boqCleaner->cleanDescription($longest);\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: boilerplate strip failed, keeping row as-is.', [\n 'error' => $e->getMessage(),\n ]);\n return $row;\n }\n\n // Guard against an over-eager pattern eating the product name: keep the\n // original unless a recognisable description survives.\n if (mb_strlen($cleaned) < 12 || mb_strlen($cleaned) < mb_strlen($longest) * 0.3) {\n return $row;\n }\n\n $row[$index] = $cleaned;\n\n return $row;\n }\n\n private function isNonSupplyRow(array $row): bool\n {\n $description = '';\n foreach ($row as $cell) {\n $text = trim((string) $cell);\n // Longest cell containing letters — numbers are qty/rate columns.\n if (preg_match('/\\p{L}/u', $text) && mb_strlen($text) > mb_strlen($description)) {\n $description = $text;\n }\n }\n\n // No text at all: leave it to isNoiseRow()/the AI rather than guessing.\n if ($description === '' || mb_strlen($description) < 3) {\n return false;\n }\n\n try {\n $verdict = $this->boqCleaner->filterItem($description);\n } catch (\\Throwable $e) {\n // A classifier failure must never silently drop a real product.\n Log::warning('QuotationAiService: pre-filter classifier failed, keeping row.', [\n 'error' => $e->getMessage(),\n ]);\n return false;\n }\n\n return ! ($verdict['keep'] ?? true);\n }\n\n private function isNoiseRow(array $row): bool\n {\n if (empty($row)) {\n return true;\n }\n\n $joined = trim(implode('', array_map(fn($c) => trim((string) $c), $row)));\n\n // Nothing but separators/whitespace.\n if ($joined === '') {\n return true;\n }\n\n // Ruled lines and decorative fills (\"-----\", \"====\", \"___\").\n if (preg_match('/^[\\-_=.*\\s|]+$/u', $joined)) {\n return true;\n }\n\n // Any letter — Latin or Arabic — means this could be a description.\n if (preg_match('/\\p{L}/u', $joined)) {\n return false;\n }\n\n // No letters at all: numbers only. A single number is a stray row\n // number or a dangling total; two or more could be qty/rate/amount\n // belonging to a real line, so keep those for the AI to judge.\n $numericCells = 0;\n foreach ($row as $cell) {\n if (trim((string) $cell) !== '') {\n $numericCells++;\n }\n }\n\n return $numericCells <= 1;\n }\n\n /**\n * Safely convert a PhpSpreadsheet cell value to a string.\n *\n * Cells may contain RichText or, in malformed/heavily-formatted files,\n * embedded objects such as Worksheet\\Drawing that have no __toString().\n * Casting those directly throws \"Object ... could not be converted to string\".\n */\n private function cellValueToString(mixed $value): string\n {\n if ($value === null) {\n return '';\n }\n\n if ($value instanceof \\PhpOffice\\PhpSpreadsheet\\RichText\\RichText) {\n return $value->getPlainText();\n }\n\n if (is_object($value)) {\n // Only stringify objects that explicitly support it; ignore the rest\n // (e.g. Worksheet\\Drawing) instead of crashing.\n return method_exists($value, '__toString') ? (string) $value : '';\n }\n\n if (is_array($value)) {\n return '';\n }\n\n return (string) $value;\n }\n\n private function csvEscape(mixed $value): string\n {\n $value = $this->cellValueToString($value);\n return str_contains($value, ',') || str_contains($value, '\"') || str_contains($value, \"\\n\")\n ? '\"' . str_replace('\"', '\"\"', $value) . '\"'\n : $value;\n }\n\n private function verifyLargeBoqItemsWithAi(array $result): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n Log::warning('QuotationAiService: large BOQ verification skipped, AI key is not configured.');\n return $result;\n }\n\n $items = array_values(array_filter($result['items'] ?? [], 'is_array'));\n if (count($items) <= self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n return $result;\n }\n\n Log::info('QuotationAiService: starting second-pass physical item verification.', [\n 'items_count' => count($items),\n 'threshold' => self::LARGE_BOQ_VERIFICATION_THRESHOLD,\n ]);\n\n $verifiedItems = [];\n $rejectedItems = $result['rejected'] ?? [];\n\n foreach (array_chunk($items, self::LARGE_BOQ_VERIFICATION_CHUNK_SIZE) as $chunkIndex => $chunk) {\n $payload = array_map(fn(array $item) => [\n 'description' => (string) ($item['description'] ?? ''),\n 'quantity' => $item['quantity'] ?? null,\n 'unit' => (string) ($item['unit'] ?? ''),\n 'unit_price' => $item['unit_price'] ?? null,\n 'category' => (string) ($item['category'] ?? ''),\n 'brand' => (string) ($item['brand'] ?? ''),\n ], $chunk);\n\n $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);\n if (! is_string($json)) {\n Log::warning('QuotationAiService: large BOQ verification chunk JSON encode failed.', [\n 'chunk' => $chunkIndex + 1,\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n $prompt = <<<PROMPT\nYou are doing a SECOND PASS verification on a BOQ extraction result.\n\nThe first pass may hallucinate non-products when the BOQ has thousands of rows.\nYour job is to keep ONLY physical, tangible, procurable supply products.\n\nRules:\n- Keep items only if a supplier can actually deliver them as materials/equipment.\n- Reject headings, trade names, systems, scopes, prose, specifications-only rows, totals, labor, installation, testing, commissioning, supervision, drawings, insurance, preliminaries, mobilisation, site works, and vague fragments.\n- Do not invent new products.\n- Do not add items that are not in the input.\n- You may clean a product description, but keep the original quantity, unit, unit_price, category, and brand when available.\n\nInput JSON array:\n{$json}\n\nReturn ONLY valid JSON in this shape:\n{\"items\":[{\"description\":\"\",\"quantity\":1,\"unit\":\"\",\"unit_price\":null,\"category\":\"\",\"brand\":\"\",\"confidence\":0.95}]}\nPROMPT;\n\n try {\n $chunkResult = $this->callDeepSeekChat($prompt, $apiKey, $this->deepSeekModel());\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: large BOQ verification chunk threw an exception.', [\n 'chunk' => $chunkIndex + 1,\n 'message' => $e->getMessage(),\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n if ($chunkResult['success'] ?? false) {\n array_push($verifiedItems, ...($chunkResult['items'] ?? []));\n $rejectedItems = array_merge($rejectedItems, $chunkResult['rejected'] ?? []);\n continue;\n }\n\n Log::warning('QuotationAiService: large BOQ verification chunk failed, keeping original chunk.', [\n 'chunk' => $chunkIndex + 1,\n 'error' => $chunkResult['error'] ?? null,\n ]);\n array_push($verifiedItems, ...$chunk);\n }\n\n if (empty($verifiedItems)) {\n Log::warning('QuotationAiService: second-pass verification returned no items, keeping first-pass result.', [\n 'original_items_count' => count($items),\n ]);\n return $result;\n }\n\n $result['items'] = $verifiedItems;\n $result['rejected'] = $rejectedItems;\n $result['large_boq_verified'] = true;\n\n Log::info('QuotationAiService: second-pass physical item verification completed.', [\n 'before' => count($items),\n 'after' => count($verifiedItems),\n ]);\n\n return $result;\n }\n\n private function normaliseAiItem(array $raw): ?array\n {\n if (! empty($raw['rejected'])) {\n return null;\n }\n\n $description = trim((string) ($raw['cleaned_description'] ?? $raw['product_name'] ?? $raw['description'] ?? ''));\n if ($description === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $description)) {\n return null;\n }\n\n $quantity = is_numeric($raw['quantity'] ?? null) ? (float) $raw['quantity'] : 1;\n $unitPrice = is_numeric($raw['unit_price'] ?? null) ? (float) $raw['unit_price'] : null;\n $totalPrice = is_numeric($raw['total_price'] ?? null) ? (float) $raw['total_price'] : null;\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $specs = is_array($raw['specifications'] ?? null) ? $raw['specifications'] : [];\n\n return [\n 'description' => $description,\n 'quantity' => $quantity,\n 'unit' => (string) ($raw['unit'] ?? ''),\n 'category' => (string) ($raw['core_product'] ?? $raw['category'] ?? ''),\n 'brand' => (string) ($specs['brand'] ?? $raw['brand'] ?? ''),\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($description),\n 'unit_price' => $unitPrice,\n 'confidence' => is_numeric($raw['confidence'] ?? null) ? (float) $raw['confidence'] : null,\n 'needs_review' => ! empty($raw['needs_review']),\n 'raw_data' => [\n 'source_item_no' => (string) ($raw['source_item_no'] ?? ''),\n 'original_description' => (string) ($raw['original_description'] ?? $description),\n 'cleaned_description' => $description,\n 'core_product' => (string) ($raw['core_product'] ?? ''),\n 'specifications' => $specs,\n 'extraction_type' => (string) ($raw['extraction_type'] ?? 'supply_only'),\n 'note' => (string) ($raw['note'] ?? $raw['notes'] ?? ''),\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function buildSystemInstruction(): string\n {\n return <<<'TXT'\nYou are a strict BOQ Supply Product Extraction Engine.\nExtract ONLY concrete, tangible, physical products that a supplier can box up and deliver to site — a specific item you could point at and say \"this is the thing being bought\" (e.g. \"Wall mounted LED luminaire 36W\", \"PVC pipe 110mm\", \"Single phase distribution board 12-way\", \"Fire extinguisher CO2 6kg\", \"CCTV dome camera 4MP\").\n\nABSOLUTELY REJECT abstract concepts, disciplines, categories, section names, and trade headings. These are NOT products even though they appear as rows:\n- Bare discipline/trade words: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", \"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\".\n- Summary / total / abstract / \"dry cost\" rows of any kind.\n- Anything that names a system, scope, or category instead of one buyable item.\n\nRule of thumb: if you cannot name a unit and physically hand the item over (a piece, a box, a length, a unit of equipment), REJECT it. A word that describes a field of work, a system, or a section of the BOQ is NEVER a product.\nReject totals, headings, general requirements, installation-only, testing, commissioning, supervision, mobilization, civil works, labor, and vague rows.\nFor Supply & Install rows, keep only the supply product and remove install/labor wording.\nReturn only valid JSON: {\"items\":[{\"source_item_no\":null,\"original_description\":\"\",\"cleaned_description\":\"\",\"core_product\":\"\",\"specifications\":{},\"quantity\":0,\"unit\":\"\",\"unit_price\":0,\"total_price\":0,\"extraction_type\":\"supply_only\",\"needs_review\":false,\"note\":\"\",\"rejected\":false,\"rejection_reason\":null,\"confidence\":0.95}]}\nTXT;\n }\n\n private function buildDeepSeekPrompt(array $context = [], string $mime = ''): string\n {\n $projectName = (string) ($context['project_name'] ?? '');\n $projectStatus = (string) ($context['project_status'] ?? '');\n $contextLine = $projectName !== ''\n ? \"Project: {$projectName}\" . ($projectStatus !== '' ? \" ({$projectStatus})\" : '')\n : '';\n $sourceHint = str_starts_with($mime, 'image/')\n ? 'The input is an image of a BOQ table. OCR every cell carefully — read the quantity/count column (العدد, الكمية, Qty) and extract the EXACT numeric value from each row.'\n : 'The input is a BOQ document/spreadsheet.';\n\n return trim(<<<PROMPT\nYou are a procurement specialist. Your task is to extract ONLY tangible, physical supply products from this Bill of Quantities (BOQ).\n\n{$contextLine}\n{$sourceHint}\n\nCRITICAL — MERGE WRAPPED / SPLIT ROWS INTO ONE ITEM:\nBOQ tables extracted from PDF often break ONE item's long description across several\nlines/rows. These continuation lines repeat the SAME quantity, unit and category, and\ntheir text is a grammatical continuation of the previous line (it starts mid-sentence,\nlowercase, or with \"the/and/-\", or is a spec/instruction clause). You MUST reconstruct\nthe original single item:\n- Treat consecutive rows that share the SAME quantity + unit + category as ONE product\n when the later rows are clearly continuation text of the first, NOT new products.\n- Emit ONE item with the merged description and the price counted ONCE. Do NOT repeat\n the same quantity×price for every wrapped line — that multiplies the total wrongly.\n- Example: rows \"The asphalt flooring is to be supplied\" / \"installed in accordance\" /\n \"standard road specifications...\" all with 7,100 m2 are ONE asphalt item, not 3+.\n\nCRITICAL — UNDERSTAND INTENT, DO NOT COPY ROWS LITERALLY:\nYour job is to output WHAT A SUPPLIER MUST ACTUALLY DELIVER, not a copy of the row text.\nRead each description for MEANING and decide how many real, separately-buyable products it\nimplies. One BOQ row can imply SEVERAL products, or a spread of rows can imply ONE product.\n- SPLIT a row into multiple items when it lists distinct products that a supplier would\n buy/price separately — even when joined by commas, \"with\", \"and\", \"including\", \"c/w\",\n \"consisting of\", \"comprising\", or \":\" then a list.\n Example: \"Fire pump set consisting of: fire pump, electric motor, control panel, diesel\n engine\" → FOUR items: (1) fire pump, (2) electric motor, (3) control panel, (4) diesel\n engine — each with its own quantity and priced on its own.\n Example: \"Wash basin complete with mixer tap and bottle trap\" → THREE items: wash basin,\n mixer tap, bottle trap.\n- Give each split product the correct quantity implied by the text (usually the parent\n quantity, unless the text states a different count for a sub-item). Each product is\n priced individually; the line total becomes the SUM of its products.\n- DO NOT split when the extra words are just specifications, dimensions, colours, ratings,\n standards, or a single product's integral parts. \"Steel door 900x2100 with vision panel\n and SS ironmongery\" is usually ONE door product — vision panel and ironmongery are part\n of that door, not separately-bought items. Use judgement: would a supplier issue a\n separate price line for it? If no, keep it as one.\n- Balanced approach: split only when genuinely separate products are present; when in doubt\n about whether something is a separate product vs. an accessory/spec of the main one, keep\n it as a single item. Do not invent products that are not in the text.\n\nSTRICT RULES — include ONLY items that are ALL of the following:\n1. A specific, concrete, tangible product that can be boxed up by a supplier and delivered to site — something you can point at and say \"this exact thing is being bought\" (e.g. \"LED luminaire 36W\", \"PVC pipe 110mm\", \"Distribution board 12-way\", \"CO2 fire extinguisher 6kg\").\n2. Has a real quantity (not 0 or \"varies\" with no meaning).\n3. Has enough description to identify what the product is.\n4. Is an actual PRODUCT, not a sentence, instruction, or specification clause. REJECT rows that are prose such as \"the supervising engineer's instructions\", \"The price includes all materials, labor\", \"Measurement will be based on the geometric cubic meter\", \"necessary steps to complete the work\", \"soil-contacting elements\", \"in compliance\", \"everything necessary\". These are description fragments, not buyable items.\n\nNEVER extract abstract concepts, disciplines, trades, systems, or section/category names — they are NOT products even when they appear as their own row:\n- Bare discipline/trade/system words such as: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", and their Arabic equivalents (\"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\", \"تشطيبات\").\n- Test: if the word names a FIELD OF WORK or a SYSTEM rather than one buyable item with a unit (pcs, m, set, kg…), REJECT it.\n\nEXCLUDE all of the following (do NOT include them):\n- Labor, installation, or workmanship items (e.g. \"install\", \"fix\", \"erect\", \"dismantle\", \"painting\", \"cleaning service\")\n- Project management, overhead, or profit items\n- Insurance, bonds, liability items (e.g. \"bodily injury\", \"third party liability\", \"CAR insurance\")\n- Shop drawings, as-built drawings, method statements, documentation\n- Preliminary items, mobilisation, demobilisation, provisional sums\n- Housekeeping, site clearance, waste removal\n- Floor/level labels (e.g. \"GF Floor\", \"Level 3\") — these are headers, not products\n- Pure dimension specs without a product noun (e.g. \"Dia 32 mm\", \"25 mm\") — reject unless a product name is present\n- Security services, night shifts, rentals, payments\n- Any item whose description is too vague or fragmentary to identify a specific product\n\nEmit ONE JSON item PER real product. A single source row may therefore produce several\nitems (when it lists multiple products) or several rows may collapse into one item (when\nthey are wrapped fragments of the same product). The \"items\" array is your final delivery\nlist, not a row-by-row transcript.\n\nFor each valid physical product return JSON with:\n- description: clean product name only (strip \"supply and install\", \"supply and fix\", installation clauses, floor/spec suffixes). For a split product, use just that product's own name.\n- quantity: the EXACT numeric value from the quantity/count column (العدد, الكمية, Qty, Quantity, No.). NEVER default to 1 unless the quantity column is truly absent or empty.\n- unit: unit of measure (pcs, m, m2, m3, set, no, kg, etc.)\n- category: product category\n- brand: brand/manufacturer if mentioned, else \"\"\n- engineering_required: true if the item needs engineering design before procurement (panel boards, chillers, generators, fire suppression systems, BMS, pumps, AHUs, switchgear, transformers), else false\n\nReturn ONLY a valid JSON object: {\"items\": [...]}. No explanations, no markdown, no extra text.\nPROMPT);\n }\n\n private function mockResponse(): array\n {\n return [\n 'success' => true,\n 'error' => null,\n 'items' => [[\n 'description' => 'Sample BOQ Item',\n 'quantity' => 1,\n 'unit' => 'No',\n 'category' => 'Sample',\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 1,\n 'raw_data' => [],\n 'ai_extracted' => true,\n ]],\n 'rejected' => [],\n ];\n }\n\n private function failure(string $message, bool $serviceUnavailable = false): array\n {\n return [\n 'success' => false,\n 'items' => [],\n 'rejected' => [],\n 'error' => $this->userFacingError($message, $serviceUnavailable),\n 'service_unavailable' => $serviceUnavailable,\n ];\n }\n\n /**\n * Translate any internal error into a neutral, user-safe message.\n *\n * The user must never see the name of the underlying AI/processing engine\n * (DeepSeek, Groq, Gemini, Vision, etc.) or configuration hints like API\n * keys. The detailed message is kept in the logs for debugging only.\n */\n private function userFacingError(string $message, bool $serviceUnavailable = false): string\n {\n // Detect any reference to an AI provider / engine / API config so we can\n // swap it for a generic, branded-neutral message.\n $sensitive = '/deepseek|groq|gemini|vision|openai|openrouter|api[\\s_-]?key|\\.env|non-?json|token limit/i';\n\n if (preg_match($sensitive, $message)) {\n Log::warning('QuotationAiService: suppressed technical error from user.', ['internal' => $message]);\n\n return $serviceUnavailable\n ? 'The file could not be processed right now. Please try again in a moment.'\n : 'We could not read this file automatically. Please make sure it is a clear BOQ (Excel, CSV, PDF, or image) and try again, or add the items manually.';\n }\n\n return $message;\n }\n\n private function mimeForExtension(string $ext): string\n {\n return match (strtolower($ext)) {\n 'pdf' => 'application/pdf',\n 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n 'xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12',\n 'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',\n 'xls' => 'application/vnd.ms-excel',\n 'csv' => 'text/csv',\n 'jpg', 'jpeg' => 'image/jpeg',\n 'png' => 'image/png',\n 'gif' => 'image/gif',\n 'bmp' => 'image/bmp',\n 'webp' => 'image/webp',\n 'tiff', 'tif' => 'image/tiff',\n 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'doc' => 'application/msword',\n default => 'application/octet-stream',\n };\n }\n}\n"}, "a660dac8c322b56ea312fba576f45cecfac61ae4": {"t": 1785748360.9265428, "n": 88241, "h": "e5922490ac1978ee", "c": "<?php\n\nnamespace App\\Services;\n\nuse Illuminate\\Http\\Client\\ConnectionException;\nuse Illuminate\\Http\\UploadedFile;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\nuse PhpOffice\\PhpSpreadsheet\\Cell\\Coordinate;\nuse PhpOffice\\PhpSpreadsheet\\IOFactory;\n\nclass QuotationAiService\n{\n /** Increment this whenever BOQ parsing logic changes to invalidate old caches. */\n private const PARSER_VERSION = 10;\n private const LARGE_BOQ_VERIFICATION_THRESHOLD = 2000;\n private const LARGE_BOQ_VERIFICATION_CHUNK_SIZE = 120;\n\n /**\n * Characters of document text sent per AI call.\n *\n * Above this the document is split and parsed over several calls. Sized well\n * under the model's context so the prompt, the slice, and a full JSON answer\n * for every row in it all fit — the binding limit is the *output* budget, not\n * the input, since each input row produces a JSON object.\n */\n private const TEXT_CHUNK_CHARS = 60000;\n\n /**\n * A workbook with more sheets than this is split regardless of its size.\n *\n * Multiple tabs almost always mean multiple disciplines (electrical, HVAC,\n * civil…). Sending them in one call makes the model summarise across\n * sections and silently drop rows, so each slice gets a narrower scope.\n */\n private const MAX_SHEETS_PER_CALL = 2;\n\n /** A PDF longer than this is split regardless of its extracted text length. */\n private const MAX_PDF_PAGES_PER_CALL = 5;\n\n private string $baseUrl;\n private string $parseEndpoint;\n private string $apiKey;\n private int $timeout;\n private bool $testMode;\n private BoqCleaningService $boqCleaner;\n\n /** Page count of the most recently parsed PDF; 0 when unknown. */\n private int $lastPdfPageCount = 0;\n\n /**\n * Optional progress reporter for chunked parsing, called as ($part, $total).\n *\n * Set by the queue job so the polling UI can show real progress on a large\n * BOQ instead of a spinner that appears frozen for many minutes.\n *\n * @var (callable(int, int): void)|null\n */\n private $onChunkProgress = null;\n\n /** @param callable(int, int): void $callback */\n public function onChunkProgress(callable $callback): self\n {\n $this->onChunkProgress = $callback;\n return $this;\n }\n\n /**\n * Optional per-chunk item sink, called as ($items, $part, $total).\n *\n * Lets the caller persist each slice's rows the moment they arrive instead\n * of waiting for the whole document. On a large BOQ this is the difference\n * between a table that fills in progressively and one that stays empty for\n * many minutes.\n *\n * @var (callable(array, int, int): void)|null\n */\n private $onChunkItems = null;\n\n /** @param callable(array, int, int): void $callback */\n public function onChunkItems(callable $callback): self\n {\n $this->onChunkItems = $callback;\n return $this;\n }\n\n /**\n * Report a non-chunk stage (local parse, handing off to AI) to the caller.\n *\n * A spreadsheet is read locally first and only sent to the AI when that\n * fails, so a large file can spend minutes before any chunk exists. Without\n * these the UI shows a generic spinner for that whole period.\n */\n private function reportStage(string $message): void\n {\n if ($this->onStage !== null) {\n ($this->onStage)($message);\n }\n }\n\n /**\n * Report a workbook's shape from grids that are already in memory.\n *\n * Deliberately takes the parsed grids rather than a path: re-reading a large\n * spreadsheet just to count its sheets would double the slowest step of the\n * whole extraction.\n *\n * @param array<int, array{name: string, grid: array}> $sheets\n */\n private function reportWorkbookShape(array $sheets): void\n {\n if ($this->onStage === null || empty($sheets)) {\n return;\n }\n\n $rows = 0;\n foreach ($sheets as $sheet) {\n $rows += count($sheet['grid']);\n }\n\n $count = count($sheets);\n\n $this->reportStage($count > 1\n ? \"Found {$count} sheets, {$rows} rows in total. Reading…\"\n : \"Found {$rows} rows. Reading…\");\n }\n\n /** @var (callable(string): void)|null */\n private $onStage = null;\n\n /** @param callable(string): void $callback */\n public function onStage(callable $callback): self\n {\n $this->onStage = $callback;\n return $this;\n }\n\n public function __construct(BoqCleaningService $boqCleaner)\n {\n $this->boqCleaner = $boqCleaner;\n $this->baseUrl = rtrim((string) config('services.ai_quotation.base_url', ''), '/');\n $this->parseEndpoint = ltrim((string) config('services.ai_quotation.parse_endpoint', 'parse'), '/');\n $this->apiKey = (string) config('services.ai_quotation.api_key', '');\n $this->timeout = (int) config('services.ai_quotation.timeout', 300);\n $this->testMode = (bool) config('services.ai_quotation.test_mode', false);\n }\n\n /**\n * Give the running script enough head-room to outlast the AI HTTP call.\n *\n * The web request / queue worker must not be killed before the HTTP timeout\n * completes, otherwise the user sees a generic \"timeout or unavailable\"\n * error even though the AI may have responded. We allow the HTTP timeout\n * plus a 120s margin for connecting, response parsing and DB writes.\n */\n private function extendExecutionTime(): void\n {\n // Ignored on some SAPIs (e.g. when running under fastcgi with a hard\n // limit), but harmless to attempt.\n @set_time_limit($this->timeout + 120);\n }\n\n public function parseBoq(UploadedFile|string $file, array $context = []): array\n {\n if ($this->testMode) {\n return $this->mockResponse();\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n\n if (! is_file($absPath)) {\n return $this->failure('File not found or not readable.');\n }\n\n $fileHash = hash_file('sha256', $absPath);\n // Bump PARSER_VERSION whenever the extraction logic changes so that\n // previously-uploaded files are re-parsed instead of returning a stale\n // cached result produced by the old code.\n $cacheKey = 'boq_analysis_v' . self::PARSER_VERSION . '_' . $fileHash;\n $forceRefresh = (bool) ($context['force_refresh'] ?? false);\n\n if (! $forceRefresh && ($cached = Cache::get($cacheKey)) !== null) {\n return $cached;\n }\n\n // Clear stale cached result when force-refreshing\n if ($forceRefresh) {\n Cache::forget($cacheKey);\n }\n\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n // Read Excel locally first. Fall back to AI if local parse failed,\n // found 0 items, OR could not read one of the sheets (so multi-sheet\n // files never silently drop a sheet the local parser didn't understand).\n $this->reportStage('Reading the spreadsheet…');\n\n $result = $this->parseSpreadsheetDirect($absPath);\n\n $hasSkippedSheets = ! empty($result['skipped_sheets']);\n\n if (! $result['success'] || empty($result['items']) || $hasSkippedSheets) {\n // The local pass could not handle this file on its own. Say so:\n // otherwise the UI sits on a generic spinner through the local\n // parse AND the whole AI parse with nothing to show for either.\n $this->reportStage($hasSkippedSheets\n ? 'Some sheets need AI to read. Sending to AI…'\n : 'Spreadsheet needs AI to read. Sending to AI…');\n\n $deepSeekResult = $this->parseBoqWithDeepSeek($file, $context);\n // AI sees every sheet at once, so prefer its result whenever it\n // actually returned items (covers the skipped-sheet case too).\n if ($deepSeekResult['success'] && ! empty($deepSeekResult['items'])) {\n // Keep the larger of the two item sets so we never lose the\n // rows the local parser already extracted correctly.\n if (count($deepSeekResult['items']) >= count($result['items'] ?? [])) {\n $result = $deepSeekResult;\n }\n } elseif (! $result['success']) {\n $result = $deepSeekResult;\n }\n // else: keep local result if AI also failed/returned nothing\n }\n } elseif (in_array($ext, ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif'], true)) {\n $result = $this->parseBoqWithDeepSeek($file, $context);\n } else {\n return $this->failure('Unsupported file type. Please upload Excel, CSV, PDF, or image file.');\n }\n\n if ($result['success'] && ! empty($result['items'])) {\n $cleaning = $this->boqCleaner->process($result['items']);\n $result['items'] = $cleaning['accepted'] ?? $result['items'];\n $result['rejected'] = array_merge($result['rejected'] ?? [], $cleaning['rejected'] ?? []);\n }\n\n if ($result['success'] && count($result['items'] ?? []) > self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n $result = $this->verifyLargeBoqItemsWithAi($result);\n }\n\n // Only cache when we actually extracted items; empty results should be re-tried.\n if ($result['success'] && ! empty($result['items'])) {\n Cache::put($cacheKey, $result, now()->addDays(30));\n }\n\n return $result;\n }\n\n private function resolveFile(UploadedFile|string $file): array\n {\n if ($file instanceof UploadedFile) {\n return [(string) $file->getRealPath(), strtolower($file->getClientOriginalExtension())];\n }\n\n $absPath = is_file($file) ? $file : storage_path('app/' . ltrim($file, '/'));\n return [$absPath, strtolower(pathinfo($absPath, PATHINFO_EXTENSION))];\n }\n\n private function parseSpreadsheetDirect(string $absPath): array\n {\n ini_set('memory_limit', '2048M');\n\n // 180s was not enough for a real BOQ: an 11 200-row workbook was killed\n // mid-parse, and because set_time_limit() kills the process outright the\n // job reported a bare failure with nothing in the log to explain it.\n // This runs on the queue, where wall time is cheap.\n set_time_limit(1800);\n\n try {\n $sheets = $this->spreadsheetToGrids($absPath);\n\n if (empty($sheets)) {\n return $this->failure('Could not read the Excel file.');\n }\n\n $this->reportWorkbookShape($sheets);\n\n $items = [];\n $rejected = [];\n $skippedSheets = []; // sheets we could not parse locally (header undetected)\n\n foreach ($sheets as $sheet) {\n $grid = $sheet['grid'];\n if (count($grid) < 2) {\n continue;\n }\n\n // Skip dedicated summary / cost-abstract sheets entirely. Their rows\n // are per-discipline totals (Structural Concrete, Electrical, …) and\n // grand totals, not procurable products.\n if ($this->isSummarySheet($sheet['name'])) {\n Log::info('QuotationAiService: skipped summary sheet.', ['sheet' => $sheet['name']]);\n continue;\n }\n\n $header = $this->detectHeader($grid);\n\n if ($header === null || ! isset($header['map']['description'])) {\n Log::warning('QuotationAiService: header not detected in sheet.', ['sheet' => $sheet['name']]);\n $skippedSheets[] = $sheet['name'];\n continue;\n }\n\n $map = $header['map'];\n $start = $header['row'] + 1;\n\n for ($r = $start; $r < count($grid); $r++) {\n $row = $grid[$r];\n $description = $this->cell($row, $map['description'] ?? null);\n $itemNo = $this->cell($row, $map['item_code'] ?? null);\n $unit = $this->cell($row, $map['unit'] ?? null);\n $quantity = $this->number($this->cell($row, $map['quantity'] ?? null));\n $unitPrice = $this->number($this->cell($row, $map['unit_price'] ?? null));\n $totalPrice = $this->number($this->cell($row, $map['total_price'] ?? null));\n\n if ($description === '') {\n continue;\n }\n\n if (! isset($map['quantity'])) {\n $quantity = 1.0;\n }\n\n // Skip floor breakdown sub-rows: Ref. is \"-\" meaning it's a per-floor\n // quantity split of the parent item, not a standalone product.\n if ($itemNo === '-' || $this->isFloorBreakdownRow($itemNo, $description)) {\n continue;\n }\n\n if ($this->isTotalOrHeaderRow($description)) {\n continue;\n }\n\n if ($quantity === null || $quantity <= 0) {\n $rejected[] = $this->rejectedRow($description, $unit, $quantity, 'Missing quantity', $itemNo, $sheet['name']);\n continue;\n }\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $supply = $this->boqCleaner->filterItem($description, $unitPrice, $quantity);\n\n if (! ($supply['keep'] ?? false)) {\n $rejected[] = $this->rejectedRow(\n $description,\n $unit,\n $quantity,\n (string) ($supply['rejection_reason'] ?? 'Rejected by cleaner'),\n $itemNo,\n $sheet['name']\n );\n continue;\n }\n\n $finalDescription = (string) ($supply['description'] ?? $description);\n\n $items[] = [\n 'description' => $finalDescription,\n 'quantity' => $quantity,\n 'unit' => $unit,\n 'category' => $sheet['name'],\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($finalDescription),\n 'unit_price' => $unitPrice,\n 'confidence' => 0.95,\n 'needs_review' => false,\n 'raw_data' => [\n 'sheet' => $sheet['name'],\n 'row_number' => $r + 1,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'cleaned_description' => $finalDescription,\n 'extraction_type' => (string) ($supply['extraction_type'] ?? 'supply_only'),\n ],\n 'ai_extracted' => true,\n ];\n }\n }\n\n if (empty($items)) {\n return $this->failure('No BOQ supply items could be extracted from the Excel file.');\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'skipped_sheets' => $skippedSheets,\n ];\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: parseSpreadsheetDirect failed.', [\n 'path' => $absPath,\n 'message' => $e->getMessage(),\n 'trace' => $e->getTraceAsString(),\n ]);\n\n return $this->failure('Failed to read the Excel file: ' . $e->getMessage());\n }\n }\n\n private function spreadsheetToGrids(string $absPath): array\n {\n $ext = strtolower(pathinfo($absPath, PATHINFO_EXTENSION));\n\n if ($ext === 'csv') {\n $rows = [];\n $handle = fopen($absPath, 'rb');\n if ($handle !== false) {\n while (($row = fgetcsv($handle)) !== false) {\n $rows[] = $this->trimTrailingEmptyCells($row);\n }\n fclose($handle);\n }\n return [['name' => 'Sheet1', 'grid' => $rows]];\n }\n\n $reader = IOFactory::createReader($ext === 'xls' ? 'Xls' : 'Xlsx');\n $reader->setReadDataOnly(true);\n $spreadsheet = $reader->load($absPath);\n $sheets = [];\n\n foreach ($spreadsheet->getAllSheets() as $sheet) {\n $highestRow = $sheet->getHighestDataRow();\n $highestColumn = $sheet->getHighestDataColumn();\n $highestColumnIndex = Coordinate::columnIndexFromString($highestColumn);\n\n // BOQ data is normally in first 8-20 columns.\n // Files with heavy formatting/images can report 300+ columns — limit to 30.\n $maxColumnIndex = min($highestColumnIndex, 30);\n\n $grid = [];\n for ($row = 1; $row <= $highestRow; $row++) {\n $rowData = [];\n $hasValue = false;\n\n for ($col = 1; $col <= $maxColumnIndex; $col++) {\n $value = $sheet->getCell([$col, $row])->getValue();\n $value = $this->cellValueToString($value);\n $value = trim($value);\n if ($value !== null && $value !== '') {\n $hasValue = true;\n }\n $rowData[] = $value;\n }\n\n $rowData = $this->trimTrailingEmptyCells($rowData);\n if ($hasValue) {\n $grid[] = $rowData;\n }\n }\n\n if (! empty($grid)) {\n $sheets[] = ['name' => $sheet->getTitle(), 'grid' => $grid];\n }\n }\n\n $spreadsheet->disconnectWorksheets();\n unset($spreadsheet);\n\n return $sheets;\n }\n\n private function detectHeader(array $grid): ?array\n {\n $keywords = [\n 'item_code' => ['رقم البند', 'item no', 'item number', 'ref.', 'ref', 'item', 'no.', 'no', '#', 'كود'],\n 'description' => ['وص٠البند', 'scope of works', 'scope of work', 'scope', 'item description', 'description of works', 'description', 'بالعربية', 'الوصÙ', 'البند', 'بيان'],\n 'unit' => ['Ø§Ù„ÙˆØØ¯Ø©', 'unit', 'uom', 'u/m'],\n 'quantity' => ['الكمية', 'qty', 'quantity', 'q.ty'],\n 'unit_price' => ['سعر Ø§Ù„ÙˆØØ¯Ø©', 'u/price', 'unit price', 'unit rate', 'rate', 'price'],\n 'total_price' => ['السعر الإجمالي', 'total amount', 'total price', 'amount', 'الإجمالي'],\n ];\n\n $best = null;\n $bestScore = 0;\n\n for ($r = 0; $r < min(30, count($grid)); $r++) {\n $map = [];\n $score = 0;\n\n foreach ($grid[$r] as $c => $value) {\n $cell = mb_strtolower(trim((string) $value));\n if ($cell === '') {\n continue;\n }\n\n foreach ($keywords as $field => $list) {\n foreach ($list as $kw) {\n if (str_contains($cell, mb_strtolower($kw))) {\n if (! isset($map[$field])) {\n $map[$field] = $c;\n $score++;\n }\n break 2;\n }\n }\n }\n }\n\n if ($score > $bestScore) {\n $bestScore = $score;\n $best = ['row' => $r, 'map' => $map];\n }\n }\n\n if ($bestScore >= 3) {\n return $best;\n }\n\n // Allow simpler BOQ sheets that only define a description column plus one\n // other BOQ-related column, e.g. Item + Description or Description + Qty.\n if ($bestScore === 2 && isset($best['map']['description']) && (\n isset($best['map']['item_code']) ||\n isset($best['map']['quantity']) ||\n isset($best['map']['unit'])\n )) {\n return $best;\n }\n\n return null;\n }\n\n private function cell(array $row, ?int $index): string\n {\n if ($index === null) {\n return '';\n }\n return trim((string) ($row[$index] ?? ''));\n }\n\n private function number(string $value): ?float\n {\n $value = trim(str_replace([',', 'SAR', 'ر.س'], '', $value));\n if ($value === '' || ! is_numeric($value)) {\n return null;\n }\n return (float) $value;\n }\n\n private function trimTrailingEmptyCells(array $row): array\n {\n while (! empty($row) && trim((string) end($row)) === '') {\n array_pop($row);\n }\n return array_values($row);\n }\n\n private function isTotalOrHeaderRow(string $description): bool\n {\n $d = mb_strtolower(trim($description));\n\n if ($d === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $d)) {\n return true;\n }\n\n // Section totals / summaries\n if (preg_match('/(اجمالي|إجمالي|المجموع|grand\\s*total|sub\\s*total|total\\s*amount|total\\s*price)/iu', $d)) {\n return true;\n }\n\n // Bare \"Total\" lines, optionally with a currency suffix, e.g. \"Total\",\n // \"Total (SAR)\", \"Total cost\", \"Total (SAR) unit cost\".\n if (preg_match('/^total\\b[\\s\\(\\):a-z]*$/iu', $d)) {\n return true;\n }\n\n // Pure floor / level labels used as section headers\n if (preg_match('/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor)\\s*$/iu', $d)) {\n return true;\n }\n\n // Division / section header lines (e.g. \"Division - 02: Wood Works\")\n if (preg_match('/^division\\s*([-:]|\\d)/iu', $d)) {\n return true;\n }\n\n return false;\n }\n\n /**\n * Detect dedicated summary / cost-abstract sheets by their name. These hold\n * per-discipline rollups and grand totals (e.g. a \"Summary (Dry Cost)\" tab),\n * never procurable supply items, so they are skipped wholesale.\n */\n private function isSummarySheet(string $name): bool\n {\n $n = mb_strtolower(trim($name));\n\n if ($n === '') {\n return false;\n }\n\n return (bool) preg_match(\n '/\\b(summary|abstract|cost\\s*summary|grand\\s*summary|recap(?:itulation)?|bill\\s*summary|boq\\s*summary|dry\\s*cost)\\b|ملخص|الملخص|الملخّص|ملخّص|اجمالي|إجمالي/iu',\n $n\n );\n }\n\n private function isFloorBreakdownRow(string $itemNo, string $description): bool\n {\n // A row whose Ref column is empty or a dash is a sub-breakdown row (per-floor split).\n $ref = trim($itemNo);\n if ($ref === '' || $ref === '-') {\n // Confirm by checking the description is a location/floor label\n $d = mb_strtolower(trim($description));\n return (bool) preg_match(\n '/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor|\\d+f)\\s*$/iu',\n $d\n );\n }\n return false;\n }\n\n private function rejectedRow(string $description, string $unit, ?float $quantity, string $reason, string $itemNo = '', string $sheet = ''): array\n {\n return [\n 'description' => $description,\n 'quantity' => $quantity ?? 0,\n 'unit' => $unit,\n 'category' => $sheet,\n 'brand' => '',\n 'status' => 'rejected',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 0.0,\n 'raw_data' => [\n 'sheet' => $sheet,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'extraction_type' => 'rejected',\n 'rejection_reason' => $reason,\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function parseBoqWithDeepSeek(UploadedFile|string $file, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured. Please set DEEPSEEK_API_KEY in .env file.');\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n $mime = $this->mimeForExtension($ext);\n\n try {\n // ── Spreadsheets ──────────────────────────────────────────────────\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n $text = $this->spreadsheetToCompactCsvText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not convert spreadsheet to readable text.');\n }\n\n $text = $this->sanitizeUtf8($text);\n\n // A large BOQ exceeds what one request can carry, so send it in\n // sequential slices and merge. Anything that fits goes as-is.\n //\n // Size alone is not a good enough signal: a workbook with several\n // sheets is structurally a big BOQ even when the text is compact,\n // and asking the model to hold every discipline at once is where\n // rows start getting dropped. So split on either signal.\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS || $sheetCount > self::MAX_SHEETS_PER_CALL) {\n Log::info('QuotationAiService: spreadsheet routed to chunked parsing.', [\n 'chars' => mb_strlen($text),\n 'sheets' => $sheetCount,\n ]);\n return $this->parseTextInChunks($text, 'BOQ spreadsheet converted to CSV', $apiKey, $context);\n }\n\n $userContent = \"BOQ spreadsheet converted to CSV:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── PDF ───────────────────────────────────────────────────────────\n if ($ext === 'pdf') {\n $extracted = $this->extractPdfText($absPath);\n if ($extracted !== null && mb_strlen(trim($extracted)) > 50) {\n $extracted = $this->sanitizeUtf8($extracted);\n\n // Split on either signal — long text, or many pages. A BOQ\n // spanning several pages is a multi-section document even if\n // each page is sparse.\n if (mb_strlen($extracted) > self::TEXT_CHUNK_CHARS\n || $this->lastPdfPageCount > self::MAX_PDF_PAGES_PER_CALL) {\n Log::info('QuotationAiService: PDF routed to chunked parsing.', [\n 'chars' => mb_strlen($extracted),\n 'pages' => $this->lastPdfPageCount,\n ]);\n return $this->parseTextInChunks($extracted, 'BOQ PDF extracted text', $apiKey, $context, 'application/pdf');\n }\n\n $userContent = \"BOQ PDF extracted text:\\n\\n\" . $extracted . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'application/pdf');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n return $this->failure('Could not extract text from the PDF. Please make sure it is a text-based PDF (not a scanned image), or convert it to Excel or CSV.');\n }\n\n // -- Word documents (docx) ---------------------------------------------------\n if (in_array($ext, ['docx', 'doc'], true)) {\n $text = $this->extractDocxText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not extract text from the Word document. Please convert it to Excel, CSV, or PDF.');\n }\n $text = $this->sanitizeUtf8($text);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS) {\n return $this->parseTextInChunks($text, 'BOQ Word document extracted text', $apiKey, $context);\n }\n\n $userContent = \"BOQ Word document extracted text:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── Images ──────────────────────────────────────────────────────────────\n // DeepSeek cannot receive images. A vision-capable AI processes the image\n // directly (no OCR) — configure GROQ_API_KEY or GEMINI_API_KEY in .env.\n $imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif', 'heic', 'heif'];\n if (in_array($ext, $imageExts, true)) {\n $bytes = (string) file_get_contents($absPath);\n return $this->callDeepSeekVision($bytes, $mime, $context);\n }\n\n return $this->failure('Unsupported file type. Please upload an Excel, CSV, PDF, or image file.');\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: DeepSeek fallback failed.', ['message' => $e->getMessage()]);\n return $this->failure('An error occurred while processing the file with AI: ' . $e->getMessage());\n }\n }\n\n private function deepSeekModel(): string\n {\n return (string) config('services.deepseek.model', 'deepseek-chat');\n }\n\n /**\n * Remove or replace any non-UTF-8 / malformed byte sequences so json_encode never fails.\n */\n private function sanitizeUtf8(string $text): string\n {\n // Replace invalid byte sequences with the UTF-8 replacement character\n $clean = mb_convert_encoding($text, 'UTF-8', 'UTF-8');\n // Strip any remaining non-printable control characters (except tab/newline/CR)\n $clean = (string) preg_replace('/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]/u', '', $clean);\n return $clean;\n }\n\n /**\n * Extract plain text from a Word docx file using ZipArchive (no external library needed).\n * Reads word/document.xml and strips XML tags, preserving paragraph and table structure.\n */\n private function extractDocxText(string $absPath): ?string\n {\n try {\n $zip = new \\ZipArchive();\n if ($zip->open($absPath) !== true) {\n return null;\n }\n\n $xml = $zip->getFromName('word/document.xml');\n $zip->close();\n\n if ($xml === false || $xml === '') {\n return null;\n }\n\n // Add newlines before paragraph/table-row/table-cell tags so structure is preserved\n $xml = preg_replace('/<w:p[ >]/', \"\\n<w:p>\", $xml) ?? $xml;\n $xml = preg_replace('/<w:tr[ >]/', \"\\n<w:tr>\", $xml) ?? $xml;\n $xml = preg_replace('/<\\/w:tc>/', \"\\t\", $xml) ?? $xml;\n\n $text = strip_tags($xml);\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n return trim($text) !== '' ? trim($text) : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: DOCX parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Extract plain text from a PDF using smalot/pdfparser (pure PHP, no binary required).\n */\n private function extractPdfText(string $absPath): ?string\n {\n try {\n $parser = new \\Smalot\\PdfParser\\Parser();\n $pdf = $parser->parseFile($absPath);\n\n // Recorded for the chunking decision: a long PDF is structurally a\n // big BOQ even when its extracted text is short.\n try {\n $this->lastPdfPageCount = count($pdf->getPages());\n } catch (\\Throwable) {\n $this->lastPdfPageCount = 0;\n }\n\n $text = $pdf->getText();\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n return $text !== '' ? $text : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: PDF parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Send a vision request to a vision-capable AI model.\n * DeepSeek's commercial API (api.deepseek.com) does NOT support image inputs — any model.\n * Priority order (first configured key wins):\n * 1. GROQ_API_KEY → Groq llama-4-scout (free: https://console.groq.com)\n * 2. GEMINI_API_KEY → Google Gemini Flash (free: https://aistudio.google.com/apikey)\n * 3. VISION_API_KEY → any OpenAI-compatible endpoint (OpenAI, OpenRouter …)\n */\n private function callDeepSeekVision(string $bytes, string $mime, array $context): array\n {\n // ── 1. Groq (free, reliable vision) ─────────────────────────────────────\n if ((string) config('services.groq.key', '') !== '') {\n $visionKey = (string) config('services.groq.key');\n $visionBaseUrl = 'https://api.groq.com/openai/v1';\n $visionModel = (string) config('services.groq.model', 'meta-llama/llama-4-scout-17b-16e-instruct');\n }\n // ── 2. Google Gemini ─────────────────────────────────────────────────────\n elseif ((string) config('services.gemini.key', '') !== '') {\n $visionKey = (string) config('services.gemini.key');\n $visionBaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai';\n $visionModel = (string) config('services.gemini.model', 'gemini-2.0-flash');\n }\n // ── 3. Generic VISION_API_KEY (OpenAI, OpenRouter, …) ───────────────────\n elseif ((string) config('services.vision.key', '') !== '') {\n $visionKey = (string) config('services.vision.key');\n $visionBaseUrl = rtrim((string) config('services.vision.base_url', 'https://openrouter.ai/api/v1'), '/');\n $visionModel = (string) config('services.vision.model', 'google/gemini-flash-1.5-8b');\n }\n else {\n return $this->failure(\n 'Image processing is not available. DeepSeek\\'s API does not support images. ' .\n 'Get a free key at https://console.groq.com and add GROQ_API_KEY to your .env file.'\n );\n }\n\n $dataUrl = \"data:{$mime};base64,\" . base64_encode($bytes);\n $prompt = $this->buildDeepSeekPrompt($context, $mime);\n\n $userContent = [\n ['type' => 'text', 'text' => $prompt],\n ['type' => 'image_url', 'image_url' => ['url' => $dataUrl]],\n ];\n\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders([\n 'Authorization' => 'Bearer ' . $visionKey,\n 'HTTP-Referer' => config('app.url', 'https://qimta.sa'),\n 'X-Title' => 'Qimta Platform',\n ])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post($visionBaseUrl . '/chat/completions', [\n 'model' => $visionModel,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 16384,\n 'temperature' => 0.2,\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('Vision API connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: Vision API HTTP error.', [\n 'model' => $visionModel,\n 'base_url' => $visionBaseUrl,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n return $this->failure(\"Vision API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n return $this->parseDeepSeekJsonResponse((string) $response->json('choices.0.message.content'));\n }\n\n private function callDeepSeekChat(string $userContent, string $key, string $model): array\n {\n // Ensure the PHP script outlives the HTTP call (+ a margin for parsing the\n // response and writing to the DB) so the worker/web request is not killed\n // mid-flight, which surfaces to users as a generic \"timeout or unavailable\".\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders(['Authorization' => 'Bearer ' . $key])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 32768,\n 'temperature' => 0.1,\n 'response_format' => ['type' => 'json_object'],\n 'user' => 'Qimta_Platform',\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('DeepSeek connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: DeepSeek HTTP error.', [\n 'model' => $model,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n if ($response->status() === 401) {\n return $this->failure('DeepSeek API key is invalid or unauthorised. Please update DEEPSEEK_API_KEY in .env.', false);\n }\n return $this->failure(\"DeepSeek API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n $text = (string) $response->json('choices.0.message.content');\n return $this->parseDeepSeekJsonResponse($text);\n }\n\n /**\n * Parse a document too large for one AI call by sending it in slices.\n *\n * A 20 000-row BOQ is several megabytes of text — far beyond what a single\n * request can carry, and beyond what the model can answer without hitting its\n * output token limit. Previously the text was simply cut at 180 000 chars and\n * the remainder discarded: the parse \"succeeded\" while silently dropping most\n * of the file. Now the text is split and each slice parsed on its own call,\n * with the items merged.\n *\n * Splitting happens on line boundaries so a BOQ row is never cut in half.\n * Slices run sequentially rather than pooled: DeepSeek rate-limits hard on\n * concurrent large prompts, and this already runs on the queue where wall\n * time is cheap.\n *\n * A slice that fails does not fail the document — its rows are lost, but the\n * rest are kept and the shortfall is logged. Total failure is reported only\n * when every slice fails.\n *\n * @param string $label How the payload is introduced to the model.\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n /**\n * Split a spreadsheet into the slices the AI will be asked to read.\n *\n * Exposed so the work can be spread over several queue jobs instead of one\n * job walking every slice in sequence. Returns an empty array when the file\n * is small enough to go in a single call, or cannot be read.\n *\n * @return array<int, string>\n */\n public function chunkSpreadsheet(string $absPath): array\n {\n ini_set('memory_limit', '2048M');\n set_time_limit(1800);\n\n $text = $this->spreadsheetToCompactCsvText($absPath);\n\n if ($text === null || trim($text) === '') {\n return [];\n }\n\n $text = $this->sanitizeUtf8($text);\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) <= self::TEXT_CHUNK_CHARS && $sheetCount <= self::MAX_SHEETS_PER_CALL) {\n return [];\n }\n\n $chunks = $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n\n // Free the source text: the slices are a second full copy of it, and on\n // a large workbook that doubling — on top of PhpSpreadsheet's grids —\n // is what exhausted memory here.\n unset($text);\n\n Log::info('QuotationAiService: chunked spreadsheet.', [\n 'chunks' => count($chunks),\n 'peak_mb' => round(memory_get_peak_usage(true) / 1048576),\n ]);\n\n return $chunks;\n }\n\n /**\n * Split a spreadsheet straight to disk, one file per slice.\n *\n * chunkSpreadsheet() returns every slice as an array, which is a second\n * full copy of the document in memory on top of PhpSpreadsheet's grids.\n * This hands each slice to $write and drops it immediately, so peak memory\n * is one slice rather than the whole file twice.\n *\n * @param callable(int, string): void $write ($part, $chunk)\n * @return int number of slices written; 0 when the file needs no split\n */\n public function chunkSpreadsheetToDisk(string $absPath, callable $write): int\n {\n ini_set('memory_limit', '2048M');\n set_time_limit(1800);\n\n $text = $this->spreadsheetToCompactCsvText($absPath);\n\n if ($text === null || trim($text) === '') {\n return 0;\n }\n\n $text = $this->sanitizeUtf8($text);\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) <= self::TEXT_CHUNK_CHARS && $sheetCount <= self::MAX_SHEETS_PER_CALL) {\n return 0;\n }\n\n $part = 0;\n foreach ($this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS) as $chunk) {\n $write(++$part, $chunk);\n }\n\n unset($text);\n\n Log::info('QuotationAiService: chunked spreadsheet to disk.', [\n 'chunks' => $part,\n 'peak_mb' => round(memory_get_peak_usage(true) / 1048576),\n ]);\n\n return $part;\n }\n\n /**\n * Parse one slice produced by chunkSpreadsheet().\n *\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n public function parseChunk(string $chunk, int $part, int $total, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured.');\n }\n\n // Same framing parseTextInChunks() uses, so a slice is never mistaken\n // for a complete BOQ and totals are not inferred from a fragment.\n $userContent = \"BOQ spreadsheet converted to CSV (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, 'text/plain');\n\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n private function parseTextInChunks(\n string $text,\n string $label,\n string $apiKey,\n array $context,\n string $mime = 'text/plain',\n ): array {\n $chunks = $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n $total = count($chunks);\n\n Log::info('QuotationAiService: large document split for parsing.', [\n 'chars' => mb_strlen($text),\n 'chunks' => $total,\n ]);\n\n // Announce the split before the first slice is sent, so the UI can show\n // how many parts there are instead of sitting on a generic message for\n // however long the first AI call takes.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)(0, $total);\n }\n\n $items = [];\n $rejected = [];\n $failed = 0;\n $lastError = null;\n\n foreach ($chunks as $index => $chunk) {\n $part = $index + 1;\n\n // Report progress so a 28-slice parse does not look frozen to the user.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)($part, $total);\n }\n\n // Tell the model this is a fragment, so it does not try to reconcile\n // totals or treat a mid-document slice as a complete BOQ.\n $userContent = \"{$label} (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, $mime);\n\n $result = $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n\n if (! ($result['success'] ?? false)) {\n $failed++;\n $lastError = $result['error'] ?? 'Chunk parsing failed.';\n Log::warning('QuotationAiService: chunk failed, continuing.', [\n 'part' => $part,\n 'of' => $total,\n 'error' => $lastError,\n ]);\n continue;\n }\n\n foreach ($result['items'] as $item) {\n $items[] = $item;\n }\n foreach ($result['rejected'] ?? [] as $row) {\n $rejected[] = $row;\n }\n\n // Hand this slice's rows straight to the caller so they can be shown\n // while the remaining slices are still being parsed.\n if ($this->onChunkItems !== null && $result['items'] !== []) {\n ($this->onChunkItems)($result['items'], $part, $total);\n }\n }\n\n // Every slice failed — there is nothing to show, so report the failure.\n if ($failed === $total) {\n return $this->failure($lastError ?? 'Could not parse the document.');\n }\n\n if ($failed > 0) {\n Log::error('QuotationAiService: document parsed with missing parts.', [\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n 'items_kept' => count($items),\n ]);\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'service_unavailable' => false,\n 'partial' => $failed > 0,\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n ];\n }\n\n /**\n * Split text into slices of at most $limit characters, breaking only at\n * newlines so no BOQ row is ever cut in half.\n *\n * A single line longer than the limit (rare — a pathological cell) is passed\n * through oversized rather than severed mid-row.\n *\n * @return array<int, string>\n */\n private function splitOnLineBoundaries(string $text, int $limit): array\n {\n $chunks = [];\n $current = '';\n\n // Sheet a chunk starts in the middle of. Repeated at the top of the next\n // chunk so the model always knows which sheet the rows belong to — the\n // header would otherwise be stranded in the previous slice, and rows\n // would arrive with no discipline context.\n $currentSheet = null;\n\n foreach (explode(\"\\n\", $text) as $line) {\n $isSheetHeader = str_starts_with($line, 'Sheet: ');\n\n // Start a new chunk at every sheet boundary. Sheets are usually\n // separate disciplines, and the whole point of splitting a multi-tab\n // workbook is to give the model one discipline at a time — a size-only\n // split would leave a compact 3-tab file as a single chunk, silently\n // undoing the sheet gate that routed it here.\n if ($isSheetHeader && $current !== '') {\n $chunks[] = $current;\n $current = $line;\n $currentSheet = $line;\n continue;\n }\n\n if ($isSheetHeader) {\n $currentSheet = $line;\n }\n\n $candidate = $current === '' ? $line : $current . \"\\n\" . $line;\n\n if (mb_strlen($candidate) > $limit && $current !== '') {\n $chunks[] = $current;\n\n // Carry the sheet header over, unless this line is itself one.\n $current = ($currentSheet !== null && ! str_starts_with($line, 'Sheet: '))\n ? $currentSheet . \" (continued)\\n\" . $line\n : $line;\n continue;\n }\n\n $current = $candidate;\n }\n\n if (trim($current) !== '') {\n $chunks[] = $current;\n }\n\n return $chunks;\n }\n\n /**\n * Shared JSON response parser used by both chat and vision paths.\n */\n private function parseDeepSeekJsonResponse(string $text): array\n {\n if ($text === '') {\n return $this->failure('DeepSeek returned an empty response.');\n }\n\n // Strip markdown code fences if the model wrapped the JSON.\n if (preg_match('/```(?:json)?\\s*([\\s\\S]*?)\\s*```/i', $text, $m)) {\n $text = $m[1];\n }\n // Narrow to the outermost JSON object/array.\n if (preg_match('/[\\{\\[][\\s\\S]*[\\}\\]]/s', $text, $m)) {\n $text = $m[0];\n }\n $text = trim($text);\n\n $decoded = json_decode($text, true);\n\n // The response can be truncated when the model hits the token limit,\n // leaving an unterminated JSON string/object. Try to repair it before\n // giving up so we can still salvage the items extracted so far.\n if (! is_array($decoded)) {\n $repaired = $this->repairTruncatedJson($text);\n if ($repaired !== null) {\n $decoded = json_decode($repaired, true);\n }\n }\n\n if (! is_array($decoded)) {\n Log::error('QuotationAiService: DeepSeek non-JSON response.', ['text' => mb_substr($text, 0, 1000)]);\n return $this->failure('DeepSeek returned a non-JSON response. The file may be too large — try splitting it into smaller files.');\n }\n\n $rawItems = $decoded['items'] ?? (array_is_list($decoded) ? $decoded : []);\n $items = [];\n $rejected = [];\n\n foreach ($rawItems as $raw) {\n if (! is_array($raw)) {\n continue;\n }\n $item = $this->normaliseAiItem($raw);\n if ($item === null) {\n continue;\n }\n $check = $this->boqCleaner->filterItem((string) $item['description']);\n if ($check['keep'] ?? false) {\n $items[] = $item;\n } else {\n $item['status'] = 'rejected';\n $item['raw_data']['rejection_reason'] = $check['rejection_reason'] ?? 'Rejected by cleaner';\n $rejected[] = $item;\n }\n }\n\n if (empty($items)) {\n Log::info('QuotationAiService: DeepSeek returned 0 accepted items.', [\n 'raw_items_count' => count($rawItems),\n 'rejected_count' => count($rejected),\n 'first_rejected' => ! empty($rejected) ? ($rejected[0]['description'] ?? '') : '',\n ]);\n }\n\n return ['success' => true, 'items' => $items, 'rejected' => $rejected, 'error' => null];\n }\n\n /**\n * Best-effort repair of a truncated JSON string produced when the model hits\n * its token limit mid-output. Drops any incomplete trailing object, then\n * closes the open string/array/object brackets so the result is valid JSON.\n *\n * Returns the repaired JSON string, or null if it cannot be salvaged.\n */\n private function repairTruncatedJson(string $text): ?string\n {\n if ($text === '') {\n return null;\n }\n\n // Walk the string tracking structural state, ignoring everything inside\n // string literals (and their escapes). Remember the last position where\n // we were back at the top of the \"items\" array between complete elements,\n // so we can cut a half-written trailing object.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($text);\n $lastSafeCut = null; // index just after a completed array element\n\n for ($i = 0; $i < $len; $i++) {\n $ch = $text[$i];\n\n if ($inString) {\n if ($escaped) {\n $escaped = false;\n } elseif ($ch === '\\\\') {\n $escaped = true;\n } elseif ($ch === '\"') {\n $inString = false;\n }\n continue;\n }\n\n if ($ch === '\"') {\n $inString = true;\n } elseif ($ch === '{' || $ch === '[') {\n $stack[] = $ch;\n } elseif ($ch === '}' || $ch === ']') {\n array_pop($stack);\n } elseif ($ch === ',' && ! empty($stack) && end($stack) === '[') {\n // Separator directly inside an array: a clean boundary between\n // elements (the items array, whatever its nesting depth).\n $lastSafeCut = $i;\n }\n }\n\n // If we are still inside a string, the last element is incomplete — fall\n // back to the last clean array boundary if we have one.\n $candidate = $text;\n if ($inString && $lastSafeCut !== null) {\n $candidate = substr($text, 0, $lastSafeCut);\n } else {\n // Drop a dangling partial object after the last completed element.\n $candidate = rtrim($candidate);\n if ($lastSafeCut !== null && substr(rtrim($candidate), -1) !== '}' && substr(rtrim($candidate), -1) !== ']') {\n $candidate = substr($text, 0, $lastSafeCut);\n }\n }\n\n // Recompute the open brackets for the (possibly trimmed) candidate and\n // append the matching closers in reverse order.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($candidate);\n for ($i = 0; $i < $len; $i++) {\n $ch = $candidate[$i];\n if ($inString) {\n if ($escaped) { $escaped = false; }\n elseif ($ch === '\\\\') { $escaped = true; }\n elseif ($ch === '\"') { $inString = false; }\n continue;\n }\n if ($ch === '\"') { $inString = true; }\n elseif ($ch === '{' || $ch === '[') { $stack[] = $ch; }\n elseif ($ch === '}' || $ch === ']') { array_pop($stack); }\n }\n\n $candidate = rtrim($candidate);\n // Remove a trailing comma left after cutting an element.\n $candidate = rtrim($candidate, ',');\n\n $closers = '';\n for ($i = count($stack) - 1; $i >= 0; $i--) {\n $closers .= ($stack[$i] === '{') ? '}' : ']';\n }\n\n $repaired = $candidate . $closers;\n\n // Validate before returning.\n return is_array(json_decode($repaired, true)) ? $repaired : null;\n }\n\n private function spreadsheetToCompactCsvText(string $absPath): ?string\n {\n $sheets = $this->spreadsheetToGrids($absPath);\n if (empty($sheets)) {\n return null;\n }\n\n $output = '';\n $kept = 0;\n $dropped = 0;\n $nonSupply = 0;\n $duplicates = 0;\n\n /** @var array<string, true> Fingerprints of rows already emitted. */\n $seenRows = [];\n\n foreach ($sheets as $sheet) {\n // Don't feed summary / cost-abstract sheets to the AI either.\n if ($this->isSummarySheet($sheet['name'])) {\n continue;\n }\n $sheetRows = '';\n foreach ($sheet['grid'] as $row) {\n $row = $this->trimTrailingEmptyCells($row);\n\n if ($this->isNoiseRow($row)) {\n $dropped++;\n continue;\n }\n\n // Drop rows that are definitely not procurable before they cost\n // a single token: section totals, discipline headings,\n // preliminaries, labour-only lines. The same classifier already\n // runs on the AI's output, but by then the tokens are spent — on\n // an 11 200-row BOQ that is most of the bill.\n if ($this->isNonSupplyRow($row)) {\n $dropped++;\n $nonSupply++;\n continue;\n }\n\n // Byte-identical rows tell the AI nothing new. Only exact\n // duplicates are collapsed — description, quantity, unit and all\n // — so nothing is lost: two rows differing in quantity are two\n // different line items and both are sent.\n $fingerprint = $this->rowFingerprint($row);\n\n if (isset($seenRows[$fingerprint])) {\n $duplicates++;\n $dropped++;\n continue;\n }\n $seenRows[$fingerprint] = true;\n\n $sheetRows .= implode(',', array_map([$this, 'csvEscape'], $this->stripBoilerplate($row))) . \"\\n\";\n $kept++;\n }\n\n // A sheet whose rows were all noise contributes nothing but its own\n // header, which would just be another thing for the model to explain.\n if ($sheetRows === '') {\n continue;\n }\n\n $output .= 'Sheet: ' . $sheet['name'] . \"\\n\" . $sheetRows . \"\\n\";\n }\n\n $blank = $dropped - $nonSupply - $duplicates;\n\n Log::info('QuotationAiService: filtered rows before sending to AI.', [\n 'kept' => $kept,\n 'blank' => $blank,\n 'non_supply' => $nonSupply,\n 'duplicates' => $duplicates,\n ]);\n\n if ($dropped > 0) {\n $parts = [];\n if ($blank > 0) { $parts[] = \"{$blank} blank\"; }\n if ($nonSupply > 0) { $parts[] = \"{$nonSupply} non-supply\"; }\n if ($duplicates > 0) { $parts[] = \"{$duplicates} duplicate\"; }\n\n $this->reportStage('Skipped ' . implode(', ', $parts) . \" rows. Sending {$kept}…\");\n }\n\n return $output;\n }\n\n /**\n * True when a row carries no information worth sending to the AI.\n *\n * A real BOQ is mostly formatting: spacer rows, stray borders, a lone\n * section number, a leftover cell from a merged block. They survive\n * trimTrailingEmptyCells() because they are not *entirely* empty, then cost\n * tokens, dilute the prompt, and inflate the number of parts a large file is\n * split into.\n *\n * Deliberately conservative — a row is dropped only when nothing in it could\n * name a product. Anything with letters is kept, because a description is\n * the one field an item cannot do without.\n *\n * @param array<int, mixed> $row\n */\n /**\n * True when a row is definitely not a procurable supply item.\n *\n * Runs BoqCleaningService over the row's longest text cell — the one most\n * likely to be the description — so headings, totals, preliminaries and\n * labour-only lines never reach the AI. The same classifier already vets the\n * AI's *output*; doing it here as well means we stop paying to have those\n * rows read in the first place.\n *\n * Conservative by construction: only rows the classifier positively rejects\n * are dropped. Anything it is unsure about goes to the AI, which is the\n * better judge — a wrongly dropped row is invisible, a wrongly kept one only\n * costs tokens.\n *\n * @param array<int, mixed> $row\n */\n /**\n * Stable identity for a row, used to collapse exact duplicates.\n *\n * Whitespace and case are normalised so \"DN50 PIPE\" and \"dn50 pipe \" are one\n * row, but nothing else is: a differing quantity or unit produces a\n * different fingerprint and both rows are sent.\n *\n * @param array<int, mixed> $row\n */\n private function rowFingerprint(array $row): string\n {\n $parts = array_map(\n fn($cell) => mb_strtolower(preg_replace('/\\s+/u', ' ', trim((string) $cell)) ?? ''),\n $row,\n );\n\n // Drop a leading BOQ sequence number. Otherwise \"1,Pipe,m,100\" and\n // \"2,Pipe,m,100\" fingerprint differently and the dedup never fires —\n // which is exactly the shape every real BOQ has.\n if (isset($parts[0]) && $parts[0] !== '' && preg_match('/^[\\d.]+$/', $parts[0])) {\n array_shift($parts);\n }\n\n return md5(implode('|', $parts));\n }\n\n /**\n * Strip contractual boilerplate from a row's description cell.\n *\n * \"as per drawings\", \"complete with all necessary accessories\" and friends\n * repeat on nearly every line of a real BOQ and carry no information the\n * pricing engine can use — but they are a large share of the tokens.\n *\n * Only the longest text cell is touched, and only when the result is still a\n * usable description: if stripping would leave too little to identify the\n * product, the original is kept. A shorter-but-wrong description is far\n * worse than a verbose one.\n *\n * @param array<int, mixed> $row\n * @return array<int, mixed>\n */\n private function stripBoilerplate(array $row): array\n {\n $index = null;\n $longest = '';\n\n foreach ($row as $i => $cell) {\n $text = trim((string) $cell);\n if (preg_match('/\\p{L}/u', $text) && mb_strlen($text) > mb_strlen($longest)) {\n $longest = $text;\n $index = $i;\n }\n }\n\n // Nothing worth cleaning, or too short to risk it.\n if ($index === null || mb_strlen($longest) < 40) {\n return $row;\n }\n\n try {\n $cleaned = $this->boqCleaner->cleanDescription($longest);\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: boilerplate strip failed, keeping row as-is.', [\n 'error' => $e->getMessage(),\n ]);\n return $row;\n }\n\n // Guard against an over-eager pattern eating the product name: keep the\n // original unless a recognisable description survives.\n if (mb_strlen($cleaned) < 12 || mb_strlen($cleaned) < mb_strlen($longest) * 0.3) {\n return $row;\n }\n\n $row[$index] = $cleaned;\n\n return $row;\n }\n\n private function isNonSupplyRow(array $row): bool\n {\n $description = '';\n foreach ($row as $cell) {\n $text = trim((string) $cell);\n // Longest cell containing letters — numbers are qty/rate columns.\n if (preg_match('/\\p{L}/u', $text) && mb_strlen($text) > mb_strlen($description)) {\n $description = $text;\n }\n }\n\n // No text at all: leave it to isNoiseRow()/the AI rather than guessing.\n if ($description === '' || mb_strlen($description) < 3) {\n return false;\n }\n\n try {\n $verdict = $this->boqCleaner->filterItem($description);\n } catch (\\Throwable $e) {\n // A classifier failure must never silently drop a real product.\n Log::warning('QuotationAiService: pre-filter classifier failed, keeping row.', [\n 'error' => $e->getMessage(),\n ]);\n return false;\n }\n\n return ! ($verdict['keep'] ?? true);\n }\n\n private function isNoiseRow(array $row): bool\n {\n if (empty($row)) {\n return true;\n }\n\n $joined = trim(implode('', array_map(fn($c) => trim((string) $c), $row)));\n\n // Nothing but separators/whitespace.\n if ($joined === '') {\n return true;\n }\n\n // Ruled lines and decorative fills (\"-----\", \"====\", \"___\").\n if (preg_match('/^[\\-_=.*\\s|]+$/u', $joined)) {\n return true;\n }\n\n // Any letter — Latin or Arabic — means this could be a description.\n if (preg_match('/\\p{L}/u', $joined)) {\n return false;\n }\n\n // No letters at all: numbers only. A single number is a stray row\n // number or a dangling total; two or more could be qty/rate/amount\n // belonging to a real line, so keep those for the AI to judge.\n $numericCells = 0;\n foreach ($row as $cell) {\n if (trim((string) $cell) !== '') {\n $numericCells++;\n }\n }\n\n return $numericCells <= 1;\n }\n\n /**\n * Safely convert a PhpSpreadsheet cell value to a string.\n *\n * Cells may contain RichText or, in malformed/heavily-formatted files,\n * embedded objects such as Worksheet\\Drawing that have no __toString().\n * Casting those directly throws \"Object ... could not be converted to string\".\n */\n private function cellValueToString(mixed $value): string\n {\n if ($value === null) {\n return '';\n }\n\n if ($value instanceof \\PhpOffice\\PhpSpreadsheet\\RichText\\RichText) {\n return $value->getPlainText();\n }\n\n if (is_object($value)) {\n // Only stringify objects that explicitly support it; ignore the rest\n // (e.g. Worksheet\\Drawing) instead of crashing.\n return method_exists($value, '__toString') ? (string) $value : '';\n }\n\n if (is_array($value)) {\n return '';\n }\n\n return (string) $value;\n }\n\n private function csvEscape(mixed $value): string\n {\n $value = $this->cellValueToString($value);\n return str_contains($value, ',') || str_contains($value, '\"') || str_contains($value, \"\\n\")\n ? '\"' . str_replace('\"', '\"\"', $value) . '\"'\n : $value;\n }\n\n private function verifyLargeBoqItemsWithAi(array $result): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n Log::warning('QuotationAiService: large BOQ verification skipped, AI key is not configured.');\n return $result;\n }\n\n $items = array_values(array_filter($result['items'] ?? [], 'is_array'));\n if (count($items) <= self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n return $result;\n }\n\n Log::info('QuotationAiService: starting second-pass physical item verification.', [\n 'items_count' => count($items),\n 'threshold' => self::LARGE_BOQ_VERIFICATION_THRESHOLD,\n ]);\n\n $verifiedItems = [];\n $rejectedItems = $result['rejected'] ?? [];\n\n foreach (array_chunk($items, self::LARGE_BOQ_VERIFICATION_CHUNK_SIZE) as $chunkIndex => $chunk) {\n $payload = array_map(fn(array $item) => [\n 'description' => (string) ($item['description'] ?? ''),\n 'quantity' => $item['quantity'] ?? null,\n 'unit' => (string) ($item['unit'] ?? ''),\n 'unit_price' => $item['unit_price'] ?? null,\n 'category' => (string) ($item['category'] ?? ''),\n 'brand' => (string) ($item['brand'] ?? ''),\n ], $chunk);\n\n $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);\n if (! is_string($json)) {\n Log::warning('QuotationAiService: large BOQ verification chunk JSON encode failed.', [\n 'chunk' => $chunkIndex + 1,\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n $prompt = <<<PROMPT\nYou are doing a SECOND PASS verification on a BOQ extraction result.\n\nThe first pass may hallucinate non-products when the BOQ has thousands of rows.\nYour job is to keep ONLY physical, tangible, procurable supply products.\n\nRules:\n- Keep items only if a supplier can actually deliver them as materials/equipment.\n- Reject headings, trade names, systems, scopes, prose, specifications-only rows, totals, labor, installation, testing, commissioning, supervision, drawings, insurance, preliminaries, mobilisation, site works, and vague fragments.\n- Do not invent new products.\n- Do not add items that are not in the input.\n- You may clean a product description, but keep the original quantity, unit, unit_price, category, and brand when available.\n\nInput JSON array:\n{$json}\n\nReturn ONLY valid JSON in this shape:\n{\"items\":[{\"description\":\"\",\"quantity\":1,\"unit\":\"\",\"unit_price\":null,\"category\":\"\",\"brand\":\"\",\"confidence\":0.95}]}\nPROMPT;\n\n try {\n $chunkResult = $this->callDeepSeekChat($prompt, $apiKey, $this->deepSeekModel());\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: large BOQ verification chunk threw an exception.', [\n 'chunk' => $chunkIndex + 1,\n 'message' => $e->getMessage(),\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n if ($chunkResult['success'] ?? false) {\n array_push($verifiedItems, ...($chunkResult['items'] ?? []));\n $rejectedItems = array_merge($rejectedItems, $chunkResult['rejected'] ?? []);\n continue;\n }\n\n Log::warning('QuotationAiService: large BOQ verification chunk failed, keeping original chunk.', [\n 'chunk' => $chunkIndex + 1,\n 'error' => $chunkResult['error'] ?? null,\n ]);\n array_push($verifiedItems, ...$chunk);\n }\n\n if (empty($verifiedItems)) {\n Log::warning('QuotationAiService: second-pass verification returned no items, keeping first-pass result.', [\n 'original_items_count' => count($items),\n ]);\n return $result;\n }\n\n $result['items'] = $verifiedItems;\n $result['rejected'] = $rejectedItems;\n $result['large_boq_verified'] = true;\n\n Log::info('QuotationAiService: second-pass physical item verification completed.', [\n 'before' => count($items),\n 'after' => count($verifiedItems),\n ]);\n\n return $result;\n }\n\n private function normaliseAiItem(array $raw): ?array\n {\n if (! empty($raw['rejected'])) {\n return null;\n }\n\n $description = trim((string) ($raw['cleaned_description'] ?? $raw['product_name'] ?? $raw['description'] ?? ''));\n if ($description === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $description)) {\n return null;\n }\n\n $quantity = is_numeric($raw['quantity'] ?? null) ? (float) $raw['quantity'] : 1;\n $unitPrice = is_numeric($raw['unit_price'] ?? null) ? (float) $raw['unit_price'] : null;\n $totalPrice = is_numeric($raw['total_price'] ?? null) ? (float) $raw['total_price'] : null;\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $specs = is_array($raw['specifications'] ?? null) ? $raw['specifications'] : [];\n\n return [\n 'description' => $description,\n 'quantity' => $quantity,\n 'unit' => (string) ($raw['unit'] ?? ''),\n 'category' => (string) ($raw['core_product'] ?? $raw['category'] ?? ''),\n 'brand' => (string) ($specs['brand'] ?? $raw['brand'] ?? ''),\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($description),\n 'unit_price' => $unitPrice,\n 'confidence' => is_numeric($raw['confidence'] ?? null) ? (float) $raw['confidence'] : null,\n 'needs_review' => ! empty($raw['needs_review']),\n 'raw_data' => [\n 'source_item_no' => (string) ($raw['source_item_no'] ?? ''),\n 'original_description' => (string) ($raw['original_description'] ?? $description),\n 'cleaned_description' => $description,\n 'core_product' => (string) ($raw['core_product'] ?? ''),\n 'specifications' => $specs,\n 'extraction_type' => (string) ($raw['extraction_type'] ?? 'supply_only'),\n 'note' => (string) ($raw['note'] ?? $raw['notes'] ?? ''),\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function buildSystemInstruction(): string\n {\n return <<<'TXT'\nYou are a strict BOQ Supply Product Extraction Engine.\nExtract ONLY concrete, tangible, physical products that a supplier can box up and deliver to site — a specific item you could point at and say \"this is the thing being bought\" (e.g. \"Wall mounted LED luminaire 36W\", \"PVC pipe 110mm\", \"Single phase distribution board 12-way\", \"Fire extinguisher CO2 6kg\", \"CCTV dome camera 4MP\").\n\nABSOLUTELY REJECT abstract concepts, disciplines, categories, section names, and trade headings. These are NOT products even though they appear as rows:\n- Bare discipline/trade words: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", \"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\".\n- Summary / total / abstract / \"dry cost\" rows of any kind.\n- Anything that names a system, scope, or category instead of one buyable item.\n\nRule of thumb: if you cannot name a unit and physically hand the item over (a piece, a box, a length, a unit of equipment), REJECT it. A word that describes a field of work, a system, or a section of the BOQ is NEVER a product.\nReject totals, headings, general requirements, installation-only, testing, commissioning, supervision, mobilization, civil works, labor, and vague rows.\nFor Supply & Install rows, keep only the supply product and remove install/labor wording.\nReturn only valid JSON: {\"items\":[{\"source_item_no\":null,\"original_description\":\"\",\"cleaned_description\":\"\",\"core_product\":\"\",\"specifications\":{},\"quantity\":0,\"unit\":\"\",\"unit_price\":0,\"total_price\":0,\"extraction_type\":\"supply_only\",\"needs_review\":false,\"note\":\"\",\"rejected\":false,\"rejection_reason\":null,\"confidence\":0.95}]}\nTXT;\n }\n\n private function buildDeepSeekPrompt(array $context = [], string $mime = ''): string\n {\n $projectName = (string) ($context['project_name'] ?? '');\n $projectStatus = (string) ($context['project_status'] ?? '');\n $contextLine = $projectName !== ''\n ? \"Project: {$projectName}\" . ($projectStatus !== '' ? \" ({$projectStatus})\" : '')\n : '';\n $sourceHint = str_starts_with($mime, 'image/')\n ? 'The input is an image of a BOQ table. OCR every cell carefully — read the quantity/count column (العدد, الكمية, Qty) and extract the EXACT numeric value from each row.'\n : 'The input is a BOQ document/spreadsheet.';\n\n return trim(<<<PROMPT\nYou are a procurement specialist. Your task is to extract ONLY tangible, physical supply products from this Bill of Quantities (BOQ).\n\n{$contextLine}\n{$sourceHint}\n\nCRITICAL — MERGE WRAPPED / SPLIT ROWS INTO ONE ITEM:\nBOQ tables extracted from PDF often break ONE item's long description across several\nlines/rows. These continuation lines repeat the SAME quantity, unit and category, and\ntheir text is a grammatical continuation of the previous line (it starts mid-sentence,\nlowercase, or with \"the/and/-\", or is a spec/instruction clause). You MUST reconstruct\nthe original single item:\n- Treat consecutive rows that share the SAME quantity + unit + category as ONE product\n when the later rows are clearly continuation text of the first, NOT new products.\n- Emit ONE item with the merged description and the price counted ONCE. Do NOT repeat\n the same quantity×price for every wrapped line — that multiplies the total wrongly.\n- Example: rows \"The asphalt flooring is to be supplied\" / \"installed in accordance\" /\n \"standard road specifications...\" all with 7,100 m2 are ONE asphalt item, not 3+.\n\nCRITICAL — UNDERSTAND INTENT, DO NOT COPY ROWS LITERALLY:\nYour job is to output WHAT A SUPPLIER MUST ACTUALLY DELIVER, not a copy of the row text.\nRead each description for MEANING and decide how many real, separately-buyable products it\nimplies. One BOQ row can imply SEVERAL products, or a spread of rows can imply ONE product.\n- SPLIT a row into multiple items when it lists distinct products that a supplier would\n buy/price separately — even when joined by commas, \"with\", \"and\", \"including\", \"c/w\",\n \"consisting of\", \"comprising\", or \":\" then a list.\n Example: \"Fire pump set consisting of: fire pump, electric motor, control panel, diesel\n engine\" → FOUR items: (1) fire pump, (2) electric motor, (3) control panel, (4) diesel\n engine — each with its own quantity and priced on its own.\n Example: \"Wash basin complete with mixer tap and bottle trap\" → THREE items: wash basin,\n mixer tap, bottle trap.\n- Give each split product the correct quantity implied by the text (usually the parent\n quantity, unless the text states a different count for a sub-item). Each product is\n priced individually; the line total becomes the SUM of its products.\n- DO NOT split when the extra words are just specifications, dimensions, colours, ratings,\n standards, or a single product's integral parts. \"Steel door 900x2100 with vision panel\n and SS ironmongery\" is usually ONE door product — vision panel and ironmongery are part\n of that door, not separately-bought items. Use judgement: would a supplier issue a\n separate price line for it? If no, keep it as one.\n- Balanced approach: split only when genuinely separate products are present; when in doubt\n about whether something is a separate product vs. an accessory/spec of the main one, keep\n it as a single item. Do not invent products that are not in the text.\n\nSTRICT RULES — include ONLY items that are ALL of the following:\n1. A specific, concrete, tangible product that can be boxed up by a supplier and delivered to site — something you can point at and say \"this exact thing is being bought\" (e.g. \"LED luminaire 36W\", \"PVC pipe 110mm\", \"Distribution board 12-way\", \"CO2 fire extinguisher 6kg\").\n2. Has a real quantity (not 0 or \"varies\" with no meaning).\n3. Has enough description to identify what the product is.\n4. Is an actual PRODUCT, not a sentence, instruction, or specification clause. REJECT rows that are prose such as \"the supervising engineer's instructions\", \"The price includes all materials, labor\", \"Measurement will be based on the geometric cubic meter\", \"necessary steps to complete the work\", \"soil-contacting elements\", \"in compliance\", \"everything necessary\". These are description fragments, not buyable items.\n\nNEVER extract abstract concepts, disciplines, trades, systems, or section/category names — they are NOT products even when they appear as their own row:\n- Bare discipline/trade/system words such as: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", and their Arabic equivalents (\"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\", \"تشطيبات\").\n- Test: if the word names a FIELD OF WORK or a SYSTEM rather than one buyable item with a unit (pcs, m, set, kg…), REJECT it.\n\nEXCLUDE all of the following (do NOT include them):\n- Labor, installation, or workmanship items (e.g. \"install\", \"fix\", \"erect\", \"dismantle\", \"painting\", \"cleaning service\")\n- Project management, overhead, or profit items\n- Insurance, bonds, liability items (e.g. \"bodily injury\", \"third party liability\", \"CAR insurance\")\n- Shop drawings, as-built drawings, method statements, documentation\n- Preliminary items, mobilisation, demobilisation, provisional sums\n- Housekeeping, site clearance, waste removal\n- Floor/level labels (e.g. \"GF Floor\", \"Level 3\") — these are headers, not products\n- Pure dimension specs without a product noun (e.g. \"Dia 32 mm\", \"25 mm\") — reject unless a product name is present\n- Security services, night shifts, rentals, payments\n- Any item whose description is too vague or fragmentary to identify a specific product\n\nEmit ONE JSON item PER real product. A single source row may therefore produce several\nitems (when it lists multiple products) or several rows may collapse into one item (when\nthey are wrapped fragments of the same product). The \"items\" array is your final delivery\nlist, not a row-by-row transcript.\n\nFor each valid physical product return JSON with:\n- description: clean product name only (strip \"supply and install\", \"supply and fix\", installation clauses, floor/spec suffixes). For a split product, use just that product's own name.\n- quantity: the EXACT numeric value from the quantity/count column (العدد, الكمية, Qty, Quantity, No.). NEVER default to 1 unless the quantity column is truly absent or empty.\n- unit: unit of measure (pcs, m, m2, m3, set, no, kg, etc.)\n- category: product category\n- brand: brand/manufacturer if mentioned, else \"\"\n- engineering_required: true if the item needs engineering design before procurement (panel boards, chillers, generators, fire suppression systems, BMS, pumps, AHUs, switchgear, transformers), else false\n\nReturn ONLY a valid JSON object: {\"items\": [...]}. No explanations, no markdown, no extra text.\nPROMPT);\n }\n\n private function mockResponse(): array\n {\n return [\n 'success' => true,\n 'error' => null,\n 'items' => [[\n 'description' => 'Sample BOQ Item',\n 'quantity' => 1,\n 'unit' => 'No',\n 'category' => 'Sample',\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 1,\n 'raw_data' => [],\n 'ai_extracted' => true,\n ]],\n 'rejected' => [],\n ];\n }\n\n private function failure(string $message, bool $serviceUnavailable = false): array\n {\n return [\n 'success' => false,\n 'items' => [],\n 'rejected' => [],\n 'error' => $this->userFacingError($message, $serviceUnavailable),\n 'service_unavailable' => $serviceUnavailable,\n ];\n }\n\n /**\n * Translate any internal error into a neutral, user-safe message.\n *\n * The user must never see the name of the underlying AI/processing engine\n * (DeepSeek, Groq, Gemini, Vision, etc.) or configuration hints like API\n * keys. The detailed message is kept in the logs for debugging only.\n */\n private function userFacingError(string $message, bool $serviceUnavailable = false): string\n {\n // Detect any reference to an AI provider / engine / API config so we can\n // swap it for a generic, branded-neutral message.\n $sensitive = '/deepseek|groq|gemini|vision|openai|openrouter|api[\\s_-]?key|\\.env|non-?json|token limit/i';\n\n if (preg_match($sensitive, $message)) {\n Log::warning('QuotationAiService: suppressed technical error from user.', ['internal' => $message]);\n\n return $serviceUnavailable\n ? 'The file could not be processed right now. Please try again in a moment.'\n : 'We could not read this file automatically. Please make sure it is a clear BOQ (Excel, CSV, PDF, or image) and try again, or add the items manually.';\n }\n\n return $message;\n }\n\n private function mimeForExtension(string $ext): string\n {\n return match (strtolower($ext)) {\n 'pdf' => 'application/pdf',\n 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n 'xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12',\n 'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',\n 'xls' => 'application/vnd.ms-excel',\n 'csv' => 'text/csv',\n 'jpg', 'jpeg' => 'image/jpeg',\n 'png' => 'image/png',\n 'gif' => 'image/gif',\n 'bmp' => 'image/bmp',\n 'webp' => 'image/webp',\n 'tiff', 'tif' => 'image/tiff',\n 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'doc' => 'application/msword',\n default => 'application/octet-stream',\n };\n }\n}\n"}, "ffab6c52ecaf2c34acbac553b1e081a500d944a8": {"t": 1785748362.3283331, "n": 86277, "h": "7b17078a7600646a", "c": "<?php\n\nnamespace App\\Services;\n\nuse Illuminate\\Http\\Client\\ConnectionException;\nuse Illuminate\\Http\\UploadedFile;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\nuse PhpOffice\\PhpSpreadsheet\\Cell\\Coordinate;\nuse PhpOffice\\PhpSpreadsheet\\IOFactory;\n\nclass QuotationAiService\n{\n /** Increment this whenever BOQ parsing logic changes to invalidate old caches. */\n private const PARSER_VERSION = 10;\n private const LARGE_BOQ_VERIFICATION_THRESHOLD = 2000;\n private const LARGE_BOQ_VERIFICATION_CHUNK_SIZE = 120;\n\n /**\n * Characters of document text sent per AI call.\n *\n * Above this the document is split and parsed over several calls. Sized well\n * under the model's context so the prompt, the slice, and a full JSON answer\n * for every row in it all fit — the binding limit is the *output* budget, not\n * the input, since each input row produces a JSON object.\n */\n private const TEXT_CHUNK_CHARS = 60000;\n\n /**\n * A workbook with more sheets than this is split regardless of its size.\n *\n * Multiple tabs almost always mean multiple disciplines (electrical, HVAC,\n * civil…). Sending them in one call makes the model summarise across\n * sections and silently drop rows, so each slice gets a narrower scope.\n */\n private const MAX_SHEETS_PER_CALL = 2;\n\n /** A PDF longer than this is split regardless of its extracted text length. */\n private const MAX_PDF_PAGES_PER_CALL = 5;\n\n private string $baseUrl;\n private string $parseEndpoint;\n private string $apiKey;\n private int $timeout;\n private bool $testMode;\n private BoqCleaningService $boqCleaner;\n\n /** Page count of the most recently parsed PDF; 0 when unknown. */\n private int $lastPdfPageCount = 0;\n\n /**\n * Optional progress reporter for chunked parsing, called as ($part, $total).\n *\n * Set by the queue job so the polling UI can show real progress on a large\n * BOQ instead of a spinner that appears frozen for many minutes.\n *\n * @var (callable(int, int): void)|null\n */\n private $onChunkProgress = null;\n\n /** @param callable(int, int): void $callback */\n public function onChunkProgress(callable $callback): self\n {\n $this->onChunkProgress = $callback;\n return $this;\n }\n\n /**\n * Optional per-chunk item sink, called as ($items, $part, $total).\n *\n * Lets the caller persist each slice's rows the moment they arrive instead\n * of waiting for the whole document. On a large BOQ this is the difference\n * between a table that fills in progressively and one that stays empty for\n * many minutes.\n *\n * @var (callable(array, int, int): void)|null\n */\n private $onChunkItems = null;\n\n /** @param callable(array, int, int): void $callback */\n public function onChunkItems(callable $callback): self\n {\n $this->onChunkItems = $callback;\n return $this;\n }\n\n /**\n * Report a non-chunk stage (local parse, handing off to AI) to the caller.\n *\n * A spreadsheet is read locally first and only sent to the AI when that\n * fails, so a large file can spend minutes before any chunk exists. Without\n * these the UI shows a generic spinner for that whole period.\n */\n private function reportStage(string $message): void\n {\n if ($this->onStage !== null) {\n ($this->onStage)($message);\n }\n }\n\n /**\n * Report a workbook's shape from grids that are already in memory.\n *\n * Deliberately takes the parsed grids rather than a path: re-reading a large\n * spreadsheet just to count its sheets would double the slowest step of the\n * whole extraction.\n *\n * @param array<int, array{name: string, grid: array}> $sheets\n */\n private function reportWorkbookShape(array $sheets): void\n {\n if ($this->onStage === null || empty($sheets)) {\n return;\n }\n\n $rows = 0;\n foreach ($sheets as $sheet) {\n $rows += count($sheet['grid']);\n }\n\n $count = count($sheets);\n\n $this->reportStage($count > 1\n ? \"Found {$count} sheets, {$rows} rows in total. Reading…\"\n : \"Found {$rows} rows. Reading…\");\n }\n\n /** @var (callable(string): void)|null */\n private $onStage = null;\n\n /** @param callable(string): void $callback */\n public function onStage(callable $callback): self\n {\n $this->onStage = $callback;\n return $this;\n }\n\n public function __construct(BoqCleaningService $boqCleaner)\n {\n $this->boqCleaner = $boqCleaner;\n $this->baseUrl = rtrim((string) config('services.ai_quotation.base_url', ''), '/');\n $this->parseEndpoint = ltrim((string) config('services.ai_quotation.parse_endpoint', 'parse'), '/');\n $this->apiKey = (string) config('services.ai_quotation.api_key', '');\n $this->timeout = (int) config('services.ai_quotation.timeout', 300);\n $this->testMode = (bool) config('services.ai_quotation.test_mode', false);\n }\n\n /**\n * Give the running script enough head-room to outlast the AI HTTP call.\n *\n * The web request / queue worker must not be killed before the HTTP timeout\n * completes, otherwise the user sees a generic \"timeout or unavailable\"\n * error even though the AI may have responded. We allow the HTTP timeout\n * plus a 120s margin for connecting, response parsing and DB writes.\n */\n private function extendExecutionTime(): void\n {\n // Ignored on some SAPIs (e.g. when running under fastcgi with a hard\n // limit), but harmless to attempt.\n @set_time_limit($this->timeout + 120);\n }\n\n public function parseBoq(UploadedFile|string $file, array $context = []): array\n {\n if ($this->testMode) {\n return $this->mockResponse();\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n\n if (! is_file($absPath)) {\n return $this->failure('File not found or not readable.');\n }\n\n $fileHash = hash_file('sha256', $absPath);\n // Bump PARSER_VERSION whenever the extraction logic changes so that\n // previously-uploaded files are re-parsed instead of returning a stale\n // cached result produced by the old code.\n $cacheKey = 'boq_analysis_v' . self::PARSER_VERSION . '_' . $fileHash;\n $forceRefresh = (bool) ($context['force_refresh'] ?? false);\n\n if (! $forceRefresh && ($cached = Cache::get($cacheKey)) !== null) {\n return $cached;\n }\n\n // Clear stale cached result when force-refreshing\n if ($forceRefresh) {\n Cache::forget($cacheKey);\n }\n\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n // Read Excel locally first. Fall back to AI if local parse failed,\n // found 0 items, OR could not read one of the sheets (so multi-sheet\n // files never silently drop a sheet the local parser didn't understand).\n $this->reportStage('Reading the spreadsheet…');\n\n $result = $this->parseSpreadsheetDirect($absPath);\n\n $hasSkippedSheets = ! empty($result['skipped_sheets']);\n\n if (! $result['success'] || empty($result['items']) || $hasSkippedSheets) {\n // The local pass could not handle this file on its own. Say so:\n // otherwise the UI sits on a generic spinner through the local\n // parse AND the whole AI parse with nothing to show for either.\n $this->reportStage($hasSkippedSheets\n ? 'Some sheets need AI to read. Sending to AI…'\n : 'Spreadsheet needs AI to read. Sending to AI…');\n\n $deepSeekResult = $this->parseBoqWithDeepSeek($file, $context);\n // AI sees every sheet at once, so prefer its result whenever it\n // actually returned items (covers the skipped-sheet case too).\n if ($deepSeekResult['success'] && ! empty($deepSeekResult['items'])) {\n // Keep the larger of the two item sets so we never lose the\n // rows the local parser already extracted correctly.\n if (count($deepSeekResult['items']) >= count($result['items'] ?? [])) {\n $result = $deepSeekResult;\n }\n } elseif (! $result['success']) {\n $result = $deepSeekResult;\n }\n // else: keep local result if AI also failed/returned nothing\n }\n } elseif (in_array($ext, ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif'], true)) {\n $result = $this->parseBoqWithDeepSeek($file, $context);\n } else {\n return $this->failure('Unsupported file type. Please upload Excel, CSV, PDF, or image file.');\n }\n\n if ($result['success'] && ! empty($result['items'])) {\n $cleaning = $this->boqCleaner->process($result['items']);\n $result['items'] = $cleaning['accepted'] ?? $result['items'];\n $result['rejected'] = array_merge($result['rejected'] ?? [], $cleaning['rejected'] ?? []);\n }\n\n if ($result['success'] && count($result['items'] ?? []) > self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n $result = $this->verifyLargeBoqItemsWithAi($result);\n }\n\n // Only cache when we actually extracted items; empty results should be re-tried.\n if ($result['success'] && ! empty($result['items'])) {\n Cache::put($cacheKey, $result, now()->addDays(30));\n }\n\n return $result;\n }\n\n private function resolveFile(UploadedFile|string $file): array\n {\n if ($file instanceof UploadedFile) {\n return [(string) $file->getRealPath(), strtolower($file->getClientOriginalExtension())];\n }\n\n $absPath = is_file($file) ? $file : storage_path('app/' . ltrim($file, '/'));\n return [$absPath, strtolower(pathinfo($absPath, PATHINFO_EXTENSION))];\n }\n\n private function parseSpreadsheetDirect(string $absPath): array\n {\n ini_set('memory_limit', '2048M');\n\n // 180s was not enough for a real BOQ: an 11 200-row workbook was killed\n // mid-parse, and because set_time_limit() kills the process outright the\n // job reported a bare failure with nothing in the log to explain it.\n // This runs on the queue, where wall time is cheap.\n set_time_limit(1800);\n\n try {\n $sheets = $this->spreadsheetToGrids($absPath);\n\n if (empty($sheets)) {\n return $this->failure('Could not read the Excel file.');\n }\n\n $this->reportWorkbookShape($sheets);\n\n $items = [];\n $rejected = [];\n $skippedSheets = []; // sheets we could not parse locally (header undetected)\n\n foreach ($sheets as $sheet) {\n $grid = $sheet['grid'];\n if (count($grid) < 2) {\n continue;\n }\n\n // Skip dedicated summary / cost-abstract sheets entirely. Their rows\n // are per-discipline totals (Structural Concrete, Electrical, …) and\n // grand totals, not procurable products.\n if ($this->isSummarySheet($sheet['name'])) {\n Log::info('QuotationAiService: skipped summary sheet.', ['sheet' => $sheet['name']]);\n continue;\n }\n\n $header = $this->detectHeader($grid);\n\n if ($header === null || ! isset($header['map']['description'])) {\n Log::warning('QuotationAiService: header not detected in sheet.', ['sheet' => $sheet['name']]);\n $skippedSheets[] = $sheet['name'];\n continue;\n }\n\n $map = $header['map'];\n $start = $header['row'] + 1;\n\n for ($r = $start; $r < count($grid); $r++) {\n $row = $grid[$r];\n $description = $this->cell($row, $map['description'] ?? null);\n $itemNo = $this->cell($row, $map['item_code'] ?? null);\n $unit = $this->cell($row, $map['unit'] ?? null);\n $quantity = $this->number($this->cell($row, $map['quantity'] ?? null));\n $unitPrice = $this->number($this->cell($row, $map['unit_price'] ?? null));\n $totalPrice = $this->number($this->cell($row, $map['total_price'] ?? null));\n\n if ($description === '') {\n continue;\n }\n\n if (! isset($map['quantity'])) {\n $quantity = 1.0;\n }\n\n // Skip floor breakdown sub-rows: Ref. is \"-\" meaning it's a per-floor\n // quantity split of the parent item, not a standalone product.\n if ($itemNo === '-' || $this->isFloorBreakdownRow($itemNo, $description)) {\n continue;\n }\n\n if ($this->isTotalOrHeaderRow($description)) {\n continue;\n }\n\n if ($quantity === null || $quantity <= 0) {\n $rejected[] = $this->rejectedRow($description, $unit, $quantity, 'Missing quantity', $itemNo, $sheet['name']);\n continue;\n }\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $supply = $this->boqCleaner->filterItem($description, $unitPrice, $quantity);\n\n if (! ($supply['keep'] ?? false)) {\n $rejected[] = $this->rejectedRow(\n $description,\n $unit,\n $quantity,\n (string) ($supply['rejection_reason'] ?? 'Rejected by cleaner'),\n $itemNo,\n $sheet['name']\n );\n continue;\n }\n\n $finalDescription = (string) ($supply['description'] ?? $description);\n\n $items[] = [\n 'description' => $finalDescription,\n 'quantity' => $quantity,\n 'unit' => $unit,\n 'category' => $sheet['name'],\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($finalDescription),\n 'unit_price' => $unitPrice,\n 'confidence' => 0.95,\n 'needs_review' => false,\n 'raw_data' => [\n 'sheet' => $sheet['name'],\n 'row_number' => $r + 1,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'cleaned_description' => $finalDescription,\n 'extraction_type' => (string) ($supply['extraction_type'] ?? 'supply_only'),\n ],\n 'ai_extracted' => true,\n ];\n }\n }\n\n if (empty($items)) {\n return $this->failure('No BOQ supply items could be extracted from the Excel file.');\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'skipped_sheets' => $skippedSheets,\n ];\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: parseSpreadsheetDirect failed.', [\n 'path' => $absPath,\n 'message' => $e->getMessage(),\n 'trace' => $e->getTraceAsString(),\n ]);\n\n return $this->failure('Failed to read the Excel file: ' . $e->getMessage());\n }\n }\n\n private function spreadsheetToGrids(string $absPath): array\n {\n $ext = strtolower(pathinfo($absPath, PATHINFO_EXTENSION));\n\n if ($ext === 'csv') {\n $rows = [];\n $handle = fopen($absPath, 'rb');\n if ($handle !== false) {\n while (($row = fgetcsv($handle)) !== false) {\n $rows[] = $this->trimTrailingEmptyCells($row);\n }\n fclose($handle);\n }\n return [['name' => 'Sheet1', 'grid' => $rows]];\n }\n\n $reader = IOFactory::createReader($ext === 'xls' ? 'Xls' : 'Xlsx');\n $reader->setReadDataOnly(true);\n $spreadsheet = $reader->load($absPath);\n $sheets = [];\n\n foreach ($spreadsheet->getAllSheets() as $sheet) {\n $highestRow = $sheet->getHighestDataRow();\n $highestColumn = $sheet->getHighestDataColumn();\n $highestColumnIndex = Coordinate::columnIndexFromString($highestColumn);\n\n // BOQ data is normally in first 8-20 columns.\n // Files with heavy formatting/images can report 300+ columns — limit to 30.\n $maxColumnIndex = min($highestColumnIndex, 30);\n\n $grid = [];\n for ($row = 1; $row <= $highestRow; $row++) {\n $rowData = [];\n $hasValue = false;\n\n for ($col = 1; $col <= $maxColumnIndex; $col++) {\n $value = $sheet->getCell([$col, $row])->getValue();\n $value = $this->cellValueToString($value);\n $value = trim($value);\n if ($value !== null && $value !== '') {\n $hasValue = true;\n }\n $rowData[] = $value;\n }\n\n $rowData = $this->trimTrailingEmptyCells($rowData);\n if ($hasValue) {\n $grid[] = $rowData;\n }\n }\n\n if (! empty($grid)) {\n $sheets[] = ['name' => $sheet->getTitle(), 'grid' => $grid];\n }\n }\n\n $spreadsheet->disconnectWorksheets();\n unset($spreadsheet);\n\n return $sheets;\n }\n\n private function detectHeader(array $grid): ?array\n {\n $keywords = [\n 'item_code' => ['رقم البند', 'item no', 'item number', 'ref.', 'ref', 'item', 'no.', 'no', '#', 'كود'],\n 'description' => ['وص٠البند', 'scope of works', 'scope of work', 'scope', 'item description', 'description of works', 'description', 'بالعربية', 'الوصÙ', 'البند', 'بيان'],\n 'unit' => ['Ø§Ù„ÙˆØØ¯Ø©', 'unit', 'uom', 'u/m'],\n 'quantity' => ['الكمية', 'qty', 'quantity', 'q.ty'],\n 'unit_price' => ['سعر Ø§Ù„ÙˆØØ¯Ø©', 'u/price', 'unit price', 'unit rate', 'rate', 'price'],\n 'total_price' => ['السعر الإجمالي', 'total amount', 'total price', 'amount', 'الإجمالي'],\n ];\n\n $best = null;\n $bestScore = 0;\n\n for ($r = 0; $r < min(30, count($grid)); $r++) {\n $map = [];\n $score = 0;\n\n foreach ($grid[$r] as $c => $value) {\n $cell = mb_strtolower(trim((string) $value));\n if ($cell === '') {\n continue;\n }\n\n foreach ($keywords as $field => $list) {\n foreach ($list as $kw) {\n if (str_contains($cell, mb_strtolower($kw))) {\n if (! isset($map[$field])) {\n $map[$field] = $c;\n $score++;\n }\n break 2;\n }\n }\n }\n }\n\n if ($score > $bestScore) {\n $bestScore = $score;\n $best = ['row' => $r, 'map' => $map];\n }\n }\n\n if ($bestScore >= 3) {\n return $best;\n }\n\n // Allow simpler BOQ sheets that only define a description column plus one\n // other BOQ-related column, e.g. Item + Description or Description + Qty.\n if ($bestScore === 2 && isset($best['map']['description']) && (\n isset($best['map']['item_code']) ||\n isset($best['map']['quantity']) ||\n isset($best['map']['unit'])\n )) {\n return $best;\n }\n\n return null;\n }\n\n private function cell(array $row, ?int $index): string\n {\n if ($index === null) {\n return '';\n }\n return trim((string) ($row[$index] ?? ''));\n }\n\n private function number(string $value): ?float\n {\n $value = trim(str_replace([',', 'SAR', 'ر.س'], '', $value));\n if ($value === '' || ! is_numeric($value)) {\n return null;\n }\n return (float) $value;\n }\n\n private function trimTrailingEmptyCells(array $row): array\n {\n while (! empty($row) && trim((string) end($row)) === '') {\n array_pop($row);\n }\n return array_values($row);\n }\n\n private function isTotalOrHeaderRow(string $description): bool\n {\n $d = mb_strtolower(trim($description));\n\n if ($d === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $d)) {\n return true;\n }\n\n // Section totals / summaries\n if (preg_match('/(اجمالي|إجمالي|المجموع|grand\\s*total|sub\\s*total|total\\s*amount|total\\s*price)/iu', $d)) {\n return true;\n }\n\n // Bare \"Total\" lines, optionally with a currency suffix, e.g. \"Total\",\n // \"Total (SAR)\", \"Total cost\", \"Total (SAR) unit cost\".\n if (preg_match('/^total\\b[\\s\\(\\):a-z]*$/iu', $d)) {\n return true;\n }\n\n // Pure floor / level labels used as section headers\n if (preg_match('/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor)\\s*$/iu', $d)) {\n return true;\n }\n\n // Division / section header lines (e.g. \"Division - 02: Wood Works\")\n if (preg_match('/^division\\s*([-:]|\\d)/iu', $d)) {\n return true;\n }\n\n return false;\n }\n\n /**\n * Detect dedicated summary / cost-abstract sheets by their name. These hold\n * per-discipline rollups and grand totals (e.g. a \"Summary (Dry Cost)\" tab),\n * never procurable supply items, so they are skipped wholesale.\n */\n private function isSummarySheet(string $name): bool\n {\n $n = mb_strtolower(trim($name));\n\n if ($n === '') {\n return false;\n }\n\n return (bool) preg_match(\n '/\\b(summary|abstract|cost\\s*summary|grand\\s*summary|recap(?:itulation)?|bill\\s*summary|boq\\s*summary|dry\\s*cost)\\b|ملخص|الملخص|الملخّص|ملخّص|اجمالي|إجمالي/iu',\n $n\n );\n }\n\n private function isFloorBreakdownRow(string $itemNo, string $description): bool\n {\n // A row whose Ref column is empty or a dash is a sub-breakdown row (per-floor split).\n $ref = trim($itemNo);\n if ($ref === '' || $ref === '-') {\n // Confirm by checking the description is a location/floor label\n $d = mb_strtolower(trim($description));\n return (bool) preg_match(\n '/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor|\\d+f)\\s*$/iu',\n $d\n );\n }\n return false;\n }\n\n private function rejectedRow(string $description, string $unit, ?float $quantity, string $reason, string $itemNo = '', string $sheet = ''): array\n {\n return [\n 'description' => $description,\n 'quantity' => $quantity ?? 0,\n 'unit' => $unit,\n 'category' => $sheet,\n 'brand' => '',\n 'status' => 'rejected',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 0.0,\n 'raw_data' => [\n 'sheet' => $sheet,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'extraction_type' => 'rejected',\n 'rejection_reason' => $reason,\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function parseBoqWithDeepSeek(UploadedFile|string $file, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured. Please set DEEPSEEK_API_KEY in .env file.');\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n $mime = $this->mimeForExtension($ext);\n\n try {\n // ── Spreadsheets ──────────────────────────────────────────────────\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n $text = $this->spreadsheetToCompactCsvText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not convert spreadsheet to readable text.');\n }\n\n $text = $this->sanitizeUtf8($text);\n\n // A large BOQ exceeds what one request can carry, so send it in\n // sequential slices and merge. Anything that fits goes as-is.\n //\n // Size alone is not a good enough signal: a workbook with several\n // sheets is structurally a big BOQ even when the text is compact,\n // and asking the model to hold every discipline at once is where\n // rows start getting dropped. So split on either signal.\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS || $sheetCount > self::MAX_SHEETS_PER_CALL) {\n Log::info('QuotationAiService: spreadsheet routed to chunked parsing.', [\n 'chars' => mb_strlen($text),\n 'sheets' => $sheetCount,\n ]);\n return $this->parseTextInChunks($text, 'BOQ spreadsheet converted to CSV', $apiKey, $context);\n }\n\n $userContent = \"BOQ spreadsheet converted to CSV:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── PDF ───────────────────────────────────────────────────────────\n if ($ext === 'pdf') {\n $extracted = $this->extractPdfText($absPath);\n if ($extracted !== null && mb_strlen(trim($extracted)) > 50) {\n $extracted = $this->sanitizeUtf8($extracted);\n\n // Split on either signal — long text, or many pages. A BOQ\n // spanning several pages is a multi-section document even if\n // each page is sparse.\n if (mb_strlen($extracted) > self::TEXT_CHUNK_CHARS\n || $this->lastPdfPageCount > self::MAX_PDF_PAGES_PER_CALL) {\n Log::info('QuotationAiService: PDF routed to chunked parsing.', [\n 'chars' => mb_strlen($extracted),\n 'pages' => $this->lastPdfPageCount,\n ]);\n return $this->parseTextInChunks($extracted, 'BOQ PDF extracted text', $apiKey, $context, 'application/pdf');\n }\n\n $userContent = \"BOQ PDF extracted text:\\n\\n\" . $extracted . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'application/pdf');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n return $this->failure('Could not extract text from the PDF. Please make sure it is a text-based PDF (not a scanned image), or convert it to Excel or CSV.');\n }\n\n // -- Word documents (docx) ---------------------------------------------------\n if (in_array($ext, ['docx', 'doc'], true)) {\n $text = $this->extractDocxText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not extract text from the Word document. Please convert it to Excel, CSV, or PDF.');\n }\n $text = $this->sanitizeUtf8($text);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS) {\n return $this->parseTextInChunks($text, 'BOQ Word document extracted text', $apiKey, $context);\n }\n\n $userContent = \"BOQ Word document extracted text:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── Images ──────────────────────────────────────────────────────────────\n // DeepSeek cannot receive images. A vision-capable AI processes the image\n // directly (no OCR) — configure GROQ_API_KEY or GEMINI_API_KEY in .env.\n $imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif', 'heic', 'heif'];\n if (in_array($ext, $imageExts, true)) {\n $bytes = (string) file_get_contents($absPath);\n return $this->callDeepSeekVision($bytes, $mime, $context);\n }\n\n return $this->failure('Unsupported file type. Please upload an Excel, CSV, PDF, or image file.');\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: DeepSeek fallback failed.', ['message' => $e->getMessage()]);\n return $this->failure('An error occurred while processing the file with AI: ' . $e->getMessage());\n }\n }\n\n private function deepSeekModel(): string\n {\n return (string) config('services.deepseek.model', 'deepseek-chat');\n }\n\n /**\n * Remove or replace any non-UTF-8 / malformed byte sequences so json_encode never fails.\n */\n private function sanitizeUtf8(string $text): string\n {\n // Replace invalid byte sequences with the UTF-8 replacement character\n $clean = mb_convert_encoding($text, 'UTF-8', 'UTF-8');\n // Strip any remaining non-printable control characters (except tab/newline/CR)\n $clean = (string) preg_replace('/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]/u', '', $clean);\n return $clean;\n }\n\n /**\n * Extract plain text from a Word docx file using ZipArchive (no external library needed).\n * Reads word/document.xml and strips XML tags, preserving paragraph and table structure.\n */\n private function extractDocxText(string $absPath): ?string\n {\n try {\n $zip = new \\ZipArchive();\n if ($zip->open($absPath) !== true) {\n return null;\n }\n\n $xml = $zip->getFromName('word/document.xml');\n $zip->close();\n\n if ($xml === false || $xml === '') {\n return null;\n }\n\n // Add newlines before paragraph/table-row/table-cell tags so structure is preserved\n $xml = preg_replace('/<w:p[ >]/', \"\\n<w:p>\", $xml) ?? $xml;\n $xml = preg_replace('/<w:tr[ >]/', \"\\n<w:tr>\", $xml) ?? $xml;\n $xml = preg_replace('/<\\/w:tc>/', \"\\t\", $xml) ?? $xml;\n\n $text = strip_tags($xml);\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n return trim($text) !== '' ? trim($text) : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: DOCX parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Extract plain text from a PDF using smalot/pdfparser (pure PHP, no binary required).\n */\n private function extractPdfText(string $absPath): ?string\n {\n try {\n $parser = new \\Smalot\\PdfParser\\Parser();\n $pdf = $parser->parseFile($absPath);\n\n // Recorded for the chunking decision: a long PDF is structurally a\n // big BOQ even when its extracted text is short.\n try {\n $this->lastPdfPageCount = count($pdf->getPages());\n } catch (\\Throwable) {\n $this->lastPdfPageCount = 0;\n }\n\n $text = $pdf->getText();\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n return $text !== '' ? $text : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: PDF parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Send a vision request to a vision-capable AI model.\n * DeepSeek's commercial API (api.deepseek.com) does NOT support image inputs — any model.\n * Priority order (first configured key wins):\n * 1. GROQ_API_KEY → Groq llama-4-scout (free: https://console.groq.com)\n * 2. GEMINI_API_KEY → Google Gemini Flash (free: https://aistudio.google.com/apikey)\n * 3. VISION_API_KEY → any OpenAI-compatible endpoint (OpenAI, OpenRouter …)\n */\n private function callDeepSeekVision(string $bytes, string $mime, array $context): array\n {\n // ── 1. Groq (free, reliable vision) ─────────────────────────────────────\n if ((string) config('services.groq.key', '') !== '') {\n $visionKey = (string) config('services.groq.key');\n $visionBaseUrl = 'https://api.groq.com/openai/v1';\n $visionModel = (string) config('services.groq.model', 'meta-llama/llama-4-scout-17b-16e-instruct');\n }\n // ── 2. Google Gemini ─────────────────────────────────────────────────────\n elseif ((string) config('services.gemini.key', '') !== '') {\n $visionKey = (string) config('services.gemini.key');\n $visionBaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai';\n $visionModel = (string) config('services.gemini.model', 'gemini-2.0-flash');\n }\n // ── 3. Generic VISION_API_KEY (OpenAI, OpenRouter, …) ───────────────────\n elseif ((string) config('services.vision.key', '') !== '') {\n $visionKey = (string) config('services.vision.key');\n $visionBaseUrl = rtrim((string) config('services.vision.base_url', 'https://openrouter.ai/api/v1'), '/');\n $visionModel = (string) config('services.vision.model', 'google/gemini-flash-1.5-8b');\n }\n else {\n return $this->failure(\n 'Image processing is not available. DeepSeek\\'s API does not support images. ' .\n 'Get a free key at https://console.groq.com and add GROQ_API_KEY to your .env file.'\n );\n }\n\n $dataUrl = \"data:{$mime};base64,\" . base64_encode($bytes);\n $prompt = $this->buildDeepSeekPrompt($context, $mime);\n\n $userContent = [\n ['type' => 'text', 'text' => $prompt],\n ['type' => 'image_url', 'image_url' => ['url' => $dataUrl]],\n ];\n\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders([\n 'Authorization' => 'Bearer ' . $visionKey,\n 'HTTP-Referer' => config('app.url', 'https://qimta.sa'),\n 'X-Title' => 'Qimta Platform',\n ])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post($visionBaseUrl . '/chat/completions', [\n 'model' => $visionModel,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 16384,\n 'temperature' => 0.2,\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('Vision API connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: Vision API HTTP error.', [\n 'model' => $visionModel,\n 'base_url' => $visionBaseUrl,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n return $this->failure(\"Vision API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n return $this->parseDeepSeekJsonResponse((string) $response->json('choices.0.message.content'));\n }\n\n private function callDeepSeekChat(string $userContent, string $key, string $model): array\n {\n // Ensure the PHP script outlives the HTTP call (+ a margin for parsing the\n // response and writing to the DB) so the worker/web request is not killed\n // mid-flight, which surfaces to users as a generic \"timeout or unavailable\".\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders(['Authorization' => 'Bearer ' . $key])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 32768,\n 'temperature' => 0.1,\n 'response_format' => ['type' => 'json_object'],\n 'user' => 'Qimta_Platform',\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('DeepSeek connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: DeepSeek HTTP error.', [\n 'model' => $model,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n if ($response->status() === 401) {\n return $this->failure('DeepSeek API key is invalid or unauthorised. Please update DEEPSEEK_API_KEY in .env.', false);\n }\n return $this->failure(\"DeepSeek API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n $text = (string) $response->json('choices.0.message.content');\n return $this->parseDeepSeekJsonResponse($text);\n }\n\n /**\n * Parse a document too large for one AI call by sending it in slices.\n *\n * A 20 000-row BOQ is several megabytes of text — far beyond what a single\n * request can carry, and beyond what the model can answer without hitting its\n * output token limit. Previously the text was simply cut at 180 000 chars and\n * the remainder discarded: the parse \"succeeded\" while silently dropping most\n * of the file. Now the text is split and each slice parsed on its own call,\n * with the items merged.\n *\n * Splitting happens on line boundaries so a BOQ row is never cut in half.\n * Slices run sequentially rather than pooled: DeepSeek rate-limits hard on\n * concurrent large prompts, and this already runs on the queue where wall\n * time is cheap.\n *\n * A slice that fails does not fail the document — its rows are lost, but the\n * rest are kept and the shortfall is logged. Total failure is reported only\n * when every slice fails.\n *\n * @param string $label How the payload is introduced to the model.\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n /**\n * Split a spreadsheet into the slices the AI will be asked to read.\n *\n * Exposed so the work can be spread over several queue jobs instead of one\n * job walking every slice in sequence. Returns an empty array when the file\n * is small enough to go in a single call, or cannot be read.\n *\n * @return array<int, string>\n */\n public function chunkSpreadsheet(string $absPath): array\n {\n ini_set('memory_limit', '2048M');\n set_time_limit(1800);\n\n $text = $this->spreadsheetToCompactCsvText($absPath);\n\n if ($text === null || trim($text) === '') {\n return [];\n }\n\n $text = $this->sanitizeUtf8($text);\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) <= self::TEXT_CHUNK_CHARS && $sheetCount <= self::MAX_SHEETS_PER_CALL) {\n return [];\n }\n\n return $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n }\n\n /**\n * Parse one slice produced by chunkSpreadsheet().\n *\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n public function parseChunk(string $chunk, int $part, int $total, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured.');\n }\n\n // Same framing parseTextInChunks() uses, so a slice is never mistaken\n // for a complete BOQ and totals are not inferred from a fragment.\n $userContent = \"BOQ spreadsheet converted to CSV (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, 'text/plain');\n\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n private function parseTextInChunks(\n string $text,\n string $label,\n string $apiKey,\n array $context,\n string $mime = 'text/plain',\n ): array {\n $chunks = $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n $total = count($chunks);\n\n Log::info('QuotationAiService: large document split for parsing.', [\n 'chars' => mb_strlen($text),\n 'chunks' => $total,\n ]);\n\n // Announce the split before the first slice is sent, so the UI can show\n // how many parts there are instead of sitting on a generic message for\n // however long the first AI call takes.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)(0, $total);\n }\n\n $items = [];\n $rejected = [];\n $failed = 0;\n $lastError = null;\n\n foreach ($chunks as $index => $chunk) {\n $part = $index + 1;\n\n // Report progress so a 28-slice parse does not look frozen to the user.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)($part, $total);\n }\n\n // Tell the model this is a fragment, so it does not try to reconcile\n // totals or treat a mid-document slice as a complete BOQ.\n $userContent = \"{$label} (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, $mime);\n\n $result = $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n\n if (! ($result['success'] ?? false)) {\n $failed++;\n $lastError = $result['error'] ?? 'Chunk parsing failed.';\n Log::warning('QuotationAiService: chunk failed, continuing.', [\n 'part' => $part,\n 'of' => $total,\n 'error' => $lastError,\n ]);\n continue;\n }\n\n foreach ($result['items'] as $item) {\n $items[] = $item;\n }\n foreach ($result['rejected'] ?? [] as $row) {\n $rejected[] = $row;\n }\n\n // Hand this slice's rows straight to the caller so they can be shown\n // while the remaining slices are still being parsed.\n if ($this->onChunkItems !== null && $result['items'] !== []) {\n ($this->onChunkItems)($result['items'], $part, $total);\n }\n }\n\n // Every slice failed — there is nothing to show, so report the failure.\n if ($failed === $total) {\n return $this->failure($lastError ?? 'Could not parse the document.');\n }\n\n if ($failed > 0) {\n Log::error('QuotationAiService: document parsed with missing parts.', [\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n 'items_kept' => count($items),\n ]);\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'service_unavailable' => false,\n 'partial' => $failed > 0,\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n ];\n }\n\n /**\n * Split text into slices of at most $limit characters, breaking only at\n * newlines so no BOQ row is ever cut in half.\n *\n * A single line longer than the limit (rare — a pathological cell) is passed\n * through oversized rather than severed mid-row.\n *\n * @return array<int, string>\n */\n private function splitOnLineBoundaries(string $text, int $limit): array\n {\n $chunks = [];\n $current = '';\n\n // Sheet a chunk starts in the middle of. Repeated at the top of the next\n // chunk so the model always knows which sheet the rows belong to — the\n // header would otherwise be stranded in the previous slice, and rows\n // would arrive with no discipline context.\n $currentSheet = null;\n\n foreach (explode(\"\\n\", $text) as $line) {\n $isSheetHeader = str_starts_with($line, 'Sheet: ');\n\n // Start a new chunk at every sheet boundary. Sheets are usually\n // separate disciplines, and the whole point of splitting a multi-tab\n // workbook is to give the model one discipline at a time — a size-only\n // split would leave a compact 3-tab file as a single chunk, silently\n // undoing the sheet gate that routed it here.\n if ($isSheetHeader && $current !== '') {\n $chunks[] = $current;\n $current = $line;\n $currentSheet = $line;\n continue;\n }\n\n if ($isSheetHeader) {\n $currentSheet = $line;\n }\n\n $candidate = $current === '' ? $line : $current . \"\\n\" . $line;\n\n if (mb_strlen($candidate) > $limit && $current !== '') {\n $chunks[] = $current;\n\n // Carry the sheet header over, unless this line is itself one.\n $current = ($currentSheet !== null && ! str_starts_with($line, 'Sheet: '))\n ? $currentSheet . \" (continued)\\n\" . $line\n : $line;\n continue;\n }\n\n $current = $candidate;\n }\n\n if (trim($current) !== '') {\n $chunks[] = $current;\n }\n\n return $chunks;\n }\n\n /**\n * Shared JSON response parser used by both chat and vision paths.\n */\n private function parseDeepSeekJsonResponse(string $text): array\n {\n if ($text === '') {\n return $this->failure('DeepSeek returned an empty response.');\n }\n\n // Strip markdown code fences if the model wrapped the JSON.\n if (preg_match('/```(?:json)?\\s*([\\s\\S]*?)\\s*```/i', $text, $m)) {\n $text = $m[1];\n }\n // Narrow to the outermost JSON object/array.\n if (preg_match('/[\\{\\[][\\s\\S]*[\\}\\]]/s', $text, $m)) {\n $text = $m[0];\n }\n $text = trim($text);\n\n $decoded = json_decode($text, true);\n\n // The response can be truncated when the model hits the token limit,\n // leaving an unterminated JSON string/object. Try to repair it before\n // giving up so we can still salvage the items extracted so far.\n if (! is_array($decoded)) {\n $repaired = $this->repairTruncatedJson($text);\n if ($repaired !== null) {\n $decoded = json_decode($repaired, true);\n }\n }\n\n if (! is_array($decoded)) {\n Log::error('QuotationAiService: DeepSeek non-JSON response.', ['text' => mb_substr($text, 0, 1000)]);\n return $this->failure('DeepSeek returned a non-JSON response. The file may be too large — try splitting it into smaller files.');\n }\n\n $rawItems = $decoded['items'] ?? (array_is_list($decoded) ? $decoded : []);\n $items = [];\n $rejected = [];\n\n foreach ($rawItems as $raw) {\n if (! is_array($raw)) {\n continue;\n }\n $item = $this->normaliseAiItem($raw);\n if ($item === null) {\n continue;\n }\n $check = $this->boqCleaner->filterItem((string) $item['description']);\n if ($check['keep'] ?? false) {\n $items[] = $item;\n } else {\n $item['status'] = 'rejected';\n $item['raw_data']['rejection_reason'] = $check['rejection_reason'] ?? 'Rejected by cleaner';\n $rejected[] = $item;\n }\n }\n\n if (empty($items)) {\n Log::info('QuotationAiService: DeepSeek returned 0 accepted items.', [\n 'raw_items_count' => count($rawItems),\n 'rejected_count' => count($rejected),\n 'first_rejected' => ! empty($rejected) ? ($rejected[0]['description'] ?? '') : '',\n ]);\n }\n\n return ['success' => true, 'items' => $items, 'rejected' => $rejected, 'error' => null];\n }\n\n /**\n * Best-effort repair of a truncated JSON string produced when the model hits\n * its token limit mid-output. Drops any incomplete trailing object, then\n * closes the open string/array/object brackets so the result is valid JSON.\n *\n * Returns the repaired JSON string, or null if it cannot be salvaged.\n */\n private function repairTruncatedJson(string $text): ?string\n {\n if ($text === '') {\n return null;\n }\n\n // Walk the string tracking structural state, ignoring everything inside\n // string literals (and their escapes). Remember the last position where\n // we were back at the top of the \"items\" array between complete elements,\n // so we can cut a half-written trailing object.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($text);\n $lastSafeCut = null; // index just after a completed array element\n\n for ($i = 0; $i < $len; $i++) {\n $ch = $text[$i];\n\n if ($inString) {\n if ($escaped) {\n $escaped = false;\n } elseif ($ch === '\\\\') {\n $escaped = true;\n } elseif ($ch === '\"') {\n $inString = false;\n }\n continue;\n }\n\n if ($ch === '\"') {\n $inString = true;\n } elseif ($ch === '{' || $ch === '[') {\n $stack[] = $ch;\n } elseif ($ch === '}' || $ch === ']') {\n array_pop($stack);\n } elseif ($ch === ',' && ! empty($stack) && end($stack) === '[') {\n // Separator directly inside an array: a clean boundary between\n // elements (the items array, whatever its nesting depth).\n $lastSafeCut = $i;\n }\n }\n\n // If we are still inside a string, the last element is incomplete — fall\n // back to the last clean array boundary if we have one.\n $candidate = $text;\n if ($inString && $lastSafeCut !== null) {\n $candidate = substr($text, 0, $lastSafeCut);\n } else {\n // Drop a dangling partial object after the last completed element.\n $candidate = rtrim($candidate);\n if ($lastSafeCut !== null && substr(rtrim($candidate), -1) !== '}' && substr(rtrim($candidate), -1) !== ']') {\n $candidate = substr($text, 0, $lastSafeCut);\n }\n }\n\n // Recompute the open brackets for the (possibly trimmed) candidate and\n // append the matching closers in reverse order.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($candidate);\n for ($i = 0; $i < $len; $i++) {\n $ch = $candidate[$i];\n if ($inString) {\n if ($escaped) { $escaped = false; }\n elseif ($ch === '\\\\') { $escaped = true; }\n elseif ($ch === '\"') { $inString = false; }\n continue;\n }\n if ($ch === '\"') { $inString = true; }\n elseif ($ch === '{' || $ch === '[') { $stack[] = $ch; }\n elseif ($ch === '}' || $ch === ']') { array_pop($stack); }\n }\n\n $candidate = rtrim($candidate);\n // Remove a trailing comma left after cutting an element.\n $candidate = rtrim($candidate, ',');\n\n $closers = '';\n for ($i = count($stack) - 1; $i >= 0; $i--) {\n $closers .= ($stack[$i] === '{') ? '}' : ']';\n }\n\n $repaired = $candidate . $closers;\n\n // Validate before returning.\n return is_array(json_decode($repaired, true)) ? $repaired : null;\n }\n\n private function spreadsheetToCompactCsvText(string $absPath): ?string\n {\n $sheets = $this->spreadsheetToGrids($absPath);\n if (empty($sheets)) {\n return null;\n }\n\n $output = '';\n $kept = 0;\n $dropped = 0;\n $nonSupply = 0;\n $duplicates = 0;\n\n /** @var array<string, true> Fingerprints of rows already emitted. */\n $seenRows = [];\n\n foreach ($sheets as $sheet) {\n // Don't feed summary / cost-abstract sheets to the AI either.\n if ($this->isSummarySheet($sheet['name'])) {\n continue;\n }\n $sheetRows = '';\n foreach ($sheet['grid'] as $row) {\n $row = $this->trimTrailingEmptyCells($row);\n\n if ($this->isNoiseRow($row)) {\n $dropped++;\n continue;\n }\n\n // Drop rows that are definitely not procurable before they cost\n // a single token: section totals, discipline headings,\n // preliminaries, labour-only lines. The same classifier already\n // runs on the AI's output, but by then the tokens are spent — on\n // an 11 200-row BOQ that is most of the bill.\n if ($this->isNonSupplyRow($row)) {\n $dropped++;\n $nonSupply++;\n continue;\n }\n\n // Byte-identical rows tell the AI nothing new. Only exact\n // duplicates are collapsed — description, quantity, unit and all\n // — so nothing is lost: two rows differing in quantity are two\n // different line items and both are sent.\n $fingerprint = $this->rowFingerprint($row);\n\n if (isset($seenRows[$fingerprint])) {\n $duplicates++;\n $dropped++;\n continue;\n }\n $seenRows[$fingerprint] = true;\n\n $sheetRows .= implode(',', array_map([$this, 'csvEscape'], $this->stripBoilerplate($row))) . \"\\n\";\n $kept++;\n }\n\n // A sheet whose rows were all noise contributes nothing but its own\n // header, which would just be another thing for the model to explain.\n if ($sheetRows === '') {\n continue;\n }\n\n $output .= 'Sheet: ' . $sheet['name'] . \"\\n\" . $sheetRows . \"\\n\";\n }\n\n $blank = $dropped - $nonSupply - $duplicates;\n\n Log::info('QuotationAiService: filtered rows before sending to AI.', [\n 'kept' => $kept,\n 'blank' => $blank,\n 'non_supply' => $nonSupply,\n 'duplicates' => $duplicates,\n ]);\n\n if ($dropped > 0) {\n $parts = [];\n if ($blank > 0) { $parts[] = \"{$blank} blank\"; }\n if ($nonSupply > 0) { $parts[] = \"{$nonSupply} non-supply\"; }\n if ($duplicates > 0) { $parts[] = \"{$duplicates} duplicate\"; }\n\n $this->reportStage('Skipped ' . implode(', ', $parts) . \" rows. Sending {$kept}…\");\n }\n\n return $output;\n }\n\n /**\n * True when a row carries no information worth sending to the AI.\n *\n * A real BOQ is mostly formatting: spacer rows, stray borders, a lone\n * section number, a leftover cell from a merged block. They survive\n * trimTrailingEmptyCells() because they are not *entirely* empty, then cost\n * tokens, dilute the prompt, and inflate the number of parts a large file is\n * split into.\n *\n * Deliberately conservative — a row is dropped only when nothing in it could\n * name a product. Anything with letters is kept, because a description is\n * the one field an item cannot do without.\n *\n * @param array<int, mixed> $row\n */\n /**\n * True when a row is definitely not a procurable supply item.\n *\n * Runs BoqCleaningService over the row's longest text cell — the one most\n * likely to be the description — so headings, totals, preliminaries and\n * labour-only lines never reach the AI. The same classifier already vets the\n * AI's *output*; doing it here as well means we stop paying to have those\n * rows read in the first place.\n *\n * Conservative by construction: only rows the classifier positively rejects\n * are dropped. Anything it is unsure about goes to the AI, which is the\n * better judge — a wrongly dropped row is invisible, a wrongly kept one only\n * costs tokens.\n *\n * @param array<int, mixed> $row\n */\n /**\n * Stable identity for a row, used to collapse exact duplicates.\n *\n * Whitespace and case are normalised so \"DN50 PIPE\" and \"dn50 pipe \" are one\n * row, but nothing else is: a differing quantity or unit produces a\n * different fingerprint and both rows are sent.\n *\n * @param array<int, mixed> $row\n */\n private function rowFingerprint(array $row): string\n {\n $parts = array_map(\n fn($cell) => mb_strtolower(preg_replace('/\\s+/u', ' ', trim((string) $cell)) ?? ''),\n $row,\n );\n\n // Drop a leading BOQ sequence number. Otherwise \"1,Pipe,m,100\" and\n // \"2,Pipe,m,100\" fingerprint differently and the dedup never fires —\n // which is exactly the shape every real BOQ has.\n if (isset($parts[0]) && $parts[0] !== '' && preg_match('/^[\\d.]+$/', $parts[0])) {\n array_shift($parts);\n }\n\n return md5(implode('|', $parts));\n }\n\n /**\n * Strip contractual boilerplate from a row's description cell.\n *\n * \"as per drawings\", \"complete with all necessary accessories\" and friends\n * repeat on nearly every line of a real BOQ and carry no information the\n * pricing engine can use — but they are a large share of the tokens.\n *\n * Only the longest text cell is touched, and only when the result is still a\n * usable description: if stripping would leave too little to identify the\n * product, the original is kept. A shorter-but-wrong description is far\n * worse than a verbose one.\n *\n * @param array<int, mixed> $row\n * @return array<int, mixed>\n */\n private function stripBoilerplate(array $row): array\n {\n $index = null;\n $longest = '';\n\n foreach ($row as $i => $cell) {\n $text = trim((string) $cell);\n if (preg_match('/\\p{L}/u', $text) && mb_strlen($text) > mb_strlen($longest)) {\n $longest = $text;\n $index = $i;\n }\n }\n\n // Nothing worth cleaning, or too short to risk it.\n if ($index === null || mb_strlen($longest) < 40) {\n return $row;\n }\n\n try {\n $cleaned = $this->boqCleaner->cleanDescription($longest);\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: boilerplate strip failed, keeping row as-is.', [\n 'error' => $e->getMessage(),\n ]);\n return $row;\n }\n\n // Guard against an over-eager pattern eating the product name: keep the\n // original unless a recognisable description survives.\n if (mb_strlen($cleaned) < 12 || mb_strlen($cleaned) < mb_strlen($longest) * 0.3) {\n return $row;\n }\n\n $row[$index] = $cleaned;\n\n return $row;\n }\n\n private function isNonSupplyRow(array $row): bool\n {\n $description = '';\n foreach ($row as $cell) {\n $text = trim((string) $cell);\n // Longest cell containing letters — numbers are qty/rate columns.\n if (preg_match('/\\p{L}/u', $text) && mb_strlen($text) > mb_strlen($description)) {\n $description = $text;\n }\n }\n\n // No text at all: leave it to isNoiseRow()/the AI rather than guessing.\n if ($description === '' || mb_strlen($description) < 3) {\n return false;\n }\n\n try {\n $verdict = $this->boqCleaner->filterItem($description);\n } catch (\\Throwable $e) {\n // A classifier failure must never silently drop a real product.\n Log::warning('QuotationAiService: pre-filter classifier failed, keeping row.', [\n 'error' => $e->getMessage(),\n ]);\n return false;\n }\n\n return ! ($verdict['keep'] ?? true);\n }\n\n private function isNoiseRow(array $row): bool\n {\n if (empty($row)) {\n return true;\n }\n\n $joined = trim(implode('', array_map(fn($c) => trim((string) $c), $row)));\n\n // Nothing but separators/whitespace.\n if ($joined === '') {\n return true;\n }\n\n // Ruled lines and decorative fills (\"-----\", \"====\", \"___\").\n if (preg_match('/^[\\-_=.*\\s|]+$/u', $joined)) {\n return true;\n }\n\n // Any letter — Latin or Arabic — means this could be a description.\n if (preg_match('/\\p{L}/u', $joined)) {\n return false;\n }\n\n // No letters at all: numbers only. A single number is a stray row\n // number or a dangling total; two or more could be qty/rate/amount\n // belonging to a real line, so keep those for the AI to judge.\n $numericCells = 0;\n foreach ($row as $cell) {\n if (trim((string) $cell) !== '') {\n $numericCells++;\n }\n }\n\n return $numericCells <= 1;\n }\n\n /**\n * Safely convert a PhpSpreadsheet cell value to a string.\n *\n * Cells may contain RichText or, in malformed/heavily-formatted files,\n * embedded objects such as Worksheet\\Drawing that have no __toString().\n * Casting those directly throws \"Object ... could not be converted to string\".\n */\n private function cellValueToString(mixed $value): string\n {\n if ($value === null) {\n return '';\n }\n\n if ($value instanceof \\PhpOffice\\PhpSpreadsheet\\RichText\\RichText) {\n return $value->getPlainText();\n }\n\n if (is_object($value)) {\n // Only stringify objects that explicitly support it; ignore the rest\n // (e.g. Worksheet\\Drawing) instead of crashing.\n return method_exists($value, '__toString') ? (string) $value : '';\n }\n\n if (is_array($value)) {\n return '';\n }\n\n return (string) $value;\n }\n\n private function csvEscape(mixed $value): string\n {\n $value = $this->cellValueToString($value);\n return str_contains($value, ',') || str_contains($value, '\"') || str_contains($value, \"\\n\")\n ? '\"' . str_replace('\"', '\"\"', $value) . '\"'\n : $value;\n }\n\n private function verifyLargeBoqItemsWithAi(array $result): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n Log::warning('QuotationAiService: large BOQ verification skipped, AI key is not configured.');\n return $result;\n }\n\n $items = array_values(array_filter($result['items'] ?? [], 'is_array'));\n if (count($items) <= self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n return $result;\n }\n\n Log::info('QuotationAiService: starting second-pass physical item verification.', [\n 'items_count' => count($items),\n 'threshold' => self::LARGE_BOQ_VERIFICATION_THRESHOLD,\n ]);\n\n $verifiedItems = [];\n $rejectedItems = $result['rejected'] ?? [];\n\n foreach (array_chunk($items, self::LARGE_BOQ_VERIFICATION_CHUNK_SIZE) as $chunkIndex => $chunk) {\n $payload = array_map(fn(array $item) => [\n 'description' => (string) ($item['description'] ?? ''),\n 'quantity' => $item['quantity'] ?? null,\n 'unit' => (string) ($item['unit'] ?? ''),\n 'unit_price' => $item['unit_price'] ?? null,\n 'category' => (string) ($item['category'] ?? ''),\n 'brand' => (string) ($item['brand'] ?? ''),\n ], $chunk);\n\n $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);\n if (! is_string($json)) {\n Log::warning('QuotationAiService: large BOQ verification chunk JSON encode failed.', [\n 'chunk' => $chunkIndex + 1,\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n $prompt = <<<PROMPT\nYou are doing a SECOND PASS verification on a BOQ extraction result.\n\nThe first pass may hallucinate non-products when the BOQ has thousands of rows.\nYour job is to keep ONLY physical, tangible, procurable supply products.\n\nRules:\n- Keep items only if a supplier can actually deliver them as materials/equipment.\n- Reject headings, trade names, systems, scopes, prose, specifications-only rows, totals, labor, installation, testing, commissioning, supervision, drawings, insurance, preliminaries, mobilisation, site works, and vague fragments.\n- Do not invent new products.\n- Do not add items that are not in the input.\n- You may clean a product description, but keep the original quantity, unit, unit_price, category, and brand when available.\n\nInput JSON array:\n{$json}\n\nReturn ONLY valid JSON in this shape:\n{\"items\":[{\"description\":\"\",\"quantity\":1,\"unit\":\"\",\"unit_price\":null,\"category\":\"\",\"brand\":\"\",\"confidence\":0.95}]}\nPROMPT;\n\n try {\n $chunkResult = $this->callDeepSeekChat($prompt, $apiKey, $this->deepSeekModel());\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: large BOQ verification chunk threw an exception.', [\n 'chunk' => $chunkIndex + 1,\n 'message' => $e->getMessage(),\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n if ($chunkResult['success'] ?? false) {\n array_push($verifiedItems, ...($chunkResult['items'] ?? []));\n $rejectedItems = array_merge($rejectedItems, $chunkResult['rejected'] ?? []);\n continue;\n }\n\n Log::warning('QuotationAiService: large BOQ verification chunk failed, keeping original chunk.', [\n 'chunk' => $chunkIndex + 1,\n 'error' => $chunkResult['error'] ?? null,\n ]);\n array_push($verifiedItems, ...$chunk);\n }\n\n if (empty($verifiedItems)) {\n Log::warning('QuotationAiService: second-pass verification returned no items, keeping first-pass result.', [\n 'original_items_count' => count($items),\n ]);\n return $result;\n }\n\n $result['items'] = $verifiedItems;\n $result['rejected'] = $rejectedItems;\n $result['large_boq_verified'] = true;\n\n Log::info('QuotationAiService: second-pass physical item verification completed.', [\n 'before' => count($items),\n 'after' => count($verifiedItems),\n ]);\n\n return $result;\n }\n\n private function normaliseAiItem(array $raw): ?array\n {\n if (! empty($raw['rejected'])) {\n return null;\n }\n\n $description = trim((string) ($raw['cleaned_description'] ?? $raw['product_name'] ?? $raw['description'] ?? ''));\n if ($description === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $description)) {\n return null;\n }\n\n $quantity = is_numeric($raw['quantity'] ?? null) ? (float) $raw['quantity'] : 1;\n $unitPrice = is_numeric($raw['unit_price'] ?? null) ? (float) $raw['unit_price'] : null;\n $totalPrice = is_numeric($raw['total_price'] ?? null) ? (float) $raw['total_price'] : null;\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $specs = is_array($raw['specifications'] ?? null) ? $raw['specifications'] : [];\n\n return [\n 'description' => $description,\n 'quantity' => $quantity,\n 'unit' => (string) ($raw['unit'] ?? ''),\n 'category' => (string) ($raw['core_product'] ?? $raw['category'] ?? ''),\n 'brand' => (string) ($specs['brand'] ?? $raw['brand'] ?? ''),\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($description),\n 'unit_price' => $unitPrice,\n 'confidence' => is_numeric($raw['confidence'] ?? null) ? (float) $raw['confidence'] : null,\n 'needs_review' => ! empty($raw['needs_review']),\n 'raw_data' => [\n 'source_item_no' => (string) ($raw['source_item_no'] ?? ''),\n 'original_description' => (string) ($raw['original_description'] ?? $description),\n 'cleaned_description' => $description,\n 'core_product' => (string) ($raw['core_product'] ?? ''),\n 'specifications' => $specs,\n 'extraction_type' => (string) ($raw['extraction_type'] ?? 'supply_only'),\n 'note' => (string) ($raw['note'] ?? $raw['notes'] ?? ''),\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function buildSystemInstruction(): string\n {\n return <<<'TXT'\nYou are a strict BOQ Supply Product Extraction Engine.\nExtract ONLY concrete, tangible, physical products that a supplier can box up and deliver to site — a specific item you could point at and say \"this is the thing being bought\" (e.g. \"Wall mounted LED luminaire 36W\", \"PVC pipe 110mm\", \"Single phase distribution board 12-way\", \"Fire extinguisher CO2 6kg\", \"CCTV dome camera 4MP\").\n\nABSOLUTELY REJECT abstract concepts, disciplines, categories, section names, and trade headings. These are NOT products even though they appear as rows:\n- Bare discipline/trade words: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", \"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\".\n- Summary / total / abstract / \"dry cost\" rows of any kind.\n- Anything that names a system, scope, or category instead of one buyable item.\n\nRule of thumb: if you cannot name a unit and physically hand the item over (a piece, a box, a length, a unit of equipment), REJECT it. A word that describes a field of work, a system, or a section of the BOQ is NEVER a product.\nReject totals, headings, general requirements, installation-only, testing, commissioning, supervision, mobilization, civil works, labor, and vague rows.\nFor Supply & Install rows, keep only the supply product and remove install/labor wording.\nReturn only valid JSON: {\"items\":[{\"source_item_no\":null,\"original_description\":\"\",\"cleaned_description\":\"\",\"core_product\":\"\",\"specifications\":{},\"quantity\":0,\"unit\":\"\",\"unit_price\":0,\"total_price\":0,\"extraction_type\":\"supply_only\",\"needs_review\":false,\"note\":\"\",\"rejected\":false,\"rejection_reason\":null,\"confidence\":0.95}]}\nTXT;\n }\n\n private function buildDeepSeekPrompt(array $context = [], string $mime = ''): string\n {\n $projectName = (string) ($context['project_name'] ?? '');\n $projectStatus = (string) ($context['project_status'] ?? '');\n $contextLine = $projectName !== ''\n ? \"Project: {$projectName}\" . ($projectStatus !== '' ? \" ({$projectStatus})\" : '')\n : '';\n $sourceHint = str_starts_with($mime, 'image/')\n ? 'The input is an image of a BOQ table. OCR every cell carefully — read the quantity/count column (العدد, الكمية, Qty) and extract the EXACT numeric value from each row.'\n : 'The input is a BOQ document/spreadsheet.';\n\n return trim(<<<PROMPT\nYou are a procurement specialist. Your task is to extract ONLY tangible, physical supply products from this Bill of Quantities (BOQ).\n\n{$contextLine}\n{$sourceHint}\n\nCRITICAL — MERGE WRAPPED / SPLIT ROWS INTO ONE ITEM:\nBOQ tables extracted from PDF often break ONE item's long description across several\nlines/rows. These continuation lines repeat the SAME quantity, unit and category, and\ntheir text is a grammatical continuation of the previous line (it starts mid-sentence,\nlowercase, or with \"the/and/-\", or is a spec/instruction clause). You MUST reconstruct\nthe original single item:\n- Treat consecutive rows that share the SAME quantity + unit + category as ONE product\n when the later rows are clearly continuation text of the first, NOT new products.\n- Emit ONE item with the merged description and the price counted ONCE. Do NOT repeat\n the same quantity×price for every wrapped line — that multiplies the total wrongly.\n- Example: rows \"The asphalt flooring is to be supplied\" / \"installed in accordance\" /\n \"standard road specifications...\" all with 7,100 m2 are ONE asphalt item, not 3+.\n\nCRITICAL — UNDERSTAND INTENT, DO NOT COPY ROWS LITERALLY:\nYour job is to output WHAT A SUPPLIER MUST ACTUALLY DELIVER, not a copy of the row text.\nRead each description for MEANING and decide how many real, separately-buyable products it\nimplies. One BOQ row can imply SEVERAL products, or a spread of rows can imply ONE product.\n- SPLIT a row into multiple items when it lists distinct products that a supplier would\n buy/price separately — even when joined by commas, \"with\", \"and\", \"including\", \"c/w\",\n \"consisting of\", \"comprising\", or \":\" then a list.\n Example: \"Fire pump set consisting of: fire pump, electric motor, control panel, diesel\n engine\" → FOUR items: (1) fire pump, (2) electric motor, (3) control panel, (4) diesel\n engine — each with its own quantity and priced on its own.\n Example: \"Wash basin complete with mixer tap and bottle trap\" → THREE items: wash basin,\n mixer tap, bottle trap.\n- Give each split product the correct quantity implied by the text (usually the parent\n quantity, unless the text states a different count for a sub-item). Each product is\n priced individually; the line total becomes the SUM of its products.\n- DO NOT split when the extra words are just specifications, dimensions, colours, ratings,\n standards, or a single product's integral parts. \"Steel door 900x2100 with vision panel\n and SS ironmongery\" is usually ONE door product — vision panel and ironmongery are part\n of that door, not separately-bought items. Use judgement: would a supplier issue a\n separate price line for it? If no, keep it as one.\n- Balanced approach: split only when genuinely separate products are present; when in doubt\n about whether something is a separate product vs. an accessory/spec of the main one, keep\n it as a single item. Do not invent products that are not in the text.\n\nSTRICT RULES — include ONLY items that are ALL of the following:\n1. A specific, concrete, tangible product that can be boxed up by a supplier and delivered to site — something you can point at and say \"this exact thing is being bought\" (e.g. \"LED luminaire 36W\", \"PVC pipe 110mm\", \"Distribution board 12-way\", \"CO2 fire extinguisher 6kg\").\n2. Has a real quantity (not 0 or \"varies\" with no meaning).\n3. Has enough description to identify what the product is.\n4. Is an actual PRODUCT, not a sentence, instruction, or specification clause. REJECT rows that are prose such as \"the supervising engineer's instructions\", \"The price includes all materials, labor\", \"Measurement will be based on the geometric cubic meter\", \"necessary steps to complete the work\", \"soil-contacting elements\", \"in compliance\", \"everything necessary\". These are description fragments, not buyable items.\n\nNEVER extract abstract concepts, disciplines, trades, systems, or section/category names — they are NOT products even when they appear as their own row:\n- Bare discipline/trade/system words such as: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", and their Arabic equivalents (\"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\", \"تشطيبات\").\n- Test: if the word names a FIELD OF WORK or a SYSTEM rather than one buyable item with a unit (pcs, m, set, kg…), REJECT it.\n\nEXCLUDE all of the following (do NOT include them):\n- Labor, installation, or workmanship items (e.g. \"install\", \"fix\", \"erect\", \"dismantle\", \"painting\", \"cleaning service\")\n- Project management, overhead, or profit items\n- Insurance, bonds, liability items (e.g. \"bodily injury\", \"third party liability\", \"CAR insurance\")\n- Shop drawings, as-built drawings, method statements, documentation\n- Preliminary items, mobilisation, demobilisation, provisional sums\n- Housekeeping, site clearance, waste removal\n- Floor/level labels (e.g. \"GF Floor\", \"Level 3\") — these are headers, not products\n- Pure dimension specs without a product noun (e.g. \"Dia 32 mm\", \"25 mm\") — reject unless a product name is present\n- Security services, night shifts, rentals, payments\n- Any item whose description is too vague or fragmentary to identify a specific product\n\nEmit ONE JSON item PER real product. A single source row may therefore produce several\nitems (when it lists multiple products) or several rows may collapse into one item (when\nthey are wrapped fragments of the same product). The \"items\" array is your final delivery\nlist, not a row-by-row transcript.\n\nFor each valid physical product return JSON with:\n- description: clean product name only (strip \"supply and install\", \"supply and fix\", installation clauses, floor/spec suffixes). For a split product, use just that product's own name.\n- quantity: the EXACT numeric value from the quantity/count column (العدد, الكمية, Qty, Quantity, No.). NEVER default to 1 unless the quantity column is truly absent or empty.\n- unit: unit of measure (pcs, m, m2, m3, set, no, kg, etc.)\n- category: product category\n- brand: brand/manufacturer if mentioned, else \"\"\n- engineering_required: true if the item needs engineering design before procurement (panel boards, chillers, generators, fire suppression systems, BMS, pumps, AHUs, switchgear, transformers), else false\n\nReturn ONLY a valid JSON object: {\"items\": [...]}. No explanations, no markdown, no extra text.\nPROMPT);\n }\n\n private function mockResponse(): array\n {\n return [\n 'success' => true,\n 'error' => null,\n 'items' => [[\n 'description' => 'Sample BOQ Item',\n 'quantity' => 1,\n 'unit' => 'No',\n 'category' => 'Sample',\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 1,\n 'raw_data' => [],\n 'ai_extracted' => true,\n ]],\n 'rejected' => [],\n ];\n }\n\n private function failure(string $message, bool $serviceUnavailable = false): array\n {\n return [\n 'success' => false,\n 'items' => [],\n 'rejected' => [],\n 'error' => $this->userFacingError($message, $serviceUnavailable),\n 'service_unavailable' => $serviceUnavailable,\n ];\n }\n\n /**\n * Translate any internal error into a neutral, user-safe message.\n *\n * The user must never see the name of the underlying AI/processing engine\n * (DeepSeek, Groq, Gemini, Vision, etc.) or configuration hints like API\n * keys. The detailed message is kept in the logs for debugging only.\n */\n private function userFacingError(string $message, bool $serviceUnavailable = false): string\n {\n // Detect any reference to an AI provider / engine / API config so we can\n // swap it for a generic, branded-neutral message.\n $sensitive = '/deepseek|groq|gemini|vision|openai|openrouter|api[\\s_-]?key|\\.env|non-?json|token limit/i';\n\n if (preg_match($sensitive, $message)) {\n Log::warning('QuotationAiService: suppressed technical error from user.', ['internal' => $message]);\n\n return $serviceUnavailable\n ? 'The file could not be processed right now. Please try again in a moment.'\n : 'We could not read this file automatically. Please make sure it is a clear BOQ (Excel, CSV, PDF, or image) and try again, or add the items manually.';\n }\n\n return $message;\n }\n\n private function mimeForExtension(string $ext): string\n {\n return match (strtolower($ext)) {\n 'pdf' => 'application/pdf',\n 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n 'xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12',\n 'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',\n 'xls' => 'application/vnd.ms-excel',\n 'csv' => 'text/csv',\n 'jpg', 'jpeg' => 'image/jpeg',\n 'png' => 'image/png',\n 'gif' => 'image/gif',\n 'bmp' => 'image/bmp',\n 'webp' => 'image/webp',\n 'tiff', 'tif' => 'image/tiff',\n 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'doc' => 'application/msword',\n default => 'application/octet-stream',\n };\n }\n}\n"}, "62ffe1c043c72fac40db0f04bef89ebd9b889aed": {"t": 1785748363.7379313, "n": 82553, "h": "68a4a986024f0c5b", "c": "<?php\n\nnamespace App\\Services;\n\nuse Illuminate\\Http\\Client\\ConnectionException;\nuse Illuminate\\Http\\UploadedFile;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\nuse PhpOffice\\PhpSpreadsheet\\Cell\\Coordinate;\nuse PhpOffice\\PhpSpreadsheet\\IOFactory;\n\nclass QuotationAiService\n{\n /** Increment this whenever BOQ parsing logic changes to invalidate old caches. */\n private const PARSER_VERSION = 10;\n private const LARGE_BOQ_VERIFICATION_THRESHOLD = 2000;\n private const LARGE_BOQ_VERIFICATION_CHUNK_SIZE = 120;\n\n /**\n * Characters of document text sent per AI call.\n *\n * Above this the document is split and parsed over several calls. Sized well\n * under the model's context so the prompt, the slice, and a full JSON answer\n * for every row in it all fit — the binding limit is the *output* budget, not\n * the input, since each input row produces a JSON object.\n */\n private const TEXT_CHUNK_CHARS = 60000;\n\n /**\n * A workbook with more sheets than this is split regardless of its size.\n *\n * Multiple tabs almost always mean multiple disciplines (electrical, HVAC,\n * civil…). Sending them in one call makes the model summarise across\n * sections and silently drop rows, so each slice gets a narrower scope.\n */\n private const MAX_SHEETS_PER_CALL = 2;\n\n /** A PDF longer than this is split regardless of its extracted text length. */\n private const MAX_PDF_PAGES_PER_CALL = 5;\n\n private string $baseUrl;\n private string $parseEndpoint;\n private string $apiKey;\n private int $timeout;\n private bool $testMode;\n private BoqCleaningService $boqCleaner;\n\n /** Page count of the most recently parsed PDF; 0 when unknown. */\n private int $lastPdfPageCount = 0;\n\n /**\n * Optional progress reporter for chunked parsing, called as ($part, $total).\n *\n * Set by the queue job so the polling UI can show real progress on a large\n * BOQ instead of a spinner that appears frozen for many minutes.\n *\n * @var (callable(int, int): void)|null\n */\n private $onChunkProgress = null;\n\n /** @param callable(int, int): void $callback */\n public function onChunkProgress(callable $callback): self\n {\n $this->onChunkProgress = $callback;\n return $this;\n }\n\n /**\n * Optional per-chunk item sink, called as ($items, $part, $total).\n *\n * Lets the caller persist each slice's rows the moment they arrive instead\n * of waiting for the whole document. On a large BOQ this is the difference\n * between a table that fills in progressively and one that stays empty for\n * many minutes.\n *\n * @var (callable(array, int, int): void)|null\n */\n private $onChunkItems = null;\n\n /** @param callable(array, int, int): void $callback */\n public function onChunkItems(callable $callback): self\n {\n $this->onChunkItems = $callback;\n return $this;\n }\n\n /**\n * Report a non-chunk stage (local parse, handing off to AI) to the caller.\n *\n * A spreadsheet is read locally first and only sent to the AI when that\n * fails, so a large file can spend minutes before any chunk exists. Without\n * these the UI shows a generic spinner for that whole period.\n */\n private function reportStage(string $message): void\n {\n if ($this->onStage !== null) {\n ($this->onStage)($message);\n }\n }\n\n /**\n * Report a workbook's shape from grids that are already in memory.\n *\n * Deliberately takes the parsed grids rather than a path: re-reading a large\n * spreadsheet just to count its sheets would double the slowest step of the\n * whole extraction.\n *\n * @param array<int, array{name: string, grid: array}> $sheets\n */\n private function reportWorkbookShape(array $sheets): void\n {\n if ($this->onStage === null || empty($sheets)) {\n return;\n }\n\n $rows = 0;\n foreach ($sheets as $sheet) {\n $rows += count($sheet['grid']);\n }\n\n $count = count($sheets);\n\n $this->reportStage($count > 1\n ? \"Found {$count} sheets, {$rows} rows in total. Reading…\"\n : \"Found {$rows} rows. Reading…\");\n }\n\n /** @var (callable(string): void)|null */\n private $onStage = null;\n\n /** @param callable(string): void $callback */\n public function onStage(callable $callback): self\n {\n $this->onStage = $callback;\n return $this;\n }\n\n public function __construct(BoqCleaningService $boqCleaner)\n {\n $this->boqCleaner = $boqCleaner;\n $this->baseUrl = rtrim((string) config('services.ai_quotation.base_url', ''), '/');\n $this->parseEndpoint = ltrim((string) config('services.ai_quotation.parse_endpoint', 'parse'), '/');\n $this->apiKey = (string) config('services.ai_quotation.api_key', '');\n $this->timeout = (int) config('services.ai_quotation.timeout', 300);\n $this->testMode = (bool) config('services.ai_quotation.test_mode', false);\n }\n\n /**\n * Give the running script enough head-room to outlast the AI HTTP call.\n *\n * The web request / queue worker must not be killed before the HTTP timeout\n * completes, otherwise the user sees a generic \"timeout or unavailable\"\n * error even though the AI may have responded. We allow the HTTP timeout\n * plus a 120s margin for connecting, response parsing and DB writes.\n */\n private function extendExecutionTime(): void\n {\n // Ignored on some SAPIs (e.g. when running under fastcgi with a hard\n // limit), but harmless to attempt.\n @set_time_limit($this->timeout + 120);\n }\n\n public function parseBoq(UploadedFile|string $file, array $context = []): array\n {\n if ($this->testMode) {\n return $this->mockResponse();\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n\n if (! is_file($absPath)) {\n return $this->failure('File not found or not readable.');\n }\n\n $fileHash = hash_file('sha256', $absPath);\n // Bump PARSER_VERSION whenever the extraction logic changes so that\n // previously-uploaded files are re-parsed instead of returning a stale\n // cached result produced by the old code.\n $cacheKey = 'boq_analysis_v' . self::PARSER_VERSION . '_' . $fileHash;\n $forceRefresh = (bool) ($context['force_refresh'] ?? false);\n\n if (! $forceRefresh && ($cached = Cache::get($cacheKey)) !== null) {\n return $cached;\n }\n\n // Clear stale cached result when force-refreshing\n if ($forceRefresh) {\n Cache::forget($cacheKey);\n }\n\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n // Read Excel locally first. Fall back to AI if local parse failed,\n // found 0 items, OR could not read one of the sheets (so multi-sheet\n // files never silently drop a sheet the local parser didn't understand).\n $this->reportStage('Reading the spreadsheet…');\n\n $result = $this->parseSpreadsheetDirect($absPath);\n\n $hasSkippedSheets = ! empty($result['skipped_sheets']);\n\n if (! $result['success'] || empty($result['items']) || $hasSkippedSheets) {\n // The local pass could not handle this file on its own. Say so:\n // otherwise the UI sits on a generic spinner through the local\n // parse AND the whole AI parse with nothing to show for either.\n $this->reportStage($hasSkippedSheets\n ? 'Some sheets need AI to read. Sending to AI…'\n : 'Spreadsheet needs AI to read. Sending to AI…');\n\n $deepSeekResult = $this->parseBoqWithDeepSeek($file, $context);\n // AI sees every sheet at once, so prefer its result whenever it\n // actually returned items (covers the skipped-sheet case too).\n if ($deepSeekResult['success'] && ! empty($deepSeekResult['items'])) {\n // Keep the larger of the two item sets so we never lose the\n // rows the local parser already extracted correctly.\n if (count($deepSeekResult['items']) >= count($result['items'] ?? [])) {\n $result = $deepSeekResult;\n }\n } elseif (! $result['success']) {\n $result = $deepSeekResult;\n }\n // else: keep local result if AI also failed/returned nothing\n }\n } elseif (in_array($ext, ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif'], true)) {\n $result = $this->parseBoqWithDeepSeek($file, $context);\n } else {\n return $this->failure('Unsupported file type. Please upload Excel, CSV, PDF, or image file.');\n }\n\n if ($result['success'] && ! empty($result['items'])) {\n $cleaning = $this->boqCleaner->process($result['items']);\n $result['items'] = $cleaning['accepted'] ?? $result['items'];\n $result['rejected'] = array_merge($result['rejected'] ?? [], $cleaning['rejected'] ?? []);\n }\n\n if ($result['success'] && count($result['items'] ?? []) > self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n $result = $this->verifyLargeBoqItemsWithAi($result);\n }\n\n // Only cache when we actually extracted items; empty results should be re-tried.\n if ($result['success'] && ! empty($result['items'])) {\n Cache::put($cacheKey, $result, now()->addDays(30));\n }\n\n return $result;\n }\n\n private function resolveFile(UploadedFile|string $file): array\n {\n if ($file instanceof UploadedFile) {\n return [(string) $file->getRealPath(), strtolower($file->getClientOriginalExtension())];\n }\n\n $absPath = is_file($file) ? $file : storage_path('app/' . ltrim($file, '/'));\n return [$absPath, strtolower(pathinfo($absPath, PATHINFO_EXTENSION))];\n }\n\n private function parseSpreadsheetDirect(string $absPath): array\n {\n ini_set('memory_limit', '2048M');\n\n // 180s was not enough for a real BOQ: an 11 200-row workbook was killed\n // mid-parse, and because set_time_limit() kills the process outright the\n // job reported a bare failure with nothing in the log to explain it.\n // This runs on the queue, where wall time is cheap.\n set_time_limit(1800);\n\n try {\n $sheets = $this->spreadsheetToGrids($absPath);\n\n if (empty($sheets)) {\n return $this->failure('Could not read the Excel file.');\n }\n\n $this->reportWorkbookShape($sheets);\n\n $items = [];\n $rejected = [];\n $skippedSheets = []; // sheets we could not parse locally (header undetected)\n\n foreach ($sheets as $sheet) {\n $grid = $sheet['grid'];\n if (count($grid) < 2) {\n continue;\n }\n\n // Skip dedicated summary / cost-abstract sheets entirely. Their rows\n // are per-discipline totals (Structural Concrete, Electrical, …) and\n // grand totals, not procurable products.\n if ($this->isSummarySheet($sheet['name'])) {\n Log::info('QuotationAiService: skipped summary sheet.', ['sheet' => $sheet['name']]);\n continue;\n }\n\n $header = $this->detectHeader($grid);\n\n if ($header === null || ! isset($header['map']['description'])) {\n Log::warning('QuotationAiService: header not detected in sheet.', ['sheet' => $sheet['name']]);\n $skippedSheets[] = $sheet['name'];\n continue;\n }\n\n $map = $header['map'];\n $start = $header['row'] + 1;\n\n for ($r = $start; $r < count($grid); $r++) {\n $row = $grid[$r];\n $description = $this->cell($row, $map['description'] ?? null);\n $itemNo = $this->cell($row, $map['item_code'] ?? null);\n $unit = $this->cell($row, $map['unit'] ?? null);\n $quantity = $this->number($this->cell($row, $map['quantity'] ?? null));\n $unitPrice = $this->number($this->cell($row, $map['unit_price'] ?? null));\n $totalPrice = $this->number($this->cell($row, $map['total_price'] ?? null));\n\n if ($description === '') {\n continue;\n }\n\n if (! isset($map['quantity'])) {\n $quantity = 1.0;\n }\n\n // Skip floor breakdown sub-rows: Ref. is \"-\" meaning it's a per-floor\n // quantity split of the parent item, not a standalone product.\n if ($itemNo === '-' || $this->isFloorBreakdownRow($itemNo, $description)) {\n continue;\n }\n\n if ($this->isTotalOrHeaderRow($description)) {\n continue;\n }\n\n if ($quantity === null || $quantity <= 0) {\n $rejected[] = $this->rejectedRow($description, $unit, $quantity, 'Missing quantity', $itemNo, $sheet['name']);\n continue;\n }\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $supply = $this->boqCleaner->filterItem($description, $unitPrice, $quantity);\n\n if (! ($supply['keep'] ?? false)) {\n $rejected[] = $this->rejectedRow(\n $description,\n $unit,\n $quantity,\n (string) ($supply['rejection_reason'] ?? 'Rejected by cleaner'),\n $itemNo,\n $sheet['name']\n );\n continue;\n }\n\n $finalDescription = (string) ($supply['description'] ?? $description);\n\n $items[] = [\n 'description' => $finalDescription,\n 'quantity' => $quantity,\n 'unit' => $unit,\n 'category' => $sheet['name'],\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($finalDescription),\n 'unit_price' => $unitPrice,\n 'confidence' => 0.95,\n 'needs_review' => false,\n 'raw_data' => [\n 'sheet' => $sheet['name'],\n 'row_number' => $r + 1,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'cleaned_description' => $finalDescription,\n 'extraction_type' => (string) ($supply['extraction_type'] ?? 'supply_only'),\n ],\n 'ai_extracted' => true,\n ];\n }\n }\n\n if (empty($items)) {\n return $this->failure('No BOQ supply items could be extracted from the Excel file.');\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'skipped_sheets' => $skippedSheets,\n ];\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: parseSpreadsheetDirect failed.', [\n 'path' => $absPath,\n 'message' => $e->getMessage(),\n 'trace' => $e->getTraceAsString(),\n ]);\n\n return $this->failure('Failed to read the Excel file: ' . $e->getMessage());\n }\n }\n\n private function spreadsheetToGrids(string $absPath): array\n {\n $ext = strtolower(pathinfo($absPath, PATHINFO_EXTENSION));\n\n if ($ext === 'csv') {\n $rows = [];\n $handle = fopen($absPath, 'rb');\n if ($handle !== false) {\n while (($row = fgetcsv($handle)) !== false) {\n $rows[] = $this->trimTrailingEmptyCells($row);\n }\n fclose($handle);\n }\n return [['name' => 'Sheet1', 'grid' => $rows]];\n }\n\n $reader = IOFactory::createReader($ext === 'xls' ? 'Xls' : 'Xlsx');\n $reader->setReadDataOnly(true);\n $spreadsheet = $reader->load($absPath);\n $sheets = [];\n\n foreach ($spreadsheet->getAllSheets() as $sheet) {\n $highestRow = $sheet->getHighestDataRow();\n $highestColumn = $sheet->getHighestDataColumn();\n $highestColumnIndex = Coordinate::columnIndexFromString($highestColumn);\n\n // BOQ data is normally in first 8-20 columns.\n // Files with heavy formatting/images can report 300+ columns — limit to 30.\n $maxColumnIndex = min($highestColumnIndex, 30);\n\n $grid = [];\n for ($row = 1; $row <= $highestRow; $row++) {\n $rowData = [];\n $hasValue = false;\n\n for ($col = 1; $col <= $maxColumnIndex; $col++) {\n $value = $sheet->getCell([$col, $row])->getValue();\n $value = $this->cellValueToString($value);\n $value = trim($value);\n if ($value !== null && $value !== '') {\n $hasValue = true;\n }\n $rowData[] = $value;\n }\n\n $rowData = $this->trimTrailingEmptyCells($rowData);\n if ($hasValue) {\n $grid[] = $rowData;\n }\n }\n\n if (! empty($grid)) {\n $sheets[] = ['name' => $sheet->getTitle(), 'grid' => $grid];\n }\n }\n\n $spreadsheet->disconnectWorksheets();\n unset($spreadsheet);\n\n return $sheets;\n }\n\n private function detectHeader(array $grid): ?array\n {\n $keywords = [\n 'item_code' => ['رقم البند', 'item no', 'item number', 'ref.', 'ref', 'item', 'no.', 'no', '#', 'كود'],\n 'description' => ['وص٠البند', 'scope of works', 'scope of work', 'scope', 'item description', 'description of works', 'description', 'بالعربية', 'الوصÙ', 'البند', 'بيان'],\n 'unit' => ['Ø§Ù„ÙˆØØ¯Ø©', 'unit', 'uom', 'u/m'],\n 'quantity' => ['الكمية', 'qty', 'quantity', 'q.ty'],\n 'unit_price' => ['سعر Ø§Ù„ÙˆØØ¯Ø©', 'u/price', 'unit price', 'unit rate', 'rate', 'price'],\n 'total_price' => ['السعر الإجمالي', 'total amount', 'total price', 'amount', 'الإجمالي'],\n ];\n\n $best = null;\n $bestScore = 0;\n\n for ($r = 0; $r < min(30, count($grid)); $r++) {\n $map = [];\n $score = 0;\n\n foreach ($grid[$r] as $c => $value) {\n $cell = mb_strtolower(trim((string) $value));\n if ($cell === '') {\n continue;\n }\n\n foreach ($keywords as $field => $list) {\n foreach ($list as $kw) {\n if (str_contains($cell, mb_strtolower($kw))) {\n if (! isset($map[$field])) {\n $map[$field] = $c;\n $score++;\n }\n break 2;\n }\n }\n }\n }\n\n if ($score > $bestScore) {\n $bestScore = $score;\n $best = ['row' => $r, 'map' => $map];\n }\n }\n\n if ($bestScore >= 3) {\n return $best;\n }\n\n // Allow simpler BOQ sheets that only define a description column plus one\n // other BOQ-related column, e.g. Item + Description or Description + Qty.\n if ($bestScore === 2 && isset($best['map']['description']) && (\n isset($best['map']['item_code']) ||\n isset($best['map']['quantity']) ||\n isset($best['map']['unit'])\n )) {\n return $best;\n }\n\n return null;\n }\n\n private function cell(array $row, ?int $index): string\n {\n if ($index === null) {\n return '';\n }\n return trim((string) ($row[$index] ?? ''));\n }\n\n private function number(string $value): ?float\n {\n $value = trim(str_replace([',', 'SAR', 'ر.س'], '', $value));\n if ($value === '' || ! is_numeric($value)) {\n return null;\n }\n return (float) $value;\n }\n\n private function trimTrailingEmptyCells(array $row): array\n {\n while (! empty($row) && trim((string) end($row)) === '') {\n array_pop($row);\n }\n return array_values($row);\n }\n\n private function isTotalOrHeaderRow(string $description): bool\n {\n $d = mb_strtolower(trim($description));\n\n if ($d === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $d)) {\n return true;\n }\n\n // Section totals / summaries\n if (preg_match('/(اجمالي|إجمالي|المجموع|grand\\s*total|sub\\s*total|total\\s*amount|total\\s*price)/iu', $d)) {\n return true;\n }\n\n // Bare \"Total\" lines, optionally with a currency suffix, e.g. \"Total\",\n // \"Total (SAR)\", \"Total cost\", \"Total (SAR) unit cost\".\n if (preg_match('/^total\\b[\\s\\(\\):a-z]*$/iu', $d)) {\n return true;\n }\n\n // Pure floor / level labels used as section headers\n if (preg_match('/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor)\\s*$/iu', $d)) {\n return true;\n }\n\n // Division / section header lines (e.g. \"Division - 02: Wood Works\")\n if (preg_match('/^division\\s*([-:]|\\d)/iu', $d)) {\n return true;\n }\n\n return false;\n }\n\n /**\n * Detect dedicated summary / cost-abstract sheets by their name. These hold\n * per-discipline rollups and grand totals (e.g. a \"Summary (Dry Cost)\" tab),\n * never procurable supply items, so they are skipped wholesale.\n */\n private function isSummarySheet(string $name): bool\n {\n $n = mb_strtolower(trim($name));\n\n if ($n === '') {\n return false;\n }\n\n return (bool) preg_match(\n '/\\b(summary|abstract|cost\\s*summary|grand\\s*summary|recap(?:itulation)?|bill\\s*summary|boq\\s*summary|dry\\s*cost)\\b|ملخص|الملخص|الملخّص|ملخّص|اجمالي|إجمالي/iu',\n $n\n );\n }\n\n private function isFloorBreakdownRow(string $itemNo, string $description): bool\n {\n // A row whose Ref column is empty or a dash is a sub-breakdown row (per-floor split).\n $ref = trim($itemNo);\n if ($ref === '' || $ref === '-') {\n // Confirm by checking the description is a location/floor label\n $d = mb_strtolower(trim($description));\n return (bool) preg_match(\n '/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor|\\d+f)\\s*$/iu',\n $d\n );\n }\n return false;\n }\n\n private function rejectedRow(string $description, string $unit, ?float $quantity, string $reason, string $itemNo = '', string $sheet = ''): array\n {\n return [\n 'description' => $description,\n 'quantity' => $quantity ?? 0,\n 'unit' => $unit,\n 'category' => $sheet,\n 'brand' => '',\n 'status' => 'rejected',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 0.0,\n 'raw_data' => [\n 'sheet' => $sheet,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'extraction_type' => 'rejected',\n 'rejection_reason' => $reason,\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function parseBoqWithDeepSeek(UploadedFile|string $file, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured. Please set DEEPSEEK_API_KEY in .env file.');\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n $mime = $this->mimeForExtension($ext);\n\n try {\n // ── Spreadsheets ──────────────────────────────────────────────────\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n $text = $this->spreadsheetToCompactCsvText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not convert spreadsheet to readable text.');\n }\n\n $text = $this->sanitizeUtf8($text);\n\n // A large BOQ exceeds what one request can carry, so send it in\n // sequential slices and merge. Anything that fits goes as-is.\n //\n // Size alone is not a good enough signal: a workbook with several\n // sheets is structurally a big BOQ even when the text is compact,\n // and asking the model to hold every discipline at once is where\n // rows start getting dropped. So split on either signal.\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS || $sheetCount > self::MAX_SHEETS_PER_CALL) {\n Log::info('QuotationAiService: spreadsheet routed to chunked parsing.', [\n 'chars' => mb_strlen($text),\n 'sheets' => $sheetCount,\n ]);\n return $this->parseTextInChunks($text, 'BOQ spreadsheet converted to CSV', $apiKey, $context);\n }\n\n $userContent = \"BOQ spreadsheet converted to CSV:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── PDF ───────────────────────────────────────────────────────────\n if ($ext === 'pdf') {\n $extracted = $this->extractPdfText($absPath);\n if ($extracted !== null && mb_strlen(trim($extracted)) > 50) {\n $extracted = $this->sanitizeUtf8($extracted);\n\n // Split on either signal — long text, or many pages. A BOQ\n // spanning several pages is a multi-section document even if\n // each page is sparse.\n if (mb_strlen($extracted) > self::TEXT_CHUNK_CHARS\n || $this->lastPdfPageCount > self::MAX_PDF_PAGES_PER_CALL) {\n Log::info('QuotationAiService: PDF routed to chunked parsing.', [\n 'chars' => mb_strlen($extracted),\n 'pages' => $this->lastPdfPageCount,\n ]);\n return $this->parseTextInChunks($extracted, 'BOQ PDF extracted text', $apiKey, $context, 'application/pdf');\n }\n\n $userContent = \"BOQ PDF extracted text:\\n\\n\" . $extracted . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'application/pdf');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n return $this->failure('Could not extract text from the PDF. Please make sure it is a text-based PDF (not a scanned image), or convert it to Excel or CSV.');\n }\n\n // -- Word documents (docx) ---------------------------------------------------\n if (in_array($ext, ['docx', 'doc'], true)) {\n $text = $this->extractDocxText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not extract text from the Word document. Please convert it to Excel, CSV, or PDF.');\n }\n $text = $this->sanitizeUtf8($text);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS) {\n return $this->parseTextInChunks($text, 'BOQ Word document extracted text', $apiKey, $context);\n }\n\n $userContent = \"BOQ Word document extracted text:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── Images ──────────────────────────────────────────────────────────────\n // DeepSeek cannot receive images. A vision-capable AI processes the image\n // directly (no OCR) — configure GROQ_API_KEY or GEMINI_API_KEY in .env.\n $imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif', 'heic', 'heif'];\n if (in_array($ext, $imageExts, true)) {\n $bytes = (string) file_get_contents($absPath);\n return $this->callDeepSeekVision($bytes, $mime, $context);\n }\n\n return $this->failure('Unsupported file type. Please upload an Excel, CSV, PDF, or image file.');\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: DeepSeek fallback failed.', ['message' => $e->getMessage()]);\n return $this->failure('An error occurred while processing the file with AI: ' . $e->getMessage());\n }\n }\n\n private function deepSeekModel(): string\n {\n return (string) config('services.deepseek.model', 'deepseek-chat');\n }\n\n /**\n * Remove or replace any non-UTF-8 / malformed byte sequences so json_encode never fails.\n */\n private function sanitizeUtf8(string $text): string\n {\n // Replace invalid byte sequences with the UTF-8 replacement character\n $clean = mb_convert_encoding($text, 'UTF-8', 'UTF-8');\n // Strip any remaining non-printable control characters (except tab/newline/CR)\n $clean = (string) preg_replace('/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]/u', '', $clean);\n return $clean;\n }\n\n /**\n * Extract plain text from a Word docx file using ZipArchive (no external library needed).\n * Reads word/document.xml and strips XML tags, preserving paragraph and table structure.\n */\n private function extractDocxText(string $absPath): ?string\n {\n try {\n $zip = new \\ZipArchive();\n if ($zip->open($absPath) !== true) {\n return null;\n }\n\n $xml = $zip->getFromName('word/document.xml');\n $zip->close();\n\n if ($xml === false || $xml === '') {\n return null;\n }\n\n // Add newlines before paragraph/table-row/table-cell tags so structure is preserved\n $xml = preg_replace('/<w:p[ >]/', \"\\n<w:p>\", $xml) ?? $xml;\n $xml = preg_replace('/<w:tr[ >]/', \"\\n<w:tr>\", $xml) ?? $xml;\n $xml = preg_replace('/<\\/w:tc>/', \"\\t\", $xml) ?? $xml;\n\n $text = strip_tags($xml);\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n return trim($text) !== '' ? trim($text) : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: DOCX parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Extract plain text from a PDF using smalot/pdfparser (pure PHP, no binary required).\n */\n private function extractPdfText(string $absPath): ?string\n {\n try {\n $parser = new \\Smalot\\PdfParser\\Parser();\n $pdf = $parser->parseFile($absPath);\n\n // Recorded for the chunking decision: a long PDF is structurally a\n // big BOQ even when its extracted text is short.\n try {\n $this->lastPdfPageCount = count($pdf->getPages());\n } catch (\\Throwable) {\n $this->lastPdfPageCount = 0;\n }\n\n $text = $pdf->getText();\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n return $text !== '' ? $text : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: PDF parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Send a vision request to a vision-capable AI model.\n * DeepSeek's commercial API (api.deepseek.com) does NOT support image inputs — any model.\n * Priority order (first configured key wins):\n * 1. GROQ_API_KEY → Groq llama-4-scout (free: https://console.groq.com)\n * 2. GEMINI_API_KEY → Google Gemini Flash (free: https://aistudio.google.com/apikey)\n * 3. VISION_API_KEY → any OpenAI-compatible endpoint (OpenAI, OpenRouter …)\n */\n private function callDeepSeekVision(string $bytes, string $mime, array $context): array\n {\n // ── 1. Groq (free, reliable vision) ─────────────────────────────────────\n if ((string) config('services.groq.key', '') !== '') {\n $visionKey = (string) config('services.groq.key');\n $visionBaseUrl = 'https://api.groq.com/openai/v1';\n $visionModel = (string) config('services.groq.model', 'meta-llama/llama-4-scout-17b-16e-instruct');\n }\n // ── 2. Google Gemini ─────────────────────────────────────────────────────\n elseif ((string) config('services.gemini.key', '') !== '') {\n $visionKey = (string) config('services.gemini.key');\n $visionBaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai';\n $visionModel = (string) config('services.gemini.model', 'gemini-2.0-flash');\n }\n // ── 3. Generic VISION_API_KEY (OpenAI, OpenRouter, …) ───────────────────\n elseif ((string) config('services.vision.key', '') !== '') {\n $visionKey = (string) config('services.vision.key');\n $visionBaseUrl = rtrim((string) config('services.vision.base_url', 'https://openrouter.ai/api/v1'), '/');\n $visionModel = (string) config('services.vision.model', 'google/gemini-flash-1.5-8b');\n }\n else {\n return $this->failure(\n 'Image processing is not available. DeepSeek\\'s API does not support images. ' .\n 'Get a free key at https://console.groq.com and add GROQ_API_KEY to your .env file.'\n );\n }\n\n $dataUrl = \"data:{$mime};base64,\" . base64_encode($bytes);\n $prompt = $this->buildDeepSeekPrompt($context, $mime);\n\n $userContent = [\n ['type' => 'text', 'text' => $prompt],\n ['type' => 'image_url', 'image_url' => ['url' => $dataUrl]],\n ];\n\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders([\n 'Authorization' => 'Bearer ' . $visionKey,\n 'HTTP-Referer' => config('app.url', 'https://qimta.sa'),\n 'X-Title' => 'Qimta Platform',\n ])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post($visionBaseUrl . '/chat/completions', [\n 'model' => $visionModel,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 16384,\n 'temperature' => 0.2,\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('Vision API connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: Vision API HTTP error.', [\n 'model' => $visionModel,\n 'base_url' => $visionBaseUrl,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n return $this->failure(\"Vision API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n return $this->parseDeepSeekJsonResponse((string) $response->json('choices.0.message.content'));\n }\n\n private function callDeepSeekChat(string $userContent, string $key, string $model): array\n {\n // Ensure the PHP script outlives the HTTP call (+ a margin for parsing the\n // response and writing to the DB) so the worker/web request is not killed\n // mid-flight, which surfaces to users as a generic \"timeout or unavailable\".\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders(['Authorization' => 'Bearer ' . $key])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 32768,\n 'temperature' => 0.1,\n 'response_format' => ['type' => 'json_object'],\n 'user' => 'Qimta_Platform',\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('DeepSeek connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: DeepSeek HTTP error.', [\n 'model' => $model,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n if ($response->status() === 401) {\n return $this->failure('DeepSeek API key is invalid or unauthorised. Please update DEEPSEEK_API_KEY in .env.', false);\n }\n return $this->failure(\"DeepSeek API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n $text = (string) $response->json('choices.0.message.content');\n return $this->parseDeepSeekJsonResponse($text);\n }\n\n /**\n * Parse a document too large for one AI call by sending it in slices.\n *\n * A 20 000-row BOQ is several megabytes of text — far beyond what a single\n * request can carry, and beyond what the model can answer without hitting its\n * output token limit. Previously the text was simply cut at 180 000 chars and\n * the remainder discarded: the parse \"succeeded\" while silently dropping most\n * of the file. Now the text is split and each slice parsed on its own call,\n * with the items merged.\n *\n * Splitting happens on line boundaries so a BOQ row is never cut in half.\n * Slices run sequentially rather than pooled: DeepSeek rate-limits hard on\n * concurrent large prompts, and this already runs on the queue where wall\n * time is cheap.\n *\n * A slice that fails does not fail the document — its rows are lost, but the\n * rest are kept and the shortfall is logged. Total failure is reported only\n * when every slice fails.\n *\n * @param string $label How the payload is introduced to the model.\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n /**\n * Split a spreadsheet into the slices the AI will be asked to read.\n *\n * Exposed so the work can be spread over several queue jobs instead of one\n * job walking every slice in sequence. Returns an empty array when the file\n * is small enough to go in a single call, or cannot be read.\n *\n * @return array<int, string>\n */\n public function chunkSpreadsheet(string $absPath): array\n {\n ini_set('memory_limit', '2048M');\n set_time_limit(1800);\n\n $text = $this->spreadsheetToCompactCsvText($absPath);\n\n if ($text === null || trim($text) === '') {\n return [];\n }\n\n $text = $this->sanitizeUtf8($text);\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) <= self::TEXT_CHUNK_CHARS && $sheetCount <= self::MAX_SHEETS_PER_CALL) {\n return [];\n }\n\n return $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n }\n\n /**\n * Parse one slice produced by chunkSpreadsheet().\n *\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n public function parseChunk(string $chunk, int $part, int $total, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured.');\n }\n\n // Same framing parseTextInChunks() uses, so a slice is never mistaken\n // for a complete BOQ and totals are not inferred from a fragment.\n $userContent = \"BOQ spreadsheet converted to CSV (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, 'text/plain');\n\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n private function parseTextInChunks(\n string $text,\n string $label,\n string $apiKey,\n array $context,\n string $mime = 'text/plain',\n ): array {\n $chunks = $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n $total = count($chunks);\n\n Log::info('QuotationAiService: large document split for parsing.', [\n 'chars' => mb_strlen($text),\n 'chunks' => $total,\n ]);\n\n // Announce the split before the first slice is sent, so the UI can show\n // how many parts there are instead of sitting on a generic message for\n // however long the first AI call takes.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)(0, $total);\n }\n\n $items = [];\n $rejected = [];\n $failed = 0;\n $lastError = null;\n\n foreach ($chunks as $index => $chunk) {\n $part = $index + 1;\n\n // Report progress so a 28-slice parse does not look frozen to the user.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)($part, $total);\n }\n\n // Tell the model this is a fragment, so it does not try to reconcile\n // totals or treat a mid-document slice as a complete BOQ.\n $userContent = \"{$label} (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, $mime);\n\n $result = $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n\n if (! ($result['success'] ?? false)) {\n $failed++;\n $lastError = $result['error'] ?? 'Chunk parsing failed.';\n Log::warning('QuotationAiService: chunk failed, continuing.', [\n 'part' => $part,\n 'of' => $total,\n 'error' => $lastError,\n ]);\n continue;\n }\n\n foreach ($result['items'] as $item) {\n $items[] = $item;\n }\n foreach ($result['rejected'] ?? [] as $row) {\n $rejected[] = $row;\n }\n\n // Hand this slice's rows straight to the caller so they can be shown\n // while the remaining slices are still being parsed.\n if ($this->onChunkItems !== null && $result['items'] !== []) {\n ($this->onChunkItems)($result['items'], $part, $total);\n }\n }\n\n // Every slice failed — there is nothing to show, so report the failure.\n if ($failed === $total) {\n return $this->failure($lastError ?? 'Could not parse the document.');\n }\n\n if ($failed > 0) {\n Log::error('QuotationAiService: document parsed with missing parts.', [\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n 'items_kept' => count($items),\n ]);\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'service_unavailable' => false,\n 'partial' => $failed > 0,\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n ];\n }\n\n /**\n * Split text into slices of at most $limit characters, breaking only at\n * newlines so no BOQ row is ever cut in half.\n *\n * A single line longer than the limit (rare — a pathological cell) is passed\n * through oversized rather than severed mid-row.\n *\n * @return array<int, string>\n */\n private function splitOnLineBoundaries(string $text, int $limit): array\n {\n $chunks = [];\n $current = '';\n\n // Sheet a chunk starts in the middle of. Repeated at the top of the next\n // chunk so the model always knows which sheet the rows belong to — the\n // header would otherwise be stranded in the previous slice, and rows\n // would arrive with no discipline context.\n $currentSheet = null;\n\n foreach (explode(\"\\n\", $text) as $line) {\n $isSheetHeader = str_starts_with($line, 'Sheet: ');\n\n // Start a new chunk at every sheet boundary. Sheets are usually\n // separate disciplines, and the whole point of splitting a multi-tab\n // workbook is to give the model one discipline at a time — a size-only\n // split would leave a compact 3-tab file as a single chunk, silently\n // undoing the sheet gate that routed it here.\n if ($isSheetHeader && $current !== '') {\n $chunks[] = $current;\n $current = $line;\n $currentSheet = $line;\n continue;\n }\n\n if ($isSheetHeader) {\n $currentSheet = $line;\n }\n\n $candidate = $current === '' ? $line : $current . \"\\n\" . $line;\n\n if (mb_strlen($candidate) > $limit && $current !== '') {\n $chunks[] = $current;\n\n // Carry the sheet header over, unless this line is itself one.\n $current = ($currentSheet !== null && ! str_starts_with($line, 'Sheet: '))\n ? $currentSheet . \" (continued)\\n\" . $line\n : $line;\n continue;\n }\n\n $current = $candidate;\n }\n\n if (trim($current) !== '') {\n $chunks[] = $current;\n }\n\n return $chunks;\n }\n\n /**\n * Shared JSON response parser used by both chat and vision paths.\n */\n private function parseDeepSeekJsonResponse(string $text): array\n {\n if ($text === '') {\n return $this->failure('DeepSeek returned an empty response.');\n }\n\n // Strip markdown code fences if the model wrapped the JSON.\n if (preg_match('/```(?:json)?\\s*([\\s\\S]*?)\\s*```/i', $text, $m)) {\n $text = $m[1];\n }\n // Narrow to the outermost JSON object/array.\n if (preg_match('/[\\{\\[][\\s\\S]*[\\}\\]]/s', $text, $m)) {\n $text = $m[0];\n }\n $text = trim($text);\n\n $decoded = json_decode($text, true);\n\n // The response can be truncated when the model hits the token limit,\n // leaving an unterminated JSON string/object. Try to repair it before\n // giving up so we can still salvage the items extracted so far.\n if (! is_array($decoded)) {\n $repaired = $this->repairTruncatedJson($text);\n if ($repaired !== null) {\n $decoded = json_decode($repaired, true);\n }\n }\n\n if (! is_array($decoded)) {\n Log::error('QuotationAiService: DeepSeek non-JSON response.', ['text' => mb_substr($text, 0, 1000)]);\n return $this->failure('DeepSeek returned a non-JSON response. The file may be too large — try splitting it into smaller files.');\n }\n\n $rawItems = $decoded['items'] ?? (array_is_list($decoded) ? $decoded : []);\n $items = [];\n $rejected = [];\n\n foreach ($rawItems as $raw) {\n if (! is_array($raw)) {\n continue;\n }\n $item = $this->normaliseAiItem($raw);\n if ($item === null) {\n continue;\n }\n $check = $this->boqCleaner->filterItem((string) $item['description']);\n if ($check['keep'] ?? false) {\n $items[] = $item;\n } else {\n $item['status'] = 'rejected';\n $item['raw_data']['rejection_reason'] = $check['rejection_reason'] ?? 'Rejected by cleaner';\n $rejected[] = $item;\n }\n }\n\n if (empty($items)) {\n Log::info('QuotationAiService: DeepSeek returned 0 accepted items.', [\n 'raw_items_count' => count($rawItems),\n 'rejected_count' => count($rejected),\n 'first_rejected' => ! empty($rejected) ? ($rejected[0]['description'] ?? '') : '',\n ]);\n }\n\n return ['success' => true, 'items' => $items, 'rejected' => $rejected, 'error' => null];\n }\n\n /**\n * Best-effort repair of a truncated JSON string produced when the model hits\n * its token limit mid-output. Drops any incomplete trailing object, then\n * closes the open string/array/object brackets so the result is valid JSON.\n *\n * Returns the repaired JSON string, or null if it cannot be salvaged.\n */\n private function repairTruncatedJson(string $text): ?string\n {\n if ($text === '') {\n return null;\n }\n\n // Walk the string tracking structural state, ignoring everything inside\n // string literals (and their escapes). Remember the last position where\n // we were back at the top of the \"items\" array between complete elements,\n // so we can cut a half-written trailing object.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($text);\n $lastSafeCut = null; // index just after a completed array element\n\n for ($i = 0; $i < $len; $i++) {\n $ch = $text[$i];\n\n if ($inString) {\n if ($escaped) {\n $escaped = false;\n } elseif ($ch === '\\\\') {\n $escaped = true;\n } elseif ($ch === '\"') {\n $inString = false;\n }\n continue;\n }\n\n if ($ch === '\"') {\n $inString = true;\n } elseif ($ch === '{' || $ch === '[') {\n $stack[] = $ch;\n } elseif ($ch === '}' || $ch === ']') {\n array_pop($stack);\n } elseif ($ch === ',' && ! empty($stack) && end($stack) === '[') {\n // Separator directly inside an array: a clean boundary between\n // elements (the items array, whatever its nesting depth).\n $lastSafeCut = $i;\n }\n }\n\n // If we are still inside a string, the last element is incomplete — fall\n // back to the last clean array boundary if we have one.\n $candidate = $text;\n if ($inString && $lastSafeCut !== null) {\n $candidate = substr($text, 0, $lastSafeCut);\n } else {\n // Drop a dangling partial object after the last completed element.\n $candidate = rtrim($candidate);\n if ($lastSafeCut !== null && substr(rtrim($candidate), -1) !== '}' && substr(rtrim($candidate), -1) !== ']') {\n $candidate = substr($text, 0, $lastSafeCut);\n }\n }\n\n // Recompute the open brackets for the (possibly trimmed) candidate and\n // append the matching closers in reverse order.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($candidate);\n for ($i = 0; $i < $len; $i++) {\n $ch = $candidate[$i];\n if ($inString) {\n if ($escaped) { $escaped = false; }\n elseif ($ch === '\\\\') { $escaped = true; }\n elseif ($ch === '\"') { $inString = false; }\n continue;\n }\n if ($ch === '\"') { $inString = true; }\n elseif ($ch === '{' || $ch === '[') { $stack[] = $ch; }\n elseif ($ch === '}' || $ch === ']') { array_pop($stack); }\n }\n\n $candidate = rtrim($candidate);\n // Remove a trailing comma left after cutting an element.\n $candidate = rtrim($candidate, ',');\n\n $closers = '';\n for ($i = count($stack) - 1; $i >= 0; $i--) {\n $closers .= ($stack[$i] === '{') ? '}' : ']';\n }\n\n $repaired = $candidate . $closers;\n\n // Validate before returning.\n return is_array(json_decode($repaired, true)) ? $repaired : null;\n }\n\n private function spreadsheetToCompactCsvText(string $absPath): ?string\n {\n $sheets = $this->spreadsheetToGrids($absPath);\n if (empty($sheets)) {\n return null;\n }\n\n $output = '';\n $kept = 0;\n $dropped = 0;\n $nonSupply = 0;\n\n foreach ($sheets as $sheet) {\n // Don't feed summary / cost-abstract sheets to the AI either.\n if ($this->isSummarySheet($sheet['name'])) {\n continue;\n }\n $sheetRows = '';\n foreach ($sheet['grid'] as $row) {\n $row = $this->trimTrailingEmptyCells($row);\n\n if ($this->isNoiseRow($row)) {\n $dropped++;\n continue;\n }\n\n // Drop rows that are definitely not procurable before they cost\n // a single token: section totals, discipline headings,\n // preliminaries, labour-only lines. The same classifier already\n // runs on the AI's output, but by then the tokens are spent — on\n // an 11 200-row BOQ that is most of the bill.\n if ($this->isNonSupplyRow($row)) {\n $dropped++;\n $nonSupply++;\n continue;\n }\n\n $sheetRows .= implode(',', array_map([$this, 'csvEscape'], $row)) . \"\\n\";\n $kept++;\n }\n\n // A sheet whose rows were all noise contributes nothing but its own\n // header, which would just be another thing for the model to explain.\n if ($sheetRows === '') {\n continue;\n }\n\n $output .= 'Sheet: ' . $sheet['name'] . \"\\n\" . $sheetRows . \"\\n\";\n }\n\n $blank = $dropped - $nonSupply;\n\n Log::info('QuotationAiService: filtered rows before sending to AI.', [\n 'kept' => $kept,\n 'blank' => $blank,\n 'non_supply' => $nonSupply,\n ]);\n\n if ($dropped > 0) {\n $this->reportStage($nonSupply > 0\n ? \"Skipped {$blank} blank and {$nonSupply} non-supply rows. Sending {$kept}…\"\n : \"Removed {$blank} blank rows. Sending {$kept} rows…\");\n }\n\n return $output;\n }\n\n /**\n * True when a row carries no information worth sending to the AI.\n *\n * A real BOQ is mostly formatting: spacer rows, stray borders, a lone\n * section number, a leftover cell from a merged block. They survive\n * trimTrailingEmptyCells() because they are not *entirely* empty, then cost\n * tokens, dilute the prompt, and inflate the number of parts a large file is\n * split into.\n *\n * Deliberately conservative — a row is dropped only when nothing in it could\n * name a product. Anything with letters is kept, because a description is\n * the one field an item cannot do without.\n *\n * @param array<int, mixed> $row\n */\n /**\n * True when a row is definitely not a procurable supply item.\n *\n * Runs BoqCleaningService over the row's longest text cell — the one most\n * likely to be the description — so headings, totals, preliminaries and\n * labour-only lines never reach the AI. The same classifier already vets the\n * AI's *output*; doing it here as well means we stop paying to have those\n * rows read in the first place.\n *\n * Conservative by construction: only rows the classifier positively rejects\n * are dropped. Anything it is unsure about goes to the AI, which is the\n * better judge — a wrongly dropped row is invisible, a wrongly kept one only\n * costs tokens.\n *\n * @param array<int, mixed> $row\n */\n private function isNonSupplyRow(array $row): bool\n {\n $description = '';\n foreach ($row as $cell) {\n $text = trim((string) $cell);\n // Longest cell containing letters — numbers are qty/rate columns.\n if (preg_match('/\\p{L}/u', $text) && mb_strlen($text) > mb_strlen($description)) {\n $description = $text;\n }\n }\n\n // No text at all: leave it to isNoiseRow()/the AI rather than guessing.\n if ($description === '' || mb_strlen($description) < 3) {\n return false;\n }\n\n try {\n $verdict = $this->boqCleaner->filterItem($description);\n } catch (\\Throwable $e) {\n // A classifier failure must never silently drop a real product.\n Log::warning('QuotationAiService: pre-filter classifier failed, keeping row.', [\n 'error' => $e->getMessage(),\n ]);\n return false;\n }\n\n return ! ($verdict['keep'] ?? true);\n }\n\n private function isNoiseRow(array $row): bool\n {\n if (empty($row)) {\n return true;\n }\n\n $joined = trim(implode('', array_map(fn($c) => trim((string) $c), $row)));\n\n // Nothing but separators/whitespace.\n if ($joined === '') {\n return true;\n }\n\n // Ruled lines and decorative fills (\"-----\", \"====\", \"___\").\n if (preg_match('/^[\\-_=.*\\s|]+$/u', $joined)) {\n return true;\n }\n\n // Any letter — Latin or Arabic — means this could be a description.\n if (preg_match('/\\p{L}/u', $joined)) {\n return false;\n }\n\n // No letters at all: numbers only. A single number is a stray row\n // number or a dangling total; two or more could be qty/rate/amount\n // belonging to a real line, so keep those for the AI to judge.\n $numericCells = 0;\n foreach ($row as $cell) {\n if (trim((string) $cell) !== '') {\n $numericCells++;\n }\n }\n\n return $numericCells <= 1;\n }\n\n /**\n * Safely convert a PhpSpreadsheet cell value to a string.\n *\n * Cells may contain RichText or, in malformed/heavily-formatted files,\n * embedded objects such as Worksheet\\Drawing that have no __toString().\n * Casting those directly throws \"Object ... could not be converted to string\".\n */\n private function cellValueToString(mixed $value): string\n {\n if ($value === null) {\n return '';\n }\n\n if ($value instanceof \\PhpOffice\\PhpSpreadsheet\\RichText\\RichText) {\n return $value->getPlainText();\n }\n\n if (is_object($value)) {\n // Only stringify objects that explicitly support it; ignore the rest\n // (e.g. Worksheet\\Drawing) instead of crashing.\n return method_exists($value, '__toString') ? (string) $value : '';\n }\n\n if (is_array($value)) {\n return '';\n }\n\n return (string) $value;\n }\n\n private function csvEscape(mixed $value): string\n {\n $value = $this->cellValueToString($value);\n return str_contains($value, ',') || str_contains($value, '\"') || str_contains($value, \"\\n\")\n ? '\"' . str_replace('\"', '\"\"', $value) . '\"'\n : $value;\n }\n\n private function verifyLargeBoqItemsWithAi(array $result): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n Log::warning('QuotationAiService: large BOQ verification skipped, AI key is not configured.');\n return $result;\n }\n\n $items = array_values(array_filter($result['items'] ?? [], 'is_array'));\n if (count($items) <= self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n return $result;\n }\n\n Log::info('QuotationAiService: starting second-pass physical item verification.', [\n 'items_count' => count($items),\n 'threshold' => self::LARGE_BOQ_VERIFICATION_THRESHOLD,\n ]);\n\n $verifiedItems = [];\n $rejectedItems = $result['rejected'] ?? [];\n\n foreach (array_chunk($items, self::LARGE_BOQ_VERIFICATION_CHUNK_SIZE) as $chunkIndex => $chunk) {\n $payload = array_map(fn(array $item) => [\n 'description' => (string) ($item['description'] ?? ''),\n 'quantity' => $item['quantity'] ?? null,\n 'unit' => (string) ($item['unit'] ?? ''),\n 'unit_price' => $item['unit_price'] ?? null,\n 'category' => (string) ($item['category'] ?? ''),\n 'brand' => (string) ($item['brand'] ?? ''),\n ], $chunk);\n\n $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);\n if (! is_string($json)) {\n Log::warning('QuotationAiService: large BOQ verification chunk JSON encode failed.', [\n 'chunk' => $chunkIndex + 1,\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n $prompt = <<<PROMPT\nYou are doing a SECOND PASS verification on a BOQ extraction result.\n\nThe first pass may hallucinate non-products when the BOQ has thousands of rows.\nYour job is to keep ONLY physical, tangible, procurable supply products.\n\nRules:\n- Keep items only if a supplier can actually deliver them as materials/equipment.\n- Reject headings, trade names, systems, scopes, prose, specifications-only rows, totals, labor, installation, testing, commissioning, supervision, drawings, insurance, preliminaries, mobilisation, site works, and vague fragments.\n- Do not invent new products.\n- Do not add items that are not in the input.\n- You may clean a product description, but keep the original quantity, unit, unit_price, category, and brand when available.\n\nInput JSON array:\n{$json}\n\nReturn ONLY valid JSON in this shape:\n{\"items\":[{\"description\":\"\",\"quantity\":1,\"unit\":\"\",\"unit_price\":null,\"category\":\"\",\"brand\":\"\",\"confidence\":0.95}]}\nPROMPT;\n\n try {\n $chunkResult = $this->callDeepSeekChat($prompt, $apiKey, $this->deepSeekModel());\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: large BOQ verification chunk threw an exception.', [\n 'chunk' => $chunkIndex + 1,\n 'message' => $e->getMessage(),\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n if ($chunkResult['success'] ?? false) {\n array_push($verifiedItems, ...($chunkResult['items'] ?? []));\n $rejectedItems = array_merge($rejectedItems, $chunkResult['rejected'] ?? []);\n continue;\n }\n\n Log::warning('QuotationAiService: large BOQ verification chunk failed, keeping original chunk.', [\n 'chunk' => $chunkIndex + 1,\n 'error' => $chunkResult['error'] ?? null,\n ]);\n array_push($verifiedItems, ...$chunk);\n }\n\n if (empty($verifiedItems)) {\n Log::warning('QuotationAiService: second-pass verification returned no items, keeping first-pass result.', [\n 'original_items_count' => count($items),\n ]);\n return $result;\n }\n\n $result['items'] = $verifiedItems;\n $result['rejected'] = $rejectedItems;\n $result['large_boq_verified'] = true;\n\n Log::info('QuotationAiService: second-pass physical item verification completed.', [\n 'before' => count($items),\n 'after' => count($verifiedItems),\n ]);\n\n return $result;\n }\n\n private function normaliseAiItem(array $raw): ?array\n {\n if (! empty($raw['rejected'])) {\n return null;\n }\n\n $description = trim((string) ($raw['cleaned_description'] ?? $raw['product_name'] ?? $raw['description'] ?? ''));\n if ($description === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $description)) {\n return null;\n }\n\n $quantity = is_numeric($raw['quantity'] ?? null) ? (float) $raw['quantity'] : 1;\n $unitPrice = is_numeric($raw['unit_price'] ?? null) ? (float) $raw['unit_price'] : null;\n $totalPrice = is_numeric($raw['total_price'] ?? null) ? (float) $raw['total_price'] : null;\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $specs = is_array($raw['specifications'] ?? null) ? $raw['specifications'] : [];\n\n return [\n 'description' => $description,\n 'quantity' => $quantity,\n 'unit' => (string) ($raw['unit'] ?? ''),\n 'category' => (string) ($raw['core_product'] ?? $raw['category'] ?? ''),\n 'brand' => (string) ($specs['brand'] ?? $raw['brand'] ?? ''),\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($description),\n 'unit_price' => $unitPrice,\n 'confidence' => is_numeric($raw['confidence'] ?? null) ? (float) $raw['confidence'] : null,\n 'needs_review' => ! empty($raw['needs_review']),\n 'raw_data' => [\n 'source_item_no' => (string) ($raw['source_item_no'] ?? ''),\n 'original_description' => (string) ($raw['original_description'] ?? $description),\n 'cleaned_description' => $description,\n 'core_product' => (string) ($raw['core_product'] ?? ''),\n 'specifications' => $specs,\n 'extraction_type' => (string) ($raw['extraction_type'] ?? 'supply_only'),\n 'note' => (string) ($raw['note'] ?? $raw['notes'] ?? ''),\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function buildSystemInstruction(): string\n {\n return <<<'TXT'\nYou are a strict BOQ Supply Product Extraction Engine.\nExtract ONLY concrete, tangible, physical products that a supplier can box up and deliver to site — a specific item you could point at and say \"this is the thing being bought\" (e.g. \"Wall mounted LED luminaire 36W\", \"PVC pipe 110mm\", \"Single phase distribution board 12-way\", \"Fire extinguisher CO2 6kg\", \"CCTV dome camera 4MP\").\n\nABSOLUTELY REJECT abstract concepts, disciplines, categories, section names, and trade headings. These are NOT products even though they appear as rows:\n- Bare discipline/trade words: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", \"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\".\n- Summary / total / abstract / \"dry cost\" rows of any kind.\n- Anything that names a system, scope, or category instead of one buyable item.\n\nRule of thumb: if you cannot name a unit and physically hand the item over (a piece, a box, a length, a unit of equipment), REJECT it. A word that describes a field of work, a system, or a section of the BOQ is NEVER a product.\nReject totals, headings, general requirements, installation-only, testing, commissioning, supervision, mobilization, civil works, labor, and vague rows.\nFor Supply & Install rows, keep only the supply product and remove install/labor wording.\nReturn only valid JSON: {\"items\":[{\"source_item_no\":null,\"original_description\":\"\",\"cleaned_description\":\"\",\"core_product\":\"\",\"specifications\":{},\"quantity\":0,\"unit\":\"\",\"unit_price\":0,\"total_price\":0,\"extraction_type\":\"supply_only\",\"needs_review\":false,\"note\":\"\",\"rejected\":false,\"rejection_reason\":null,\"confidence\":0.95}]}\nTXT;\n }\n\n private function buildDeepSeekPrompt(array $context = [], string $mime = ''): string\n {\n $projectName = (string) ($context['project_name'] ?? '');\n $projectStatus = (string) ($context['project_status'] ?? '');\n $contextLine = $projectName !== ''\n ? \"Project: {$projectName}\" . ($projectStatus !== '' ? \" ({$projectStatus})\" : '')\n : '';\n $sourceHint = str_starts_with($mime, 'image/')\n ? 'The input is an image of a BOQ table. OCR every cell carefully — read the quantity/count column (العدد, الكمية, Qty) and extract the EXACT numeric value from each row.'\n : 'The input is a BOQ document/spreadsheet.';\n\n return trim(<<<PROMPT\nYou are a procurement specialist. Your task is to extract ONLY tangible, physical supply products from this Bill of Quantities (BOQ).\n\n{$contextLine}\n{$sourceHint}\n\nCRITICAL — MERGE WRAPPED / SPLIT ROWS INTO ONE ITEM:\nBOQ tables extracted from PDF often break ONE item's long description across several\nlines/rows. These continuation lines repeat the SAME quantity, unit and category, and\ntheir text is a grammatical continuation of the previous line (it starts mid-sentence,\nlowercase, or with \"the/and/-\", or is a spec/instruction clause). You MUST reconstruct\nthe original single item:\n- Treat consecutive rows that share the SAME quantity + unit + category as ONE product\n when the later rows are clearly continuation text of the first, NOT new products.\n- Emit ONE item with the merged description and the price counted ONCE. Do NOT repeat\n the same quantity×price for every wrapped line — that multiplies the total wrongly.\n- Example: rows \"The asphalt flooring is to be supplied\" / \"installed in accordance\" /\n \"standard road specifications...\" all with 7,100 m2 are ONE asphalt item, not 3+.\n\nCRITICAL — UNDERSTAND INTENT, DO NOT COPY ROWS LITERALLY:\nYour job is to output WHAT A SUPPLIER MUST ACTUALLY DELIVER, not a copy of the row text.\nRead each description for MEANING and decide how many real, separately-buyable products it\nimplies. One BOQ row can imply SEVERAL products, or a spread of rows can imply ONE product.\n- SPLIT a row into multiple items when it lists distinct products that a supplier would\n buy/price separately — even when joined by commas, \"with\", \"and\", \"including\", \"c/w\",\n \"consisting of\", \"comprising\", or \":\" then a list.\n Example: \"Fire pump set consisting of: fire pump, electric motor, control panel, diesel\n engine\" → FOUR items: (1) fire pump, (2) electric motor, (3) control panel, (4) diesel\n engine — each with its own quantity and priced on its own.\n Example: \"Wash basin complete with mixer tap and bottle trap\" → THREE items: wash basin,\n mixer tap, bottle trap.\n- Give each split product the correct quantity implied by the text (usually the parent\n quantity, unless the text states a different count for a sub-item). Each product is\n priced individually; the line total becomes the SUM of its products.\n- DO NOT split when the extra words are just specifications, dimensions, colours, ratings,\n standards, or a single product's integral parts. \"Steel door 900x2100 with vision panel\n and SS ironmongery\" is usually ONE door product — vision panel and ironmongery are part\n of that door, not separately-bought items. Use judgement: would a supplier issue a\n separate price line for it? If no, keep it as one.\n- Balanced approach: split only when genuinely separate products are present; when in doubt\n about whether something is a separate product vs. an accessory/spec of the main one, keep\n it as a single item. Do not invent products that are not in the text.\n\nSTRICT RULES — include ONLY items that are ALL of the following:\n1. A specific, concrete, tangible product that can be boxed up by a supplier and delivered to site — something you can point at and say \"this exact thing is being bought\" (e.g. \"LED luminaire 36W\", \"PVC pipe 110mm\", \"Distribution board 12-way\", \"CO2 fire extinguisher 6kg\").\n2. Has a real quantity (not 0 or \"varies\" with no meaning).\n3. Has enough description to identify what the product is.\n4. Is an actual PRODUCT, not a sentence, instruction, or specification clause. REJECT rows that are prose such as \"the supervising engineer's instructions\", \"The price includes all materials, labor\", \"Measurement will be based on the geometric cubic meter\", \"necessary steps to complete the work\", \"soil-contacting elements\", \"in compliance\", \"everything necessary\". These are description fragments, not buyable items.\n\nNEVER extract abstract concepts, disciplines, trades, systems, or section/category names — they are NOT products even when they appear as their own row:\n- Bare discipline/trade/system words such as: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", and their Arabic equivalents (\"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\", \"تشطيبات\").\n- Test: if the word names a FIELD OF WORK or a SYSTEM rather than one buyable item with a unit (pcs, m, set, kg…), REJECT it.\n\nEXCLUDE all of the following (do NOT include them):\n- Labor, installation, or workmanship items (e.g. \"install\", \"fix\", \"erect\", \"dismantle\", \"painting\", \"cleaning service\")\n- Project management, overhead, or profit items\n- Insurance, bonds, liability items (e.g. \"bodily injury\", \"third party liability\", \"CAR insurance\")\n- Shop drawings, as-built drawings, method statements, documentation\n- Preliminary items, mobilisation, demobilisation, provisional sums\n- Housekeeping, site clearance, waste removal\n- Floor/level labels (e.g. \"GF Floor\", \"Level 3\") — these are headers, not products\n- Pure dimension specs without a product noun (e.g. \"Dia 32 mm\", \"25 mm\") — reject unless a product name is present\n- Security services, night shifts, rentals, payments\n- Any item whose description is too vague or fragmentary to identify a specific product\n\nEmit ONE JSON item PER real product. A single source row may therefore produce several\nitems (when it lists multiple products) or several rows may collapse into one item (when\nthey are wrapped fragments of the same product). The \"items\" array is your final delivery\nlist, not a row-by-row transcript.\n\nFor each valid physical product return JSON with:\n- description: clean product name only (strip \"supply and install\", \"supply and fix\", installation clauses, floor/spec suffixes). For a split product, use just that product's own name.\n- quantity: the EXACT numeric value from the quantity/count column (العدد, الكمية, Qty, Quantity, No.). NEVER default to 1 unless the quantity column is truly absent or empty.\n- unit: unit of measure (pcs, m, m2, m3, set, no, kg, etc.)\n- category: product category\n- brand: brand/manufacturer if mentioned, else \"\"\n- engineering_required: true if the item needs engineering design before procurement (panel boards, chillers, generators, fire suppression systems, BMS, pumps, AHUs, switchgear, transformers), else false\n\nReturn ONLY a valid JSON object: {\"items\": [...]}. No explanations, no markdown, no extra text.\nPROMPT);\n }\n\n private function mockResponse(): array\n {\n return [\n 'success' => true,\n 'error' => null,\n 'items' => [[\n 'description' => 'Sample BOQ Item',\n 'quantity' => 1,\n 'unit' => 'No',\n 'category' => 'Sample',\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 1,\n 'raw_data' => [],\n 'ai_extracted' => true,\n ]],\n 'rejected' => [],\n ];\n }\n\n private function failure(string $message, bool $serviceUnavailable = false): array\n {\n return [\n 'success' => false,\n 'items' => [],\n 'rejected' => [],\n 'error' => $this->userFacingError($message, $serviceUnavailable),\n 'service_unavailable' => $serviceUnavailable,\n ];\n }\n\n /**\n * Translate any internal error into a neutral, user-safe message.\n *\n * The user must never see the name of the underlying AI/processing engine\n * (DeepSeek, Groq, Gemini, Vision, etc.) or configuration hints like API\n * keys. The detailed message is kept in the logs for debugging only.\n */\n private function userFacingError(string $message, bool $serviceUnavailable = false): string\n {\n // Detect any reference to an AI provider / engine / API config so we can\n // swap it for a generic, branded-neutral message.\n $sensitive = '/deepseek|groq|gemini|vision|openai|openrouter|api[\\s_-]?key|\\.env|non-?json|token limit/i';\n\n if (preg_match($sensitive, $message)) {\n Log::warning('QuotationAiService: suppressed technical error from user.', ['internal' => $message]);\n\n return $serviceUnavailable\n ? 'The file could not be processed right now. Please try again in a moment.'\n : 'We could not read this file automatically. Please make sure it is a clear BOQ (Excel, CSV, PDF, or image) and try again, or add the items manually.';\n }\n\n return $message;\n }\n\n private function mimeForExtension(string $ext): string\n {\n return match (strtolower($ext)) {\n 'pdf' => 'application/pdf',\n 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n 'xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12',\n 'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',\n 'xls' => 'application/vnd.ms-excel',\n 'csv' => 'text/csv',\n 'jpg', 'jpeg' => 'image/jpeg',\n 'png' => 'image/png',\n 'gif' => 'image/gif',\n 'bmp' => 'image/bmp',\n 'webp' => 'image/webp',\n 'tiff', 'tif' => 'image/tiff',\n 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'doc' => 'application/msword',\n default => 'application/octet-stream',\n };\n }\n}\n"}, "fb313038bdd6fd0ebd62127ad96cf9b969cc0a5b": {"t": 1785748365.1720457, "n": 79996, "h": "0765d7183cd1fae8", "c": "<?php\n\nnamespace App\\Services;\n\nuse Illuminate\\Http\\Client\\ConnectionException;\nuse Illuminate\\Http\\UploadedFile;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\nuse PhpOffice\\PhpSpreadsheet\\Cell\\Coordinate;\nuse PhpOffice\\PhpSpreadsheet\\IOFactory;\n\nclass QuotationAiService\n{\n /** Increment this whenever BOQ parsing logic changes to invalidate old caches. */\n private const PARSER_VERSION = 10;\n private const LARGE_BOQ_VERIFICATION_THRESHOLD = 2000;\n private const LARGE_BOQ_VERIFICATION_CHUNK_SIZE = 120;\n\n /**\n * Characters of document text sent per AI call.\n *\n * Above this the document is split and parsed over several calls. Sized well\n * under the model's context so the prompt, the slice, and a full JSON answer\n * for every row in it all fit — the binding limit is the *output* budget, not\n * the input, since each input row produces a JSON object.\n */\n private const TEXT_CHUNK_CHARS = 60000;\n\n /**\n * A workbook with more sheets than this is split regardless of its size.\n *\n * Multiple tabs almost always mean multiple disciplines (electrical, HVAC,\n * civil…). Sending them in one call makes the model summarise across\n * sections and silently drop rows, so each slice gets a narrower scope.\n */\n private const MAX_SHEETS_PER_CALL = 2;\n\n /** A PDF longer than this is split regardless of its extracted text length. */\n private const MAX_PDF_PAGES_PER_CALL = 5;\n\n private string $baseUrl;\n private string $parseEndpoint;\n private string $apiKey;\n private int $timeout;\n private bool $testMode;\n private BoqCleaningService $boqCleaner;\n\n /** Page count of the most recently parsed PDF; 0 when unknown. */\n private int $lastPdfPageCount = 0;\n\n /**\n * Optional progress reporter for chunked parsing, called as ($part, $total).\n *\n * Set by the queue job so the polling UI can show real progress on a large\n * BOQ instead of a spinner that appears frozen for many minutes.\n *\n * @var (callable(int, int): void)|null\n */\n private $onChunkProgress = null;\n\n /** @param callable(int, int): void $callback */\n public function onChunkProgress(callable $callback): self\n {\n $this->onChunkProgress = $callback;\n return $this;\n }\n\n /**\n * Optional per-chunk item sink, called as ($items, $part, $total).\n *\n * Lets the caller persist each slice's rows the moment they arrive instead\n * of waiting for the whole document. On a large BOQ this is the difference\n * between a table that fills in progressively and one that stays empty for\n * many minutes.\n *\n * @var (callable(array, int, int): void)|null\n */\n private $onChunkItems = null;\n\n /** @param callable(array, int, int): void $callback */\n public function onChunkItems(callable $callback): self\n {\n $this->onChunkItems = $callback;\n return $this;\n }\n\n /**\n * Report a non-chunk stage (local parse, handing off to AI) to the caller.\n *\n * A spreadsheet is read locally first and only sent to the AI when that\n * fails, so a large file can spend minutes before any chunk exists. Without\n * these the UI shows a generic spinner for that whole period.\n */\n private function reportStage(string $message): void\n {\n if ($this->onStage !== null) {\n ($this->onStage)($message);\n }\n }\n\n /**\n * Report a workbook's shape from grids that are already in memory.\n *\n * Deliberately takes the parsed grids rather than a path: re-reading a large\n * spreadsheet just to count its sheets would double the slowest step of the\n * whole extraction.\n *\n * @param array<int, array{name: string, grid: array}> $sheets\n */\n private function reportWorkbookShape(array $sheets): void\n {\n if ($this->onStage === null || empty($sheets)) {\n return;\n }\n\n $rows = 0;\n foreach ($sheets as $sheet) {\n $rows += count($sheet['grid']);\n }\n\n $count = count($sheets);\n\n $this->reportStage($count > 1\n ? \"Found {$count} sheets, {$rows} rows in total. Reading…\"\n : \"Found {$rows} rows. Reading…\");\n }\n\n /** @var (callable(string): void)|null */\n private $onStage = null;\n\n /** @param callable(string): void $callback */\n public function onStage(callable $callback): self\n {\n $this->onStage = $callback;\n return $this;\n }\n\n public function __construct(BoqCleaningService $boqCleaner)\n {\n $this->boqCleaner = $boqCleaner;\n $this->baseUrl = rtrim((string) config('services.ai_quotation.base_url', ''), '/');\n $this->parseEndpoint = ltrim((string) config('services.ai_quotation.parse_endpoint', 'parse'), '/');\n $this->apiKey = (string) config('services.ai_quotation.api_key', '');\n $this->timeout = (int) config('services.ai_quotation.timeout', 300);\n $this->testMode = (bool) config('services.ai_quotation.test_mode', false);\n }\n\n /**\n * Give the running script enough head-room to outlast the AI HTTP call.\n *\n * The web request / queue worker must not be killed before the HTTP timeout\n * completes, otherwise the user sees a generic \"timeout or unavailable\"\n * error even though the AI may have responded. We allow the HTTP timeout\n * plus a 120s margin for connecting, response parsing and DB writes.\n */\n private function extendExecutionTime(): void\n {\n // Ignored on some SAPIs (e.g. when running under fastcgi with a hard\n // limit), but harmless to attempt.\n @set_time_limit($this->timeout + 120);\n }\n\n public function parseBoq(UploadedFile|string $file, array $context = []): array\n {\n if ($this->testMode) {\n return $this->mockResponse();\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n\n if (! is_file($absPath)) {\n return $this->failure('File not found or not readable.');\n }\n\n $fileHash = hash_file('sha256', $absPath);\n // Bump PARSER_VERSION whenever the extraction logic changes so that\n // previously-uploaded files are re-parsed instead of returning a stale\n // cached result produced by the old code.\n $cacheKey = 'boq_analysis_v' . self::PARSER_VERSION . '_' . $fileHash;\n $forceRefresh = (bool) ($context['force_refresh'] ?? false);\n\n if (! $forceRefresh && ($cached = Cache::get($cacheKey)) !== null) {\n return $cached;\n }\n\n // Clear stale cached result when force-refreshing\n if ($forceRefresh) {\n Cache::forget($cacheKey);\n }\n\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n // Read Excel locally first. Fall back to AI if local parse failed,\n // found 0 items, OR could not read one of the sheets (so multi-sheet\n // files never silently drop a sheet the local parser didn't understand).\n $this->reportStage('Reading the spreadsheet…');\n\n $result = $this->parseSpreadsheetDirect($absPath);\n\n $hasSkippedSheets = ! empty($result['skipped_sheets']);\n\n if (! $result['success'] || empty($result['items']) || $hasSkippedSheets) {\n // The local pass could not handle this file on its own. Say so:\n // otherwise the UI sits on a generic spinner through the local\n // parse AND the whole AI parse with nothing to show for either.\n $this->reportStage($hasSkippedSheets\n ? 'Some sheets need AI to read. Sending to AI…'\n : 'Spreadsheet needs AI to read. Sending to AI…');\n\n $deepSeekResult = $this->parseBoqWithDeepSeek($file, $context);\n // AI sees every sheet at once, so prefer its result whenever it\n // actually returned items (covers the skipped-sheet case too).\n if ($deepSeekResult['success'] && ! empty($deepSeekResult['items'])) {\n // Keep the larger of the two item sets so we never lose the\n // rows the local parser already extracted correctly.\n if (count($deepSeekResult['items']) >= count($result['items'] ?? [])) {\n $result = $deepSeekResult;\n }\n } elseif (! $result['success']) {\n $result = $deepSeekResult;\n }\n // else: keep local result if AI also failed/returned nothing\n }\n } elseif (in_array($ext, ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif'], true)) {\n $result = $this->parseBoqWithDeepSeek($file, $context);\n } else {\n return $this->failure('Unsupported file type. Please upload Excel, CSV, PDF, or image file.');\n }\n\n if ($result['success'] && ! empty($result['items'])) {\n $cleaning = $this->boqCleaner->process($result['items']);\n $result['items'] = $cleaning['accepted'] ?? $result['items'];\n $result['rejected'] = array_merge($result['rejected'] ?? [], $cleaning['rejected'] ?? []);\n }\n\n if ($result['success'] && count($result['items'] ?? []) > self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n $result = $this->verifyLargeBoqItemsWithAi($result);\n }\n\n // Only cache when we actually extracted items; empty results should be re-tried.\n if ($result['success'] && ! empty($result['items'])) {\n Cache::put($cacheKey, $result, now()->addDays(30));\n }\n\n return $result;\n }\n\n private function resolveFile(UploadedFile|string $file): array\n {\n if ($file instanceof UploadedFile) {\n return [(string) $file->getRealPath(), strtolower($file->getClientOriginalExtension())];\n }\n\n $absPath = is_file($file) ? $file : storage_path('app/' . ltrim($file, '/'));\n return [$absPath, strtolower(pathinfo($absPath, PATHINFO_EXTENSION))];\n }\n\n private function parseSpreadsheetDirect(string $absPath): array\n {\n ini_set('memory_limit', '2048M');\n\n // 180s was not enough for a real BOQ: an 11 200-row workbook was killed\n // mid-parse, and because set_time_limit() kills the process outright the\n // job reported a bare failure with nothing in the log to explain it.\n // This runs on the queue, where wall time is cheap.\n set_time_limit(1800);\n\n try {\n $sheets = $this->spreadsheetToGrids($absPath);\n\n if (empty($sheets)) {\n return $this->failure('Could not read the Excel file.');\n }\n\n $this->reportWorkbookShape($sheets);\n\n $items = [];\n $rejected = [];\n $skippedSheets = []; // sheets we could not parse locally (header undetected)\n\n foreach ($sheets as $sheet) {\n $grid = $sheet['grid'];\n if (count($grid) < 2) {\n continue;\n }\n\n // Skip dedicated summary / cost-abstract sheets entirely. Their rows\n // are per-discipline totals (Structural Concrete, Electrical, …) and\n // grand totals, not procurable products.\n if ($this->isSummarySheet($sheet['name'])) {\n Log::info('QuotationAiService: skipped summary sheet.', ['sheet' => $sheet['name']]);\n continue;\n }\n\n $header = $this->detectHeader($grid);\n\n if ($header === null || ! isset($header['map']['description'])) {\n Log::warning('QuotationAiService: header not detected in sheet.', ['sheet' => $sheet['name']]);\n $skippedSheets[] = $sheet['name'];\n continue;\n }\n\n $map = $header['map'];\n $start = $header['row'] + 1;\n\n for ($r = $start; $r < count($grid); $r++) {\n $row = $grid[$r];\n $description = $this->cell($row, $map['description'] ?? null);\n $itemNo = $this->cell($row, $map['item_code'] ?? null);\n $unit = $this->cell($row, $map['unit'] ?? null);\n $quantity = $this->number($this->cell($row, $map['quantity'] ?? null));\n $unitPrice = $this->number($this->cell($row, $map['unit_price'] ?? null));\n $totalPrice = $this->number($this->cell($row, $map['total_price'] ?? null));\n\n if ($description === '') {\n continue;\n }\n\n if (! isset($map['quantity'])) {\n $quantity = 1.0;\n }\n\n // Skip floor breakdown sub-rows: Ref. is \"-\" meaning it's a per-floor\n // quantity split of the parent item, not a standalone product.\n if ($itemNo === '-' || $this->isFloorBreakdownRow($itemNo, $description)) {\n continue;\n }\n\n if ($this->isTotalOrHeaderRow($description)) {\n continue;\n }\n\n if ($quantity === null || $quantity <= 0) {\n $rejected[] = $this->rejectedRow($description, $unit, $quantity, 'Missing quantity', $itemNo, $sheet['name']);\n continue;\n }\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $supply = $this->boqCleaner->filterItem($description, $unitPrice, $quantity);\n\n if (! ($supply['keep'] ?? false)) {\n $rejected[] = $this->rejectedRow(\n $description,\n $unit,\n $quantity,\n (string) ($supply['rejection_reason'] ?? 'Rejected by cleaner'),\n $itemNo,\n $sheet['name']\n );\n continue;\n }\n\n $finalDescription = (string) ($supply['description'] ?? $description);\n\n $items[] = [\n 'description' => $finalDescription,\n 'quantity' => $quantity,\n 'unit' => $unit,\n 'category' => $sheet['name'],\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($finalDescription),\n 'unit_price' => $unitPrice,\n 'confidence' => 0.95,\n 'needs_review' => false,\n 'raw_data' => [\n 'sheet' => $sheet['name'],\n 'row_number' => $r + 1,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'cleaned_description' => $finalDescription,\n 'extraction_type' => (string) ($supply['extraction_type'] ?? 'supply_only'),\n ],\n 'ai_extracted' => true,\n ];\n }\n }\n\n if (empty($items)) {\n return $this->failure('No BOQ supply items could be extracted from the Excel file.');\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'skipped_sheets' => $skippedSheets,\n ];\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: parseSpreadsheetDirect failed.', [\n 'path' => $absPath,\n 'message' => $e->getMessage(),\n 'trace' => $e->getTraceAsString(),\n ]);\n\n return $this->failure('Failed to read the Excel file: ' . $e->getMessage());\n }\n }\n\n private function spreadsheetToGrids(string $absPath): array\n {\n $ext = strtolower(pathinfo($absPath, PATHINFO_EXTENSION));\n\n if ($ext === 'csv') {\n $rows = [];\n $handle = fopen($absPath, 'rb');\n if ($handle !== false) {\n while (($row = fgetcsv($handle)) !== false) {\n $rows[] = $this->trimTrailingEmptyCells($row);\n }\n fclose($handle);\n }\n return [['name' => 'Sheet1', 'grid' => $rows]];\n }\n\n $reader = IOFactory::createReader($ext === 'xls' ? 'Xls' : 'Xlsx');\n $reader->setReadDataOnly(true);\n $spreadsheet = $reader->load($absPath);\n $sheets = [];\n\n foreach ($spreadsheet->getAllSheets() as $sheet) {\n $highestRow = $sheet->getHighestDataRow();\n $highestColumn = $sheet->getHighestDataColumn();\n $highestColumnIndex = Coordinate::columnIndexFromString($highestColumn);\n\n // BOQ data is normally in first 8-20 columns.\n // Files with heavy formatting/images can report 300+ columns — limit to 30.\n $maxColumnIndex = min($highestColumnIndex, 30);\n\n $grid = [];\n for ($row = 1; $row <= $highestRow; $row++) {\n $rowData = [];\n $hasValue = false;\n\n for ($col = 1; $col <= $maxColumnIndex; $col++) {\n $value = $sheet->getCell([$col, $row])->getValue();\n $value = $this->cellValueToString($value);\n $value = trim($value);\n if ($value !== null && $value !== '') {\n $hasValue = true;\n }\n $rowData[] = $value;\n }\n\n $rowData = $this->trimTrailingEmptyCells($rowData);\n if ($hasValue) {\n $grid[] = $rowData;\n }\n }\n\n if (! empty($grid)) {\n $sheets[] = ['name' => $sheet->getTitle(), 'grid' => $grid];\n }\n }\n\n $spreadsheet->disconnectWorksheets();\n unset($spreadsheet);\n\n return $sheets;\n }\n\n private function detectHeader(array $grid): ?array\n {\n $keywords = [\n 'item_code' => ['رقم البند', 'item no', 'item number', 'ref.', 'ref', 'item', 'no.', 'no', '#', 'كود'],\n 'description' => ['وص٠البند', 'scope of works', 'scope of work', 'scope', 'item description', 'description of works', 'description', 'بالعربية', 'الوصÙ', 'البند', 'بيان'],\n 'unit' => ['Ø§Ù„ÙˆØØ¯Ø©', 'unit', 'uom', 'u/m'],\n 'quantity' => ['الكمية', 'qty', 'quantity', 'q.ty'],\n 'unit_price' => ['سعر Ø§Ù„ÙˆØØ¯Ø©', 'u/price', 'unit price', 'unit rate', 'rate', 'price'],\n 'total_price' => ['السعر الإجمالي', 'total amount', 'total price', 'amount', 'الإجمالي'],\n ];\n\n $best = null;\n $bestScore = 0;\n\n for ($r = 0; $r < min(30, count($grid)); $r++) {\n $map = [];\n $score = 0;\n\n foreach ($grid[$r] as $c => $value) {\n $cell = mb_strtolower(trim((string) $value));\n if ($cell === '') {\n continue;\n }\n\n foreach ($keywords as $field => $list) {\n foreach ($list as $kw) {\n if (str_contains($cell, mb_strtolower($kw))) {\n if (! isset($map[$field])) {\n $map[$field] = $c;\n $score++;\n }\n break 2;\n }\n }\n }\n }\n\n if ($score > $bestScore) {\n $bestScore = $score;\n $best = ['row' => $r, 'map' => $map];\n }\n }\n\n if ($bestScore >= 3) {\n return $best;\n }\n\n // Allow simpler BOQ sheets that only define a description column plus one\n // other BOQ-related column, e.g. Item + Description or Description + Qty.\n if ($bestScore === 2 && isset($best['map']['description']) && (\n isset($best['map']['item_code']) ||\n isset($best['map']['quantity']) ||\n isset($best['map']['unit'])\n )) {\n return $best;\n }\n\n return null;\n }\n\n private function cell(array $row, ?int $index): string\n {\n if ($index === null) {\n return '';\n }\n return trim((string) ($row[$index] ?? ''));\n }\n\n private function number(string $value): ?float\n {\n $value = trim(str_replace([',', 'SAR', 'ر.س'], '', $value));\n if ($value === '' || ! is_numeric($value)) {\n return null;\n }\n return (float) $value;\n }\n\n private function trimTrailingEmptyCells(array $row): array\n {\n while (! empty($row) && trim((string) end($row)) === '') {\n array_pop($row);\n }\n return array_values($row);\n }\n\n private function isTotalOrHeaderRow(string $description): bool\n {\n $d = mb_strtolower(trim($description));\n\n if ($d === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $d)) {\n return true;\n }\n\n // Section totals / summaries\n if (preg_match('/(اجمالي|إجمالي|المجموع|grand\\s*total|sub\\s*total|total\\s*amount|total\\s*price)/iu', $d)) {\n return true;\n }\n\n // Bare \"Total\" lines, optionally with a currency suffix, e.g. \"Total\",\n // \"Total (SAR)\", \"Total cost\", \"Total (SAR) unit cost\".\n if (preg_match('/^total\\b[\\s\\(\\):a-z]*$/iu', $d)) {\n return true;\n }\n\n // Pure floor / level labels used as section headers\n if (preg_match('/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor)\\s*$/iu', $d)) {\n return true;\n }\n\n // Division / section header lines (e.g. \"Division - 02: Wood Works\")\n if (preg_match('/^division\\s*([-:]|\\d)/iu', $d)) {\n return true;\n }\n\n return false;\n }\n\n /**\n * Detect dedicated summary / cost-abstract sheets by their name. These hold\n * per-discipline rollups and grand totals (e.g. a \"Summary (Dry Cost)\" tab),\n * never procurable supply items, so they are skipped wholesale.\n */\n private function isSummarySheet(string $name): bool\n {\n $n = mb_strtolower(trim($name));\n\n if ($n === '') {\n return false;\n }\n\n return (bool) preg_match(\n '/\\b(summary|abstract|cost\\s*summary|grand\\s*summary|recap(?:itulation)?|bill\\s*summary|boq\\s*summary|dry\\s*cost)\\b|ملخص|الملخص|الملخّص|ملخّص|اجمالي|إجمالي/iu',\n $n\n );\n }\n\n private function isFloorBreakdownRow(string $itemNo, string $description): bool\n {\n // A row whose Ref column is empty or a dash is a sub-breakdown row (per-floor split).\n $ref = trim($itemNo);\n if ($ref === '' || $ref === '-') {\n // Confirm by checking the description is a location/floor label\n $d = mb_strtolower(trim($description));\n return (bool) preg_match(\n '/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor|\\d+f)\\s*$/iu',\n $d\n );\n }\n return false;\n }\n\n private function rejectedRow(string $description, string $unit, ?float $quantity, string $reason, string $itemNo = '', string $sheet = ''): array\n {\n return [\n 'description' => $description,\n 'quantity' => $quantity ?? 0,\n 'unit' => $unit,\n 'category' => $sheet,\n 'brand' => '',\n 'status' => 'rejected',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 0.0,\n 'raw_data' => [\n 'sheet' => $sheet,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'extraction_type' => 'rejected',\n 'rejection_reason' => $reason,\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function parseBoqWithDeepSeek(UploadedFile|string $file, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured. Please set DEEPSEEK_API_KEY in .env file.');\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n $mime = $this->mimeForExtension($ext);\n\n try {\n // ── Spreadsheets ──────────────────────────────────────────────────\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n $text = $this->spreadsheetToCompactCsvText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not convert spreadsheet to readable text.');\n }\n\n $text = $this->sanitizeUtf8($text);\n\n // A large BOQ exceeds what one request can carry, so send it in\n // sequential slices and merge. Anything that fits goes as-is.\n //\n // Size alone is not a good enough signal: a workbook with several\n // sheets is structurally a big BOQ even when the text is compact,\n // and asking the model to hold every discipline at once is where\n // rows start getting dropped. So split on either signal.\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS || $sheetCount > self::MAX_SHEETS_PER_CALL) {\n Log::info('QuotationAiService: spreadsheet routed to chunked parsing.', [\n 'chars' => mb_strlen($text),\n 'sheets' => $sheetCount,\n ]);\n return $this->parseTextInChunks($text, 'BOQ spreadsheet converted to CSV', $apiKey, $context);\n }\n\n $userContent = \"BOQ spreadsheet converted to CSV:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── PDF ───────────────────────────────────────────────────────────\n if ($ext === 'pdf') {\n $extracted = $this->extractPdfText($absPath);\n if ($extracted !== null && mb_strlen(trim($extracted)) > 50) {\n $extracted = $this->sanitizeUtf8($extracted);\n\n // Split on either signal — long text, or many pages. A BOQ\n // spanning several pages is a multi-section document even if\n // each page is sparse.\n if (mb_strlen($extracted) > self::TEXT_CHUNK_CHARS\n || $this->lastPdfPageCount > self::MAX_PDF_PAGES_PER_CALL) {\n Log::info('QuotationAiService: PDF routed to chunked parsing.', [\n 'chars' => mb_strlen($extracted),\n 'pages' => $this->lastPdfPageCount,\n ]);\n return $this->parseTextInChunks($extracted, 'BOQ PDF extracted text', $apiKey, $context, 'application/pdf');\n }\n\n $userContent = \"BOQ PDF extracted text:\\n\\n\" . $extracted . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'application/pdf');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n return $this->failure('Could not extract text from the PDF. Please make sure it is a text-based PDF (not a scanned image), or convert it to Excel or CSV.');\n }\n\n // -- Word documents (docx) ---------------------------------------------------\n if (in_array($ext, ['docx', 'doc'], true)) {\n $text = $this->extractDocxText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not extract text from the Word document. Please convert it to Excel, CSV, or PDF.');\n }\n $text = $this->sanitizeUtf8($text);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS) {\n return $this->parseTextInChunks($text, 'BOQ Word document extracted text', $apiKey, $context);\n }\n\n $userContent = \"BOQ Word document extracted text:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── Images ──────────────────────────────────────────────────────────────\n // DeepSeek cannot receive images. A vision-capable AI processes the image\n // directly (no OCR) — configure GROQ_API_KEY or GEMINI_API_KEY in .env.\n $imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif', 'heic', 'heif'];\n if (in_array($ext, $imageExts, true)) {\n $bytes = (string) file_get_contents($absPath);\n return $this->callDeepSeekVision($bytes, $mime, $context);\n }\n\n return $this->failure('Unsupported file type. Please upload an Excel, CSV, PDF, or image file.');\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: DeepSeek fallback failed.', ['message' => $e->getMessage()]);\n return $this->failure('An error occurred while processing the file with AI: ' . $e->getMessage());\n }\n }\n\n private function deepSeekModel(): string\n {\n return (string) config('services.deepseek.model', 'deepseek-chat');\n }\n\n /**\n * Remove or replace any non-UTF-8 / malformed byte sequences so json_encode never fails.\n */\n private function sanitizeUtf8(string $text): string\n {\n // Replace invalid byte sequences with the UTF-8 replacement character\n $clean = mb_convert_encoding($text, 'UTF-8', 'UTF-8');\n // Strip any remaining non-printable control characters (except tab/newline/CR)\n $clean = (string) preg_replace('/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]/u', '', $clean);\n return $clean;\n }\n\n /**\n * Extract plain text from a Word docx file using ZipArchive (no external library needed).\n * Reads word/document.xml and strips XML tags, preserving paragraph and table structure.\n */\n private function extractDocxText(string $absPath): ?string\n {\n try {\n $zip = new \\ZipArchive();\n if ($zip->open($absPath) !== true) {\n return null;\n }\n\n $xml = $zip->getFromName('word/document.xml');\n $zip->close();\n\n if ($xml === false || $xml === '') {\n return null;\n }\n\n // Add newlines before paragraph/table-row/table-cell tags so structure is preserved\n $xml = preg_replace('/<w:p[ >]/', \"\\n<w:p>\", $xml) ?? $xml;\n $xml = preg_replace('/<w:tr[ >]/', \"\\n<w:tr>\", $xml) ?? $xml;\n $xml = preg_replace('/<\\/w:tc>/', \"\\t\", $xml) ?? $xml;\n\n $text = strip_tags($xml);\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n return trim($text) !== '' ? trim($text) : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: DOCX parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Extract plain text from a PDF using smalot/pdfparser (pure PHP, no binary required).\n */\n private function extractPdfText(string $absPath): ?string\n {\n try {\n $parser = new \\Smalot\\PdfParser\\Parser();\n $pdf = $parser->parseFile($absPath);\n\n // Recorded for the chunking decision: a long PDF is structurally a\n // big BOQ even when its extracted text is short.\n try {\n $this->lastPdfPageCount = count($pdf->getPages());\n } catch (\\Throwable) {\n $this->lastPdfPageCount = 0;\n }\n\n $text = $pdf->getText();\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n return $text !== '' ? $text : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: PDF parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Send a vision request to a vision-capable AI model.\n * DeepSeek's commercial API (api.deepseek.com) does NOT support image inputs — any model.\n * Priority order (first configured key wins):\n * 1. GROQ_API_KEY → Groq llama-4-scout (free: https://console.groq.com)\n * 2. GEMINI_API_KEY → Google Gemini Flash (free: https://aistudio.google.com/apikey)\n * 3. VISION_API_KEY → any OpenAI-compatible endpoint (OpenAI, OpenRouter …)\n */\n private function callDeepSeekVision(string $bytes, string $mime, array $context): array\n {\n // ── 1. Groq (free, reliable vision) ─────────────────────────────────────\n if ((string) config('services.groq.key', '') !== '') {\n $visionKey = (string) config('services.groq.key');\n $visionBaseUrl = 'https://api.groq.com/openai/v1';\n $visionModel = (string) config('services.groq.model', 'meta-llama/llama-4-scout-17b-16e-instruct');\n }\n // ── 2. Google Gemini ─────────────────────────────────────────────────────\n elseif ((string) config('services.gemini.key', '') !== '') {\n $visionKey = (string) config('services.gemini.key');\n $visionBaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai';\n $visionModel = (string) config('services.gemini.model', 'gemini-2.0-flash');\n }\n // ── 3. Generic VISION_API_KEY (OpenAI, OpenRouter, …) ───────────────────\n elseif ((string) config('services.vision.key', '') !== '') {\n $visionKey = (string) config('services.vision.key');\n $visionBaseUrl = rtrim((string) config('services.vision.base_url', 'https://openrouter.ai/api/v1'), '/');\n $visionModel = (string) config('services.vision.model', 'google/gemini-flash-1.5-8b');\n }\n else {\n return $this->failure(\n 'Image processing is not available. DeepSeek\\'s API does not support images. ' .\n 'Get a free key at https://console.groq.com and add GROQ_API_KEY to your .env file.'\n );\n }\n\n $dataUrl = \"data:{$mime};base64,\" . base64_encode($bytes);\n $prompt = $this->buildDeepSeekPrompt($context, $mime);\n\n $userContent = [\n ['type' => 'text', 'text' => $prompt],\n ['type' => 'image_url', 'image_url' => ['url' => $dataUrl]],\n ];\n\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders([\n 'Authorization' => 'Bearer ' . $visionKey,\n 'HTTP-Referer' => config('app.url', 'https://qimta.sa'),\n 'X-Title' => 'Qimta Platform',\n ])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post($visionBaseUrl . '/chat/completions', [\n 'model' => $visionModel,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 16384,\n 'temperature' => 0.2,\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('Vision API connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: Vision API HTTP error.', [\n 'model' => $visionModel,\n 'base_url' => $visionBaseUrl,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n return $this->failure(\"Vision API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n return $this->parseDeepSeekJsonResponse((string) $response->json('choices.0.message.content'));\n }\n\n private function callDeepSeekChat(string $userContent, string $key, string $model): array\n {\n // Ensure the PHP script outlives the HTTP call (+ a margin for parsing the\n // response and writing to the DB) so the worker/web request is not killed\n // mid-flight, which surfaces to users as a generic \"timeout or unavailable\".\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders(['Authorization' => 'Bearer ' . $key])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 32768,\n 'temperature' => 0.1,\n 'response_format' => ['type' => 'json_object'],\n 'user' => 'Qimta_Platform',\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('DeepSeek connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: DeepSeek HTTP error.', [\n 'model' => $model,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n if ($response->status() === 401) {\n return $this->failure('DeepSeek API key is invalid or unauthorised. Please update DEEPSEEK_API_KEY in .env.', false);\n }\n return $this->failure(\"DeepSeek API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n $text = (string) $response->json('choices.0.message.content');\n return $this->parseDeepSeekJsonResponse($text);\n }\n\n /**\n * Parse a document too large for one AI call by sending it in slices.\n *\n * A 20 000-row BOQ is several megabytes of text — far beyond what a single\n * request can carry, and beyond what the model can answer without hitting its\n * output token limit. Previously the text was simply cut at 180 000 chars and\n * the remainder discarded: the parse \"succeeded\" while silently dropping most\n * of the file. Now the text is split and each slice parsed on its own call,\n * with the items merged.\n *\n * Splitting happens on line boundaries so a BOQ row is never cut in half.\n * Slices run sequentially rather than pooled: DeepSeek rate-limits hard on\n * concurrent large prompts, and this already runs on the queue where wall\n * time is cheap.\n *\n * A slice that fails does not fail the document — its rows are lost, but the\n * rest are kept and the shortfall is logged. Total failure is reported only\n * when every slice fails.\n *\n * @param string $label How the payload is introduced to the model.\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n /**\n * Split a spreadsheet into the slices the AI will be asked to read.\n *\n * Exposed so the work can be spread over several queue jobs instead of one\n * job walking every slice in sequence. Returns an empty array when the file\n * is small enough to go in a single call, or cannot be read.\n *\n * @return array<int, string>\n */\n public function chunkSpreadsheet(string $absPath): array\n {\n ini_set('memory_limit', '2048M');\n set_time_limit(1800);\n\n $text = $this->spreadsheetToCompactCsvText($absPath);\n\n if ($text === null || trim($text) === '') {\n return [];\n }\n\n $text = $this->sanitizeUtf8($text);\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) <= self::TEXT_CHUNK_CHARS && $sheetCount <= self::MAX_SHEETS_PER_CALL) {\n return [];\n }\n\n return $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n }\n\n /**\n * Parse one slice produced by chunkSpreadsheet().\n *\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n public function parseChunk(string $chunk, int $part, int $total, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured.');\n }\n\n // Same framing parseTextInChunks() uses, so a slice is never mistaken\n // for a complete BOQ and totals are not inferred from a fragment.\n $userContent = \"BOQ spreadsheet converted to CSV (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, 'text/plain');\n\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n private function parseTextInChunks(\n string $text,\n string $label,\n string $apiKey,\n array $context,\n string $mime = 'text/plain',\n ): array {\n $chunks = $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n $total = count($chunks);\n\n Log::info('QuotationAiService: large document split for parsing.', [\n 'chars' => mb_strlen($text),\n 'chunks' => $total,\n ]);\n\n // Announce the split before the first slice is sent, so the UI can show\n // how many parts there are instead of sitting on a generic message for\n // however long the first AI call takes.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)(0, $total);\n }\n\n $items = [];\n $rejected = [];\n $failed = 0;\n $lastError = null;\n\n foreach ($chunks as $index => $chunk) {\n $part = $index + 1;\n\n // Report progress so a 28-slice parse does not look frozen to the user.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)($part, $total);\n }\n\n // Tell the model this is a fragment, so it does not try to reconcile\n // totals or treat a mid-document slice as a complete BOQ.\n $userContent = \"{$label} (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, $mime);\n\n $result = $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n\n if (! ($result['success'] ?? false)) {\n $failed++;\n $lastError = $result['error'] ?? 'Chunk parsing failed.';\n Log::warning('QuotationAiService: chunk failed, continuing.', [\n 'part' => $part,\n 'of' => $total,\n 'error' => $lastError,\n ]);\n continue;\n }\n\n foreach ($result['items'] as $item) {\n $items[] = $item;\n }\n foreach ($result['rejected'] ?? [] as $row) {\n $rejected[] = $row;\n }\n\n // Hand this slice's rows straight to the caller so they can be shown\n // while the remaining slices are still being parsed.\n if ($this->onChunkItems !== null && $result['items'] !== []) {\n ($this->onChunkItems)($result['items'], $part, $total);\n }\n }\n\n // Every slice failed — there is nothing to show, so report the failure.\n if ($failed === $total) {\n return $this->failure($lastError ?? 'Could not parse the document.');\n }\n\n if ($failed > 0) {\n Log::error('QuotationAiService: document parsed with missing parts.', [\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n 'items_kept' => count($items),\n ]);\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'service_unavailable' => false,\n 'partial' => $failed > 0,\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n ];\n }\n\n /**\n * Split text into slices of at most $limit characters, breaking only at\n * newlines so no BOQ row is ever cut in half.\n *\n * A single line longer than the limit (rare — a pathological cell) is passed\n * through oversized rather than severed mid-row.\n *\n * @return array<int, string>\n */\n private function splitOnLineBoundaries(string $text, int $limit): array\n {\n $chunks = [];\n $current = '';\n\n // Sheet a chunk starts in the middle of. Repeated at the top of the next\n // chunk so the model always knows which sheet the rows belong to — the\n // header would otherwise be stranded in the previous slice, and rows\n // would arrive with no discipline context.\n $currentSheet = null;\n\n foreach (explode(\"\\n\", $text) as $line) {\n $isSheetHeader = str_starts_with($line, 'Sheet: ');\n\n // Start a new chunk at every sheet boundary. Sheets are usually\n // separate disciplines, and the whole point of splitting a multi-tab\n // workbook is to give the model one discipline at a time — a size-only\n // split would leave a compact 3-tab file as a single chunk, silently\n // undoing the sheet gate that routed it here.\n if ($isSheetHeader && $current !== '') {\n $chunks[] = $current;\n $current = $line;\n $currentSheet = $line;\n continue;\n }\n\n if ($isSheetHeader) {\n $currentSheet = $line;\n }\n\n $candidate = $current === '' ? $line : $current . \"\\n\" . $line;\n\n if (mb_strlen($candidate) > $limit && $current !== '') {\n $chunks[] = $current;\n\n // Carry the sheet header over, unless this line is itself one.\n $current = ($currentSheet !== null && ! str_starts_with($line, 'Sheet: '))\n ? $currentSheet . \" (continued)\\n\" . $line\n : $line;\n continue;\n }\n\n $current = $candidate;\n }\n\n if (trim($current) !== '') {\n $chunks[] = $current;\n }\n\n return $chunks;\n }\n\n /**\n * Shared JSON response parser used by both chat and vision paths.\n */\n private function parseDeepSeekJsonResponse(string $text): array\n {\n if ($text === '') {\n return $this->failure('DeepSeek returned an empty response.');\n }\n\n // Strip markdown code fences if the model wrapped the JSON.\n if (preg_match('/```(?:json)?\\s*([\\s\\S]*?)\\s*```/i', $text, $m)) {\n $text = $m[1];\n }\n // Narrow to the outermost JSON object/array.\n if (preg_match('/[\\{\\[][\\s\\S]*[\\}\\]]/s', $text, $m)) {\n $text = $m[0];\n }\n $text = trim($text);\n\n $decoded = json_decode($text, true);\n\n // The response can be truncated when the model hits the token limit,\n // leaving an unterminated JSON string/object. Try to repair it before\n // giving up so we can still salvage the items extracted so far.\n if (! is_array($decoded)) {\n $repaired = $this->repairTruncatedJson($text);\n if ($repaired !== null) {\n $decoded = json_decode($repaired, true);\n }\n }\n\n if (! is_array($decoded)) {\n Log::error('QuotationAiService: DeepSeek non-JSON response.', ['text' => mb_substr($text, 0, 1000)]);\n return $this->failure('DeepSeek returned a non-JSON response. The file may be too large — try splitting it into smaller files.');\n }\n\n $rawItems = $decoded['items'] ?? (array_is_list($decoded) ? $decoded : []);\n $items = [];\n $rejected = [];\n\n foreach ($rawItems as $raw) {\n if (! is_array($raw)) {\n continue;\n }\n $item = $this->normaliseAiItem($raw);\n if ($item === null) {\n continue;\n }\n $check = $this->boqCleaner->filterItem((string) $item['description']);\n if ($check['keep'] ?? false) {\n $items[] = $item;\n } else {\n $item['status'] = 'rejected';\n $item['raw_data']['rejection_reason'] = $check['rejection_reason'] ?? 'Rejected by cleaner';\n $rejected[] = $item;\n }\n }\n\n if (empty($items)) {\n Log::info('QuotationAiService: DeepSeek returned 0 accepted items.', [\n 'raw_items_count' => count($rawItems),\n 'rejected_count' => count($rejected),\n 'first_rejected' => ! empty($rejected) ? ($rejected[0]['description'] ?? '') : '',\n ]);\n }\n\n return ['success' => true, 'items' => $items, 'rejected' => $rejected, 'error' => null];\n }\n\n /**\n * Best-effort repair of a truncated JSON string produced when the model hits\n * its token limit mid-output. Drops any incomplete trailing object, then\n * closes the open string/array/object brackets so the result is valid JSON.\n *\n * Returns the repaired JSON string, or null if it cannot be salvaged.\n */\n private function repairTruncatedJson(string $text): ?string\n {\n if ($text === '') {\n return null;\n }\n\n // Walk the string tracking structural state, ignoring everything inside\n // string literals (and their escapes). Remember the last position where\n // we were back at the top of the \"items\" array between complete elements,\n // so we can cut a half-written trailing object.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($text);\n $lastSafeCut = null; // index just after a completed array element\n\n for ($i = 0; $i < $len; $i++) {\n $ch = $text[$i];\n\n if ($inString) {\n if ($escaped) {\n $escaped = false;\n } elseif ($ch === '\\\\') {\n $escaped = true;\n } elseif ($ch === '\"') {\n $inString = false;\n }\n continue;\n }\n\n if ($ch === '\"') {\n $inString = true;\n } elseif ($ch === '{' || $ch === '[') {\n $stack[] = $ch;\n } elseif ($ch === '}' || $ch === ']') {\n array_pop($stack);\n } elseif ($ch === ',' && ! empty($stack) && end($stack) === '[') {\n // Separator directly inside an array: a clean boundary between\n // elements (the items array, whatever its nesting depth).\n $lastSafeCut = $i;\n }\n }\n\n // If we are still inside a string, the last element is incomplete — fall\n // back to the last clean array boundary if we have one.\n $candidate = $text;\n if ($inString && $lastSafeCut !== null) {\n $candidate = substr($text, 0, $lastSafeCut);\n } else {\n // Drop a dangling partial object after the last completed element.\n $candidate = rtrim($candidate);\n if ($lastSafeCut !== null && substr(rtrim($candidate), -1) !== '}' && substr(rtrim($candidate), -1) !== ']') {\n $candidate = substr($text, 0, $lastSafeCut);\n }\n }\n\n // Recompute the open brackets for the (possibly trimmed) candidate and\n // append the matching closers in reverse order.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($candidate);\n for ($i = 0; $i < $len; $i++) {\n $ch = $candidate[$i];\n if ($inString) {\n if ($escaped) { $escaped = false; }\n elseif ($ch === '\\\\') { $escaped = true; }\n elseif ($ch === '\"') { $inString = false; }\n continue;\n }\n if ($ch === '\"') { $inString = true; }\n elseif ($ch === '{' || $ch === '[') { $stack[] = $ch; }\n elseif ($ch === '}' || $ch === ']') { array_pop($stack); }\n }\n\n $candidate = rtrim($candidate);\n // Remove a trailing comma left after cutting an element.\n $candidate = rtrim($candidate, ',');\n\n $closers = '';\n for ($i = count($stack) - 1; $i >= 0; $i--) {\n $closers .= ($stack[$i] === '{') ? '}' : ']';\n }\n\n $repaired = $candidate . $closers;\n\n // Validate before returning.\n return is_array(json_decode($repaired, true)) ? $repaired : null;\n }\n\n private function spreadsheetToCompactCsvText(string $absPath): ?string\n {\n $sheets = $this->spreadsheetToGrids($absPath);\n if (empty($sheets)) {\n return null;\n }\n\n $output = '';\n $kept = 0;\n $dropped = 0;\n\n foreach ($sheets as $sheet) {\n // Don't feed summary / cost-abstract sheets to the AI either.\n if ($this->isSummarySheet($sheet['name'])) {\n continue;\n }\n $sheetRows = '';\n foreach ($sheet['grid'] as $row) {\n $row = $this->trimTrailingEmptyCells($row);\n\n if ($this->isNoiseRow($row)) {\n $dropped++;\n continue;\n }\n\n $sheetRows .= implode(',', array_map([$this, 'csvEscape'], $row)) . \"\\n\";\n $kept++;\n }\n\n // A sheet whose rows were all noise contributes nothing but its own\n // header, which would just be another thing for the model to explain.\n if ($sheetRows === '') {\n continue;\n }\n\n $output .= 'Sheet: ' . $sheet['name'] . \"\\n\" . $sheetRows . \"\\n\";\n }\n\n Log::info('QuotationAiService: stripped blank rows before sending to AI.', [\n 'kept' => $kept,\n 'dropped' => $dropped,\n ]);\n\n if ($dropped > 0) {\n $this->reportStage(\"Removed {$dropped} blank rows. Sending {$kept} rows…\");\n }\n\n return $output;\n }\n\n /**\n * True when a row carries no information worth sending to the AI.\n *\n * A real BOQ is mostly formatting: spacer rows, stray borders, a lone\n * section number, a leftover cell from a merged block. They survive\n * trimTrailingEmptyCells() because they are not *entirely* empty, then cost\n * tokens, dilute the prompt, and inflate the number of parts a large file is\n * split into.\n *\n * Deliberately conservative — a row is dropped only when nothing in it could\n * name a product. Anything with letters is kept, because a description is\n * the one field an item cannot do without.\n *\n * @param array<int, mixed> $row\n */\n private function isNoiseRow(array $row): bool\n {\n if (empty($row)) {\n return true;\n }\n\n $joined = trim(implode('', array_map(fn($c) => trim((string) $c), $row)));\n\n // Nothing but separators/whitespace.\n if ($joined === '') {\n return true;\n }\n\n // Ruled lines and decorative fills (\"-----\", \"====\", \"___\").\n if (preg_match('/^[\\-_=.*\\s|]+$/u', $joined)) {\n return true;\n }\n\n // Any letter — Latin or Arabic — means this could be a description.\n if (preg_match('/\\p{L}/u', $joined)) {\n return false;\n }\n\n // No letters at all: numbers only. A single number is a stray row\n // number or a dangling total; two or more could be qty/rate/amount\n // belonging to a real line, so keep those for the AI to judge.\n $numericCells = 0;\n foreach ($row as $cell) {\n if (trim((string) $cell) !== '') {\n $numericCells++;\n }\n }\n\n return $numericCells <= 1;\n }\n\n /**\n * Safely convert a PhpSpreadsheet cell value to a string.\n *\n * Cells may contain RichText or, in malformed/heavily-formatted files,\n * embedded objects such as Worksheet\\Drawing that have no __toString().\n * Casting those directly throws \"Object ... could not be converted to string\".\n */\n private function cellValueToString(mixed $value): string\n {\n if ($value === null) {\n return '';\n }\n\n if ($value instanceof \\PhpOffice\\PhpSpreadsheet\\RichText\\RichText) {\n return $value->getPlainText();\n }\n\n if (is_object($value)) {\n // Only stringify objects that explicitly support it; ignore the rest\n // (e.g. Worksheet\\Drawing) instead of crashing.\n return method_exists($value, '__toString') ? (string) $value : '';\n }\n\n if (is_array($value)) {\n return '';\n }\n\n return (string) $value;\n }\n\n private function csvEscape(mixed $value): string\n {\n $value = $this->cellValueToString($value);\n return str_contains($value, ',') || str_contains($value, '\"') || str_contains($value, \"\\n\")\n ? '\"' . str_replace('\"', '\"\"', $value) . '\"'\n : $value;\n }\n\n private function verifyLargeBoqItemsWithAi(array $result): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n Log::warning('QuotationAiService: large BOQ verification skipped, AI key is not configured.');\n return $result;\n }\n\n $items = array_values(array_filter($result['items'] ?? [], 'is_array'));\n if (count($items) <= self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n return $result;\n }\n\n Log::info('QuotationAiService: starting second-pass physical item verification.', [\n 'items_count' => count($items),\n 'threshold' => self::LARGE_BOQ_VERIFICATION_THRESHOLD,\n ]);\n\n $verifiedItems = [];\n $rejectedItems = $result['rejected'] ?? [];\n\n foreach (array_chunk($items, self::LARGE_BOQ_VERIFICATION_CHUNK_SIZE) as $chunkIndex => $chunk) {\n $payload = array_map(fn(array $item) => [\n 'description' => (string) ($item['description'] ?? ''),\n 'quantity' => $item['quantity'] ?? null,\n 'unit' => (string) ($item['unit'] ?? ''),\n 'unit_price' => $item['unit_price'] ?? null,\n 'category' => (string) ($item['category'] ?? ''),\n 'brand' => (string) ($item['brand'] ?? ''),\n ], $chunk);\n\n $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);\n if (! is_string($json)) {\n Log::warning('QuotationAiService: large BOQ verification chunk JSON encode failed.', [\n 'chunk' => $chunkIndex + 1,\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n $prompt = <<<PROMPT\nYou are doing a SECOND PASS verification on a BOQ extraction result.\n\nThe first pass may hallucinate non-products when the BOQ has thousands of rows.\nYour job is to keep ONLY physical, tangible, procurable supply products.\n\nRules:\n- Keep items only if a supplier can actually deliver them as materials/equipment.\n- Reject headings, trade names, systems, scopes, prose, specifications-only rows, totals, labor, installation, testing, commissioning, supervision, drawings, insurance, preliminaries, mobilisation, site works, and vague fragments.\n- Do not invent new products.\n- Do not add items that are not in the input.\n- You may clean a product description, but keep the original quantity, unit, unit_price, category, and brand when available.\n\nInput JSON array:\n{$json}\n\nReturn ONLY valid JSON in this shape:\n{\"items\":[{\"description\":\"\",\"quantity\":1,\"unit\":\"\",\"unit_price\":null,\"category\":\"\",\"brand\":\"\",\"confidence\":0.95}]}\nPROMPT;\n\n try {\n $chunkResult = $this->callDeepSeekChat($prompt, $apiKey, $this->deepSeekModel());\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: large BOQ verification chunk threw an exception.', [\n 'chunk' => $chunkIndex + 1,\n 'message' => $e->getMessage(),\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n if ($chunkResult['success'] ?? false) {\n array_push($verifiedItems, ...($chunkResult['items'] ?? []));\n $rejectedItems = array_merge($rejectedItems, $chunkResult['rejected'] ?? []);\n continue;\n }\n\n Log::warning('QuotationAiService: large BOQ verification chunk failed, keeping original chunk.', [\n 'chunk' => $chunkIndex + 1,\n 'error' => $chunkResult['error'] ?? null,\n ]);\n array_push($verifiedItems, ...$chunk);\n }\n\n if (empty($verifiedItems)) {\n Log::warning('QuotationAiService: second-pass verification returned no items, keeping first-pass result.', [\n 'original_items_count' => count($items),\n ]);\n return $result;\n }\n\n $result['items'] = $verifiedItems;\n $result['rejected'] = $rejectedItems;\n $result['large_boq_verified'] = true;\n\n Log::info('QuotationAiService: second-pass physical item verification completed.', [\n 'before' => count($items),\n 'after' => count($verifiedItems),\n ]);\n\n return $result;\n }\n\n private function normaliseAiItem(array $raw): ?array\n {\n if (! empty($raw['rejected'])) {\n return null;\n }\n\n $description = trim((string) ($raw['cleaned_description'] ?? $raw['product_name'] ?? $raw['description'] ?? ''));\n if ($description === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $description)) {\n return null;\n }\n\n $quantity = is_numeric($raw['quantity'] ?? null) ? (float) $raw['quantity'] : 1;\n $unitPrice = is_numeric($raw['unit_price'] ?? null) ? (float) $raw['unit_price'] : null;\n $totalPrice = is_numeric($raw['total_price'] ?? null) ? (float) $raw['total_price'] : null;\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $specs = is_array($raw['specifications'] ?? null) ? $raw['specifications'] : [];\n\n return [\n 'description' => $description,\n 'quantity' => $quantity,\n 'unit' => (string) ($raw['unit'] ?? ''),\n 'category' => (string) ($raw['core_product'] ?? $raw['category'] ?? ''),\n 'brand' => (string) ($specs['brand'] ?? $raw['brand'] ?? ''),\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($description),\n 'unit_price' => $unitPrice,\n 'confidence' => is_numeric($raw['confidence'] ?? null) ? (float) $raw['confidence'] : null,\n 'needs_review' => ! empty($raw['needs_review']),\n 'raw_data' => [\n 'source_item_no' => (string) ($raw['source_item_no'] ?? ''),\n 'original_description' => (string) ($raw['original_description'] ?? $description),\n 'cleaned_description' => $description,\n 'core_product' => (string) ($raw['core_product'] ?? ''),\n 'specifications' => $specs,\n 'extraction_type' => (string) ($raw['extraction_type'] ?? 'supply_only'),\n 'note' => (string) ($raw['note'] ?? $raw['notes'] ?? ''),\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function buildSystemInstruction(): string\n {\n return <<<'TXT'\nYou are a strict BOQ Supply Product Extraction Engine.\nExtract ONLY concrete, tangible, physical products that a supplier can box up and deliver to site — a specific item you could point at and say \"this is the thing being bought\" (e.g. \"Wall mounted LED luminaire 36W\", \"PVC pipe 110mm\", \"Single phase distribution board 12-way\", \"Fire extinguisher CO2 6kg\", \"CCTV dome camera 4MP\").\n\nABSOLUTELY REJECT abstract concepts, disciplines, categories, section names, and trade headings. These are NOT products even though they appear as rows:\n- Bare discipline/trade words: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", \"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\".\n- Summary / total / abstract / \"dry cost\" rows of any kind.\n- Anything that names a system, scope, or category instead of one buyable item.\n\nRule of thumb: if you cannot name a unit and physically hand the item over (a piece, a box, a length, a unit of equipment), REJECT it. A word that describes a field of work, a system, or a section of the BOQ is NEVER a product.\nReject totals, headings, general requirements, installation-only, testing, commissioning, supervision, mobilization, civil works, labor, and vague rows.\nFor Supply & Install rows, keep only the supply product and remove install/labor wording.\nReturn only valid JSON: {\"items\":[{\"source_item_no\":null,\"original_description\":\"\",\"cleaned_description\":\"\",\"core_product\":\"\",\"specifications\":{},\"quantity\":0,\"unit\":\"\",\"unit_price\":0,\"total_price\":0,\"extraction_type\":\"supply_only\",\"needs_review\":false,\"note\":\"\",\"rejected\":false,\"rejection_reason\":null,\"confidence\":0.95}]}\nTXT;\n }\n\n private function buildDeepSeekPrompt(array $context = [], string $mime = ''): string\n {\n $projectName = (string) ($context['project_name'] ?? '');\n $projectStatus = (string) ($context['project_status'] ?? '');\n $contextLine = $projectName !== ''\n ? \"Project: {$projectName}\" . ($projectStatus !== '' ? \" ({$projectStatus})\" : '')\n : '';\n $sourceHint = str_starts_with($mime, 'image/')\n ? 'The input is an image of a BOQ table. OCR every cell carefully — read the quantity/count column (العدد, الكمية, Qty) and extract the EXACT numeric value from each row.'\n : 'The input is a BOQ document/spreadsheet.';\n\n return trim(<<<PROMPT\nYou are a procurement specialist. Your task is to extract ONLY tangible, physical supply products from this Bill of Quantities (BOQ).\n\n{$contextLine}\n{$sourceHint}\n\nCRITICAL — MERGE WRAPPED / SPLIT ROWS INTO ONE ITEM:\nBOQ tables extracted from PDF often break ONE item's long description across several\nlines/rows. These continuation lines repeat the SAME quantity, unit and category, and\ntheir text is a grammatical continuation of the previous line (it starts mid-sentence,\nlowercase, or with \"the/and/-\", or is a spec/instruction clause). You MUST reconstruct\nthe original single item:\n- Treat consecutive rows that share the SAME quantity + unit + category as ONE product\n when the later rows are clearly continuation text of the first, NOT new products.\n- Emit ONE item with the merged description and the price counted ONCE. Do NOT repeat\n the same quantity×price for every wrapped line — that multiplies the total wrongly.\n- Example: rows \"The asphalt flooring is to be supplied\" / \"installed in accordance\" /\n \"standard road specifications...\" all with 7,100 m2 are ONE asphalt item, not 3+.\n\nCRITICAL — UNDERSTAND INTENT, DO NOT COPY ROWS LITERALLY:\nYour job is to output WHAT A SUPPLIER MUST ACTUALLY DELIVER, not a copy of the row text.\nRead each description for MEANING and decide how many real, separately-buyable products it\nimplies. One BOQ row can imply SEVERAL products, or a spread of rows can imply ONE product.\n- SPLIT a row into multiple items when it lists distinct products that a supplier would\n buy/price separately — even when joined by commas, \"with\", \"and\", \"including\", \"c/w\",\n \"consisting of\", \"comprising\", or \":\" then a list.\n Example: \"Fire pump set consisting of: fire pump, electric motor, control panel, diesel\n engine\" → FOUR items: (1) fire pump, (2) electric motor, (3) control panel, (4) diesel\n engine — each with its own quantity and priced on its own.\n Example: \"Wash basin complete with mixer tap and bottle trap\" → THREE items: wash basin,\n mixer tap, bottle trap.\n- Give each split product the correct quantity implied by the text (usually the parent\n quantity, unless the text states a different count for a sub-item). Each product is\n priced individually; the line total becomes the SUM of its products.\n- DO NOT split when the extra words are just specifications, dimensions, colours, ratings,\n standards, or a single product's integral parts. \"Steel door 900x2100 with vision panel\n and SS ironmongery\" is usually ONE door product — vision panel and ironmongery are part\n of that door, not separately-bought items. Use judgement: would a supplier issue a\n separate price line for it? If no, keep it as one.\n- Balanced approach: split only when genuinely separate products are present; when in doubt\n about whether something is a separate product vs. an accessory/spec of the main one, keep\n it as a single item. Do not invent products that are not in the text.\n\nSTRICT RULES — include ONLY items that are ALL of the following:\n1. A specific, concrete, tangible product that can be boxed up by a supplier and delivered to site — something you can point at and say \"this exact thing is being bought\" (e.g. \"LED luminaire 36W\", \"PVC pipe 110mm\", \"Distribution board 12-way\", \"CO2 fire extinguisher 6kg\").\n2. Has a real quantity (not 0 or \"varies\" with no meaning).\n3. Has enough description to identify what the product is.\n4. Is an actual PRODUCT, not a sentence, instruction, or specification clause. REJECT rows that are prose such as \"the supervising engineer's instructions\", \"The price includes all materials, labor\", \"Measurement will be based on the geometric cubic meter\", \"necessary steps to complete the work\", \"soil-contacting elements\", \"in compliance\", \"everything necessary\". These are description fragments, not buyable items.\n\nNEVER extract abstract concepts, disciplines, trades, systems, or section/category names — they are NOT products even when they appear as their own row:\n- Bare discipline/trade/system words such as: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", and their Arabic equivalents (\"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\", \"تشطيبات\").\n- Test: if the word names a FIELD OF WORK or a SYSTEM rather than one buyable item with a unit (pcs, m, set, kg…), REJECT it.\n\nEXCLUDE all of the following (do NOT include them):\n- Labor, installation, or workmanship items (e.g. \"install\", \"fix\", \"erect\", \"dismantle\", \"painting\", \"cleaning service\")\n- Project management, overhead, or profit items\n- Insurance, bonds, liability items (e.g. \"bodily injury\", \"third party liability\", \"CAR insurance\")\n- Shop drawings, as-built drawings, method statements, documentation\n- Preliminary items, mobilisation, demobilisation, provisional sums\n- Housekeeping, site clearance, waste removal\n- Floor/level labels (e.g. \"GF Floor\", \"Level 3\") — these are headers, not products\n- Pure dimension specs without a product noun (e.g. \"Dia 32 mm\", \"25 mm\") — reject unless a product name is present\n- Security services, night shifts, rentals, payments\n- Any item whose description is too vague or fragmentary to identify a specific product\n\nEmit ONE JSON item PER real product. A single source row may therefore produce several\nitems (when it lists multiple products) or several rows may collapse into one item (when\nthey are wrapped fragments of the same product). The \"items\" array is your final delivery\nlist, not a row-by-row transcript.\n\nFor each valid physical product return JSON with:\n- description: clean product name only (strip \"supply and install\", \"supply and fix\", installation clauses, floor/spec suffixes). For a split product, use just that product's own name.\n- quantity: the EXACT numeric value from the quantity/count column (العدد, الكمية, Qty, Quantity, No.). NEVER default to 1 unless the quantity column is truly absent or empty.\n- unit: unit of measure (pcs, m, m2, m3, set, no, kg, etc.)\n- category: product category\n- brand: brand/manufacturer if mentioned, else \"\"\n- engineering_required: true if the item needs engineering design before procurement (panel boards, chillers, generators, fire suppression systems, BMS, pumps, AHUs, switchgear, transformers), else false\n\nReturn ONLY a valid JSON object: {\"items\": [...]}. No explanations, no markdown, no extra text.\nPROMPT);\n }\n\n private function mockResponse(): array\n {\n return [\n 'success' => true,\n 'error' => null,\n 'items' => [[\n 'description' => 'Sample BOQ Item',\n 'quantity' => 1,\n 'unit' => 'No',\n 'category' => 'Sample',\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 1,\n 'raw_data' => [],\n 'ai_extracted' => true,\n ]],\n 'rejected' => [],\n ];\n }\n\n private function failure(string $message, bool $serviceUnavailable = false): array\n {\n return [\n 'success' => false,\n 'items' => [],\n 'rejected' => [],\n 'error' => $this->userFacingError($message, $serviceUnavailable),\n 'service_unavailable' => $serviceUnavailable,\n ];\n }\n\n /**\n * Translate any internal error into a neutral, user-safe message.\n *\n * The user must never see the name of the underlying AI/processing engine\n * (DeepSeek, Groq, Gemini, Vision, etc.) or configuration hints like API\n * keys. The detailed message is kept in the logs for debugging only.\n */\n private function userFacingError(string $message, bool $serviceUnavailable = false): string\n {\n // Detect any reference to an AI provider / engine / API config so we can\n // swap it for a generic, branded-neutral message.\n $sensitive = '/deepseek|groq|gemini|vision|openai|openrouter|api[\\s_-]?key|\\.env|non-?json|token limit/i';\n\n if (preg_match($sensitive, $message)) {\n Log::warning('QuotationAiService: suppressed technical error from user.', ['internal' => $message]);\n\n return $serviceUnavailable\n ? 'The file could not be processed right now. Please try again in a moment.'\n : 'We could not read this file automatically. Please make sure it is a clear BOQ (Excel, CSV, PDF, or image) and try again, or add the items manually.';\n }\n\n return $message;\n }\n\n private function mimeForExtension(string $ext): string\n {\n return match (strtolower($ext)) {\n 'pdf' => 'application/pdf',\n 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n 'xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12',\n 'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',\n 'xls' => 'application/vnd.ms-excel',\n 'csv' => 'text/csv',\n 'jpg', 'jpeg' => 'image/jpeg',\n 'png' => 'image/png',\n 'gif' => 'image/gif',\n 'bmp' => 'image/bmp',\n 'webp' => 'image/webp',\n 'tiff', 'tif' => 'image/tiff',\n 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'doc' => 'application/msword',\n default => 'application/octet-stream',\n };\n }\n}\n"}, "2c0e6147e0acffd0074328e34971efef7eef0115": {"t": 1785748366.6170099, "n": 79619, "h": "9c0b0544c85eaad0", "c": "<?php\n\nnamespace App\\Services;\n\nuse Illuminate\\Http\\Client\\ConnectionException;\nuse Illuminate\\Http\\UploadedFile;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\nuse PhpOffice\\PhpSpreadsheet\\Cell\\Coordinate;\nuse PhpOffice\\PhpSpreadsheet\\IOFactory;\n\nclass QuotationAiService\n{\n /** Increment this whenever BOQ parsing logic changes to invalidate old caches. */\n private const PARSER_VERSION = 10;\n private const LARGE_BOQ_VERIFICATION_THRESHOLD = 2000;\n private const LARGE_BOQ_VERIFICATION_CHUNK_SIZE = 120;\n\n /**\n * Characters of document text sent per AI call.\n *\n * Above this the document is split and parsed over several calls. Sized well\n * under the model's context so the prompt, the slice, and a full JSON answer\n * for every row in it all fit — the binding limit is the *output* budget, not\n * the input, since each input row produces a JSON object.\n */\n private const TEXT_CHUNK_CHARS = 60000;\n\n /**\n * A workbook with more sheets than this is split regardless of its size.\n *\n * Multiple tabs almost always mean multiple disciplines (electrical, HVAC,\n * civil…). Sending them in one call makes the model summarise across\n * sections and silently drop rows, so each slice gets a narrower scope.\n */\n private const MAX_SHEETS_PER_CALL = 2;\n\n /** A PDF longer than this is split regardless of its extracted text length. */\n private const MAX_PDF_PAGES_PER_CALL = 5;\n\n private string $baseUrl;\n private string $parseEndpoint;\n private string $apiKey;\n private int $timeout;\n private bool $testMode;\n private BoqCleaningService $boqCleaner;\n\n /** Page count of the most recently parsed PDF; 0 when unknown. */\n private int $lastPdfPageCount = 0;\n\n /**\n * Optional progress reporter for chunked parsing, called as ($part, $total).\n *\n * Set by the queue job so the polling UI can show real progress on a large\n * BOQ instead of a spinner that appears frozen for many minutes.\n *\n * @var (callable(int, int): void)|null\n */\n private $onChunkProgress = null;\n\n /** @param callable(int, int): void $callback */\n public function onChunkProgress(callable $callback): self\n {\n $this->onChunkProgress = $callback;\n return $this;\n }\n\n /**\n * Optional per-chunk item sink, called as ($items, $part, $total).\n *\n * Lets the caller persist each slice's rows the moment they arrive instead\n * of waiting for the whole document. On a large BOQ this is the difference\n * between a table that fills in progressively and one that stays empty for\n * many minutes.\n *\n * @var (callable(array, int, int): void)|null\n */\n private $onChunkItems = null;\n\n /** @param callable(array, int, int): void $callback */\n public function onChunkItems(callable $callback): self\n {\n $this->onChunkItems = $callback;\n return $this;\n }\n\n /**\n * Report a non-chunk stage (local parse, handing off to AI) to the caller.\n *\n * A spreadsheet is read locally first and only sent to the AI when that\n * fails, so a large file can spend minutes before any chunk exists. Without\n * these the UI shows a generic spinner for that whole period.\n */\n private function reportStage(string $message): void\n {\n if ($this->onStage !== null) {\n ($this->onStage)($message);\n }\n }\n\n /**\n * Report a workbook's shape from grids that are already in memory.\n *\n * Deliberately takes the parsed grids rather than a path: re-reading a large\n * spreadsheet just to count its sheets would double the slowest step of the\n * whole extraction.\n *\n * @param array<int, array{name: string, grid: array}> $sheets\n */\n private function reportWorkbookShape(array $sheets): void\n {\n if ($this->onStage === null || empty($sheets)) {\n return;\n }\n\n $rows = 0;\n foreach ($sheets as $sheet) {\n $rows += count($sheet['grid']);\n }\n\n $count = count($sheets);\n\n $this->reportStage($count > 1\n ? \"Found {$count} sheets, {$rows} rows in total. Reading…\"\n : \"Found {$rows} rows. Reading…\");\n }\n\n /** @var (callable(string): void)|null */\n private $onStage = null;\n\n /** @param callable(string): void $callback */\n public function onStage(callable $callback): self\n {\n $this->onStage = $callback;\n return $this;\n }\n\n public function __construct(BoqCleaningService $boqCleaner)\n {\n $this->boqCleaner = $boqCleaner;\n $this->baseUrl = rtrim((string) config('services.ai_quotation.base_url', ''), '/');\n $this->parseEndpoint = ltrim((string) config('services.ai_quotation.parse_endpoint', 'parse'), '/');\n $this->apiKey = (string) config('services.ai_quotation.api_key', '');\n $this->timeout = (int) config('services.ai_quotation.timeout', 300);\n $this->testMode = (bool) config('services.ai_quotation.test_mode', false);\n }\n\n /**\n * Give the running script enough head-room to outlast the AI HTTP call.\n *\n * The web request / queue worker must not be killed before the HTTP timeout\n * completes, otherwise the user sees a generic \"timeout or unavailable\"\n * error even though the AI may have responded. We allow the HTTP timeout\n * plus a 120s margin for connecting, response parsing and DB writes.\n */\n private function extendExecutionTime(): void\n {\n // Ignored on some SAPIs (e.g. when running under fastcgi with a hard\n // limit), but harmless to attempt.\n @set_time_limit($this->timeout + 120);\n }\n\n public function parseBoq(UploadedFile|string $file, array $context = []): array\n {\n if ($this->testMode) {\n return $this->mockResponse();\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n\n if (! is_file($absPath)) {\n return $this->failure('File not found or not readable.');\n }\n\n $fileHash = hash_file('sha256', $absPath);\n // Bump PARSER_VERSION whenever the extraction logic changes so that\n // previously-uploaded files are re-parsed instead of returning a stale\n // cached result produced by the old code.\n $cacheKey = 'boq_analysis_v' . self::PARSER_VERSION . '_' . $fileHash;\n $forceRefresh = (bool) ($context['force_refresh'] ?? false);\n\n if (! $forceRefresh && ($cached = Cache::get($cacheKey)) !== null) {\n return $cached;\n }\n\n // Clear stale cached result when force-refreshing\n if ($forceRefresh) {\n Cache::forget($cacheKey);\n }\n\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n // Read Excel locally first. Fall back to AI if local parse failed,\n // found 0 items, OR could not read one of the sheets (so multi-sheet\n // files never silently drop a sheet the local parser didn't understand).\n $this->reportStage('Reading the spreadsheet…');\n\n $result = $this->parseSpreadsheetDirect($absPath);\n\n $hasSkippedSheets = ! empty($result['skipped_sheets']);\n\n if (! $result['success'] || empty($result['items']) || $hasSkippedSheets) {\n // The local pass could not handle this file on its own. Say so:\n // otherwise the UI sits on a generic spinner through the local\n // parse AND the whole AI parse with nothing to show for either.\n $this->reportStage($hasSkippedSheets\n ? 'Some sheets need AI to read. Sending to AI…'\n : 'Spreadsheet needs AI to read. Sending to AI…');\n\n $deepSeekResult = $this->parseBoqWithDeepSeek($file, $context);\n // AI sees every sheet at once, so prefer its result whenever it\n // actually returned items (covers the skipped-sheet case too).\n if ($deepSeekResult['success'] && ! empty($deepSeekResult['items'])) {\n // Keep the larger of the two item sets so we never lose the\n // rows the local parser already extracted correctly.\n if (count($deepSeekResult['items']) >= count($result['items'] ?? [])) {\n $result = $deepSeekResult;\n }\n } elseif (! $result['success']) {\n $result = $deepSeekResult;\n }\n // else: keep local result if AI also failed/returned nothing\n }\n } elseif (in_array($ext, ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif'], true)) {\n $result = $this->parseBoqWithDeepSeek($file, $context);\n } else {\n return $this->failure('Unsupported file type. Please upload Excel, CSV, PDF, or image file.');\n }\n\n if ($result['success'] && ! empty($result['items'])) {\n $cleaning = $this->boqCleaner->process($result['items']);\n $result['items'] = $cleaning['accepted'] ?? $result['items'];\n $result['rejected'] = array_merge($result['rejected'] ?? [], $cleaning['rejected'] ?? []);\n }\n\n if ($result['success'] && count($result['items'] ?? []) > self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n $result = $this->verifyLargeBoqItemsWithAi($result);\n }\n\n // Only cache when we actually extracted items; empty results should be re-tried.\n if ($result['success'] && ! empty($result['items'])) {\n Cache::put($cacheKey, $result, now()->addDays(30));\n }\n\n return $result;\n }\n\n private function resolveFile(UploadedFile|string $file): array\n {\n if ($file instanceof UploadedFile) {\n return [(string) $file->getRealPath(), strtolower($file->getClientOriginalExtension())];\n }\n\n $absPath = is_file($file) ? $file : storage_path('app/' . ltrim($file, '/'));\n return [$absPath, strtolower(pathinfo($absPath, PATHINFO_EXTENSION))];\n }\n\n private function parseSpreadsheetDirect(string $absPath): array\n {\n ini_set('memory_limit', '1024M');\n set_time_limit(180);\n\n try {\n $sheets = $this->spreadsheetToGrids($absPath);\n\n if (empty($sheets)) {\n return $this->failure('Could not read the Excel file.');\n }\n\n $this->reportWorkbookShape($sheets);\n\n $items = [];\n $rejected = [];\n $skippedSheets = []; // sheets we could not parse locally (header undetected)\n\n foreach ($sheets as $sheet) {\n $grid = $sheet['grid'];\n if (count($grid) < 2) {\n continue;\n }\n\n // Skip dedicated summary / cost-abstract sheets entirely. Their rows\n // are per-discipline totals (Structural Concrete, Electrical, …) and\n // grand totals, not procurable products.\n if ($this->isSummarySheet($sheet['name'])) {\n Log::info('QuotationAiService: skipped summary sheet.', ['sheet' => $sheet['name']]);\n continue;\n }\n\n $header = $this->detectHeader($grid);\n\n if ($header === null || ! isset($header['map']['description'])) {\n Log::warning('QuotationAiService: header not detected in sheet.', ['sheet' => $sheet['name']]);\n $skippedSheets[] = $sheet['name'];\n continue;\n }\n\n $map = $header['map'];\n $start = $header['row'] + 1;\n\n for ($r = $start; $r < count($grid); $r++) {\n $row = $grid[$r];\n $description = $this->cell($row, $map['description'] ?? null);\n $itemNo = $this->cell($row, $map['item_code'] ?? null);\n $unit = $this->cell($row, $map['unit'] ?? null);\n $quantity = $this->number($this->cell($row, $map['quantity'] ?? null));\n $unitPrice = $this->number($this->cell($row, $map['unit_price'] ?? null));\n $totalPrice = $this->number($this->cell($row, $map['total_price'] ?? null));\n\n if ($description === '') {\n continue;\n }\n\n if (! isset($map['quantity'])) {\n $quantity = 1.0;\n }\n\n // Skip floor breakdown sub-rows: Ref. is \"-\" meaning it's a per-floor\n // quantity split of the parent item, not a standalone product.\n if ($itemNo === '-' || $this->isFloorBreakdownRow($itemNo, $description)) {\n continue;\n }\n\n if ($this->isTotalOrHeaderRow($description)) {\n continue;\n }\n\n if ($quantity === null || $quantity <= 0) {\n $rejected[] = $this->rejectedRow($description, $unit, $quantity, 'Missing quantity', $itemNo, $sheet['name']);\n continue;\n }\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $supply = $this->boqCleaner->filterItem($description, $unitPrice, $quantity);\n\n if (! ($supply['keep'] ?? false)) {\n $rejected[] = $this->rejectedRow(\n $description,\n $unit,\n $quantity,\n (string) ($supply['rejection_reason'] ?? 'Rejected by cleaner'),\n $itemNo,\n $sheet['name']\n );\n continue;\n }\n\n $finalDescription = (string) ($supply['description'] ?? $description);\n\n $items[] = [\n 'description' => $finalDescription,\n 'quantity' => $quantity,\n 'unit' => $unit,\n 'category' => $sheet['name'],\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($finalDescription),\n 'unit_price' => $unitPrice,\n 'confidence' => 0.95,\n 'needs_review' => false,\n 'raw_data' => [\n 'sheet' => $sheet['name'],\n 'row_number' => $r + 1,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'cleaned_description' => $finalDescription,\n 'extraction_type' => (string) ($supply['extraction_type'] ?? 'supply_only'),\n ],\n 'ai_extracted' => true,\n ];\n }\n }\n\n if (empty($items)) {\n return $this->failure('No BOQ supply items could be extracted from the Excel file.');\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'skipped_sheets' => $skippedSheets,\n ];\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: parseSpreadsheetDirect failed.', [\n 'path' => $absPath,\n 'message' => $e->getMessage(),\n 'trace' => $e->getTraceAsString(),\n ]);\n\n return $this->failure('Failed to read the Excel file: ' . $e->getMessage());\n }\n }\n\n private function spreadsheetToGrids(string $absPath): array\n {\n $ext = strtolower(pathinfo($absPath, PATHINFO_EXTENSION));\n\n if ($ext === 'csv') {\n $rows = [];\n $handle = fopen($absPath, 'rb');\n if ($handle !== false) {\n while (($row = fgetcsv($handle)) !== false) {\n $rows[] = $this->trimTrailingEmptyCells($row);\n }\n fclose($handle);\n }\n return [['name' => 'Sheet1', 'grid' => $rows]];\n }\n\n $reader = IOFactory::createReader($ext === 'xls' ? 'Xls' : 'Xlsx');\n $reader->setReadDataOnly(true);\n $spreadsheet = $reader->load($absPath);\n $sheets = [];\n\n foreach ($spreadsheet->getAllSheets() as $sheet) {\n $highestRow = $sheet->getHighestDataRow();\n $highestColumn = $sheet->getHighestDataColumn();\n $highestColumnIndex = Coordinate::columnIndexFromString($highestColumn);\n\n // BOQ data is normally in first 8-20 columns.\n // Files with heavy formatting/images can report 300+ columns — limit to 30.\n $maxColumnIndex = min($highestColumnIndex, 30);\n\n $grid = [];\n for ($row = 1; $row <= $highestRow; $row++) {\n $rowData = [];\n $hasValue = false;\n\n for ($col = 1; $col <= $maxColumnIndex; $col++) {\n $value = $sheet->getCell([$col, $row])->getValue();\n $value = $this->cellValueToString($value);\n $value = trim($value);\n if ($value !== null && $value !== '') {\n $hasValue = true;\n }\n $rowData[] = $value;\n }\n\n $rowData = $this->trimTrailingEmptyCells($rowData);\n if ($hasValue) {\n $grid[] = $rowData;\n }\n }\n\n if (! empty($grid)) {\n $sheets[] = ['name' => $sheet->getTitle(), 'grid' => $grid];\n }\n }\n\n $spreadsheet->disconnectWorksheets();\n unset($spreadsheet);\n\n return $sheets;\n }\n\n private function detectHeader(array $grid): ?array\n {\n $keywords = [\n 'item_code' => ['رقم البند', 'item no', 'item number', 'ref.', 'ref', 'item', 'no.', 'no', '#', 'كود'],\n 'description' => ['وص٠البند', 'scope of works', 'scope of work', 'scope', 'item description', 'description of works', 'description', 'بالعربية', 'الوصÙ', 'البند', 'بيان'],\n 'unit' => ['Ø§Ù„ÙˆØØ¯Ø©', 'unit', 'uom', 'u/m'],\n 'quantity' => ['الكمية', 'qty', 'quantity', 'q.ty'],\n 'unit_price' => ['سعر Ø§Ù„ÙˆØØ¯Ø©', 'u/price', 'unit price', 'unit rate', 'rate', 'price'],\n 'total_price' => ['السعر الإجمالي', 'total amount', 'total price', 'amount', 'الإجمالي'],\n ];\n\n $best = null;\n $bestScore = 0;\n\n for ($r = 0; $r < min(30, count($grid)); $r++) {\n $map = [];\n $score = 0;\n\n foreach ($grid[$r] as $c => $value) {\n $cell = mb_strtolower(trim((string) $value));\n if ($cell === '') {\n continue;\n }\n\n foreach ($keywords as $field => $list) {\n foreach ($list as $kw) {\n if (str_contains($cell, mb_strtolower($kw))) {\n if (! isset($map[$field])) {\n $map[$field] = $c;\n $score++;\n }\n break 2;\n }\n }\n }\n }\n\n if ($score > $bestScore) {\n $bestScore = $score;\n $best = ['row' => $r, 'map' => $map];\n }\n }\n\n if ($bestScore >= 3) {\n return $best;\n }\n\n // Allow simpler BOQ sheets that only define a description column plus one\n // other BOQ-related column, e.g. Item + Description or Description + Qty.\n if ($bestScore === 2 && isset($best['map']['description']) && (\n isset($best['map']['item_code']) ||\n isset($best['map']['quantity']) ||\n isset($best['map']['unit'])\n )) {\n return $best;\n }\n\n return null;\n }\n\n private function cell(array $row, ?int $index): string\n {\n if ($index === null) {\n return '';\n }\n return trim((string) ($row[$index] ?? ''));\n }\n\n private function number(string $value): ?float\n {\n $value = trim(str_replace([',', 'SAR', 'ر.س'], '', $value));\n if ($value === '' || ! is_numeric($value)) {\n return null;\n }\n return (float) $value;\n }\n\n private function trimTrailingEmptyCells(array $row): array\n {\n while (! empty($row) && trim((string) end($row)) === '') {\n array_pop($row);\n }\n return array_values($row);\n }\n\n private function isTotalOrHeaderRow(string $description): bool\n {\n $d = mb_strtolower(trim($description));\n\n if ($d === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $d)) {\n return true;\n }\n\n // Section totals / summaries\n if (preg_match('/(اجمالي|إجمالي|المجموع|grand\\s*total|sub\\s*total|total\\s*amount|total\\s*price)/iu', $d)) {\n return true;\n }\n\n // Bare \"Total\" lines, optionally with a currency suffix, e.g. \"Total\",\n // \"Total (SAR)\", \"Total cost\", \"Total (SAR) unit cost\".\n if (preg_match('/^total\\b[\\s\\(\\):a-z]*$/iu', $d)) {\n return true;\n }\n\n // Pure floor / level labels used as section headers\n if (preg_match('/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor)\\s*$/iu', $d)) {\n return true;\n }\n\n // Division / section header lines (e.g. \"Division - 02: Wood Works\")\n if (preg_match('/^division\\s*([-:]|\\d)/iu', $d)) {\n return true;\n }\n\n return false;\n }\n\n /**\n * Detect dedicated summary / cost-abstract sheets by their name. These hold\n * per-discipline rollups and grand totals (e.g. a \"Summary (Dry Cost)\" tab),\n * never procurable supply items, so they are skipped wholesale.\n */\n private function isSummarySheet(string $name): bool\n {\n $n = mb_strtolower(trim($name));\n\n if ($n === '') {\n return false;\n }\n\n return (bool) preg_match(\n '/\\b(summary|abstract|cost\\s*summary|grand\\s*summary|recap(?:itulation)?|bill\\s*summary|boq\\s*summary|dry\\s*cost)\\b|ملخص|الملخص|الملخّص|ملخّص|اجمالي|إجمالي/iu',\n $n\n );\n }\n\n private function isFloorBreakdownRow(string $itemNo, string $description): bool\n {\n // A row whose Ref column is empty or a dash is a sub-breakdown row (per-floor split).\n $ref = trim($itemNo);\n if ($ref === '' || $ref === '-') {\n // Confirm by checking the description is a location/floor label\n $d = mb_strtolower(trim($description));\n return (bool) preg_match(\n '/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor|\\d+f)\\s*$/iu',\n $d\n );\n }\n return false;\n }\n\n private function rejectedRow(string $description, string $unit, ?float $quantity, string $reason, string $itemNo = '', string $sheet = ''): array\n {\n return [\n 'description' => $description,\n 'quantity' => $quantity ?? 0,\n 'unit' => $unit,\n 'category' => $sheet,\n 'brand' => '',\n 'status' => 'rejected',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 0.0,\n 'raw_data' => [\n 'sheet' => $sheet,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'extraction_type' => 'rejected',\n 'rejection_reason' => $reason,\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function parseBoqWithDeepSeek(UploadedFile|string $file, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured. Please set DEEPSEEK_API_KEY in .env file.');\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n $mime = $this->mimeForExtension($ext);\n\n try {\n // ── Spreadsheets ──────────────────────────────────────────────────\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n $text = $this->spreadsheetToCompactCsvText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not convert spreadsheet to readable text.');\n }\n\n $text = $this->sanitizeUtf8($text);\n\n // A large BOQ exceeds what one request can carry, so send it in\n // sequential slices and merge. Anything that fits goes as-is.\n //\n // Size alone is not a good enough signal: a workbook with several\n // sheets is structurally a big BOQ even when the text is compact,\n // and asking the model to hold every discipline at once is where\n // rows start getting dropped. So split on either signal.\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS || $sheetCount > self::MAX_SHEETS_PER_CALL) {\n Log::info('QuotationAiService: spreadsheet routed to chunked parsing.', [\n 'chars' => mb_strlen($text),\n 'sheets' => $sheetCount,\n ]);\n return $this->parseTextInChunks($text, 'BOQ spreadsheet converted to CSV', $apiKey, $context);\n }\n\n $userContent = \"BOQ spreadsheet converted to CSV:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── PDF ───────────────────────────────────────────────────────────\n if ($ext === 'pdf') {\n $extracted = $this->extractPdfText($absPath);\n if ($extracted !== null && mb_strlen(trim($extracted)) > 50) {\n $extracted = $this->sanitizeUtf8($extracted);\n\n // Split on either signal — long text, or many pages. A BOQ\n // spanning several pages is a multi-section document even if\n // each page is sparse.\n if (mb_strlen($extracted) > self::TEXT_CHUNK_CHARS\n || $this->lastPdfPageCount > self::MAX_PDF_PAGES_PER_CALL) {\n Log::info('QuotationAiService: PDF routed to chunked parsing.', [\n 'chars' => mb_strlen($extracted),\n 'pages' => $this->lastPdfPageCount,\n ]);\n return $this->parseTextInChunks($extracted, 'BOQ PDF extracted text', $apiKey, $context, 'application/pdf');\n }\n\n $userContent = \"BOQ PDF extracted text:\\n\\n\" . $extracted . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'application/pdf');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n return $this->failure('Could not extract text from the PDF. Please make sure it is a text-based PDF (not a scanned image), or convert it to Excel or CSV.');\n }\n\n // -- Word documents (docx) ---------------------------------------------------\n if (in_array($ext, ['docx', 'doc'], true)) {\n $text = $this->extractDocxText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not extract text from the Word document. Please convert it to Excel, CSV, or PDF.');\n }\n $text = $this->sanitizeUtf8($text);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS) {\n return $this->parseTextInChunks($text, 'BOQ Word document extracted text', $apiKey, $context);\n }\n\n $userContent = \"BOQ Word document extracted text:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── Images ──────────────────────────────────────────────────────────────\n // DeepSeek cannot receive images. A vision-capable AI processes the image\n // directly (no OCR) — configure GROQ_API_KEY or GEMINI_API_KEY in .env.\n $imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif', 'heic', 'heif'];\n if (in_array($ext, $imageExts, true)) {\n $bytes = (string) file_get_contents($absPath);\n return $this->callDeepSeekVision($bytes, $mime, $context);\n }\n\n return $this->failure('Unsupported file type. Please upload an Excel, CSV, PDF, or image file.');\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: DeepSeek fallback failed.', ['message' => $e->getMessage()]);\n return $this->failure('An error occurred while processing the file with AI: ' . $e->getMessage());\n }\n }\n\n private function deepSeekModel(): string\n {\n return (string) config('services.deepseek.model', 'deepseek-chat');\n }\n\n /**\n * Remove or replace any non-UTF-8 / malformed byte sequences so json_encode never fails.\n */\n private function sanitizeUtf8(string $text): string\n {\n // Replace invalid byte sequences with the UTF-8 replacement character\n $clean = mb_convert_encoding($text, 'UTF-8', 'UTF-8');\n // Strip any remaining non-printable control characters (except tab/newline/CR)\n $clean = (string) preg_replace('/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]/u', '', $clean);\n return $clean;\n }\n\n /**\n * Extract plain text from a Word docx file using ZipArchive (no external library needed).\n * Reads word/document.xml and strips XML tags, preserving paragraph and table structure.\n */\n private function extractDocxText(string $absPath): ?string\n {\n try {\n $zip = new \\ZipArchive();\n if ($zip->open($absPath) !== true) {\n return null;\n }\n\n $xml = $zip->getFromName('word/document.xml');\n $zip->close();\n\n if ($xml === false || $xml === '') {\n return null;\n }\n\n // Add newlines before paragraph/table-row/table-cell tags so structure is preserved\n $xml = preg_replace('/<w:p[ >]/', \"\\n<w:p>\", $xml) ?? $xml;\n $xml = preg_replace('/<w:tr[ >]/', \"\\n<w:tr>\", $xml) ?? $xml;\n $xml = preg_replace('/<\\/w:tc>/', \"\\t\", $xml) ?? $xml;\n\n $text = strip_tags($xml);\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n return trim($text) !== '' ? trim($text) : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: DOCX parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Extract plain text from a PDF using smalot/pdfparser (pure PHP, no binary required).\n */\n private function extractPdfText(string $absPath): ?string\n {\n try {\n $parser = new \\Smalot\\PdfParser\\Parser();\n $pdf = $parser->parseFile($absPath);\n\n // Recorded for the chunking decision: a long PDF is structurally a\n // big BOQ even when its extracted text is short.\n try {\n $this->lastPdfPageCount = count($pdf->getPages());\n } catch (\\Throwable) {\n $this->lastPdfPageCount = 0;\n }\n\n $text = $pdf->getText();\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n return $text !== '' ? $text : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: PDF parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Send a vision request to a vision-capable AI model.\n * DeepSeek's commercial API (api.deepseek.com) does NOT support image inputs — any model.\n * Priority order (first configured key wins):\n * 1. GROQ_API_KEY → Groq llama-4-scout (free: https://console.groq.com)\n * 2. GEMINI_API_KEY → Google Gemini Flash (free: https://aistudio.google.com/apikey)\n * 3. VISION_API_KEY → any OpenAI-compatible endpoint (OpenAI, OpenRouter …)\n */\n private function callDeepSeekVision(string $bytes, string $mime, array $context): array\n {\n // ── 1. Groq (free, reliable vision) ─────────────────────────────────────\n if ((string) config('services.groq.key', '') !== '') {\n $visionKey = (string) config('services.groq.key');\n $visionBaseUrl = 'https://api.groq.com/openai/v1';\n $visionModel = (string) config('services.groq.model', 'meta-llama/llama-4-scout-17b-16e-instruct');\n }\n // ── 2. Google Gemini ─────────────────────────────────────────────────────\n elseif ((string) config('services.gemini.key', '') !== '') {\n $visionKey = (string) config('services.gemini.key');\n $visionBaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai';\n $visionModel = (string) config('services.gemini.model', 'gemini-2.0-flash');\n }\n // ── 3. Generic VISION_API_KEY (OpenAI, OpenRouter, …) ───────────────────\n elseif ((string) config('services.vision.key', '') !== '') {\n $visionKey = (string) config('services.vision.key');\n $visionBaseUrl = rtrim((string) config('services.vision.base_url', 'https://openrouter.ai/api/v1'), '/');\n $visionModel = (string) config('services.vision.model', 'google/gemini-flash-1.5-8b');\n }\n else {\n return $this->failure(\n 'Image processing is not available. DeepSeek\\'s API does not support images. ' .\n 'Get a free key at https://console.groq.com and add GROQ_API_KEY to your .env file.'\n );\n }\n\n $dataUrl = \"data:{$mime};base64,\" . base64_encode($bytes);\n $prompt = $this->buildDeepSeekPrompt($context, $mime);\n\n $userContent = [\n ['type' => 'text', 'text' => $prompt],\n ['type' => 'image_url', 'image_url' => ['url' => $dataUrl]],\n ];\n\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders([\n 'Authorization' => 'Bearer ' . $visionKey,\n 'HTTP-Referer' => config('app.url', 'https://qimta.sa'),\n 'X-Title' => 'Qimta Platform',\n ])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post($visionBaseUrl . '/chat/completions', [\n 'model' => $visionModel,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 16384,\n 'temperature' => 0.2,\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('Vision API connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: Vision API HTTP error.', [\n 'model' => $visionModel,\n 'base_url' => $visionBaseUrl,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n return $this->failure(\"Vision API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n return $this->parseDeepSeekJsonResponse((string) $response->json('choices.0.message.content'));\n }\n\n private function callDeepSeekChat(string $userContent, string $key, string $model): array\n {\n // Ensure the PHP script outlives the HTTP call (+ a margin for parsing the\n // response and writing to the DB) so the worker/web request is not killed\n // mid-flight, which surfaces to users as a generic \"timeout or unavailable\".\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders(['Authorization' => 'Bearer ' . $key])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 32768,\n 'temperature' => 0.1,\n 'response_format' => ['type' => 'json_object'],\n 'user' => 'Qimta_Platform',\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('DeepSeek connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: DeepSeek HTTP error.', [\n 'model' => $model,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n if ($response->status() === 401) {\n return $this->failure('DeepSeek API key is invalid or unauthorised. Please update DEEPSEEK_API_KEY in .env.', false);\n }\n return $this->failure(\"DeepSeek API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n $text = (string) $response->json('choices.0.message.content');\n return $this->parseDeepSeekJsonResponse($text);\n }\n\n /**\n * Parse a document too large for one AI call by sending it in slices.\n *\n * A 20 000-row BOQ is several megabytes of text — far beyond what a single\n * request can carry, and beyond what the model can answer without hitting its\n * output token limit. Previously the text was simply cut at 180 000 chars and\n * the remainder discarded: the parse \"succeeded\" while silently dropping most\n * of the file. Now the text is split and each slice parsed on its own call,\n * with the items merged.\n *\n * Splitting happens on line boundaries so a BOQ row is never cut in half.\n * Slices run sequentially rather than pooled: DeepSeek rate-limits hard on\n * concurrent large prompts, and this already runs on the queue where wall\n * time is cheap.\n *\n * A slice that fails does not fail the document — its rows are lost, but the\n * rest are kept and the shortfall is logged. Total failure is reported only\n * when every slice fails.\n *\n * @param string $label How the payload is introduced to the model.\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n /**\n * Split a spreadsheet into the slices the AI will be asked to read.\n *\n * Exposed so the work can be spread over several queue jobs instead of one\n * job walking every slice in sequence. Returns an empty array when the file\n * is small enough to go in a single call, or cannot be read.\n *\n * @return array<int, string>\n */\n public function chunkSpreadsheet(string $absPath): array\n {\n $text = $this->spreadsheetToCompactCsvText($absPath);\n\n if ($text === null || trim($text) === '') {\n return [];\n }\n\n $text = $this->sanitizeUtf8($text);\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) <= self::TEXT_CHUNK_CHARS && $sheetCount <= self::MAX_SHEETS_PER_CALL) {\n return [];\n }\n\n return $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n }\n\n /**\n * Parse one slice produced by chunkSpreadsheet().\n *\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n public function parseChunk(string $chunk, int $part, int $total, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured.');\n }\n\n // Same framing parseTextInChunks() uses, so a slice is never mistaken\n // for a complete BOQ and totals are not inferred from a fragment.\n $userContent = \"BOQ spreadsheet converted to CSV (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, 'text/plain');\n\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n private function parseTextInChunks(\n string $text,\n string $label,\n string $apiKey,\n array $context,\n string $mime = 'text/plain',\n ): array {\n $chunks = $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n $total = count($chunks);\n\n Log::info('QuotationAiService: large document split for parsing.', [\n 'chars' => mb_strlen($text),\n 'chunks' => $total,\n ]);\n\n // Announce the split before the first slice is sent, so the UI can show\n // how many parts there are instead of sitting on a generic message for\n // however long the first AI call takes.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)(0, $total);\n }\n\n $items = [];\n $rejected = [];\n $failed = 0;\n $lastError = null;\n\n foreach ($chunks as $index => $chunk) {\n $part = $index + 1;\n\n // Report progress so a 28-slice parse does not look frozen to the user.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)($part, $total);\n }\n\n // Tell the model this is a fragment, so it does not try to reconcile\n // totals or treat a mid-document slice as a complete BOQ.\n $userContent = \"{$label} (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, $mime);\n\n $result = $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n\n if (! ($result['success'] ?? false)) {\n $failed++;\n $lastError = $result['error'] ?? 'Chunk parsing failed.';\n Log::warning('QuotationAiService: chunk failed, continuing.', [\n 'part' => $part,\n 'of' => $total,\n 'error' => $lastError,\n ]);\n continue;\n }\n\n foreach ($result['items'] as $item) {\n $items[] = $item;\n }\n foreach ($result['rejected'] ?? [] as $row) {\n $rejected[] = $row;\n }\n\n // Hand this slice's rows straight to the caller so they can be shown\n // while the remaining slices are still being parsed.\n if ($this->onChunkItems !== null && $result['items'] !== []) {\n ($this->onChunkItems)($result['items'], $part, $total);\n }\n }\n\n // Every slice failed — there is nothing to show, so report the failure.\n if ($failed === $total) {\n return $this->failure($lastError ?? 'Could not parse the document.');\n }\n\n if ($failed > 0) {\n Log::error('QuotationAiService: document parsed with missing parts.', [\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n 'items_kept' => count($items),\n ]);\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'service_unavailable' => false,\n 'partial' => $failed > 0,\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n ];\n }\n\n /**\n * Split text into slices of at most $limit characters, breaking only at\n * newlines so no BOQ row is ever cut in half.\n *\n * A single line longer than the limit (rare — a pathological cell) is passed\n * through oversized rather than severed mid-row.\n *\n * @return array<int, string>\n */\n private function splitOnLineBoundaries(string $text, int $limit): array\n {\n $chunks = [];\n $current = '';\n\n // Sheet a chunk starts in the middle of. Repeated at the top of the next\n // chunk so the model always knows which sheet the rows belong to — the\n // header would otherwise be stranded in the previous slice, and rows\n // would arrive with no discipline context.\n $currentSheet = null;\n\n foreach (explode(\"\\n\", $text) as $line) {\n $isSheetHeader = str_starts_with($line, 'Sheet: ');\n\n // Start a new chunk at every sheet boundary. Sheets are usually\n // separate disciplines, and the whole point of splitting a multi-tab\n // workbook is to give the model one discipline at a time — a size-only\n // split would leave a compact 3-tab file as a single chunk, silently\n // undoing the sheet gate that routed it here.\n if ($isSheetHeader && $current !== '') {\n $chunks[] = $current;\n $current = $line;\n $currentSheet = $line;\n continue;\n }\n\n if ($isSheetHeader) {\n $currentSheet = $line;\n }\n\n $candidate = $current === '' ? $line : $current . \"\\n\" . $line;\n\n if (mb_strlen($candidate) > $limit && $current !== '') {\n $chunks[] = $current;\n\n // Carry the sheet header over, unless this line is itself one.\n $current = ($currentSheet !== null && ! str_starts_with($line, 'Sheet: '))\n ? $currentSheet . \" (continued)\\n\" . $line\n : $line;\n continue;\n }\n\n $current = $candidate;\n }\n\n if (trim($current) !== '') {\n $chunks[] = $current;\n }\n\n return $chunks;\n }\n\n /**\n * Shared JSON response parser used by both chat and vision paths.\n */\n private function parseDeepSeekJsonResponse(string $text): array\n {\n if ($text === '') {\n return $this->failure('DeepSeek returned an empty response.');\n }\n\n // Strip markdown code fences if the model wrapped the JSON.\n if (preg_match('/```(?:json)?\\s*([\\s\\S]*?)\\s*```/i', $text, $m)) {\n $text = $m[1];\n }\n // Narrow to the outermost JSON object/array.\n if (preg_match('/[\\{\\[][\\s\\S]*[\\}\\]]/s', $text, $m)) {\n $text = $m[0];\n }\n $text = trim($text);\n\n $decoded = json_decode($text, true);\n\n // The response can be truncated when the model hits the token limit,\n // leaving an unterminated JSON string/object. Try to repair it before\n // giving up so we can still salvage the items extracted so far.\n if (! is_array($decoded)) {\n $repaired = $this->repairTruncatedJson($text);\n if ($repaired !== null) {\n $decoded = json_decode($repaired, true);\n }\n }\n\n if (! is_array($decoded)) {\n Log::error('QuotationAiService: DeepSeek non-JSON response.', ['text' => mb_substr($text, 0, 1000)]);\n return $this->failure('DeepSeek returned a non-JSON response. The file may be too large — try splitting it into smaller files.');\n }\n\n $rawItems = $decoded['items'] ?? (array_is_list($decoded) ? $decoded : []);\n $items = [];\n $rejected = [];\n\n foreach ($rawItems as $raw) {\n if (! is_array($raw)) {\n continue;\n }\n $item = $this->normaliseAiItem($raw);\n if ($item === null) {\n continue;\n }\n $check = $this->boqCleaner->filterItem((string) $item['description']);\n if ($check['keep'] ?? false) {\n $items[] = $item;\n } else {\n $item['status'] = 'rejected';\n $item['raw_data']['rejection_reason'] = $check['rejection_reason'] ?? 'Rejected by cleaner';\n $rejected[] = $item;\n }\n }\n\n if (empty($items)) {\n Log::info('QuotationAiService: DeepSeek returned 0 accepted items.', [\n 'raw_items_count' => count($rawItems),\n 'rejected_count' => count($rejected),\n 'first_rejected' => ! empty($rejected) ? ($rejected[0]['description'] ?? '') : '',\n ]);\n }\n\n return ['success' => true, 'items' => $items, 'rejected' => $rejected, 'error' => null];\n }\n\n /**\n * Best-effort repair of a truncated JSON string produced when the model hits\n * its token limit mid-output. Drops any incomplete trailing object, then\n * closes the open string/array/object brackets so the result is valid JSON.\n *\n * Returns the repaired JSON string, or null if it cannot be salvaged.\n */\n private function repairTruncatedJson(string $text): ?string\n {\n if ($text === '') {\n return null;\n }\n\n // Walk the string tracking structural state, ignoring everything inside\n // string literals (and their escapes). Remember the last position where\n // we were back at the top of the \"items\" array between complete elements,\n // so we can cut a half-written trailing object.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($text);\n $lastSafeCut = null; // index just after a completed array element\n\n for ($i = 0; $i < $len; $i++) {\n $ch = $text[$i];\n\n if ($inString) {\n if ($escaped) {\n $escaped = false;\n } elseif ($ch === '\\\\') {\n $escaped = true;\n } elseif ($ch === '\"') {\n $inString = false;\n }\n continue;\n }\n\n if ($ch === '\"') {\n $inString = true;\n } elseif ($ch === '{' || $ch === '[') {\n $stack[] = $ch;\n } elseif ($ch === '}' || $ch === ']') {\n array_pop($stack);\n } elseif ($ch === ',' && ! empty($stack) && end($stack) === '[') {\n // Separator directly inside an array: a clean boundary between\n // elements (the items array, whatever its nesting depth).\n $lastSafeCut = $i;\n }\n }\n\n // If we are still inside a string, the last element is incomplete — fall\n // back to the last clean array boundary if we have one.\n $candidate = $text;\n if ($inString && $lastSafeCut !== null) {\n $candidate = substr($text, 0, $lastSafeCut);\n } else {\n // Drop a dangling partial object after the last completed element.\n $candidate = rtrim($candidate);\n if ($lastSafeCut !== null && substr(rtrim($candidate), -1) !== '}' && substr(rtrim($candidate), -1) !== ']') {\n $candidate = substr($text, 0, $lastSafeCut);\n }\n }\n\n // Recompute the open brackets for the (possibly trimmed) candidate and\n // append the matching closers in reverse order.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($candidate);\n for ($i = 0; $i < $len; $i++) {\n $ch = $candidate[$i];\n if ($inString) {\n if ($escaped) { $escaped = false; }\n elseif ($ch === '\\\\') { $escaped = true; }\n elseif ($ch === '\"') { $inString = false; }\n continue;\n }\n if ($ch === '\"') { $inString = true; }\n elseif ($ch === '{' || $ch === '[') { $stack[] = $ch; }\n elseif ($ch === '}' || $ch === ']') { array_pop($stack); }\n }\n\n $candidate = rtrim($candidate);\n // Remove a trailing comma left after cutting an element.\n $candidate = rtrim($candidate, ',');\n\n $closers = '';\n for ($i = count($stack) - 1; $i >= 0; $i--) {\n $closers .= ($stack[$i] === '{') ? '}' : ']';\n }\n\n $repaired = $candidate . $closers;\n\n // Validate before returning.\n return is_array(json_decode($repaired, true)) ? $repaired : null;\n }\n\n private function spreadsheetToCompactCsvText(string $absPath): ?string\n {\n $sheets = $this->spreadsheetToGrids($absPath);\n if (empty($sheets)) {\n return null;\n }\n\n $output = '';\n $kept = 0;\n $dropped = 0;\n\n foreach ($sheets as $sheet) {\n // Don't feed summary / cost-abstract sheets to the AI either.\n if ($this->isSummarySheet($sheet['name'])) {\n continue;\n }\n $sheetRows = '';\n foreach ($sheet['grid'] as $row) {\n $row = $this->trimTrailingEmptyCells($row);\n\n if ($this->isNoiseRow($row)) {\n $dropped++;\n continue;\n }\n\n $sheetRows .= implode(',', array_map([$this, 'csvEscape'], $row)) . \"\\n\";\n $kept++;\n }\n\n // A sheet whose rows were all noise contributes nothing but its own\n // header, which would just be another thing for the model to explain.\n if ($sheetRows === '') {\n continue;\n }\n\n $output .= 'Sheet: ' . $sheet['name'] . \"\\n\" . $sheetRows . \"\\n\";\n }\n\n Log::info('QuotationAiService: stripped blank rows before sending to AI.', [\n 'kept' => $kept,\n 'dropped' => $dropped,\n ]);\n\n if ($dropped > 0) {\n $this->reportStage(\"Removed {$dropped} blank rows. Sending {$kept} rows…\");\n }\n\n return $output;\n }\n\n /**\n * True when a row carries no information worth sending to the AI.\n *\n * A real BOQ is mostly formatting: spacer rows, stray borders, a lone\n * section number, a leftover cell from a merged block. They survive\n * trimTrailingEmptyCells() because they are not *entirely* empty, then cost\n * tokens, dilute the prompt, and inflate the number of parts a large file is\n * split into.\n *\n * Deliberately conservative — a row is dropped only when nothing in it could\n * name a product. Anything with letters is kept, because a description is\n * the one field an item cannot do without.\n *\n * @param array<int, mixed> $row\n */\n private function isNoiseRow(array $row): bool\n {\n if (empty($row)) {\n return true;\n }\n\n $joined = trim(implode('', array_map(fn($c) => trim((string) $c), $row)));\n\n // Nothing but separators/whitespace.\n if ($joined === '') {\n return true;\n }\n\n // Ruled lines and decorative fills (\"-----\", \"====\", \"___\").\n if (preg_match('/^[\\-_=.*\\s|]+$/u', $joined)) {\n return true;\n }\n\n // Any letter — Latin or Arabic — means this could be a description.\n if (preg_match('/\\p{L}/u', $joined)) {\n return false;\n }\n\n // No letters at all: numbers only. A single number is a stray row\n // number or a dangling total; two or more could be qty/rate/amount\n // belonging to a real line, so keep those for the AI to judge.\n $numericCells = 0;\n foreach ($row as $cell) {\n if (trim((string) $cell) !== '') {\n $numericCells++;\n }\n }\n\n return $numericCells <= 1;\n }\n\n /**\n * Safely convert a PhpSpreadsheet cell value to a string.\n *\n * Cells may contain RichText or, in malformed/heavily-formatted files,\n * embedded objects such as Worksheet\\Drawing that have no __toString().\n * Casting those directly throws \"Object ... could not be converted to string\".\n */\n private function cellValueToString(mixed $value): string\n {\n if ($value === null) {\n return '';\n }\n\n if ($value instanceof \\PhpOffice\\PhpSpreadsheet\\RichText\\RichText) {\n return $value->getPlainText();\n }\n\n if (is_object($value)) {\n // Only stringify objects that explicitly support it; ignore the rest\n // (e.g. Worksheet\\Drawing) instead of crashing.\n return method_exists($value, '__toString') ? (string) $value : '';\n }\n\n if (is_array($value)) {\n return '';\n }\n\n return (string) $value;\n }\n\n private function csvEscape(mixed $value): string\n {\n $value = $this->cellValueToString($value);\n return str_contains($value, ',') || str_contains($value, '\"') || str_contains($value, \"\\n\")\n ? '\"' . str_replace('\"', '\"\"', $value) . '\"'\n : $value;\n }\n\n private function verifyLargeBoqItemsWithAi(array $result): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n Log::warning('QuotationAiService: large BOQ verification skipped, AI key is not configured.');\n return $result;\n }\n\n $items = array_values(array_filter($result['items'] ?? [], 'is_array'));\n if (count($items) <= self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n return $result;\n }\n\n Log::info('QuotationAiService: starting second-pass physical item verification.', [\n 'items_count' => count($items),\n 'threshold' => self::LARGE_BOQ_VERIFICATION_THRESHOLD,\n ]);\n\n $verifiedItems = [];\n $rejectedItems = $result['rejected'] ?? [];\n\n foreach (array_chunk($items, self::LARGE_BOQ_VERIFICATION_CHUNK_SIZE) as $chunkIndex => $chunk) {\n $payload = array_map(fn(array $item) => [\n 'description' => (string) ($item['description'] ?? ''),\n 'quantity' => $item['quantity'] ?? null,\n 'unit' => (string) ($item['unit'] ?? ''),\n 'unit_price' => $item['unit_price'] ?? null,\n 'category' => (string) ($item['category'] ?? ''),\n 'brand' => (string) ($item['brand'] ?? ''),\n ], $chunk);\n\n $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);\n if (! is_string($json)) {\n Log::warning('QuotationAiService: large BOQ verification chunk JSON encode failed.', [\n 'chunk' => $chunkIndex + 1,\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n $prompt = <<<PROMPT\nYou are doing a SECOND PASS verification on a BOQ extraction result.\n\nThe first pass may hallucinate non-products when the BOQ has thousands of rows.\nYour job is to keep ONLY physical, tangible, procurable supply products.\n\nRules:\n- Keep items only if a supplier can actually deliver them as materials/equipment.\n- Reject headings, trade names, systems, scopes, prose, specifications-only rows, totals, labor, installation, testing, commissioning, supervision, drawings, insurance, preliminaries, mobilisation, site works, and vague fragments.\n- Do not invent new products.\n- Do not add items that are not in the input.\n- You may clean a product description, but keep the original quantity, unit, unit_price, category, and brand when available.\n\nInput JSON array:\n{$json}\n\nReturn ONLY valid JSON in this shape:\n{\"items\":[{\"description\":\"\",\"quantity\":1,\"unit\":\"\",\"unit_price\":null,\"category\":\"\",\"brand\":\"\",\"confidence\":0.95}]}\nPROMPT;\n\n try {\n $chunkResult = $this->callDeepSeekChat($prompt, $apiKey, $this->deepSeekModel());\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: large BOQ verification chunk threw an exception.', [\n 'chunk' => $chunkIndex + 1,\n 'message' => $e->getMessage(),\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n if ($chunkResult['success'] ?? false) {\n array_push($verifiedItems, ...($chunkResult['items'] ?? []));\n $rejectedItems = array_merge($rejectedItems, $chunkResult['rejected'] ?? []);\n continue;\n }\n\n Log::warning('QuotationAiService: large BOQ verification chunk failed, keeping original chunk.', [\n 'chunk' => $chunkIndex + 1,\n 'error' => $chunkResult['error'] ?? null,\n ]);\n array_push($verifiedItems, ...$chunk);\n }\n\n if (empty($verifiedItems)) {\n Log::warning('QuotationAiService: second-pass verification returned no items, keeping first-pass result.', [\n 'original_items_count' => count($items),\n ]);\n return $result;\n }\n\n $result['items'] = $verifiedItems;\n $result['rejected'] = $rejectedItems;\n $result['large_boq_verified'] = true;\n\n Log::info('QuotationAiService: second-pass physical item verification completed.', [\n 'before' => count($items),\n 'after' => count($verifiedItems),\n ]);\n\n return $result;\n }\n\n private function normaliseAiItem(array $raw): ?array\n {\n if (! empty($raw['rejected'])) {\n return null;\n }\n\n $description = trim((string) ($raw['cleaned_description'] ?? $raw['product_name'] ?? $raw['description'] ?? ''));\n if ($description === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $description)) {\n return null;\n }\n\n $quantity = is_numeric($raw['quantity'] ?? null) ? (float) $raw['quantity'] : 1;\n $unitPrice = is_numeric($raw['unit_price'] ?? null) ? (float) $raw['unit_price'] : null;\n $totalPrice = is_numeric($raw['total_price'] ?? null) ? (float) $raw['total_price'] : null;\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $specs = is_array($raw['specifications'] ?? null) ? $raw['specifications'] : [];\n\n return [\n 'description' => $description,\n 'quantity' => $quantity,\n 'unit' => (string) ($raw['unit'] ?? ''),\n 'category' => (string) ($raw['core_product'] ?? $raw['category'] ?? ''),\n 'brand' => (string) ($specs['brand'] ?? $raw['brand'] ?? ''),\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($description),\n 'unit_price' => $unitPrice,\n 'confidence' => is_numeric($raw['confidence'] ?? null) ? (float) $raw['confidence'] : null,\n 'needs_review' => ! empty($raw['needs_review']),\n 'raw_data' => [\n 'source_item_no' => (string) ($raw['source_item_no'] ?? ''),\n 'original_description' => (string) ($raw['original_description'] ?? $description),\n 'cleaned_description' => $description,\n 'core_product' => (string) ($raw['core_product'] ?? ''),\n 'specifications' => $specs,\n 'extraction_type' => (string) ($raw['extraction_type'] ?? 'supply_only'),\n 'note' => (string) ($raw['note'] ?? $raw['notes'] ?? ''),\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function buildSystemInstruction(): string\n {\n return <<<'TXT'\nYou are a strict BOQ Supply Product Extraction Engine.\nExtract ONLY concrete, tangible, physical products that a supplier can box up and deliver to site — a specific item you could point at and say \"this is the thing being bought\" (e.g. \"Wall mounted LED luminaire 36W\", \"PVC pipe 110mm\", \"Single phase distribution board 12-way\", \"Fire extinguisher CO2 6kg\", \"CCTV dome camera 4MP\").\n\nABSOLUTELY REJECT abstract concepts, disciplines, categories, section names, and trade headings. These are NOT products even though they appear as rows:\n- Bare discipline/trade words: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", \"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\".\n- Summary / total / abstract / \"dry cost\" rows of any kind.\n- Anything that names a system, scope, or category instead of one buyable item.\n\nRule of thumb: if you cannot name a unit and physically hand the item over (a piece, a box, a length, a unit of equipment), REJECT it. A word that describes a field of work, a system, or a section of the BOQ is NEVER a product.\nReject totals, headings, general requirements, installation-only, testing, commissioning, supervision, mobilization, civil works, labor, and vague rows.\nFor Supply & Install rows, keep only the supply product and remove install/labor wording.\nReturn only valid JSON: {\"items\":[{\"source_item_no\":null,\"original_description\":\"\",\"cleaned_description\":\"\",\"core_product\":\"\",\"specifications\":{},\"quantity\":0,\"unit\":\"\",\"unit_price\":0,\"total_price\":0,\"extraction_type\":\"supply_only\",\"needs_review\":false,\"note\":\"\",\"rejected\":false,\"rejection_reason\":null,\"confidence\":0.95}]}\nTXT;\n }\n\n private function buildDeepSeekPrompt(array $context = [], string $mime = ''): string\n {\n $projectName = (string) ($context['project_name'] ?? '');\n $projectStatus = (string) ($context['project_status'] ?? '');\n $contextLine = $projectName !== ''\n ? \"Project: {$projectName}\" . ($projectStatus !== '' ? \" ({$projectStatus})\" : '')\n : '';\n $sourceHint = str_starts_with($mime, 'image/')\n ? 'The input is an image of a BOQ table. OCR every cell carefully — read the quantity/count column (العدد, الكمية, Qty) and extract the EXACT numeric value from each row.'\n : 'The input is a BOQ document/spreadsheet.';\n\n return trim(<<<PROMPT\nYou are a procurement specialist. Your task is to extract ONLY tangible, physical supply products from this Bill of Quantities (BOQ).\n\n{$contextLine}\n{$sourceHint}\n\nCRITICAL — MERGE WRAPPED / SPLIT ROWS INTO ONE ITEM:\nBOQ tables extracted from PDF often break ONE item's long description across several\nlines/rows. These continuation lines repeat the SAME quantity, unit and category, and\ntheir text is a grammatical continuation of the previous line (it starts mid-sentence,\nlowercase, or with \"the/and/-\", or is a spec/instruction clause). You MUST reconstruct\nthe original single item:\n- Treat consecutive rows that share the SAME quantity + unit + category as ONE product\n when the later rows are clearly continuation text of the first, NOT new products.\n- Emit ONE item with the merged description and the price counted ONCE. Do NOT repeat\n the same quantity×price for every wrapped line — that multiplies the total wrongly.\n- Example: rows \"The asphalt flooring is to be supplied\" / \"installed in accordance\" /\n \"standard road specifications...\" all with 7,100 m2 are ONE asphalt item, not 3+.\n\nCRITICAL — UNDERSTAND INTENT, DO NOT COPY ROWS LITERALLY:\nYour job is to output WHAT A SUPPLIER MUST ACTUALLY DELIVER, not a copy of the row text.\nRead each description for MEANING and decide how many real, separately-buyable products it\nimplies. One BOQ row can imply SEVERAL products, or a spread of rows can imply ONE product.\n- SPLIT a row into multiple items when it lists distinct products that a supplier would\n buy/price separately — even when joined by commas, \"with\", \"and\", \"including\", \"c/w\",\n \"consisting of\", \"comprising\", or \":\" then a list.\n Example: \"Fire pump set consisting of: fire pump, electric motor, control panel, diesel\n engine\" → FOUR items: (1) fire pump, (2) electric motor, (3) control panel, (4) diesel\n engine — each with its own quantity and priced on its own.\n Example: \"Wash basin complete with mixer tap and bottle trap\" → THREE items: wash basin,\n mixer tap, bottle trap.\n- Give each split product the correct quantity implied by the text (usually the parent\n quantity, unless the text states a different count for a sub-item). Each product is\n priced individually; the line total becomes the SUM of its products.\n- DO NOT split when the extra words are just specifications, dimensions, colours, ratings,\n standards, or a single product's integral parts. \"Steel door 900x2100 with vision panel\n and SS ironmongery\" is usually ONE door product — vision panel and ironmongery are part\n of that door, not separately-bought items. Use judgement: would a supplier issue a\n separate price line for it? If no, keep it as one.\n- Balanced approach: split only when genuinely separate products are present; when in doubt\n about whether something is a separate product vs. an accessory/spec of the main one, keep\n it as a single item. Do not invent products that are not in the text.\n\nSTRICT RULES — include ONLY items that are ALL of the following:\n1. A specific, concrete, tangible product that can be boxed up by a supplier and delivered to site — something you can point at and say \"this exact thing is being bought\" (e.g. \"LED luminaire 36W\", \"PVC pipe 110mm\", \"Distribution board 12-way\", \"CO2 fire extinguisher 6kg\").\n2. Has a real quantity (not 0 or \"varies\" with no meaning).\n3. Has enough description to identify what the product is.\n4. Is an actual PRODUCT, not a sentence, instruction, or specification clause. REJECT rows that are prose such as \"the supervising engineer's instructions\", \"The price includes all materials, labor\", \"Measurement will be based on the geometric cubic meter\", \"necessary steps to complete the work\", \"soil-contacting elements\", \"in compliance\", \"everything necessary\". These are description fragments, not buyable items.\n\nNEVER extract abstract concepts, disciplines, trades, systems, or section/category names — they are NOT products even when they appear as their own row:\n- Bare discipline/trade/system words such as: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", and their Arabic equivalents (\"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\", \"تشطيبات\").\n- Test: if the word names a FIELD OF WORK or a SYSTEM rather than one buyable item with a unit (pcs, m, set, kg…), REJECT it.\n\nEXCLUDE all of the following (do NOT include them):\n- Labor, installation, or workmanship items (e.g. \"install\", \"fix\", \"erect\", \"dismantle\", \"painting\", \"cleaning service\")\n- Project management, overhead, or profit items\n- Insurance, bonds, liability items (e.g. \"bodily injury\", \"third party liability\", \"CAR insurance\")\n- Shop drawings, as-built drawings, method statements, documentation\n- Preliminary items, mobilisation, demobilisation, provisional sums\n- Housekeeping, site clearance, waste removal\n- Floor/level labels (e.g. \"GF Floor\", \"Level 3\") — these are headers, not products\n- Pure dimension specs without a product noun (e.g. \"Dia 32 mm\", \"25 mm\") — reject unless a product name is present\n- Security services, night shifts, rentals, payments\n- Any item whose description is too vague or fragmentary to identify a specific product\n\nEmit ONE JSON item PER real product. A single source row may therefore produce several\nitems (when it lists multiple products) or several rows may collapse into one item (when\nthey are wrapped fragments of the same product). The \"items\" array is your final delivery\nlist, not a row-by-row transcript.\n\nFor each valid physical product return JSON with:\n- description: clean product name only (strip \"supply and install\", \"supply and fix\", installation clauses, floor/spec suffixes). For a split product, use just that product's own name.\n- quantity: the EXACT numeric value from the quantity/count column (العدد, الكمية, Qty, Quantity, No.). NEVER default to 1 unless the quantity column is truly absent or empty.\n- unit: unit of measure (pcs, m, m2, m3, set, no, kg, etc.)\n- category: product category\n- brand: brand/manufacturer if mentioned, else \"\"\n- engineering_required: true if the item needs engineering design before procurement (panel boards, chillers, generators, fire suppression systems, BMS, pumps, AHUs, switchgear, transformers), else false\n\nReturn ONLY a valid JSON object: {\"items\": [...]}. No explanations, no markdown, no extra text.\nPROMPT);\n }\n\n private function mockResponse(): array\n {\n return [\n 'success' => true,\n 'error' => null,\n 'items' => [[\n 'description' => 'Sample BOQ Item',\n 'quantity' => 1,\n 'unit' => 'No',\n 'category' => 'Sample',\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 1,\n 'raw_data' => [],\n 'ai_extracted' => true,\n ]],\n 'rejected' => [],\n ];\n }\n\n private function failure(string $message, bool $serviceUnavailable = false): array\n {\n return [\n 'success' => false,\n 'items' => [],\n 'rejected' => [],\n 'error' => $this->userFacingError($message, $serviceUnavailable),\n 'service_unavailable' => $serviceUnavailable,\n ];\n }\n\n /**\n * Translate any internal error into a neutral, user-safe message.\n *\n * The user must never see the name of the underlying AI/processing engine\n * (DeepSeek, Groq, Gemini, Vision, etc.) or configuration hints like API\n * keys. The detailed message is kept in the logs for debugging only.\n */\n private function userFacingError(string $message, bool $serviceUnavailable = false): string\n {\n // Detect any reference to an AI provider / engine / API config so we can\n // swap it for a generic, branded-neutral message.\n $sensitive = '/deepseek|groq|gemini|vision|openai|openrouter|api[\\s_-]?key|\\.env|non-?json|token limit/i';\n\n if (preg_match($sensitive, $message)) {\n Log::warning('QuotationAiService: suppressed technical error from user.', ['internal' => $message]);\n\n return $serviceUnavailable\n ? 'The file could not be processed right now. Please try again in a moment.'\n : 'We could not read this file automatically. Please make sure it is a clear BOQ (Excel, CSV, PDF, or image) and try again, or add the items manually.';\n }\n\n return $message;\n }\n\n private function mimeForExtension(string $ext): string\n {\n return match (strtolower($ext)) {\n 'pdf' => 'application/pdf',\n 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n 'xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12',\n 'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',\n 'xls' => 'application/vnd.ms-excel',\n 'csv' => 'text/csv',\n 'jpg', 'jpeg' => 'image/jpeg',\n 'png' => 'image/png',\n 'gif' => 'image/gif',\n 'bmp' => 'image/bmp',\n 'webp' => 'image/webp',\n 'tiff', 'tif' => 'image/tiff',\n 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'doc' => 'application/msword',\n default => 'application/octet-stream',\n };\n }\n}\n"}, "af84adf334f292529f732fed3a4a1aee26b52c34": {"t": 1785748368.0307813, "n": 77633, "h": "3ce932cedc5cc4e5", "c": "<?php\n\nnamespace App\\Services;\n\nuse Illuminate\\Http\\Client\\ConnectionException;\nuse Illuminate\\Http\\UploadedFile;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\nuse PhpOffice\\PhpSpreadsheet\\Cell\\Coordinate;\nuse PhpOffice\\PhpSpreadsheet\\IOFactory;\n\nclass QuotationAiService\n{\n /** Increment this whenever BOQ parsing logic changes to invalidate old caches. */\n private const PARSER_VERSION = 10;\n private const LARGE_BOQ_VERIFICATION_THRESHOLD = 2000;\n private const LARGE_BOQ_VERIFICATION_CHUNK_SIZE = 120;\n\n /**\n * Characters of document text sent per AI call.\n *\n * Above this the document is split and parsed over several calls. Sized well\n * under the model's context so the prompt, the slice, and a full JSON answer\n * for every row in it all fit — the binding limit is the *output* budget, not\n * the input, since each input row produces a JSON object.\n */\n private const TEXT_CHUNK_CHARS = 60000;\n\n /**\n * A workbook with more sheets than this is split regardless of its size.\n *\n * Multiple tabs almost always mean multiple disciplines (electrical, HVAC,\n * civil…). Sending them in one call makes the model summarise across\n * sections and silently drop rows, so each slice gets a narrower scope.\n */\n private const MAX_SHEETS_PER_CALL = 2;\n\n /** A PDF longer than this is split regardless of its extracted text length. */\n private const MAX_PDF_PAGES_PER_CALL = 5;\n\n private string $baseUrl;\n private string $parseEndpoint;\n private string $apiKey;\n private int $timeout;\n private bool $testMode;\n private BoqCleaningService $boqCleaner;\n\n /** Page count of the most recently parsed PDF; 0 when unknown. */\n private int $lastPdfPageCount = 0;\n\n /**\n * Optional progress reporter for chunked parsing, called as ($part, $total).\n *\n * Set by the queue job so the polling UI can show real progress on a large\n * BOQ instead of a spinner that appears frozen for many minutes.\n *\n * @var (callable(int, int): void)|null\n */\n private $onChunkProgress = null;\n\n /** @param callable(int, int): void $callback */\n public function onChunkProgress(callable $callback): self\n {\n $this->onChunkProgress = $callback;\n return $this;\n }\n\n /**\n * Optional per-chunk item sink, called as ($items, $part, $total).\n *\n * Lets the caller persist each slice's rows the moment they arrive instead\n * of waiting for the whole document. On a large BOQ this is the difference\n * between a table that fills in progressively and one that stays empty for\n * many minutes.\n *\n * @var (callable(array, int, int): void)|null\n */\n private $onChunkItems = null;\n\n /** @param callable(array, int, int): void $callback */\n public function onChunkItems(callable $callback): self\n {\n $this->onChunkItems = $callback;\n return $this;\n }\n\n /**\n * Report a non-chunk stage (local parse, handing off to AI) to the caller.\n *\n * A spreadsheet is read locally first and only sent to the AI when that\n * fails, so a large file can spend minutes before any chunk exists. Without\n * these the UI shows a generic spinner for that whole period.\n */\n private function reportStage(string $message): void\n {\n if ($this->onStage !== null) {\n ($this->onStage)($message);\n }\n }\n\n /**\n * Report a workbook's shape from grids that are already in memory.\n *\n * Deliberately takes the parsed grids rather than a path: re-reading a large\n * spreadsheet just to count its sheets would double the slowest step of the\n * whole extraction.\n *\n * @param array<int, array{name: string, grid: array}> $sheets\n */\n private function reportWorkbookShape(array $sheets): void\n {\n if ($this->onStage === null || empty($sheets)) {\n return;\n }\n\n $rows = 0;\n foreach ($sheets as $sheet) {\n $rows += count($sheet['grid']);\n }\n\n $count = count($sheets);\n\n $this->reportStage($count > 1\n ? \"Found {$count} sheets, {$rows} rows in total. Reading…\"\n : \"Found {$rows} rows. Reading…\");\n }\n\n /** @var (callable(string): void)|null */\n private $onStage = null;\n\n /** @param callable(string): void $callback */\n public function onStage(callable $callback): self\n {\n $this->onStage = $callback;\n return $this;\n }\n\n public function __construct(BoqCleaningService $boqCleaner)\n {\n $this->boqCleaner = $boqCleaner;\n $this->baseUrl = rtrim((string) config('services.ai_quotation.base_url', ''), '/');\n $this->parseEndpoint = ltrim((string) config('services.ai_quotation.parse_endpoint', 'parse'), '/');\n $this->apiKey = (string) config('services.ai_quotation.api_key', '');\n $this->timeout = (int) config('services.ai_quotation.timeout', 300);\n $this->testMode = (bool) config('services.ai_quotation.test_mode', false);\n }\n\n /**\n * Give the running script enough head-room to outlast the AI HTTP call.\n *\n * The web request / queue worker must not be killed before the HTTP timeout\n * completes, otherwise the user sees a generic \"timeout or unavailable\"\n * error even though the AI may have responded. We allow the HTTP timeout\n * plus a 120s margin for connecting, response parsing and DB writes.\n */\n private function extendExecutionTime(): void\n {\n // Ignored on some SAPIs (e.g. when running under fastcgi with a hard\n // limit), but harmless to attempt.\n @set_time_limit($this->timeout + 120);\n }\n\n public function parseBoq(UploadedFile|string $file, array $context = []): array\n {\n if ($this->testMode) {\n return $this->mockResponse();\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n\n if (! is_file($absPath)) {\n return $this->failure('File not found or not readable.');\n }\n\n $fileHash = hash_file('sha256', $absPath);\n // Bump PARSER_VERSION whenever the extraction logic changes so that\n // previously-uploaded files are re-parsed instead of returning a stale\n // cached result produced by the old code.\n $cacheKey = 'boq_analysis_v' . self::PARSER_VERSION . '_' . $fileHash;\n $forceRefresh = (bool) ($context['force_refresh'] ?? false);\n\n if (! $forceRefresh && ($cached = Cache::get($cacheKey)) !== null) {\n return $cached;\n }\n\n // Clear stale cached result when force-refreshing\n if ($forceRefresh) {\n Cache::forget($cacheKey);\n }\n\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n // Read Excel locally first. Fall back to AI if local parse failed,\n // found 0 items, OR could not read one of the sheets (so multi-sheet\n // files never silently drop a sheet the local parser didn't understand).\n $this->reportStage('Reading the spreadsheet…');\n\n $result = $this->parseSpreadsheetDirect($absPath);\n\n $hasSkippedSheets = ! empty($result['skipped_sheets']);\n\n if (! $result['success'] || empty($result['items']) || $hasSkippedSheets) {\n // The local pass could not handle this file on its own. Say so:\n // otherwise the UI sits on a generic spinner through the local\n // parse AND the whole AI parse with nothing to show for either.\n $this->reportStage($hasSkippedSheets\n ? 'Some sheets need AI to read. Sending to AI…'\n : 'Spreadsheet needs AI to read. Sending to AI…');\n\n $deepSeekResult = $this->parseBoqWithDeepSeek($file, $context);\n // AI sees every sheet at once, so prefer its result whenever it\n // actually returned items (covers the skipped-sheet case too).\n if ($deepSeekResult['success'] && ! empty($deepSeekResult['items'])) {\n // Keep the larger of the two item sets so we never lose the\n // rows the local parser already extracted correctly.\n if (count($deepSeekResult['items']) >= count($result['items'] ?? [])) {\n $result = $deepSeekResult;\n }\n } elseif (! $result['success']) {\n $result = $deepSeekResult;\n }\n // else: keep local result if AI also failed/returned nothing\n }\n } elseif (in_array($ext, ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif'], true)) {\n $result = $this->parseBoqWithDeepSeek($file, $context);\n } else {\n return $this->failure('Unsupported file type. Please upload Excel, CSV, PDF, or image file.');\n }\n\n if ($result['success'] && ! empty($result['items'])) {\n $cleaning = $this->boqCleaner->process($result['items']);\n $result['items'] = $cleaning['accepted'] ?? $result['items'];\n $result['rejected'] = array_merge($result['rejected'] ?? [], $cleaning['rejected'] ?? []);\n }\n\n if ($result['success'] && count($result['items'] ?? []) > self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n $result = $this->verifyLargeBoqItemsWithAi($result);\n }\n\n // Only cache when we actually extracted items; empty results should be re-tried.\n if ($result['success'] && ! empty($result['items'])) {\n Cache::put($cacheKey, $result, now()->addDays(30));\n }\n\n return $result;\n }\n\n private function resolveFile(UploadedFile|string $file): array\n {\n if ($file instanceof UploadedFile) {\n return [(string) $file->getRealPath(), strtolower($file->getClientOriginalExtension())];\n }\n\n $absPath = is_file($file) ? $file : storage_path('app/' . ltrim($file, '/'));\n return [$absPath, strtolower(pathinfo($absPath, PATHINFO_EXTENSION))];\n }\n\n private function parseSpreadsheetDirect(string $absPath): array\n {\n ini_set('memory_limit', '1024M');\n set_time_limit(180);\n\n try {\n $sheets = $this->spreadsheetToGrids($absPath);\n\n if (empty($sheets)) {\n return $this->failure('Could not read the Excel file.');\n }\n\n $this->reportWorkbookShape($sheets);\n\n $items = [];\n $rejected = [];\n $skippedSheets = []; // sheets we could not parse locally (header undetected)\n\n foreach ($sheets as $sheet) {\n $grid = $sheet['grid'];\n if (count($grid) < 2) {\n continue;\n }\n\n // Skip dedicated summary / cost-abstract sheets entirely. Their rows\n // are per-discipline totals (Structural Concrete, Electrical, …) and\n // grand totals, not procurable products.\n if ($this->isSummarySheet($sheet['name'])) {\n Log::info('QuotationAiService: skipped summary sheet.', ['sheet' => $sheet['name']]);\n continue;\n }\n\n $header = $this->detectHeader($grid);\n\n if ($header === null || ! isset($header['map']['description'])) {\n Log::warning('QuotationAiService: header not detected in sheet.', ['sheet' => $sheet['name']]);\n $skippedSheets[] = $sheet['name'];\n continue;\n }\n\n $map = $header['map'];\n $start = $header['row'] + 1;\n\n for ($r = $start; $r < count($grid); $r++) {\n $row = $grid[$r];\n $description = $this->cell($row, $map['description'] ?? null);\n $itemNo = $this->cell($row, $map['item_code'] ?? null);\n $unit = $this->cell($row, $map['unit'] ?? null);\n $quantity = $this->number($this->cell($row, $map['quantity'] ?? null));\n $unitPrice = $this->number($this->cell($row, $map['unit_price'] ?? null));\n $totalPrice = $this->number($this->cell($row, $map['total_price'] ?? null));\n\n if ($description === '') {\n continue;\n }\n\n if (! isset($map['quantity'])) {\n $quantity = 1.0;\n }\n\n // Skip floor breakdown sub-rows: Ref. is \"-\" meaning it's a per-floor\n // quantity split of the parent item, not a standalone product.\n if ($itemNo === '-' || $this->isFloorBreakdownRow($itemNo, $description)) {\n continue;\n }\n\n if ($this->isTotalOrHeaderRow($description)) {\n continue;\n }\n\n if ($quantity === null || $quantity <= 0) {\n $rejected[] = $this->rejectedRow($description, $unit, $quantity, 'Missing quantity', $itemNo, $sheet['name']);\n continue;\n }\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $supply = $this->boqCleaner->filterItem($description, $unitPrice, $quantity);\n\n if (! ($supply['keep'] ?? false)) {\n $rejected[] = $this->rejectedRow(\n $description,\n $unit,\n $quantity,\n (string) ($supply['rejection_reason'] ?? 'Rejected by cleaner'),\n $itemNo,\n $sheet['name']\n );\n continue;\n }\n\n $finalDescription = (string) ($supply['description'] ?? $description);\n\n $items[] = [\n 'description' => $finalDescription,\n 'quantity' => $quantity,\n 'unit' => $unit,\n 'category' => $sheet['name'],\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($finalDescription),\n 'unit_price' => $unitPrice,\n 'confidence' => 0.95,\n 'needs_review' => false,\n 'raw_data' => [\n 'sheet' => $sheet['name'],\n 'row_number' => $r + 1,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'cleaned_description' => $finalDescription,\n 'extraction_type' => (string) ($supply['extraction_type'] ?? 'supply_only'),\n ],\n 'ai_extracted' => true,\n ];\n }\n }\n\n if (empty($items)) {\n return $this->failure('No BOQ supply items could be extracted from the Excel file.');\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'skipped_sheets' => $skippedSheets,\n ];\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: parseSpreadsheetDirect failed.', [\n 'path' => $absPath,\n 'message' => $e->getMessage(),\n 'trace' => $e->getTraceAsString(),\n ]);\n\n return $this->failure('Failed to read the Excel file: ' . $e->getMessage());\n }\n }\n\n private function spreadsheetToGrids(string $absPath): array\n {\n $ext = strtolower(pathinfo($absPath, PATHINFO_EXTENSION));\n\n if ($ext === 'csv') {\n $rows = [];\n $handle = fopen($absPath, 'rb');\n if ($handle !== false) {\n while (($row = fgetcsv($handle)) !== false) {\n $rows[] = $this->trimTrailingEmptyCells($row);\n }\n fclose($handle);\n }\n return [['name' => 'Sheet1', 'grid' => $rows]];\n }\n\n $reader = IOFactory::createReader($ext === 'xls' ? 'Xls' : 'Xlsx');\n $reader->setReadDataOnly(true);\n $spreadsheet = $reader->load($absPath);\n $sheets = [];\n\n foreach ($spreadsheet->getAllSheets() as $sheet) {\n $highestRow = $sheet->getHighestDataRow();\n $highestColumn = $sheet->getHighestDataColumn();\n $highestColumnIndex = Coordinate::columnIndexFromString($highestColumn);\n\n // BOQ data is normally in first 8-20 columns.\n // Files with heavy formatting/images can report 300+ columns — limit to 30.\n $maxColumnIndex = min($highestColumnIndex, 30);\n\n $grid = [];\n for ($row = 1; $row <= $highestRow; $row++) {\n $rowData = [];\n $hasValue = false;\n\n for ($col = 1; $col <= $maxColumnIndex; $col++) {\n $value = $sheet->getCell([$col, $row])->getValue();\n $value = $this->cellValueToString($value);\n $value = trim($value);\n if ($value !== null && $value !== '') {\n $hasValue = true;\n }\n $rowData[] = $value;\n }\n\n $rowData = $this->trimTrailingEmptyCells($rowData);\n if ($hasValue) {\n $grid[] = $rowData;\n }\n }\n\n if (! empty($grid)) {\n $sheets[] = ['name' => $sheet->getTitle(), 'grid' => $grid];\n }\n }\n\n $spreadsheet->disconnectWorksheets();\n unset($spreadsheet);\n\n return $sheets;\n }\n\n private function detectHeader(array $grid): ?array\n {\n $keywords = [\n 'item_code' => ['رقم البند', 'item no', 'item number', 'ref.', 'ref', 'item', 'no.', 'no', '#', 'كود'],\n 'description' => ['وص٠البند', 'scope of works', 'scope of work', 'scope', 'item description', 'description of works', 'description', 'بالعربية', 'الوصÙ', 'البند', 'بيان'],\n 'unit' => ['Ø§Ù„ÙˆØØ¯Ø©', 'unit', 'uom', 'u/m'],\n 'quantity' => ['الكمية', 'qty', 'quantity', 'q.ty'],\n 'unit_price' => ['سعر Ø§Ù„ÙˆØØ¯Ø©', 'u/price', 'unit price', 'unit rate', 'rate', 'price'],\n 'total_price' => ['السعر الإجمالي', 'total amount', 'total price', 'amount', 'الإجمالي'],\n ];\n\n $best = null;\n $bestScore = 0;\n\n for ($r = 0; $r < min(30, count($grid)); $r++) {\n $map = [];\n $score = 0;\n\n foreach ($grid[$r] as $c => $value) {\n $cell = mb_strtolower(trim((string) $value));\n if ($cell === '') {\n continue;\n }\n\n foreach ($keywords as $field => $list) {\n foreach ($list as $kw) {\n if (str_contains($cell, mb_strtolower($kw))) {\n if (! isset($map[$field])) {\n $map[$field] = $c;\n $score++;\n }\n break 2;\n }\n }\n }\n }\n\n if ($score > $bestScore) {\n $bestScore = $score;\n $best = ['row' => $r, 'map' => $map];\n }\n }\n\n if ($bestScore >= 3) {\n return $best;\n }\n\n // Allow simpler BOQ sheets that only define a description column plus one\n // other BOQ-related column, e.g. Item + Description or Description + Qty.\n if ($bestScore === 2 && isset($best['map']['description']) && (\n isset($best['map']['item_code']) ||\n isset($best['map']['quantity']) ||\n isset($best['map']['unit'])\n )) {\n return $best;\n }\n\n return null;\n }\n\n private function cell(array $row, ?int $index): string\n {\n if ($index === null) {\n return '';\n }\n return trim((string) ($row[$index] ?? ''));\n }\n\n private function number(string $value): ?float\n {\n $value = trim(str_replace([',', 'SAR', 'ر.س'], '', $value));\n if ($value === '' || ! is_numeric($value)) {\n return null;\n }\n return (float) $value;\n }\n\n private function trimTrailingEmptyCells(array $row): array\n {\n while (! empty($row) && trim((string) end($row)) === '') {\n array_pop($row);\n }\n return array_values($row);\n }\n\n private function isTotalOrHeaderRow(string $description): bool\n {\n $d = mb_strtolower(trim($description));\n\n if ($d === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $d)) {\n return true;\n }\n\n // Section totals / summaries\n if (preg_match('/(اجمالي|إجمالي|المجموع|grand\\s*total|sub\\s*total|total\\s*amount|total\\s*price)/iu', $d)) {\n return true;\n }\n\n // Bare \"Total\" lines, optionally with a currency suffix, e.g. \"Total\",\n // \"Total (SAR)\", \"Total cost\", \"Total (SAR) unit cost\".\n if (preg_match('/^total\\b[\\s\\(\\):a-z]*$/iu', $d)) {\n return true;\n }\n\n // Pure floor / level labels used as section headers\n if (preg_match('/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor)\\s*$/iu', $d)) {\n return true;\n }\n\n // Division / section header lines (e.g. \"Division - 02: Wood Works\")\n if (preg_match('/^division\\s*([-:]|\\d)/iu', $d)) {\n return true;\n }\n\n return false;\n }\n\n /**\n * Detect dedicated summary / cost-abstract sheets by their name. These hold\n * per-discipline rollups and grand totals (e.g. a \"Summary (Dry Cost)\" tab),\n * never procurable supply items, so they are skipped wholesale.\n */\n private function isSummarySheet(string $name): bool\n {\n $n = mb_strtolower(trim($name));\n\n if ($n === '') {\n return false;\n }\n\n return (bool) preg_match(\n '/\\b(summary|abstract|cost\\s*summary|grand\\s*summary|recap(?:itulation)?|bill\\s*summary|boq\\s*summary|dry\\s*cost)\\b|ملخص|الملخص|الملخّص|ملخّص|اجمالي|إجمالي/iu',\n $n\n );\n }\n\n private function isFloorBreakdownRow(string $itemNo, string $description): bool\n {\n // A row whose Ref column is empty or a dash is a sub-breakdown row (per-floor split).\n $ref = trim($itemNo);\n if ($ref === '' || $ref === '-') {\n // Confirm by checking the description is a location/floor label\n $d = mb_strtolower(trim($description));\n return (bool) preg_match(\n '/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor|\\d+f)\\s*$/iu',\n $d\n );\n }\n return false;\n }\n\n private function rejectedRow(string $description, string $unit, ?float $quantity, string $reason, string $itemNo = '', string $sheet = ''): array\n {\n return [\n 'description' => $description,\n 'quantity' => $quantity ?? 0,\n 'unit' => $unit,\n 'category' => $sheet,\n 'brand' => '',\n 'status' => 'rejected',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 0.0,\n 'raw_data' => [\n 'sheet' => $sheet,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'extraction_type' => 'rejected',\n 'rejection_reason' => $reason,\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function parseBoqWithDeepSeek(UploadedFile|string $file, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured. Please set DEEPSEEK_API_KEY in .env file.');\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n $mime = $this->mimeForExtension($ext);\n\n try {\n // ── Spreadsheets ──────────────────────────────────────────────────\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n $text = $this->spreadsheetToCompactCsvText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not convert spreadsheet to readable text.');\n }\n\n $text = $this->sanitizeUtf8($text);\n\n // A large BOQ exceeds what one request can carry, so send it in\n // sequential slices and merge. Anything that fits goes as-is.\n //\n // Size alone is not a good enough signal: a workbook with several\n // sheets is structurally a big BOQ even when the text is compact,\n // and asking the model to hold every discipline at once is where\n // rows start getting dropped. So split on either signal.\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS || $sheetCount > self::MAX_SHEETS_PER_CALL) {\n Log::info('QuotationAiService: spreadsheet routed to chunked parsing.', [\n 'chars' => mb_strlen($text),\n 'sheets' => $sheetCount,\n ]);\n return $this->parseTextInChunks($text, 'BOQ spreadsheet converted to CSV', $apiKey, $context);\n }\n\n $userContent = \"BOQ spreadsheet converted to CSV:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── PDF ───────────────────────────────────────────────────────────\n if ($ext === 'pdf') {\n $extracted = $this->extractPdfText($absPath);\n if ($extracted !== null && mb_strlen(trim($extracted)) > 50) {\n $extracted = $this->sanitizeUtf8($extracted);\n\n // Split on either signal — long text, or many pages. A BOQ\n // spanning several pages is a multi-section document even if\n // each page is sparse.\n if (mb_strlen($extracted) > self::TEXT_CHUNK_CHARS\n || $this->lastPdfPageCount > self::MAX_PDF_PAGES_PER_CALL) {\n Log::info('QuotationAiService: PDF routed to chunked parsing.', [\n 'chars' => mb_strlen($extracted),\n 'pages' => $this->lastPdfPageCount,\n ]);\n return $this->parseTextInChunks($extracted, 'BOQ PDF extracted text', $apiKey, $context, 'application/pdf');\n }\n\n $userContent = \"BOQ PDF extracted text:\\n\\n\" . $extracted . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'application/pdf');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n return $this->failure('Could not extract text from the PDF. Please make sure it is a text-based PDF (not a scanned image), or convert it to Excel or CSV.');\n }\n\n // -- Word documents (docx) ---------------------------------------------------\n if (in_array($ext, ['docx', 'doc'], true)) {\n $text = $this->extractDocxText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not extract text from the Word document. Please convert it to Excel, CSV, or PDF.');\n }\n $text = $this->sanitizeUtf8($text);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS) {\n return $this->parseTextInChunks($text, 'BOQ Word document extracted text', $apiKey, $context);\n }\n\n $userContent = \"BOQ Word document extracted text:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── Images ──────────────────────────────────────────────────────────────\n // DeepSeek cannot receive images. A vision-capable AI processes the image\n // directly (no OCR) — configure GROQ_API_KEY or GEMINI_API_KEY in .env.\n $imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif', 'heic', 'heif'];\n if (in_array($ext, $imageExts, true)) {\n $bytes = (string) file_get_contents($absPath);\n return $this->callDeepSeekVision($bytes, $mime, $context);\n }\n\n return $this->failure('Unsupported file type. Please upload an Excel, CSV, PDF, or image file.');\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: DeepSeek fallback failed.', ['message' => $e->getMessage()]);\n return $this->failure('An error occurred while processing the file with AI: ' . $e->getMessage());\n }\n }\n\n private function deepSeekModel(): string\n {\n return (string) config('services.deepseek.model', 'deepseek-chat');\n }\n\n /**\n * Remove or replace any non-UTF-8 / malformed byte sequences so json_encode never fails.\n */\n private function sanitizeUtf8(string $text): string\n {\n // Replace invalid byte sequences with the UTF-8 replacement character\n $clean = mb_convert_encoding($text, 'UTF-8', 'UTF-8');\n // Strip any remaining non-printable control characters (except tab/newline/CR)\n $clean = (string) preg_replace('/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]/u', '', $clean);\n return $clean;\n }\n\n /**\n * Extract plain text from a Word docx file using ZipArchive (no external library needed).\n * Reads word/document.xml and strips XML tags, preserving paragraph and table structure.\n */\n private function extractDocxText(string $absPath): ?string\n {\n try {\n $zip = new \\ZipArchive();\n if ($zip->open($absPath) !== true) {\n return null;\n }\n\n $xml = $zip->getFromName('word/document.xml');\n $zip->close();\n\n if ($xml === false || $xml === '') {\n return null;\n }\n\n // Add newlines before paragraph/table-row/table-cell tags so structure is preserved\n $xml = preg_replace('/<w:p[ >]/', \"\\n<w:p>\", $xml) ?? $xml;\n $xml = preg_replace('/<w:tr[ >]/', \"\\n<w:tr>\", $xml) ?? $xml;\n $xml = preg_replace('/<\\/w:tc>/', \"\\t\", $xml) ?? $xml;\n\n $text = strip_tags($xml);\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n return trim($text) !== '' ? trim($text) : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: DOCX parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Extract plain text from a PDF using smalot/pdfparser (pure PHP, no binary required).\n */\n private function extractPdfText(string $absPath): ?string\n {\n try {\n $parser = new \\Smalot\\PdfParser\\Parser();\n $pdf = $parser->parseFile($absPath);\n\n // Recorded for the chunking decision: a long PDF is structurally a\n // big BOQ even when its extracted text is short.\n try {\n $this->lastPdfPageCount = count($pdf->getPages());\n } catch (\\Throwable) {\n $this->lastPdfPageCount = 0;\n }\n\n $text = $pdf->getText();\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n return $text !== '' ? $text : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: PDF parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Send a vision request to a vision-capable AI model.\n * DeepSeek's commercial API (api.deepseek.com) does NOT support image inputs — any model.\n * Priority order (first configured key wins):\n * 1. GROQ_API_KEY → Groq llama-4-scout (free: https://console.groq.com)\n * 2. GEMINI_API_KEY → Google Gemini Flash (free: https://aistudio.google.com/apikey)\n * 3. VISION_API_KEY → any OpenAI-compatible endpoint (OpenAI, OpenRouter …)\n */\n private function callDeepSeekVision(string $bytes, string $mime, array $context): array\n {\n // ── 1. Groq (free, reliable vision) ─────────────────────────────────────\n if ((string) config('services.groq.key', '') !== '') {\n $visionKey = (string) config('services.groq.key');\n $visionBaseUrl = 'https://api.groq.com/openai/v1';\n $visionModel = (string) config('services.groq.model', 'meta-llama/llama-4-scout-17b-16e-instruct');\n }\n // ── 2. Google Gemini ─────────────────────────────────────────────────────\n elseif ((string) config('services.gemini.key', '') !== '') {\n $visionKey = (string) config('services.gemini.key');\n $visionBaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai';\n $visionModel = (string) config('services.gemini.model', 'gemini-2.0-flash');\n }\n // ── 3. Generic VISION_API_KEY (OpenAI, OpenRouter, …) ───────────────────\n elseif ((string) config('services.vision.key', '') !== '') {\n $visionKey = (string) config('services.vision.key');\n $visionBaseUrl = rtrim((string) config('services.vision.base_url', 'https://openrouter.ai/api/v1'), '/');\n $visionModel = (string) config('services.vision.model', 'google/gemini-flash-1.5-8b');\n }\n else {\n return $this->failure(\n 'Image processing is not available. DeepSeek\\'s API does not support images. ' .\n 'Get a free key at https://console.groq.com and add GROQ_API_KEY to your .env file.'\n );\n }\n\n $dataUrl = \"data:{$mime};base64,\" . base64_encode($bytes);\n $prompt = $this->buildDeepSeekPrompt($context, $mime);\n\n $userContent = [\n ['type' => 'text', 'text' => $prompt],\n ['type' => 'image_url', 'image_url' => ['url' => $dataUrl]],\n ];\n\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders([\n 'Authorization' => 'Bearer ' . $visionKey,\n 'HTTP-Referer' => config('app.url', 'https://qimta.sa'),\n 'X-Title' => 'Qimta Platform',\n ])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post($visionBaseUrl . '/chat/completions', [\n 'model' => $visionModel,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 16384,\n 'temperature' => 0.2,\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('Vision API connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: Vision API HTTP error.', [\n 'model' => $visionModel,\n 'base_url' => $visionBaseUrl,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n return $this->failure(\"Vision API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n return $this->parseDeepSeekJsonResponse((string) $response->json('choices.0.message.content'));\n }\n\n private function callDeepSeekChat(string $userContent, string $key, string $model): array\n {\n // Ensure the PHP script outlives the HTTP call (+ a margin for parsing the\n // response and writing to the DB) so the worker/web request is not killed\n // mid-flight, which surfaces to users as a generic \"timeout or unavailable\".\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders(['Authorization' => 'Bearer ' . $key])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 32768,\n 'temperature' => 0.1,\n 'response_format' => ['type' => 'json_object'],\n 'user' => 'Qimta_Platform',\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('DeepSeek connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: DeepSeek HTTP error.', [\n 'model' => $model,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n if ($response->status() === 401) {\n return $this->failure('DeepSeek API key is invalid or unauthorised. Please update DEEPSEEK_API_KEY in .env.', false);\n }\n return $this->failure(\"DeepSeek API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n $text = (string) $response->json('choices.0.message.content');\n return $this->parseDeepSeekJsonResponse($text);\n }\n\n /**\n * Parse a document too large for one AI call by sending it in slices.\n *\n * A 20 000-row BOQ is several megabytes of text — far beyond what a single\n * request can carry, and beyond what the model can answer without hitting its\n * output token limit. Previously the text was simply cut at 180 000 chars and\n * the remainder discarded: the parse \"succeeded\" while silently dropping most\n * of the file. Now the text is split and each slice parsed on its own call,\n * with the items merged.\n *\n * Splitting happens on line boundaries so a BOQ row is never cut in half.\n * Slices run sequentially rather than pooled: DeepSeek rate-limits hard on\n * concurrent large prompts, and this already runs on the queue where wall\n * time is cheap.\n *\n * A slice that fails does not fail the document — its rows are lost, but the\n * rest are kept and the shortfall is logged. Total failure is reported only\n * when every slice fails.\n *\n * @param string $label How the payload is introduced to the model.\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n private function parseTextInChunks(\n string $text,\n string $label,\n string $apiKey,\n array $context,\n string $mime = 'text/plain',\n ): array {\n $chunks = $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n $total = count($chunks);\n\n Log::info('QuotationAiService: large document split for parsing.', [\n 'chars' => mb_strlen($text),\n 'chunks' => $total,\n ]);\n\n // Announce the split before the first slice is sent, so the UI can show\n // how many parts there are instead of sitting on a generic message for\n // however long the first AI call takes.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)(0, $total);\n }\n\n $items = [];\n $rejected = [];\n $failed = 0;\n $lastError = null;\n\n foreach ($chunks as $index => $chunk) {\n $part = $index + 1;\n\n // Report progress so a 28-slice parse does not look frozen to the user.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)($part, $total);\n }\n\n // Tell the model this is a fragment, so it does not try to reconcile\n // totals or treat a mid-document slice as a complete BOQ.\n $userContent = \"{$label} (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, $mime);\n\n $result = $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n\n if (! ($result['success'] ?? false)) {\n $failed++;\n $lastError = $result['error'] ?? 'Chunk parsing failed.';\n Log::warning('QuotationAiService: chunk failed, continuing.', [\n 'part' => $part,\n 'of' => $total,\n 'error' => $lastError,\n ]);\n continue;\n }\n\n foreach ($result['items'] as $item) {\n $items[] = $item;\n }\n foreach ($result['rejected'] ?? [] as $row) {\n $rejected[] = $row;\n }\n\n // Hand this slice's rows straight to the caller so they can be shown\n // while the remaining slices are still being parsed.\n if ($this->onChunkItems !== null && $result['items'] !== []) {\n ($this->onChunkItems)($result['items'], $part, $total);\n }\n }\n\n // Every slice failed — there is nothing to show, so report the failure.\n if ($failed === $total) {\n return $this->failure($lastError ?? 'Could not parse the document.');\n }\n\n if ($failed > 0) {\n Log::error('QuotationAiService: document parsed with missing parts.', [\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n 'items_kept' => count($items),\n ]);\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'service_unavailable' => false,\n 'partial' => $failed > 0,\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n ];\n }\n\n /**\n * Split text into slices of at most $limit characters, breaking only at\n * newlines so no BOQ row is ever cut in half.\n *\n * A single line longer than the limit (rare — a pathological cell) is passed\n * through oversized rather than severed mid-row.\n *\n * @return array<int, string>\n */\n private function splitOnLineBoundaries(string $text, int $limit): array\n {\n $chunks = [];\n $current = '';\n\n // Sheet a chunk starts in the middle of. Repeated at the top of the next\n // chunk so the model always knows which sheet the rows belong to — the\n // header would otherwise be stranded in the previous slice, and rows\n // would arrive with no discipline context.\n $currentSheet = null;\n\n foreach (explode(\"\\n\", $text) as $line) {\n $isSheetHeader = str_starts_with($line, 'Sheet: ');\n\n // Start a new chunk at every sheet boundary. Sheets are usually\n // separate disciplines, and the whole point of splitting a multi-tab\n // workbook is to give the model one discipline at a time — a size-only\n // split would leave a compact 3-tab file as a single chunk, silently\n // undoing the sheet gate that routed it here.\n if ($isSheetHeader && $current !== '') {\n $chunks[] = $current;\n $current = $line;\n $currentSheet = $line;\n continue;\n }\n\n if ($isSheetHeader) {\n $currentSheet = $line;\n }\n\n $candidate = $current === '' ? $line : $current . \"\\n\" . $line;\n\n if (mb_strlen($candidate) > $limit && $current !== '') {\n $chunks[] = $current;\n\n // Carry the sheet header over, unless this line is itself one.\n $current = ($currentSheet !== null && ! str_starts_with($line, 'Sheet: '))\n ? $currentSheet . \" (continued)\\n\" . $line\n : $line;\n continue;\n }\n\n $current = $candidate;\n }\n\n if (trim($current) !== '') {\n $chunks[] = $current;\n }\n\n return $chunks;\n }\n\n /**\n * Shared JSON response parser used by both chat and vision paths.\n */\n private function parseDeepSeekJsonResponse(string $text): array\n {\n if ($text === '') {\n return $this->failure('DeepSeek returned an empty response.');\n }\n\n // Strip markdown code fences if the model wrapped the JSON.\n if (preg_match('/```(?:json)?\\s*([\\s\\S]*?)\\s*```/i', $text, $m)) {\n $text = $m[1];\n }\n // Narrow to the outermost JSON object/array.\n if (preg_match('/[\\{\\[][\\s\\S]*[\\}\\]]/s', $text, $m)) {\n $text = $m[0];\n }\n $text = trim($text);\n\n $decoded = json_decode($text, true);\n\n // The response can be truncated when the model hits the token limit,\n // leaving an unterminated JSON string/object. Try to repair it before\n // giving up so we can still salvage the items extracted so far.\n if (! is_array($decoded)) {\n $repaired = $this->repairTruncatedJson($text);\n if ($repaired !== null) {\n $decoded = json_decode($repaired, true);\n }\n }\n\n if (! is_array($decoded)) {\n Log::error('QuotationAiService: DeepSeek non-JSON response.', ['text' => mb_substr($text, 0, 1000)]);\n return $this->failure('DeepSeek returned a non-JSON response. The file may be too large — try splitting it into smaller files.');\n }\n\n $rawItems = $decoded['items'] ?? (array_is_list($decoded) ? $decoded : []);\n $items = [];\n $rejected = [];\n\n foreach ($rawItems as $raw) {\n if (! is_array($raw)) {\n continue;\n }\n $item = $this->normaliseAiItem($raw);\n if ($item === null) {\n continue;\n }\n $check = $this->boqCleaner->filterItem((string) $item['description']);\n if ($check['keep'] ?? false) {\n $items[] = $item;\n } else {\n $item['status'] = 'rejected';\n $item['raw_data']['rejection_reason'] = $check['rejection_reason'] ?? 'Rejected by cleaner';\n $rejected[] = $item;\n }\n }\n\n if (empty($items)) {\n Log::info('QuotationAiService: DeepSeek returned 0 accepted items.', [\n 'raw_items_count' => count($rawItems),\n 'rejected_count' => count($rejected),\n 'first_rejected' => ! empty($rejected) ? ($rejected[0]['description'] ?? '') : '',\n ]);\n }\n\n return ['success' => true, 'items' => $items, 'rejected' => $rejected, 'error' => null];\n }\n\n /**\n * Best-effort repair of a truncated JSON string produced when the model hits\n * its token limit mid-output. Drops any incomplete trailing object, then\n * closes the open string/array/object brackets so the result is valid JSON.\n *\n * Returns the repaired JSON string, or null if it cannot be salvaged.\n */\n private function repairTruncatedJson(string $text): ?string\n {\n if ($text === '') {\n return null;\n }\n\n // Walk the string tracking structural state, ignoring everything inside\n // string literals (and their escapes). Remember the last position where\n // we were back at the top of the \"items\" array between complete elements,\n // so we can cut a half-written trailing object.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($text);\n $lastSafeCut = null; // index just after a completed array element\n\n for ($i = 0; $i < $len; $i++) {\n $ch = $text[$i];\n\n if ($inString) {\n if ($escaped) {\n $escaped = false;\n } elseif ($ch === '\\\\') {\n $escaped = true;\n } elseif ($ch === '\"') {\n $inString = false;\n }\n continue;\n }\n\n if ($ch === '\"') {\n $inString = true;\n } elseif ($ch === '{' || $ch === '[') {\n $stack[] = $ch;\n } elseif ($ch === '}' || $ch === ']') {\n array_pop($stack);\n } elseif ($ch === ',' && ! empty($stack) && end($stack) === '[') {\n // Separator directly inside an array: a clean boundary between\n // elements (the items array, whatever its nesting depth).\n $lastSafeCut = $i;\n }\n }\n\n // If we are still inside a string, the last element is incomplete — fall\n // back to the last clean array boundary if we have one.\n $candidate = $text;\n if ($inString && $lastSafeCut !== null) {\n $candidate = substr($text, 0, $lastSafeCut);\n } else {\n // Drop a dangling partial object after the last completed element.\n $candidate = rtrim($candidate);\n if ($lastSafeCut !== null && substr(rtrim($candidate), -1) !== '}' && substr(rtrim($candidate), -1) !== ']') {\n $candidate = substr($text, 0, $lastSafeCut);\n }\n }\n\n // Recompute the open brackets for the (possibly trimmed) candidate and\n // append the matching closers in reverse order.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($candidate);\n for ($i = 0; $i < $len; $i++) {\n $ch = $candidate[$i];\n if ($inString) {\n if ($escaped) { $escaped = false; }\n elseif ($ch === '\\\\') { $escaped = true; }\n elseif ($ch === '\"') { $inString = false; }\n continue;\n }\n if ($ch === '\"') { $inString = true; }\n elseif ($ch === '{' || $ch === '[') { $stack[] = $ch; }\n elseif ($ch === '}' || $ch === ']') { array_pop($stack); }\n }\n\n $candidate = rtrim($candidate);\n // Remove a trailing comma left after cutting an element.\n $candidate = rtrim($candidate, ',');\n\n $closers = '';\n for ($i = count($stack) - 1; $i >= 0; $i--) {\n $closers .= ($stack[$i] === '{') ? '}' : ']';\n }\n\n $repaired = $candidate . $closers;\n\n // Validate before returning.\n return is_array(json_decode($repaired, true)) ? $repaired : null;\n }\n\n private function spreadsheetToCompactCsvText(string $absPath): ?string\n {\n $sheets = $this->spreadsheetToGrids($absPath);\n if (empty($sheets)) {\n return null;\n }\n\n $output = '';\n $kept = 0;\n $dropped = 0;\n\n foreach ($sheets as $sheet) {\n // Don't feed summary / cost-abstract sheets to the AI either.\n if ($this->isSummarySheet($sheet['name'])) {\n continue;\n }\n $sheetRows = '';\n foreach ($sheet['grid'] as $row) {\n $row = $this->trimTrailingEmptyCells($row);\n\n if ($this->isNoiseRow($row)) {\n $dropped++;\n continue;\n }\n\n $sheetRows .= implode(',', array_map([$this, 'csvEscape'], $row)) . \"\\n\";\n $kept++;\n }\n\n // A sheet whose rows were all noise contributes nothing but its own\n // header, which would just be another thing for the model to explain.\n if ($sheetRows === '') {\n continue;\n }\n\n $output .= 'Sheet: ' . $sheet['name'] . \"\\n\" . $sheetRows . \"\\n\";\n }\n\n Log::info('QuotationAiService: stripped blank rows before sending to AI.', [\n 'kept' => $kept,\n 'dropped' => $dropped,\n ]);\n\n if ($dropped > 0) {\n $this->reportStage(\"Removed {$dropped} blank rows. Sending {$kept} rows…\");\n }\n\n return $output;\n }\n\n /**\n * True when a row carries no information worth sending to the AI.\n *\n * A real BOQ is mostly formatting: spacer rows, stray borders, a lone\n * section number, a leftover cell from a merged block. They survive\n * trimTrailingEmptyCells() because they are not *entirely* empty, then cost\n * tokens, dilute the prompt, and inflate the number of parts a large file is\n * split into.\n *\n * Deliberately conservative — a row is dropped only when nothing in it could\n * name a product. Anything with letters is kept, because a description is\n * the one field an item cannot do without.\n *\n * @param array<int, mixed> $row\n */\n private function isNoiseRow(array $row): bool\n {\n if (empty($row)) {\n return true;\n }\n\n $joined = trim(implode('', array_map(fn($c) => trim((string) $c), $row)));\n\n // Nothing but separators/whitespace.\n if ($joined === '') {\n return true;\n }\n\n // Ruled lines and decorative fills (\"-----\", \"====\", \"___\").\n if (preg_match('/^[\\-_=.*\\s|]+$/u', $joined)) {\n return true;\n }\n\n // Any letter — Latin or Arabic — means this could be a description.\n if (preg_match('/\\p{L}/u', $joined)) {\n return false;\n }\n\n // No letters at all: numbers only. A single number is a stray row\n // number or a dangling total; two or more could be qty/rate/amount\n // belonging to a real line, so keep those for the AI to judge.\n $numericCells = 0;\n foreach ($row as $cell) {\n if (trim((string) $cell) !== '') {\n $numericCells++;\n }\n }\n\n return $numericCells <= 1;\n }\n\n /**\n * Safely convert a PhpSpreadsheet cell value to a string.\n *\n * Cells may contain RichText or, in malformed/heavily-formatted files,\n * embedded objects such as Worksheet\\Drawing that have no __toString().\n * Casting those directly throws \"Object ... could not be converted to string\".\n */\n private function cellValueToString(mixed $value): string\n {\n if ($value === null) {\n return '';\n }\n\n if ($value instanceof \\PhpOffice\\PhpSpreadsheet\\RichText\\RichText) {\n return $value->getPlainText();\n }\n\n if (is_object($value)) {\n // Only stringify objects that explicitly support it; ignore the rest\n // (e.g. Worksheet\\Drawing) instead of crashing.\n return method_exists($value, '__toString') ? (string) $value : '';\n }\n\n if (is_array($value)) {\n return '';\n }\n\n return (string) $value;\n }\n\n private function csvEscape(mixed $value): string\n {\n $value = $this->cellValueToString($value);\n return str_contains($value, ',') || str_contains($value, '\"') || str_contains($value, \"\\n\")\n ? '\"' . str_replace('\"', '\"\"', $value) . '\"'\n : $value;\n }\n\n private function verifyLargeBoqItemsWithAi(array $result): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n Log::warning('QuotationAiService: large BOQ verification skipped, AI key is not configured.');\n return $result;\n }\n\n $items = array_values(array_filter($result['items'] ?? [], 'is_array'));\n if (count($items) <= self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n return $result;\n }\n\n Log::info('QuotationAiService: starting second-pass physical item verification.', [\n 'items_count' => count($items),\n 'threshold' => self::LARGE_BOQ_VERIFICATION_THRESHOLD,\n ]);\n\n $verifiedItems = [];\n $rejectedItems = $result['rejected'] ?? [];\n\n foreach (array_chunk($items, self::LARGE_BOQ_VERIFICATION_CHUNK_SIZE) as $chunkIndex => $chunk) {\n $payload = array_map(fn(array $item) => [\n 'description' => (string) ($item['description'] ?? ''),\n 'quantity' => $item['quantity'] ?? null,\n 'unit' => (string) ($item['unit'] ?? ''),\n 'unit_price' => $item['unit_price'] ?? null,\n 'category' => (string) ($item['category'] ?? ''),\n 'brand' => (string) ($item['brand'] ?? ''),\n ], $chunk);\n\n $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);\n if (! is_string($json)) {\n Log::warning('QuotationAiService: large BOQ verification chunk JSON encode failed.', [\n 'chunk' => $chunkIndex + 1,\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n $prompt = <<<PROMPT\nYou are doing a SECOND PASS verification on a BOQ extraction result.\n\nThe first pass may hallucinate non-products when the BOQ has thousands of rows.\nYour job is to keep ONLY physical, tangible, procurable supply products.\n\nRules:\n- Keep items only if a supplier can actually deliver them as materials/equipment.\n- Reject headings, trade names, systems, scopes, prose, specifications-only rows, totals, labor, installation, testing, commissioning, supervision, drawings, insurance, preliminaries, mobilisation, site works, and vague fragments.\n- Do not invent new products.\n- Do not add items that are not in the input.\n- You may clean a product description, but keep the original quantity, unit, unit_price, category, and brand when available.\n\nInput JSON array:\n{$json}\n\nReturn ONLY valid JSON in this shape:\n{\"items\":[{\"description\":\"\",\"quantity\":1,\"unit\":\"\",\"unit_price\":null,\"category\":\"\",\"brand\":\"\",\"confidence\":0.95}]}\nPROMPT;\n\n try {\n $chunkResult = $this->callDeepSeekChat($prompt, $apiKey, $this->deepSeekModel());\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: large BOQ verification chunk threw an exception.', [\n 'chunk' => $chunkIndex + 1,\n 'message' => $e->getMessage(),\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n if ($chunkResult['success'] ?? false) {\n array_push($verifiedItems, ...($chunkResult['items'] ?? []));\n $rejectedItems = array_merge($rejectedItems, $chunkResult['rejected'] ?? []);\n continue;\n }\n\n Log::warning('QuotationAiService: large BOQ verification chunk failed, keeping original chunk.', [\n 'chunk' => $chunkIndex + 1,\n 'error' => $chunkResult['error'] ?? null,\n ]);\n array_push($verifiedItems, ...$chunk);\n }\n\n if (empty($verifiedItems)) {\n Log::warning('QuotationAiService: second-pass verification returned no items, keeping first-pass result.', [\n 'original_items_count' => count($items),\n ]);\n return $result;\n }\n\n $result['items'] = $verifiedItems;\n $result['rejected'] = $rejectedItems;\n $result['large_boq_verified'] = true;\n\n Log::info('QuotationAiService: second-pass physical item verification completed.', [\n 'before' => count($items),\n 'after' => count($verifiedItems),\n ]);\n\n return $result;\n }\n\n private function normaliseAiItem(array $raw): ?array\n {\n if (! empty($raw['rejected'])) {\n return null;\n }\n\n $description = trim((string) ($raw['cleaned_description'] ?? $raw['product_name'] ?? $raw['description'] ?? ''));\n if ($description === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $description)) {\n return null;\n }\n\n $quantity = is_numeric($raw['quantity'] ?? null) ? (float) $raw['quantity'] : 1;\n $unitPrice = is_numeric($raw['unit_price'] ?? null) ? (float) $raw['unit_price'] : null;\n $totalPrice = is_numeric($raw['total_price'] ?? null) ? (float) $raw['total_price'] : null;\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $specs = is_array($raw['specifications'] ?? null) ? $raw['specifications'] : [];\n\n return [\n 'description' => $description,\n 'quantity' => $quantity,\n 'unit' => (string) ($raw['unit'] ?? ''),\n 'category' => (string) ($raw['core_product'] ?? $raw['category'] ?? ''),\n 'brand' => (string) ($specs['brand'] ?? $raw['brand'] ?? ''),\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($description),\n 'unit_price' => $unitPrice,\n 'confidence' => is_numeric($raw['confidence'] ?? null) ? (float) $raw['confidence'] : null,\n 'needs_review' => ! empty($raw['needs_review']),\n 'raw_data' => [\n 'source_item_no' => (string) ($raw['source_item_no'] ?? ''),\n 'original_description' => (string) ($raw['original_description'] ?? $description),\n 'cleaned_description' => $description,\n 'core_product' => (string) ($raw['core_product'] ?? ''),\n 'specifications' => $specs,\n 'extraction_type' => (string) ($raw['extraction_type'] ?? 'supply_only'),\n 'note' => (string) ($raw['note'] ?? $raw['notes'] ?? ''),\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function buildSystemInstruction(): string\n {\n return <<<'TXT'\nYou are a strict BOQ Supply Product Extraction Engine.\nExtract ONLY concrete, tangible, physical products that a supplier can box up and deliver to site — a specific item you could point at and say \"this is the thing being bought\" (e.g. \"Wall mounted LED luminaire 36W\", \"PVC pipe 110mm\", \"Single phase distribution board 12-way\", \"Fire extinguisher CO2 6kg\", \"CCTV dome camera 4MP\").\n\nABSOLUTELY REJECT abstract concepts, disciplines, categories, section names, and trade headings. These are NOT products even though they appear as rows:\n- Bare discipline/trade words: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", \"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\".\n- Summary / total / abstract / \"dry cost\" rows of any kind.\n- Anything that names a system, scope, or category instead of one buyable item.\n\nRule of thumb: if you cannot name a unit and physically hand the item over (a piece, a box, a length, a unit of equipment), REJECT it. A word that describes a field of work, a system, or a section of the BOQ is NEVER a product.\nReject totals, headings, general requirements, installation-only, testing, commissioning, supervision, mobilization, civil works, labor, and vague rows.\nFor Supply & Install rows, keep only the supply product and remove install/labor wording.\nReturn only valid JSON: {\"items\":[{\"source_item_no\":null,\"original_description\":\"\",\"cleaned_description\":\"\",\"core_product\":\"\",\"specifications\":{},\"quantity\":0,\"unit\":\"\",\"unit_price\":0,\"total_price\":0,\"extraction_type\":\"supply_only\",\"needs_review\":false,\"note\":\"\",\"rejected\":false,\"rejection_reason\":null,\"confidence\":0.95}]}\nTXT;\n }\n\n private function buildDeepSeekPrompt(array $context = [], string $mime = ''): string\n {\n $projectName = (string) ($context['project_name'] ?? '');\n $projectStatus = (string) ($context['project_status'] ?? '');\n $contextLine = $projectName !== ''\n ? \"Project: {$projectName}\" . ($projectStatus !== '' ? \" ({$projectStatus})\" : '')\n : '';\n $sourceHint = str_starts_with($mime, 'image/')\n ? 'The input is an image of a BOQ table. OCR every cell carefully — read the quantity/count column (العدد, الكمية, Qty) and extract the EXACT numeric value from each row.'\n : 'The input is a BOQ document/spreadsheet.';\n\n return trim(<<<PROMPT\nYou are a procurement specialist. Your task is to extract ONLY tangible, physical supply products from this Bill of Quantities (BOQ).\n\n{$contextLine}\n{$sourceHint}\n\nCRITICAL — MERGE WRAPPED / SPLIT ROWS INTO ONE ITEM:\nBOQ tables extracted from PDF often break ONE item's long description across several\nlines/rows. These continuation lines repeat the SAME quantity, unit and category, and\ntheir text is a grammatical continuation of the previous line (it starts mid-sentence,\nlowercase, or with \"the/and/-\", or is a spec/instruction clause). You MUST reconstruct\nthe original single item:\n- Treat consecutive rows that share the SAME quantity + unit + category as ONE product\n when the later rows are clearly continuation text of the first, NOT new products.\n- Emit ONE item with the merged description and the price counted ONCE. Do NOT repeat\n the same quantity×price for every wrapped line — that multiplies the total wrongly.\n- Example: rows \"The asphalt flooring is to be supplied\" / \"installed in accordance\" /\n \"standard road specifications...\" all with 7,100 m2 are ONE asphalt item, not 3+.\n\nCRITICAL — UNDERSTAND INTENT, DO NOT COPY ROWS LITERALLY:\nYour job is to output WHAT A SUPPLIER MUST ACTUALLY DELIVER, not a copy of the row text.\nRead each description for MEANING and decide how many real, separately-buyable products it\nimplies. One BOQ row can imply SEVERAL products, or a spread of rows can imply ONE product.\n- SPLIT a row into multiple items when it lists distinct products that a supplier would\n buy/price separately — even when joined by commas, \"with\", \"and\", \"including\", \"c/w\",\n \"consisting of\", \"comprising\", or \":\" then a list.\n Example: \"Fire pump set consisting of: fire pump, electric motor, control panel, diesel\n engine\" → FOUR items: (1) fire pump, (2) electric motor, (3) control panel, (4) diesel\n engine — each with its own quantity and priced on its own.\n Example: \"Wash basin complete with mixer tap and bottle trap\" → THREE items: wash basin,\n mixer tap, bottle trap.\n- Give each split product the correct quantity implied by the text (usually the parent\n quantity, unless the text states a different count for a sub-item). Each product is\n priced individually; the line total becomes the SUM of its products.\n- DO NOT split when the extra words are just specifications, dimensions, colours, ratings,\n standards, or a single product's integral parts. \"Steel door 900x2100 with vision panel\n and SS ironmongery\" is usually ONE door product — vision panel and ironmongery are part\n of that door, not separately-bought items. Use judgement: would a supplier issue a\n separate price line for it? If no, keep it as one.\n- Balanced approach: split only when genuinely separate products are present; when in doubt\n about whether something is a separate product vs. an accessory/spec of the main one, keep\n it as a single item. Do not invent products that are not in the text.\n\nSTRICT RULES — include ONLY items that are ALL of the following:\n1. A specific, concrete, tangible product that can be boxed up by a supplier and delivered to site — something you can point at and say \"this exact thing is being bought\" (e.g. \"LED luminaire 36W\", \"PVC pipe 110mm\", \"Distribution board 12-way\", \"CO2 fire extinguisher 6kg\").\n2. Has a real quantity (not 0 or \"varies\" with no meaning).\n3. Has enough description to identify what the product is.\n4. Is an actual PRODUCT, not a sentence, instruction, or specification clause. REJECT rows that are prose such as \"the supervising engineer's instructions\", \"The price includes all materials, labor\", \"Measurement will be based on the geometric cubic meter\", \"necessary steps to complete the work\", \"soil-contacting elements\", \"in compliance\", \"everything necessary\". These are description fragments, not buyable items.\n\nNEVER extract abstract concepts, disciplines, trades, systems, or section/category names — they are NOT products even when they appear as their own row:\n- Bare discipline/trade/system words such as: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", and their Arabic equivalents (\"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\", \"تشطيبات\").\n- Test: if the word names a FIELD OF WORK or a SYSTEM rather than one buyable item with a unit (pcs, m, set, kg…), REJECT it.\n\nEXCLUDE all of the following (do NOT include them):\n- Labor, installation, or workmanship items (e.g. \"install\", \"fix\", \"erect\", \"dismantle\", \"painting\", \"cleaning service\")\n- Project management, overhead, or profit items\n- Insurance, bonds, liability items (e.g. \"bodily injury\", \"third party liability\", \"CAR insurance\")\n- Shop drawings, as-built drawings, method statements, documentation\n- Preliminary items, mobilisation, demobilisation, provisional sums\n- Housekeeping, site clearance, waste removal\n- Floor/level labels (e.g. \"GF Floor\", \"Level 3\") — these are headers, not products\n- Pure dimension specs without a product noun (e.g. \"Dia 32 mm\", \"25 mm\") — reject unless a product name is present\n- Security services, night shifts, rentals, payments\n- Any item whose description is too vague or fragmentary to identify a specific product\n\nEmit ONE JSON item PER real product. A single source row may therefore produce several\nitems (when it lists multiple products) or several rows may collapse into one item (when\nthey are wrapped fragments of the same product). The \"items\" array is your final delivery\nlist, not a row-by-row transcript.\n\nFor each valid physical product return JSON with:\n- description: clean product name only (strip \"supply and install\", \"supply and fix\", installation clauses, floor/spec suffixes). For a split product, use just that product's own name.\n- quantity: the EXACT numeric value from the quantity/count column (العدد, الكمية, Qty, Quantity, No.). NEVER default to 1 unless the quantity column is truly absent or empty.\n- unit: unit of measure (pcs, m, m2, m3, set, no, kg, etc.)\n- category: product category\n- brand: brand/manufacturer if mentioned, else \"\"\n- engineering_required: true if the item needs engineering design before procurement (panel boards, chillers, generators, fire suppression systems, BMS, pumps, AHUs, switchgear, transformers), else false\n\nReturn ONLY a valid JSON object: {\"items\": [...]}. No explanations, no markdown, no extra text.\nPROMPT);\n }\n\n private function mockResponse(): array\n {\n return [\n 'success' => true,\n 'error' => null,\n 'items' => [[\n 'description' => 'Sample BOQ Item',\n 'quantity' => 1,\n 'unit' => 'No',\n 'category' => 'Sample',\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 1,\n 'raw_data' => [],\n 'ai_extracted' => true,\n ]],\n 'rejected' => [],\n ];\n }\n\n private function failure(string $message, bool $serviceUnavailable = false): array\n {\n return [\n 'success' => false,\n 'items' => [],\n 'rejected' => [],\n 'error' => $this->userFacingError($message, $serviceUnavailable),\n 'service_unavailable' => $serviceUnavailable,\n ];\n }\n\n /**\n * Translate any internal error into a neutral, user-safe message.\n *\n * The user must never see the name of the underlying AI/processing engine\n * (DeepSeek, Groq, Gemini, Vision, etc.) or configuration hints like API\n * keys. The detailed message is kept in the logs for debugging only.\n */\n private function userFacingError(string $message, bool $serviceUnavailable = false): string\n {\n // Detect any reference to an AI provider / engine / API config so we can\n // swap it for a generic, branded-neutral message.\n $sensitive = '/deepseek|groq|gemini|vision|openai|openrouter|api[\\s_-]?key|\\.env|non-?json|token limit/i';\n\n if (preg_match($sensitive, $message)) {\n Log::warning('QuotationAiService: suppressed technical error from user.', ['internal' => $message]);\n\n return $serviceUnavailable\n ? 'The file could not be processed right now. Please try again in a moment.'\n : 'We could not read this file automatically. Please make sure it is a clear BOQ (Excel, CSV, PDF, or image) and try again, or add the items manually.';\n }\n\n return $message;\n }\n\n private function mimeForExtension(string $ext): string\n {\n return match (strtolower($ext)) {\n 'pdf' => 'application/pdf',\n 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n 'xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12',\n 'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',\n 'xls' => 'application/vnd.ms-excel',\n 'csv' => 'text/csv',\n 'jpg', 'jpeg' => 'image/jpeg',\n 'png' => 'image/png',\n 'gif' => 'image/gif',\n 'bmp' => 'image/bmp',\n 'webp' => 'image/webp',\n 'tiff', 'tif' => 'image/tiff',\n 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'doc' => 'application/msword',\n default => 'application/octet-stream',\n };\n }\n}\n"}, "eb051ce6c79d115451ba88c10f94c8302b8e14f2": {"t": 1785748369.45644, "n": 75203, "h": "e33b5f6d315bae64", "c": "<?php\n\nnamespace App\\Services;\n\nuse Illuminate\\Http\\Client\\ConnectionException;\nuse Illuminate\\Http\\UploadedFile;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\nuse PhpOffice\\PhpSpreadsheet\\Cell\\Coordinate;\nuse PhpOffice\\PhpSpreadsheet\\IOFactory;\n\nclass QuotationAiService\n{\n /** Increment this whenever BOQ parsing logic changes to invalidate old caches. */\n private const PARSER_VERSION = 10;\n private const LARGE_BOQ_VERIFICATION_THRESHOLD = 2000;\n private const LARGE_BOQ_VERIFICATION_CHUNK_SIZE = 120;\n\n /**\n * Characters of document text sent per AI call.\n *\n * Above this the document is split and parsed over several calls. Sized well\n * under the model's context so the prompt, the slice, and a full JSON answer\n * for every row in it all fit — the binding limit is the *output* budget, not\n * the input, since each input row produces a JSON object.\n */\n private const TEXT_CHUNK_CHARS = 60000;\n\n /**\n * A workbook with more sheets than this is split regardless of its size.\n *\n * Multiple tabs almost always mean multiple disciplines (electrical, HVAC,\n * civil…). Sending them in one call makes the model summarise across\n * sections and silently drop rows, so each slice gets a narrower scope.\n */\n private const MAX_SHEETS_PER_CALL = 2;\n\n /** A PDF longer than this is split regardless of its extracted text length. */\n private const MAX_PDF_PAGES_PER_CALL = 5;\n\n private string $baseUrl;\n private string $parseEndpoint;\n private string $apiKey;\n private int $timeout;\n private bool $testMode;\n private BoqCleaningService $boqCleaner;\n\n /** Page count of the most recently parsed PDF; 0 when unknown. */\n private int $lastPdfPageCount = 0;\n\n /**\n * Optional progress reporter for chunked parsing, called as ($part, $total).\n *\n * Set by the queue job so the polling UI can show real progress on a large\n * BOQ instead of a spinner that appears frozen for many minutes.\n *\n * @var (callable(int, int): void)|null\n */\n private $onChunkProgress = null;\n\n /** @param callable(int, int): void $callback */\n public function onChunkProgress(callable $callback): self\n {\n $this->onChunkProgress = $callback;\n return $this;\n }\n\n /**\n * Optional per-chunk item sink, called as ($items, $part, $total).\n *\n * Lets the caller persist each slice's rows the moment they arrive instead\n * of waiting for the whole document. On a large BOQ this is the difference\n * between a table that fills in progressively and one that stays empty for\n * many minutes.\n *\n * @var (callable(array, int, int): void)|null\n */\n private $onChunkItems = null;\n\n /** @param callable(array, int, int): void $callback */\n public function onChunkItems(callable $callback): self\n {\n $this->onChunkItems = $callback;\n return $this;\n }\n\n /**\n * Report a non-chunk stage (local parse, handing off to AI) to the caller.\n *\n * A spreadsheet is read locally first and only sent to the AI when that\n * fails, so a large file can spend minutes before any chunk exists. Without\n * these the UI shows a generic spinner for that whole period.\n */\n private function reportStage(string $message): void\n {\n if ($this->onStage !== null) {\n ($this->onStage)($message);\n }\n }\n\n /**\n * Report a workbook's shape from grids that are already in memory.\n *\n * Deliberately takes the parsed grids rather than a path: re-reading a large\n * spreadsheet just to count its sheets would double the slowest step of the\n * whole extraction.\n *\n * @param array<int, array{name: string, grid: array}> $sheets\n */\n private function reportWorkbookShape(array $sheets): void\n {\n if ($this->onStage === null || empty($sheets)) {\n return;\n }\n\n $rows = 0;\n foreach ($sheets as $sheet) {\n $rows += count($sheet['grid']);\n }\n\n $count = count($sheets);\n\n $this->reportStage($count > 1\n ? \"Found {$count} sheets, {$rows} rows in total. Reading…\"\n : \"Found {$rows} rows. Reading…\");\n }\n\n /** @var (callable(string): void)|null */\n private $onStage = null;\n\n /** @param callable(string): void $callback */\n public function onStage(callable $callback): self\n {\n $this->onStage = $callback;\n return $this;\n }\n\n public function __construct(BoqCleaningService $boqCleaner)\n {\n $this->boqCleaner = $boqCleaner;\n $this->baseUrl = rtrim((string) config('services.ai_quotation.base_url', ''), '/');\n $this->parseEndpoint = ltrim((string) config('services.ai_quotation.parse_endpoint', 'parse'), '/');\n $this->apiKey = (string) config('services.ai_quotation.api_key', '');\n $this->timeout = (int) config('services.ai_quotation.timeout', 300);\n $this->testMode = (bool) config('services.ai_quotation.test_mode', false);\n }\n\n /**\n * Give the running script enough head-room to outlast the AI HTTP call.\n *\n * The web request / queue worker must not be killed before the HTTP timeout\n * completes, otherwise the user sees a generic \"timeout or unavailable\"\n * error even though the AI may have responded. We allow the HTTP timeout\n * plus a 120s margin for connecting, response parsing and DB writes.\n */\n private function extendExecutionTime(): void\n {\n // Ignored on some SAPIs (e.g. when running under fastcgi with a hard\n // limit), but harmless to attempt.\n @set_time_limit($this->timeout + 120);\n }\n\n public function parseBoq(UploadedFile|string $file, array $context = []): array\n {\n if ($this->testMode) {\n return $this->mockResponse();\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n\n if (! is_file($absPath)) {\n return $this->failure('File not found or not readable.');\n }\n\n $fileHash = hash_file('sha256', $absPath);\n // Bump PARSER_VERSION whenever the extraction logic changes so that\n // previously-uploaded files are re-parsed instead of returning a stale\n // cached result produced by the old code.\n $cacheKey = 'boq_analysis_v' . self::PARSER_VERSION . '_' . $fileHash;\n $forceRefresh = (bool) ($context['force_refresh'] ?? false);\n\n if (! $forceRefresh && ($cached = Cache::get($cacheKey)) !== null) {\n return $cached;\n }\n\n // Clear stale cached result when force-refreshing\n if ($forceRefresh) {\n Cache::forget($cacheKey);\n }\n\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n // Read Excel locally first. Fall back to AI if local parse failed,\n // found 0 items, OR could not read one of the sheets (so multi-sheet\n // files never silently drop a sheet the local parser didn't understand).\n $this->reportStage('Reading the spreadsheet…');\n\n $result = $this->parseSpreadsheetDirect($absPath);\n\n $hasSkippedSheets = ! empty($result['skipped_sheets']);\n\n if (! $result['success'] || empty($result['items']) || $hasSkippedSheets) {\n // The local pass could not handle this file on its own. Say so:\n // otherwise the UI sits on a generic spinner through the local\n // parse AND the whole AI parse with nothing to show for either.\n $this->reportStage($hasSkippedSheets\n ? 'Some sheets need AI to read. Sending to AI…'\n : 'Spreadsheet needs AI to read. Sending to AI…');\n\n $deepSeekResult = $this->parseBoqWithDeepSeek($file, $context);\n // AI sees every sheet at once, so prefer its result whenever it\n // actually returned items (covers the skipped-sheet case too).\n if ($deepSeekResult['success'] && ! empty($deepSeekResult['items'])) {\n // Keep the larger of the two item sets so we never lose the\n // rows the local parser already extracted correctly.\n if (count($deepSeekResult['items']) >= count($result['items'] ?? [])) {\n $result = $deepSeekResult;\n }\n } elseif (! $result['success']) {\n $result = $deepSeekResult;\n }\n // else: keep local result if AI also failed/returned nothing\n }\n } elseif (in_array($ext, ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif'], true)) {\n $result = $this->parseBoqWithDeepSeek($file, $context);\n } else {\n return $this->failure('Unsupported file type. Please upload Excel, CSV, PDF, or image file.');\n }\n\n if ($result['success'] && ! empty($result['items'])) {\n $cleaning = $this->boqCleaner->process($result['items']);\n $result['items'] = $cleaning['accepted'] ?? $result['items'];\n $result['rejected'] = array_merge($result['rejected'] ?? [], $cleaning['rejected'] ?? []);\n }\n\n if ($result['success'] && count($result['items'] ?? []) > self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n $result = $this->verifyLargeBoqItemsWithAi($result);\n }\n\n // Only cache when we actually extracted items; empty results should be re-tried.\n if ($result['success'] && ! empty($result['items'])) {\n Cache::put($cacheKey, $result, now()->addDays(30));\n }\n\n return $result;\n }\n\n private function resolveFile(UploadedFile|string $file): array\n {\n if ($file instanceof UploadedFile) {\n return [(string) $file->getRealPath(), strtolower($file->getClientOriginalExtension())];\n }\n\n $absPath = is_file($file) ? $file : storage_path('app/' . ltrim($file, '/'));\n return [$absPath, strtolower(pathinfo($absPath, PATHINFO_EXTENSION))];\n }\n\n private function parseSpreadsheetDirect(string $absPath): array\n {\n ini_set('memory_limit', '1024M');\n set_time_limit(180);\n\n try {\n $sheets = $this->spreadsheetToGrids($absPath);\n\n if (empty($sheets)) {\n return $this->failure('Could not read the Excel file.');\n }\n\n $this->reportWorkbookShape($sheets);\n\n $items = [];\n $rejected = [];\n $skippedSheets = []; // sheets we could not parse locally (header undetected)\n\n foreach ($sheets as $sheet) {\n $grid = $sheet['grid'];\n if (count($grid) < 2) {\n continue;\n }\n\n // Skip dedicated summary / cost-abstract sheets entirely. Their rows\n // are per-discipline totals (Structural Concrete, Electrical, …) and\n // grand totals, not procurable products.\n if ($this->isSummarySheet($sheet['name'])) {\n Log::info('QuotationAiService: skipped summary sheet.', ['sheet' => $sheet['name']]);\n continue;\n }\n\n $header = $this->detectHeader($grid);\n\n if ($header === null || ! isset($header['map']['description'])) {\n Log::warning('QuotationAiService: header not detected in sheet.', ['sheet' => $sheet['name']]);\n $skippedSheets[] = $sheet['name'];\n continue;\n }\n\n $map = $header['map'];\n $start = $header['row'] + 1;\n\n for ($r = $start; $r < count($grid); $r++) {\n $row = $grid[$r];\n $description = $this->cell($row, $map['description'] ?? null);\n $itemNo = $this->cell($row, $map['item_code'] ?? null);\n $unit = $this->cell($row, $map['unit'] ?? null);\n $quantity = $this->number($this->cell($row, $map['quantity'] ?? null));\n $unitPrice = $this->number($this->cell($row, $map['unit_price'] ?? null));\n $totalPrice = $this->number($this->cell($row, $map['total_price'] ?? null));\n\n if ($description === '') {\n continue;\n }\n\n if (! isset($map['quantity'])) {\n $quantity = 1.0;\n }\n\n // Skip floor breakdown sub-rows: Ref. is \"-\" meaning it's a per-floor\n // quantity split of the parent item, not a standalone product.\n if ($itemNo === '-' || $this->isFloorBreakdownRow($itemNo, $description)) {\n continue;\n }\n\n if ($this->isTotalOrHeaderRow($description)) {\n continue;\n }\n\n if ($quantity === null || $quantity <= 0) {\n $rejected[] = $this->rejectedRow($description, $unit, $quantity, 'Missing quantity', $itemNo, $sheet['name']);\n continue;\n }\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $supply = $this->boqCleaner->filterItem($description, $unitPrice, $quantity);\n\n if (! ($supply['keep'] ?? false)) {\n $rejected[] = $this->rejectedRow(\n $description,\n $unit,\n $quantity,\n (string) ($supply['rejection_reason'] ?? 'Rejected by cleaner'),\n $itemNo,\n $sheet['name']\n );\n continue;\n }\n\n $finalDescription = (string) ($supply['description'] ?? $description);\n\n $items[] = [\n 'description' => $finalDescription,\n 'quantity' => $quantity,\n 'unit' => $unit,\n 'category' => $sheet['name'],\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($finalDescription),\n 'unit_price' => $unitPrice,\n 'confidence' => 0.95,\n 'needs_review' => false,\n 'raw_data' => [\n 'sheet' => $sheet['name'],\n 'row_number' => $r + 1,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'cleaned_description' => $finalDescription,\n 'extraction_type' => (string) ($supply['extraction_type'] ?? 'supply_only'),\n ],\n 'ai_extracted' => true,\n ];\n }\n }\n\n if (empty($items)) {\n return $this->failure('No BOQ supply items could be extracted from the Excel file.');\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'skipped_sheets' => $skippedSheets,\n ];\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: parseSpreadsheetDirect failed.', [\n 'path' => $absPath,\n 'message' => $e->getMessage(),\n 'trace' => $e->getTraceAsString(),\n ]);\n\n return $this->failure('Failed to read the Excel file: ' . $e->getMessage());\n }\n }\n\n private function spreadsheetToGrids(string $absPath): array\n {\n $ext = strtolower(pathinfo($absPath, PATHINFO_EXTENSION));\n\n if ($ext === 'csv') {\n $rows = [];\n $handle = fopen($absPath, 'rb');\n if ($handle !== false) {\n while (($row = fgetcsv($handle)) !== false) {\n $rows[] = $this->trimTrailingEmptyCells($row);\n }\n fclose($handle);\n }\n return [['name' => 'Sheet1', 'grid' => $rows]];\n }\n\n $reader = IOFactory::createReader($ext === 'xls' ? 'Xls' : 'Xlsx');\n $reader->setReadDataOnly(true);\n $spreadsheet = $reader->load($absPath);\n $sheets = [];\n\n foreach ($spreadsheet->getAllSheets() as $sheet) {\n $highestRow = $sheet->getHighestDataRow();\n $highestColumn = $sheet->getHighestDataColumn();\n $highestColumnIndex = Coordinate::columnIndexFromString($highestColumn);\n\n // BOQ data is normally in first 8-20 columns.\n // Files with heavy formatting/images can report 300+ columns — limit to 30.\n $maxColumnIndex = min($highestColumnIndex, 30);\n\n $grid = [];\n for ($row = 1; $row <= $highestRow; $row++) {\n $rowData = [];\n $hasValue = false;\n\n for ($col = 1; $col <= $maxColumnIndex; $col++) {\n $value = $sheet->getCell([$col, $row])->getValue();\n $value = $this->cellValueToString($value);\n $value = trim($value);\n if ($value !== null && $value !== '') {\n $hasValue = true;\n }\n $rowData[] = $value;\n }\n\n $rowData = $this->trimTrailingEmptyCells($rowData);\n if ($hasValue) {\n $grid[] = $rowData;\n }\n }\n\n if (! empty($grid)) {\n $sheets[] = ['name' => $sheet->getTitle(), 'grid' => $grid];\n }\n }\n\n $spreadsheet->disconnectWorksheets();\n unset($spreadsheet);\n\n return $sheets;\n }\n\n private function detectHeader(array $grid): ?array\n {\n $keywords = [\n 'item_code' => ['رقم البند', 'item no', 'item number', 'ref.', 'ref', 'item', 'no.', 'no', '#', 'كود'],\n 'description' => ['وص٠البند', 'scope of works', 'scope of work', 'scope', 'item description', 'description of works', 'description', 'بالعربية', 'الوصÙ', 'البند', 'بيان'],\n 'unit' => ['Ø§Ù„ÙˆØØ¯Ø©', 'unit', 'uom', 'u/m'],\n 'quantity' => ['الكمية', 'qty', 'quantity', 'q.ty'],\n 'unit_price' => ['سعر Ø§Ù„ÙˆØØ¯Ø©', 'u/price', 'unit price', 'unit rate', 'rate', 'price'],\n 'total_price' => ['السعر الإجمالي', 'total amount', 'total price', 'amount', 'الإجمالي'],\n ];\n\n $best = null;\n $bestScore = 0;\n\n for ($r = 0; $r < min(30, count($grid)); $r++) {\n $map = [];\n $score = 0;\n\n foreach ($grid[$r] as $c => $value) {\n $cell = mb_strtolower(trim((string) $value));\n if ($cell === '') {\n continue;\n }\n\n foreach ($keywords as $field => $list) {\n foreach ($list as $kw) {\n if (str_contains($cell, mb_strtolower($kw))) {\n if (! isset($map[$field])) {\n $map[$field] = $c;\n $score++;\n }\n break 2;\n }\n }\n }\n }\n\n if ($score > $bestScore) {\n $bestScore = $score;\n $best = ['row' => $r, 'map' => $map];\n }\n }\n\n if ($bestScore >= 3) {\n return $best;\n }\n\n // Allow simpler BOQ sheets that only define a description column plus one\n // other BOQ-related column, e.g. Item + Description or Description + Qty.\n if ($bestScore === 2 && isset($best['map']['description']) && (\n isset($best['map']['item_code']) ||\n isset($best['map']['quantity']) ||\n isset($best['map']['unit'])\n )) {\n return $best;\n }\n\n return null;\n }\n\n private function cell(array $row, ?int $index): string\n {\n if ($index === null) {\n return '';\n }\n return trim((string) ($row[$index] ?? ''));\n }\n\n private function number(string $value): ?float\n {\n $value = trim(str_replace([',', 'SAR', 'ر.س'], '', $value));\n if ($value === '' || ! is_numeric($value)) {\n return null;\n }\n return (float) $value;\n }\n\n private function trimTrailingEmptyCells(array $row): array\n {\n while (! empty($row) && trim((string) end($row)) === '') {\n array_pop($row);\n }\n return array_values($row);\n }\n\n private function isTotalOrHeaderRow(string $description): bool\n {\n $d = mb_strtolower(trim($description));\n\n if ($d === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $d)) {\n return true;\n }\n\n // Section totals / summaries\n if (preg_match('/(اجمالي|إجمالي|المجموع|grand\\s*total|sub\\s*total|total\\s*amount|total\\s*price)/iu', $d)) {\n return true;\n }\n\n // Bare \"Total\" lines, optionally with a currency suffix, e.g. \"Total\",\n // \"Total (SAR)\", \"Total cost\", \"Total (SAR) unit cost\".\n if (preg_match('/^total\\b[\\s\\(\\):a-z]*$/iu', $d)) {\n return true;\n }\n\n // Pure floor / level labels used as section headers\n if (preg_match('/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor)\\s*$/iu', $d)) {\n return true;\n }\n\n // Division / section header lines (e.g. \"Division - 02: Wood Works\")\n if (preg_match('/^division\\s*([-:]|\\d)/iu', $d)) {\n return true;\n }\n\n return false;\n }\n\n /**\n * Detect dedicated summary / cost-abstract sheets by their name. These hold\n * per-discipline rollups and grand totals (e.g. a \"Summary (Dry Cost)\" tab),\n * never procurable supply items, so they are skipped wholesale.\n */\n private function isSummarySheet(string $name): bool\n {\n $n = mb_strtolower(trim($name));\n\n if ($n === '') {\n return false;\n }\n\n return (bool) preg_match(\n '/\\b(summary|abstract|cost\\s*summary|grand\\s*summary|recap(?:itulation)?|bill\\s*summary|boq\\s*summary|dry\\s*cost)\\b|ملخص|الملخص|الملخّص|ملخّص|اجمالي|إجمالي/iu',\n $n\n );\n }\n\n private function isFloorBreakdownRow(string $itemNo, string $description): bool\n {\n // A row whose Ref column is empty or a dash is a sub-breakdown row (per-floor split).\n $ref = trim($itemNo);\n if ($ref === '' || $ref === '-') {\n // Confirm by checking the description is a location/floor label\n $d = mb_strtolower(trim($description));\n return (bool) preg_match(\n '/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor|\\d+f)\\s*$/iu',\n $d\n );\n }\n return false;\n }\n\n private function rejectedRow(string $description, string $unit, ?float $quantity, string $reason, string $itemNo = '', string $sheet = ''): array\n {\n return [\n 'description' => $description,\n 'quantity' => $quantity ?? 0,\n 'unit' => $unit,\n 'category' => $sheet,\n 'brand' => '',\n 'status' => 'rejected',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 0.0,\n 'raw_data' => [\n 'sheet' => $sheet,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'extraction_type' => 'rejected',\n 'rejection_reason' => $reason,\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function parseBoqWithDeepSeek(UploadedFile|string $file, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured. Please set DEEPSEEK_API_KEY in .env file.');\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n $mime = $this->mimeForExtension($ext);\n\n try {\n // ── Spreadsheets ──────────────────────────────────────────────────\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n $text = $this->spreadsheetToCompactCsvText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not convert spreadsheet to readable text.');\n }\n\n $text = $this->sanitizeUtf8($text);\n\n // A large BOQ exceeds what one request can carry, so send it in\n // sequential slices and merge. Anything that fits goes as-is.\n //\n // Size alone is not a good enough signal: a workbook with several\n // sheets is structurally a big BOQ even when the text is compact,\n // and asking the model to hold every discipline at once is where\n // rows start getting dropped. So split on either signal.\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS || $sheetCount > self::MAX_SHEETS_PER_CALL) {\n Log::info('QuotationAiService: spreadsheet routed to chunked parsing.', [\n 'chars' => mb_strlen($text),\n 'sheets' => $sheetCount,\n ]);\n return $this->parseTextInChunks($text, 'BOQ spreadsheet converted to CSV', $apiKey, $context);\n }\n\n $userContent = \"BOQ spreadsheet converted to CSV:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── PDF ───────────────────────────────────────────────────────────\n if ($ext === 'pdf') {\n $extracted = $this->extractPdfText($absPath);\n if ($extracted !== null && mb_strlen(trim($extracted)) > 50) {\n $extracted = $this->sanitizeUtf8($extracted);\n\n // Split on either signal — long text, or many pages. A BOQ\n // spanning several pages is a multi-section document even if\n // each page is sparse.\n if (mb_strlen($extracted) > self::TEXT_CHUNK_CHARS\n || $this->lastPdfPageCount > self::MAX_PDF_PAGES_PER_CALL) {\n Log::info('QuotationAiService: PDF routed to chunked parsing.', [\n 'chars' => mb_strlen($extracted),\n 'pages' => $this->lastPdfPageCount,\n ]);\n return $this->parseTextInChunks($extracted, 'BOQ PDF extracted text', $apiKey, $context, 'application/pdf');\n }\n\n $userContent = \"BOQ PDF extracted text:\\n\\n\" . $extracted . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'application/pdf');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n return $this->failure('Could not extract text from the PDF. Please make sure it is a text-based PDF (not a scanned image), or convert it to Excel or CSV.');\n }\n\n // -- Word documents (docx) ---------------------------------------------------\n if (in_array($ext, ['docx', 'doc'], true)) {\n $text = $this->extractDocxText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not extract text from the Word document. Please convert it to Excel, CSV, or PDF.');\n }\n $text = $this->sanitizeUtf8($text);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS) {\n return $this->parseTextInChunks($text, 'BOQ Word document extracted text', $apiKey, $context);\n }\n\n $userContent = \"BOQ Word document extracted text:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── Images ──────────────────────────────────────────────────────────────\n // DeepSeek cannot receive images. A vision-capable AI processes the image\n // directly (no OCR) — configure GROQ_API_KEY or GEMINI_API_KEY in .env.\n $imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif', 'heic', 'heif'];\n if (in_array($ext, $imageExts, true)) {\n $bytes = (string) file_get_contents($absPath);\n return $this->callDeepSeekVision($bytes, $mime, $context);\n }\n\n return $this->failure('Unsupported file type. Please upload an Excel, CSV, PDF, or image file.');\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: DeepSeek fallback failed.', ['message' => $e->getMessage()]);\n return $this->failure('An error occurred while processing the file with AI: ' . $e->getMessage());\n }\n }\n\n private function deepSeekModel(): string\n {\n return (string) config('services.deepseek.model', 'deepseek-chat');\n }\n\n /**\n * Remove or replace any non-UTF-8 / malformed byte sequences so json_encode never fails.\n */\n private function sanitizeUtf8(string $text): string\n {\n // Replace invalid byte sequences with the UTF-8 replacement character\n $clean = mb_convert_encoding($text, 'UTF-8', 'UTF-8');\n // Strip any remaining non-printable control characters (except tab/newline/CR)\n $clean = (string) preg_replace('/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]/u', '', $clean);\n return $clean;\n }\n\n /**\n * Extract plain text from a Word docx file using ZipArchive (no external library needed).\n * Reads word/document.xml and strips XML tags, preserving paragraph and table structure.\n */\n private function extractDocxText(string $absPath): ?string\n {\n try {\n $zip = new \\ZipArchive();\n if ($zip->open($absPath) !== true) {\n return null;\n }\n\n $xml = $zip->getFromName('word/document.xml');\n $zip->close();\n\n if ($xml === false || $xml === '') {\n return null;\n }\n\n // Add newlines before paragraph/table-row/table-cell tags so structure is preserved\n $xml = preg_replace('/<w:p[ >]/', \"\\n<w:p>\", $xml) ?? $xml;\n $xml = preg_replace('/<w:tr[ >]/', \"\\n<w:tr>\", $xml) ?? $xml;\n $xml = preg_replace('/<\\/w:tc>/', \"\\t\", $xml) ?? $xml;\n\n $text = strip_tags($xml);\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n return trim($text) !== '' ? trim($text) : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: DOCX parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Extract plain text from a PDF using smalot/pdfparser (pure PHP, no binary required).\n */\n private function extractPdfText(string $absPath): ?string\n {\n try {\n $parser = new \\Smalot\\PdfParser\\Parser();\n $pdf = $parser->parseFile($absPath);\n\n // Recorded for the chunking decision: a long PDF is structurally a\n // big BOQ even when its extracted text is short.\n try {\n $this->lastPdfPageCount = count($pdf->getPages());\n } catch (\\Throwable) {\n $this->lastPdfPageCount = 0;\n }\n\n $text = $pdf->getText();\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n return $text !== '' ? $text : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: PDF parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Send a vision request to a vision-capable AI model.\n * DeepSeek's commercial API (api.deepseek.com) does NOT support image inputs — any model.\n * Priority order (first configured key wins):\n * 1. GROQ_API_KEY → Groq llama-4-scout (free: https://console.groq.com)\n * 2. GEMINI_API_KEY → Google Gemini Flash (free: https://aistudio.google.com/apikey)\n * 3. VISION_API_KEY → any OpenAI-compatible endpoint (OpenAI, OpenRouter …)\n */\n private function callDeepSeekVision(string $bytes, string $mime, array $context): array\n {\n // ── 1. Groq (free, reliable vision) ─────────────────────────────────────\n if ((string) config('services.groq.key', '') !== '') {\n $visionKey = (string) config('services.groq.key');\n $visionBaseUrl = 'https://api.groq.com/openai/v1';\n $visionModel = (string) config('services.groq.model', 'meta-llama/llama-4-scout-17b-16e-instruct');\n }\n // ── 2. Google Gemini ─────────────────────────────────────────────────────\n elseif ((string) config('services.gemini.key', '') !== '') {\n $visionKey = (string) config('services.gemini.key');\n $visionBaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai';\n $visionModel = (string) config('services.gemini.model', 'gemini-2.0-flash');\n }\n // ── 3. Generic VISION_API_KEY (OpenAI, OpenRouter, …) ───────────────────\n elseif ((string) config('services.vision.key', '') !== '') {\n $visionKey = (string) config('services.vision.key');\n $visionBaseUrl = rtrim((string) config('services.vision.base_url', 'https://openrouter.ai/api/v1'), '/');\n $visionModel = (string) config('services.vision.model', 'google/gemini-flash-1.5-8b');\n }\n else {\n return $this->failure(\n 'Image processing is not available. DeepSeek\\'s API does not support images. ' .\n 'Get a free key at https://console.groq.com and add GROQ_API_KEY to your .env file.'\n );\n }\n\n $dataUrl = \"data:{$mime};base64,\" . base64_encode($bytes);\n $prompt = $this->buildDeepSeekPrompt($context, $mime);\n\n $userContent = [\n ['type' => 'text', 'text' => $prompt],\n ['type' => 'image_url', 'image_url' => ['url' => $dataUrl]],\n ];\n\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders([\n 'Authorization' => 'Bearer ' . $visionKey,\n 'HTTP-Referer' => config('app.url', 'https://qimta.sa'),\n 'X-Title' => 'Qimta Platform',\n ])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post($visionBaseUrl . '/chat/completions', [\n 'model' => $visionModel,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 16384,\n 'temperature' => 0.2,\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('Vision API connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: Vision API HTTP error.', [\n 'model' => $visionModel,\n 'base_url' => $visionBaseUrl,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n return $this->failure(\"Vision API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n return $this->parseDeepSeekJsonResponse((string) $response->json('choices.0.message.content'));\n }\n\n private function callDeepSeekChat(string $userContent, string $key, string $model): array\n {\n // Ensure the PHP script outlives the HTTP call (+ a margin for parsing the\n // response and writing to the DB) so the worker/web request is not killed\n // mid-flight, which surfaces to users as a generic \"timeout or unavailable\".\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders(['Authorization' => 'Bearer ' . $key])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 32768,\n 'temperature' => 0.1,\n 'response_format' => ['type' => 'json_object'],\n 'user' => 'Qimta_Platform',\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('DeepSeek connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: DeepSeek HTTP error.', [\n 'model' => $model,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n if ($response->status() === 401) {\n return $this->failure('DeepSeek API key is invalid or unauthorised. Please update DEEPSEEK_API_KEY in .env.', false);\n }\n return $this->failure(\"DeepSeek API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n $text = (string) $response->json('choices.0.message.content');\n return $this->parseDeepSeekJsonResponse($text);\n }\n\n /**\n * Parse a document too large for one AI call by sending it in slices.\n *\n * A 20 000-row BOQ is several megabytes of text — far beyond what a single\n * request can carry, and beyond what the model can answer without hitting its\n * output token limit. Previously the text was simply cut at 180 000 chars and\n * the remainder discarded: the parse \"succeeded\" while silently dropping most\n * of the file. Now the text is split and each slice parsed on its own call,\n * with the items merged.\n *\n * Splitting happens on line boundaries so a BOQ row is never cut in half.\n * Slices run sequentially rather than pooled: DeepSeek rate-limits hard on\n * concurrent large prompts, and this already runs on the queue where wall\n * time is cheap.\n *\n * A slice that fails does not fail the document — its rows are lost, but the\n * rest are kept and the shortfall is logged. Total failure is reported only\n * when every slice fails.\n *\n * @param string $label How the payload is introduced to the model.\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n private function parseTextInChunks(\n string $text,\n string $label,\n string $apiKey,\n array $context,\n string $mime = 'text/plain',\n ): array {\n $chunks = $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n $total = count($chunks);\n\n Log::info('QuotationAiService: large document split for parsing.', [\n 'chars' => mb_strlen($text),\n 'chunks' => $total,\n ]);\n\n // Announce the split before the first slice is sent, so the UI can show\n // how many parts there are instead of sitting on a generic message for\n // however long the first AI call takes.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)(0, $total);\n }\n\n $items = [];\n $rejected = [];\n $failed = 0;\n $lastError = null;\n\n foreach ($chunks as $index => $chunk) {\n $part = $index + 1;\n\n // Report progress so a 28-slice parse does not look frozen to the user.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)($part, $total);\n }\n\n // Tell the model this is a fragment, so it does not try to reconcile\n // totals or treat a mid-document slice as a complete BOQ.\n $userContent = \"{$label} (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, $mime);\n\n $result = $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n\n if (! ($result['success'] ?? false)) {\n $failed++;\n $lastError = $result['error'] ?? 'Chunk parsing failed.';\n Log::warning('QuotationAiService: chunk failed, continuing.', [\n 'part' => $part,\n 'of' => $total,\n 'error' => $lastError,\n ]);\n continue;\n }\n\n foreach ($result['items'] as $item) {\n $items[] = $item;\n }\n foreach ($result['rejected'] ?? [] as $row) {\n $rejected[] = $row;\n }\n\n // Hand this slice's rows straight to the caller so they can be shown\n // while the remaining slices are still being parsed.\n if ($this->onChunkItems !== null && $result['items'] !== []) {\n ($this->onChunkItems)($result['items'], $part, $total);\n }\n }\n\n // Every slice failed — there is nothing to show, so report the failure.\n if ($failed === $total) {\n return $this->failure($lastError ?? 'Could not parse the document.');\n }\n\n if ($failed > 0) {\n Log::error('QuotationAiService: document parsed with missing parts.', [\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n 'items_kept' => count($items),\n ]);\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'service_unavailable' => false,\n 'partial' => $failed > 0,\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n ];\n }\n\n /**\n * Split text into slices of at most $limit characters, breaking only at\n * newlines so no BOQ row is ever cut in half.\n *\n * A single line longer than the limit (rare — a pathological cell) is passed\n * through oversized rather than severed mid-row.\n *\n * @return array<int, string>\n */\n private function splitOnLineBoundaries(string $text, int $limit): array\n {\n $chunks = [];\n $current = '';\n\n // Sheet a chunk starts in the middle of. Repeated at the top of the next\n // chunk so the model always knows which sheet the rows belong to — the\n // header would otherwise be stranded in the previous slice, and rows\n // would arrive with no discipline context.\n $currentSheet = null;\n\n foreach (explode(\"\\n\", $text) as $line) {\n $isSheetHeader = str_starts_with($line, 'Sheet: ');\n\n // Start a new chunk at every sheet boundary. Sheets are usually\n // separate disciplines, and the whole point of splitting a multi-tab\n // workbook is to give the model one discipline at a time — a size-only\n // split would leave a compact 3-tab file as a single chunk, silently\n // undoing the sheet gate that routed it here.\n if ($isSheetHeader && $current !== '') {\n $chunks[] = $current;\n $current = $line;\n $currentSheet = $line;\n continue;\n }\n\n if ($isSheetHeader) {\n $currentSheet = $line;\n }\n\n $candidate = $current === '' ? $line : $current . \"\\n\" . $line;\n\n if (mb_strlen($candidate) > $limit && $current !== '') {\n $chunks[] = $current;\n\n // Carry the sheet header over, unless this line is itself one.\n $current = ($currentSheet !== null && ! str_starts_with($line, 'Sheet: '))\n ? $currentSheet . \" (continued)\\n\" . $line\n : $line;\n continue;\n }\n\n $current = $candidate;\n }\n\n if (trim($current) !== '') {\n $chunks[] = $current;\n }\n\n return $chunks;\n }\n\n /**\n * Shared JSON response parser used by both chat and vision paths.\n */\n private function parseDeepSeekJsonResponse(string $text): array\n {\n if ($text === '') {\n return $this->failure('DeepSeek returned an empty response.');\n }\n\n // Strip markdown code fences if the model wrapped the JSON.\n if (preg_match('/```(?:json)?\\s*([\\s\\S]*?)\\s*```/i', $text, $m)) {\n $text = $m[1];\n }\n // Narrow to the outermost JSON object/array.\n if (preg_match('/[\\{\\[][\\s\\S]*[\\}\\]]/s', $text, $m)) {\n $text = $m[0];\n }\n $text = trim($text);\n\n $decoded = json_decode($text, true);\n\n // The response can be truncated when the model hits the token limit,\n // leaving an unterminated JSON string/object. Try to repair it before\n // giving up so we can still salvage the items extracted so far.\n if (! is_array($decoded)) {\n $repaired = $this->repairTruncatedJson($text);\n if ($repaired !== null) {\n $decoded = json_decode($repaired, true);\n }\n }\n\n if (! is_array($decoded)) {\n Log::error('QuotationAiService: DeepSeek non-JSON response.', ['text' => mb_substr($text, 0, 1000)]);\n return $this->failure('DeepSeek returned a non-JSON response. The file may be too large — try splitting it into smaller files.');\n }\n\n $rawItems = $decoded['items'] ?? (array_is_list($decoded) ? $decoded : []);\n $items = [];\n $rejected = [];\n\n foreach ($rawItems as $raw) {\n if (! is_array($raw)) {\n continue;\n }\n $item = $this->normaliseAiItem($raw);\n if ($item === null) {\n continue;\n }\n $check = $this->boqCleaner->filterItem((string) $item['description']);\n if ($check['keep'] ?? false) {\n $items[] = $item;\n } else {\n $item['status'] = 'rejected';\n $item['raw_data']['rejection_reason'] = $check['rejection_reason'] ?? 'Rejected by cleaner';\n $rejected[] = $item;\n }\n }\n\n if (empty($items)) {\n Log::info('QuotationAiService: DeepSeek returned 0 accepted items.', [\n 'raw_items_count' => count($rawItems),\n 'rejected_count' => count($rejected),\n 'first_rejected' => ! empty($rejected) ? ($rejected[0]['description'] ?? '') : '',\n ]);\n }\n\n return ['success' => true, 'items' => $items, 'rejected' => $rejected, 'error' => null];\n }\n\n /**\n * Best-effort repair of a truncated JSON string produced when the model hits\n * its token limit mid-output. Drops any incomplete trailing object, then\n * closes the open string/array/object brackets so the result is valid JSON.\n *\n * Returns the repaired JSON string, or null if it cannot be salvaged.\n */\n private function repairTruncatedJson(string $text): ?string\n {\n if ($text === '') {\n return null;\n }\n\n // Walk the string tracking structural state, ignoring everything inside\n // string literals (and their escapes). Remember the last position where\n // we were back at the top of the \"items\" array between complete elements,\n // so we can cut a half-written trailing object.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($text);\n $lastSafeCut = null; // index just after a completed array element\n\n for ($i = 0; $i < $len; $i++) {\n $ch = $text[$i];\n\n if ($inString) {\n if ($escaped) {\n $escaped = false;\n } elseif ($ch === '\\\\') {\n $escaped = true;\n } elseif ($ch === '\"') {\n $inString = false;\n }\n continue;\n }\n\n if ($ch === '\"') {\n $inString = true;\n } elseif ($ch === '{' || $ch === '[') {\n $stack[] = $ch;\n } elseif ($ch === '}' || $ch === ']') {\n array_pop($stack);\n } elseif ($ch === ',' && ! empty($stack) && end($stack) === '[') {\n // Separator directly inside an array: a clean boundary between\n // elements (the items array, whatever its nesting depth).\n $lastSafeCut = $i;\n }\n }\n\n // If we are still inside a string, the last element is incomplete — fall\n // back to the last clean array boundary if we have one.\n $candidate = $text;\n if ($inString && $lastSafeCut !== null) {\n $candidate = substr($text, 0, $lastSafeCut);\n } else {\n // Drop a dangling partial object after the last completed element.\n $candidate = rtrim($candidate);\n if ($lastSafeCut !== null && substr(rtrim($candidate), -1) !== '}' && substr(rtrim($candidate), -1) !== ']') {\n $candidate = substr($text, 0, $lastSafeCut);\n }\n }\n\n // Recompute the open brackets for the (possibly trimmed) candidate and\n // append the matching closers in reverse order.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($candidate);\n for ($i = 0; $i < $len; $i++) {\n $ch = $candidate[$i];\n if ($inString) {\n if ($escaped) { $escaped = false; }\n elseif ($ch === '\\\\') { $escaped = true; }\n elseif ($ch === '\"') { $inString = false; }\n continue;\n }\n if ($ch === '\"') { $inString = true; }\n elseif ($ch === '{' || $ch === '[') { $stack[] = $ch; }\n elseif ($ch === '}' || $ch === ']') { array_pop($stack); }\n }\n\n $candidate = rtrim($candidate);\n // Remove a trailing comma left after cutting an element.\n $candidate = rtrim($candidate, ',');\n\n $closers = '';\n for ($i = count($stack) - 1; $i >= 0; $i--) {\n $closers .= ($stack[$i] === '{') ? '}' : ']';\n }\n\n $repaired = $candidate . $closers;\n\n // Validate before returning.\n return is_array(json_decode($repaired, true)) ? $repaired : null;\n }\n\n private function spreadsheetToCompactCsvText(string $absPath): ?string\n {\n $sheets = $this->spreadsheetToGrids($absPath);\n if (empty($sheets)) {\n return null;\n }\n\n $output = '';\n foreach ($sheets as $sheet) {\n // Don't feed summary / cost-abstract sheets to the AI either.\n if ($this->isSummarySheet($sheet['name'])) {\n continue;\n }\n $output .= 'Sheet: ' . $sheet['name'] . \"\\n\";\n foreach ($sheet['grid'] as $row) {\n $row = $this->trimTrailingEmptyCells($row);\n if (empty($row)) {\n continue;\n }\n $output .= implode(',', array_map([$this, 'csvEscape'], $row)) . \"\\n\";\n }\n $output .= \"\\n\";\n }\n\n return $output;\n }\n\n /**\n * Safely convert a PhpSpreadsheet cell value to a string.\n *\n * Cells may contain RichText or, in malformed/heavily-formatted files,\n * embedded objects such as Worksheet\\Drawing that have no __toString().\n * Casting those directly throws \"Object ... could not be converted to string\".\n */\n private function cellValueToString(mixed $value): string\n {\n if ($value === null) {\n return '';\n }\n\n if ($value instanceof \\PhpOffice\\PhpSpreadsheet\\RichText\\RichText) {\n return $value->getPlainText();\n }\n\n if (is_object($value)) {\n // Only stringify objects that explicitly support it; ignore the rest\n // (e.g. Worksheet\\Drawing) instead of crashing.\n return method_exists($value, '__toString') ? (string) $value : '';\n }\n\n if (is_array($value)) {\n return '';\n }\n\n return (string) $value;\n }\n\n private function csvEscape(mixed $value): string\n {\n $value = $this->cellValueToString($value);\n return str_contains($value, ',') || str_contains($value, '\"') || str_contains($value, \"\\n\")\n ? '\"' . str_replace('\"', '\"\"', $value) . '\"'\n : $value;\n }\n\n private function verifyLargeBoqItemsWithAi(array $result): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n Log::warning('QuotationAiService: large BOQ verification skipped, AI key is not configured.');\n return $result;\n }\n\n $items = array_values(array_filter($result['items'] ?? [], 'is_array'));\n if (count($items) <= self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n return $result;\n }\n\n Log::info('QuotationAiService: starting second-pass physical item verification.', [\n 'items_count' => count($items),\n 'threshold' => self::LARGE_BOQ_VERIFICATION_THRESHOLD,\n ]);\n\n $verifiedItems = [];\n $rejectedItems = $result['rejected'] ?? [];\n\n foreach (array_chunk($items, self::LARGE_BOQ_VERIFICATION_CHUNK_SIZE) as $chunkIndex => $chunk) {\n $payload = array_map(fn(array $item) => [\n 'description' => (string) ($item['description'] ?? ''),\n 'quantity' => $item['quantity'] ?? null,\n 'unit' => (string) ($item['unit'] ?? ''),\n 'unit_price' => $item['unit_price'] ?? null,\n 'category' => (string) ($item['category'] ?? ''),\n 'brand' => (string) ($item['brand'] ?? ''),\n ], $chunk);\n\n $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);\n if (! is_string($json)) {\n Log::warning('QuotationAiService: large BOQ verification chunk JSON encode failed.', [\n 'chunk' => $chunkIndex + 1,\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n $prompt = <<<PROMPT\nYou are doing a SECOND PASS verification on a BOQ extraction result.\n\nThe first pass may hallucinate non-products when the BOQ has thousands of rows.\nYour job is to keep ONLY physical, tangible, procurable supply products.\n\nRules:\n- Keep items only if a supplier can actually deliver them as materials/equipment.\n- Reject headings, trade names, systems, scopes, prose, specifications-only rows, totals, labor, installation, testing, commissioning, supervision, drawings, insurance, preliminaries, mobilisation, site works, and vague fragments.\n- Do not invent new products.\n- Do not add items that are not in the input.\n- You may clean a product description, but keep the original quantity, unit, unit_price, category, and brand when available.\n\nInput JSON array:\n{$json}\n\nReturn ONLY valid JSON in this shape:\n{\"items\":[{\"description\":\"\",\"quantity\":1,\"unit\":\"\",\"unit_price\":null,\"category\":\"\",\"brand\":\"\",\"confidence\":0.95}]}\nPROMPT;\n\n try {\n $chunkResult = $this->callDeepSeekChat($prompt, $apiKey, $this->deepSeekModel());\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: large BOQ verification chunk threw an exception.', [\n 'chunk' => $chunkIndex + 1,\n 'message' => $e->getMessage(),\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n if ($chunkResult['success'] ?? false) {\n array_push($verifiedItems, ...($chunkResult['items'] ?? []));\n $rejectedItems = array_merge($rejectedItems, $chunkResult['rejected'] ?? []);\n continue;\n }\n\n Log::warning('QuotationAiService: large BOQ verification chunk failed, keeping original chunk.', [\n 'chunk' => $chunkIndex + 1,\n 'error' => $chunkResult['error'] ?? null,\n ]);\n array_push($verifiedItems, ...$chunk);\n }\n\n if (empty($verifiedItems)) {\n Log::warning('QuotationAiService: second-pass verification returned no items, keeping first-pass result.', [\n 'original_items_count' => count($items),\n ]);\n return $result;\n }\n\n $result['items'] = $verifiedItems;\n $result['rejected'] = $rejectedItems;\n $result['large_boq_verified'] = true;\n\n Log::info('QuotationAiService: second-pass physical item verification completed.', [\n 'before' => count($items),\n 'after' => count($verifiedItems),\n ]);\n\n return $result;\n }\n\n private function normaliseAiItem(array $raw): ?array\n {\n if (! empty($raw['rejected'])) {\n return null;\n }\n\n $description = trim((string) ($raw['cleaned_description'] ?? $raw['product_name'] ?? $raw['description'] ?? ''));\n if ($description === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $description)) {\n return null;\n }\n\n $quantity = is_numeric($raw['quantity'] ?? null) ? (float) $raw['quantity'] : 1;\n $unitPrice = is_numeric($raw['unit_price'] ?? null) ? (float) $raw['unit_price'] : null;\n $totalPrice = is_numeric($raw['total_price'] ?? null) ? (float) $raw['total_price'] : null;\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $specs = is_array($raw['specifications'] ?? null) ? $raw['specifications'] : [];\n\n return [\n 'description' => $description,\n 'quantity' => $quantity,\n 'unit' => (string) ($raw['unit'] ?? ''),\n 'category' => (string) ($raw['core_product'] ?? $raw['category'] ?? ''),\n 'brand' => (string) ($specs['brand'] ?? $raw['brand'] ?? ''),\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($description),\n 'unit_price' => $unitPrice,\n 'confidence' => is_numeric($raw['confidence'] ?? null) ? (float) $raw['confidence'] : null,\n 'needs_review' => ! empty($raw['needs_review']),\n 'raw_data' => [\n 'source_item_no' => (string) ($raw['source_item_no'] ?? ''),\n 'original_description' => (string) ($raw['original_description'] ?? $description),\n 'cleaned_description' => $description,\n 'core_product' => (string) ($raw['core_product'] ?? ''),\n 'specifications' => $specs,\n 'extraction_type' => (string) ($raw['extraction_type'] ?? 'supply_only'),\n 'note' => (string) ($raw['note'] ?? $raw['notes'] ?? ''),\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function buildSystemInstruction(): string\n {\n return <<<'TXT'\nYou are a strict BOQ Supply Product Extraction Engine.\nExtract ONLY concrete, tangible, physical products that a supplier can box up and deliver to site — a specific item you could point at and say \"this is the thing being bought\" (e.g. \"Wall mounted LED luminaire 36W\", \"PVC pipe 110mm\", \"Single phase distribution board 12-way\", \"Fire extinguisher CO2 6kg\", \"CCTV dome camera 4MP\").\n\nABSOLUTELY REJECT abstract concepts, disciplines, categories, section names, and trade headings. These are NOT products even though they appear as rows:\n- Bare discipline/trade words: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", \"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\".\n- Summary / total / abstract / \"dry cost\" rows of any kind.\n- Anything that names a system, scope, or category instead of one buyable item.\n\nRule of thumb: if you cannot name a unit and physically hand the item over (a piece, a box, a length, a unit of equipment), REJECT it. A word that describes a field of work, a system, or a section of the BOQ is NEVER a product.\nReject totals, headings, general requirements, installation-only, testing, commissioning, supervision, mobilization, civil works, labor, and vague rows.\nFor Supply & Install rows, keep only the supply product and remove install/labor wording.\nReturn only valid JSON: {\"items\":[{\"source_item_no\":null,\"original_description\":\"\",\"cleaned_description\":\"\",\"core_product\":\"\",\"specifications\":{},\"quantity\":0,\"unit\":\"\",\"unit_price\":0,\"total_price\":0,\"extraction_type\":\"supply_only\",\"needs_review\":false,\"note\":\"\",\"rejected\":false,\"rejection_reason\":null,\"confidence\":0.95}]}\nTXT;\n }\n\n private function buildDeepSeekPrompt(array $context = [], string $mime = ''): string\n {\n $projectName = (string) ($context['project_name'] ?? '');\n $projectStatus = (string) ($context['project_status'] ?? '');\n $contextLine = $projectName !== ''\n ? \"Project: {$projectName}\" . ($projectStatus !== '' ? \" ({$projectStatus})\" : '')\n : '';\n $sourceHint = str_starts_with($mime, 'image/')\n ? 'The input is an image of a BOQ table. OCR every cell carefully — read the quantity/count column (العدد, الكمية, Qty) and extract the EXACT numeric value from each row.'\n : 'The input is a BOQ document/spreadsheet.';\n\n return trim(<<<PROMPT\nYou are a procurement specialist. Your task is to extract ONLY tangible, physical supply products from this Bill of Quantities (BOQ).\n\n{$contextLine}\n{$sourceHint}\n\nCRITICAL — MERGE WRAPPED / SPLIT ROWS INTO ONE ITEM:\nBOQ tables extracted from PDF often break ONE item's long description across several\nlines/rows. These continuation lines repeat the SAME quantity, unit and category, and\ntheir text is a grammatical continuation of the previous line (it starts mid-sentence,\nlowercase, or with \"the/and/-\", or is a spec/instruction clause). You MUST reconstruct\nthe original single item:\n- Treat consecutive rows that share the SAME quantity + unit + category as ONE product\n when the later rows are clearly continuation text of the first, NOT new products.\n- Emit ONE item with the merged description and the price counted ONCE. Do NOT repeat\n the same quantity×price for every wrapped line — that multiplies the total wrongly.\n- Example: rows \"The asphalt flooring is to be supplied\" / \"installed in accordance\" /\n \"standard road specifications...\" all with 7,100 m2 are ONE asphalt item, not 3+.\n\nCRITICAL — UNDERSTAND INTENT, DO NOT COPY ROWS LITERALLY:\nYour job is to output WHAT A SUPPLIER MUST ACTUALLY DELIVER, not a copy of the row text.\nRead each description for MEANING and decide how many real, separately-buyable products it\nimplies. One BOQ row can imply SEVERAL products, or a spread of rows can imply ONE product.\n- SPLIT a row into multiple items when it lists distinct products that a supplier would\n buy/price separately — even when joined by commas, \"with\", \"and\", \"including\", \"c/w\",\n \"consisting of\", \"comprising\", or \":\" then a list.\n Example: \"Fire pump set consisting of: fire pump, electric motor, control panel, diesel\n engine\" → FOUR items: (1) fire pump, (2) electric motor, (3) control panel, (4) diesel\n engine — each with its own quantity and priced on its own.\n Example: \"Wash basin complete with mixer tap and bottle trap\" → THREE items: wash basin,\n mixer tap, bottle trap.\n- Give each split product the correct quantity implied by the text (usually the parent\n quantity, unless the text states a different count for a sub-item). Each product is\n priced individually; the line total becomes the SUM of its products.\n- DO NOT split when the extra words are just specifications, dimensions, colours, ratings,\n standards, or a single product's integral parts. \"Steel door 900x2100 with vision panel\n and SS ironmongery\" is usually ONE door product — vision panel and ironmongery are part\n of that door, not separately-bought items. Use judgement: would a supplier issue a\n separate price line for it? If no, keep it as one.\n- Balanced approach: split only when genuinely separate products are present; when in doubt\n about whether something is a separate product vs. an accessory/spec of the main one, keep\n it as a single item. Do not invent products that are not in the text.\n\nSTRICT RULES — include ONLY items that are ALL of the following:\n1. A specific, concrete, tangible product that can be boxed up by a supplier and delivered to site — something you can point at and say \"this exact thing is being bought\" (e.g. \"LED luminaire 36W\", \"PVC pipe 110mm\", \"Distribution board 12-way\", \"CO2 fire extinguisher 6kg\").\n2. Has a real quantity (not 0 or \"varies\" with no meaning).\n3. Has enough description to identify what the product is.\n4. Is an actual PRODUCT, not a sentence, instruction, or specification clause. REJECT rows that are prose such as \"the supervising engineer's instructions\", \"The price includes all materials, labor\", \"Measurement will be based on the geometric cubic meter\", \"necessary steps to complete the work\", \"soil-contacting elements\", \"in compliance\", \"everything necessary\". These are description fragments, not buyable items.\n\nNEVER extract abstract concepts, disciplines, trades, systems, or section/category names — they are NOT products even when they appear as their own row:\n- Bare discipline/trade/system words such as: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", and their Arabic equivalents (\"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\", \"تشطيبات\").\n- Test: if the word names a FIELD OF WORK or a SYSTEM rather than one buyable item with a unit (pcs, m, set, kg…), REJECT it.\n\nEXCLUDE all of the following (do NOT include them):\n- Labor, installation, or workmanship items (e.g. \"install\", \"fix\", \"erect\", \"dismantle\", \"painting\", \"cleaning service\")\n- Project management, overhead, or profit items\n- Insurance, bonds, liability items (e.g. \"bodily injury\", \"third party liability\", \"CAR insurance\")\n- Shop drawings, as-built drawings, method statements, documentation\n- Preliminary items, mobilisation, demobilisation, provisional sums\n- Housekeeping, site clearance, waste removal\n- Floor/level labels (e.g. \"GF Floor\", \"Level 3\") — these are headers, not products\n- Pure dimension specs without a product noun (e.g. \"Dia 32 mm\", \"25 mm\") — reject unless a product name is present\n- Security services, night shifts, rentals, payments\n- Any item whose description is too vague or fragmentary to identify a specific product\n\nEmit ONE JSON item PER real product. A single source row may therefore produce several\nitems (when it lists multiple products) or several rows may collapse into one item (when\nthey are wrapped fragments of the same product). The \"items\" array is your final delivery\nlist, not a row-by-row transcript.\n\nFor each valid physical product return JSON with:\n- description: clean product name only (strip \"supply and install\", \"supply and fix\", installation clauses, floor/spec suffixes). For a split product, use just that product's own name.\n- quantity: the EXACT numeric value from the quantity/count column (العدد, الكمية, Qty, Quantity, No.). NEVER default to 1 unless the quantity column is truly absent or empty.\n- unit: unit of measure (pcs, m, m2, m3, set, no, kg, etc.)\n- category: product category\n- brand: brand/manufacturer if mentioned, else \"\"\n- engineering_required: true if the item needs engineering design before procurement (panel boards, chillers, generators, fire suppression systems, BMS, pumps, AHUs, switchgear, transformers), else false\n\nReturn ONLY a valid JSON object: {\"items\": [...]}. No explanations, no markdown, no extra text.\nPROMPT);\n }\n\n private function mockResponse(): array\n {\n return [\n 'success' => true,\n 'error' => null,\n 'items' => [[\n 'description' => 'Sample BOQ Item',\n 'quantity' => 1,\n 'unit' => 'No',\n 'category' => 'Sample',\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 1,\n 'raw_data' => [],\n 'ai_extracted' => true,\n ]],\n 'rejected' => [],\n ];\n }\n\n private function failure(string $message, bool $serviceUnavailable = false): array\n {\n return [\n 'success' => false,\n 'items' => [],\n 'rejected' => [],\n 'error' => $this->userFacingError($message, $serviceUnavailable),\n 'service_unavailable' => $serviceUnavailable,\n ];\n }\n\n /**\n * Translate any internal error into a neutral, user-safe message.\n *\n * The user must never see the name of the underlying AI/processing engine\n * (DeepSeek, Groq, Gemini, Vision, etc.) or configuration hints like API\n * keys. The detailed message is kept in the logs for debugging only.\n */\n private function userFacingError(string $message, bool $serviceUnavailable = false): string\n {\n // Detect any reference to an AI provider / engine / API config so we can\n // swap it for a generic, branded-neutral message.\n $sensitive = '/deepseek|groq|gemini|vision|openai|openrouter|api[\\s_-]?key|\\.env|non-?json|token limit/i';\n\n if (preg_match($sensitive, $message)) {\n Log::warning('QuotationAiService: suppressed technical error from user.', ['internal' => $message]);\n\n return $serviceUnavailable\n ? 'The file could not be processed right now. Please try again in a moment.'\n : 'We could not read this file automatically. Please make sure it is a clear BOQ (Excel, CSV, PDF, or image) and try again, or add the items manually.';\n }\n\n return $message;\n }\n\n private function mimeForExtension(string $ext): string\n {\n return match (strtolower($ext)) {\n 'pdf' => 'application/pdf',\n 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n 'xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12',\n 'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',\n 'xls' => 'application/vnd.ms-excel',\n 'csv' => 'text/csv',\n 'jpg', 'jpeg' => 'image/jpeg',\n 'png' => 'image/png',\n 'gif' => 'image/gif',\n 'bmp' => 'image/bmp',\n 'webp' => 'image/webp',\n 'tiff', 'tif' => 'image/tiff',\n 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'doc' => 'application/msword',\n default => 'application/octet-stream',\n };\n }\n}\n"}, "54c919aa45efc8162f2a8fb035023e12abf6eeea": {"t": 1785748370.8722367, "n": 74329, "h": "d6908808f3282639", "c": "<?php\n\nnamespace App\\Services;\n\nuse Illuminate\\Http\\Client\\ConnectionException;\nuse Illuminate\\Http\\UploadedFile;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\nuse PhpOffice\\PhpSpreadsheet\\Cell\\Coordinate;\nuse PhpOffice\\PhpSpreadsheet\\IOFactory;\n\nclass QuotationAiService\n{\n /** Increment this whenever BOQ parsing logic changes to invalidate old caches. */\n private const PARSER_VERSION = 10;\n private const LARGE_BOQ_VERIFICATION_THRESHOLD = 2000;\n private const LARGE_BOQ_VERIFICATION_CHUNK_SIZE = 120;\n\n /**\n * Characters of document text sent per AI call.\n *\n * Above this the document is split and parsed over several calls. Sized well\n * under the model's context so the prompt, the slice, and a full JSON answer\n * for every row in it all fit — the binding limit is the *output* budget, not\n * the input, since each input row produces a JSON object.\n */\n private const TEXT_CHUNK_CHARS = 60000;\n\n /**\n * A workbook with more sheets than this is split regardless of its size.\n *\n * Multiple tabs almost always mean multiple disciplines (electrical, HVAC,\n * civil…). Sending them in one call makes the model summarise across\n * sections and silently drop rows, so each slice gets a narrower scope.\n */\n private const MAX_SHEETS_PER_CALL = 2;\n\n /** A PDF longer than this is split regardless of its extracted text length. */\n private const MAX_PDF_PAGES_PER_CALL = 5;\n\n private string $baseUrl;\n private string $parseEndpoint;\n private string $apiKey;\n private int $timeout;\n private bool $testMode;\n private BoqCleaningService $boqCleaner;\n\n /** Page count of the most recently parsed PDF; 0 when unknown. */\n private int $lastPdfPageCount = 0;\n\n /**\n * Optional progress reporter for chunked parsing, called as ($part, $total).\n *\n * Set by the queue job so the polling UI can show real progress on a large\n * BOQ instead of a spinner that appears frozen for many minutes.\n *\n * @var (callable(int, int): void)|null\n */\n private $onChunkProgress = null;\n\n /** @param callable(int, int): void $callback */\n public function onChunkProgress(callable $callback): self\n {\n $this->onChunkProgress = $callback;\n return $this;\n }\n\n /**\n * Optional per-chunk item sink, called as ($items, $part, $total).\n *\n * Lets the caller persist each slice's rows the moment they arrive instead\n * of waiting for the whole document. On a large BOQ this is the difference\n * between a table that fills in progressively and one that stays empty for\n * many minutes.\n *\n * @var (callable(array, int, int): void)|null\n */\n private $onChunkItems = null;\n\n /** @param callable(array, int, int): void $callback */\n public function onChunkItems(callable $callback): self\n {\n $this->onChunkItems = $callback;\n return $this;\n }\n\n /**\n * Report a non-chunk stage (local parse, handing off to AI) to the caller.\n *\n * A spreadsheet is read locally first and only sent to the AI when that\n * fails, so a large file can spend minutes before any chunk exists. Without\n * these the UI shows a generic spinner for that whole period.\n */\n private function reportStage(string $message): void\n {\n if ($this->onStage !== null) {\n ($this->onStage)($message);\n }\n }\n\n /** @var (callable(string): void)|null */\n private $onStage = null;\n\n /** @param callable(string): void $callback */\n public function onStage(callable $callback): self\n {\n $this->onStage = $callback;\n return $this;\n }\n\n public function __construct(BoqCleaningService $boqCleaner)\n {\n $this->boqCleaner = $boqCleaner;\n $this->baseUrl = rtrim((string) config('services.ai_quotation.base_url', ''), '/');\n $this->parseEndpoint = ltrim((string) config('services.ai_quotation.parse_endpoint', 'parse'), '/');\n $this->apiKey = (string) config('services.ai_quotation.api_key', '');\n $this->timeout = (int) config('services.ai_quotation.timeout', 300);\n $this->testMode = (bool) config('services.ai_quotation.test_mode', false);\n }\n\n /**\n * Give the running script enough head-room to outlast the AI HTTP call.\n *\n * The web request / queue worker must not be killed before the HTTP timeout\n * completes, otherwise the user sees a generic \"timeout or unavailable\"\n * error even though the AI may have responded. We allow the HTTP timeout\n * plus a 120s margin for connecting, response parsing and DB writes.\n */\n private function extendExecutionTime(): void\n {\n // Ignored on some SAPIs (e.g. when running under fastcgi with a hard\n // limit), but harmless to attempt.\n @set_time_limit($this->timeout + 120);\n }\n\n public function parseBoq(UploadedFile|string $file, array $context = []): array\n {\n if ($this->testMode) {\n return $this->mockResponse();\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n\n if (! is_file($absPath)) {\n return $this->failure('File not found or not readable.');\n }\n\n $fileHash = hash_file('sha256', $absPath);\n // Bump PARSER_VERSION whenever the extraction logic changes so that\n // previously-uploaded files are re-parsed instead of returning a stale\n // cached result produced by the old code.\n $cacheKey = 'boq_analysis_v' . self::PARSER_VERSION . '_' . $fileHash;\n $forceRefresh = (bool) ($context['force_refresh'] ?? false);\n\n if (! $forceRefresh && ($cached = Cache::get($cacheKey)) !== null) {\n return $cached;\n }\n\n // Clear stale cached result when force-refreshing\n if ($forceRefresh) {\n Cache::forget($cacheKey);\n }\n\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n // Read Excel locally first. Fall back to AI if local parse failed,\n // found 0 items, OR could not read one of the sheets (so multi-sheet\n // files never silently drop a sheet the local parser didn't understand).\n $this->reportStage('Reading the spreadsheet…');\n\n $result = $this->parseSpreadsheetDirect($absPath);\n\n $hasSkippedSheets = ! empty($result['skipped_sheets']);\n\n if (! $result['success'] || empty($result['items']) || $hasSkippedSheets) {\n // The local pass could not handle this file on its own. Say so:\n // otherwise the UI sits on a generic spinner through the local\n // parse AND the whole AI parse with nothing to show for either.\n $this->reportStage($hasSkippedSheets\n ? 'Some sheets need AI to read. Sending to AI…'\n : 'Spreadsheet needs AI to read. Sending to AI…');\n\n $deepSeekResult = $this->parseBoqWithDeepSeek($file, $context);\n // AI sees every sheet at once, so prefer its result whenever it\n // actually returned items (covers the skipped-sheet case too).\n if ($deepSeekResult['success'] && ! empty($deepSeekResult['items'])) {\n // Keep the larger of the two item sets so we never lose the\n // rows the local parser already extracted correctly.\n if (count($deepSeekResult['items']) >= count($result['items'] ?? [])) {\n $result = $deepSeekResult;\n }\n } elseif (! $result['success']) {\n $result = $deepSeekResult;\n }\n // else: keep local result if AI also failed/returned nothing\n }\n } elseif (in_array($ext, ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif'], true)) {\n $result = $this->parseBoqWithDeepSeek($file, $context);\n } else {\n return $this->failure('Unsupported file type. Please upload Excel, CSV, PDF, or image file.');\n }\n\n if ($result['success'] && ! empty($result['items'])) {\n $cleaning = $this->boqCleaner->process($result['items']);\n $result['items'] = $cleaning['accepted'] ?? $result['items'];\n $result['rejected'] = array_merge($result['rejected'] ?? [], $cleaning['rejected'] ?? []);\n }\n\n if ($result['success'] && count($result['items'] ?? []) > self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n $result = $this->verifyLargeBoqItemsWithAi($result);\n }\n\n // Only cache when we actually extracted items; empty results should be re-tried.\n if ($result['success'] && ! empty($result['items'])) {\n Cache::put($cacheKey, $result, now()->addDays(30));\n }\n\n return $result;\n }\n\n private function resolveFile(UploadedFile|string $file): array\n {\n if ($file instanceof UploadedFile) {\n return [(string) $file->getRealPath(), strtolower($file->getClientOriginalExtension())];\n }\n\n $absPath = is_file($file) ? $file : storage_path('app/' . ltrim($file, '/'));\n return [$absPath, strtolower(pathinfo($absPath, PATHINFO_EXTENSION))];\n }\n\n private function parseSpreadsheetDirect(string $absPath): array\n {\n ini_set('memory_limit', '1024M');\n set_time_limit(180);\n\n try {\n $sheets = $this->spreadsheetToGrids($absPath);\n\n if (empty($sheets)) {\n return $this->failure('Could not read the Excel file.');\n }\n\n $items = [];\n $rejected = [];\n $skippedSheets = []; // sheets we could not parse locally (header undetected)\n\n foreach ($sheets as $sheet) {\n $grid = $sheet['grid'];\n if (count($grid) < 2) {\n continue;\n }\n\n // Skip dedicated summary / cost-abstract sheets entirely. Their rows\n // are per-discipline totals (Structural Concrete, Electrical, …) and\n // grand totals, not procurable products.\n if ($this->isSummarySheet($sheet['name'])) {\n Log::info('QuotationAiService: skipped summary sheet.', ['sheet' => $sheet['name']]);\n continue;\n }\n\n $header = $this->detectHeader($grid);\n\n if ($header === null || ! isset($header['map']['description'])) {\n Log::warning('QuotationAiService: header not detected in sheet.', ['sheet' => $sheet['name']]);\n $skippedSheets[] = $sheet['name'];\n continue;\n }\n\n $map = $header['map'];\n $start = $header['row'] + 1;\n\n for ($r = $start; $r < count($grid); $r++) {\n $row = $grid[$r];\n $description = $this->cell($row, $map['description'] ?? null);\n $itemNo = $this->cell($row, $map['item_code'] ?? null);\n $unit = $this->cell($row, $map['unit'] ?? null);\n $quantity = $this->number($this->cell($row, $map['quantity'] ?? null));\n $unitPrice = $this->number($this->cell($row, $map['unit_price'] ?? null));\n $totalPrice = $this->number($this->cell($row, $map['total_price'] ?? null));\n\n if ($description === '') {\n continue;\n }\n\n if (! isset($map['quantity'])) {\n $quantity = 1.0;\n }\n\n // Skip floor breakdown sub-rows: Ref. is \"-\" meaning it's a per-floor\n // quantity split of the parent item, not a standalone product.\n if ($itemNo === '-' || $this->isFloorBreakdownRow($itemNo, $description)) {\n continue;\n }\n\n if ($this->isTotalOrHeaderRow($description)) {\n continue;\n }\n\n if ($quantity === null || $quantity <= 0) {\n $rejected[] = $this->rejectedRow($description, $unit, $quantity, 'Missing quantity', $itemNo, $sheet['name']);\n continue;\n }\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $supply = $this->boqCleaner->filterItem($description, $unitPrice, $quantity);\n\n if (! ($supply['keep'] ?? false)) {\n $rejected[] = $this->rejectedRow(\n $description,\n $unit,\n $quantity,\n (string) ($supply['rejection_reason'] ?? 'Rejected by cleaner'),\n $itemNo,\n $sheet['name']\n );\n continue;\n }\n\n $finalDescription = (string) ($supply['description'] ?? $description);\n\n $items[] = [\n 'description' => $finalDescription,\n 'quantity' => $quantity,\n 'unit' => $unit,\n 'category' => $sheet['name'],\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($finalDescription),\n 'unit_price' => $unitPrice,\n 'confidence' => 0.95,\n 'needs_review' => false,\n 'raw_data' => [\n 'sheet' => $sheet['name'],\n 'row_number' => $r + 1,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'cleaned_description' => $finalDescription,\n 'extraction_type' => (string) ($supply['extraction_type'] ?? 'supply_only'),\n ],\n 'ai_extracted' => true,\n ];\n }\n }\n\n if (empty($items)) {\n return $this->failure('No BOQ supply items could be extracted from the Excel file.');\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'skipped_sheets' => $skippedSheets,\n ];\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: parseSpreadsheetDirect failed.', [\n 'path' => $absPath,\n 'message' => $e->getMessage(),\n 'trace' => $e->getTraceAsString(),\n ]);\n\n return $this->failure('Failed to read the Excel file: ' . $e->getMessage());\n }\n }\n\n private function spreadsheetToGrids(string $absPath): array\n {\n $ext = strtolower(pathinfo($absPath, PATHINFO_EXTENSION));\n\n if ($ext === 'csv') {\n $rows = [];\n $handle = fopen($absPath, 'rb');\n if ($handle !== false) {\n while (($row = fgetcsv($handle)) !== false) {\n $rows[] = $this->trimTrailingEmptyCells($row);\n }\n fclose($handle);\n }\n return [['name' => 'Sheet1', 'grid' => $rows]];\n }\n\n $reader = IOFactory::createReader($ext === 'xls' ? 'Xls' : 'Xlsx');\n $reader->setReadDataOnly(true);\n $spreadsheet = $reader->load($absPath);\n $sheets = [];\n\n foreach ($spreadsheet->getAllSheets() as $sheet) {\n $highestRow = $sheet->getHighestDataRow();\n $highestColumn = $sheet->getHighestDataColumn();\n $highestColumnIndex = Coordinate::columnIndexFromString($highestColumn);\n\n // BOQ data is normally in first 8-20 columns.\n // Files with heavy formatting/images can report 300+ columns — limit to 30.\n $maxColumnIndex = min($highestColumnIndex, 30);\n\n $grid = [];\n for ($row = 1; $row <= $highestRow; $row++) {\n $rowData = [];\n $hasValue = false;\n\n for ($col = 1; $col <= $maxColumnIndex; $col++) {\n $value = $sheet->getCell([$col, $row])->getValue();\n $value = $this->cellValueToString($value);\n $value = trim($value);\n if ($value !== null && $value !== '') {\n $hasValue = true;\n }\n $rowData[] = $value;\n }\n\n $rowData = $this->trimTrailingEmptyCells($rowData);\n if ($hasValue) {\n $grid[] = $rowData;\n }\n }\n\n if (! empty($grid)) {\n $sheets[] = ['name' => $sheet->getTitle(), 'grid' => $grid];\n }\n }\n\n $spreadsheet->disconnectWorksheets();\n unset($spreadsheet);\n\n return $sheets;\n }\n\n private function detectHeader(array $grid): ?array\n {\n $keywords = [\n 'item_code' => ['رقم البند', 'item no', 'item number', 'ref.', 'ref', 'item', 'no.', 'no', '#', 'كود'],\n 'description' => ['وص٠البند', 'scope of works', 'scope of work', 'scope', 'item description', 'description of works', 'description', 'بالعربية', 'الوصÙ', 'البند', 'بيان'],\n 'unit' => ['Ø§Ù„ÙˆØØ¯Ø©', 'unit', 'uom', 'u/m'],\n 'quantity' => ['الكمية', 'qty', 'quantity', 'q.ty'],\n 'unit_price' => ['سعر Ø§Ù„ÙˆØØ¯Ø©', 'u/price', 'unit price', 'unit rate', 'rate', 'price'],\n 'total_price' => ['السعر الإجمالي', 'total amount', 'total price', 'amount', 'الإجمالي'],\n ];\n\n $best = null;\n $bestScore = 0;\n\n for ($r = 0; $r < min(30, count($grid)); $r++) {\n $map = [];\n $score = 0;\n\n foreach ($grid[$r] as $c => $value) {\n $cell = mb_strtolower(trim((string) $value));\n if ($cell === '') {\n continue;\n }\n\n foreach ($keywords as $field => $list) {\n foreach ($list as $kw) {\n if (str_contains($cell, mb_strtolower($kw))) {\n if (! isset($map[$field])) {\n $map[$field] = $c;\n $score++;\n }\n break 2;\n }\n }\n }\n }\n\n if ($score > $bestScore) {\n $bestScore = $score;\n $best = ['row' => $r, 'map' => $map];\n }\n }\n\n if ($bestScore >= 3) {\n return $best;\n }\n\n // Allow simpler BOQ sheets that only define a description column plus one\n // other BOQ-related column, e.g. Item + Description or Description + Qty.\n if ($bestScore === 2 && isset($best['map']['description']) && (\n isset($best['map']['item_code']) ||\n isset($best['map']['quantity']) ||\n isset($best['map']['unit'])\n )) {\n return $best;\n }\n\n return null;\n }\n\n private function cell(array $row, ?int $index): string\n {\n if ($index === null) {\n return '';\n }\n return trim((string) ($row[$index] ?? ''));\n }\n\n private function number(string $value): ?float\n {\n $value = trim(str_replace([',', 'SAR', 'ر.س'], '', $value));\n if ($value === '' || ! is_numeric($value)) {\n return null;\n }\n return (float) $value;\n }\n\n private function trimTrailingEmptyCells(array $row): array\n {\n while (! empty($row) && trim((string) end($row)) === '') {\n array_pop($row);\n }\n return array_values($row);\n }\n\n private function isTotalOrHeaderRow(string $description): bool\n {\n $d = mb_strtolower(trim($description));\n\n if ($d === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $d)) {\n return true;\n }\n\n // Section totals / summaries\n if (preg_match('/(اجمالي|إجمالي|المجموع|grand\\s*total|sub\\s*total|total\\s*amount|total\\s*price)/iu', $d)) {\n return true;\n }\n\n // Bare \"Total\" lines, optionally with a currency suffix, e.g. \"Total\",\n // \"Total (SAR)\", \"Total cost\", \"Total (SAR) unit cost\".\n if (preg_match('/^total\\b[\\s\\(\\):a-z]*$/iu', $d)) {\n return true;\n }\n\n // Pure floor / level labels used as section headers\n if (preg_match('/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor)\\s*$/iu', $d)) {\n return true;\n }\n\n // Division / section header lines (e.g. \"Division - 02: Wood Works\")\n if (preg_match('/^division\\s*([-:]|\\d)/iu', $d)) {\n return true;\n }\n\n return false;\n }\n\n /**\n * Detect dedicated summary / cost-abstract sheets by their name. These hold\n * per-discipline rollups and grand totals (e.g. a \"Summary (Dry Cost)\" tab),\n * never procurable supply items, so they are skipped wholesale.\n */\n private function isSummarySheet(string $name): bool\n {\n $n = mb_strtolower(trim($name));\n\n if ($n === '') {\n return false;\n }\n\n return (bool) preg_match(\n '/\\b(summary|abstract|cost\\s*summary|grand\\s*summary|recap(?:itulation)?|bill\\s*summary|boq\\s*summary|dry\\s*cost)\\b|ملخص|الملخص|الملخّص|ملخّص|اجمالي|إجمالي/iu',\n $n\n );\n }\n\n private function isFloorBreakdownRow(string $itemNo, string $description): bool\n {\n // A row whose Ref column is empty or a dash is a sub-breakdown row (per-floor split).\n $ref = trim($itemNo);\n if ($ref === '' || $ref === '-') {\n // Confirm by checking the description is a location/floor label\n $d = mb_strtolower(trim($description));\n return (bool) preg_match(\n '/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor|\\d+f)\\s*$/iu',\n $d\n );\n }\n return false;\n }\n\n private function rejectedRow(string $description, string $unit, ?float $quantity, string $reason, string $itemNo = '', string $sheet = ''): array\n {\n return [\n 'description' => $description,\n 'quantity' => $quantity ?? 0,\n 'unit' => $unit,\n 'category' => $sheet,\n 'brand' => '',\n 'status' => 'rejected',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 0.0,\n 'raw_data' => [\n 'sheet' => $sheet,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'extraction_type' => 'rejected',\n 'rejection_reason' => $reason,\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function parseBoqWithDeepSeek(UploadedFile|string $file, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured. Please set DEEPSEEK_API_KEY in .env file.');\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n $mime = $this->mimeForExtension($ext);\n\n try {\n // ── Spreadsheets ──────────────────────────────────────────────────\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n $text = $this->spreadsheetToCompactCsvText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not convert spreadsheet to readable text.');\n }\n\n $text = $this->sanitizeUtf8($text);\n\n // A large BOQ exceeds what one request can carry, so send it in\n // sequential slices and merge. Anything that fits goes as-is.\n //\n // Size alone is not a good enough signal: a workbook with several\n // sheets is structurally a big BOQ even when the text is compact,\n // and asking the model to hold every discipline at once is where\n // rows start getting dropped. So split on either signal.\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS || $sheetCount > self::MAX_SHEETS_PER_CALL) {\n Log::info('QuotationAiService: spreadsheet routed to chunked parsing.', [\n 'chars' => mb_strlen($text),\n 'sheets' => $sheetCount,\n ]);\n return $this->parseTextInChunks($text, 'BOQ spreadsheet converted to CSV', $apiKey, $context);\n }\n\n $userContent = \"BOQ spreadsheet converted to CSV:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── PDF ───────────────────────────────────────────────────────────\n if ($ext === 'pdf') {\n $extracted = $this->extractPdfText($absPath);\n if ($extracted !== null && mb_strlen(trim($extracted)) > 50) {\n $extracted = $this->sanitizeUtf8($extracted);\n\n // Split on either signal — long text, or many pages. A BOQ\n // spanning several pages is a multi-section document even if\n // each page is sparse.\n if (mb_strlen($extracted) > self::TEXT_CHUNK_CHARS\n || $this->lastPdfPageCount > self::MAX_PDF_PAGES_PER_CALL) {\n Log::info('QuotationAiService: PDF routed to chunked parsing.', [\n 'chars' => mb_strlen($extracted),\n 'pages' => $this->lastPdfPageCount,\n ]);\n return $this->parseTextInChunks($extracted, 'BOQ PDF extracted text', $apiKey, $context, 'application/pdf');\n }\n\n $userContent = \"BOQ PDF extracted text:\\n\\n\" . $extracted . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'application/pdf');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n return $this->failure('Could not extract text from the PDF. Please make sure it is a text-based PDF (not a scanned image), or convert it to Excel or CSV.');\n }\n\n // -- Word documents (docx) ---------------------------------------------------\n if (in_array($ext, ['docx', 'doc'], true)) {\n $text = $this->extractDocxText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not extract text from the Word document. Please convert it to Excel, CSV, or PDF.');\n }\n $text = $this->sanitizeUtf8($text);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS) {\n return $this->parseTextInChunks($text, 'BOQ Word document extracted text', $apiKey, $context);\n }\n\n $userContent = \"BOQ Word document extracted text:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── Images ──────────────────────────────────────────────────────────────\n // DeepSeek cannot receive images. A vision-capable AI processes the image\n // directly (no OCR) — configure GROQ_API_KEY or GEMINI_API_KEY in .env.\n $imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif', 'heic', 'heif'];\n if (in_array($ext, $imageExts, true)) {\n $bytes = (string) file_get_contents($absPath);\n return $this->callDeepSeekVision($bytes, $mime, $context);\n }\n\n return $this->failure('Unsupported file type. Please upload an Excel, CSV, PDF, or image file.');\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: DeepSeek fallback failed.', ['message' => $e->getMessage()]);\n return $this->failure('An error occurred while processing the file with AI: ' . $e->getMessage());\n }\n }\n\n private function deepSeekModel(): string\n {\n return (string) config('services.deepseek.model', 'deepseek-chat');\n }\n\n /**\n * Remove or replace any non-UTF-8 / malformed byte sequences so json_encode never fails.\n */\n private function sanitizeUtf8(string $text): string\n {\n // Replace invalid byte sequences with the UTF-8 replacement character\n $clean = mb_convert_encoding($text, 'UTF-8', 'UTF-8');\n // Strip any remaining non-printable control characters (except tab/newline/CR)\n $clean = (string) preg_replace('/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]/u', '', $clean);\n return $clean;\n }\n\n /**\n * Extract plain text from a Word docx file using ZipArchive (no external library needed).\n * Reads word/document.xml and strips XML tags, preserving paragraph and table structure.\n */\n private function extractDocxText(string $absPath): ?string\n {\n try {\n $zip = new \\ZipArchive();\n if ($zip->open($absPath) !== true) {\n return null;\n }\n\n $xml = $zip->getFromName('word/document.xml');\n $zip->close();\n\n if ($xml === false || $xml === '') {\n return null;\n }\n\n // Add newlines before paragraph/table-row/table-cell tags so structure is preserved\n $xml = preg_replace('/<w:p[ >]/', \"\\n<w:p>\", $xml) ?? $xml;\n $xml = preg_replace('/<w:tr[ >]/', \"\\n<w:tr>\", $xml) ?? $xml;\n $xml = preg_replace('/<\\/w:tc>/', \"\\t\", $xml) ?? $xml;\n\n $text = strip_tags($xml);\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n return trim($text) !== '' ? trim($text) : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: DOCX parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Extract plain text from a PDF using smalot/pdfparser (pure PHP, no binary required).\n */\n private function extractPdfText(string $absPath): ?string\n {\n try {\n $parser = new \\Smalot\\PdfParser\\Parser();\n $pdf = $parser->parseFile($absPath);\n\n // Recorded for the chunking decision: a long PDF is structurally a\n // big BOQ even when its extracted text is short.\n try {\n $this->lastPdfPageCount = count($pdf->getPages());\n } catch (\\Throwable) {\n $this->lastPdfPageCount = 0;\n }\n\n $text = $pdf->getText();\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n return $text !== '' ? $text : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: PDF parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Send a vision request to a vision-capable AI model.\n * DeepSeek's commercial API (api.deepseek.com) does NOT support image inputs — any model.\n * Priority order (first configured key wins):\n * 1. GROQ_API_KEY → Groq llama-4-scout (free: https://console.groq.com)\n * 2. GEMINI_API_KEY → Google Gemini Flash (free: https://aistudio.google.com/apikey)\n * 3. VISION_API_KEY → any OpenAI-compatible endpoint (OpenAI, OpenRouter …)\n */\n private function callDeepSeekVision(string $bytes, string $mime, array $context): array\n {\n // ── 1. Groq (free, reliable vision) ─────────────────────────────────────\n if ((string) config('services.groq.key', '') !== '') {\n $visionKey = (string) config('services.groq.key');\n $visionBaseUrl = 'https://api.groq.com/openai/v1';\n $visionModel = (string) config('services.groq.model', 'meta-llama/llama-4-scout-17b-16e-instruct');\n }\n // ── 2. Google Gemini ─────────────────────────────────────────────────────\n elseif ((string) config('services.gemini.key', '') !== '') {\n $visionKey = (string) config('services.gemini.key');\n $visionBaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai';\n $visionModel = (string) config('services.gemini.model', 'gemini-2.0-flash');\n }\n // ── 3. Generic VISION_API_KEY (OpenAI, OpenRouter, …) ───────────────────\n elseif ((string) config('services.vision.key', '') !== '') {\n $visionKey = (string) config('services.vision.key');\n $visionBaseUrl = rtrim((string) config('services.vision.base_url', 'https://openrouter.ai/api/v1'), '/');\n $visionModel = (string) config('services.vision.model', 'google/gemini-flash-1.5-8b');\n }\n else {\n return $this->failure(\n 'Image processing is not available. DeepSeek\\'s API does not support images. ' .\n 'Get a free key at https://console.groq.com and add GROQ_API_KEY to your .env file.'\n );\n }\n\n $dataUrl = \"data:{$mime};base64,\" . base64_encode($bytes);\n $prompt = $this->buildDeepSeekPrompt($context, $mime);\n\n $userContent = [\n ['type' => 'text', 'text' => $prompt],\n ['type' => 'image_url', 'image_url' => ['url' => $dataUrl]],\n ];\n\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders([\n 'Authorization' => 'Bearer ' . $visionKey,\n 'HTTP-Referer' => config('app.url', 'https://qimta.sa'),\n 'X-Title' => 'Qimta Platform',\n ])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post($visionBaseUrl . '/chat/completions', [\n 'model' => $visionModel,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 16384,\n 'temperature' => 0.2,\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('Vision API connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: Vision API HTTP error.', [\n 'model' => $visionModel,\n 'base_url' => $visionBaseUrl,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n return $this->failure(\"Vision API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n return $this->parseDeepSeekJsonResponse((string) $response->json('choices.0.message.content'));\n }\n\n private function callDeepSeekChat(string $userContent, string $key, string $model): array\n {\n // Ensure the PHP script outlives the HTTP call (+ a margin for parsing the\n // response and writing to the DB) so the worker/web request is not killed\n // mid-flight, which surfaces to users as a generic \"timeout or unavailable\".\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders(['Authorization' => 'Bearer ' . $key])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 32768,\n 'temperature' => 0.1,\n 'response_format' => ['type' => 'json_object'],\n 'user' => 'Qimta_Platform',\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('DeepSeek connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: DeepSeek HTTP error.', [\n 'model' => $model,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n if ($response->status() === 401) {\n return $this->failure('DeepSeek API key is invalid or unauthorised. Please update DEEPSEEK_API_KEY in .env.', false);\n }\n return $this->failure(\"DeepSeek API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n $text = (string) $response->json('choices.0.message.content');\n return $this->parseDeepSeekJsonResponse($text);\n }\n\n /**\n * Parse a document too large for one AI call by sending it in slices.\n *\n * A 20 000-row BOQ is several megabytes of text — far beyond what a single\n * request can carry, and beyond what the model can answer without hitting its\n * output token limit. Previously the text was simply cut at 180 000 chars and\n * the remainder discarded: the parse \"succeeded\" while silently dropping most\n * of the file. Now the text is split and each slice parsed on its own call,\n * with the items merged.\n *\n * Splitting happens on line boundaries so a BOQ row is never cut in half.\n * Slices run sequentially rather than pooled: DeepSeek rate-limits hard on\n * concurrent large prompts, and this already runs on the queue where wall\n * time is cheap.\n *\n * A slice that fails does not fail the document — its rows are lost, but the\n * rest are kept and the shortfall is logged. Total failure is reported only\n * when every slice fails.\n *\n * @param string $label How the payload is introduced to the model.\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n private function parseTextInChunks(\n string $text,\n string $label,\n string $apiKey,\n array $context,\n string $mime = 'text/plain',\n ): array {\n $chunks = $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n $total = count($chunks);\n\n Log::info('QuotationAiService: large document split for parsing.', [\n 'chars' => mb_strlen($text),\n 'chunks' => $total,\n ]);\n\n // Announce the split before the first slice is sent, so the UI can show\n // how many parts there are instead of sitting on a generic message for\n // however long the first AI call takes.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)(0, $total);\n }\n\n $items = [];\n $rejected = [];\n $failed = 0;\n $lastError = null;\n\n foreach ($chunks as $index => $chunk) {\n $part = $index + 1;\n\n // Report progress so a 28-slice parse does not look frozen to the user.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)($part, $total);\n }\n\n // Tell the model this is a fragment, so it does not try to reconcile\n // totals or treat a mid-document slice as a complete BOQ.\n $userContent = \"{$label} (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, $mime);\n\n $result = $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n\n if (! ($result['success'] ?? false)) {\n $failed++;\n $lastError = $result['error'] ?? 'Chunk parsing failed.';\n Log::warning('QuotationAiService: chunk failed, continuing.', [\n 'part' => $part,\n 'of' => $total,\n 'error' => $lastError,\n ]);\n continue;\n }\n\n foreach ($result['items'] as $item) {\n $items[] = $item;\n }\n foreach ($result['rejected'] ?? [] as $row) {\n $rejected[] = $row;\n }\n\n // Hand this slice's rows straight to the caller so they can be shown\n // while the remaining slices are still being parsed.\n if ($this->onChunkItems !== null && $result['items'] !== []) {\n ($this->onChunkItems)($result['items'], $part, $total);\n }\n }\n\n // Every slice failed — there is nothing to show, so report the failure.\n if ($failed === $total) {\n return $this->failure($lastError ?? 'Could not parse the document.');\n }\n\n if ($failed > 0) {\n Log::error('QuotationAiService: document parsed with missing parts.', [\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n 'items_kept' => count($items),\n ]);\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'service_unavailable' => false,\n 'partial' => $failed > 0,\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n ];\n }\n\n /**\n * Split text into slices of at most $limit characters, breaking only at\n * newlines so no BOQ row is ever cut in half.\n *\n * A single line longer than the limit (rare — a pathological cell) is passed\n * through oversized rather than severed mid-row.\n *\n * @return array<int, string>\n */\n private function splitOnLineBoundaries(string $text, int $limit): array\n {\n $chunks = [];\n $current = '';\n\n // Sheet a chunk starts in the middle of. Repeated at the top of the next\n // chunk so the model always knows which sheet the rows belong to — the\n // header would otherwise be stranded in the previous slice, and rows\n // would arrive with no discipline context.\n $currentSheet = null;\n\n foreach (explode(\"\\n\", $text) as $line) {\n $isSheetHeader = str_starts_with($line, 'Sheet: ');\n\n // Start a new chunk at every sheet boundary. Sheets are usually\n // separate disciplines, and the whole point of splitting a multi-tab\n // workbook is to give the model one discipline at a time — a size-only\n // split would leave a compact 3-tab file as a single chunk, silently\n // undoing the sheet gate that routed it here.\n if ($isSheetHeader && $current !== '') {\n $chunks[] = $current;\n $current = $line;\n $currentSheet = $line;\n continue;\n }\n\n if ($isSheetHeader) {\n $currentSheet = $line;\n }\n\n $candidate = $current === '' ? $line : $current . \"\\n\" . $line;\n\n if (mb_strlen($candidate) > $limit && $current !== '') {\n $chunks[] = $current;\n\n // Carry the sheet header over, unless this line is itself one.\n $current = ($currentSheet !== null && ! str_starts_with($line, 'Sheet: '))\n ? $currentSheet . \" (continued)\\n\" . $line\n : $line;\n continue;\n }\n\n $current = $candidate;\n }\n\n if (trim($current) !== '') {\n $chunks[] = $current;\n }\n\n return $chunks;\n }\n\n /**\n * Shared JSON response parser used by both chat and vision paths.\n */\n private function parseDeepSeekJsonResponse(string $text): array\n {\n if ($text === '') {\n return $this->failure('DeepSeek returned an empty response.');\n }\n\n // Strip markdown code fences if the model wrapped the JSON.\n if (preg_match('/```(?:json)?\\s*([\\s\\S]*?)\\s*```/i', $text, $m)) {\n $text = $m[1];\n }\n // Narrow to the outermost JSON object/array.\n if (preg_match('/[\\{\\[][\\s\\S]*[\\}\\]]/s', $text, $m)) {\n $text = $m[0];\n }\n $text = trim($text);\n\n $decoded = json_decode($text, true);\n\n // The response can be truncated when the model hits the token limit,\n // leaving an unterminated JSON string/object. Try to repair it before\n // giving up so we can still salvage the items extracted so far.\n if (! is_array($decoded)) {\n $repaired = $this->repairTruncatedJson($text);\n if ($repaired !== null) {\n $decoded = json_decode($repaired, true);\n }\n }\n\n if (! is_array($decoded)) {\n Log::error('QuotationAiService: DeepSeek non-JSON response.', ['text' => mb_substr($text, 0, 1000)]);\n return $this->failure('DeepSeek returned a non-JSON response. The file may be too large — try splitting it into smaller files.');\n }\n\n $rawItems = $decoded['items'] ?? (array_is_list($decoded) ? $decoded : []);\n $items = [];\n $rejected = [];\n\n foreach ($rawItems as $raw) {\n if (! is_array($raw)) {\n continue;\n }\n $item = $this->normaliseAiItem($raw);\n if ($item === null) {\n continue;\n }\n $check = $this->boqCleaner->filterItem((string) $item['description']);\n if ($check['keep'] ?? false) {\n $items[] = $item;\n } else {\n $item['status'] = 'rejected';\n $item['raw_data']['rejection_reason'] = $check['rejection_reason'] ?? 'Rejected by cleaner';\n $rejected[] = $item;\n }\n }\n\n if (empty($items)) {\n Log::info('QuotationAiService: DeepSeek returned 0 accepted items.', [\n 'raw_items_count' => count($rawItems),\n 'rejected_count' => count($rejected),\n 'first_rejected' => ! empty($rejected) ? ($rejected[0]['description'] ?? '') : '',\n ]);\n }\n\n return ['success' => true, 'items' => $items, 'rejected' => $rejected, 'error' => null];\n }\n\n /**\n * Best-effort repair of a truncated JSON string produced when the model hits\n * its token limit mid-output. Drops any incomplete trailing object, then\n * closes the open string/array/object brackets so the result is valid JSON.\n *\n * Returns the repaired JSON string, or null if it cannot be salvaged.\n */\n private function repairTruncatedJson(string $text): ?string\n {\n if ($text === '') {\n return null;\n }\n\n // Walk the string tracking structural state, ignoring everything inside\n // string literals (and their escapes). Remember the last position where\n // we were back at the top of the \"items\" array between complete elements,\n // so we can cut a half-written trailing object.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($text);\n $lastSafeCut = null; // index just after a completed array element\n\n for ($i = 0; $i < $len; $i++) {\n $ch = $text[$i];\n\n if ($inString) {\n if ($escaped) {\n $escaped = false;\n } elseif ($ch === '\\\\') {\n $escaped = true;\n } elseif ($ch === '\"') {\n $inString = false;\n }\n continue;\n }\n\n if ($ch === '\"') {\n $inString = true;\n } elseif ($ch === '{' || $ch === '[') {\n $stack[] = $ch;\n } elseif ($ch === '}' || $ch === ']') {\n array_pop($stack);\n } elseif ($ch === ',' && ! empty($stack) && end($stack) === '[') {\n // Separator directly inside an array: a clean boundary between\n // elements (the items array, whatever its nesting depth).\n $lastSafeCut = $i;\n }\n }\n\n // If we are still inside a string, the last element is incomplete — fall\n // back to the last clean array boundary if we have one.\n $candidate = $text;\n if ($inString && $lastSafeCut !== null) {\n $candidate = substr($text, 0, $lastSafeCut);\n } else {\n // Drop a dangling partial object after the last completed element.\n $candidate = rtrim($candidate);\n if ($lastSafeCut !== null && substr(rtrim($candidate), -1) !== '}' && substr(rtrim($candidate), -1) !== ']') {\n $candidate = substr($text, 0, $lastSafeCut);\n }\n }\n\n // Recompute the open brackets for the (possibly trimmed) candidate and\n // append the matching closers in reverse order.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($candidate);\n for ($i = 0; $i < $len; $i++) {\n $ch = $candidate[$i];\n if ($inString) {\n if ($escaped) { $escaped = false; }\n elseif ($ch === '\\\\') { $escaped = true; }\n elseif ($ch === '\"') { $inString = false; }\n continue;\n }\n if ($ch === '\"') { $inString = true; }\n elseif ($ch === '{' || $ch === '[') { $stack[] = $ch; }\n elseif ($ch === '}' || $ch === ']') { array_pop($stack); }\n }\n\n $candidate = rtrim($candidate);\n // Remove a trailing comma left after cutting an element.\n $candidate = rtrim($candidate, ',');\n\n $closers = '';\n for ($i = count($stack) - 1; $i >= 0; $i--) {\n $closers .= ($stack[$i] === '{') ? '}' : ']';\n }\n\n $repaired = $candidate . $closers;\n\n // Validate before returning.\n return is_array(json_decode($repaired, true)) ? $repaired : null;\n }\n\n private function spreadsheetToCompactCsvText(string $absPath): ?string\n {\n $sheets = $this->spreadsheetToGrids($absPath);\n if (empty($sheets)) {\n return null;\n }\n\n $output = '';\n foreach ($sheets as $sheet) {\n // Don't feed summary / cost-abstract sheets to the AI either.\n if ($this->isSummarySheet($sheet['name'])) {\n continue;\n }\n $output .= 'Sheet: ' . $sheet['name'] . \"\\n\";\n foreach ($sheet['grid'] as $row) {\n $row = $this->trimTrailingEmptyCells($row);\n if (empty($row)) {\n continue;\n }\n $output .= implode(',', array_map([$this, 'csvEscape'], $row)) . \"\\n\";\n }\n $output .= \"\\n\";\n }\n\n return $output;\n }\n\n /**\n * Safely convert a PhpSpreadsheet cell value to a string.\n *\n * Cells may contain RichText or, in malformed/heavily-formatted files,\n * embedded objects such as Worksheet\\Drawing that have no __toString().\n * Casting those directly throws \"Object ... could not be converted to string\".\n */\n private function cellValueToString(mixed $value): string\n {\n if ($value === null) {\n return '';\n }\n\n if ($value instanceof \\PhpOffice\\PhpSpreadsheet\\RichText\\RichText) {\n return $value->getPlainText();\n }\n\n if (is_object($value)) {\n // Only stringify objects that explicitly support it; ignore the rest\n // (e.g. Worksheet\\Drawing) instead of crashing.\n return method_exists($value, '__toString') ? (string) $value : '';\n }\n\n if (is_array($value)) {\n return '';\n }\n\n return (string) $value;\n }\n\n private function csvEscape(mixed $value): string\n {\n $value = $this->cellValueToString($value);\n return str_contains($value, ',') || str_contains($value, '\"') || str_contains($value, \"\\n\")\n ? '\"' . str_replace('\"', '\"\"', $value) . '\"'\n : $value;\n }\n\n private function verifyLargeBoqItemsWithAi(array $result): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n Log::warning('QuotationAiService: large BOQ verification skipped, AI key is not configured.');\n return $result;\n }\n\n $items = array_values(array_filter($result['items'] ?? [], 'is_array'));\n if (count($items) <= self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n return $result;\n }\n\n Log::info('QuotationAiService: starting second-pass physical item verification.', [\n 'items_count' => count($items),\n 'threshold' => self::LARGE_BOQ_VERIFICATION_THRESHOLD,\n ]);\n\n $verifiedItems = [];\n $rejectedItems = $result['rejected'] ?? [];\n\n foreach (array_chunk($items, self::LARGE_BOQ_VERIFICATION_CHUNK_SIZE) as $chunkIndex => $chunk) {\n $payload = array_map(fn(array $item) => [\n 'description' => (string) ($item['description'] ?? ''),\n 'quantity' => $item['quantity'] ?? null,\n 'unit' => (string) ($item['unit'] ?? ''),\n 'unit_price' => $item['unit_price'] ?? null,\n 'category' => (string) ($item['category'] ?? ''),\n 'brand' => (string) ($item['brand'] ?? ''),\n ], $chunk);\n\n $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);\n if (! is_string($json)) {\n Log::warning('QuotationAiService: large BOQ verification chunk JSON encode failed.', [\n 'chunk' => $chunkIndex + 1,\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n $prompt = <<<PROMPT\nYou are doing a SECOND PASS verification on a BOQ extraction result.\n\nThe first pass may hallucinate non-products when the BOQ has thousands of rows.\nYour job is to keep ONLY physical, tangible, procurable supply products.\n\nRules:\n- Keep items only if a supplier can actually deliver them as materials/equipment.\n- Reject headings, trade names, systems, scopes, prose, specifications-only rows, totals, labor, installation, testing, commissioning, supervision, drawings, insurance, preliminaries, mobilisation, site works, and vague fragments.\n- Do not invent new products.\n- Do not add items that are not in the input.\n- You may clean a product description, but keep the original quantity, unit, unit_price, category, and brand when available.\n\nInput JSON array:\n{$json}\n\nReturn ONLY valid JSON in this shape:\n{\"items\":[{\"description\":\"\",\"quantity\":1,\"unit\":\"\",\"unit_price\":null,\"category\":\"\",\"brand\":\"\",\"confidence\":0.95}]}\nPROMPT;\n\n try {\n $chunkResult = $this->callDeepSeekChat($prompt, $apiKey, $this->deepSeekModel());\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: large BOQ verification chunk threw an exception.', [\n 'chunk' => $chunkIndex + 1,\n 'message' => $e->getMessage(),\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n if ($chunkResult['success'] ?? false) {\n array_push($verifiedItems, ...($chunkResult['items'] ?? []));\n $rejectedItems = array_merge($rejectedItems, $chunkResult['rejected'] ?? []);\n continue;\n }\n\n Log::warning('QuotationAiService: large BOQ verification chunk failed, keeping original chunk.', [\n 'chunk' => $chunkIndex + 1,\n 'error' => $chunkResult['error'] ?? null,\n ]);\n array_push($verifiedItems, ...$chunk);\n }\n\n if (empty($verifiedItems)) {\n Log::warning('QuotationAiService: second-pass verification returned no items, keeping first-pass result.', [\n 'original_items_count' => count($items),\n ]);\n return $result;\n }\n\n $result['items'] = $verifiedItems;\n $result['rejected'] = $rejectedItems;\n $result['large_boq_verified'] = true;\n\n Log::info('QuotationAiService: second-pass physical item verification completed.', [\n 'before' => count($items),\n 'after' => count($verifiedItems),\n ]);\n\n return $result;\n }\n\n private function normaliseAiItem(array $raw): ?array\n {\n if (! empty($raw['rejected'])) {\n return null;\n }\n\n $description = trim((string) ($raw['cleaned_description'] ?? $raw['product_name'] ?? $raw['description'] ?? ''));\n if ($description === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $description)) {\n return null;\n }\n\n $quantity = is_numeric($raw['quantity'] ?? null) ? (float) $raw['quantity'] : 1;\n $unitPrice = is_numeric($raw['unit_price'] ?? null) ? (float) $raw['unit_price'] : null;\n $totalPrice = is_numeric($raw['total_price'] ?? null) ? (float) $raw['total_price'] : null;\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $specs = is_array($raw['specifications'] ?? null) ? $raw['specifications'] : [];\n\n return [\n 'description' => $description,\n 'quantity' => $quantity,\n 'unit' => (string) ($raw['unit'] ?? ''),\n 'category' => (string) ($raw['core_product'] ?? $raw['category'] ?? ''),\n 'brand' => (string) ($specs['brand'] ?? $raw['brand'] ?? ''),\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($description),\n 'unit_price' => $unitPrice,\n 'confidence' => is_numeric($raw['confidence'] ?? null) ? (float) $raw['confidence'] : null,\n 'needs_review' => ! empty($raw['needs_review']),\n 'raw_data' => [\n 'source_item_no' => (string) ($raw['source_item_no'] ?? ''),\n 'original_description' => (string) ($raw['original_description'] ?? $description),\n 'cleaned_description' => $description,\n 'core_product' => (string) ($raw['core_product'] ?? ''),\n 'specifications' => $specs,\n 'extraction_type' => (string) ($raw['extraction_type'] ?? 'supply_only'),\n 'note' => (string) ($raw['note'] ?? $raw['notes'] ?? ''),\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function buildSystemInstruction(): string\n {\n return <<<'TXT'\nYou are a strict BOQ Supply Product Extraction Engine.\nExtract ONLY concrete, tangible, physical products that a supplier can box up and deliver to site — a specific item you could point at and say \"this is the thing being bought\" (e.g. \"Wall mounted LED luminaire 36W\", \"PVC pipe 110mm\", \"Single phase distribution board 12-way\", \"Fire extinguisher CO2 6kg\", \"CCTV dome camera 4MP\").\n\nABSOLUTELY REJECT abstract concepts, disciplines, categories, section names, and trade headings. These are NOT products even though they appear as rows:\n- Bare discipline/trade words: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", \"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\".\n- Summary / total / abstract / \"dry cost\" rows of any kind.\n- Anything that names a system, scope, or category instead of one buyable item.\n\nRule of thumb: if you cannot name a unit and physically hand the item over (a piece, a box, a length, a unit of equipment), REJECT it. A word that describes a field of work, a system, or a section of the BOQ is NEVER a product.\nReject totals, headings, general requirements, installation-only, testing, commissioning, supervision, mobilization, civil works, labor, and vague rows.\nFor Supply & Install rows, keep only the supply product and remove install/labor wording.\nReturn only valid JSON: {\"items\":[{\"source_item_no\":null,\"original_description\":\"\",\"cleaned_description\":\"\",\"core_product\":\"\",\"specifications\":{},\"quantity\":0,\"unit\":\"\",\"unit_price\":0,\"total_price\":0,\"extraction_type\":\"supply_only\",\"needs_review\":false,\"note\":\"\",\"rejected\":false,\"rejection_reason\":null,\"confidence\":0.95}]}\nTXT;\n }\n\n private function buildDeepSeekPrompt(array $context = [], string $mime = ''): string\n {\n $projectName = (string) ($context['project_name'] ?? '');\n $projectStatus = (string) ($context['project_status'] ?? '');\n $contextLine = $projectName !== ''\n ? \"Project: {$projectName}\" . ($projectStatus !== '' ? \" ({$projectStatus})\" : '')\n : '';\n $sourceHint = str_starts_with($mime, 'image/')\n ? 'The input is an image of a BOQ table. OCR every cell carefully — read the quantity/count column (العدد, الكمية, Qty) and extract the EXACT numeric value from each row.'\n : 'The input is a BOQ document/spreadsheet.';\n\n return trim(<<<PROMPT\nYou are a procurement specialist. Your task is to extract ONLY tangible, physical supply products from this Bill of Quantities (BOQ).\n\n{$contextLine}\n{$sourceHint}\n\nCRITICAL — MERGE WRAPPED / SPLIT ROWS INTO ONE ITEM:\nBOQ tables extracted from PDF often break ONE item's long description across several\nlines/rows. These continuation lines repeat the SAME quantity, unit and category, and\ntheir text is a grammatical continuation of the previous line (it starts mid-sentence,\nlowercase, or with \"the/and/-\", or is a spec/instruction clause). You MUST reconstruct\nthe original single item:\n- Treat consecutive rows that share the SAME quantity + unit + category as ONE product\n when the later rows are clearly continuation text of the first, NOT new products.\n- Emit ONE item with the merged description and the price counted ONCE. Do NOT repeat\n the same quantity×price for every wrapped line — that multiplies the total wrongly.\n- Example: rows \"The asphalt flooring is to be supplied\" / \"installed in accordance\" /\n \"standard road specifications...\" all with 7,100 m2 are ONE asphalt item, not 3+.\n\nCRITICAL — UNDERSTAND INTENT, DO NOT COPY ROWS LITERALLY:\nYour job is to output WHAT A SUPPLIER MUST ACTUALLY DELIVER, not a copy of the row text.\nRead each description for MEANING and decide how many real, separately-buyable products it\nimplies. One BOQ row can imply SEVERAL products, or a spread of rows can imply ONE product.\n- SPLIT a row into multiple items when it lists distinct products that a supplier would\n buy/price separately — even when joined by commas, \"with\", \"and\", \"including\", \"c/w\",\n \"consisting of\", \"comprising\", or \":\" then a list.\n Example: \"Fire pump set consisting of: fire pump, electric motor, control panel, diesel\n engine\" → FOUR items: (1) fire pump, (2) electric motor, (3) control panel, (4) diesel\n engine — each with its own quantity and priced on its own.\n Example: \"Wash basin complete with mixer tap and bottle trap\" → THREE items: wash basin,\n mixer tap, bottle trap.\n- Give each split product the correct quantity implied by the text (usually the parent\n quantity, unless the text states a different count for a sub-item). Each product is\n priced individually; the line total becomes the SUM of its products.\n- DO NOT split when the extra words are just specifications, dimensions, colours, ratings,\n standards, or a single product's integral parts. \"Steel door 900x2100 with vision panel\n and SS ironmongery\" is usually ONE door product — vision panel and ironmongery are part\n of that door, not separately-bought items. Use judgement: would a supplier issue a\n separate price line for it? If no, keep it as one.\n- Balanced approach: split only when genuinely separate products are present; when in doubt\n about whether something is a separate product vs. an accessory/spec of the main one, keep\n it as a single item. Do not invent products that are not in the text.\n\nSTRICT RULES — include ONLY items that are ALL of the following:\n1. A specific, concrete, tangible product that can be boxed up by a supplier and delivered to site — something you can point at and say \"this exact thing is being bought\" (e.g. \"LED luminaire 36W\", \"PVC pipe 110mm\", \"Distribution board 12-way\", \"CO2 fire extinguisher 6kg\").\n2. Has a real quantity (not 0 or \"varies\" with no meaning).\n3. Has enough description to identify what the product is.\n4. Is an actual PRODUCT, not a sentence, instruction, or specification clause. REJECT rows that are prose such as \"the supervising engineer's instructions\", \"The price includes all materials, labor\", \"Measurement will be based on the geometric cubic meter\", \"necessary steps to complete the work\", \"soil-contacting elements\", \"in compliance\", \"everything necessary\". These are description fragments, not buyable items.\n\nNEVER extract abstract concepts, disciplines, trades, systems, or section/category names — they are NOT products even when they appear as their own row:\n- Bare discipline/trade/system words such as: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", and their Arabic equivalents (\"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\", \"تشطيبات\").\n- Test: if the word names a FIELD OF WORK or a SYSTEM rather than one buyable item with a unit (pcs, m, set, kg…), REJECT it.\n\nEXCLUDE all of the following (do NOT include them):\n- Labor, installation, or workmanship items (e.g. \"install\", \"fix\", \"erect\", \"dismantle\", \"painting\", \"cleaning service\")\n- Project management, overhead, or profit items\n- Insurance, bonds, liability items (e.g. \"bodily injury\", \"third party liability\", \"CAR insurance\")\n- Shop drawings, as-built drawings, method statements, documentation\n- Preliminary items, mobilisation, demobilisation, provisional sums\n- Housekeeping, site clearance, waste removal\n- Floor/level labels (e.g. \"GF Floor\", \"Level 3\") — these are headers, not products\n- Pure dimension specs without a product noun (e.g. \"Dia 32 mm\", \"25 mm\") — reject unless a product name is present\n- Security services, night shifts, rentals, payments\n- Any item whose description is too vague or fragmentary to identify a specific product\n\nEmit ONE JSON item PER real product. A single source row may therefore produce several\nitems (when it lists multiple products) or several rows may collapse into one item (when\nthey are wrapped fragments of the same product). The \"items\" array is your final delivery\nlist, not a row-by-row transcript.\n\nFor each valid physical product return JSON with:\n- description: clean product name only (strip \"supply and install\", \"supply and fix\", installation clauses, floor/spec suffixes). For a split product, use just that product's own name.\n- quantity: the EXACT numeric value from the quantity/count column (العدد, الكمية, Qty, Quantity, No.). NEVER default to 1 unless the quantity column is truly absent or empty.\n- unit: unit of measure (pcs, m, m2, m3, set, no, kg, etc.)\n- category: product category\n- brand: brand/manufacturer if mentioned, else \"\"\n- engineering_required: true if the item needs engineering design before procurement (panel boards, chillers, generators, fire suppression systems, BMS, pumps, AHUs, switchgear, transformers), else false\n\nReturn ONLY a valid JSON object: {\"items\": [...]}. No explanations, no markdown, no extra text.\nPROMPT);\n }\n\n private function mockResponse(): array\n {\n return [\n 'success' => true,\n 'error' => null,\n 'items' => [[\n 'description' => 'Sample BOQ Item',\n 'quantity' => 1,\n 'unit' => 'No',\n 'category' => 'Sample',\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 1,\n 'raw_data' => [],\n 'ai_extracted' => true,\n ]],\n 'rejected' => [],\n ];\n }\n\n private function failure(string $message, bool $serviceUnavailable = false): array\n {\n return [\n 'success' => false,\n 'items' => [],\n 'rejected' => [],\n 'error' => $this->userFacingError($message, $serviceUnavailable),\n 'service_unavailable' => $serviceUnavailable,\n ];\n }\n\n /**\n * Translate any internal error into a neutral, user-safe message.\n *\n * The user must never see the name of the underlying AI/processing engine\n * (DeepSeek, Groq, Gemini, Vision, etc.) or configuration hints like API\n * keys. The detailed message is kept in the logs for debugging only.\n */\n private function userFacingError(string $message, bool $serviceUnavailable = false): string\n {\n // Detect any reference to an AI provider / engine / API config so we can\n // swap it for a generic, branded-neutral message.\n $sensitive = '/deepseek|groq|gemini|vision|openai|openrouter|api[\\s_-]?key|\\.env|non-?json|token limit/i';\n\n if (preg_match($sensitive, $message)) {\n Log::warning('QuotationAiService: suppressed technical error from user.', ['internal' => $message]);\n\n return $serviceUnavailable\n ? 'The file could not be processed right now. Please try again in a moment.'\n : 'We could not read this file automatically. Please make sure it is a clear BOQ (Excel, CSV, PDF, or image) and try again, or add the items manually.';\n }\n\n return $message;\n }\n\n private function mimeForExtension(string $ext): string\n {\n return match (strtolower($ext)) {\n 'pdf' => 'application/pdf',\n 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n 'xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12',\n 'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',\n 'xls' => 'application/vnd.ms-excel',\n 'csv' => 'text/csv',\n 'jpg', 'jpeg' => 'image/jpeg',\n 'png' => 'image/png',\n 'gif' => 'image/gif',\n 'bmp' => 'image/bmp',\n 'webp' => 'image/webp',\n 'tiff', 'tif' => 'image/tiff',\n 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'doc' => 'application/msword',\n default => 'application/octet-stream',\n };\n }\n}\n"}, "a04efeddb42344e31b8b514abedeea42174828bc": {"t": 1785748372.2594821, "n": 73093, "h": "bbb11664015c6012", "c": "<?php\n\nnamespace App\\Services;\n\nuse Illuminate\\Http\\Client\\ConnectionException;\nuse Illuminate\\Http\\UploadedFile;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\nuse PhpOffice\\PhpSpreadsheet\\Cell\\Coordinate;\nuse PhpOffice\\PhpSpreadsheet\\IOFactory;\n\nclass QuotationAiService\n{\n /** Increment this whenever BOQ parsing logic changes to invalidate old caches. */\n private const PARSER_VERSION = 10;\n private const LARGE_BOQ_VERIFICATION_THRESHOLD = 2000;\n private const LARGE_BOQ_VERIFICATION_CHUNK_SIZE = 120;\n\n /**\n * Characters of document text sent per AI call.\n *\n * Above this the document is split and parsed over several calls. Sized well\n * under the model's context so the prompt, the slice, and a full JSON answer\n * for every row in it all fit — the binding limit is the *output* budget, not\n * the input, since each input row produces a JSON object.\n */\n private const TEXT_CHUNK_CHARS = 60000;\n\n /**\n * A workbook with more sheets than this is split regardless of its size.\n *\n * Multiple tabs almost always mean multiple disciplines (electrical, HVAC,\n * civil…). Sending them in one call makes the model summarise across\n * sections and silently drop rows, so each slice gets a narrower scope.\n */\n private const MAX_SHEETS_PER_CALL = 2;\n\n /** A PDF longer than this is split regardless of its extracted text length. */\n private const MAX_PDF_PAGES_PER_CALL = 5;\n\n private string $baseUrl;\n private string $parseEndpoint;\n private string $apiKey;\n private int $timeout;\n private bool $testMode;\n private BoqCleaningService $boqCleaner;\n\n /** Page count of the most recently parsed PDF; 0 when unknown. */\n private int $lastPdfPageCount = 0;\n\n /**\n * Optional progress reporter for chunked parsing, called as ($part, $total).\n *\n * Set by the queue job so the polling UI can show real progress on a large\n * BOQ instead of a spinner that appears frozen for many minutes.\n *\n * @var (callable(int, int): void)|null\n */\n private $onChunkProgress = null;\n\n /** @param callable(int, int): void $callback */\n public function onChunkProgress(callable $callback): self\n {\n $this->onChunkProgress = $callback;\n return $this;\n }\n\n /**\n * Optional per-chunk item sink, called as ($items, $part, $total).\n *\n * Lets the caller persist each slice's rows the moment they arrive instead\n * of waiting for the whole document. On a large BOQ this is the difference\n * between a table that fills in progressively and one that stays empty for\n * many minutes.\n *\n * @var (callable(array, int, int): void)|null\n */\n private $onChunkItems = null;\n\n /** @param callable(array, int, int): void $callback */\n public function onChunkItems(callable $callback): self\n {\n $this->onChunkItems = $callback;\n return $this;\n }\n\n public function __construct(BoqCleaningService $boqCleaner)\n {\n $this->boqCleaner = $boqCleaner;\n $this->baseUrl = rtrim((string) config('services.ai_quotation.base_url', ''), '/');\n $this->parseEndpoint = ltrim((string) config('services.ai_quotation.parse_endpoint', 'parse'), '/');\n $this->apiKey = (string) config('services.ai_quotation.api_key', '');\n $this->timeout = (int) config('services.ai_quotation.timeout', 300);\n $this->testMode = (bool) config('services.ai_quotation.test_mode', false);\n }\n\n /**\n * Give the running script enough head-room to outlast the AI HTTP call.\n *\n * The web request / queue worker must not be killed before the HTTP timeout\n * completes, otherwise the user sees a generic \"timeout or unavailable\"\n * error even though the AI may have responded. We allow the HTTP timeout\n * plus a 120s margin for connecting, response parsing and DB writes.\n */\n private function extendExecutionTime(): void\n {\n // Ignored on some SAPIs (e.g. when running under fastcgi with a hard\n // limit), but harmless to attempt.\n @set_time_limit($this->timeout + 120);\n }\n\n public function parseBoq(UploadedFile|string $file, array $context = []): array\n {\n if ($this->testMode) {\n return $this->mockResponse();\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n\n if (! is_file($absPath)) {\n return $this->failure('File not found or not readable.');\n }\n\n $fileHash = hash_file('sha256', $absPath);\n // Bump PARSER_VERSION whenever the extraction logic changes so that\n // previously-uploaded files are re-parsed instead of returning a stale\n // cached result produced by the old code.\n $cacheKey = 'boq_analysis_v' . self::PARSER_VERSION . '_' . $fileHash;\n $forceRefresh = (bool) ($context['force_refresh'] ?? false);\n\n if (! $forceRefresh && ($cached = Cache::get($cacheKey)) !== null) {\n return $cached;\n }\n\n // Clear stale cached result when force-refreshing\n if ($forceRefresh) {\n Cache::forget($cacheKey);\n }\n\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n // Read Excel locally first. Fall back to AI if local parse failed,\n // found 0 items, OR could not read one of the sheets (so multi-sheet\n // files never silently drop a sheet the local parser didn't understand).\n $result = $this->parseSpreadsheetDirect($absPath);\n\n $hasSkippedSheets = ! empty($result['skipped_sheets']);\n\n if (! $result['success'] || empty($result['items']) || $hasSkippedSheets) {\n $deepSeekResult = $this->parseBoqWithDeepSeek($file, $context);\n // AI sees every sheet at once, so prefer its result whenever it\n // actually returned items (covers the skipped-sheet case too).\n if ($deepSeekResult['success'] && ! empty($deepSeekResult['items'])) {\n // Keep the larger of the two item sets so we never lose the\n // rows the local parser already extracted correctly.\n if (count($deepSeekResult['items']) >= count($result['items'] ?? [])) {\n $result = $deepSeekResult;\n }\n } elseif (! $result['success']) {\n $result = $deepSeekResult;\n }\n // else: keep local result if AI also failed/returned nothing\n }\n } elseif (in_array($ext, ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif'], true)) {\n $result = $this->parseBoqWithDeepSeek($file, $context);\n } else {\n return $this->failure('Unsupported file type. Please upload Excel, CSV, PDF, or image file.');\n }\n\n if ($result['success'] && ! empty($result['items'])) {\n $cleaning = $this->boqCleaner->process($result['items']);\n $result['items'] = $cleaning['accepted'] ?? $result['items'];\n $result['rejected'] = array_merge($result['rejected'] ?? [], $cleaning['rejected'] ?? []);\n }\n\n if ($result['success'] && count($result['items'] ?? []) > self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n $result = $this->verifyLargeBoqItemsWithAi($result);\n }\n\n // Only cache when we actually extracted items; empty results should be re-tried.\n if ($result['success'] && ! empty($result['items'])) {\n Cache::put($cacheKey, $result, now()->addDays(30));\n }\n\n return $result;\n }\n\n private function resolveFile(UploadedFile|string $file): array\n {\n if ($file instanceof UploadedFile) {\n return [(string) $file->getRealPath(), strtolower($file->getClientOriginalExtension())];\n }\n\n $absPath = is_file($file) ? $file : storage_path('app/' . ltrim($file, '/'));\n return [$absPath, strtolower(pathinfo($absPath, PATHINFO_EXTENSION))];\n }\n\n private function parseSpreadsheetDirect(string $absPath): array\n {\n ini_set('memory_limit', '1024M');\n set_time_limit(180);\n\n try {\n $sheets = $this->spreadsheetToGrids($absPath);\n\n if (empty($sheets)) {\n return $this->failure('Could not read the Excel file.');\n }\n\n $items = [];\n $rejected = [];\n $skippedSheets = []; // sheets we could not parse locally (header undetected)\n\n foreach ($sheets as $sheet) {\n $grid = $sheet['grid'];\n if (count($grid) < 2) {\n continue;\n }\n\n // Skip dedicated summary / cost-abstract sheets entirely. Their rows\n // are per-discipline totals (Structural Concrete, Electrical, …) and\n // grand totals, not procurable products.\n if ($this->isSummarySheet($sheet['name'])) {\n Log::info('QuotationAiService: skipped summary sheet.', ['sheet' => $sheet['name']]);\n continue;\n }\n\n $header = $this->detectHeader($grid);\n\n if ($header === null || ! isset($header['map']['description'])) {\n Log::warning('QuotationAiService: header not detected in sheet.', ['sheet' => $sheet['name']]);\n $skippedSheets[] = $sheet['name'];\n continue;\n }\n\n $map = $header['map'];\n $start = $header['row'] + 1;\n\n for ($r = $start; $r < count($grid); $r++) {\n $row = $grid[$r];\n $description = $this->cell($row, $map['description'] ?? null);\n $itemNo = $this->cell($row, $map['item_code'] ?? null);\n $unit = $this->cell($row, $map['unit'] ?? null);\n $quantity = $this->number($this->cell($row, $map['quantity'] ?? null));\n $unitPrice = $this->number($this->cell($row, $map['unit_price'] ?? null));\n $totalPrice = $this->number($this->cell($row, $map['total_price'] ?? null));\n\n if ($description === '') {\n continue;\n }\n\n if (! isset($map['quantity'])) {\n $quantity = 1.0;\n }\n\n // Skip floor breakdown sub-rows: Ref. is \"-\" meaning it's a per-floor\n // quantity split of the parent item, not a standalone product.\n if ($itemNo === '-' || $this->isFloorBreakdownRow($itemNo, $description)) {\n continue;\n }\n\n if ($this->isTotalOrHeaderRow($description)) {\n continue;\n }\n\n if ($quantity === null || $quantity <= 0) {\n $rejected[] = $this->rejectedRow($description, $unit, $quantity, 'Missing quantity', $itemNo, $sheet['name']);\n continue;\n }\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $supply = $this->boqCleaner->filterItem($description, $unitPrice, $quantity);\n\n if (! ($supply['keep'] ?? false)) {\n $rejected[] = $this->rejectedRow(\n $description,\n $unit,\n $quantity,\n (string) ($supply['rejection_reason'] ?? 'Rejected by cleaner'),\n $itemNo,\n $sheet['name']\n );\n continue;\n }\n\n $finalDescription = (string) ($supply['description'] ?? $description);\n\n $items[] = [\n 'description' => $finalDescription,\n 'quantity' => $quantity,\n 'unit' => $unit,\n 'category' => $sheet['name'],\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($finalDescription),\n 'unit_price' => $unitPrice,\n 'confidence' => 0.95,\n 'needs_review' => false,\n 'raw_data' => [\n 'sheet' => $sheet['name'],\n 'row_number' => $r + 1,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'cleaned_description' => $finalDescription,\n 'extraction_type' => (string) ($supply['extraction_type'] ?? 'supply_only'),\n ],\n 'ai_extracted' => true,\n ];\n }\n }\n\n if (empty($items)) {\n return $this->failure('No BOQ supply items could be extracted from the Excel file.');\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'skipped_sheets' => $skippedSheets,\n ];\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: parseSpreadsheetDirect failed.', [\n 'path' => $absPath,\n 'message' => $e->getMessage(),\n 'trace' => $e->getTraceAsString(),\n ]);\n\n return $this->failure('Failed to read the Excel file: ' . $e->getMessage());\n }\n }\n\n private function spreadsheetToGrids(string $absPath): array\n {\n $ext = strtolower(pathinfo($absPath, PATHINFO_EXTENSION));\n\n if ($ext === 'csv') {\n $rows = [];\n $handle = fopen($absPath, 'rb');\n if ($handle !== false) {\n while (($row = fgetcsv($handle)) !== false) {\n $rows[] = $this->trimTrailingEmptyCells($row);\n }\n fclose($handle);\n }\n return [['name' => 'Sheet1', 'grid' => $rows]];\n }\n\n $reader = IOFactory::createReader($ext === 'xls' ? 'Xls' : 'Xlsx');\n $reader->setReadDataOnly(true);\n $spreadsheet = $reader->load($absPath);\n $sheets = [];\n\n foreach ($spreadsheet->getAllSheets() as $sheet) {\n $highestRow = $sheet->getHighestDataRow();\n $highestColumn = $sheet->getHighestDataColumn();\n $highestColumnIndex = Coordinate::columnIndexFromString($highestColumn);\n\n // BOQ data is normally in first 8-20 columns.\n // Files with heavy formatting/images can report 300+ columns — limit to 30.\n $maxColumnIndex = min($highestColumnIndex, 30);\n\n $grid = [];\n for ($row = 1; $row <= $highestRow; $row++) {\n $rowData = [];\n $hasValue = false;\n\n for ($col = 1; $col <= $maxColumnIndex; $col++) {\n $value = $sheet->getCell([$col, $row])->getValue();\n $value = $this->cellValueToString($value);\n $value = trim($value);\n if ($value !== null && $value !== '') {\n $hasValue = true;\n }\n $rowData[] = $value;\n }\n\n $rowData = $this->trimTrailingEmptyCells($rowData);\n if ($hasValue) {\n $grid[] = $rowData;\n }\n }\n\n if (! empty($grid)) {\n $sheets[] = ['name' => $sheet->getTitle(), 'grid' => $grid];\n }\n }\n\n $spreadsheet->disconnectWorksheets();\n unset($spreadsheet);\n\n return $sheets;\n }\n\n private function detectHeader(array $grid): ?array\n {\n $keywords = [\n 'item_code' => ['رقم البند', 'item no', 'item number', 'ref.', 'ref', 'item', 'no.', 'no', '#', 'كود'],\n 'description' => ['وص٠البند', 'scope of works', 'scope of work', 'scope', 'item description', 'description of works', 'description', 'بالعربية', 'الوصÙ', 'البند', 'بيان'],\n 'unit' => ['Ø§Ù„ÙˆØØ¯Ø©', 'unit', 'uom', 'u/m'],\n 'quantity' => ['الكمية', 'qty', 'quantity', 'q.ty'],\n 'unit_price' => ['سعر Ø§Ù„ÙˆØØ¯Ø©', 'u/price', 'unit price', 'unit rate', 'rate', 'price'],\n 'total_price' => ['السعر الإجمالي', 'total amount', 'total price', 'amount', 'الإجمالي'],\n ];\n\n $best = null;\n $bestScore = 0;\n\n for ($r = 0; $r < min(30, count($grid)); $r++) {\n $map = [];\n $score = 0;\n\n foreach ($grid[$r] as $c => $value) {\n $cell = mb_strtolower(trim((string) $value));\n if ($cell === '') {\n continue;\n }\n\n foreach ($keywords as $field => $list) {\n foreach ($list as $kw) {\n if (str_contains($cell, mb_strtolower($kw))) {\n if (! isset($map[$field])) {\n $map[$field] = $c;\n $score++;\n }\n break 2;\n }\n }\n }\n }\n\n if ($score > $bestScore) {\n $bestScore = $score;\n $best = ['row' => $r, 'map' => $map];\n }\n }\n\n if ($bestScore >= 3) {\n return $best;\n }\n\n // Allow simpler BOQ sheets that only define a description column plus one\n // other BOQ-related column, e.g. Item + Description or Description + Qty.\n if ($bestScore === 2 && isset($best['map']['description']) && (\n isset($best['map']['item_code']) ||\n isset($best['map']['quantity']) ||\n isset($best['map']['unit'])\n )) {\n return $best;\n }\n\n return null;\n }\n\n private function cell(array $row, ?int $index): string\n {\n if ($index === null) {\n return '';\n }\n return trim((string) ($row[$index] ?? ''));\n }\n\n private function number(string $value): ?float\n {\n $value = trim(str_replace([',', 'SAR', 'ر.س'], '', $value));\n if ($value === '' || ! is_numeric($value)) {\n return null;\n }\n return (float) $value;\n }\n\n private function trimTrailingEmptyCells(array $row): array\n {\n while (! empty($row) && trim((string) end($row)) === '') {\n array_pop($row);\n }\n return array_values($row);\n }\n\n private function isTotalOrHeaderRow(string $description): bool\n {\n $d = mb_strtolower(trim($description));\n\n if ($d === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $d)) {\n return true;\n }\n\n // Section totals / summaries\n if (preg_match('/(اجمالي|إجمالي|المجموع|grand\\s*total|sub\\s*total|total\\s*amount|total\\s*price)/iu', $d)) {\n return true;\n }\n\n // Bare \"Total\" lines, optionally with a currency suffix, e.g. \"Total\",\n // \"Total (SAR)\", \"Total cost\", \"Total (SAR) unit cost\".\n if (preg_match('/^total\\b[\\s\\(\\):a-z]*$/iu', $d)) {\n return true;\n }\n\n // Pure floor / level labels used as section headers\n if (preg_match('/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor)\\s*$/iu', $d)) {\n return true;\n }\n\n // Division / section header lines (e.g. \"Division - 02: Wood Works\")\n if (preg_match('/^division\\s*([-:]|\\d)/iu', $d)) {\n return true;\n }\n\n return false;\n }\n\n /**\n * Detect dedicated summary / cost-abstract sheets by their name. These hold\n * per-discipline rollups and grand totals (e.g. a \"Summary (Dry Cost)\" tab),\n * never procurable supply items, so they are skipped wholesale.\n */\n private function isSummarySheet(string $name): bool\n {\n $n = mb_strtolower(trim($name));\n\n if ($n === '') {\n return false;\n }\n\n return (bool) preg_match(\n '/\\b(summary|abstract|cost\\s*summary|grand\\s*summary|recap(?:itulation)?|bill\\s*summary|boq\\s*summary|dry\\s*cost)\\b|ملخص|الملخص|الملخّص|ملخّص|اجمالي|إجمالي/iu',\n $n\n );\n }\n\n private function isFloorBreakdownRow(string $itemNo, string $description): bool\n {\n // A row whose Ref column is empty or a dash is a sub-breakdown row (per-floor split).\n $ref = trim($itemNo);\n if ($ref === '' || $ref === '-') {\n // Confirm by checking the description is a location/floor label\n $d = mb_strtolower(trim($description));\n return (bool) preg_match(\n '/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor|\\d+f)\\s*$/iu',\n $d\n );\n }\n return false;\n }\n\n private function rejectedRow(string $description, string $unit, ?float $quantity, string $reason, string $itemNo = '', string $sheet = ''): array\n {\n return [\n 'description' => $description,\n 'quantity' => $quantity ?? 0,\n 'unit' => $unit,\n 'category' => $sheet,\n 'brand' => '',\n 'status' => 'rejected',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 0.0,\n 'raw_data' => [\n 'sheet' => $sheet,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'extraction_type' => 'rejected',\n 'rejection_reason' => $reason,\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function parseBoqWithDeepSeek(UploadedFile|string $file, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured. Please set DEEPSEEK_API_KEY in .env file.');\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n $mime = $this->mimeForExtension($ext);\n\n try {\n // ── Spreadsheets ──────────────────────────────────────────────────\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n $text = $this->spreadsheetToCompactCsvText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not convert spreadsheet to readable text.');\n }\n\n $text = $this->sanitizeUtf8($text);\n\n // A large BOQ exceeds what one request can carry, so send it in\n // sequential slices and merge. Anything that fits goes as-is.\n //\n // Size alone is not a good enough signal: a workbook with several\n // sheets is structurally a big BOQ even when the text is compact,\n // and asking the model to hold every discipline at once is where\n // rows start getting dropped. So split on either signal.\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS || $sheetCount > self::MAX_SHEETS_PER_CALL) {\n Log::info('QuotationAiService: spreadsheet routed to chunked parsing.', [\n 'chars' => mb_strlen($text),\n 'sheets' => $sheetCount,\n ]);\n return $this->parseTextInChunks($text, 'BOQ spreadsheet converted to CSV', $apiKey, $context);\n }\n\n $userContent = \"BOQ spreadsheet converted to CSV:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── PDF ───────────────────────────────────────────────────────────\n if ($ext === 'pdf') {\n $extracted = $this->extractPdfText($absPath);\n if ($extracted !== null && mb_strlen(trim($extracted)) > 50) {\n $extracted = $this->sanitizeUtf8($extracted);\n\n // Split on either signal — long text, or many pages. A BOQ\n // spanning several pages is a multi-section document even if\n // each page is sparse.\n if (mb_strlen($extracted) > self::TEXT_CHUNK_CHARS\n || $this->lastPdfPageCount > self::MAX_PDF_PAGES_PER_CALL) {\n Log::info('QuotationAiService: PDF routed to chunked parsing.', [\n 'chars' => mb_strlen($extracted),\n 'pages' => $this->lastPdfPageCount,\n ]);\n return $this->parseTextInChunks($extracted, 'BOQ PDF extracted text', $apiKey, $context, 'application/pdf');\n }\n\n $userContent = \"BOQ PDF extracted text:\\n\\n\" . $extracted . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'application/pdf');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n return $this->failure('Could not extract text from the PDF. Please make sure it is a text-based PDF (not a scanned image), or convert it to Excel or CSV.');\n }\n\n // -- Word documents (docx) ---------------------------------------------------\n if (in_array($ext, ['docx', 'doc'], true)) {\n $text = $this->extractDocxText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not extract text from the Word document. Please convert it to Excel, CSV, or PDF.');\n }\n $text = $this->sanitizeUtf8($text);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS) {\n return $this->parseTextInChunks($text, 'BOQ Word document extracted text', $apiKey, $context);\n }\n\n $userContent = \"BOQ Word document extracted text:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── Images ──────────────────────────────────────────────────────────────\n // DeepSeek cannot receive images. A vision-capable AI processes the image\n // directly (no OCR) — configure GROQ_API_KEY or GEMINI_API_KEY in .env.\n $imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif', 'heic', 'heif'];\n if (in_array($ext, $imageExts, true)) {\n $bytes = (string) file_get_contents($absPath);\n return $this->callDeepSeekVision($bytes, $mime, $context);\n }\n\n return $this->failure('Unsupported file type. Please upload an Excel, CSV, PDF, or image file.');\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: DeepSeek fallback failed.', ['message' => $e->getMessage()]);\n return $this->failure('An error occurred while processing the file with AI: ' . $e->getMessage());\n }\n }\n\n private function deepSeekModel(): string\n {\n return (string) config('services.deepseek.model', 'deepseek-chat');\n }\n\n /**\n * Remove or replace any non-UTF-8 / malformed byte sequences so json_encode never fails.\n */\n private function sanitizeUtf8(string $text): string\n {\n // Replace invalid byte sequences with the UTF-8 replacement character\n $clean = mb_convert_encoding($text, 'UTF-8', 'UTF-8');\n // Strip any remaining non-printable control characters (except tab/newline/CR)\n $clean = (string) preg_replace('/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]/u', '', $clean);\n return $clean;\n }\n\n /**\n * Extract plain text from a Word docx file using ZipArchive (no external library needed).\n * Reads word/document.xml and strips XML tags, preserving paragraph and table structure.\n */\n private function extractDocxText(string $absPath): ?string\n {\n try {\n $zip = new \\ZipArchive();\n if ($zip->open($absPath) !== true) {\n return null;\n }\n\n $xml = $zip->getFromName('word/document.xml');\n $zip->close();\n\n if ($xml === false || $xml === '') {\n return null;\n }\n\n // Add newlines before paragraph/table-row/table-cell tags so structure is preserved\n $xml = preg_replace('/<w:p[ >]/', \"\\n<w:p>\", $xml) ?? $xml;\n $xml = preg_replace('/<w:tr[ >]/', \"\\n<w:tr>\", $xml) ?? $xml;\n $xml = preg_replace('/<\\/w:tc>/', \"\\t\", $xml) ?? $xml;\n\n $text = strip_tags($xml);\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n return trim($text) !== '' ? trim($text) : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: DOCX parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Extract plain text from a PDF using smalot/pdfparser (pure PHP, no binary required).\n */\n private function extractPdfText(string $absPath): ?string\n {\n try {\n $parser = new \\Smalot\\PdfParser\\Parser();\n $pdf = $parser->parseFile($absPath);\n\n // Recorded for the chunking decision: a long PDF is structurally a\n // big BOQ even when its extracted text is short.\n try {\n $this->lastPdfPageCount = count($pdf->getPages());\n } catch (\\Throwable) {\n $this->lastPdfPageCount = 0;\n }\n\n $text = $pdf->getText();\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n return $text !== '' ? $text : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: PDF parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Send a vision request to a vision-capable AI model.\n * DeepSeek's commercial API (api.deepseek.com) does NOT support image inputs — any model.\n * Priority order (first configured key wins):\n * 1. GROQ_API_KEY → Groq llama-4-scout (free: https://console.groq.com)\n * 2. GEMINI_API_KEY → Google Gemini Flash (free: https://aistudio.google.com/apikey)\n * 3. VISION_API_KEY → any OpenAI-compatible endpoint (OpenAI, OpenRouter …)\n */\n private function callDeepSeekVision(string $bytes, string $mime, array $context): array\n {\n // ── 1. Groq (free, reliable vision) ─────────────────────────────────────\n if ((string) config('services.groq.key', '') !== '') {\n $visionKey = (string) config('services.groq.key');\n $visionBaseUrl = 'https://api.groq.com/openai/v1';\n $visionModel = (string) config('services.groq.model', 'meta-llama/llama-4-scout-17b-16e-instruct');\n }\n // ── 2. Google Gemini ─────────────────────────────────────────────────────\n elseif ((string) config('services.gemini.key', '') !== '') {\n $visionKey = (string) config('services.gemini.key');\n $visionBaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai';\n $visionModel = (string) config('services.gemini.model', 'gemini-2.0-flash');\n }\n // ── 3. Generic VISION_API_KEY (OpenAI, OpenRouter, …) ───────────────────\n elseif ((string) config('services.vision.key', '') !== '') {\n $visionKey = (string) config('services.vision.key');\n $visionBaseUrl = rtrim((string) config('services.vision.base_url', 'https://openrouter.ai/api/v1'), '/');\n $visionModel = (string) config('services.vision.model', 'google/gemini-flash-1.5-8b');\n }\n else {\n return $this->failure(\n 'Image processing is not available. DeepSeek\\'s API does not support images. ' .\n 'Get a free key at https://console.groq.com and add GROQ_API_KEY to your .env file.'\n );\n }\n\n $dataUrl = \"data:{$mime};base64,\" . base64_encode($bytes);\n $prompt = $this->buildDeepSeekPrompt($context, $mime);\n\n $userContent = [\n ['type' => 'text', 'text' => $prompt],\n ['type' => 'image_url', 'image_url' => ['url' => $dataUrl]],\n ];\n\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders([\n 'Authorization' => 'Bearer ' . $visionKey,\n 'HTTP-Referer' => config('app.url', 'https://qimta.sa'),\n 'X-Title' => 'Qimta Platform',\n ])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post($visionBaseUrl . '/chat/completions', [\n 'model' => $visionModel,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 16384,\n 'temperature' => 0.2,\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('Vision API connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: Vision API HTTP error.', [\n 'model' => $visionModel,\n 'base_url' => $visionBaseUrl,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n return $this->failure(\"Vision API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n return $this->parseDeepSeekJsonResponse((string) $response->json('choices.0.message.content'));\n }\n\n private function callDeepSeekChat(string $userContent, string $key, string $model): array\n {\n // Ensure the PHP script outlives the HTTP call (+ a margin for parsing the\n // response and writing to the DB) so the worker/web request is not killed\n // mid-flight, which surfaces to users as a generic \"timeout or unavailable\".\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders(['Authorization' => 'Bearer ' . $key])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 32768,\n 'temperature' => 0.1,\n 'response_format' => ['type' => 'json_object'],\n 'user' => 'Qimta_Platform',\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('DeepSeek connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: DeepSeek HTTP error.', [\n 'model' => $model,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n if ($response->status() === 401) {\n return $this->failure('DeepSeek API key is invalid or unauthorised. Please update DEEPSEEK_API_KEY in .env.', false);\n }\n return $this->failure(\"DeepSeek API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n $text = (string) $response->json('choices.0.message.content');\n return $this->parseDeepSeekJsonResponse($text);\n }\n\n /**\n * Parse a document too large for one AI call by sending it in slices.\n *\n * A 20 000-row BOQ is several megabytes of text — far beyond what a single\n * request can carry, and beyond what the model can answer without hitting its\n * output token limit. Previously the text was simply cut at 180 000 chars and\n * the remainder discarded: the parse \"succeeded\" while silently dropping most\n * of the file. Now the text is split and each slice parsed on its own call,\n * with the items merged.\n *\n * Splitting happens on line boundaries so a BOQ row is never cut in half.\n * Slices run sequentially rather than pooled: DeepSeek rate-limits hard on\n * concurrent large prompts, and this already runs on the queue where wall\n * time is cheap.\n *\n * A slice that fails does not fail the document — its rows are lost, but the\n * rest are kept and the shortfall is logged. Total failure is reported only\n * when every slice fails.\n *\n * @param string $label How the payload is introduced to the model.\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n private function parseTextInChunks(\n string $text,\n string $label,\n string $apiKey,\n array $context,\n string $mime = 'text/plain',\n ): array {\n $chunks = $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n $total = count($chunks);\n\n Log::info('QuotationAiService: large document split for parsing.', [\n 'chars' => mb_strlen($text),\n 'chunks' => $total,\n ]);\n\n // Announce the split before the first slice is sent, so the UI can show\n // how many parts there are instead of sitting on a generic message for\n // however long the first AI call takes.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)(0, $total);\n }\n\n $items = [];\n $rejected = [];\n $failed = 0;\n $lastError = null;\n\n foreach ($chunks as $index => $chunk) {\n $part = $index + 1;\n\n // Report progress so a 28-slice parse does not look frozen to the user.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)($part, $total);\n }\n\n // Tell the model this is a fragment, so it does not try to reconcile\n // totals or treat a mid-document slice as a complete BOQ.\n $userContent = \"{$label} (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, $mime);\n\n $result = $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n\n if (! ($result['success'] ?? false)) {\n $failed++;\n $lastError = $result['error'] ?? 'Chunk parsing failed.';\n Log::warning('QuotationAiService: chunk failed, continuing.', [\n 'part' => $part,\n 'of' => $total,\n 'error' => $lastError,\n ]);\n continue;\n }\n\n foreach ($result['items'] as $item) {\n $items[] = $item;\n }\n foreach ($result['rejected'] ?? [] as $row) {\n $rejected[] = $row;\n }\n\n // Hand this slice's rows straight to the caller so they can be shown\n // while the remaining slices are still being parsed.\n if ($this->onChunkItems !== null && $result['items'] !== []) {\n ($this->onChunkItems)($result['items'], $part, $total);\n }\n }\n\n // Every slice failed — there is nothing to show, so report the failure.\n if ($failed === $total) {\n return $this->failure($lastError ?? 'Could not parse the document.');\n }\n\n if ($failed > 0) {\n Log::error('QuotationAiService: document parsed with missing parts.', [\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n 'items_kept' => count($items),\n ]);\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'service_unavailable' => false,\n 'partial' => $failed > 0,\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n ];\n }\n\n /**\n * Split text into slices of at most $limit characters, breaking only at\n * newlines so no BOQ row is ever cut in half.\n *\n * A single line longer than the limit (rare — a pathological cell) is passed\n * through oversized rather than severed mid-row.\n *\n * @return array<int, string>\n */\n private function splitOnLineBoundaries(string $text, int $limit): array\n {\n $chunks = [];\n $current = '';\n\n // Sheet a chunk starts in the middle of. Repeated at the top of the next\n // chunk so the model always knows which sheet the rows belong to — the\n // header would otherwise be stranded in the previous slice, and rows\n // would arrive with no discipline context.\n $currentSheet = null;\n\n foreach (explode(\"\\n\", $text) as $line) {\n $isSheetHeader = str_starts_with($line, 'Sheet: ');\n\n // Start a new chunk at every sheet boundary. Sheets are usually\n // separate disciplines, and the whole point of splitting a multi-tab\n // workbook is to give the model one discipline at a time — a size-only\n // split would leave a compact 3-tab file as a single chunk, silently\n // undoing the sheet gate that routed it here.\n if ($isSheetHeader && $current !== '') {\n $chunks[] = $current;\n $current = $line;\n $currentSheet = $line;\n continue;\n }\n\n if ($isSheetHeader) {\n $currentSheet = $line;\n }\n\n $candidate = $current === '' ? $line : $current . \"\\n\" . $line;\n\n if (mb_strlen($candidate) > $limit && $current !== '') {\n $chunks[] = $current;\n\n // Carry the sheet header over, unless this line is itself one.\n $current = ($currentSheet !== null && ! str_starts_with($line, 'Sheet: '))\n ? $currentSheet . \" (continued)\\n\" . $line\n : $line;\n continue;\n }\n\n $current = $candidate;\n }\n\n if (trim($current) !== '') {\n $chunks[] = $current;\n }\n\n return $chunks;\n }\n\n /**\n * Shared JSON response parser used by both chat and vision paths.\n */\n private function parseDeepSeekJsonResponse(string $text): array\n {\n if ($text === '') {\n return $this->failure('DeepSeek returned an empty response.');\n }\n\n // Strip markdown code fences if the model wrapped the JSON.\n if (preg_match('/```(?:json)?\\s*([\\s\\S]*?)\\s*```/i', $text, $m)) {\n $text = $m[1];\n }\n // Narrow to the outermost JSON object/array.\n if (preg_match('/[\\{\\[][\\s\\S]*[\\}\\]]/s', $text, $m)) {\n $text = $m[0];\n }\n $text = trim($text);\n\n $decoded = json_decode($text, true);\n\n // The response can be truncated when the model hits the token limit,\n // leaving an unterminated JSON string/object. Try to repair it before\n // giving up so we can still salvage the items extracted so far.\n if (! is_array($decoded)) {\n $repaired = $this->repairTruncatedJson($text);\n if ($repaired !== null) {\n $decoded = json_decode($repaired, true);\n }\n }\n\n if (! is_array($decoded)) {\n Log::error('QuotationAiService: DeepSeek non-JSON response.', ['text' => mb_substr($text, 0, 1000)]);\n return $this->failure('DeepSeek returned a non-JSON response. The file may be too large — try splitting it into smaller files.');\n }\n\n $rawItems = $decoded['items'] ?? (array_is_list($decoded) ? $decoded : []);\n $items = [];\n $rejected = [];\n\n foreach ($rawItems as $raw) {\n if (! is_array($raw)) {\n continue;\n }\n $item = $this->normaliseAiItem($raw);\n if ($item === null) {\n continue;\n }\n $check = $this->boqCleaner->filterItem((string) $item['description']);\n if ($check['keep'] ?? false) {\n $items[] = $item;\n } else {\n $item['status'] = 'rejected';\n $item['raw_data']['rejection_reason'] = $check['rejection_reason'] ?? 'Rejected by cleaner';\n $rejected[] = $item;\n }\n }\n\n if (empty($items)) {\n Log::info('QuotationAiService: DeepSeek returned 0 accepted items.', [\n 'raw_items_count' => count($rawItems),\n 'rejected_count' => count($rejected),\n 'first_rejected' => ! empty($rejected) ? ($rejected[0]['description'] ?? '') : '',\n ]);\n }\n\n return ['success' => true, 'items' => $items, 'rejected' => $rejected, 'error' => null];\n }\n\n /**\n * Best-effort repair of a truncated JSON string produced when the model hits\n * its token limit mid-output. Drops any incomplete trailing object, then\n * closes the open string/array/object brackets so the result is valid JSON.\n *\n * Returns the repaired JSON string, or null if it cannot be salvaged.\n */\n private function repairTruncatedJson(string $text): ?string\n {\n if ($text === '') {\n return null;\n }\n\n // Walk the string tracking structural state, ignoring everything inside\n // string literals (and their escapes). Remember the last position where\n // we were back at the top of the \"items\" array between complete elements,\n // so we can cut a half-written trailing object.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($text);\n $lastSafeCut = null; // index just after a completed array element\n\n for ($i = 0; $i < $len; $i++) {\n $ch = $text[$i];\n\n if ($inString) {\n if ($escaped) {\n $escaped = false;\n } elseif ($ch === '\\\\') {\n $escaped = true;\n } elseif ($ch === '\"') {\n $inString = false;\n }\n continue;\n }\n\n if ($ch === '\"') {\n $inString = true;\n } elseif ($ch === '{' || $ch === '[') {\n $stack[] = $ch;\n } elseif ($ch === '}' || $ch === ']') {\n array_pop($stack);\n } elseif ($ch === ',' && ! empty($stack) && end($stack) === '[') {\n // Separator directly inside an array: a clean boundary between\n // elements (the items array, whatever its nesting depth).\n $lastSafeCut = $i;\n }\n }\n\n // If we are still inside a string, the last element is incomplete — fall\n // back to the last clean array boundary if we have one.\n $candidate = $text;\n if ($inString && $lastSafeCut !== null) {\n $candidate = substr($text, 0, $lastSafeCut);\n } else {\n // Drop a dangling partial object after the last completed element.\n $candidate = rtrim($candidate);\n if ($lastSafeCut !== null && substr(rtrim($candidate), -1) !== '}' && substr(rtrim($candidate), -1) !== ']') {\n $candidate = substr($text, 0, $lastSafeCut);\n }\n }\n\n // Recompute the open brackets for the (possibly trimmed) candidate and\n // append the matching closers in reverse order.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($candidate);\n for ($i = 0; $i < $len; $i++) {\n $ch = $candidate[$i];\n if ($inString) {\n if ($escaped) { $escaped = false; }\n elseif ($ch === '\\\\') { $escaped = true; }\n elseif ($ch === '\"') { $inString = false; }\n continue;\n }\n if ($ch === '\"') { $inString = true; }\n elseif ($ch === '{' || $ch === '[') { $stack[] = $ch; }\n elseif ($ch === '}' || $ch === ']') { array_pop($stack); }\n }\n\n $candidate = rtrim($candidate);\n // Remove a trailing comma left after cutting an element.\n $candidate = rtrim($candidate, ',');\n\n $closers = '';\n for ($i = count($stack) - 1; $i >= 0; $i--) {\n $closers .= ($stack[$i] === '{') ? '}' : ']';\n }\n\n $repaired = $candidate . $closers;\n\n // Validate before returning.\n return is_array(json_decode($repaired, true)) ? $repaired : null;\n }\n\n private function spreadsheetToCompactCsvText(string $absPath): ?string\n {\n $sheets = $this->spreadsheetToGrids($absPath);\n if (empty($sheets)) {\n return null;\n }\n\n $output = '';\n foreach ($sheets as $sheet) {\n // Don't feed summary / cost-abstract sheets to the AI either.\n if ($this->isSummarySheet($sheet['name'])) {\n continue;\n }\n $output .= 'Sheet: ' . $sheet['name'] . \"\\n\";\n foreach ($sheet['grid'] as $row) {\n $row = $this->trimTrailingEmptyCells($row);\n if (empty($row)) {\n continue;\n }\n $output .= implode(',', array_map([$this, 'csvEscape'], $row)) . \"\\n\";\n }\n $output .= \"\\n\";\n }\n\n return $output;\n }\n\n /**\n * Safely convert a PhpSpreadsheet cell value to a string.\n *\n * Cells may contain RichText or, in malformed/heavily-formatted files,\n * embedded objects such as Worksheet\\Drawing that have no __toString().\n * Casting those directly throws \"Object ... could not be converted to string\".\n */\n private function cellValueToString(mixed $value): string\n {\n if ($value === null) {\n return '';\n }\n\n if ($value instanceof \\PhpOffice\\PhpSpreadsheet\\RichText\\RichText) {\n return $value->getPlainText();\n }\n\n if (is_object($value)) {\n // Only stringify objects that explicitly support it; ignore the rest\n // (e.g. Worksheet\\Drawing) instead of crashing.\n return method_exists($value, '__toString') ? (string) $value : '';\n }\n\n if (is_array($value)) {\n return '';\n }\n\n return (string) $value;\n }\n\n private function csvEscape(mixed $value): string\n {\n $value = $this->cellValueToString($value);\n return str_contains($value, ',') || str_contains($value, '\"') || str_contains($value, \"\\n\")\n ? '\"' . str_replace('\"', '\"\"', $value) . '\"'\n : $value;\n }\n\n private function verifyLargeBoqItemsWithAi(array $result): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n Log::warning('QuotationAiService: large BOQ verification skipped, AI key is not configured.');\n return $result;\n }\n\n $items = array_values(array_filter($result['items'] ?? [], 'is_array'));\n if (count($items) <= self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n return $result;\n }\n\n Log::info('QuotationAiService: starting second-pass physical item verification.', [\n 'items_count' => count($items),\n 'threshold' => self::LARGE_BOQ_VERIFICATION_THRESHOLD,\n ]);\n\n $verifiedItems = [];\n $rejectedItems = $result['rejected'] ?? [];\n\n foreach (array_chunk($items, self::LARGE_BOQ_VERIFICATION_CHUNK_SIZE) as $chunkIndex => $chunk) {\n $payload = array_map(fn(array $item) => [\n 'description' => (string) ($item['description'] ?? ''),\n 'quantity' => $item['quantity'] ?? null,\n 'unit' => (string) ($item['unit'] ?? ''),\n 'unit_price' => $item['unit_price'] ?? null,\n 'category' => (string) ($item['category'] ?? ''),\n 'brand' => (string) ($item['brand'] ?? ''),\n ], $chunk);\n\n $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);\n if (! is_string($json)) {\n Log::warning('QuotationAiService: large BOQ verification chunk JSON encode failed.', [\n 'chunk' => $chunkIndex + 1,\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n $prompt = <<<PROMPT\nYou are doing a SECOND PASS verification on a BOQ extraction result.\n\nThe first pass may hallucinate non-products when the BOQ has thousands of rows.\nYour job is to keep ONLY physical, tangible, procurable supply products.\n\nRules:\n- Keep items only if a supplier can actually deliver them as materials/equipment.\n- Reject headings, trade names, systems, scopes, prose, specifications-only rows, totals, labor, installation, testing, commissioning, supervision, drawings, insurance, preliminaries, mobilisation, site works, and vague fragments.\n- Do not invent new products.\n- Do not add items that are not in the input.\n- You may clean a product description, but keep the original quantity, unit, unit_price, category, and brand when available.\n\nInput JSON array:\n{$json}\n\nReturn ONLY valid JSON in this shape:\n{\"items\":[{\"description\":\"\",\"quantity\":1,\"unit\":\"\",\"unit_price\":null,\"category\":\"\",\"brand\":\"\",\"confidence\":0.95}]}\nPROMPT;\n\n try {\n $chunkResult = $this->callDeepSeekChat($prompt, $apiKey, $this->deepSeekModel());\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: large BOQ verification chunk threw an exception.', [\n 'chunk' => $chunkIndex + 1,\n 'message' => $e->getMessage(),\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n if ($chunkResult['success'] ?? false) {\n array_push($verifiedItems, ...($chunkResult['items'] ?? []));\n $rejectedItems = array_merge($rejectedItems, $chunkResult['rejected'] ?? []);\n continue;\n }\n\n Log::warning('QuotationAiService: large BOQ verification chunk failed, keeping original chunk.', [\n 'chunk' => $chunkIndex + 1,\n 'error' => $chunkResult['error'] ?? null,\n ]);\n array_push($verifiedItems, ...$chunk);\n }\n\n if (empty($verifiedItems)) {\n Log::warning('QuotationAiService: second-pass verification returned no items, keeping first-pass result.', [\n 'original_items_count' => count($items),\n ]);\n return $result;\n }\n\n $result['items'] = $verifiedItems;\n $result['rejected'] = $rejectedItems;\n $result['large_boq_verified'] = true;\n\n Log::info('QuotationAiService: second-pass physical item verification completed.', [\n 'before' => count($items),\n 'after' => count($verifiedItems),\n ]);\n\n return $result;\n }\n\n private function normaliseAiItem(array $raw): ?array\n {\n if (! empty($raw['rejected'])) {\n return null;\n }\n\n $description = trim((string) ($raw['cleaned_description'] ?? $raw['product_name'] ?? $raw['description'] ?? ''));\n if ($description === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $description)) {\n return null;\n }\n\n $quantity = is_numeric($raw['quantity'] ?? null) ? (float) $raw['quantity'] : 1;\n $unitPrice = is_numeric($raw['unit_price'] ?? null) ? (float) $raw['unit_price'] : null;\n $totalPrice = is_numeric($raw['total_price'] ?? null) ? (float) $raw['total_price'] : null;\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $specs = is_array($raw['specifications'] ?? null) ? $raw['specifications'] : [];\n\n return [\n 'description' => $description,\n 'quantity' => $quantity,\n 'unit' => (string) ($raw['unit'] ?? ''),\n 'category' => (string) ($raw['core_product'] ?? $raw['category'] ?? ''),\n 'brand' => (string) ($specs['brand'] ?? $raw['brand'] ?? ''),\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($description),\n 'unit_price' => $unitPrice,\n 'confidence' => is_numeric($raw['confidence'] ?? null) ? (float) $raw['confidence'] : null,\n 'needs_review' => ! empty($raw['needs_review']),\n 'raw_data' => [\n 'source_item_no' => (string) ($raw['source_item_no'] ?? ''),\n 'original_description' => (string) ($raw['original_description'] ?? $description),\n 'cleaned_description' => $description,\n 'core_product' => (string) ($raw['core_product'] ?? ''),\n 'specifications' => $specs,\n 'extraction_type' => (string) ($raw['extraction_type'] ?? 'supply_only'),\n 'note' => (string) ($raw['note'] ?? $raw['notes'] ?? ''),\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function buildSystemInstruction(): string\n {\n return <<<'TXT'\nYou are a strict BOQ Supply Product Extraction Engine.\nExtract ONLY concrete, tangible, physical products that a supplier can box up and deliver to site — a specific item you could point at and say \"this is the thing being bought\" (e.g. \"Wall mounted LED luminaire 36W\", \"PVC pipe 110mm\", \"Single phase distribution board 12-way\", \"Fire extinguisher CO2 6kg\", \"CCTV dome camera 4MP\").\n\nABSOLUTELY REJECT abstract concepts, disciplines, categories, section names, and trade headings. These are NOT products even though they appear as rows:\n- Bare discipline/trade words: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", \"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\".\n- Summary / total / abstract / \"dry cost\" rows of any kind.\n- Anything that names a system, scope, or category instead of one buyable item.\n\nRule of thumb: if you cannot name a unit and physically hand the item over (a piece, a box, a length, a unit of equipment), REJECT it. A word that describes a field of work, a system, or a section of the BOQ is NEVER a product.\nReject totals, headings, general requirements, installation-only, testing, commissioning, supervision, mobilization, civil works, labor, and vague rows.\nFor Supply & Install rows, keep only the supply product and remove install/labor wording.\nReturn only valid JSON: {\"items\":[{\"source_item_no\":null,\"original_description\":\"\",\"cleaned_description\":\"\",\"core_product\":\"\",\"specifications\":{},\"quantity\":0,\"unit\":\"\",\"unit_price\":0,\"total_price\":0,\"extraction_type\":\"supply_only\",\"needs_review\":false,\"note\":\"\",\"rejected\":false,\"rejection_reason\":null,\"confidence\":0.95}]}\nTXT;\n }\n\n private function buildDeepSeekPrompt(array $context = [], string $mime = ''): string\n {\n $projectName = (string) ($context['project_name'] ?? '');\n $projectStatus = (string) ($context['project_status'] ?? '');\n $contextLine = $projectName !== ''\n ? \"Project: {$projectName}\" . ($projectStatus !== '' ? \" ({$projectStatus})\" : '')\n : '';\n $sourceHint = str_starts_with($mime, 'image/')\n ? 'The input is an image of a BOQ table. OCR every cell carefully — read the quantity/count column (العدد, الكمية, Qty) and extract the EXACT numeric value from each row.'\n : 'The input is a BOQ document/spreadsheet.';\n\n return trim(<<<PROMPT\nYou are a procurement specialist. Your task is to extract ONLY tangible, physical supply products from this Bill of Quantities (BOQ).\n\n{$contextLine}\n{$sourceHint}\n\nCRITICAL — MERGE WRAPPED / SPLIT ROWS INTO ONE ITEM:\nBOQ tables extracted from PDF often break ONE item's long description across several\nlines/rows. These continuation lines repeat the SAME quantity, unit and category, and\ntheir text is a grammatical continuation of the previous line (it starts mid-sentence,\nlowercase, or with \"the/and/-\", or is a spec/instruction clause). You MUST reconstruct\nthe original single item:\n- Treat consecutive rows that share the SAME quantity + unit + category as ONE product\n when the later rows are clearly continuation text of the first, NOT new products.\n- Emit ONE item with the merged description and the price counted ONCE. Do NOT repeat\n the same quantity×price for every wrapped line — that multiplies the total wrongly.\n- Example: rows \"The asphalt flooring is to be supplied\" / \"installed in accordance\" /\n \"standard road specifications...\" all with 7,100 m2 are ONE asphalt item, not 3+.\n\nCRITICAL — UNDERSTAND INTENT, DO NOT COPY ROWS LITERALLY:\nYour job is to output WHAT A SUPPLIER MUST ACTUALLY DELIVER, not a copy of the row text.\nRead each description for MEANING and decide how many real, separately-buyable products it\nimplies. One BOQ row can imply SEVERAL products, or a spread of rows can imply ONE product.\n- SPLIT a row into multiple items when it lists distinct products that a supplier would\n buy/price separately — even when joined by commas, \"with\", \"and\", \"including\", \"c/w\",\n \"consisting of\", \"comprising\", or \":\" then a list.\n Example: \"Fire pump set consisting of: fire pump, electric motor, control panel, diesel\n engine\" → FOUR items: (1) fire pump, (2) electric motor, (3) control panel, (4) diesel\n engine — each with its own quantity and priced on its own.\n Example: \"Wash basin complete with mixer tap and bottle trap\" → THREE items: wash basin,\n mixer tap, bottle trap.\n- Give each split product the correct quantity implied by the text (usually the parent\n quantity, unless the text states a different count for a sub-item). Each product is\n priced individually; the line total becomes the SUM of its products.\n- DO NOT split when the extra words are just specifications, dimensions, colours, ratings,\n standards, or a single product's integral parts. \"Steel door 900x2100 with vision panel\n and SS ironmongery\" is usually ONE door product — vision panel and ironmongery are part\n of that door, not separately-bought items. Use judgement: would a supplier issue a\n separate price line for it? If no, keep it as one.\n- Balanced approach: split only when genuinely separate products are present; when in doubt\n about whether something is a separate product vs. an accessory/spec of the main one, keep\n it as a single item. Do not invent products that are not in the text.\n\nSTRICT RULES — include ONLY items that are ALL of the following:\n1. A specific, concrete, tangible product that can be boxed up by a supplier and delivered to site — something you can point at and say \"this exact thing is being bought\" (e.g. \"LED luminaire 36W\", \"PVC pipe 110mm\", \"Distribution board 12-way\", \"CO2 fire extinguisher 6kg\").\n2. Has a real quantity (not 0 or \"varies\" with no meaning).\n3. Has enough description to identify what the product is.\n4. Is an actual PRODUCT, not a sentence, instruction, or specification clause. REJECT rows that are prose such as \"the supervising engineer's instructions\", \"The price includes all materials, labor\", \"Measurement will be based on the geometric cubic meter\", \"necessary steps to complete the work\", \"soil-contacting elements\", \"in compliance\", \"everything necessary\". These are description fragments, not buyable items.\n\nNEVER extract abstract concepts, disciplines, trades, systems, or section/category names — they are NOT products even when they appear as their own row:\n- Bare discipline/trade/system words such as: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", and their Arabic equivalents (\"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\", \"تشطيبات\").\n- Test: if the word names a FIELD OF WORK or a SYSTEM rather than one buyable item with a unit (pcs, m, set, kg…), REJECT it.\n\nEXCLUDE all of the following (do NOT include them):\n- Labor, installation, or workmanship items (e.g. \"install\", \"fix\", \"erect\", \"dismantle\", \"painting\", \"cleaning service\")\n- Project management, overhead, or profit items\n- Insurance, bonds, liability items (e.g. \"bodily injury\", \"third party liability\", \"CAR insurance\")\n- Shop drawings, as-built drawings, method statements, documentation\n- Preliminary items, mobilisation, demobilisation, provisional sums\n- Housekeeping, site clearance, waste removal\n- Floor/level labels (e.g. \"GF Floor\", \"Level 3\") — these are headers, not products\n- Pure dimension specs without a product noun (e.g. \"Dia 32 mm\", \"25 mm\") — reject unless a product name is present\n- Security services, night shifts, rentals, payments\n- Any item whose description is too vague or fragmentary to identify a specific product\n\nEmit ONE JSON item PER real product. A single source row may therefore produce several\nitems (when it lists multiple products) or several rows may collapse into one item (when\nthey are wrapped fragments of the same product). The \"items\" array is your final delivery\nlist, not a row-by-row transcript.\n\nFor each valid physical product return JSON with:\n- description: clean product name only (strip \"supply and install\", \"supply and fix\", installation clauses, floor/spec suffixes). For a split product, use just that product's own name.\n- quantity: the EXACT numeric value from the quantity/count column (العدد, الكمية, Qty, Quantity, No.). NEVER default to 1 unless the quantity column is truly absent or empty.\n- unit: unit of measure (pcs, m, m2, m3, set, no, kg, etc.)\n- category: product category\n- brand: brand/manufacturer if mentioned, else \"\"\n- engineering_required: true if the item needs engineering design before procurement (panel boards, chillers, generators, fire suppression systems, BMS, pumps, AHUs, switchgear, transformers), else false\n\nReturn ONLY a valid JSON object: {\"items\": [...]}. No explanations, no markdown, no extra text.\nPROMPT);\n }\n\n private function mockResponse(): array\n {\n return [\n 'success' => true,\n 'error' => null,\n 'items' => [[\n 'description' => 'Sample BOQ Item',\n 'quantity' => 1,\n 'unit' => 'No',\n 'category' => 'Sample',\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 1,\n 'raw_data' => [],\n 'ai_extracted' => true,\n ]],\n 'rejected' => [],\n ];\n }\n\n private function failure(string $message, bool $serviceUnavailable = false): array\n {\n return [\n 'success' => false,\n 'items' => [],\n 'rejected' => [],\n 'error' => $this->userFacingError($message, $serviceUnavailable),\n 'service_unavailable' => $serviceUnavailable,\n ];\n }\n\n /**\n * Translate any internal error into a neutral, user-safe message.\n *\n * The user must never see the name of the underlying AI/processing engine\n * (DeepSeek, Groq, Gemini, Vision, etc.) or configuration hints like API\n * keys. The detailed message is kept in the logs for debugging only.\n */\n private function userFacingError(string $message, bool $serviceUnavailable = false): string\n {\n // Detect any reference to an AI provider / engine / API config so we can\n // swap it for a generic, branded-neutral message.\n $sensitive = '/deepseek|groq|gemini|vision|openai|openrouter|api[\\s_-]?key|\\.env|non-?json|token limit/i';\n\n if (preg_match($sensitive, $message)) {\n Log::warning('QuotationAiService: suppressed technical error from user.', ['internal' => $message]);\n\n return $serviceUnavailable\n ? 'The file could not be processed right now. Please try again in a moment.'\n : 'We could not read this file automatically. Please make sure it is a clear BOQ (Excel, CSV, PDF, or image) and try again, or add the items manually.';\n }\n\n return $message;\n }\n\n private function mimeForExtension(string $ext): string\n {\n return match (strtolower($ext)) {\n 'pdf' => 'application/pdf',\n 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n 'xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12',\n 'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',\n 'xls' => 'application/vnd.ms-excel',\n 'csv' => 'text/csv',\n 'jpg', 'jpeg' => 'image/jpeg',\n 'png' => 'image/png',\n 'gif' => 'image/gif',\n 'bmp' => 'image/bmp',\n 'webp' => 'image/webp',\n 'tiff', 'tif' => 'image/tiff',\n 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'doc' => 'application/msword',\n default => 'application/octet-stream',\n };\n }\n}\n"}, "d00ac635c7976e4b9c7f4d223c66f262576ddb12": {"t": 1785748377.943306, "n": 72132, "h": "1d7480d74de15a93", "c": "<?php\n\nnamespace App\\Services;\n\nuse Illuminate\\Http\\Client\\ConnectionException;\nuse Illuminate\\Http\\UploadedFile;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\nuse PhpOffice\\PhpSpreadsheet\\Cell\\Coordinate;\nuse PhpOffice\\PhpSpreadsheet\\IOFactory;\n\nclass QuotationAiService\n{\n /** Increment this whenever BOQ parsing logic changes to invalidate old caches. */\n private const PARSER_VERSION = 10;\n private const LARGE_BOQ_VERIFICATION_THRESHOLD = 2000;\n private const LARGE_BOQ_VERIFICATION_CHUNK_SIZE = 120;\n\n /**\n * Characters of document text sent per AI call.\n *\n * Above this the document is split and parsed over several calls. Sized well\n * under the model's context so the prompt, the slice, and a full JSON answer\n * for every row in it all fit — the binding limit is the *output* budget, not\n * the input, since each input row produces a JSON object.\n */\n private const TEXT_CHUNK_CHARS = 60000;\n\n /**\n * A workbook with more sheets than this is split regardless of its size.\n *\n * Multiple tabs almost always mean multiple disciplines (electrical, HVAC,\n * civil…). Sending them in one call makes the model summarise across\n * sections and silently drop rows, so each slice gets a narrower scope.\n */\n private const MAX_SHEETS_PER_CALL = 2;\n\n /** A PDF longer than this is split regardless of its extracted text length. */\n private const MAX_PDF_PAGES_PER_CALL = 5;\n\n private string $baseUrl;\n private string $parseEndpoint;\n private string $apiKey;\n private int $timeout;\n private bool $testMode;\n private BoqCleaningService $boqCleaner;\n\n /** Page count of the most recently parsed PDF; 0 when unknown. */\n private int $lastPdfPageCount = 0;\n\n /**\n * Optional progress reporter for chunked parsing, called as ($part, $total).\n *\n * Set by the queue job so the polling UI can show real progress on a large\n * BOQ instead of a spinner that appears frozen for many minutes.\n *\n * @var (callable(int, int): void)|null\n */\n private $onChunkProgress = null;\n\n /** @param callable(int, int): void $callback */\n public function onChunkProgress(callable $callback): self\n {\n $this->onChunkProgress = $callback;\n return $this;\n }\n\n /**\n * Optional per-chunk item sink, called as ($items, $part, $total).\n *\n * Lets the caller persist each slice's rows the moment they arrive instead\n * of waiting for the whole document. On a large BOQ this is the difference\n * between a table that fills in progressively and one that stays empty for\n * many minutes.\n *\n * @var (callable(array, int, int): void)|null\n */\n private $onChunkItems = null;\n\n /** @param callable(array, int, int): void $callback */\n public function onChunkItems(callable $callback): self\n {\n $this->onChunkItems = $callback;\n return $this;\n }\n\n public function __construct(BoqCleaningService $boqCleaner)\n {\n $this->boqCleaner = $boqCleaner;\n $this->baseUrl = rtrim((string) config('services.ai_quotation.base_url', ''), '/');\n $this->parseEndpoint = ltrim((string) config('services.ai_quotation.parse_endpoint', 'parse'), '/');\n $this->apiKey = (string) config('services.ai_quotation.api_key', '');\n $this->timeout = (int) config('services.ai_quotation.timeout', 300);\n $this->testMode = (bool) config('services.ai_quotation.test_mode', false);\n }\n\n /**\n * Give the running script enough head-room to outlast the AI HTTP call.\n *\n * The web request / queue worker must not be killed before the HTTP timeout\n * completes, otherwise the user sees a generic \"timeout or unavailable\"\n * error even though the AI may have responded. We allow the HTTP timeout\n * plus a 120s margin for connecting, response parsing and DB writes.\n */\n private function extendExecutionTime(): void\n {\n // Ignored on some SAPIs (e.g. when running under fastcgi with a hard\n // limit), but harmless to attempt.\n @set_time_limit($this->timeout + 120);\n }\n\n public function parseBoq(UploadedFile|string $file, array $context = []): array\n {\n if ($this->testMode) {\n return $this->mockResponse();\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n\n if (! is_file($absPath)) {\n return $this->failure('File not found or not readable.');\n }\n\n $fileHash = hash_file('sha256', $absPath);\n // Bump PARSER_VERSION whenever the extraction logic changes so that\n // previously-uploaded files are re-parsed instead of returning a stale\n // cached result produced by the old code.\n $cacheKey = 'boq_analysis_v' . self::PARSER_VERSION . '_' . $fileHash;\n $forceRefresh = (bool) ($context['force_refresh'] ?? false);\n\n if (! $forceRefresh && ($cached = Cache::get($cacheKey)) !== null) {\n return $cached;\n }\n\n // Clear stale cached result when force-refreshing\n if ($forceRefresh) {\n Cache::forget($cacheKey);\n }\n\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n // Read Excel locally first. Fall back to AI if local parse failed,\n // found 0 items, OR could not read one of the sheets (so multi-sheet\n // files never silently drop a sheet the local parser didn't understand).\n $result = $this->parseSpreadsheetDirect($absPath);\n\n $hasSkippedSheets = ! empty($result['skipped_sheets']);\n\n if (! $result['success'] || empty($result['items']) || $hasSkippedSheets) {\n $deepSeekResult = $this->parseBoqWithDeepSeek($file, $context);\n // AI sees every sheet at once, so prefer its result whenever it\n // actually returned items (covers the skipped-sheet case too).\n if ($deepSeekResult['success'] && ! empty($deepSeekResult['items'])) {\n // Keep the larger of the two item sets so we never lose the\n // rows the local parser already extracted correctly.\n if (count($deepSeekResult['items']) >= count($result['items'] ?? [])) {\n $result = $deepSeekResult;\n }\n } elseif (! $result['success']) {\n $result = $deepSeekResult;\n }\n // else: keep local result if AI also failed/returned nothing\n }\n } elseif (in_array($ext, ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif'], true)) {\n $result = $this->parseBoqWithDeepSeek($file, $context);\n } else {\n return $this->failure('Unsupported file type. Please upload Excel, CSV, PDF, or image file.');\n }\n\n if ($result['success'] && ! empty($result['items'])) {\n $cleaning = $this->boqCleaner->process($result['items']);\n $result['items'] = $cleaning['accepted'] ?? $result['items'];\n $result['rejected'] = array_merge($result['rejected'] ?? [], $cleaning['rejected'] ?? []);\n }\n\n if ($result['success'] && count($result['items'] ?? []) > self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n $result = $this->verifyLargeBoqItemsWithAi($result);\n }\n\n // Only cache when we actually extracted items; empty results should be re-tried.\n if ($result['success'] && ! empty($result['items'])) {\n Cache::put($cacheKey, $result, now()->addDays(30));\n }\n\n return $result;\n }\n\n private function resolveFile(UploadedFile|string $file): array\n {\n if ($file instanceof UploadedFile) {\n return [(string) $file->getRealPath(), strtolower($file->getClientOriginalExtension())];\n }\n\n $absPath = is_file($file) ? $file : storage_path('app/' . ltrim($file, '/'));\n return [$absPath, strtolower(pathinfo($absPath, PATHINFO_EXTENSION))];\n }\n\n private function parseSpreadsheetDirect(string $absPath): array\n {\n ini_set('memory_limit', '1024M');\n set_time_limit(180);\n\n try {\n $sheets = $this->spreadsheetToGrids($absPath);\n\n if (empty($sheets)) {\n return $this->failure('Could not read the Excel file.');\n }\n\n $items = [];\n $rejected = [];\n $skippedSheets = []; // sheets we could not parse locally (header undetected)\n\n foreach ($sheets as $sheet) {\n $grid = $sheet['grid'];\n if (count($grid) < 2) {\n continue;\n }\n\n // Skip dedicated summary / cost-abstract sheets entirely. Their rows\n // are per-discipline totals (Structural Concrete, Electrical, …) and\n // grand totals, not procurable products.\n if ($this->isSummarySheet($sheet['name'])) {\n Log::info('QuotationAiService: skipped summary sheet.', ['sheet' => $sheet['name']]);\n continue;\n }\n\n $header = $this->detectHeader($grid);\n\n if ($header === null || ! isset($header['map']['description'])) {\n Log::warning('QuotationAiService: header not detected in sheet.', ['sheet' => $sheet['name']]);\n $skippedSheets[] = $sheet['name'];\n continue;\n }\n\n $map = $header['map'];\n $start = $header['row'] + 1;\n\n for ($r = $start; $r < count($grid); $r++) {\n $row = $grid[$r];\n $description = $this->cell($row, $map['description'] ?? null);\n $itemNo = $this->cell($row, $map['item_code'] ?? null);\n $unit = $this->cell($row, $map['unit'] ?? null);\n $quantity = $this->number($this->cell($row, $map['quantity'] ?? null));\n $unitPrice = $this->number($this->cell($row, $map['unit_price'] ?? null));\n $totalPrice = $this->number($this->cell($row, $map['total_price'] ?? null));\n\n if ($description === '') {\n continue;\n }\n\n if (! isset($map['quantity'])) {\n $quantity = 1.0;\n }\n\n // Skip floor breakdown sub-rows: Ref. is \"-\" meaning it's a per-floor\n // quantity split of the parent item, not a standalone product.\n if ($itemNo === '-' || $this->isFloorBreakdownRow($itemNo, $description)) {\n continue;\n }\n\n if ($this->isTotalOrHeaderRow($description)) {\n continue;\n }\n\n if ($quantity === null || $quantity <= 0) {\n $rejected[] = $this->rejectedRow($description, $unit, $quantity, 'Missing quantity', $itemNo, $sheet['name']);\n continue;\n }\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $supply = $this->boqCleaner->filterItem($description, $unitPrice, $quantity);\n\n if (! ($supply['keep'] ?? false)) {\n $rejected[] = $this->rejectedRow(\n $description,\n $unit,\n $quantity,\n (string) ($supply['rejection_reason'] ?? 'Rejected by cleaner'),\n $itemNo,\n $sheet['name']\n );\n continue;\n }\n\n $finalDescription = (string) ($supply['description'] ?? $description);\n\n $items[] = [\n 'description' => $finalDescription,\n 'quantity' => $quantity,\n 'unit' => $unit,\n 'category' => $sheet['name'],\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($finalDescription),\n 'unit_price' => $unitPrice,\n 'confidence' => 0.95,\n 'needs_review' => false,\n 'raw_data' => [\n 'sheet' => $sheet['name'],\n 'row_number' => $r + 1,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'cleaned_description' => $finalDescription,\n 'extraction_type' => (string) ($supply['extraction_type'] ?? 'supply_only'),\n ],\n 'ai_extracted' => true,\n ];\n }\n }\n\n if (empty($items)) {\n return $this->failure('No BOQ supply items could be extracted from the Excel file.');\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'skipped_sheets' => $skippedSheets,\n ];\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: parseSpreadsheetDirect failed.', [\n 'path' => $absPath,\n 'message' => $e->getMessage(),\n 'trace' => $e->getTraceAsString(),\n ]);\n\n return $this->failure('Failed to read the Excel file: ' . $e->getMessage());\n }\n }\n\n private function spreadsheetToGrids(string $absPath): array\n {\n $ext = strtolower(pathinfo($absPath, PATHINFO_EXTENSION));\n\n if ($ext === 'csv') {\n $rows = [];\n $handle = fopen($absPath, 'rb');\n if ($handle !== false) {\n while (($row = fgetcsv($handle)) !== false) {\n $rows[] = $this->trimTrailingEmptyCells($row);\n }\n fclose($handle);\n }\n return [['name' => 'Sheet1', 'grid' => $rows]];\n }\n\n $reader = IOFactory::createReader($ext === 'xls' ? 'Xls' : 'Xlsx');\n $reader->setReadDataOnly(true);\n $spreadsheet = $reader->load($absPath);\n $sheets = [];\n\n foreach ($spreadsheet->getAllSheets() as $sheet) {\n $highestRow = $sheet->getHighestDataRow();\n $highestColumn = $sheet->getHighestDataColumn();\n $highestColumnIndex = Coordinate::columnIndexFromString($highestColumn);\n\n // BOQ data is normally in first 8-20 columns.\n // Files with heavy formatting/images can report 300+ columns — limit to 30.\n $maxColumnIndex = min($highestColumnIndex, 30);\n\n $grid = [];\n for ($row = 1; $row <= $highestRow; $row++) {\n $rowData = [];\n $hasValue = false;\n\n for ($col = 1; $col <= $maxColumnIndex; $col++) {\n $value = $sheet->getCell([$col, $row])->getValue();\n $value = $this->cellValueToString($value);\n $value = trim($value);\n if ($value !== null && $value !== '') {\n $hasValue = true;\n }\n $rowData[] = $value;\n }\n\n $rowData = $this->trimTrailingEmptyCells($rowData);\n if ($hasValue) {\n $grid[] = $rowData;\n }\n }\n\n if (! empty($grid)) {\n $sheets[] = ['name' => $sheet->getTitle(), 'grid' => $grid];\n }\n }\n\n $spreadsheet->disconnectWorksheets();\n unset($spreadsheet);\n\n return $sheets;\n }\n\n private function detectHeader(array $grid): ?array\n {\n $keywords = [\n 'item_code' => ['رقم البند', 'item no', 'item number', 'ref.', 'ref', 'item', 'no.', 'no', '#', 'كود'],\n 'description' => ['وص٠البند', 'scope of works', 'scope of work', 'scope', 'item description', 'description of works', 'description', 'بالعربية', 'الوصÙ', 'البند', 'بيان'],\n 'unit' => ['Ø§Ù„ÙˆØØ¯Ø©', 'unit', 'uom', 'u/m'],\n 'quantity' => ['الكمية', 'qty', 'quantity', 'q.ty'],\n 'unit_price' => ['سعر Ø§Ù„ÙˆØØ¯Ø©', 'u/price', 'unit price', 'unit rate', 'rate', 'price'],\n 'total_price' => ['السعر الإجمالي', 'total amount', 'total price', 'amount', 'الإجمالي'],\n ];\n\n $best = null;\n $bestScore = 0;\n\n for ($r = 0; $r < min(30, count($grid)); $r++) {\n $map = [];\n $score = 0;\n\n foreach ($grid[$r] as $c => $value) {\n $cell = mb_strtolower(trim((string) $value));\n if ($cell === '') {\n continue;\n }\n\n foreach ($keywords as $field => $list) {\n foreach ($list as $kw) {\n if (str_contains($cell, mb_strtolower($kw))) {\n if (! isset($map[$field])) {\n $map[$field] = $c;\n $score++;\n }\n break 2;\n }\n }\n }\n }\n\n if ($score > $bestScore) {\n $bestScore = $score;\n $best = ['row' => $r, 'map' => $map];\n }\n }\n\n if ($bestScore >= 3) {\n return $best;\n }\n\n // Allow simpler BOQ sheets that only define a description column plus one\n // other BOQ-related column, e.g. Item + Description or Description + Qty.\n if ($bestScore === 2 && isset($best['map']['description']) && (\n isset($best['map']['item_code']) ||\n isset($best['map']['quantity']) ||\n isset($best['map']['unit'])\n )) {\n return $best;\n }\n\n return null;\n }\n\n private function cell(array $row, ?int $index): string\n {\n if ($index === null) {\n return '';\n }\n return trim((string) ($row[$index] ?? ''));\n }\n\n private function number(string $value): ?float\n {\n $value = trim(str_replace([',', 'SAR', 'ر.س'], '', $value));\n if ($value === '' || ! is_numeric($value)) {\n return null;\n }\n return (float) $value;\n }\n\n private function trimTrailingEmptyCells(array $row): array\n {\n while (! empty($row) && trim((string) end($row)) === '') {\n array_pop($row);\n }\n return array_values($row);\n }\n\n private function isTotalOrHeaderRow(string $description): bool\n {\n $d = mb_strtolower(trim($description));\n\n if ($d === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $d)) {\n return true;\n }\n\n // Section totals / summaries\n if (preg_match('/(اجمالي|إجمالي|المجموع|grand\\s*total|sub\\s*total|total\\s*amount|total\\s*price)/iu', $d)) {\n return true;\n }\n\n // Bare \"Total\" lines, optionally with a currency suffix, e.g. \"Total\",\n // \"Total (SAR)\", \"Total cost\", \"Total (SAR) unit cost\".\n if (preg_match('/^total\\b[\\s\\(\\):a-z]*$/iu', $d)) {\n return true;\n }\n\n // Pure floor / level labels used as section headers\n if (preg_match('/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor)\\s*$/iu', $d)) {\n return true;\n }\n\n // Division / section header lines (e.g. \"Division - 02: Wood Works\")\n if (preg_match('/^division\\s*([-:]|\\d)/iu', $d)) {\n return true;\n }\n\n return false;\n }\n\n /**\n * Detect dedicated summary / cost-abstract sheets by their name. These hold\n * per-discipline rollups and grand totals (e.g. a \"Summary (Dry Cost)\" tab),\n * never procurable supply items, so they are skipped wholesale.\n */\n private function isSummarySheet(string $name): bool\n {\n $n = mb_strtolower(trim($name));\n\n if ($n === '') {\n return false;\n }\n\n return (bool) preg_match(\n '/\\b(summary|abstract|cost\\s*summary|grand\\s*summary|recap(?:itulation)?|bill\\s*summary|boq\\s*summary|dry\\s*cost)\\b|ملخص|الملخص|الملخّص|ملخّص|اجمالي|إجمالي/iu',\n $n\n );\n }\n\n private function isFloorBreakdownRow(string $itemNo, string $description): bool\n {\n // A row whose Ref column is empty or a dash is a sub-breakdown row (per-floor split).\n $ref = trim($itemNo);\n if ($ref === '' || $ref === '-') {\n // Confirm by checking the description is a location/floor label\n $d = mb_strtolower(trim($description));\n return (bool) preg_match(\n '/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor|\\d+f)\\s*$/iu',\n $d\n );\n }\n return false;\n }\n\n private function rejectedRow(string $description, string $unit, ?float $quantity, string $reason, string $itemNo = '', string $sheet = ''): array\n {\n return [\n 'description' => $description,\n 'quantity' => $quantity ?? 0,\n 'unit' => $unit,\n 'category' => $sheet,\n 'brand' => '',\n 'status' => 'rejected',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 0.0,\n 'raw_data' => [\n 'sheet' => $sheet,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'extraction_type' => 'rejected',\n 'rejection_reason' => $reason,\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function parseBoqWithDeepSeek(UploadedFile|string $file, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured. Please set DEEPSEEK_API_KEY in .env file.');\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n $mime = $this->mimeForExtension($ext);\n\n try {\n // ── Spreadsheets ──────────────────────────────────────────────────\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n $text = $this->spreadsheetToCompactCsvText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not convert spreadsheet to readable text.');\n }\n\n $text = $this->sanitizeUtf8($text);\n\n // A large BOQ exceeds what one request can carry, so send it in\n // sequential slices and merge. Anything that fits goes as-is.\n //\n // Size alone is not a good enough signal: a workbook with several\n // sheets is structurally a big BOQ even when the text is compact,\n // and asking the model to hold every discipline at once is where\n // rows start getting dropped. So split on either signal.\n $sheetCount = substr_count($text, \"\\nSheet: \") + (str_starts_with($text, 'Sheet: ') ? 1 : 0);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS || $sheetCount > self::MAX_SHEETS_PER_CALL) {\n Log::info('QuotationAiService: spreadsheet routed to chunked parsing.', [\n 'chars' => mb_strlen($text),\n 'sheets' => $sheetCount,\n ]);\n return $this->parseTextInChunks($text, 'BOQ spreadsheet converted to CSV', $apiKey, $context);\n }\n\n $userContent = \"BOQ spreadsheet converted to CSV:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── PDF ───────────────────────────────────────────────────────────\n if ($ext === 'pdf') {\n $extracted = $this->extractPdfText($absPath);\n if ($extracted !== null && mb_strlen(trim($extracted)) > 50) {\n $extracted = $this->sanitizeUtf8($extracted);\n\n // Split on either signal — long text, or many pages. A BOQ\n // spanning several pages is a multi-section document even if\n // each page is sparse.\n if (mb_strlen($extracted) > self::TEXT_CHUNK_CHARS\n || $this->lastPdfPageCount > self::MAX_PDF_PAGES_PER_CALL) {\n Log::info('QuotationAiService: PDF routed to chunked parsing.', [\n 'chars' => mb_strlen($extracted),\n 'pages' => $this->lastPdfPageCount,\n ]);\n return $this->parseTextInChunks($extracted, 'BOQ PDF extracted text', $apiKey, $context, 'application/pdf');\n }\n\n $userContent = \"BOQ PDF extracted text:\\n\\n\" . $extracted . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'application/pdf');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n return $this->failure('Could not extract text from the PDF. Please make sure it is a text-based PDF (not a scanned image), or convert it to Excel or CSV.');\n }\n\n // -- Word documents (docx) ---------------------------------------------------\n if (in_array($ext, ['docx', 'doc'], true)) {\n $text = $this->extractDocxText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not extract text from the Word document. Please convert it to Excel, CSV, or PDF.');\n }\n $text = $this->sanitizeUtf8($text);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS) {\n return $this->parseTextInChunks($text, 'BOQ Word document extracted text', $apiKey, $context);\n }\n\n $userContent = \"BOQ Word document extracted text:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── Images ──────────────────────────────────────────────────────────────\n // DeepSeek cannot receive images. A vision-capable AI processes the image\n // directly (no OCR) — configure GROQ_API_KEY or GEMINI_API_KEY in .env.\n $imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif', 'heic', 'heif'];\n if (in_array($ext, $imageExts, true)) {\n $bytes = (string) file_get_contents($absPath);\n return $this->callDeepSeekVision($bytes, $mime, $context);\n }\n\n return $this->failure('Unsupported file type. Please upload an Excel, CSV, PDF, or image file.');\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: DeepSeek fallback failed.', ['message' => $e->getMessage()]);\n return $this->failure('An error occurred while processing the file with AI: ' . $e->getMessage());\n }\n }\n\n private function deepSeekModel(): string\n {\n return (string) config('services.deepseek.model', 'deepseek-chat');\n }\n\n /**\n * Remove or replace any non-UTF-8 / malformed byte sequences so json_encode never fails.\n */\n private function sanitizeUtf8(string $text): string\n {\n // Replace invalid byte sequences with the UTF-8 replacement character\n $clean = mb_convert_encoding($text, 'UTF-8', 'UTF-8');\n // Strip any remaining non-printable control characters (except tab/newline/CR)\n $clean = (string) preg_replace('/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]/u', '', $clean);\n return $clean;\n }\n\n /**\n * Extract plain text from a Word docx file using ZipArchive (no external library needed).\n * Reads word/document.xml and strips XML tags, preserving paragraph and table structure.\n */\n private function extractDocxText(string $absPath): ?string\n {\n try {\n $zip = new \\ZipArchive();\n if ($zip->open($absPath) !== true) {\n return null;\n }\n\n $xml = $zip->getFromName('word/document.xml');\n $zip->close();\n\n if ($xml === false || $xml === '') {\n return null;\n }\n\n // Add newlines before paragraph/table-row/table-cell tags so structure is preserved\n $xml = preg_replace('/<w:p[ >]/', \"\\n<w:p>\", $xml) ?? $xml;\n $xml = preg_replace('/<w:tr[ >]/', \"\\n<w:tr>\", $xml) ?? $xml;\n $xml = preg_replace('/<\\/w:tc>/', \"\\t\", $xml) ?? $xml;\n\n $text = strip_tags($xml);\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n return trim($text) !== '' ? trim($text) : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: DOCX parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Extract plain text from a PDF using smalot/pdfparser (pure PHP, no binary required).\n */\n private function extractPdfText(string $absPath): ?string\n {\n try {\n $parser = new \\Smalot\\PdfParser\\Parser();\n $pdf = $parser->parseFile($absPath);\n\n // Recorded for the chunking decision: a long PDF is structurally a\n // big BOQ even when its extracted text is short.\n try {\n $this->lastPdfPageCount = count($pdf->getPages());\n } catch (\\Throwable) {\n $this->lastPdfPageCount = 0;\n }\n\n $text = $pdf->getText();\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n return $text !== '' ? $text : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: PDF parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Send a vision request to a vision-capable AI model.\n * DeepSeek's commercial API (api.deepseek.com) does NOT support image inputs — any model.\n * Priority order (first configured key wins):\n * 1. GROQ_API_KEY → Groq llama-4-scout (free: https://console.groq.com)\n * 2. GEMINI_API_KEY → Google Gemini Flash (free: https://aistudio.google.com/apikey)\n * 3. VISION_API_KEY → any OpenAI-compatible endpoint (OpenAI, OpenRouter …)\n */\n private function callDeepSeekVision(string $bytes, string $mime, array $context): array\n {\n // ── 1. Groq (free, reliable vision) ─────────────────────────────────────\n if ((string) config('services.groq.key', '') !== '') {\n $visionKey = (string) config('services.groq.key');\n $visionBaseUrl = 'https://api.groq.com/openai/v1';\n $visionModel = (string) config('services.groq.model', 'meta-llama/llama-4-scout-17b-16e-instruct');\n }\n // ── 2. Google Gemini ─────────────────────────────────────────────────────\n elseif ((string) config('services.gemini.key', '') !== '') {\n $visionKey = (string) config('services.gemini.key');\n $visionBaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai';\n $visionModel = (string) config('services.gemini.model', 'gemini-2.0-flash');\n }\n // ── 3. Generic VISION_API_KEY (OpenAI, OpenRouter, …) ───────────────────\n elseif ((string) config('services.vision.key', '') !== '') {\n $visionKey = (string) config('services.vision.key');\n $visionBaseUrl = rtrim((string) config('services.vision.base_url', 'https://openrouter.ai/api/v1'), '/');\n $visionModel = (string) config('services.vision.model', 'google/gemini-flash-1.5-8b');\n }\n else {\n return $this->failure(\n 'Image processing is not available. DeepSeek\\'s API does not support images. ' .\n 'Get a free key at https://console.groq.com and add GROQ_API_KEY to your .env file.'\n );\n }\n\n $dataUrl = \"data:{$mime};base64,\" . base64_encode($bytes);\n $prompt = $this->buildDeepSeekPrompt($context, $mime);\n\n $userContent = [\n ['type' => 'text', 'text' => $prompt],\n ['type' => 'image_url', 'image_url' => ['url' => $dataUrl]],\n ];\n\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders([\n 'Authorization' => 'Bearer ' . $visionKey,\n 'HTTP-Referer' => config('app.url', 'https://qimta.sa'),\n 'X-Title' => 'Qimta Platform',\n ])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post($visionBaseUrl . '/chat/completions', [\n 'model' => $visionModel,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 16384,\n 'temperature' => 0.2,\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('Vision API connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: Vision API HTTP error.', [\n 'model' => $visionModel,\n 'base_url' => $visionBaseUrl,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n return $this->failure(\"Vision API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n return $this->parseDeepSeekJsonResponse((string) $response->json('choices.0.message.content'));\n }\n\n private function callDeepSeekChat(string $userContent, string $key, string $model): array\n {\n // Ensure the PHP script outlives the HTTP call (+ a margin for parsing the\n // response and writing to the DB) so the worker/web request is not killed\n // mid-flight, which surfaces to users as a generic \"timeout or unavailable\".\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders(['Authorization' => 'Bearer ' . $key])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 32768,\n 'temperature' => 0.1,\n 'response_format' => ['type' => 'json_object'],\n 'user' => 'Qimta_Platform',\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('DeepSeek connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: DeepSeek HTTP error.', [\n 'model' => $model,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n if ($response->status() === 401) {\n return $this->failure('DeepSeek API key is invalid or unauthorised. Please update DEEPSEEK_API_KEY in .env.', false);\n }\n return $this->failure(\"DeepSeek API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n $text = (string) $response->json('choices.0.message.content');\n return $this->parseDeepSeekJsonResponse($text);\n }\n\n /**\n * Parse a document too large for one AI call by sending it in slices.\n *\n * A 20 000-row BOQ is several megabytes of text — far beyond what a single\n * request can carry, and beyond what the model can answer without hitting its\n * output token limit. Previously the text was simply cut at 180 000 chars and\n * the remainder discarded: the parse \"succeeded\" while silently dropping most\n * of the file. Now the text is split and each slice parsed on its own call,\n * with the items merged.\n *\n * Splitting happens on line boundaries so a BOQ row is never cut in half.\n * Slices run sequentially rather than pooled: DeepSeek rate-limits hard on\n * concurrent large prompts, and this already runs on the queue where wall\n * time is cheap.\n *\n * A slice that fails does not fail the document — its rows are lost, but the\n * rest are kept and the shortfall is logged. Total failure is reported only\n * when every slice fails.\n *\n * @param string $label How the payload is introduced to the model.\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n private function parseTextInChunks(\n string $text,\n string $label,\n string $apiKey,\n array $context,\n string $mime = 'text/plain',\n ): array {\n $chunks = $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n $total = count($chunks);\n\n Log::info('QuotationAiService: large document split for parsing.', [\n 'chars' => mb_strlen($text),\n 'chunks' => $total,\n ]);\n\n $items = [];\n $rejected = [];\n $failed = 0;\n $lastError = null;\n\n foreach ($chunks as $index => $chunk) {\n $part = $index + 1;\n\n // Report progress so a 28-slice parse does not look frozen to the user.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)($part, $total);\n }\n\n // Tell the model this is a fragment, so it does not try to reconcile\n // totals or treat a mid-document slice as a complete BOQ.\n $userContent = \"{$label} (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, $mime);\n\n $result = $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n\n if (! ($result['success'] ?? false)) {\n $failed++;\n $lastError = $result['error'] ?? 'Chunk parsing failed.';\n Log::warning('QuotationAiService: chunk failed, continuing.', [\n 'part' => $part,\n 'of' => $total,\n 'error' => $lastError,\n ]);\n continue;\n }\n\n foreach ($result['items'] as $item) {\n $items[] = $item;\n }\n foreach ($result['rejected'] ?? [] as $row) {\n $rejected[] = $row;\n }\n\n // Hand this slice's rows straight to the caller so they can be shown\n // while the remaining slices are still being parsed.\n if ($this->onChunkItems !== null && $result['items'] !== []) {\n ($this->onChunkItems)($result['items'], $part, $total);\n }\n }\n\n // Every slice failed — there is nothing to show, so report the failure.\n if ($failed === $total) {\n return $this->failure($lastError ?? 'Could not parse the document.');\n }\n\n if ($failed > 0) {\n Log::error('QuotationAiService: document parsed with missing parts.', [\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n 'items_kept' => count($items),\n ]);\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'service_unavailable' => false,\n 'partial' => $failed > 0,\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n ];\n }\n\n /**\n * Split text into slices of at most $limit characters, breaking only at\n * newlines so no BOQ row is ever cut in half.\n *\n * A single line longer than the limit (rare — a pathological cell) is passed\n * through oversized rather than severed mid-row.\n *\n * @return array<int, string>\n */\n private function splitOnLineBoundaries(string $text, int $limit): array\n {\n $chunks = [];\n $current = '';\n\n // Sheet a chunk starts in the middle of. Repeated at the top of the next\n // chunk so the model always knows which sheet the rows belong to — the\n // header would otherwise be stranded in the previous slice, and rows\n // would arrive with no discipline context.\n $currentSheet = null;\n\n foreach (explode(\"\\n\", $text) as $line) {\n if (str_starts_with($line, 'Sheet: ')) {\n $currentSheet = $line;\n }\n\n $candidate = $current === '' ? $line : $current . \"\\n\" . $line;\n\n if (mb_strlen($candidate) > $limit && $current !== '') {\n $chunks[] = $current;\n\n // Carry the sheet header over, unless this line is itself one.\n $current = ($currentSheet !== null && ! str_starts_with($line, 'Sheet: '))\n ? $currentSheet . \" (continued)\\n\" . $line\n : $line;\n continue;\n }\n\n $current = $candidate;\n }\n\n if (trim($current) !== '') {\n $chunks[] = $current;\n }\n\n return $chunks;\n }\n\n /**\n * Shared JSON response parser used by both chat and vision paths.\n */\n private function parseDeepSeekJsonResponse(string $text): array\n {\n if ($text === '') {\n return $this->failure('DeepSeek returned an empty response.');\n }\n\n // Strip markdown code fences if the model wrapped the JSON.\n if (preg_match('/```(?:json)?\\s*([\\s\\S]*?)\\s*```/i', $text, $m)) {\n $text = $m[1];\n }\n // Narrow to the outermost JSON object/array.\n if (preg_match('/[\\{\\[][\\s\\S]*[\\}\\]]/s', $text, $m)) {\n $text = $m[0];\n }\n $text = trim($text);\n\n $decoded = json_decode($text, true);\n\n // The response can be truncated when the model hits the token limit,\n // leaving an unterminated JSON string/object. Try to repair it before\n // giving up so we can still salvage the items extracted so far.\n if (! is_array($decoded)) {\n $repaired = $this->repairTruncatedJson($text);\n if ($repaired !== null) {\n $decoded = json_decode($repaired, true);\n }\n }\n\n if (! is_array($decoded)) {\n Log::error('QuotationAiService: DeepSeek non-JSON response.', ['text' => mb_substr($text, 0, 1000)]);\n return $this->failure('DeepSeek returned a non-JSON response. The file may be too large — try splitting it into smaller files.');\n }\n\n $rawItems = $decoded['items'] ?? (array_is_list($decoded) ? $decoded : []);\n $items = [];\n $rejected = [];\n\n foreach ($rawItems as $raw) {\n if (! is_array($raw)) {\n continue;\n }\n $item = $this->normaliseAiItem($raw);\n if ($item === null) {\n continue;\n }\n $check = $this->boqCleaner->filterItem((string) $item['description']);\n if ($check['keep'] ?? false) {\n $items[] = $item;\n } else {\n $item['status'] = 'rejected';\n $item['raw_data']['rejection_reason'] = $check['rejection_reason'] ?? 'Rejected by cleaner';\n $rejected[] = $item;\n }\n }\n\n if (empty($items)) {\n Log::info('QuotationAiService: DeepSeek returned 0 accepted items.', [\n 'raw_items_count' => count($rawItems),\n 'rejected_count' => count($rejected),\n 'first_rejected' => ! empty($rejected) ? ($rejected[0]['description'] ?? '') : '',\n ]);\n }\n\n return ['success' => true, 'items' => $items, 'rejected' => $rejected, 'error' => null];\n }\n\n /**\n * Best-effort repair of a truncated JSON string produced when the model hits\n * its token limit mid-output. Drops any incomplete trailing object, then\n * closes the open string/array/object brackets so the result is valid JSON.\n *\n * Returns the repaired JSON string, or null if it cannot be salvaged.\n */\n private function repairTruncatedJson(string $text): ?string\n {\n if ($text === '') {\n return null;\n }\n\n // Walk the string tracking structural state, ignoring everything inside\n // string literals (and their escapes). Remember the last position where\n // we were back at the top of the \"items\" array between complete elements,\n // so we can cut a half-written trailing object.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($text);\n $lastSafeCut = null; // index just after a completed array element\n\n for ($i = 0; $i < $len; $i++) {\n $ch = $text[$i];\n\n if ($inString) {\n if ($escaped) {\n $escaped = false;\n } elseif ($ch === '\\\\') {\n $escaped = true;\n } elseif ($ch === '\"') {\n $inString = false;\n }\n continue;\n }\n\n if ($ch === '\"') {\n $inString = true;\n } elseif ($ch === '{' || $ch === '[') {\n $stack[] = $ch;\n } elseif ($ch === '}' || $ch === ']') {\n array_pop($stack);\n } elseif ($ch === ',' && ! empty($stack) && end($stack) === '[') {\n // Separator directly inside an array: a clean boundary between\n // elements (the items array, whatever its nesting depth).\n $lastSafeCut = $i;\n }\n }\n\n // If we are still inside a string, the last element is incomplete — fall\n // back to the last clean array boundary if we have one.\n $candidate = $text;\n if ($inString && $lastSafeCut !== null) {\n $candidate = substr($text, 0, $lastSafeCut);\n } else {\n // Drop a dangling partial object after the last completed element.\n $candidate = rtrim($candidate);\n if ($lastSafeCut !== null && substr(rtrim($candidate), -1) !== '}' && substr(rtrim($candidate), -1) !== ']') {\n $candidate = substr($text, 0, $lastSafeCut);\n }\n }\n\n // Recompute the open brackets for the (possibly trimmed) candidate and\n // append the matching closers in reverse order.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($candidate);\n for ($i = 0; $i < $len; $i++) {\n $ch = $candidate[$i];\n if ($inString) {\n if ($escaped) { $escaped = false; }\n elseif ($ch === '\\\\') { $escaped = true; }\n elseif ($ch === '\"') { $inString = false; }\n continue;\n }\n if ($ch === '\"') { $inString = true; }\n elseif ($ch === '{' || $ch === '[') { $stack[] = $ch; }\n elseif ($ch === '}' || $ch === ']') { array_pop($stack); }\n }\n\n $candidate = rtrim($candidate);\n // Remove a trailing comma left after cutting an element.\n $candidate = rtrim($candidate, ',');\n\n $closers = '';\n for ($i = count($stack) - 1; $i >= 0; $i--) {\n $closers .= ($stack[$i] === '{') ? '}' : ']';\n }\n\n $repaired = $candidate . $closers;\n\n // Validate before returning.\n return is_array(json_decode($repaired, true)) ? $repaired : null;\n }\n\n private function spreadsheetToCompactCsvText(string $absPath): ?string\n {\n $sheets = $this->spreadsheetToGrids($absPath);\n if (empty($sheets)) {\n return null;\n }\n\n $output = '';\n foreach ($sheets as $sheet) {\n // Don't feed summary / cost-abstract sheets to the AI either.\n if ($this->isSummarySheet($sheet['name'])) {\n continue;\n }\n $output .= 'Sheet: ' . $sheet['name'] . \"\\n\";\n foreach ($sheet['grid'] as $row) {\n $row = $this->trimTrailingEmptyCells($row);\n if (empty($row)) {\n continue;\n }\n $output .= implode(',', array_map([$this, 'csvEscape'], $row)) . \"\\n\";\n }\n $output .= \"\\n\";\n }\n\n return $output;\n }\n\n /**\n * Safely convert a PhpSpreadsheet cell value to a string.\n *\n * Cells may contain RichText or, in malformed/heavily-formatted files,\n * embedded objects such as Worksheet\\Drawing that have no __toString().\n * Casting those directly throws \"Object ... could not be converted to string\".\n */\n private function cellValueToString(mixed $value): string\n {\n if ($value === null) {\n return '';\n }\n\n if ($value instanceof \\PhpOffice\\PhpSpreadsheet\\RichText\\RichText) {\n return $value->getPlainText();\n }\n\n if (is_object($value)) {\n // Only stringify objects that explicitly support it; ignore the rest\n // (e.g. Worksheet\\Drawing) instead of crashing.\n return method_exists($value, '__toString') ? (string) $value : '';\n }\n\n if (is_array($value)) {\n return '';\n }\n\n return (string) $value;\n }\n\n private function csvEscape(mixed $value): string\n {\n $value = $this->cellValueToString($value);\n return str_contains($value, ',') || str_contains($value, '\"') || str_contains($value, \"\\n\")\n ? '\"' . str_replace('\"', '\"\"', $value) . '\"'\n : $value;\n }\n\n private function verifyLargeBoqItemsWithAi(array $result): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n Log::warning('QuotationAiService: large BOQ verification skipped, AI key is not configured.');\n return $result;\n }\n\n $items = array_values(array_filter($result['items'] ?? [], 'is_array'));\n if (count($items) <= self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n return $result;\n }\n\n Log::info('QuotationAiService: starting second-pass physical item verification.', [\n 'items_count' => count($items),\n 'threshold' => self::LARGE_BOQ_VERIFICATION_THRESHOLD,\n ]);\n\n $verifiedItems = [];\n $rejectedItems = $result['rejected'] ?? [];\n\n foreach (array_chunk($items, self::LARGE_BOQ_VERIFICATION_CHUNK_SIZE) as $chunkIndex => $chunk) {\n $payload = array_map(fn(array $item) => [\n 'description' => (string) ($item['description'] ?? ''),\n 'quantity' => $item['quantity'] ?? null,\n 'unit' => (string) ($item['unit'] ?? ''),\n 'unit_price' => $item['unit_price'] ?? null,\n 'category' => (string) ($item['category'] ?? ''),\n 'brand' => (string) ($item['brand'] ?? ''),\n ], $chunk);\n\n $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);\n if (! is_string($json)) {\n Log::warning('QuotationAiService: large BOQ verification chunk JSON encode failed.', [\n 'chunk' => $chunkIndex + 1,\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n $prompt = <<<PROMPT\nYou are doing a SECOND PASS verification on a BOQ extraction result.\n\nThe first pass may hallucinate non-products when the BOQ has thousands of rows.\nYour job is to keep ONLY physical, tangible, procurable supply products.\n\nRules:\n- Keep items only if a supplier can actually deliver them as materials/equipment.\n- Reject headings, trade names, systems, scopes, prose, specifications-only rows, totals, labor, installation, testing, commissioning, supervision, drawings, insurance, preliminaries, mobilisation, site works, and vague fragments.\n- Do not invent new products.\n- Do not add items that are not in the input.\n- You may clean a product description, but keep the original quantity, unit, unit_price, category, and brand when available.\n\nInput JSON array:\n{$json}\n\nReturn ONLY valid JSON in this shape:\n{\"items\":[{\"description\":\"\",\"quantity\":1,\"unit\":\"\",\"unit_price\":null,\"category\":\"\",\"brand\":\"\",\"confidence\":0.95}]}\nPROMPT;\n\n try {\n $chunkResult = $this->callDeepSeekChat($prompt, $apiKey, $this->deepSeekModel());\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: large BOQ verification chunk threw an exception.', [\n 'chunk' => $chunkIndex + 1,\n 'message' => $e->getMessage(),\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n if ($chunkResult['success'] ?? false) {\n array_push($verifiedItems, ...($chunkResult['items'] ?? []));\n $rejectedItems = array_merge($rejectedItems, $chunkResult['rejected'] ?? []);\n continue;\n }\n\n Log::warning('QuotationAiService: large BOQ verification chunk failed, keeping original chunk.', [\n 'chunk' => $chunkIndex + 1,\n 'error' => $chunkResult['error'] ?? null,\n ]);\n array_push($verifiedItems, ...$chunk);\n }\n\n if (empty($verifiedItems)) {\n Log::warning('QuotationAiService: second-pass verification returned no items, keeping first-pass result.', [\n 'original_items_count' => count($items),\n ]);\n return $result;\n }\n\n $result['items'] = $verifiedItems;\n $result['rejected'] = $rejectedItems;\n $result['large_boq_verified'] = true;\n\n Log::info('QuotationAiService: second-pass physical item verification completed.', [\n 'before' => count($items),\n 'after' => count($verifiedItems),\n ]);\n\n return $result;\n }\n\n private function normaliseAiItem(array $raw): ?array\n {\n if (! empty($raw['rejected'])) {\n return null;\n }\n\n $description = trim((string) ($raw['cleaned_description'] ?? $raw['product_name'] ?? $raw['description'] ?? ''));\n if ($description === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $description)) {\n return null;\n }\n\n $quantity = is_numeric($raw['quantity'] ?? null) ? (float) $raw['quantity'] : 1;\n $unitPrice = is_numeric($raw['unit_price'] ?? null) ? (float) $raw['unit_price'] : null;\n $totalPrice = is_numeric($raw['total_price'] ?? null) ? (float) $raw['total_price'] : null;\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $specs = is_array($raw['specifications'] ?? null) ? $raw['specifications'] : [];\n\n return [\n 'description' => $description,\n 'quantity' => $quantity,\n 'unit' => (string) ($raw['unit'] ?? ''),\n 'category' => (string) ($raw['core_product'] ?? $raw['category'] ?? ''),\n 'brand' => (string) ($specs['brand'] ?? $raw['brand'] ?? ''),\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($description),\n 'unit_price' => $unitPrice,\n 'confidence' => is_numeric($raw['confidence'] ?? null) ? (float) $raw['confidence'] : null,\n 'needs_review' => ! empty($raw['needs_review']),\n 'raw_data' => [\n 'source_item_no' => (string) ($raw['source_item_no'] ?? ''),\n 'original_description' => (string) ($raw['original_description'] ?? $description),\n 'cleaned_description' => $description,\n 'core_product' => (string) ($raw['core_product'] ?? ''),\n 'specifications' => $specs,\n 'extraction_type' => (string) ($raw['extraction_type'] ?? 'supply_only'),\n 'note' => (string) ($raw['note'] ?? $raw['notes'] ?? ''),\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function buildSystemInstruction(): string\n {\n return <<<'TXT'\nYou are a strict BOQ Supply Product Extraction Engine.\nExtract ONLY concrete, tangible, physical products that a supplier can box up and deliver to site — a specific item you could point at and say \"this is the thing being bought\" (e.g. \"Wall mounted LED luminaire 36W\", \"PVC pipe 110mm\", \"Single phase distribution board 12-way\", \"Fire extinguisher CO2 6kg\", \"CCTV dome camera 4MP\").\n\nABSOLUTELY REJECT abstract concepts, disciplines, categories, section names, and trade headings. These are NOT products even though they appear as rows:\n- Bare discipline/trade words: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", \"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\".\n- Summary / total / abstract / \"dry cost\" rows of any kind.\n- Anything that names a system, scope, or category instead of one buyable item.\n\nRule of thumb: if you cannot name a unit and physically hand the item over (a piece, a box, a length, a unit of equipment), REJECT it. A word that describes a field of work, a system, or a section of the BOQ is NEVER a product.\nReject totals, headings, general requirements, installation-only, testing, commissioning, supervision, mobilization, civil works, labor, and vague rows.\nFor Supply & Install rows, keep only the supply product and remove install/labor wording.\nReturn only valid JSON: {\"items\":[{\"source_item_no\":null,\"original_description\":\"\",\"cleaned_description\":\"\",\"core_product\":\"\",\"specifications\":{},\"quantity\":0,\"unit\":\"\",\"unit_price\":0,\"total_price\":0,\"extraction_type\":\"supply_only\",\"needs_review\":false,\"note\":\"\",\"rejected\":false,\"rejection_reason\":null,\"confidence\":0.95}]}\nTXT;\n }\n\n private function buildDeepSeekPrompt(array $context = [], string $mime = ''): string\n {\n $projectName = (string) ($context['project_name'] ?? '');\n $projectStatus = (string) ($context['project_status'] ?? '');\n $contextLine = $projectName !== ''\n ? \"Project: {$projectName}\" . ($projectStatus !== '' ? \" ({$projectStatus})\" : '')\n : '';\n $sourceHint = str_starts_with($mime, 'image/')\n ? 'The input is an image of a BOQ table. OCR every cell carefully — read the quantity/count column (العدد, الكمية, Qty) and extract the EXACT numeric value from each row.'\n : 'The input is a BOQ document/spreadsheet.';\n\n return trim(<<<PROMPT\nYou are a procurement specialist. Your task is to extract ONLY tangible, physical supply products from this Bill of Quantities (BOQ).\n\n{$contextLine}\n{$sourceHint}\n\nCRITICAL — MERGE WRAPPED / SPLIT ROWS INTO ONE ITEM:\nBOQ tables extracted from PDF often break ONE item's long description across several\nlines/rows. These continuation lines repeat the SAME quantity, unit and category, and\ntheir text is a grammatical continuation of the previous line (it starts mid-sentence,\nlowercase, or with \"the/and/-\", or is a spec/instruction clause). You MUST reconstruct\nthe original single item:\n- Treat consecutive rows that share the SAME quantity + unit + category as ONE product\n when the later rows are clearly continuation text of the first, NOT new products.\n- Emit ONE item with the merged description and the price counted ONCE. Do NOT repeat\n the same quantity×price for every wrapped line — that multiplies the total wrongly.\n- Example: rows \"The asphalt flooring is to be supplied\" / \"installed in accordance\" /\n \"standard road specifications...\" all with 7,100 m2 are ONE asphalt item, not 3+.\n\nCRITICAL — UNDERSTAND INTENT, DO NOT COPY ROWS LITERALLY:\nYour job is to output WHAT A SUPPLIER MUST ACTUALLY DELIVER, not a copy of the row text.\nRead each description for MEANING and decide how many real, separately-buyable products it\nimplies. One BOQ row can imply SEVERAL products, or a spread of rows can imply ONE product.\n- SPLIT a row into multiple items when it lists distinct products that a supplier would\n buy/price separately — even when joined by commas, \"with\", \"and\", \"including\", \"c/w\",\n \"consisting of\", \"comprising\", or \":\" then a list.\n Example: \"Fire pump set consisting of: fire pump, electric motor, control panel, diesel\n engine\" → FOUR items: (1) fire pump, (2) electric motor, (3) control panel, (4) diesel\n engine — each with its own quantity and priced on its own.\n Example: \"Wash basin complete with mixer tap and bottle trap\" → THREE items: wash basin,\n mixer tap, bottle trap.\n- Give each split product the correct quantity implied by the text (usually the parent\n quantity, unless the text states a different count for a sub-item). Each product is\n priced individually; the line total becomes the SUM of its products.\n- DO NOT split when the extra words are just specifications, dimensions, colours, ratings,\n standards, or a single product's integral parts. \"Steel door 900x2100 with vision panel\n and SS ironmongery\" is usually ONE door product — vision panel and ironmongery are part\n of that door, not separately-bought items. Use judgement: would a supplier issue a\n separate price line for it? If no, keep it as one.\n- Balanced approach: split only when genuinely separate products are present; when in doubt\n about whether something is a separate product vs. an accessory/spec of the main one, keep\n it as a single item. Do not invent products that are not in the text.\n\nSTRICT RULES — include ONLY items that are ALL of the following:\n1. A specific, concrete, tangible product that can be boxed up by a supplier and delivered to site — something you can point at and say \"this exact thing is being bought\" (e.g. \"LED luminaire 36W\", \"PVC pipe 110mm\", \"Distribution board 12-way\", \"CO2 fire extinguisher 6kg\").\n2. Has a real quantity (not 0 or \"varies\" with no meaning).\n3. Has enough description to identify what the product is.\n4. Is an actual PRODUCT, not a sentence, instruction, or specification clause. REJECT rows that are prose such as \"the supervising engineer's instructions\", \"The price includes all materials, labor\", \"Measurement will be based on the geometric cubic meter\", \"necessary steps to complete the work\", \"soil-contacting elements\", \"in compliance\", \"everything necessary\". These are description fragments, not buyable items.\n\nNEVER extract abstract concepts, disciplines, trades, systems, or section/category names — they are NOT products even when they appear as their own row:\n- Bare discipline/trade/system words such as: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", and their Arabic equivalents (\"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\", \"تشطيبات\").\n- Test: if the word names a FIELD OF WORK or a SYSTEM rather than one buyable item with a unit (pcs, m, set, kg…), REJECT it.\n\nEXCLUDE all of the following (do NOT include them):\n- Labor, installation, or workmanship items (e.g. \"install\", \"fix\", \"erect\", \"dismantle\", \"painting\", \"cleaning service\")\n- Project management, overhead, or profit items\n- Insurance, bonds, liability items (e.g. \"bodily injury\", \"third party liability\", \"CAR insurance\")\n- Shop drawings, as-built drawings, method statements, documentation\n- Preliminary items, mobilisation, demobilisation, provisional sums\n- Housekeeping, site clearance, waste removal\n- Floor/level labels (e.g. \"GF Floor\", \"Level 3\") — these are headers, not products\n- Pure dimension specs without a product noun (e.g. \"Dia 32 mm\", \"25 mm\") — reject unless a product name is present\n- Security services, night shifts, rentals, payments\n- Any item whose description is too vague or fragmentary to identify a specific product\n\nEmit ONE JSON item PER real product. A single source row may therefore produce several\nitems (when it lists multiple products) or several rows may collapse into one item (when\nthey are wrapped fragments of the same product). The \"items\" array is your final delivery\nlist, not a row-by-row transcript.\n\nFor each valid physical product return JSON with:\n- description: clean product name only (strip \"supply and install\", \"supply and fix\", installation clauses, floor/spec suffixes). For a split product, use just that product's own name.\n- quantity: the EXACT numeric value from the quantity/count column (العدد, الكمية, Qty, Quantity, No.). NEVER default to 1 unless the quantity column is truly absent or empty.\n- unit: unit of measure (pcs, m, m2, m3, set, no, kg, etc.)\n- category: product category\n- brand: brand/manufacturer if mentioned, else \"\"\n- engineering_required: true if the item needs engineering design before procurement (panel boards, chillers, generators, fire suppression systems, BMS, pumps, AHUs, switchgear, transformers), else false\n\nReturn ONLY a valid JSON object: {\"items\": [...]}. No explanations, no markdown, no extra text.\nPROMPT);\n }\n\n private function mockResponse(): array\n {\n return [\n 'success' => true,\n 'error' => null,\n 'items' => [[\n 'description' => 'Sample BOQ Item',\n 'quantity' => 1,\n 'unit' => 'No',\n 'category' => 'Sample',\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 1,\n 'raw_data' => [],\n 'ai_extracted' => true,\n ]],\n 'rejected' => [],\n ];\n }\n\n private function failure(string $message, bool $serviceUnavailable = false): array\n {\n return [\n 'success' => false,\n 'items' => [],\n 'rejected' => [],\n 'error' => $this->userFacingError($message, $serviceUnavailable),\n 'service_unavailable' => $serviceUnavailable,\n ];\n }\n\n /**\n * Translate any internal error into a neutral, user-safe message.\n *\n * The user must never see the name of the underlying AI/processing engine\n * (DeepSeek, Groq, Gemini, Vision, etc.) or configuration hints like API\n * keys. The detailed message is kept in the logs for debugging only.\n */\n private function userFacingError(string $message, bool $serviceUnavailable = false): string\n {\n // Detect any reference to an AI provider / engine / API config so we can\n // swap it for a generic, branded-neutral message.\n $sensitive = '/deepseek|groq|gemini|vision|openai|openrouter|api[\\s_-]?key|\\.env|non-?json|token limit/i';\n\n if (preg_match($sensitive, $message)) {\n Log::warning('QuotationAiService: suppressed technical error from user.', ['internal' => $message]);\n\n return $serviceUnavailable\n ? 'The file could not be processed right now. Please try again in a moment.'\n : 'We could not read this file automatically. Please make sure it is a clear BOQ (Excel, CSV, PDF, or image) and try again, or add the items manually.';\n }\n\n return $message;\n }\n\n private function mimeForExtension(string $ext): string\n {\n return match (strtolower($ext)) {\n 'pdf' => 'application/pdf',\n 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n 'xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12',\n 'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',\n 'xls' => 'application/vnd.ms-excel',\n 'csv' => 'text/csv',\n 'jpg', 'jpeg' => 'image/jpeg',\n 'png' => 'image/png',\n 'gif' => 'image/gif',\n 'bmp' => 'image/bmp',\n 'webp' => 'image/webp',\n 'tiff', 'tif' => 'image/tiff',\n 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'doc' => 'application/msword',\n default => 'application/octet-stream',\n };\n }\n}\n"}, "52c63627beac204bb669af7b24c4e651869d1474": {"t": 1785748379.5048864, "n": 69284, "h": "733b86e152d6a561", "c": "<?php\n\nnamespace App\\Services;\n\nuse Illuminate\\Http\\Client\\ConnectionException;\nuse Illuminate\\Http\\UploadedFile;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\nuse PhpOffice\\PhpSpreadsheet\\Cell\\Coordinate;\nuse PhpOffice\\PhpSpreadsheet\\IOFactory;\n\nclass QuotationAiService\n{\n /** Increment this whenever BOQ parsing logic changes to invalidate old caches. */\n private const PARSER_VERSION = 10;\n private const LARGE_BOQ_VERIFICATION_THRESHOLD = 2000;\n private const LARGE_BOQ_VERIFICATION_CHUNK_SIZE = 120;\n\n /**\n * Characters of document text sent per AI call.\n *\n * Above this the document is split and parsed over several calls. Sized well\n * under the model's context so the prompt, the slice, and a full JSON answer\n * for every row in it all fit — the binding limit is the *output* budget, not\n * the input, since each input row produces a JSON object.\n */\n private const TEXT_CHUNK_CHARS = 60000;\n\n private string $baseUrl;\n private string $parseEndpoint;\n private string $apiKey;\n private int $timeout;\n private bool $testMode;\n private BoqCleaningService $boqCleaner;\n\n /**\n * Optional progress reporter for chunked parsing, called as ($part, $total).\n *\n * Set by the queue job so the polling UI can show real progress on a large\n * BOQ instead of a spinner that appears frozen for many minutes.\n *\n * @var (callable(int, int): void)|null\n */\n private $onChunkProgress = null;\n\n /** @param callable(int, int): void $callback */\n public function onChunkProgress(callable $callback): self\n {\n $this->onChunkProgress = $callback;\n return $this;\n }\n\n /**\n * Optional per-chunk item sink, called as ($items, $part, $total).\n *\n * Lets the caller persist each slice's rows the moment they arrive instead\n * of waiting for the whole document. On a large BOQ this is the difference\n * between a table that fills in progressively and one that stays empty for\n * many minutes.\n *\n * @var (callable(array, int, int): void)|null\n */\n private $onChunkItems = null;\n\n /** @param callable(array, int, int): void $callback */\n public function onChunkItems(callable $callback): self\n {\n $this->onChunkItems = $callback;\n return $this;\n }\n\n public function __construct(BoqCleaningService $boqCleaner)\n {\n $this->boqCleaner = $boqCleaner;\n $this->baseUrl = rtrim((string) config('services.ai_quotation.base_url', ''), '/');\n $this->parseEndpoint = ltrim((string) config('services.ai_quotation.parse_endpoint', 'parse'), '/');\n $this->apiKey = (string) config('services.ai_quotation.api_key', '');\n $this->timeout = (int) config('services.ai_quotation.timeout', 300);\n $this->testMode = (bool) config('services.ai_quotation.test_mode', false);\n }\n\n /**\n * Give the running script enough head-room to outlast the AI HTTP call.\n *\n * The web request / queue worker must not be killed before the HTTP timeout\n * completes, otherwise the user sees a generic \"timeout or unavailable\"\n * error even though the AI may have responded. We allow the HTTP timeout\n * plus a 120s margin for connecting, response parsing and DB writes.\n */\n private function extendExecutionTime(): void\n {\n // Ignored on some SAPIs (e.g. when running under fastcgi with a hard\n // limit), but harmless to attempt.\n @set_time_limit($this->timeout + 120);\n }\n\n public function parseBoq(UploadedFile|string $file, array $context = []): array\n {\n if ($this->testMode) {\n return $this->mockResponse();\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n\n if (! is_file($absPath)) {\n return $this->failure('File not found or not readable.');\n }\n\n $fileHash = hash_file('sha256', $absPath);\n // Bump PARSER_VERSION whenever the extraction logic changes so that\n // previously-uploaded files are re-parsed instead of returning a stale\n // cached result produced by the old code.\n $cacheKey = 'boq_analysis_v' . self::PARSER_VERSION . '_' . $fileHash;\n $forceRefresh = (bool) ($context['force_refresh'] ?? false);\n\n if (! $forceRefresh && ($cached = Cache::get($cacheKey)) !== null) {\n return $cached;\n }\n\n // Clear stale cached result when force-refreshing\n if ($forceRefresh) {\n Cache::forget($cacheKey);\n }\n\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n // Read Excel locally first. Fall back to AI if local parse failed,\n // found 0 items, OR could not read one of the sheets (so multi-sheet\n // files never silently drop a sheet the local parser didn't understand).\n $result = $this->parseSpreadsheetDirect($absPath);\n\n $hasSkippedSheets = ! empty($result['skipped_sheets']);\n\n if (! $result['success'] || empty($result['items']) || $hasSkippedSheets) {\n $deepSeekResult = $this->parseBoqWithDeepSeek($file, $context);\n // AI sees every sheet at once, so prefer its result whenever it\n // actually returned items (covers the skipped-sheet case too).\n if ($deepSeekResult['success'] && ! empty($deepSeekResult['items'])) {\n // Keep the larger of the two item sets so we never lose the\n // rows the local parser already extracted correctly.\n if (count($deepSeekResult['items']) >= count($result['items'] ?? [])) {\n $result = $deepSeekResult;\n }\n } elseif (! $result['success']) {\n $result = $deepSeekResult;\n }\n // else: keep local result if AI also failed/returned nothing\n }\n } elseif (in_array($ext, ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif'], true)) {\n $result = $this->parseBoqWithDeepSeek($file, $context);\n } else {\n return $this->failure('Unsupported file type. Please upload Excel, CSV, PDF, or image file.');\n }\n\n if ($result['success'] && ! empty($result['items'])) {\n $cleaning = $this->boqCleaner->process($result['items']);\n $result['items'] = $cleaning['accepted'] ?? $result['items'];\n $result['rejected'] = array_merge($result['rejected'] ?? [], $cleaning['rejected'] ?? []);\n }\n\n if ($result['success'] && count($result['items'] ?? []) > self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n $result = $this->verifyLargeBoqItemsWithAi($result);\n }\n\n // Only cache when we actually extracted items; empty results should be re-tried.\n if ($result['success'] && ! empty($result['items'])) {\n Cache::put($cacheKey, $result, now()->addDays(30));\n }\n\n return $result;\n }\n\n private function resolveFile(UploadedFile|string $file): array\n {\n if ($file instanceof UploadedFile) {\n return [(string) $file->getRealPath(), strtolower($file->getClientOriginalExtension())];\n }\n\n $absPath = is_file($file) ? $file : storage_path('app/' . ltrim($file, '/'));\n return [$absPath, strtolower(pathinfo($absPath, PATHINFO_EXTENSION))];\n }\n\n private function parseSpreadsheetDirect(string $absPath): array\n {\n ini_set('memory_limit', '1024M');\n set_time_limit(180);\n\n try {\n $sheets = $this->spreadsheetToGrids($absPath);\n\n if (empty($sheets)) {\n return $this->failure('Could not read the Excel file.');\n }\n\n $items = [];\n $rejected = [];\n $skippedSheets = []; // sheets we could not parse locally (header undetected)\n\n foreach ($sheets as $sheet) {\n $grid = $sheet['grid'];\n if (count($grid) < 2) {\n continue;\n }\n\n // Skip dedicated summary / cost-abstract sheets entirely. Their rows\n // are per-discipline totals (Structural Concrete, Electrical, …) and\n // grand totals, not procurable products.\n if ($this->isSummarySheet($sheet['name'])) {\n Log::info('QuotationAiService: skipped summary sheet.', ['sheet' => $sheet['name']]);\n continue;\n }\n\n $header = $this->detectHeader($grid);\n\n if ($header === null || ! isset($header['map']['description'])) {\n Log::warning('QuotationAiService: header not detected in sheet.', ['sheet' => $sheet['name']]);\n $skippedSheets[] = $sheet['name'];\n continue;\n }\n\n $map = $header['map'];\n $start = $header['row'] + 1;\n\n for ($r = $start; $r < count($grid); $r++) {\n $row = $grid[$r];\n $description = $this->cell($row, $map['description'] ?? null);\n $itemNo = $this->cell($row, $map['item_code'] ?? null);\n $unit = $this->cell($row, $map['unit'] ?? null);\n $quantity = $this->number($this->cell($row, $map['quantity'] ?? null));\n $unitPrice = $this->number($this->cell($row, $map['unit_price'] ?? null));\n $totalPrice = $this->number($this->cell($row, $map['total_price'] ?? null));\n\n if ($description === '') {\n continue;\n }\n\n if (! isset($map['quantity'])) {\n $quantity = 1.0;\n }\n\n // Skip floor breakdown sub-rows: Ref. is \"-\" meaning it's a per-floor\n // quantity split of the parent item, not a standalone product.\n if ($itemNo === '-' || $this->isFloorBreakdownRow($itemNo, $description)) {\n continue;\n }\n\n if ($this->isTotalOrHeaderRow($description)) {\n continue;\n }\n\n if ($quantity === null || $quantity <= 0) {\n $rejected[] = $this->rejectedRow($description, $unit, $quantity, 'Missing quantity', $itemNo, $sheet['name']);\n continue;\n }\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $supply = $this->boqCleaner->filterItem($description, $unitPrice, $quantity);\n\n if (! ($supply['keep'] ?? false)) {\n $rejected[] = $this->rejectedRow(\n $description,\n $unit,\n $quantity,\n (string) ($supply['rejection_reason'] ?? 'Rejected by cleaner'),\n $itemNo,\n $sheet['name']\n );\n continue;\n }\n\n $finalDescription = (string) ($supply['description'] ?? $description);\n\n $items[] = [\n 'description' => $finalDescription,\n 'quantity' => $quantity,\n 'unit' => $unit,\n 'category' => $sheet['name'],\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($finalDescription),\n 'unit_price' => $unitPrice,\n 'confidence' => 0.95,\n 'needs_review' => false,\n 'raw_data' => [\n 'sheet' => $sheet['name'],\n 'row_number' => $r + 1,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'cleaned_description' => $finalDescription,\n 'extraction_type' => (string) ($supply['extraction_type'] ?? 'supply_only'),\n ],\n 'ai_extracted' => true,\n ];\n }\n }\n\n if (empty($items)) {\n return $this->failure('No BOQ supply items could be extracted from the Excel file.');\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'skipped_sheets' => $skippedSheets,\n ];\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: parseSpreadsheetDirect failed.', [\n 'path' => $absPath,\n 'message' => $e->getMessage(),\n 'trace' => $e->getTraceAsString(),\n ]);\n\n return $this->failure('Failed to read the Excel file: ' . $e->getMessage());\n }\n }\n\n private function spreadsheetToGrids(string $absPath): array\n {\n $ext = strtolower(pathinfo($absPath, PATHINFO_EXTENSION));\n\n if ($ext === 'csv') {\n $rows = [];\n $handle = fopen($absPath, 'rb');\n if ($handle !== false) {\n while (($row = fgetcsv($handle)) !== false) {\n $rows[] = $this->trimTrailingEmptyCells($row);\n }\n fclose($handle);\n }\n return [['name' => 'Sheet1', 'grid' => $rows]];\n }\n\n $reader = IOFactory::createReader($ext === 'xls' ? 'Xls' : 'Xlsx');\n $reader->setReadDataOnly(true);\n $spreadsheet = $reader->load($absPath);\n $sheets = [];\n\n foreach ($spreadsheet->getAllSheets() as $sheet) {\n $highestRow = $sheet->getHighestDataRow();\n $highestColumn = $sheet->getHighestDataColumn();\n $highestColumnIndex = Coordinate::columnIndexFromString($highestColumn);\n\n // BOQ data is normally in first 8-20 columns.\n // Files with heavy formatting/images can report 300+ columns — limit to 30.\n $maxColumnIndex = min($highestColumnIndex, 30);\n\n $grid = [];\n for ($row = 1; $row <= $highestRow; $row++) {\n $rowData = [];\n $hasValue = false;\n\n for ($col = 1; $col <= $maxColumnIndex; $col++) {\n $value = $sheet->getCell([$col, $row])->getValue();\n $value = $this->cellValueToString($value);\n $value = trim($value);\n if ($value !== null && $value !== '') {\n $hasValue = true;\n }\n $rowData[] = $value;\n }\n\n $rowData = $this->trimTrailingEmptyCells($rowData);\n if ($hasValue) {\n $grid[] = $rowData;\n }\n }\n\n if (! empty($grid)) {\n $sheets[] = ['name' => $sheet->getTitle(), 'grid' => $grid];\n }\n }\n\n $spreadsheet->disconnectWorksheets();\n unset($spreadsheet);\n\n return $sheets;\n }\n\n private function detectHeader(array $grid): ?array\n {\n $keywords = [\n 'item_code' => ['رقم البند', 'item no', 'item number', 'ref.', 'ref', 'item', 'no.', 'no', '#', 'كود'],\n 'description' => ['وص٠البند', 'scope of works', 'scope of work', 'scope', 'item description', 'description of works', 'description', 'بالعربية', 'الوصÙ', 'البند', 'بيان'],\n 'unit' => ['Ø§Ù„ÙˆØØ¯Ø©', 'unit', 'uom', 'u/m'],\n 'quantity' => ['الكمية', 'qty', 'quantity', 'q.ty'],\n 'unit_price' => ['سعر Ø§Ù„ÙˆØØ¯Ø©', 'u/price', 'unit price', 'unit rate', 'rate', 'price'],\n 'total_price' => ['السعر الإجمالي', 'total amount', 'total price', 'amount', 'الإجمالي'],\n ];\n\n $best = null;\n $bestScore = 0;\n\n for ($r = 0; $r < min(30, count($grid)); $r++) {\n $map = [];\n $score = 0;\n\n foreach ($grid[$r] as $c => $value) {\n $cell = mb_strtolower(trim((string) $value));\n if ($cell === '') {\n continue;\n }\n\n foreach ($keywords as $field => $list) {\n foreach ($list as $kw) {\n if (str_contains($cell, mb_strtolower($kw))) {\n if (! isset($map[$field])) {\n $map[$field] = $c;\n $score++;\n }\n break 2;\n }\n }\n }\n }\n\n if ($score > $bestScore) {\n $bestScore = $score;\n $best = ['row' => $r, 'map' => $map];\n }\n }\n\n if ($bestScore >= 3) {\n return $best;\n }\n\n // Allow simpler BOQ sheets that only define a description column plus one\n // other BOQ-related column, e.g. Item + Description or Description + Qty.\n if ($bestScore === 2 && isset($best['map']['description']) && (\n isset($best['map']['item_code']) ||\n isset($best['map']['quantity']) ||\n isset($best['map']['unit'])\n )) {\n return $best;\n }\n\n return null;\n }\n\n private function cell(array $row, ?int $index): string\n {\n if ($index === null) {\n return '';\n }\n return trim((string) ($row[$index] ?? ''));\n }\n\n private function number(string $value): ?float\n {\n $value = trim(str_replace([',', 'SAR', 'ر.س'], '', $value));\n if ($value === '' || ! is_numeric($value)) {\n return null;\n }\n return (float) $value;\n }\n\n private function trimTrailingEmptyCells(array $row): array\n {\n while (! empty($row) && trim((string) end($row)) === '') {\n array_pop($row);\n }\n return array_values($row);\n }\n\n private function isTotalOrHeaderRow(string $description): bool\n {\n $d = mb_strtolower(trim($description));\n\n if ($d === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $d)) {\n return true;\n }\n\n // Section totals / summaries\n if (preg_match('/(اجمالي|إجمالي|المجموع|grand\\s*total|sub\\s*total|total\\s*amount|total\\s*price)/iu', $d)) {\n return true;\n }\n\n // Bare \"Total\" lines, optionally with a currency suffix, e.g. \"Total\",\n // \"Total (SAR)\", \"Total cost\", \"Total (SAR) unit cost\".\n if (preg_match('/^total\\b[\\s\\(\\):a-z]*$/iu', $d)) {\n return true;\n }\n\n // Pure floor / level labels used as section headers\n if (preg_match('/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor)\\s*$/iu', $d)) {\n return true;\n }\n\n // Division / section header lines (e.g. \"Division - 02: Wood Works\")\n if (preg_match('/^division\\s*([-:]|\\d)/iu', $d)) {\n return true;\n }\n\n return false;\n }\n\n /**\n * Detect dedicated summary / cost-abstract sheets by their name. These hold\n * per-discipline rollups and grand totals (e.g. a \"Summary (Dry Cost)\" tab),\n * never procurable supply items, so they are skipped wholesale.\n */\n private function isSummarySheet(string $name): bool\n {\n $n = mb_strtolower(trim($name));\n\n if ($n === '') {\n return false;\n }\n\n return (bool) preg_match(\n '/\\b(summary|abstract|cost\\s*summary|grand\\s*summary|recap(?:itulation)?|bill\\s*summary|boq\\s*summary|dry\\s*cost)\\b|ملخص|الملخص|الملخّص|ملخّص|اجمالي|إجمالي/iu',\n $n\n );\n }\n\n private function isFloorBreakdownRow(string $itemNo, string $description): bool\n {\n // A row whose Ref column is empty or a dash is a sub-breakdown row (per-floor split).\n $ref = trim($itemNo);\n if ($ref === '' || $ref === '-') {\n // Confirm by checking the description is a location/floor label\n $d = mb_strtolower(trim($description));\n return (bool) preg_match(\n '/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor|\\d+f)\\s*$/iu',\n $d\n );\n }\n return false;\n }\n\n private function rejectedRow(string $description, string $unit, ?float $quantity, string $reason, string $itemNo = '', string $sheet = ''): array\n {\n return [\n 'description' => $description,\n 'quantity' => $quantity ?? 0,\n 'unit' => $unit,\n 'category' => $sheet,\n 'brand' => '',\n 'status' => 'rejected',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 0.0,\n 'raw_data' => [\n 'sheet' => $sheet,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'extraction_type' => 'rejected',\n 'rejection_reason' => $reason,\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function parseBoqWithDeepSeek(UploadedFile|string $file, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured. Please set DEEPSEEK_API_KEY in .env file.');\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n $mime = $this->mimeForExtension($ext);\n\n try {\n // ── Spreadsheets ──────────────────────────────────────────────────\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n $text = $this->spreadsheetToCompactCsvText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not convert spreadsheet to readable text.');\n }\n\n $text = $this->sanitizeUtf8($text);\n\n // A large BOQ exceeds what one request can carry, so send it in\n // sequential slices and merge. Anything that fits goes as-is.\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS) {\n return $this->parseTextInChunks($text, 'BOQ spreadsheet converted to CSV', $apiKey, $context);\n }\n\n $userContent = \"BOQ spreadsheet converted to CSV:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── PDF ───────────────────────────────────────────────────────────\n if ($ext === 'pdf') {\n $extracted = $this->extractPdfText($absPath);\n if ($extracted !== null && mb_strlen(trim($extracted)) > 50) {\n $extracted = $this->sanitizeUtf8($extracted);\n\n if (mb_strlen($extracted) > self::TEXT_CHUNK_CHARS) {\n return $this->parseTextInChunks($extracted, 'BOQ PDF extracted text', $apiKey, $context, 'application/pdf');\n }\n\n $userContent = \"BOQ PDF extracted text:\\n\\n\" . $extracted . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'application/pdf');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n return $this->failure('Could not extract text from the PDF. Please make sure it is a text-based PDF (not a scanned image), or convert it to Excel or CSV.');\n }\n\n // -- Word documents (docx) ---------------------------------------------------\n if (in_array($ext, ['docx', 'doc'], true)) {\n $text = $this->extractDocxText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not extract text from the Word document. Please convert it to Excel, CSV, or PDF.');\n }\n $text = $this->sanitizeUtf8($text);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS) {\n return $this->parseTextInChunks($text, 'BOQ Word document extracted text', $apiKey, $context);\n }\n\n $userContent = \"BOQ Word document extracted text:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── Images ──────────────────────────────────────────────────────────────\n // DeepSeek cannot receive images. A vision-capable AI processes the image\n // directly (no OCR) — configure GROQ_API_KEY or GEMINI_API_KEY in .env.\n $imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif', 'heic', 'heif'];\n if (in_array($ext, $imageExts, true)) {\n $bytes = (string) file_get_contents($absPath);\n return $this->callDeepSeekVision($bytes, $mime, $context);\n }\n\n return $this->failure('Unsupported file type. Please upload an Excel, CSV, PDF, or image file.');\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: DeepSeek fallback failed.', ['message' => $e->getMessage()]);\n return $this->failure('An error occurred while processing the file with AI: ' . $e->getMessage());\n }\n }\n\n private function deepSeekModel(): string\n {\n return (string) config('services.deepseek.model', 'deepseek-chat');\n }\n\n /**\n * Remove or replace any non-UTF-8 / malformed byte sequences so json_encode never fails.\n */\n private function sanitizeUtf8(string $text): string\n {\n // Replace invalid byte sequences with the UTF-8 replacement character\n $clean = mb_convert_encoding($text, 'UTF-8', 'UTF-8');\n // Strip any remaining non-printable control characters (except tab/newline/CR)\n $clean = (string) preg_replace('/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]/u', '', $clean);\n return $clean;\n }\n\n /**\n * Extract plain text from a Word docx file using ZipArchive (no external library needed).\n * Reads word/document.xml and strips XML tags, preserving paragraph and table structure.\n */\n private function extractDocxText(string $absPath): ?string\n {\n try {\n $zip = new \\ZipArchive();\n if ($zip->open($absPath) !== true) {\n return null;\n }\n\n $xml = $zip->getFromName('word/document.xml');\n $zip->close();\n\n if ($xml === false || $xml === '') {\n return null;\n }\n\n // Add newlines before paragraph/table-row/table-cell tags so structure is preserved\n $xml = preg_replace('/<w:p[ >]/', \"\\n<w:p>\", $xml) ?? $xml;\n $xml = preg_replace('/<w:tr[ >]/', \"\\n<w:tr>\", $xml) ?? $xml;\n $xml = preg_replace('/<\\/w:tc>/', \"\\t\", $xml) ?? $xml;\n\n $text = strip_tags($xml);\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n return trim($text) !== '' ? trim($text) : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: DOCX parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Extract plain text from a PDF using smalot/pdfparser (pure PHP, no binary required).\n */\n private function extractPdfText(string $absPath): ?string\n {\n try {\n $parser = new \\Smalot\\PdfParser\\Parser();\n $pdf = $parser->parseFile($absPath);\n $text = $pdf->getText();\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n return $text !== '' ? $text : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: PDF parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Send a vision request to a vision-capable AI model.\n * DeepSeek's commercial API (api.deepseek.com) does NOT support image inputs — any model.\n * Priority order (first configured key wins):\n * 1. GROQ_API_KEY → Groq llama-4-scout (free: https://console.groq.com)\n * 2. GEMINI_API_KEY → Google Gemini Flash (free: https://aistudio.google.com/apikey)\n * 3. VISION_API_KEY → any OpenAI-compatible endpoint (OpenAI, OpenRouter …)\n */\n private function callDeepSeekVision(string $bytes, string $mime, array $context): array\n {\n // ── 1. Groq (free, reliable vision) ─────────────────────────────────────\n if ((string) config('services.groq.key', '') !== '') {\n $visionKey = (string) config('services.groq.key');\n $visionBaseUrl = 'https://api.groq.com/openai/v1';\n $visionModel = (string) config('services.groq.model', 'meta-llama/llama-4-scout-17b-16e-instruct');\n }\n // ── 2. Google Gemini ─────────────────────────────────────────────────────\n elseif ((string) config('services.gemini.key', '') !== '') {\n $visionKey = (string) config('services.gemini.key');\n $visionBaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai';\n $visionModel = (string) config('services.gemini.model', 'gemini-2.0-flash');\n }\n // ── 3. Generic VISION_API_KEY (OpenAI, OpenRouter, …) ───────────────────\n elseif ((string) config('services.vision.key', '') !== '') {\n $visionKey = (string) config('services.vision.key');\n $visionBaseUrl = rtrim((string) config('services.vision.base_url', 'https://openrouter.ai/api/v1'), '/');\n $visionModel = (string) config('services.vision.model', 'google/gemini-flash-1.5-8b');\n }\n else {\n return $this->failure(\n 'Image processing is not available. DeepSeek\\'s API does not support images. ' .\n 'Get a free key at https://console.groq.com and add GROQ_API_KEY to your .env file.'\n );\n }\n\n $dataUrl = \"data:{$mime};base64,\" . base64_encode($bytes);\n $prompt = $this->buildDeepSeekPrompt($context, $mime);\n\n $userContent = [\n ['type' => 'text', 'text' => $prompt],\n ['type' => 'image_url', 'image_url' => ['url' => $dataUrl]],\n ];\n\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders([\n 'Authorization' => 'Bearer ' . $visionKey,\n 'HTTP-Referer' => config('app.url', 'https://qimta.sa'),\n 'X-Title' => 'Qimta Platform',\n ])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post($visionBaseUrl . '/chat/completions', [\n 'model' => $visionModel,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 16384,\n 'temperature' => 0.2,\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('Vision API connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: Vision API HTTP error.', [\n 'model' => $visionModel,\n 'base_url' => $visionBaseUrl,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n return $this->failure(\"Vision API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n return $this->parseDeepSeekJsonResponse((string) $response->json('choices.0.message.content'));\n }\n\n private function callDeepSeekChat(string $userContent, string $key, string $model): array\n {\n // Ensure the PHP script outlives the HTTP call (+ a margin for parsing the\n // response and writing to the DB) so the worker/web request is not killed\n // mid-flight, which surfaces to users as a generic \"timeout or unavailable\".\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders(['Authorization' => 'Bearer ' . $key])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 32768,\n 'temperature' => 0.1,\n 'response_format' => ['type' => 'json_object'],\n 'user' => 'Qimta_Platform',\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('DeepSeek connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: DeepSeek HTTP error.', [\n 'model' => $model,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n if ($response->status() === 401) {\n return $this->failure('DeepSeek API key is invalid or unauthorised. Please update DEEPSEEK_API_KEY in .env.', false);\n }\n return $this->failure(\"DeepSeek API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n $text = (string) $response->json('choices.0.message.content');\n return $this->parseDeepSeekJsonResponse($text);\n }\n\n /**\n * Parse a document too large for one AI call by sending it in slices.\n *\n * A 20 000-row BOQ is several megabytes of text — far beyond what a single\n * request can carry, and beyond what the model can answer without hitting its\n * output token limit. Previously the text was simply cut at 180 000 chars and\n * the remainder discarded: the parse \"succeeded\" while silently dropping most\n * of the file. Now the text is split and each slice parsed on its own call,\n * with the items merged.\n *\n * Splitting happens on line boundaries so a BOQ row is never cut in half.\n * Slices run sequentially rather than pooled: DeepSeek rate-limits hard on\n * concurrent large prompts, and this already runs on the queue where wall\n * time is cheap.\n *\n * A slice that fails does not fail the document — its rows are lost, but the\n * rest are kept and the shortfall is logged. Total failure is reported only\n * when every slice fails.\n *\n * @param string $label How the payload is introduced to the model.\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n private function parseTextInChunks(\n string $text,\n string $label,\n string $apiKey,\n array $context,\n string $mime = 'text/plain',\n ): array {\n $chunks = $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n $total = count($chunks);\n\n Log::info('QuotationAiService: large document split for parsing.', [\n 'chars' => mb_strlen($text),\n 'chunks' => $total,\n ]);\n\n $items = [];\n $rejected = [];\n $failed = 0;\n $lastError = null;\n\n foreach ($chunks as $index => $chunk) {\n $part = $index + 1;\n\n // Report progress so a 28-slice parse does not look frozen to the user.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)($part, $total);\n }\n\n // Tell the model this is a fragment, so it does not try to reconcile\n // totals or treat a mid-document slice as a complete BOQ.\n $userContent = \"{$label} (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, $mime);\n\n $result = $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n\n if (! ($result['success'] ?? false)) {\n $failed++;\n $lastError = $result['error'] ?? 'Chunk parsing failed.';\n Log::warning('QuotationAiService: chunk failed, continuing.', [\n 'part' => $part,\n 'of' => $total,\n 'error' => $lastError,\n ]);\n continue;\n }\n\n foreach ($result['items'] as $item) {\n $items[] = $item;\n }\n foreach ($result['rejected'] ?? [] as $row) {\n $rejected[] = $row;\n }\n\n // Hand this slice's rows straight to the caller so they can be shown\n // while the remaining slices are still being parsed.\n if ($this->onChunkItems !== null && $result['items'] !== []) {\n ($this->onChunkItems)($result['items'], $part, $total);\n }\n }\n\n // Every slice failed — there is nothing to show, so report the failure.\n if ($failed === $total) {\n return $this->failure($lastError ?? 'Could not parse the document.');\n }\n\n if ($failed > 0) {\n Log::error('QuotationAiService: document parsed with missing parts.', [\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n 'items_kept' => count($items),\n ]);\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'service_unavailable' => false,\n 'partial' => $failed > 0,\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n ];\n }\n\n /**\n * Split text into slices of at most $limit characters, breaking only at\n * newlines so no BOQ row is ever cut in half.\n *\n * A single line longer than the limit (rare — a pathological cell) is passed\n * through oversized rather than severed mid-row.\n *\n * @return array<int, string>\n */\n private function splitOnLineBoundaries(string $text, int $limit): array\n {\n $chunks = [];\n $current = '';\n\n foreach (explode(\"\\n\", $text) as $line) {\n $candidate = $current === '' ? $line : $current . \"\\n\" . $line;\n\n if (mb_strlen($candidate) > $limit && $current !== '') {\n $chunks[] = $current;\n $current = $line;\n continue;\n }\n\n $current = $candidate;\n }\n\n if (trim($current) !== '') {\n $chunks[] = $current;\n }\n\n return $chunks;\n }\n\n /**\n * Shared JSON response parser used by both chat and vision paths.\n */\n private function parseDeepSeekJsonResponse(string $text): array\n {\n if ($text === '') {\n return $this->failure('DeepSeek returned an empty response.');\n }\n\n // Strip markdown code fences if the model wrapped the JSON.\n if (preg_match('/```(?:json)?\\s*([\\s\\S]*?)\\s*```/i', $text, $m)) {\n $text = $m[1];\n }\n // Narrow to the outermost JSON object/array.\n if (preg_match('/[\\{\\[][\\s\\S]*[\\}\\]]/s', $text, $m)) {\n $text = $m[0];\n }\n $text = trim($text);\n\n $decoded = json_decode($text, true);\n\n // The response can be truncated when the model hits the token limit,\n // leaving an unterminated JSON string/object. Try to repair it before\n // giving up so we can still salvage the items extracted so far.\n if (! is_array($decoded)) {\n $repaired = $this->repairTruncatedJson($text);\n if ($repaired !== null) {\n $decoded = json_decode($repaired, true);\n }\n }\n\n if (! is_array($decoded)) {\n Log::error('QuotationAiService: DeepSeek non-JSON response.', ['text' => mb_substr($text, 0, 1000)]);\n return $this->failure('DeepSeek returned a non-JSON response. The file may be too large — try splitting it into smaller files.');\n }\n\n $rawItems = $decoded['items'] ?? (array_is_list($decoded) ? $decoded : []);\n $items = [];\n $rejected = [];\n\n foreach ($rawItems as $raw) {\n if (! is_array($raw)) {\n continue;\n }\n $item = $this->normaliseAiItem($raw);\n if ($item === null) {\n continue;\n }\n $check = $this->boqCleaner->filterItem((string) $item['description']);\n if ($check['keep'] ?? false) {\n $items[] = $item;\n } else {\n $item['status'] = 'rejected';\n $item['raw_data']['rejection_reason'] = $check['rejection_reason'] ?? 'Rejected by cleaner';\n $rejected[] = $item;\n }\n }\n\n if (empty($items)) {\n Log::info('QuotationAiService: DeepSeek returned 0 accepted items.', [\n 'raw_items_count' => count($rawItems),\n 'rejected_count' => count($rejected),\n 'first_rejected' => ! empty($rejected) ? ($rejected[0]['description'] ?? '') : '',\n ]);\n }\n\n return ['success' => true, 'items' => $items, 'rejected' => $rejected, 'error' => null];\n }\n\n /**\n * Best-effort repair of a truncated JSON string produced when the model hits\n * its token limit mid-output. Drops any incomplete trailing object, then\n * closes the open string/array/object brackets so the result is valid JSON.\n *\n * Returns the repaired JSON string, or null if it cannot be salvaged.\n */\n private function repairTruncatedJson(string $text): ?string\n {\n if ($text === '') {\n return null;\n }\n\n // Walk the string tracking structural state, ignoring everything inside\n // string literals (and their escapes). Remember the last position where\n // we were back at the top of the \"items\" array between complete elements,\n // so we can cut a half-written trailing object.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($text);\n $lastSafeCut = null; // index just after a completed array element\n\n for ($i = 0; $i < $len; $i++) {\n $ch = $text[$i];\n\n if ($inString) {\n if ($escaped) {\n $escaped = false;\n } elseif ($ch === '\\\\') {\n $escaped = true;\n } elseif ($ch === '\"') {\n $inString = false;\n }\n continue;\n }\n\n if ($ch === '\"') {\n $inString = true;\n } elseif ($ch === '{' || $ch === '[') {\n $stack[] = $ch;\n } elseif ($ch === '}' || $ch === ']') {\n array_pop($stack);\n } elseif ($ch === ',' && ! empty($stack) && end($stack) === '[') {\n // Separator directly inside an array: a clean boundary between\n // elements (the items array, whatever its nesting depth).\n $lastSafeCut = $i;\n }\n }\n\n // If we are still inside a string, the last element is incomplete — fall\n // back to the last clean array boundary if we have one.\n $candidate = $text;\n if ($inString && $lastSafeCut !== null) {\n $candidate = substr($text, 0, $lastSafeCut);\n } else {\n // Drop a dangling partial object after the last completed element.\n $candidate = rtrim($candidate);\n if ($lastSafeCut !== null && substr(rtrim($candidate), -1) !== '}' && substr(rtrim($candidate), -1) !== ']') {\n $candidate = substr($text, 0, $lastSafeCut);\n }\n }\n\n // Recompute the open brackets for the (possibly trimmed) candidate and\n // append the matching closers in reverse order.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($candidate);\n for ($i = 0; $i < $len; $i++) {\n $ch = $candidate[$i];\n if ($inString) {\n if ($escaped) { $escaped = false; }\n elseif ($ch === '\\\\') { $escaped = true; }\n elseif ($ch === '\"') { $inString = false; }\n continue;\n }\n if ($ch === '\"') { $inString = true; }\n elseif ($ch === '{' || $ch === '[') { $stack[] = $ch; }\n elseif ($ch === '}' || $ch === ']') { array_pop($stack); }\n }\n\n $candidate = rtrim($candidate);\n // Remove a trailing comma left after cutting an element.\n $candidate = rtrim($candidate, ',');\n\n $closers = '';\n for ($i = count($stack) - 1; $i >= 0; $i--) {\n $closers .= ($stack[$i] === '{') ? '}' : ']';\n }\n\n $repaired = $candidate . $closers;\n\n // Validate before returning.\n return is_array(json_decode($repaired, true)) ? $repaired : null;\n }\n\n private function spreadsheetToCompactCsvText(string $absPath): ?string\n {\n $sheets = $this->spreadsheetToGrids($absPath);\n if (empty($sheets)) {\n return null;\n }\n\n $output = '';\n foreach ($sheets as $sheet) {\n // Don't feed summary / cost-abstract sheets to the AI either.\n if ($this->isSummarySheet($sheet['name'])) {\n continue;\n }\n $output .= 'Sheet: ' . $sheet['name'] . \"\\n\";\n foreach ($sheet['grid'] as $row) {\n $row = $this->trimTrailingEmptyCells($row);\n if (empty($row)) {\n continue;\n }\n $output .= implode(',', array_map([$this, 'csvEscape'], $row)) . \"\\n\";\n }\n $output .= \"\\n\";\n }\n\n return $output;\n }\n\n /**\n * Safely convert a PhpSpreadsheet cell value to a string.\n *\n * Cells may contain RichText or, in malformed/heavily-formatted files,\n * embedded objects such as Worksheet\\Drawing that have no __toString().\n * Casting those directly throws \"Object ... could not be converted to string\".\n */\n private function cellValueToString(mixed $value): string\n {\n if ($value === null) {\n return '';\n }\n\n if ($value instanceof \\PhpOffice\\PhpSpreadsheet\\RichText\\RichText) {\n return $value->getPlainText();\n }\n\n if (is_object($value)) {\n // Only stringify objects that explicitly support it; ignore the rest\n // (e.g. Worksheet\\Drawing) instead of crashing.\n return method_exists($value, '__toString') ? (string) $value : '';\n }\n\n if (is_array($value)) {\n return '';\n }\n\n return (string) $value;\n }\n\n private function csvEscape(mixed $value): string\n {\n $value = $this->cellValueToString($value);\n return str_contains($value, ',') || str_contains($value, '\"') || str_contains($value, \"\\n\")\n ? '\"' . str_replace('\"', '\"\"', $value) . '\"'\n : $value;\n }\n\n private function verifyLargeBoqItemsWithAi(array $result): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n Log::warning('QuotationAiService: large BOQ verification skipped, AI key is not configured.');\n return $result;\n }\n\n $items = array_values(array_filter($result['items'] ?? [], 'is_array'));\n if (count($items) <= self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n return $result;\n }\n\n Log::info('QuotationAiService: starting second-pass physical item verification.', [\n 'items_count' => count($items),\n 'threshold' => self::LARGE_BOQ_VERIFICATION_THRESHOLD,\n ]);\n\n $verifiedItems = [];\n $rejectedItems = $result['rejected'] ?? [];\n\n foreach (array_chunk($items, self::LARGE_BOQ_VERIFICATION_CHUNK_SIZE) as $chunkIndex => $chunk) {\n $payload = array_map(fn(array $item) => [\n 'description' => (string) ($item['description'] ?? ''),\n 'quantity' => $item['quantity'] ?? null,\n 'unit' => (string) ($item['unit'] ?? ''),\n 'unit_price' => $item['unit_price'] ?? null,\n 'category' => (string) ($item['category'] ?? ''),\n 'brand' => (string) ($item['brand'] ?? ''),\n ], $chunk);\n\n $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);\n if (! is_string($json)) {\n Log::warning('QuotationAiService: large BOQ verification chunk JSON encode failed.', [\n 'chunk' => $chunkIndex + 1,\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n $prompt = <<<PROMPT\nYou are doing a SECOND PASS verification on a BOQ extraction result.\n\nThe first pass may hallucinate non-products when the BOQ has thousands of rows.\nYour job is to keep ONLY physical, tangible, procurable supply products.\n\nRules:\n- Keep items only if a supplier can actually deliver them as materials/equipment.\n- Reject headings, trade names, systems, scopes, prose, specifications-only rows, totals, labor, installation, testing, commissioning, supervision, drawings, insurance, preliminaries, mobilisation, site works, and vague fragments.\n- Do not invent new products.\n- Do not add items that are not in the input.\n- You may clean a product description, but keep the original quantity, unit, unit_price, category, and brand when available.\n\nInput JSON array:\n{$json}\n\nReturn ONLY valid JSON in this shape:\n{\"items\":[{\"description\":\"\",\"quantity\":1,\"unit\":\"\",\"unit_price\":null,\"category\":\"\",\"brand\":\"\",\"confidence\":0.95}]}\nPROMPT;\n\n try {\n $chunkResult = $this->callDeepSeekChat($prompt, $apiKey, $this->deepSeekModel());\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: large BOQ verification chunk threw an exception.', [\n 'chunk' => $chunkIndex + 1,\n 'message' => $e->getMessage(),\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n if ($chunkResult['success'] ?? false) {\n array_push($verifiedItems, ...($chunkResult['items'] ?? []));\n $rejectedItems = array_merge($rejectedItems, $chunkResult['rejected'] ?? []);\n continue;\n }\n\n Log::warning('QuotationAiService: large BOQ verification chunk failed, keeping original chunk.', [\n 'chunk' => $chunkIndex + 1,\n 'error' => $chunkResult['error'] ?? null,\n ]);\n array_push($verifiedItems, ...$chunk);\n }\n\n if (empty($verifiedItems)) {\n Log::warning('QuotationAiService: second-pass verification returned no items, keeping first-pass result.', [\n 'original_items_count' => count($items),\n ]);\n return $result;\n }\n\n $result['items'] = $verifiedItems;\n $result['rejected'] = $rejectedItems;\n $result['large_boq_verified'] = true;\n\n Log::info('QuotationAiService: second-pass physical item verification completed.', [\n 'before' => count($items),\n 'after' => count($verifiedItems),\n ]);\n\n return $result;\n }\n\n private function normaliseAiItem(array $raw): ?array\n {\n if (! empty($raw['rejected'])) {\n return null;\n }\n\n $description = trim((string) ($raw['cleaned_description'] ?? $raw['product_name'] ?? $raw['description'] ?? ''));\n if ($description === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $description)) {\n return null;\n }\n\n $quantity = is_numeric($raw['quantity'] ?? null) ? (float) $raw['quantity'] : 1;\n $unitPrice = is_numeric($raw['unit_price'] ?? null) ? (float) $raw['unit_price'] : null;\n $totalPrice = is_numeric($raw['total_price'] ?? null) ? (float) $raw['total_price'] : null;\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $specs = is_array($raw['specifications'] ?? null) ? $raw['specifications'] : [];\n\n return [\n 'description' => $description,\n 'quantity' => $quantity,\n 'unit' => (string) ($raw['unit'] ?? ''),\n 'category' => (string) ($raw['core_product'] ?? $raw['category'] ?? ''),\n 'brand' => (string) ($specs['brand'] ?? $raw['brand'] ?? ''),\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($description),\n 'unit_price' => $unitPrice,\n 'confidence' => is_numeric($raw['confidence'] ?? null) ? (float) $raw['confidence'] : null,\n 'needs_review' => ! empty($raw['needs_review']),\n 'raw_data' => [\n 'source_item_no' => (string) ($raw['source_item_no'] ?? ''),\n 'original_description' => (string) ($raw['original_description'] ?? $description),\n 'cleaned_description' => $description,\n 'core_product' => (string) ($raw['core_product'] ?? ''),\n 'specifications' => $specs,\n 'extraction_type' => (string) ($raw['extraction_type'] ?? 'supply_only'),\n 'note' => (string) ($raw['note'] ?? $raw['notes'] ?? ''),\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function buildSystemInstruction(): string\n {\n return <<<'TXT'\nYou are a strict BOQ Supply Product Extraction Engine.\nExtract ONLY concrete, tangible, physical products that a supplier can box up and deliver to site — a specific item you could point at and say \"this is the thing being bought\" (e.g. \"Wall mounted LED luminaire 36W\", \"PVC pipe 110mm\", \"Single phase distribution board 12-way\", \"Fire extinguisher CO2 6kg\", \"CCTV dome camera 4MP\").\n\nABSOLUTELY REJECT abstract concepts, disciplines, categories, section names, and trade headings. These are NOT products even though they appear as rows:\n- Bare discipline/trade words: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", \"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\".\n- Summary / total / abstract / \"dry cost\" rows of any kind.\n- Anything that names a system, scope, or category instead of one buyable item.\n\nRule of thumb: if you cannot name a unit and physically hand the item over (a piece, a box, a length, a unit of equipment), REJECT it. A word that describes a field of work, a system, or a section of the BOQ is NEVER a product.\nReject totals, headings, general requirements, installation-only, testing, commissioning, supervision, mobilization, civil works, labor, and vague rows.\nFor Supply & Install rows, keep only the supply product and remove install/labor wording.\nReturn only valid JSON: {\"items\":[{\"source_item_no\":null,\"original_description\":\"\",\"cleaned_description\":\"\",\"core_product\":\"\",\"specifications\":{},\"quantity\":0,\"unit\":\"\",\"unit_price\":0,\"total_price\":0,\"extraction_type\":\"supply_only\",\"needs_review\":false,\"note\":\"\",\"rejected\":false,\"rejection_reason\":null,\"confidence\":0.95}]}\nTXT;\n }\n\n private function buildDeepSeekPrompt(array $context = [], string $mime = ''): string\n {\n $projectName = (string) ($context['project_name'] ?? '');\n $projectStatus = (string) ($context['project_status'] ?? '');\n $contextLine = $projectName !== ''\n ? \"Project: {$projectName}\" . ($projectStatus !== '' ? \" ({$projectStatus})\" : '')\n : '';\n $sourceHint = str_starts_with($mime, 'image/')\n ? 'The input is an image of a BOQ table. OCR every cell carefully — read the quantity/count column (العدد, الكمية, Qty) and extract the EXACT numeric value from each row.'\n : 'The input is a BOQ document/spreadsheet.';\n\n return trim(<<<PROMPT\nYou are a procurement specialist. Your task is to extract ONLY tangible, physical supply products from this Bill of Quantities (BOQ).\n\n{$contextLine}\n{$sourceHint}\n\nCRITICAL — MERGE WRAPPED / SPLIT ROWS INTO ONE ITEM:\nBOQ tables extracted from PDF often break ONE item's long description across several\nlines/rows. These continuation lines repeat the SAME quantity, unit and category, and\ntheir text is a grammatical continuation of the previous line (it starts mid-sentence,\nlowercase, or with \"the/and/-\", or is a spec/instruction clause). You MUST reconstruct\nthe original single item:\n- Treat consecutive rows that share the SAME quantity + unit + category as ONE product\n when the later rows are clearly continuation text of the first, NOT new products.\n- Emit ONE item with the merged description and the price counted ONCE. Do NOT repeat\n the same quantity×price for every wrapped line — that multiplies the total wrongly.\n- Example: rows \"The asphalt flooring is to be supplied\" / \"installed in accordance\" /\n \"standard road specifications...\" all with 7,100 m2 are ONE asphalt item, not 3+.\n\nCRITICAL — UNDERSTAND INTENT, DO NOT COPY ROWS LITERALLY:\nYour job is to output WHAT A SUPPLIER MUST ACTUALLY DELIVER, not a copy of the row text.\nRead each description for MEANING and decide how many real, separately-buyable products it\nimplies. One BOQ row can imply SEVERAL products, or a spread of rows can imply ONE product.\n- SPLIT a row into multiple items when it lists distinct products that a supplier would\n buy/price separately — even when joined by commas, \"with\", \"and\", \"including\", \"c/w\",\n \"consisting of\", \"comprising\", or \":\" then a list.\n Example: \"Fire pump set consisting of: fire pump, electric motor, control panel, diesel\n engine\" → FOUR items: (1) fire pump, (2) electric motor, (3) control panel, (4) diesel\n engine — each with its own quantity and priced on its own.\n Example: \"Wash basin complete with mixer tap and bottle trap\" → THREE items: wash basin,\n mixer tap, bottle trap.\n- Give each split product the correct quantity implied by the text (usually the parent\n quantity, unless the text states a different count for a sub-item). Each product is\n priced individually; the line total becomes the SUM of its products.\n- DO NOT split when the extra words are just specifications, dimensions, colours, ratings,\n standards, or a single product's integral parts. \"Steel door 900x2100 with vision panel\n and SS ironmongery\" is usually ONE door product — vision panel and ironmongery are part\n of that door, not separately-bought items. Use judgement: would a supplier issue a\n separate price line for it? If no, keep it as one.\n- Balanced approach: split only when genuinely separate products are present; when in doubt\n about whether something is a separate product vs. an accessory/spec of the main one, keep\n it as a single item. Do not invent products that are not in the text.\n\nSTRICT RULES — include ONLY items that are ALL of the following:\n1. A specific, concrete, tangible product that can be boxed up by a supplier and delivered to site — something you can point at and say \"this exact thing is being bought\" (e.g. \"LED luminaire 36W\", \"PVC pipe 110mm\", \"Distribution board 12-way\", \"CO2 fire extinguisher 6kg\").\n2. Has a real quantity (not 0 or \"varies\" with no meaning).\n3. Has enough description to identify what the product is.\n4. Is an actual PRODUCT, not a sentence, instruction, or specification clause. REJECT rows that are prose such as \"the supervising engineer's instructions\", \"The price includes all materials, labor\", \"Measurement will be based on the geometric cubic meter\", \"necessary steps to complete the work\", \"soil-contacting elements\", \"in compliance\", \"everything necessary\". These are description fragments, not buyable items.\n\nNEVER extract abstract concepts, disciplines, trades, systems, or section/category names — they are NOT products even when they appear as their own row:\n- Bare discipline/trade/system words such as: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", and their Arabic equivalents (\"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\", \"تشطيبات\").\n- Test: if the word names a FIELD OF WORK or a SYSTEM rather than one buyable item with a unit (pcs, m, set, kg…), REJECT it.\n\nEXCLUDE all of the following (do NOT include them):\n- Labor, installation, or workmanship items (e.g. \"install\", \"fix\", \"erect\", \"dismantle\", \"painting\", \"cleaning service\")\n- Project management, overhead, or profit items\n- Insurance, bonds, liability items (e.g. \"bodily injury\", \"third party liability\", \"CAR insurance\")\n- Shop drawings, as-built drawings, method statements, documentation\n- Preliminary items, mobilisation, demobilisation, provisional sums\n- Housekeeping, site clearance, waste removal\n- Floor/level labels (e.g. \"GF Floor\", \"Level 3\") — these are headers, not products\n- Pure dimension specs without a product noun (e.g. \"Dia 32 mm\", \"25 mm\") — reject unless a product name is present\n- Security services, night shifts, rentals, payments\n- Any item whose description is too vague or fragmentary to identify a specific product\n\nEmit ONE JSON item PER real product. A single source row may therefore produce several\nitems (when it lists multiple products) or several rows may collapse into one item (when\nthey are wrapped fragments of the same product). The \"items\" array is your final delivery\nlist, not a row-by-row transcript.\n\nFor each valid physical product return JSON with:\n- description: clean product name only (strip \"supply and install\", \"supply and fix\", installation clauses, floor/spec suffixes). For a split product, use just that product's own name.\n- quantity: the EXACT numeric value from the quantity/count column (العدد, الكمية, Qty, Quantity, No.). NEVER default to 1 unless the quantity column is truly absent or empty.\n- unit: unit of measure (pcs, m, m2, m3, set, no, kg, etc.)\n- category: product category\n- brand: brand/manufacturer if mentioned, else \"\"\n- engineering_required: true if the item needs engineering design before procurement (panel boards, chillers, generators, fire suppression systems, BMS, pumps, AHUs, switchgear, transformers), else false\n\nReturn ONLY a valid JSON object: {\"items\": [...]}. No explanations, no markdown, no extra text.\nPROMPT);\n }\n\n private function mockResponse(): array\n {\n return [\n 'success' => true,\n 'error' => null,\n 'items' => [[\n 'description' => 'Sample BOQ Item',\n 'quantity' => 1,\n 'unit' => 'No',\n 'category' => 'Sample',\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 1,\n 'raw_data' => [],\n 'ai_extracted' => true,\n ]],\n 'rejected' => [],\n ];\n }\n\n private function failure(string $message, bool $serviceUnavailable = false): array\n {\n return [\n 'success' => false,\n 'items' => [],\n 'rejected' => [],\n 'error' => $this->userFacingError($message, $serviceUnavailable),\n 'service_unavailable' => $serviceUnavailable,\n ];\n }\n\n /**\n * Translate any internal error into a neutral, user-safe message.\n *\n * The user must never see the name of the underlying AI/processing engine\n * (DeepSeek, Groq, Gemini, Vision, etc.) or configuration hints like API\n * keys. The detailed message is kept in the logs for debugging only.\n */\n private function userFacingError(string $message, bool $serviceUnavailable = false): string\n {\n // Detect any reference to an AI provider / engine / API config so we can\n // swap it for a generic, branded-neutral message.\n $sensitive = '/deepseek|groq|gemini|vision|openai|openrouter|api[\\s_-]?key|\\.env|non-?json|token limit/i';\n\n if (preg_match($sensitive, $message)) {\n Log::warning('QuotationAiService: suppressed technical error from user.', ['internal' => $message]);\n\n return $serviceUnavailable\n ? 'The file could not be processed right now. Please try again in a moment.'\n : 'We could not read this file automatically. Please make sure it is a clear BOQ (Excel, CSV, PDF, or image) and try again, or add the items manually.';\n }\n\n return $message;\n }\n\n private function mimeForExtension(string $ext): string\n {\n return match (strtolower($ext)) {\n 'pdf' => 'application/pdf',\n 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n 'xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12',\n 'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',\n 'xls' => 'application/vnd.ms-excel',\n 'csv' => 'text/csv',\n 'jpg', 'jpeg' => 'image/jpeg',\n 'png' => 'image/png',\n 'gif' => 'image/gif',\n 'bmp' => 'image/bmp',\n 'webp' => 'image/webp',\n 'tiff', 'tif' => 'image/tiff',\n 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'doc' => 'application/msword',\n default => 'application/octet-stream',\n };\n }\n}\n"}, "7be8671e8a42b6c7e31cdb9dbe3eccc731cfd9db": {"t": 1785748380.9081903, "n": 68328, "h": "5548cad491eb191c", "c": "<?php\n\nnamespace App\\Services;\n\nuse Illuminate\\Http\\Client\\ConnectionException;\nuse Illuminate\\Http\\UploadedFile;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\nuse PhpOffice\\PhpSpreadsheet\\Cell\\Coordinate;\nuse PhpOffice\\PhpSpreadsheet\\IOFactory;\n\nclass QuotationAiService\n{\n /** Increment this whenever BOQ parsing logic changes to invalidate old caches. */\n private const PARSER_VERSION = 10;\n private const LARGE_BOQ_VERIFICATION_THRESHOLD = 2000;\n private const LARGE_BOQ_VERIFICATION_CHUNK_SIZE = 120;\n\n /**\n * Characters of document text sent per AI call.\n *\n * Above this the document is split and parsed over several calls. Sized well\n * under the model's context so the prompt, the slice, and a full JSON answer\n * for every row in it all fit — the binding limit is the *output* budget, not\n * the input, since each input row produces a JSON object.\n */\n private const TEXT_CHUNK_CHARS = 60000;\n\n private string $baseUrl;\n private string $parseEndpoint;\n private string $apiKey;\n private int $timeout;\n private bool $testMode;\n private BoqCleaningService $boqCleaner;\n\n /**\n * Optional progress reporter for chunked parsing, called as ($part, $total).\n *\n * Set by the queue job so the polling UI can show real progress on a large\n * BOQ instead of a spinner that appears frozen for many minutes.\n *\n * @var (callable(int, int): void)|null\n */\n private $onChunkProgress = null;\n\n /** @param callable(int, int): void $callback */\n public function onChunkProgress(callable $callback): self\n {\n $this->onChunkProgress = $callback;\n return $this;\n }\n\n public function __construct(BoqCleaningService $boqCleaner)\n {\n $this->boqCleaner = $boqCleaner;\n $this->baseUrl = rtrim((string) config('services.ai_quotation.base_url', ''), '/');\n $this->parseEndpoint = ltrim((string) config('services.ai_quotation.parse_endpoint', 'parse'), '/');\n $this->apiKey = (string) config('services.ai_quotation.api_key', '');\n $this->timeout = (int) config('services.ai_quotation.timeout', 300);\n $this->testMode = (bool) config('services.ai_quotation.test_mode', false);\n }\n\n /**\n * Give the running script enough head-room to outlast the AI HTTP call.\n *\n * The web request / queue worker must not be killed before the HTTP timeout\n * completes, otherwise the user sees a generic \"timeout or unavailable\"\n * error even though the AI may have responded. We allow the HTTP timeout\n * plus a 120s margin for connecting, response parsing and DB writes.\n */\n private function extendExecutionTime(): void\n {\n // Ignored on some SAPIs (e.g. when running under fastcgi with a hard\n // limit), but harmless to attempt.\n @set_time_limit($this->timeout + 120);\n }\n\n public function parseBoq(UploadedFile|string $file, array $context = []): array\n {\n if ($this->testMode) {\n return $this->mockResponse();\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n\n if (! is_file($absPath)) {\n return $this->failure('File not found or not readable.');\n }\n\n $fileHash = hash_file('sha256', $absPath);\n // Bump PARSER_VERSION whenever the extraction logic changes so that\n // previously-uploaded files are re-parsed instead of returning a stale\n // cached result produced by the old code.\n $cacheKey = 'boq_analysis_v' . self::PARSER_VERSION . '_' . $fileHash;\n $forceRefresh = (bool) ($context['force_refresh'] ?? false);\n\n if (! $forceRefresh && ($cached = Cache::get($cacheKey)) !== null) {\n return $cached;\n }\n\n // Clear stale cached result when force-refreshing\n if ($forceRefresh) {\n Cache::forget($cacheKey);\n }\n\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n // Read Excel locally first. Fall back to AI if local parse failed,\n // found 0 items, OR could not read one of the sheets (so multi-sheet\n // files never silently drop a sheet the local parser didn't understand).\n $result = $this->parseSpreadsheetDirect($absPath);\n\n $hasSkippedSheets = ! empty($result['skipped_sheets']);\n\n if (! $result['success'] || empty($result['items']) || $hasSkippedSheets) {\n $deepSeekResult = $this->parseBoqWithDeepSeek($file, $context);\n // AI sees every sheet at once, so prefer its result whenever it\n // actually returned items (covers the skipped-sheet case too).\n if ($deepSeekResult['success'] && ! empty($deepSeekResult['items'])) {\n // Keep the larger of the two item sets so we never lose the\n // rows the local parser already extracted correctly.\n if (count($deepSeekResult['items']) >= count($result['items'] ?? [])) {\n $result = $deepSeekResult;\n }\n } elseif (! $result['success']) {\n $result = $deepSeekResult;\n }\n // else: keep local result if AI also failed/returned nothing\n }\n } elseif (in_array($ext, ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif'], true)) {\n $result = $this->parseBoqWithDeepSeek($file, $context);\n } else {\n return $this->failure('Unsupported file type. Please upload Excel, CSV, PDF, or image file.');\n }\n\n if ($result['success'] && ! empty($result['items'])) {\n $cleaning = $this->boqCleaner->process($result['items']);\n $result['items'] = $cleaning['accepted'] ?? $result['items'];\n $result['rejected'] = array_merge($result['rejected'] ?? [], $cleaning['rejected'] ?? []);\n }\n\n if ($result['success'] && count($result['items'] ?? []) > self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n $result = $this->verifyLargeBoqItemsWithAi($result);\n }\n\n // Only cache when we actually extracted items; empty results should be re-tried.\n if ($result['success'] && ! empty($result['items'])) {\n Cache::put($cacheKey, $result, now()->addDays(30));\n }\n\n return $result;\n }\n\n private function resolveFile(UploadedFile|string $file): array\n {\n if ($file instanceof UploadedFile) {\n return [(string) $file->getRealPath(), strtolower($file->getClientOriginalExtension())];\n }\n\n $absPath = is_file($file) ? $file : storage_path('app/' . ltrim($file, '/'));\n return [$absPath, strtolower(pathinfo($absPath, PATHINFO_EXTENSION))];\n }\n\n private function parseSpreadsheetDirect(string $absPath): array\n {\n ini_set('memory_limit', '1024M');\n set_time_limit(180);\n\n try {\n $sheets = $this->spreadsheetToGrids($absPath);\n\n if (empty($sheets)) {\n return $this->failure('Could not read the Excel file.');\n }\n\n $items = [];\n $rejected = [];\n $skippedSheets = []; // sheets we could not parse locally (header undetected)\n\n foreach ($sheets as $sheet) {\n $grid = $sheet['grid'];\n if (count($grid) < 2) {\n continue;\n }\n\n // Skip dedicated summary / cost-abstract sheets entirely. Their rows\n // are per-discipline totals (Structural Concrete, Electrical, …) and\n // grand totals, not procurable products.\n if ($this->isSummarySheet($sheet['name'])) {\n Log::info('QuotationAiService: skipped summary sheet.', ['sheet' => $sheet['name']]);\n continue;\n }\n\n $header = $this->detectHeader($grid);\n\n if ($header === null || ! isset($header['map']['description'])) {\n Log::warning('QuotationAiService: header not detected in sheet.', ['sheet' => $sheet['name']]);\n $skippedSheets[] = $sheet['name'];\n continue;\n }\n\n $map = $header['map'];\n $start = $header['row'] + 1;\n\n for ($r = $start; $r < count($grid); $r++) {\n $row = $grid[$r];\n $description = $this->cell($row, $map['description'] ?? null);\n $itemNo = $this->cell($row, $map['item_code'] ?? null);\n $unit = $this->cell($row, $map['unit'] ?? null);\n $quantity = $this->number($this->cell($row, $map['quantity'] ?? null));\n $unitPrice = $this->number($this->cell($row, $map['unit_price'] ?? null));\n $totalPrice = $this->number($this->cell($row, $map['total_price'] ?? null));\n\n if ($description === '') {\n continue;\n }\n\n if (! isset($map['quantity'])) {\n $quantity = 1.0;\n }\n\n // Skip floor breakdown sub-rows: Ref. is \"-\" meaning it's a per-floor\n // quantity split of the parent item, not a standalone product.\n if ($itemNo === '-' || $this->isFloorBreakdownRow($itemNo, $description)) {\n continue;\n }\n\n if ($this->isTotalOrHeaderRow($description)) {\n continue;\n }\n\n if ($quantity === null || $quantity <= 0) {\n $rejected[] = $this->rejectedRow($description, $unit, $quantity, 'Missing quantity', $itemNo, $sheet['name']);\n continue;\n }\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $supply = $this->boqCleaner->filterItem($description, $unitPrice, $quantity);\n\n if (! ($supply['keep'] ?? false)) {\n $rejected[] = $this->rejectedRow(\n $description,\n $unit,\n $quantity,\n (string) ($supply['rejection_reason'] ?? 'Rejected by cleaner'),\n $itemNo,\n $sheet['name']\n );\n continue;\n }\n\n $finalDescription = (string) ($supply['description'] ?? $description);\n\n $items[] = [\n 'description' => $finalDescription,\n 'quantity' => $quantity,\n 'unit' => $unit,\n 'category' => $sheet['name'],\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($finalDescription),\n 'unit_price' => $unitPrice,\n 'confidence' => 0.95,\n 'needs_review' => false,\n 'raw_data' => [\n 'sheet' => $sheet['name'],\n 'row_number' => $r + 1,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'cleaned_description' => $finalDescription,\n 'extraction_type' => (string) ($supply['extraction_type'] ?? 'supply_only'),\n ],\n 'ai_extracted' => true,\n ];\n }\n }\n\n if (empty($items)) {\n return $this->failure('No BOQ supply items could be extracted from the Excel file.');\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'skipped_sheets' => $skippedSheets,\n ];\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: parseSpreadsheetDirect failed.', [\n 'path' => $absPath,\n 'message' => $e->getMessage(),\n 'trace' => $e->getTraceAsString(),\n ]);\n\n return $this->failure('Failed to read the Excel file: ' . $e->getMessage());\n }\n }\n\n private function spreadsheetToGrids(string $absPath): array\n {\n $ext = strtolower(pathinfo($absPath, PATHINFO_EXTENSION));\n\n if ($ext === 'csv') {\n $rows = [];\n $handle = fopen($absPath, 'rb');\n if ($handle !== false) {\n while (($row = fgetcsv($handle)) !== false) {\n $rows[] = $this->trimTrailingEmptyCells($row);\n }\n fclose($handle);\n }\n return [['name' => 'Sheet1', 'grid' => $rows]];\n }\n\n $reader = IOFactory::createReader($ext === 'xls' ? 'Xls' : 'Xlsx');\n $reader->setReadDataOnly(true);\n $spreadsheet = $reader->load($absPath);\n $sheets = [];\n\n foreach ($spreadsheet->getAllSheets() as $sheet) {\n $highestRow = $sheet->getHighestDataRow();\n $highestColumn = $sheet->getHighestDataColumn();\n $highestColumnIndex = Coordinate::columnIndexFromString($highestColumn);\n\n // BOQ data is normally in first 8-20 columns.\n // Files with heavy formatting/images can report 300+ columns — limit to 30.\n $maxColumnIndex = min($highestColumnIndex, 30);\n\n $grid = [];\n for ($row = 1; $row <= $highestRow; $row++) {\n $rowData = [];\n $hasValue = false;\n\n for ($col = 1; $col <= $maxColumnIndex; $col++) {\n $value = $sheet->getCell([$col, $row])->getValue();\n $value = $this->cellValueToString($value);\n $value = trim($value);\n if ($value !== null && $value !== '') {\n $hasValue = true;\n }\n $rowData[] = $value;\n }\n\n $rowData = $this->trimTrailingEmptyCells($rowData);\n if ($hasValue) {\n $grid[] = $rowData;\n }\n }\n\n if (! empty($grid)) {\n $sheets[] = ['name' => $sheet->getTitle(), 'grid' => $grid];\n }\n }\n\n $spreadsheet->disconnectWorksheets();\n unset($spreadsheet);\n\n return $sheets;\n }\n\n private function detectHeader(array $grid): ?array\n {\n $keywords = [\n 'item_code' => ['رقم البند', 'item no', 'item number', 'ref.', 'ref', 'item', 'no.', 'no', '#', 'كود'],\n 'description' => ['وص٠البند', 'scope of works', 'scope of work', 'scope', 'item description', 'description of works', 'description', 'بالعربية', 'الوصÙ', 'البند', 'بيان'],\n 'unit' => ['Ø§Ù„ÙˆØØ¯Ø©', 'unit', 'uom', 'u/m'],\n 'quantity' => ['الكمية', 'qty', 'quantity', 'q.ty'],\n 'unit_price' => ['سعر Ø§Ù„ÙˆØØ¯Ø©', 'u/price', 'unit price', 'unit rate', 'rate', 'price'],\n 'total_price' => ['السعر الإجمالي', 'total amount', 'total price', 'amount', 'الإجمالي'],\n ];\n\n $best = null;\n $bestScore = 0;\n\n for ($r = 0; $r < min(30, count($grid)); $r++) {\n $map = [];\n $score = 0;\n\n foreach ($grid[$r] as $c => $value) {\n $cell = mb_strtolower(trim((string) $value));\n if ($cell === '') {\n continue;\n }\n\n foreach ($keywords as $field => $list) {\n foreach ($list as $kw) {\n if (str_contains($cell, mb_strtolower($kw))) {\n if (! isset($map[$field])) {\n $map[$field] = $c;\n $score++;\n }\n break 2;\n }\n }\n }\n }\n\n if ($score > $bestScore) {\n $bestScore = $score;\n $best = ['row' => $r, 'map' => $map];\n }\n }\n\n if ($bestScore >= 3) {\n return $best;\n }\n\n // Allow simpler BOQ sheets that only define a description column plus one\n // other BOQ-related column, e.g. Item + Description or Description + Qty.\n if ($bestScore === 2 && isset($best['map']['description']) && (\n isset($best['map']['item_code']) ||\n isset($best['map']['quantity']) ||\n isset($best['map']['unit'])\n )) {\n return $best;\n }\n\n return null;\n }\n\n private function cell(array $row, ?int $index): string\n {\n if ($index === null) {\n return '';\n }\n return trim((string) ($row[$index] ?? ''));\n }\n\n private function number(string $value): ?float\n {\n $value = trim(str_replace([',', 'SAR', 'ر.س'], '', $value));\n if ($value === '' || ! is_numeric($value)) {\n return null;\n }\n return (float) $value;\n }\n\n private function trimTrailingEmptyCells(array $row): array\n {\n while (! empty($row) && trim((string) end($row)) === '') {\n array_pop($row);\n }\n return array_values($row);\n }\n\n private function isTotalOrHeaderRow(string $description): bool\n {\n $d = mb_strtolower(trim($description));\n\n if ($d === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $d)) {\n return true;\n }\n\n // Section totals / summaries\n if (preg_match('/(اجمالي|إجمالي|المجموع|grand\\s*total|sub\\s*total|total\\s*amount|total\\s*price)/iu', $d)) {\n return true;\n }\n\n // Bare \"Total\" lines, optionally with a currency suffix, e.g. \"Total\",\n // \"Total (SAR)\", \"Total cost\", \"Total (SAR) unit cost\".\n if (preg_match('/^total\\b[\\s\\(\\):a-z]*$/iu', $d)) {\n return true;\n }\n\n // Pure floor / level labels used as section headers\n if (preg_match('/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor)\\s*$/iu', $d)) {\n return true;\n }\n\n // Division / section header lines (e.g. \"Division - 02: Wood Works\")\n if (preg_match('/^division\\s*([-:]|\\d)/iu', $d)) {\n return true;\n }\n\n return false;\n }\n\n /**\n * Detect dedicated summary / cost-abstract sheets by their name. These hold\n * per-discipline rollups and grand totals (e.g. a \"Summary (Dry Cost)\" tab),\n * never procurable supply items, so they are skipped wholesale.\n */\n private function isSummarySheet(string $name): bool\n {\n $n = mb_strtolower(trim($name));\n\n if ($n === '') {\n return false;\n }\n\n return (bool) preg_match(\n '/\\b(summary|abstract|cost\\s*summary|grand\\s*summary|recap(?:itulation)?|bill\\s*summary|boq\\s*summary|dry\\s*cost)\\b|ملخص|الملخص|الملخّص|ملخّص|اجمالي|إجمالي/iu',\n $n\n );\n }\n\n private function isFloorBreakdownRow(string $itemNo, string $description): bool\n {\n // A row whose Ref column is empty or a dash is a sub-breakdown row (per-floor split).\n $ref = trim($itemNo);\n if ($ref === '' || $ref === '-') {\n // Confirm by checking the description is a location/floor label\n $d = mb_strtolower(trim($description));\n return (bool) preg_match(\n '/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor|\\d+f)\\s*$/iu',\n $d\n );\n }\n return false;\n }\n\n private function rejectedRow(string $description, string $unit, ?float $quantity, string $reason, string $itemNo = '', string $sheet = ''): array\n {\n return [\n 'description' => $description,\n 'quantity' => $quantity ?? 0,\n 'unit' => $unit,\n 'category' => $sheet,\n 'brand' => '',\n 'status' => 'rejected',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 0.0,\n 'raw_data' => [\n 'sheet' => $sheet,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'extraction_type' => 'rejected',\n 'rejection_reason' => $reason,\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function parseBoqWithDeepSeek(UploadedFile|string $file, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured. Please set DEEPSEEK_API_KEY in .env file.');\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n $mime = $this->mimeForExtension($ext);\n\n try {\n // ── Spreadsheets ──────────────────────────────────────────────────\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n $text = $this->spreadsheetToCompactCsvText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not convert spreadsheet to readable text.');\n }\n\n $text = $this->sanitizeUtf8($text);\n\n // A large BOQ exceeds what one request can carry, so send it in\n // sequential slices and merge. Anything that fits goes as-is.\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS) {\n return $this->parseTextInChunks($text, 'BOQ spreadsheet converted to CSV', $apiKey, $context);\n }\n\n $userContent = \"BOQ spreadsheet converted to CSV:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── PDF ───────────────────────────────────────────────────────────\n if ($ext === 'pdf') {\n $extracted = $this->extractPdfText($absPath);\n if ($extracted !== null && mb_strlen(trim($extracted)) > 50) {\n $extracted = $this->sanitizeUtf8($extracted);\n\n if (mb_strlen($extracted) > self::TEXT_CHUNK_CHARS) {\n return $this->parseTextInChunks($extracted, 'BOQ PDF extracted text', $apiKey, $context, 'application/pdf');\n }\n\n $userContent = \"BOQ PDF extracted text:\\n\\n\" . $extracted . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'application/pdf');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n return $this->failure('Could not extract text from the PDF. Please make sure it is a text-based PDF (not a scanned image), or convert it to Excel or CSV.');\n }\n\n // -- Word documents (docx) ---------------------------------------------------\n if (in_array($ext, ['docx', 'doc'], true)) {\n $text = $this->extractDocxText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not extract text from the Word document. Please convert it to Excel, CSV, or PDF.');\n }\n $text = $this->sanitizeUtf8($text);\n\n if (mb_strlen($text) > self::TEXT_CHUNK_CHARS) {\n return $this->parseTextInChunks($text, 'BOQ Word document extracted text', $apiKey, $context);\n }\n\n $userContent = \"BOQ Word document extracted text:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── Images ──────────────────────────────────────────────────────────────\n // DeepSeek cannot receive images. A vision-capable AI processes the image\n // directly (no OCR) — configure GROQ_API_KEY or GEMINI_API_KEY in .env.\n $imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif', 'heic', 'heif'];\n if (in_array($ext, $imageExts, true)) {\n $bytes = (string) file_get_contents($absPath);\n return $this->callDeepSeekVision($bytes, $mime, $context);\n }\n\n return $this->failure('Unsupported file type. Please upload an Excel, CSV, PDF, or image file.');\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: DeepSeek fallback failed.', ['message' => $e->getMessage()]);\n return $this->failure('An error occurred while processing the file with AI: ' . $e->getMessage());\n }\n }\n\n private function deepSeekModel(): string\n {\n return (string) config('services.deepseek.model', 'deepseek-chat');\n }\n\n /**\n * Remove or replace any non-UTF-8 / malformed byte sequences so json_encode never fails.\n */\n private function sanitizeUtf8(string $text): string\n {\n // Replace invalid byte sequences with the UTF-8 replacement character\n $clean = mb_convert_encoding($text, 'UTF-8', 'UTF-8');\n // Strip any remaining non-printable control characters (except tab/newline/CR)\n $clean = (string) preg_replace('/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]/u', '', $clean);\n return $clean;\n }\n\n /**\n * Extract plain text from a Word docx file using ZipArchive (no external library needed).\n * Reads word/document.xml and strips XML tags, preserving paragraph and table structure.\n */\n private function extractDocxText(string $absPath): ?string\n {\n try {\n $zip = new \\ZipArchive();\n if ($zip->open($absPath) !== true) {\n return null;\n }\n\n $xml = $zip->getFromName('word/document.xml');\n $zip->close();\n\n if ($xml === false || $xml === '') {\n return null;\n }\n\n // Add newlines before paragraph/table-row/table-cell tags so structure is preserved\n $xml = preg_replace('/<w:p[ >]/', \"\\n<w:p>\", $xml) ?? $xml;\n $xml = preg_replace('/<w:tr[ >]/', \"\\n<w:tr>\", $xml) ?? $xml;\n $xml = preg_replace('/<\\/w:tc>/', \"\\t\", $xml) ?? $xml;\n\n $text = strip_tags($xml);\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n return trim($text) !== '' ? trim($text) : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: DOCX parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Extract plain text from a PDF using smalot/pdfparser (pure PHP, no binary required).\n */\n private function extractPdfText(string $absPath): ?string\n {\n try {\n $parser = new \\Smalot\\PdfParser\\Parser();\n $pdf = $parser->parseFile($absPath);\n $text = $pdf->getText();\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n return $text !== '' ? $text : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: PDF parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Send a vision request to a vision-capable AI model.\n * DeepSeek's commercial API (api.deepseek.com) does NOT support image inputs — any model.\n * Priority order (first configured key wins):\n * 1. GROQ_API_KEY → Groq llama-4-scout (free: https://console.groq.com)\n * 2. GEMINI_API_KEY → Google Gemini Flash (free: https://aistudio.google.com/apikey)\n * 3. VISION_API_KEY → any OpenAI-compatible endpoint (OpenAI, OpenRouter …)\n */\n private function callDeepSeekVision(string $bytes, string $mime, array $context): array\n {\n // ── 1. Groq (free, reliable vision) ─────────────────────────────────────\n if ((string) config('services.groq.key', '') !== '') {\n $visionKey = (string) config('services.groq.key');\n $visionBaseUrl = 'https://api.groq.com/openai/v1';\n $visionModel = (string) config('services.groq.model', 'meta-llama/llama-4-scout-17b-16e-instruct');\n }\n // ── 2. Google Gemini ─────────────────────────────────────────────────────\n elseif ((string) config('services.gemini.key', '') !== '') {\n $visionKey = (string) config('services.gemini.key');\n $visionBaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai';\n $visionModel = (string) config('services.gemini.model', 'gemini-2.0-flash');\n }\n // ── 3. Generic VISION_API_KEY (OpenAI, OpenRouter, …) ───────────────────\n elseif ((string) config('services.vision.key', '') !== '') {\n $visionKey = (string) config('services.vision.key');\n $visionBaseUrl = rtrim((string) config('services.vision.base_url', 'https://openrouter.ai/api/v1'), '/');\n $visionModel = (string) config('services.vision.model', 'google/gemini-flash-1.5-8b');\n }\n else {\n return $this->failure(\n 'Image processing is not available. DeepSeek\\'s API does not support images. ' .\n 'Get a free key at https://console.groq.com and add GROQ_API_KEY to your .env file.'\n );\n }\n\n $dataUrl = \"data:{$mime};base64,\" . base64_encode($bytes);\n $prompt = $this->buildDeepSeekPrompt($context, $mime);\n\n $userContent = [\n ['type' => 'text', 'text' => $prompt],\n ['type' => 'image_url', 'image_url' => ['url' => $dataUrl]],\n ];\n\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders([\n 'Authorization' => 'Bearer ' . $visionKey,\n 'HTTP-Referer' => config('app.url', 'https://qimta.sa'),\n 'X-Title' => 'Qimta Platform',\n ])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post($visionBaseUrl . '/chat/completions', [\n 'model' => $visionModel,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 16384,\n 'temperature' => 0.2,\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('Vision API connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: Vision API HTTP error.', [\n 'model' => $visionModel,\n 'base_url' => $visionBaseUrl,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n return $this->failure(\"Vision API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n return $this->parseDeepSeekJsonResponse((string) $response->json('choices.0.message.content'));\n }\n\n private function callDeepSeekChat(string $userContent, string $key, string $model): array\n {\n // Ensure the PHP script outlives the HTTP call (+ a margin for parsing the\n // response and writing to the DB) so the worker/web request is not killed\n // mid-flight, which surfaces to users as a generic \"timeout or unavailable\".\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders(['Authorization' => 'Bearer ' . $key])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 32768,\n 'temperature' => 0.1,\n 'response_format' => ['type' => 'json_object'],\n 'user' => 'Qimta_Platform',\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('DeepSeek connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: DeepSeek HTTP error.', [\n 'model' => $model,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n if ($response->status() === 401) {\n return $this->failure('DeepSeek API key is invalid or unauthorised. Please update DEEPSEEK_API_KEY in .env.', false);\n }\n return $this->failure(\"DeepSeek API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n $text = (string) $response->json('choices.0.message.content');\n return $this->parseDeepSeekJsonResponse($text);\n }\n\n /**\n * Parse a document too large for one AI call by sending it in slices.\n *\n * A 20 000-row BOQ is several megabytes of text — far beyond what a single\n * request can carry, and beyond what the model can answer without hitting its\n * output token limit. Previously the text was simply cut at 180 000 chars and\n * the remainder discarded: the parse \"succeeded\" while silently dropping most\n * of the file. Now the text is split and each slice parsed on its own call,\n * with the items merged.\n *\n * Splitting happens on line boundaries so a BOQ row is never cut in half.\n * Slices run sequentially rather than pooled: DeepSeek rate-limits hard on\n * concurrent large prompts, and this already runs on the queue where wall\n * time is cheap.\n *\n * A slice that fails does not fail the document — its rows are lost, but the\n * rest are kept and the shortfall is logged. Total failure is reported only\n * when every slice fails.\n *\n * @param string $label How the payload is introduced to the model.\n * @return array{success:bool,items:array,rejected:array,error:?string}\n */\n private function parseTextInChunks(\n string $text,\n string $label,\n string $apiKey,\n array $context,\n string $mime = 'text/plain',\n ): array {\n $chunks = $this->splitOnLineBoundaries($text, self::TEXT_CHUNK_CHARS);\n $total = count($chunks);\n\n Log::info('QuotationAiService: large document split for parsing.', [\n 'chars' => mb_strlen($text),\n 'chunks' => $total,\n ]);\n\n $items = [];\n $rejected = [];\n $failed = 0;\n $lastError = null;\n\n foreach ($chunks as $index => $chunk) {\n $part = $index + 1;\n\n // Report progress so a 28-slice parse does not look frozen to the user.\n if ($this->onChunkProgress !== null) {\n ($this->onChunkProgress)($part, $total);\n }\n\n // Tell the model this is a fragment, so it does not try to reconcile\n // totals or treat a mid-document slice as a complete BOQ.\n $userContent = \"{$label} (part {$part} of {$total}):\\n\\n\"\n . $chunk . \"\\n\\n\"\n . \"NOTE: This is part {$part} of {$total} of a larger document. Extract only the rows present in this part. Do not infer totals for the whole document.\\n\\n\"\n . $this->buildDeepSeekPrompt($context, $mime);\n\n $result = $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n\n if (! ($result['success'] ?? false)) {\n $failed++;\n $lastError = $result['error'] ?? 'Chunk parsing failed.';\n Log::warning('QuotationAiService: chunk failed, continuing.', [\n 'part' => $part,\n 'of' => $total,\n 'error' => $lastError,\n ]);\n continue;\n }\n\n foreach ($result['items'] as $item) {\n $items[] = $item;\n }\n foreach ($result['rejected'] ?? [] as $row) {\n $rejected[] = $row;\n }\n }\n\n // Every slice failed — there is nothing to show, so report the failure.\n if ($failed === $total) {\n return $this->failure($lastError ?? 'Could not parse the document.');\n }\n\n if ($failed > 0) {\n Log::error('QuotationAiService: document parsed with missing parts.', [\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n 'items_kept' => count($items),\n ]);\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'service_unavailable' => false,\n 'partial' => $failed > 0,\n 'failed_chunks' => $failed,\n 'total_chunks' => $total,\n ];\n }\n\n /**\n * Split text into slices of at most $limit characters, breaking only at\n * newlines so no BOQ row is ever cut in half.\n *\n * A single line longer than the limit (rare — a pathological cell) is passed\n * through oversized rather than severed mid-row.\n *\n * @return array<int, string>\n */\n private function splitOnLineBoundaries(string $text, int $limit): array\n {\n $chunks = [];\n $current = '';\n\n foreach (explode(\"\\n\", $text) as $line) {\n $candidate = $current === '' ? $line : $current . \"\\n\" . $line;\n\n if (mb_strlen($candidate) > $limit && $current !== '') {\n $chunks[] = $current;\n $current = $line;\n continue;\n }\n\n $current = $candidate;\n }\n\n if (trim($current) !== '') {\n $chunks[] = $current;\n }\n\n return $chunks;\n }\n\n /**\n * Shared JSON response parser used by both chat and vision paths.\n */\n private function parseDeepSeekJsonResponse(string $text): array\n {\n if ($text === '') {\n return $this->failure('DeepSeek returned an empty response.');\n }\n\n // Strip markdown code fences if the model wrapped the JSON.\n if (preg_match('/```(?:json)?\\s*([\\s\\S]*?)\\s*```/i', $text, $m)) {\n $text = $m[1];\n }\n // Narrow to the outermost JSON object/array.\n if (preg_match('/[\\{\\[][\\s\\S]*[\\}\\]]/s', $text, $m)) {\n $text = $m[0];\n }\n $text = trim($text);\n\n $decoded = json_decode($text, true);\n\n // The response can be truncated when the model hits the token limit,\n // leaving an unterminated JSON string/object. Try to repair it before\n // giving up so we can still salvage the items extracted so far.\n if (! is_array($decoded)) {\n $repaired = $this->repairTruncatedJson($text);\n if ($repaired !== null) {\n $decoded = json_decode($repaired, true);\n }\n }\n\n if (! is_array($decoded)) {\n Log::error('QuotationAiService: DeepSeek non-JSON response.', ['text' => mb_substr($text, 0, 1000)]);\n return $this->failure('DeepSeek returned a non-JSON response. The file may be too large — try splitting it into smaller files.');\n }\n\n $rawItems = $decoded['items'] ?? (array_is_list($decoded) ? $decoded : []);\n $items = [];\n $rejected = [];\n\n foreach ($rawItems as $raw) {\n if (! is_array($raw)) {\n continue;\n }\n $item = $this->normaliseAiItem($raw);\n if ($item === null) {\n continue;\n }\n $check = $this->boqCleaner->filterItem((string) $item['description']);\n if ($check['keep'] ?? false) {\n $items[] = $item;\n } else {\n $item['status'] = 'rejected';\n $item['raw_data']['rejection_reason'] = $check['rejection_reason'] ?? 'Rejected by cleaner';\n $rejected[] = $item;\n }\n }\n\n if (empty($items)) {\n Log::info('QuotationAiService: DeepSeek returned 0 accepted items.', [\n 'raw_items_count' => count($rawItems),\n 'rejected_count' => count($rejected),\n 'first_rejected' => ! empty($rejected) ? ($rejected[0]['description'] ?? '') : '',\n ]);\n }\n\n return ['success' => true, 'items' => $items, 'rejected' => $rejected, 'error' => null];\n }\n\n /**\n * Best-effort repair of a truncated JSON string produced when the model hits\n * its token limit mid-output. Drops any incomplete trailing object, then\n * closes the open string/array/object brackets so the result is valid JSON.\n *\n * Returns the repaired JSON string, or null if it cannot be salvaged.\n */\n private function repairTruncatedJson(string $text): ?string\n {\n if ($text === '') {\n return null;\n }\n\n // Walk the string tracking structural state, ignoring everything inside\n // string literals (and their escapes). Remember the last position where\n // we were back at the top of the \"items\" array between complete elements,\n // so we can cut a half-written trailing object.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($text);\n $lastSafeCut = null; // index just after a completed array element\n\n for ($i = 0; $i < $len; $i++) {\n $ch = $text[$i];\n\n if ($inString) {\n if ($escaped) {\n $escaped = false;\n } elseif ($ch === '\\\\') {\n $escaped = true;\n } elseif ($ch === '\"') {\n $inString = false;\n }\n continue;\n }\n\n if ($ch === '\"') {\n $inString = true;\n } elseif ($ch === '{' || $ch === '[') {\n $stack[] = $ch;\n } elseif ($ch === '}' || $ch === ']') {\n array_pop($stack);\n } elseif ($ch === ',' && ! empty($stack) && end($stack) === '[') {\n // Separator directly inside an array: a clean boundary between\n // elements (the items array, whatever its nesting depth).\n $lastSafeCut = $i;\n }\n }\n\n // If we are still inside a string, the last element is incomplete — fall\n // back to the last clean array boundary if we have one.\n $candidate = $text;\n if ($inString && $lastSafeCut !== null) {\n $candidate = substr($text, 0, $lastSafeCut);\n } else {\n // Drop a dangling partial object after the last completed element.\n $candidate = rtrim($candidate);\n if ($lastSafeCut !== null && substr(rtrim($candidate), -1) !== '}' && substr(rtrim($candidate), -1) !== ']') {\n $candidate = substr($text, 0, $lastSafeCut);\n }\n }\n\n // Recompute the open brackets for the (possibly trimmed) candidate and\n // append the matching closers in reverse order.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($candidate);\n for ($i = 0; $i < $len; $i++) {\n $ch = $candidate[$i];\n if ($inString) {\n if ($escaped) { $escaped = false; }\n elseif ($ch === '\\\\') { $escaped = true; }\n elseif ($ch === '\"') { $inString = false; }\n continue;\n }\n if ($ch === '\"') { $inString = true; }\n elseif ($ch === '{' || $ch === '[') { $stack[] = $ch; }\n elseif ($ch === '}' || $ch === ']') { array_pop($stack); }\n }\n\n $candidate = rtrim($candidate);\n // Remove a trailing comma left after cutting an element.\n $candidate = rtrim($candidate, ',');\n\n $closers = '';\n for ($i = count($stack) - 1; $i >= 0; $i--) {\n $closers .= ($stack[$i] === '{') ? '}' : ']';\n }\n\n $repaired = $candidate . $closers;\n\n // Validate before returning.\n return is_array(json_decode($repaired, true)) ? $repaired : null;\n }\n\n private function spreadsheetToCompactCsvText(string $absPath): ?string\n {\n $sheets = $this->spreadsheetToGrids($absPath);\n if (empty($sheets)) {\n return null;\n }\n\n $output = '';\n foreach ($sheets as $sheet) {\n // Don't feed summary / cost-abstract sheets to the AI either.\n if ($this->isSummarySheet($sheet['name'])) {\n continue;\n }\n $output .= 'Sheet: ' . $sheet['name'] . \"\\n\";\n foreach ($sheet['grid'] as $row) {\n $row = $this->trimTrailingEmptyCells($row);\n if (empty($row)) {\n continue;\n }\n $output .= implode(',', array_map([$this, 'csvEscape'], $row)) . \"\\n\";\n }\n $output .= \"\\n\";\n }\n\n return $output;\n }\n\n /**\n * Safely convert a PhpSpreadsheet cell value to a string.\n *\n * Cells may contain RichText or, in malformed/heavily-formatted files,\n * embedded objects such as Worksheet\\Drawing that have no __toString().\n * Casting those directly throws \"Object ... could not be converted to string\".\n */\n private function cellValueToString(mixed $value): string\n {\n if ($value === null) {\n return '';\n }\n\n if ($value instanceof \\PhpOffice\\PhpSpreadsheet\\RichText\\RichText) {\n return $value->getPlainText();\n }\n\n if (is_object($value)) {\n // Only stringify objects that explicitly support it; ignore the rest\n // (e.g. Worksheet\\Drawing) instead of crashing.\n return method_exists($value, '__toString') ? (string) $value : '';\n }\n\n if (is_array($value)) {\n return '';\n }\n\n return (string) $value;\n }\n\n private function csvEscape(mixed $value): string\n {\n $value = $this->cellValueToString($value);\n return str_contains($value, ',') || str_contains($value, '\"') || str_contains($value, \"\\n\")\n ? '\"' . str_replace('\"', '\"\"', $value) . '\"'\n : $value;\n }\n\n private function verifyLargeBoqItemsWithAi(array $result): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n Log::warning('QuotationAiService: large BOQ verification skipped, AI key is not configured.');\n return $result;\n }\n\n $items = array_values(array_filter($result['items'] ?? [], 'is_array'));\n if (count($items) <= self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n return $result;\n }\n\n Log::info('QuotationAiService: starting second-pass physical item verification.', [\n 'items_count' => count($items),\n 'threshold' => self::LARGE_BOQ_VERIFICATION_THRESHOLD,\n ]);\n\n $verifiedItems = [];\n $rejectedItems = $result['rejected'] ?? [];\n\n foreach (array_chunk($items, self::LARGE_BOQ_VERIFICATION_CHUNK_SIZE) as $chunkIndex => $chunk) {\n $payload = array_map(fn(array $item) => [\n 'description' => (string) ($item['description'] ?? ''),\n 'quantity' => $item['quantity'] ?? null,\n 'unit' => (string) ($item['unit'] ?? ''),\n 'unit_price' => $item['unit_price'] ?? null,\n 'category' => (string) ($item['category'] ?? ''),\n 'brand' => (string) ($item['brand'] ?? ''),\n ], $chunk);\n\n $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);\n if (! is_string($json)) {\n Log::warning('QuotationAiService: large BOQ verification chunk JSON encode failed.', [\n 'chunk' => $chunkIndex + 1,\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n $prompt = <<<PROMPT\nYou are doing a SECOND PASS verification on a BOQ extraction result.\n\nThe first pass may hallucinate non-products when the BOQ has thousands of rows.\nYour job is to keep ONLY physical, tangible, procurable supply products.\n\nRules:\n- Keep items only if a supplier can actually deliver them as materials/equipment.\n- Reject headings, trade names, systems, scopes, prose, specifications-only rows, totals, labor, installation, testing, commissioning, supervision, drawings, insurance, preliminaries, mobilisation, site works, and vague fragments.\n- Do not invent new products.\n- Do not add items that are not in the input.\n- You may clean a product description, but keep the original quantity, unit, unit_price, category, and brand when available.\n\nInput JSON array:\n{$json}\n\nReturn ONLY valid JSON in this shape:\n{\"items\":[{\"description\":\"\",\"quantity\":1,\"unit\":\"\",\"unit_price\":null,\"category\":\"\",\"brand\":\"\",\"confidence\":0.95}]}\nPROMPT;\n\n try {\n $chunkResult = $this->callDeepSeekChat($prompt, $apiKey, $this->deepSeekModel());\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: large BOQ verification chunk threw an exception.', [\n 'chunk' => $chunkIndex + 1,\n 'message' => $e->getMessage(),\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n if ($chunkResult['success'] ?? false) {\n array_push($verifiedItems, ...($chunkResult['items'] ?? []));\n $rejectedItems = array_merge($rejectedItems, $chunkResult['rejected'] ?? []);\n continue;\n }\n\n Log::warning('QuotationAiService: large BOQ verification chunk failed, keeping original chunk.', [\n 'chunk' => $chunkIndex + 1,\n 'error' => $chunkResult['error'] ?? null,\n ]);\n array_push($verifiedItems, ...$chunk);\n }\n\n if (empty($verifiedItems)) {\n Log::warning('QuotationAiService: second-pass verification returned no items, keeping first-pass result.', [\n 'original_items_count' => count($items),\n ]);\n return $result;\n }\n\n $result['items'] = $verifiedItems;\n $result['rejected'] = $rejectedItems;\n $result['large_boq_verified'] = true;\n\n Log::info('QuotationAiService: second-pass physical item verification completed.', [\n 'before' => count($items),\n 'after' => count($verifiedItems),\n ]);\n\n return $result;\n }\n\n private function normaliseAiItem(array $raw): ?array\n {\n if (! empty($raw['rejected'])) {\n return null;\n }\n\n $description = trim((string) ($raw['cleaned_description'] ?? $raw['product_name'] ?? $raw['description'] ?? ''));\n if ($description === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $description)) {\n return null;\n }\n\n $quantity = is_numeric($raw['quantity'] ?? null) ? (float) $raw['quantity'] : 1;\n $unitPrice = is_numeric($raw['unit_price'] ?? null) ? (float) $raw['unit_price'] : null;\n $totalPrice = is_numeric($raw['total_price'] ?? null) ? (float) $raw['total_price'] : null;\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $specs = is_array($raw['specifications'] ?? null) ? $raw['specifications'] : [];\n\n return [\n 'description' => $description,\n 'quantity' => $quantity,\n 'unit' => (string) ($raw['unit'] ?? ''),\n 'category' => (string) ($raw['core_product'] ?? $raw['category'] ?? ''),\n 'brand' => (string) ($specs['brand'] ?? $raw['brand'] ?? ''),\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($description),\n 'unit_price' => $unitPrice,\n 'confidence' => is_numeric($raw['confidence'] ?? null) ? (float) $raw['confidence'] : null,\n 'needs_review' => ! empty($raw['needs_review']),\n 'raw_data' => [\n 'source_item_no' => (string) ($raw['source_item_no'] ?? ''),\n 'original_description' => (string) ($raw['original_description'] ?? $description),\n 'cleaned_description' => $description,\n 'core_product' => (string) ($raw['core_product'] ?? ''),\n 'specifications' => $specs,\n 'extraction_type' => (string) ($raw['extraction_type'] ?? 'supply_only'),\n 'note' => (string) ($raw['note'] ?? $raw['notes'] ?? ''),\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function buildSystemInstruction(): string\n {\n return <<<'TXT'\nYou are a strict BOQ Supply Product Extraction Engine.\nExtract ONLY concrete, tangible, physical products that a supplier can box up and deliver to site — a specific item you could point at and say \"this is the thing being bought\" (e.g. \"Wall mounted LED luminaire 36W\", \"PVC pipe 110mm\", \"Single phase distribution board 12-way\", \"Fire extinguisher CO2 6kg\", \"CCTV dome camera 4MP\").\n\nABSOLUTELY REJECT abstract concepts, disciplines, categories, section names, and trade headings. These are NOT products even though they appear as rows:\n- Bare discipline/trade words: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", \"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\".\n- Summary / total / abstract / \"dry cost\" rows of any kind.\n- Anything that names a system, scope, or category instead of one buyable item.\n\nRule of thumb: if you cannot name a unit and physically hand the item over (a piece, a box, a length, a unit of equipment), REJECT it. A word that describes a field of work, a system, or a section of the BOQ is NEVER a product.\nReject totals, headings, general requirements, installation-only, testing, commissioning, supervision, mobilization, civil works, labor, and vague rows.\nFor Supply & Install rows, keep only the supply product and remove install/labor wording.\nReturn only valid JSON: {\"items\":[{\"source_item_no\":null,\"original_description\":\"\",\"cleaned_description\":\"\",\"core_product\":\"\",\"specifications\":{},\"quantity\":0,\"unit\":\"\",\"unit_price\":0,\"total_price\":0,\"extraction_type\":\"supply_only\",\"needs_review\":false,\"note\":\"\",\"rejected\":false,\"rejection_reason\":null,\"confidence\":0.95}]}\nTXT;\n }\n\n private function buildDeepSeekPrompt(array $context = [], string $mime = ''): string\n {\n $projectName = (string) ($context['project_name'] ?? '');\n $projectStatus = (string) ($context['project_status'] ?? '');\n $contextLine = $projectName !== ''\n ? \"Project: {$projectName}\" . ($projectStatus !== '' ? \" ({$projectStatus})\" : '')\n : '';\n $sourceHint = str_starts_with($mime, 'image/')\n ? 'The input is an image of a BOQ table. OCR every cell carefully — read the quantity/count column (العدد, الكمية, Qty) and extract the EXACT numeric value from each row.'\n : 'The input is a BOQ document/spreadsheet.';\n\n return trim(<<<PROMPT\nYou are a procurement specialist. Your task is to extract ONLY tangible, physical supply products from this Bill of Quantities (BOQ).\n\n{$contextLine}\n{$sourceHint}\n\nCRITICAL — MERGE WRAPPED / SPLIT ROWS INTO ONE ITEM:\nBOQ tables extracted from PDF often break ONE item's long description across several\nlines/rows. These continuation lines repeat the SAME quantity, unit and category, and\ntheir text is a grammatical continuation of the previous line (it starts mid-sentence,\nlowercase, or with \"the/and/-\", or is a spec/instruction clause). You MUST reconstruct\nthe original single item:\n- Treat consecutive rows that share the SAME quantity + unit + category as ONE product\n when the later rows are clearly continuation text of the first, NOT new products.\n- Emit ONE item with the merged description and the price counted ONCE. Do NOT repeat\n the same quantity×price for every wrapped line — that multiplies the total wrongly.\n- Example: rows \"The asphalt flooring is to be supplied\" / \"installed in accordance\" /\n \"standard road specifications...\" all with 7,100 m2 are ONE asphalt item, not 3+.\n\nCRITICAL — UNDERSTAND INTENT, DO NOT COPY ROWS LITERALLY:\nYour job is to output WHAT A SUPPLIER MUST ACTUALLY DELIVER, not a copy of the row text.\nRead each description for MEANING and decide how many real, separately-buyable products it\nimplies. One BOQ row can imply SEVERAL products, or a spread of rows can imply ONE product.\n- SPLIT a row into multiple items when it lists distinct products that a supplier would\n buy/price separately — even when joined by commas, \"with\", \"and\", \"including\", \"c/w\",\n \"consisting of\", \"comprising\", or \":\" then a list.\n Example: \"Fire pump set consisting of: fire pump, electric motor, control panel, diesel\n engine\" → FOUR items: (1) fire pump, (2) electric motor, (3) control panel, (4) diesel\n engine — each with its own quantity and priced on its own.\n Example: \"Wash basin complete with mixer tap and bottle trap\" → THREE items: wash basin,\n mixer tap, bottle trap.\n- Give each split product the correct quantity implied by the text (usually the parent\n quantity, unless the text states a different count for a sub-item). Each product is\n priced individually; the line total becomes the SUM of its products.\n- DO NOT split when the extra words are just specifications, dimensions, colours, ratings,\n standards, or a single product's integral parts. \"Steel door 900x2100 with vision panel\n and SS ironmongery\" is usually ONE door product — vision panel and ironmongery are part\n of that door, not separately-bought items. Use judgement: would a supplier issue a\n separate price line for it? If no, keep it as one.\n- Balanced approach: split only when genuinely separate products are present; when in doubt\n about whether something is a separate product vs. an accessory/spec of the main one, keep\n it as a single item. Do not invent products that are not in the text.\n\nSTRICT RULES — include ONLY items that are ALL of the following:\n1. A specific, concrete, tangible product that can be boxed up by a supplier and delivered to site — something you can point at and say \"this exact thing is being bought\" (e.g. \"LED luminaire 36W\", \"PVC pipe 110mm\", \"Distribution board 12-way\", \"CO2 fire extinguisher 6kg\").\n2. Has a real quantity (not 0 or \"varies\" with no meaning).\n3. Has enough description to identify what the product is.\n4. Is an actual PRODUCT, not a sentence, instruction, or specification clause. REJECT rows that are prose such as \"the supervising engineer's instructions\", \"The price includes all materials, labor\", \"Measurement will be based on the geometric cubic meter\", \"necessary steps to complete the work\", \"soil-contacting elements\", \"in compliance\", \"everything necessary\". These are description fragments, not buyable items.\n\nNEVER extract abstract concepts, disciplines, trades, systems, or section/category names — they are NOT products even when they appear as their own row:\n- Bare discipline/trade/system words such as: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", and their Arabic equivalents (\"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\", \"تشطيبات\").\n- Test: if the word names a FIELD OF WORK or a SYSTEM rather than one buyable item with a unit (pcs, m, set, kg…), REJECT it.\n\nEXCLUDE all of the following (do NOT include them):\n- Labor, installation, or workmanship items (e.g. \"install\", \"fix\", \"erect\", \"dismantle\", \"painting\", \"cleaning service\")\n- Project management, overhead, or profit items\n- Insurance, bonds, liability items (e.g. \"bodily injury\", \"third party liability\", \"CAR insurance\")\n- Shop drawings, as-built drawings, method statements, documentation\n- Preliminary items, mobilisation, demobilisation, provisional sums\n- Housekeeping, site clearance, waste removal\n- Floor/level labels (e.g. \"GF Floor\", \"Level 3\") — these are headers, not products\n- Pure dimension specs without a product noun (e.g. \"Dia 32 mm\", \"25 mm\") — reject unless a product name is present\n- Security services, night shifts, rentals, payments\n- Any item whose description is too vague or fragmentary to identify a specific product\n\nEmit ONE JSON item PER real product. A single source row may therefore produce several\nitems (when it lists multiple products) or several rows may collapse into one item (when\nthey are wrapped fragments of the same product). The \"items\" array is your final delivery\nlist, not a row-by-row transcript.\n\nFor each valid physical product return JSON with:\n- description: clean product name only (strip \"supply and install\", \"supply and fix\", installation clauses, floor/spec suffixes). For a split product, use just that product's own name.\n- quantity: the EXACT numeric value from the quantity/count column (العدد, الكمية, Qty, Quantity, No.). NEVER default to 1 unless the quantity column is truly absent or empty.\n- unit: unit of measure (pcs, m, m2, m3, set, no, kg, etc.)\n- category: product category\n- brand: brand/manufacturer if mentioned, else \"\"\n- engineering_required: true if the item needs engineering design before procurement (panel boards, chillers, generators, fire suppression systems, BMS, pumps, AHUs, switchgear, transformers), else false\n\nReturn ONLY a valid JSON object: {\"items\": [...]}. No explanations, no markdown, no extra text.\nPROMPT);\n }\n\n private function mockResponse(): array\n {\n return [\n 'success' => true,\n 'error' => null,\n 'items' => [[\n 'description' => 'Sample BOQ Item',\n 'quantity' => 1,\n 'unit' => 'No',\n 'category' => 'Sample',\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 1,\n 'raw_data' => [],\n 'ai_extracted' => true,\n ]],\n 'rejected' => [],\n ];\n }\n\n private function failure(string $message, bool $serviceUnavailable = false): array\n {\n return [\n 'success' => false,\n 'items' => [],\n 'rejected' => [],\n 'error' => $this->userFacingError($message, $serviceUnavailable),\n 'service_unavailable' => $serviceUnavailable,\n ];\n }\n\n /**\n * Translate any internal error into a neutral, user-safe message.\n *\n * The user must never see the name of the underlying AI/processing engine\n * (DeepSeek, Groq, Gemini, Vision, etc.) or configuration hints like API\n * keys. The detailed message is kept in the logs for debugging only.\n */\n private function userFacingError(string $message, bool $serviceUnavailable = false): string\n {\n // Detect any reference to an AI provider / engine / API config so we can\n // swap it for a generic, branded-neutral message.\n $sensitive = '/deepseek|groq|gemini|vision|openai|openrouter|api[\\s_-]?key|\\.env|non-?json|token limit/i';\n\n if (preg_match($sensitive, $message)) {\n Log::warning('QuotationAiService: suppressed technical error from user.', ['internal' => $message]);\n\n return $serviceUnavailable\n ? 'The file could not be processed right now. Please try again in a moment.'\n : 'We could not read this file automatically. Please make sure it is a clear BOQ (Excel, CSV, PDF, or image) and try again, or add the items manually.';\n }\n\n return $message;\n }\n\n private function mimeForExtension(string $ext): string\n {\n return match (strtolower($ext)) {\n 'pdf' => 'application/pdf',\n 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n 'xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12',\n 'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',\n 'xls' => 'application/vnd.ms-excel',\n 'csv' => 'text/csv',\n 'jpg', 'jpeg' => 'image/jpeg',\n 'png' => 'image/png',\n 'gif' => 'image/gif',\n 'bmp' => 'image/bmp',\n 'webp' => 'image/webp',\n 'tiff', 'tif' => 'image/tiff',\n 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'doc' => 'application/msword',\n default => 'application/octet-stream',\n };\n }\n}\n"}, "c11b0e8d3aa3e00749a0756db173c862c113e665": {"t": 1785748382.2988863, "n": 61603, "h": "c8529eead95933d3", "c": "<?php\n\nnamespace App\\Services;\n\nuse Illuminate\\Http\\Client\\ConnectionException;\nuse Illuminate\\Http\\UploadedFile;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\nuse PhpOffice\\PhpSpreadsheet\\Cell\\Coordinate;\nuse PhpOffice\\PhpSpreadsheet\\IOFactory;\n\nclass QuotationAiService\n{\n /** Increment this whenever BOQ parsing logic changes to invalidate old caches. */\n private const PARSER_VERSION = 9;\n private const LARGE_BOQ_VERIFICATION_THRESHOLD = 2000;\n private const LARGE_BOQ_VERIFICATION_CHUNK_SIZE = 120;\n\n private string $baseUrl;\n private string $parseEndpoint;\n private string $apiKey;\n private int $timeout;\n private bool $testMode;\n private BoqCleaningService $boqCleaner;\n\n public function __construct(BoqCleaningService $boqCleaner)\n {\n $this->boqCleaner = $boqCleaner;\n $this->baseUrl = rtrim((string) config('services.ai_quotation.base_url', ''), '/');\n $this->parseEndpoint = ltrim((string) config('services.ai_quotation.parse_endpoint', 'parse'), '/');\n $this->apiKey = (string) config('services.ai_quotation.api_key', '');\n $this->timeout = (int) config('services.ai_quotation.timeout', 300);\n $this->testMode = (bool) config('services.ai_quotation.test_mode', false);\n }\n\n /**\n * Give the running script enough head-room to outlast the AI HTTP call.\n *\n * The web request / queue worker must not be killed before the HTTP timeout\n * completes, otherwise the user sees a generic \"timeout or unavailable\"\n * error even though the AI may have responded. We allow the HTTP timeout\n * plus a 120s margin for connecting, response parsing and DB writes.\n */\n private function extendExecutionTime(): void\n {\n // Ignored on some SAPIs (e.g. when running under fastcgi with a hard\n // limit), but harmless to attempt.\n @set_time_limit($this->timeout + 120);\n }\n\n public function parseBoq(UploadedFile|string $file, array $context = []): array\n {\n if ($this->testMode) {\n return $this->mockResponse();\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n\n if (! is_file($absPath)) {\n return $this->failure('File not found or not readable.');\n }\n\n $fileHash = hash_file('sha256', $absPath);\n // Bump PARSER_VERSION whenever the extraction logic changes so that\n // previously-uploaded files are re-parsed instead of returning a stale\n // cached result produced by the old code.\n $cacheKey = 'boq_analysis_v' . self::PARSER_VERSION . '_' . $fileHash;\n $forceRefresh = (bool) ($context['force_refresh'] ?? false);\n\n if (! $forceRefresh && ($cached = Cache::get($cacheKey)) !== null) {\n return $cached;\n }\n\n // Clear stale cached result when force-refreshing\n if ($forceRefresh) {\n Cache::forget($cacheKey);\n }\n\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n // Read Excel locally first. Fall back to AI if local parse failed,\n // found 0 items, OR could not read one of the sheets (so multi-sheet\n // files never silently drop a sheet the local parser didn't understand).\n $result = $this->parseSpreadsheetDirect($absPath);\n\n $hasSkippedSheets = ! empty($result['skipped_sheets']);\n\n if (! $result['success'] || empty($result['items']) || $hasSkippedSheets) {\n $deepSeekResult = $this->parseBoqWithDeepSeek($file, $context);\n // AI sees every sheet at once, so prefer its result whenever it\n // actually returned items (covers the skipped-sheet case too).\n if ($deepSeekResult['success'] && ! empty($deepSeekResult['items'])) {\n // Keep the larger of the two item sets so we never lose the\n // rows the local parser already extracted correctly.\n if (count($deepSeekResult['items']) >= count($result['items'] ?? [])) {\n $result = $deepSeekResult;\n }\n } elseif (! $result['success']) {\n $result = $deepSeekResult;\n }\n // else: keep local result if AI also failed/returned nothing\n }\n } elseif (in_array($ext, ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif'], true)) {\n $result = $this->parseBoqWithDeepSeek($file, $context);\n } else {\n return $this->failure('Unsupported file type. Please upload Excel, CSV, PDF, or image file.');\n }\n\n if ($result['success'] && ! empty($result['items'])) {\n $cleaning = $this->boqCleaner->process($result['items']);\n $result['items'] = $cleaning['accepted'] ?? $result['items'];\n $result['rejected'] = array_merge($result['rejected'] ?? [], $cleaning['rejected'] ?? []);\n }\n\n if ($result['success'] && count($result['items'] ?? []) > self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n $result = $this->verifyLargeBoqItemsWithAi($result);\n }\n\n // Only cache when we actually extracted items; empty results should be re-tried.\n if ($result['success'] && ! empty($result['items'])) {\n Cache::put($cacheKey, $result, now()->addDays(30));\n }\n\n return $result;\n }\n\n private function resolveFile(UploadedFile|string $file): array\n {\n if ($file instanceof UploadedFile) {\n return [(string) $file->getRealPath(), strtolower($file->getClientOriginalExtension())];\n }\n\n $absPath = is_file($file) ? $file : storage_path('app/' . ltrim($file, '/'));\n return [$absPath, strtolower(pathinfo($absPath, PATHINFO_EXTENSION))];\n }\n\n private function parseSpreadsheetDirect(string $absPath): array\n {\n ini_set('memory_limit', '1024M');\n set_time_limit(180);\n\n try {\n $sheets = $this->spreadsheetToGrids($absPath);\n\n if (empty($sheets)) {\n return $this->failure('Could not read the Excel file.');\n }\n\n $items = [];\n $rejected = [];\n $skippedSheets = []; // sheets we could not parse locally (header undetected)\n\n foreach ($sheets as $sheet) {\n $grid = $sheet['grid'];\n if (count($grid) < 2) {\n continue;\n }\n\n // Skip dedicated summary / cost-abstract sheets entirely. Their rows\n // are per-discipline totals (Structural Concrete, Electrical, …) and\n // grand totals, not procurable products.\n if ($this->isSummarySheet($sheet['name'])) {\n Log::info('QuotationAiService: skipped summary sheet.', ['sheet' => $sheet['name']]);\n continue;\n }\n\n $header = $this->detectHeader($grid);\n\n if ($header === null || ! isset($header['map']['description'])) {\n Log::warning('QuotationAiService: header not detected in sheet.', ['sheet' => $sheet['name']]);\n $skippedSheets[] = $sheet['name'];\n continue;\n }\n\n $map = $header['map'];\n $start = $header['row'] + 1;\n\n for ($r = $start; $r < count($grid); $r++) {\n $row = $grid[$r];\n $description = $this->cell($row, $map['description'] ?? null);\n $itemNo = $this->cell($row, $map['item_code'] ?? null);\n $unit = $this->cell($row, $map['unit'] ?? null);\n $quantity = $this->number($this->cell($row, $map['quantity'] ?? null));\n $unitPrice = $this->number($this->cell($row, $map['unit_price'] ?? null));\n $totalPrice = $this->number($this->cell($row, $map['total_price'] ?? null));\n\n if ($description === '') {\n continue;\n }\n\n if (! isset($map['quantity'])) {\n $quantity = 1.0;\n }\n\n // Skip floor breakdown sub-rows: Ref. is \"-\" meaning it's a per-floor\n // quantity split of the parent item, not a standalone product.\n if ($itemNo === '-' || $this->isFloorBreakdownRow($itemNo, $description)) {\n continue;\n }\n\n if ($this->isTotalOrHeaderRow($description)) {\n continue;\n }\n\n if ($quantity === null || $quantity <= 0) {\n $rejected[] = $this->rejectedRow($description, $unit, $quantity, 'Missing quantity', $itemNo, $sheet['name']);\n continue;\n }\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $supply = $this->boqCleaner->filterItem($description, $unitPrice, $quantity);\n\n if (! ($supply['keep'] ?? false)) {\n $rejected[] = $this->rejectedRow(\n $description,\n $unit,\n $quantity,\n (string) ($supply['rejection_reason'] ?? 'Rejected by cleaner'),\n $itemNo,\n $sheet['name']\n );\n continue;\n }\n\n $finalDescription = (string) ($supply['description'] ?? $description);\n\n $items[] = [\n 'description' => $finalDescription,\n 'quantity' => $quantity,\n 'unit' => $unit,\n 'category' => $sheet['name'],\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($finalDescription),\n 'unit_price' => $unitPrice,\n 'confidence' => 0.95,\n 'needs_review' => false,\n 'raw_data' => [\n 'sheet' => $sheet['name'],\n 'row_number' => $r + 1,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'cleaned_description' => $finalDescription,\n 'extraction_type' => (string) ($supply['extraction_type'] ?? 'supply_only'),\n ],\n 'ai_extracted' => true,\n ];\n }\n }\n\n if (empty($items)) {\n return $this->failure('No BOQ supply items could be extracted from the Excel file.');\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'skipped_sheets' => $skippedSheets,\n ];\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: parseSpreadsheetDirect failed.', [\n 'path' => $absPath,\n 'message' => $e->getMessage(),\n 'trace' => $e->getTraceAsString(),\n ]);\n\n return $this->failure('Failed to read the Excel file: ' . $e->getMessage());\n }\n }\n\n private function spreadsheetToGrids(string $absPath): array\n {\n $ext = strtolower(pathinfo($absPath, PATHINFO_EXTENSION));\n\n if ($ext === 'csv') {\n $rows = [];\n $handle = fopen($absPath, 'rb');\n if ($handle !== false) {\n while (($row = fgetcsv($handle)) !== false) {\n $rows[] = $this->trimTrailingEmptyCells($row);\n }\n fclose($handle);\n }\n return [['name' => 'Sheet1', 'grid' => $rows]];\n }\n\n $reader = IOFactory::createReader($ext === 'xls' ? 'Xls' : 'Xlsx');\n $reader->setReadDataOnly(true);\n $spreadsheet = $reader->load($absPath);\n $sheets = [];\n\n foreach ($spreadsheet->getAllSheets() as $sheet) {\n $highestRow = $sheet->getHighestDataRow();\n $highestColumn = $sheet->getHighestDataColumn();\n $highestColumnIndex = Coordinate::columnIndexFromString($highestColumn);\n\n // BOQ data is normally in first 8-20 columns.\n // Files with heavy formatting/images can report 300+ columns — limit to 30.\n $maxColumnIndex = min($highestColumnIndex, 30);\n\n $grid = [];\n for ($row = 1; $row <= $highestRow; $row++) {\n $rowData = [];\n $hasValue = false;\n\n for ($col = 1; $col <= $maxColumnIndex; $col++) {\n $value = $sheet->getCell([$col, $row])->getValue();\n $value = $this->cellValueToString($value);\n $value = trim($value);\n if ($value !== null && $value !== '') {\n $hasValue = true;\n }\n $rowData[] = $value;\n }\n\n $rowData = $this->trimTrailingEmptyCells($rowData);\n if ($hasValue) {\n $grid[] = $rowData;\n }\n }\n\n if (! empty($grid)) {\n $sheets[] = ['name' => $sheet->getTitle(), 'grid' => $grid];\n }\n }\n\n $spreadsheet->disconnectWorksheets();\n unset($spreadsheet);\n\n return $sheets;\n }\n\n private function detectHeader(array $grid): ?array\n {\n $keywords = [\n 'item_code' => ['رقم البند', 'item no', 'item number', 'ref.', 'ref', 'item', 'no.', 'no', '#', 'كود'],\n 'description' => ['وص٠البند', 'scope of works', 'scope of work', 'scope', 'item description', 'description of works', 'description', 'بالعربية', 'الوصÙ', 'البند', 'بيان'],\n 'unit' => ['Ø§Ù„ÙˆØØ¯Ø©', 'unit', 'uom', 'u/m'],\n 'quantity' => ['الكمية', 'qty', 'quantity', 'q.ty'],\n 'unit_price' => ['سعر Ø§Ù„ÙˆØØ¯Ø©', 'u/price', 'unit price', 'unit rate', 'rate', 'price'],\n 'total_price' => ['السعر الإجمالي', 'total amount', 'total price', 'amount', 'الإجمالي'],\n ];\n\n $best = null;\n $bestScore = 0;\n\n for ($r = 0; $r < min(30, count($grid)); $r++) {\n $map = [];\n $score = 0;\n\n foreach ($grid[$r] as $c => $value) {\n $cell = mb_strtolower(trim((string) $value));\n if ($cell === '') {\n continue;\n }\n\n foreach ($keywords as $field => $list) {\n foreach ($list as $kw) {\n if (str_contains($cell, mb_strtolower($kw))) {\n if (! isset($map[$field])) {\n $map[$field] = $c;\n $score++;\n }\n break 2;\n }\n }\n }\n }\n\n if ($score > $bestScore) {\n $bestScore = $score;\n $best = ['row' => $r, 'map' => $map];\n }\n }\n\n if ($bestScore >= 3) {\n return $best;\n }\n\n // Allow simpler BOQ sheets that only define a description column plus one\n // other BOQ-related column, e.g. Item + Description or Description + Qty.\n if ($bestScore === 2 && isset($best['map']['description']) && (\n isset($best['map']['item_code']) ||\n isset($best['map']['quantity']) ||\n isset($best['map']['unit'])\n )) {\n return $best;\n }\n\n return null;\n }\n\n private function cell(array $row, ?int $index): string\n {\n if ($index === null) {\n return '';\n }\n return trim((string) ($row[$index] ?? ''));\n }\n\n private function number(string $value): ?float\n {\n $value = trim(str_replace([',', 'SAR', 'ر.س'], '', $value));\n if ($value === '' || ! is_numeric($value)) {\n return null;\n }\n return (float) $value;\n }\n\n private function trimTrailingEmptyCells(array $row): array\n {\n while (! empty($row) && trim((string) end($row)) === '') {\n array_pop($row);\n }\n return array_values($row);\n }\n\n private function isTotalOrHeaderRow(string $description): bool\n {\n $d = mb_strtolower(trim($description));\n\n if ($d === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $d)) {\n return true;\n }\n\n // Section totals / summaries\n if (preg_match('/(اجمالي|إجمالي|المجموع|grand\\s*total|sub\\s*total|total\\s*amount|total\\s*price)/iu', $d)) {\n return true;\n }\n\n // Bare \"Total\" lines, optionally with a currency suffix, e.g. \"Total\",\n // \"Total (SAR)\", \"Total cost\", \"Total (SAR) unit cost\".\n if (preg_match('/^total\\b[\\s\\(\\):a-z]*$/iu', $d)) {\n return true;\n }\n\n // Pure floor / level labels used as section headers\n if (preg_match('/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor)\\s*$/iu', $d)) {\n return true;\n }\n\n // Division / section header lines (e.g. \"Division - 02: Wood Works\")\n if (preg_match('/^division\\s*([-:]|\\d)/iu', $d)) {\n return true;\n }\n\n return false;\n }\n\n /**\n * Detect dedicated summary / cost-abstract sheets by their name. These hold\n * per-discipline rollups and grand totals (e.g. a \"Summary (Dry Cost)\" tab),\n * never procurable supply items, so they are skipped wholesale.\n */\n private function isSummarySheet(string $name): bool\n {\n $n = mb_strtolower(trim($name));\n\n if ($n === '') {\n return false;\n }\n\n return (bool) preg_match(\n '/\\b(summary|abstract|cost\\s*summary|grand\\s*summary|recap(?:itulation)?|bill\\s*summary|boq\\s*summary|dry\\s*cost)\\b|ملخص|الملخص|الملخّص|ملخّص|اجمالي|إجمالي/iu',\n $n\n );\n }\n\n private function isFloorBreakdownRow(string $itemNo, string $description): bool\n {\n // A row whose Ref column is empty or a dash is a sub-breakdown row (per-floor split).\n $ref = trim($itemNo);\n if ($ref === '' || $ref === '-') {\n // Confirm by checking the description is a location/floor label\n $d = mb_strtolower(trim($description));\n return (bool) preg_match(\n '/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor|\\d+f)\\s*$/iu',\n $d\n );\n }\n return false;\n }\n\n private function rejectedRow(string $description, string $unit, ?float $quantity, string $reason, string $itemNo = '', string $sheet = ''): array\n {\n return [\n 'description' => $description,\n 'quantity' => $quantity ?? 0,\n 'unit' => $unit,\n 'category' => $sheet,\n 'brand' => '',\n 'status' => 'rejected',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 0.0,\n 'raw_data' => [\n 'sheet' => $sheet,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'extraction_type' => 'rejected',\n 'rejection_reason' => $reason,\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function parseBoqWithDeepSeek(UploadedFile|string $file, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured. Please set DEEPSEEK_API_KEY in .env file.');\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n $mime = $this->mimeForExtension($ext);\n\n try {\n // ── Spreadsheets ──────────────────────────────────────────────────\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n $text = $this->spreadsheetToCompactCsvText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not convert spreadsheet to readable text.');\n }\n $text = $this->sanitizeUtf8(mb_substr($text, 0, 180000));\n $userContent = \"BOQ spreadsheet converted to CSV:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── PDF ───────────────────────────────────────────────────────────\n if ($ext === 'pdf') {\n $extracted = $this->extractPdfText($absPath);\n if ($extracted !== null && mb_strlen(trim($extracted)) > 50) {\n $extracted = $this->sanitizeUtf8(mb_substr($extracted, 0, 180000));\n $userContent = \"BOQ PDF extracted text:\\n\\n\" . $extracted . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'application/pdf');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n return $this->failure('Could not extract text from the PDF. Please make sure it is a text-based PDF (not a scanned image), or convert it to Excel or CSV.');\n }\n\n // -- Word documents (docx) ---------------------------------------------------\n if (in_array($ext, ['docx', 'doc'], true)) {\n $text = $this->extractDocxText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not extract text from the Word document. Please convert it to Excel, CSV, or PDF.');\n }\n $text = $this->sanitizeUtf8(mb_substr($text, 0, 180000));\n $userContent = \"BOQ Word document extracted text:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── Images ──────────────────────────────────────────────────────────────\n // DeepSeek cannot receive images. A vision-capable AI processes the image\n // directly (no OCR) — configure GROQ_API_KEY or GEMINI_API_KEY in .env.\n $imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif', 'heic', 'heif'];\n if (in_array($ext, $imageExts, true)) {\n $bytes = (string) file_get_contents($absPath);\n return $this->callDeepSeekVision($bytes, $mime, $context);\n }\n\n return $this->failure('Unsupported file type. Please upload an Excel, CSV, PDF, or image file.');\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: DeepSeek fallback failed.', ['message' => $e->getMessage()]);\n return $this->failure('An error occurred while processing the file with AI: ' . $e->getMessage());\n }\n }\n\n private function deepSeekModel(): string\n {\n return (string) config('services.deepseek.model', 'deepseek-chat');\n }\n\n /**\n * Remove or replace any non-UTF-8 / malformed byte sequences so json_encode never fails.\n */\n private function sanitizeUtf8(string $text): string\n {\n // Replace invalid byte sequences with the UTF-8 replacement character\n $clean = mb_convert_encoding($text, 'UTF-8', 'UTF-8');\n // Strip any remaining non-printable control characters (except tab/newline/CR)\n $clean = (string) preg_replace('/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]/u', '', $clean);\n return $clean;\n }\n\n /**\n * Extract plain text from a Word docx file using ZipArchive (no external library needed).\n * Reads word/document.xml and strips XML tags, preserving paragraph and table structure.\n */\n private function extractDocxText(string $absPath): ?string\n {\n try {\n $zip = new \\ZipArchive();\n if ($zip->open($absPath) !== true) {\n return null;\n }\n\n $xml = $zip->getFromName('word/document.xml');\n $zip->close();\n\n if ($xml === false || $xml === '') {\n return null;\n }\n\n // Add newlines before paragraph/table-row/table-cell tags so structure is preserved\n $xml = preg_replace('/<w:p[ >]/', \"\\n<w:p>\", $xml) ?? $xml;\n $xml = preg_replace('/<w:tr[ >]/', \"\\n<w:tr>\", $xml) ?? $xml;\n $xml = preg_replace('/<\\/w:tc>/', \"\\t\", $xml) ?? $xml;\n\n $text = strip_tags($xml);\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n return trim($text) !== '' ? trim($text) : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: DOCX parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Extract plain text from a PDF using smalot/pdfparser (pure PHP, no binary required).\n */\n private function extractPdfText(string $absPath): ?string\n {\n try {\n $parser = new \\Smalot\\PdfParser\\Parser();\n $pdf = $parser->parseFile($absPath);\n $text = $pdf->getText();\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n return $text !== '' ? $text : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: PDF parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Send a vision request to a vision-capable AI model.\n * DeepSeek's commercial API (api.deepseek.com) does NOT support image inputs — any model.\n * Priority order (first configured key wins):\n * 1. GROQ_API_KEY → Groq llama-4-scout (free: https://console.groq.com)\n * 2. GEMINI_API_KEY → Google Gemini Flash (free: https://aistudio.google.com/apikey)\n * 3. VISION_API_KEY → any OpenAI-compatible endpoint (OpenAI, OpenRouter …)\n */\n private function callDeepSeekVision(string $bytes, string $mime, array $context): array\n {\n // ── 1. Groq (free, reliable vision) ─────────────────────────────────────\n if ((string) config('services.groq.key', '') !== '') {\n $visionKey = (string) config('services.groq.key');\n $visionBaseUrl = 'https://api.groq.com/openai/v1';\n $visionModel = (string) config('services.groq.model', 'meta-llama/llama-4-scout-17b-16e-instruct');\n }\n // ── 2. Google Gemini ─────────────────────────────────────────────────────\n elseif ((string) config('services.gemini.key', '') !== '') {\n $visionKey = (string) config('services.gemini.key');\n $visionBaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai';\n $visionModel = (string) config('services.gemini.model', 'gemini-2.0-flash');\n }\n // ── 3. Generic VISION_API_KEY (OpenAI, OpenRouter, …) ───────────────────\n elseif ((string) config('services.vision.key', '') !== '') {\n $visionKey = (string) config('services.vision.key');\n $visionBaseUrl = rtrim((string) config('services.vision.base_url', 'https://openrouter.ai/api/v1'), '/');\n $visionModel = (string) config('services.vision.model', 'google/gemini-flash-1.5-8b');\n }\n else {\n return $this->failure(\n 'Image processing is not available. DeepSeek\\'s API does not support images. ' .\n 'Get a free key at https://console.groq.com and add GROQ_API_KEY to your .env file.'\n );\n }\n\n $dataUrl = \"data:{$mime};base64,\" . base64_encode($bytes);\n $prompt = $this->buildDeepSeekPrompt($context, $mime);\n\n $userContent = [\n ['type' => 'text', 'text' => $prompt],\n ['type' => 'image_url', 'image_url' => ['url' => $dataUrl]],\n ];\n\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders([\n 'Authorization' => 'Bearer ' . $visionKey,\n 'HTTP-Referer' => config('app.url', 'https://qimta.sa'),\n 'X-Title' => 'Qimta Platform',\n ])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post($visionBaseUrl . '/chat/completions', [\n 'model' => $visionModel,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 16384,\n 'temperature' => 0.2,\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('Vision API connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: Vision API HTTP error.', [\n 'model' => $visionModel,\n 'base_url' => $visionBaseUrl,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n return $this->failure(\"Vision API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n return $this->parseDeepSeekJsonResponse((string) $response->json('choices.0.message.content'));\n }\n\n private function callDeepSeekChat(string $userContent, string $key, string $model): array\n {\n // Ensure the PHP script outlives the HTTP call (+ a margin for parsing the\n // response and writing to the DB) so the worker/web request is not killed\n // mid-flight, which surfaces to users as a generic \"timeout or unavailable\".\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders(['Authorization' => 'Bearer ' . $key])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 32768,\n 'temperature' => 0.1,\n 'response_format' => ['type' => 'json_object'],\n 'user' => 'Qimta_Platform',\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('DeepSeek connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: DeepSeek HTTP error.', [\n 'model' => $model,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n if ($response->status() === 401) {\n return $this->failure('DeepSeek API key is invalid or unauthorised. Please update DEEPSEEK_API_KEY in .env.', false);\n }\n return $this->failure(\"DeepSeek API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n $text = (string) $response->json('choices.0.message.content');\n return $this->parseDeepSeekJsonResponse($text);\n }\n\n /**\n * Shared JSON response parser used by both chat and vision paths.\n */\n private function parseDeepSeekJsonResponse(string $text): array\n {\n if ($text === '') {\n return $this->failure('DeepSeek returned an empty response.');\n }\n\n // Strip markdown code fences if the model wrapped the JSON.\n if (preg_match('/```(?:json)?\\s*([\\s\\S]*?)\\s*```/i', $text, $m)) {\n $text = $m[1];\n }\n // Narrow to the outermost JSON object/array.\n if (preg_match('/[\\{\\[][\\s\\S]*[\\}\\]]/s', $text, $m)) {\n $text = $m[0];\n }\n $text = trim($text);\n\n $decoded = json_decode($text, true);\n\n // The response can be truncated when the model hits the token limit,\n // leaving an unterminated JSON string/object. Try to repair it before\n // giving up so we can still salvage the items extracted so far.\n if (! is_array($decoded)) {\n $repaired = $this->repairTruncatedJson($text);\n if ($repaired !== null) {\n $decoded = json_decode($repaired, true);\n }\n }\n\n if (! is_array($decoded)) {\n Log::error('QuotationAiService: DeepSeek non-JSON response.', ['text' => mb_substr($text, 0, 1000)]);\n return $this->failure('DeepSeek returned a non-JSON response. The file may be too large — try splitting it into smaller files.');\n }\n\n $rawItems = $decoded['items'] ?? (array_is_list($decoded) ? $decoded : []);\n $items = [];\n $rejected = [];\n\n foreach ($rawItems as $raw) {\n if (! is_array($raw)) {\n continue;\n }\n $item = $this->normaliseAiItem($raw);\n if ($item === null) {\n continue;\n }\n $check = $this->boqCleaner->filterItem((string) $item['description']);\n if ($check['keep'] ?? false) {\n $items[] = $item;\n } else {\n $item['status'] = 'rejected';\n $item['raw_data']['rejection_reason'] = $check['rejection_reason'] ?? 'Rejected by cleaner';\n $rejected[] = $item;\n }\n }\n\n if (empty($items)) {\n Log::info('QuotationAiService: DeepSeek returned 0 accepted items.', [\n 'raw_items_count' => count($rawItems),\n 'rejected_count' => count($rejected),\n 'first_rejected' => ! empty($rejected) ? ($rejected[0]['description'] ?? '') : '',\n ]);\n }\n\n return ['success' => true, 'items' => $items, 'rejected' => $rejected, 'error' => null];\n }\n\n /**\n * Best-effort repair of a truncated JSON string produced when the model hits\n * its token limit mid-output. Drops any incomplete trailing object, then\n * closes the open string/array/object brackets so the result is valid JSON.\n *\n * Returns the repaired JSON string, or null if it cannot be salvaged.\n */\n private function repairTruncatedJson(string $text): ?string\n {\n if ($text === '') {\n return null;\n }\n\n // Walk the string tracking structural state, ignoring everything inside\n // string literals (and their escapes). Remember the last position where\n // we were back at the top of the \"items\" array between complete elements,\n // so we can cut a half-written trailing object.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($text);\n $lastSafeCut = null; // index just after a completed array element\n\n for ($i = 0; $i < $len; $i++) {\n $ch = $text[$i];\n\n if ($inString) {\n if ($escaped) {\n $escaped = false;\n } elseif ($ch === '\\\\') {\n $escaped = true;\n } elseif ($ch === '\"') {\n $inString = false;\n }\n continue;\n }\n\n if ($ch === '\"') {\n $inString = true;\n } elseif ($ch === '{' || $ch === '[') {\n $stack[] = $ch;\n } elseif ($ch === '}' || $ch === ']') {\n array_pop($stack);\n } elseif ($ch === ',' && ! empty($stack) && end($stack) === '[') {\n // Separator directly inside an array: a clean boundary between\n // elements (the items array, whatever its nesting depth).\n $lastSafeCut = $i;\n }\n }\n\n // If we are still inside a string, the last element is incomplete — fall\n // back to the last clean array boundary if we have one.\n $candidate = $text;\n if ($inString && $lastSafeCut !== null) {\n $candidate = substr($text, 0, $lastSafeCut);\n } else {\n // Drop a dangling partial object after the last completed element.\n $candidate = rtrim($candidate);\n if ($lastSafeCut !== null && substr(rtrim($candidate), -1) !== '}' && substr(rtrim($candidate), -1) !== ']') {\n $candidate = substr($text, 0, $lastSafeCut);\n }\n }\n\n // Recompute the open brackets for the (possibly trimmed) candidate and\n // append the matching closers in reverse order.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($candidate);\n for ($i = 0; $i < $len; $i++) {\n $ch = $candidate[$i];\n if ($inString) {\n if ($escaped) { $escaped = false; }\n elseif ($ch === '\\\\') { $escaped = true; }\n elseif ($ch === '\"') { $inString = false; }\n continue;\n }\n if ($ch === '\"') { $inString = true; }\n elseif ($ch === '{' || $ch === '[') { $stack[] = $ch; }\n elseif ($ch === '}' || $ch === ']') { array_pop($stack); }\n }\n\n $candidate = rtrim($candidate);\n // Remove a trailing comma left after cutting an element.\n $candidate = rtrim($candidate, ',');\n\n $closers = '';\n for ($i = count($stack) - 1; $i >= 0; $i--) {\n $closers .= ($stack[$i] === '{') ? '}' : ']';\n }\n\n $repaired = $candidate . $closers;\n\n // Validate before returning.\n return is_array(json_decode($repaired, true)) ? $repaired : null;\n }\n\n private function spreadsheetToCompactCsvText(string $absPath): ?string\n {\n $sheets = $this->spreadsheetToGrids($absPath);\n if (empty($sheets)) {\n return null;\n }\n\n $output = '';\n foreach ($sheets as $sheet) {\n // Don't feed summary / cost-abstract sheets to the AI either.\n if ($this->isSummarySheet($sheet['name'])) {\n continue;\n }\n $output .= 'Sheet: ' . $sheet['name'] . \"\\n\";\n foreach ($sheet['grid'] as $row) {\n $row = $this->trimTrailingEmptyCells($row);\n if (empty($row)) {\n continue;\n }\n $output .= implode(',', array_map([$this, 'csvEscape'], $row)) . \"\\n\";\n }\n $output .= \"\\n\";\n }\n\n return $output;\n }\n\n /**\n * Safely convert a PhpSpreadsheet cell value to a string.\n *\n * Cells may contain RichText or, in malformed/heavily-formatted files,\n * embedded objects such as Worksheet\\Drawing that have no __toString().\n * Casting those directly throws \"Object ... could not be converted to string\".\n */\n private function cellValueToString(mixed $value): string\n {\n if ($value === null) {\n return '';\n }\n\n if ($value instanceof \\PhpOffice\\PhpSpreadsheet\\RichText\\RichText) {\n return $value->getPlainText();\n }\n\n if (is_object($value)) {\n // Only stringify objects that explicitly support it; ignore the rest\n // (e.g. Worksheet\\Drawing) instead of crashing.\n return method_exists($value, '__toString') ? (string) $value : '';\n }\n\n if (is_array($value)) {\n return '';\n }\n\n return (string) $value;\n }\n\n private function csvEscape(mixed $value): string\n {\n $value = $this->cellValueToString($value);\n return str_contains($value, ',') || str_contains($value, '\"') || str_contains($value, \"\\n\")\n ? '\"' . str_replace('\"', '\"\"', $value) . '\"'\n : $value;\n }\n\n private function verifyLargeBoqItemsWithAi(array $result): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n Log::warning('QuotationAiService: large BOQ verification skipped, AI key is not configured.');\n return $result;\n }\n\n $items = array_values(array_filter($result['items'] ?? [], 'is_array'));\n if (count($items) <= self::LARGE_BOQ_VERIFICATION_THRESHOLD) {\n return $result;\n }\n\n Log::info('QuotationAiService: starting second-pass physical item verification.', [\n 'items_count' => count($items),\n 'threshold' => self::LARGE_BOQ_VERIFICATION_THRESHOLD,\n ]);\n\n $verifiedItems = [];\n $rejectedItems = $result['rejected'] ?? [];\n\n foreach (array_chunk($items, self::LARGE_BOQ_VERIFICATION_CHUNK_SIZE) as $chunkIndex => $chunk) {\n $payload = array_map(fn(array $item) => [\n 'description' => (string) ($item['description'] ?? ''),\n 'quantity' => $item['quantity'] ?? null,\n 'unit' => (string) ($item['unit'] ?? ''),\n 'unit_price' => $item['unit_price'] ?? null,\n 'category' => (string) ($item['category'] ?? ''),\n 'brand' => (string) ($item['brand'] ?? ''),\n ], $chunk);\n\n $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);\n if (! is_string($json)) {\n Log::warning('QuotationAiService: large BOQ verification chunk JSON encode failed.', [\n 'chunk' => $chunkIndex + 1,\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n $prompt = <<<PROMPT\nYou are doing a SECOND PASS verification on a BOQ extraction result.\n\nThe first pass may hallucinate non-products when the BOQ has thousands of rows.\nYour job is to keep ONLY physical, tangible, procurable supply products.\n\nRules:\n- Keep items only if a supplier can actually deliver them as materials/equipment.\n- Reject headings, trade names, systems, scopes, prose, specifications-only rows, totals, labor, installation, testing, commissioning, supervision, drawings, insurance, preliminaries, mobilisation, site works, and vague fragments.\n- Do not invent new products.\n- Do not add items that are not in the input.\n- You may clean a product description, but keep the original quantity, unit, unit_price, category, and brand when available.\n\nInput JSON array:\n{$json}\n\nReturn ONLY valid JSON in this shape:\n{\"items\":[{\"description\":\"\",\"quantity\":1,\"unit\":\"\",\"unit_price\":null,\"category\":\"\",\"brand\":\"\",\"confidence\":0.95}]}\nPROMPT;\n\n try {\n $chunkResult = $this->callDeepSeekChat($prompt, $apiKey, $this->deepSeekModel());\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: large BOQ verification chunk threw an exception.', [\n 'chunk' => $chunkIndex + 1,\n 'message' => $e->getMessage(),\n ]);\n array_push($verifiedItems, ...$chunk);\n continue;\n }\n\n if ($chunkResult['success'] ?? false) {\n array_push($verifiedItems, ...($chunkResult['items'] ?? []));\n $rejectedItems = array_merge($rejectedItems, $chunkResult['rejected'] ?? []);\n continue;\n }\n\n Log::warning('QuotationAiService: large BOQ verification chunk failed, keeping original chunk.', [\n 'chunk' => $chunkIndex + 1,\n 'error' => $chunkResult['error'] ?? null,\n ]);\n array_push($verifiedItems, ...$chunk);\n }\n\n if (empty($verifiedItems)) {\n Log::warning('QuotationAiService: second-pass verification returned no items, keeping first-pass result.', [\n 'original_items_count' => count($items),\n ]);\n return $result;\n }\n\n $result['items'] = $verifiedItems;\n $result['rejected'] = $rejectedItems;\n $result['large_boq_verified'] = true;\n\n Log::info('QuotationAiService: second-pass physical item verification completed.', [\n 'before' => count($items),\n 'after' => count($verifiedItems),\n ]);\n\n return $result;\n }\n\n private function normaliseAiItem(array $raw): ?array\n {\n if (! empty($raw['rejected'])) {\n return null;\n }\n\n $description = trim((string) ($raw['cleaned_description'] ?? $raw['product_name'] ?? $raw['description'] ?? ''));\n if ($description === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $description)) {\n return null;\n }\n\n $quantity = is_numeric($raw['quantity'] ?? null) ? (float) $raw['quantity'] : 1;\n $unitPrice = is_numeric($raw['unit_price'] ?? null) ? (float) $raw['unit_price'] : null;\n $totalPrice = is_numeric($raw['total_price'] ?? null) ? (float) $raw['total_price'] : null;\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $specs = is_array($raw['specifications'] ?? null) ? $raw['specifications'] : [];\n\n return [\n 'description' => $description,\n 'quantity' => $quantity,\n 'unit' => (string) ($raw['unit'] ?? ''),\n 'category' => (string) ($raw['core_product'] ?? $raw['category'] ?? ''),\n 'brand' => (string) ($specs['brand'] ?? $raw['brand'] ?? ''),\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($description),\n 'unit_price' => $unitPrice,\n 'confidence' => is_numeric($raw['confidence'] ?? null) ? (float) $raw['confidence'] : null,\n 'needs_review' => ! empty($raw['needs_review']),\n 'raw_data' => [\n 'source_item_no' => (string) ($raw['source_item_no'] ?? ''),\n 'original_description' => (string) ($raw['original_description'] ?? $description),\n 'cleaned_description' => $description,\n 'core_product' => (string) ($raw['core_product'] ?? ''),\n 'specifications' => $specs,\n 'extraction_type' => (string) ($raw['extraction_type'] ?? 'supply_only'),\n 'note' => (string) ($raw['note'] ?? $raw['notes'] ?? ''),\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function buildSystemInstruction(): string\n {\n return <<<'TXT'\nYou are a strict BOQ Supply Product Extraction Engine.\nExtract ONLY concrete, tangible, physical products that a supplier can box up and deliver to site — a specific item you could point at and say \"this is the thing being bought\" (e.g. \"Wall mounted LED luminaire 36W\", \"PVC pipe 110mm\", \"Single phase distribution board 12-way\", \"Fire extinguisher CO2 6kg\", \"CCTV dome camera 4MP\").\n\nABSOLUTELY REJECT abstract concepts, disciplines, categories, section names, and trade headings. These are NOT products even though they appear as rows:\n- Bare discipline/trade words: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", \"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\".\n- Summary / total / abstract / \"dry cost\" rows of any kind.\n- Anything that names a system, scope, or category instead of one buyable item.\n\nRule of thumb: if you cannot name a unit and physically hand the item over (a piece, a box, a length, a unit of equipment), REJECT it. A word that describes a field of work, a system, or a section of the BOQ is NEVER a product.\nReject totals, headings, general requirements, installation-only, testing, commissioning, supervision, mobilization, civil works, labor, and vague rows.\nFor Supply & Install rows, keep only the supply product and remove install/labor wording.\nReturn only valid JSON: {\"items\":[{\"source_item_no\":null,\"original_description\":\"\",\"cleaned_description\":\"\",\"core_product\":\"\",\"specifications\":{},\"quantity\":0,\"unit\":\"\",\"unit_price\":0,\"total_price\":0,\"extraction_type\":\"supply_only\",\"needs_review\":false,\"note\":\"\",\"rejected\":false,\"rejection_reason\":null,\"confidence\":0.95}]}\nTXT;\n }\n\n private function buildDeepSeekPrompt(array $context = [], string $mime = ''): string\n {\n $projectName = (string) ($context['project_name'] ?? '');\n $projectStatus = (string) ($context['project_status'] ?? '');\n $contextLine = $projectName !== ''\n ? \"Project: {$projectName}\" . ($projectStatus !== '' ? \" ({$projectStatus})\" : '')\n : '';\n $sourceHint = str_starts_with($mime, 'image/')\n ? 'The input is an image of a BOQ table. OCR every cell carefully — read the quantity/count column (العدد, الكمية, Qty) and extract the EXACT numeric value from each row.'\n : 'The input is a BOQ document/spreadsheet.';\n\n return trim(<<<PROMPT\nYou are a procurement specialist. Your task is to extract ONLY tangible, physical supply products from this Bill of Quantities (BOQ).\n\n{$contextLine}\n{$sourceHint}\n\nCRITICAL — MERGE WRAPPED / SPLIT ROWS INTO ONE ITEM:\nBOQ tables extracted from PDF often break ONE item's long description across several\nlines/rows. These continuation lines repeat the SAME quantity, unit and category, and\ntheir text is a grammatical continuation of the previous line (it starts mid-sentence,\nlowercase, or with \"the/and/-\", or is a spec/instruction clause). You MUST reconstruct\nthe original single item:\n- Treat consecutive rows that share the SAME quantity + unit + category as ONE product\n when the later rows are clearly continuation text of the first, NOT new products.\n- Emit ONE item with the merged description and the price counted ONCE. Do NOT repeat\n the same quantity×price for every wrapped line — that multiplies the total wrongly.\n- Example: rows \"The asphalt flooring is to be supplied\" / \"installed in accordance\" /\n \"standard road specifications...\" all with 7,100 m2 are ONE asphalt item, not 3+.\n\nCRITICAL — UNDERSTAND INTENT, DO NOT COPY ROWS LITERALLY:\nYour job is to output WHAT A SUPPLIER MUST ACTUALLY DELIVER, not a copy of the row text.\nRead each description for MEANING and decide how many real, separately-buyable products it\nimplies. One BOQ row can imply SEVERAL products, or a spread of rows can imply ONE product.\n- SPLIT a row into multiple items when it lists distinct products that a supplier would\n buy/price separately — even when joined by commas, \"with\", \"and\", \"including\", \"c/w\",\n \"consisting of\", \"comprising\", or \":\" then a list.\n Example: \"Fire pump set consisting of: fire pump, electric motor, control panel, diesel\n engine\" → FOUR items: (1) fire pump, (2) electric motor, (3) control panel, (4) diesel\n engine — each with its own quantity and priced on its own.\n Example: \"Wash basin complete with mixer tap and bottle trap\" → THREE items: wash basin,\n mixer tap, bottle trap.\n- Give each split product the correct quantity implied by the text (usually the parent\n quantity, unless the text states a different count for a sub-item). Each product is\n priced individually; the line total becomes the SUM of its products.\n- DO NOT split when the extra words are just specifications, dimensions, colours, ratings,\n standards, or a single product's integral parts. \"Steel door 900x2100 with vision panel\n and SS ironmongery\" is usually ONE door product — vision panel and ironmongery are part\n of that door, not separately-bought items. Use judgement: would a supplier issue a\n separate price line for it? If no, keep it as one.\n- Balanced approach: split only when genuinely separate products are present; when in doubt\n about whether something is a separate product vs. an accessory/spec of the main one, keep\n it as a single item. Do not invent products that are not in the text.\n\nSTRICT RULES — include ONLY items that are ALL of the following:\n1. A specific, concrete, tangible product that can be boxed up by a supplier and delivered to site — something you can point at and say \"this exact thing is being bought\" (e.g. \"LED luminaire 36W\", \"PVC pipe 110mm\", \"Distribution board 12-way\", \"CO2 fire extinguisher 6kg\").\n2. Has a real quantity (not 0 or \"varies\" with no meaning).\n3. Has enough description to identify what the product is.\n4. Is an actual PRODUCT, not a sentence, instruction, or specification clause. REJECT rows that are prose such as \"the supervising engineer's instructions\", \"The price includes all materials, labor\", \"Measurement will be based on the geometric cubic meter\", \"necessary steps to complete the work\", \"soil-contacting elements\", \"in compliance\", \"everything necessary\". These are description fragments, not buyable items.\n\nNEVER extract abstract concepts, disciplines, trades, systems, or section/category names — they are NOT products even when they appear as their own row:\n- Bare discipline/trade/system words such as: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", and their Arabic equivalents (\"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\", \"تشطيبات\").\n- Test: if the word names a FIELD OF WORK or a SYSTEM rather than one buyable item with a unit (pcs, m, set, kg…), REJECT it.\n\nEXCLUDE all of the following (do NOT include them):\n- Labor, installation, or workmanship items (e.g. \"install\", \"fix\", \"erect\", \"dismantle\", \"painting\", \"cleaning service\")\n- Project management, overhead, or profit items\n- Insurance, bonds, liability items (e.g. \"bodily injury\", \"third party liability\", \"CAR insurance\")\n- Shop drawings, as-built drawings, method statements, documentation\n- Preliminary items, mobilisation, demobilisation, provisional sums\n- Housekeeping, site clearance, waste removal\n- Floor/level labels (e.g. \"GF Floor\", \"Level 3\") — these are headers, not products\n- Pure dimension specs without a product noun (e.g. \"Dia 32 mm\", \"25 mm\") — reject unless a product name is present\n- Security services, night shifts, rentals, payments\n- Any item whose description is too vague or fragmentary to identify a specific product\n\nEmit ONE JSON item PER real product. A single source row may therefore produce several\nitems (when it lists multiple products) or several rows may collapse into one item (when\nthey are wrapped fragments of the same product). The \"items\" array is your final delivery\nlist, not a row-by-row transcript.\n\nFor each valid physical product return JSON with:\n- description: clean product name only (strip \"supply and install\", \"supply and fix\", installation clauses, floor/spec suffixes). For a split product, use just that product's own name.\n- quantity: the EXACT numeric value from the quantity/count column (العدد, الكمية, Qty, Quantity, No.). NEVER default to 1 unless the quantity column is truly absent or empty.\n- unit: unit of measure (pcs, m, m2, m3, set, no, kg, etc.)\n- category: product category\n- brand: brand/manufacturer if mentioned, else \"\"\n- engineering_required: true if the item needs engineering design before procurement (panel boards, chillers, generators, fire suppression systems, BMS, pumps, AHUs, switchgear, transformers), else false\n\nReturn ONLY a valid JSON object: {\"items\": [...]}. No explanations, no markdown, no extra text.\nPROMPT);\n }\n\n private function mockResponse(): array\n {\n return [\n 'success' => true,\n 'error' => null,\n 'items' => [[\n 'description' => 'Sample BOQ Item',\n 'quantity' => 1,\n 'unit' => 'No',\n 'category' => 'Sample',\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 1,\n 'raw_data' => [],\n 'ai_extracted' => true,\n ]],\n 'rejected' => [],\n ];\n }\n\n private function failure(string $message, bool $serviceUnavailable = false): array\n {\n return [\n 'success' => false,\n 'items' => [],\n 'rejected' => [],\n 'error' => $this->userFacingError($message, $serviceUnavailable),\n 'service_unavailable' => $serviceUnavailable,\n ];\n }\n\n /**\n * Translate any internal error into a neutral, user-safe message.\n *\n * The user must never see the name of the underlying AI/processing engine\n * (DeepSeek, Groq, Gemini, Vision, etc.) or configuration hints like API\n * keys. The detailed message is kept in the logs for debugging only.\n */\n private function userFacingError(string $message, bool $serviceUnavailable = false): string\n {\n // Detect any reference to an AI provider / engine / API config so we can\n // swap it for a generic, branded-neutral message.\n $sensitive = '/deepseek|groq|gemini|vision|openai|openrouter|api[\\s_-]?key|\\.env|non-?json|token limit/i';\n\n if (preg_match($sensitive, $message)) {\n Log::warning('QuotationAiService: suppressed technical error from user.', ['internal' => $message]);\n\n return $serviceUnavailable\n ? 'The file could not be processed right now. Please try again in a moment.'\n : 'We could not read this file automatically. Please make sure it is a clear BOQ (Excel, CSV, PDF, or image) and try again, or add the items manually.';\n }\n\n return $message;\n }\n\n private function mimeForExtension(string $ext): string\n {\n return match (strtolower($ext)) {\n 'pdf' => 'application/pdf',\n 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n 'xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12',\n 'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',\n 'xls' => 'application/vnd.ms-excel',\n 'csv' => 'text/csv',\n 'jpg', 'jpeg' => 'image/jpeg',\n 'png' => 'image/png',\n 'gif' => 'image/gif',\n 'bmp' => 'image/bmp',\n 'webp' => 'image/webp',\n 'tiff', 'tif' => 'image/tiff',\n 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'doc' => 'application/msword',\n default => 'application/octet-stream',\n };\n }\n}\n"}, "ca827c092853eeb50651f314116da97327281f1e": {"t": 1785748383.6825216, "n": 56893, "h": "bbb45898ce237ff2", "c": "<?php\n\nnamespace App\\Services;\n\nuse Illuminate\\Http\\Client\\ConnectionException;\nuse Illuminate\\Http\\UploadedFile;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\nuse PhpOffice\\PhpSpreadsheet\\Cell\\Coordinate;\nuse PhpOffice\\PhpSpreadsheet\\IOFactory;\n\nclass QuotationAiService\n{\n /** Increment this whenever BOQ parsing logic changes to invalidate old caches. */\n private const PARSER_VERSION = 8;\n\n private string $baseUrl;\n private string $parseEndpoint;\n private string $apiKey;\n private int $timeout;\n private bool $testMode;\n private BoqCleaningService $boqCleaner;\n\n public function __construct(BoqCleaningService $boqCleaner)\n {\n $this->boqCleaner = $boqCleaner;\n $this->baseUrl = rtrim((string) config('services.ai_quotation.base_url', ''), '/');\n $this->parseEndpoint = ltrim((string) config('services.ai_quotation.parse_endpoint', 'parse'), '/');\n $this->apiKey = (string) config('services.ai_quotation.api_key', '');\n $this->timeout = (int) config('services.ai_quotation.timeout', 300);\n $this->testMode = (bool) config('services.ai_quotation.test_mode', false);\n }\n\n /**\n * Give the running script enough head-room to outlast the AI HTTP call.\n *\n * The web request / queue worker must not be killed before the HTTP timeout\n * completes, otherwise the user sees a generic \"timeout or unavailable\"\n * error even though the AI may have responded. We allow the HTTP timeout\n * plus a 120s margin for connecting, response parsing and DB writes.\n */\n private function extendExecutionTime(): void\n {\n // Ignored on some SAPIs (e.g. when running under fastcgi with a hard\n // limit), but harmless to attempt.\n @set_time_limit($this->timeout + 120);\n }\n\n public function parseBoq(UploadedFile|string $file, array $context = []): array\n {\n if ($this->testMode) {\n return $this->mockResponse();\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n\n if (! is_file($absPath)) {\n return $this->failure('File not found or not readable.');\n }\n\n $fileHash = hash_file('sha256', $absPath);\n // Bump PARSER_VERSION whenever the extraction logic changes so that\n // previously-uploaded files are re-parsed instead of returning a stale\n // cached result produced by the old code.\n $cacheKey = 'boq_analysis_v' . self::PARSER_VERSION . '_' . $fileHash;\n $forceRefresh = (bool) ($context['force_refresh'] ?? false);\n\n if (! $forceRefresh && ($cached = Cache::get($cacheKey)) !== null) {\n return $cached;\n }\n\n // Clear stale cached result when force-refreshing\n if ($forceRefresh) {\n Cache::forget($cacheKey);\n }\n\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n // Read Excel locally first. Fall back to AI if local parse failed,\n // found 0 items, OR could not read one of the sheets (so multi-sheet\n // files never silently drop a sheet the local parser didn't understand).\n $result = $this->parseSpreadsheetDirect($absPath);\n\n $hasSkippedSheets = ! empty($result['skipped_sheets']);\n\n if (! $result['success'] || empty($result['items']) || $hasSkippedSheets) {\n $deepSeekResult = $this->parseBoqWithDeepSeek($file, $context);\n // AI sees every sheet at once, so prefer its result whenever it\n // actually returned items (covers the skipped-sheet case too).\n if ($deepSeekResult['success'] && ! empty($deepSeekResult['items'])) {\n // Keep the larger of the two item sets so we never lose the\n // rows the local parser already extracted correctly.\n if (count($deepSeekResult['items']) >= count($result['items'] ?? [])) {\n $result = $deepSeekResult;\n }\n } elseif (! $result['success']) {\n $result = $deepSeekResult;\n }\n // else: keep local result if AI also failed/returned nothing\n }\n } elseif (in_array($ext, ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif'], true)) {\n $result = $this->parseBoqWithDeepSeek($file, $context);\n } else {\n return $this->failure('Unsupported file type. Please upload Excel, CSV, PDF, or image file.');\n }\n\n if ($result['success'] && ! empty($result['items'])) {\n $cleaning = $this->boqCleaner->process($result['items']);\n $result['items'] = $cleaning['accepted'] ?? $result['items'];\n $result['rejected'] = array_merge($result['rejected'] ?? [], $cleaning['rejected'] ?? []);\n }\n\n // Only cache when we actually extracted items; empty results should be re-tried.\n if ($result['success'] && ! empty($result['items'])) {\n Cache::put($cacheKey, $result, now()->addDays(30));\n }\n\n return $result;\n }\n\n private function resolveFile(UploadedFile|string $file): array\n {\n if ($file instanceof UploadedFile) {\n return [(string) $file->getRealPath(), strtolower($file->getClientOriginalExtension())];\n }\n\n $absPath = is_file($file) ? $file : storage_path('app/' . ltrim($file, '/'));\n return [$absPath, strtolower(pathinfo($absPath, PATHINFO_EXTENSION))];\n }\n\n private function parseSpreadsheetDirect(string $absPath): array\n {\n ini_set('memory_limit', '1024M');\n set_time_limit(180);\n\n try {\n $sheets = $this->spreadsheetToGrids($absPath);\n\n if (empty($sheets)) {\n return $this->failure('Could not read the Excel file.');\n }\n\n $items = [];\n $rejected = [];\n $skippedSheets = []; // sheets we could not parse locally (header undetected)\n\n foreach ($sheets as $sheet) {\n $grid = $sheet['grid'];\n if (count($grid) < 2) {\n continue;\n }\n\n // Skip dedicated summary / cost-abstract sheets entirely. Their rows\n // are per-discipline totals (Structural Concrete, Electrical, …) and\n // grand totals, not procurable products.\n if ($this->isSummarySheet($sheet['name'])) {\n Log::info('QuotationAiService: skipped summary sheet.', ['sheet' => $sheet['name']]);\n continue;\n }\n\n $header = $this->detectHeader($grid);\n\n if ($header === null || ! isset($header['map']['description'])) {\n Log::warning('QuotationAiService: header not detected in sheet.', ['sheet' => $sheet['name']]);\n $skippedSheets[] = $sheet['name'];\n continue;\n }\n\n $map = $header['map'];\n $start = $header['row'] + 1;\n\n for ($r = $start; $r < count($grid); $r++) {\n $row = $grid[$r];\n $description = $this->cell($row, $map['description'] ?? null);\n $itemNo = $this->cell($row, $map['item_code'] ?? null);\n $unit = $this->cell($row, $map['unit'] ?? null);\n $quantity = $this->number($this->cell($row, $map['quantity'] ?? null));\n $unitPrice = $this->number($this->cell($row, $map['unit_price'] ?? null));\n $totalPrice = $this->number($this->cell($row, $map['total_price'] ?? null));\n\n if ($description === '') {\n continue;\n }\n\n if (! isset($map['quantity'])) {\n $quantity = 1.0;\n }\n\n // Skip floor breakdown sub-rows: Ref. is \"-\" meaning it's a per-floor\n // quantity split of the parent item, not a standalone product.\n if ($itemNo === '-' || $this->isFloorBreakdownRow($itemNo, $description)) {\n continue;\n }\n\n if ($this->isTotalOrHeaderRow($description)) {\n continue;\n }\n\n if ($quantity === null || $quantity <= 0) {\n $rejected[] = $this->rejectedRow($description, $unit, $quantity, 'Missing quantity', $itemNo, $sheet['name']);\n continue;\n }\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $supply = $this->boqCleaner->filterItem($description, $unitPrice, $quantity);\n\n if (! ($supply['keep'] ?? false)) {\n $rejected[] = $this->rejectedRow(\n $description,\n $unit,\n $quantity,\n (string) ($supply['rejection_reason'] ?? 'Rejected by cleaner'),\n $itemNo,\n $sheet['name']\n );\n continue;\n }\n\n $finalDescription = (string) ($supply['description'] ?? $description);\n\n $items[] = [\n 'description' => $finalDescription,\n 'quantity' => $quantity,\n 'unit' => $unit,\n 'category' => $sheet['name'],\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($finalDescription),\n 'unit_price' => $unitPrice,\n 'confidence' => 0.95,\n 'needs_review' => false,\n 'raw_data' => [\n 'sheet' => $sheet['name'],\n 'row_number' => $r + 1,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'cleaned_description' => $finalDescription,\n 'extraction_type' => (string) ($supply['extraction_type'] ?? 'supply_only'),\n ],\n 'ai_extracted' => true,\n ];\n }\n }\n\n if (empty($items)) {\n return $this->failure('No BOQ supply items could be extracted from the Excel file.');\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'skipped_sheets' => $skippedSheets,\n ];\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: parseSpreadsheetDirect failed.', [\n 'path' => $absPath,\n 'message' => $e->getMessage(),\n 'trace' => $e->getTraceAsString(),\n ]);\n\n return $this->failure('Failed to read the Excel file: ' . $e->getMessage());\n }\n }\n\n private function spreadsheetToGrids(string $absPath): array\n {\n $ext = strtolower(pathinfo($absPath, PATHINFO_EXTENSION));\n\n if ($ext === 'csv') {\n $rows = [];\n $handle = fopen($absPath, 'rb');\n if ($handle !== false) {\n while (($row = fgetcsv($handle)) !== false) {\n $rows[] = $this->trimTrailingEmptyCells($row);\n }\n fclose($handle);\n }\n return [['name' => 'Sheet1', 'grid' => $rows]];\n }\n\n $reader = IOFactory::createReader($ext === 'xls' ? 'Xls' : 'Xlsx');\n $reader->setReadDataOnly(true);\n $spreadsheet = $reader->load($absPath);\n $sheets = [];\n\n foreach ($spreadsheet->getAllSheets() as $sheet) {\n $highestRow = $sheet->getHighestDataRow();\n $highestColumn = $sheet->getHighestDataColumn();\n $highestColumnIndex = Coordinate::columnIndexFromString($highestColumn);\n\n // BOQ data is normally in first 8-20 columns.\n // Files with heavy formatting/images can report 300+ columns — limit to 30.\n $maxColumnIndex = min($highestColumnIndex, 30);\n\n $grid = [];\n for ($row = 1; $row <= $highestRow; $row++) {\n $rowData = [];\n $hasValue = false;\n\n for ($col = 1; $col <= $maxColumnIndex; $col++) {\n $value = $sheet->getCell([$col, $row])->getValue();\n $value = $this->cellValueToString($value);\n $value = trim($value);\n if ($value !== null && $value !== '') {\n $hasValue = true;\n }\n $rowData[] = $value;\n }\n\n $rowData = $this->trimTrailingEmptyCells($rowData);\n if ($hasValue) {\n $grid[] = $rowData;\n }\n }\n\n if (! empty($grid)) {\n $sheets[] = ['name' => $sheet->getTitle(), 'grid' => $grid];\n }\n }\n\n $spreadsheet->disconnectWorksheets();\n unset($spreadsheet);\n\n return $sheets;\n }\n\n private function detectHeader(array $grid): ?array\n {\n $keywords = [\n 'item_code' => ['رقم البند', 'item no', 'item number', 'ref.', 'ref', 'item', 'no.', 'no', '#', 'كود'],\n 'description' => ['وص٠البند', 'scope of works', 'scope of work', 'scope', 'item description', 'description of works', 'description', 'بالعربية', 'الوصÙ', 'البند', 'بيان'],\n 'unit' => ['Ø§Ù„ÙˆØØ¯Ø©', 'unit', 'uom', 'u/m'],\n 'quantity' => ['الكمية', 'qty', 'quantity', 'q.ty'],\n 'unit_price' => ['سعر Ø§Ù„ÙˆØØ¯Ø©', 'u/price', 'unit price', 'unit rate', 'rate', 'price'],\n 'total_price' => ['السعر الإجمالي', 'total amount', 'total price', 'amount', 'الإجمالي'],\n ];\n\n $best = null;\n $bestScore = 0;\n\n for ($r = 0; $r < min(30, count($grid)); $r++) {\n $map = [];\n $score = 0;\n\n foreach ($grid[$r] as $c => $value) {\n $cell = mb_strtolower(trim((string) $value));\n if ($cell === '') {\n continue;\n }\n\n foreach ($keywords as $field => $list) {\n foreach ($list as $kw) {\n if (str_contains($cell, mb_strtolower($kw))) {\n if (! isset($map[$field])) {\n $map[$field] = $c;\n $score++;\n }\n break 2;\n }\n }\n }\n }\n\n if ($score > $bestScore) {\n $bestScore = $score;\n $best = ['row' => $r, 'map' => $map];\n }\n }\n\n if ($bestScore >= 3) {\n return $best;\n }\n\n // Allow simpler BOQ sheets that only define a description column plus one\n // other BOQ-related column, e.g. Item + Description or Description + Qty.\n if ($bestScore === 2 && isset($best['map']['description']) && (\n isset($best['map']['item_code']) ||\n isset($best['map']['quantity']) ||\n isset($best['map']['unit'])\n )) {\n return $best;\n }\n\n return null;\n }\n\n private function cell(array $row, ?int $index): string\n {\n if ($index === null) {\n return '';\n }\n return trim((string) ($row[$index] ?? ''));\n }\n\n private function number(string $value): ?float\n {\n $value = trim(str_replace([',', 'SAR', 'ر.س'], '', $value));\n if ($value === '' || ! is_numeric($value)) {\n return null;\n }\n return (float) $value;\n }\n\n private function trimTrailingEmptyCells(array $row): array\n {\n while (! empty($row) && trim((string) end($row)) === '') {\n array_pop($row);\n }\n return array_values($row);\n }\n\n private function isTotalOrHeaderRow(string $description): bool\n {\n $d = mb_strtolower(trim($description));\n\n if ($d === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $d)) {\n return true;\n }\n\n // Section totals / summaries\n if (preg_match('/(اجمالي|إجمالي|المجموع|grand\\s*total|sub\\s*total|total\\s*amount|total\\s*price)/iu', $d)) {\n return true;\n }\n\n // Bare \"Total\" lines, optionally with a currency suffix, e.g. \"Total\",\n // \"Total (SAR)\", \"Total cost\", \"Total (SAR) unit cost\".\n if (preg_match('/^total\\b[\\s\\(\\):a-z]*$/iu', $d)) {\n return true;\n }\n\n // Pure floor / level labels used as section headers\n if (preg_match('/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor)\\s*$/iu', $d)) {\n return true;\n }\n\n // Division / section header lines (e.g. \"Division - 02: Wood Works\")\n if (preg_match('/^division\\s*([-:]|\\d)/iu', $d)) {\n return true;\n }\n\n return false;\n }\n\n /**\n * Detect dedicated summary / cost-abstract sheets by their name. These hold\n * per-discipline rollups and grand totals (e.g. a \"Summary (Dry Cost)\" tab),\n * never procurable supply items, so they are skipped wholesale.\n */\n private function isSummarySheet(string $name): bool\n {\n $n = mb_strtolower(trim($name));\n\n if ($n === '') {\n return false;\n }\n\n return (bool) preg_match(\n '/\\b(summary|abstract|cost\\s*summary|grand\\s*summary|recap(?:itulation)?|bill\\s*summary|boq\\s*summary|dry\\s*cost)\\b|ملخص|الملخص|الملخّص|ملخّص|اجمالي|إجمالي/iu',\n $n\n );\n }\n\n private function isFloorBreakdownRow(string $itemNo, string $description): bool\n {\n // A row whose Ref column is empty or a dash is a sub-breakdown row (per-floor split).\n $ref = trim($itemNo);\n if ($ref === '' || $ref === '-') {\n // Confirm by checking the description is a location/floor label\n $d = mb_strtolower(trim($description));\n return (bool) preg_match(\n '/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor|\\d+f)\\s*$/iu',\n $d\n );\n }\n return false;\n }\n\n private function rejectedRow(string $description, string $unit, ?float $quantity, string $reason, string $itemNo = '', string $sheet = ''): array\n {\n return [\n 'description' => $description,\n 'quantity' => $quantity ?? 0,\n 'unit' => $unit,\n 'category' => $sheet,\n 'brand' => '',\n 'status' => 'rejected',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 0.0,\n 'raw_data' => [\n 'sheet' => $sheet,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'extraction_type' => 'rejected',\n 'rejection_reason' => $reason,\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function parseBoqWithDeepSeek(UploadedFile|string $file, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured. Please set DEEPSEEK_API_KEY in .env file.');\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n $mime = $this->mimeForExtension($ext);\n\n try {\n // ── Spreadsheets ──────────────────────────────────────────────────\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n $text = $this->spreadsheetToCompactCsvText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not convert spreadsheet to readable text.');\n }\n $text = $this->sanitizeUtf8(mb_substr($text, 0, 180000));\n $userContent = \"BOQ spreadsheet converted to CSV:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── PDF ───────────────────────────────────────────────────────────\n if ($ext === 'pdf') {\n $extracted = $this->extractPdfText($absPath);\n if ($extracted !== null && mb_strlen(trim($extracted)) > 50) {\n $extracted = $this->sanitizeUtf8(mb_substr($extracted, 0, 180000));\n $userContent = \"BOQ PDF extracted text:\\n\\n\" . $extracted . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'application/pdf');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n return $this->failure('Could not extract text from the PDF. Please make sure it is a text-based PDF (not a scanned image), or convert it to Excel or CSV.');\n }\n\n // -- Word documents (docx) ---------------------------------------------------\n if (in_array($ext, ['docx', 'doc'], true)) {\n $text = $this->extractDocxText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not extract text from the Word document. Please convert it to Excel, CSV, or PDF.');\n }\n $text = $this->sanitizeUtf8(mb_substr($text, 0, 180000));\n $userContent = \"BOQ Word document extracted text:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── Images ──────────────────────────────────────────────────────────────\n // DeepSeek cannot receive images. A vision-capable AI processes the image\n // directly (no OCR) — configure GROQ_API_KEY or GEMINI_API_KEY in .env.\n $imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif', 'heic', 'heif'];\n if (in_array($ext, $imageExts, true)) {\n $bytes = (string) file_get_contents($absPath);\n return $this->callDeepSeekVision($bytes, $mime, $context);\n }\n\n return $this->failure('Unsupported file type. Please upload an Excel, CSV, PDF, or image file.');\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: DeepSeek fallback failed.', ['message' => $e->getMessage()]);\n return $this->failure('An error occurred while processing the file with AI: ' . $e->getMessage());\n }\n }\n\n private function deepSeekModel(): string\n {\n return (string) config('services.deepseek.model', 'deepseek-chat');\n }\n\n /**\n * Remove or replace any non-UTF-8 / malformed byte sequences so json_encode never fails.\n */\n private function sanitizeUtf8(string $text): string\n {\n // Replace invalid byte sequences with the UTF-8 replacement character\n $clean = mb_convert_encoding($text, 'UTF-8', 'UTF-8');\n // Strip any remaining non-printable control characters (except tab/newline/CR)\n $clean = (string) preg_replace('/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]/u', '', $clean);\n return $clean;\n }\n\n /**\n * Extract plain text from a Word docx file using ZipArchive (no external library needed).\n * Reads word/document.xml and strips XML tags, preserving paragraph and table structure.\n */\n private function extractDocxText(string $absPath): ?string\n {\n try {\n $zip = new \\ZipArchive();\n if ($zip->open($absPath) !== true) {\n return null;\n }\n\n $xml = $zip->getFromName('word/document.xml');\n $zip->close();\n\n if ($xml === false || $xml === '') {\n return null;\n }\n\n // Add newlines before paragraph/table-row/table-cell tags so structure is preserved\n $xml = preg_replace('/<w:p[ >]/', \"\\n<w:p>\", $xml) ?? $xml;\n $xml = preg_replace('/<w:tr[ >]/', \"\\n<w:tr>\", $xml) ?? $xml;\n $xml = preg_replace('/<\\/w:tc>/', \"\\t\", $xml) ?? $xml;\n\n $text = strip_tags($xml);\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n return trim($text) !== '' ? trim($text) : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: DOCX parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Extract plain text from a PDF using smalot/pdfparser (pure PHP, no binary required).\n */\n private function extractPdfText(string $absPath): ?string\n {\n try {\n $parser = new \\Smalot\\PdfParser\\Parser();\n $pdf = $parser->parseFile($absPath);\n $text = $pdf->getText();\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n return $text !== '' ? $text : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: PDF parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Send a vision request to a vision-capable AI model.\n * DeepSeek's commercial API (api.deepseek.com) does NOT support image inputs — any model.\n * Priority order (first configured key wins):\n * 1. GROQ_API_KEY → Groq llama-4-scout (free: https://console.groq.com)\n * 2. GEMINI_API_KEY → Google Gemini Flash (free: https://aistudio.google.com/apikey)\n * 3. VISION_API_KEY → any OpenAI-compatible endpoint (OpenAI, OpenRouter …)\n */\n private function callDeepSeekVision(string $bytes, string $mime, array $context): array\n {\n // ── 1. Groq (free, reliable vision) ─────────────────────────────────────\n if ((string) config('services.groq.key', '') !== '') {\n $visionKey = (string) config('services.groq.key');\n $visionBaseUrl = 'https://api.groq.com/openai/v1';\n $visionModel = (string) config('services.groq.model', 'meta-llama/llama-4-scout-17b-16e-instruct');\n }\n // ── 2. Google Gemini ─────────────────────────────────────────────────────\n elseif ((string) config('services.gemini.key', '') !== '') {\n $visionKey = (string) config('services.gemini.key');\n $visionBaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai';\n $visionModel = (string) config('services.gemini.model', 'gemini-2.0-flash');\n }\n // ── 3. Generic VISION_API_KEY (OpenAI, OpenRouter, …) ───────────────────\n elseif ((string) config('services.vision.key', '') !== '') {\n $visionKey = (string) config('services.vision.key');\n $visionBaseUrl = rtrim((string) config('services.vision.base_url', 'https://openrouter.ai/api/v1'), '/');\n $visionModel = (string) config('services.vision.model', 'google/gemini-flash-1.5-8b');\n }\n else {\n return $this->failure(\n 'Image processing is not available. DeepSeek\\'s API does not support images. ' .\n 'Get a free key at https://console.groq.com and add GROQ_API_KEY to your .env file.'\n );\n }\n\n $dataUrl = \"data:{$mime};base64,\" . base64_encode($bytes);\n $prompt = $this->buildDeepSeekPrompt($context, $mime);\n\n $userContent = [\n ['type' => 'text', 'text' => $prompt],\n ['type' => 'image_url', 'image_url' => ['url' => $dataUrl]],\n ];\n\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders([\n 'Authorization' => 'Bearer ' . $visionKey,\n 'HTTP-Referer' => config('app.url', 'https://qimta.sa'),\n 'X-Title' => 'Qimta Platform',\n ])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post($visionBaseUrl . '/chat/completions', [\n 'model' => $visionModel,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 16384,\n 'temperature' => 0.2,\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('Vision API connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: Vision API HTTP error.', [\n 'model' => $visionModel,\n 'base_url' => $visionBaseUrl,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n return $this->failure(\"Vision API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n return $this->parseDeepSeekJsonResponse((string) $response->json('choices.0.message.content'));\n }\n\n private function callDeepSeekChat(string $userContent, string $key, string $model): array\n {\n // Ensure the PHP script outlives the HTTP call (+ a margin for parsing the\n // response and writing to the DB) so the worker/web request is not killed\n // mid-flight, which surfaces to users as a generic \"timeout or unavailable\".\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders(['Authorization' => 'Bearer ' . $key])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 32768,\n 'temperature' => 0.1,\n 'response_format' => ['type' => 'json_object'],\n 'user' => 'Qimta_Platform',\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('DeepSeek connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: DeepSeek HTTP error.', [\n 'model' => $model,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n if ($response->status() === 401) {\n return $this->failure('DeepSeek API key is invalid or unauthorised. Please update DEEPSEEK_API_KEY in .env.', false);\n }\n return $this->failure(\"DeepSeek API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n $text = (string) $response->json('choices.0.message.content');\n return $this->parseDeepSeekJsonResponse($text);\n }\n\n /**\n * Shared JSON response parser used by both chat and vision paths.\n */\n private function parseDeepSeekJsonResponse(string $text): array\n {\n if ($text === '') {\n return $this->failure('DeepSeek returned an empty response.');\n }\n\n // Strip markdown code fences if the model wrapped the JSON.\n if (preg_match('/```(?:json)?\\s*([\\s\\S]*?)\\s*```/i', $text, $m)) {\n $text = $m[1];\n }\n // Narrow to the outermost JSON object/array.\n if (preg_match('/[\\{\\[][\\s\\S]*[\\}\\]]/s', $text, $m)) {\n $text = $m[0];\n }\n $text = trim($text);\n\n $decoded = json_decode($text, true);\n\n // The response can be truncated when the model hits the token limit,\n // leaving an unterminated JSON string/object. Try to repair it before\n // giving up so we can still salvage the items extracted so far.\n if (! is_array($decoded)) {\n $repaired = $this->repairTruncatedJson($text);\n if ($repaired !== null) {\n $decoded = json_decode($repaired, true);\n }\n }\n\n if (! is_array($decoded)) {\n Log::error('QuotationAiService: DeepSeek non-JSON response.', ['text' => mb_substr($text, 0, 1000)]);\n return $this->failure('DeepSeek returned a non-JSON response. The file may be too large — try splitting it into smaller files.');\n }\n\n $rawItems = $decoded['items'] ?? (array_is_list($decoded) ? $decoded : []);\n $items = [];\n $rejected = [];\n\n foreach ($rawItems as $raw) {\n if (! is_array($raw)) {\n continue;\n }\n $item = $this->normaliseAiItem($raw);\n if ($item === null) {\n continue;\n }\n $check = $this->boqCleaner->filterItem((string) $item['description']);\n if ($check['keep'] ?? false) {\n $items[] = $item;\n } else {\n $item['status'] = 'rejected';\n $item['raw_data']['rejection_reason'] = $check['rejection_reason'] ?? 'Rejected by cleaner';\n $rejected[] = $item;\n }\n }\n\n if (empty($items)) {\n Log::info('QuotationAiService: DeepSeek returned 0 accepted items.', [\n 'raw_items_count' => count($rawItems),\n 'rejected_count' => count($rejected),\n 'first_rejected' => ! empty($rejected) ? ($rejected[0]['description'] ?? '') : '',\n ]);\n }\n\n return ['success' => true, 'items' => $items, 'rejected' => $rejected, 'error' => null];\n }\n\n /**\n * Best-effort repair of a truncated JSON string produced when the model hits\n * its token limit mid-output. Drops any incomplete trailing object, then\n * closes the open string/array/object brackets so the result is valid JSON.\n *\n * Returns the repaired JSON string, or null if it cannot be salvaged.\n */\n private function repairTruncatedJson(string $text): ?string\n {\n if ($text === '') {\n return null;\n }\n\n // Walk the string tracking structural state, ignoring everything inside\n // string literals (and their escapes). Remember the last position where\n // we were back at the top of the \"items\" array between complete elements,\n // so we can cut a half-written trailing object.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($text);\n $lastSafeCut = null; // index just after a completed array element\n\n for ($i = 0; $i < $len; $i++) {\n $ch = $text[$i];\n\n if ($inString) {\n if ($escaped) {\n $escaped = false;\n } elseif ($ch === '\\\\') {\n $escaped = true;\n } elseif ($ch === '\"') {\n $inString = false;\n }\n continue;\n }\n\n if ($ch === '\"') {\n $inString = true;\n } elseif ($ch === '{' || $ch === '[') {\n $stack[] = $ch;\n } elseif ($ch === '}' || $ch === ']') {\n array_pop($stack);\n } elseif ($ch === ',' && ! empty($stack) && end($stack) === '[') {\n // Separator directly inside an array: a clean boundary between\n // elements (the items array, whatever its nesting depth).\n $lastSafeCut = $i;\n }\n }\n\n // If we are still inside a string, the last element is incomplete — fall\n // back to the last clean array boundary if we have one.\n $candidate = $text;\n if ($inString && $lastSafeCut !== null) {\n $candidate = substr($text, 0, $lastSafeCut);\n } else {\n // Drop a dangling partial object after the last completed element.\n $candidate = rtrim($candidate);\n if ($lastSafeCut !== null && substr(rtrim($candidate), -1) !== '}' && substr(rtrim($candidate), -1) !== ']') {\n $candidate = substr($text, 0, $lastSafeCut);\n }\n }\n\n // Recompute the open brackets for the (possibly trimmed) candidate and\n // append the matching closers in reverse order.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($candidate);\n for ($i = 0; $i < $len; $i++) {\n $ch = $candidate[$i];\n if ($inString) {\n if ($escaped) { $escaped = false; }\n elseif ($ch === '\\\\') { $escaped = true; }\n elseif ($ch === '\"') { $inString = false; }\n continue;\n }\n if ($ch === '\"') { $inString = true; }\n elseif ($ch === '{' || $ch === '[') { $stack[] = $ch; }\n elseif ($ch === '}' || $ch === ']') { array_pop($stack); }\n }\n\n $candidate = rtrim($candidate);\n // Remove a trailing comma left after cutting an element.\n $candidate = rtrim($candidate, ',');\n\n $closers = '';\n for ($i = count($stack) - 1; $i >= 0; $i--) {\n $closers .= ($stack[$i] === '{') ? '}' : ']';\n }\n\n $repaired = $candidate . $closers;\n\n // Validate before returning.\n return is_array(json_decode($repaired, true)) ? $repaired : null;\n }\n\n private function spreadsheetToCompactCsvText(string $absPath): ?string\n {\n $sheets = $this->spreadsheetToGrids($absPath);\n if (empty($sheets)) {\n return null;\n }\n\n $output = '';\n foreach ($sheets as $sheet) {\n // Don't feed summary / cost-abstract sheets to the AI either.\n if ($this->isSummarySheet($sheet['name'])) {\n continue;\n }\n $output .= 'Sheet: ' . $sheet['name'] . \"\\n\";\n foreach ($sheet['grid'] as $row) {\n $row = $this->trimTrailingEmptyCells($row);\n if (empty($row)) {\n continue;\n }\n $output .= implode(',', array_map([$this, 'csvEscape'], $row)) . \"\\n\";\n }\n $output .= \"\\n\";\n }\n\n return $output;\n }\n\n /**\n * Safely convert a PhpSpreadsheet cell value to a string.\n *\n * Cells may contain RichText or, in malformed/heavily-formatted files,\n * embedded objects such as Worksheet\\Drawing that have no __toString().\n * Casting those directly throws \"Object ... could not be converted to string\".\n */\n private function cellValueToString(mixed $value): string\n {\n if ($value === null) {\n return '';\n }\n\n if ($value instanceof \\PhpOffice\\PhpSpreadsheet\\RichText\\RichText) {\n return $value->getPlainText();\n }\n\n if (is_object($value)) {\n // Only stringify objects that explicitly support it; ignore the rest\n // (e.g. Worksheet\\Drawing) instead of crashing.\n return method_exists($value, '__toString') ? (string) $value : '';\n }\n\n if (is_array($value)) {\n return '';\n }\n\n return (string) $value;\n }\n\n private function csvEscape(mixed $value): string\n {\n $value = $this->cellValueToString($value);\n return str_contains($value, ',') || str_contains($value, '\"') || str_contains($value, \"\\n\")\n ? '\"' . str_replace('\"', '\"\"', $value) . '\"'\n : $value;\n }\n\n private function normaliseAiItem(array $raw): ?array\n {\n if (! empty($raw['rejected'])) {\n return null;\n }\n\n $description = trim((string) ($raw['cleaned_description'] ?? $raw['product_name'] ?? $raw['description'] ?? ''));\n if ($description === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $description)) {\n return null;\n }\n\n $quantity = is_numeric($raw['quantity'] ?? null) ? (float) $raw['quantity'] : 1;\n $unitPrice = is_numeric($raw['unit_price'] ?? null) ? (float) $raw['unit_price'] : null;\n $totalPrice = is_numeric($raw['total_price'] ?? null) ? (float) $raw['total_price'] : null;\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $specs = is_array($raw['specifications'] ?? null) ? $raw['specifications'] : [];\n\n return [\n 'description' => $description,\n 'quantity' => $quantity,\n 'unit' => (string) ($raw['unit'] ?? ''),\n 'category' => (string) ($raw['core_product'] ?? $raw['category'] ?? ''),\n 'brand' => (string) ($specs['brand'] ?? $raw['brand'] ?? ''),\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($description),\n 'unit_price' => $unitPrice,\n 'confidence' => is_numeric($raw['confidence'] ?? null) ? (float) $raw['confidence'] : null,\n 'needs_review' => ! empty($raw['needs_review']),\n 'raw_data' => [\n 'source_item_no' => (string) ($raw['source_item_no'] ?? ''),\n 'original_description' => (string) ($raw['original_description'] ?? $description),\n 'cleaned_description' => $description,\n 'core_product' => (string) ($raw['core_product'] ?? ''),\n 'specifications' => $specs,\n 'extraction_type' => (string) ($raw['extraction_type'] ?? 'supply_only'),\n 'note' => (string) ($raw['note'] ?? $raw['notes'] ?? ''),\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function buildSystemInstruction(): string\n {\n return <<<'TXT'\nYou are a strict BOQ Supply Product Extraction Engine.\nExtract ONLY concrete, tangible, physical products that a supplier can box up and deliver to site — a specific item you could point at and say \"this is the thing being bought\" (e.g. \"Wall mounted LED luminaire 36W\", \"PVC pipe 110mm\", \"Single phase distribution board 12-way\", \"Fire extinguisher CO2 6kg\", \"CCTV dome camera 4MP\").\n\nABSOLUTELY REJECT abstract concepts, disciplines, categories, section names, and trade headings. These are NOT products even though they appear as rows:\n- Bare discipline/trade words: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", \"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\".\n- Summary / total / abstract / \"dry cost\" rows of any kind.\n- Anything that names a system, scope, or category instead of one buyable item.\n\nRule of thumb: if you cannot name a unit and physically hand the item over (a piece, a box, a length, a unit of equipment), REJECT it. A word that describes a field of work, a system, or a section of the BOQ is NEVER a product.\nReject totals, headings, general requirements, installation-only, testing, commissioning, supervision, mobilization, civil works, labor, and vague rows.\nFor Supply & Install rows, keep only the supply product and remove install/labor wording.\nReturn only valid JSON: {\"items\":[{\"source_item_no\":null,\"original_description\":\"\",\"cleaned_description\":\"\",\"core_product\":\"\",\"specifications\":{},\"quantity\":0,\"unit\":\"\",\"unit_price\":0,\"total_price\":0,\"extraction_type\":\"supply_only\",\"needs_review\":false,\"note\":\"\",\"rejected\":false,\"rejection_reason\":null,\"confidence\":0.95}]}\nTXT;\n }\n\n private function buildDeepSeekPrompt(array $context = [], string $mime = ''): string\n {\n $projectName = (string) ($context['project_name'] ?? '');\n $projectStatus = (string) ($context['project_status'] ?? '');\n $contextLine = $projectName !== ''\n ? \"Project: {$projectName}\" . ($projectStatus !== '' ? \" ({$projectStatus})\" : '')\n : '';\n $sourceHint = str_starts_with($mime, 'image/')\n ? 'The input is an image of a BOQ table. OCR every cell carefully — read the quantity/count column (العدد, الكمية, Qty) and extract the EXACT numeric value from each row.'\n : 'The input is a BOQ document/spreadsheet.';\n\n return trim(<<<PROMPT\nYou are a procurement specialist. Your task is to extract ONLY tangible, physical supply products from this Bill of Quantities (BOQ).\n\n{$contextLine}\n{$sourceHint}\n\nCRITICAL — MERGE WRAPPED / SPLIT ROWS INTO ONE ITEM:\nBOQ tables extracted from PDF often break ONE item's long description across several\nlines/rows. These continuation lines repeat the SAME quantity, unit and category, and\ntheir text is a grammatical continuation of the previous line (it starts mid-sentence,\nlowercase, or with \"the/and/-\", or is a spec/instruction clause). You MUST reconstruct\nthe original single item:\n- Treat consecutive rows that share the SAME quantity + unit + category as ONE product\n when the later rows are clearly continuation text of the first, NOT new products.\n- Emit ONE item with the merged description and the price counted ONCE. Do NOT repeat\n the same quantity×price for every wrapped line — that multiplies the total wrongly.\n- Example: rows \"The asphalt flooring is to be supplied\" / \"installed in accordance\" /\n \"standard road specifications...\" all with 7,100 m2 are ONE asphalt item, not 3+.\n\nCRITICAL — UNDERSTAND INTENT, DO NOT COPY ROWS LITERALLY:\nYour job is to output WHAT A SUPPLIER MUST ACTUALLY DELIVER, not a copy of the row text.\nRead each description for MEANING and decide how many real, separately-buyable products it\nimplies. One BOQ row can imply SEVERAL products, or a spread of rows can imply ONE product.\n- SPLIT a row into multiple items when it lists distinct products that a supplier would\n buy/price separately — even when joined by commas, \"with\", \"and\", \"including\", \"c/w\",\n \"consisting of\", \"comprising\", or \":\" then a list.\n Example: \"Fire pump set consisting of: fire pump, electric motor, control panel, diesel\n engine\" → FOUR items: (1) fire pump, (2) electric motor, (3) control panel, (4) diesel\n engine — each with its own quantity and priced on its own.\n Example: \"Wash basin complete with mixer tap and bottle trap\" → THREE items: wash basin,\n mixer tap, bottle trap.\n- Give each split product the correct quantity implied by the text (usually the parent\n quantity, unless the text states a different count for a sub-item). Each product is\n priced individually; the line total becomes the SUM of its products.\n- DO NOT split when the extra words are just specifications, dimensions, colours, ratings,\n standards, or a single product's integral parts. \"Steel door 900x2100 with vision panel\n and SS ironmongery\" is usually ONE door product — vision panel and ironmongery are part\n of that door, not separately-bought items. Use judgement: would a supplier issue a\n separate price line for it? If no, keep it as one.\n- Balanced approach: split only when genuinely separate products are present; when in doubt\n about whether something is a separate product vs. an accessory/spec of the main one, keep\n it as a single item. Do not invent products that are not in the text.\n\nSTRICT RULES — include ONLY items that are ALL of the following:\n1. A specific, concrete, tangible product that can be boxed up by a supplier and delivered to site — something you can point at and say \"this exact thing is being bought\" (e.g. \"LED luminaire 36W\", \"PVC pipe 110mm\", \"Distribution board 12-way\", \"CO2 fire extinguisher 6kg\").\n2. Has a real quantity (not 0 or \"varies\" with no meaning).\n3. Has enough description to identify what the product is.\n4. Is an actual PRODUCT, not a sentence, instruction, or specification clause. REJECT rows that are prose such as \"the supervising engineer's instructions\", \"The price includes all materials, labor\", \"Measurement will be based on the geometric cubic meter\", \"necessary steps to complete the work\", \"soil-contacting elements\", \"in compliance\", \"everything necessary\". These are description fragments, not buyable items.\n\nNEVER extract abstract concepts, disciplines, trades, systems, or section/category names — they are NOT products even when they appear as their own row:\n- Bare discipline/trade/system words such as: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", and their Arabic equivalents (\"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\", \"تشطيبات\").\n- Test: if the word names a FIELD OF WORK or a SYSTEM rather than one buyable item with a unit (pcs, m, set, kg…), REJECT it.\n\nEXCLUDE all of the following (do NOT include them):\n- Labor, installation, or workmanship items (e.g. \"install\", \"fix\", \"erect\", \"dismantle\", \"painting\", \"cleaning service\")\n- Project management, overhead, or profit items\n- Insurance, bonds, liability items (e.g. \"bodily injury\", \"third party liability\", \"CAR insurance\")\n- Shop drawings, as-built drawings, method statements, documentation\n- Preliminary items, mobilisation, demobilisation, provisional sums\n- Housekeeping, site clearance, waste removal\n- Floor/level labels (e.g. \"GF Floor\", \"Level 3\") — these are headers, not products\n- Pure dimension specs without a product noun (e.g. \"Dia 32 mm\", \"25 mm\") — reject unless a product name is present\n- Security services, night shifts, rentals, payments\n- Any item whose description is too vague or fragmentary to identify a specific product\n\nEmit ONE JSON item PER real product. A single source row may therefore produce several\nitems (when it lists multiple products) or several rows may collapse into one item (when\nthey are wrapped fragments of the same product). The \"items\" array is your final delivery\nlist, not a row-by-row transcript.\n\nFor each valid physical product return JSON with:\n- description: clean product name only (strip \"supply and install\", \"supply and fix\", installation clauses, floor/spec suffixes). For a split product, use just that product's own name.\n- quantity: the EXACT numeric value from the quantity/count column (العدد, الكمية, Qty, Quantity, No.). NEVER default to 1 unless the quantity column is truly absent or empty.\n- unit: unit of measure (pcs, m, m2, m3, set, no, kg, etc.)\n- category: product category\n- brand: brand/manufacturer if mentioned, else \"\"\n- engineering_required: true if the item needs engineering design before procurement (panel boards, chillers, generators, fire suppression systems, BMS, pumps, AHUs, switchgear, transformers), else false\n\nReturn ONLY a valid JSON object: {\"items\": [...]}. No explanations, no markdown, no extra text.\nPROMPT);\n }\n\n private function mockResponse(): array\n {\n return [\n 'success' => true,\n 'error' => null,\n 'items' => [[\n 'description' => 'Sample BOQ Item',\n 'quantity' => 1,\n 'unit' => 'No',\n 'category' => 'Sample',\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 1,\n 'raw_data' => [],\n 'ai_extracted' => true,\n ]],\n 'rejected' => [],\n ];\n }\n\n private function failure(string $message, bool $serviceUnavailable = false): array\n {\n return [\n 'success' => false,\n 'items' => [],\n 'rejected' => [],\n 'error' => $this->userFacingError($message, $serviceUnavailable),\n 'service_unavailable' => $serviceUnavailable,\n ];\n }\n\n /**\n * Translate any internal error into a neutral, user-safe message.\n *\n * The user must never see the name of the underlying AI/processing engine\n * (DeepSeek, Groq, Gemini, Vision, etc.) or configuration hints like API\n * keys. The detailed message is kept in the logs for debugging only.\n */\n private function userFacingError(string $message, bool $serviceUnavailable = false): string\n {\n // Detect any reference to an AI provider / engine / API config so we can\n // swap it for a generic, branded-neutral message.\n $sensitive = '/deepseek|groq|gemini|vision|openai|openrouter|api[\\s_-]?key|\\.env|non-?json|token limit/i';\n\n if (preg_match($sensitive, $message)) {\n Log::warning('QuotationAiService: suppressed technical error from user.', ['internal' => $message]);\n\n return $serviceUnavailable\n ? 'The file could not be processed right now. Please try again in a moment.'\n : 'We could not read this file automatically. Please make sure it is a clear BOQ (Excel, CSV, PDF, or image) and try again, or add the items manually.';\n }\n\n return $message;\n }\n\n private function mimeForExtension(string $ext): string\n {\n return match (strtolower($ext)) {\n 'pdf' => 'application/pdf',\n 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n 'xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12',\n 'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',\n 'xls' => 'application/vnd.ms-excel',\n 'csv' => 'text/csv',\n 'jpg', 'jpeg' => 'image/jpeg',\n 'png' => 'image/png',\n 'gif' => 'image/gif',\n 'bmp' => 'image/bmp',\n 'webp' => 'image/webp',\n 'tiff', 'tif' => 'image/tiff',\n 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'doc' => 'application/msword',\n default => 'application/octet-stream',\n };\n }\n}\n"}, "5757a4820f53b92f9f21a773fd1274172273958b": {"t": 1785748385.0312154, "n": 56870, "h": "4a796ac12fc9c052", "c": "<?php\n\nnamespace App\\Services;\n\nuse Illuminate\\Http\\Client\\ConnectionException;\nuse Illuminate\\Http\\UploadedFile;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\nuse PhpOffice\\PhpSpreadsheet\\Cell\\Coordinate;\nuse PhpOffice\\PhpSpreadsheet\\IOFactory;\n\nclass QuotationAiService\n{\n /** Increment this whenever BOQ parsing logic changes to invalidate old caches. */\n private const PARSER_VERSION = 7;\n\n private string $baseUrl;\n private string $parseEndpoint;\n private string $apiKey;\n private int $timeout;\n private bool $testMode;\n private BoqCleaningService $boqCleaner;\n\n public function __construct(BoqCleaningService $boqCleaner)\n {\n $this->boqCleaner = $boqCleaner;\n $this->baseUrl = rtrim((string) config('services.ai_quotation.base_url', ''), '/');\n $this->parseEndpoint = ltrim((string) config('services.ai_quotation.parse_endpoint', 'parse'), '/');\n $this->apiKey = (string) config('services.ai_quotation.api_key', '');\n $this->timeout = (int) config('services.ai_quotation.timeout', 300);\n $this->testMode = (bool) config('services.ai_quotation.test_mode', false);\n }\n\n /**\n * Give the running script enough head-room to outlast the AI HTTP call.\n *\n * The web request / queue worker must not be killed before the HTTP timeout\n * completes, otherwise the user sees a generic \"timeout or unavailable\"\n * error even though the AI may have responded. We allow the HTTP timeout\n * plus a 120s margin for connecting, response parsing and DB writes.\n */\n private function extendExecutionTime(): void\n {\n // Ignored on some SAPIs (e.g. when running under fastcgi with a hard\n // limit), but harmless to attempt.\n @set_time_limit($this->timeout + 120);\n }\n\n public function parseBoq(UploadedFile|string $file, array $context = []): array\n {\n if ($this->testMode) {\n return $this->mockResponse();\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n\n if (! is_file($absPath)) {\n return $this->failure('File not found or not readable.');\n }\n\n $fileHash = hash_file('sha256', $absPath);\n // Bump PARSER_VERSION whenever the extraction logic changes so that\n // previously-uploaded files are re-parsed instead of returning a stale\n // cached result produced by the old code.\n $cacheKey = 'boq_analysis_v' . self::PARSER_VERSION . '_' . $fileHash;\n $forceRefresh = (bool) ($context['force_refresh'] ?? false);\n\n if (! $forceRefresh && ($cached = Cache::get($cacheKey)) !== null) {\n return $cached;\n }\n\n // Clear stale cached result when force-refreshing\n if ($forceRefresh) {\n Cache::forget($cacheKey);\n }\n\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n // Read Excel locally first. Fall back to AI if local parse failed,\n // found 0 items, OR could not read one of the sheets (so multi-sheet\n // files never silently drop a sheet the local parser didn't understand).\n $result = $this->parseSpreadsheetDirect($absPath);\n\n $hasSkippedSheets = ! empty($result['skipped_sheets']);\n\n if (! $result['success'] || empty($result['items']) || $hasSkippedSheets) {\n $deepSeekResult = $this->parseBoqWithDeepSeek($file, $context);\n // AI sees every sheet at once, so prefer its result whenever it\n // actually returned items (covers the skipped-sheet case too).\n if ($deepSeekResult['success'] && ! empty($deepSeekResult['items'])) {\n // Keep the larger of the two item sets so we never lose the\n // rows the local parser already extracted correctly.\n if (count($deepSeekResult['items']) >= count($result['items'] ?? [])) {\n $result = $deepSeekResult;\n }\n } elseif (! $result['success']) {\n $result = $deepSeekResult;\n }\n // else: keep local result if AI also failed/returned nothing\n }\n } elseif (in_array($ext, ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif'], true)) {\n $result = $this->parseBoqWithDeepSeek($file, $context);\n } else {\n return $this->failure('Unsupported file type. Please upload Excel, CSV, PDF, or image file.');\n }\n\n if ($result['success'] && ! empty($result['items'])) {\n $cleaning = $this->boqCleaner->process($result['items']);\n $result['items'] = $cleaning['accepted'] ?? $result['items'];\n $result['rejected'] = array_merge($result['rejected'] ?? [], $cleaning['rejected'] ?? []);\n }\n\n // Only cache when we actually extracted items; empty results should be re-tried.\n if ($result['success'] && ! empty($result['items'])) {\n Cache::put($cacheKey, $result, now()->addDays(30));\n }\n\n return $result;\n }\n\n private function resolveFile(UploadedFile|string $file): array\n {\n if ($file instanceof UploadedFile) {\n return [(string) $file->getRealPath(), strtolower($file->getClientOriginalExtension())];\n }\n\n $absPath = is_file($file) ? $file : storage_path('app/' . ltrim($file, '/'));\n return [$absPath, strtolower(pathinfo($absPath, PATHINFO_EXTENSION))];\n }\n\n private function parseSpreadsheetDirect(string $absPath): array\n {\n ini_set('memory_limit', '1024M');\n set_time_limit(180);\n\n try {\n $sheets = $this->spreadsheetToGrids($absPath);\n\n if (empty($sheets)) {\n return $this->failure('Could not read the Excel file.');\n }\n\n $items = [];\n $rejected = [];\n $skippedSheets = []; // sheets we could not parse locally (header undetected)\n\n foreach ($sheets as $sheet) {\n $grid = $sheet['grid'];\n if (count($grid) < 2) {\n continue;\n }\n\n // Skip dedicated summary / cost-abstract sheets entirely. Their rows\n // are per-discipline totals (Structural Concrete, Electrical, …) and\n // grand totals, not procurable products.\n if ($this->isSummarySheet($sheet['name'])) {\n Log::info('QuotationAiService: skipped summary sheet.', ['sheet' => $sheet['name']]);\n continue;\n }\n\n $header = $this->detectHeader($grid);\n\n if ($header === null || ! isset($header['map']['description'])) {\n Log::warning('QuotationAiService: header not detected in sheet.', ['sheet' => $sheet['name']]);\n $skippedSheets[] = $sheet['name'];\n continue;\n }\n\n $map = $header['map'];\n $start = $header['row'] + 1;\n\n for ($r = $start; $r < count($grid); $r++) {\n $row = $grid[$r];\n $description = $this->cell($row, $map['description'] ?? null);\n $itemNo = $this->cell($row, $map['item_code'] ?? null);\n $unit = $this->cell($row, $map['unit'] ?? null);\n $quantity = $this->number($this->cell($row, $map['quantity'] ?? null));\n $unitPrice = $this->number($this->cell($row, $map['unit_price'] ?? null));\n $totalPrice = $this->number($this->cell($row, $map['total_price'] ?? null));\n\n if ($description === '') {\n continue;\n }\n\n if (! isset($map['quantity'])) {\n $quantity = 1.0;\n }\n\n // Skip floor breakdown sub-rows: Ref. is \"-\" meaning it's a per-floor\n // quantity split of the parent item, not a standalone product.\n if ($itemNo === '-' || $this->isFloorBreakdownRow($itemNo, $description)) {\n continue;\n }\n\n if ($this->isTotalOrHeaderRow($description)) {\n continue;\n }\n\n if ($quantity === null || $quantity <= 0) {\n $rejected[] = $this->rejectedRow($description, $unit, $quantity, 'Missing quantity', $itemNo, $sheet['name']);\n continue;\n }\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $supply = $this->boqCleaner->filterItem($description);\n\n if (! ($supply['keep'] ?? false)) {\n $rejected[] = $this->rejectedRow(\n $description,\n $unit,\n $quantity,\n (string) ($supply['rejection_reason'] ?? 'Rejected by cleaner'),\n $itemNo,\n $sheet['name']\n );\n continue;\n }\n\n $finalDescription = (string) ($supply['description'] ?? $description);\n\n $items[] = [\n 'description' => $finalDescription,\n 'quantity' => $quantity,\n 'unit' => $unit,\n 'category' => $sheet['name'],\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($finalDescription),\n 'unit_price' => $unitPrice,\n 'confidence' => 0.95,\n 'needs_review' => false,\n 'raw_data' => [\n 'sheet' => $sheet['name'],\n 'row_number' => $r + 1,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'cleaned_description' => $finalDescription,\n 'extraction_type' => (string) ($supply['extraction_type'] ?? 'supply_only'),\n ],\n 'ai_extracted' => true,\n ];\n }\n }\n\n if (empty($items)) {\n return $this->failure('No BOQ supply items could be extracted from the Excel file.');\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'skipped_sheets' => $skippedSheets,\n ];\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: parseSpreadsheetDirect failed.', [\n 'path' => $absPath,\n 'message' => $e->getMessage(),\n 'trace' => $e->getTraceAsString(),\n ]);\n\n return $this->failure('Failed to read the Excel file: ' . $e->getMessage());\n }\n }\n\n private function spreadsheetToGrids(string $absPath): array\n {\n $ext = strtolower(pathinfo($absPath, PATHINFO_EXTENSION));\n\n if ($ext === 'csv') {\n $rows = [];\n $handle = fopen($absPath, 'rb');\n if ($handle !== false) {\n while (($row = fgetcsv($handle)) !== false) {\n $rows[] = $this->trimTrailingEmptyCells($row);\n }\n fclose($handle);\n }\n return [['name' => 'Sheet1', 'grid' => $rows]];\n }\n\n $reader = IOFactory::createReader($ext === 'xls' ? 'Xls' : 'Xlsx');\n $reader->setReadDataOnly(true);\n $spreadsheet = $reader->load($absPath);\n $sheets = [];\n\n foreach ($spreadsheet->getAllSheets() as $sheet) {\n $highestRow = $sheet->getHighestDataRow();\n $highestColumn = $sheet->getHighestDataColumn();\n $highestColumnIndex = Coordinate::columnIndexFromString($highestColumn);\n\n // BOQ data is normally in first 8-20 columns.\n // Files with heavy formatting/images can report 300+ columns — limit to 30.\n $maxColumnIndex = min($highestColumnIndex, 30);\n\n $grid = [];\n for ($row = 1; $row <= $highestRow; $row++) {\n $rowData = [];\n $hasValue = false;\n\n for ($col = 1; $col <= $maxColumnIndex; $col++) {\n $value = $sheet->getCell([$col, $row])->getValue();\n $value = $this->cellValueToString($value);\n $value = trim($value);\n if ($value !== null && $value !== '') {\n $hasValue = true;\n }\n $rowData[] = $value;\n }\n\n $rowData = $this->trimTrailingEmptyCells($rowData);\n if ($hasValue) {\n $grid[] = $rowData;\n }\n }\n\n if (! empty($grid)) {\n $sheets[] = ['name' => $sheet->getTitle(), 'grid' => $grid];\n }\n }\n\n $spreadsheet->disconnectWorksheets();\n unset($spreadsheet);\n\n return $sheets;\n }\n\n private function detectHeader(array $grid): ?array\n {\n $keywords = [\n 'item_code' => ['رقم البند', 'item no', 'item number', 'ref.', 'ref', 'item', 'no.', 'no', '#', 'كود'],\n 'description' => ['وص٠البند', 'scope of works', 'scope of work', 'scope', 'item description', 'description of works', 'description', 'بالعربية', 'الوصÙ', 'البند', 'بيان'],\n 'unit' => ['Ø§Ù„ÙˆØØ¯Ø©', 'unit', 'uom', 'u/m'],\n 'quantity' => ['الكمية', 'qty', 'quantity', 'q.ty'],\n 'unit_price' => ['سعر Ø§Ù„ÙˆØØ¯Ø©', 'u/price', 'unit price', 'unit rate', 'rate', 'price'],\n 'total_price' => ['السعر الإجمالي', 'total amount', 'total price', 'amount', 'الإجمالي'],\n ];\n\n $best = null;\n $bestScore = 0;\n\n for ($r = 0; $r < min(30, count($grid)); $r++) {\n $map = [];\n $score = 0;\n\n foreach ($grid[$r] as $c => $value) {\n $cell = mb_strtolower(trim((string) $value));\n if ($cell === '') {\n continue;\n }\n\n foreach ($keywords as $field => $list) {\n foreach ($list as $kw) {\n if (str_contains($cell, mb_strtolower($kw))) {\n if (! isset($map[$field])) {\n $map[$field] = $c;\n $score++;\n }\n break 2;\n }\n }\n }\n }\n\n if ($score > $bestScore) {\n $bestScore = $score;\n $best = ['row' => $r, 'map' => $map];\n }\n }\n\n if ($bestScore >= 3) {\n return $best;\n }\n\n // Allow simpler BOQ sheets that only define a description column plus one\n // other BOQ-related column, e.g. Item + Description or Description + Qty.\n if ($bestScore === 2 && isset($best['map']['description']) && (\n isset($best['map']['item_code']) ||\n isset($best['map']['quantity']) ||\n isset($best['map']['unit'])\n )) {\n return $best;\n }\n\n return null;\n }\n\n private function cell(array $row, ?int $index): string\n {\n if ($index === null) {\n return '';\n }\n return trim((string) ($row[$index] ?? ''));\n }\n\n private function number(string $value): ?float\n {\n $value = trim(str_replace([',', 'SAR', 'ر.س'], '', $value));\n if ($value === '' || ! is_numeric($value)) {\n return null;\n }\n return (float) $value;\n }\n\n private function trimTrailingEmptyCells(array $row): array\n {\n while (! empty($row) && trim((string) end($row)) === '') {\n array_pop($row);\n }\n return array_values($row);\n }\n\n private function isTotalOrHeaderRow(string $description): bool\n {\n $d = mb_strtolower(trim($description));\n\n if ($d === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $d)) {\n return true;\n }\n\n // Section totals / summaries\n if (preg_match('/(اجمالي|إجمالي|المجموع|grand\\s*total|sub\\s*total|total\\s*amount|total\\s*price)/iu', $d)) {\n return true;\n }\n\n // Bare \"Total\" lines, optionally with a currency suffix, e.g. \"Total\",\n // \"Total (SAR)\", \"Total cost\", \"Total (SAR) unit cost\".\n if (preg_match('/^total\\b[\\s\\(\\):a-z]*$/iu', $d)) {\n return true;\n }\n\n // Pure floor / level labels used as section headers\n if (preg_match('/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor)\\s*$/iu', $d)) {\n return true;\n }\n\n // Division / section header lines (e.g. \"Division - 02: Wood Works\")\n if (preg_match('/^division\\s*([-:]|\\d)/iu', $d)) {\n return true;\n }\n\n return false;\n }\n\n /**\n * Detect dedicated summary / cost-abstract sheets by their name. These hold\n * per-discipline rollups and grand totals (e.g. a \"Summary (Dry Cost)\" tab),\n * never procurable supply items, so they are skipped wholesale.\n */\n private function isSummarySheet(string $name): bool\n {\n $n = mb_strtolower(trim($name));\n\n if ($n === '') {\n return false;\n }\n\n return (bool) preg_match(\n '/\\b(summary|abstract|cost\\s*summary|grand\\s*summary|recap(?:itulation)?|bill\\s*summary|boq\\s*summary|dry\\s*cost)\\b|ملخص|الملخص|الملخّص|ملخّص|اجمالي|إجمالي/iu',\n $n\n );\n }\n\n private function isFloorBreakdownRow(string $itemNo, string $description): bool\n {\n // A row whose Ref column is empty or a dash is a sub-breakdown row (per-floor split).\n $ref = trim($itemNo);\n if ($ref === '' || $ref === '-') {\n // Confirm by checking the description is a location/floor label\n $d = mb_strtolower(trim($description));\n return (bool) preg_match(\n '/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor|\\d+f)\\s*$/iu',\n $d\n );\n }\n return false;\n }\n\n private function rejectedRow(string $description, string $unit, ?float $quantity, string $reason, string $itemNo = '', string $sheet = ''): array\n {\n return [\n 'description' => $description,\n 'quantity' => $quantity ?? 0,\n 'unit' => $unit,\n 'category' => $sheet,\n 'brand' => '',\n 'status' => 'rejected',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 0.0,\n 'raw_data' => [\n 'sheet' => $sheet,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'extraction_type' => 'rejected',\n 'rejection_reason' => $reason,\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function parseBoqWithDeepSeek(UploadedFile|string $file, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured. Please set DEEPSEEK_API_KEY in .env file.');\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n $mime = $this->mimeForExtension($ext);\n\n try {\n // ── Spreadsheets ──────────────────────────────────────────────────\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n $text = $this->spreadsheetToCompactCsvText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not convert spreadsheet to readable text.');\n }\n $text = $this->sanitizeUtf8(mb_substr($text, 0, 180000));\n $userContent = \"BOQ spreadsheet converted to CSV:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── PDF ───────────────────────────────────────────────────────────\n if ($ext === 'pdf') {\n $extracted = $this->extractPdfText($absPath);\n if ($extracted !== null && mb_strlen(trim($extracted)) > 50) {\n $extracted = $this->sanitizeUtf8(mb_substr($extracted, 0, 180000));\n $userContent = \"BOQ PDF extracted text:\\n\\n\" . $extracted . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'application/pdf');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n return $this->failure('Could not extract text from the PDF. Please make sure it is a text-based PDF (not a scanned image), or convert it to Excel or CSV.');\n }\n\n // -- Word documents (docx) ---------------------------------------------------\n if (in_array($ext, ['docx', 'doc'], true)) {\n $text = $this->extractDocxText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not extract text from the Word document. Please convert it to Excel, CSV, or PDF.');\n }\n $text = $this->sanitizeUtf8(mb_substr($text, 0, 180000));\n $userContent = \"BOQ Word document extracted text:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── Images ──────────────────────────────────────────────────────────────\n // DeepSeek cannot receive images. A vision-capable AI processes the image\n // directly (no OCR) — configure GROQ_API_KEY or GEMINI_API_KEY in .env.\n $imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif', 'heic', 'heif'];\n if (in_array($ext, $imageExts, true)) {\n $bytes = (string) file_get_contents($absPath);\n return $this->callDeepSeekVision($bytes, $mime, $context);\n }\n\n return $this->failure('Unsupported file type. Please upload an Excel, CSV, PDF, or image file.');\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: DeepSeek fallback failed.', ['message' => $e->getMessage()]);\n return $this->failure('An error occurred while processing the file with AI: ' . $e->getMessage());\n }\n }\n\n private function deepSeekModel(): string\n {\n return (string) config('services.deepseek.model', 'deepseek-chat');\n }\n\n /**\n * Remove or replace any non-UTF-8 / malformed byte sequences so json_encode never fails.\n */\n private function sanitizeUtf8(string $text): string\n {\n // Replace invalid byte sequences with the UTF-8 replacement character\n $clean = mb_convert_encoding($text, 'UTF-8', 'UTF-8');\n // Strip any remaining non-printable control characters (except tab/newline/CR)\n $clean = (string) preg_replace('/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]/u', '', $clean);\n return $clean;\n }\n\n /**\n * Extract plain text from a Word docx file using ZipArchive (no external library needed).\n * Reads word/document.xml and strips XML tags, preserving paragraph and table structure.\n */\n private function extractDocxText(string $absPath): ?string\n {\n try {\n $zip = new \\ZipArchive();\n if ($zip->open($absPath) !== true) {\n return null;\n }\n\n $xml = $zip->getFromName('word/document.xml');\n $zip->close();\n\n if ($xml === false || $xml === '') {\n return null;\n }\n\n // Add newlines before paragraph/table-row/table-cell tags so structure is preserved\n $xml = preg_replace('/<w:p[ >]/', \"\\n<w:p>\", $xml) ?? $xml;\n $xml = preg_replace('/<w:tr[ >]/', \"\\n<w:tr>\", $xml) ?? $xml;\n $xml = preg_replace('/<\\/w:tc>/', \"\\t\", $xml) ?? $xml;\n\n $text = strip_tags($xml);\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n return trim($text) !== '' ? trim($text) : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: DOCX parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Extract plain text from a PDF using smalot/pdfparser (pure PHP, no binary required).\n */\n private function extractPdfText(string $absPath): ?string\n {\n try {\n $parser = new \\Smalot\\PdfParser\\Parser();\n $pdf = $parser->parseFile($absPath);\n $text = $pdf->getText();\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n return $text !== '' ? $text : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: PDF parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Send a vision request to a vision-capable AI model.\n * DeepSeek's commercial API (api.deepseek.com) does NOT support image inputs — any model.\n * Priority order (first configured key wins):\n * 1. GROQ_API_KEY → Groq llama-4-scout (free: https://console.groq.com)\n * 2. GEMINI_API_KEY → Google Gemini Flash (free: https://aistudio.google.com/apikey)\n * 3. VISION_API_KEY → any OpenAI-compatible endpoint (OpenAI, OpenRouter …)\n */\n private function callDeepSeekVision(string $bytes, string $mime, array $context): array\n {\n // ── 1. Groq (free, reliable vision) ─────────────────────────────────────\n if ((string) config('services.groq.key', '') !== '') {\n $visionKey = (string) config('services.groq.key');\n $visionBaseUrl = 'https://api.groq.com/openai/v1';\n $visionModel = (string) config('services.groq.model', 'meta-llama/llama-4-scout-17b-16e-instruct');\n }\n // ── 2. Google Gemini ─────────────────────────────────────────────────────\n elseif ((string) config('services.gemini.key', '') !== '') {\n $visionKey = (string) config('services.gemini.key');\n $visionBaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai';\n $visionModel = (string) config('services.gemini.model', 'gemini-2.0-flash');\n }\n // ── 3. Generic VISION_API_KEY (OpenAI, OpenRouter, …) ───────────────────\n elseif ((string) config('services.vision.key', '') !== '') {\n $visionKey = (string) config('services.vision.key');\n $visionBaseUrl = rtrim((string) config('services.vision.base_url', 'https://openrouter.ai/api/v1'), '/');\n $visionModel = (string) config('services.vision.model', 'google/gemini-flash-1.5-8b');\n }\n else {\n return $this->failure(\n 'Image processing is not available. DeepSeek\\'s API does not support images. ' .\n 'Get a free key at https://console.groq.com and add GROQ_API_KEY to your .env file.'\n );\n }\n\n $dataUrl = \"data:{$mime};base64,\" . base64_encode($bytes);\n $prompt = $this->buildDeepSeekPrompt($context, $mime);\n\n $userContent = [\n ['type' => 'text', 'text' => $prompt],\n ['type' => 'image_url', 'image_url' => ['url' => $dataUrl]],\n ];\n\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders([\n 'Authorization' => 'Bearer ' . $visionKey,\n 'HTTP-Referer' => config('app.url', 'https://qimta.sa'),\n 'X-Title' => 'Qimta Platform',\n ])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post($visionBaseUrl . '/chat/completions', [\n 'model' => $visionModel,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 16384,\n 'temperature' => 0.2,\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('Vision API connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: Vision API HTTP error.', [\n 'model' => $visionModel,\n 'base_url' => $visionBaseUrl,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n return $this->failure(\"Vision API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n return $this->parseDeepSeekJsonResponse((string) $response->json('choices.0.message.content'));\n }\n\n private function callDeepSeekChat(string $userContent, string $key, string $model): array\n {\n // Ensure the PHP script outlives the HTTP call (+ a margin for parsing the\n // response and writing to the DB) so the worker/web request is not killed\n // mid-flight, which surfaces to users as a generic \"timeout or unavailable\".\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders(['Authorization' => 'Bearer ' . $key])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 32768,\n 'temperature' => 0.1,\n 'response_format' => ['type' => 'json_object'],\n 'user' => 'Qimta_Platform',\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('DeepSeek connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: DeepSeek HTTP error.', [\n 'model' => $model,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n if ($response->status() === 401) {\n return $this->failure('DeepSeek API key is invalid or unauthorised. Please update DEEPSEEK_API_KEY in .env.', false);\n }\n return $this->failure(\"DeepSeek API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n $text = (string) $response->json('choices.0.message.content');\n return $this->parseDeepSeekJsonResponse($text);\n }\n\n /**\n * Shared JSON response parser used by both chat and vision paths.\n */\n private function parseDeepSeekJsonResponse(string $text): array\n {\n if ($text === '') {\n return $this->failure('DeepSeek returned an empty response.');\n }\n\n // Strip markdown code fences if the model wrapped the JSON.\n if (preg_match('/```(?:json)?\\s*([\\s\\S]*?)\\s*```/i', $text, $m)) {\n $text = $m[1];\n }\n // Narrow to the outermost JSON object/array.\n if (preg_match('/[\\{\\[][\\s\\S]*[\\}\\]]/s', $text, $m)) {\n $text = $m[0];\n }\n $text = trim($text);\n\n $decoded = json_decode($text, true);\n\n // The response can be truncated when the model hits the token limit,\n // leaving an unterminated JSON string/object. Try to repair it before\n // giving up so we can still salvage the items extracted so far.\n if (! is_array($decoded)) {\n $repaired = $this->repairTruncatedJson($text);\n if ($repaired !== null) {\n $decoded = json_decode($repaired, true);\n }\n }\n\n if (! is_array($decoded)) {\n Log::error('QuotationAiService: DeepSeek non-JSON response.', ['text' => mb_substr($text, 0, 1000)]);\n return $this->failure('DeepSeek returned a non-JSON response. The file may be too large — try splitting it into smaller files.');\n }\n\n $rawItems = $decoded['items'] ?? (array_is_list($decoded) ? $decoded : []);\n $items = [];\n $rejected = [];\n\n foreach ($rawItems as $raw) {\n if (! is_array($raw)) {\n continue;\n }\n $item = $this->normaliseAiItem($raw);\n if ($item === null) {\n continue;\n }\n $check = $this->boqCleaner->filterItem((string) $item['description']);\n if ($check['keep'] ?? false) {\n $items[] = $item;\n } else {\n $item['status'] = 'rejected';\n $item['raw_data']['rejection_reason'] = $check['rejection_reason'] ?? 'Rejected by cleaner';\n $rejected[] = $item;\n }\n }\n\n if (empty($items)) {\n Log::info('QuotationAiService: DeepSeek returned 0 accepted items.', [\n 'raw_items_count' => count($rawItems),\n 'rejected_count' => count($rejected),\n 'first_rejected' => ! empty($rejected) ? ($rejected[0]['description'] ?? '') : '',\n ]);\n }\n\n return ['success' => true, 'items' => $items, 'rejected' => $rejected, 'error' => null];\n }\n\n /**\n * Best-effort repair of a truncated JSON string produced when the model hits\n * its token limit mid-output. Drops any incomplete trailing object, then\n * closes the open string/array/object brackets so the result is valid JSON.\n *\n * Returns the repaired JSON string, or null if it cannot be salvaged.\n */\n private function repairTruncatedJson(string $text): ?string\n {\n if ($text === '') {\n return null;\n }\n\n // Walk the string tracking structural state, ignoring everything inside\n // string literals (and their escapes). Remember the last position where\n // we were back at the top of the \"items\" array between complete elements,\n // so we can cut a half-written trailing object.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($text);\n $lastSafeCut = null; // index just after a completed array element\n\n for ($i = 0; $i < $len; $i++) {\n $ch = $text[$i];\n\n if ($inString) {\n if ($escaped) {\n $escaped = false;\n } elseif ($ch === '\\\\') {\n $escaped = true;\n } elseif ($ch === '\"') {\n $inString = false;\n }\n continue;\n }\n\n if ($ch === '\"') {\n $inString = true;\n } elseif ($ch === '{' || $ch === '[') {\n $stack[] = $ch;\n } elseif ($ch === '}' || $ch === ']') {\n array_pop($stack);\n } elseif ($ch === ',' && ! empty($stack) && end($stack) === '[') {\n // Separator directly inside an array: a clean boundary between\n // elements (the items array, whatever its nesting depth).\n $lastSafeCut = $i;\n }\n }\n\n // If we are still inside a string, the last element is incomplete — fall\n // back to the last clean array boundary if we have one.\n $candidate = $text;\n if ($inString && $lastSafeCut !== null) {\n $candidate = substr($text, 0, $lastSafeCut);\n } else {\n // Drop a dangling partial object after the last completed element.\n $candidate = rtrim($candidate);\n if ($lastSafeCut !== null && substr(rtrim($candidate), -1) !== '}' && substr(rtrim($candidate), -1) !== ']') {\n $candidate = substr($text, 0, $lastSafeCut);\n }\n }\n\n // Recompute the open brackets for the (possibly trimmed) candidate and\n // append the matching closers in reverse order.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($candidate);\n for ($i = 0; $i < $len; $i++) {\n $ch = $candidate[$i];\n if ($inString) {\n if ($escaped) { $escaped = false; }\n elseif ($ch === '\\\\') { $escaped = true; }\n elseif ($ch === '\"') { $inString = false; }\n continue;\n }\n if ($ch === '\"') { $inString = true; }\n elseif ($ch === '{' || $ch === '[') { $stack[] = $ch; }\n elseif ($ch === '}' || $ch === ']') { array_pop($stack); }\n }\n\n $candidate = rtrim($candidate);\n // Remove a trailing comma left after cutting an element.\n $candidate = rtrim($candidate, ',');\n\n $closers = '';\n for ($i = count($stack) - 1; $i >= 0; $i--) {\n $closers .= ($stack[$i] === '{') ? '}' : ']';\n }\n\n $repaired = $candidate . $closers;\n\n // Validate before returning.\n return is_array(json_decode($repaired, true)) ? $repaired : null;\n }\n\n private function spreadsheetToCompactCsvText(string $absPath): ?string\n {\n $sheets = $this->spreadsheetToGrids($absPath);\n if (empty($sheets)) {\n return null;\n }\n\n $output = '';\n foreach ($sheets as $sheet) {\n // Don't feed summary / cost-abstract sheets to the AI either.\n if ($this->isSummarySheet($sheet['name'])) {\n continue;\n }\n $output .= 'Sheet: ' . $sheet['name'] . \"\\n\";\n foreach ($sheet['grid'] as $row) {\n $row = $this->trimTrailingEmptyCells($row);\n if (empty($row)) {\n continue;\n }\n $output .= implode(',', array_map([$this, 'csvEscape'], $row)) . \"\\n\";\n }\n $output .= \"\\n\";\n }\n\n return $output;\n }\n\n /**\n * Safely convert a PhpSpreadsheet cell value to a string.\n *\n * Cells may contain RichText or, in malformed/heavily-formatted files,\n * embedded objects such as Worksheet\\Drawing that have no __toString().\n * Casting those directly throws \"Object ... could not be converted to string\".\n */\n private function cellValueToString(mixed $value): string\n {\n if ($value === null) {\n return '';\n }\n\n if ($value instanceof \\PhpOffice\\PhpSpreadsheet\\RichText\\RichText) {\n return $value->getPlainText();\n }\n\n if (is_object($value)) {\n // Only stringify objects that explicitly support it; ignore the rest\n // (e.g. Worksheet\\Drawing) instead of crashing.\n return method_exists($value, '__toString') ? (string) $value : '';\n }\n\n if (is_array($value)) {\n return '';\n }\n\n return (string) $value;\n }\n\n private function csvEscape(mixed $value): string\n {\n $value = $this->cellValueToString($value);\n return str_contains($value, ',') || str_contains($value, '\"') || str_contains($value, \"\\n\")\n ? '\"' . str_replace('\"', '\"\"', $value) . '\"'\n : $value;\n }\n\n private function normaliseAiItem(array $raw): ?array\n {\n if (! empty($raw['rejected'])) {\n return null;\n }\n\n $description = trim((string) ($raw['cleaned_description'] ?? $raw['product_name'] ?? $raw['description'] ?? ''));\n if ($description === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $description)) {\n return null;\n }\n\n $quantity = is_numeric($raw['quantity'] ?? null) ? (float) $raw['quantity'] : 1;\n $unitPrice = is_numeric($raw['unit_price'] ?? null) ? (float) $raw['unit_price'] : null;\n $totalPrice = is_numeric($raw['total_price'] ?? null) ? (float) $raw['total_price'] : null;\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $specs = is_array($raw['specifications'] ?? null) ? $raw['specifications'] : [];\n\n return [\n 'description' => $description,\n 'quantity' => $quantity,\n 'unit' => (string) ($raw['unit'] ?? ''),\n 'category' => (string) ($raw['core_product'] ?? $raw['category'] ?? ''),\n 'brand' => (string) ($specs['brand'] ?? $raw['brand'] ?? ''),\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($description),\n 'unit_price' => $unitPrice,\n 'confidence' => is_numeric($raw['confidence'] ?? null) ? (float) $raw['confidence'] : null,\n 'needs_review' => ! empty($raw['needs_review']),\n 'raw_data' => [\n 'source_item_no' => (string) ($raw['source_item_no'] ?? ''),\n 'original_description' => (string) ($raw['original_description'] ?? $description),\n 'cleaned_description' => $description,\n 'core_product' => (string) ($raw['core_product'] ?? ''),\n 'specifications' => $specs,\n 'extraction_type' => (string) ($raw['extraction_type'] ?? 'supply_only'),\n 'note' => (string) ($raw['note'] ?? $raw['notes'] ?? ''),\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function buildSystemInstruction(): string\n {\n return <<<'TXT'\nYou are a strict BOQ Supply Product Extraction Engine.\nExtract ONLY concrete, tangible, physical products that a supplier can box up and deliver to site — a specific item you could point at and say \"this is the thing being bought\" (e.g. \"Wall mounted LED luminaire 36W\", \"PVC pipe 110mm\", \"Single phase distribution board 12-way\", \"Fire extinguisher CO2 6kg\", \"CCTV dome camera 4MP\").\n\nABSOLUTELY REJECT abstract concepts, disciplines, categories, section names, and trade headings. These are NOT products even though they appear as rows:\n- Bare discipline/trade words: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", \"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\".\n- Summary / total / abstract / \"dry cost\" rows of any kind.\n- Anything that names a system, scope, or category instead of one buyable item.\n\nRule of thumb: if you cannot name a unit and physically hand the item over (a piece, a box, a length, a unit of equipment), REJECT it. A word that describes a field of work, a system, or a section of the BOQ is NEVER a product.\nReject totals, headings, general requirements, installation-only, testing, commissioning, supervision, mobilization, civil works, labor, and vague rows.\nFor Supply & Install rows, keep only the supply product and remove install/labor wording.\nReturn only valid JSON: {\"items\":[{\"source_item_no\":null,\"original_description\":\"\",\"cleaned_description\":\"\",\"core_product\":\"\",\"specifications\":{},\"quantity\":0,\"unit\":\"\",\"unit_price\":0,\"total_price\":0,\"extraction_type\":\"supply_only\",\"needs_review\":false,\"note\":\"\",\"rejected\":false,\"rejection_reason\":null,\"confidence\":0.95}]}\nTXT;\n }\n\n private function buildDeepSeekPrompt(array $context = [], string $mime = ''): string\n {\n $projectName = (string) ($context['project_name'] ?? '');\n $projectStatus = (string) ($context['project_status'] ?? '');\n $contextLine = $projectName !== ''\n ? \"Project: {$projectName}\" . ($projectStatus !== '' ? \" ({$projectStatus})\" : '')\n : '';\n $sourceHint = str_starts_with($mime, 'image/')\n ? 'The input is an image of a BOQ table. OCR every cell carefully — read the quantity/count column (العدد, الكمية, Qty) and extract the EXACT numeric value from each row.'\n : 'The input is a BOQ document/spreadsheet.';\n\n return trim(<<<PROMPT\nYou are a procurement specialist. Your task is to extract ONLY tangible, physical supply products from this Bill of Quantities (BOQ).\n\n{$contextLine}\n{$sourceHint}\n\nCRITICAL — MERGE WRAPPED / SPLIT ROWS INTO ONE ITEM:\nBOQ tables extracted from PDF often break ONE item's long description across several\nlines/rows. These continuation lines repeat the SAME quantity, unit and category, and\ntheir text is a grammatical continuation of the previous line (it starts mid-sentence,\nlowercase, or with \"the/and/-\", or is a spec/instruction clause). You MUST reconstruct\nthe original single item:\n- Treat consecutive rows that share the SAME quantity + unit + category as ONE product\n when the later rows are clearly continuation text of the first, NOT new products.\n- Emit ONE item with the merged description and the price counted ONCE. Do NOT repeat\n the same quantity×price for every wrapped line — that multiplies the total wrongly.\n- Example: rows \"The asphalt flooring is to be supplied\" / \"installed in accordance\" /\n \"standard road specifications...\" all with 7,100 m2 are ONE asphalt item, not 3+.\n\nCRITICAL — UNDERSTAND INTENT, DO NOT COPY ROWS LITERALLY:\nYour job is to output WHAT A SUPPLIER MUST ACTUALLY DELIVER, not a copy of the row text.\nRead each description for MEANING and decide how many real, separately-buyable products it\nimplies. One BOQ row can imply SEVERAL products, or a spread of rows can imply ONE product.\n- SPLIT a row into multiple items when it lists distinct products that a supplier would\n buy/price separately — even when joined by commas, \"with\", \"and\", \"including\", \"c/w\",\n \"consisting of\", \"comprising\", or \":\" then a list.\n Example: \"Fire pump set consisting of: fire pump, electric motor, control panel, diesel\n engine\" → FOUR items: (1) fire pump, (2) electric motor, (3) control panel, (4) diesel\n engine — each with its own quantity and priced on its own.\n Example: \"Wash basin complete with mixer tap and bottle trap\" → THREE items: wash basin,\n mixer tap, bottle trap.\n- Give each split product the correct quantity implied by the text (usually the parent\n quantity, unless the text states a different count for a sub-item). Each product is\n priced individually; the line total becomes the SUM of its products.\n- DO NOT split when the extra words are just specifications, dimensions, colours, ratings,\n standards, or a single product's integral parts. \"Steel door 900x2100 with vision panel\n and SS ironmongery\" is usually ONE door product — vision panel and ironmongery are part\n of that door, not separately-bought items. Use judgement: would a supplier issue a\n separate price line for it? If no, keep it as one.\n- Balanced approach: split only when genuinely separate products are present; when in doubt\n about whether something is a separate product vs. an accessory/spec of the main one, keep\n it as a single item. Do not invent products that are not in the text.\n\nSTRICT RULES — include ONLY items that are ALL of the following:\n1. A specific, concrete, tangible product that can be boxed up by a supplier and delivered to site — something you can point at and say \"this exact thing is being bought\" (e.g. \"LED luminaire 36W\", \"PVC pipe 110mm\", \"Distribution board 12-way\", \"CO2 fire extinguisher 6kg\").\n2. Has a real quantity (not 0 or \"varies\" with no meaning).\n3. Has enough description to identify what the product is.\n4. Is an actual PRODUCT, not a sentence, instruction, or specification clause. REJECT rows that are prose such as \"the supervising engineer's instructions\", \"The price includes all materials, labor\", \"Measurement will be based on the geometric cubic meter\", \"necessary steps to complete the work\", \"soil-contacting elements\", \"in compliance\", \"everything necessary\". These are description fragments, not buyable items.\n\nNEVER extract abstract concepts, disciplines, trades, systems, or section/category names — they are NOT products even when they appear as their own row:\n- Bare discipline/trade/system words such as: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", and their Arabic equivalents (\"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\", \"تشطيبات\").\n- Test: if the word names a FIELD OF WORK or a SYSTEM rather than one buyable item with a unit (pcs, m, set, kg…), REJECT it.\n\nEXCLUDE all of the following (do NOT include them):\n- Labor, installation, or workmanship items (e.g. \"install\", \"fix\", \"erect\", \"dismantle\", \"painting\", \"cleaning service\")\n- Project management, overhead, or profit items\n- Insurance, bonds, liability items (e.g. \"bodily injury\", \"third party liability\", \"CAR insurance\")\n- Shop drawings, as-built drawings, method statements, documentation\n- Preliminary items, mobilisation, demobilisation, provisional sums\n- Housekeeping, site clearance, waste removal\n- Floor/level labels (e.g. \"GF Floor\", \"Level 3\") — these are headers, not products\n- Pure dimension specs without a product noun (e.g. \"Dia 32 mm\", \"25 mm\") — reject unless a product name is present\n- Security services, night shifts, rentals, payments\n- Any item whose description is too vague or fragmentary to identify a specific product\n\nEmit ONE JSON item PER real product. A single source row may therefore produce several\nitems (when it lists multiple products) or several rows may collapse into one item (when\nthey are wrapped fragments of the same product). The \"items\" array is your final delivery\nlist, not a row-by-row transcript.\n\nFor each valid physical product return JSON with:\n- description: clean product name only (strip \"supply and install\", \"supply and fix\", installation clauses, floor/spec suffixes). For a split product, use just that product's own name.\n- quantity: the EXACT numeric value from the quantity/count column (العدد, الكمية, Qty, Quantity, No.). NEVER default to 1 unless the quantity column is truly absent or empty.\n- unit: unit of measure (pcs, m, m2, m3, set, no, kg, etc.)\n- category: product category\n- brand: brand/manufacturer if mentioned, else \"\"\n- engineering_required: true if the item needs engineering design before procurement (panel boards, chillers, generators, fire suppression systems, BMS, pumps, AHUs, switchgear, transformers), else false\n\nReturn ONLY a valid JSON object: {\"items\": [...]}. No explanations, no markdown, no extra text.\nPROMPT);\n }\n\n private function mockResponse(): array\n {\n return [\n 'success' => true,\n 'error' => null,\n 'items' => [[\n 'description' => 'Sample BOQ Item',\n 'quantity' => 1,\n 'unit' => 'No',\n 'category' => 'Sample',\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 1,\n 'raw_data' => [],\n 'ai_extracted' => true,\n ]],\n 'rejected' => [],\n ];\n }\n\n private function failure(string $message, bool $serviceUnavailable = false): array\n {\n return [\n 'success' => false,\n 'items' => [],\n 'rejected' => [],\n 'error' => $this->userFacingError($message, $serviceUnavailable),\n 'service_unavailable' => $serviceUnavailable,\n ];\n }\n\n /**\n * Translate any internal error into a neutral, user-safe message.\n *\n * The user must never see the name of the underlying AI/processing engine\n * (DeepSeek, Groq, Gemini, Vision, etc.) or configuration hints like API\n * keys. The detailed message is kept in the logs for debugging only.\n */\n private function userFacingError(string $message, bool $serviceUnavailable = false): string\n {\n // Detect any reference to an AI provider / engine / API config so we can\n // swap it for a generic, branded-neutral message.\n $sensitive = '/deepseek|groq|gemini|vision|openai|openrouter|api[\\s_-]?key|\\.env|non-?json|token limit/i';\n\n if (preg_match($sensitive, $message)) {\n Log::warning('QuotationAiService: suppressed technical error from user.', ['internal' => $message]);\n\n return $serviceUnavailable\n ? 'The file could not be processed right now. Please try again in a moment.'\n : 'We could not read this file automatically. Please make sure it is a clear BOQ (Excel, CSV, PDF, or image) and try again, or add the items manually.';\n }\n\n return $message;\n }\n\n private function mimeForExtension(string $ext): string\n {\n return match (strtolower($ext)) {\n 'pdf' => 'application/pdf',\n 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n 'xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12',\n 'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',\n 'xls' => 'application/vnd.ms-excel',\n 'csv' => 'text/csv',\n 'jpg', 'jpeg' => 'image/jpeg',\n 'png' => 'image/png',\n 'gif' => 'image/gif',\n 'bmp' => 'image/bmp',\n 'webp' => 'image/webp',\n 'tiff', 'tif' => 'image/tiff',\n 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'doc' => 'application/msword',\n default => 'application/octet-stream',\n };\n }\n}\n"}, "b89222a285363f5a3988031b5311b4c04a0f2a64": {"t": 1785748386.3781297, "n": 54684, "h": "6b8038eeaccb1e0a", "c": "<?php\n\nnamespace App\\Services;\n\nuse Illuminate\\Http\\Client\\ConnectionException;\nuse Illuminate\\Http\\UploadedFile;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\nuse PhpOffice\\PhpSpreadsheet\\Cell\\Coordinate;\nuse PhpOffice\\PhpSpreadsheet\\IOFactory;\n\nclass QuotationAiService\n{\n /** Increment this whenever BOQ parsing logic changes to invalidate old caches. */\n private const PARSER_VERSION = 5;\n\n private string $baseUrl;\n private string $parseEndpoint;\n private string $apiKey;\n private int $timeout;\n private bool $testMode;\n private BoqCleaningService $boqCleaner;\n\n public function __construct(BoqCleaningService $boqCleaner)\n {\n $this->boqCleaner = $boqCleaner;\n $this->baseUrl = rtrim((string) config('services.ai_quotation.base_url', ''), '/');\n $this->parseEndpoint = ltrim((string) config('services.ai_quotation.parse_endpoint', 'parse'), '/');\n $this->apiKey = (string) config('services.ai_quotation.api_key', '');\n $this->timeout = (int) config('services.ai_quotation.timeout', 300);\n $this->testMode = (bool) config('services.ai_quotation.test_mode', false);\n }\n\n /**\n * Give the running script enough head-room to outlast the AI HTTP call.\n *\n * The web request / queue worker must not be killed before the HTTP timeout\n * completes, otherwise the user sees a generic \"timeout or unavailable\"\n * error even though the AI may have responded. We allow the HTTP timeout\n * plus a 120s margin for connecting, response parsing and DB writes.\n */\n private function extendExecutionTime(): void\n {\n // Ignored on some SAPIs (e.g. when running under fastcgi with a hard\n // limit), but harmless to attempt.\n @set_time_limit($this->timeout + 120);\n }\n\n public function parseBoq(UploadedFile|string $file, array $context = []): array\n {\n if ($this->testMode) {\n return $this->mockResponse();\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n\n if (! is_file($absPath)) {\n return $this->failure('File not found or not readable.');\n }\n\n $fileHash = hash_file('sha256', $absPath);\n // Bump PARSER_VERSION whenever the extraction logic changes so that\n // previously-uploaded files are re-parsed instead of returning a stale\n // cached result produced by the old code.\n $cacheKey = 'boq_analysis_v' . self::PARSER_VERSION . '_' . $fileHash;\n $forceRefresh = (bool) ($context['force_refresh'] ?? false);\n\n if (! $forceRefresh && ($cached = Cache::get($cacheKey)) !== null) {\n return $cached;\n }\n\n // Clear stale cached result when force-refreshing\n if ($forceRefresh) {\n Cache::forget($cacheKey);\n }\n\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n // Read Excel locally first. Fall back to AI if local parse failed,\n // found 0 items, OR could not read one of the sheets (so multi-sheet\n // files never silently drop a sheet the local parser didn't understand).\n $result = $this->parseSpreadsheetDirect($absPath);\n\n $hasSkippedSheets = ! empty($result['skipped_sheets']);\n\n if (! $result['success'] || empty($result['items']) || $hasSkippedSheets) {\n $deepSeekResult = $this->parseBoqWithDeepSeek($file, $context);\n // AI sees every sheet at once, so prefer its result whenever it\n // actually returned items (covers the skipped-sheet case too).\n if ($deepSeekResult['success'] && ! empty($deepSeekResult['items'])) {\n // Keep the larger of the two item sets so we never lose the\n // rows the local parser already extracted correctly.\n if (count($deepSeekResult['items']) >= count($result['items'] ?? [])) {\n $result = $deepSeekResult;\n }\n } elseif (! $result['success']) {\n $result = $deepSeekResult;\n }\n // else: keep local result if AI also failed/returned nothing\n }\n } elseif (in_array($ext, ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif'], true)) {\n $result = $this->parseBoqWithDeepSeek($file, $context);\n } else {\n return $this->failure('Unsupported file type. Please upload Excel, CSV, PDF, or image file.');\n }\n\n if ($result['success'] && ! empty($result['items'])) {\n $cleaning = $this->boqCleaner->process($result['items']);\n $result['items'] = $cleaning['accepted'] ?? $result['items'];\n $result['rejected'] = array_merge($result['rejected'] ?? [], $cleaning['rejected'] ?? []);\n }\n\n // Only cache when we actually extracted items; empty results should be re-tried.\n if ($result['success'] && ! empty($result['items'])) {\n Cache::put($cacheKey, $result, now()->addDays(30));\n }\n\n return $result;\n }\n\n private function resolveFile(UploadedFile|string $file): array\n {\n if ($file instanceof UploadedFile) {\n return [(string) $file->getRealPath(), strtolower($file->getClientOriginalExtension())];\n }\n\n $absPath = is_file($file) ? $file : storage_path('app/' . ltrim($file, '/'));\n return [$absPath, strtolower(pathinfo($absPath, PATHINFO_EXTENSION))];\n }\n\n private function parseSpreadsheetDirect(string $absPath): array\n {\n ini_set('memory_limit', '1024M');\n set_time_limit(180);\n\n try {\n $sheets = $this->spreadsheetToGrids($absPath);\n\n if (empty($sheets)) {\n return $this->failure('Could not read the Excel file.');\n }\n\n $items = [];\n $rejected = [];\n $skippedSheets = []; // sheets we could not parse locally (header undetected)\n\n foreach ($sheets as $sheet) {\n $grid = $sheet['grid'];\n if (count($grid) < 2) {\n continue;\n }\n\n // Skip dedicated summary / cost-abstract sheets entirely. Their rows\n // are per-discipline totals (Structural Concrete, Electrical, …) and\n // grand totals, not procurable products.\n if ($this->isSummarySheet($sheet['name'])) {\n Log::info('QuotationAiService: skipped summary sheet.', ['sheet' => $sheet['name']]);\n continue;\n }\n\n $header = $this->detectHeader($grid);\n\n if ($header === null || ! isset($header['map']['description'])) {\n Log::warning('QuotationAiService: header not detected in sheet.', ['sheet' => $sheet['name']]);\n $skippedSheets[] = $sheet['name'];\n continue;\n }\n\n $map = $header['map'];\n $start = $header['row'] + 1;\n\n for ($r = $start; $r < count($grid); $r++) {\n $row = $grid[$r];\n $description = $this->cell($row, $map['description'] ?? null);\n $itemNo = $this->cell($row, $map['item_code'] ?? null);\n $unit = $this->cell($row, $map['unit'] ?? null);\n $quantity = $this->number($this->cell($row, $map['quantity'] ?? null));\n $unitPrice = $this->number($this->cell($row, $map['unit_price'] ?? null));\n $totalPrice = $this->number($this->cell($row, $map['total_price'] ?? null));\n\n if ($description === '') {\n continue;\n }\n\n if (! isset($map['quantity'])) {\n $quantity = 1.0;\n }\n\n // Skip floor breakdown sub-rows: Ref. is \"-\" meaning it's a per-floor\n // quantity split of the parent item, not a standalone product.\n if ($itemNo === '-' || $this->isFloorBreakdownRow($itemNo, $description)) {\n continue;\n }\n\n if ($this->isTotalOrHeaderRow($description)) {\n continue;\n }\n\n if ($quantity === null || $quantity <= 0) {\n $rejected[] = $this->rejectedRow($description, $unit, $quantity, 'Missing quantity', $itemNo, $sheet['name']);\n continue;\n }\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $supply = $this->boqCleaner->filterItem($description);\n\n if (! ($supply['keep'] ?? false)) {\n $rejected[] = $this->rejectedRow(\n $description,\n $unit,\n $quantity,\n (string) ($supply['rejection_reason'] ?? 'Rejected by cleaner'),\n $itemNo,\n $sheet['name']\n );\n continue;\n }\n\n $finalDescription = (string) ($supply['description'] ?? $description);\n\n $items[] = [\n 'description' => $finalDescription,\n 'quantity' => $quantity,\n 'unit' => $unit,\n 'category' => $sheet['name'],\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($finalDescription),\n 'unit_price' => $unitPrice,\n 'confidence' => 0.95,\n 'needs_review' => false,\n 'raw_data' => [\n 'sheet' => $sheet['name'],\n 'row_number' => $r + 1,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'cleaned_description' => $finalDescription,\n 'extraction_type' => (string) ($supply['extraction_type'] ?? 'supply_only'),\n ],\n 'ai_extracted' => true,\n ];\n }\n }\n\n if (empty($items)) {\n return $this->failure('No BOQ supply items could be extracted from the Excel file.');\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'skipped_sheets' => $skippedSheets,\n ];\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: parseSpreadsheetDirect failed.', [\n 'path' => $absPath,\n 'message' => $e->getMessage(),\n 'trace' => $e->getTraceAsString(),\n ]);\n\n return $this->failure('Failed to read the Excel file: ' . $e->getMessage());\n }\n }\n\n private function spreadsheetToGrids(string $absPath): array\n {\n $ext = strtolower(pathinfo($absPath, PATHINFO_EXTENSION));\n\n if ($ext === 'csv') {\n $rows = [];\n $handle = fopen($absPath, 'rb');\n if ($handle !== false) {\n while (($row = fgetcsv($handle)) !== false) {\n $rows[] = $this->trimTrailingEmptyCells($row);\n }\n fclose($handle);\n }\n return [['name' => 'Sheet1', 'grid' => $rows]];\n }\n\n $reader = IOFactory::createReader($ext === 'xls' ? 'Xls' : 'Xlsx');\n $reader->setReadDataOnly(true);\n $spreadsheet = $reader->load($absPath);\n $sheets = [];\n\n foreach ($spreadsheet->getAllSheets() as $sheet) {\n $highestRow = $sheet->getHighestDataRow();\n $highestColumn = $sheet->getHighestDataColumn();\n $highestColumnIndex = Coordinate::columnIndexFromString($highestColumn);\n\n // BOQ data is normally in first 8-20 columns.\n // Files with heavy formatting/images can report 300+ columns — limit to 30.\n $maxColumnIndex = min($highestColumnIndex, 30);\n\n $grid = [];\n for ($row = 1; $row <= $highestRow; $row++) {\n $rowData = [];\n $hasValue = false;\n\n for ($col = 1; $col <= $maxColumnIndex; $col++) {\n $value = $sheet->getCell([$col, $row])->getValue();\n $value = $this->cellValueToString($value);\n $value = trim($value);\n if ($value !== null && $value !== '') {\n $hasValue = true;\n }\n $rowData[] = $value;\n }\n\n $rowData = $this->trimTrailingEmptyCells($rowData);\n if ($hasValue) {\n $grid[] = $rowData;\n }\n }\n\n if (! empty($grid)) {\n $sheets[] = ['name' => $sheet->getTitle(), 'grid' => $grid];\n }\n }\n\n $spreadsheet->disconnectWorksheets();\n unset($spreadsheet);\n\n return $sheets;\n }\n\n private function detectHeader(array $grid): ?array\n {\n $keywords = [\n 'item_code' => ['رقم البند', 'item no', 'item number', 'ref.', 'ref', 'item', 'no.', 'no', '#', 'كود'],\n 'description' => ['وص٠البند', 'scope of works', 'scope of work', 'scope', 'item description', 'description of works', 'description', 'بالعربية', 'الوصÙ', 'البند', 'بيان'],\n 'unit' => ['Ø§Ù„ÙˆØØ¯Ø©', 'unit', 'uom', 'u/m'],\n 'quantity' => ['الكمية', 'qty', 'quantity', 'q.ty'],\n 'unit_price' => ['سعر Ø§Ù„ÙˆØØ¯Ø©', 'u/price', 'unit price', 'unit rate', 'rate', 'price'],\n 'total_price' => ['السعر الإجمالي', 'total amount', 'total price', 'amount', 'الإجمالي'],\n ];\n\n $best = null;\n $bestScore = 0;\n\n for ($r = 0; $r < min(30, count($grid)); $r++) {\n $map = [];\n $score = 0;\n\n foreach ($grid[$r] as $c => $value) {\n $cell = mb_strtolower(trim((string) $value));\n if ($cell === '') {\n continue;\n }\n\n foreach ($keywords as $field => $list) {\n foreach ($list as $kw) {\n if (str_contains($cell, mb_strtolower($kw))) {\n if (! isset($map[$field])) {\n $map[$field] = $c;\n $score++;\n }\n break 2;\n }\n }\n }\n }\n\n if ($score > $bestScore) {\n $bestScore = $score;\n $best = ['row' => $r, 'map' => $map];\n }\n }\n\n if ($bestScore >= 3) {\n return $best;\n }\n\n // Allow simpler BOQ sheets that only define a description column plus one\n // other BOQ-related column, e.g. Item + Description or Description + Qty.\n if ($bestScore === 2 && isset($best['map']['description']) && (\n isset($best['map']['item_code']) ||\n isset($best['map']['quantity']) ||\n isset($best['map']['unit'])\n )) {\n return $best;\n }\n\n return null;\n }\n\n private function cell(array $row, ?int $index): string\n {\n if ($index === null) {\n return '';\n }\n return trim((string) ($row[$index] ?? ''));\n }\n\n private function number(string $value): ?float\n {\n $value = trim(str_replace([',', 'SAR', 'ر.س'], '', $value));\n if ($value === '' || ! is_numeric($value)) {\n return null;\n }\n return (float) $value;\n }\n\n private function trimTrailingEmptyCells(array $row): array\n {\n while (! empty($row) && trim((string) end($row)) === '') {\n array_pop($row);\n }\n return array_values($row);\n }\n\n private function isTotalOrHeaderRow(string $description): bool\n {\n $d = mb_strtolower(trim($description));\n\n if ($d === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $d)) {\n return true;\n }\n\n // Section totals / summaries\n if (preg_match('/(اجمالي|إجمالي|المجموع|grand\\s*total|sub\\s*total|total\\s*amount|total\\s*price)/iu', $d)) {\n return true;\n }\n\n // Bare \"Total\" lines, optionally with a currency suffix, e.g. \"Total\",\n // \"Total (SAR)\", \"Total cost\", \"Total (SAR) unit cost\".\n if (preg_match('/^total\\b[\\s\\(\\):a-z]*$/iu', $d)) {\n return true;\n }\n\n // Pure floor / level labels used as section headers\n if (preg_match('/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor)\\s*$/iu', $d)) {\n return true;\n }\n\n // Division / section header lines (e.g. \"Division - 02: Wood Works\")\n if (preg_match('/^division\\s*([-:]|\\d)/iu', $d)) {\n return true;\n }\n\n return false;\n }\n\n /**\n * Detect dedicated summary / cost-abstract sheets by their name. These hold\n * per-discipline rollups and grand totals (e.g. a \"Summary (Dry Cost)\" tab),\n * never procurable supply items, so they are skipped wholesale.\n */\n private function isSummarySheet(string $name): bool\n {\n $n = mb_strtolower(trim($name));\n\n if ($n === '') {\n return false;\n }\n\n return (bool) preg_match(\n '/\\b(summary|abstract|cost\\s*summary|grand\\s*summary|recap(?:itulation)?|bill\\s*summary|boq\\s*summary|dry\\s*cost)\\b|ملخص|الملخص|الملخّص|ملخّص|اجمالي|إجمالي/iu',\n $n\n );\n }\n\n private function isFloorBreakdownRow(string $itemNo, string $description): bool\n {\n // A row whose Ref column is empty or a dash is a sub-breakdown row (per-floor split).\n $ref = trim($itemNo);\n if ($ref === '' || $ref === '-') {\n // Confirm by checking the description is a location/floor label\n $d = mb_strtolower(trim($description));\n return (bool) preg_match(\n '/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor|\\d+f)\\s*$/iu',\n $d\n );\n }\n return false;\n }\n\n private function rejectedRow(string $description, string $unit, ?float $quantity, string $reason, string $itemNo = '', string $sheet = ''): array\n {\n return [\n 'description' => $description,\n 'quantity' => $quantity ?? 0,\n 'unit' => $unit,\n 'category' => $sheet,\n 'brand' => '',\n 'status' => 'rejected',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 0.0,\n 'raw_data' => [\n 'sheet' => $sheet,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'extraction_type' => 'rejected',\n 'rejection_reason' => $reason,\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function parseBoqWithDeepSeek(UploadedFile|string $file, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured. Please set DEEPSEEK_API_KEY in .env file.');\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n $mime = $this->mimeForExtension($ext);\n\n try {\n // ── Spreadsheets ──────────────────────────────────────────────────\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n $text = $this->spreadsheetToCompactCsvText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not convert spreadsheet to readable text.');\n }\n $text = $this->sanitizeUtf8(mb_substr($text, 0, 180000));\n $userContent = \"BOQ spreadsheet converted to CSV:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── PDF ───────────────────────────────────────────────────────────\n if ($ext === 'pdf') {\n $extracted = $this->extractPdfText($absPath);\n if ($extracted !== null && mb_strlen(trim($extracted)) > 50) {\n $extracted = $this->sanitizeUtf8(mb_substr($extracted, 0, 180000));\n $userContent = \"BOQ PDF extracted text:\\n\\n\" . $extracted . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'application/pdf');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n return $this->failure('Could not extract text from the PDF. Please make sure it is a text-based PDF (not a scanned image), or convert it to Excel or CSV.');\n }\n\n // -- Word documents (docx) ---------------------------------------------------\n if (in_array($ext, ['docx', 'doc'], true)) {\n $text = $this->extractDocxText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not extract text from the Word document. Please convert it to Excel, CSV, or PDF.');\n }\n $text = $this->sanitizeUtf8(mb_substr($text, 0, 180000));\n $userContent = \"BOQ Word document extracted text:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── Images ──────────────────────────────────────────────────────────────\n // DeepSeek cannot receive images. A vision-capable AI processes the image\n // directly (no OCR) — configure GROQ_API_KEY or GEMINI_API_KEY in .env.\n $imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif', 'heic', 'heif'];\n if (in_array($ext, $imageExts, true)) {\n $bytes = (string) file_get_contents($absPath);\n return $this->callDeepSeekVision($bytes, $mime, $context);\n }\n\n return $this->failure('Unsupported file type. Please upload an Excel, CSV, PDF, or image file.');\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: DeepSeek fallback failed.', ['message' => $e->getMessage()]);\n return $this->failure('An error occurred while processing the file with AI: ' . $e->getMessage());\n }\n }\n\n private function deepSeekModel(): string\n {\n return (string) config('services.deepseek.model', 'deepseek-chat');\n }\n\n /**\n * Remove or replace any non-UTF-8 / malformed byte sequences so json_encode never fails.\n */\n private function sanitizeUtf8(string $text): string\n {\n // Replace invalid byte sequences with the UTF-8 replacement character\n $clean = mb_convert_encoding($text, 'UTF-8', 'UTF-8');\n // Strip any remaining non-printable control characters (except tab/newline/CR)\n $clean = (string) preg_replace('/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]/u', '', $clean);\n return $clean;\n }\n\n /**\n * Extract plain text from a Word docx file using ZipArchive (no external library needed).\n * Reads word/document.xml and strips XML tags, preserving paragraph and table structure.\n */\n private function extractDocxText(string $absPath): ?string\n {\n try {\n $zip = new \\ZipArchive();\n if ($zip->open($absPath) !== true) {\n return null;\n }\n\n $xml = $zip->getFromName('word/document.xml');\n $zip->close();\n\n if ($xml === false || $xml === '') {\n return null;\n }\n\n // Add newlines before paragraph/table-row/table-cell tags so structure is preserved\n $xml = preg_replace('/<w:p[ >]/', \"\\n<w:p>\", $xml) ?? $xml;\n $xml = preg_replace('/<w:tr[ >]/', \"\\n<w:tr>\", $xml) ?? $xml;\n $xml = preg_replace('/<\\/w:tc>/', \"\\t\", $xml) ?? $xml;\n\n $text = strip_tags($xml);\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n return trim($text) !== '' ? trim($text) : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: DOCX parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Extract plain text from a PDF using smalot/pdfparser (pure PHP, no binary required).\n */\n private function extractPdfText(string $absPath): ?string\n {\n try {\n $parser = new \\Smalot\\PdfParser\\Parser();\n $pdf = $parser->parseFile($absPath);\n $text = $pdf->getText();\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n return $text !== '' ? $text : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: PDF parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Send a vision request to a vision-capable AI model.\n * DeepSeek's commercial API (api.deepseek.com) does NOT support image inputs — any model.\n * Priority order (first configured key wins):\n * 1. GROQ_API_KEY → Groq llama-4-scout (free: https://console.groq.com)\n * 2. GEMINI_API_KEY → Google Gemini Flash (free: https://aistudio.google.com/apikey)\n * 3. VISION_API_KEY → any OpenAI-compatible endpoint (OpenAI, OpenRouter …)\n */\n private function callDeepSeekVision(string $bytes, string $mime, array $context): array\n {\n // ── 1. Groq (free, reliable vision) ─────────────────────────────────────\n if ((string) config('services.groq.key', '') !== '') {\n $visionKey = (string) config('services.groq.key');\n $visionBaseUrl = 'https://api.groq.com/openai/v1';\n $visionModel = (string) config('services.groq.model', 'meta-llama/llama-4-scout-17b-16e-instruct');\n }\n // ── 2. Google Gemini ─────────────────────────────────────────────────────\n elseif ((string) config('services.gemini.key', '') !== '') {\n $visionKey = (string) config('services.gemini.key');\n $visionBaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai';\n $visionModel = (string) config('services.gemini.model', 'gemini-2.0-flash');\n }\n // ── 3. Generic VISION_API_KEY (OpenAI, OpenRouter, …) ───────────────────\n elseif ((string) config('services.vision.key', '') !== '') {\n $visionKey = (string) config('services.vision.key');\n $visionBaseUrl = rtrim((string) config('services.vision.base_url', 'https://openrouter.ai/api/v1'), '/');\n $visionModel = (string) config('services.vision.model', 'google/gemini-flash-1.5-8b');\n }\n else {\n return $this->failure(\n 'Image processing is not available. DeepSeek\\'s API does not support images. ' .\n 'Get a free key at https://console.groq.com and add GROQ_API_KEY to your .env file.'\n );\n }\n\n $dataUrl = \"data:{$mime};base64,\" . base64_encode($bytes);\n $prompt = $this->buildDeepSeekPrompt($context, $mime);\n\n $userContent = [\n ['type' => 'text', 'text' => $prompt],\n ['type' => 'image_url', 'image_url' => ['url' => $dataUrl]],\n ];\n\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders([\n 'Authorization' => 'Bearer ' . $visionKey,\n 'HTTP-Referer' => config('app.url', 'https://qimta.sa'),\n 'X-Title' => 'Qimta Platform',\n ])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post($visionBaseUrl . '/chat/completions', [\n 'model' => $visionModel,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 16384,\n 'temperature' => 0.2,\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('Vision API connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: Vision API HTTP error.', [\n 'model' => $visionModel,\n 'base_url' => $visionBaseUrl,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n return $this->failure(\"Vision API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n return $this->parseDeepSeekJsonResponse((string) $response->json('choices.0.message.content'));\n }\n\n private function callDeepSeekChat(string $userContent, string $key, string $model): array\n {\n // Ensure the PHP script outlives the HTTP call (+ a margin for parsing the\n // response and writing to the DB) so the worker/web request is not killed\n // mid-flight, which surfaces to users as a generic \"timeout or unavailable\".\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders(['Authorization' => 'Bearer ' . $key])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 32768,\n 'temperature' => 0.1,\n 'response_format' => ['type' => 'json_object'],\n 'user' => 'Qimta_Platform',\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('DeepSeek connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: DeepSeek HTTP error.', [\n 'model' => $model,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n if ($response->status() === 401) {\n return $this->failure('DeepSeek API key is invalid or unauthorised. Please update DEEPSEEK_API_KEY in .env.', false);\n }\n return $this->failure(\"DeepSeek API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n $text = (string) $response->json('choices.0.message.content');\n return $this->parseDeepSeekJsonResponse($text);\n }\n\n /**\n * Shared JSON response parser used by both chat and vision paths.\n */\n private function parseDeepSeekJsonResponse(string $text): array\n {\n if ($text === '') {\n return $this->failure('DeepSeek returned an empty response.');\n }\n\n // Strip markdown code fences if the model wrapped the JSON.\n if (preg_match('/```(?:json)?\\s*([\\s\\S]*?)\\s*```/i', $text, $m)) {\n $text = $m[1];\n }\n // Narrow to the outermost JSON object/array.\n if (preg_match('/[\\{\\[][\\s\\S]*[\\}\\]]/s', $text, $m)) {\n $text = $m[0];\n }\n $text = trim($text);\n\n $decoded = json_decode($text, true);\n\n // The response can be truncated when the model hits the token limit,\n // leaving an unterminated JSON string/object. Try to repair it before\n // giving up so we can still salvage the items extracted so far.\n if (! is_array($decoded)) {\n $repaired = $this->repairTruncatedJson($text);\n if ($repaired !== null) {\n $decoded = json_decode($repaired, true);\n }\n }\n\n if (! is_array($decoded)) {\n Log::error('QuotationAiService: DeepSeek non-JSON response.', ['text' => mb_substr($text, 0, 1000)]);\n return $this->failure('DeepSeek returned a non-JSON response. The file may be too large — try splitting it into smaller files.');\n }\n\n $rawItems = $decoded['items'] ?? (array_is_list($decoded) ? $decoded : []);\n $items = [];\n $rejected = [];\n\n foreach ($rawItems as $raw) {\n if (! is_array($raw)) {\n continue;\n }\n $item = $this->normaliseAiItem($raw);\n if ($item === null) {\n continue;\n }\n $check = $this->boqCleaner->filterItem((string) $item['description']);\n if ($check['keep'] ?? false) {\n $items[] = $item;\n } else {\n $item['status'] = 'rejected';\n $item['raw_data']['rejection_reason'] = $check['rejection_reason'] ?? 'Rejected by cleaner';\n $rejected[] = $item;\n }\n }\n\n if (empty($items)) {\n Log::info('QuotationAiService: DeepSeek returned 0 accepted items.', [\n 'raw_items_count' => count($rawItems),\n 'rejected_count' => count($rejected),\n 'first_rejected' => ! empty($rejected) ? ($rejected[0]['description'] ?? '') : '',\n ]);\n }\n\n return ['success' => true, 'items' => $items, 'rejected' => $rejected, 'error' => null];\n }\n\n /**\n * Best-effort repair of a truncated JSON string produced when the model hits\n * its token limit mid-output. Drops any incomplete trailing object, then\n * closes the open string/array/object brackets so the result is valid JSON.\n *\n * Returns the repaired JSON string, or null if it cannot be salvaged.\n */\n private function repairTruncatedJson(string $text): ?string\n {\n if ($text === '') {\n return null;\n }\n\n // Walk the string tracking structural state, ignoring everything inside\n // string literals (and their escapes). Remember the last position where\n // we were back at the top of the \"items\" array between complete elements,\n // so we can cut a half-written trailing object.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($text);\n $lastSafeCut = null; // index just after a completed array element\n\n for ($i = 0; $i < $len; $i++) {\n $ch = $text[$i];\n\n if ($inString) {\n if ($escaped) {\n $escaped = false;\n } elseif ($ch === '\\\\') {\n $escaped = true;\n } elseif ($ch === '\"') {\n $inString = false;\n }\n continue;\n }\n\n if ($ch === '\"') {\n $inString = true;\n } elseif ($ch === '{' || $ch === '[') {\n $stack[] = $ch;\n } elseif ($ch === '}' || $ch === ']') {\n array_pop($stack);\n } elseif ($ch === ',' && ! empty($stack) && end($stack) === '[') {\n // Separator directly inside an array: a clean boundary between\n // elements (the items array, whatever its nesting depth).\n $lastSafeCut = $i;\n }\n }\n\n // If we are still inside a string, the last element is incomplete — fall\n // back to the last clean array boundary if we have one.\n $candidate = $text;\n if ($inString && $lastSafeCut !== null) {\n $candidate = substr($text, 0, $lastSafeCut);\n } else {\n // Drop a dangling partial object after the last completed element.\n $candidate = rtrim($candidate);\n if ($lastSafeCut !== null && substr(rtrim($candidate), -1) !== '}' && substr(rtrim($candidate), -1) !== ']') {\n $candidate = substr($text, 0, $lastSafeCut);\n }\n }\n\n // Recompute the open brackets for the (possibly trimmed) candidate and\n // append the matching closers in reverse order.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($candidate);\n for ($i = 0; $i < $len; $i++) {\n $ch = $candidate[$i];\n if ($inString) {\n if ($escaped) { $escaped = false; }\n elseif ($ch === '\\\\') { $escaped = true; }\n elseif ($ch === '\"') { $inString = false; }\n continue;\n }\n if ($ch === '\"') { $inString = true; }\n elseif ($ch === '{' || $ch === '[') { $stack[] = $ch; }\n elseif ($ch === '}' || $ch === ']') { array_pop($stack); }\n }\n\n $candidate = rtrim($candidate);\n // Remove a trailing comma left after cutting an element.\n $candidate = rtrim($candidate, ',');\n\n $closers = '';\n for ($i = count($stack) - 1; $i >= 0; $i--) {\n $closers .= ($stack[$i] === '{') ? '}' : ']';\n }\n\n $repaired = $candidate . $closers;\n\n // Validate before returning.\n return is_array(json_decode($repaired, true)) ? $repaired : null;\n }\n\n private function spreadsheetToCompactCsvText(string $absPath): ?string\n {\n $sheets = $this->spreadsheetToGrids($absPath);\n if (empty($sheets)) {\n return null;\n }\n\n $output = '';\n foreach ($sheets as $sheet) {\n // Don't feed summary / cost-abstract sheets to the AI either.\n if ($this->isSummarySheet($sheet['name'])) {\n continue;\n }\n $output .= 'Sheet: ' . $sheet['name'] . \"\\n\";\n foreach ($sheet['grid'] as $row) {\n $row = $this->trimTrailingEmptyCells($row);\n if (empty($row)) {\n continue;\n }\n $output .= implode(',', array_map([$this, 'csvEscape'], $row)) . \"\\n\";\n }\n $output .= \"\\n\";\n }\n\n return $output;\n }\n\n /**\n * Safely convert a PhpSpreadsheet cell value to a string.\n *\n * Cells may contain RichText or, in malformed/heavily-formatted files,\n * embedded objects such as Worksheet\\Drawing that have no __toString().\n * Casting those directly throws \"Object ... could not be converted to string\".\n */\n private function cellValueToString(mixed $value): string\n {\n if ($value === null) {\n return '';\n }\n\n if ($value instanceof \\PhpOffice\\PhpSpreadsheet\\RichText\\RichText) {\n return $value->getPlainText();\n }\n\n if (is_object($value)) {\n // Only stringify objects that explicitly support it; ignore the rest\n // (e.g. Worksheet\\Drawing) instead of crashing.\n return method_exists($value, '__toString') ? (string) $value : '';\n }\n\n if (is_array($value)) {\n return '';\n }\n\n return (string) $value;\n }\n\n private function csvEscape(mixed $value): string\n {\n $value = $this->cellValueToString($value);\n return str_contains($value, ',') || str_contains($value, '\"') || str_contains($value, \"\\n\")\n ? '\"' . str_replace('\"', '\"\"', $value) . '\"'\n : $value;\n }\n\n private function normaliseAiItem(array $raw): ?array\n {\n if (! empty($raw['rejected'])) {\n return null;\n }\n\n $description = trim((string) ($raw['cleaned_description'] ?? $raw['product_name'] ?? $raw['description'] ?? ''));\n if ($description === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $description)) {\n return null;\n }\n\n $quantity = is_numeric($raw['quantity'] ?? null) ? (float) $raw['quantity'] : 1;\n $unitPrice = is_numeric($raw['unit_price'] ?? null) ? (float) $raw['unit_price'] : null;\n $totalPrice = is_numeric($raw['total_price'] ?? null) ? (float) $raw['total_price'] : null;\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $specs = is_array($raw['specifications'] ?? null) ? $raw['specifications'] : [];\n\n return [\n 'description' => $description,\n 'quantity' => $quantity,\n 'unit' => (string) ($raw['unit'] ?? ''),\n 'category' => (string) ($raw['core_product'] ?? $raw['category'] ?? ''),\n 'brand' => (string) ($specs['brand'] ?? $raw['brand'] ?? ''),\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($description),\n 'unit_price' => $unitPrice,\n 'confidence' => is_numeric($raw['confidence'] ?? null) ? (float) $raw['confidence'] : null,\n 'needs_review' => ! empty($raw['needs_review']),\n 'raw_data' => [\n 'source_item_no' => (string) ($raw['source_item_no'] ?? ''),\n 'original_description' => (string) ($raw['original_description'] ?? $description),\n 'cleaned_description' => $description,\n 'core_product' => (string) ($raw['core_product'] ?? ''),\n 'specifications' => $specs,\n 'extraction_type' => (string) ($raw['extraction_type'] ?? 'supply_only'),\n 'note' => (string) ($raw['note'] ?? $raw['notes'] ?? ''),\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function buildSystemInstruction(): string\n {\n return <<<'TXT'\nYou are a strict BOQ Supply Product Extraction Engine.\nExtract ONLY concrete, tangible, physical products that a supplier can box up and deliver to site — a specific item you could point at and say \"this is the thing being bought\" (e.g. \"Wall mounted LED luminaire 36W\", \"PVC pipe 110mm\", \"Single phase distribution board 12-way\", \"Fire extinguisher CO2 6kg\", \"CCTV dome camera 4MP\").\n\nABSOLUTELY REJECT abstract concepts, disciplines, categories, section names, and trade headings. These are NOT products even though they appear as rows:\n- Bare discipline/trade words: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", \"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\".\n- Summary / total / abstract / \"dry cost\" rows of any kind.\n- Anything that names a system, scope, or category instead of one buyable item.\n\nRule of thumb: if you cannot name a unit and physically hand the item over (a piece, a box, a length, a unit of equipment), REJECT it. A word that describes a field of work, a system, or a section of the BOQ is NEVER a product.\nReject totals, headings, general requirements, installation-only, testing, commissioning, supervision, mobilization, civil works, labor, and vague rows.\nFor Supply & Install rows, keep only the supply product and remove install/labor wording.\nReturn only valid JSON: {\"items\":[{\"source_item_no\":null,\"original_description\":\"\",\"cleaned_description\":\"\",\"core_product\":\"\",\"specifications\":{},\"quantity\":0,\"unit\":\"\",\"unit_price\":0,\"total_price\":0,\"extraction_type\":\"supply_only\",\"needs_review\":false,\"note\":\"\",\"rejected\":false,\"rejection_reason\":null,\"confidence\":0.95}]}\nTXT;\n }\n\n private function buildDeepSeekPrompt(array $context = [], string $mime = ''): string\n {\n $projectName = (string) ($context['project_name'] ?? '');\n $projectStatus = (string) ($context['project_status'] ?? '');\n $contextLine = $projectName !== ''\n ? \"Project: {$projectName}\" . ($projectStatus !== '' ? \" ({$projectStatus})\" : '')\n : '';\n $sourceHint = str_starts_with($mime, 'image/')\n ? 'The input is an image of a BOQ table. OCR every cell carefully — read the quantity/count column (العدد, الكمية, Qty) and extract the EXACT numeric value from each row.'\n : 'The input is a BOQ document/spreadsheet.';\n\n return trim(<<<PROMPT\nYou are a procurement specialist. Your task is to extract ONLY tangible, physical supply products from this Bill of Quantities (BOQ).\n\n{$contextLine}\n{$sourceHint}\n\nCRITICAL — MERGE WRAPPED / SPLIT ROWS INTO ONE ITEM:\nBOQ tables extracted from PDF often break ONE item's long description across several\nlines/rows. These continuation lines repeat the SAME quantity, unit and category, and\ntheir text is a grammatical continuation of the previous line (it starts mid-sentence,\nlowercase, or with \"the/and/-\", or is a spec/instruction clause). You MUST reconstruct\nthe original single item:\n- Treat consecutive rows that share the SAME quantity + unit + category as ONE product\n when the later rows are clearly continuation text of the first, NOT new products.\n- Emit ONE item with the merged description and the price counted ONCE. Do NOT repeat\n the same quantity×price for every wrapped line — that multiplies the total wrongly.\n- Example: rows \"The asphalt flooring is to be supplied\" / \"installed in accordance\" /\n \"standard road specifications...\" all with 7,100 m2 are ONE asphalt item, not 3+.\n\nSTRICT RULES — include ONLY items that are ALL of the following:\n1. A specific, concrete, tangible product that can be boxed up by a supplier and delivered to site — something you can point at and say \"this exact thing is being bought\" (e.g. \"LED luminaire 36W\", \"PVC pipe 110mm\", \"Distribution board 12-way\", \"CO2 fire extinguisher 6kg\").\n2. Has a real quantity (not 0 or \"varies\" with no meaning).\n3. Has enough description to identify what the product is.\n4. Is an actual PRODUCT, not a sentence, instruction, or specification clause. REJECT rows that are prose such as \"the supervising engineer's instructions\", \"The price includes all materials, labor\", \"Measurement will be based on the geometric cubic meter\", \"necessary steps to complete the work\", \"soil-contacting elements\", \"in compliance\", \"everything necessary\". These are description fragments, not buyable items.\n\nNEVER extract abstract concepts, disciplines, trades, systems, or section/category names — they are NOT products even when they appear as their own row:\n- Bare discipline/trade/system words such as: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", and their Arabic equivalents (\"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\", \"تشطيبات\").\n- Test: if the word names a FIELD OF WORK or a SYSTEM rather than one buyable item with a unit (pcs, m, set, kg…), REJECT it.\n\nEXCLUDE all of the following (do NOT include them):\n- Labor, installation, or workmanship items (e.g. \"install\", \"fix\", \"erect\", \"dismantle\", \"painting\", \"cleaning service\")\n- Project management, overhead, or profit items\n- Insurance, bonds, liability items (e.g. \"bodily injury\", \"third party liability\", \"CAR insurance\")\n- Shop drawings, as-built drawings, method statements, documentation\n- Preliminary items, mobilisation, demobilisation, provisional sums\n- Housekeeping, site clearance, waste removal\n- Floor/level labels (e.g. \"GF Floor\", \"Level 3\") — these are headers, not products\n- Pure dimension specs without a product noun (e.g. \"Dia 32 mm\", \"25 mm\") — reject unless a product name is present\n- Security services, night shifts, rentals, payments\n- Any item whose description is too vague or fragmentary to identify a specific product\n\nFor each valid physical product return JSON with:\n- description: clean product name only (strip \"supply and install\", \"supply and fix\", installation clauses, floor/spec suffixes)\n- quantity: the EXACT numeric value from the quantity/count column (العدد, الكمية, Qty, Quantity, No.). NEVER default to 1 unless the quantity column is truly absent or empty.\n- unit: unit of measure (pcs, m, m2, m3, set, no, kg, etc.)\n- category: product category\n- brand: brand/manufacturer if mentioned, else \"\"\n- engineering_required: true if the item needs engineering design before procurement (panel boards, chillers, generators, fire suppression systems, BMS, pumps, AHUs, switchgear, transformers), else false\n\nReturn ONLY a valid JSON object: {\"items\": [...]}. No explanations, no markdown, no extra text.\nPROMPT);\n }\n\n private function mockResponse(): array\n {\n return [\n 'success' => true,\n 'error' => null,\n 'items' => [[\n 'description' => 'Sample BOQ Item',\n 'quantity' => 1,\n 'unit' => 'No',\n 'category' => 'Sample',\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 1,\n 'raw_data' => [],\n 'ai_extracted' => true,\n ]],\n 'rejected' => [],\n ];\n }\n\n private function failure(string $message, bool $serviceUnavailable = false): array\n {\n return [\n 'success' => false,\n 'items' => [],\n 'rejected' => [],\n 'error' => $this->userFacingError($message, $serviceUnavailable),\n 'service_unavailable' => $serviceUnavailable,\n ];\n }\n\n /**\n * Translate any internal error into a neutral, user-safe message.\n *\n * The user must never see the name of the underlying AI/processing engine\n * (DeepSeek, Groq, Gemini, Vision, etc.) or configuration hints like API\n * keys. The detailed message is kept in the logs for debugging only.\n */\n private function userFacingError(string $message, bool $serviceUnavailable = false): string\n {\n // Detect any reference to an AI provider / engine / API config so we can\n // swap it for a generic, branded-neutral message.\n $sensitive = '/deepseek|groq|gemini|vision|openai|openrouter|api[\\s_-]?key|\\.env|non-?json|token limit/i';\n\n if (preg_match($sensitive, $message)) {\n Log::warning('QuotationAiService: suppressed technical error from user.', ['internal' => $message]);\n\n return $serviceUnavailable\n ? 'The file could not be processed right now. Please try again in a moment.'\n : 'We could not read this file automatically. Please make sure it is a clear BOQ (Excel, CSV, PDF, or image) and try again, or add the items manually.';\n }\n\n return $message;\n }\n\n private function mimeForExtension(string $ext): string\n {\n return match (strtolower($ext)) {\n 'pdf' => 'application/pdf',\n 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n 'xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12',\n 'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',\n 'xls' => 'application/vnd.ms-excel',\n 'csv' => 'text/csv',\n 'jpg', 'jpeg' => 'image/jpeg',\n 'png' => 'image/png',\n 'gif' => 'image/gif',\n 'bmp' => 'image/bmp',\n 'webp' => 'image/webp',\n 'tiff', 'tif' => 'image/tiff',\n 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'doc' => 'application/msword',\n default => 'application/octet-stream',\n };\n }\n}\n"}, "c45560667a217d6e69c4f825fad38b73e29b7a8b": {"t": 1785748387.7459095, "n": 53324, "h": "7798536dc9d018ab", "c": "<?php\n\nnamespace App\\Services;\n\nuse Illuminate\\Http\\Client\\ConnectionException;\nuse Illuminate\\Http\\UploadedFile;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\nuse PhpOffice\\PhpSpreadsheet\\Cell\\Coordinate;\nuse PhpOffice\\PhpSpreadsheet\\IOFactory;\n\nclass QuotationAiService\n{\n /** Increment this whenever BOQ parsing logic changes to invalidate old caches. */\n private const PARSER_VERSION = 4;\n\n private string $baseUrl;\n private string $parseEndpoint;\n private string $apiKey;\n private int $timeout;\n private bool $testMode;\n private BoqCleaningService $boqCleaner;\n\n public function __construct(BoqCleaningService $boqCleaner)\n {\n $this->boqCleaner = $boqCleaner;\n $this->baseUrl = rtrim((string) config('services.ai_quotation.base_url', ''), '/');\n $this->parseEndpoint = ltrim((string) config('services.ai_quotation.parse_endpoint', 'parse'), '/');\n $this->apiKey = (string) config('services.ai_quotation.api_key', '');\n $this->timeout = (int) config('services.ai_quotation.timeout', 300);\n $this->testMode = (bool) config('services.ai_quotation.test_mode', false);\n }\n\n /**\n * Give the running script enough head-room to outlast the AI HTTP call.\n *\n * The web request / queue worker must not be killed before the HTTP timeout\n * completes, otherwise the user sees a generic \"timeout or unavailable\"\n * error even though the AI may have responded. We allow the HTTP timeout\n * plus a 120s margin for connecting, response parsing and DB writes.\n */\n private function extendExecutionTime(): void\n {\n // Ignored on some SAPIs (e.g. when running under fastcgi with a hard\n // limit), but harmless to attempt.\n @set_time_limit($this->timeout + 120);\n }\n\n public function parseBoq(UploadedFile|string $file, array $context = []): array\n {\n if ($this->testMode) {\n return $this->mockResponse();\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n\n if (! is_file($absPath)) {\n return $this->failure('File not found or not readable.');\n }\n\n $fileHash = hash_file('sha256', $absPath);\n // Bump PARSER_VERSION whenever the extraction logic changes so that\n // previously-uploaded files are re-parsed instead of returning a stale\n // cached result produced by the old code.\n $cacheKey = 'boq_analysis_v' . self::PARSER_VERSION . '_' . $fileHash;\n $forceRefresh = (bool) ($context['force_refresh'] ?? false);\n\n if (! $forceRefresh && ($cached = Cache::get($cacheKey)) !== null) {\n return $cached;\n }\n\n // Clear stale cached result when force-refreshing\n if ($forceRefresh) {\n Cache::forget($cacheKey);\n }\n\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n // Read Excel locally first. Fall back to AI if local parse failed,\n // found 0 items, OR could not read one of the sheets (so multi-sheet\n // files never silently drop a sheet the local parser didn't understand).\n $result = $this->parseSpreadsheetDirect($absPath);\n\n $hasSkippedSheets = ! empty($result['skipped_sheets']);\n\n if (! $result['success'] || empty($result['items']) || $hasSkippedSheets) {\n $deepSeekResult = $this->parseBoqWithDeepSeek($file, $context);\n // AI sees every sheet at once, so prefer its result whenever it\n // actually returned items (covers the skipped-sheet case too).\n if ($deepSeekResult['success'] && ! empty($deepSeekResult['items'])) {\n // Keep the larger of the two item sets so we never lose the\n // rows the local parser already extracted correctly.\n if (count($deepSeekResult['items']) >= count($result['items'] ?? [])) {\n $result = $deepSeekResult;\n }\n } elseif (! $result['success']) {\n $result = $deepSeekResult;\n }\n // else: keep local result if AI also failed/returned nothing\n }\n } elseif (in_array($ext, ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif'], true)) {\n $result = $this->parseBoqWithDeepSeek($file, $context);\n } else {\n return $this->failure('Unsupported file type. Please upload Excel, CSV, PDF, or image file.');\n }\n\n if ($result['success'] && ! empty($result['items'])) {\n $cleaning = $this->boqCleaner->process($result['items']);\n $result['items'] = $cleaning['accepted'] ?? $result['items'];\n $result['rejected'] = array_merge($result['rejected'] ?? [], $cleaning['rejected'] ?? []);\n }\n\n // Only cache when we actually extracted items; empty results should be re-tried.\n if ($result['success'] && ! empty($result['items'])) {\n Cache::put($cacheKey, $result, now()->addDays(30));\n }\n\n return $result;\n }\n\n private function resolveFile(UploadedFile|string $file): array\n {\n if ($file instanceof UploadedFile) {\n return [(string) $file->getRealPath(), strtolower($file->getClientOriginalExtension())];\n }\n\n $absPath = is_file($file) ? $file : storage_path('app/' . ltrim($file, '/'));\n return [$absPath, strtolower(pathinfo($absPath, PATHINFO_EXTENSION))];\n }\n\n private function parseSpreadsheetDirect(string $absPath): array\n {\n ini_set('memory_limit', '1024M');\n set_time_limit(180);\n\n try {\n $sheets = $this->spreadsheetToGrids($absPath);\n\n if (empty($sheets)) {\n return $this->failure('Could not read the Excel file.');\n }\n\n $items = [];\n $rejected = [];\n $skippedSheets = []; // sheets we could not parse locally (header undetected)\n\n foreach ($sheets as $sheet) {\n $grid = $sheet['grid'];\n if (count($grid) < 2) {\n continue;\n }\n\n // Skip dedicated summary / cost-abstract sheets entirely. Their rows\n // are per-discipline totals (Structural Concrete, Electrical, …) and\n // grand totals, not procurable products.\n if ($this->isSummarySheet($sheet['name'])) {\n Log::info('QuotationAiService: skipped summary sheet.', ['sheet' => $sheet['name']]);\n continue;\n }\n\n $header = $this->detectHeader($grid);\n\n if ($header === null || ! isset($header['map']['description'])) {\n Log::warning('QuotationAiService: header not detected in sheet.', ['sheet' => $sheet['name']]);\n $skippedSheets[] = $sheet['name'];\n continue;\n }\n\n $map = $header['map'];\n $start = $header['row'] + 1;\n\n for ($r = $start; $r < count($grid); $r++) {\n $row = $grid[$r];\n $description = $this->cell($row, $map['description'] ?? null);\n $itemNo = $this->cell($row, $map['item_code'] ?? null);\n $unit = $this->cell($row, $map['unit'] ?? null);\n $quantity = $this->number($this->cell($row, $map['quantity'] ?? null));\n $unitPrice = $this->number($this->cell($row, $map['unit_price'] ?? null));\n $totalPrice = $this->number($this->cell($row, $map['total_price'] ?? null));\n\n if ($description === '') {\n continue;\n }\n\n if (! isset($map['quantity'])) {\n $quantity = 1.0;\n }\n\n // Skip floor breakdown sub-rows: Ref. is \"-\" meaning it's a per-floor\n // quantity split of the parent item, not a standalone product.\n if ($itemNo === '-' || $this->isFloorBreakdownRow($itemNo, $description)) {\n continue;\n }\n\n if ($this->isTotalOrHeaderRow($description)) {\n continue;\n }\n\n if ($quantity === null || $quantity <= 0) {\n $rejected[] = $this->rejectedRow($description, $unit, $quantity, 'Missing quantity', $itemNo, $sheet['name']);\n continue;\n }\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $supply = $this->boqCleaner->filterItem($description);\n\n if (! ($supply['keep'] ?? false)) {\n $rejected[] = $this->rejectedRow(\n $description,\n $unit,\n $quantity,\n (string) ($supply['rejection_reason'] ?? 'Rejected by cleaner'),\n $itemNo,\n $sheet['name']\n );\n continue;\n }\n\n $finalDescription = (string) ($supply['description'] ?? $description);\n\n $items[] = [\n 'description' => $finalDescription,\n 'quantity' => $quantity,\n 'unit' => $unit,\n 'category' => $sheet['name'],\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($finalDescription),\n 'unit_price' => $unitPrice,\n 'confidence' => 0.95,\n 'needs_review' => false,\n 'raw_data' => [\n 'sheet' => $sheet['name'],\n 'row_number' => $r + 1,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'cleaned_description' => $finalDescription,\n 'extraction_type' => (string) ($supply['extraction_type'] ?? 'supply_only'),\n ],\n 'ai_extracted' => true,\n ];\n }\n }\n\n if (empty($items)) {\n return $this->failure('No BOQ supply items could be extracted from the Excel file.');\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'skipped_sheets' => $skippedSheets,\n ];\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: parseSpreadsheetDirect failed.', [\n 'path' => $absPath,\n 'message' => $e->getMessage(),\n 'trace' => $e->getTraceAsString(),\n ]);\n\n return $this->failure('Failed to read the Excel file: ' . $e->getMessage());\n }\n }\n\n private function spreadsheetToGrids(string $absPath): array\n {\n $ext = strtolower(pathinfo($absPath, PATHINFO_EXTENSION));\n\n if ($ext === 'csv') {\n $rows = [];\n $handle = fopen($absPath, 'rb');\n if ($handle !== false) {\n while (($row = fgetcsv($handle)) !== false) {\n $rows[] = $this->trimTrailingEmptyCells($row);\n }\n fclose($handle);\n }\n return [['name' => 'Sheet1', 'grid' => $rows]];\n }\n\n $reader = IOFactory::createReader($ext === 'xls' ? 'Xls' : 'Xlsx');\n $reader->setReadDataOnly(true);\n $spreadsheet = $reader->load($absPath);\n $sheets = [];\n\n foreach ($spreadsheet->getAllSheets() as $sheet) {\n $highestRow = $sheet->getHighestDataRow();\n $highestColumn = $sheet->getHighestDataColumn();\n $highestColumnIndex = Coordinate::columnIndexFromString($highestColumn);\n\n // BOQ data is normally in first 8-20 columns.\n // Files with heavy formatting/images can report 300+ columns — limit to 30.\n $maxColumnIndex = min($highestColumnIndex, 30);\n\n $grid = [];\n for ($row = 1; $row <= $highestRow; $row++) {\n $rowData = [];\n $hasValue = false;\n\n for ($col = 1; $col <= $maxColumnIndex; $col++) {\n $value = $sheet->getCell([$col, $row])->getValue();\n $value = $this->cellValueToString($value);\n $value = trim($value);\n if ($value !== null && $value !== '') {\n $hasValue = true;\n }\n $rowData[] = $value;\n }\n\n $rowData = $this->trimTrailingEmptyCells($rowData);\n if ($hasValue) {\n $grid[] = $rowData;\n }\n }\n\n if (! empty($grid)) {\n $sheets[] = ['name' => $sheet->getTitle(), 'grid' => $grid];\n }\n }\n\n $spreadsheet->disconnectWorksheets();\n unset($spreadsheet);\n\n return $sheets;\n }\n\n private function detectHeader(array $grid): ?array\n {\n $keywords = [\n 'item_code' => ['رقم البند', 'item no', 'item number', 'ref.', 'ref', 'item', 'no.', 'no', '#', 'كود'],\n 'description' => ['وص٠البند', 'scope of works', 'scope of work', 'scope', 'item description', 'description of works', 'description', 'بالعربية', 'الوصÙ', 'البند', 'بيان'],\n 'unit' => ['Ø§Ù„ÙˆØØ¯Ø©', 'unit', 'uom', 'u/m'],\n 'quantity' => ['الكمية', 'qty', 'quantity', 'q.ty'],\n 'unit_price' => ['سعر Ø§Ù„ÙˆØØ¯Ø©', 'u/price', 'unit price', 'unit rate', 'rate', 'price'],\n 'total_price' => ['السعر الإجمالي', 'total amount', 'total price', 'amount', 'الإجمالي'],\n ];\n\n $best = null;\n $bestScore = 0;\n\n for ($r = 0; $r < min(30, count($grid)); $r++) {\n $map = [];\n $score = 0;\n\n foreach ($grid[$r] as $c => $value) {\n $cell = mb_strtolower(trim((string) $value));\n if ($cell === '') {\n continue;\n }\n\n foreach ($keywords as $field => $list) {\n foreach ($list as $kw) {\n if (str_contains($cell, mb_strtolower($kw))) {\n if (! isset($map[$field])) {\n $map[$field] = $c;\n $score++;\n }\n break 2;\n }\n }\n }\n }\n\n if ($score > $bestScore) {\n $bestScore = $score;\n $best = ['row' => $r, 'map' => $map];\n }\n }\n\n if ($bestScore >= 3) {\n return $best;\n }\n\n // Allow simpler BOQ sheets that only define a description column plus one\n // other BOQ-related column, e.g. Item + Description or Description + Qty.\n if ($bestScore === 2 && isset($best['map']['description']) && (\n isset($best['map']['item_code']) ||\n isset($best['map']['quantity']) ||\n isset($best['map']['unit'])\n )) {\n return $best;\n }\n\n return null;\n }\n\n private function cell(array $row, ?int $index): string\n {\n if ($index === null) {\n return '';\n }\n return trim((string) ($row[$index] ?? ''));\n }\n\n private function number(string $value): ?float\n {\n $value = trim(str_replace([',', 'SAR', 'ر.س'], '', $value));\n if ($value === '' || ! is_numeric($value)) {\n return null;\n }\n return (float) $value;\n }\n\n private function trimTrailingEmptyCells(array $row): array\n {\n while (! empty($row) && trim((string) end($row)) === '') {\n array_pop($row);\n }\n return array_values($row);\n }\n\n private function isTotalOrHeaderRow(string $description): bool\n {\n $d = mb_strtolower(trim($description));\n\n if ($d === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $d)) {\n return true;\n }\n\n // Section totals / summaries\n if (preg_match('/(اجمالي|إجمالي|المجموع|grand\\s*total|sub\\s*total|total\\s*amount|total\\s*price)/iu', $d)) {\n return true;\n }\n\n // Bare \"Total\" lines, optionally with a currency suffix, e.g. \"Total\",\n // \"Total (SAR)\", \"Total cost\", \"Total (SAR) unit cost\".\n if (preg_match('/^total\\b[\\s\\(\\):a-z]*$/iu', $d)) {\n return true;\n }\n\n // Pure floor / level labels used as section headers\n if (preg_match('/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor)\\s*$/iu', $d)) {\n return true;\n }\n\n // Division / section header lines (e.g. \"Division - 02: Wood Works\")\n if (preg_match('/^division\\s*([-:]|\\d)/iu', $d)) {\n return true;\n }\n\n return false;\n }\n\n /**\n * Detect dedicated summary / cost-abstract sheets by their name. These hold\n * per-discipline rollups and grand totals (e.g. a \"Summary (Dry Cost)\" tab),\n * never procurable supply items, so they are skipped wholesale.\n */\n private function isSummarySheet(string $name): bool\n {\n $n = mb_strtolower(trim($name));\n\n if ($n === '') {\n return false;\n }\n\n return (bool) preg_match(\n '/\\b(summary|abstract|cost\\s*summary|grand\\s*summary|recap(?:itulation)?|bill\\s*summary|boq\\s*summary|dry\\s*cost)\\b|ملخص|الملخص|الملخّص|ملخّص|اجمالي|إجمالي/iu',\n $n\n );\n }\n\n private function isFloorBreakdownRow(string $itemNo, string $description): bool\n {\n // A row whose Ref column is empty or a dash is a sub-breakdown row (per-floor split).\n $ref = trim($itemNo);\n if ($ref === '' || $ref === '-') {\n // Confirm by checking the description is a location/floor label\n $d = mb_strtolower(trim($description));\n return (bool) preg_match(\n '/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor|\\d+f)\\s*$/iu',\n $d\n );\n }\n return false;\n }\n\n private function rejectedRow(string $description, string $unit, ?float $quantity, string $reason, string $itemNo = '', string $sheet = ''): array\n {\n return [\n 'description' => $description,\n 'quantity' => $quantity ?? 0,\n 'unit' => $unit,\n 'category' => $sheet,\n 'brand' => '',\n 'status' => 'rejected',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 0.0,\n 'raw_data' => [\n 'sheet' => $sheet,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'extraction_type' => 'rejected',\n 'rejection_reason' => $reason,\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function parseBoqWithDeepSeek(UploadedFile|string $file, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured. Please set DEEPSEEK_API_KEY in .env file.');\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n $mime = $this->mimeForExtension($ext);\n\n try {\n // ── Spreadsheets ──────────────────────────────────────────────────\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n $text = $this->spreadsheetToCompactCsvText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not convert spreadsheet to readable text.');\n }\n $text = $this->sanitizeUtf8(mb_substr($text, 0, 180000));\n $userContent = \"BOQ spreadsheet converted to CSV:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── PDF ───────────────────────────────────────────────────────────\n if ($ext === 'pdf') {\n $extracted = $this->extractPdfText($absPath);\n if ($extracted !== null && mb_strlen(trim($extracted)) > 50) {\n $extracted = $this->sanitizeUtf8(mb_substr($extracted, 0, 180000));\n $userContent = \"BOQ PDF extracted text:\\n\\n\" . $extracted . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'application/pdf');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n return $this->failure('Could not extract text from the PDF. Please make sure it is a text-based PDF (not a scanned image), or convert it to Excel or CSV.');\n }\n\n // -- Word documents (docx) ---------------------------------------------------\n if (in_array($ext, ['docx', 'doc'], true)) {\n $text = $this->extractDocxText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not extract text from the Word document. Please convert it to Excel, CSV, or PDF.');\n }\n $text = $this->sanitizeUtf8(mb_substr($text, 0, 180000));\n $userContent = \"BOQ Word document extracted text:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── Images ──────────────────────────────────────────────────────────────\n // DeepSeek cannot receive images. A vision-capable AI processes the image\n // directly (no OCR) — configure GROQ_API_KEY or GEMINI_API_KEY in .env.\n $imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif', 'heic', 'heif'];\n if (in_array($ext, $imageExts, true)) {\n $bytes = (string) file_get_contents($absPath);\n return $this->callDeepSeekVision($bytes, $mime, $context);\n }\n\n return $this->failure('Unsupported file type. Please upload an Excel, CSV, PDF, or image file.');\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: DeepSeek fallback failed.', ['message' => $e->getMessage()]);\n return $this->failure('An error occurred while processing the file with AI: ' . $e->getMessage());\n }\n }\n\n private function deepSeekModel(): string\n {\n return (string) config('services.deepseek.model', 'deepseek-chat');\n }\n\n /**\n * Remove or replace any non-UTF-8 / malformed byte sequences so json_encode never fails.\n */\n private function sanitizeUtf8(string $text): string\n {\n // Replace invalid byte sequences with the UTF-8 replacement character\n $clean = mb_convert_encoding($text, 'UTF-8', 'UTF-8');\n // Strip any remaining non-printable control characters (except tab/newline/CR)\n $clean = (string) preg_replace('/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]/u', '', $clean);\n return $clean;\n }\n\n /**\n * Extract plain text from a Word docx file using ZipArchive (no external library needed).\n * Reads word/document.xml and strips XML tags, preserving paragraph and table structure.\n */\n private function extractDocxText(string $absPath): ?string\n {\n try {\n $zip = new \\ZipArchive();\n if ($zip->open($absPath) !== true) {\n return null;\n }\n\n $xml = $zip->getFromName('word/document.xml');\n $zip->close();\n\n if ($xml === false || $xml === '') {\n return null;\n }\n\n // Add newlines before paragraph/table-row/table-cell tags so structure is preserved\n $xml = preg_replace('/<w:p[ >]/', \"\\n<w:p>\", $xml) ?? $xml;\n $xml = preg_replace('/<w:tr[ >]/', \"\\n<w:tr>\", $xml) ?? $xml;\n $xml = preg_replace('/<\\/w:tc>/', \"\\t\", $xml) ?? $xml;\n\n $text = strip_tags($xml);\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n return trim($text) !== '' ? trim($text) : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: DOCX parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Extract plain text from a PDF using smalot/pdfparser (pure PHP, no binary required).\n */\n private function extractPdfText(string $absPath): ?string\n {\n try {\n $parser = new \\Smalot\\PdfParser\\Parser();\n $pdf = $parser->parseFile($absPath);\n $text = $pdf->getText();\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n return $text !== '' ? $text : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: PDF parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Send a vision request to a vision-capable AI model.\n * DeepSeek's commercial API (api.deepseek.com) does NOT support image inputs — any model.\n * Priority order (first configured key wins):\n * 1. GROQ_API_KEY → Groq llama-4-scout (free: https://console.groq.com)\n * 2. GEMINI_API_KEY → Google Gemini Flash (free: https://aistudio.google.com/apikey)\n * 3. VISION_API_KEY → any OpenAI-compatible endpoint (OpenAI, OpenRouter …)\n */\n private function callDeepSeekVision(string $bytes, string $mime, array $context): array\n {\n // ── 1. Groq (free, reliable vision) ─────────────────────────────────────\n if ((string) config('services.groq.key', '') !== '') {\n $visionKey = (string) config('services.groq.key');\n $visionBaseUrl = 'https://api.groq.com/openai/v1';\n $visionModel = (string) config('services.groq.model', 'meta-llama/llama-4-scout-17b-16e-instruct');\n }\n // ── 2. Google Gemini ─────────────────────────────────────────────────────\n elseif ((string) config('services.gemini.key', '') !== '') {\n $visionKey = (string) config('services.gemini.key');\n $visionBaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai';\n $visionModel = (string) config('services.gemini.model', 'gemini-2.0-flash');\n }\n // ── 3. Generic VISION_API_KEY (OpenAI, OpenRouter, …) ───────────────────\n elseif ((string) config('services.vision.key', '') !== '') {\n $visionKey = (string) config('services.vision.key');\n $visionBaseUrl = rtrim((string) config('services.vision.base_url', 'https://openrouter.ai/api/v1'), '/');\n $visionModel = (string) config('services.vision.model', 'google/gemini-flash-1.5-8b');\n }\n else {\n return $this->failure(\n 'Image processing is not available. DeepSeek\\'s API does not support images. ' .\n 'Get a free key at https://console.groq.com and add GROQ_API_KEY to your .env file.'\n );\n }\n\n $dataUrl = \"data:{$mime};base64,\" . base64_encode($bytes);\n $prompt = $this->buildDeepSeekPrompt($context, $mime);\n\n $userContent = [\n ['type' => 'text', 'text' => $prompt],\n ['type' => 'image_url', 'image_url' => ['url' => $dataUrl]],\n ];\n\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders([\n 'Authorization' => 'Bearer ' . $visionKey,\n 'HTTP-Referer' => config('app.url', 'https://qimta.sa'),\n 'X-Title' => 'Qimta Platform',\n ])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post($visionBaseUrl . '/chat/completions', [\n 'model' => $visionModel,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 16384,\n 'temperature' => 0.2,\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('Vision API connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: Vision API HTTP error.', [\n 'model' => $visionModel,\n 'base_url' => $visionBaseUrl,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n return $this->failure(\"Vision API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n return $this->parseDeepSeekJsonResponse((string) $response->json('choices.0.message.content'));\n }\n\n private function callDeepSeekChat(string $userContent, string $key, string $model): array\n {\n // Ensure the PHP script outlives the HTTP call (+ a margin for parsing the\n // response and writing to the DB) so the worker/web request is not killed\n // mid-flight, which surfaces to users as a generic \"timeout or unavailable\".\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders(['Authorization' => 'Bearer ' . $key])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 32768,\n 'temperature' => 0.1,\n 'response_format' => ['type' => 'json_object'],\n 'user' => 'Qimta_Platform',\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('DeepSeek connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: DeepSeek HTTP error.', [\n 'model' => $model,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n if ($response->status() === 401) {\n return $this->failure('DeepSeek API key is invalid or unauthorised. Please update DEEPSEEK_API_KEY in .env.', false);\n }\n return $this->failure(\"DeepSeek API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n $text = (string) $response->json('choices.0.message.content');\n return $this->parseDeepSeekJsonResponse($text);\n }\n\n /**\n * Shared JSON response parser used by both chat and vision paths.\n */\n private function parseDeepSeekJsonResponse(string $text): array\n {\n if ($text === '') {\n return $this->failure('DeepSeek returned an empty response.');\n }\n\n // Strip markdown code fences if the model wrapped the JSON.\n if (preg_match('/```(?:json)?\\s*([\\s\\S]*?)\\s*```/i', $text, $m)) {\n $text = $m[1];\n }\n // Narrow to the outermost JSON object/array.\n if (preg_match('/[\\{\\[][\\s\\S]*[\\}\\]]/s', $text, $m)) {\n $text = $m[0];\n }\n $text = trim($text);\n\n $decoded = json_decode($text, true);\n\n // The response can be truncated when the model hits the token limit,\n // leaving an unterminated JSON string/object. Try to repair it before\n // giving up so we can still salvage the items extracted so far.\n if (! is_array($decoded)) {\n $repaired = $this->repairTruncatedJson($text);\n if ($repaired !== null) {\n $decoded = json_decode($repaired, true);\n }\n }\n\n if (! is_array($decoded)) {\n Log::error('QuotationAiService: DeepSeek non-JSON response.', ['text' => mb_substr($text, 0, 1000)]);\n return $this->failure('DeepSeek returned a non-JSON response. The file may be too large — try splitting it into smaller files.');\n }\n\n $rawItems = $decoded['items'] ?? (array_is_list($decoded) ? $decoded : []);\n $items = [];\n $rejected = [];\n\n foreach ($rawItems as $raw) {\n if (! is_array($raw)) {\n continue;\n }\n $item = $this->normaliseAiItem($raw);\n if ($item === null) {\n continue;\n }\n $check = $this->boqCleaner->filterItem((string) $item['description']);\n if ($check['keep'] ?? false) {\n $items[] = $item;\n } else {\n $item['status'] = 'rejected';\n $item['raw_data']['rejection_reason'] = $check['rejection_reason'] ?? 'Rejected by cleaner';\n $rejected[] = $item;\n }\n }\n\n if (empty($items)) {\n Log::info('QuotationAiService: DeepSeek returned 0 accepted items.', [\n 'raw_items_count' => count($rawItems),\n 'rejected_count' => count($rejected),\n 'first_rejected' => ! empty($rejected) ? ($rejected[0]['description'] ?? '') : '',\n ]);\n }\n\n return ['success' => true, 'items' => $items, 'rejected' => $rejected, 'error' => null];\n }\n\n /**\n * Best-effort repair of a truncated JSON string produced when the model hits\n * its token limit mid-output. Drops any incomplete trailing object, then\n * closes the open string/array/object brackets so the result is valid JSON.\n *\n * Returns the repaired JSON string, or null if it cannot be salvaged.\n */\n private function repairTruncatedJson(string $text): ?string\n {\n if ($text === '') {\n return null;\n }\n\n // Walk the string tracking structural state, ignoring everything inside\n // string literals (and their escapes). Remember the last position where\n // we were back at the top of the \"items\" array between complete elements,\n // so we can cut a half-written trailing object.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($text);\n $lastSafeCut = null; // index just after a completed array element\n\n for ($i = 0; $i < $len; $i++) {\n $ch = $text[$i];\n\n if ($inString) {\n if ($escaped) {\n $escaped = false;\n } elseif ($ch === '\\\\') {\n $escaped = true;\n } elseif ($ch === '\"') {\n $inString = false;\n }\n continue;\n }\n\n if ($ch === '\"') {\n $inString = true;\n } elseif ($ch === '{' || $ch === '[') {\n $stack[] = $ch;\n } elseif ($ch === '}' || $ch === ']') {\n array_pop($stack);\n } elseif ($ch === ',' && ! empty($stack) && end($stack) === '[') {\n // Separator directly inside an array: a clean boundary between\n // elements (the items array, whatever its nesting depth).\n $lastSafeCut = $i;\n }\n }\n\n // If we are still inside a string, the last element is incomplete — fall\n // back to the last clean array boundary if we have one.\n $candidate = $text;\n if ($inString && $lastSafeCut !== null) {\n $candidate = substr($text, 0, $lastSafeCut);\n } else {\n // Drop a dangling partial object after the last completed element.\n $candidate = rtrim($candidate);\n if ($lastSafeCut !== null && substr(rtrim($candidate), -1) !== '}' && substr(rtrim($candidate), -1) !== ']') {\n $candidate = substr($text, 0, $lastSafeCut);\n }\n }\n\n // Recompute the open brackets for the (possibly trimmed) candidate and\n // append the matching closers in reverse order.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($candidate);\n for ($i = 0; $i < $len; $i++) {\n $ch = $candidate[$i];\n if ($inString) {\n if ($escaped) { $escaped = false; }\n elseif ($ch === '\\\\') { $escaped = true; }\n elseif ($ch === '\"') { $inString = false; }\n continue;\n }\n if ($ch === '\"') { $inString = true; }\n elseif ($ch === '{' || $ch === '[') { $stack[] = $ch; }\n elseif ($ch === '}' || $ch === ']') { array_pop($stack); }\n }\n\n $candidate = rtrim($candidate);\n // Remove a trailing comma left after cutting an element.\n $candidate = rtrim($candidate, ',');\n\n $closers = '';\n for ($i = count($stack) - 1; $i >= 0; $i--) {\n $closers .= ($stack[$i] === '{') ? '}' : ']';\n }\n\n $repaired = $candidate . $closers;\n\n // Validate before returning.\n return is_array(json_decode($repaired, true)) ? $repaired : null;\n }\n\n private function spreadsheetToCompactCsvText(string $absPath): ?string\n {\n $sheets = $this->spreadsheetToGrids($absPath);\n if (empty($sheets)) {\n return null;\n }\n\n $output = '';\n foreach ($sheets as $sheet) {\n // Don't feed summary / cost-abstract sheets to the AI either.\n if ($this->isSummarySheet($sheet['name'])) {\n continue;\n }\n $output .= 'Sheet: ' . $sheet['name'] . \"\\n\";\n foreach ($sheet['grid'] as $row) {\n $row = $this->trimTrailingEmptyCells($row);\n if (empty($row)) {\n continue;\n }\n $output .= implode(',', array_map([$this, 'csvEscape'], $row)) . \"\\n\";\n }\n $output .= \"\\n\";\n }\n\n return $output;\n }\n\n /**\n * Safely convert a PhpSpreadsheet cell value to a string.\n *\n * Cells may contain RichText or, in malformed/heavily-formatted files,\n * embedded objects such as Worksheet\\Drawing that have no __toString().\n * Casting those directly throws \"Object ... could not be converted to string\".\n */\n private function cellValueToString(mixed $value): string\n {\n if ($value === null) {\n return '';\n }\n\n if ($value instanceof \\PhpOffice\\PhpSpreadsheet\\RichText\\RichText) {\n return $value->getPlainText();\n }\n\n if (is_object($value)) {\n // Only stringify objects that explicitly support it; ignore the rest\n // (e.g. Worksheet\\Drawing) instead of crashing.\n return method_exists($value, '__toString') ? (string) $value : '';\n }\n\n if (is_array($value)) {\n return '';\n }\n\n return (string) $value;\n }\n\n private function csvEscape(mixed $value): string\n {\n $value = $this->cellValueToString($value);\n return str_contains($value, ',') || str_contains($value, '\"') || str_contains($value, \"\\n\")\n ? '\"' . str_replace('\"', '\"\"', $value) . '\"'\n : $value;\n }\n\n private function normaliseAiItem(array $raw): ?array\n {\n if (! empty($raw['rejected'])) {\n return null;\n }\n\n $description = trim((string) ($raw['cleaned_description'] ?? $raw['product_name'] ?? $raw['description'] ?? ''));\n if ($description === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $description)) {\n return null;\n }\n\n $quantity = is_numeric($raw['quantity'] ?? null) ? (float) $raw['quantity'] : 1;\n $unitPrice = is_numeric($raw['unit_price'] ?? null) ? (float) $raw['unit_price'] : null;\n $totalPrice = is_numeric($raw['total_price'] ?? null) ? (float) $raw['total_price'] : null;\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $specs = is_array($raw['specifications'] ?? null) ? $raw['specifications'] : [];\n\n return [\n 'description' => $description,\n 'quantity' => $quantity,\n 'unit' => (string) ($raw['unit'] ?? ''),\n 'category' => (string) ($raw['core_product'] ?? $raw['category'] ?? ''),\n 'brand' => (string) ($specs['brand'] ?? $raw['brand'] ?? ''),\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($description),\n 'unit_price' => $unitPrice,\n 'confidence' => is_numeric($raw['confidence'] ?? null) ? (float) $raw['confidence'] : null,\n 'needs_review' => ! empty($raw['needs_review']),\n 'raw_data' => [\n 'source_item_no' => (string) ($raw['source_item_no'] ?? ''),\n 'original_description' => (string) ($raw['original_description'] ?? $description),\n 'cleaned_description' => $description,\n 'core_product' => (string) ($raw['core_product'] ?? ''),\n 'specifications' => $specs,\n 'extraction_type' => (string) ($raw['extraction_type'] ?? 'supply_only'),\n 'note' => (string) ($raw['note'] ?? $raw['notes'] ?? ''),\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function buildSystemInstruction(): string\n {\n return <<<'TXT'\nYou are a strict BOQ Supply Product Extraction Engine.\nExtract ONLY concrete, tangible, physical products that a supplier can box up and deliver to site — a specific item you could point at and say \"this is the thing being bought\" (e.g. \"Wall mounted LED luminaire 36W\", \"PVC pipe 110mm\", \"Single phase distribution board 12-way\", \"Fire extinguisher CO2 6kg\", \"CCTV dome camera 4MP\").\n\nABSOLUTELY REJECT abstract concepts, disciplines, categories, section names, and trade headings. These are NOT products even though they appear as rows:\n- Bare discipline/trade words: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", \"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\".\n- Summary / total / abstract / \"dry cost\" rows of any kind.\n- Anything that names a system, scope, or category instead of one buyable item.\n\nRule of thumb: if you cannot name a unit and physically hand the item over (a piece, a box, a length, a unit of equipment), REJECT it. A word that describes a field of work, a system, or a section of the BOQ is NEVER a product.\nReject totals, headings, general requirements, installation-only, testing, commissioning, supervision, mobilization, civil works, labor, and vague rows.\nFor Supply & Install rows, keep only the supply product and remove install/labor wording.\nReturn only valid JSON: {\"items\":[{\"source_item_no\":null,\"original_description\":\"\",\"cleaned_description\":\"\",\"core_product\":\"\",\"specifications\":{},\"quantity\":0,\"unit\":\"\",\"unit_price\":0,\"total_price\":0,\"extraction_type\":\"supply_only\",\"needs_review\":false,\"note\":\"\",\"rejected\":false,\"rejection_reason\":null,\"confidence\":0.95}]}\nTXT;\n }\n\n private function buildDeepSeekPrompt(array $context = [], string $mime = ''): string\n {\n $projectName = (string) ($context['project_name'] ?? '');\n $projectStatus = (string) ($context['project_status'] ?? '');\n $contextLine = $projectName !== ''\n ? \"Project: {$projectName}\" . ($projectStatus !== '' ? \" ({$projectStatus})\" : '')\n : '';\n $sourceHint = str_starts_with($mime, 'image/')\n ? 'The input is an image of a BOQ table. OCR every cell carefully — read the quantity/count column (العدد, الكمية, Qty) and extract the EXACT numeric value from each row.'\n : 'The input is a BOQ document/spreadsheet.';\n\n return trim(<<<PROMPT\nYou are a procurement specialist. Your task is to extract ONLY tangible, physical supply products from this Bill of Quantities (BOQ).\n\n{$contextLine}\n{$sourceHint}\n\nSTRICT RULES — include ONLY items that are ALL of the following:\n1. A specific, concrete, tangible product that can be boxed up by a supplier and delivered to site — something you can point at and say \"this exact thing is being bought\" (e.g. \"LED luminaire 36W\", \"PVC pipe 110mm\", \"Distribution board 12-way\", \"CO2 fire extinguisher 6kg\").\n2. Has a real quantity (not 0 or \"varies\" with no meaning).\n3. Has enough description to identify what the product is.\n\nNEVER extract abstract concepts, disciplines, trades, systems, or section/category names — they are NOT products even when they appear as their own row:\n- Bare discipline/trade/system words such as: \"Electrical\", \"Mechanical\", \"Plumbing\", \"HVAC\", \"Firefighting\", \"Low current\", \"Architectural\", \"Structural Concrete\", \"Elevators\", \"Security\", \"Civil\", \"Finishing\", and their Arabic equivalents (\"كهرباء\", \"ميكانيكا\", \"سباكة\", \"تكييف\", \"حريق\", \"أمن\", \"إنشائي\", \"تشطيبات\").\n- Test: if the word names a FIELD OF WORK or a SYSTEM rather than one buyable item with a unit (pcs, m, set, kg…), REJECT it.\n\nEXCLUDE all of the following (do NOT include them):\n- Labor, installation, or workmanship items (e.g. \"install\", \"fix\", \"erect\", \"dismantle\", \"painting\", \"cleaning service\")\n- Project management, overhead, or profit items\n- Insurance, bonds, liability items (e.g. \"bodily injury\", \"third party liability\", \"CAR insurance\")\n- Shop drawings, as-built drawings, method statements, documentation\n- Preliminary items, mobilisation, demobilisation, provisional sums\n- Housekeeping, site clearance, waste removal\n- Floor/level labels (e.g. \"GF Floor\", \"Level 3\") — these are headers, not products\n- Pure dimension specs without a product noun (e.g. \"Dia 32 mm\", \"25 mm\") — reject unless a product name is present\n- Security services, night shifts, rentals, payments\n- Any item whose description is too vague or fragmentary to identify a specific product\n\nFor each valid physical product return JSON with:\n- description: clean product name only (strip \"supply and install\", \"supply and fix\", installation clauses, floor/spec suffixes)\n- quantity: the EXACT numeric value from the quantity/count column (العدد, الكمية, Qty, Quantity, No.). NEVER default to 1 unless the quantity column is truly absent or empty.\n- unit: unit of measure (pcs, m, m2, m3, set, no, kg, etc.)\n- category: product category\n- brand: brand/manufacturer if mentioned, else \"\"\n- engineering_required: true if the item needs engineering design before procurement (panel boards, chillers, generators, fire suppression systems, BMS, pumps, AHUs, switchgear, transformers), else false\n\nReturn ONLY a valid JSON object: {\"items\": [...]}. No explanations, no markdown, no extra text.\nPROMPT);\n }\n\n private function mockResponse(): array\n {\n return [\n 'success' => true,\n 'error' => null,\n 'items' => [[\n 'description' => 'Sample BOQ Item',\n 'quantity' => 1,\n 'unit' => 'No',\n 'category' => 'Sample',\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 1,\n 'raw_data' => [],\n 'ai_extracted' => true,\n ]],\n 'rejected' => [],\n ];\n }\n\n private function failure(string $message, bool $serviceUnavailable = false): array\n {\n return [\n 'success' => false,\n 'items' => [],\n 'rejected' => [],\n 'error' => $this->userFacingError($message, $serviceUnavailable),\n 'service_unavailable' => $serviceUnavailable,\n ];\n }\n\n /**\n * Translate any internal error into a neutral, user-safe message.\n *\n * The user must never see the name of the underlying AI/processing engine\n * (DeepSeek, Groq, Gemini, Vision, etc.) or configuration hints like API\n * keys. The detailed message is kept in the logs for debugging only.\n */\n private function userFacingError(string $message, bool $serviceUnavailable = false): string\n {\n // Detect any reference to an AI provider / engine / API config so we can\n // swap it for a generic, branded-neutral message.\n $sensitive = '/deepseek|groq|gemini|vision|openai|openrouter|api[\\s_-]?key|\\.env|non-?json|token limit/i';\n\n if (preg_match($sensitive, $message)) {\n Log::warning('QuotationAiService: suppressed technical error from user.', ['internal' => $message]);\n\n return $serviceUnavailable\n ? 'The file could not be processed right now. Please try again in a moment.'\n : 'We could not read this file automatically. Please make sure it is a clear BOQ (Excel, CSV, PDF, or image) and try again, or add the items manually.';\n }\n\n return $message;\n }\n\n private function mimeForExtension(string $ext): string\n {\n return match (strtolower($ext)) {\n 'pdf' => 'application/pdf',\n 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n 'xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12',\n 'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',\n 'xls' => 'application/vnd.ms-excel',\n 'csv' => 'text/csv',\n 'jpg', 'jpeg' => 'image/jpeg',\n 'png' => 'image/png',\n 'gif' => 'image/gif',\n 'bmp' => 'image/bmp',\n 'webp' => 'image/webp',\n 'tiff', 'tif' => 'image/tiff',\n 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'doc' => 'application/msword',\n default => 'application/octet-stream',\n };\n }\n}\n"}, "99a6f00086bef82241863c38b4a13b945729f834": {"t": 1785748389.0834527, "n": 49930, "h": "aee442e2ec251a79", "c": "<?php\n\nnamespace App\\Services;\n\nuse Illuminate\\Http\\Client\\ConnectionException;\nuse Illuminate\\Http\\UploadedFile;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\nuse PhpOffice\\PhpSpreadsheet\\Cell\\Coordinate;\nuse PhpOffice\\PhpSpreadsheet\\IOFactory;\n\nclass QuotationAiService\n{\n /** Increment this whenever BOQ parsing logic changes to invalidate old caches. */\n private const PARSER_VERSION = 2;\n\n private string $baseUrl;\n private string $parseEndpoint;\n private string $apiKey;\n private int $timeout;\n private bool $testMode;\n private BoqCleaningService $boqCleaner;\n\n public function __construct(BoqCleaningService $boqCleaner)\n {\n $this->boqCleaner = $boqCleaner;\n $this->baseUrl = rtrim((string) config('services.ai_quotation.base_url', ''), '/');\n $this->parseEndpoint = ltrim((string) config('services.ai_quotation.parse_endpoint', 'parse'), '/');\n $this->apiKey = (string) config('services.ai_quotation.api_key', '');\n $this->timeout = (int) config('services.ai_quotation.timeout', 300);\n $this->testMode = (bool) config('services.ai_quotation.test_mode', false);\n }\n\n /**\n * Give the running script enough head-room to outlast the AI HTTP call.\n *\n * The web request / queue worker must not be killed before the HTTP timeout\n * completes, otherwise the user sees a generic \"timeout or unavailable\"\n * error even though the AI may have responded. We allow the HTTP timeout\n * plus a 120s margin for connecting, response parsing and DB writes.\n */\n private function extendExecutionTime(): void\n {\n // Ignored on some SAPIs (e.g. when running under fastcgi with a hard\n // limit), but harmless to attempt.\n @set_time_limit($this->timeout + 120);\n }\n\n public function parseBoq(UploadedFile|string $file, array $context = []): array\n {\n if ($this->testMode) {\n return $this->mockResponse();\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n\n if (! is_file($absPath)) {\n return $this->failure('File not found or not readable.');\n }\n\n $fileHash = hash_file('sha256', $absPath);\n // Bump PARSER_VERSION whenever the extraction logic changes so that\n // previously-uploaded files are re-parsed instead of returning a stale\n // cached result produced by the old code.\n $cacheKey = 'boq_analysis_v' . self::PARSER_VERSION . '_' . $fileHash;\n $forceRefresh = (bool) ($context['force_refresh'] ?? false);\n\n if (! $forceRefresh && ($cached = Cache::get($cacheKey)) !== null) {\n return $cached;\n }\n\n // Clear stale cached result when force-refreshing\n if ($forceRefresh) {\n Cache::forget($cacheKey);\n }\n\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n // Read Excel locally first. Fall back to AI if local parse failed,\n // found 0 items, OR could not read one of the sheets (so multi-sheet\n // files never silently drop a sheet the local parser didn't understand).\n $result = $this->parseSpreadsheetDirect($absPath);\n\n $hasSkippedSheets = ! empty($result['skipped_sheets']);\n\n if (! $result['success'] || empty($result['items']) || $hasSkippedSheets) {\n $deepSeekResult = $this->parseBoqWithDeepSeek($file, $context);\n // AI sees every sheet at once, so prefer its result whenever it\n // actually returned items (covers the skipped-sheet case too).\n if ($deepSeekResult['success'] && ! empty($deepSeekResult['items'])) {\n // Keep the larger of the two item sets so we never lose the\n // rows the local parser already extracted correctly.\n if (count($deepSeekResult['items']) >= count($result['items'] ?? [])) {\n $result = $deepSeekResult;\n }\n } elseif (! $result['success']) {\n $result = $deepSeekResult;\n }\n // else: keep local result if AI also failed/returned nothing\n }\n } elseif (in_array($ext, ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif'], true)) {\n $result = $this->parseBoqWithDeepSeek($file, $context);\n } else {\n return $this->failure('Unsupported file type. Please upload Excel, CSV, PDF, or image file.');\n }\n\n if ($result['success'] && ! empty($result['items'])) {\n $cleaning = $this->boqCleaner->process($result['items']);\n $result['items'] = $cleaning['accepted'] ?? $result['items'];\n $result['rejected'] = array_merge($result['rejected'] ?? [], $cleaning['rejected'] ?? []);\n }\n\n // Only cache when we actually extracted items; empty results should be re-tried.\n if ($result['success'] && ! empty($result['items'])) {\n Cache::put($cacheKey, $result, now()->addDays(30));\n }\n\n return $result;\n }\n\n private function resolveFile(UploadedFile|string $file): array\n {\n if ($file instanceof UploadedFile) {\n return [(string) $file->getRealPath(), strtolower($file->getClientOriginalExtension())];\n }\n\n $absPath = is_file($file) ? $file : storage_path('app/' . ltrim($file, '/'));\n return [$absPath, strtolower(pathinfo($absPath, PATHINFO_EXTENSION))];\n }\n\n private function parseSpreadsheetDirect(string $absPath): array\n {\n ini_set('memory_limit', '1024M');\n set_time_limit(180);\n\n try {\n $sheets = $this->spreadsheetToGrids($absPath);\n\n if (empty($sheets)) {\n return $this->failure('Could not read the Excel file.');\n }\n\n $items = [];\n $rejected = [];\n $skippedSheets = []; // sheets we could not parse locally (header undetected)\n\n foreach ($sheets as $sheet) {\n $grid = $sheet['grid'];\n if (count($grid) < 2) {\n continue;\n }\n\n $header = $this->detectHeader($grid);\n\n if ($header === null || ! isset($header['map']['description'])) {\n Log::warning('QuotationAiService: header not detected in sheet.', ['sheet' => $sheet['name']]);\n $skippedSheets[] = $sheet['name'];\n continue;\n }\n\n $map = $header['map'];\n $start = $header['row'] + 1;\n\n for ($r = $start; $r < count($grid); $r++) {\n $row = $grid[$r];\n $description = $this->cell($row, $map['description'] ?? null);\n $itemNo = $this->cell($row, $map['item_code'] ?? null);\n $unit = $this->cell($row, $map['unit'] ?? null);\n $quantity = $this->number($this->cell($row, $map['quantity'] ?? null));\n $unitPrice = $this->number($this->cell($row, $map['unit_price'] ?? null));\n $totalPrice = $this->number($this->cell($row, $map['total_price'] ?? null));\n\n if ($description === '') {\n continue;\n }\n\n if (! isset($map['quantity'])) {\n $quantity = 1.0;\n }\n\n // Skip floor breakdown sub-rows: Ref. is \"-\" meaning it's a per-floor\n // quantity split of the parent item, not a standalone product.\n if ($itemNo === '-' || $this->isFloorBreakdownRow($itemNo, $description)) {\n continue;\n }\n\n if ($this->isTotalOrHeaderRow($description)) {\n continue;\n }\n\n if ($quantity === null || $quantity <= 0) {\n $rejected[] = $this->rejectedRow($description, $unit, $quantity, 'Missing quantity', $itemNo, $sheet['name']);\n continue;\n }\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $supply = $this->boqCleaner->filterItem($description);\n\n if (! ($supply['keep'] ?? false)) {\n $rejected[] = $this->rejectedRow(\n $description,\n $unit,\n $quantity,\n (string) ($supply['rejection_reason'] ?? 'Rejected by cleaner'),\n $itemNo,\n $sheet['name']\n );\n continue;\n }\n\n $finalDescription = (string) ($supply['description'] ?? $description);\n\n $items[] = [\n 'description' => $finalDescription,\n 'quantity' => $quantity,\n 'unit' => $unit,\n 'category' => $sheet['name'],\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($finalDescription),\n 'unit_price' => $unitPrice,\n 'confidence' => 0.95,\n 'needs_review' => false,\n 'raw_data' => [\n 'sheet' => $sheet['name'],\n 'row_number' => $r + 1,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'cleaned_description' => $finalDescription,\n 'extraction_type' => (string) ($supply['extraction_type'] ?? 'supply_only'),\n ],\n 'ai_extracted' => true,\n ];\n }\n }\n\n if (empty($items)) {\n return $this->failure('No BOQ supply items could be extracted from the Excel file.');\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'skipped_sheets' => $skippedSheets,\n ];\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: parseSpreadsheetDirect failed.', [\n 'path' => $absPath,\n 'message' => $e->getMessage(),\n 'trace' => $e->getTraceAsString(),\n ]);\n\n return $this->failure('Failed to read the Excel file: ' . $e->getMessage());\n }\n }\n\n private function spreadsheetToGrids(string $absPath): array\n {\n $ext = strtolower(pathinfo($absPath, PATHINFO_EXTENSION));\n\n if ($ext === 'csv') {\n $rows = [];\n $handle = fopen($absPath, 'rb');\n if ($handle !== false) {\n while (($row = fgetcsv($handle)) !== false) {\n $rows[] = $this->trimTrailingEmptyCells($row);\n }\n fclose($handle);\n }\n return [['name' => 'Sheet1', 'grid' => $rows]];\n }\n\n $reader = IOFactory::createReader($ext === 'xls' ? 'Xls' : 'Xlsx');\n $reader->setReadDataOnly(true);\n $spreadsheet = $reader->load($absPath);\n $sheets = [];\n\n foreach ($spreadsheet->getAllSheets() as $sheet) {\n $highestRow = $sheet->getHighestDataRow();\n $highestColumn = $sheet->getHighestDataColumn();\n $highestColumnIndex = Coordinate::columnIndexFromString($highestColumn);\n\n // BOQ data is normally in first 8-20 columns.\n // Files with heavy formatting/images can report 300+ columns — limit to 30.\n $maxColumnIndex = min($highestColumnIndex, 30);\n\n $grid = [];\n for ($row = 1; $row <= $highestRow; $row++) {\n $rowData = [];\n $hasValue = false;\n\n for ($col = 1; $col <= $maxColumnIndex; $col++) {\n $value = $sheet->getCell([$col, $row])->getValue();\n $value = $this->cellValueToString($value);\n $value = trim($value);\n if ($value !== null && $value !== '') {\n $hasValue = true;\n }\n $rowData[] = $value;\n }\n\n $rowData = $this->trimTrailingEmptyCells($rowData);\n if ($hasValue) {\n $grid[] = $rowData;\n }\n }\n\n if (! empty($grid)) {\n $sheets[] = ['name' => $sheet->getTitle(), 'grid' => $grid];\n }\n }\n\n $spreadsheet->disconnectWorksheets();\n unset($spreadsheet);\n\n return $sheets;\n }\n\n private function detectHeader(array $grid): ?array\n {\n $keywords = [\n 'item_code' => ['رقم البند', 'item no', 'item number', 'ref.', 'ref', 'item', 'no.', 'no', '#', 'كود'],\n 'description' => ['وص٠البند', 'scope of works', 'scope of work', 'scope', 'item description', 'description of works', 'description', 'بالعربية', 'الوصÙ', 'البند', 'بيان'],\n 'unit' => ['Ø§Ù„ÙˆØØ¯Ø©', 'unit', 'uom', 'u/m'],\n 'quantity' => ['الكمية', 'qty', 'quantity', 'q.ty'],\n 'unit_price' => ['سعر Ø§Ù„ÙˆØØ¯Ø©', 'u/price', 'unit price', 'unit rate', 'rate', 'price'],\n 'total_price' => ['السعر الإجمالي', 'total amount', 'total price', 'amount', 'الإجمالي'],\n ];\n\n $best = null;\n $bestScore = 0;\n\n for ($r = 0; $r < min(30, count($grid)); $r++) {\n $map = [];\n $score = 0;\n\n foreach ($grid[$r] as $c => $value) {\n $cell = mb_strtolower(trim((string) $value));\n if ($cell === '') {\n continue;\n }\n\n foreach ($keywords as $field => $list) {\n foreach ($list as $kw) {\n if (str_contains($cell, mb_strtolower($kw))) {\n if (! isset($map[$field])) {\n $map[$field] = $c;\n $score++;\n }\n break 2;\n }\n }\n }\n }\n\n if ($score > $bestScore) {\n $bestScore = $score;\n $best = ['row' => $r, 'map' => $map];\n }\n }\n\n if ($bestScore >= 3) {\n return $best;\n }\n\n // Allow simpler BOQ sheets that only define a description column plus one\n // other BOQ-related column, e.g. Item + Description or Description + Qty.\n if ($bestScore === 2 && isset($best['map']['description']) && (\n isset($best['map']['item_code']) ||\n isset($best['map']['quantity']) ||\n isset($best['map']['unit'])\n )) {\n return $best;\n }\n\n return null;\n }\n\n private function cell(array $row, ?int $index): string\n {\n if ($index === null) {\n return '';\n }\n return trim((string) ($row[$index] ?? ''));\n }\n\n private function number(string $value): ?float\n {\n $value = trim(str_replace([',', 'SAR', 'ر.س'], '', $value));\n if ($value === '' || ! is_numeric($value)) {\n return null;\n }\n return (float) $value;\n }\n\n private function trimTrailingEmptyCells(array $row): array\n {\n while (! empty($row) && trim((string) end($row)) === '') {\n array_pop($row);\n }\n return array_values($row);\n }\n\n private function isTotalOrHeaderRow(string $description): bool\n {\n $d = mb_strtolower(trim($description));\n\n if ($d === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $d)) {\n return true;\n }\n\n // Section totals / summaries\n if (preg_match('/(اجمالي|إجمالي|المجموع|grand\\s*total|sub\\s*total|total\\s*amount|total\\s*price)/iu', $d)) {\n return true;\n }\n\n // Pure floor / level labels used as section headers\n if (preg_match('/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor)\\s*$/iu', $d)) {\n return true;\n }\n\n // Division / section header lines (e.g. \"Division - 02: Wood Works\")\n if (preg_match('/^division\\s*([-:]|\\d)/iu', $d)) {\n return true;\n }\n\n return false;\n }\n\n private function isFloorBreakdownRow(string $itemNo, string $description): bool\n {\n // A row whose Ref column is empty or a dash is a sub-breakdown row (per-floor split).\n $ref = trim($itemNo);\n if ($ref === '' || $ref === '-') {\n // Confirm by checking the description is a location/floor label\n $d = mb_strtolower(trim($description));\n return (bool) preg_match(\n '/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor|\\d+f)\\s*$/iu',\n $d\n );\n }\n return false;\n }\n\n private function rejectedRow(string $description, string $unit, ?float $quantity, string $reason, string $itemNo = '', string $sheet = ''): array\n {\n return [\n 'description' => $description,\n 'quantity' => $quantity ?? 0,\n 'unit' => $unit,\n 'category' => $sheet,\n 'brand' => '',\n 'status' => 'rejected',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 0.0,\n 'raw_data' => [\n 'sheet' => $sheet,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'extraction_type' => 'rejected',\n 'rejection_reason' => $reason,\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function parseBoqWithDeepSeek(UploadedFile|string $file, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured. Please set DEEPSEEK_API_KEY in .env file.');\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n $mime = $this->mimeForExtension($ext);\n\n try {\n // ── Spreadsheets ──────────────────────────────────────────────────\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n $text = $this->spreadsheetToCompactCsvText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not convert spreadsheet to readable text.');\n }\n $text = $this->sanitizeUtf8(mb_substr($text, 0, 180000));\n $userContent = \"BOQ spreadsheet converted to CSV:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── PDF ───────────────────────────────────────────────────────────\n if ($ext === 'pdf') {\n $extracted = $this->extractPdfText($absPath);\n if ($extracted !== null && mb_strlen(trim($extracted)) > 50) {\n $extracted = $this->sanitizeUtf8(mb_substr($extracted, 0, 180000));\n $userContent = \"BOQ PDF extracted text:\\n\\n\" . $extracted . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'application/pdf');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n return $this->failure('Could not extract text from the PDF. Please make sure it is a text-based PDF (not a scanned image), or convert it to Excel or CSV.');\n }\n\n // -- Word documents (docx) ---------------------------------------------------\n if (in_array($ext, ['docx', 'doc'], true)) {\n $text = $this->extractDocxText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not extract text from the Word document. Please convert it to Excel, CSV, or PDF.');\n }\n $text = $this->sanitizeUtf8(mb_substr($text, 0, 180000));\n $userContent = \"BOQ Word document extracted text:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── Images ──────────────────────────────────────────────────────────────\n // DeepSeek cannot receive images. A vision-capable AI processes the image\n // directly (no OCR) — configure GROQ_API_KEY or GEMINI_API_KEY in .env.\n $imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif', 'heic', 'heif'];\n if (in_array($ext, $imageExts, true)) {\n $bytes = (string) file_get_contents($absPath);\n return $this->callDeepSeekVision($bytes, $mime, $context);\n }\n\n return $this->failure('Unsupported file type. Please upload an Excel, CSV, PDF, or image file.');\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: DeepSeek fallback failed.', ['message' => $e->getMessage()]);\n return $this->failure('An error occurred while processing the file with AI: ' . $e->getMessage());\n }\n }\n\n private function deepSeekModel(): string\n {\n return (string) config('services.deepseek.model', 'deepseek-chat');\n }\n\n /**\n * Remove or replace any non-UTF-8 / malformed byte sequences so json_encode never fails.\n */\n private function sanitizeUtf8(string $text): string\n {\n // Replace invalid byte sequences with the UTF-8 replacement character\n $clean = mb_convert_encoding($text, 'UTF-8', 'UTF-8');\n // Strip any remaining non-printable control characters (except tab/newline/CR)\n $clean = (string) preg_replace('/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]/u', '', $clean);\n return $clean;\n }\n\n /**\n * Extract plain text from a Word docx file using ZipArchive (no external library needed).\n * Reads word/document.xml and strips XML tags, preserving paragraph and table structure.\n */\n private function extractDocxText(string $absPath): ?string\n {\n try {\n $zip = new \\ZipArchive();\n if ($zip->open($absPath) !== true) {\n return null;\n }\n\n $xml = $zip->getFromName('word/document.xml');\n $zip->close();\n\n if ($xml === false || $xml === '') {\n return null;\n }\n\n // Add newlines before paragraph/table-row/table-cell tags so structure is preserved\n $xml = preg_replace('/<w:p[ >]/', \"\\n<w:p>\", $xml) ?? $xml;\n $xml = preg_replace('/<w:tr[ >]/', \"\\n<w:tr>\", $xml) ?? $xml;\n $xml = preg_replace('/<\\/w:tc>/', \"\\t\", $xml) ?? $xml;\n\n $text = strip_tags($xml);\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n return trim($text) !== '' ? trim($text) : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: DOCX parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Extract plain text from a PDF using smalot/pdfparser (pure PHP, no binary required).\n */\n private function extractPdfText(string $absPath): ?string\n {\n try {\n $parser = new \\Smalot\\PdfParser\\Parser();\n $pdf = $parser->parseFile($absPath);\n $text = $pdf->getText();\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n return $text !== '' ? $text : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: PDF parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Send a vision request to a vision-capable AI model.\n * DeepSeek's commercial API (api.deepseek.com) does NOT support image inputs — any model.\n * Priority order (first configured key wins):\n * 1. GROQ_API_KEY → Groq llama-4-scout (free: https://console.groq.com)\n * 2. GEMINI_API_KEY → Google Gemini Flash (free: https://aistudio.google.com/apikey)\n * 3. VISION_API_KEY → any OpenAI-compatible endpoint (OpenAI, OpenRouter …)\n */\n private function callDeepSeekVision(string $bytes, string $mime, array $context): array\n {\n // ── 1. Groq (free, reliable vision) ─────────────────────────────────────\n if ((string) config('services.groq.key', '') !== '') {\n $visionKey = (string) config('services.groq.key');\n $visionBaseUrl = 'https://api.groq.com/openai/v1';\n $visionModel = (string) config('services.groq.model', 'meta-llama/llama-4-scout-17b-16e-instruct');\n }\n // ── 2. Google Gemini ─────────────────────────────────────────────────────\n elseif ((string) config('services.gemini.key', '') !== '') {\n $visionKey = (string) config('services.gemini.key');\n $visionBaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai';\n $visionModel = (string) config('services.gemini.model', 'gemini-2.0-flash');\n }\n // ── 3. Generic VISION_API_KEY (OpenAI, OpenRouter, …) ───────────────────\n elseif ((string) config('services.vision.key', '') !== '') {\n $visionKey = (string) config('services.vision.key');\n $visionBaseUrl = rtrim((string) config('services.vision.base_url', 'https://openrouter.ai/api/v1'), '/');\n $visionModel = (string) config('services.vision.model', 'google/gemini-flash-1.5-8b');\n }\n else {\n return $this->failure(\n 'Image processing is not available. DeepSeek\\'s API does not support images. ' .\n 'Get a free key at https://console.groq.com and add GROQ_API_KEY to your .env file.'\n );\n }\n\n $dataUrl = \"data:{$mime};base64,\" . base64_encode($bytes);\n $prompt = $this->buildDeepSeekPrompt($context, $mime);\n\n $userContent = [\n ['type' => 'text', 'text' => $prompt],\n ['type' => 'image_url', 'image_url' => ['url' => $dataUrl]],\n ];\n\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders([\n 'Authorization' => 'Bearer ' . $visionKey,\n 'HTTP-Referer' => config('app.url', 'https://qimta.sa'),\n 'X-Title' => 'Qimta Platform',\n ])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post($visionBaseUrl . '/chat/completions', [\n 'model' => $visionModel,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 16384,\n 'temperature' => 0.2,\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('Vision API connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: Vision API HTTP error.', [\n 'model' => $visionModel,\n 'base_url' => $visionBaseUrl,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n return $this->failure(\"Vision API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n return $this->parseDeepSeekJsonResponse((string) $response->json('choices.0.message.content'));\n }\n\n private function callDeepSeekChat(string $userContent, string $key, string $model): array\n {\n // Ensure the PHP script outlives the HTTP call (+ a margin for parsing the\n // response and writing to the DB) so the worker/web request is not killed\n // mid-flight, which surfaces to users as a generic \"timeout or unavailable\".\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders(['Authorization' => 'Bearer ' . $key])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 32768,\n 'temperature' => 0.1,\n 'response_format' => ['type' => 'json_object'],\n 'user' => 'Qimta_Platform',\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('DeepSeek connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: DeepSeek HTTP error.', [\n 'model' => $model,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n if ($response->status() === 401) {\n return $this->failure('DeepSeek API key is invalid or unauthorised. Please update DEEPSEEK_API_KEY in .env.', false);\n }\n return $this->failure(\"DeepSeek API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n $text = (string) $response->json('choices.0.message.content');\n return $this->parseDeepSeekJsonResponse($text);\n }\n\n /**\n * Shared JSON response parser used by both chat and vision paths.\n */\n private function parseDeepSeekJsonResponse(string $text): array\n {\n if ($text === '') {\n return $this->failure('DeepSeek returned an empty response.');\n }\n\n // Strip markdown code fences if the model wrapped the JSON.\n if (preg_match('/```(?:json)?\\s*([\\s\\S]*?)\\s*```/i', $text, $m)) {\n $text = $m[1];\n }\n // Narrow to the outermost JSON object/array.\n if (preg_match('/[\\{\\[][\\s\\S]*[\\}\\]]/s', $text, $m)) {\n $text = $m[0];\n }\n $text = trim($text);\n\n $decoded = json_decode($text, true);\n\n // The response can be truncated when the model hits the token limit,\n // leaving an unterminated JSON string/object. Try to repair it before\n // giving up so we can still salvage the items extracted so far.\n if (! is_array($decoded)) {\n $repaired = $this->repairTruncatedJson($text);\n if ($repaired !== null) {\n $decoded = json_decode($repaired, true);\n }\n }\n\n if (! is_array($decoded)) {\n Log::error('QuotationAiService: DeepSeek non-JSON response.', ['text' => mb_substr($text, 0, 1000)]);\n return $this->failure('DeepSeek returned a non-JSON response. The file may be too large — try splitting it into smaller files.');\n }\n\n $rawItems = $decoded['items'] ?? (array_is_list($decoded) ? $decoded : []);\n $items = [];\n $rejected = [];\n\n foreach ($rawItems as $raw) {\n if (! is_array($raw)) {\n continue;\n }\n $item = $this->normaliseAiItem($raw);\n if ($item === null) {\n continue;\n }\n $check = $this->boqCleaner->filterItem((string) $item['description']);\n if ($check['keep'] ?? false) {\n $items[] = $item;\n } else {\n $item['status'] = 'rejected';\n $item['raw_data']['rejection_reason'] = $check['rejection_reason'] ?? 'Rejected by cleaner';\n $rejected[] = $item;\n }\n }\n\n if (empty($items)) {\n Log::info('QuotationAiService: DeepSeek returned 0 accepted items.', [\n 'raw_items_count' => count($rawItems),\n 'rejected_count' => count($rejected),\n 'first_rejected' => ! empty($rejected) ? ($rejected[0]['description'] ?? '') : '',\n ]);\n }\n\n return ['success' => true, 'items' => $items, 'rejected' => $rejected, 'error' => null];\n }\n\n /**\n * Best-effort repair of a truncated JSON string produced when the model hits\n * its token limit mid-output. Drops any incomplete trailing object, then\n * closes the open string/array/object brackets so the result is valid JSON.\n *\n * Returns the repaired JSON string, or null if it cannot be salvaged.\n */\n private function repairTruncatedJson(string $text): ?string\n {\n if ($text === '') {\n return null;\n }\n\n // Walk the string tracking structural state, ignoring everything inside\n // string literals (and their escapes). Remember the last position where\n // we were back at the top of the \"items\" array between complete elements,\n // so we can cut a half-written trailing object.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($text);\n $lastSafeCut = null; // index just after a completed array element\n\n for ($i = 0; $i < $len; $i++) {\n $ch = $text[$i];\n\n if ($inString) {\n if ($escaped) {\n $escaped = false;\n } elseif ($ch === '\\\\') {\n $escaped = true;\n } elseif ($ch === '\"') {\n $inString = false;\n }\n continue;\n }\n\n if ($ch === '\"') {\n $inString = true;\n } elseif ($ch === '{' || $ch === '[') {\n $stack[] = $ch;\n } elseif ($ch === '}' || $ch === ']') {\n array_pop($stack);\n } elseif ($ch === ',' && ! empty($stack) && end($stack) === '[') {\n // Separator directly inside an array: a clean boundary between\n // elements (the items array, whatever its nesting depth).\n $lastSafeCut = $i;\n }\n }\n\n // If we are still inside a string, the last element is incomplete — fall\n // back to the last clean array boundary if we have one.\n $candidate = $text;\n if ($inString && $lastSafeCut !== null) {\n $candidate = substr($text, 0, $lastSafeCut);\n } else {\n // Drop a dangling partial object after the last completed element.\n $candidate = rtrim($candidate);\n if ($lastSafeCut !== null && substr(rtrim($candidate), -1) !== '}' && substr(rtrim($candidate), -1) !== ']') {\n $candidate = substr($text, 0, $lastSafeCut);\n }\n }\n\n // Recompute the open brackets for the (possibly trimmed) candidate and\n // append the matching closers in reverse order.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($candidate);\n for ($i = 0; $i < $len; $i++) {\n $ch = $candidate[$i];\n if ($inString) {\n if ($escaped) { $escaped = false; }\n elseif ($ch === '\\\\') { $escaped = true; }\n elseif ($ch === '\"') { $inString = false; }\n continue;\n }\n if ($ch === '\"') { $inString = true; }\n elseif ($ch === '{' || $ch === '[') { $stack[] = $ch; }\n elseif ($ch === '}' || $ch === ']') { array_pop($stack); }\n }\n\n $candidate = rtrim($candidate);\n // Remove a trailing comma left after cutting an element.\n $candidate = rtrim($candidate, ',');\n\n $closers = '';\n for ($i = count($stack) - 1; $i >= 0; $i--) {\n $closers .= ($stack[$i] === '{') ? '}' : ']';\n }\n\n $repaired = $candidate . $closers;\n\n // Validate before returning.\n return is_array(json_decode($repaired, true)) ? $repaired : null;\n }\n\n private function spreadsheetToCompactCsvText(string $absPath): ?string\n {\n $sheets = $this->spreadsheetToGrids($absPath);\n if (empty($sheets)) {\n return null;\n }\n\n $output = '';\n foreach ($sheets as $sheet) {\n $output .= 'Sheet: ' . $sheet['name'] . \"\\n\";\n foreach ($sheet['grid'] as $row) {\n $row = $this->trimTrailingEmptyCells($row);\n if (empty($row)) {\n continue;\n }\n $output .= implode(',', array_map([$this, 'csvEscape'], $row)) . \"\\n\";\n }\n $output .= \"\\n\";\n }\n\n return $output;\n }\n\n /**\n * Safely convert a PhpSpreadsheet cell value to a string.\n *\n * Cells may contain RichText or, in malformed/heavily-formatted files,\n * embedded objects such as Worksheet\\Drawing that have no __toString().\n * Casting those directly throws \"Object ... could not be converted to string\".\n */\n private function cellValueToString(mixed $value): string\n {\n if ($value === null) {\n return '';\n }\n\n if ($value instanceof \\PhpOffice\\PhpSpreadsheet\\RichText\\RichText) {\n return $value->getPlainText();\n }\n\n if (is_object($value)) {\n // Only stringify objects that explicitly support it; ignore the rest\n // (e.g. Worksheet\\Drawing) instead of crashing.\n return method_exists($value, '__toString') ? (string) $value : '';\n }\n\n if (is_array($value)) {\n return '';\n }\n\n return (string) $value;\n }\n\n private function csvEscape(mixed $value): string\n {\n $value = $this->cellValueToString($value);\n return str_contains($value, ',') || str_contains($value, '\"') || str_contains($value, \"\\n\")\n ? '\"' . str_replace('\"', '\"\"', $value) . '\"'\n : $value;\n }\n\n private function normaliseAiItem(array $raw): ?array\n {\n if (! empty($raw['rejected'])) {\n return null;\n }\n\n $description = trim((string) ($raw['cleaned_description'] ?? $raw['product_name'] ?? $raw['description'] ?? ''));\n if ($description === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $description)) {\n return null;\n }\n\n $quantity = is_numeric($raw['quantity'] ?? null) ? (float) $raw['quantity'] : 1;\n $unitPrice = is_numeric($raw['unit_price'] ?? null) ? (float) $raw['unit_price'] : null;\n $totalPrice = is_numeric($raw['total_price'] ?? null) ? (float) $raw['total_price'] : null;\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $specs = is_array($raw['specifications'] ?? null) ? $raw['specifications'] : [];\n\n return [\n 'description' => $description,\n 'quantity' => $quantity,\n 'unit' => (string) ($raw['unit'] ?? ''),\n 'category' => (string) ($raw['core_product'] ?? $raw['category'] ?? ''),\n 'brand' => (string) ($specs['brand'] ?? $raw['brand'] ?? ''),\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($description),\n 'unit_price' => $unitPrice,\n 'confidence' => is_numeric($raw['confidence'] ?? null) ? (float) $raw['confidence'] : null,\n 'needs_review' => ! empty($raw['needs_review']),\n 'raw_data' => [\n 'source_item_no' => (string) ($raw['source_item_no'] ?? ''),\n 'original_description' => (string) ($raw['original_description'] ?? $description),\n 'cleaned_description' => $description,\n 'core_product' => (string) ($raw['core_product'] ?? ''),\n 'specifications' => $specs,\n 'extraction_type' => (string) ($raw['extraction_type'] ?? 'supply_only'),\n 'note' => (string) ($raw['note'] ?? $raw['notes'] ?? ''),\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function buildSystemInstruction(): string\n {\n return <<<'TXT'\nYou are a strict BOQ Supply Product Extraction Engine.\nExtract only purchasable physical supply products. Reject totals, headings, general requirements, installation-only, testing, commissioning, supervision, mobilization, civil works, labor, and vague rows.\nFor Supply & Install rows, keep only the supply product and remove install/labor wording.\nReturn only valid JSON: {\"items\":[{\"source_item_no\":null,\"original_description\":\"\",\"cleaned_description\":\"\",\"core_product\":\"\",\"specifications\":{},\"quantity\":0,\"unit\":\"\",\"unit_price\":0,\"total_price\":0,\"extraction_type\":\"supply_only\",\"needs_review\":false,\"note\":\"\",\"rejected\":false,\"rejection_reason\":null,\"confidence\":0.95}]}\nTXT;\n }\n\n private function buildDeepSeekPrompt(array $context = [], string $mime = ''): string\n {\n $projectName = (string) ($context['project_name'] ?? '');\n $projectStatus = (string) ($context['project_status'] ?? '');\n $contextLine = $projectName !== ''\n ? \"Project: {$projectName}\" . ($projectStatus !== '' ? \" ({$projectStatus})\" : '')\n : '';\n $sourceHint = str_starts_with($mime, 'image/')\n ? 'The input is an image of a BOQ table. OCR every cell carefully — read the quantity/count column (العدد, الكمية, Qty) and extract the EXACT numeric value from each row.'\n : 'The input is a BOQ document/spreadsheet.';\n\n return trim(<<<PROMPT\nYou are a procurement specialist. Your task is to extract ONLY tangible, physical supply products from this Bill of Quantities (BOQ).\n\n{$contextLine}\n{$sourceHint}\n\nSTRICT RULES — include ONLY items that are ALL of the following:\n1. A physical, tangible product that can be ordered from a supplier and delivered to site.\n2. Has a real quantity (not 0 or \"varies\" with no meaning).\n3. Has enough description to identify what the product is.\n\nEXCLUDE all of the following (do NOT include them):\n- Labor, installation, or workmanship items (e.g. \"install\", \"fix\", \"erect\", \"dismantle\", \"painting\", \"cleaning service\")\n- Project management, overhead, or profit items\n- Insurance, bonds, liability items (e.g. \"bodily injury\", \"third party liability\", \"CAR insurance\")\n- Shop drawings, as-built drawings, method statements, documentation\n- Preliminary items, mobilisation, demobilisation, provisional sums\n- Housekeeping, site clearance, waste removal\n- Floor/level labels (e.g. \"GF Floor\", \"Level 3\") — these are headers, not products\n- Pure dimension specs without a product noun (e.g. \"Dia 32 mm\", \"25 mm\") — reject unless a product name is present\n- Security services, night shifts, rentals, payments\n- Any item whose description is too vague or fragmentary to identify a specific product\n\nFor each valid physical product return JSON with:\n- description: clean product name only (strip \"supply and install\", \"supply and fix\", installation clauses, floor/spec suffixes)\n- quantity: the EXACT numeric value from the quantity/count column (العدد, الكمية, Qty, Quantity, No.). NEVER default to 1 unless the quantity column is truly absent or empty.\n- unit: unit of measure (pcs, m, m2, m3, set, no, kg, etc.)\n- category: product category\n- brand: brand/manufacturer if mentioned, else \"\"\n- engineering_required: true if the item needs engineering design before procurement (panel boards, chillers, generators, fire suppression systems, BMS, pumps, AHUs, switchgear, transformers), else false\n\nReturn ONLY a valid JSON object: {\"items\": [...]}. No explanations, no markdown, no extra text.\nPROMPT);\n }\n\n private function mockResponse(): array\n {\n return [\n 'success' => true,\n 'error' => null,\n 'items' => [[\n 'description' => 'Sample BOQ Item',\n 'quantity' => 1,\n 'unit' => 'No',\n 'category' => 'Sample',\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 1,\n 'raw_data' => [],\n 'ai_extracted' => true,\n ]],\n 'rejected' => [],\n ];\n }\n\n private function failure(string $message, bool $serviceUnavailable = false): array\n {\n return [\n 'success' => false,\n 'items' => [],\n 'rejected' => [],\n 'error' => $this->userFacingError($message, $serviceUnavailable),\n 'service_unavailable' => $serviceUnavailable,\n ];\n }\n\n /**\n * Translate any internal error into a neutral, user-safe message.\n *\n * The user must never see the name of the underlying AI/processing engine\n * (DeepSeek, Groq, Gemini, Vision, etc.) or configuration hints like API\n * keys. The detailed message is kept in the logs for debugging only.\n */\n private function userFacingError(string $message, bool $serviceUnavailable = false): string\n {\n // Detect any reference to an AI provider / engine / API config so we can\n // swap it for a generic, branded-neutral message.\n $sensitive = '/deepseek|groq|gemini|vision|openai|openrouter|api[\\s_-]?key|\\.env|non-?json|token limit/i';\n\n if (preg_match($sensitive, $message)) {\n Log::warning('QuotationAiService: suppressed technical error from user.', ['internal' => $message]);\n\n return $serviceUnavailable\n ? 'The file could not be processed right now. Please try again in a moment.'\n : 'We could not read this file automatically. Please make sure it is a clear BOQ (Excel, CSV, PDF, or image) and try again, or add the items manually.';\n }\n\n return $message;\n }\n\n private function mimeForExtension(string $ext): string\n {\n return match (strtolower($ext)) {\n 'pdf' => 'application/pdf',\n 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n 'xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12',\n 'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',\n 'xls' => 'application/vnd.ms-excel',\n 'csv' => 'text/csv',\n 'jpg', 'jpeg' => 'image/jpeg',\n 'png' => 'image/png',\n 'gif' => 'image/gif',\n 'bmp' => 'image/bmp',\n 'webp' => 'image/webp',\n 'tiff', 'tif' => 'image/tiff',\n 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'doc' => 'application/msword',\n default => 'application/octet-stream',\n };\n }\n}\n"}, "4899193889c08a561d2e173e1865ff601a46b0fa": {"t": 1785748390.4544926, "n": 49348, "h": "ad327072abc1cb55", "c": "<?php\n\nnamespace App\\Services;\n\nuse Illuminate\\Http\\Client\\ConnectionException;\nuse Illuminate\\Http\\UploadedFile;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\nuse PhpOffice\\PhpSpreadsheet\\Cell\\Coordinate;\nuse PhpOffice\\PhpSpreadsheet\\IOFactory;\n\nclass QuotationAiService\n{\n /** Increment this whenever BOQ parsing logic changes to invalidate old caches. */\n private const PARSER_VERSION = 2;\n\n private string $baseUrl;\n private string $parseEndpoint;\n private string $apiKey;\n private int $timeout;\n private bool $testMode;\n private BoqCleaningService $boqCleaner;\n\n public function __construct(BoqCleaningService $boqCleaner)\n {\n $this->boqCleaner = $boqCleaner;\n $this->baseUrl = rtrim((string) config('services.ai_quotation.base_url', ''), '/');\n $this->parseEndpoint = ltrim((string) config('services.ai_quotation.parse_endpoint', 'parse'), '/');\n $this->apiKey = (string) config('services.ai_quotation.api_key', '');\n $this->timeout = (int) config('services.ai_quotation.timeout', 300);\n $this->testMode = (bool) config('services.ai_quotation.test_mode', false);\n }\n\n /**\n * Give the running script enough head-room to outlast the AI HTTP call.\n *\n * The web request / queue worker must not be killed before the HTTP timeout\n * completes, otherwise the user sees a generic \"timeout or unavailable\"\n * error even though the AI may have responded. We allow the HTTP timeout\n * plus a 120s margin for connecting, response parsing and DB writes.\n */\n private function extendExecutionTime(): void\n {\n // Ignored on some SAPIs (e.g. when running under fastcgi with a hard\n // limit), but harmless to attempt.\n @set_time_limit($this->timeout + 120);\n }\n\n public function parseBoq(UploadedFile|string $file, array $context = []): array\n {\n if ($this->testMode) {\n return $this->mockResponse();\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n\n if (! is_file($absPath)) {\n return $this->failure('File not found or not readable.');\n }\n\n $fileHash = hash_file('sha256', $absPath);\n // Bump PARSER_VERSION whenever the extraction logic changes so that\n // previously-uploaded files are re-parsed instead of returning a stale\n // cached result produced by the old code.\n $cacheKey = 'boq_analysis_v' . self::PARSER_VERSION . '_' . $fileHash;\n $forceRefresh = (bool) ($context['force_refresh'] ?? false);\n\n if (! $forceRefresh && ($cached = Cache::get($cacheKey)) !== null) {\n return $cached;\n }\n\n // Clear stale cached result when force-refreshing\n if ($forceRefresh) {\n Cache::forget($cacheKey);\n }\n\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n // Read Excel locally first. Fall back to AI if local parse failed,\n // found 0 items, OR could not read one of the sheets (so multi-sheet\n // files never silently drop a sheet the local parser didn't understand).\n $result = $this->parseSpreadsheetDirect($absPath);\n\n $hasSkippedSheets = ! empty($result['skipped_sheets']);\n\n if (! $result['success'] || empty($result['items']) || $hasSkippedSheets) {\n $deepSeekResult = $this->parseBoqWithDeepSeek($file, $context);\n // AI sees every sheet at once, so prefer its result whenever it\n // actually returned items (covers the skipped-sheet case too).\n if ($deepSeekResult['success'] && ! empty($deepSeekResult['items'])) {\n // Keep the larger of the two item sets so we never lose the\n // rows the local parser already extracted correctly.\n if (count($deepSeekResult['items']) >= count($result['items'] ?? [])) {\n $result = $deepSeekResult;\n }\n } elseif (! $result['success']) {\n $result = $deepSeekResult;\n }\n // else: keep local result if AI also failed/returned nothing\n }\n } elseif (in_array($ext, ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif'], true)) {\n $result = $this->parseBoqWithDeepSeek($file, $context);\n } else {\n return $this->failure('Unsupported file type. Please upload Excel, CSV, PDF, or image file.');\n }\n\n if ($result['success'] && ! empty($result['items'])) {\n $cleaning = $this->boqCleaner->process($result['items']);\n $result['items'] = $cleaning['accepted'] ?? $result['items'];\n $result['rejected'] = array_merge($result['rejected'] ?? [], $cleaning['rejected'] ?? []);\n }\n\n // Only cache when we actually extracted items; empty results should be re-tried.\n if ($result['success'] && ! empty($result['items'])) {\n Cache::put($cacheKey, $result, now()->addDays(30));\n }\n\n return $result;\n }\n\n private function resolveFile(UploadedFile|string $file): array\n {\n if ($file instanceof UploadedFile) {\n return [(string) $file->getRealPath(), strtolower($file->getClientOriginalExtension())];\n }\n\n $absPath = is_file($file) ? $file : storage_path('app/' . ltrim($file, '/'));\n return [$absPath, strtolower(pathinfo($absPath, PATHINFO_EXTENSION))];\n }\n\n private function parseSpreadsheetDirect(string $absPath): array\n {\n ini_set('memory_limit', '1024M');\n set_time_limit(180);\n\n try {\n $sheets = $this->spreadsheetToGrids($absPath);\n\n if (empty($sheets)) {\n return $this->failure('Could not read the Excel file.');\n }\n\n $items = [];\n $rejected = [];\n $skippedSheets = []; // sheets we could not parse locally (header undetected)\n\n foreach ($sheets as $sheet) {\n $grid = $sheet['grid'];\n if (count($grid) < 2) {\n continue;\n }\n\n $header = $this->detectHeader($grid);\n\n if ($header === null || ! isset($header['map']['description'])) {\n Log::warning('QuotationAiService: header not detected in sheet.', ['sheet' => $sheet['name']]);\n $skippedSheets[] = $sheet['name'];\n continue;\n }\n\n $map = $header['map'];\n $start = $header['row'] + 1;\n\n for ($r = $start; $r < count($grid); $r++) {\n $row = $grid[$r];\n $description = $this->cell($row, $map['description'] ?? null);\n $itemNo = $this->cell($row, $map['item_code'] ?? null);\n $unit = $this->cell($row, $map['unit'] ?? null);\n $quantity = $this->number($this->cell($row, $map['quantity'] ?? null));\n $unitPrice = $this->number($this->cell($row, $map['unit_price'] ?? null));\n $totalPrice = $this->number($this->cell($row, $map['total_price'] ?? null));\n\n if ($description === '') {\n continue;\n }\n\n // Skip floor breakdown sub-rows: Ref. is \"-\" meaning it's a per-floor\n // quantity split of the parent item, not a standalone product.\n if ($itemNo === '-' || $this->isFloorBreakdownRow($itemNo, $description)) {\n continue;\n }\n\n if ($this->isTotalOrHeaderRow($description)) {\n continue;\n }\n\n if ($quantity === null || $quantity <= 0) {\n $rejected[] = $this->rejectedRow($description, $unit, $quantity, 'Missing quantity', $itemNo, $sheet['name']);\n continue;\n }\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $supply = $this->boqCleaner->filterItem($description);\n\n if (! ($supply['keep'] ?? false)) {\n $rejected[] = $this->rejectedRow(\n $description,\n $unit,\n $quantity,\n (string) ($supply['rejection_reason'] ?? 'Rejected by cleaner'),\n $itemNo,\n $sheet['name']\n );\n continue;\n }\n\n $finalDescription = (string) ($supply['description'] ?? $description);\n\n $items[] = [\n 'description' => $finalDescription,\n 'quantity' => $quantity,\n 'unit' => $unit,\n 'category' => $sheet['name'],\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($finalDescription),\n 'unit_price' => $unitPrice,\n 'confidence' => 0.95,\n 'needs_review' => false,\n 'raw_data' => [\n 'sheet' => $sheet['name'],\n 'row_number' => $r + 1,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'cleaned_description' => $finalDescription,\n 'extraction_type' => (string) ($supply['extraction_type'] ?? 'supply_only'),\n ],\n 'ai_extracted' => true,\n ];\n }\n }\n\n if (empty($items)) {\n return $this->failure('No BOQ supply items could be extracted from the Excel file.');\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'skipped_sheets' => $skippedSheets,\n ];\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: parseSpreadsheetDirect failed.', [\n 'path' => $absPath,\n 'message' => $e->getMessage(),\n 'trace' => $e->getTraceAsString(),\n ]);\n\n return $this->failure('Failed to read the Excel file: ' . $e->getMessage());\n }\n }\n\n private function spreadsheetToGrids(string $absPath): array\n {\n $ext = strtolower(pathinfo($absPath, PATHINFO_EXTENSION));\n\n if ($ext === 'csv') {\n $rows = [];\n $handle = fopen($absPath, 'rb');\n if ($handle !== false) {\n while (($row = fgetcsv($handle)) !== false) {\n $rows[] = $this->trimTrailingEmptyCells($row);\n }\n fclose($handle);\n }\n return [['name' => 'Sheet1', 'grid' => $rows]];\n }\n\n $reader = IOFactory::createReader($ext === 'xls' ? 'Xls' : 'Xlsx');\n $reader->setReadDataOnly(true);\n $spreadsheet = $reader->load($absPath);\n $sheets = [];\n\n foreach ($spreadsheet->getAllSheets() as $sheet) {\n $highestRow = $sheet->getHighestDataRow();\n $highestColumn = $sheet->getHighestDataColumn();\n $highestColumnIndex = Coordinate::columnIndexFromString($highestColumn);\n\n // BOQ data is normally in first 8-20 columns.\n // Files with heavy formatting/images can report 300+ columns — limit to 30.\n $maxColumnIndex = min($highestColumnIndex, 30);\n\n $grid = [];\n for ($row = 1; $row <= $highestRow; $row++) {\n $rowData = [];\n $hasValue = false;\n\n for ($col = 1; $col <= $maxColumnIndex; $col++) {\n $value = $sheet->getCell([$col, $row])->getValue();\n $value = $this->cellValueToString($value);\n $value = trim($value);\n if ($value !== null && $value !== '') {\n $hasValue = true;\n }\n $rowData[] = $value;\n }\n\n $rowData = $this->trimTrailingEmptyCells($rowData);\n if ($hasValue) {\n $grid[] = $rowData;\n }\n }\n\n if (! empty($grid)) {\n $sheets[] = ['name' => $sheet->getTitle(), 'grid' => $grid];\n }\n }\n\n $spreadsheet->disconnectWorksheets();\n unset($spreadsheet);\n\n return $sheets;\n }\n\n private function detectHeader(array $grid): ?array\n {\n $keywords = [\n 'item_code' => ['رقم البند', 'item no', 'item number', 'ref.', 'ref', 'item', 'no.', 'no', '#', 'كود'],\n 'description' => ['وص٠البند', 'scope of works', 'scope of work', 'scope', 'item description', 'description of works', 'description', 'بالعربية', 'الوصÙ', 'البند', 'بيان'],\n 'unit' => ['Ø§Ù„ÙˆØØ¯Ø©', 'unit', 'uom', 'u/m'],\n 'quantity' => ['الكمية', 'qty', 'quantity', 'q.ty'],\n 'unit_price' => ['سعر Ø§Ù„ÙˆØØ¯Ø©', 'u/price', 'unit price', 'unit rate', 'rate', 'price'],\n 'total_price' => ['السعر الإجمالي', 'total amount', 'total price', 'amount', 'الإجمالي'],\n ];\n\n $best = null;\n $bestScore = 0;\n\n for ($r = 0; $r < min(30, count($grid)); $r++) {\n $map = [];\n $score = 0;\n\n foreach ($grid[$r] as $c => $value) {\n $cell = mb_strtolower(trim((string) $value));\n if ($cell === '') {\n continue;\n }\n\n foreach ($keywords as $field => $list) {\n foreach ($list as $kw) {\n if (str_contains($cell, mb_strtolower($kw))) {\n if (! isset($map[$field])) {\n $map[$field] = $c;\n $score++;\n }\n break 2;\n }\n }\n }\n }\n\n if ($score > $bestScore) {\n $bestScore = $score;\n $best = ['row' => $r, 'map' => $map];\n }\n }\n\n return $bestScore >= 3 ? $best : null;\n }\n\n private function cell(array $row, ?int $index): string\n {\n if ($index === null) {\n return '';\n }\n return trim((string) ($row[$index] ?? ''));\n }\n\n private function number(string $value): ?float\n {\n $value = trim(str_replace([',', 'SAR', 'ر.س'], '', $value));\n if ($value === '' || ! is_numeric($value)) {\n return null;\n }\n return (float) $value;\n }\n\n private function trimTrailingEmptyCells(array $row): array\n {\n while (! empty($row) && trim((string) end($row)) === '') {\n array_pop($row);\n }\n return array_values($row);\n }\n\n private function isTotalOrHeaderRow(string $description): bool\n {\n $d = mb_strtolower(trim($description));\n\n if ($d === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $d)) {\n return true;\n }\n\n // Section totals / summaries\n if (preg_match('/(اجمالي|إجمالي|المجموع|grand\\s*total|sub\\s*total|total\\s*amount|total\\s*price)/iu', $d)) {\n return true;\n }\n\n // Pure floor / level labels used as section headers\n if (preg_match('/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor)\\s*$/iu', $d)) {\n return true;\n }\n\n // Division / section header lines (e.g. \"Division - 02: Wood Works\")\n if (preg_match('/^division\\s*([-:]|\\d)/iu', $d)) {\n return true;\n }\n\n return false;\n }\n\n private function isFloorBreakdownRow(string $itemNo, string $description): bool\n {\n // A row whose Ref column is empty or a dash is a sub-breakdown row (per-floor split).\n $ref = trim($itemNo);\n if ($ref === '' || $ref === '-') {\n // Confirm by checking the description is a location/floor label\n $d = mb_strtolower(trim($description));\n return (bool) preg_match(\n '/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor|\\d+f)\\s*$/iu',\n $d\n );\n }\n return false;\n }\n\n private function rejectedRow(string $description, string $unit, ?float $quantity, string $reason, string $itemNo = '', string $sheet = ''): array\n {\n return [\n 'description' => $description,\n 'quantity' => $quantity ?? 0,\n 'unit' => $unit,\n 'category' => $sheet,\n 'brand' => '',\n 'status' => 'rejected',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 0.0,\n 'raw_data' => [\n 'sheet' => $sheet,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'extraction_type' => 'rejected',\n 'rejection_reason' => $reason,\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function parseBoqWithDeepSeek(UploadedFile|string $file, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured. Please set DEEPSEEK_API_KEY in .env file.');\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n $mime = $this->mimeForExtension($ext);\n\n try {\n // ── Spreadsheets ──────────────────────────────────────────────────\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n $text = $this->spreadsheetToCompactCsvText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not convert spreadsheet to readable text.');\n }\n $text = $this->sanitizeUtf8(mb_substr($text, 0, 180000));\n $userContent = \"BOQ spreadsheet converted to CSV:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── PDF ───────────────────────────────────────────────────────────\n if ($ext === 'pdf') {\n $extracted = $this->extractPdfText($absPath);\n if ($extracted !== null && mb_strlen(trim($extracted)) > 50) {\n $extracted = $this->sanitizeUtf8(mb_substr($extracted, 0, 180000));\n $userContent = \"BOQ PDF extracted text:\\n\\n\" . $extracted . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'application/pdf');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n return $this->failure('Could not extract text from the PDF. Please make sure it is a text-based PDF (not a scanned image), or convert it to Excel or CSV.');\n }\n\n // -- Word documents (docx) ---------------------------------------------------\n if (in_array($ext, ['docx', 'doc'], true)) {\n $text = $this->extractDocxText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not extract text from the Word document. Please convert it to Excel, CSV, or PDF.');\n }\n $text = $this->sanitizeUtf8(mb_substr($text, 0, 180000));\n $userContent = \"BOQ Word document extracted text:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── Images ──────────────────────────────────────────────────────────────\n // DeepSeek cannot receive images. A vision-capable AI processes the image\n // directly (no OCR) — configure GROQ_API_KEY or GEMINI_API_KEY in .env.\n $imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif', 'heic', 'heif'];\n if (in_array($ext, $imageExts, true)) {\n $bytes = (string) file_get_contents($absPath);\n return $this->callDeepSeekVision($bytes, $mime, $context);\n }\n\n return $this->failure('Unsupported file type. Please upload an Excel, CSV, PDF, or image file.');\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: DeepSeek fallback failed.', ['message' => $e->getMessage()]);\n return $this->failure('An error occurred while processing the file with AI: ' . $e->getMessage());\n }\n }\n\n private function deepSeekModel(): string\n {\n return (string) config('services.deepseek.model', 'deepseek-chat');\n }\n\n /**\n * Remove or replace any non-UTF-8 / malformed byte sequences so json_encode never fails.\n */\n private function sanitizeUtf8(string $text): string\n {\n // Replace invalid byte sequences with the UTF-8 replacement character\n $clean = mb_convert_encoding($text, 'UTF-8', 'UTF-8');\n // Strip any remaining non-printable control characters (except tab/newline/CR)\n $clean = (string) preg_replace('/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]/u', '', $clean);\n return $clean;\n }\n\n /**\n * Extract plain text from a Word docx file using ZipArchive (no external library needed).\n * Reads word/document.xml and strips XML tags, preserving paragraph and table structure.\n */\n private function extractDocxText(string $absPath): ?string\n {\n try {\n $zip = new \\ZipArchive();\n if ($zip->open($absPath) !== true) {\n return null;\n }\n\n $xml = $zip->getFromName('word/document.xml');\n $zip->close();\n\n if ($xml === false || $xml === '') {\n return null;\n }\n\n // Add newlines before paragraph/table-row/table-cell tags so structure is preserved\n $xml = preg_replace('/<w:p[ >]/', \"\\n<w:p>\", $xml) ?? $xml;\n $xml = preg_replace('/<w:tr[ >]/', \"\\n<w:tr>\", $xml) ?? $xml;\n $xml = preg_replace('/<\\/w:tc>/', \"\\t\", $xml) ?? $xml;\n\n $text = strip_tags($xml);\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n return trim($text) !== '' ? trim($text) : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: DOCX parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Extract plain text from a PDF using smalot/pdfparser (pure PHP, no binary required).\n */\n private function extractPdfText(string $absPath): ?string\n {\n try {\n $parser = new \\Smalot\\PdfParser\\Parser();\n $pdf = $parser->parseFile($absPath);\n $text = $pdf->getText();\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n return $text !== '' ? $text : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: PDF parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Send a vision request to a vision-capable AI model.\n * DeepSeek's commercial API (api.deepseek.com) does NOT support image inputs — any model.\n * Priority order (first configured key wins):\n * 1. GROQ_API_KEY → Groq llama-4-scout (free: https://console.groq.com)\n * 2. GEMINI_API_KEY → Google Gemini Flash (free: https://aistudio.google.com/apikey)\n * 3. VISION_API_KEY → any OpenAI-compatible endpoint (OpenAI, OpenRouter …)\n */\n private function callDeepSeekVision(string $bytes, string $mime, array $context): array\n {\n // ── 1. Groq (free, reliable vision) ─────────────────────────────────────\n if ((string) config('services.groq.key', '') !== '') {\n $visionKey = (string) config('services.groq.key');\n $visionBaseUrl = 'https://api.groq.com/openai/v1';\n $visionModel = (string) config('services.groq.model', 'meta-llama/llama-4-scout-17b-16e-instruct');\n }\n // ── 2. Google Gemini ─────────────────────────────────────────────────────\n elseif ((string) config('services.gemini.key', '') !== '') {\n $visionKey = (string) config('services.gemini.key');\n $visionBaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai';\n $visionModel = (string) config('services.gemini.model', 'gemini-2.0-flash');\n }\n // ── 3. Generic VISION_API_KEY (OpenAI, OpenRouter, …) ───────────────────\n elseif ((string) config('services.vision.key', '') !== '') {\n $visionKey = (string) config('services.vision.key');\n $visionBaseUrl = rtrim((string) config('services.vision.base_url', 'https://openrouter.ai/api/v1'), '/');\n $visionModel = (string) config('services.vision.model', 'google/gemini-flash-1.5-8b');\n }\n else {\n return $this->failure(\n 'Image processing is not available. DeepSeek\\'s API does not support images. ' .\n 'Get a free key at https://console.groq.com and add GROQ_API_KEY to your .env file.'\n );\n }\n\n $dataUrl = \"data:{$mime};base64,\" . base64_encode($bytes);\n $prompt = $this->buildDeepSeekPrompt($context, $mime);\n\n $userContent = [\n ['type' => 'text', 'text' => $prompt],\n ['type' => 'image_url', 'image_url' => ['url' => $dataUrl]],\n ];\n\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders([\n 'Authorization' => 'Bearer ' . $visionKey,\n 'HTTP-Referer' => config('app.url', 'https://qimta.sa'),\n 'X-Title' => 'Qimta Platform',\n ])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post($visionBaseUrl . '/chat/completions', [\n 'model' => $visionModel,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 16384,\n 'temperature' => 0.2,\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('Vision API connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: Vision API HTTP error.', [\n 'model' => $visionModel,\n 'base_url' => $visionBaseUrl,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n return $this->failure(\"Vision API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n return $this->parseDeepSeekJsonResponse((string) $response->json('choices.0.message.content'));\n }\n\n private function callDeepSeekChat(string $userContent, string $key, string $model): array\n {\n // Ensure the PHP script outlives the HTTP call (+ a margin for parsing the\n // response and writing to the DB) so the worker/web request is not killed\n // mid-flight, which surfaces to users as a generic \"timeout or unavailable\".\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders(['Authorization' => 'Bearer ' . $key])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 32768,\n 'temperature' => 0.1,\n 'response_format' => ['type' => 'json_object'],\n 'user' => 'Qimta_Platform',\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('DeepSeek connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: DeepSeek HTTP error.', [\n 'model' => $model,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n if ($response->status() === 401) {\n return $this->failure('DeepSeek API key is invalid or unauthorised. Please update DEEPSEEK_API_KEY in .env.', false);\n }\n return $this->failure(\"DeepSeek API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n $text = (string) $response->json('choices.0.message.content');\n return $this->parseDeepSeekJsonResponse($text);\n }\n\n /**\n * Shared JSON response parser used by both chat and vision paths.\n */\n private function parseDeepSeekJsonResponse(string $text): array\n {\n if ($text === '') {\n return $this->failure('DeepSeek returned an empty response.');\n }\n\n // Strip markdown code fences if the model wrapped the JSON.\n if (preg_match('/```(?:json)?\\s*([\\s\\S]*?)\\s*```/i', $text, $m)) {\n $text = $m[1];\n }\n // Narrow to the outermost JSON object/array.\n if (preg_match('/[\\{\\[][\\s\\S]*[\\}\\]]/s', $text, $m)) {\n $text = $m[0];\n }\n $text = trim($text);\n\n $decoded = json_decode($text, true);\n\n // The response can be truncated when the model hits the token limit,\n // leaving an unterminated JSON string/object. Try to repair it before\n // giving up so we can still salvage the items extracted so far.\n if (! is_array($decoded)) {\n $repaired = $this->repairTruncatedJson($text);\n if ($repaired !== null) {\n $decoded = json_decode($repaired, true);\n }\n }\n\n if (! is_array($decoded)) {\n Log::error('QuotationAiService: DeepSeek non-JSON response.', ['text' => mb_substr($text, 0, 1000)]);\n return $this->failure('DeepSeek returned a non-JSON response. The file may be too large — try splitting it into smaller files.');\n }\n\n $rawItems = $decoded['items'] ?? (array_is_list($decoded) ? $decoded : []);\n $items = [];\n $rejected = [];\n\n foreach ($rawItems as $raw) {\n if (! is_array($raw)) {\n continue;\n }\n $item = $this->normaliseAiItem($raw);\n if ($item === null) {\n continue;\n }\n $check = $this->boqCleaner->filterItem((string) $item['description']);\n if ($check['keep'] ?? false) {\n $items[] = $item;\n } else {\n $item['status'] = 'rejected';\n $item['raw_data']['rejection_reason'] = $check['rejection_reason'] ?? 'Rejected by cleaner';\n $rejected[] = $item;\n }\n }\n\n if (empty($items)) {\n Log::info('QuotationAiService: DeepSeek returned 0 accepted items.', [\n 'raw_items_count' => count($rawItems),\n 'rejected_count' => count($rejected),\n 'first_rejected' => ! empty($rejected) ? ($rejected[0]['description'] ?? '') : '',\n ]);\n }\n\n return ['success' => true, 'items' => $items, 'rejected' => $rejected, 'error' => null];\n }\n\n /**\n * Best-effort repair of a truncated JSON string produced when the model hits\n * its token limit mid-output. Drops any incomplete trailing object, then\n * closes the open string/array/object brackets so the result is valid JSON.\n *\n * Returns the repaired JSON string, or null if it cannot be salvaged.\n */\n private function repairTruncatedJson(string $text): ?string\n {\n if ($text === '') {\n return null;\n }\n\n // Walk the string tracking structural state, ignoring everything inside\n // string literals (and their escapes). Remember the last position where\n // we were back at the top of the \"items\" array between complete elements,\n // so we can cut a half-written trailing object.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($text);\n $lastSafeCut = null; // index just after a completed array element\n\n for ($i = 0; $i < $len; $i++) {\n $ch = $text[$i];\n\n if ($inString) {\n if ($escaped) {\n $escaped = false;\n } elseif ($ch === '\\\\') {\n $escaped = true;\n } elseif ($ch === '\"') {\n $inString = false;\n }\n continue;\n }\n\n if ($ch === '\"') {\n $inString = true;\n } elseif ($ch === '{' || $ch === '[') {\n $stack[] = $ch;\n } elseif ($ch === '}' || $ch === ']') {\n array_pop($stack);\n } elseif ($ch === ',' && ! empty($stack) && end($stack) === '[') {\n // Separator directly inside an array: a clean boundary between\n // elements (the items array, whatever its nesting depth).\n $lastSafeCut = $i;\n }\n }\n\n // If we are still inside a string, the last element is incomplete — fall\n // back to the last clean array boundary if we have one.\n $candidate = $text;\n if ($inString && $lastSafeCut !== null) {\n $candidate = substr($text, 0, $lastSafeCut);\n } else {\n // Drop a dangling partial object after the last completed element.\n $candidate = rtrim($candidate);\n if ($lastSafeCut !== null && substr(rtrim($candidate), -1) !== '}' && substr(rtrim($candidate), -1) !== ']') {\n $candidate = substr($text, 0, $lastSafeCut);\n }\n }\n\n // Recompute the open brackets for the (possibly trimmed) candidate and\n // append the matching closers in reverse order.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($candidate);\n for ($i = 0; $i < $len; $i++) {\n $ch = $candidate[$i];\n if ($inString) {\n if ($escaped) { $escaped = false; }\n elseif ($ch === '\\\\') { $escaped = true; }\n elseif ($ch === '\"') { $inString = false; }\n continue;\n }\n if ($ch === '\"') { $inString = true; }\n elseif ($ch === '{' || $ch === '[') { $stack[] = $ch; }\n elseif ($ch === '}' || $ch === ']') { array_pop($stack); }\n }\n\n $candidate = rtrim($candidate);\n // Remove a trailing comma left after cutting an element.\n $candidate = rtrim($candidate, ',');\n\n $closers = '';\n for ($i = count($stack) - 1; $i >= 0; $i--) {\n $closers .= ($stack[$i] === '{') ? '}' : ']';\n }\n\n $repaired = $candidate . $closers;\n\n // Validate before returning.\n return is_array(json_decode($repaired, true)) ? $repaired : null;\n }\n\n private function spreadsheetToCompactCsvText(string $absPath): ?string\n {\n $sheets = $this->spreadsheetToGrids($absPath);\n if (empty($sheets)) {\n return null;\n }\n\n $output = '';\n foreach ($sheets as $sheet) {\n $output .= 'Sheet: ' . $sheet['name'] . \"\\n\";\n foreach ($sheet['grid'] as $row) {\n $row = $this->trimTrailingEmptyCells($row);\n if (empty($row)) {\n continue;\n }\n $output .= implode(',', array_map([$this, 'csvEscape'], $row)) . \"\\n\";\n }\n $output .= \"\\n\";\n }\n\n return $output;\n }\n\n /**\n * Safely convert a PhpSpreadsheet cell value to a string.\n *\n * Cells may contain RichText or, in malformed/heavily-formatted files,\n * embedded objects such as Worksheet\\Drawing that have no __toString().\n * Casting those directly throws \"Object ... could not be converted to string\".\n */\n private function cellValueToString(mixed $value): string\n {\n if ($value === null) {\n return '';\n }\n\n if ($value instanceof \\PhpOffice\\PhpSpreadsheet\\RichText\\RichText) {\n return $value->getPlainText();\n }\n\n if (is_object($value)) {\n // Only stringify objects that explicitly support it; ignore the rest\n // (e.g. Worksheet\\Drawing) instead of crashing.\n return method_exists($value, '__toString') ? (string) $value : '';\n }\n\n if (is_array($value)) {\n return '';\n }\n\n return (string) $value;\n }\n\n private function csvEscape(mixed $value): string\n {\n $value = $this->cellValueToString($value);\n return str_contains($value, ',') || str_contains($value, '\"') || str_contains($value, \"\\n\")\n ? '\"' . str_replace('\"', '\"\"', $value) . '\"'\n : $value;\n }\n\n private function normaliseAiItem(array $raw): ?array\n {\n if (! empty($raw['rejected'])) {\n return null;\n }\n\n $description = trim((string) ($raw['cleaned_description'] ?? $raw['product_name'] ?? $raw['description'] ?? ''));\n if ($description === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $description)) {\n return null;\n }\n\n $quantity = is_numeric($raw['quantity'] ?? null) ? (float) $raw['quantity'] : 1;\n $unitPrice = is_numeric($raw['unit_price'] ?? null) ? (float) $raw['unit_price'] : null;\n $totalPrice = is_numeric($raw['total_price'] ?? null) ? (float) $raw['total_price'] : null;\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $specs = is_array($raw['specifications'] ?? null) ? $raw['specifications'] : [];\n\n return [\n 'description' => $description,\n 'quantity' => $quantity,\n 'unit' => (string) ($raw['unit'] ?? ''),\n 'category' => (string) ($raw['core_product'] ?? $raw['category'] ?? ''),\n 'brand' => (string) ($specs['brand'] ?? $raw['brand'] ?? ''),\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($description),\n 'unit_price' => $unitPrice,\n 'confidence' => is_numeric($raw['confidence'] ?? null) ? (float) $raw['confidence'] : null,\n 'needs_review' => ! empty($raw['needs_review']),\n 'raw_data' => [\n 'source_item_no' => (string) ($raw['source_item_no'] ?? ''),\n 'original_description' => (string) ($raw['original_description'] ?? $description),\n 'cleaned_description' => $description,\n 'core_product' => (string) ($raw['core_product'] ?? ''),\n 'specifications' => $specs,\n 'extraction_type' => (string) ($raw['extraction_type'] ?? 'supply_only'),\n 'note' => (string) ($raw['note'] ?? $raw['notes'] ?? ''),\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function buildSystemInstruction(): string\n {\n return <<<'TXT'\nYou are a strict BOQ Supply Product Extraction Engine.\nExtract only purchasable physical supply products. Reject totals, headings, general requirements, installation-only, testing, commissioning, supervision, mobilization, civil works, labor, and vague rows.\nFor Supply & Install rows, keep only the supply product and remove install/labor wording.\nReturn only valid JSON: {\"items\":[{\"source_item_no\":null,\"original_description\":\"\",\"cleaned_description\":\"\",\"core_product\":\"\",\"specifications\":{},\"quantity\":0,\"unit\":\"\",\"unit_price\":0,\"total_price\":0,\"extraction_type\":\"supply_only\",\"needs_review\":false,\"note\":\"\",\"rejected\":false,\"rejection_reason\":null,\"confidence\":0.95}]}\nTXT;\n }\n\n private function buildDeepSeekPrompt(array $context = [], string $mime = ''): string\n {\n $projectName = (string) ($context['project_name'] ?? '');\n $projectStatus = (string) ($context['project_status'] ?? '');\n $contextLine = $projectName !== ''\n ? \"Project: {$projectName}\" . ($projectStatus !== '' ? \" ({$projectStatus})\" : '')\n : '';\n $sourceHint = str_starts_with($mime, 'image/')\n ? 'The input is an image of a BOQ table. OCR every cell carefully — read the quantity/count column (العدد, الكمية, Qty) and extract the EXACT numeric value from each row.'\n : 'The input is a BOQ document/spreadsheet.';\n\n return trim(<<<PROMPT\nYou are a procurement specialist. Your task is to extract ONLY tangible, physical supply products from this Bill of Quantities (BOQ).\n\n{$contextLine}\n{$sourceHint}\n\nSTRICT RULES — include ONLY items that are ALL of the following:\n1. A physical, tangible product that can be ordered from a supplier and delivered to site.\n2. Has a real quantity (not 0 or \"varies\" with no meaning).\n3. Has enough description to identify what the product is.\n\nEXCLUDE all of the following (do NOT include them):\n- Labor, installation, or workmanship items (e.g. \"install\", \"fix\", \"erect\", \"dismantle\", \"painting\", \"cleaning service\")\n- Project management, overhead, or profit items\n- Insurance, bonds, liability items (e.g. \"bodily injury\", \"third party liability\", \"CAR insurance\")\n- Shop drawings, as-built drawings, method statements, documentation\n- Preliminary items, mobilisation, demobilisation, provisional sums\n- Housekeeping, site clearance, waste removal\n- Floor/level labels (e.g. \"GF Floor\", \"Level 3\") — these are headers, not products\n- Pure dimension specs without a product noun (e.g. \"Dia 32 mm\", \"25 mm\") — reject unless a product name is present\n- Security services, night shifts, rentals, payments\n- Any item whose description is too vague or fragmentary to identify a specific product\n\nFor each valid physical product return JSON with:\n- description: clean product name only (strip \"supply and install\", \"supply and fix\", installation clauses, floor/spec suffixes)\n- quantity: the EXACT numeric value from the quantity/count column (العدد, الكمية, Qty, Quantity, No.). NEVER default to 1 unless the quantity column is truly absent or empty.\n- unit: unit of measure (pcs, m, m2, m3, set, no, kg, etc.)\n- category: product category\n- brand: brand/manufacturer if mentioned, else \"\"\n- engineering_required: true if the item needs engineering design before procurement (panel boards, chillers, generators, fire suppression systems, BMS, pumps, AHUs, switchgear, transformers), else false\n\nReturn ONLY a valid JSON object: {\"items\": [...]}. No explanations, no markdown, no extra text.\nPROMPT);\n }\n\n private function mockResponse(): array\n {\n return [\n 'success' => true,\n 'error' => null,\n 'items' => [[\n 'description' => 'Sample BOQ Item',\n 'quantity' => 1,\n 'unit' => 'No',\n 'category' => 'Sample',\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 1,\n 'raw_data' => [],\n 'ai_extracted' => true,\n ]],\n 'rejected' => [],\n ];\n }\n\n private function failure(string $message, bool $serviceUnavailable = false): array\n {\n return [\n 'success' => false,\n 'items' => [],\n 'rejected' => [],\n 'error' => $this->userFacingError($message, $serviceUnavailable),\n 'service_unavailable' => $serviceUnavailable,\n ];\n }\n\n /**\n * Translate any internal error into a neutral, user-safe message.\n *\n * The user must never see the name of the underlying AI/processing engine\n * (DeepSeek, Groq, Gemini, Vision, etc.) or configuration hints like API\n * keys. The detailed message is kept in the logs for debugging only.\n */\n private function userFacingError(string $message, bool $serviceUnavailable = false): string\n {\n // Detect any reference to an AI provider / engine / API config so we can\n // swap it for a generic, branded-neutral message.\n $sensitive = '/deepseek|groq|gemini|vision|openai|openrouter|api[\\s_-]?key|\\.env|non-?json|token limit/i';\n\n if (preg_match($sensitive, $message)) {\n Log::warning('QuotationAiService: suppressed technical error from user.', ['internal' => $message]);\n\n return $serviceUnavailable\n ? 'The file could not be processed right now. Please try again in a moment.'\n : 'We could not read this file automatically. Please make sure it is a clear BOQ (Excel, CSV, PDF, or image) and try again, or add the items manually.';\n }\n\n return $message;\n }\n\n private function mimeForExtension(string $ext): string\n {\n return match (strtolower($ext)) {\n 'pdf' => 'application/pdf',\n 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n 'xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12',\n 'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',\n 'xls' => 'application/vnd.ms-excel',\n 'csv' => 'text/csv',\n 'jpg', 'jpeg' => 'image/jpeg',\n 'png' => 'image/png',\n 'gif' => 'image/gif',\n 'bmp' => 'image/bmp',\n 'webp' => 'image/webp',\n 'tiff', 'tif' => 'image/tiff',\n 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'doc' => 'application/msword',\n default => 'application/octet-stream',\n };\n }\n}\n"}, "ed91cf7f5173f67311b3f74ee5255bb04048ea45": {"t": 1785748391.7940319, "n": 48984, "h": "cf7f9cbbdb26d379", "c": "<?php\n\nnamespace App\\Services;\n\nuse Illuminate\\Http\\Client\\ConnectionException;\nuse Illuminate\\Http\\UploadedFile;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\nuse PhpOffice\\PhpSpreadsheet\\Cell\\Coordinate;\nuse PhpOffice\\PhpSpreadsheet\\IOFactory;\n\nclass QuotationAiService\n{\n private string $baseUrl;\n private string $parseEndpoint;\n private string $apiKey;\n private int $timeout;\n private bool $testMode;\n private BoqCleaningService $boqCleaner;\n\n public function __construct(BoqCleaningService $boqCleaner)\n {\n $this->boqCleaner = $boqCleaner;\n $this->baseUrl = rtrim((string) config('services.ai_quotation.base_url', ''), '/');\n $this->parseEndpoint = ltrim((string) config('services.ai_quotation.parse_endpoint', 'parse'), '/');\n $this->apiKey = (string) config('services.ai_quotation.api_key', '');\n $this->timeout = (int) config('services.ai_quotation.timeout', 300);\n $this->testMode = (bool) config('services.ai_quotation.test_mode', false);\n }\n\n /**\n * Give the running script enough head-room to outlast the AI HTTP call.\n *\n * The web request / queue worker must not be killed before the HTTP timeout\n * completes, otherwise the user sees a generic \"timeout or unavailable\"\n * error even though the AI may have responded. We allow the HTTP timeout\n * plus a 120s margin for connecting, response parsing and DB writes.\n */\n private function extendExecutionTime(): void\n {\n // Ignored on some SAPIs (e.g. when running under fastcgi with a hard\n // limit), but harmless to attempt.\n @set_time_limit($this->timeout + 120);\n }\n\n public function parseBoq(UploadedFile|string $file, array $context = []): array\n {\n if ($this->testMode) {\n return $this->mockResponse();\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n\n if (! is_file($absPath)) {\n return $this->failure('File not found or not readable.');\n }\n\n $fileHash = hash_file('sha256', $absPath);\n $cacheKey = 'boq_analysis_' . $fileHash;\n $forceRefresh = (bool) ($context['force_refresh'] ?? false);\n\n if (! $forceRefresh && ($cached = Cache::get($cacheKey)) !== null) {\n return $cached;\n }\n\n // Clear stale cached result when force-refreshing\n if ($forceRefresh) {\n Cache::forget($cacheKey);\n }\n\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n // Read Excel locally first. Fall back to AI if local parse failed,\n // found 0 items, OR could not read one of the sheets (so multi-sheet\n // files never silently drop a sheet the local parser didn't understand).\n $result = $this->parseSpreadsheetDirect($absPath);\n\n $hasSkippedSheets = ! empty($result['skipped_sheets']);\n\n if (! $result['success'] || empty($result['items']) || $hasSkippedSheets) {\n $deepSeekResult = $this->parseBoqWithDeepSeek($file, $context);\n // AI sees every sheet at once, so prefer its result whenever it\n // actually returned items (covers the skipped-sheet case too).\n if ($deepSeekResult['success'] && ! empty($deepSeekResult['items'])) {\n // Keep the larger of the two item sets so we never lose the\n // rows the local parser already extracted correctly.\n if (count($deepSeekResult['items']) >= count($result['items'] ?? [])) {\n $result = $deepSeekResult;\n }\n } elseif (! $result['success']) {\n $result = $deepSeekResult;\n }\n // else: keep local result if AI also failed/returned nothing\n }\n } elseif (in_array($ext, ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif'], true)) {\n $result = $this->parseBoqWithDeepSeek($file, $context);\n } else {\n return $this->failure('Unsupported file type. Please upload Excel, CSV, PDF, or image file.');\n }\n\n if ($result['success'] && ! empty($result['items'])) {\n $cleaning = $this->boqCleaner->process($result['items']);\n $result['items'] = $cleaning['accepted'] ?? $result['items'];\n $result['rejected'] = array_merge($result['rejected'] ?? [], $cleaning['rejected'] ?? []);\n }\n\n // Only cache when we actually extracted items; empty results should be re-tried.\n if ($result['success'] && ! empty($result['items'])) {\n Cache::put($cacheKey, $result, now()->addDays(30));\n }\n\n return $result;\n }\n\n private function resolveFile(UploadedFile|string $file): array\n {\n if ($file instanceof UploadedFile) {\n return [(string) $file->getRealPath(), strtolower($file->getClientOriginalExtension())];\n }\n\n $absPath = is_file($file) ? $file : storage_path('app/' . ltrim($file, '/'));\n return [$absPath, strtolower(pathinfo($absPath, PATHINFO_EXTENSION))];\n }\n\n private function parseSpreadsheetDirect(string $absPath): array\n {\n ini_set('memory_limit', '1024M');\n set_time_limit(180);\n\n try {\n $sheets = $this->spreadsheetToGrids($absPath);\n\n if (empty($sheets)) {\n return $this->failure('Could not read the Excel file.');\n }\n\n $items = [];\n $rejected = [];\n $skippedSheets = []; // sheets we could not parse locally (header undetected)\n\n foreach ($sheets as $sheet) {\n $grid = $sheet['grid'];\n if (count($grid) < 2) {\n continue;\n }\n\n $header = $this->detectHeader($grid);\n\n if ($header === null || ! isset($header['map']['description'])) {\n Log::warning('QuotationAiService: header not detected in sheet.', ['sheet' => $sheet['name']]);\n $skippedSheets[] = $sheet['name'];\n continue;\n }\n\n $map = $header['map'];\n $start = $header['row'] + 1;\n\n for ($r = $start; $r < count($grid); $r++) {\n $row = $grid[$r];\n $description = $this->cell($row, $map['description'] ?? null);\n $itemNo = $this->cell($row, $map['item_code'] ?? null);\n $unit = $this->cell($row, $map['unit'] ?? null);\n $quantity = $this->number($this->cell($row, $map['quantity'] ?? null));\n $unitPrice = $this->number($this->cell($row, $map['unit_price'] ?? null));\n $totalPrice = $this->number($this->cell($row, $map['total_price'] ?? null));\n\n if ($description === '') {\n continue;\n }\n\n // Skip floor breakdown sub-rows: Ref. is \"-\" meaning it's a per-floor\n // quantity split of the parent item, not a standalone product.\n if ($itemNo === '-' || $this->isFloorBreakdownRow($itemNo, $description)) {\n continue;\n }\n\n if ($this->isTotalOrHeaderRow($description)) {\n continue;\n }\n\n if ($quantity === null || $quantity <= 0) {\n $rejected[] = $this->rejectedRow($description, $unit, $quantity, 'Missing quantity', $itemNo, $sheet['name']);\n continue;\n }\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $supply = $this->boqCleaner->filterItem($description);\n\n if (! ($supply['keep'] ?? false)) {\n $rejected[] = $this->rejectedRow(\n $description,\n $unit,\n $quantity,\n (string) ($supply['rejection_reason'] ?? 'Rejected by cleaner'),\n $itemNo,\n $sheet['name']\n );\n continue;\n }\n\n $finalDescription = (string) ($supply['description'] ?? $description);\n\n $items[] = [\n 'description' => $finalDescription,\n 'quantity' => $quantity,\n 'unit' => $unit,\n 'category' => $sheet['name'],\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($finalDescription),\n 'unit_price' => $unitPrice,\n 'confidence' => 0.95,\n 'needs_review' => false,\n 'raw_data' => [\n 'sheet' => $sheet['name'],\n 'row_number' => $r + 1,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'cleaned_description' => $finalDescription,\n 'extraction_type' => (string) ($supply['extraction_type'] ?? 'supply_only'),\n ],\n 'ai_extracted' => true,\n ];\n }\n }\n\n if (empty($items)) {\n return $this->failure('No BOQ supply items could be extracted from the Excel file.');\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n 'skipped_sheets' => $skippedSheets,\n ];\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: parseSpreadsheetDirect failed.', [\n 'path' => $absPath,\n 'message' => $e->getMessage(),\n 'trace' => $e->getTraceAsString(),\n ]);\n\n return $this->failure('Failed to read the Excel file: ' . $e->getMessage());\n }\n }\n\n private function spreadsheetToGrids(string $absPath): array\n {\n $ext = strtolower(pathinfo($absPath, PATHINFO_EXTENSION));\n\n if ($ext === 'csv') {\n $rows = [];\n $handle = fopen($absPath, 'rb');\n if ($handle !== false) {\n while (($row = fgetcsv($handle)) !== false) {\n $rows[] = $this->trimTrailingEmptyCells($row);\n }\n fclose($handle);\n }\n return [['name' => 'Sheet1', 'grid' => $rows]];\n }\n\n $reader = IOFactory::createReader($ext === 'xls' ? 'Xls' : 'Xlsx');\n $reader->setReadDataOnly(true);\n $spreadsheet = $reader->load($absPath);\n $sheets = [];\n\n foreach ($spreadsheet->getAllSheets() as $sheet) {\n $highestRow = $sheet->getHighestDataRow();\n $highestColumn = $sheet->getHighestDataColumn();\n $highestColumnIndex = Coordinate::columnIndexFromString($highestColumn);\n\n // BOQ data is normally in first 8-20 columns.\n // Files with heavy formatting/images can report 300+ columns — limit to 30.\n $maxColumnIndex = min($highestColumnIndex, 30);\n\n $grid = [];\n for ($row = 1; $row <= $highestRow; $row++) {\n $rowData = [];\n $hasValue = false;\n\n for ($col = 1; $col <= $maxColumnIndex; $col++) {\n $value = $sheet->getCell([$col, $row])->getValue();\n $value = $this->cellValueToString($value);\n $value = trim($value);\n if ($value !== null && $value !== '') {\n $hasValue = true;\n }\n $rowData[] = $value;\n }\n\n $rowData = $this->trimTrailingEmptyCells($rowData);\n if ($hasValue) {\n $grid[] = $rowData;\n }\n }\n\n if (! empty($grid)) {\n $sheets[] = ['name' => $sheet->getTitle(), 'grid' => $grid];\n }\n }\n\n $spreadsheet->disconnectWorksheets();\n unset($spreadsheet);\n\n return $sheets;\n }\n\n private function detectHeader(array $grid): ?array\n {\n $keywords = [\n 'item_code' => ['رقم البند', 'item no', 'item number', 'ref.', 'ref', 'item', 'no.', 'no', '#', 'كود'],\n 'description' => ['وص٠البند', 'scope of works', 'scope of work', 'scope', 'item description', 'description of works', 'description', 'بالعربية', 'الوصÙ', 'البند', 'بيان'],\n 'unit' => ['Ø§Ù„ÙˆØØ¯Ø©', 'unit', 'uom', 'u/m'],\n 'quantity' => ['الكمية', 'qty', 'quantity', 'q.ty'],\n 'unit_price' => ['سعر Ø§Ù„ÙˆØØ¯Ø©', 'u/price', 'unit price', 'unit rate', 'rate', 'price'],\n 'total_price' => ['السعر الإجمالي', 'total amount', 'total price', 'amount', 'الإجمالي'],\n ];\n\n $best = null;\n $bestScore = 0;\n\n for ($r = 0; $r < min(30, count($grid)); $r++) {\n $map = [];\n $score = 0;\n\n foreach ($grid[$r] as $c => $value) {\n $cell = mb_strtolower(trim((string) $value));\n if ($cell === '') {\n continue;\n }\n\n foreach ($keywords as $field => $list) {\n foreach ($list as $kw) {\n if (str_contains($cell, mb_strtolower($kw))) {\n if (! isset($map[$field])) {\n $map[$field] = $c;\n $score++;\n }\n break 2;\n }\n }\n }\n }\n\n if ($score > $bestScore) {\n $bestScore = $score;\n $best = ['row' => $r, 'map' => $map];\n }\n }\n\n return $bestScore >= 3 ? $best : null;\n }\n\n private function cell(array $row, ?int $index): string\n {\n if ($index === null) {\n return '';\n }\n return trim((string) ($row[$index] ?? ''));\n }\n\n private function number(string $value): ?float\n {\n $value = trim(str_replace([',', 'SAR', 'ر.س'], '', $value));\n if ($value === '' || ! is_numeric($value)) {\n return null;\n }\n return (float) $value;\n }\n\n private function trimTrailingEmptyCells(array $row): array\n {\n while (! empty($row) && trim((string) end($row)) === '') {\n array_pop($row);\n }\n return array_values($row);\n }\n\n private function isTotalOrHeaderRow(string $description): bool\n {\n $d = mb_strtolower(trim($description));\n\n if ($d === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $d)) {\n return true;\n }\n\n // Section totals / summaries\n if (preg_match('/(اجمالي|إجمالي|المجموع|grand\\s*total|sub\\s*total|total\\s*amount|total\\s*price)/iu', $d)) {\n return true;\n }\n\n // Pure floor / level labels used as section headers\n if (preg_match('/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor)\\s*$/iu', $d)) {\n return true;\n }\n\n // Division / section header lines (e.g. \"Division - 02: Wood Works\")\n if (preg_match('/^division\\s*([-:]|\\d)/iu', $d)) {\n return true;\n }\n\n return false;\n }\n\n private function isFloorBreakdownRow(string $itemNo, string $description): bool\n {\n // A row whose Ref column is empty or a dash is a sub-breakdown row (per-floor split).\n $ref = trim($itemNo);\n if ($ref === '' || $ref === '-') {\n // Confirm by checking the description is a location/floor label\n $d = mb_strtolower(trim($description));\n return (bool) preg_match(\n '/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor|\\d+f)\\s*$/iu',\n $d\n );\n }\n return false;\n }\n\n private function rejectedRow(string $description, string $unit, ?float $quantity, string $reason, string $itemNo = '', string $sheet = ''): array\n {\n return [\n 'description' => $description,\n 'quantity' => $quantity ?? 0,\n 'unit' => $unit,\n 'category' => $sheet,\n 'brand' => '',\n 'status' => 'rejected',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 0.0,\n 'raw_data' => [\n 'sheet' => $sheet,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'extraction_type' => 'rejected',\n 'rejection_reason' => $reason,\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function parseBoqWithDeepSeek(UploadedFile|string $file, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured. Please set DEEPSEEK_API_KEY in .env file.');\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n $mime = $this->mimeForExtension($ext);\n\n try {\n // ── Spreadsheets ──────────────────────────────────────────────────\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n $text = $this->spreadsheetToCompactCsvText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not convert spreadsheet to readable text.');\n }\n $text = $this->sanitizeUtf8(mb_substr($text, 0, 180000));\n $userContent = \"BOQ spreadsheet converted to CSV:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── PDF ───────────────────────────────────────────────────────────\n if ($ext === 'pdf') {\n $extracted = $this->extractPdfText($absPath);\n if ($extracted !== null && mb_strlen(trim($extracted)) > 50) {\n $extracted = $this->sanitizeUtf8(mb_substr($extracted, 0, 180000));\n $userContent = \"BOQ PDF extracted text:\\n\\n\" . $extracted . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'application/pdf');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n return $this->failure('Could not extract text from the PDF. Please make sure it is a text-based PDF (not a scanned image), or convert it to Excel or CSV.');\n }\n\n // -- Word documents (docx) ---------------------------------------------------\n if (in_array($ext, ['docx', 'doc'], true)) {\n $text = $this->extractDocxText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not extract text from the Word document. Please convert it to Excel, CSV, or PDF.');\n }\n $text = $this->sanitizeUtf8(mb_substr($text, 0, 180000));\n $userContent = \"BOQ Word document extracted text:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── Images ──────────────────────────────────────────────────────────────\n // DeepSeek cannot receive images. A vision-capable AI processes the image\n // directly (no OCR) — configure GROQ_API_KEY or GEMINI_API_KEY in .env.\n $imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif', 'heic', 'heif'];\n if (in_array($ext, $imageExts, true)) {\n $bytes = (string) file_get_contents($absPath);\n return $this->callDeepSeekVision($bytes, $mime, $context);\n }\n\n return $this->failure('Unsupported file type. Please upload an Excel, CSV, PDF, or image file.');\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: DeepSeek fallback failed.', ['message' => $e->getMessage()]);\n return $this->failure('An error occurred while processing the file with AI: ' . $e->getMessage());\n }\n }\n\n private function deepSeekModel(): string\n {\n return (string) config('services.deepseek.model', 'deepseek-chat');\n }\n\n /**\n * Remove or replace any non-UTF-8 / malformed byte sequences so json_encode never fails.\n */\n private function sanitizeUtf8(string $text): string\n {\n // Replace invalid byte sequences with the UTF-8 replacement character\n $clean = mb_convert_encoding($text, 'UTF-8', 'UTF-8');\n // Strip any remaining non-printable control characters (except tab/newline/CR)\n $clean = (string) preg_replace('/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]/u', '', $clean);\n return $clean;\n }\n\n /**\n * Extract plain text from a Word docx file using ZipArchive (no external library needed).\n * Reads word/document.xml and strips XML tags, preserving paragraph and table structure.\n */\n private function extractDocxText(string $absPath): ?string\n {\n try {\n $zip = new \\ZipArchive();\n if ($zip->open($absPath) !== true) {\n return null;\n }\n\n $xml = $zip->getFromName('word/document.xml');\n $zip->close();\n\n if ($xml === false || $xml === '') {\n return null;\n }\n\n // Add newlines before paragraph/table-row/table-cell tags so structure is preserved\n $xml = preg_replace('/<w:p[ >]/', \"\\n<w:p>\", $xml) ?? $xml;\n $xml = preg_replace('/<w:tr[ >]/', \"\\n<w:tr>\", $xml) ?? $xml;\n $xml = preg_replace('/<\\/w:tc>/', \"\\t\", $xml) ?? $xml;\n\n $text = strip_tags($xml);\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n return trim($text) !== '' ? trim($text) : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: DOCX parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Extract plain text from a PDF using smalot/pdfparser (pure PHP, no binary required).\n */\n private function extractPdfText(string $absPath): ?string\n {\n try {\n $parser = new \\Smalot\\PdfParser\\Parser();\n $pdf = $parser->parseFile($absPath);\n $text = $pdf->getText();\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n return $text !== '' ? $text : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: PDF parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Send a vision request to a vision-capable AI model.\n * DeepSeek's commercial API (api.deepseek.com) does NOT support image inputs — any model.\n * Priority order (first configured key wins):\n * 1. GROQ_API_KEY → Groq llama-4-scout (free: https://console.groq.com)\n * 2. GEMINI_API_KEY → Google Gemini Flash (free: https://aistudio.google.com/apikey)\n * 3. VISION_API_KEY → any OpenAI-compatible endpoint (OpenAI, OpenRouter …)\n */\n private function callDeepSeekVision(string $bytes, string $mime, array $context): array\n {\n // ── 1. Groq (free, reliable vision) ─────────────────────────────────────\n if ((string) config('services.groq.key', '') !== '') {\n $visionKey = (string) config('services.groq.key');\n $visionBaseUrl = 'https://api.groq.com/openai/v1';\n $visionModel = (string) config('services.groq.model', 'meta-llama/llama-4-scout-17b-16e-instruct');\n }\n // ── 2. Google Gemini ─────────────────────────────────────────────────────\n elseif ((string) config('services.gemini.key', '') !== '') {\n $visionKey = (string) config('services.gemini.key');\n $visionBaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai';\n $visionModel = (string) config('services.gemini.model', 'gemini-2.0-flash');\n }\n // ── 3. Generic VISION_API_KEY (OpenAI, OpenRouter, …) ───────────────────\n elseif ((string) config('services.vision.key', '') !== '') {\n $visionKey = (string) config('services.vision.key');\n $visionBaseUrl = rtrim((string) config('services.vision.base_url', 'https://openrouter.ai/api/v1'), '/');\n $visionModel = (string) config('services.vision.model', 'google/gemini-flash-1.5-8b');\n }\n else {\n return $this->failure(\n 'Image processing is not available. DeepSeek\\'s API does not support images. ' .\n 'Get a free key at https://console.groq.com and add GROQ_API_KEY to your .env file.'\n );\n }\n\n $dataUrl = \"data:{$mime};base64,\" . base64_encode($bytes);\n $prompt = $this->buildDeepSeekPrompt($context, $mime);\n\n $userContent = [\n ['type' => 'text', 'text' => $prompt],\n ['type' => 'image_url', 'image_url' => ['url' => $dataUrl]],\n ];\n\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders([\n 'Authorization' => 'Bearer ' . $visionKey,\n 'HTTP-Referer' => config('app.url', 'https://qimta.sa'),\n 'X-Title' => 'Qimta Platform',\n ])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post($visionBaseUrl . '/chat/completions', [\n 'model' => $visionModel,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 16384,\n 'temperature' => 0.2,\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('Vision API connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: Vision API HTTP error.', [\n 'model' => $visionModel,\n 'base_url' => $visionBaseUrl,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n return $this->failure(\"Vision API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n return $this->parseDeepSeekJsonResponse((string) $response->json('choices.0.message.content'));\n }\n\n private function callDeepSeekChat(string $userContent, string $key, string $model): array\n {\n // Ensure the PHP script outlives the HTTP call (+ a margin for parsing the\n // response and writing to the DB) so the worker/web request is not killed\n // mid-flight, which surfaces to users as a generic \"timeout or unavailable\".\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders(['Authorization' => 'Bearer ' . $key])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 32768,\n 'temperature' => 0.1,\n 'response_format' => ['type' => 'json_object'],\n 'user' => 'Qimta_Platform',\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('DeepSeek connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: DeepSeek HTTP error.', [\n 'model' => $model,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n if ($response->status() === 401) {\n return $this->failure('DeepSeek API key is invalid or unauthorised. Please update DEEPSEEK_API_KEY in .env.', false);\n }\n return $this->failure(\"DeepSeek API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n $text = (string) $response->json('choices.0.message.content');\n return $this->parseDeepSeekJsonResponse($text);\n }\n\n /**\n * Shared JSON response parser used by both chat and vision paths.\n */\n private function parseDeepSeekJsonResponse(string $text): array\n {\n if ($text === '') {\n return $this->failure('DeepSeek returned an empty response.');\n }\n\n // Strip markdown code fences if the model wrapped the JSON.\n if (preg_match('/```(?:json)?\\s*([\\s\\S]*?)\\s*```/i', $text, $m)) {\n $text = $m[1];\n }\n // Narrow to the outermost JSON object/array.\n if (preg_match('/[\\{\\[][\\s\\S]*[\\}\\]]/s', $text, $m)) {\n $text = $m[0];\n }\n $text = trim($text);\n\n $decoded = json_decode($text, true);\n\n // The response can be truncated when the model hits the token limit,\n // leaving an unterminated JSON string/object. Try to repair it before\n // giving up so we can still salvage the items extracted so far.\n if (! is_array($decoded)) {\n $repaired = $this->repairTruncatedJson($text);\n if ($repaired !== null) {\n $decoded = json_decode($repaired, true);\n }\n }\n\n if (! is_array($decoded)) {\n Log::error('QuotationAiService: DeepSeek non-JSON response.', ['text' => mb_substr($text, 0, 1000)]);\n return $this->failure('DeepSeek returned a non-JSON response. The file may be too large — try splitting it into smaller files.');\n }\n\n $rawItems = $decoded['items'] ?? (array_is_list($decoded) ? $decoded : []);\n $items = [];\n $rejected = [];\n\n foreach ($rawItems as $raw) {\n if (! is_array($raw)) {\n continue;\n }\n $item = $this->normaliseAiItem($raw);\n if ($item === null) {\n continue;\n }\n $check = $this->boqCleaner->filterItem((string) $item['description']);\n if ($check['keep'] ?? false) {\n $items[] = $item;\n } else {\n $item['status'] = 'rejected';\n $item['raw_data']['rejection_reason'] = $check['rejection_reason'] ?? 'Rejected by cleaner';\n $rejected[] = $item;\n }\n }\n\n if (empty($items)) {\n Log::info('QuotationAiService: DeepSeek returned 0 accepted items.', [\n 'raw_items_count' => count($rawItems),\n 'rejected_count' => count($rejected),\n 'first_rejected' => ! empty($rejected) ? ($rejected[0]['description'] ?? '') : '',\n ]);\n }\n\n return ['success' => true, 'items' => $items, 'rejected' => $rejected, 'error' => null];\n }\n\n /**\n * Best-effort repair of a truncated JSON string produced when the model hits\n * its token limit mid-output. Drops any incomplete trailing object, then\n * closes the open string/array/object brackets so the result is valid JSON.\n *\n * Returns the repaired JSON string, or null if it cannot be salvaged.\n */\n private function repairTruncatedJson(string $text): ?string\n {\n if ($text === '') {\n return null;\n }\n\n // Walk the string tracking structural state, ignoring everything inside\n // string literals (and their escapes). Remember the last position where\n // we were back at the top of the \"items\" array between complete elements,\n // so we can cut a half-written trailing object.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($text);\n $lastSafeCut = null; // index just after a completed array element\n\n for ($i = 0; $i < $len; $i++) {\n $ch = $text[$i];\n\n if ($inString) {\n if ($escaped) {\n $escaped = false;\n } elseif ($ch === '\\\\') {\n $escaped = true;\n } elseif ($ch === '\"') {\n $inString = false;\n }\n continue;\n }\n\n if ($ch === '\"') {\n $inString = true;\n } elseif ($ch === '{' || $ch === '[') {\n $stack[] = $ch;\n } elseif ($ch === '}' || $ch === ']') {\n array_pop($stack);\n } elseif ($ch === ',' && ! empty($stack) && end($stack) === '[') {\n // Separator directly inside an array: a clean boundary between\n // elements (the items array, whatever its nesting depth).\n $lastSafeCut = $i;\n }\n }\n\n // If we are still inside a string, the last element is incomplete — fall\n // back to the last clean array boundary if we have one.\n $candidate = $text;\n if ($inString && $lastSafeCut !== null) {\n $candidate = substr($text, 0, $lastSafeCut);\n } else {\n // Drop a dangling partial object after the last completed element.\n $candidate = rtrim($candidate);\n if ($lastSafeCut !== null && substr(rtrim($candidate), -1) !== '}' && substr(rtrim($candidate), -1) !== ']') {\n $candidate = substr($text, 0, $lastSafeCut);\n }\n }\n\n // Recompute the open brackets for the (possibly trimmed) candidate and\n // append the matching closers in reverse order.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($candidate);\n for ($i = 0; $i < $len; $i++) {\n $ch = $candidate[$i];\n if ($inString) {\n if ($escaped) { $escaped = false; }\n elseif ($ch === '\\\\') { $escaped = true; }\n elseif ($ch === '\"') { $inString = false; }\n continue;\n }\n if ($ch === '\"') { $inString = true; }\n elseif ($ch === '{' || $ch === '[') { $stack[] = $ch; }\n elseif ($ch === '}' || $ch === ']') { array_pop($stack); }\n }\n\n $candidate = rtrim($candidate);\n // Remove a trailing comma left after cutting an element.\n $candidate = rtrim($candidate, ',');\n\n $closers = '';\n for ($i = count($stack) - 1; $i >= 0; $i--) {\n $closers .= ($stack[$i] === '{') ? '}' : ']';\n }\n\n $repaired = $candidate . $closers;\n\n // Validate before returning.\n return is_array(json_decode($repaired, true)) ? $repaired : null;\n }\n\n private function spreadsheetToCompactCsvText(string $absPath): ?string\n {\n $sheets = $this->spreadsheetToGrids($absPath);\n if (empty($sheets)) {\n return null;\n }\n\n $output = '';\n foreach ($sheets as $sheet) {\n $output .= 'Sheet: ' . $sheet['name'] . \"\\n\";\n foreach ($sheet['grid'] as $row) {\n $row = $this->trimTrailingEmptyCells($row);\n if (empty($row)) {\n continue;\n }\n $output .= implode(',', array_map([$this, 'csvEscape'], $row)) . \"\\n\";\n }\n $output .= \"\\n\";\n }\n\n return $output;\n }\n\n /**\n * Safely convert a PhpSpreadsheet cell value to a string.\n *\n * Cells may contain RichText or, in malformed/heavily-formatted files,\n * embedded objects such as Worksheet\\Drawing that have no __toString().\n * Casting those directly throws \"Object ... could not be converted to string\".\n */\n private function cellValueToString(mixed $value): string\n {\n if ($value === null) {\n return '';\n }\n\n if ($value instanceof \\PhpOffice\\PhpSpreadsheet\\RichText\\RichText) {\n return $value->getPlainText();\n }\n\n if (is_object($value)) {\n // Only stringify objects that explicitly support it; ignore the rest\n // (e.g. Worksheet\\Drawing) instead of crashing.\n return method_exists($value, '__toString') ? (string) $value : '';\n }\n\n if (is_array($value)) {\n return '';\n }\n\n return (string) $value;\n }\n\n private function csvEscape(mixed $value): string\n {\n $value = $this->cellValueToString($value);\n return str_contains($value, ',') || str_contains($value, '\"') || str_contains($value, \"\\n\")\n ? '\"' . str_replace('\"', '\"\"', $value) . '\"'\n : $value;\n }\n\n private function normaliseAiItem(array $raw): ?array\n {\n if (! empty($raw['rejected'])) {\n return null;\n }\n\n $description = trim((string) ($raw['cleaned_description'] ?? $raw['product_name'] ?? $raw['description'] ?? ''));\n if ($description === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $description)) {\n return null;\n }\n\n $quantity = is_numeric($raw['quantity'] ?? null) ? (float) $raw['quantity'] : 1;\n $unitPrice = is_numeric($raw['unit_price'] ?? null) ? (float) $raw['unit_price'] : null;\n $totalPrice = is_numeric($raw['total_price'] ?? null) ? (float) $raw['total_price'] : null;\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $specs = is_array($raw['specifications'] ?? null) ? $raw['specifications'] : [];\n\n return [\n 'description' => $description,\n 'quantity' => $quantity,\n 'unit' => (string) ($raw['unit'] ?? ''),\n 'category' => (string) ($raw['core_product'] ?? $raw['category'] ?? ''),\n 'brand' => (string) ($specs['brand'] ?? $raw['brand'] ?? ''),\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($description),\n 'unit_price' => $unitPrice,\n 'confidence' => is_numeric($raw['confidence'] ?? null) ? (float) $raw['confidence'] : null,\n 'needs_review' => ! empty($raw['needs_review']),\n 'raw_data' => [\n 'source_item_no' => (string) ($raw['source_item_no'] ?? ''),\n 'original_description' => (string) ($raw['original_description'] ?? $description),\n 'cleaned_description' => $description,\n 'core_product' => (string) ($raw['core_product'] ?? ''),\n 'specifications' => $specs,\n 'extraction_type' => (string) ($raw['extraction_type'] ?? 'supply_only'),\n 'note' => (string) ($raw['note'] ?? $raw['notes'] ?? ''),\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function buildSystemInstruction(): string\n {\n return <<<'TXT'\nYou are a strict BOQ Supply Product Extraction Engine.\nExtract only purchasable physical supply products. Reject totals, headings, general requirements, installation-only, testing, commissioning, supervision, mobilization, civil works, labor, and vague rows.\nFor Supply & Install rows, keep only the supply product and remove install/labor wording.\nReturn only valid JSON: {\"items\":[{\"source_item_no\":null,\"original_description\":\"\",\"cleaned_description\":\"\",\"core_product\":\"\",\"specifications\":{},\"quantity\":0,\"unit\":\"\",\"unit_price\":0,\"total_price\":0,\"extraction_type\":\"supply_only\",\"needs_review\":false,\"note\":\"\",\"rejected\":false,\"rejection_reason\":null,\"confidence\":0.95}]}\nTXT;\n }\n\n private function buildDeepSeekPrompt(array $context = [], string $mime = ''): string\n {\n $projectName = (string) ($context['project_name'] ?? '');\n $projectStatus = (string) ($context['project_status'] ?? '');\n $contextLine = $projectName !== ''\n ? \"Project: {$projectName}\" . ($projectStatus !== '' ? \" ({$projectStatus})\" : '')\n : '';\n $sourceHint = str_starts_with($mime, 'image/')\n ? 'The input is an image of a BOQ table. OCR every cell carefully — read the quantity/count column (العدد, الكمية, Qty) and extract the EXACT numeric value from each row.'\n : 'The input is a BOQ document/spreadsheet.';\n\n return trim(<<<PROMPT\nYou are a procurement specialist. Your task is to extract ONLY tangible, physical supply products from this Bill of Quantities (BOQ).\n\n{$contextLine}\n{$sourceHint}\n\nSTRICT RULES — include ONLY items that are ALL of the following:\n1. A physical, tangible product that can be ordered from a supplier and delivered to site.\n2. Has a real quantity (not 0 or \"varies\" with no meaning).\n3. Has enough description to identify what the product is.\n\nEXCLUDE all of the following (do NOT include them):\n- Labor, installation, or workmanship items (e.g. \"install\", \"fix\", \"erect\", \"dismantle\", \"painting\", \"cleaning service\")\n- Project management, overhead, or profit items\n- Insurance, bonds, liability items (e.g. \"bodily injury\", \"third party liability\", \"CAR insurance\")\n- Shop drawings, as-built drawings, method statements, documentation\n- Preliminary items, mobilisation, demobilisation, provisional sums\n- Housekeeping, site clearance, waste removal\n- Floor/level labels (e.g. \"GF Floor\", \"Level 3\") — these are headers, not products\n- Pure dimension specs without a product noun (e.g. \"Dia 32 mm\", \"25 mm\") — reject unless a product name is present\n- Security services, night shifts, rentals, payments\n- Any item whose description is too vague or fragmentary to identify a specific product\n\nFor each valid physical product return JSON with:\n- description: clean product name only (strip \"supply and install\", \"supply and fix\", installation clauses, floor/spec suffixes)\n- quantity: the EXACT numeric value from the quantity/count column (العدد, الكمية, Qty, Quantity, No.). NEVER default to 1 unless the quantity column is truly absent or empty.\n- unit: unit of measure (pcs, m, m2, m3, set, no, kg, etc.)\n- category: product category\n- brand: brand/manufacturer if mentioned, else \"\"\n- engineering_required: true if the item needs engineering design before procurement (panel boards, chillers, generators, fire suppression systems, BMS, pumps, AHUs, switchgear, transformers), else false\n\nReturn ONLY a valid JSON object: {\"items\": [...]}. No explanations, no markdown, no extra text.\nPROMPT);\n }\n\n private function mockResponse(): array\n {\n return [\n 'success' => true,\n 'error' => null,\n 'items' => [[\n 'description' => 'Sample BOQ Item',\n 'quantity' => 1,\n 'unit' => 'No',\n 'category' => 'Sample',\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 1,\n 'raw_data' => [],\n 'ai_extracted' => true,\n ]],\n 'rejected' => [],\n ];\n }\n\n private function failure(string $message, bool $serviceUnavailable = false): array\n {\n return [\n 'success' => false,\n 'items' => [],\n 'rejected' => [],\n 'error' => $this->userFacingError($message, $serviceUnavailable),\n 'service_unavailable' => $serviceUnavailable,\n ];\n }\n\n /**\n * Translate any internal error into a neutral, user-safe message.\n *\n * The user must never see the name of the underlying AI/processing engine\n * (DeepSeek, Groq, Gemini, Vision, etc.) or configuration hints like API\n * keys. The detailed message is kept in the logs for debugging only.\n */\n private function userFacingError(string $message, bool $serviceUnavailable = false): string\n {\n // Detect any reference to an AI provider / engine / API config so we can\n // swap it for a generic, branded-neutral message.\n $sensitive = '/deepseek|groq|gemini|vision|openai|openrouter|api[\\s_-]?key|\\.env|non-?json|token limit/i';\n\n if (preg_match($sensitive, $message)) {\n Log::warning('QuotationAiService: suppressed technical error from user.', ['internal' => $message]);\n\n return $serviceUnavailable\n ? 'The file could not be processed right now. Please try again in a moment.'\n : 'We could not read this file automatically. Please make sure it is a clear BOQ (Excel, CSV, PDF, or image) and try again, or add the items manually.';\n }\n\n return $message;\n }\n\n private function mimeForExtension(string $ext): string\n {\n return match (strtolower($ext)) {\n 'pdf' => 'application/pdf',\n 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n 'xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12',\n 'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',\n 'xls' => 'application/vnd.ms-excel',\n 'csv' => 'text/csv',\n 'jpg', 'jpeg' => 'image/jpeg',\n 'png' => 'image/png',\n 'gif' => 'image/gif',\n 'bmp' => 'image/bmp',\n 'webp' => 'image/webp',\n 'tiff', 'tif' => 'image/tiff',\n 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'doc' => 'application/msword',\n default => 'application/octet-stream',\n };\n }\n}\n"}, "61b247e493c7cd9de56db3794e0d19d4730a6c64": {"t": 1785748393.1045017, "n": 46936, "h": "853e8a043558de4e", "c": "<?php\n\nnamespace App\\Services;\n\nuse Illuminate\\Http\\Client\\ConnectionException;\nuse Illuminate\\Http\\UploadedFile;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\nuse PhpOffice\\PhpSpreadsheet\\Cell\\Coordinate;\nuse PhpOffice\\PhpSpreadsheet\\IOFactory;\n\nclass QuotationAiService\n{\n private string $baseUrl;\n private string $parseEndpoint;\n private string $apiKey;\n private int $timeout;\n private bool $testMode;\n private BoqCleaningService $boqCleaner;\n\n public function __construct(BoqCleaningService $boqCleaner)\n {\n $this->boqCleaner = $boqCleaner;\n $this->baseUrl = rtrim((string) config('services.ai_quotation.base_url', ''), '/');\n $this->parseEndpoint = ltrim((string) config('services.ai_quotation.parse_endpoint', 'parse'), '/');\n $this->apiKey = (string) config('services.ai_quotation.api_key', '');\n $this->timeout = (int) config('services.ai_quotation.timeout', 300);\n $this->testMode = (bool) config('services.ai_quotation.test_mode', false);\n }\n\n /**\n * Give the running script enough head-room to outlast the AI HTTP call.\n *\n * The web request / queue worker must not be killed before the HTTP timeout\n * completes, otherwise the user sees a generic \"timeout or unavailable\"\n * error even though the AI may have responded. We allow the HTTP timeout\n * plus a 120s margin for connecting, response parsing and DB writes.\n */\n private function extendExecutionTime(): void\n {\n // Ignored on some SAPIs (e.g. when running under fastcgi with a hard\n // limit), but harmless to attempt.\n @set_time_limit($this->timeout + 120);\n }\n\n public function parseBoq(UploadedFile|string $file, array $context = []): array\n {\n if ($this->testMode) {\n return $this->mockResponse();\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n\n if (! is_file($absPath)) {\n return $this->failure('File not found or not readable.');\n }\n\n $fileHash = hash_file('sha256', $absPath);\n $cacheKey = 'boq_analysis_' . $fileHash;\n $forceRefresh = (bool) ($context['force_refresh'] ?? false);\n\n if (! $forceRefresh && ($cached = Cache::get($cacheKey)) !== null) {\n return $cached;\n }\n\n // Clear stale cached result when force-refreshing\n if ($forceRefresh) {\n Cache::forget($cacheKey);\n }\n\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n // Read Excel locally first. Fallback to DeepSeek if local parse failed or found 0 items.\n $result = $this->parseSpreadsheetDirect($absPath);\n\n if (! $result['success'] || empty($result['items'])) {\n $deepSeekResult = $this->parseBoqWithDeepSeek($file, $context);\n // Only use DeepSeek result if it actually found items\n if ($deepSeekResult['success'] && ! empty($deepSeekResult['items'])) {\n $result = $deepSeekResult;\n } elseif (! $result['success']) {\n $result = $deepSeekResult;\n }\n // else: keep local result (even with 0 items) if DeepSeek also failed\n }\n } elseif (in_array($ext, ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif'], true)) {\n $result = $this->parseBoqWithDeepSeek($file, $context);\n } else {\n return $this->failure('Unsupported file type. Please upload Excel, CSV, PDF, or image file.');\n }\n\n if ($result['success'] && ! empty($result['items'])) {\n $cleaning = $this->boqCleaner->process($result['items']);\n $result['items'] = $cleaning['accepted'] ?? $result['items'];\n $result['rejected'] = array_merge($result['rejected'] ?? [], $cleaning['rejected'] ?? []);\n }\n\n // Only cache when we actually extracted items; empty results should be re-tried.\n if ($result['success'] && ! empty($result['items'])) {\n Cache::put($cacheKey, $result, now()->addDays(30));\n }\n\n return $result;\n }\n\n private function resolveFile(UploadedFile|string $file): array\n {\n if ($file instanceof UploadedFile) {\n return [(string) $file->getRealPath(), strtolower($file->getClientOriginalExtension())];\n }\n\n $absPath = is_file($file) ? $file : storage_path('app/' . ltrim($file, '/'));\n return [$absPath, strtolower(pathinfo($absPath, PATHINFO_EXTENSION))];\n }\n\n private function parseSpreadsheetDirect(string $absPath): array\n {\n ini_set('memory_limit', '1024M');\n set_time_limit(180);\n\n try {\n $sheets = $this->spreadsheetToGrids($absPath);\n\n if (empty($sheets)) {\n return $this->failure('Could not read the Excel file.');\n }\n\n $items = [];\n $rejected = [];\n\n foreach ($sheets as $sheet) {\n $grid = $sheet['grid'];\n if (count($grid) < 2) {\n continue;\n }\n\n $header = $this->detectHeader($grid);\n\n if ($header === null || ! isset($header['map']['description'])) {\n Log::warning('QuotationAiService: header not detected in sheet.', ['sheet' => $sheet['name']]);\n continue;\n }\n\n $map = $header['map'];\n $start = $header['row'] + 1;\n\n for ($r = $start; $r < count($grid); $r++) {\n $row = $grid[$r];\n $description = $this->cell($row, $map['description'] ?? null);\n $itemNo = $this->cell($row, $map['item_code'] ?? null);\n $unit = $this->cell($row, $map['unit'] ?? null);\n $quantity = $this->number($this->cell($row, $map['quantity'] ?? null));\n $unitPrice = $this->number($this->cell($row, $map['unit_price'] ?? null));\n $totalPrice = $this->number($this->cell($row, $map['total_price'] ?? null));\n\n if ($description === '') {\n continue;\n }\n\n // Skip floor breakdown sub-rows: Ref. is \"-\" meaning it's a per-floor\n // quantity split of the parent item, not a standalone product.\n if ($itemNo === '-' || $this->isFloorBreakdownRow($itemNo, $description)) {\n continue;\n }\n\n if ($this->isTotalOrHeaderRow($description)) {\n continue;\n }\n\n if ($quantity === null || $quantity <= 0) {\n $rejected[] = $this->rejectedRow($description, $unit, $quantity, 'Missing quantity', $itemNo, $sheet['name']);\n continue;\n }\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $supply = $this->boqCleaner->filterItem($description);\n\n if (! ($supply['keep'] ?? false)) {\n $rejected[] = $this->rejectedRow(\n $description,\n $unit,\n $quantity,\n (string) ($supply['rejection_reason'] ?? 'Rejected by cleaner'),\n $itemNo,\n $sheet['name']\n );\n continue;\n }\n\n $finalDescription = (string) ($supply['description'] ?? $description);\n\n $items[] = [\n 'description' => $finalDescription,\n 'quantity' => $quantity,\n 'unit' => $unit,\n 'category' => $sheet['name'],\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($finalDescription),\n 'unit_price' => $unitPrice,\n 'confidence' => 0.95,\n 'needs_review' => false,\n 'raw_data' => [\n 'sheet' => $sheet['name'],\n 'row_number' => $r + 1,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'cleaned_description' => $finalDescription,\n 'extraction_type' => (string) ($supply['extraction_type'] ?? 'supply_only'),\n ],\n 'ai_extracted' => true,\n ];\n }\n }\n\n if (empty($items)) {\n return $this->failure('No BOQ supply items could be extracted from the Excel file.');\n }\n\n return [\n 'success' => true,\n 'items' => $items,\n 'rejected' => $rejected,\n 'error' => null,\n ];\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: parseSpreadsheetDirect failed.', [\n 'path' => $absPath,\n 'message' => $e->getMessage(),\n 'trace' => $e->getTraceAsString(),\n ]);\n\n return $this->failure('Failed to read the Excel file: ' . $e->getMessage());\n }\n }\n\n private function spreadsheetToGrids(string $absPath): array\n {\n $ext = strtolower(pathinfo($absPath, PATHINFO_EXTENSION));\n\n if ($ext === 'csv') {\n $rows = [];\n $handle = fopen($absPath, 'rb');\n if ($handle !== false) {\n while (($row = fgetcsv($handle)) !== false) {\n $rows[] = $this->trimTrailingEmptyCells($row);\n }\n fclose($handle);\n }\n return [['name' => 'Sheet1', 'grid' => $rows]];\n }\n\n $reader = IOFactory::createReader($ext === 'xls' ? 'Xls' : 'Xlsx');\n $reader->setReadDataOnly(true);\n $spreadsheet = $reader->load($absPath);\n $sheets = [];\n\n foreach ($spreadsheet->getAllSheets() as $sheet) {\n $highestRow = $sheet->getHighestDataRow();\n $highestColumn = $sheet->getHighestDataColumn();\n $highestColumnIndex = Coordinate::columnIndexFromString($highestColumn);\n\n // BOQ data is normally in first 8-20 columns.\n // Files with heavy formatting/images can report 300+ columns — limit to 30.\n $maxColumnIndex = min($highestColumnIndex, 30);\n\n $grid = [];\n for ($row = 1; $row <= $highestRow; $row++) {\n $rowData = [];\n $hasValue = false;\n\n for ($col = 1; $col <= $maxColumnIndex; $col++) {\n $value = $sheet->getCell([$col, $row])->getValue();\n $value = $this->cellValueToString($value);\n $value = trim($value);\n if ($value !== null && $value !== '') {\n $hasValue = true;\n }\n $rowData[] = $value;\n }\n\n $rowData = $this->trimTrailingEmptyCells($rowData);\n if ($hasValue) {\n $grid[] = $rowData;\n }\n }\n\n if (! empty($grid)) {\n $sheets[] = ['name' => $sheet->getTitle(), 'grid' => $grid];\n }\n }\n\n $spreadsheet->disconnectWorksheets();\n unset($spreadsheet);\n\n return $sheets;\n }\n\n private function detectHeader(array $grid): ?array\n {\n $keywords = [\n 'item_code' => ['رقم البند', 'item no', 'item number', 'ref.', 'ref', 'item', 'no.', 'no', '#', 'كود'],\n 'description' => ['وص٠البند', 'scope of works', 'scope of work', 'scope', 'item description', 'description of works', 'description', 'بالعربية', 'الوصÙ', 'البند', 'بيان'],\n 'unit' => ['Ø§Ù„ÙˆØØ¯Ø©', 'unit', 'uom', 'u/m'],\n 'quantity' => ['الكمية', 'qty', 'quantity', 'q.ty'],\n 'unit_price' => ['سعر Ø§Ù„ÙˆØØ¯Ø©', 'u/price', 'unit price', 'unit rate', 'rate', 'price'],\n 'total_price' => ['السعر الإجمالي', 'total amount', 'total price', 'amount', 'الإجمالي'],\n ];\n\n $best = null;\n $bestScore = 0;\n\n for ($r = 0; $r < min(30, count($grid)); $r++) {\n $map = [];\n $score = 0;\n\n foreach ($grid[$r] as $c => $value) {\n $cell = mb_strtolower(trim((string) $value));\n if ($cell === '') {\n continue;\n }\n\n foreach ($keywords as $field => $list) {\n foreach ($list as $kw) {\n if (str_contains($cell, mb_strtolower($kw))) {\n if (! isset($map[$field])) {\n $map[$field] = $c;\n $score++;\n }\n break 2;\n }\n }\n }\n }\n\n if ($score > $bestScore) {\n $bestScore = $score;\n $best = ['row' => $r, 'map' => $map];\n }\n }\n\n return $bestScore >= 3 ? $best : null;\n }\n\n private function cell(array $row, ?int $index): string\n {\n if ($index === null) {\n return '';\n }\n return trim((string) ($row[$index] ?? ''));\n }\n\n private function number(string $value): ?float\n {\n $value = trim(str_replace([',', 'SAR', 'ر.س'], '', $value));\n if ($value === '' || ! is_numeric($value)) {\n return null;\n }\n return (float) $value;\n }\n\n private function trimTrailingEmptyCells(array $row): array\n {\n while (! empty($row) && trim((string) end($row)) === '') {\n array_pop($row);\n }\n return array_values($row);\n }\n\n private function isTotalOrHeaderRow(string $description): bool\n {\n $d = mb_strtolower(trim($description));\n\n if ($d === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $d)) {\n return true;\n }\n\n // Section totals / summaries\n if (preg_match('/(اجمالي|إجمالي|المجموع|grand\\s*total|sub\\s*total|total\\s*amount|total\\s*price)/iu', $d)) {\n return true;\n }\n\n // Pure floor / level labels used as section headers\n if (preg_match('/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor)\\s*$/iu', $d)) {\n return true;\n }\n\n // Division / section header lines (e.g. \"Division - 02: Wood Works\")\n if (preg_match('/^division\\s*([-:]|\\d)/iu', $d)) {\n return true;\n }\n\n return false;\n }\n\n private function isFloorBreakdownRow(string $itemNo, string $description): bool\n {\n // A row whose Ref column is empty or a dash is a sub-breakdown row (per-floor split).\n $ref = trim($itemNo);\n if ($ref === '' || $ref === '-') {\n // Confirm by checking the description is a location/floor label\n $d = mb_strtolower(trim($description));\n return (bool) preg_match(\n '/^(gf|g\\.f\\.?|ground\\s*floor|basement|rooftop|mezzanine|podium|floor\\s*\\d+|\\d+(st|nd|rd|th)\\s*floor|\\d+f)\\s*$/iu',\n $d\n );\n }\n return false;\n }\n\n private function rejectedRow(string $description, string $unit, ?float $quantity, string $reason, string $itemNo = '', string $sheet = ''): array\n {\n return [\n 'description' => $description,\n 'quantity' => $quantity ?? 0,\n 'unit' => $unit,\n 'category' => $sheet,\n 'brand' => '',\n 'status' => 'rejected',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 0.0,\n 'raw_data' => [\n 'sheet' => $sheet,\n 'source_item_no' => $itemNo,\n 'original_description' => $description,\n 'extraction_type' => 'rejected',\n 'rejection_reason' => $reason,\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function parseBoqWithDeepSeek(UploadedFile|string $file, array $context = []): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n return $this->failure('AI service is not configured. Please set DEEPSEEK_API_KEY in .env file.');\n }\n\n [$absPath, $ext] = $this->resolveFile($file);\n $mime = $this->mimeForExtension($ext);\n\n try {\n // ── Spreadsheets ──────────────────────────────────────────────────\n if (in_array($ext, ['xlsx', 'xlsm', 'xlsb', 'xls', 'csv'], true)) {\n $text = $this->spreadsheetToCompactCsvText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not convert spreadsheet to readable text.');\n }\n $text = $this->sanitizeUtf8(mb_substr($text, 0, 180000));\n $userContent = \"BOQ spreadsheet converted to CSV:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── PDF ───────────────────────────────────────────────────────────\n if ($ext === 'pdf') {\n $extracted = $this->extractPdfText($absPath);\n if ($extracted !== null && mb_strlen(trim($extracted)) > 50) {\n $extracted = $this->sanitizeUtf8(mb_substr($extracted, 0, 180000));\n $userContent = \"BOQ PDF extracted text:\\n\\n\" . $extracted . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'application/pdf');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n return $this->failure('Could not extract text from the PDF. Please make sure it is a text-based PDF (not a scanned image), or convert it to Excel or CSV.');\n }\n\n // -- Word documents (docx) ---------------------------------------------------\n if (in_array($ext, ['docx', 'doc'], true)) {\n $text = $this->extractDocxText($absPath);\n if ($text === null || trim($text) === '') {\n return $this->failure('Could not extract text from the Word document. Please convert it to Excel, CSV, or PDF.');\n }\n $text = $this->sanitizeUtf8(mb_substr($text, 0, 180000));\n $userContent = \"BOQ Word document extracted text:\\n\\n\" . $text . \"\\n\\n\" . $this->buildDeepSeekPrompt($context, 'text/plain');\n return $this->callDeepSeekChat($userContent, $apiKey, $this->deepSeekModel());\n }\n\n // ── Images ──────────────────────────────────────────────────────────────\n // DeepSeek cannot receive images. A vision-capable AI processes the image\n // directly (no OCR) — configure GROQ_API_KEY or GEMINI_API_KEY in .env.\n $imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'tif', 'heic', 'heif'];\n if (in_array($ext, $imageExts, true)) {\n $bytes = (string) file_get_contents($absPath);\n return $this->callDeepSeekVision($bytes, $mime, $context);\n }\n\n return $this->failure('Unsupported file type. Please upload an Excel, CSV, PDF, or image file.');\n } catch (\\Throwable $e) {\n Log::error('QuotationAiService: DeepSeek fallback failed.', ['message' => $e->getMessage()]);\n return $this->failure('An error occurred while processing the file with AI: ' . $e->getMessage());\n }\n }\n\n private function deepSeekModel(): string\n {\n return (string) config('services.deepseek.model', 'deepseek-chat');\n }\n\n /**\n * Remove or replace any non-UTF-8 / malformed byte sequences so json_encode never fails.\n */\n private function sanitizeUtf8(string $text): string\n {\n // Replace invalid byte sequences with the UTF-8 replacement character\n $clean = mb_convert_encoding($text, 'UTF-8', 'UTF-8');\n // Strip any remaining non-printable control characters (except tab/newline/CR)\n $clean = (string) preg_replace('/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]/u', '', $clean);\n return $clean;\n }\n\n /**\n * Extract plain text from a Word docx file using ZipArchive (no external library needed).\n * Reads word/document.xml and strips XML tags, preserving paragraph and table structure.\n */\n private function extractDocxText(string $absPath): ?string\n {\n try {\n $zip = new \\ZipArchive();\n if ($zip->open($absPath) !== true) {\n return null;\n }\n\n $xml = $zip->getFromName('word/document.xml');\n $zip->close();\n\n if ($xml === false || $xml === '') {\n return null;\n }\n\n // Add newlines before paragraph/table-row/table-cell tags so structure is preserved\n $xml = preg_replace('/<w:p[ >]/', \"\\n<w:p>\", $xml) ?? $xml;\n $xml = preg_replace('/<w:tr[ >]/', \"\\n<w:tr>\", $xml) ?? $xml;\n $xml = preg_replace('/<\\/w:tc>/', \"\\t\", $xml) ?? $xml;\n\n $text = strip_tags($xml);\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n return trim($text) !== '' ? trim($text) : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: DOCX parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Extract plain text from a PDF using smalot/pdfparser (pure PHP, no binary required).\n */\n private function extractPdfText(string $absPath): ?string\n {\n try {\n $parser = new \\Smalot\\PdfParser\\Parser();\n $pdf = $parser->parseFile($absPath);\n $text = $pdf->getText();\n $text = (string) preg_replace('/[ \\t]{2,}/', ' ', $text);\n $text = (string) preg_replace('/\\n{3,}/', \"\\n\\n\", $text);\n $text = trim($text);\n return $text !== '' ? $text : null;\n } catch (\\Throwable $e) {\n Log::warning('QuotationAiService: PDF parsing failed.', ['error' => $e->getMessage()]);\n return null;\n }\n }\n\n /**\n * Send a vision request to a vision-capable AI model.\n * DeepSeek's commercial API (api.deepseek.com) does NOT support image inputs — any model.\n * Priority order (first configured key wins):\n * 1. GROQ_API_KEY → Groq llama-4-scout (free: https://console.groq.com)\n * 2. GEMINI_API_KEY → Google Gemini Flash (free: https://aistudio.google.com/apikey)\n * 3. VISION_API_KEY → any OpenAI-compatible endpoint (OpenAI, OpenRouter …)\n */\n private function callDeepSeekVision(string $bytes, string $mime, array $context): array\n {\n // ── 1. Groq (free, reliable vision) ─────────────────────────────────────\n if ((string) config('services.groq.key', '') !== '') {\n $visionKey = (string) config('services.groq.key');\n $visionBaseUrl = 'https://api.groq.com/openai/v1';\n $visionModel = (string) config('services.groq.model', 'meta-llama/llama-4-scout-17b-16e-instruct');\n }\n // ── 2. Google Gemini ─────────────────────────────────────────────────────\n elseif ((string) config('services.gemini.key', '') !== '') {\n $visionKey = (string) config('services.gemini.key');\n $visionBaseUrl = 'https://generativelanguage.googleapis.com/v1beta/openai';\n $visionModel = (string) config('services.gemini.model', 'gemini-2.0-flash');\n }\n // ── 3. Generic VISION_API_KEY (OpenAI, OpenRouter, …) ───────────────────\n elseif ((string) config('services.vision.key', '') !== '') {\n $visionKey = (string) config('services.vision.key');\n $visionBaseUrl = rtrim((string) config('services.vision.base_url', 'https://openrouter.ai/api/v1'), '/');\n $visionModel = (string) config('services.vision.model', 'google/gemini-flash-1.5-8b');\n }\n else {\n return $this->failure(\n 'Image processing is not available. DeepSeek\\'s API does not support images. ' .\n 'Get a free key at https://console.groq.com and add GROQ_API_KEY to your .env file.'\n );\n }\n\n $dataUrl = \"data:{$mime};base64,\" . base64_encode($bytes);\n $prompt = $this->buildDeepSeekPrompt($context, $mime);\n\n $userContent = [\n ['type' => 'text', 'text' => $prompt],\n ['type' => 'image_url', 'image_url' => ['url' => $dataUrl]],\n ];\n\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders([\n 'Authorization' => 'Bearer ' . $visionKey,\n 'HTTP-Referer' => config('app.url', 'https://qimta.sa'),\n 'X-Title' => 'Qimta Platform',\n ])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post($visionBaseUrl . '/chat/completions', [\n 'model' => $visionModel,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 16384,\n 'temperature' => 0.2,\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('Vision API connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: Vision API HTTP error.', [\n 'model' => $visionModel,\n 'base_url' => $visionBaseUrl,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n return $this->failure(\"Vision API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n return $this->parseDeepSeekJsonResponse((string) $response->json('choices.0.message.content'));\n }\n\n private function callDeepSeekChat(string $userContent, string $key, string $model): array\n {\n // Ensure the PHP script outlives the HTTP call (+ a margin for parsing the\n // response and writing to the DB) so the worker/web request is not killed\n // mid-flight, which surfaces to users as a generic \"timeout or unavailable\".\n $this->extendExecutionTime();\n\n try {\n $response = Http::timeout($this->timeout)\n ->connectTimeout(30)\n ->withoutVerifying()\n ->withHeaders(['Authorization' => 'Bearer ' . $key])\n ->withOptions([\n 'curl' => [\n CURLOPT_SSLVERSION => CURL_SSLVERSION_TLSv1_2,\n CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,\n ],\n ])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'system', 'content' => $this->buildSystemInstruction()],\n ['role' => 'user', 'content' => $userContent],\n ],\n 'max_tokens' => 32768,\n 'temperature' => 0.1,\n 'response_format' => ['type' => 'json_object'],\n 'user' => 'Qimta_Platform',\n ]);\n } catch (ConnectionException $e) {\n return $this->failure('DeepSeek connection timeout or unavailable.', true);\n }\n\n if (! $response->successful()) {\n $message = (string) data_get($response->json(), 'error.message', $response->body());\n Log::error('QuotationAiService: DeepSeek HTTP error.', [\n 'model' => $model,\n 'status' => $response->status(),\n 'message' => $message,\n ]);\n if ($response->status() === 401) {\n return $this->failure('DeepSeek API key is invalid or unauthorised. Please update DEEPSEEK_API_KEY in .env.', false);\n }\n return $this->failure(\"DeepSeek API returned HTTP {$response->status()}: {$message}\", in_array($response->status(), [429, 500, 503], true));\n }\n\n $text = (string) $response->json('choices.0.message.content');\n return $this->parseDeepSeekJsonResponse($text);\n }\n\n /**\n * Shared JSON response parser used by both chat and vision paths.\n */\n private function parseDeepSeekJsonResponse(string $text): array\n {\n if ($text === '') {\n return $this->failure('DeepSeek returned an empty response.');\n }\n\n // Strip markdown code fences if the model wrapped the JSON.\n if (preg_match('/```(?:json)?\\s*([\\s\\S]*?)\\s*```/i', $text, $m)) {\n $text = $m[1];\n }\n // Narrow to the outermost JSON object/array.\n if (preg_match('/[\\{\\[][\\s\\S]*[\\}\\]]/s', $text, $m)) {\n $text = $m[0];\n }\n $text = trim($text);\n\n $decoded = json_decode($text, true);\n\n // The response can be truncated when the model hits the token limit,\n // leaving an unterminated JSON string/object. Try to repair it before\n // giving up so we can still salvage the items extracted so far.\n if (! is_array($decoded)) {\n $repaired = $this->repairTruncatedJson($text);\n if ($repaired !== null) {\n $decoded = json_decode($repaired, true);\n }\n }\n\n if (! is_array($decoded)) {\n Log::error('QuotationAiService: DeepSeek non-JSON response.', ['text' => mb_substr($text, 0, 1000)]);\n return $this->failure('DeepSeek returned a non-JSON response. The file may be too large — try splitting it into smaller files.');\n }\n\n $rawItems = $decoded['items'] ?? (array_is_list($decoded) ? $decoded : []);\n $items = [];\n $rejected = [];\n\n foreach ($rawItems as $raw) {\n if (! is_array($raw)) {\n continue;\n }\n $item = $this->normaliseAiItem($raw);\n if ($item === null) {\n continue;\n }\n $check = $this->boqCleaner->filterItem((string) $item['description']);\n if ($check['keep'] ?? false) {\n $items[] = $item;\n } else {\n $item['status'] = 'rejected';\n $item['raw_data']['rejection_reason'] = $check['rejection_reason'] ?? 'Rejected by cleaner';\n $rejected[] = $item;\n }\n }\n\n if (empty($items)) {\n Log::info('QuotationAiService: DeepSeek returned 0 accepted items.', [\n 'raw_items_count' => count($rawItems),\n 'rejected_count' => count($rejected),\n 'first_rejected' => ! empty($rejected) ? ($rejected[0]['description'] ?? '') : '',\n ]);\n }\n\n return ['success' => true, 'items' => $items, 'rejected' => $rejected, 'error' => null];\n }\n\n /**\n * Best-effort repair of a truncated JSON string produced when the model hits\n * its token limit mid-output. Drops any incomplete trailing object, then\n * closes the open string/array/object brackets so the result is valid JSON.\n *\n * Returns the repaired JSON string, or null if it cannot be salvaged.\n */\n private function repairTruncatedJson(string $text): ?string\n {\n if ($text === '') {\n return null;\n }\n\n // Walk the string tracking structural state, ignoring everything inside\n // string literals (and their escapes). Remember the last position where\n // we were back at the top of the \"items\" array between complete elements,\n // so we can cut a half-written trailing object.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($text);\n $lastSafeCut = null; // index just after a completed array element\n\n for ($i = 0; $i < $len; $i++) {\n $ch = $text[$i];\n\n if ($inString) {\n if ($escaped) {\n $escaped = false;\n } elseif ($ch === '\\\\') {\n $escaped = true;\n } elseif ($ch === '\"') {\n $inString = false;\n }\n continue;\n }\n\n if ($ch === '\"') {\n $inString = true;\n } elseif ($ch === '{' || $ch === '[') {\n $stack[] = $ch;\n } elseif ($ch === '}' || $ch === ']') {\n array_pop($stack);\n } elseif ($ch === ',' && ! empty($stack) && end($stack) === '[') {\n // Separator directly inside an array: a clean boundary between\n // elements (the items array, whatever its nesting depth).\n $lastSafeCut = $i;\n }\n }\n\n // If we are still inside a string, the last element is incomplete — fall\n // back to the last clean array boundary if we have one.\n $candidate = $text;\n if ($inString && $lastSafeCut !== null) {\n $candidate = substr($text, 0, $lastSafeCut);\n } else {\n // Drop a dangling partial object after the last completed element.\n $candidate = rtrim($candidate);\n if ($lastSafeCut !== null && substr(rtrim($candidate), -1) !== '}' && substr(rtrim($candidate), -1) !== ']') {\n $candidate = substr($text, 0, $lastSafeCut);\n }\n }\n\n // Recompute the open brackets for the (possibly trimmed) candidate and\n // append the matching closers in reverse order.\n $stack = [];\n $inString = false;\n $escaped = false;\n $len = strlen($candidate);\n for ($i = 0; $i < $len; $i++) {\n $ch = $candidate[$i];\n if ($inString) {\n if ($escaped) { $escaped = false; }\n elseif ($ch === '\\\\') { $escaped = true; }\n elseif ($ch === '\"') { $inString = false; }\n continue;\n }\n if ($ch === '\"') { $inString = true; }\n elseif ($ch === '{' || $ch === '[') { $stack[] = $ch; }\n elseif ($ch === '}' || $ch === ']') { array_pop($stack); }\n }\n\n $candidate = rtrim($candidate);\n // Remove a trailing comma left after cutting an element.\n $candidate = rtrim($candidate, ',');\n\n $closers = '';\n for ($i = count($stack) - 1; $i >= 0; $i--) {\n $closers .= ($stack[$i] === '{') ? '}' : ']';\n }\n\n $repaired = $candidate . $closers;\n\n // Validate before returning.\n return is_array(json_decode($repaired, true)) ? $repaired : null;\n }\n\n private function spreadsheetToCompactCsvText(string $absPath): ?string\n {\n $sheets = $this->spreadsheetToGrids($absPath);\n if (empty($sheets)) {\n return null;\n }\n\n $output = '';\n foreach ($sheets as $sheet) {\n $output .= 'Sheet: ' . $sheet['name'] . \"\\n\";\n foreach ($sheet['grid'] as $row) {\n $row = $this->trimTrailingEmptyCells($row);\n if (empty($row)) {\n continue;\n }\n $output .= implode(',', array_map([$this, 'csvEscape'], $row)) . \"\\n\";\n }\n $output .= \"\\n\";\n }\n\n return $output;\n }\n\n /**\n * Safely convert a PhpSpreadsheet cell value to a string.\n *\n * Cells may contain RichText or, in malformed/heavily-formatted files,\n * embedded objects such as Worksheet\\Drawing that have no __toString().\n * Casting those directly throws \"Object ... could not be converted to string\".\n */\n private function cellValueToString(mixed $value): string\n {\n if ($value === null) {\n return '';\n }\n\n if ($value instanceof \\PhpOffice\\PhpSpreadsheet\\RichText\\RichText) {\n return $value->getPlainText();\n }\n\n if (is_object($value)) {\n // Only stringify objects that explicitly support it; ignore the rest\n // (e.g. Worksheet\\Drawing) instead of crashing.\n return method_exists($value, '__toString') ? (string) $value : '';\n }\n\n if (is_array($value)) {\n return '';\n }\n\n return (string) $value;\n }\n\n private function csvEscape(mixed $value): string\n {\n $value = $this->cellValueToString($value);\n return str_contains($value, ',') || str_contains($value, '\"') || str_contains($value, \"\\n\")\n ? '\"' . str_replace('\"', '\"\"', $value) . '\"'\n : $value;\n }\n\n private function normaliseAiItem(array $raw): ?array\n {\n if (! empty($raw['rejected'])) {\n return null;\n }\n\n $description = trim((string) ($raw['cleaned_description'] ?? $raw['product_name'] ?? $raw['description'] ?? ''));\n if ($description === '' || preg_match('/^[\\d\\.\\-\\/\\s]+$/u', $description)) {\n return null;\n }\n\n $quantity = is_numeric($raw['quantity'] ?? null) ? (float) $raw['quantity'] : 1;\n $unitPrice = is_numeric($raw['unit_price'] ?? null) ? (float) $raw['unit_price'] : null;\n $totalPrice = is_numeric($raw['total_price'] ?? null) ? (float) $raw['total_price'] : null;\n\n if ($unitPrice === null && $totalPrice !== null && $quantity > 0) {\n $unitPrice = $totalPrice / $quantity;\n }\n\n $specs = is_array($raw['specifications'] ?? null) ? $raw['specifications'] : [];\n\n return [\n 'description' => $description,\n 'quantity' => $quantity,\n 'unit' => (string) ($raw['unit'] ?? ''),\n 'category' => (string) ($raw['core_product'] ?? $raw['category'] ?? ''),\n 'brand' => (string) ($specs['brand'] ?? $raw['brand'] ?? ''),\n 'status' => 'pending',\n 'engineering_required' => $this->boqCleaner->requiresEngineering($description),\n 'unit_price' => $unitPrice,\n 'confidence' => is_numeric($raw['confidence'] ?? null) ? (float) $raw['confidence'] : null,\n 'needs_review' => ! empty($raw['needs_review']),\n 'raw_data' => [\n 'source_item_no' => (string) ($raw['source_item_no'] ?? ''),\n 'original_description' => (string) ($raw['original_description'] ?? $description),\n 'cleaned_description' => $description,\n 'core_product' => (string) ($raw['core_product'] ?? ''),\n 'specifications' => $specs,\n 'extraction_type' => (string) ($raw['extraction_type'] ?? 'supply_only'),\n 'note' => (string) ($raw['note'] ?? $raw['notes'] ?? ''),\n ],\n 'ai_extracted' => true,\n ];\n }\n\n private function buildSystemInstruction(): string\n {\n return <<<'TXT'\nYou are a strict BOQ Supply Product Extraction Engine.\nExtract only purchasable physical supply products. Reject totals, headings, general requirements, installation-only, testing, commissioning, supervision, mobilization, civil works, labor, and vague rows.\nFor Supply & Install rows, keep only the supply product and remove install/labor wording.\nReturn only valid JSON: {\"items\":[{\"source_item_no\":null,\"original_description\":\"\",\"cleaned_description\":\"\",\"core_product\":\"\",\"specifications\":{},\"quantity\":0,\"unit\":\"\",\"unit_price\":0,\"total_price\":0,\"extraction_type\":\"supply_only\",\"needs_review\":false,\"note\":\"\",\"rejected\":false,\"rejection_reason\":null,\"confidence\":0.95}]}\nTXT;\n }\n\n private function buildDeepSeekPrompt(array $context = [], string $mime = ''): string\n {\n $projectName = (string) ($context['project_name'] ?? '');\n $projectStatus = (string) ($context['project_status'] ?? '');\n $contextLine = $projectName !== ''\n ? \"Project: {$projectName}\" . ($projectStatus !== '' ? \" ({$projectStatus})\" : '')\n : '';\n $sourceHint = str_starts_with($mime, 'image/')\n ? 'The input is an image of a BOQ table. OCR every cell carefully — read the quantity/count column (العدد, الكمية, Qty) and extract the EXACT numeric value from each row.'\n : 'The input is a BOQ document/spreadsheet.';\n\n return trim(<<<PROMPT\nYou are a procurement specialist. Your task is to extract ONLY tangible, physical supply products from this Bill of Quantities (BOQ).\n\n{$contextLine}\n{$sourceHint}\n\nSTRICT RULES — include ONLY items that are ALL of the following:\n1. A physical, tangible product that can be ordered from a supplier and delivered to site.\n2. Has a real quantity (not 0 or \"varies\" with no meaning).\n3. Has enough description to identify what the product is.\n\nEXCLUDE all of the following (do NOT include them):\n- Labor, installation, or workmanship items (e.g. \"install\", \"fix\", \"erect\", \"dismantle\", \"painting\", \"cleaning service\")\n- Project management, overhead, or profit items\n- Insurance, bonds, liability items (e.g. \"bodily injury\", \"third party liability\", \"CAR insurance\")\n- Shop drawings, as-built drawings, method statements, documentation\n- Preliminary items, mobilisation, demobilisation, provisional sums\n- Housekeeping, site clearance, waste removal\n- Floor/level labels (e.g. \"GF Floor\", \"Level 3\") — these are headers, not products\n- Pure dimension specs without a product noun (e.g. \"Dia 32 mm\", \"25 mm\") — reject unless a product name is present\n- Security services, night shifts, rentals, payments\n- Any item whose description is too vague or fragmentary to identify a specific product\n\nFor each valid physical product return JSON with:\n- description: clean product name only (strip \"supply and install\", \"supply and fix\", installation clauses, floor/spec suffixes)\n- quantity: the EXACT numeric value from the quantity/count column (العدد, الكمية, Qty, Quantity, No.). NEVER default to 1 unless the quantity column is truly absent or empty.\n- unit: unit of measure (pcs, m, m2, m3, set, no, kg, etc.)\n- category: product category\n- brand: brand/manufacturer if mentioned, else \"\"\n- engineering_required: true if the item needs engineering design before procurement (panel boards, chillers, generators, fire suppression systems, BMS, pumps, AHUs, switchgear, transformers), else false\n\nReturn ONLY a valid JSON object: {\"items\": [...]}. No explanations, no markdown, no extra text.\nPROMPT);\n }\n\n private function mockResponse(): array\n {\n return [\n 'success' => true,\n 'error' => null,\n 'items' => [[\n 'description' => 'Sample BOQ Item',\n 'quantity' => 1,\n 'unit' => 'No',\n 'category' => 'Sample',\n 'brand' => '',\n 'status' => 'pending',\n 'engineering_required' => false,\n 'unit_price' => null,\n 'confidence' => 1,\n 'raw_data' => [],\n 'ai_extracted' => true,\n ]],\n 'rejected' => [],\n ];\n }\n\n private function failure(string $message, bool $serviceUnavailable = false): array\n {\n return [\n 'success' => false,\n 'items' => [],\n 'rejected' => [],\n 'error' => $message,\n 'service_unavailable' => $serviceUnavailable,\n ];\n }\n\n private function mimeForExtension(string $ext): string\n {\n return match (strtolower($ext)) {\n 'pdf' => 'application/pdf',\n 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n 'xlsm' => 'application/vnd.ms-excel.sheet.macroEnabled.12',\n 'xlsb' => 'application/vnd.ms-excel.sheet.binary.macroEnabled.12',\n 'xls' => 'application/vnd.ms-excel',\n 'csv' => 'text/csv',\n 'jpg', 'jpeg' => 'image/jpeg',\n 'png' => 'image/png',\n 'gif' => 'image/gif',\n 'bmp' => 'image/bmp',\n 'webp' => 'image/webp',\n 'tiff', 'tif' => 'image/tiff',\n 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n 'doc' => 'application/msword',\n default => 'application/octet-stream',\n };\n }\n}\n"}}, "app/Services/PriceAnalysisService.php": {"9a5ada4ab1ceb0de386bc1ce99c561f8869c09d7": {"t": 1785745788.6481616, "n": 13624, "h": "423c9d9d9020afa8", "c": "<?php\n\nnamespace App\\Services;\n\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\nuse App\\Support\\AiCache;\n\n/**\n * Third-pass quotation analysis.\n *\n * After PricingService produces unit prices, this service inspects the WHOLE set\n * of priced items and surfaces problems the per-item passes cannot see, plus a\n * market price range per item. Nothing here mutates prices — it only reports.\n *\n * It produces findings keyed by a stable code so the UI can style and translate\n * each one:\n *\n * - PRICE_INCONSISTENCY: rows that describe the same item carry different unit\n * prices. One of them is probably wrong.\n *\n * Duplication and VAT are validated EARLIER, at BOQ-upload time\n * (see BoqValidationService), so they are intentionally not repeated here.\n *\n * The market range (min / avg / max unit price per item) comes from a fresh\n * DeepSeek query, mirroring PricingService's batched-parallel call shape.\n */\nclass PriceAnalysisService\n{\n /** Saudi standard VAT rate. Mirrors the rate hard-coded in the pricing blade. */\n public const VAT_RATE = 0.15;\n\n /** Items per DeepSeek range call; kept small so calls run in parallel. */\n private const CHUNK_SIZE = 10;\n\n /**\n * How long a market range stays reusable.\n *\n * Matches the price and verdict caches, so everything the user sees about\n * one product expires together rather than in pieces.\n */\n private const RANGE_CACHE_DAYS = 7;\n\n /**\n * Run the pricing-stage analysis over a set of already-priced items.\n *\n * @param array<int, array<string, mixed>> $items\n * @return array{\n * findings: list<array{code:string, severity:string, message:string, rows:list<int>}>,\n * ranges: array<int, array{min:float, avg:float, max:float}>,\n * summary: array{subtotal:float, vat:float, total:float}\n * }\n */\n public function analyze(array $items): array\n {\n return [\n 'findings' => array_values($this->detectPriceInconsistencies($items)),\n 'ranges' => $this->fetchMarketRanges($items),\n 'summary' => $this->computeSummary($items),\n ];\n }\n\n // -------------------------------------------------------------------------\n // PRICE_INCONSISTENCY\n // -------------------------------------------------------------------------\n\n /**\n * Rows that describe the same item (same normalized key) but carry different\n * unit prices. Rows without a numeric price are ignored.\n *\n * @param array<int, array<string, mixed>> $items\n * @return list<array{code:string, severity:string, message:string, rows:list<int>}>\n */\n private function detectPriceInconsistencies(array $items): array\n {\n $groups = [];\n foreach ($items as $index => $item) {\n if (($item['price_status'] ?? 'pending') === 'rejected') {\n continue;\n }\n $price = $item['unit_price'] ?? null;\n if (! is_numeric($price)) {\n continue;\n }\n $key = $this->normalizeKey($item);\n if ($key === '') {\n continue;\n }\n $groups[$key][$index] = round((float) $price, 2);\n }\n\n $findings = [];\n foreach ($groups as $priced) {\n $distinct = array_unique(array_values($priced));\n if (count($distinct) < 2) {\n continue;\n }\n\n $rows = array_keys($priced);\n $findings[] = [\n 'code' => 'PRICE_INCONSISTENCY',\n 'severity' => 'danger',\n 'message' => __('app.analysis_inconsistency_msg', [\n 'desc' => $this->label($items[$rows[0]]),\n 'min' => number_format(min($distinct), 2),\n 'max' => number_format(max($distinct), 2),\n ]),\n 'rows' => $rows,\n ];\n }\n\n return $findings;\n }\n\n // -------------------------------------------------------------------------\n // Summary\n // -------------------------------------------------------------------------\n\n /**\n * Recompute subtotal / VAT / total from non-rejected priced rows.\n *\n * @param array<int, array<string, mixed>> $items\n * @return array{subtotal:float, vat:float, total:float}\n */\n private function computeSummary(array $items): array\n {\n $subtotal = 0.0;\n foreach ($items as $item) {\n if (($item['price_status'] ?? 'pending') === 'rejected') {\n continue;\n }\n $price = $item['unit_price'] ?? null;\n if (! is_numeric($price)) {\n continue;\n }\n $subtotal += (float) $price * (float) ($item['quantity'] ?? 0);\n }\n\n $vat = round($subtotal * self::VAT_RATE, 2);\n\n return [\n 'subtotal' => round($subtotal, 2),\n 'vat' => $vat,\n 'total' => round($subtotal + $vat, 2),\n ];\n }\n\n // -------------------------------------------------------------------------\n // Market range (min / avg / max) via DeepSeek\n // -------------------------------------------------------------------------\n\n /**\n * Ask DeepSeek for a realistic Saudi market unit-price range per priced item.\n *\n * @param array<int, array<string, mixed>> $items\n * @return array<int, array{min:float, avg:float, max:float}> Keyed by item index.\n */\n private function fetchMarketRanges(array $items): array\n {\n $indices = [];\n foreach ($items as $index => $item) {\n if (($item['price_status'] ?? 'pending') === 'rejected') {\n continue;\n }\n if (is_numeric($item['unit_price'] ?? null) && (float) $item['unit_price'] > 0) {\n $indices[] = $index;\n }\n }\n\n if (empty($indices)) {\n return [];\n }\n\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n Log::warning('PriceAnalysisService: DEEPSEEK_API_KEY not configured; skipping market ranges.');\n return [];\n }\n\n // Reuse ranges already looked up for these products.\n //\n // This runs again on every approve/reject click, so a user working\n // through a quotation re-asked the AI for the same market ranges over\n // and over — and got slightly different numbers each time. The range is\n // a property of the product, not of the current price, so it is safe to\n // keep and safe to share across quotations.\n $ranges = [];\n $pending = [];\n\n foreach ($indices as $index) {\n $cached = AiCache::store()->get($this->rangeCacheKey($items[$index]));\n\n if (is_array($cached)) {\n $ranges[$index] = $cached;\n continue;\n }\n\n $pending[] = $index;\n }\n\n foreach (array_chunk($pending, self::CHUNK_SIZE) as $chunk) {\n $fetched = $this->fetchRangeChunk($items, $chunk, $apiKey);\n\n foreach ($fetched as $index => $range) {\n AiCache::store()->put(\n $this->rangeCacheKey($items[$index]),\n $range,\n now()->addDays(self::RANGE_CACHE_DAYS),\n );\n }\n\n $ranges += $fetched;\n }\n\n return $ranges;\n }\n\n /**\n * Cache key for one product's market range.\n *\n * Keyed on what the model is actually asked about — description, category,\n * brand and unit — and deliberately not on the current price, which does not\n * change what the market charges.\n */\n private function rangeCacheKey(array $item): string\n {\n $signature = mb_strtolower(trim(preg_replace('/\\s+/u', ' ', (string) ($item['description'] ?? '')) ?? ''))\n . '|' . mb_strtolower(trim((string) ($item['category'] ?? '')))\n . '|' . mb_strtolower(trim((string) ($item['brand'] ?? '')))\n . '|' . mb_strtolower(trim((string) ($item['unit'] ?? '')));\n\n return 'price_range_' . hash('sha256', $signature);\n }\n\n /**\n * @param list<int> $indices\n * @return array<int, array{min:float, avg:float, max:float}>\n */\n private function fetchRangeChunk(array $items, array $indices, string $apiKey): array\n {\n $payload = [];\n foreach ($indices as $idx) {\n $payload[] = [\n 'i' => $idx,\n 'd' => mb_substr((string) ($items[$idx]['description'] ?? ''), 0, 80),\n 'cat' => mb_substr((string) ($items[$idx]['category'] ?? ''), 0, 30),\n 'br' => mb_substr((string) ($items[$idx]['brand'] ?? ''), 0, 30),\n 'unit' => mb_substr((string) ($items[$idx]['unit'] ?? ''), 0, 15),\n ];\n }\n\n $prompt = 'You are a procurement pricing expert for the Saudi Arabia construction and MEP materials market. '\n . 'For each item below, give a realistic CURRENT unit-price RANGE in SAR based on typical 2024-2026 Saudi '\n . 'supplier rates: the lowest (\"mn\"), the average (\"av\") and the highest (\"mx\") you would expect to see. '\n . 'RULES: '\n . '(1) All three must be positive numbers with mn <= av <= mx. '\n . '(2) Return ONLY a valid compact JSON array, NO whitespace/newlines between elements, no markdown, no extra keys. '\n . '(3) Each element must be exactly: {\"i\":<index>,\"mn\":<number>,\"av\":<number>,\"mx\":<number>} '\n . 'Example: [{\"i\":0,\"mn\":1200,\"av\":1500,\"mx\":1900}] '\n . 'Items: ' . json_encode($payload, JSON_UNESCAPED_UNICODE);\n\n try {\n $response = Http::timeout(90)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => (string) config('services.deepseek.model', 'deepseek-chat'),\n 'messages' => [['role' => 'user', 'content' => $prompt]],\n 'temperature' => 0,\n 'max_tokens' => 8192,\n 'user' => 'Qimta_Platform',\n ]);\n\n if (! $response->successful()) {\n Log::warning('PriceAnalysisService: DeepSeek range request failed.', ['status' => $response->status()]);\n return [];\n }\n\n return $this->parseRanges($response->json('choices.0.message.content') ?? '');\n } catch (\\Throwable $e) {\n Log::error('PriceAnalysisService: Exception fetching ranges.', ['message' => $e->getMessage()]);\n return [];\n }\n }\n\n /**\n * @return array<int, array{min:float, avg:float, max:float}>\n */\n private function parseRanges(string $text): array\n {\n $text = preg_replace('/^```json\\s*/i', '', trim($text));\n $text = preg_replace('/```\\s*$/i', '', $text);\n\n $data = json_decode($text, true);\n\n if (! is_array($data)) {\n preg_match_all(\n '/\\{\\s*\"i\"\\s*:\\s*(\\d+)\\s*,\\s*\"mn\"\\s*:\\s*([\\d.]+)\\s*,\\s*\"av\"\\s*:\\s*([\\d.]+)\\s*,\\s*\"mx\"\\s*:\\s*([\\d.]+)\\s*\\}/',\n $text,\n $matches,\n PREG_SET_ORDER\n );\n $data = array_map(fn($m) => [\n 'i' => (int) $m[1],\n 'mn' => (float) $m[2],\n 'av' => (float) $m[3],\n 'mx' => (float) $m[4],\n ], $matches);\n }\n\n if (! is_array($data)) {\n return [];\n }\n\n $ranges = [];\n foreach ($data as $entry) {\n $idx = $entry['i'] ?? null;\n if (! is_int($idx) && ! (is_string($idx) && ctype_digit($idx))) {\n continue;\n }\n $mn = is_numeric($entry['mn'] ?? null) ? (float) $entry['mn'] : null;\n $av = is_numeric($entry['av'] ?? null) ? (float) $entry['av'] : null;\n $mx = is_numeric($entry['mx'] ?? null) ? (float) $entry['mx'] : null;\n\n if ($mn === null || $av === null || $mx === null || $mn <= 0 || $mx <= 0) {\n continue;\n }\n\n // Guard against the model returning them out of order.\n $sorted = [$mn, $av, $mx];\n sort($sorted);\n\n $ranges[(int) $idx] = [\n 'min' => round($sorted[0], 2),\n 'avg' => round($sorted[1], 2),\n 'max' => round($sorted[2], 2),\n ];\n }\n\n return $ranges;\n }\n\n // -------------------------------------------------------------------------\n // Helpers\n // -------------------------------------------------------------------------\n\n /**\n * A normalized grouping key: lower-cased, whitespace-collapsed description + unit.\n * Empty when there is no description to compare on.\n */\n private function normalizeKey(array $item): string\n {\n $desc = mb_strtolower(trim((string) ($item['description'] ?? '')));\n $desc = preg_replace('/\\s+/u', ' ', $desc);\n\n if ($desc === '') {\n return '';\n }\n\n $unit = mb_strtolower(trim((string) ($item['unit'] ?? '')));\n\n return $desc . '|' . $unit;\n }\n\n /** Short human label for a finding message. */\n private function label(array $item): string\n {\n $desc = trim((string) ($item['description'] ?? ''));\n return $desc !== '' ? mb_substr($desc, 0, 60) : '—';\n }\n}\n"}, "06c9320bf7fd9a0d5e611ec5a69092739739349f": {"t": 1785748347.1838262, "n": 13624, "h": "423c9d9d9020afa8", "c": "<?php\n\nnamespace App\\Services;\n\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\nuse App\\Support\\AiCache;\n\n/**\n * Third-pass quotation analysis.\n *\n * After PricingService produces unit prices, this service inspects the WHOLE set\n * of priced items and surfaces problems the per-item passes cannot see, plus a\n * market price range per item. Nothing here mutates prices — it only reports.\n *\n * It produces findings keyed by a stable code so the UI can style and translate\n * each one:\n *\n * - PRICE_INCONSISTENCY: rows that describe the same item carry different unit\n * prices. One of them is probably wrong.\n *\n * Duplication and VAT are validated EARLIER, at BOQ-upload time\n * (see BoqValidationService), so they are intentionally not repeated here.\n *\n * The market range (min / avg / max unit price per item) comes from a fresh\n * DeepSeek query, mirroring PricingService's batched-parallel call shape.\n */\nclass PriceAnalysisService\n{\n /** Saudi standard VAT rate. Mirrors the rate hard-coded in the pricing blade. */\n public const VAT_RATE = 0.15;\n\n /** Items per DeepSeek range call; kept small so calls run in parallel. */\n private const CHUNK_SIZE = 10;\n\n /**\n * How long a market range stays reusable.\n *\n * Matches the price and verdict caches, so everything the user sees about\n * one product expires together rather than in pieces.\n */\n private const RANGE_CACHE_DAYS = 7;\n\n /**\n * Run the pricing-stage analysis over a set of already-priced items.\n *\n * @param array<int, array<string, mixed>> $items\n * @return array{\n * findings: list<array{code:string, severity:string, message:string, rows:list<int>}>,\n * ranges: array<int, array{min:float, avg:float, max:float}>,\n * summary: array{subtotal:float, vat:float, total:float}\n * }\n */\n public function analyze(array $items): array\n {\n return [\n 'findings' => array_values($this->detectPriceInconsistencies($items)),\n 'ranges' => $this->fetchMarketRanges($items),\n 'summary' => $this->computeSummary($items),\n ];\n }\n\n // -------------------------------------------------------------------------\n // PRICE_INCONSISTENCY\n // -------------------------------------------------------------------------\n\n /**\n * Rows that describe the same item (same normalized key) but carry different\n * unit prices. Rows without a numeric price are ignored.\n *\n * @param array<int, array<string, mixed>> $items\n * @return list<array{code:string, severity:string, message:string, rows:list<int>}>\n */\n private function detectPriceInconsistencies(array $items): array\n {\n $groups = [];\n foreach ($items as $index => $item) {\n if (($item['price_status'] ?? 'pending') === 'rejected') {\n continue;\n }\n $price = $item['unit_price'] ?? null;\n if (! is_numeric($price)) {\n continue;\n }\n $key = $this->normalizeKey($item);\n if ($key === '') {\n continue;\n }\n $groups[$key][$index] = round((float) $price, 2);\n }\n\n $findings = [];\n foreach ($groups as $priced) {\n $distinct = array_unique(array_values($priced));\n if (count($distinct) < 2) {\n continue;\n }\n\n $rows = array_keys($priced);\n $findings[] = [\n 'code' => 'PRICE_INCONSISTENCY',\n 'severity' => 'danger',\n 'message' => __('app.analysis_inconsistency_msg', [\n 'desc' => $this->label($items[$rows[0]]),\n 'min' => number_format(min($distinct), 2),\n 'max' => number_format(max($distinct), 2),\n ]),\n 'rows' => $rows,\n ];\n }\n\n return $findings;\n }\n\n // -------------------------------------------------------------------------\n // Summary\n // -------------------------------------------------------------------------\n\n /**\n * Recompute subtotal / VAT / total from non-rejected priced rows.\n *\n * @param array<int, array<string, mixed>> $items\n * @return array{subtotal:float, vat:float, total:float}\n */\n private function computeSummary(array $items): array\n {\n $subtotal = 0.0;\n foreach ($items as $item) {\n if (($item['price_status'] ?? 'pending') === 'rejected') {\n continue;\n }\n $price = $item['unit_price'] ?? null;\n if (! is_numeric($price)) {\n continue;\n }\n $subtotal += (float) $price * (float) ($item['quantity'] ?? 0);\n }\n\n $vat = round($subtotal * self::VAT_RATE, 2);\n\n return [\n 'subtotal' => round($subtotal, 2),\n 'vat' => $vat,\n 'total' => round($subtotal + $vat, 2),\n ];\n }\n\n // -------------------------------------------------------------------------\n // Market range (min / avg / max) via DeepSeek\n // -------------------------------------------------------------------------\n\n /**\n * Ask DeepSeek for a realistic Saudi market unit-price range per priced item.\n *\n * @param array<int, array<string, mixed>> $items\n * @return array<int, array{min:float, avg:float, max:float}> Keyed by item index.\n */\n private function fetchMarketRanges(array $items): array\n {\n $indices = [];\n foreach ($items as $index => $item) {\n if (($item['price_status'] ?? 'pending') === 'rejected') {\n continue;\n }\n if (is_numeric($item['unit_price'] ?? null) && (float) $item['unit_price'] > 0) {\n $indices[] = $index;\n }\n }\n\n if (empty($indices)) {\n return [];\n }\n\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n Log::warning('PriceAnalysisService: DEEPSEEK_API_KEY not configured; skipping market ranges.');\n return [];\n }\n\n // Reuse ranges already looked up for these products.\n //\n // This runs again on every approve/reject click, so a user working\n // through a quotation re-asked the AI for the same market ranges over\n // and over — and got slightly different numbers each time. The range is\n // a property of the product, not of the current price, so it is safe to\n // keep and safe to share across quotations.\n $ranges = [];\n $pending = [];\n\n foreach ($indices as $index) {\n $cached = AiCache::store()->get($this->rangeCacheKey($items[$index]));\n\n if (is_array($cached)) {\n $ranges[$index] = $cached;\n continue;\n }\n\n $pending[] = $index;\n }\n\n foreach (array_chunk($pending, self::CHUNK_SIZE) as $chunk) {\n $fetched = $this->fetchRangeChunk($items, $chunk, $apiKey);\n\n foreach ($fetched as $index => $range) {\n AiCache::store()->put(\n $this->rangeCacheKey($items[$index]),\n $range,\n now()->addDays(self::RANGE_CACHE_DAYS),\n );\n }\n\n $ranges += $fetched;\n }\n\n return $ranges;\n }\n\n /**\n * Cache key for one product's market range.\n *\n * Keyed on what the model is actually asked about — description, category,\n * brand and unit — and deliberately not on the current price, which does not\n * change what the market charges.\n */\n private function rangeCacheKey(array $item): string\n {\n $signature = mb_strtolower(trim(preg_replace('/\\s+/u', ' ', (string) ($item['description'] ?? '')) ?? ''))\n . '|' . mb_strtolower(trim((string) ($item['category'] ?? '')))\n . '|' . mb_strtolower(trim((string) ($item['brand'] ?? '')))\n . '|' . mb_strtolower(trim((string) ($item['unit'] ?? '')));\n\n return 'price_range_' . hash('sha256', $signature);\n }\n\n /**\n * @param list<int> $indices\n * @return array<int, array{min:float, avg:float, max:float}>\n */\n private function fetchRangeChunk(array $items, array $indices, string $apiKey): array\n {\n $payload = [];\n foreach ($indices as $idx) {\n $payload[] = [\n 'i' => $idx,\n 'd' => mb_substr((string) ($items[$idx]['description'] ?? ''), 0, 80),\n 'cat' => mb_substr((string) ($items[$idx]['category'] ?? ''), 0, 30),\n 'br' => mb_substr((string) ($items[$idx]['brand'] ?? ''), 0, 30),\n 'unit' => mb_substr((string) ($items[$idx]['unit'] ?? ''), 0, 15),\n ];\n }\n\n $prompt = 'You are a procurement pricing expert for the Saudi Arabia construction and MEP materials market. '\n . 'For each item below, give a realistic CURRENT unit-price RANGE in SAR based on typical 2024-2026 Saudi '\n . 'supplier rates: the lowest (\"mn\"), the average (\"av\") and the highest (\"mx\") you would expect to see. '\n . 'RULES: '\n . '(1) All three must be positive numbers with mn <= av <= mx. '\n . '(2) Return ONLY a valid compact JSON array, NO whitespace/newlines between elements, no markdown, no extra keys. '\n . '(3) Each element must be exactly: {\"i\":<index>,\"mn\":<number>,\"av\":<number>,\"mx\":<number>} '\n . 'Example: [{\"i\":0,\"mn\":1200,\"av\":1500,\"mx\":1900}] '\n . 'Items: ' . json_encode($payload, JSON_UNESCAPED_UNICODE);\n\n try {\n $response = Http::timeout(90)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => (string) config('services.deepseek.model', 'deepseek-chat'),\n 'messages' => [['role' => 'user', 'content' => $prompt]],\n 'temperature' => 0,\n 'max_tokens' => 8192,\n 'user' => 'Qimta_Platform',\n ]);\n\n if (! $response->successful()) {\n Log::warning('PriceAnalysisService: DeepSeek range request failed.', ['status' => $response->status()]);\n return [];\n }\n\n return $this->parseRanges($response->json('choices.0.message.content') ?? '');\n } catch (\\Throwable $e) {\n Log::error('PriceAnalysisService: Exception fetching ranges.', ['message' => $e->getMessage()]);\n return [];\n }\n }\n\n /**\n * @return array<int, array{min:float, avg:float, max:float}>\n */\n private function parseRanges(string $text): array\n {\n $text = preg_replace('/^```json\\s*/i', '', trim($text));\n $text = preg_replace('/```\\s*$/i', '', $text);\n\n $data = json_decode($text, true);\n\n if (! is_array($data)) {\n preg_match_all(\n '/\\{\\s*\"i\"\\s*:\\s*(\\d+)\\s*,\\s*\"mn\"\\s*:\\s*([\\d.]+)\\s*,\\s*\"av\"\\s*:\\s*([\\d.]+)\\s*,\\s*\"mx\"\\s*:\\s*([\\d.]+)\\s*\\}/',\n $text,\n $matches,\n PREG_SET_ORDER\n );\n $data = array_map(fn($m) => [\n 'i' => (int) $m[1],\n 'mn' => (float) $m[2],\n 'av' => (float) $m[3],\n 'mx' => (float) $m[4],\n ], $matches);\n }\n\n if (! is_array($data)) {\n return [];\n }\n\n $ranges = [];\n foreach ($data as $entry) {\n $idx = $entry['i'] ?? null;\n if (! is_int($idx) && ! (is_string($idx) && ctype_digit($idx))) {\n continue;\n }\n $mn = is_numeric($entry['mn'] ?? null) ? (float) $entry['mn'] : null;\n $av = is_numeric($entry['av'] ?? null) ? (float) $entry['av'] : null;\n $mx = is_numeric($entry['mx'] ?? null) ? (float) $entry['mx'] : null;\n\n if ($mn === null || $av === null || $mx === null || $mn <= 0 || $mx <= 0) {\n continue;\n }\n\n // Guard against the model returning them out of order.\n $sorted = [$mn, $av, $mx];\n sort($sorted);\n\n $ranges[(int) $idx] = [\n 'min' => round($sorted[0], 2),\n 'avg' => round($sorted[1], 2),\n 'max' => round($sorted[2], 2),\n ];\n }\n\n return $ranges;\n }\n\n // -------------------------------------------------------------------------\n // Helpers\n // -------------------------------------------------------------------------\n\n /**\n * A normalized grouping key: lower-cased, whitespace-collapsed description + unit.\n * Empty when there is no description to compare on.\n */\n private function normalizeKey(array $item): string\n {\n $desc = mb_strtolower(trim((string) ($item['description'] ?? '')));\n $desc = preg_replace('/\\s+/u', ' ', $desc);\n\n if ($desc === '') {\n return '';\n }\n\n $unit = mb_strtolower(trim((string) ($item['unit'] ?? '')));\n\n return $desc . '|' . $unit;\n }\n\n /** Short human label for a finding message. */\n private function label(array $item): string\n {\n $desc = trim((string) ($item['description'] ?? ''));\n return $desc !== '' ? mb_substr($desc, 0, 60) : '—';\n }\n}\n"}, "8fd48f258c675a8fe54dab5da10ea820e80627cc": {"t": 1785748348.3132017, "n": 13603, "h": "882a1e638af55e7e", "c": "<?php\n\nnamespace App\\Services;\n\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\n\n/**\n * Third-pass quotation analysis.\n *\n * After PricingService produces unit prices, this service inspects the WHOLE set\n * of priced items and surfaces problems the per-item passes cannot see, plus a\n * market price range per item. Nothing here mutates prices — it only reports.\n *\n * It produces findings keyed by a stable code so the UI can style and translate\n * each one:\n *\n * - PRICE_INCONSISTENCY: rows that describe the same item carry different unit\n * prices. One of them is probably wrong.\n *\n * Duplication and VAT are validated EARLIER, at BOQ-upload time\n * (see BoqValidationService), so they are intentionally not repeated here.\n *\n * The market range (min / avg / max unit price per item) comes from a fresh\n * DeepSeek query, mirroring PricingService's batched-parallel call shape.\n */\nclass PriceAnalysisService\n{\n /** Saudi standard VAT rate. Mirrors the rate hard-coded in the pricing blade. */\n public const VAT_RATE = 0.15;\n\n /** Items per DeepSeek range call; kept small so calls run in parallel. */\n private const CHUNK_SIZE = 10;\n\n /**\n * How long a market range stays reusable.\n *\n * Matches the price and verdict caches, so everything the user sees about\n * one product expires together rather than in pieces.\n */\n private const RANGE_CACHE_DAYS = 7;\n\n /**\n * Run the pricing-stage analysis over a set of already-priced items.\n *\n * @param array<int, array<string, mixed>> $items\n * @return array{\n * findings: list<array{code:string, severity:string, message:string, rows:list<int>}>,\n * ranges: array<int, array{min:float, avg:float, max:float}>,\n * summary: array{subtotal:float, vat:float, total:float}\n * }\n */\n public function analyze(array $items): array\n {\n return [\n 'findings' => array_values($this->detectPriceInconsistencies($items)),\n 'ranges' => $this->fetchMarketRanges($items),\n 'summary' => $this->computeSummary($items),\n ];\n }\n\n // -------------------------------------------------------------------------\n // PRICE_INCONSISTENCY\n // -------------------------------------------------------------------------\n\n /**\n * Rows that describe the same item (same normalized key) but carry different\n * unit prices. Rows without a numeric price are ignored.\n *\n * @param array<int, array<string, mixed>> $items\n * @return list<array{code:string, severity:string, message:string, rows:list<int>}>\n */\n private function detectPriceInconsistencies(array $items): array\n {\n $groups = [];\n foreach ($items as $index => $item) {\n if (($item['price_status'] ?? 'pending') === 'rejected') {\n continue;\n }\n $price = $item['unit_price'] ?? null;\n if (! is_numeric($price)) {\n continue;\n }\n $key = $this->normalizeKey($item);\n if ($key === '') {\n continue;\n }\n $groups[$key][$index] = round((float) $price, 2);\n }\n\n $findings = [];\n foreach ($groups as $priced) {\n $distinct = array_unique(array_values($priced));\n if (count($distinct) < 2) {\n continue;\n }\n\n $rows = array_keys($priced);\n $findings[] = [\n 'code' => 'PRICE_INCONSISTENCY',\n 'severity' => 'danger',\n 'message' => __('app.analysis_inconsistency_msg', [\n 'desc' => $this->label($items[$rows[0]]),\n 'min' => number_format(min($distinct), 2),\n 'max' => number_format(max($distinct), 2),\n ]),\n 'rows' => $rows,\n ];\n }\n\n return $findings;\n }\n\n // -------------------------------------------------------------------------\n // Summary\n // -------------------------------------------------------------------------\n\n /**\n * Recompute subtotal / VAT / total from non-rejected priced rows.\n *\n * @param array<int, array<string, mixed>> $items\n * @return array{subtotal:float, vat:float, total:float}\n */\n private function computeSummary(array $items): array\n {\n $subtotal = 0.0;\n foreach ($items as $item) {\n if (($item['price_status'] ?? 'pending') === 'rejected') {\n continue;\n }\n $price = $item['unit_price'] ?? null;\n if (! is_numeric($price)) {\n continue;\n }\n $subtotal += (float) $price * (float) ($item['quantity'] ?? 0);\n }\n\n $vat = round($subtotal * self::VAT_RATE, 2);\n\n return [\n 'subtotal' => round($subtotal, 2),\n 'vat' => $vat,\n 'total' => round($subtotal + $vat, 2),\n ];\n }\n\n // -------------------------------------------------------------------------\n // Market range (min / avg / max) via DeepSeek\n // -------------------------------------------------------------------------\n\n /**\n * Ask DeepSeek for a realistic Saudi market unit-price range per priced item.\n *\n * @param array<int, array<string, mixed>> $items\n * @return array<int, array{min:float, avg:float, max:float}> Keyed by item index.\n */\n private function fetchMarketRanges(array $items): array\n {\n $indices = [];\n foreach ($items as $index => $item) {\n if (($item['price_status'] ?? 'pending') === 'rejected') {\n continue;\n }\n if (is_numeric($item['unit_price'] ?? null) && (float) $item['unit_price'] > 0) {\n $indices[] = $index;\n }\n }\n\n if (empty($indices)) {\n return [];\n }\n\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n Log::warning('PriceAnalysisService: DEEPSEEK_API_KEY not configured; skipping market ranges.');\n return [];\n }\n\n // Reuse ranges already looked up for these products.\n //\n // This runs again on every approve/reject click, so a user working\n // through a quotation re-asked the AI for the same market ranges over\n // and over — and got slightly different numbers each time. The range is\n // a property of the product, not of the current price, so it is safe to\n // keep and safe to share across quotations.\n $ranges = [];\n $pending = [];\n\n foreach ($indices as $index) {\n $cached = Cache::store('ai')->get($this->rangeCacheKey($items[$index]));\n\n if (is_array($cached)) {\n $ranges[$index] = $cached;\n continue;\n }\n\n $pending[] = $index;\n }\n\n foreach (array_chunk($pending, self::CHUNK_SIZE) as $chunk) {\n $fetched = $this->fetchRangeChunk($items, $chunk, $apiKey);\n\n foreach ($fetched as $index => $range) {\n Cache::store('ai')->put(\n $this->rangeCacheKey($items[$index]),\n $range,\n now()->addDays(self::RANGE_CACHE_DAYS),\n );\n }\n\n $ranges += $fetched;\n }\n\n return $ranges;\n }\n\n /**\n * Cache key for one product's market range.\n *\n * Keyed on what the model is actually asked about — description, category,\n * brand and unit — and deliberately not on the current price, which does not\n * change what the market charges.\n */\n private function rangeCacheKey(array $item): string\n {\n $signature = mb_strtolower(trim(preg_replace('/\\s+/u', ' ', (string) ($item['description'] ?? '')) ?? ''))\n . '|' . mb_strtolower(trim((string) ($item['category'] ?? '')))\n . '|' . mb_strtolower(trim((string) ($item['brand'] ?? '')))\n . '|' . mb_strtolower(trim((string) ($item['unit'] ?? '')));\n\n return 'price_range_' . hash('sha256', $signature);\n }\n\n /**\n * @param list<int> $indices\n * @return array<int, array{min:float, avg:float, max:float}>\n */\n private function fetchRangeChunk(array $items, array $indices, string $apiKey): array\n {\n $payload = [];\n foreach ($indices as $idx) {\n $payload[] = [\n 'i' => $idx,\n 'd' => mb_substr((string) ($items[$idx]['description'] ?? ''), 0, 80),\n 'cat' => mb_substr((string) ($items[$idx]['category'] ?? ''), 0, 30),\n 'br' => mb_substr((string) ($items[$idx]['brand'] ?? ''), 0, 30),\n 'unit' => mb_substr((string) ($items[$idx]['unit'] ?? ''), 0, 15),\n ];\n }\n\n $prompt = 'You are a procurement pricing expert for the Saudi Arabia construction and MEP materials market. '\n . 'For each item below, give a realistic CURRENT unit-price RANGE in SAR based on typical 2024-2026 Saudi '\n . 'supplier rates: the lowest (\"mn\"), the average (\"av\") and the highest (\"mx\") you would expect to see. '\n . 'RULES: '\n . '(1) All three must be positive numbers with mn <= av <= mx. '\n . '(2) Return ONLY a valid compact JSON array, NO whitespace/newlines between elements, no markdown, no extra keys. '\n . '(3) Each element must be exactly: {\"i\":<index>,\"mn\":<number>,\"av\":<number>,\"mx\":<number>} '\n . 'Example: [{\"i\":0,\"mn\":1200,\"av\":1500,\"mx\":1900}] '\n . 'Items: ' . json_encode($payload, JSON_UNESCAPED_UNICODE);\n\n try {\n $response = Http::timeout(90)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => (string) config('services.deepseek.model', 'deepseek-chat'),\n 'messages' => [['role' => 'user', 'content' => $prompt]],\n 'temperature' => 0,\n 'max_tokens' => 8192,\n 'user' => 'Qimta_Platform',\n ]);\n\n if (! $response->successful()) {\n Log::warning('PriceAnalysisService: DeepSeek range request failed.', ['status' => $response->status()]);\n return [];\n }\n\n return $this->parseRanges($response->json('choices.0.message.content') ?? '');\n } catch (\\Throwable $e) {\n Log::error('PriceAnalysisService: Exception fetching ranges.', ['message' => $e->getMessage()]);\n return [];\n }\n }\n\n /**\n * @return array<int, array{min:float, avg:float, max:float}>\n */\n private function parseRanges(string $text): array\n {\n $text = preg_replace('/^```json\\s*/i', '', trim($text));\n $text = preg_replace('/```\\s*$/i', '', $text);\n\n $data = json_decode($text, true);\n\n if (! is_array($data)) {\n preg_match_all(\n '/\\{\\s*\"i\"\\s*:\\s*(\\d+)\\s*,\\s*\"mn\"\\s*:\\s*([\\d.]+)\\s*,\\s*\"av\"\\s*:\\s*([\\d.]+)\\s*,\\s*\"mx\"\\s*:\\s*([\\d.]+)\\s*\\}/',\n $text,\n $matches,\n PREG_SET_ORDER\n );\n $data = array_map(fn($m) => [\n 'i' => (int) $m[1],\n 'mn' => (float) $m[2],\n 'av' => (float) $m[3],\n 'mx' => (float) $m[4],\n ], $matches);\n }\n\n if (! is_array($data)) {\n return [];\n }\n\n $ranges = [];\n foreach ($data as $entry) {\n $idx = $entry['i'] ?? null;\n if (! is_int($idx) && ! (is_string($idx) && ctype_digit($idx))) {\n continue;\n }\n $mn = is_numeric($entry['mn'] ?? null) ? (float) $entry['mn'] : null;\n $av = is_numeric($entry['av'] ?? null) ? (float) $entry['av'] : null;\n $mx = is_numeric($entry['mx'] ?? null) ? (float) $entry['mx'] : null;\n\n if ($mn === null || $av === null || $mx === null || $mn <= 0 || $mx <= 0) {\n continue;\n }\n\n // Guard against the model returning them out of order.\n $sorted = [$mn, $av, $mx];\n sort($sorted);\n\n $ranges[(int) $idx] = [\n 'min' => round($sorted[0], 2),\n 'avg' => round($sorted[1], 2),\n 'max' => round($sorted[2], 2),\n ];\n }\n\n return $ranges;\n }\n\n // -------------------------------------------------------------------------\n // Helpers\n // -------------------------------------------------------------------------\n\n /**\n * A normalized grouping key: lower-cased, whitespace-collapsed description + unit.\n * Empty when there is no description to compare on.\n */\n private function normalizeKey(array $item): string\n {\n $desc = mb_strtolower(trim((string) ($item['description'] ?? '')));\n $desc = preg_replace('/\\s+/u', ' ', $desc);\n\n if ($desc === '') {\n return '';\n }\n\n $unit = mb_strtolower(trim((string) ($item['unit'] ?? '')));\n\n return $desc . '|' . $unit;\n }\n\n /** Short human label for a finding message. */\n private function label(array $item): string\n {\n $desc = trim((string) ($item['description'] ?? ''));\n return $desc !== '' ? mb_substr($desc, 0, 60) : '—';\n }\n}\n"}, "61f9b1587699bc38d19f087d7b90074121d30a70": {"t": 1785748349.4228656, "n": 13577, "h": "5e2c1d48343f7804", "c": "<?php\n\nnamespace App\\Services;\n\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\n\n/**\n * Third-pass quotation analysis.\n *\n * After PricingService produces unit prices, this service inspects the WHOLE set\n * of priced items and surfaces problems the per-item passes cannot see, plus a\n * market price range per item. Nothing here mutates prices — it only reports.\n *\n * It produces findings keyed by a stable code so the UI can style and translate\n * each one:\n *\n * - PRICE_INCONSISTENCY: rows that describe the same item carry different unit\n * prices. One of them is probably wrong.\n *\n * Duplication and VAT are validated EARLIER, at BOQ-upload time\n * (see BoqValidationService), so they are intentionally not repeated here.\n *\n * The market range (min / avg / max unit price per item) comes from a fresh\n * DeepSeek query, mirroring PricingService's batched-parallel call shape.\n */\nclass PriceAnalysisService\n{\n /** Saudi standard VAT rate. Mirrors the rate hard-coded in the pricing blade. */\n public const VAT_RATE = 0.15;\n\n /** Items per DeepSeek range call; kept small so calls run in parallel. */\n private const CHUNK_SIZE = 10;\n\n /**\n * How long a market range stays reusable.\n *\n * Matches the price and verdict caches, so everything the user sees about\n * one product expires together rather than in pieces.\n */\n private const RANGE_CACHE_DAYS = 7;\n\n /**\n * Run the pricing-stage analysis over a set of already-priced items.\n *\n * @param array<int, array<string, mixed>> $items\n * @return array{\n * findings: list<array{code:string, severity:string, message:string, rows:list<int>}>,\n * ranges: array<int, array{min:float, avg:float, max:float}>,\n * summary: array{subtotal:float, vat:float, total:float}\n * }\n */\n public function analyze(array $items): array\n {\n return [\n 'findings' => array_values($this->detectPriceInconsistencies($items)),\n 'ranges' => $this->fetchMarketRanges($items),\n 'summary' => $this->computeSummary($items),\n ];\n }\n\n // -------------------------------------------------------------------------\n // PRICE_INCONSISTENCY\n // -------------------------------------------------------------------------\n\n /**\n * Rows that describe the same item (same normalized key) but carry different\n * unit prices. Rows without a numeric price are ignored.\n *\n * @param array<int, array<string, mixed>> $items\n * @return list<array{code:string, severity:string, message:string, rows:list<int>}>\n */\n private function detectPriceInconsistencies(array $items): array\n {\n $groups = [];\n foreach ($items as $index => $item) {\n if (($item['price_status'] ?? 'pending') === 'rejected') {\n continue;\n }\n $price = $item['unit_price'] ?? null;\n if (! is_numeric($price)) {\n continue;\n }\n $key = $this->normalizeKey($item);\n if ($key === '') {\n continue;\n }\n $groups[$key][$index] = round((float) $price, 2);\n }\n\n $findings = [];\n foreach ($groups as $priced) {\n $distinct = array_unique(array_values($priced));\n if (count($distinct) < 2) {\n continue;\n }\n\n $rows = array_keys($priced);\n $findings[] = [\n 'code' => 'PRICE_INCONSISTENCY',\n 'severity' => 'danger',\n 'message' => __('app.analysis_inconsistency_msg', [\n 'desc' => $this->label($items[$rows[0]]),\n 'min' => number_format(min($distinct), 2),\n 'max' => number_format(max($distinct), 2),\n ]),\n 'rows' => $rows,\n ];\n }\n\n return $findings;\n }\n\n // -------------------------------------------------------------------------\n // Summary\n // -------------------------------------------------------------------------\n\n /**\n * Recompute subtotal / VAT / total from non-rejected priced rows.\n *\n * @param array<int, array<string, mixed>> $items\n * @return array{subtotal:float, vat:float, total:float}\n */\n private function computeSummary(array $items): array\n {\n $subtotal = 0.0;\n foreach ($items as $item) {\n if (($item['price_status'] ?? 'pending') === 'rejected') {\n continue;\n }\n $price = $item['unit_price'] ?? null;\n if (! is_numeric($price)) {\n continue;\n }\n $subtotal += (float) $price * (float) ($item['quantity'] ?? 0);\n }\n\n $vat = round($subtotal * self::VAT_RATE, 2);\n\n return [\n 'subtotal' => round($subtotal, 2),\n 'vat' => $vat,\n 'total' => round($subtotal + $vat, 2),\n ];\n }\n\n // -------------------------------------------------------------------------\n // Market range (min / avg / max) via DeepSeek\n // -------------------------------------------------------------------------\n\n /**\n * Ask DeepSeek for a realistic Saudi market unit-price range per priced item.\n *\n * @param array<int, array<string, mixed>> $items\n * @return array<int, array{min:float, avg:float, max:float}> Keyed by item index.\n */\n private function fetchMarketRanges(array $items): array\n {\n $indices = [];\n foreach ($items as $index => $item) {\n if (($item['price_status'] ?? 'pending') === 'rejected') {\n continue;\n }\n if (is_numeric($item['unit_price'] ?? null) && (float) $item['unit_price'] > 0) {\n $indices[] = $index;\n }\n }\n\n if (empty($indices)) {\n return [];\n }\n\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n Log::warning('PriceAnalysisService: DEEPSEEK_API_KEY not configured; skipping market ranges.');\n return [];\n }\n\n // Reuse ranges already looked up for these products.\n //\n // This runs again on every approve/reject click, so a user working\n // through a quotation re-asked the AI for the same market ranges over\n // and over — and got slightly different numbers each time. The range is\n // a property of the product, not of the current price, so it is safe to\n // keep and safe to share across quotations.\n $ranges = [];\n $pending = [];\n\n foreach ($indices as $index) {\n $cached = Cache::get($this->rangeCacheKey($items[$index]));\n\n if (is_array($cached)) {\n $ranges[$index] = $cached;\n continue;\n }\n\n $pending[] = $index;\n }\n\n foreach (array_chunk($pending, self::CHUNK_SIZE) as $chunk) {\n $fetched = $this->fetchRangeChunk($items, $chunk, $apiKey);\n\n foreach ($fetched as $index => $range) {\n Cache::put(\n $this->rangeCacheKey($items[$index]),\n $range,\n now()->addDays(self::RANGE_CACHE_DAYS),\n );\n }\n\n $ranges += $fetched;\n }\n\n return $ranges;\n }\n\n /**\n * Cache key for one product's market range.\n *\n * Keyed on what the model is actually asked about — description, category,\n * brand and unit — and deliberately not on the current price, which does not\n * change what the market charges.\n */\n private function rangeCacheKey(array $item): string\n {\n $signature = mb_strtolower(trim(preg_replace('/\\s+/u', ' ', (string) ($item['description'] ?? '')) ?? ''))\n . '|' . mb_strtolower(trim((string) ($item['category'] ?? '')))\n . '|' . mb_strtolower(trim((string) ($item['brand'] ?? '')))\n . '|' . mb_strtolower(trim((string) ($item['unit'] ?? '')));\n\n return 'price_range_' . hash('sha256', $signature);\n }\n\n /**\n * @param list<int> $indices\n * @return array<int, array{min:float, avg:float, max:float}>\n */\n private function fetchRangeChunk(array $items, array $indices, string $apiKey): array\n {\n $payload = [];\n foreach ($indices as $idx) {\n $payload[] = [\n 'i' => $idx,\n 'd' => mb_substr((string) ($items[$idx]['description'] ?? ''), 0, 80),\n 'cat' => mb_substr((string) ($items[$idx]['category'] ?? ''), 0, 30),\n 'br' => mb_substr((string) ($items[$idx]['brand'] ?? ''), 0, 30),\n 'unit' => mb_substr((string) ($items[$idx]['unit'] ?? ''), 0, 15),\n ];\n }\n\n $prompt = 'You are a procurement pricing expert for the Saudi Arabia construction and MEP materials market. '\n . 'For each item below, give a realistic CURRENT unit-price RANGE in SAR based on typical 2024-2026 Saudi '\n . 'supplier rates: the lowest (\"mn\"), the average (\"av\") and the highest (\"mx\") you would expect to see. '\n . 'RULES: '\n . '(1) All three must be positive numbers with mn <= av <= mx. '\n . '(2) Return ONLY a valid compact JSON array, NO whitespace/newlines between elements, no markdown, no extra keys. '\n . '(3) Each element must be exactly: {\"i\":<index>,\"mn\":<number>,\"av\":<number>,\"mx\":<number>} '\n . 'Example: [{\"i\":0,\"mn\":1200,\"av\":1500,\"mx\":1900}] '\n . 'Items: ' . json_encode($payload, JSON_UNESCAPED_UNICODE);\n\n try {\n $response = Http::timeout(90)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => (string) config('services.deepseek.model', 'deepseek-chat'),\n 'messages' => [['role' => 'user', 'content' => $prompt]],\n 'temperature' => 0,\n 'max_tokens' => 8192,\n 'user' => 'Qimta_Platform',\n ]);\n\n if (! $response->successful()) {\n Log::warning('PriceAnalysisService: DeepSeek range request failed.', ['status' => $response->status()]);\n return [];\n }\n\n return $this->parseRanges($response->json('choices.0.message.content') ?? '');\n } catch (\\Throwable $e) {\n Log::error('PriceAnalysisService: Exception fetching ranges.', ['message' => $e->getMessage()]);\n return [];\n }\n }\n\n /**\n * @return array<int, array{min:float, avg:float, max:float}>\n */\n private function parseRanges(string $text): array\n {\n $text = preg_replace('/^```json\\s*/i', '', trim($text));\n $text = preg_replace('/```\\s*$/i', '', $text);\n\n $data = json_decode($text, true);\n\n if (! is_array($data)) {\n preg_match_all(\n '/\\{\\s*\"i\"\\s*:\\s*(\\d+)\\s*,\\s*\"mn\"\\s*:\\s*([\\d.]+)\\s*,\\s*\"av\"\\s*:\\s*([\\d.]+)\\s*,\\s*\"mx\"\\s*:\\s*([\\d.]+)\\s*\\}/',\n $text,\n $matches,\n PREG_SET_ORDER\n );\n $data = array_map(fn($m) => [\n 'i' => (int) $m[1],\n 'mn' => (float) $m[2],\n 'av' => (float) $m[3],\n 'mx' => (float) $m[4],\n ], $matches);\n }\n\n if (! is_array($data)) {\n return [];\n }\n\n $ranges = [];\n foreach ($data as $entry) {\n $idx = $entry['i'] ?? null;\n if (! is_int($idx) && ! (is_string($idx) && ctype_digit($idx))) {\n continue;\n }\n $mn = is_numeric($entry['mn'] ?? null) ? (float) $entry['mn'] : null;\n $av = is_numeric($entry['av'] ?? null) ? (float) $entry['av'] : null;\n $mx = is_numeric($entry['mx'] ?? null) ? (float) $entry['mx'] : null;\n\n if ($mn === null || $av === null || $mx === null || $mn <= 0 || $mx <= 0) {\n continue;\n }\n\n // Guard against the model returning them out of order.\n $sorted = [$mn, $av, $mx];\n sort($sorted);\n\n $ranges[(int) $idx] = [\n 'min' => round($sorted[0], 2),\n 'avg' => round($sorted[1], 2),\n 'max' => round($sorted[2], 2),\n ];\n }\n\n return $ranges;\n }\n\n // -------------------------------------------------------------------------\n // Helpers\n // -------------------------------------------------------------------------\n\n /**\n * A normalized grouping key: lower-cased, whitespace-collapsed description + unit.\n * Empty when there is no description to compare on.\n */\n private function normalizeKey(array $item): string\n {\n $desc = mb_strtolower(trim((string) ($item['description'] ?? '')));\n $desc = preg_replace('/\\s+/u', ' ', $desc);\n\n if ($desc === '') {\n return '';\n }\n\n $unit = mb_strtolower(trim((string) ($item['unit'] ?? '')));\n\n return $desc . '|' . $unit;\n }\n\n /** Short human label for a finding message. */\n private function label(array $item): string\n {\n $desc = trim((string) ($item['description'] ?? ''));\n return $desc !== '' ? mb_substr($desc, 0, 60) : '—';\n }\n}\n"}, "ea6c7a169184892a12edda05c08c8a5b44d31c5d": {"t": 1785748351.1370747, "n": 11510, "h": "f2e102b846cf4867", "c": "<?php\n\nnamespace App\\Services;\n\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\n\n/**\n * Third-pass quotation analysis.\n *\n * After PricingService produces unit prices, this service inspects the WHOLE set\n * of priced items and surfaces problems the per-item passes cannot see, plus a\n * market price range per item. Nothing here mutates prices — it only reports.\n *\n * It produces findings keyed by a stable code so the UI can style and translate\n * each one:\n *\n * - PRICE_INCONSISTENCY: rows that describe the same item carry different unit\n * prices. One of them is probably wrong.\n *\n * Duplication and VAT are validated EARLIER, at BOQ-upload time\n * (see BoqValidationService), so they are intentionally not repeated here.\n *\n * The market range (min / avg / max unit price per item) comes from a fresh\n * DeepSeek query, mirroring PricingService's batched-parallel call shape.\n */\nclass PriceAnalysisService\n{\n /** Saudi standard VAT rate. Mirrors the rate hard-coded in the pricing blade. */\n public const VAT_RATE = 0.15;\n\n /** Items per DeepSeek range call; kept small so calls run in parallel. */\n private const CHUNK_SIZE = 10;\n\n /**\n * Run the pricing-stage analysis over a set of already-priced items.\n *\n * @param array<int, array<string, mixed>> $items\n * @return array{\n * findings: list<array{code:string, severity:string, message:string, rows:list<int>}>,\n * ranges: array<int, array{min:float, avg:float, max:float}>,\n * summary: array{subtotal:float, vat:float, total:float}\n * }\n */\n public function analyze(array $items): array\n {\n return [\n 'findings' => array_values($this->detectPriceInconsistencies($items)),\n 'ranges' => $this->fetchMarketRanges($items),\n 'summary' => $this->computeSummary($items),\n ];\n }\n\n // -------------------------------------------------------------------------\n // PRICE_INCONSISTENCY\n // -------------------------------------------------------------------------\n\n /**\n * Rows that describe the same item (same normalized key) but carry different\n * unit prices. Rows without a numeric price are ignored.\n *\n * @param array<int, array<string, mixed>> $items\n * @return list<array{code:string, severity:string, message:string, rows:list<int>}>\n */\n private function detectPriceInconsistencies(array $items): array\n {\n $groups = [];\n foreach ($items as $index => $item) {\n if (($item['price_status'] ?? 'pending') === 'rejected') {\n continue;\n }\n $price = $item['unit_price'] ?? null;\n if (! is_numeric($price)) {\n continue;\n }\n $key = $this->normalizeKey($item);\n if ($key === '') {\n continue;\n }\n $groups[$key][$index] = round((float) $price, 2);\n }\n\n $findings = [];\n foreach ($groups as $priced) {\n $distinct = array_unique(array_values($priced));\n if (count($distinct) < 2) {\n continue;\n }\n\n $rows = array_keys($priced);\n $findings[] = [\n 'code' => 'PRICE_INCONSISTENCY',\n 'severity' => 'danger',\n 'message' => __('app.analysis_inconsistency_msg', [\n 'desc' => $this->label($items[$rows[0]]),\n 'min' => number_format(min($distinct), 2),\n 'max' => number_format(max($distinct), 2),\n ]),\n 'rows' => $rows,\n ];\n }\n\n return $findings;\n }\n\n // -------------------------------------------------------------------------\n // Summary\n // -------------------------------------------------------------------------\n\n /**\n * Recompute subtotal / VAT / total from non-rejected priced rows.\n *\n * @param array<int, array<string, mixed>> $items\n * @return array{subtotal:float, vat:float, total:float}\n */\n private function computeSummary(array $items): array\n {\n $subtotal = 0.0;\n foreach ($items as $item) {\n if (($item['price_status'] ?? 'pending') === 'rejected') {\n continue;\n }\n $price = $item['unit_price'] ?? null;\n if (! is_numeric($price)) {\n continue;\n }\n $subtotal += (float) $price * (float) ($item['quantity'] ?? 0);\n }\n\n $vat = round($subtotal * self::VAT_RATE, 2);\n\n return [\n 'subtotal' => round($subtotal, 2),\n 'vat' => $vat,\n 'total' => round($subtotal + $vat, 2),\n ];\n }\n\n // -------------------------------------------------------------------------\n // Market range (min / avg / max) via DeepSeek\n // -------------------------------------------------------------------------\n\n /**\n * Ask DeepSeek for a realistic Saudi market unit-price range per priced item.\n *\n * @param array<int, array<string, mixed>> $items\n * @return array<int, array{min:float, avg:float, max:float}> Keyed by item index.\n */\n private function fetchMarketRanges(array $items): array\n {\n $indices = [];\n foreach ($items as $index => $item) {\n if (($item['price_status'] ?? 'pending') === 'rejected') {\n continue;\n }\n if (is_numeric($item['unit_price'] ?? null) && (float) $item['unit_price'] > 0) {\n $indices[] = $index;\n }\n }\n\n if (empty($indices)) {\n return [];\n }\n\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n Log::warning('PriceAnalysisService: DEEPSEEK_API_KEY not configured; skipping market ranges.');\n return [];\n }\n\n $ranges = [];\n foreach (array_chunk($indices, self::CHUNK_SIZE) as $chunk) {\n $ranges += $this->fetchRangeChunk($items, $chunk, $apiKey);\n }\n\n return $ranges;\n }\n\n /**\n * @param list<int> $indices\n * @return array<int, array{min:float, avg:float, max:float}>\n */\n private function fetchRangeChunk(array $items, array $indices, string $apiKey): array\n {\n $payload = [];\n foreach ($indices as $idx) {\n $payload[] = [\n 'i' => $idx,\n 'd' => mb_substr((string) ($items[$idx]['description'] ?? ''), 0, 80),\n 'cat' => mb_substr((string) ($items[$idx]['category'] ?? ''), 0, 30),\n 'br' => mb_substr((string) ($items[$idx]['brand'] ?? ''), 0, 30),\n 'unit' => mb_substr((string) ($items[$idx]['unit'] ?? ''), 0, 15),\n ];\n }\n\n $prompt = 'You are a procurement pricing expert for the Saudi Arabia construction and MEP materials market. '\n . 'For each item below, give a realistic CURRENT unit-price RANGE in SAR based on typical 2024-2026 Saudi '\n . 'supplier rates: the lowest (\"mn\"), the average (\"av\") and the highest (\"mx\") you would expect to see. '\n . 'RULES: '\n . '(1) All three must be positive numbers with mn <= av <= mx. '\n . '(2) Return ONLY a valid compact JSON array, NO whitespace/newlines between elements, no markdown, no extra keys. '\n . '(3) Each element must be exactly: {\"i\":<index>,\"mn\":<number>,\"av\":<number>,\"mx\":<number>} '\n . 'Example: [{\"i\":0,\"mn\":1200,\"av\":1500,\"mx\":1900}] '\n . 'Items: ' . json_encode($payload, JSON_UNESCAPED_UNICODE);\n\n try {\n $response = Http::timeout(90)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => (string) config('services.deepseek.model', 'deepseek-chat'),\n 'messages' => [['role' => 'user', 'content' => $prompt]],\n 'temperature' => 0,\n 'max_tokens' => 8192,\n 'user' => 'Qimta_Platform',\n ]);\n\n if (! $response->successful()) {\n Log::warning('PriceAnalysisService: DeepSeek range request failed.', ['status' => $response->status()]);\n return [];\n }\n\n return $this->parseRanges($response->json('choices.0.message.content') ?? '');\n } catch (\\Throwable $e) {\n Log::error('PriceAnalysisService: Exception fetching ranges.', ['message' => $e->getMessage()]);\n return [];\n }\n }\n\n /**\n * @return array<int, array{min:float, avg:float, max:float}>\n */\n private function parseRanges(string $text): array\n {\n $text = preg_replace('/^```json\\s*/i', '', trim($text));\n $text = preg_replace('/```\\s*$/i', '', $text);\n\n $data = json_decode($text, true);\n\n if (! is_array($data)) {\n preg_match_all(\n '/\\{\\s*\"i\"\\s*:\\s*(\\d+)\\s*,\\s*\"mn\"\\s*:\\s*([\\d.]+)\\s*,\\s*\"av\"\\s*:\\s*([\\d.]+)\\s*,\\s*\"mx\"\\s*:\\s*([\\d.]+)\\s*\\}/',\n $text,\n $matches,\n PREG_SET_ORDER\n );\n $data = array_map(fn($m) => [\n 'i' => (int) $m[1],\n 'mn' => (float) $m[2],\n 'av' => (float) $m[3],\n 'mx' => (float) $m[4],\n ], $matches);\n }\n\n if (! is_array($data)) {\n return [];\n }\n\n $ranges = [];\n foreach ($data as $entry) {\n $idx = $entry['i'] ?? null;\n if (! is_int($idx) && ! (is_string($idx) && ctype_digit($idx))) {\n continue;\n }\n $mn = is_numeric($entry['mn'] ?? null) ? (float) $entry['mn'] : null;\n $av = is_numeric($entry['av'] ?? null) ? (float) $entry['av'] : null;\n $mx = is_numeric($entry['mx'] ?? null) ? (float) $entry['mx'] : null;\n\n if ($mn === null || $av === null || $mx === null || $mn <= 0 || $mx <= 0) {\n continue;\n }\n\n // Guard against the model returning them out of order.\n $sorted = [$mn, $av, $mx];\n sort($sorted);\n\n $ranges[(int) $idx] = [\n 'min' => round($sorted[0], 2),\n 'avg' => round($sorted[1], 2),\n 'max' => round($sorted[2], 2),\n ];\n }\n\n return $ranges;\n }\n\n // -------------------------------------------------------------------------\n // Helpers\n // -------------------------------------------------------------------------\n\n /**\n * A normalized grouping key: lower-cased, whitespace-collapsed description + unit.\n * Empty when there is no description to compare on.\n */\n private function normalizeKey(array $item): string\n {\n $desc = mb_strtolower(trim((string) ($item['description'] ?? '')));\n $desc = preg_replace('/\\s+/u', ' ', $desc);\n\n if ($desc === '') {\n return '';\n }\n\n $unit = mb_strtolower(trim((string) ($item['unit'] ?? '')));\n\n return $desc . '|' . $unit;\n }\n\n /** Short human label for a finding message. */\n private function label(array $item): string\n {\n $desc = trim((string) ($item['description'] ?? ''));\n return $desc !== '' ? mb_substr($desc, 0, 60) : '—';\n }\n}\n"}, "1c80976405e15ef42f9881511c01c48fcb4daf79": {"t": 1785748352.2400446, "n": 11512, "h": "3a05161e071b3242", "c": "<?php\n\nnamespace App\\Services;\n\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\n\n/**\n * Third-pass quotation analysis.\n *\n * After PricingService produces unit prices, this service inspects the WHOLE set\n * of priced items and surfaces problems the per-item passes cannot see, plus a\n * market price range per item. Nothing here mutates prices — it only reports.\n *\n * It produces findings keyed by a stable code so the UI can style and translate\n * each one:\n *\n * - PRICE_INCONSISTENCY: rows that describe the same item carry different unit\n * prices. One of them is probably wrong.\n *\n * Duplication and VAT are validated EARLIER, at BOQ-upload time\n * (see BoqValidationService), so they are intentionally not repeated here.\n *\n * The market range (min / avg / max unit price per item) comes from a fresh\n * DeepSeek query, mirroring PricingService's batched-parallel call shape.\n */\nclass PriceAnalysisService\n{\n /** Saudi standard VAT rate. Mirrors the rate hard-coded in the pricing blade. */\n public const VAT_RATE = 0.15;\n\n /** Items per DeepSeek range call; kept small so calls run in parallel. */\n private const CHUNK_SIZE = 10;\n\n /**\n * Run the pricing-stage analysis over a set of already-priced items.\n *\n * @param array<int, array<string, mixed>> $items\n * @return array{\n * findings: list<array{code:string, severity:string, message:string, rows:list<int>}>,\n * ranges: array<int, array{min:float, avg:float, max:float}>,\n * summary: array{subtotal:float, vat:float, total:float}\n * }\n */\n public function analyze(array $items): array\n {\n return [\n 'findings' => array_values($this->detectPriceInconsistencies($items)),\n 'ranges' => $this->fetchMarketRanges($items),\n 'summary' => $this->computeSummary($items),\n ];\n }\n\n // -------------------------------------------------------------------------\n // PRICE_INCONSISTENCY\n // -------------------------------------------------------------------------\n\n /**\n * Rows that describe the same item (same normalized key) but carry different\n * unit prices. Rows without a numeric price are ignored.\n *\n * @param array<int, array<string, mixed>> $items\n * @return list<array{code:string, severity:string, message:string, rows:list<int>}>\n */\n private function detectPriceInconsistencies(array $items): array\n {\n $groups = [];\n foreach ($items as $index => $item) {\n if (($item['price_status'] ?? 'pending') === 'rejected') {\n continue;\n }\n $price = $item['unit_price'] ?? null;\n if (! is_numeric($price)) {\n continue;\n }\n $key = $this->normalizeKey($item);\n if ($key === '') {\n continue;\n }\n $groups[$key][$index] = round((float) $price, 2);\n }\n\n $findings = [];\n foreach ($groups as $priced) {\n $distinct = array_unique(array_values($priced));\n if (count($distinct) < 2) {\n continue;\n }\n\n $rows = array_keys($priced);\n $findings[] = [\n 'code' => 'PRICE_INCONSISTENCY',\n 'severity' => 'danger',\n 'message' => __('app.analysis_inconsistency_msg', [\n 'desc' => $this->label($items[$rows[0]]),\n 'min' => number_format(min($distinct), 2),\n 'max' => number_format(max($distinct), 2),\n ]),\n 'rows' => $rows,\n ];\n }\n\n return $findings;\n }\n\n // -------------------------------------------------------------------------\n // Summary\n // -------------------------------------------------------------------------\n\n /**\n * Recompute subtotal / VAT / total from non-rejected priced rows.\n *\n * @param array<int, array<string, mixed>> $items\n * @return array{subtotal:float, vat:float, total:float}\n */\n private function computeSummary(array $items): array\n {\n $subtotal = 0.0;\n foreach ($items as $item) {\n if (($item['price_status'] ?? 'pending') === 'rejected') {\n continue;\n }\n $price = $item['unit_price'] ?? null;\n if (! is_numeric($price)) {\n continue;\n }\n $subtotal += (float) $price * (float) ($item['quantity'] ?? 0);\n }\n\n $vat = round($subtotal * self::VAT_RATE, 2);\n\n return [\n 'subtotal' => round($subtotal, 2),\n 'vat' => $vat,\n 'total' => round($subtotal + $vat, 2),\n ];\n }\n\n // -------------------------------------------------------------------------\n // Market range (min / avg / max) via DeepSeek\n // -------------------------------------------------------------------------\n\n /**\n * Ask DeepSeek for a realistic Saudi market unit-price range per priced item.\n *\n * @param array<int, array<string, mixed>> $items\n * @return array<int, array{min:float, avg:float, max:float}> Keyed by item index.\n */\n private function fetchMarketRanges(array $items): array\n {\n $indices = [];\n foreach ($items as $index => $item) {\n if (($item['price_status'] ?? 'pending') === 'rejected') {\n continue;\n }\n if (is_numeric($item['unit_price'] ?? null) && (float) $item['unit_price'] > 0) {\n $indices[] = $index;\n }\n }\n\n if (empty($indices)) {\n return [];\n }\n\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n Log::warning('PriceAnalysisService: DEEPSEEK_API_KEY not configured; skipping market ranges.');\n return [];\n }\n\n $ranges = [];\n foreach (array_chunk($indices, self::CHUNK_SIZE) as $chunk) {\n $ranges += $this->fetchRangeChunk($items, $chunk, $apiKey);\n }\n\n return $ranges;\n }\n\n /**\n * @param list<int> $indices\n * @return array<int, array{min:float, avg:float, max:float}>\n */\n private function fetchRangeChunk(array $items, array $indices, string $apiKey): array\n {\n $payload = [];\n foreach ($indices as $idx) {\n $payload[] = [\n 'i' => $idx,\n 'd' => mb_substr((string) ($items[$idx]['description'] ?? ''), 0, 80),\n 'cat' => mb_substr((string) ($items[$idx]['category'] ?? ''), 0, 30),\n 'br' => mb_substr((string) ($items[$idx]['brand'] ?? ''), 0, 30),\n 'unit' => mb_substr((string) ($items[$idx]['unit'] ?? ''), 0, 15),\n ];\n }\n\n $prompt = 'You are a procurement pricing expert for the Saudi Arabia construction and MEP materials market. '\n . 'For each item below, give a realistic CURRENT unit-price RANGE in SAR based on typical 2024-2026 Saudi '\n . 'supplier rates: the lowest (\"mn\"), the average (\"av\") and the highest (\"mx\") you would expect to see. '\n . 'RULES: '\n . '(1) All three must be positive numbers with mn <= av <= mx. '\n . '(2) Return ONLY a valid compact JSON array, NO whitespace/newlines between elements, no markdown, no extra keys. '\n . '(3) Each element must be exactly: {\"i\":<index>,\"mn\":<number>,\"av\":<number>,\"mx\":<number>} '\n . 'Example: [{\"i\":0,\"mn\":1200,\"av\":1500,\"mx\":1900}] '\n . 'Items: ' . json_encode($payload, JSON_UNESCAPED_UNICODE);\n\n try {\n $response = Http::timeout(90)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => (string) config('services.deepseek.model', 'deepseek-chat'),\n 'messages' => [['role' => 'user', 'content' => $prompt]],\n 'temperature' => 0.2,\n 'max_tokens' => 8192,\n 'user' => 'Qimta_Platform',\n ]);\n\n if (! $response->successful()) {\n Log::warning('PriceAnalysisService: DeepSeek range request failed.', ['status' => $response->status()]);\n return [];\n }\n\n return $this->parseRanges($response->json('choices.0.message.content') ?? '');\n } catch (\\Throwable $e) {\n Log::error('PriceAnalysisService: Exception fetching ranges.', ['message' => $e->getMessage()]);\n return [];\n }\n }\n\n /**\n * @return array<int, array{min:float, avg:float, max:float}>\n */\n private function parseRanges(string $text): array\n {\n $text = preg_replace('/^```json\\s*/i', '', trim($text));\n $text = preg_replace('/```\\s*$/i', '', $text);\n\n $data = json_decode($text, true);\n\n if (! is_array($data)) {\n preg_match_all(\n '/\\{\\s*\"i\"\\s*:\\s*(\\d+)\\s*,\\s*\"mn\"\\s*:\\s*([\\d.]+)\\s*,\\s*\"av\"\\s*:\\s*([\\d.]+)\\s*,\\s*\"mx\"\\s*:\\s*([\\d.]+)\\s*\\}/',\n $text,\n $matches,\n PREG_SET_ORDER\n );\n $data = array_map(fn($m) => [\n 'i' => (int) $m[1],\n 'mn' => (float) $m[2],\n 'av' => (float) $m[3],\n 'mx' => (float) $m[4],\n ], $matches);\n }\n\n if (! is_array($data)) {\n return [];\n }\n\n $ranges = [];\n foreach ($data as $entry) {\n $idx = $entry['i'] ?? null;\n if (! is_int($idx) && ! (is_string($idx) && ctype_digit($idx))) {\n continue;\n }\n $mn = is_numeric($entry['mn'] ?? null) ? (float) $entry['mn'] : null;\n $av = is_numeric($entry['av'] ?? null) ? (float) $entry['av'] : null;\n $mx = is_numeric($entry['mx'] ?? null) ? (float) $entry['mx'] : null;\n\n if ($mn === null || $av === null || $mx === null || $mn <= 0 || $mx <= 0) {\n continue;\n }\n\n // Guard against the model returning them out of order.\n $sorted = [$mn, $av, $mx];\n sort($sorted);\n\n $ranges[(int) $idx] = [\n 'min' => round($sorted[0], 2),\n 'avg' => round($sorted[1], 2),\n 'max' => round($sorted[2], 2),\n ];\n }\n\n return $ranges;\n }\n\n // -------------------------------------------------------------------------\n // Helpers\n // -------------------------------------------------------------------------\n\n /**\n * A normalized grouping key: lower-cased, whitespace-collapsed description + unit.\n * Empty when there is no description to compare on.\n */\n private function normalizeKey(array $item): string\n {\n $desc = mb_strtolower(trim((string) ($item['description'] ?? '')));\n $desc = preg_replace('/\\s+/u', ' ', $desc);\n\n if ($desc === '') {\n return '';\n }\n\n $unit = mb_strtolower(trim((string) ($item['unit'] ?? '')));\n\n return $desc . '|' . $unit;\n }\n\n /** Short human label for a finding message. */\n private function label(array $item): string\n {\n $desc = trim((string) ($item['description'] ?? ''));\n return $desc !== '' ? mb_substr($desc, 0, 60) : '—';\n }\n}\n"}, "6bbe61ec72f324bcdd4b15b5136fa271f9eb004f": {"t": 1785748353.3665688, "n": 14623, "h": "0a369564ee746c25", "c": "<?php\n\nnamespace App\\Services;\n\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\n\n/**\n * Third-pass quotation analysis.\n *\n * After PricingService produces unit prices, this service inspects the WHOLE set\n * of priced items and surfaces problems the per-item passes cannot see, plus a\n * market price range per item. Nothing here mutates prices — it only reports.\n *\n * It produces four kinds of finding, keyed by a stable code so the UI can style\n * and translate each one:\n *\n * - DUPLICATION : two or more rows describe the same item (near-identical\n * description + unit). A likely double-count in the BOQ.\n * - PRICE_INCONSISTENCY: rows that describe the same item carry different unit\n * prices. One of them is probably wrong.\n * - VAT_MISMATCH : the 15% VAT the caller computed does not match a fresh\n * recomputation from the non-rejected line items.\n *\n * The market range (min / avg / max unit price per item) comes from a fresh\n * DeepSeek query, mirroring PricingService's batched-parallel call shape.\n */\nclass PriceAnalysisService\n{\n /** Saudi standard VAT rate. Mirrors the rate hard-coded in the pricing blade. */\n public const VAT_RATE = 0.15;\n\n /** Absolute SAR tolerance when comparing the caller's VAT to a recomputation. */\n private const VAT_TOLERANCE = 0.5;\n\n /** Items per DeepSeek range call; kept small so calls run in parallel. */\n private const CHUNK_SIZE = 10;\n\n /**\n * Run every analysis over a set of already-priced items.\n *\n * @param array<int, array<string, mixed>> $items\n * @param float|null $reportedVat The VAT figure the caller displays, to be checked.\n * @return array{\n * findings: list<array{code:string, severity:string, message:string, rows:list<int>}>,\n * ranges: array<int, array{min:float, avg:float, max:float}>,\n * summary: array{subtotal:float, vat:float, total:float}\n * }\n */\n public function analyze(array $items, ?float $reportedVat = null): array\n {\n $findings = [];\n\n $findings = array_merge($findings, $this->detectDuplicates($items));\n $findings = array_merge($findings, $this->detectPriceInconsistencies($items));\n\n $summary = $this->computeSummary($items);\n\n if ($reportedVat !== null) {\n $vatFinding = $this->checkVat($reportedVat, $summary['vat']);\n if ($vatFinding !== null) {\n $findings[] = $vatFinding;\n }\n }\n\n return [\n 'findings' => array_values($findings),\n 'ranges' => $this->fetchMarketRanges($items),\n 'summary' => $summary,\n ];\n }\n\n // -------------------------------------------------------------------------\n // DUPLICATION\n // -------------------------------------------------------------------------\n\n /**\n * Group rows by a normalized (description + unit) key; any key with more than\n * one non-rejected row is a suspected duplicate.\n *\n * @param array<int, array<string, mixed>> $items\n * @return list<array{code:string, severity:string, message:string, rows:list<int>}>\n */\n private function detectDuplicates(array $items): array\n {\n $groups = [];\n foreach ($items as $index => $item) {\n if (($item['price_status'] ?? 'pending') === 'rejected') {\n continue;\n }\n $key = $this->normalizeKey($item);\n if ($key === '') {\n continue;\n }\n $groups[$key][] = $index;\n }\n\n $findings = [];\n foreach ($groups as $rows) {\n if (count($rows) < 2) {\n continue;\n }\n $findings[] = [\n 'code' => 'DUPLICATION',\n 'severity' => 'warning',\n 'message' => trans_choice(\n 'app.analysis_duplication_msg',\n count($rows),\n ['count' => count($rows), 'desc' => $this->label($items[$rows[0]])]\n ),\n 'rows' => $rows,\n ];\n }\n\n return $findings;\n }\n\n // -------------------------------------------------------------------------\n // PRICE_INCONSISTENCY\n // -------------------------------------------------------------------------\n\n /**\n * Rows that describe the same item (same normalized key) but carry different\n * unit prices. Rows without a numeric price are ignored.\n *\n * @param array<int, array<string, mixed>> $items\n * @return list<array{code:string, severity:string, message:string, rows:list<int>}>\n */\n private function detectPriceInconsistencies(array $items): array\n {\n $groups = [];\n foreach ($items as $index => $item) {\n if (($item['price_status'] ?? 'pending') === 'rejected') {\n continue;\n }\n $price = $item['unit_price'] ?? null;\n if (! is_numeric($price)) {\n continue;\n }\n $key = $this->normalizeKey($item);\n if ($key === '') {\n continue;\n }\n $groups[$key][$index] = round((float) $price, 2);\n }\n\n $findings = [];\n foreach ($groups as $priced) {\n $distinct = array_unique(array_values($priced));\n if (count($distinct) < 2) {\n continue;\n }\n\n $rows = array_keys($priced);\n $findings[] = [\n 'code' => 'PRICE_INCONSISTENCY',\n 'severity' => 'danger',\n 'message' => __('app.analysis_inconsistency_msg', [\n 'desc' => $this->label($items[$rows[0]]),\n 'min' => number_format(min($distinct), 2),\n 'max' => number_format(max($distinct), 2),\n ]),\n 'rows' => $rows,\n ];\n }\n\n return $findings;\n }\n\n // -------------------------------------------------------------------------\n // VAT review\n // -------------------------------------------------------------------------\n\n /**\n * Compare the caller's reported VAT against a fresh recomputation.\n * Returns a finding only when they diverge beyond tolerance.\n *\n * @return array{code:string, severity:string, message:string, rows:list<int>}|null\n */\n private function checkVat(float $reportedVat, float $expectedVat): ?array\n {\n if (abs($reportedVat - $expectedVat) <= self::VAT_TOLERANCE) {\n return null;\n }\n\n return [\n 'code' => 'VAT_MISMATCH',\n 'severity' => 'danger',\n 'message' => __('app.analysis_vat_mismatch_msg', [\n 'reported' => number_format($reportedVat, 2),\n 'expected' => number_format($expectedVat, 2),\n ]),\n 'rows' => [],\n ];\n }\n\n /**\n * Recompute subtotal / VAT / total from non-rejected priced rows.\n *\n * @param array<int, array<string, mixed>> $items\n * @return array{subtotal:float, vat:float, total:float}\n */\n private function computeSummary(array $items): array\n {\n $subtotal = 0.0;\n foreach ($items as $item) {\n if (($item['price_status'] ?? 'pending') === 'rejected') {\n continue;\n }\n $price = $item['unit_price'] ?? null;\n if (! is_numeric($price)) {\n continue;\n }\n $subtotal += (float) $price * (float) ($item['quantity'] ?? 0);\n }\n\n $vat = round($subtotal * self::VAT_RATE, 2);\n\n return [\n 'subtotal' => round($subtotal, 2),\n 'vat' => $vat,\n 'total' => round($subtotal + $vat, 2),\n ];\n }\n\n // -------------------------------------------------------------------------\n // Market range (min / avg / max) via DeepSeek\n // -------------------------------------------------------------------------\n\n /**\n * Ask DeepSeek for a realistic Saudi market unit-price range per priced item.\n *\n * @param array<int, array<string, mixed>> $items\n * @return array<int, array{min:float, avg:float, max:float}> Keyed by item index.\n */\n private function fetchMarketRanges(array $items): array\n {\n $indices = [];\n foreach ($items as $index => $item) {\n if (($item['price_status'] ?? 'pending') === 'rejected') {\n continue;\n }\n if (is_numeric($item['unit_price'] ?? null) && (float) $item['unit_price'] > 0) {\n $indices[] = $index;\n }\n }\n\n if (empty($indices)) {\n return [];\n }\n\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n Log::warning('PriceAnalysisService: DEEPSEEK_API_KEY not configured; skipping market ranges.');\n return [];\n }\n\n $ranges = [];\n foreach (array_chunk($indices, self::CHUNK_SIZE) as $chunk) {\n $ranges += $this->fetchRangeChunk($items, $chunk, $apiKey);\n }\n\n return $ranges;\n }\n\n /**\n * @param list<int> $indices\n * @return array<int, array{min:float, avg:float, max:float}>\n */\n private function fetchRangeChunk(array $items, array $indices, string $apiKey): array\n {\n $payload = [];\n foreach ($indices as $idx) {\n $payload[] = [\n 'i' => $idx,\n 'd' => mb_substr((string) ($items[$idx]['description'] ?? ''), 0, 80),\n 'cat' => mb_substr((string) ($items[$idx]['category'] ?? ''), 0, 30),\n 'br' => mb_substr((string) ($items[$idx]['brand'] ?? ''), 0, 30),\n 'unit' => mb_substr((string) ($items[$idx]['unit'] ?? ''), 0, 15),\n ];\n }\n\n $prompt = 'You are a procurement pricing expert for the Saudi Arabia construction and MEP materials market. '\n . 'For each item below, give a realistic CURRENT unit-price RANGE in SAR based on typical 2024-2026 Saudi '\n . 'supplier rates: the lowest (\"mn\"), the average (\"av\") and the highest (\"mx\") you would expect to see. '\n . 'RULES: '\n . '(1) All three must be positive numbers with mn <= av <= mx. '\n . '(2) Return ONLY a valid compact JSON array, NO whitespace/newlines between elements, no markdown, no extra keys. '\n . '(3) Each element must be exactly: {\"i\":<index>,\"mn\":<number>,\"av\":<number>,\"mx\":<number>} '\n . 'Example: [{\"i\":0,\"mn\":1200,\"av\":1500,\"mx\":1900}] '\n . 'Items: ' . json_encode($payload, JSON_UNESCAPED_UNICODE);\n\n try {\n $response = Http::timeout(90)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => (string) config('services.deepseek.model', 'deepseek-chat'),\n 'messages' => [['role' => 'user', 'content' => $prompt]],\n 'temperature' => 0.2,\n 'max_tokens' => 8192,\n 'user' => 'Qimta_Platform',\n ]);\n\n if (! $response->successful()) {\n Log::warning('PriceAnalysisService: DeepSeek range request failed.', ['status' => $response->status()]);\n return [];\n }\n\n return $this->parseRanges($response->json('choices.0.message.content') ?? '');\n } catch (\\Throwable $e) {\n Log::error('PriceAnalysisService: Exception fetching ranges.', ['message' => $e->getMessage()]);\n return [];\n }\n }\n\n /**\n * @return array<int, array{min:float, avg:float, max:float}>\n */\n private function parseRanges(string $text): array\n {\n $text = preg_replace('/^```json\\s*/i', '', trim($text));\n $text = preg_replace('/```\\s*$/i', '', $text);\n\n $data = json_decode($text, true);\n\n if (! is_array($data)) {\n preg_match_all(\n '/\\{\\s*\"i\"\\s*:\\s*(\\d+)\\s*,\\s*\"mn\"\\s*:\\s*([\\d.]+)\\s*,\\s*\"av\"\\s*:\\s*([\\d.]+)\\s*,\\s*\"mx\"\\s*:\\s*([\\d.]+)\\s*\\}/',\n $text,\n $matches,\n PREG_SET_ORDER\n );\n $data = array_map(fn($m) => [\n 'i' => (int) $m[1],\n 'mn' => (float) $m[2],\n 'av' => (float) $m[3],\n 'mx' => (float) $m[4],\n ], $matches);\n }\n\n if (! is_array($data)) {\n return [];\n }\n\n $ranges = [];\n foreach ($data as $entry) {\n $idx = $entry['i'] ?? null;\n if (! is_int($idx) && ! (is_string($idx) && ctype_digit($idx))) {\n continue;\n }\n $mn = is_numeric($entry['mn'] ?? null) ? (float) $entry['mn'] : null;\n $av = is_numeric($entry['av'] ?? null) ? (float) $entry['av'] : null;\n $mx = is_numeric($entry['mx'] ?? null) ? (float) $entry['mx'] : null;\n\n if ($mn === null || $av === null || $mx === null || $mn <= 0 || $mx <= 0) {\n continue;\n }\n\n // Guard against the model returning them out of order.\n $sorted = [$mn, $av, $mx];\n sort($sorted);\n\n $ranges[(int) $idx] = [\n 'min' => round($sorted[0], 2),\n 'avg' => round($sorted[1], 2),\n 'max' => round($sorted[2], 2),\n ];\n }\n\n return $ranges;\n }\n\n // -------------------------------------------------------------------------\n // Helpers\n // -------------------------------------------------------------------------\n\n /**\n * A normalized grouping key: lower-cased, whitespace-collapsed description + unit.\n * Empty when there is no description to compare on.\n */\n private function normalizeKey(array $item): string\n {\n $desc = mb_strtolower(trim((string) ($item['description'] ?? '')));\n $desc = preg_replace('/\\s+/u', ' ', $desc);\n\n if ($desc === '') {\n return '';\n }\n\n $unit = mb_strtolower(trim((string) ($item['unit'] ?? '')));\n\n return $desc . '|' . $unit;\n }\n\n /** Short human label for a finding message. */\n private function label(array $item): string\n {\n $desc = trim((string) ($item['description'] ?? ''));\n return $desc !== '' ? mb_substr($desc, 0, 60) : '—';\n }\n}\n"}}, "app/Services/PricingService.php": {"9a5ada4ab1ceb0de386bc1ce99c561f8869c09d7": {"t": 1785745788.8965852, "n": 24430, "h": "08188f969b82d466", "c": "<?php\n\nnamespace App\\Services;\n\nuse App\\Models\\Product;\nuse App\\Models\\QuotationItem;\nuse Illuminate\\Http\\Client\\Pool;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\nuse App\\Support\\AiCache;\n\nclass PricingService\n{\n /**\n * Items per DeepSeek call. Multiple calls are made in PARALLEL so ALL unpriced items get a price faster.\n * Smaller = more parallel requests = faster overall. 10 items per request is optimal.\n */\n private const DEEPSEEK_CHUNK_SIZE = 10;\n\n /**\n * Concurrent AI requests per pool batch.\n *\n * Http::pool sends everything it is handed simultaneously, so an unbounded\n * pool on a large BOQ opens thousands of connections at once and gets\n * rate-limited. Batching keeps the parallelism useful without that.\n */\n private const MAX_PARALLEL_CHUNKS = 4;\n\n /**\n * How long an AI price stays reusable.\n *\n * Shorter than QuotationRequest::EXPIRY_DAYS (10) on purpose: a quotation\n * that has expired must re-price against genuinely current rates, so the\n * cache has to have lapsed by the time the quotation does.\n */\n private const PRICE_CACHE_DAYS = 7;\n\n /**\n * Optional progress reporter, called as ($chunksDone, $chunksTotal).\n *\n * @var (callable(int, int): void)|null\n */\n private $onProgress = null;\n\n /** @param callable(int, int): void $callback */\n public function onProgress(callable $callback): self\n {\n $this->onProgress = $callback;\n return $this;\n }\n\n private function reportProgress(int $done, int $total): void\n {\n if ($this->onProgress !== null) {\n ($this->onProgress)($done, $total);\n }\n }\n\n /**\n * Fetch unit prices for an array of quotation items.\n *\n * Strategy:\n * 1. Try the products table first (keyword match on name + optional category match).\n * 2. Collect all unmatched items and send them in ONE batched DeepSeek call.\n *\n * @param array<int, array<string, mixed>> $items\n * @return array<int, array<string, mixed>> Items enriched with unit_price, price_source, price_status\n */\n /**\n * Build the cache key for one row's price.\n *\n * Keyed on the product itself — description and unit — not the quotation, so\n * the same item priced from two different BOQs agrees with itself. The unit\n * is part of the key because a price per metre and a price per piece are\n * different numbers for the same product.\n */\n private function priceCacheKey(array $item): string\n {\n $description = mb_strtolower(trim(preg_replace('/\\s+/u', ' ', (string) ($item['description'] ?? '')) ?? ''));\n $unit = mb_strtolower(trim((string) ($item['unit'] ?? '')));\n\n return 'ai_price_' . hash('sha256', $description . '|' . $unit);\n }\n\n /**\n * Remember the AI's prices so a re-run returns the same numbers.\n *\n * @param array<int, array<string, mixed>> $items\n * @param list<int> $indices rows this pass priced\n */\n private function cachePrices(array $items, array $indices): void\n {\n foreach ($indices as $index) {\n $item = $items[$index] ?? null;\n\n if (! $item || empty($item['unit_price']) || $item['unit_price'] <= 0) {\n continue;\n }\n\n AiCache::store()->put(\n $this->priceCacheKey($item),\n [\n 'unit_price' => (float) $item['unit_price'],\n 'price_source' => $item['price_source'] ?? 'ai',\n ],\n now()->addDays(self::PRICE_CACHE_DAYS),\n );\n }\n }\n\n /**\n * The most recent price this product was quoted at, if it is still fresh.\n *\n * Searched before the AI is asked, so a product already priced on another\n * quotation keeps that price instead of being re-estimated at a different\n * figure. Only rows priced within PRICE_CACHE_DAYS count: past that the\n * market may genuinely have moved, and a stale number is worse than a\n * current one.\n *\n * Matching ignores case and collapses whitespace — \"Wooden Office Desk\" and\n * \"wooden office desk\" are the same product — but nothing more. A size or\n * spec in the description makes it a different product, and pricing it from\n * a shorter description would quote the wrong item.\n */\n private function lookupRecentQuotationPrice(array $item): ?float\n {\n $description = trim(preg_replace('/\\s+/u', ' ', (string) ($item['description'] ?? '')) ?? '');\n $unit = trim((string) ($item['unit'] ?? ''));\n\n if ($description === '') {\n return null;\n }\n\n // LOWER() on both sides rather than relying on the collation, which\n // differs between servers and would make this behave inconsistently.\n $query = QuotationItem::query()\n ->whereRaw('LOWER(TRIM(description)) = ?', [mb_strtolower($description)])\n ->whereNotNull('unit_price')\n ->where('unit_price', '>', 0)\n ->where('updated_at', '>=', now()->subDays(self::PRICE_CACHE_DAYS));\n\n // Same product measured differently is a different price, so the unit\n // has to agree when the row carries one.\n if ($unit !== '') {\n $query->whereHas('unit', function ($q) use ($unit) {\n $q->whereRaw('LOWER(TRIM(name)) = ?', [mb_strtolower($unit)])\n ->orWhereRaw('LOWER(TRIM(symbol)) = ?', [mb_strtolower($unit)]);\n });\n }\n\n // Newest first: if the product was re-priced, that is the figure to use.\n $price = $query->orderByDesc('updated_at')->value('unit_price');\n\n return $price !== null ? (float) $price : null;\n }\n\n public function fetchPrices(array $items): array\n {\n $unmatched = [];\n\n foreach ($items as $index => $item) {\n // Skip already-priced rows (e.g., re-fetch after partial approval)\n if (! empty($item['unit_price'])) {\n continue;\n }\n\n $price = $this->lookupProductsTable($item);\n\n if ($price !== null) {\n $items[$index]['unit_price'] = $price;\n $items[$index]['price_source'] = 'products';\n $items[$index]['price_status'] = 'pending';\n\n continue;\n }\n\n // Reuse a recent AI price for the same product.\n //\n // Without this, re-pricing the same BOQ produced different numbers\n // every time: the model is sampled at a non-zero temperature, so it\n // does not return an identical figure twice. A client seeing two\n // prices for one line on the same day has no reason to trust either.\n //\n // Held for PRICE_CACHE_DAYS, deliberately shorter than the\n // quotation's own expiry window, so a re-price after expiry does\n // fetch genuinely current rates.\n $cached = AiCache::store()->get($this->priceCacheKey($item));\n\n if (is_array($cached) && isset($cached['unit_price'])) {\n $items[$index]['unit_price'] = (float) $cached['unit_price'];\n $items[$index]['price_source'] = $cached['price_source'] ?? 'ai_cached';\n $items[$index]['price_status'] = 'pending';\n\n continue;\n }\n\n // Then what this product was priced at on a previous quotation.\n //\n // The cache above can expire or be flushed, and when it did the same\n // product went back to the AI and came back at a different figure.\n // The quotation rows are permanent, so they answer the question the\n // cache was only remembering: what did we quote this at recently?\n $recent = $this->lookupRecentQuotationPrice($item);\n\n if ($recent !== null) {\n $items[$index]['unit_price'] = $recent;\n $items[$index]['price_source'] = 'previous_quotation';\n $items[$index]['price_status'] = 'pending';\n\n // Re-warm the cache so the rest of this run does not re-query.\n AiCache::store()->put(\n $this->priceCacheKey($item),\n ['unit_price' => $recent, 'price_source' => 'previous_quotation'],\n now()->addDays(self::PRICE_CACHE_DAYS),\n );\n\n continue;\n }\n\n $items[$index]['unit_price'] = null;\n $items[$index]['price_source'] = null;\n $items[$index]['price_status'] = 'pending';\n $unmatched[] = $index;\n }\n\n if (! empty($unmatched)) {\n $chunks = array_chunk($unmatched, self::DEEPSEEK_CHUNK_SIZE);\n\n if (count($chunks) === 1) {\n // Single chunk — direct call (no pool overhead)\n $items = $this->enrichWithDeepSeek($items, $chunks[0]);\n $this->cachePrices($items, $chunks[0]);\n } else {\n // Multiple chunks. Http::pool fires every request it is given at\n // once, so a large BOQ would open thousands of concurrent calls\n // and be rate-limited into mass failure. Run the pool in batches\n // instead: still parallel, but bounded.\n $batches = array_chunk($chunks, self::MAX_PARALLEL_CHUNKS);\n $total = count($chunks);\n $done = 0;\n\n foreach ($batches as $batch) {\n $items = $this->enrichChunksParallel($items, $batch);\n\n $this->cachePrices($items, array_merge(...$batch));\n\n $done += count($batch);\n $this->reportProgress($done, $total);\n }\n }\n }\n\n return $items;\n }\n\n // -------------------------------------------------------------------------\n // Products table lookup\n // -------------------------------------------------------------------------\n\n /**\n * Search the products table for a matching product by keyword.\n * Returns the unit_price when a confident match is found, null otherwise.\n */\n private function lookupProductsTable(array $item): ?float\n {\n $description = trim((string) ($item['description'] ?? ''));\n\n if (empty($description)) {\n return null;\n }\n\n // Extract significant words (≥ 3 chars)\n $words = array_values(array_filter(\n explode(' ', preg_replace('/[^a-zA-Z0-9\\s]/', ' ', $description)),\n fn(string $w) => strlen($w) >= 3\n ));\n\n if (empty($words)) {\n return null;\n }\n\n $query = Product::query()\n ->whereNotNull('unit_price')\n ->where('unit_price', '>', 0)\n ->where('active', true);\n\n // Require up to 4 keywords to appear in the product name (AND logic)\n foreach (array_slice($words, 0, 4) as $word) {\n $query->where('name', 'like', '%' . $word . '%');\n }\n\n // Narrow by category name if available\n $category = trim((string) ($item['category'] ?? ''));\n if ($category !== '') {\n $query->whereHas('category', fn($q) => $q->where('name', 'like', '%' . $category . '%'));\n }\n\n // CRITICAL: only accept a price whose UNIT matches the item's unit.\n // A product priced per m² must never be used to price an item measured in m³ —\n // that would return a real number that is the wrong \"unit price\".\n $unit = $this->normalizeUnit((string) ($item['unit'] ?? ''));\n if ($unit === '') {\n // No unit on the item → we cannot guarantee the price is per the correct unit.\n // Fall through to the AI estimator, which is told the unit explicitly.\n return null;\n }\n\n $query->whereHas('unit', function ($q) use ($unit) {\n $q->whereRaw('LOWER(TRIM(name)) = ?', [$unit])\n ->orWhereRaw('LOWER(TRIM(symbol)) = ?', [$unit]);\n });\n\n // Ordered explicitly. Without an ORDER BY, MySQL is free to return any\n // of the matching rows, and it does not always pick the same one — so a\n // keyword that matched several products priced the item differently on\n // different runs, while items with a single match stayed stable. That is\n // exactly the pattern that showed up: most rows identical, a few moving.\n //\n // Lowest price wins the tie, and the id breaks a tie on price, so the\n // result is both deterministic and defensible to a client.\n $product = $query->select('unit_price')\n ->orderBy('unit_price')\n ->orderBy('id')\n ->first();\n\n return $product ? (float) $product->unit_price : null;\n }\n\n /**\n * Canonicalize a free-text unit string so equivalent spellings match.\n * e.g. \"م³\", \"م3\", \"م^3\", \"M3\", \"متر مكعب\", \"cubic meter\" → \"م3\".\n * Returns '' when the input is empty.\n */\n private function normalizeUnit(string $unit): string\n {\n $u = trim(mb_strtolower($unit));\n if ($u === '') {\n return '';\n }\n\n // Normalize superscripts and notation: م³ / م^3 / م 3 → م3\n $u = strtr($u, ['²' => '2', '³' => '3', '^' => '', '.' => '']);\n $u = preg_replace('/\\s+/', '', $u);\n\n // Map common synonyms to a single canonical token.\n static $map = [\n 'متر مكعب' => 'م3', 'مترمكعب' => 'م3', 'cubicmeter' => 'م3', 'cubicmetre' => 'م3', 'cbm' => 'م3', 'm3' => 'م3',\n 'متر مربع' => 'م2', 'مترمربع' => 'م2', 'squaremeter' => 'م2', 'squaremetre' => 'م2', 'sqm' => 'م2', 'm2' => 'م2',\n 'متر طولي' => 'مط', 'مترطولي' => 'مط', 'linearmeter' => 'مط', 'lm' => 'مط',\n 'متر' => 'م', 'meter' => 'م', 'metre' => 'م',\n 'عدد' => 'عدد', 'piece' => 'عدد', 'pcs' => 'عدد', 'pc' => 'عدد', 'no' => 'عدد', 'nos' => 'عدد', 'unit' => 'عدد',\n 'كيلو' => 'كجم', 'كيلوغرام' => 'كجم', 'كيلوجرام' => 'كجم', 'kg' => 'كجم', 'kilogram' => 'كجم',\n 'طن' => 'طن', 'ton' => 'طن', 'tonne' => 'طن',\n 'لتر' => 'لتر', 'liter' => 'لتر', 'litre' => 'لتر', 'l' => 'لتر',\n 'كيس' => 'كيس', 'bag' => 'كيس', 'bags' => 'كيس',\n ];\n\n return $map[$u] ?? $u;\n }\n\n // -------------------------------------------------------------------------\n // DeepSeek fallback — helpers\n // -------------------------------------------------------------------------\n\n /**\n * Build the compact payload sent to DeepSeek for a set of item indices.\n */\n private function buildDeepSeekPayload(array $items, array $indices): array\n {\n $payload = [];\n foreach ($indices as $idx) {\n $payload[] = [\n 'i' => $idx,\n // 400 chars: BOQ lines routinely carry the specs that decide the\n // price (size, grade, rating), and the old 80-char cut dropped them.\n 'd' => mb_substr((string) ($items[$idx]['description'] ?? ''), 0, 400),\n 'cat' => mb_substr((string) ($items[$idx]['category'] ?? ''), 0, 30),\n 'br' => mb_substr((string) ($items[$idx]['brand'] ?? ''), 0, 30),\n 'unit' => mb_substr((string) ($items[$idx]['unit'] ?? ''), 0, 15),\n ];\n }\n return $payload;\n }\n\n /**\n * Build the DeepSeek prompt string for a given payload array.\n */\n private function buildPricingPrompt(array $payload): string\n {\n return 'You are a procurement pricing expert for the Saudi Arabia construction and MEP materials market. '\n . 'Estimate a realistic current unit price in SAR for each item below. '\n . 'RULES: '\n . '(1) Always return a positive non-zero price — use your best market estimate, never 0. '\n . '(2) Base prices on typical Saudi supplier/contractor rates for 2024-2026. '\n . '(3) Return ONLY a valid compact JSON array with NO whitespace or newlines between elements. '\n . '(4) Each element must be exactly: {\"i\":<index>,\"p\":<price_number>} '\n . '(5) No markdown, no explanation, no extra keys, no pretty-printing. '\n . 'Example output: [{\"i\":0,\"p\":1500},{\"i\":1,\"p\":350}] '\n . 'Items: ' . json_encode($payload, JSON_UNESCAPED_UNICODE);\n }\n\n /**\n * Parse a DeepSeek text response and apply prices to $items.\n */\n private function applyDeepSeekText(string $text, array $items, string $model): array\n {\n $text = preg_replace('/^```json\\s*/i', '', trim($text));\n $text = preg_replace('/```\\s*$/i', '', $text);\n\n $priceData = json_decode($text, true);\n\n if (! is_array($priceData)) {\n preg_match_all('/\\{\\s*\"i\"\\s*:\\s*(\\d+)\\s*,\\s*\"p\"\\s*:\\s*([\\d.]+)\\s*\\}/', $text, $matches, PREG_SET_ORDER);\n if (! empty($matches)) {\n $priceData = array_map(fn($m) => ['i' => (int) $m[1], 'p' => (float) $m[2]], $matches);\n Log::info('PricingService: Partial extraction recovered ' . count($priceData) . ' price(s).');\n }\n }\n\n if (! is_array($priceData) || empty($priceData)) {\n Log::warning('PricingService: Could not extract any prices from DeepSeek response.', [\n 'model' => $model,\n 'text_preview' => mb_substr($text, 0, 300),\n ]);\n return $items;\n }\n\n foreach ($priceData as $entry) {\n $idx = $entry['i'] ?? null;\n $price = $entry['p'] ?? null;\n\n if ($idx === null || ! array_key_exists($idx, $items)) {\n continue;\n }\n\n $price = is_numeric($price) ? (float) $price : 0.0;\n\n if ($price > 0) {\n $items[$idx]['unit_price'] = $price;\n $items[$idx]['price_source'] = 'deepseek';\n }\n }\n\n return $items;\n }\n\n /**\n * Send multiple chunks to DeepSeek in PARALLEL using Http::pool().\n * Falls back to sequential per-chunk calls if pool fails.\n *\n * @param array<int, array<string, mixed>> $items\n * @param list<list<int>> $chunks\n * @return array<int, array<string, mixed>>\n */\n private function enrichChunksParallel(array $items, array $chunks): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n $model = (string) config('services.deepseek.model', 'deepseek-chat');\n\n if (empty($apiKey)) {\n Log::warning('PricingService: DEEPSEEK_API_KEY not configured; skipping AI pricing.');\n return $items;\n }\n\n $url = 'https://api.deepseek.com/chat/completions';\n\n // Build request bodies once\n $requestBodies = [];\n foreach ($chunks as $ci => $chunkIndices) {\n $payload = $this->buildDeepSeekPayload($items, $chunkIndices);\n $requestBodies[$ci] = [\n 'model' => $model,\n 'messages' => [\n ['role' => 'user', 'content' => $this->buildPricingPrompt($payload)],\n ],\n 'temperature' => 0,\n 'max_tokens' => 8192,\n 'user' => 'Qimta_Platform',\n ];\n }\n\n $failedChunks = array_keys($chunks);\n\n // Single attempt with the configured model\n if (! empty($failedChunks)) {\n $pendingIndices = $failedChunks;\n $failedChunks = [];\n\n try {\n $responses = Http::pool(function (Pool $pool) use ($url, $apiKey, $requestBodies, $pendingIndices) {\n $reqs = [];\n foreach ($pendingIndices as $ci) {\n $reqs[] = $pool->as((string) $ci)\n ->timeout(90)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post($url, $requestBodies[$ci]);\n }\n return $reqs;\n });\n\n foreach ($responses as $key => $response) {\n $ci = (int) $key;\n\n if (! $response->successful()) {\n Log::warning('PricingService: Parallel chunk failed, will retry sequentially.', [\n 'model' => $model,\n 'chunk' => $ci,\n 'status' => $response->status(),\n ]);\n $failedChunks[] = $ci;\n continue;\n }\n\n $text = $response->json('choices.0.message.content') ?? '';\n $items = $this->applyDeepSeekText($text, $items, $model);\n\n // Released as soon as it is parsed. max_tokens is 8192, and\n // Guzzle buffers each response in full — holding a whole\n // pool's worth is what exhausted the memory limit.\n unset($responses[$key], $response, $text);\n }\n\n unset($responses);\n } catch (\\Throwable $e) {\n Log::error('PricingService: Http::pool exception, falling back to serial.', [\n 'model' => $model,\n 'message' => $e->getMessage(),\n ]);\n foreach ($pendingIndices as $ci) {\n $items = $this->enrichWithDeepSeek($items, $chunks[$ci]);\n }\n return $items;\n }\n }\n\n if (! empty($failedChunks)) {\n foreach ($failedChunks as $ci) {\n $items = $this->enrichWithDeepSeek($items, $chunks[$ci]);\n }\n }\n\n return $items;\n }\n\n // -------------------------------------------------------------------------\n // DeepSeek fallback (single batched request)\n // -------------------------------------------------------------------------\n\n /**\n * Send all unmatched items to DeepSeek in ONE request to minimise token consumption.\n * Only the fields DeepSeek actually needs are sent (description, category, brand, unit).\n * Output tokens are capped via max_tokens to protect against runaway usage.\n *\n * @param array<int, array<string, mixed>> $items\n * @param list<int> $unmatchedIndices\n * @return array<int, array<string, mixed>>\n */\n private function enrichWithDeepSeek(array $items, array $unmatchedIndices): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n $model = (string) config('services.deepseek.model', 'deepseek-chat');\n\n if (empty($apiKey)) {\n Log::warning('PricingService: DEEPSEEK_API_KEY not configured; skipping AI pricing.');\n return $items;\n }\n\n $payload = $this->buildDeepSeekPayload($items, $unmatchedIndices);\n $prompt = $this->buildPricingPrompt($payload);\n $lastStatus = null;\n\n try {\n $response = Http::timeout(90)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'user', 'content' => $prompt],\n ],\n 'temperature' => 0,\n 'max_tokens' => 8192,\n 'user' => 'Qimta_Platform',\n ]);\n\n if (! $response->successful()) {\n $lastStatus = $response->status();\n Log::warning('PricingService: DeepSeek request failed.', ['model' => $model, 'status' => $lastStatus]);\n } else {\n $text = $response->json('choices.0.message.content') ?? '';\n $items = $this->applyDeepSeekText($text, $items, $model);\n return $items;\n }\n } catch (\\Throwable $e) {\n Log::error('PricingService: Exception calling DeepSeek.', ['model' => $model, 'message' => $e->getMessage()]);\n }\n\n Log::error('PricingService: DeepSeek request failed.', ['last_status' => $lastStatus]);\n\n return $items;\n }\n}\n"}, "5371a106a464b64117d1d68542f7104d14568a63": {"t": 1785748347.0960226, "n": 24430, "h": "08188f969b82d466", "c": "<?php\n\nnamespace App\\Services;\n\nuse App\\Models\\Product;\nuse App\\Models\\QuotationItem;\nuse Illuminate\\Http\\Client\\Pool;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\nuse App\\Support\\AiCache;\n\nclass PricingService\n{\n /**\n * Items per DeepSeek call. Multiple calls are made in PARALLEL so ALL unpriced items get a price faster.\n * Smaller = more parallel requests = faster overall. 10 items per request is optimal.\n */\n private const DEEPSEEK_CHUNK_SIZE = 10;\n\n /**\n * Concurrent AI requests per pool batch.\n *\n * Http::pool sends everything it is handed simultaneously, so an unbounded\n * pool on a large BOQ opens thousands of connections at once and gets\n * rate-limited. Batching keeps the parallelism useful without that.\n */\n private const MAX_PARALLEL_CHUNKS = 4;\n\n /**\n * How long an AI price stays reusable.\n *\n * Shorter than QuotationRequest::EXPIRY_DAYS (10) on purpose: a quotation\n * that has expired must re-price against genuinely current rates, so the\n * cache has to have lapsed by the time the quotation does.\n */\n private const PRICE_CACHE_DAYS = 7;\n\n /**\n * Optional progress reporter, called as ($chunksDone, $chunksTotal).\n *\n * @var (callable(int, int): void)|null\n */\n private $onProgress = null;\n\n /** @param callable(int, int): void $callback */\n public function onProgress(callable $callback): self\n {\n $this->onProgress = $callback;\n return $this;\n }\n\n private function reportProgress(int $done, int $total): void\n {\n if ($this->onProgress !== null) {\n ($this->onProgress)($done, $total);\n }\n }\n\n /**\n * Fetch unit prices for an array of quotation items.\n *\n * Strategy:\n * 1. Try the products table first (keyword match on name + optional category match).\n * 2. Collect all unmatched items and send them in ONE batched DeepSeek call.\n *\n * @param array<int, array<string, mixed>> $items\n * @return array<int, array<string, mixed>> Items enriched with unit_price, price_source, price_status\n */\n /**\n * Build the cache key for one row's price.\n *\n * Keyed on the product itself — description and unit — not the quotation, so\n * the same item priced from two different BOQs agrees with itself. The unit\n * is part of the key because a price per metre and a price per piece are\n * different numbers for the same product.\n */\n private function priceCacheKey(array $item): string\n {\n $description = mb_strtolower(trim(preg_replace('/\\s+/u', ' ', (string) ($item['description'] ?? '')) ?? ''));\n $unit = mb_strtolower(trim((string) ($item['unit'] ?? '')));\n\n return 'ai_price_' . hash('sha256', $description . '|' . $unit);\n }\n\n /**\n * Remember the AI's prices so a re-run returns the same numbers.\n *\n * @param array<int, array<string, mixed>> $items\n * @param list<int> $indices rows this pass priced\n */\n private function cachePrices(array $items, array $indices): void\n {\n foreach ($indices as $index) {\n $item = $items[$index] ?? null;\n\n if (! $item || empty($item['unit_price']) || $item['unit_price'] <= 0) {\n continue;\n }\n\n AiCache::store()->put(\n $this->priceCacheKey($item),\n [\n 'unit_price' => (float) $item['unit_price'],\n 'price_source' => $item['price_source'] ?? 'ai',\n ],\n now()->addDays(self::PRICE_CACHE_DAYS),\n );\n }\n }\n\n /**\n * The most recent price this product was quoted at, if it is still fresh.\n *\n * Searched before the AI is asked, so a product already priced on another\n * quotation keeps that price instead of being re-estimated at a different\n * figure. Only rows priced within PRICE_CACHE_DAYS count: past that the\n * market may genuinely have moved, and a stale number is worse than a\n * current one.\n *\n * Matching ignores case and collapses whitespace — \"Wooden Office Desk\" and\n * \"wooden office desk\" are the same product — but nothing more. A size or\n * spec in the description makes it a different product, and pricing it from\n * a shorter description would quote the wrong item.\n */\n private function lookupRecentQuotationPrice(array $item): ?float\n {\n $description = trim(preg_replace('/\\s+/u', ' ', (string) ($item['description'] ?? '')) ?? '');\n $unit = trim((string) ($item['unit'] ?? ''));\n\n if ($description === '') {\n return null;\n }\n\n // LOWER() on both sides rather than relying on the collation, which\n // differs between servers and would make this behave inconsistently.\n $query = QuotationItem::query()\n ->whereRaw('LOWER(TRIM(description)) = ?', [mb_strtolower($description)])\n ->whereNotNull('unit_price')\n ->where('unit_price', '>', 0)\n ->where('updated_at', '>=', now()->subDays(self::PRICE_CACHE_DAYS));\n\n // Same product measured differently is a different price, so the unit\n // has to agree when the row carries one.\n if ($unit !== '') {\n $query->whereHas('unit', function ($q) use ($unit) {\n $q->whereRaw('LOWER(TRIM(name)) = ?', [mb_strtolower($unit)])\n ->orWhereRaw('LOWER(TRIM(symbol)) = ?', [mb_strtolower($unit)]);\n });\n }\n\n // Newest first: if the product was re-priced, that is the figure to use.\n $price = $query->orderByDesc('updated_at')->value('unit_price');\n\n return $price !== null ? (float) $price : null;\n }\n\n public function fetchPrices(array $items): array\n {\n $unmatched = [];\n\n foreach ($items as $index => $item) {\n // Skip already-priced rows (e.g., re-fetch after partial approval)\n if (! empty($item['unit_price'])) {\n continue;\n }\n\n $price = $this->lookupProductsTable($item);\n\n if ($price !== null) {\n $items[$index]['unit_price'] = $price;\n $items[$index]['price_source'] = 'products';\n $items[$index]['price_status'] = 'pending';\n\n continue;\n }\n\n // Reuse a recent AI price for the same product.\n //\n // Without this, re-pricing the same BOQ produced different numbers\n // every time: the model is sampled at a non-zero temperature, so it\n // does not return an identical figure twice. A client seeing two\n // prices for one line on the same day has no reason to trust either.\n //\n // Held for PRICE_CACHE_DAYS, deliberately shorter than the\n // quotation's own expiry window, so a re-price after expiry does\n // fetch genuinely current rates.\n $cached = AiCache::store()->get($this->priceCacheKey($item));\n\n if (is_array($cached) && isset($cached['unit_price'])) {\n $items[$index]['unit_price'] = (float) $cached['unit_price'];\n $items[$index]['price_source'] = $cached['price_source'] ?? 'ai_cached';\n $items[$index]['price_status'] = 'pending';\n\n continue;\n }\n\n // Then what this product was priced at on a previous quotation.\n //\n // The cache above can expire or be flushed, and when it did the same\n // product went back to the AI and came back at a different figure.\n // The quotation rows are permanent, so they answer the question the\n // cache was only remembering: what did we quote this at recently?\n $recent = $this->lookupRecentQuotationPrice($item);\n\n if ($recent !== null) {\n $items[$index]['unit_price'] = $recent;\n $items[$index]['price_source'] = 'previous_quotation';\n $items[$index]['price_status'] = 'pending';\n\n // Re-warm the cache so the rest of this run does not re-query.\n AiCache::store()->put(\n $this->priceCacheKey($item),\n ['unit_price' => $recent, 'price_source' => 'previous_quotation'],\n now()->addDays(self::PRICE_CACHE_DAYS),\n );\n\n continue;\n }\n\n $items[$index]['unit_price'] = null;\n $items[$index]['price_source'] = null;\n $items[$index]['price_status'] = 'pending';\n $unmatched[] = $index;\n }\n\n if (! empty($unmatched)) {\n $chunks = array_chunk($unmatched, self::DEEPSEEK_CHUNK_SIZE);\n\n if (count($chunks) === 1) {\n // Single chunk — direct call (no pool overhead)\n $items = $this->enrichWithDeepSeek($items, $chunks[0]);\n $this->cachePrices($items, $chunks[0]);\n } else {\n // Multiple chunks. Http::pool fires every request it is given at\n // once, so a large BOQ would open thousands of concurrent calls\n // and be rate-limited into mass failure. Run the pool in batches\n // instead: still parallel, but bounded.\n $batches = array_chunk($chunks, self::MAX_PARALLEL_CHUNKS);\n $total = count($chunks);\n $done = 0;\n\n foreach ($batches as $batch) {\n $items = $this->enrichChunksParallel($items, $batch);\n\n $this->cachePrices($items, array_merge(...$batch));\n\n $done += count($batch);\n $this->reportProgress($done, $total);\n }\n }\n }\n\n return $items;\n }\n\n // -------------------------------------------------------------------------\n // Products table lookup\n // -------------------------------------------------------------------------\n\n /**\n * Search the products table for a matching product by keyword.\n * Returns the unit_price when a confident match is found, null otherwise.\n */\n private function lookupProductsTable(array $item): ?float\n {\n $description = trim((string) ($item['description'] ?? ''));\n\n if (empty($description)) {\n return null;\n }\n\n // Extract significant words (≥ 3 chars)\n $words = array_values(array_filter(\n explode(' ', preg_replace('/[^a-zA-Z0-9\\s]/', ' ', $description)),\n fn(string $w) => strlen($w) >= 3\n ));\n\n if (empty($words)) {\n return null;\n }\n\n $query = Product::query()\n ->whereNotNull('unit_price')\n ->where('unit_price', '>', 0)\n ->where('active', true);\n\n // Require up to 4 keywords to appear in the product name (AND logic)\n foreach (array_slice($words, 0, 4) as $word) {\n $query->where('name', 'like', '%' . $word . '%');\n }\n\n // Narrow by category name if available\n $category = trim((string) ($item['category'] ?? ''));\n if ($category !== '') {\n $query->whereHas('category', fn($q) => $q->where('name', 'like', '%' . $category . '%'));\n }\n\n // CRITICAL: only accept a price whose UNIT matches the item's unit.\n // A product priced per m² must never be used to price an item measured in m³ —\n // that would return a real number that is the wrong \"unit price\".\n $unit = $this->normalizeUnit((string) ($item['unit'] ?? ''));\n if ($unit === '') {\n // No unit on the item → we cannot guarantee the price is per the correct unit.\n // Fall through to the AI estimator, which is told the unit explicitly.\n return null;\n }\n\n $query->whereHas('unit', function ($q) use ($unit) {\n $q->whereRaw('LOWER(TRIM(name)) = ?', [$unit])\n ->orWhereRaw('LOWER(TRIM(symbol)) = ?', [$unit]);\n });\n\n // Ordered explicitly. Without an ORDER BY, MySQL is free to return any\n // of the matching rows, and it does not always pick the same one — so a\n // keyword that matched several products priced the item differently on\n // different runs, while items with a single match stayed stable. That is\n // exactly the pattern that showed up: most rows identical, a few moving.\n //\n // Lowest price wins the tie, and the id breaks a tie on price, so the\n // result is both deterministic and defensible to a client.\n $product = $query->select('unit_price')\n ->orderBy('unit_price')\n ->orderBy('id')\n ->first();\n\n return $product ? (float) $product->unit_price : null;\n }\n\n /**\n * Canonicalize a free-text unit string so equivalent spellings match.\n * e.g. \"م³\", \"م3\", \"م^3\", \"M3\", \"متر مكعب\", \"cubic meter\" → \"م3\".\n * Returns '' when the input is empty.\n */\n private function normalizeUnit(string $unit): string\n {\n $u = trim(mb_strtolower($unit));\n if ($u === '') {\n return '';\n }\n\n // Normalize superscripts and notation: م³ / م^3 / م 3 → م3\n $u = strtr($u, ['²' => '2', '³' => '3', '^' => '', '.' => '']);\n $u = preg_replace('/\\s+/', '', $u);\n\n // Map common synonyms to a single canonical token.\n static $map = [\n 'متر مكعب' => 'م3', 'مترمكعب' => 'م3', 'cubicmeter' => 'م3', 'cubicmetre' => 'م3', 'cbm' => 'م3', 'm3' => 'م3',\n 'متر مربع' => 'م2', 'مترمربع' => 'م2', 'squaremeter' => 'م2', 'squaremetre' => 'م2', 'sqm' => 'م2', 'm2' => 'م2',\n 'متر طولي' => 'مط', 'مترطولي' => 'مط', 'linearmeter' => 'مط', 'lm' => 'مط',\n 'متر' => 'م', 'meter' => 'م', 'metre' => 'م',\n 'عدد' => 'عدد', 'piece' => 'عدد', 'pcs' => 'عدد', 'pc' => 'عدد', 'no' => 'عدد', 'nos' => 'عدد', 'unit' => 'عدد',\n 'كيلو' => 'كجم', 'كيلوغرام' => 'كجم', 'كيلوجرام' => 'كجم', 'kg' => 'كجم', 'kilogram' => 'كجم',\n 'طن' => 'طن', 'ton' => 'طن', 'tonne' => 'طن',\n 'لتر' => 'لتر', 'liter' => 'لتر', 'litre' => 'لتر', 'l' => 'لتر',\n 'كيس' => 'كيس', 'bag' => 'كيس', 'bags' => 'كيس',\n ];\n\n return $map[$u] ?? $u;\n }\n\n // -------------------------------------------------------------------------\n // DeepSeek fallback — helpers\n // -------------------------------------------------------------------------\n\n /**\n * Build the compact payload sent to DeepSeek for a set of item indices.\n */\n private function buildDeepSeekPayload(array $items, array $indices): array\n {\n $payload = [];\n foreach ($indices as $idx) {\n $payload[] = [\n 'i' => $idx,\n // 400 chars: BOQ lines routinely carry the specs that decide the\n // price (size, grade, rating), and the old 80-char cut dropped them.\n 'd' => mb_substr((string) ($items[$idx]['description'] ?? ''), 0, 400),\n 'cat' => mb_substr((string) ($items[$idx]['category'] ?? ''), 0, 30),\n 'br' => mb_substr((string) ($items[$idx]['brand'] ?? ''), 0, 30),\n 'unit' => mb_substr((string) ($items[$idx]['unit'] ?? ''), 0, 15),\n ];\n }\n return $payload;\n }\n\n /**\n * Build the DeepSeek prompt string for a given payload array.\n */\n private function buildPricingPrompt(array $payload): string\n {\n return 'You are a procurement pricing expert for the Saudi Arabia construction and MEP materials market. '\n . 'Estimate a realistic current unit price in SAR for each item below. '\n . 'RULES: '\n . '(1) Always return a positive non-zero price — use your best market estimate, never 0. '\n . '(2) Base prices on typical Saudi supplier/contractor rates for 2024-2026. '\n . '(3) Return ONLY a valid compact JSON array with NO whitespace or newlines between elements. '\n . '(4) Each element must be exactly: {\"i\":<index>,\"p\":<price_number>} '\n . '(5) No markdown, no explanation, no extra keys, no pretty-printing. '\n . 'Example output: [{\"i\":0,\"p\":1500},{\"i\":1,\"p\":350}] '\n . 'Items: ' . json_encode($payload, JSON_UNESCAPED_UNICODE);\n }\n\n /**\n * Parse a DeepSeek text response and apply prices to $items.\n */\n private function applyDeepSeekText(string $text, array $items, string $model): array\n {\n $text = preg_replace('/^```json\\s*/i', '', trim($text));\n $text = preg_replace('/```\\s*$/i', '', $text);\n\n $priceData = json_decode($text, true);\n\n if (! is_array($priceData)) {\n preg_match_all('/\\{\\s*\"i\"\\s*:\\s*(\\d+)\\s*,\\s*\"p\"\\s*:\\s*([\\d.]+)\\s*\\}/', $text, $matches, PREG_SET_ORDER);\n if (! empty($matches)) {\n $priceData = array_map(fn($m) => ['i' => (int) $m[1], 'p' => (float) $m[2]], $matches);\n Log::info('PricingService: Partial extraction recovered ' . count($priceData) . ' price(s).');\n }\n }\n\n if (! is_array($priceData) || empty($priceData)) {\n Log::warning('PricingService: Could not extract any prices from DeepSeek response.', [\n 'model' => $model,\n 'text_preview' => mb_substr($text, 0, 300),\n ]);\n return $items;\n }\n\n foreach ($priceData as $entry) {\n $idx = $entry['i'] ?? null;\n $price = $entry['p'] ?? null;\n\n if ($idx === null || ! array_key_exists($idx, $items)) {\n continue;\n }\n\n $price = is_numeric($price) ? (float) $price : 0.0;\n\n if ($price > 0) {\n $items[$idx]['unit_price'] = $price;\n $items[$idx]['price_source'] = 'deepseek';\n }\n }\n\n return $items;\n }\n\n /**\n * Send multiple chunks to DeepSeek in PARALLEL using Http::pool().\n * Falls back to sequential per-chunk calls if pool fails.\n *\n * @param array<int, array<string, mixed>> $items\n * @param list<list<int>> $chunks\n * @return array<int, array<string, mixed>>\n */\n private function enrichChunksParallel(array $items, array $chunks): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n $model = (string) config('services.deepseek.model', 'deepseek-chat');\n\n if (empty($apiKey)) {\n Log::warning('PricingService: DEEPSEEK_API_KEY not configured; skipping AI pricing.');\n return $items;\n }\n\n $url = 'https://api.deepseek.com/chat/completions';\n\n // Build request bodies once\n $requestBodies = [];\n foreach ($chunks as $ci => $chunkIndices) {\n $payload = $this->buildDeepSeekPayload($items, $chunkIndices);\n $requestBodies[$ci] = [\n 'model' => $model,\n 'messages' => [\n ['role' => 'user', 'content' => $this->buildPricingPrompt($payload)],\n ],\n 'temperature' => 0,\n 'max_tokens' => 8192,\n 'user' => 'Qimta_Platform',\n ];\n }\n\n $failedChunks = array_keys($chunks);\n\n // Single attempt with the configured model\n if (! empty($failedChunks)) {\n $pendingIndices = $failedChunks;\n $failedChunks = [];\n\n try {\n $responses = Http::pool(function (Pool $pool) use ($url, $apiKey, $requestBodies, $pendingIndices) {\n $reqs = [];\n foreach ($pendingIndices as $ci) {\n $reqs[] = $pool->as((string) $ci)\n ->timeout(90)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post($url, $requestBodies[$ci]);\n }\n return $reqs;\n });\n\n foreach ($responses as $key => $response) {\n $ci = (int) $key;\n\n if (! $response->successful()) {\n Log::warning('PricingService: Parallel chunk failed, will retry sequentially.', [\n 'model' => $model,\n 'chunk' => $ci,\n 'status' => $response->status(),\n ]);\n $failedChunks[] = $ci;\n continue;\n }\n\n $text = $response->json('choices.0.message.content') ?? '';\n $items = $this->applyDeepSeekText($text, $items, $model);\n\n // Released as soon as it is parsed. max_tokens is 8192, and\n // Guzzle buffers each response in full — holding a whole\n // pool's worth is what exhausted the memory limit.\n unset($responses[$key], $response, $text);\n }\n\n unset($responses);\n } catch (\\Throwable $e) {\n Log::error('PricingService: Http::pool exception, falling back to serial.', [\n 'model' => $model,\n 'message' => $e->getMessage(),\n ]);\n foreach ($pendingIndices as $ci) {\n $items = $this->enrichWithDeepSeek($items, $chunks[$ci]);\n }\n return $items;\n }\n }\n\n if (! empty($failedChunks)) {\n foreach ($failedChunks as $ci) {\n $items = $this->enrichWithDeepSeek($items, $chunks[$ci]);\n }\n }\n\n return $items;\n }\n\n // -------------------------------------------------------------------------\n // DeepSeek fallback (single batched request)\n // -------------------------------------------------------------------------\n\n /**\n * Send all unmatched items to DeepSeek in ONE request to minimise token consumption.\n * Only the fields DeepSeek actually needs are sent (description, category, brand, unit).\n * Output tokens are capped via max_tokens to protect against runaway usage.\n *\n * @param array<int, array<string, mixed>> $items\n * @param list<int> $unmatchedIndices\n * @return array<int, array<string, mixed>>\n */\n private function enrichWithDeepSeek(array $items, array $unmatchedIndices): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n $model = (string) config('services.deepseek.model', 'deepseek-chat');\n\n if (empty($apiKey)) {\n Log::warning('PricingService: DEEPSEEK_API_KEY not configured; skipping AI pricing.');\n return $items;\n }\n\n $payload = $this->buildDeepSeekPayload($items, $unmatchedIndices);\n $prompt = $this->buildPricingPrompt($payload);\n $lastStatus = null;\n\n try {\n $response = Http::timeout(90)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'user', 'content' => $prompt],\n ],\n 'temperature' => 0,\n 'max_tokens' => 8192,\n 'user' => 'Qimta_Platform',\n ]);\n\n if (! $response->successful()) {\n $lastStatus = $response->status();\n Log::warning('PricingService: DeepSeek request failed.', ['model' => $model, 'status' => $lastStatus]);\n } else {\n $text = $response->json('choices.0.message.content') ?? '';\n $items = $this->applyDeepSeekText($text, $items, $model);\n return $items;\n }\n } catch (\\Throwable $e) {\n Log::error('PricingService: Exception calling DeepSeek.', ['model' => $model, 'message' => $e->getMessage()]);\n }\n\n Log::error('PricingService: DeepSeek request failed.', ['last_status' => $lastStatus]);\n\n return $items;\n }\n}\n"}, "06c9320bf7fd9a0d5e611ec5a69092739739349f": {"t": 1785748348.3113337, "n": 21195, "h": "894e35717191de36", "c": "<?php\n\nnamespace App\\Services;\n\nuse App\\Models\\Product;\nuse Illuminate\\Http\\Client\\Pool;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\nuse App\\Support\\AiCache;\n\nclass PricingService\n{\n /**\n * Items per DeepSeek call. Multiple calls are made in PARALLEL so ALL unpriced items get a price faster.\n * Smaller = more parallel requests = faster overall. 10 items per request is optimal.\n */\n private const DEEPSEEK_CHUNK_SIZE = 10;\n\n /**\n * Concurrent AI requests per pool batch.\n *\n * Http::pool sends everything it is handed simultaneously, so an unbounded\n * pool on a large BOQ opens thousands of connections at once and gets\n * rate-limited. Batching keeps the parallelism useful without that.\n */\n private const MAX_PARALLEL_CHUNKS = 4;\n\n /**\n * How long an AI price stays reusable.\n *\n * Shorter than QuotationRequest::EXPIRY_DAYS (10) on purpose: a quotation\n * that has expired must re-price against genuinely current rates, so the\n * cache has to have lapsed by the time the quotation does.\n */\n private const PRICE_CACHE_DAYS = 7;\n\n /**\n * Optional progress reporter, called as ($chunksDone, $chunksTotal).\n *\n * @var (callable(int, int): void)|null\n */\n private $onProgress = null;\n\n /** @param callable(int, int): void $callback */\n public function onProgress(callable $callback): self\n {\n $this->onProgress = $callback;\n return $this;\n }\n\n private function reportProgress(int $done, int $total): void\n {\n if ($this->onProgress !== null) {\n ($this->onProgress)($done, $total);\n }\n }\n\n /**\n * Fetch unit prices for an array of quotation items.\n *\n * Strategy:\n * 1. Try the products table first (keyword match on name + optional category match).\n * 2. Collect all unmatched items and send them in ONE batched DeepSeek call.\n *\n * @param array<int, array<string, mixed>> $items\n * @return array<int, array<string, mixed>> Items enriched with unit_price, price_source, price_status\n */\n /**\n * Build the cache key for one row's price.\n *\n * Keyed on the product itself — description and unit — not the quotation, so\n * the same item priced from two different BOQs agrees with itself. The unit\n * is part of the key because a price per metre and a price per piece are\n * different numbers for the same product.\n */\n private function priceCacheKey(array $item): string\n {\n $description = mb_strtolower(trim(preg_replace('/\\s+/u', ' ', (string) ($item['description'] ?? '')) ?? ''));\n $unit = mb_strtolower(trim((string) ($item['unit'] ?? '')));\n\n return 'ai_price_' . hash('sha256', $description . '|' . $unit);\n }\n\n /**\n * Remember the AI's prices so a re-run returns the same numbers.\n *\n * @param array<int, array<string, mixed>> $items\n * @param list<int> $indices rows this pass priced\n */\n private function cachePrices(array $items, array $indices): void\n {\n foreach ($indices as $index) {\n $item = $items[$index] ?? null;\n\n if (! $item || empty($item['unit_price']) || $item['unit_price'] <= 0) {\n continue;\n }\n\n AiCache::store()->put(\n $this->priceCacheKey($item),\n [\n 'unit_price' => (float) $item['unit_price'],\n 'price_source' => $item['price_source'] ?? 'ai',\n ],\n now()->addDays(self::PRICE_CACHE_DAYS),\n );\n }\n }\n\n public function fetchPrices(array $items): array\n {\n $unmatched = [];\n\n foreach ($items as $index => $item) {\n // Skip already-priced rows (e.g., re-fetch after partial approval)\n if (! empty($item['unit_price'])) {\n continue;\n }\n\n $price = $this->lookupProductsTable($item);\n\n if ($price !== null) {\n $items[$index]['unit_price'] = $price;\n $items[$index]['price_source'] = 'products';\n $items[$index]['price_status'] = 'pending';\n\n continue;\n }\n\n // Reuse a recent AI price for the same product.\n //\n // Without this, re-pricing the same BOQ produced different numbers\n // every time: the model is sampled at a non-zero temperature, so it\n // does not return an identical figure twice. A client seeing two\n // prices for one line on the same day has no reason to trust either.\n //\n // Held for PRICE_CACHE_DAYS, deliberately shorter than the\n // quotation's own expiry window, so a re-price after expiry does\n // fetch genuinely current rates.\n $cached = AiCache::store()->get($this->priceCacheKey($item));\n\n if (is_array($cached) && isset($cached['unit_price'])) {\n $items[$index]['unit_price'] = (float) $cached['unit_price'];\n $items[$index]['price_source'] = $cached['price_source'] ?? 'ai_cached';\n $items[$index]['price_status'] = 'pending';\n\n continue;\n }\n\n $items[$index]['unit_price'] = null;\n $items[$index]['price_source'] = null;\n $items[$index]['price_status'] = 'pending';\n $unmatched[] = $index;\n }\n\n if (! empty($unmatched)) {\n $chunks = array_chunk($unmatched, self::DEEPSEEK_CHUNK_SIZE);\n\n if (count($chunks) === 1) {\n // Single chunk — direct call (no pool overhead)\n $items = $this->enrichWithDeepSeek($items, $chunks[0]);\n $this->cachePrices($items, $chunks[0]);\n } else {\n // Multiple chunks. Http::pool fires every request it is given at\n // once, so a large BOQ would open thousands of concurrent calls\n // and be rate-limited into mass failure. Run the pool in batches\n // instead: still parallel, but bounded.\n $batches = array_chunk($chunks, self::MAX_PARALLEL_CHUNKS);\n $total = count($chunks);\n $done = 0;\n\n foreach ($batches as $batch) {\n $items = $this->enrichChunksParallel($items, $batch);\n\n $this->cachePrices($items, array_merge(...$batch));\n\n $done += count($batch);\n $this->reportProgress($done, $total);\n }\n }\n }\n\n return $items;\n }\n\n // -------------------------------------------------------------------------\n // Products table lookup\n // -------------------------------------------------------------------------\n\n /**\n * Search the products table for a matching product by keyword.\n * Returns the unit_price when a confident match is found, null otherwise.\n */\n private function lookupProductsTable(array $item): ?float\n {\n $description = trim((string) ($item['description'] ?? ''));\n\n if (empty($description)) {\n return null;\n }\n\n // Extract significant words (≥ 3 chars)\n $words = array_values(array_filter(\n explode(' ', preg_replace('/[^a-zA-Z0-9\\s]/', ' ', $description)),\n fn(string $w) => strlen($w) >= 3\n ));\n\n if (empty($words)) {\n return null;\n }\n\n $query = Product::query()\n ->whereNotNull('unit_price')\n ->where('unit_price', '>', 0)\n ->where('active', true);\n\n // Require up to 4 keywords to appear in the product name (AND logic)\n foreach (array_slice($words, 0, 4) as $word) {\n $query->where('name', 'like', '%' . $word . '%');\n }\n\n // Narrow by category name if available\n $category = trim((string) ($item['category'] ?? ''));\n if ($category !== '') {\n $query->whereHas('category', fn($q) => $q->where('name', 'like', '%' . $category . '%'));\n }\n\n // CRITICAL: only accept a price whose UNIT matches the item's unit.\n // A product priced per m² must never be used to price an item measured in m³ —\n // that would return a real number that is the wrong \"unit price\".\n $unit = $this->normalizeUnit((string) ($item['unit'] ?? ''));\n if ($unit === '') {\n // No unit on the item → we cannot guarantee the price is per the correct unit.\n // Fall through to the AI estimator, which is told the unit explicitly.\n return null;\n }\n\n $query->whereHas('unit', function ($q) use ($unit) {\n $q->whereRaw('LOWER(TRIM(name)) = ?', [$unit])\n ->orWhereRaw('LOWER(TRIM(symbol)) = ?', [$unit]);\n });\n\n // Ordered explicitly. Without an ORDER BY, MySQL is free to return any\n // of the matching rows, and it does not always pick the same one — so a\n // keyword that matched several products priced the item differently on\n // different runs, while items with a single match stayed stable. That is\n // exactly the pattern that showed up: most rows identical, a few moving.\n //\n // Lowest price wins the tie, and the id breaks a tie on price, so the\n // result is both deterministic and defensible to a client.\n $product = $query->select('unit_price')\n ->orderBy('unit_price')\n ->orderBy('id')\n ->first();\n\n return $product ? (float) $product->unit_price : null;\n }\n\n /**\n * Canonicalize a free-text unit string so equivalent spellings match.\n * e.g. \"م³\", \"م3\", \"م^3\", \"M3\", \"متر مكعب\", \"cubic meter\" → \"م3\".\n * Returns '' when the input is empty.\n */\n private function normalizeUnit(string $unit): string\n {\n $u = trim(mb_strtolower($unit));\n if ($u === '') {\n return '';\n }\n\n // Normalize superscripts and notation: م³ / م^3 / م 3 → م3\n $u = strtr($u, ['²' => '2', '³' => '3', '^' => '', '.' => '']);\n $u = preg_replace('/\\s+/', '', $u);\n\n // Map common synonyms to a single canonical token.\n static $map = [\n 'متر مكعب' => 'م3', 'مترمكعب' => 'م3', 'cubicmeter' => 'م3', 'cubicmetre' => 'م3', 'cbm' => 'م3', 'm3' => 'م3',\n 'متر مربع' => 'م2', 'مترمربع' => 'م2', 'squaremeter' => 'م2', 'squaremetre' => 'م2', 'sqm' => 'م2', 'm2' => 'م2',\n 'متر طولي' => 'مط', 'مترطولي' => 'مط', 'linearmeter' => 'مط', 'lm' => 'مط',\n 'متر' => 'م', 'meter' => 'م', 'metre' => 'م',\n 'عدد' => 'عدد', 'piece' => 'عدد', 'pcs' => 'عدد', 'pc' => 'عدد', 'no' => 'عدد', 'nos' => 'عدد', 'unit' => 'عدد',\n 'كيلو' => 'كجم', 'كيلوغرام' => 'كجم', 'كيلوجرام' => 'كجم', 'kg' => 'كجم', 'kilogram' => 'كجم',\n 'طن' => 'طن', 'ton' => 'طن', 'tonne' => 'طن',\n 'لتر' => 'لتر', 'liter' => 'لتر', 'litre' => 'لتر', 'l' => 'لتر',\n 'كيس' => 'كيس', 'bag' => 'كيس', 'bags' => 'كيس',\n ];\n\n return $map[$u] ?? $u;\n }\n\n // -------------------------------------------------------------------------\n // DeepSeek fallback — helpers\n // -------------------------------------------------------------------------\n\n /**\n * Build the compact payload sent to DeepSeek for a set of item indices.\n */\n private function buildDeepSeekPayload(array $items, array $indices): array\n {\n $payload = [];\n foreach ($indices as $idx) {\n $payload[] = [\n 'i' => $idx,\n // 400 chars: BOQ lines routinely carry the specs that decide the\n // price (size, grade, rating), and the old 80-char cut dropped them.\n 'd' => mb_substr((string) ($items[$idx]['description'] ?? ''), 0, 400),\n 'cat' => mb_substr((string) ($items[$idx]['category'] ?? ''), 0, 30),\n 'br' => mb_substr((string) ($items[$idx]['brand'] ?? ''), 0, 30),\n 'unit' => mb_substr((string) ($items[$idx]['unit'] ?? ''), 0, 15),\n ];\n }\n return $payload;\n }\n\n /**\n * Build the DeepSeek prompt string for a given payload array.\n */\n private function buildPricingPrompt(array $payload): string\n {\n return 'You are a procurement pricing expert for the Saudi Arabia construction and MEP materials market. '\n . 'Estimate a realistic current unit price in SAR for each item below. '\n . 'RULES: '\n . '(1) Always return a positive non-zero price — use your best market estimate, never 0. '\n . '(2) Base prices on typical Saudi supplier/contractor rates for 2024-2026. '\n . '(3) Return ONLY a valid compact JSON array with NO whitespace or newlines between elements. '\n . '(4) Each element must be exactly: {\"i\":<index>,\"p\":<price_number>} '\n . '(5) No markdown, no explanation, no extra keys, no pretty-printing. '\n . 'Example output: [{\"i\":0,\"p\":1500},{\"i\":1,\"p\":350}] '\n . 'Items: ' . json_encode($payload, JSON_UNESCAPED_UNICODE);\n }\n\n /**\n * Parse a DeepSeek text response and apply prices to $items.\n */\n private function applyDeepSeekText(string $text, array $items, string $model): array\n {\n $text = preg_replace('/^```json\\s*/i', '', trim($text));\n $text = preg_replace('/```\\s*$/i', '', $text);\n\n $priceData = json_decode($text, true);\n\n if (! is_array($priceData)) {\n preg_match_all('/\\{\\s*\"i\"\\s*:\\s*(\\d+)\\s*,\\s*\"p\"\\s*:\\s*([\\d.]+)\\s*\\}/', $text, $matches, PREG_SET_ORDER);\n if (! empty($matches)) {\n $priceData = array_map(fn($m) => ['i' => (int) $m[1], 'p' => (float) $m[2]], $matches);\n Log::info('PricingService: Partial extraction recovered ' . count($priceData) . ' price(s).');\n }\n }\n\n if (! is_array($priceData) || empty($priceData)) {\n Log::warning('PricingService: Could not extract any prices from DeepSeek response.', [\n 'model' => $model,\n 'text_preview' => mb_substr($text, 0, 300),\n ]);\n return $items;\n }\n\n foreach ($priceData as $entry) {\n $idx = $entry['i'] ?? null;\n $price = $entry['p'] ?? null;\n\n if ($idx === null || ! array_key_exists($idx, $items)) {\n continue;\n }\n\n $price = is_numeric($price) ? (float) $price : 0.0;\n\n if ($price > 0) {\n $items[$idx]['unit_price'] = $price;\n $items[$idx]['price_source'] = 'deepseek';\n }\n }\n\n return $items;\n }\n\n /**\n * Send multiple chunks to DeepSeek in PARALLEL using Http::pool().\n * Falls back to sequential per-chunk calls if pool fails.\n *\n * @param array<int, array<string, mixed>> $items\n * @param list<list<int>> $chunks\n * @return array<int, array<string, mixed>>\n */\n private function enrichChunksParallel(array $items, array $chunks): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n $model = (string) config('services.deepseek.model', 'deepseek-chat');\n\n if (empty($apiKey)) {\n Log::warning('PricingService: DEEPSEEK_API_KEY not configured; skipping AI pricing.');\n return $items;\n }\n\n $url = 'https://api.deepseek.com/chat/completions';\n\n // Build request bodies once\n $requestBodies = [];\n foreach ($chunks as $ci => $chunkIndices) {\n $payload = $this->buildDeepSeekPayload($items, $chunkIndices);\n $requestBodies[$ci] = [\n 'model' => $model,\n 'messages' => [\n ['role' => 'user', 'content' => $this->buildPricingPrompt($payload)],\n ],\n 'temperature' => 0,\n 'max_tokens' => 8192,\n 'user' => 'Qimta_Platform',\n ];\n }\n\n $failedChunks = array_keys($chunks);\n\n // Single attempt with the configured model\n if (! empty($failedChunks)) {\n $pendingIndices = $failedChunks;\n $failedChunks = [];\n\n try {\n $responses = Http::pool(function (Pool $pool) use ($url, $apiKey, $requestBodies, $pendingIndices) {\n $reqs = [];\n foreach ($pendingIndices as $ci) {\n $reqs[] = $pool->as((string) $ci)\n ->timeout(90)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post($url, $requestBodies[$ci]);\n }\n return $reqs;\n });\n\n foreach ($responses as $key => $response) {\n $ci = (int) $key;\n\n if (! $response->successful()) {\n Log::warning('PricingService: Parallel chunk failed, will retry sequentially.', [\n 'model' => $model,\n 'chunk' => $ci,\n 'status' => $response->status(),\n ]);\n $failedChunks[] = $ci;\n continue;\n }\n\n $text = $response->json('choices.0.message.content') ?? '';\n $items = $this->applyDeepSeekText($text, $items, $model);\n\n // Released as soon as it is parsed. max_tokens is 8192, and\n // Guzzle buffers each response in full — holding a whole\n // pool's worth is what exhausted the memory limit.\n unset($responses[$key], $response, $text);\n }\n\n unset($responses);\n } catch (\\Throwable $e) {\n Log::error('PricingService: Http::pool exception, falling back to serial.', [\n 'model' => $model,\n 'message' => $e->getMessage(),\n ]);\n foreach ($pendingIndices as $ci) {\n $items = $this->enrichWithDeepSeek($items, $chunks[$ci]);\n }\n return $items;\n }\n }\n\n if (! empty($failedChunks)) {\n foreach ($failedChunks as $ci) {\n $items = $this->enrichWithDeepSeek($items, $chunks[$ci]);\n }\n }\n\n return $items;\n }\n\n // -------------------------------------------------------------------------\n // DeepSeek fallback (single batched request)\n // -------------------------------------------------------------------------\n\n /**\n * Send all unmatched items to DeepSeek in ONE request to minimise token consumption.\n * Only the fields DeepSeek actually needs are sent (description, category, brand, unit).\n * Output tokens are capped via max_tokens to protect against runaway usage.\n *\n * @param array<int, array<string, mixed>> $items\n * @param list<int> $unmatchedIndices\n * @return array<int, array<string, mixed>>\n */\n private function enrichWithDeepSeek(array $items, array $unmatchedIndices): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n $model = (string) config('services.deepseek.model', 'deepseek-chat');\n\n if (empty($apiKey)) {\n Log::warning('PricingService: DEEPSEEK_API_KEY not configured; skipping AI pricing.');\n return $items;\n }\n\n $payload = $this->buildDeepSeekPayload($items, $unmatchedIndices);\n $prompt = $this->buildPricingPrompt($payload);\n $lastStatus = null;\n\n try {\n $response = Http::timeout(90)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'user', 'content' => $prompt],\n ],\n 'temperature' => 0,\n 'max_tokens' => 8192,\n 'user' => 'Qimta_Platform',\n ]);\n\n if (! $response->successful()) {\n $lastStatus = $response->status();\n Log::warning('PricingService: DeepSeek request failed.', ['model' => $model, 'status' => $lastStatus]);\n } else {\n $text = $response->json('choices.0.message.content') ?? '';\n $items = $this->applyDeepSeekText($text, $items, $model);\n return $items;\n }\n } catch (\\Throwable $e) {\n Log::error('PricingService: Exception calling DeepSeek.', ['model' => $model, 'message' => $e->getMessage()]);\n }\n\n Log::error('PricingService: DeepSeek request failed.', ['last_status' => $lastStatus]);\n\n return $items;\n }\n}\n"}, "26efb22eb01080c935459c7864e4f4207036ee31": {"t": 1785748349.5627666, "n": 21174, "h": "0be7cdbd3b0bbe09", "c": "<?php\n\nnamespace App\\Services;\n\nuse App\\Models\\Product;\nuse Illuminate\\Http\\Client\\Pool;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\n\nclass PricingService\n{\n /**\n * Items per DeepSeek call. Multiple calls are made in PARALLEL so ALL unpriced items get a price faster.\n * Smaller = more parallel requests = faster overall. 10 items per request is optimal.\n */\n private const DEEPSEEK_CHUNK_SIZE = 10;\n\n /**\n * Concurrent AI requests per pool batch.\n *\n * Http::pool sends everything it is handed simultaneously, so an unbounded\n * pool on a large BOQ opens thousands of connections at once and gets\n * rate-limited. Batching keeps the parallelism useful without that.\n */\n private const MAX_PARALLEL_CHUNKS = 4;\n\n /**\n * How long an AI price stays reusable.\n *\n * Shorter than QuotationRequest::EXPIRY_DAYS (10) on purpose: a quotation\n * that has expired must re-price against genuinely current rates, so the\n * cache has to have lapsed by the time the quotation does.\n */\n private const PRICE_CACHE_DAYS = 7;\n\n /**\n * Optional progress reporter, called as ($chunksDone, $chunksTotal).\n *\n * @var (callable(int, int): void)|null\n */\n private $onProgress = null;\n\n /** @param callable(int, int): void $callback */\n public function onProgress(callable $callback): self\n {\n $this->onProgress = $callback;\n return $this;\n }\n\n private function reportProgress(int $done, int $total): void\n {\n if ($this->onProgress !== null) {\n ($this->onProgress)($done, $total);\n }\n }\n\n /**\n * Fetch unit prices for an array of quotation items.\n *\n * Strategy:\n * 1. Try the products table first (keyword match on name + optional category match).\n * 2. Collect all unmatched items and send them in ONE batched DeepSeek call.\n *\n * @param array<int, array<string, mixed>> $items\n * @return array<int, array<string, mixed>> Items enriched with unit_price, price_source, price_status\n */\n /**\n * Build the cache key for one row's price.\n *\n * Keyed on the product itself — description and unit — not the quotation, so\n * the same item priced from two different BOQs agrees with itself. The unit\n * is part of the key because a price per metre and a price per piece are\n * different numbers for the same product.\n */\n private function priceCacheKey(array $item): string\n {\n $description = mb_strtolower(trim(preg_replace('/\\s+/u', ' ', (string) ($item['description'] ?? '')) ?? ''));\n $unit = mb_strtolower(trim((string) ($item['unit'] ?? '')));\n\n return 'ai_price_' . hash('sha256', $description . '|' . $unit);\n }\n\n /**\n * Remember the AI's prices so a re-run returns the same numbers.\n *\n * @param array<int, array<string, mixed>> $items\n * @param list<int> $indices rows this pass priced\n */\n private function cachePrices(array $items, array $indices): void\n {\n foreach ($indices as $index) {\n $item = $items[$index] ?? null;\n\n if (! $item || empty($item['unit_price']) || $item['unit_price'] <= 0) {\n continue;\n }\n\n Cache::store('ai')->put(\n $this->priceCacheKey($item),\n [\n 'unit_price' => (float) $item['unit_price'],\n 'price_source' => $item['price_source'] ?? 'ai',\n ],\n now()->addDays(self::PRICE_CACHE_DAYS),\n );\n }\n }\n\n public function fetchPrices(array $items): array\n {\n $unmatched = [];\n\n foreach ($items as $index => $item) {\n // Skip already-priced rows (e.g., re-fetch after partial approval)\n if (! empty($item['unit_price'])) {\n continue;\n }\n\n $price = $this->lookupProductsTable($item);\n\n if ($price !== null) {\n $items[$index]['unit_price'] = $price;\n $items[$index]['price_source'] = 'products';\n $items[$index]['price_status'] = 'pending';\n\n continue;\n }\n\n // Reuse a recent AI price for the same product.\n //\n // Without this, re-pricing the same BOQ produced different numbers\n // every time: the model is sampled at a non-zero temperature, so it\n // does not return an identical figure twice. A client seeing two\n // prices for one line on the same day has no reason to trust either.\n //\n // Held for PRICE_CACHE_DAYS, deliberately shorter than the\n // quotation's own expiry window, so a re-price after expiry does\n // fetch genuinely current rates.\n $cached = Cache::store('ai')->get($this->priceCacheKey($item));\n\n if (is_array($cached) && isset($cached['unit_price'])) {\n $items[$index]['unit_price'] = (float) $cached['unit_price'];\n $items[$index]['price_source'] = $cached['price_source'] ?? 'ai_cached';\n $items[$index]['price_status'] = 'pending';\n\n continue;\n }\n\n $items[$index]['unit_price'] = null;\n $items[$index]['price_source'] = null;\n $items[$index]['price_status'] = 'pending';\n $unmatched[] = $index;\n }\n\n if (! empty($unmatched)) {\n $chunks = array_chunk($unmatched, self::DEEPSEEK_CHUNK_SIZE);\n\n if (count($chunks) === 1) {\n // Single chunk — direct call (no pool overhead)\n $items = $this->enrichWithDeepSeek($items, $chunks[0]);\n $this->cachePrices($items, $chunks[0]);\n } else {\n // Multiple chunks. Http::pool fires every request it is given at\n // once, so a large BOQ would open thousands of concurrent calls\n // and be rate-limited into mass failure. Run the pool in batches\n // instead: still parallel, but bounded.\n $batches = array_chunk($chunks, self::MAX_PARALLEL_CHUNKS);\n $total = count($chunks);\n $done = 0;\n\n foreach ($batches as $batch) {\n $items = $this->enrichChunksParallel($items, $batch);\n\n $this->cachePrices($items, array_merge(...$batch));\n\n $done += count($batch);\n $this->reportProgress($done, $total);\n }\n }\n }\n\n return $items;\n }\n\n // -------------------------------------------------------------------------\n // Products table lookup\n // -------------------------------------------------------------------------\n\n /**\n * Search the products table for a matching product by keyword.\n * Returns the unit_price when a confident match is found, null otherwise.\n */\n private function lookupProductsTable(array $item): ?float\n {\n $description = trim((string) ($item['description'] ?? ''));\n\n if (empty($description)) {\n return null;\n }\n\n // Extract significant words (≥ 3 chars)\n $words = array_values(array_filter(\n explode(' ', preg_replace('/[^a-zA-Z0-9\\s]/', ' ', $description)),\n fn(string $w) => strlen($w) >= 3\n ));\n\n if (empty($words)) {\n return null;\n }\n\n $query = Product::query()\n ->whereNotNull('unit_price')\n ->where('unit_price', '>', 0)\n ->where('active', true);\n\n // Require up to 4 keywords to appear in the product name (AND logic)\n foreach (array_slice($words, 0, 4) as $word) {\n $query->where('name', 'like', '%' . $word . '%');\n }\n\n // Narrow by category name if available\n $category = trim((string) ($item['category'] ?? ''));\n if ($category !== '') {\n $query->whereHas('category', fn($q) => $q->where('name', 'like', '%' . $category . '%'));\n }\n\n // CRITICAL: only accept a price whose UNIT matches the item's unit.\n // A product priced per m² must never be used to price an item measured in m³ —\n // that would return a real number that is the wrong \"unit price\".\n $unit = $this->normalizeUnit((string) ($item['unit'] ?? ''));\n if ($unit === '') {\n // No unit on the item → we cannot guarantee the price is per the correct unit.\n // Fall through to the AI estimator, which is told the unit explicitly.\n return null;\n }\n\n $query->whereHas('unit', function ($q) use ($unit) {\n $q->whereRaw('LOWER(TRIM(name)) = ?', [$unit])\n ->orWhereRaw('LOWER(TRIM(symbol)) = ?', [$unit]);\n });\n\n // Ordered explicitly. Without an ORDER BY, MySQL is free to return any\n // of the matching rows, and it does not always pick the same one — so a\n // keyword that matched several products priced the item differently on\n // different runs, while items with a single match stayed stable. That is\n // exactly the pattern that showed up: most rows identical, a few moving.\n //\n // Lowest price wins the tie, and the id breaks a tie on price, so the\n // result is both deterministic and defensible to a client.\n $product = $query->select('unit_price')\n ->orderBy('unit_price')\n ->orderBy('id')\n ->first();\n\n return $product ? (float) $product->unit_price : null;\n }\n\n /**\n * Canonicalize a free-text unit string so equivalent spellings match.\n * e.g. \"م³\", \"م3\", \"م^3\", \"M3\", \"متر مكعب\", \"cubic meter\" → \"م3\".\n * Returns '' when the input is empty.\n */\n private function normalizeUnit(string $unit): string\n {\n $u = trim(mb_strtolower($unit));\n if ($u === '') {\n return '';\n }\n\n // Normalize superscripts and notation: م³ / م^3 / م 3 → م3\n $u = strtr($u, ['²' => '2', '³' => '3', '^' => '', '.' => '']);\n $u = preg_replace('/\\s+/', '', $u);\n\n // Map common synonyms to a single canonical token.\n static $map = [\n 'متر مكعب' => 'م3', 'مترمكعب' => 'م3', 'cubicmeter' => 'م3', 'cubicmetre' => 'م3', 'cbm' => 'م3', 'm3' => 'م3',\n 'متر مربع' => 'م2', 'مترمربع' => 'م2', 'squaremeter' => 'م2', 'squaremetre' => 'م2', 'sqm' => 'م2', 'm2' => 'م2',\n 'متر طولي' => 'مط', 'مترطولي' => 'مط', 'linearmeter' => 'مط', 'lm' => 'مط',\n 'متر' => 'م', 'meter' => 'م', 'metre' => 'م',\n 'عدد' => 'عدد', 'piece' => 'عدد', 'pcs' => 'عدد', 'pc' => 'عدد', 'no' => 'عدد', 'nos' => 'عدد', 'unit' => 'عدد',\n 'كيلو' => 'كجم', 'كيلوغرام' => 'كجم', 'كيلوجرام' => 'كجم', 'kg' => 'كجم', 'kilogram' => 'كجم',\n 'طن' => 'طن', 'ton' => 'طن', 'tonne' => 'طن',\n 'لتر' => 'لتر', 'liter' => 'لتر', 'litre' => 'لتر', 'l' => 'لتر',\n 'كيس' => 'كيس', 'bag' => 'كيس', 'bags' => 'كيس',\n ];\n\n return $map[$u] ?? $u;\n }\n\n // -------------------------------------------------------------------------\n // DeepSeek fallback — helpers\n // -------------------------------------------------------------------------\n\n /**\n * Build the compact payload sent to DeepSeek for a set of item indices.\n */\n private function buildDeepSeekPayload(array $items, array $indices): array\n {\n $payload = [];\n foreach ($indices as $idx) {\n $payload[] = [\n 'i' => $idx,\n // 400 chars: BOQ lines routinely carry the specs that decide the\n // price (size, grade, rating), and the old 80-char cut dropped them.\n 'd' => mb_substr((string) ($items[$idx]['description'] ?? ''), 0, 400),\n 'cat' => mb_substr((string) ($items[$idx]['category'] ?? ''), 0, 30),\n 'br' => mb_substr((string) ($items[$idx]['brand'] ?? ''), 0, 30),\n 'unit' => mb_substr((string) ($items[$idx]['unit'] ?? ''), 0, 15),\n ];\n }\n return $payload;\n }\n\n /**\n * Build the DeepSeek prompt string for a given payload array.\n */\n private function buildPricingPrompt(array $payload): string\n {\n return 'You are a procurement pricing expert for the Saudi Arabia construction and MEP materials market. '\n . 'Estimate a realistic current unit price in SAR for each item below. '\n . 'RULES: '\n . '(1) Always return a positive non-zero price — use your best market estimate, never 0. '\n . '(2) Base prices on typical Saudi supplier/contractor rates for 2024-2026. '\n . '(3) Return ONLY a valid compact JSON array with NO whitespace or newlines between elements. '\n . '(4) Each element must be exactly: {\"i\":<index>,\"p\":<price_number>} '\n . '(5) No markdown, no explanation, no extra keys, no pretty-printing. '\n . 'Example output: [{\"i\":0,\"p\":1500},{\"i\":1,\"p\":350}] '\n . 'Items: ' . json_encode($payload, JSON_UNESCAPED_UNICODE);\n }\n\n /**\n * Parse a DeepSeek text response and apply prices to $items.\n */\n private function applyDeepSeekText(string $text, array $items, string $model): array\n {\n $text = preg_replace('/^```json\\s*/i', '', trim($text));\n $text = preg_replace('/```\\s*$/i', '', $text);\n\n $priceData = json_decode($text, true);\n\n if (! is_array($priceData)) {\n preg_match_all('/\\{\\s*\"i\"\\s*:\\s*(\\d+)\\s*,\\s*\"p\"\\s*:\\s*([\\d.]+)\\s*\\}/', $text, $matches, PREG_SET_ORDER);\n if (! empty($matches)) {\n $priceData = array_map(fn($m) => ['i' => (int) $m[1], 'p' => (float) $m[2]], $matches);\n Log::info('PricingService: Partial extraction recovered ' . count($priceData) . ' price(s).');\n }\n }\n\n if (! is_array($priceData) || empty($priceData)) {\n Log::warning('PricingService: Could not extract any prices from DeepSeek response.', [\n 'model' => $model,\n 'text_preview' => mb_substr($text, 0, 300),\n ]);\n return $items;\n }\n\n foreach ($priceData as $entry) {\n $idx = $entry['i'] ?? null;\n $price = $entry['p'] ?? null;\n\n if ($idx === null || ! array_key_exists($idx, $items)) {\n continue;\n }\n\n $price = is_numeric($price) ? (float) $price : 0.0;\n\n if ($price > 0) {\n $items[$idx]['unit_price'] = $price;\n $items[$idx]['price_source'] = 'deepseek';\n }\n }\n\n return $items;\n }\n\n /**\n * Send multiple chunks to DeepSeek in PARALLEL using Http::pool().\n * Falls back to sequential per-chunk calls if pool fails.\n *\n * @param array<int, array<string, mixed>> $items\n * @param list<list<int>> $chunks\n * @return array<int, array<string, mixed>>\n */\n private function enrichChunksParallel(array $items, array $chunks): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n $model = (string) config('services.deepseek.model', 'deepseek-chat');\n\n if (empty($apiKey)) {\n Log::warning('PricingService: DEEPSEEK_API_KEY not configured; skipping AI pricing.');\n return $items;\n }\n\n $url = 'https://api.deepseek.com/chat/completions';\n\n // Build request bodies once\n $requestBodies = [];\n foreach ($chunks as $ci => $chunkIndices) {\n $payload = $this->buildDeepSeekPayload($items, $chunkIndices);\n $requestBodies[$ci] = [\n 'model' => $model,\n 'messages' => [\n ['role' => 'user', 'content' => $this->buildPricingPrompt($payload)],\n ],\n 'temperature' => 0,\n 'max_tokens' => 8192,\n 'user' => 'Qimta_Platform',\n ];\n }\n\n $failedChunks = array_keys($chunks);\n\n // Single attempt with the configured model\n if (! empty($failedChunks)) {\n $pendingIndices = $failedChunks;\n $failedChunks = [];\n\n try {\n $responses = Http::pool(function (Pool $pool) use ($url, $apiKey, $requestBodies, $pendingIndices) {\n $reqs = [];\n foreach ($pendingIndices as $ci) {\n $reqs[] = $pool->as((string) $ci)\n ->timeout(90)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post($url, $requestBodies[$ci]);\n }\n return $reqs;\n });\n\n foreach ($responses as $key => $response) {\n $ci = (int) $key;\n\n if (! $response->successful()) {\n Log::warning('PricingService: Parallel chunk failed, will retry sequentially.', [\n 'model' => $model,\n 'chunk' => $ci,\n 'status' => $response->status(),\n ]);\n $failedChunks[] = $ci;\n continue;\n }\n\n $text = $response->json('choices.0.message.content') ?? '';\n $items = $this->applyDeepSeekText($text, $items, $model);\n\n // Released as soon as it is parsed. max_tokens is 8192, and\n // Guzzle buffers each response in full — holding a whole\n // pool's worth is what exhausted the memory limit.\n unset($responses[$key], $response, $text);\n }\n\n unset($responses);\n } catch (\\Throwable $e) {\n Log::error('PricingService: Http::pool exception, falling back to serial.', [\n 'model' => $model,\n 'message' => $e->getMessage(),\n ]);\n foreach ($pendingIndices as $ci) {\n $items = $this->enrichWithDeepSeek($items, $chunks[$ci]);\n }\n return $items;\n }\n }\n\n if (! empty($failedChunks)) {\n foreach ($failedChunks as $ci) {\n $items = $this->enrichWithDeepSeek($items, $chunks[$ci]);\n }\n }\n\n return $items;\n }\n\n // -------------------------------------------------------------------------\n // DeepSeek fallback (single batched request)\n // -------------------------------------------------------------------------\n\n /**\n * Send all unmatched items to DeepSeek in ONE request to minimise token consumption.\n * Only the fields DeepSeek actually needs are sent (description, category, brand, unit).\n * Output tokens are capped via max_tokens to protect against runaway usage.\n *\n * @param array<int, array<string, mixed>> $items\n * @param list<int> $unmatchedIndices\n * @return array<int, array<string, mixed>>\n */\n private function enrichWithDeepSeek(array $items, array $unmatchedIndices): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n $model = (string) config('services.deepseek.model', 'deepseek-chat');\n\n if (empty($apiKey)) {\n Log::warning('PricingService: DEEPSEEK_API_KEY not configured; skipping AI pricing.');\n return $items;\n }\n\n $payload = $this->buildDeepSeekPayload($items, $unmatchedIndices);\n $prompt = $this->buildPricingPrompt($payload);\n $lastStatus = null;\n\n try {\n $response = Http::timeout(90)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'user', 'content' => $prompt],\n ],\n 'temperature' => 0,\n 'max_tokens' => 8192,\n 'user' => 'Qimta_Platform',\n ]);\n\n if (! $response->successful()) {\n $lastStatus = $response->status();\n Log::warning('PricingService: DeepSeek request failed.', ['model' => $model, 'status' => $lastStatus]);\n } else {\n $text = $response->json('choices.0.message.content') ?? '';\n $items = $this->applyDeepSeekText($text, $items, $model);\n return $items;\n }\n } catch (\\Throwable $e) {\n Log::error('PricingService: Exception calling DeepSeek.', ['model' => $model, 'message' => $e->getMessage()]);\n }\n\n Log::error('PricingService: DeepSeek request failed.', ['last_status' => $lastStatus]);\n\n return $items;\n }\n}\n"}, "8fd48f258c675a8fe54dab5da10ea820e80627cc": {"t": 1785748350.719583, "n": 20534, "h": "a5c92676359f578a", "c": "<?php\n\nnamespace App\\Services;\n\nuse App\\Models\\Product;\nuse Illuminate\\Http\\Client\\Pool;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\n\nclass PricingService\n{\n /**\n * Items per DeepSeek call. Multiple calls are made in PARALLEL so ALL unpriced items get a price faster.\n * Smaller = more parallel requests = faster overall. 10 items per request is optimal.\n */\n private const DEEPSEEK_CHUNK_SIZE = 10;\n\n /**\n * Concurrent AI requests per pool batch.\n *\n * Http::pool sends everything it is handed simultaneously, so an unbounded\n * pool on a large BOQ opens thousands of connections at once and gets\n * rate-limited. Batching keeps the parallelism useful without that.\n */\n private const MAX_PARALLEL_CHUNKS = 4;\n\n /**\n * How long an AI price stays reusable.\n *\n * Shorter than QuotationRequest::EXPIRY_DAYS (10) on purpose: a quotation\n * that has expired must re-price against genuinely current rates, so the\n * cache has to have lapsed by the time the quotation does.\n */\n private const PRICE_CACHE_DAYS = 7;\n\n /**\n * Optional progress reporter, called as ($chunksDone, $chunksTotal).\n *\n * @var (callable(int, int): void)|null\n */\n private $onProgress = null;\n\n /** @param callable(int, int): void $callback */\n public function onProgress(callable $callback): self\n {\n $this->onProgress = $callback;\n return $this;\n }\n\n private function reportProgress(int $done, int $total): void\n {\n if ($this->onProgress !== null) {\n ($this->onProgress)($done, $total);\n }\n }\n\n /**\n * Fetch unit prices for an array of quotation items.\n *\n * Strategy:\n * 1. Try the products table first (keyword match on name + optional category match).\n * 2. Collect all unmatched items and send them in ONE batched DeepSeek call.\n *\n * @param array<int, array<string, mixed>> $items\n * @return array<int, array<string, mixed>> Items enriched with unit_price, price_source, price_status\n */\n /**\n * Build the cache key for one row's price.\n *\n * Keyed on the product itself — description and unit — not the quotation, so\n * the same item priced from two different BOQs agrees with itself. The unit\n * is part of the key because a price per metre and a price per piece are\n * different numbers for the same product.\n */\n private function priceCacheKey(array $item): string\n {\n $description = mb_strtolower(trim(preg_replace('/\\s+/u', ' ', (string) ($item['description'] ?? '')) ?? ''));\n $unit = mb_strtolower(trim((string) ($item['unit'] ?? '')));\n\n return 'ai_price_' . hash('sha256', $description . '|' . $unit);\n }\n\n /**\n * Remember the AI's prices so a re-run returns the same numbers.\n *\n * @param array<int, array<string, mixed>> $items\n * @param list<int> $indices rows this pass priced\n */\n private function cachePrices(array $items, array $indices): void\n {\n foreach ($indices as $index) {\n $item = $items[$index] ?? null;\n\n if (! $item || empty($item['unit_price']) || $item['unit_price'] <= 0) {\n continue;\n }\n\n Cache::store('ai')->put(\n $this->priceCacheKey($item),\n [\n 'unit_price' => (float) $item['unit_price'],\n 'price_source' => $item['price_source'] ?? 'ai',\n ],\n now()->addDays(self::PRICE_CACHE_DAYS),\n );\n }\n }\n\n public function fetchPrices(array $items): array\n {\n $unmatched = [];\n\n foreach ($items as $index => $item) {\n // Skip already-priced rows (e.g., re-fetch after partial approval)\n if (! empty($item['unit_price'])) {\n continue;\n }\n\n $price = $this->lookupProductsTable($item);\n\n if ($price !== null) {\n $items[$index]['unit_price'] = $price;\n $items[$index]['price_source'] = 'products';\n $items[$index]['price_status'] = 'pending';\n\n continue;\n }\n\n // Reuse a recent AI price for the same product.\n //\n // Without this, re-pricing the same BOQ produced different numbers\n // every time: the model is sampled at a non-zero temperature, so it\n // does not return an identical figure twice. A client seeing two\n // prices for one line on the same day has no reason to trust either.\n //\n // Held for PRICE_CACHE_DAYS, deliberately shorter than the\n // quotation's own expiry window, so a re-price after expiry does\n // fetch genuinely current rates.\n $cached = Cache::store('ai')->get($this->priceCacheKey($item));\n\n if (is_array($cached) && isset($cached['unit_price'])) {\n $items[$index]['unit_price'] = (float) $cached['unit_price'];\n $items[$index]['price_source'] = $cached['price_source'] ?? 'ai_cached';\n $items[$index]['price_status'] = 'pending';\n\n continue;\n }\n\n $items[$index]['unit_price'] = null;\n $items[$index]['price_source'] = null;\n $items[$index]['price_status'] = 'pending';\n $unmatched[] = $index;\n }\n\n if (! empty($unmatched)) {\n $chunks = array_chunk($unmatched, self::DEEPSEEK_CHUNK_SIZE);\n\n if (count($chunks) === 1) {\n // Single chunk — direct call (no pool overhead)\n $items = $this->enrichWithDeepSeek($items, $chunks[0]);\n $this->cachePrices($items, $chunks[0]);\n } else {\n // Multiple chunks. Http::pool fires every request it is given at\n // once, so a large BOQ would open thousands of concurrent calls\n // and be rate-limited into mass failure. Run the pool in batches\n // instead: still parallel, but bounded.\n $batches = array_chunk($chunks, self::MAX_PARALLEL_CHUNKS);\n $total = count($chunks);\n $done = 0;\n\n foreach ($batches as $batch) {\n $items = $this->enrichChunksParallel($items, $batch);\n\n $this->cachePrices($items, array_merge(...$batch));\n\n $done += count($batch);\n $this->reportProgress($done, $total);\n }\n }\n }\n\n return $items;\n }\n\n // -------------------------------------------------------------------------\n // Products table lookup\n // -------------------------------------------------------------------------\n\n /**\n * Search the products table for a matching product by keyword.\n * Returns the unit_price when a confident match is found, null otherwise.\n */\n private function lookupProductsTable(array $item): ?float\n {\n $description = trim((string) ($item['description'] ?? ''));\n\n if (empty($description)) {\n return null;\n }\n\n // Extract significant words (≥ 3 chars)\n $words = array_values(array_filter(\n explode(' ', preg_replace('/[^a-zA-Z0-9\\s]/', ' ', $description)),\n fn(string $w) => strlen($w) >= 3\n ));\n\n if (empty($words)) {\n return null;\n }\n\n $query = Product::query()\n ->whereNotNull('unit_price')\n ->where('unit_price', '>', 0)\n ->where('active', true);\n\n // Require up to 4 keywords to appear in the product name (AND logic)\n foreach (array_slice($words, 0, 4) as $word) {\n $query->where('name', 'like', '%' . $word . '%');\n }\n\n // Narrow by category name if available\n $category = trim((string) ($item['category'] ?? ''));\n if ($category !== '') {\n $query->whereHas('category', fn($q) => $q->where('name', 'like', '%' . $category . '%'));\n }\n\n // CRITICAL: only accept a price whose UNIT matches the item's unit.\n // A product priced per m² must never be used to price an item measured in m³ —\n // that would return a real number that is the wrong \"unit price\".\n $unit = $this->normalizeUnit((string) ($item['unit'] ?? ''));\n if ($unit === '') {\n // No unit on the item → we cannot guarantee the price is per the correct unit.\n // Fall through to the AI estimator, which is told the unit explicitly.\n return null;\n }\n\n $query->whereHas('unit', function ($q) use ($unit) {\n $q->whereRaw('LOWER(TRIM(name)) = ?', [$unit])\n ->orWhereRaw('LOWER(TRIM(symbol)) = ?', [$unit]);\n });\n\n $product = $query->select('unit_price')->first();\n\n return $product ? (float) $product->unit_price : null;\n }\n\n /**\n * Canonicalize a free-text unit string so equivalent spellings match.\n * e.g. \"م³\", \"م3\", \"م^3\", \"M3\", \"متر مكعب\", \"cubic meter\" → \"م3\".\n * Returns '' when the input is empty.\n */\n private function normalizeUnit(string $unit): string\n {\n $u = trim(mb_strtolower($unit));\n if ($u === '') {\n return '';\n }\n\n // Normalize superscripts and notation: م³ / م^3 / م 3 → م3\n $u = strtr($u, ['²' => '2', '³' => '3', '^' => '', '.' => '']);\n $u = preg_replace('/\\s+/', '', $u);\n\n // Map common synonyms to a single canonical token.\n static $map = [\n 'متر مكعب' => 'م3', 'مترمكعب' => 'م3', 'cubicmeter' => 'م3', 'cubicmetre' => 'م3', 'cbm' => 'م3', 'm3' => 'م3',\n 'متر مربع' => 'م2', 'مترمربع' => 'م2', 'squaremeter' => 'م2', 'squaremetre' => 'م2', 'sqm' => 'م2', 'm2' => 'م2',\n 'متر طولي' => 'مط', 'مترطولي' => 'مط', 'linearmeter' => 'مط', 'lm' => 'مط',\n 'متر' => 'م', 'meter' => 'م', 'metre' => 'م',\n 'عدد' => 'عدد', 'piece' => 'عدد', 'pcs' => 'عدد', 'pc' => 'عدد', 'no' => 'عدد', 'nos' => 'عدد', 'unit' => 'عدد',\n 'كيلو' => 'كجم', 'كيلوغرام' => 'كجم', 'كيلوجرام' => 'كجم', 'kg' => 'كجم', 'kilogram' => 'كجم',\n 'طن' => 'طن', 'ton' => 'طن', 'tonne' => 'طن',\n 'لتر' => 'لتر', 'liter' => 'لتر', 'litre' => 'لتر', 'l' => 'لتر',\n 'كيس' => 'كيس', 'bag' => 'كيس', 'bags' => 'كيس',\n ];\n\n return $map[$u] ?? $u;\n }\n\n // -------------------------------------------------------------------------\n // DeepSeek fallback — helpers\n // -------------------------------------------------------------------------\n\n /**\n * Build the compact payload sent to DeepSeek for a set of item indices.\n */\n private function buildDeepSeekPayload(array $items, array $indices): array\n {\n $payload = [];\n foreach ($indices as $idx) {\n $payload[] = [\n 'i' => $idx,\n // 400 chars: BOQ lines routinely carry the specs that decide the\n // price (size, grade, rating), and the old 80-char cut dropped them.\n 'd' => mb_substr((string) ($items[$idx]['description'] ?? ''), 0, 400),\n 'cat' => mb_substr((string) ($items[$idx]['category'] ?? ''), 0, 30),\n 'br' => mb_substr((string) ($items[$idx]['brand'] ?? ''), 0, 30),\n 'unit' => mb_substr((string) ($items[$idx]['unit'] ?? ''), 0, 15),\n ];\n }\n return $payload;\n }\n\n /**\n * Build the DeepSeek prompt string for a given payload array.\n */\n private function buildPricingPrompt(array $payload): string\n {\n return 'You are a procurement pricing expert for the Saudi Arabia construction and MEP materials market. '\n . 'Estimate a realistic current unit price in SAR for each item below. '\n . 'RULES: '\n . '(1) Always return a positive non-zero price — use your best market estimate, never 0. '\n . '(2) Base prices on typical Saudi supplier/contractor rates for 2024-2026. '\n . '(3) Return ONLY a valid compact JSON array with NO whitespace or newlines between elements. '\n . '(4) Each element must be exactly: {\"i\":<index>,\"p\":<price_number>} '\n . '(5) No markdown, no explanation, no extra keys, no pretty-printing. '\n . 'Example output: [{\"i\":0,\"p\":1500},{\"i\":1,\"p\":350}] '\n . 'Items: ' . json_encode($payload, JSON_UNESCAPED_UNICODE);\n }\n\n /**\n * Parse a DeepSeek text response and apply prices to $items.\n */\n private function applyDeepSeekText(string $text, array $items, string $model): array\n {\n $text = preg_replace('/^```json\\s*/i', '', trim($text));\n $text = preg_replace('/```\\s*$/i', '', $text);\n\n $priceData = json_decode($text, true);\n\n if (! is_array($priceData)) {\n preg_match_all('/\\{\\s*\"i\"\\s*:\\s*(\\d+)\\s*,\\s*\"p\"\\s*:\\s*([\\d.]+)\\s*\\}/', $text, $matches, PREG_SET_ORDER);\n if (! empty($matches)) {\n $priceData = array_map(fn($m) => ['i' => (int) $m[1], 'p' => (float) $m[2]], $matches);\n Log::info('PricingService: Partial extraction recovered ' . count($priceData) . ' price(s).');\n }\n }\n\n if (! is_array($priceData) || empty($priceData)) {\n Log::warning('PricingService: Could not extract any prices from DeepSeek response.', [\n 'model' => $model,\n 'text_preview' => mb_substr($text, 0, 300),\n ]);\n return $items;\n }\n\n foreach ($priceData as $entry) {\n $idx = $entry['i'] ?? null;\n $price = $entry['p'] ?? null;\n\n if ($idx === null || ! array_key_exists($idx, $items)) {\n continue;\n }\n\n $price = is_numeric($price) ? (float) $price : 0.0;\n\n if ($price > 0) {\n $items[$idx]['unit_price'] = $price;\n $items[$idx]['price_source'] = 'deepseek';\n }\n }\n\n return $items;\n }\n\n /**\n * Send multiple chunks to DeepSeek in PARALLEL using Http::pool().\n * Falls back to sequential per-chunk calls if pool fails.\n *\n * @param array<int, array<string, mixed>> $items\n * @param list<list<int>> $chunks\n * @return array<int, array<string, mixed>>\n */\n private function enrichChunksParallel(array $items, array $chunks): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n $model = (string) config('services.deepseek.model', 'deepseek-chat');\n\n if (empty($apiKey)) {\n Log::warning('PricingService: DEEPSEEK_API_KEY not configured; skipping AI pricing.');\n return $items;\n }\n\n $url = 'https://api.deepseek.com/chat/completions';\n\n // Build request bodies once\n $requestBodies = [];\n foreach ($chunks as $ci => $chunkIndices) {\n $payload = $this->buildDeepSeekPayload($items, $chunkIndices);\n $requestBodies[$ci] = [\n 'model' => $model,\n 'messages' => [\n ['role' => 'user', 'content' => $this->buildPricingPrompt($payload)],\n ],\n 'temperature' => 0,\n 'max_tokens' => 8192,\n 'user' => 'Qimta_Platform',\n ];\n }\n\n $failedChunks = array_keys($chunks);\n\n // Single attempt with the configured model\n if (! empty($failedChunks)) {\n $pendingIndices = $failedChunks;\n $failedChunks = [];\n\n try {\n $responses = Http::pool(function (Pool $pool) use ($url, $apiKey, $requestBodies, $pendingIndices) {\n $reqs = [];\n foreach ($pendingIndices as $ci) {\n $reqs[] = $pool->as((string) $ci)\n ->timeout(90)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post($url, $requestBodies[$ci]);\n }\n return $reqs;\n });\n\n foreach ($responses as $key => $response) {\n $ci = (int) $key;\n\n if (! $response->successful()) {\n Log::warning('PricingService: Parallel chunk failed, will retry sequentially.', [\n 'model' => $model,\n 'chunk' => $ci,\n 'status' => $response->status(),\n ]);\n $failedChunks[] = $ci;\n continue;\n }\n\n $text = $response->json('choices.0.message.content') ?? '';\n $items = $this->applyDeepSeekText($text, $items, $model);\n\n // Released as soon as it is parsed. max_tokens is 8192, and\n // Guzzle buffers each response in full — holding a whole\n // pool's worth is what exhausted the memory limit.\n unset($responses[$key], $response, $text);\n }\n\n unset($responses);\n } catch (\\Throwable $e) {\n Log::error('PricingService: Http::pool exception, falling back to serial.', [\n 'model' => $model,\n 'message' => $e->getMessage(),\n ]);\n foreach ($pendingIndices as $ci) {\n $items = $this->enrichWithDeepSeek($items, $chunks[$ci]);\n }\n return $items;\n }\n }\n\n if (! empty($failedChunks)) {\n foreach ($failedChunks as $ci) {\n $items = $this->enrichWithDeepSeek($items, $chunks[$ci]);\n }\n }\n\n return $items;\n }\n\n // -------------------------------------------------------------------------\n // DeepSeek fallback (single batched request)\n // -------------------------------------------------------------------------\n\n /**\n * Send all unmatched items to DeepSeek in ONE request to minimise token consumption.\n * Only the fields DeepSeek actually needs are sent (description, category, brand, unit).\n * Output tokens are capped via max_tokens to protect against runaway usage.\n *\n * @param array<int, array<string, mixed>> $items\n * @param list<int> $unmatchedIndices\n * @return array<int, array<string, mixed>>\n */\n private function enrichWithDeepSeek(array $items, array $unmatchedIndices): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n $model = (string) config('services.deepseek.model', 'deepseek-chat');\n\n if (empty($apiKey)) {\n Log::warning('PricingService: DEEPSEEK_API_KEY not configured; skipping AI pricing.');\n return $items;\n }\n\n $payload = $this->buildDeepSeekPayload($items, $unmatchedIndices);\n $prompt = $this->buildPricingPrompt($payload);\n $lastStatus = null;\n\n try {\n $response = Http::timeout(90)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'user', 'content' => $prompt],\n ],\n 'temperature' => 0,\n 'max_tokens' => 8192,\n 'user' => 'Qimta_Platform',\n ]);\n\n if (! $response->successful()) {\n $lastStatus = $response->status();\n Log::warning('PricingService: DeepSeek request failed.', ['model' => $model, 'status' => $lastStatus]);\n } else {\n $text = $response->json('choices.0.message.content') ?? '';\n $items = $this->applyDeepSeekText($text, $items, $model);\n return $items;\n }\n } catch (\\Throwable $e) {\n Log::error('PricingService: Exception calling DeepSeek.', ['model' => $model, 'message' => $e->getMessage()]);\n }\n\n Log::error('PricingService: DeepSeek request failed.', ['last_status' => $lastStatus]);\n\n return $items;\n }\n}\n"}, "4ecdad51bbb33161bd38b560a3c084a4457f317a": {"t": 1785748351.8776624, "n": 20508, "h": "e32b241b66720ea6", "c": "<?php\n\nnamespace App\\Services;\n\nuse App\\Models\\Product;\nuse Illuminate\\Http\\Client\\Pool;\nuse Illuminate\\Support\\Facades\\Cache;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\n\nclass PricingService\n{\n /**\n * Items per DeepSeek call. Multiple calls are made in PARALLEL so ALL unpriced items get a price faster.\n * Smaller = more parallel requests = faster overall. 10 items per request is optimal.\n */\n private const DEEPSEEK_CHUNK_SIZE = 10;\n\n /**\n * Concurrent AI requests per pool batch.\n *\n * Http::pool sends everything it is handed simultaneously, so an unbounded\n * pool on a large BOQ opens thousands of connections at once and gets\n * rate-limited. Batching keeps the parallelism useful without that.\n */\n private const MAX_PARALLEL_CHUNKS = 4;\n\n /**\n * How long an AI price stays reusable.\n *\n * Shorter than QuotationRequest::EXPIRY_DAYS (10) on purpose: a quotation\n * that has expired must re-price against genuinely current rates, so the\n * cache has to have lapsed by the time the quotation does.\n */\n private const PRICE_CACHE_DAYS = 7;\n\n /**\n * Optional progress reporter, called as ($chunksDone, $chunksTotal).\n *\n * @var (callable(int, int): void)|null\n */\n private $onProgress = null;\n\n /** @param callable(int, int): void $callback */\n public function onProgress(callable $callback): self\n {\n $this->onProgress = $callback;\n return $this;\n }\n\n private function reportProgress(int $done, int $total): void\n {\n if ($this->onProgress !== null) {\n ($this->onProgress)($done, $total);\n }\n }\n\n /**\n * Fetch unit prices for an array of quotation items.\n *\n * Strategy:\n * 1. Try the products table first (keyword match on name + optional category match).\n * 2. Collect all unmatched items and send them in ONE batched DeepSeek call.\n *\n * @param array<int, array<string, mixed>> $items\n * @return array<int, array<string, mixed>> Items enriched with unit_price, price_source, price_status\n */\n /**\n * Build the cache key for one row's price.\n *\n * Keyed on the product itself — description and unit — not the quotation, so\n * the same item priced from two different BOQs agrees with itself. The unit\n * is part of the key because a price per metre and a price per piece are\n * different numbers for the same product.\n */\n private function priceCacheKey(array $item): string\n {\n $description = mb_strtolower(trim(preg_replace('/\\s+/u', ' ', (string) ($item['description'] ?? '')) ?? ''));\n $unit = mb_strtolower(trim((string) ($item['unit'] ?? '')));\n\n return 'ai_price_' . hash('sha256', $description . '|' . $unit);\n }\n\n /**\n * Remember the AI's prices so a re-run returns the same numbers.\n *\n * @param array<int, array<string, mixed>> $items\n * @param list<int> $indices rows this pass priced\n */\n private function cachePrices(array $items, array $indices): void\n {\n foreach ($indices as $index) {\n $item = $items[$index] ?? null;\n\n if (! $item || empty($item['unit_price']) || $item['unit_price'] <= 0) {\n continue;\n }\n\n Cache::put(\n $this->priceCacheKey($item),\n [\n 'unit_price' => (float) $item['unit_price'],\n 'price_source' => $item['price_source'] ?? 'ai',\n ],\n now()->addDays(self::PRICE_CACHE_DAYS),\n );\n }\n }\n\n public function fetchPrices(array $items): array\n {\n $unmatched = [];\n\n foreach ($items as $index => $item) {\n // Skip already-priced rows (e.g., re-fetch after partial approval)\n if (! empty($item['unit_price'])) {\n continue;\n }\n\n $price = $this->lookupProductsTable($item);\n\n if ($price !== null) {\n $items[$index]['unit_price'] = $price;\n $items[$index]['price_source'] = 'products';\n $items[$index]['price_status'] = 'pending';\n\n continue;\n }\n\n // Reuse a recent AI price for the same product.\n //\n // Without this, re-pricing the same BOQ produced different numbers\n // every time: the model is sampled at a non-zero temperature, so it\n // does not return an identical figure twice. A client seeing two\n // prices for one line on the same day has no reason to trust either.\n //\n // Held for PRICE_CACHE_DAYS, deliberately shorter than the\n // quotation's own expiry window, so a re-price after expiry does\n // fetch genuinely current rates.\n $cached = Cache::get($this->priceCacheKey($item));\n\n if (is_array($cached) && isset($cached['unit_price'])) {\n $items[$index]['unit_price'] = (float) $cached['unit_price'];\n $items[$index]['price_source'] = $cached['price_source'] ?? 'ai_cached';\n $items[$index]['price_status'] = 'pending';\n\n continue;\n }\n\n $items[$index]['unit_price'] = null;\n $items[$index]['price_source'] = null;\n $items[$index]['price_status'] = 'pending';\n $unmatched[] = $index;\n }\n\n if (! empty($unmatched)) {\n $chunks = array_chunk($unmatched, self::DEEPSEEK_CHUNK_SIZE);\n\n if (count($chunks) === 1) {\n // Single chunk — direct call (no pool overhead)\n $items = $this->enrichWithDeepSeek($items, $chunks[0]);\n $this->cachePrices($items, $chunks[0]);\n } else {\n // Multiple chunks. Http::pool fires every request it is given at\n // once, so a large BOQ would open thousands of concurrent calls\n // and be rate-limited into mass failure. Run the pool in batches\n // instead: still parallel, but bounded.\n $batches = array_chunk($chunks, self::MAX_PARALLEL_CHUNKS);\n $total = count($chunks);\n $done = 0;\n\n foreach ($batches as $batch) {\n $items = $this->enrichChunksParallel($items, $batch);\n\n $this->cachePrices($items, array_merge(...$batch));\n\n $done += count($batch);\n $this->reportProgress($done, $total);\n }\n }\n }\n\n return $items;\n }\n\n // -------------------------------------------------------------------------\n // Products table lookup\n // -------------------------------------------------------------------------\n\n /**\n * Search the products table for a matching product by keyword.\n * Returns the unit_price when a confident match is found, null otherwise.\n */\n private function lookupProductsTable(array $item): ?float\n {\n $description = trim((string) ($item['description'] ?? ''));\n\n if (empty($description)) {\n return null;\n }\n\n // Extract significant words (≥ 3 chars)\n $words = array_values(array_filter(\n explode(' ', preg_replace('/[^a-zA-Z0-9\\s]/', ' ', $description)),\n fn(string $w) => strlen($w) >= 3\n ));\n\n if (empty($words)) {\n return null;\n }\n\n $query = Product::query()\n ->whereNotNull('unit_price')\n ->where('unit_price', '>', 0)\n ->where('active', true);\n\n // Require up to 4 keywords to appear in the product name (AND logic)\n foreach (array_slice($words, 0, 4) as $word) {\n $query->where('name', 'like', '%' . $word . '%');\n }\n\n // Narrow by category name if available\n $category = trim((string) ($item['category'] ?? ''));\n if ($category !== '') {\n $query->whereHas('category', fn($q) => $q->where('name', 'like', '%' . $category . '%'));\n }\n\n // CRITICAL: only accept a price whose UNIT matches the item's unit.\n // A product priced per m² must never be used to price an item measured in m³ —\n // that would return a real number that is the wrong \"unit price\".\n $unit = $this->normalizeUnit((string) ($item['unit'] ?? ''));\n if ($unit === '') {\n // No unit on the item → we cannot guarantee the price is per the correct unit.\n // Fall through to the AI estimator, which is told the unit explicitly.\n return null;\n }\n\n $query->whereHas('unit', function ($q) use ($unit) {\n $q->whereRaw('LOWER(TRIM(name)) = ?', [$unit])\n ->orWhereRaw('LOWER(TRIM(symbol)) = ?', [$unit]);\n });\n\n $product = $query->select('unit_price')->first();\n\n return $product ? (float) $product->unit_price : null;\n }\n\n /**\n * Canonicalize a free-text unit string so equivalent spellings match.\n * e.g. \"م³\", \"م3\", \"م^3\", \"M3\", \"متر مكعب\", \"cubic meter\" → \"م3\".\n * Returns '' when the input is empty.\n */\n private function normalizeUnit(string $unit): string\n {\n $u = trim(mb_strtolower($unit));\n if ($u === '') {\n return '';\n }\n\n // Normalize superscripts and notation: م³ / م^3 / م 3 → م3\n $u = strtr($u, ['²' => '2', '³' => '3', '^' => '', '.' => '']);\n $u = preg_replace('/\\s+/', '', $u);\n\n // Map common synonyms to a single canonical token.\n static $map = [\n 'متر مكعب' => 'م3', 'مترمكعب' => 'م3', 'cubicmeter' => 'م3', 'cubicmetre' => 'م3', 'cbm' => 'م3', 'm3' => 'م3',\n 'متر مربع' => 'م2', 'مترمربع' => 'م2', 'squaremeter' => 'م2', 'squaremetre' => 'م2', 'sqm' => 'م2', 'm2' => 'م2',\n 'متر طولي' => 'مط', 'مترطولي' => 'مط', 'linearmeter' => 'مط', 'lm' => 'مط',\n 'متر' => 'م', 'meter' => 'م', 'metre' => 'م',\n 'عدد' => 'عدد', 'piece' => 'عدد', 'pcs' => 'عدد', 'pc' => 'عدد', 'no' => 'عدد', 'nos' => 'عدد', 'unit' => 'عدد',\n 'كيلو' => 'كجم', 'كيلوغرام' => 'كجم', 'كيلوجرام' => 'كجم', 'kg' => 'كجم', 'kilogram' => 'كجم',\n 'طن' => 'طن', 'ton' => 'طن', 'tonne' => 'طن',\n 'لتر' => 'لتر', 'liter' => 'لتر', 'litre' => 'لتر', 'l' => 'لتر',\n 'كيس' => 'كيس', 'bag' => 'كيس', 'bags' => 'كيس',\n ];\n\n return $map[$u] ?? $u;\n }\n\n // -------------------------------------------------------------------------\n // DeepSeek fallback — helpers\n // -------------------------------------------------------------------------\n\n /**\n * Build the compact payload sent to DeepSeek for a set of item indices.\n */\n private function buildDeepSeekPayload(array $items, array $indices): array\n {\n $payload = [];\n foreach ($indices as $idx) {\n $payload[] = [\n 'i' => $idx,\n // 400 chars: BOQ lines routinely carry the specs that decide the\n // price (size, grade, rating), and the old 80-char cut dropped them.\n 'd' => mb_substr((string) ($items[$idx]['description'] ?? ''), 0, 400),\n 'cat' => mb_substr((string) ($items[$idx]['category'] ?? ''), 0, 30),\n 'br' => mb_substr((string) ($items[$idx]['brand'] ?? ''), 0, 30),\n 'unit' => mb_substr((string) ($items[$idx]['unit'] ?? ''), 0, 15),\n ];\n }\n return $payload;\n }\n\n /**\n * Build the DeepSeek prompt string for a given payload array.\n */\n private function buildPricingPrompt(array $payload): string\n {\n return 'You are a procurement pricing expert for the Saudi Arabia construction and MEP materials market. '\n . 'Estimate a realistic current unit price in SAR for each item below. '\n . 'RULES: '\n . '(1) Always return a positive non-zero price — use your best market estimate, never 0. '\n . '(2) Base prices on typical Saudi supplier/contractor rates for 2024-2026. '\n . '(3) Return ONLY a valid compact JSON array with NO whitespace or newlines between elements. '\n . '(4) Each element must be exactly: {\"i\":<index>,\"p\":<price_number>} '\n . '(5) No markdown, no explanation, no extra keys, no pretty-printing. '\n . 'Example output: [{\"i\":0,\"p\":1500},{\"i\":1,\"p\":350}] '\n . 'Items: ' . json_encode($payload, JSON_UNESCAPED_UNICODE);\n }\n\n /**\n * Parse a DeepSeek text response and apply prices to $items.\n */\n private function applyDeepSeekText(string $text, array $items, string $model): array\n {\n $text = preg_replace('/^```json\\s*/i', '', trim($text));\n $text = preg_replace('/```\\s*$/i', '', $text);\n\n $priceData = json_decode($text, true);\n\n if (! is_array($priceData)) {\n preg_match_all('/\\{\\s*\"i\"\\s*:\\s*(\\d+)\\s*,\\s*\"p\"\\s*:\\s*([\\d.]+)\\s*\\}/', $text, $matches, PREG_SET_ORDER);\n if (! empty($matches)) {\n $priceData = array_map(fn($m) => ['i' => (int) $m[1], 'p' => (float) $m[2]], $matches);\n Log::info('PricingService: Partial extraction recovered ' . count($priceData) . ' price(s).');\n }\n }\n\n if (! is_array($priceData) || empty($priceData)) {\n Log::warning('PricingService: Could not extract any prices from DeepSeek response.', [\n 'model' => $model,\n 'text_preview' => mb_substr($text, 0, 300),\n ]);\n return $items;\n }\n\n foreach ($priceData as $entry) {\n $idx = $entry['i'] ?? null;\n $price = $entry['p'] ?? null;\n\n if ($idx === null || ! array_key_exists($idx, $items)) {\n continue;\n }\n\n $price = is_numeric($price) ? (float) $price : 0.0;\n\n if ($price > 0) {\n $items[$idx]['unit_price'] = $price;\n $items[$idx]['price_source'] = 'deepseek';\n }\n }\n\n return $items;\n }\n\n /**\n * Send multiple chunks to DeepSeek in PARALLEL using Http::pool().\n * Falls back to sequential per-chunk calls if pool fails.\n *\n * @param array<int, array<string, mixed>> $items\n * @param list<list<int>> $chunks\n * @return array<int, array<string, mixed>>\n */\n private function enrichChunksParallel(array $items, array $chunks): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n $model = (string) config('services.deepseek.model', 'deepseek-chat');\n\n if (empty($apiKey)) {\n Log::warning('PricingService: DEEPSEEK_API_KEY not configured; skipping AI pricing.');\n return $items;\n }\n\n $url = 'https://api.deepseek.com/chat/completions';\n\n // Build request bodies once\n $requestBodies = [];\n foreach ($chunks as $ci => $chunkIndices) {\n $payload = $this->buildDeepSeekPayload($items, $chunkIndices);\n $requestBodies[$ci] = [\n 'model' => $model,\n 'messages' => [\n ['role' => 'user', 'content' => $this->buildPricingPrompt($payload)],\n ],\n 'temperature' => 0,\n 'max_tokens' => 8192,\n 'user' => 'Qimta_Platform',\n ];\n }\n\n $failedChunks = array_keys($chunks);\n\n // Single attempt with the configured model\n if (! empty($failedChunks)) {\n $pendingIndices = $failedChunks;\n $failedChunks = [];\n\n try {\n $responses = Http::pool(function (Pool $pool) use ($url, $apiKey, $requestBodies, $pendingIndices) {\n $reqs = [];\n foreach ($pendingIndices as $ci) {\n $reqs[] = $pool->as((string) $ci)\n ->timeout(90)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post($url, $requestBodies[$ci]);\n }\n return $reqs;\n });\n\n foreach ($responses as $key => $response) {\n $ci = (int) $key;\n\n if (! $response->successful()) {\n Log::warning('PricingService: Parallel chunk failed, will retry sequentially.', [\n 'model' => $model,\n 'chunk' => $ci,\n 'status' => $response->status(),\n ]);\n $failedChunks[] = $ci;\n continue;\n }\n\n $text = $response->json('choices.0.message.content') ?? '';\n $items = $this->applyDeepSeekText($text, $items, $model);\n\n // Released as soon as it is parsed. max_tokens is 8192, and\n // Guzzle buffers each response in full — holding a whole\n // pool's worth is what exhausted the memory limit.\n unset($responses[$key], $response, $text);\n }\n\n unset($responses);\n } catch (\\Throwable $e) {\n Log::error('PricingService: Http::pool exception, falling back to serial.', [\n 'model' => $model,\n 'message' => $e->getMessage(),\n ]);\n foreach ($pendingIndices as $ci) {\n $items = $this->enrichWithDeepSeek($items, $chunks[$ci]);\n }\n return $items;\n }\n }\n\n if (! empty($failedChunks)) {\n foreach ($failedChunks as $ci) {\n $items = $this->enrichWithDeepSeek($items, $chunks[$ci]);\n }\n }\n\n return $items;\n }\n\n // -------------------------------------------------------------------------\n // DeepSeek fallback (single batched request)\n // -------------------------------------------------------------------------\n\n /**\n * Send all unmatched items to DeepSeek in ONE request to minimise token consumption.\n * Only the fields DeepSeek actually needs are sent (description, category, brand, unit).\n * Output tokens are capped via max_tokens to protect against runaway usage.\n *\n * @param array<int, array<string, mixed>> $items\n * @param list<int> $unmatchedIndices\n * @return array<int, array<string, mixed>>\n */\n private function enrichWithDeepSeek(array $items, array $unmatchedIndices): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n $model = (string) config('services.deepseek.model', 'deepseek-chat');\n\n if (empty($apiKey)) {\n Log::warning('PricingService: DEEPSEEK_API_KEY not configured; skipping AI pricing.');\n return $items;\n }\n\n $payload = $this->buildDeepSeekPayload($items, $unmatchedIndices);\n $prompt = $this->buildPricingPrompt($payload);\n $lastStatus = null;\n\n try {\n $response = Http::timeout(90)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'user', 'content' => $prompt],\n ],\n 'temperature' => 0,\n 'max_tokens' => 8192,\n 'user' => 'Qimta_Platform',\n ]);\n\n if (! $response->successful()) {\n $lastStatus = $response->status();\n Log::warning('PricingService: DeepSeek request failed.', ['model' => $model, 'status' => $lastStatus]);\n } else {\n $text = $response->json('choices.0.message.content') ?? '';\n $items = $this->applyDeepSeekText($text, $items, $model);\n return $items;\n }\n } catch (\\Throwable $e) {\n Log::error('PricingService: Exception calling DeepSeek.', ['model' => $model, 'message' => $e->getMessage()]);\n }\n\n Log::error('PricingService: DeepSeek request failed.', ['last_status' => $lastStatus]);\n\n return $items;\n }\n}\n"}, "883243d8e23c5409eda619b21c0be21c7b07a5f2": {"t": 1785748353.0120487, "n": 17495, "h": "d6ececa245852310", "c": "<?php\n\nnamespace App\\Services;\n\nuse App\\Models\\Product;\nuse Illuminate\\Http\\Client\\Pool;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\n\nclass PricingService\n{\n /**\n * Items per DeepSeek call. Multiple calls are made in PARALLEL so ALL unpriced items get a price faster.\n * Smaller = more parallel requests = faster overall. 10 items per request is optimal.\n */\n private const DEEPSEEK_CHUNK_SIZE = 10;\n\n /**\n * Concurrent AI requests per pool batch.\n *\n * Http::pool sends everything it is handed simultaneously, so an unbounded\n * pool on a large BOQ opens thousands of connections at once and gets\n * rate-limited. Batching keeps the parallelism useful without that.\n */\n private const MAX_PARALLEL_CHUNKS = 4;\n\n /**\n * Optional progress reporter, called as ($chunksDone, $chunksTotal).\n *\n * @var (callable(int, int): void)|null\n */\n private $onProgress = null;\n\n /** @param callable(int, int): void $callback */\n public function onProgress(callable $callback): self\n {\n $this->onProgress = $callback;\n return $this;\n }\n\n private function reportProgress(int $done, int $total): void\n {\n if ($this->onProgress !== null) {\n ($this->onProgress)($done, $total);\n }\n }\n\n /**\n * Fetch unit prices for an array of quotation items.\n *\n * Strategy:\n * 1. Try the products table first (keyword match on name + optional category match).\n * 2. Collect all unmatched items and send them in ONE batched DeepSeek call.\n *\n * @param array<int, array<string, mixed>> $items\n * @return array<int, array<string, mixed>> Items enriched with unit_price, price_source, price_status\n */\n public function fetchPrices(array $items): array\n {\n $unmatched = [];\n\n foreach ($items as $index => $item) {\n // Skip already-priced rows (e.g., re-fetch after partial approval)\n if (! empty($item['unit_price'])) {\n continue;\n }\n\n $price = $this->lookupProductsTable($item);\n\n if ($price !== null) {\n $items[$index]['unit_price'] = $price;\n $items[$index]['price_source'] = 'products';\n $items[$index]['price_status'] = 'pending';\n } else {\n $items[$index]['unit_price'] = null;\n $items[$index]['price_source'] = null;\n $items[$index]['price_status'] = 'pending';\n $unmatched[] = $index;\n }\n }\n\n if (! empty($unmatched)) {\n $chunks = array_chunk($unmatched, self::DEEPSEEK_CHUNK_SIZE);\n\n if (count($chunks) === 1) {\n // Single chunk — direct call (no pool overhead)\n $items = $this->enrichWithDeepSeek($items, $chunks[0]);\n } else {\n // Multiple chunks. Http::pool fires every request it is given at\n // once, so a large BOQ would open thousands of concurrent calls\n // and be rate-limited into mass failure. Run the pool in batches\n // instead: still parallel, but bounded.\n $batches = array_chunk($chunks, self::MAX_PARALLEL_CHUNKS);\n $total = count($chunks);\n $done = 0;\n\n foreach ($batches as $batch) {\n $items = $this->enrichChunksParallel($items, $batch);\n\n $done += count($batch);\n $this->reportProgress($done, $total);\n }\n }\n }\n\n return $items;\n }\n\n // -------------------------------------------------------------------------\n // Products table lookup\n // -------------------------------------------------------------------------\n\n /**\n * Search the products table for a matching product by keyword.\n * Returns the unit_price when a confident match is found, null otherwise.\n */\n private function lookupProductsTable(array $item): ?float\n {\n $description = trim((string) ($item['description'] ?? ''));\n\n if (empty($description)) {\n return null;\n }\n\n // Extract significant words (≥ 3 chars)\n $words = array_values(array_filter(\n explode(' ', preg_replace('/[^a-zA-Z0-9\\s]/', ' ', $description)),\n fn(string $w) => strlen($w) >= 3\n ));\n\n if (empty($words)) {\n return null;\n }\n\n $query = Product::query()\n ->whereNotNull('unit_price')\n ->where('unit_price', '>', 0)\n ->where('active', true);\n\n // Require up to 4 keywords to appear in the product name (AND logic)\n foreach (array_slice($words, 0, 4) as $word) {\n $query->where('name', 'like', '%' . $word . '%');\n }\n\n // Narrow by category name if available\n $category = trim((string) ($item['category'] ?? ''));\n if ($category !== '') {\n $query->whereHas('category', fn($q) => $q->where('name', 'like', '%' . $category . '%'));\n }\n\n // CRITICAL: only accept a price whose UNIT matches the item's unit.\n // A product priced per m² must never be used to price an item measured in m³ —\n // that would return a real number that is the wrong \"unit price\".\n $unit = $this->normalizeUnit((string) ($item['unit'] ?? ''));\n if ($unit === '') {\n // No unit on the item → we cannot guarantee the price is per the correct unit.\n // Fall through to the AI estimator, which is told the unit explicitly.\n return null;\n }\n\n $query->whereHas('unit', function ($q) use ($unit) {\n $q->whereRaw('LOWER(TRIM(name)) = ?', [$unit])\n ->orWhereRaw('LOWER(TRIM(symbol)) = ?', [$unit]);\n });\n\n $product = $query->select('unit_price')->first();\n\n return $product ? (float) $product->unit_price : null;\n }\n\n /**\n * Canonicalize a free-text unit string so equivalent spellings match.\n * e.g. \"م³\", \"م3\", \"م^3\", \"M3\", \"متر مكعب\", \"cubic meter\" → \"م3\".\n * Returns '' when the input is empty.\n */\n private function normalizeUnit(string $unit): string\n {\n $u = trim(mb_strtolower($unit));\n if ($u === '') {\n return '';\n }\n\n // Normalize superscripts and notation: م³ / م^3 / م 3 → م3\n $u = strtr($u, ['²' => '2', '³' => '3', '^' => '', '.' => '']);\n $u = preg_replace('/\\s+/', '', $u);\n\n // Map common synonyms to a single canonical token.\n static $map = [\n 'متر مكعب' => 'م3', 'مترمكعب' => 'م3', 'cubicmeter' => 'م3', 'cubicmetre' => 'م3', 'cbm' => 'م3', 'm3' => 'م3',\n 'متر مربع' => 'م2', 'مترمربع' => 'م2', 'squaremeter' => 'م2', 'squaremetre' => 'م2', 'sqm' => 'م2', 'm2' => 'م2',\n 'متر طولي' => 'مط', 'مترطولي' => 'مط', 'linearmeter' => 'مط', 'lm' => 'مط',\n 'متر' => 'م', 'meter' => 'م', 'metre' => 'م',\n 'عدد' => 'عدد', 'piece' => 'عدد', 'pcs' => 'عدد', 'pc' => 'عدد', 'no' => 'عدد', 'nos' => 'عدد', 'unit' => 'عدد',\n 'كيلو' => 'كجم', 'كيلوغرام' => 'كجم', 'كيلوجرام' => 'كجم', 'kg' => 'كجم', 'kilogram' => 'كجم',\n 'طن' => 'طن', 'ton' => 'طن', 'tonne' => 'طن',\n 'لتر' => 'لتر', 'liter' => 'لتر', 'litre' => 'لتر', 'l' => 'لتر',\n 'كيس' => 'كيس', 'bag' => 'كيس', 'bags' => 'كيس',\n ];\n\n return $map[$u] ?? $u;\n }\n\n // -------------------------------------------------------------------------\n // DeepSeek fallback — helpers\n // -------------------------------------------------------------------------\n\n /**\n * Build the compact payload sent to DeepSeek for a set of item indices.\n */\n private function buildDeepSeekPayload(array $items, array $indices): array\n {\n $payload = [];\n foreach ($indices as $idx) {\n $payload[] = [\n 'i' => $idx,\n // 400 chars: BOQ lines routinely carry the specs that decide the\n // price (size, grade, rating), and the old 80-char cut dropped them.\n 'd' => mb_substr((string) ($items[$idx]['description'] ?? ''), 0, 400),\n 'cat' => mb_substr((string) ($items[$idx]['category'] ?? ''), 0, 30),\n 'br' => mb_substr((string) ($items[$idx]['brand'] ?? ''), 0, 30),\n 'unit' => mb_substr((string) ($items[$idx]['unit'] ?? ''), 0, 15),\n ];\n }\n return $payload;\n }\n\n /**\n * Build the DeepSeek prompt string for a given payload array.\n */\n private function buildPricingPrompt(array $payload): string\n {\n return 'You are a procurement pricing expert for the Saudi Arabia construction and MEP materials market. '\n . 'Estimate a realistic current unit price in SAR for each item below. '\n . 'RULES: '\n . '(1) Always return a positive non-zero price — use your best market estimate, never 0. '\n . '(2) Base prices on typical Saudi supplier/contractor rates for 2024-2026. '\n . '(3) Return ONLY a valid compact JSON array with NO whitespace or newlines between elements. '\n . '(4) Each element must be exactly: {\"i\":<index>,\"p\":<price_number>} '\n . '(5) No markdown, no explanation, no extra keys, no pretty-printing. '\n . 'Example output: [{\"i\":0,\"p\":1500},{\"i\":1,\"p\":350}] '\n . 'Items: ' . json_encode($payload, JSON_UNESCAPED_UNICODE);\n }\n\n /**\n * Parse a DeepSeek text response and apply prices to $items.\n */\n private function applyDeepSeekText(string $text, array $items, string $model): array\n {\n $text = preg_replace('/^```json\\s*/i', '', trim($text));\n $text = preg_replace('/```\\s*$/i', '', $text);\n\n $priceData = json_decode($text, true);\n\n if (! is_array($priceData)) {\n preg_match_all('/\\{\\s*\"i\"\\s*:\\s*(\\d+)\\s*,\\s*\"p\"\\s*:\\s*([\\d.]+)\\s*\\}/', $text, $matches, PREG_SET_ORDER);\n if (! empty($matches)) {\n $priceData = array_map(fn($m) => ['i' => (int) $m[1], 'p' => (float) $m[2]], $matches);\n Log::info('PricingService: Partial extraction recovered ' . count($priceData) . ' price(s).');\n }\n }\n\n if (! is_array($priceData) || empty($priceData)) {\n Log::warning('PricingService: Could not extract any prices from DeepSeek response.', [\n 'model' => $model,\n 'text_preview' => mb_substr($text, 0, 300),\n ]);\n return $items;\n }\n\n foreach ($priceData as $entry) {\n $idx = $entry['i'] ?? null;\n $price = $entry['p'] ?? null;\n\n if ($idx === null || ! array_key_exists($idx, $items)) {\n continue;\n }\n\n $price = is_numeric($price) ? (float) $price : 0.0;\n\n if ($price > 0) {\n $items[$idx]['unit_price'] = $price;\n $items[$idx]['price_source'] = 'deepseek';\n }\n }\n\n return $items;\n }\n\n /**\n * Send multiple chunks to DeepSeek in PARALLEL using Http::pool().\n * Falls back to sequential per-chunk calls if pool fails.\n *\n * @param array<int, array<string, mixed>> $items\n * @param list<list<int>> $chunks\n * @return array<int, array<string, mixed>>\n */\n private function enrichChunksParallel(array $items, array $chunks): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n $model = (string) config('services.deepseek.model', 'deepseek-chat');\n\n if (empty($apiKey)) {\n Log::warning('PricingService: DEEPSEEK_API_KEY not configured; skipping AI pricing.');\n return $items;\n }\n\n $url = 'https://api.deepseek.com/chat/completions';\n\n // Build request bodies once\n $requestBodies = [];\n foreach ($chunks as $ci => $chunkIndices) {\n $payload = $this->buildDeepSeekPayload($items, $chunkIndices);\n $requestBodies[$ci] = [\n 'model' => $model,\n 'messages' => [\n ['role' => 'user', 'content' => $this->buildPricingPrompt($payload)],\n ],\n 'temperature' => 0.2,\n 'max_tokens' => 8192,\n 'user' => 'Qimta_Platform',\n ];\n }\n\n $failedChunks = array_keys($chunks);\n\n // Single attempt with the configured model\n if (! empty($failedChunks)) {\n $pendingIndices = $failedChunks;\n $failedChunks = [];\n\n try {\n $responses = Http::pool(function (Pool $pool) use ($url, $apiKey, $requestBodies, $pendingIndices) {\n $reqs = [];\n foreach ($pendingIndices as $ci) {\n $reqs[] = $pool->as((string) $ci)\n ->timeout(90)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post($url, $requestBodies[$ci]);\n }\n return $reqs;\n });\n\n foreach ($responses as $key => $response) {\n $ci = (int) $key;\n\n if (! $response->successful()) {\n Log::warning('PricingService: Parallel chunk failed, will retry sequentially.', [\n 'model' => $model,\n 'chunk' => $ci,\n 'status' => $response->status(),\n ]);\n $failedChunks[] = $ci;\n continue;\n }\n\n $text = $response->json('choices.0.message.content') ?? '';\n $items = $this->applyDeepSeekText($text, $items, $model);\n\n // Released as soon as it is parsed. max_tokens is 8192, and\n // Guzzle buffers each response in full — holding a whole\n // pool's worth is what exhausted the memory limit.\n unset($responses[$key], $response, $text);\n }\n\n unset($responses);\n } catch (\\Throwable $e) {\n Log::error('PricingService: Http::pool exception, falling back to serial.', [\n 'model' => $model,\n 'message' => $e->getMessage(),\n ]);\n foreach ($pendingIndices as $ci) {\n $items = $this->enrichWithDeepSeek($items, $chunks[$ci]);\n }\n return $items;\n }\n }\n\n if (! empty($failedChunks)) {\n foreach ($failedChunks as $ci) {\n $items = $this->enrichWithDeepSeek($items, $chunks[$ci]);\n }\n }\n\n return $items;\n }\n\n // -------------------------------------------------------------------------\n // DeepSeek fallback (single batched request)\n // -------------------------------------------------------------------------\n\n /**\n * Send all unmatched items to DeepSeek in ONE request to minimise token consumption.\n * Only the fields DeepSeek actually needs are sent (description, category, brand, unit).\n * Output tokens are capped via max_tokens to protect against runaway usage.\n *\n * @param array<int, array<string, mixed>> $items\n * @param list<int> $unmatchedIndices\n * @return array<int, array<string, mixed>>\n */\n private function enrichWithDeepSeek(array $items, array $unmatchedIndices): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n $model = (string) config('services.deepseek.model', 'deepseek-chat');\n\n if (empty($apiKey)) {\n Log::warning('PricingService: DEEPSEEK_API_KEY not configured; skipping AI pricing.');\n return $items;\n }\n\n $payload = $this->buildDeepSeekPayload($items, $unmatchedIndices);\n $prompt = $this->buildPricingPrompt($payload);\n $lastStatus = null;\n\n try {\n $response = Http::timeout(90)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'user', 'content' => $prompt],\n ],\n 'temperature' => 0.2,\n 'max_tokens' => 8192,\n 'user' => 'Qimta_Platform',\n ]);\n\n if (! $response->successful()) {\n $lastStatus = $response->status();\n Log::warning('PricingService: DeepSeek request failed.', ['model' => $model, 'status' => $lastStatus]);\n } else {\n $text = $response->json('choices.0.message.content') ?? '';\n $items = $this->applyDeepSeekText($text, $items, $model);\n return $items;\n }\n } catch (\\Throwable $e) {\n Log::error('PricingService: Exception calling DeepSeek.', ['model' => $model, 'message' => $e->getMessage()]);\n }\n\n Log::error('PricingService: DeepSeek request failed.', ['last_status' => $lastStatus]);\n\n return $items;\n }\n}\n"}, "6dec2d69c09238721891e16d9089f7f02c568f15": {"t": 1785748354.1841776, "n": 17164, "h": "5c4d6ccb8f09c4fe", "c": "<?php\n\nnamespace App\\Services;\n\nuse App\\Models\\Product;\nuse Illuminate\\Http\\Client\\Pool;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\n\nclass PricingService\n{\n /**\n * Items per DeepSeek call. Multiple calls are made in PARALLEL so ALL unpriced items get a price faster.\n * Smaller = more parallel requests = faster overall. 10 items per request is optimal.\n */\n private const DEEPSEEK_CHUNK_SIZE = 10;\n\n /**\n * Concurrent AI requests per pool batch.\n *\n * Http::pool sends everything it is handed simultaneously, so an unbounded\n * pool on a large BOQ opens thousands of connections at once and gets\n * rate-limited. Batching keeps the parallelism useful without that.\n */\n private const MAX_PARALLEL_CHUNKS = 8;\n\n /**\n * Optional progress reporter, called as ($chunksDone, $chunksTotal).\n *\n * @var (callable(int, int): void)|null\n */\n private $onProgress = null;\n\n /** @param callable(int, int): void $callback */\n public function onProgress(callable $callback): self\n {\n $this->onProgress = $callback;\n return $this;\n }\n\n private function reportProgress(int $done, int $total): void\n {\n if ($this->onProgress !== null) {\n ($this->onProgress)($done, $total);\n }\n }\n\n /**\n * Fetch unit prices for an array of quotation items.\n *\n * Strategy:\n * 1. Try the products table first (keyword match on name + optional category match).\n * 2. Collect all unmatched items and send them in ONE batched DeepSeek call.\n *\n * @param array<int, array<string, mixed>> $items\n * @return array<int, array<string, mixed>> Items enriched with unit_price, price_source, price_status\n */\n public function fetchPrices(array $items): array\n {\n $unmatched = [];\n\n foreach ($items as $index => $item) {\n // Skip already-priced rows (e.g., re-fetch after partial approval)\n if (! empty($item['unit_price'])) {\n continue;\n }\n\n $price = $this->lookupProductsTable($item);\n\n if ($price !== null) {\n $items[$index]['unit_price'] = $price;\n $items[$index]['price_source'] = 'products';\n $items[$index]['price_status'] = 'pending';\n } else {\n $items[$index]['unit_price'] = null;\n $items[$index]['price_source'] = null;\n $items[$index]['price_status'] = 'pending';\n $unmatched[] = $index;\n }\n }\n\n if (! empty($unmatched)) {\n $chunks = array_chunk($unmatched, self::DEEPSEEK_CHUNK_SIZE);\n\n if (count($chunks) === 1) {\n // Single chunk — direct call (no pool overhead)\n $items = $this->enrichWithDeepSeek($items, $chunks[0]);\n } else {\n // Multiple chunks. Http::pool fires every request it is given at\n // once, so a large BOQ would open thousands of concurrent calls\n // and be rate-limited into mass failure. Run the pool in batches\n // instead: still parallel, but bounded.\n $batches = array_chunk($chunks, self::MAX_PARALLEL_CHUNKS);\n $total = count($chunks);\n $done = 0;\n\n foreach ($batches as $batch) {\n $items = $this->enrichChunksParallel($items, $batch);\n\n $done += count($batch);\n $this->reportProgress($done, $total);\n }\n }\n }\n\n return $items;\n }\n\n // -------------------------------------------------------------------------\n // Products table lookup\n // -------------------------------------------------------------------------\n\n /**\n * Search the products table for a matching product by keyword.\n * Returns the unit_price when a confident match is found, null otherwise.\n */\n private function lookupProductsTable(array $item): ?float\n {\n $description = trim((string) ($item['description'] ?? ''));\n\n if (empty($description)) {\n return null;\n }\n\n // Extract significant words (≥ 3 chars)\n $words = array_values(array_filter(\n explode(' ', preg_replace('/[^a-zA-Z0-9\\s]/', ' ', $description)),\n fn(string $w) => strlen($w) >= 3\n ));\n\n if (empty($words)) {\n return null;\n }\n\n $query = Product::query()\n ->whereNotNull('unit_price')\n ->where('unit_price', '>', 0)\n ->where('active', true);\n\n // Require up to 4 keywords to appear in the product name (AND logic)\n foreach (array_slice($words, 0, 4) as $word) {\n $query->where('name', 'like', '%' . $word . '%');\n }\n\n // Narrow by category name if available\n $category = trim((string) ($item['category'] ?? ''));\n if ($category !== '') {\n $query->whereHas('category', fn($q) => $q->where('name', 'like', '%' . $category . '%'));\n }\n\n // CRITICAL: only accept a price whose UNIT matches the item's unit.\n // A product priced per m² must never be used to price an item measured in m³ —\n // that would return a real number that is the wrong \"unit price\".\n $unit = $this->normalizeUnit((string) ($item['unit'] ?? ''));\n if ($unit === '') {\n // No unit on the item → we cannot guarantee the price is per the correct unit.\n // Fall through to the AI estimator, which is told the unit explicitly.\n return null;\n }\n\n $query->whereHas('unit', function ($q) use ($unit) {\n $q->whereRaw('LOWER(TRIM(name)) = ?', [$unit])\n ->orWhereRaw('LOWER(TRIM(symbol)) = ?', [$unit]);\n });\n\n $product = $query->select('unit_price')->first();\n\n return $product ? (float) $product->unit_price : null;\n }\n\n /**\n * Canonicalize a free-text unit string so equivalent spellings match.\n * e.g. \"م³\", \"م3\", \"م^3\", \"M3\", \"متر مكعب\", \"cubic meter\" → \"م3\".\n * Returns '' when the input is empty.\n */\n private function normalizeUnit(string $unit): string\n {\n $u = trim(mb_strtolower($unit));\n if ($u === '') {\n return '';\n }\n\n // Normalize superscripts and notation: م³ / م^3 / م 3 → م3\n $u = strtr($u, ['²' => '2', '³' => '3', '^' => '', '.' => '']);\n $u = preg_replace('/\\s+/', '', $u);\n\n // Map common synonyms to a single canonical token.\n static $map = [\n 'متر مكعب' => 'م3', 'مترمكعب' => 'م3', 'cubicmeter' => 'م3', 'cubicmetre' => 'م3', 'cbm' => 'م3', 'm3' => 'م3',\n 'متر مربع' => 'م2', 'مترمربع' => 'م2', 'squaremeter' => 'م2', 'squaremetre' => 'م2', 'sqm' => 'م2', 'm2' => 'م2',\n 'متر طولي' => 'مط', 'مترطولي' => 'مط', 'linearmeter' => 'مط', 'lm' => 'مط',\n 'متر' => 'م', 'meter' => 'م', 'metre' => 'م',\n 'عدد' => 'عدد', 'piece' => 'عدد', 'pcs' => 'عدد', 'pc' => 'عدد', 'no' => 'عدد', 'nos' => 'عدد', 'unit' => 'عدد',\n 'كيلو' => 'كجم', 'كيلوغرام' => 'كجم', 'كيلوجرام' => 'كجم', 'kg' => 'كجم', 'kilogram' => 'كجم',\n 'طن' => 'طن', 'ton' => 'طن', 'tonne' => 'طن',\n 'لتر' => 'لتر', 'liter' => 'لتر', 'litre' => 'لتر', 'l' => 'لتر',\n 'كيس' => 'كيس', 'bag' => 'كيس', 'bags' => 'كيس',\n ];\n\n return $map[$u] ?? $u;\n }\n\n // -------------------------------------------------------------------------\n // DeepSeek fallback — helpers\n // -------------------------------------------------------------------------\n\n /**\n * Build the compact payload sent to DeepSeek for a set of item indices.\n */\n private function buildDeepSeekPayload(array $items, array $indices): array\n {\n $payload = [];\n foreach ($indices as $idx) {\n $payload[] = [\n 'i' => $idx,\n // 400 chars: BOQ lines routinely carry the specs that decide the\n // price (size, grade, rating), and the old 80-char cut dropped them.\n 'd' => mb_substr((string) ($items[$idx]['description'] ?? ''), 0, 400),\n 'cat' => mb_substr((string) ($items[$idx]['category'] ?? ''), 0, 30),\n 'br' => mb_substr((string) ($items[$idx]['brand'] ?? ''), 0, 30),\n 'unit' => mb_substr((string) ($items[$idx]['unit'] ?? ''), 0, 15),\n ];\n }\n return $payload;\n }\n\n /**\n * Build the DeepSeek prompt string for a given payload array.\n */\n private function buildPricingPrompt(array $payload): string\n {\n return 'You are a procurement pricing expert for the Saudi Arabia construction and MEP materials market. '\n . 'Estimate a realistic current unit price in SAR for each item below. '\n . 'RULES: '\n . '(1) Always return a positive non-zero price — use your best market estimate, never 0. '\n . '(2) Base prices on typical Saudi supplier/contractor rates for 2024-2026. '\n . '(3) Return ONLY a valid compact JSON array with NO whitespace or newlines between elements. '\n . '(4) Each element must be exactly: {\"i\":<index>,\"p\":<price_number>} '\n . '(5) No markdown, no explanation, no extra keys, no pretty-printing. '\n . 'Example output: [{\"i\":0,\"p\":1500},{\"i\":1,\"p\":350}] '\n . 'Items: ' . json_encode($payload, JSON_UNESCAPED_UNICODE);\n }\n\n /**\n * Parse a DeepSeek text response and apply prices to $items.\n */\n private function applyDeepSeekText(string $text, array $items, string $model): array\n {\n $text = preg_replace('/^```json\\s*/i', '', trim($text));\n $text = preg_replace('/```\\s*$/i', '', $text);\n\n $priceData = json_decode($text, true);\n\n if (! is_array($priceData)) {\n preg_match_all('/\\{\\s*\"i\"\\s*:\\s*(\\d+)\\s*,\\s*\"p\"\\s*:\\s*([\\d.]+)\\s*\\}/', $text, $matches, PREG_SET_ORDER);\n if (! empty($matches)) {\n $priceData = array_map(fn($m) => ['i' => (int) $m[1], 'p' => (float) $m[2]], $matches);\n Log::info('PricingService: Partial extraction recovered ' . count($priceData) . ' price(s).');\n }\n }\n\n if (! is_array($priceData) || empty($priceData)) {\n Log::warning('PricingService: Could not extract any prices from DeepSeek response.', [\n 'model' => $model,\n 'text_preview' => mb_substr($text, 0, 300),\n ]);\n return $items;\n }\n\n foreach ($priceData as $entry) {\n $idx = $entry['i'] ?? null;\n $price = $entry['p'] ?? null;\n\n if ($idx === null || ! array_key_exists($idx, $items)) {\n continue;\n }\n\n $price = is_numeric($price) ? (float) $price : 0.0;\n\n if ($price > 0) {\n $items[$idx]['unit_price'] = $price;\n $items[$idx]['price_source'] = 'deepseek';\n }\n }\n\n return $items;\n }\n\n /**\n * Send multiple chunks to DeepSeek in PARALLEL using Http::pool().\n * Falls back to sequential per-chunk calls if pool fails.\n *\n * @param array<int, array<string, mixed>> $items\n * @param list<list<int>> $chunks\n * @return array<int, array<string, mixed>>\n */\n private function enrichChunksParallel(array $items, array $chunks): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n $model = (string) config('services.deepseek.model', 'deepseek-chat');\n\n if (empty($apiKey)) {\n Log::warning('PricingService: DEEPSEEK_API_KEY not configured; skipping AI pricing.');\n return $items;\n }\n\n $url = 'https://api.deepseek.com/chat/completions';\n\n // Build request bodies once\n $requestBodies = [];\n foreach ($chunks as $ci => $chunkIndices) {\n $payload = $this->buildDeepSeekPayload($items, $chunkIndices);\n $requestBodies[$ci] = [\n 'model' => $model,\n 'messages' => [\n ['role' => 'user', 'content' => $this->buildPricingPrompt($payload)],\n ],\n 'temperature' => 0.2,\n 'max_tokens' => 8192,\n 'user' => 'Qimta_Platform',\n ];\n }\n\n $failedChunks = array_keys($chunks);\n\n // Single attempt with the configured model\n if (! empty($failedChunks)) {\n $pendingIndices = $failedChunks;\n $failedChunks = [];\n\n try {\n $responses = Http::pool(function (Pool $pool) use ($url, $apiKey, $requestBodies, $pendingIndices) {\n $reqs = [];\n foreach ($pendingIndices as $ci) {\n $reqs[] = $pool->as((string) $ci)\n ->timeout(90)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post($url, $requestBodies[$ci]);\n }\n return $reqs;\n });\n\n foreach ($responses as $key => $response) {\n $ci = (int) $key;\n\n if (! $response->successful()) {\n Log::warning('PricingService: Parallel chunk failed, will retry sequentially.', [\n 'model' => $model,\n 'chunk' => $ci,\n 'status' => $response->status(),\n ]);\n $failedChunks[] = $ci;\n continue;\n }\n\n $text = $response->json('choices.0.message.content') ?? '';\n $items = $this->applyDeepSeekText($text, $items, $model);\n }\n } catch (\\Throwable $e) {\n Log::error('PricingService: Http::pool exception, falling back to serial.', [\n 'model' => $model,\n 'message' => $e->getMessage(),\n ]);\n foreach ($pendingIndices as $ci) {\n $items = $this->enrichWithDeepSeek($items, $chunks[$ci]);\n }\n return $items;\n }\n }\n\n if (! empty($failedChunks)) {\n foreach ($failedChunks as $ci) {\n $items = $this->enrichWithDeepSeek($items, $chunks[$ci]);\n }\n }\n\n return $items;\n }\n\n // -------------------------------------------------------------------------\n // DeepSeek fallback (single batched request)\n // -------------------------------------------------------------------------\n\n /**\n * Send all unmatched items to DeepSeek in ONE request to minimise token consumption.\n * Only the fields DeepSeek actually needs are sent (description, category, brand, unit).\n * Output tokens are capped via max_tokens to protect against runaway usage.\n *\n * @param array<int, array<string, mixed>> $items\n * @param list<int> $unmatchedIndices\n * @return array<int, array<string, mixed>>\n */\n private function enrichWithDeepSeek(array $items, array $unmatchedIndices): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n $model = (string) config('services.deepseek.model', 'deepseek-chat');\n\n if (empty($apiKey)) {\n Log::warning('PricingService: DEEPSEEK_API_KEY not configured; skipping AI pricing.');\n return $items;\n }\n\n $payload = $this->buildDeepSeekPayload($items, $unmatchedIndices);\n $prompt = $this->buildPricingPrompt($payload);\n $lastStatus = null;\n\n try {\n $response = Http::timeout(90)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'user', 'content' => $prompt],\n ],\n 'temperature' => 0.2,\n 'max_tokens' => 8192,\n 'user' => 'Qimta_Platform',\n ]);\n\n if (! $response->successful()) {\n $lastStatus = $response->status();\n Log::warning('PricingService: DeepSeek request failed.', ['model' => $model, 'status' => $lastStatus]);\n } else {\n $text = $response->json('choices.0.message.content') ?? '';\n $items = $this->applyDeepSeekText($text, $items, $model);\n return $items;\n }\n } catch (\\Throwable $e) {\n Log::error('PricingService: Exception calling DeepSeek.', ['model' => $model, 'message' => $e->getMessage()]);\n }\n\n Log::error('PricingService: DeepSeek request failed.', ['last_status' => $lastStatus]);\n\n return $items;\n }\n}\n"}, "ca5c8d92c52e7a1a84de17de7cd5ce2ab473f372": {"t": 1785748355.3735616, "n": 15733, "h": "797d2e4c1cfad463", "c": "<?php\n\nnamespace App\\Services;\n\nuse App\\Models\\Product;\nuse Illuminate\\Http\\Client\\Pool;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\n\nclass PricingService\n{\n /**\n * Items per DeepSeek call. Multiple calls are made in PARALLEL so ALL unpriced items get a price faster.\n * Smaller = more parallel requests = faster overall. 10 items per request is optimal.\n */\n private const DEEPSEEK_CHUNK_SIZE = 10;\n\n /**\n * Fetch unit prices for an array of quotation items.\n *\n * Strategy:\n * 1. Try the products table first (keyword match on name + optional category match).\n * 2. Collect all unmatched items and send them in ONE batched DeepSeek call.\n *\n * @param array<int, array<string, mixed>> $items\n * @return array<int, array<string, mixed>> Items enriched with unit_price, price_source, price_status\n */\n public function fetchPrices(array $items): array\n {\n $unmatched = [];\n\n foreach ($items as $index => $item) {\n // Skip already-priced rows (e.g., re-fetch after partial approval)\n if (! empty($item['unit_price'])) {\n continue;\n }\n\n $price = $this->lookupProductsTable($item);\n\n if ($price !== null) {\n $items[$index]['unit_price'] = $price;\n $items[$index]['price_source'] = 'products';\n $items[$index]['price_status'] = 'pending';\n } else {\n $items[$index]['unit_price'] = null;\n $items[$index]['price_source'] = null;\n $items[$index]['price_status'] = 'pending';\n $unmatched[] = $index;\n }\n }\n\n if (! empty($unmatched)) {\n $chunks = array_chunk($unmatched, self::DEEPSEEK_CHUNK_SIZE);\n\n if (count($chunks) === 1) {\n // Single chunk — direct call (no pool overhead)\n $items = $this->enrichWithDeepSeek($items, $chunks[0]);\n } else {\n // Multiple chunks — send ALL in parallel to save time\n $items = $this->enrichChunksParallel($items, $chunks);\n }\n }\n\n return $items;\n }\n\n // -------------------------------------------------------------------------\n // Products table lookup\n // -------------------------------------------------------------------------\n\n /**\n * Search the products table for a matching product by keyword.\n * Returns the unit_price when a confident match is found, null otherwise.\n */\n private function lookupProductsTable(array $item): ?float\n {\n $description = trim((string) ($item['description'] ?? ''));\n\n if (empty($description)) {\n return null;\n }\n\n // Extract significant words (≥ 3 chars)\n $words = array_values(array_filter(\n explode(' ', preg_replace('/[^a-zA-Z0-9\\s]/', ' ', $description)),\n fn(string $w) => strlen($w) >= 3\n ));\n\n if (empty($words)) {\n return null;\n }\n\n $query = Product::query()\n ->whereNotNull('unit_price')\n ->where('unit_price', '>', 0)\n ->where('active', true);\n\n // Require up to 4 keywords to appear in the product name (AND logic)\n foreach (array_slice($words, 0, 4) as $word) {\n $query->where('name', 'like', '%' . $word . '%');\n }\n\n // Narrow by category name if available\n $category = trim((string) ($item['category'] ?? ''));\n if ($category !== '') {\n $query->whereHas('category', fn($q) => $q->where('name', 'like', '%' . $category . '%'));\n }\n\n // CRITICAL: only accept a price whose UNIT matches the item's unit.\n // A product priced per m² must never be used to price an item measured in m³ —\n // that would return a real number that is the wrong \"unit price\".\n $unit = $this->normalizeUnit((string) ($item['unit'] ?? ''));\n if ($unit === '') {\n // No unit on the item → we cannot guarantee the price is per the correct unit.\n // Fall through to the AI estimator, which is told the unit explicitly.\n return null;\n }\n\n $query->whereHas('unit', function ($q) use ($unit) {\n $q->whereRaw('LOWER(TRIM(name)) = ?', [$unit])\n ->orWhereRaw('LOWER(TRIM(symbol)) = ?', [$unit]);\n });\n\n $product = $query->select('unit_price')->first();\n\n return $product ? (float) $product->unit_price : null;\n }\n\n /**\n * Canonicalize a free-text unit string so equivalent spellings match.\n * e.g. \"م³\", \"م3\", \"م^3\", \"M3\", \"متر مكعب\", \"cubic meter\" → \"م3\".\n * Returns '' when the input is empty.\n */\n private function normalizeUnit(string $unit): string\n {\n $u = trim(mb_strtolower($unit));\n if ($u === '') {\n return '';\n }\n\n // Normalize superscripts and notation: م³ / م^3 / م 3 → م3\n $u = strtr($u, ['²' => '2', '³' => '3', '^' => '', '.' => '']);\n $u = preg_replace('/\\s+/', '', $u);\n\n // Map common synonyms to a single canonical token.\n static $map = [\n 'متر مكعب' => 'م3', 'مترمكعب' => 'م3', 'cubicmeter' => 'م3', 'cubicmetre' => 'م3', 'cbm' => 'م3', 'm3' => 'م3',\n 'متر مربع' => 'م2', 'مترمربع' => 'م2', 'squaremeter' => 'م2', 'squaremetre' => 'م2', 'sqm' => 'م2', 'm2' => 'م2',\n 'متر طولي' => 'مط', 'مترطولي' => 'مط', 'linearmeter' => 'مط', 'lm' => 'مط',\n 'متر' => 'م', 'meter' => 'م', 'metre' => 'م',\n 'عدد' => 'عدد', 'piece' => 'عدد', 'pcs' => 'عدد', 'pc' => 'عدد', 'no' => 'عدد', 'nos' => 'عدد', 'unit' => 'عدد',\n 'كيلو' => 'كجم', 'كيلوغرام' => 'كجم', 'كيلوجرام' => 'كجم', 'kg' => 'كجم', 'kilogram' => 'كجم',\n 'طن' => 'طن', 'ton' => 'طن', 'tonne' => 'طن',\n 'لتر' => 'لتر', 'liter' => 'لتر', 'litre' => 'لتر', 'l' => 'لتر',\n 'كيس' => 'كيس', 'bag' => 'كيس', 'bags' => 'كيس',\n ];\n\n return $map[$u] ?? $u;\n }\n\n // -------------------------------------------------------------------------\n // DeepSeek fallback — helpers\n // -------------------------------------------------------------------------\n\n /**\n * Build the compact payload sent to DeepSeek for a set of item indices.\n */\n private function buildDeepSeekPayload(array $items, array $indices): array\n {\n $payload = [];\n foreach ($indices as $idx) {\n $payload[] = [\n 'i' => $idx,\n // 400 chars: BOQ lines routinely carry the specs that decide the\n // price (size, grade, rating), and the old 80-char cut dropped them.\n 'd' => mb_substr((string) ($items[$idx]['description'] ?? ''), 0, 400),\n 'cat' => mb_substr((string) ($items[$idx]['category'] ?? ''), 0, 30),\n 'br' => mb_substr((string) ($items[$idx]['brand'] ?? ''), 0, 30),\n 'unit' => mb_substr((string) ($items[$idx]['unit'] ?? ''), 0, 15),\n ];\n }\n return $payload;\n }\n\n /**\n * Build the DeepSeek prompt string for a given payload array.\n */\n private function buildPricingPrompt(array $payload): string\n {\n return 'You are a procurement pricing expert for the Saudi Arabia construction and MEP materials market. '\n . 'Estimate a realistic current unit price in SAR for each item below. '\n . 'RULES: '\n . '(1) Always return a positive non-zero price — use your best market estimate, never 0. '\n . '(2) Base prices on typical Saudi supplier/contractor rates for 2024-2026. '\n . '(3) Return ONLY a valid compact JSON array with NO whitespace or newlines between elements. '\n . '(4) Each element must be exactly: {\"i\":<index>,\"p\":<price_number>} '\n . '(5) No markdown, no explanation, no extra keys, no pretty-printing. '\n . 'Example output: [{\"i\":0,\"p\":1500},{\"i\":1,\"p\":350}] '\n . 'Items: ' . json_encode($payload, JSON_UNESCAPED_UNICODE);\n }\n\n /**\n * Parse a DeepSeek text response and apply prices to $items.\n */\n private function applyDeepSeekText(string $text, array $items, string $model): array\n {\n $text = preg_replace('/^```json\\s*/i', '', trim($text));\n $text = preg_replace('/```\\s*$/i', '', $text);\n\n $priceData = json_decode($text, true);\n\n if (! is_array($priceData)) {\n preg_match_all('/\\{\\s*\"i\"\\s*:\\s*(\\d+)\\s*,\\s*\"p\"\\s*:\\s*([\\d.]+)\\s*\\}/', $text, $matches, PREG_SET_ORDER);\n if (! empty($matches)) {\n $priceData = array_map(fn($m) => ['i' => (int) $m[1], 'p' => (float) $m[2]], $matches);\n Log::info('PricingService: Partial extraction recovered ' . count($priceData) . ' price(s).');\n }\n }\n\n if (! is_array($priceData) || empty($priceData)) {\n Log::warning('PricingService: Could not extract any prices from DeepSeek response.', [\n 'model' => $model,\n 'text_preview' => mb_substr($text, 0, 300),\n ]);\n return $items;\n }\n\n foreach ($priceData as $entry) {\n $idx = $entry['i'] ?? null;\n $price = $entry['p'] ?? null;\n\n if ($idx === null || ! array_key_exists($idx, $items)) {\n continue;\n }\n\n $price = is_numeric($price) ? (float) $price : 0.0;\n\n if ($price > 0) {\n $items[$idx]['unit_price'] = $price;\n $items[$idx]['price_source'] = 'deepseek';\n }\n }\n\n return $items;\n }\n\n /**\n * Send multiple chunks to DeepSeek in PARALLEL using Http::pool().\n * Falls back to sequential per-chunk calls if pool fails.\n *\n * @param array<int, array<string, mixed>> $items\n * @param list<list<int>> $chunks\n * @return array<int, array<string, mixed>>\n */\n private function enrichChunksParallel(array $items, array $chunks): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n $model = (string) config('services.deepseek.model', 'deepseek-chat');\n\n if (empty($apiKey)) {\n Log::warning('PricingService: DEEPSEEK_API_KEY not configured; skipping AI pricing.');\n return $items;\n }\n\n $url = 'https://api.deepseek.com/chat/completions';\n\n // Build request bodies once\n $requestBodies = [];\n foreach ($chunks as $ci => $chunkIndices) {\n $payload = $this->buildDeepSeekPayload($items, $chunkIndices);\n $requestBodies[$ci] = [\n 'model' => $model,\n 'messages' => [\n ['role' => 'user', 'content' => $this->buildPricingPrompt($payload)],\n ],\n 'temperature' => 0.2,\n 'max_tokens' => 8192,\n 'user' => 'Qimta_Platform',\n ];\n }\n\n $failedChunks = array_keys($chunks);\n\n // Single attempt with the configured model\n if (! empty($failedChunks)) {\n $pendingIndices = $failedChunks;\n $failedChunks = [];\n\n try {\n $responses = Http::pool(function (Pool $pool) use ($url, $apiKey, $requestBodies, $pendingIndices) {\n $reqs = [];\n foreach ($pendingIndices as $ci) {\n $reqs[] = $pool->as((string) $ci)\n ->timeout(90)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post($url, $requestBodies[$ci]);\n }\n return $reqs;\n });\n\n foreach ($responses as $key => $response) {\n $ci = (int) $key;\n\n if (! $response->successful()) {\n Log::warning('PricingService: Parallel chunk failed, will retry sequentially.', [\n 'model' => $model,\n 'chunk' => $ci,\n 'status' => $response->status(),\n ]);\n $failedChunks[] = $ci;\n continue;\n }\n\n $text = $response->json('choices.0.message.content') ?? '';\n $items = $this->applyDeepSeekText($text, $items, $model);\n }\n } catch (\\Throwable $e) {\n Log::error('PricingService: Http::pool exception, falling back to serial.', [\n 'model' => $model,\n 'message' => $e->getMessage(),\n ]);\n foreach ($pendingIndices as $ci) {\n $items = $this->enrichWithDeepSeek($items, $chunks[$ci]);\n }\n return $items;\n }\n }\n\n if (! empty($failedChunks)) {\n foreach ($failedChunks as $ci) {\n $items = $this->enrichWithDeepSeek($items, $chunks[$ci]);\n }\n }\n\n return $items;\n }\n\n // -------------------------------------------------------------------------\n // DeepSeek fallback (single batched request)\n // -------------------------------------------------------------------------\n\n /**\n * Send all unmatched items to DeepSeek in ONE request to minimise token consumption.\n * Only the fields DeepSeek actually needs are sent (description, category, brand, unit).\n * Output tokens are capped via max_tokens to protect against runaway usage.\n *\n * @param array<int, array<string, mixed>> $items\n * @param list<int> $unmatchedIndices\n * @return array<int, array<string, mixed>>\n */\n private function enrichWithDeepSeek(array $items, array $unmatchedIndices): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n $model = (string) config('services.deepseek.model', 'deepseek-chat');\n\n if (empty($apiKey)) {\n Log::warning('PricingService: DEEPSEEK_API_KEY not configured; skipping AI pricing.');\n return $items;\n }\n\n $payload = $this->buildDeepSeekPayload($items, $unmatchedIndices);\n $prompt = $this->buildPricingPrompt($payload);\n $lastStatus = null;\n\n try {\n $response = Http::timeout(90)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'user', 'content' => $prompt],\n ],\n 'temperature' => 0.2,\n 'max_tokens' => 8192,\n 'user' => 'Qimta_Platform',\n ]);\n\n if (! $response->successful()) {\n $lastStatus = $response->status();\n Log::warning('PricingService: DeepSeek request failed.', ['model' => $model, 'status' => $lastStatus]);\n } else {\n $text = $response->json('choices.0.message.content') ?? '';\n $items = $this->applyDeepSeekText($text, $items, $model);\n return $items;\n }\n } catch (\\Throwable $e) {\n Log::error('PricingService: Exception calling DeepSeek.', ['model' => $model, 'message' => $e->getMessage()]);\n }\n\n Log::error('PricingService: DeepSeek request failed.', ['last_status' => $lastStatus]);\n\n return $items;\n }\n}\n"}, "77338c4152332c74abe738de7872f7686a0e46f9": {"t": 1785748356.5325863, "n": 17663, "h": "b82838a4a6ce291b", "c": "<?php\n\nnamespace App\\Services;\n\nuse App\\Models\\Product;\nuse Illuminate\\Http\\Client\\Pool;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\n\nclass PricingService\n{\n /**\n * Items per DeepSeek call. Multiple calls are made in PARALLEL so ALL unpriced items get a price faster.\n * Smaller = more parallel requests = faster overall. 10 items per request is optimal.\n */\n private const DEEPSEEK_CHUNK_SIZE = 10;\n\n /**\n * Fetch unit prices for an array of quotation items.\n *\n * Strategy:\n * 1. Try the products table first (keyword match on name + optional category match).\n * 2. Collect all unmatched items and send them in ONE batched DeepSeek call.\n *\n * @param array<int, array<string, mixed>> $items\n * @return array<int, array<string, mixed>> Items enriched with unit_price, price_source, price_status\n */\n public function fetchPrices(array $items): array\n {\n $unmatched = [];\n\n foreach ($items as $index => $item) {\n // Skip already-priced rows (e.g., re-fetch after partial approval)\n if (! empty($item['unit_price'])) {\n continue;\n }\n\n $price = $this->lookupProductsTable($item);\n\n if ($price !== null) {\n $items[$index]['unit_price'] = $price;\n $items[$index]['price_source'] = 'products';\n $items[$index]['price_status'] = 'pending';\n } else {\n $items[$index]['unit_price'] = null;\n $items[$index]['price_source'] = null;\n $items[$index]['price_status'] = 'pending';\n $unmatched[] = $index;\n }\n }\n\n if (! empty($unmatched)) {\n $chunks = array_chunk($unmatched, self::DEEPSEEK_CHUNK_SIZE);\n\n if (count($chunks) === 1) {\n // Single chunk — direct call (no pool overhead)\n $items = $this->enrichWithDeepSeek($items, $chunks[0]);\n } else {\n // Multiple chunks — send ALL in parallel to save time\n $items = $this->enrichChunksParallel($items, $chunks);\n }\n }\n\n return $items;\n }\n\n // -------------------------------------------------------------------------\n // Products table lookup\n // -------------------------------------------------------------------------\n\n /**\n * Search the products table for a matching product by keyword.\n * Returns the unit_price when a confident match is found, null otherwise.\n */\n private function lookupProductsTable(array $item): ?float\n {\n $description = trim((string) ($item['description'] ?? ''));\n\n if (empty($description)) {\n return null;\n }\n\n // Extract significant words (≥ 3 chars)\n $words = array_values(array_filter(\n explode(' ', preg_replace('/[^a-zA-Z0-9\\s]/', ' ', $description)),\n fn(string $w) => strlen($w) >= 3\n ));\n\n if (empty($words)) {\n return null;\n }\n\n $query = Product::query()\n ->whereNotNull('unit_price')\n ->where('unit_price', '>', 0)\n ->where('active', true);\n\n // Require up to 4 keywords to appear in the product name (AND logic)\n foreach (array_slice($words, 0, 4) as $word) {\n $query->where('name', 'like', '%' . $word . '%');\n }\n\n // Narrow by category name if available\n $category = trim((string) ($item['category'] ?? ''));\n if ($category !== '') {\n $query->whereHas('category', fn($q) => $q->where('name', 'like', '%' . $category . '%'));\n }\n\n // CRITICAL: only accept a price whose UNIT matches the item's unit.\n // A product priced per m² must never be used to price an item measured in m³ —\n // that would return a real number that is the wrong \"unit price\".\n $unit = $this->normalizeUnit((string) ($item['unit'] ?? ''));\n if ($unit === '') {\n // No unit on the item → we cannot guarantee the price is per the correct unit.\n // Fall through to the AI estimator, which is told the unit explicitly.\n return null;\n }\n\n $query->whereHas('unit', function ($q) use ($unit) {\n $q->whereRaw('LOWER(TRIM(name)) = ?', [$unit])\n ->orWhereRaw('LOWER(TRIM(symbol)) = ?', [$unit]);\n });\n\n $product = $query->select('unit_price')->first();\n\n return $product ? (float) $product->unit_price : null;\n }\n\n /**\n * Canonicalize a free-text unit string so equivalent spellings match.\n * e.g. \"م³\", \"م3\", \"م^3\", \"M3\", \"متر مكعب\", \"cubic meter\" → \"م3\".\n * Returns '' when the input is empty.\n */\n private function normalizeUnit(string $unit): string\n {\n $u = trim(mb_strtolower($unit));\n if ($u === '') {\n return '';\n }\n\n // Normalize superscripts and notation: م³ / م^3 / م 3 → م3\n $u = strtr($u, ['²' => '2', '³' => '3', '^' => '', '.' => '']);\n $u = preg_replace('/\\s+/', '', $u);\n\n // Map common synonyms to a single canonical token.\n static $map = [\n 'متر مكعب' => 'م3', 'مترمكعب' => 'م3', 'cubicmeter' => 'م3', 'cubicmetre' => 'م3', 'cbm' => 'م3', 'm3' => 'م3',\n 'متر مربع' => 'م2', 'مترمربع' => 'م2', 'squaremeter' => 'م2', 'squaremetre' => 'م2', 'sqm' => 'م2', 'm2' => 'م2',\n 'متر طولي' => 'مط', 'مترطولي' => 'مط', 'linearmeter' => 'مط', 'lm' => 'مط',\n 'متر' => 'م', 'meter' => 'م', 'metre' => 'م',\n 'عدد' => 'عدد', 'piece' => 'عدد', 'pcs' => 'عدد', 'pc' => 'عدد', 'no' => 'عدد', 'nos' => 'عدد', 'unit' => 'عدد',\n 'كيلو' => 'كجم', 'كيلوغرام' => 'كجم', 'كيلوجرام' => 'كجم', 'kg' => 'كجم', 'kilogram' => 'كجم',\n 'طن' => 'طن', 'ton' => 'طن', 'tonne' => 'طن',\n 'لتر' => 'لتر', 'liter' => 'لتر', 'litre' => 'لتر', 'l' => 'لتر',\n 'كيس' => 'كيس', 'bag' => 'كيس', 'bags' => 'كيس',\n ];\n\n return $map[$u] ?? $u;\n }\n\n // -------------------------------------------------------------------------\n // DeepSeek fallback — helpers\n // -------------------------------------------------------------------------\n\n /**\n * Build the compact payload sent to DeepSeek for a set of item indices.\n */\n private function buildDeepSeekPayload(array $items, array $indices): array\n {\n $payload = [];\n foreach ($indices as $idx) {\n $payload[] = [\n 'i' => $idx,\n // 400 chars, not 80: the spec engine writes a full specification\n // here and truncating it would price a different product.\n 'd' => mb_substr((string) ($items[$idx]['description'] ?? ''), 0, 400),\n 'cat' => mb_substr((string) ($items[$idx]['category'] ?? ''), 0, 30),\n 'br' => mb_substr((string) ($items[$idx]['brand'] ?? ''), 0, 30),\n 'unit' => mb_substr((string) ($items[$idx]['unit'] ?? ''), 0, 15),\n ];\n }\n return $payload;\n }\n\n /**\n * Build the DeepSeek prompt string for a given payload array.\n */\n private function buildPricingPrompt(array $payload): string\n {\n return 'You are a procurement pricing expert for the Saudi Arabia construction, MEP and IT market. '\n . 'For each item below, pick the specific real product a Saudi supplier would actually quote '\n . 'against that specification, then price THAT product. '\n . 'RULES: '\n . '(1) \"m\" = the concrete product you priced, as \"Brand Model\" — e.g. \"HP ProBook 450 G10\", '\n . '\"Schneider Easy9 EZ9F56220\", \"Grundfos CR 5-8\". It must be a real, currently-sold product '\n . 'from a brand available in Saudi Arabia, and it must genuinely match the specification given. '\n . '(2) NEVER fabricate a model number. If you are not confident a specific model exists and fits, '\n . 'return \"m\":\"\" and price the specification generically — an empty \"m\" is far better than an '\n . 'invented one, because a buyer will try to source exactly what you name. '\n . '(3) \"p\" = the unit price in SAR for that product, for the unit given in the item. '\n . 'Always a positive non-zero number, based on typical Saudi supplier/contractor rates for 2024-2026. '\n . '(4) Price the SPECIFICATION as written. If it states 16 GB RAM, do not price an 8 GB model. '\n . '(5) Return ONLY a valid compact JSON array with NO whitespace or newlines between elements. '\n . '(6) Each element must be exactly: {\"i\":<index>,\"m\":\"<brand model>\",\"p\":<price_number>} '\n . '(7) No markdown, no explanation, no extra keys, no pretty-printing. '\n . 'Example output: [{\"i\":0,\"m\":\"HP ProBook 450 G10\",\"p\":4200},{\"i\":1,\"m\":\"\",\"p\":350}] '\n . 'Items: ' . json_encode($payload, JSON_UNESCAPED_UNICODE);\n }\n\n /**\n * Parse a DeepSeek text response and apply prices to $items.\n */\n private function applyDeepSeekText(string $text, array $items, string $model): array\n {\n $text = preg_replace('/^```json\\s*/i', '', trim($text));\n $text = preg_replace('/```\\s*$/i', '', $text);\n\n $priceData = json_decode($text, true);\n\n if (! is_array($priceData)) {\n // Salvage whole entries from a truncated or malformed response. \"m\" is\n // optional so this still recovers prices when the model name is absent.\n preg_match_all(\n '/\\{\\s*\"i\"\\s*:\\s*(\\d+)\\s*(?:,\\s*\"m\"\\s*:\\s*\"([^\"]*)\"\\s*)?,\\s*\"p\"\\s*:\\s*([\\d.]+)\\s*\\}/',\n $text,\n $matches,\n PREG_SET_ORDER\n );\n if (! empty($matches)) {\n $priceData = array_map(\n fn ($m) => ['i' => (int) $m[1], 'm' => $m[2] ?? '', 'p' => (float) $m[3]],\n $matches\n );\n Log::info('PricingService: Partial extraction recovered ' . count($priceData) . ' price(s).');\n }\n }\n\n if (! is_array($priceData) || empty($priceData)) {\n Log::warning('PricingService: Could not extract any prices from DeepSeek response.', [\n 'model' => $model,\n 'text_preview' => mb_substr($text, 0, 300),\n ]);\n return $items;\n }\n\n foreach ($priceData as $entry) {\n $idx = $entry['i'] ?? null;\n $price = $entry['p'] ?? null;\n\n if ($idx === null || ! array_key_exists($idx, $items)) {\n continue;\n }\n\n $price = is_numeric($price) ? (float) $price : 0.0;\n\n if ($price > 0) {\n $items[$idx]['unit_price'] = $price;\n $items[$idx]['price_source'] = 'deepseek';\n\n // Record which real product the price belongs to, so the quotation\n // names something a buyer can actually go and source. Prepended to\n // the description because that is what the client reads.\n $priced = trim((string) ($entry['m'] ?? ''));\n if ($priced !== '') {\n $items[$idx]['priced_product'] = $priced;\n\n $description = (string) ($items[$idx]['description'] ?? '');\n if (mb_stripos($description, $priced) === false) {\n $items[$idx]['description'] = $priced . ' — ' . $description;\n }\n }\n }\n }\n\n return $items;\n }\n\n /**\n * Send multiple chunks to DeepSeek in PARALLEL using Http::pool().\n * Falls back to sequential per-chunk calls if pool fails.\n *\n * @param array<int, array<string, mixed>> $items\n * @param list<list<int>> $chunks\n * @return array<int, array<string, mixed>>\n */\n private function enrichChunksParallel(array $items, array $chunks): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n $model = (string) config('services.deepseek.model', 'deepseek-chat');\n\n if (empty($apiKey)) {\n Log::warning('PricingService: DEEPSEEK_API_KEY not configured; skipping AI pricing.');\n return $items;\n }\n\n $url = 'https://api.deepseek.com/chat/completions';\n\n // Build request bodies once\n $requestBodies = [];\n foreach ($chunks as $ci => $chunkIndices) {\n $payload = $this->buildDeepSeekPayload($items, $chunkIndices);\n $requestBodies[$ci] = [\n 'model' => $model,\n 'messages' => [\n ['role' => 'user', 'content' => $this->buildPricingPrompt($payload)],\n ],\n 'temperature' => 0.2,\n 'max_tokens' => 8192,\n 'user' => 'Qimta_Platform',\n ];\n }\n\n $failedChunks = array_keys($chunks);\n\n // Single attempt with the configured model\n if (! empty($failedChunks)) {\n $pendingIndices = $failedChunks;\n $failedChunks = [];\n\n try {\n $responses = Http::pool(function (Pool $pool) use ($url, $apiKey, $requestBodies, $pendingIndices) {\n $reqs = [];\n foreach ($pendingIndices as $ci) {\n $reqs[] = $pool->as((string) $ci)\n ->timeout(90)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post($url, $requestBodies[$ci]);\n }\n return $reqs;\n });\n\n foreach ($responses as $key => $response) {\n $ci = (int) $key;\n\n if (! $response->successful()) {\n Log::warning('PricingService: Parallel chunk failed, will retry sequentially.', [\n 'model' => $model,\n 'chunk' => $ci,\n 'status' => $response->status(),\n ]);\n $failedChunks[] = $ci;\n continue;\n }\n\n $text = $response->json('choices.0.message.content') ?? '';\n $items = $this->applyDeepSeekText($text, $items, $model);\n }\n } catch (\\Throwable $e) {\n Log::error('PricingService: Http::pool exception, falling back to serial.', [\n 'model' => $model,\n 'message' => $e->getMessage(),\n ]);\n foreach ($pendingIndices as $ci) {\n $items = $this->enrichWithDeepSeek($items, $chunks[$ci]);\n }\n return $items;\n }\n }\n\n if (! empty($failedChunks)) {\n foreach ($failedChunks as $ci) {\n $items = $this->enrichWithDeepSeek($items, $chunks[$ci]);\n }\n }\n\n return $items;\n }\n\n // -------------------------------------------------------------------------\n // DeepSeek fallback (single batched request)\n // -------------------------------------------------------------------------\n\n /**\n * Send all unmatched items to DeepSeek in ONE request to minimise token consumption.\n * Only the fields DeepSeek actually needs are sent (description, category, brand, unit).\n * Output tokens are capped via max_tokens to protect against runaway usage.\n *\n * @param array<int, array<string, mixed>> $items\n * @param list<int> $unmatchedIndices\n * @return array<int, array<string, mixed>>\n */\n private function enrichWithDeepSeek(array $items, array $unmatchedIndices): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n $model = (string) config('services.deepseek.model', 'deepseek-chat');\n\n if (empty($apiKey)) {\n Log::warning('PricingService: DEEPSEEK_API_KEY not configured; skipping AI pricing.');\n return $items;\n }\n\n $payload = $this->buildDeepSeekPayload($items, $unmatchedIndices);\n $prompt = $this->buildPricingPrompt($payload);\n $lastStatus = null;\n\n try {\n $response = Http::timeout(90)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'user', 'content' => $prompt],\n ],\n 'temperature' => 0.2,\n 'max_tokens' => 8192,\n 'user' => 'Qimta_Platform',\n ]);\n\n if (! $response->successful()) {\n $lastStatus = $response->status();\n Log::warning('PricingService: DeepSeek request failed.', ['model' => $model, 'status' => $lastStatus]);\n } else {\n $text = $response->json('choices.0.message.content') ?? '';\n $items = $this->applyDeepSeekText($text, $items, $model);\n return $items;\n }\n } catch (\\Throwable $e) {\n Log::error('PricingService: Exception calling DeepSeek.', ['model' => $model, 'message' => $e->getMessage()]);\n }\n\n Log::error('PricingService: DeepSeek request failed.', ['last_status' => $lastStatus]);\n\n return $items;\n }\n}\n"}, "7b7fe92998dd076f92414fd2faa8951deaa85d50": {"t": 1785748357.649455, "n": 15564, "h": "520fea4f44339041", "c": "<?php\n\nnamespace App\\Services;\n\nuse App\\Models\\Product;\nuse Illuminate\\Http\\Client\\Pool;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\n\nclass PricingService\n{\n /**\n * Items per DeepSeek call. Multiple calls are made in PARALLEL so ALL unpriced items get a price faster.\n * Smaller = more parallel requests = faster overall. 10 items per request is optimal.\n */\n private const DEEPSEEK_CHUNK_SIZE = 10;\n\n /**\n * Fetch unit prices for an array of quotation items.\n *\n * Strategy:\n * 1. Try the products table first (keyword match on name + optional category match).\n * 2. Collect all unmatched items and send them in ONE batched DeepSeek call.\n *\n * @param array<int, array<string, mixed>> $items\n * @return array<int, array<string, mixed>> Items enriched with unit_price, price_source, price_status\n */\n public function fetchPrices(array $items): array\n {\n $unmatched = [];\n\n foreach ($items as $index => $item) {\n // Skip already-priced rows (e.g., re-fetch after partial approval)\n if (! empty($item['unit_price'])) {\n continue;\n }\n\n $price = $this->lookupProductsTable($item);\n\n if ($price !== null) {\n $items[$index]['unit_price'] = $price;\n $items[$index]['price_source'] = 'products';\n $items[$index]['price_status'] = 'pending';\n } else {\n $items[$index]['unit_price'] = null;\n $items[$index]['price_source'] = null;\n $items[$index]['price_status'] = 'pending';\n $unmatched[] = $index;\n }\n }\n\n if (! empty($unmatched)) {\n $chunks = array_chunk($unmatched, self::DEEPSEEK_CHUNK_SIZE);\n\n if (count($chunks) === 1) {\n // Single chunk — direct call (no pool overhead)\n $items = $this->enrichWithDeepSeek($items, $chunks[0]);\n } else {\n // Multiple chunks — send ALL in parallel to save time\n $items = $this->enrichChunksParallel($items, $chunks);\n }\n }\n\n return $items;\n }\n\n // -------------------------------------------------------------------------\n // Products table lookup\n // -------------------------------------------------------------------------\n\n /**\n * Search the products table for a matching product by keyword.\n * Returns the unit_price when a confident match is found, null otherwise.\n */\n private function lookupProductsTable(array $item): ?float\n {\n $description = trim((string) ($item['description'] ?? ''));\n\n if (empty($description)) {\n return null;\n }\n\n // Extract significant words (≥ 3 chars)\n $words = array_values(array_filter(\n explode(' ', preg_replace('/[^a-zA-Z0-9\\s]/', ' ', $description)),\n fn(string $w) => strlen($w) >= 3\n ));\n\n if (empty($words)) {\n return null;\n }\n\n $query = Product::query()\n ->whereNotNull('unit_price')\n ->where('unit_price', '>', 0)\n ->where('active', true);\n\n // Require up to 4 keywords to appear in the product name (AND logic)\n foreach (array_slice($words, 0, 4) as $word) {\n $query->where('name', 'like', '%' . $word . '%');\n }\n\n // Narrow by category name if available\n $category = trim((string) ($item['category'] ?? ''));\n if ($category !== '') {\n $query->whereHas('category', fn($q) => $q->where('name', 'like', '%' . $category . '%'));\n }\n\n // CRITICAL: only accept a price whose UNIT matches the item's unit.\n // A product priced per m² must never be used to price an item measured in m³ —\n // that would return a real number that is the wrong \"unit price\".\n $unit = $this->normalizeUnit((string) ($item['unit'] ?? ''));\n if ($unit === '') {\n // No unit on the item → we cannot guarantee the price is per the correct unit.\n // Fall through to the AI estimator, which is told the unit explicitly.\n return null;\n }\n\n $query->whereHas('unit', function ($q) use ($unit) {\n $q->whereRaw('LOWER(TRIM(name)) = ?', [$unit])\n ->orWhereRaw('LOWER(TRIM(symbol)) = ?', [$unit]);\n });\n\n $product = $query->select('unit_price')->first();\n\n return $product ? (float) $product->unit_price : null;\n }\n\n /**\n * Canonicalize a free-text unit string so equivalent spellings match.\n * e.g. \"م³\", \"م3\", \"م^3\", \"M3\", \"متر مكعب\", \"cubic meter\" → \"م3\".\n * Returns '' when the input is empty.\n */\n private function normalizeUnit(string $unit): string\n {\n $u = trim(mb_strtolower($unit));\n if ($u === '') {\n return '';\n }\n\n // Normalize superscripts and notation: م³ / م^3 / م 3 → م3\n $u = strtr($u, ['²' => '2', '³' => '3', '^' => '', '.' => '']);\n $u = preg_replace('/\\s+/', '', $u);\n\n // Map common synonyms to a single canonical token.\n static $map = [\n 'متر مكعب' => 'م3', 'مترمكعب' => 'م3', 'cubicmeter' => 'م3', 'cubicmetre' => 'م3', 'cbm' => 'م3', 'm3' => 'م3',\n 'متر مربع' => 'م2', 'مترمربع' => 'م2', 'squaremeter' => 'م2', 'squaremetre' => 'م2', 'sqm' => 'م2', 'm2' => 'م2',\n 'متر طولي' => 'مط', 'مترطولي' => 'مط', 'linearmeter' => 'مط', 'lm' => 'مط',\n 'متر' => 'م', 'meter' => 'م', 'metre' => 'م',\n 'عدد' => 'عدد', 'piece' => 'عدد', 'pcs' => 'عدد', 'pc' => 'عدد', 'no' => 'عدد', 'nos' => 'عدد', 'unit' => 'عدد',\n 'كيلو' => 'كجم', 'كيلوغرام' => 'كجم', 'كيلوجرام' => 'كجم', 'kg' => 'كجم', 'kilogram' => 'كجم',\n 'طن' => 'طن', 'ton' => 'طن', 'tonne' => 'طن',\n 'لتر' => 'لتر', 'liter' => 'لتر', 'litre' => 'لتر', 'l' => 'لتر',\n 'كيس' => 'كيس', 'bag' => 'كيس', 'bags' => 'كيس',\n ];\n\n return $map[$u] ?? $u;\n }\n\n // -------------------------------------------------------------------------\n // DeepSeek fallback — helpers\n // -------------------------------------------------------------------------\n\n /**\n * Build the compact payload sent to DeepSeek for a set of item indices.\n */\n private function buildDeepSeekPayload(array $items, array $indices): array\n {\n $payload = [];\n foreach ($indices as $idx) {\n $payload[] = [\n 'i' => $idx,\n 'd' => mb_substr((string) ($items[$idx]['description'] ?? ''), 0, 80),\n 'cat' => mb_substr((string) ($items[$idx]['category'] ?? ''), 0, 30),\n 'br' => mb_substr((string) ($items[$idx]['brand'] ?? ''), 0, 30),\n 'unit' => mb_substr((string) ($items[$idx]['unit'] ?? ''), 0, 15),\n ];\n }\n return $payload;\n }\n\n /**\n * Build the DeepSeek prompt string for a given payload array.\n */\n private function buildPricingPrompt(array $payload): string\n {\n return 'You are a procurement pricing expert for the Saudi Arabia construction and MEP materials market. '\n . 'Estimate a realistic current unit price in SAR for each item below. '\n . 'RULES: '\n . '(1) Always return a positive non-zero price — use your best market estimate, never 0. '\n . '(2) Base prices on typical Saudi supplier/contractor rates for 2024-2026. '\n . '(3) Return ONLY a valid compact JSON array with NO whitespace or newlines between elements. '\n . '(4) Each element must be exactly: {\"i\":<index>,\"p\":<price_number>} '\n . '(5) No markdown, no explanation, no extra keys, no pretty-printing. '\n . 'Example output: [{\"i\":0,\"p\":1500},{\"i\":1,\"p\":350}] '\n . 'Items: ' . json_encode($payload, JSON_UNESCAPED_UNICODE);\n }\n\n /**\n * Parse a DeepSeek text response and apply prices to $items.\n */\n private function applyDeepSeekText(string $text, array $items, string $model): array\n {\n $text = preg_replace('/^```json\\s*/i', '', trim($text));\n $text = preg_replace('/```\\s*$/i', '', $text);\n\n $priceData = json_decode($text, true);\n\n if (! is_array($priceData)) {\n preg_match_all('/\\{\\s*\"i\"\\s*:\\s*(\\d+)\\s*,\\s*\"p\"\\s*:\\s*([\\d.]+)\\s*\\}/', $text, $matches, PREG_SET_ORDER);\n if (! empty($matches)) {\n $priceData = array_map(fn($m) => ['i' => (int) $m[1], 'p' => (float) $m[2]], $matches);\n Log::info('PricingService: Partial extraction recovered ' . count($priceData) . ' price(s).');\n }\n }\n\n if (! is_array($priceData) || empty($priceData)) {\n Log::warning('PricingService: Could not extract any prices from DeepSeek response.', [\n 'model' => $model,\n 'text_preview' => mb_substr($text, 0, 300),\n ]);\n return $items;\n }\n\n foreach ($priceData as $entry) {\n $idx = $entry['i'] ?? null;\n $price = $entry['p'] ?? null;\n\n if ($idx === null || ! array_key_exists($idx, $items)) {\n continue;\n }\n\n $price = is_numeric($price) ? (float) $price : 0.0;\n\n if ($price > 0) {\n $items[$idx]['unit_price'] = $price;\n $items[$idx]['price_source'] = 'deepseek';\n }\n }\n\n return $items;\n }\n\n /**\n * Send multiple chunks to DeepSeek in PARALLEL using Http::pool().\n * Falls back to sequential per-chunk calls if pool fails.\n *\n * @param array<int, array<string, mixed>> $items\n * @param list<list<int>> $chunks\n * @return array<int, array<string, mixed>>\n */\n private function enrichChunksParallel(array $items, array $chunks): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n $model = (string) config('services.deepseek.model', 'deepseek-chat');\n\n if (empty($apiKey)) {\n Log::warning('PricingService: DEEPSEEK_API_KEY not configured; skipping AI pricing.');\n return $items;\n }\n\n $url = 'https://api.deepseek.com/chat/completions';\n\n // Build request bodies once\n $requestBodies = [];\n foreach ($chunks as $ci => $chunkIndices) {\n $payload = $this->buildDeepSeekPayload($items, $chunkIndices);\n $requestBodies[$ci] = [\n 'model' => $model,\n 'messages' => [\n ['role' => 'user', 'content' => $this->buildPricingPrompt($payload)],\n ],\n 'temperature' => 0.2,\n 'max_tokens' => 8192,\n 'user' => 'Qimta_Platform',\n ];\n }\n\n $failedChunks = array_keys($chunks);\n\n // Single attempt with the configured model\n if (! empty($failedChunks)) {\n $pendingIndices = $failedChunks;\n $failedChunks = [];\n\n try {\n $responses = Http::pool(function (Pool $pool) use ($url, $apiKey, $requestBodies, $pendingIndices) {\n $reqs = [];\n foreach ($pendingIndices as $ci) {\n $reqs[] = $pool->as((string) $ci)\n ->timeout(90)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post($url, $requestBodies[$ci]);\n }\n return $reqs;\n });\n\n foreach ($responses as $key => $response) {\n $ci = (int) $key;\n\n if (! $response->successful()) {\n Log::warning('PricingService: Parallel chunk failed, will retry sequentially.', [\n 'model' => $model,\n 'chunk' => $ci,\n 'status' => $response->status(),\n ]);\n $failedChunks[] = $ci;\n continue;\n }\n\n $text = $response->json('choices.0.message.content') ?? '';\n $items = $this->applyDeepSeekText($text, $items, $model);\n }\n } catch (\\Throwable $e) {\n Log::error('PricingService: Http::pool exception, falling back to serial.', [\n 'model' => $model,\n 'message' => $e->getMessage(),\n ]);\n foreach ($pendingIndices as $ci) {\n $items = $this->enrichWithDeepSeek($items, $chunks[$ci]);\n }\n return $items;\n }\n }\n\n if (! empty($failedChunks)) {\n foreach ($failedChunks as $ci) {\n $items = $this->enrichWithDeepSeek($items, $chunks[$ci]);\n }\n }\n\n return $items;\n }\n\n // -------------------------------------------------------------------------\n // DeepSeek fallback (single batched request)\n // -------------------------------------------------------------------------\n\n /**\n * Send all unmatched items to DeepSeek in ONE request to minimise token consumption.\n * Only the fields DeepSeek actually needs are sent (description, category, brand, unit).\n * Output tokens are capped via max_tokens to protect against runaway usage.\n *\n * @param array<int, array<string, mixed>> $items\n * @param list<int> $unmatchedIndices\n * @return array<int, array<string, mixed>>\n */\n private function enrichWithDeepSeek(array $items, array $unmatchedIndices): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n $model = (string) config('services.deepseek.model', 'deepseek-chat');\n\n if (empty($apiKey)) {\n Log::warning('PricingService: DEEPSEEK_API_KEY not configured; skipping AI pricing.');\n return $items;\n }\n\n $payload = $this->buildDeepSeekPayload($items, $unmatchedIndices);\n $prompt = $this->buildPricingPrompt($payload);\n $lastStatus = null;\n\n try {\n $response = Http::timeout(90)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'user', 'content' => $prompt],\n ],\n 'temperature' => 0.2,\n 'max_tokens' => 8192,\n 'user' => 'Qimta_Platform',\n ]);\n\n if (! $response->successful()) {\n $lastStatus = $response->status();\n Log::warning('PricingService: DeepSeek request failed.', ['model' => $model, 'status' => $lastStatus]);\n } else {\n $text = $response->json('choices.0.message.content') ?? '';\n $items = $this->applyDeepSeekText($text, $items, $model);\n return $items;\n }\n } catch (\\Throwable $e) {\n Log::error('PricingService: Exception calling DeepSeek.', ['model' => $model, 'message' => $e->getMessage()]);\n }\n\n Log::error('PricingService: DeepSeek request failed.', ['last_status' => $lastStatus]);\n\n return $items;\n }\n}\n"}, "b65ff6622e72f65525fe55404e124bc72c97539d": {"t": 1785748358.74915, "n": 13305, "h": "c4234a9adc62a7a0", "c": "<?php\n\nnamespace App\\Services;\n\nuse App\\Models\\Product;\nuse Illuminate\\Http\\Client\\Pool;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\n\nclass PricingService\n{\n /**\n * Items per DeepSeek call. Multiple calls are made in PARALLEL so ALL unpriced items get a price faster.\n * Smaller = more parallel requests = faster overall. 10 items per request is optimal.\n */\n private const DEEPSEEK_CHUNK_SIZE = 10;\n\n /**\n * Fetch unit prices for an array of quotation items.\n *\n * Strategy:\n * 1. Try the products table first (keyword match on name + optional category match).\n * 2. Collect all unmatched items and send them in ONE batched DeepSeek call.\n *\n * @param array<int, array<string, mixed>> $items\n * @return array<int, array<string, mixed>> Items enriched with unit_price, price_source, price_status\n */\n public function fetchPrices(array $items): array\n {\n $unmatched = [];\n\n foreach ($items as $index => $item) {\n // Skip already-priced rows (e.g., re-fetch after partial approval)\n if (! empty($item['unit_price'])) {\n continue;\n }\n\n $price = $this->lookupProductsTable($item);\n\n if ($price !== null) {\n $items[$index]['unit_price'] = $price;\n $items[$index]['price_source'] = 'products';\n $items[$index]['price_status'] = 'pending';\n } else {\n $items[$index]['unit_price'] = null;\n $items[$index]['price_source'] = null;\n $items[$index]['price_status'] = 'pending';\n $unmatched[] = $index;\n }\n }\n\n if (! empty($unmatched)) {\n $chunks = array_chunk($unmatched, self::DEEPSEEK_CHUNK_SIZE);\n\n if (count($chunks) === 1) {\n // Single chunk — direct call (no pool overhead)\n $items = $this->enrichWithDeepSeek($items, $chunks[0]);\n } else {\n // Multiple chunks — send ALL in parallel to save time\n $items = $this->enrichChunksParallel($items, $chunks);\n }\n }\n\n return $items;\n }\n\n // -------------------------------------------------------------------------\n // Products table lookup\n // -------------------------------------------------------------------------\n\n /**\n * Search the products table for a matching product by keyword.\n * Returns the unit_price when a confident match is found, null otherwise.\n */\n private function lookupProductsTable(array $item): ?float\n {\n $description = trim((string) ($item['description'] ?? ''));\n\n if (empty($description)) {\n return null;\n }\n\n // Extract significant words (≥ 3 chars)\n $words = array_values(array_filter(\n explode(' ', preg_replace('/[^a-zA-Z0-9\\s]/', ' ', $description)),\n fn(string $w) => strlen($w) >= 3\n ));\n\n if (empty($words)) {\n return null;\n }\n\n $query = Product::query()\n ->whereNotNull('unit_price')\n ->where('unit_price', '>', 0)\n ->where('active', true);\n\n // Require up to 4 keywords to appear in the product name (AND logic)\n foreach (array_slice($words, 0, 4) as $word) {\n $query->where('name', 'like', '%' . $word . '%');\n }\n\n // Narrow by category name if available\n $category = trim((string) ($item['category'] ?? ''));\n if ($category !== '') {\n $query->whereHas('category', fn($q) => $q->where('name', 'like', '%' . $category . '%'));\n }\n\n $product = $query->select('unit_price')->first();\n\n return $product ? (float) $product->unit_price : null;\n }\n\n // -------------------------------------------------------------------------\n // DeepSeek fallback — helpers\n // -------------------------------------------------------------------------\n\n /**\n * Build the compact payload sent to DeepSeek for a set of item indices.\n */\n private function buildDeepSeekPayload(array $items, array $indices): array\n {\n $payload = [];\n foreach ($indices as $idx) {\n $payload[] = [\n 'i' => $idx,\n 'd' => mb_substr((string) ($items[$idx]['description'] ?? ''), 0, 80),\n 'cat' => mb_substr((string) ($items[$idx]['category'] ?? ''), 0, 30),\n 'br' => mb_substr((string) ($items[$idx]['brand'] ?? ''), 0, 30),\n 'unit' => mb_substr((string) ($items[$idx]['unit'] ?? ''), 0, 15),\n ];\n }\n return $payload;\n }\n\n /**\n * Build the DeepSeek prompt string for a given payload array.\n */\n private function buildPricingPrompt(array $payload): string\n {\n return 'You are a procurement pricing expert for the Saudi Arabia construction and MEP materials market. '\n . 'Estimate a realistic current unit price in SAR for each item below. '\n . 'RULES: '\n . '(1) Always return a positive non-zero price — use your best market estimate, never 0. '\n . '(2) Base prices on typical Saudi supplier/contractor rates for 2024-2026. '\n . '(3) Return ONLY a valid compact JSON array with NO whitespace or newlines between elements. '\n . '(4) Each element must be exactly: {\"i\":<index>,\"p\":<price_number>} '\n . '(5) No markdown, no explanation, no extra keys, no pretty-printing. '\n . 'Example output: [{\"i\":0,\"p\":1500},{\"i\":1,\"p\":350}] '\n . 'Items: ' . json_encode($payload, JSON_UNESCAPED_UNICODE);\n }\n\n /**\n * Parse a DeepSeek text response and apply prices to $items.\n */\n private function applyDeepSeekText(string $text, array $items, string $model): array\n {\n $text = preg_replace('/^```json\\s*/i', '', trim($text));\n $text = preg_replace('/```\\s*$/i', '', $text);\n\n $priceData = json_decode($text, true);\n\n if (! is_array($priceData)) {\n preg_match_all('/\\{\\s*\"i\"\\s*:\\s*(\\d+)\\s*,\\s*\"p\"\\s*:\\s*([\\d.]+)\\s*\\}/', $text, $matches, PREG_SET_ORDER);\n if (! empty($matches)) {\n $priceData = array_map(fn($m) => ['i' => (int) $m[1], 'p' => (float) $m[2]], $matches);\n Log::info('PricingService: Partial extraction recovered ' . count($priceData) . ' price(s).');\n }\n }\n\n if (! is_array($priceData) || empty($priceData)) {\n Log::warning('PricingService: Could not extract any prices from DeepSeek response.', [\n 'model' => $model,\n 'text_preview' => mb_substr($text, 0, 300),\n ]);\n return $items;\n }\n\n foreach ($priceData as $entry) {\n $idx = $entry['i'] ?? null;\n $price = $entry['p'] ?? null;\n\n if ($idx === null || ! array_key_exists($idx, $items)) {\n continue;\n }\n\n $price = is_numeric($price) ? (float) $price : 0.0;\n\n if ($price > 0) {\n $items[$idx]['unit_price'] = $price;\n $items[$idx]['price_source'] = 'deepseek';\n }\n }\n\n return $items;\n }\n\n /**\n * Send multiple chunks to DeepSeek in PARALLEL using Http::pool().\n * Falls back to sequential per-chunk calls if pool fails.\n *\n * @param array<int, array<string, mixed>> $items\n * @param list<list<int>> $chunks\n * @return array<int, array<string, mixed>>\n */\n private function enrichChunksParallel(array $items, array $chunks): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n $model = (string) config('services.deepseek.model', 'deepseek-chat');\n\n if (empty($apiKey)) {\n Log::warning('PricingService: DEEPSEEK_API_KEY not configured; skipping AI pricing.');\n return $items;\n }\n\n $url = 'https://api.deepseek.com/chat/completions';\n\n // Build request bodies once\n $requestBodies = [];\n foreach ($chunks as $ci => $chunkIndices) {\n $payload = $this->buildDeepSeekPayload($items, $chunkIndices);\n $requestBodies[$ci] = [\n 'model' => $model,\n 'messages' => [\n ['role' => 'user', 'content' => $this->buildPricingPrompt($payload)],\n ],\n 'temperature' => 0.2,\n 'max_tokens' => 8192,\n 'user' => 'Qimta_Platform',\n ];\n }\n\n $failedChunks = array_keys($chunks);\n\n // Single attempt with the configured model\n if (! empty($failedChunks)) {\n $pendingIndices = $failedChunks;\n $failedChunks = [];\n\n try {\n $responses = Http::pool(function (Pool $pool) use ($url, $apiKey, $requestBodies, $pendingIndices) {\n $reqs = [];\n foreach ($pendingIndices as $ci) {\n $reqs[] = $pool->as((string) $ci)\n ->timeout(90)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post($url, $requestBodies[$ci]);\n }\n return $reqs;\n });\n\n foreach ($responses as $key => $response) {\n $ci = (int) $key;\n\n if (! $response->successful()) {\n Log::warning('PricingService: Parallel chunk failed, will retry sequentially.', [\n 'model' => $model,\n 'chunk' => $ci,\n 'status' => $response->status(),\n ]);\n $failedChunks[] = $ci;\n continue;\n }\n\n $text = $response->json('choices.0.message.content') ?? '';\n $items = $this->applyDeepSeekText($text, $items, $model);\n }\n } catch (\\Throwable $e) {\n Log::error('PricingService: Http::pool exception, falling back to serial.', [\n 'model' => $model,\n 'message' => $e->getMessage(),\n ]);\n foreach ($pendingIndices as $ci) {\n $items = $this->enrichWithDeepSeek($items, $chunks[$ci]);\n }\n return $items;\n }\n }\n\n if (! empty($failedChunks)) {\n foreach ($failedChunks as $ci) {\n $items = $this->enrichWithDeepSeek($items, $chunks[$ci]);\n }\n }\n\n return $items;\n }\n\n // -------------------------------------------------------------------------\n // DeepSeek fallback (single batched request)\n // -------------------------------------------------------------------------\n\n /**\n * Send all unmatched items to DeepSeek in ONE request to minimise token consumption.\n * Only the fields DeepSeek actually needs are sent (description, category, brand, unit).\n * Output tokens are capped via max_tokens to protect against runaway usage.\n *\n * @param array<int, array<string, mixed>> $items\n * @param list<int> $unmatchedIndices\n * @return array<int, array<string, mixed>>\n */\n private function enrichWithDeepSeek(array $items, array $unmatchedIndices): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n $model = (string) config('services.deepseek.model', 'deepseek-chat');\n\n if (empty($apiKey)) {\n Log::warning('PricingService: DEEPSEEK_API_KEY not configured; skipping AI pricing.');\n return $items;\n }\n\n $payload = $this->buildDeepSeekPayload($items, $unmatchedIndices);\n $prompt = $this->buildPricingPrompt($payload);\n $lastStatus = null;\n\n try {\n $response = Http::timeout(90)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post('https://api.deepseek.com/chat/completions', [\n 'model' => $model,\n 'messages' => [\n ['role' => 'user', 'content' => $prompt],\n ],\n 'temperature' => 0.2,\n 'max_tokens' => 8192,\n 'user' => 'Qimta_Platform',\n ]);\n\n if (! $response->successful()) {\n $lastStatus = $response->status();\n Log::warning('PricingService: DeepSeek request failed.', ['model' => $model, 'status' => $lastStatus]);\n } else {\n $text = $response->json('choices.0.message.content') ?? '';\n $items = $this->applyDeepSeekText($text, $items, $model);\n return $items;\n }\n } catch (\\Throwable $e) {\n Log::error('PricingService: Exception calling DeepSeek.', ['model' => $model, 'message' => $e->getMessage()]);\n }\n\n Log::error('PricingService: DeepSeek request failed.', ['last_status' => $lastStatus]);\n\n return $items;\n }\n}\n"}, "84d0c71fc84eb7075e9044adb63af7cc82cfd093": {"t": 1785748359.858464, "n": 13706, "h": "6914527d0403046d", "c": "<?php\n\nnamespace App\\Services;\n\nuse App\\Models\\Product;\nuse Illuminate\\Http\\Client\\Pool;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\n\nclass PricingService\n{\n /**\n * Items per Gemini call. Multiple calls are made so ALL unpriced items get a price.\n */\n private const GEMINI_CHUNK_SIZE = 100;\n\n /**\n * Fetch unit prices for an array of quotation items.\n *\n * Strategy:\n * 1. Try the products table first (keyword match on name + optional category match).\n * 2. Collect all unmatched items and send them in ONE batched Gemini call.\n *\n * @param array<int, array<string, mixed>> $items\n * @return array<int, array<string, mixed>> Items enriched with unit_price, price_source, price_status\n */\n public function fetchPrices(array $items): array\n {\n $unmatched = [];\n\n foreach ($items as $index => $item) {\n // Skip already-priced rows (e.g., re-fetch after partial approval)\n if (! empty($item['unit_price'])) {\n continue;\n }\n\n $price = $this->lookupProductsTable($item);\n\n if ($price !== null) {\n $items[$index]['unit_price'] = $price;\n $items[$index]['price_source'] = 'products';\n $items[$index]['price_status'] = 'pending';\n } else {\n $items[$index]['unit_price'] = null;\n $items[$index]['price_source'] = null;\n $items[$index]['price_status'] = 'pending';\n $unmatched[] = $index;\n }\n }\n\n if (! empty($unmatched)) {\n $chunks = array_chunk($unmatched, self::GEMINI_CHUNK_SIZE);\n\n if (count($chunks) === 1) {\n // Single chunk — direct call (no pool overhead)\n $items = $this->enrichWithGemini($items, $chunks[0]);\n } else {\n // Multiple chunks — send ALL in parallel to save time\n $items = $this->enrichChunksParallel($items, $chunks);\n }\n }\n\n return $items;\n }\n\n // -------------------------------------------------------------------------\n // Products table lookup\n // -------------------------------------------------------------------------\n\n /**\n * Search the products table for a matching product by keyword.\n * Returns the unit_price when a confident match is found, null otherwise.\n */\n private function lookupProductsTable(array $item): ?float\n {\n $description = trim((string) ($item['description'] ?? ''));\n\n if (empty($description)) {\n return null;\n }\n\n // Extract significant words (≥ 3 chars)\n $words = array_values(array_filter(\n explode(' ', preg_replace('/[^a-zA-Z0-9\\s]/', ' ', $description)),\n fn(string $w) => strlen($w) >= 3\n ));\n\n if (empty($words)) {\n return null;\n }\n\n $query = Product::query()\n ->whereNotNull('unit_price')\n ->where('unit_price', '>', 0)\n ->where('active', true);\n\n // Require up to 4 keywords to appear in the product name (AND logic)\n foreach (array_slice($words, 0, 4) as $word) {\n $query->where('name', 'like', '%' . $word . '%');\n }\n\n // Narrow by category name if available\n $category = trim((string) ($item['category'] ?? ''));\n if ($category !== '') {\n $query->whereHas('category', fn($q) => $q->where('name', 'like', '%' . $category . '%'));\n }\n\n $product = $query->select('unit_price')->first();\n\n return $product ? (float) $product->unit_price : null;\n }\n\n // -------------------------------------------------------------------------\n // Gemini fallback — helpers\n // -------------------------------------------------------------------------\n\n /**\n * Build the compact payload sent to Gemini for a set of item indices.\n */\n private function buildGeminiPayload(array $items, array $indices): array\n {\n $payload = [];\n foreach ($indices as $idx) {\n $payload[] = [\n 'i' => $idx,\n 'd' => mb_substr((string) ($items[$idx]['description'] ?? ''), 0, 80),\n 'cat' => mb_substr((string) ($items[$idx]['category'] ?? ''), 0, 30),\n 'br' => mb_substr((string) ($items[$idx]['brand'] ?? ''), 0, 30),\n 'unit' => mb_substr((string) ($items[$idx]['unit'] ?? ''), 0, 15),\n ];\n }\n return $payload;\n }\n\n /**\n * Build the Gemini prompt string for a given payload array.\n */\n private function buildGeminiPrompt(array $payload): string\n {\n return 'You are a procurement pricing expert for the Saudi Arabia construction and MEP materials market. '\n . 'Estimate a realistic current unit price in SAR for each item below. '\n . 'RULES: '\n . '(1) Always return a positive non-zero price — use your best market estimate, never 0. '\n . '(2) Base prices on typical Saudi supplier/contractor rates for 2024-2026. '\n . '(3) Return ONLY a valid compact JSON array with NO whitespace or newlines between elements. '\n . '(4) Each element must be exactly: {\"i\":<index>,\"p\":<price_number>} '\n . '(5) No markdown, no explanation, no extra keys, no pretty-printing. '\n . 'Example output: [{\"i\":0,\"p\":1500},{\"i\":1,\"p\":350}] '\n . 'Items: ' . json_encode($payload, JSON_UNESCAPED_UNICODE);\n }\n\n /**\n * Parse a Gemini text response and apply prices to $items.\n */\n private function applyGeminiText(string $text, array $items, string $model): array\n {\n $text = preg_replace('/^```json\\s*/i', '', trim($text));\n $text = preg_replace('/```\\s*$/i', '', $text);\n\n $priceData = json_decode($text, true);\n\n if (! is_array($priceData)) {\n preg_match_all('/\\{\\s*\"i\"\\s*:\\s*(\\d+)\\s*,\\s*\"p\"\\s*:\\s*([\\d.]+)\\s*\\}/', $text, $matches, PREG_SET_ORDER);\n if (! empty($matches)) {\n $priceData = array_map(fn($m) => ['i' => (int) $m[1], 'p' => (float) $m[2]], $matches);\n Log::info('PricingService: Partial extraction recovered ' . count($priceData) . ' price(s).');\n }\n }\n\n if (! is_array($priceData) || empty($priceData)) {\n Log::warning('PricingService: Could not extract any prices from Gemini response.', [\n 'model' => $model,\n 'text_preview' => mb_substr($text, 0, 300),\n ]);\n return $items;\n }\n\n foreach ($priceData as $entry) {\n $idx = $entry['i'] ?? null;\n $price = $entry['p'] ?? null;\n\n if ($idx === null || ! array_key_exists($idx, $items)) {\n continue;\n }\n\n $price = is_numeric($price) ? (float) $price : 0.0;\n\n if ($price > 0) {\n $items[$idx]['unit_price'] = $price;\n $items[$idx]['price_source'] = 'gemini';\n }\n }\n\n return $items;\n }\n\n /**\n * Send multiple chunks to Gemini in PARALLEL using Http::pool().\n * Falls back to sequential per-chunk calls if pool fails.\n *\n * @param array<int, array<string, mixed>> $items\n * @param list<list<int>> $chunks\n * @return array<int, array<string, mixed>>\n */\n private function enrichChunksParallel(array $items, array $chunks): array\n {\n $apiKey = (string) config('services.gemini.key', '');\n $primaryModel = (string) config('services.gemini.model', 'gemini-2.0-flash-lite');\n $modelChain = array_unique(array_filter([$primaryModel, 'gemini-2.0-flash-lite', 'gemini-flash-lite-latest', 'gemini-flash-latest']));\n\n if (empty($apiKey)) {\n Log::warning('PricingService: GEMINI_API_KEY not configured; skipping AI pricing.');\n return $items;\n }\n\n // Build payloads + request bodies once\n $requestBodies = [];\n foreach ($chunks as $ci => $chunkIndices) {\n $payload = $this->buildGeminiPayload($items, $chunkIndices);\n $requestBodies[$ci] = [\n 'contents' => [\n ['role' => 'user', 'parts' => [['text' => $this->buildGeminiPrompt($payload)]]],\n ],\n 'generationConfig' => [\n 'responseMimeType' => 'application/json',\n 'temperature' => 0.2,\n 'maxOutputTokens' => 8192,\n ],\n ];\n }\n\n // Track which chunks still need pricing\n $failedChunks = array_keys($chunks);\n\n foreach ($modelChain as $model) {\n if (empty($failedChunks)) {\n break;\n }\n\n $url = \"https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key={$apiKey}\";\n $pendingIndices = $failedChunks;\n $failedChunks = [];\n\n try {\n $responses = Http::pool(function (Pool $pool) use ($url, $requestBodies, $pendingIndices) {\n $reqs = [];\n foreach ($pendingIndices as $ci) {\n $reqs[] = $pool->as((string) $ci)->timeout(90)->post($url, $requestBodies[$ci]);\n }\n return $reqs;\n });\n\n foreach ($responses as $key => $response) {\n $ci = (int) $key;\n\n if (! $response->successful()) {\n Log::warning('PricingService: Parallel chunk failed, will retry.', [\n 'model' => $model,\n 'chunk' => $ci,\n 'status' => $response->status(),\n ]);\n $failedChunks[] = $ci;\n continue;\n }\n\n $text = $response->json('candidates.0.content.parts.0.text') ?? '';\n $items = $this->applyGeminiText($text, $items, $model);\n }\n } catch (\\Throwable $e) {\n Log::error('PricingService: Http::pool exception, falling back to serial.', [\n 'model' => $model,\n 'message' => $e->getMessage(),\n ]);\n // Fall back: process remaining chunks one by one\n foreach ($pendingIndices as $ci) {\n $items = $this->enrichWithGemini($items, $chunks[$ci]);\n }\n return $items;\n }\n }\n\n if (! empty($failedChunks)) {\n Log::error('PricingService: Some chunks failed all models.', ['chunks' => $failedChunks]);\n }\n\n return $items;\n }\n\n // -------------------------------------------------------------------------\n // Gemini fallback (single batched request)\n // -------------------------------------------------------------------------\n\n /**\n * Send all unmatched items to Gemini in ONE request to minimise token consumption.\n * Only the fields Gemini actually needs are sent (description, category, brand, unit).\n * Output tokens are capped via maxOutputTokens to protect against runaway usage.\n *\n * @param array<int, array<string, mixed>> $items\n * @param list<int> $unmatchedIndices\n * @return array<int, array<string, mixed>>\n */\n private function enrichWithGemini(array $items, array $unmatchedIndices): array\n {\n $apiKey = (string) config('services.gemini.key', '');\n $modelChain = array_unique(array_filter([\n (string) config('services.gemini.model', 'gemini-2.0-flash-lite'),\n 'gemini-2.0-flash-lite',\n 'gemini-flash-lite-latest',\n 'gemini-flash-latest',\n ]));\n\n if (empty($apiKey)) {\n Log::warning('PricingService: GEMINI_API_KEY not configured; skipping AI pricing.');\n return $items;\n }\n\n $payload = $this->buildGeminiPayload($items, $unmatchedIndices);\n $prompt = $this->buildGeminiPrompt($payload);\n $lastStatus = null;\n\n foreach ($modelChain as $model) {\n $url = \"https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key={$apiKey}\";\n\n try {\n $response = Http::timeout(90)->post($url, [\n 'contents' => [['role' => 'user', 'parts' => [['text' => $prompt]]]],\n 'generationConfig' => [\n 'responseMimeType' => 'application/json',\n 'temperature' => 0.2,\n 'maxOutputTokens' => 8192,\n ],\n ]);\n\n if (! $response->successful()) {\n $lastStatus = $response->status();\n Log::warning('PricingService: Gemini model failed, trying next.', ['model' => $model, 'status' => $lastStatus]);\n continue;\n }\n\n $text = $response->json('candidates.0.content.parts.0.text') ?? '';\n $items = $this->applyGeminiText($text, $items, $model);\n\n return $items;\n\n } catch (\\Throwable $e) {\n Log::error('PricingService: Exception calling Gemini.', ['model' => $model, 'message' => $e->getMessage()]);\n }\n }\n\n Log::error('PricingService: All Gemini models failed.', ['last_status' => $lastStatus]);\n\n return $items;\n }\n}\n"}, "5e2e398c204c08368052e0f53f5d840a70bf1acb": {"t": 1785748360.9541714, "n": 9835, "h": "e65607a40dbde1d6", "c": "<?php\n\nnamespace App\\Services;\n\nuse App\\Models\\Product;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\n\nclass PricingService\n{\n /**\n * Items per Gemini call. Multiple calls are made so ALL unpriced items get a price.\n */\n private const GEMINI_CHUNK_SIZE = 50;\n\n /**\n * Fetch unit prices for an array of quotation items.\n *\n * Strategy:\n * 1. Try the products table first (keyword match on name + optional category match).\n * 2. Collect all unmatched items and send them in ONE batched Gemini call.\n *\n * @param array<int, array<string, mixed>> $items\n * @return array<int, array<string, mixed>> Items enriched with unit_price, price_source, price_status\n */\n public function fetchPrices(array $items): array\n {\n $unmatched = [];\n\n foreach ($items as $index => $item) {\n // Skip already-priced rows (e.g., re-fetch after partial approval)\n if (! empty($item['unit_price'])) {\n continue;\n }\n\n $price = $this->lookupProductsTable($item);\n\n if ($price !== null) {\n $items[$index]['unit_price'] = $price;\n $items[$index]['price_source'] = 'products';\n $items[$index]['price_status'] = 'pending';\n } else {\n $items[$index]['unit_price'] = null;\n $items[$index]['price_source'] = null;\n $items[$index]['price_status'] = 'pending';\n $unmatched[] = $index;\n }\n }\n\n if (! empty($unmatched)) {\n // Process ALL unmatched items — chunk into batches so we never drop any\n foreach (array_chunk($unmatched, self::GEMINI_CHUNK_SIZE) as $chunk) {\n $items = $this->enrichWithGemini($items, $chunk);\n }\n }\n\n return $items;\n }\n\n // -------------------------------------------------------------------------\n // Products table lookup\n // -------------------------------------------------------------------------\n\n /**\n * Search the products table for a matching product by keyword.\n * Returns the unit_price when a confident match is found, null otherwise.\n */\n private function lookupProductsTable(array $item): ?float\n {\n $description = trim((string) ($item['description'] ?? ''));\n\n if (empty($description)) {\n return null;\n }\n\n // Extract significant words (≥ 3 chars)\n $words = array_values(array_filter(\n explode(' ', preg_replace('/[^a-zA-Z0-9\\s]/', ' ', $description)),\n fn(string $w) => strlen($w) >= 3\n ));\n\n if (empty($words)) {\n return null;\n }\n\n $query = Product::query()\n ->whereNotNull('unit_price')\n ->where('unit_price', '>', 0)\n ->where('active', true);\n\n // Require up to 4 keywords to appear in the product name (AND logic)\n foreach (array_slice($words, 0, 4) as $word) {\n $query->where('name', 'like', '%' . $word . '%');\n }\n\n // Narrow by category name if available\n $category = trim((string) ($item['category'] ?? ''));\n if ($category !== '') {\n $query->whereHas('category', fn($q) => $q->where('name', 'like', '%' . $category . '%'));\n }\n\n $product = $query->select('unit_price')->first();\n\n return $product ? (float) $product->unit_price : null;\n }\n\n // -------------------------------------------------------------------------\n // Gemini fallback (single batched request)\n // -------------------------------------------------------------------------\n\n /**\n * Send all unmatched items to Gemini in ONE request to minimise token consumption.\n * Only the fields Gemini actually needs are sent (description, category, brand, unit).\n * Output tokens are capped via maxOutputTokens to protect against runaway usage.\n *\n * @param array<int, array<string, mixed>> $items\n * @param list<int> $unmatchedIndices\n * @return array<int, array<string, mixed>>\n */\n private function enrichWithGemini(array $items, array $unmatchedIndices): array\n {\n $apiKey = (string) config('services.gemini.key', '');\n $primaryModel = (string) config('services.gemini.model', 'gemini-2.0-flash-lite');\n // Try models in order — all confirmed available for this API key\n $modelChain = array_unique(array_filter([$primaryModel, 'gemini-2.0-flash-lite', 'gemini-flash-lite-latest', 'gemini-flash-latest']));\n\n if (empty($apiKey)) {\n Log::warning('PricingService: GEMINI_API_KEY not configured; skipping AI pricing.');\n return $items;\n }\n\n $batch = $unmatchedIndices;\n\n // Compact payload — only essential fields to minimise input tokens\n $payload = [];\n foreach ($batch as $idx) {\n $payload[] = [\n 'i' => $idx,\n 'd' => mb_substr((string) ($items[$idx]['description'] ?? ''), 0, 80),\n 'cat' => mb_substr((string) ($items[$idx]['category'] ?? ''), 0, 30),\n 'br' => mb_substr((string) ($items[$idx]['brand'] ?? ''), 0, 30),\n 'unit' => mb_substr((string) ($items[$idx]['unit'] ?? ''), 0, 15),\n ];\n }\n\n $prompt = 'You are a procurement pricing expert for the Saudi Arabia construction and MEP materials market. '\n . 'Estimate a realistic current unit price in SAR for each item below. '\n . 'RULES: '\n . '(1) Always return a positive non-zero price — use your best market estimate, never 0. '\n . '(2) Base prices on typical Saudi supplier/contractor rates for 2024-2026. '\n . '(3) Return ONLY a valid compact JSON array with NO whitespace or newlines between elements. '\n . '(4) Each element must be exactly: {\"i\":<index>,\"p\":<price_number>} '\n . '(5) No markdown, no explanation, no extra keys, no pretty-printing. '\n . 'Example output: [{\"i\":0,\"p\":1500},{\"i\":1,\"p\":350}] '\n . 'Items: ' . json_encode($payload, JSON_UNESCAPED_UNICODE);\n\n $lastStatus = null;\n\n foreach ($modelChain as $model) {\n $url = \"https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key={$apiKey}\";\n\n try {\n $response = Http::timeout(60)->post($url, [\n 'contents' => [\n ['role' => 'user', 'parts' => [['text' => $prompt]]],\n ],\n 'generationConfig' => [\n 'responseMimeType' => 'application/json',\n 'temperature' => 0.2,\n 'maxOutputTokens' => 8192,\n ],\n ]);\n\n if (! $response->successful()) {\n $lastStatus = $response->status();\n Log::warning('PricingService: Gemini model failed, trying next.', [\n 'model' => $model,\n 'status' => $lastStatus,\n ]);\n continue; // try next model\n }\n\n $text = $response->json('candidates.0.content.parts.0.text') ?? '';\n $text = preg_replace('/^```json\\s*/i', '', trim($text));\n $text = preg_replace('/```\\s*$/i', '', $text);\n\n // First try full JSON decode\n $priceData = json_decode($text, true);\n\n // Fallback: extract individual objects via regex if truncated\n if (! is_array($priceData)) {\n Log::warning('PricingService: Full JSON parse failed — attempting partial extraction.', [\n 'model' => $model,\n 'text_preview' => mb_substr($text, 0, 200),\n ]);\n preg_match_all('/\\{\\s*\"i\"\\s*:\\s*(\\d+)\\s*,\\s*\"p\"\\s*:\\s*([\\d.]+)\\s*\\}/', $text, $matches, PREG_SET_ORDER);\n if (! empty($matches)) {\n $priceData = array_map(fn($m) => ['i' => (int) $m[1], 'p' => (float) $m[2]], $matches);\n Log::info('PricingService: Partial extraction recovered ' . count($priceData) . ' price(s).');\n }\n }\n\n if (! is_array($priceData) || empty($priceData)) {\n Log::warning('PricingService: Could not extract any prices from Gemini response.', [\n 'model' => $model,\n 'text_preview' => mb_substr($text, 0, 300),\n ]);\n continue; // try next model\n }\n\n foreach ($priceData as $entry) {\n $idx = $entry['i'] ?? null;\n $price = $entry['p'] ?? null;\n\n if ($idx === null || ! array_key_exists($idx, $items)) {\n continue;\n }\n\n $price = is_numeric($price) ? (float) $price : 0.0;\n\n if ($price > 0) {\n $items[$idx]['unit_price'] = $price;\n $items[$idx]['price_source'] = 'gemini';\n }\n }\n\n return $items; // success — no need to try further models\n\n } catch (\\Throwable $e) {\n Log::error('PricingService: Exception calling Gemini.', [\n 'model' => $model,\n 'message' => $e->getMessage(),\n ]);\n }\n }\n\n Log::error('PricingService: All Gemini models failed.', ['last_status' => $lastStatus]);\n\n return $items;\n }\n}\n"}, "d18974ac98ccde45831fafe18562ddf73e6c0c6c": {"t": 1785748362.0523841, "n": 9789, "h": "6f471e34cc26b11f", "c": "<?php\n\nnamespace App\\Services;\n\nuse App\\Models\\Product;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\n\nclass PricingService\n{\n /**\n * Maximum items sent to Gemini per request.\n * Keeps token usage under control — items beyond this limit remain unpriced via Gemini.\n */\n private const MAX_GEMINI_BATCH = 25;\n\n /**\n * Fetch unit prices for an array of quotation items.\n *\n * Strategy:\n * 1. Try the products table first (keyword match on name + optional category match).\n * 2. Collect all unmatched items and send them in ONE batched Gemini call.\n *\n * @param array<int, array<string, mixed>> $items\n * @return array<int, array<string, mixed>> Items enriched with unit_price, price_source, price_status\n */\n public function fetchPrices(array $items): array\n {\n $unmatched = [];\n\n foreach ($items as $index => $item) {\n // Skip already-priced rows (e.g., re-fetch after partial approval)\n if (! empty($item['unit_price'])) {\n continue;\n }\n\n $price = $this->lookupProductsTable($item);\n\n if ($price !== null) {\n $items[$index]['unit_price'] = $price;\n $items[$index]['price_source'] = 'products';\n $items[$index]['price_status'] = 'pending';\n } else {\n $items[$index]['unit_price'] = null;\n $items[$index]['price_source'] = null;\n $items[$index]['price_status'] = 'pending';\n $unmatched[] = $index;\n }\n }\n\n if (! empty($unmatched)) {\n $items = $this->enrichWithGemini($items, $unmatched);\n }\n\n return $items;\n }\n\n // -------------------------------------------------------------------------\n // Products table lookup\n // -------------------------------------------------------------------------\n\n /**\n * Search the products table for a matching product by keyword.\n * Returns the unit_price when a confident match is found, null otherwise.\n */\n private function lookupProductsTable(array $item): ?float\n {\n $description = trim((string) ($item['description'] ?? ''));\n\n if (empty($description)) {\n return null;\n }\n\n // Extract significant words (≥ 3 chars)\n $words = array_values(array_filter(\n explode(' ', preg_replace('/[^a-zA-Z0-9\\s]/', ' ', $description)),\n fn(string $w) => strlen($w) >= 3\n ));\n\n if (empty($words)) {\n return null;\n }\n\n $query = Product::query()\n ->whereNotNull('unit_price')\n ->where('unit_price', '>', 0)\n ->where('active', true);\n\n // Require up to 4 keywords to appear in the product name (AND logic)\n foreach (array_slice($words, 0, 4) as $word) {\n $query->where('name', 'like', '%' . $word . '%');\n }\n\n // Narrow by category name if available\n $category = trim((string) ($item['category'] ?? ''));\n if ($category !== '') {\n $query->whereHas('category', fn($q) => $q->where('name', 'like', '%' . $category . '%'));\n }\n\n $product = $query->select('unit_price')->first();\n\n return $product ? (float) $product->unit_price : null;\n }\n\n // -------------------------------------------------------------------------\n // Gemini fallback (single batched request)\n // -------------------------------------------------------------------------\n\n /**\n * Send all unmatched items to Gemini in ONE request to minimise token consumption.\n * Only the fields Gemini actually needs are sent (description, category, brand, unit).\n * Output tokens are capped via maxOutputTokens to protect against runaway usage.\n *\n * @param array<int, array<string, mixed>> $items\n * @param list<int> $unmatchedIndices\n * @return array<int, array<string, mixed>>\n */\n private function enrichWithGemini(array $items, array $unmatchedIndices): array\n {\n $apiKey = (string) config('services.gemini.key', '');\n $primaryModel = (string) config('services.gemini.model', 'gemini-2.0-flash-lite');\n // Try models in order — all confirmed available for this API key\n $modelChain = array_unique(array_filter([$primaryModel, 'gemini-2.0-flash-lite', 'gemini-flash-lite-latest', 'gemini-flash-latest']));\n\n if (empty($apiKey)) {\n Log::warning('PricingService: GEMINI_API_KEY not configured; skipping AI pricing.');\n return $items;\n }\n\n // Token protection: cap batch size\n $batch = array_slice($unmatchedIndices, 0, self::MAX_GEMINI_BATCH);\n\n // Compact payload — only essential fields to minimise input tokens\n $payload = [];\n foreach ($batch as $idx) {\n $payload[] = [\n 'i' => $idx,\n 'd' => mb_substr((string) ($items[$idx]['description'] ?? ''), 0, 80),\n 'cat' => mb_substr((string) ($items[$idx]['category'] ?? ''), 0, 30),\n 'br' => mb_substr((string) ($items[$idx]['brand'] ?? ''), 0, 30),\n 'unit' => mb_substr((string) ($items[$idx]['unit'] ?? ''), 0, 15),\n ];\n }\n\n $prompt = 'You are a procurement pricing expert for the Saudi Arabia construction and MEP materials market. '\n . 'Estimate a realistic current unit price in SAR for each item below. '\n . 'RULES: '\n . '(1) Always return a positive non-zero price — use your best market estimate, never 0. '\n . '(2) Base prices on typical Saudi supplier/contractor rates for 2024-2026. '\n . '(3) Return ONLY a valid compact JSON array with NO whitespace or newlines between elements. '\n . '(4) Each element must be exactly: {\"i\":<index>,\"p\":<price_number>} '\n . '(5) No markdown, no explanation, no extra keys, no pretty-printing. '\n . 'Example output: [{\"i\":0,\"p\":1500},{\"i\":1,\"p\":350}] '\n . 'Items: ' . json_encode($payload, JSON_UNESCAPED_UNICODE);\n\n $lastStatus = null;\n\n foreach ($modelChain as $model) {\n $url = \"https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key={$apiKey}\";\n\n try {\n $response = Http::timeout(60)->post($url, [\n 'contents' => [\n ['role' => 'user', 'parts' => [['text' => $prompt]]],\n ],\n 'generationConfig' => [\n 'responseMimeType' => 'application/json',\n 'temperature' => 0.2,\n 'maxOutputTokens' => 8192,\n ],\n ]);\n\n if (! $response->successful()) {\n $lastStatus = $response->status();\n Log::warning('PricingService: Gemini model failed, trying next.', [\n 'model' => $model,\n 'status' => $lastStatus,\n ]);\n continue; // try next model\n }\n\n $text = $response->json('candidates.0.content.parts.0.text') ?? '';\n $text = preg_replace('/^```json\\s*/i', '', trim($text));\n $text = preg_replace('/```\\s*$/i', '', $text);\n\n // First try full JSON decode\n $priceData = json_decode($text, true);\n\n // Fallback: extract individual objects via regex if truncated\n if (! is_array($priceData)) {\n Log::warning('PricingService: Full JSON parse failed — attempting partial extraction.', [\n 'model' => $model,\n 'text_preview' => mb_substr($text, 0, 200),\n ]);\n preg_match_all('/\\{\\s*\"i\"\\s*:\\s*(\\d+)\\s*,\\s*\"p\"\\s*:\\s*([\\d.]+)\\s*\\}/', $text, $matches, PREG_SET_ORDER);\n if (! empty($matches)) {\n $priceData = array_map(fn($m) => ['i' => (int) $m[1], 'p' => (float) $m[2]], $matches);\n Log::info('PricingService: Partial extraction recovered ' . count($priceData) . ' price(s).');\n }\n }\n\n if (! is_array($priceData) || empty($priceData)) {\n Log::warning('PricingService: Could not extract any prices from Gemini response.', [\n 'model' => $model,\n 'text_preview' => mb_substr($text, 0, 300),\n ]);\n continue; // try next model\n }\n\n foreach ($priceData as $entry) {\n $idx = $entry['i'] ?? null;\n $price = $entry['p'] ?? null;\n\n if ($idx === null || ! array_key_exists($idx, $items)) {\n continue;\n }\n\n $price = is_numeric($price) ? (float) $price : 0.0;\n\n if ($price > 0) {\n $items[$idx]['unit_price'] = $price;\n $items[$idx]['price_source'] = 'gemini';\n }\n }\n\n return $items; // success — no need to try further models\n\n } catch (\\Throwable $e) {\n Log::error('PricingService: Exception calling Gemini.', [\n 'model' => $model,\n 'message' => $e->getMessage(),\n ]);\n }\n }\n\n Log::error('PricingService: All Gemini models failed.', ['last_status' => $lastStatus]);\n\n return $items;\n }\n}\n"}, "53c8f6e7f81d8dfdc9642ff895a779f1b4cd7a67": {"t": 1785748363.180396, "n": 9009, "h": "05fee61fb8f23a97", "c": "<?php\n\nnamespace App\\Services;\n\nuse App\\Models\\Product;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\n\nclass PricingService\n{\n /**\n * Maximum items sent to Gemini per request.\n * Keeps token usage under control — items beyond this limit remain unpriced via Gemini.\n */\n private const MAX_GEMINI_BATCH = 25;\n\n /**\n * Fetch unit prices for an array of quotation items.\n *\n * Strategy:\n * 1. Try the products table first (keyword match on name + optional category match).\n * 2. Collect all unmatched items and send them in ONE batched Gemini call.\n *\n * @param array<int, array<string, mixed>> $items\n * @return array<int, array<string, mixed>> Items enriched with unit_price, price_source, price_status\n */\n public function fetchPrices(array $items): array\n {\n $unmatched = [];\n\n foreach ($items as $index => $item) {\n // Skip already-priced rows (e.g., re-fetch after partial approval)\n if (! empty($item['unit_price'])) {\n continue;\n }\n\n $price = $this->lookupProductsTable($item);\n\n if ($price !== null) {\n $items[$index]['unit_price'] = $price;\n $items[$index]['price_source'] = 'products';\n $items[$index]['price_status'] = 'pending';\n } else {\n $items[$index]['unit_price'] = null;\n $items[$index]['price_source'] = null;\n $items[$index]['price_status'] = 'pending';\n $unmatched[] = $index;\n }\n }\n\n if (! empty($unmatched)) {\n $items = $this->enrichWithGemini($items, $unmatched);\n }\n\n return $items;\n }\n\n // -------------------------------------------------------------------------\n // Products table lookup\n // -------------------------------------------------------------------------\n\n /**\n * Search the products table for a matching product by keyword.\n * Returns the unit_price when a confident match is found, null otherwise.\n */\n private function lookupProductsTable(array $item): ?float\n {\n $description = trim((string) ($item['description'] ?? ''));\n\n if (empty($description)) {\n return null;\n }\n\n // Extract significant words (≥ 3 chars)\n $words = array_values(array_filter(\n explode(' ', preg_replace('/[^a-zA-Z0-9\\s]/', ' ', $description)),\n fn(string $w) => strlen($w) >= 3\n ));\n\n if (empty($words)) {\n return null;\n }\n\n $query = Product::query()\n ->whereNotNull('unit_price')\n ->where('unit_price', '>', 0)\n ->where('active', true);\n\n // Require up to 4 keywords to appear in the product name (AND logic)\n foreach (array_slice($words, 0, 4) as $word) {\n $query->where('name', 'like', '%' . $word . '%');\n }\n\n // Narrow by category name if available\n $category = trim((string) ($item['category'] ?? ''));\n if ($category !== '') {\n $query->whereHas('category', fn($q) => $q->where('name', 'like', '%' . $category . '%'));\n }\n\n $product = $query->select('unit_price')->first();\n\n return $product ? (float) $product->unit_price : null;\n }\n\n // -------------------------------------------------------------------------\n // Gemini fallback (single batched request)\n // -------------------------------------------------------------------------\n\n /**\n * Send all unmatched items to Gemini in ONE request to minimise token consumption.\n * Only the fields Gemini actually needs are sent (description, category, brand, unit).\n * Output tokens are capped via maxOutputTokens to protect against runaway usage.\n *\n * @param array<int, array<string, mixed>> $items\n * @param list<int> $unmatchedIndices\n * @return array<int, array<string, mixed>>\n */\n private function enrichWithGemini(array $items, array $unmatchedIndices): array\n {\n $apiKey = (string) config('services.gemini.key', '');\n $model = (string) config('services.gemini.model', 'gemini-2.5-flash');\n\n if (empty($apiKey)) {\n Log::warning('PricingService: GEMINI_API_KEY not configured; skipping AI pricing.');\n return $items;\n }\n\n // Token protection: cap batch size\n $batch = array_slice($unmatchedIndices, 0, self::MAX_GEMINI_BATCH);\n\n // Compact payload — only essential fields to minimise input tokens\n $payload = [];\n foreach ($batch as $idx) {\n $payload[] = [\n 'i' => $idx,\n 'd' => mb_substr((string) ($items[$idx]['description'] ?? ''), 0, 80),\n 'cat' => mb_substr((string) ($items[$idx]['category'] ?? ''), 0, 30),\n 'br' => mb_substr((string) ($items[$idx]['brand'] ?? ''), 0, 30),\n 'unit' => mb_substr((string) ($items[$idx]['unit'] ?? ''), 0, 15),\n ];\n }\n\n $prompt = 'You are a procurement pricing expert for the Saudi Arabia construction and MEP materials market. '\n . 'Estimate a realistic current unit price in SAR for each item below. '\n . 'RULES: '\n . '(1) Always return a positive non-zero price — use your best market estimate, never 0. '\n . '(2) Base prices on typical Saudi supplier/contractor rates for 2024-2026. '\n . '(3) Return ONLY a valid compact JSON array with NO whitespace or newlines between elements. '\n . '(4) Each element must be exactly: {\"i\":<index>,\"p\":<price_number>} '\n . '(5) No markdown, no explanation, no extra keys, no pretty-printing. '\n . 'Example output: [{\"i\":0,\"p\":1500},{\"i\":1,\"p\":350}] '\n . 'Items: ' . json_encode($payload, JSON_UNESCAPED_UNICODE);\n\n $url = \"https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key={$apiKey}\";\n\n try {\n $response = Http::timeout(60)->post($url, [\n 'contents' => [\n ['role' => 'user', 'parts' => [['text' => $prompt]]],\n ],\n 'generationConfig' => [\n 'responseMimeType' => 'application/json',\n 'temperature' => 0.2,\n 'maxOutputTokens' => 8192,\n ],\n ]);\n\n if (! $response->successful()) {\n Log::error('PricingService: Gemini API error.', [\n 'status' => $response->status(),\n 'body' => $response->body(),\n ]);\n return $items;\n }\n\n $text = $response->json('candidates.0.content.parts.0.text') ?? '';\n $text = preg_replace('/^```json\\s*/i', '', trim($text));\n $text = preg_replace('/```\\s*$/i', '', $text);\n\n // First try full JSON decode\n $priceData = json_decode($text, true);\n\n // Fallback: if the response was truncated (json_decode fails), extract\n // individual {\"i\":N,\"p\":N} objects via regex so partial results are not wasted\n if (! is_array($priceData)) {\n Log::warning('PricingService: Full JSON parse failed — attempting partial extraction.', [\n 'text_preview' => mb_substr($text, 0, 200),\n ]);\n preg_match_all('/\\{\\s*\"i\"\\s*:\\s*(\\d+)\\s*,\\s*\"p\"\\s*:\\s*([\\d.]+)\\s*\\}/', $text, $matches, PREG_SET_ORDER);\n if (! empty($matches)) {\n $priceData = array_map(fn($m) => ['i' => (int) $m[1], 'p' => (float) $m[2]], $matches);\n Log::info('PricingService: Partial extraction recovered ' . count($priceData) . ' price(s).');\n }\n }\n\n if (! is_array($priceData) || empty($priceData)) {\n Log::warning('PricingService: Could not extract any prices from Gemini response.', [\n 'text_preview' => mb_substr($text, 0, 300),\n ]);\n return $items;\n }\n\n foreach ($priceData as $entry) {\n $idx = $entry['i'] ?? null;\n $price = $entry['p'] ?? null;\n\n if ($idx === null || ! array_key_exists($idx, $items)) {\n continue;\n }\n\n $price = is_numeric($price) ? (float) $price : 0.0;\n\n // Accept any positive price from Gemini — 0 means Gemini failed to estimate\n if ($price > 0) {\n $items[$idx]['unit_price'] = $price;\n $items[$idx]['price_source'] = 'gemini';\n }\n }\n\n } catch (\\Throwable $e) {\n Log::error('PricingService: Exception calling Gemini.', ['message' => $e->getMessage()]);\n }\n\n return $items;\n }\n}\n"}}, "app/Services/Pricing/ProductSpecEngine.php": {"9a5ada4ab1ceb0de386bc1ce99c561f8869c09d7": {"t": 1785746179.8393986, "n": 23769, "h": "70b12e5385e62908", "c": "<?php\n\nnamespace App\\Services\\Pricing;\n\nuse Illuminate\\Http\\Client\\Pool;\nuse Illuminate\\Http\\Client\\Response;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\n\n/**\n * Product Specification & Pricing Qualification Engine.\n *\n * Qualifies each BOQ line before any price is attached. For every item it:\n * 1. classifies it (supply product vs service/installation/unclear);\n * 2. normalises the unit and flags impossible ones;\n * 3. separates specs the client confirmed from safe industry defaults;\n * 4. asks only for BLOCKING specs, grouped into one question per item;\n * 5. returns a pricing-readiness status.\n *\n * Division of labour: unit rules and the spec rubric are deterministic (see\n * UnitNormalizer and ProductSpecCatalog) so they never drift between calls; the\n * AI is used only for the judgement that genuinely needs it — reading intent\n * from a free-text description and deciding what is truly missing.\n *\n * Never invents a brand, model, electrical rating, dimension, or approval.\n */\nclass ProductSpecEngine\n{\n /** Items per AI call; small enough that calls can run in parallel. */\n private const CHUNK_SIZE = 8;\n\n /** The spec caps the grouped question at five sub-parts. */\n private const MAX_QUESTION_PARTS = 5;\n\n public const CLASSIFICATIONS = [\n 'SUPPLY_PRODUCT', 'CUSTOM_MANUFACTURED_PRODUCT', 'SOFTWARE_OR_LICENSE',\n 'SERVICE', 'INSTALLATION', 'CONSULTATION', 'UNSUPPORTED_ITEM', 'UNCLEAR_ITEM',\n ];\n\n /** Only these may appear in a supply quotation. */\n public const SUPPLYABLE = [\n 'SUPPLY_PRODUCT', 'CUSTOM_MANUFACTURED_PRODUCT', 'SOFTWARE_OR_LICENSE',\n ];\n\n public const STATUSES = [\n 'READY_TO_PRICE', 'READY_WITH_ASSUMPTIONS', 'BLOCKED_MISSING_SPECIFICATIONS',\n 'INVALID_QUANTITY_OR_UNIT', 'COMPATIBILITY_REVIEW_REQUIRED', 'NOT_A_SUPPLY_PRODUCT',\n ];\n\n /**\n * Qualify a list of BOQ items.\n *\n * @param array<int, array<string, mixed>> $items each with description, quantity, unit, category, brand\n * @param array<string, mixed> $project shared context (name, type, location, delivery date…)\n * @return array<int, array<string, mixed>> one qualified record per input item, same order\n */\n public function qualify(array $items, array $project = []): array\n {\n if (empty($items)) {\n return [];\n }\n\n // Deterministic pass first — it also gives the AI a cleaner starting point.\n $prepared = [];\n foreach ($items as $i => $item) {\n $prepared[$i] = $this->prepare($item);\n }\n\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n Log::warning('ProductSpecEngine: DEEPSEEK_API_KEY not configured; returning deterministic results only.');\n return array_values($prepared);\n }\n\n $chunks = array_chunk(array_keys($prepared), self::CHUNK_SIZE);\n\n $prepared = count($chunks) === 1\n ? $this->qualifyChunk($prepared, $chunks[0], $project)\n : $this->qualifyChunksParallel($prepared, $chunks, $project);\n\n // Project-level cross-item intelligence runs last: it needs every line resolved.\n return array_values(ProjectIntelligence::analyse($prepared, $project));\n }\n\n // ─────────────────────────────────────────────────────────────────────────\n // Deterministic pre-pass\n // ─────────────────────────────────────────────────────────────────────────\n\n /**\n * Normalise a single row's unit against its matched product family.\n *\n * Deterministic and free — no AI call — so it is safe to run over every row\n * at extraction time. Returns the corrected unit, or null to leave as-is.\n *\n * Without this, a printer arrives measured in \"liter/day\" and a UPS in\n * \"carton meter\", because the extractor copies whatever the sheet said.\n */\n public function normalizeUnitFor(string $description, string $rawUnit): ?string\n {\n $catalog = ProductSpecCatalog::match($description);\n if ($catalog === null) {\n return null;\n }\n\n $unit = UnitNormalizer::normalize($rawUnit, $catalog);\n\n $normalized = (string) ($unit['normalized'] ?? '');\n if ($normalized === '' || strcasecmp($normalized, $rawUnit) === 0) {\n return null;\n }\n\n return $normalized;\n }\n\n /**\n * Build the base record: catalog match, normalised unit, quantity sanity.\n * Everything here is decidable without the AI.\n */\n private function prepare(array $item): array\n {\n $description = trim((string) ($item['description'] ?? ''));\n $quantity = (float) ($item['quantity'] ?? 0);\n $rawUnit = (string) ($item['unit'] ?? '');\n\n $catalog = ProductSpecCatalog::match($description);\n $unit = UnitNormalizer::normalize($rawUnit, $catalog);\n\n $quantityWarnings = [];\n if ($quantity <= 0) {\n $quantityWarnings[] = [\n 'code' => 'QUANTITY_NOT_POSITIVE',\n 'severity' => 'error',\n 'message' => 'Quantity must be greater than zero.',\n ];\n } elseif ($quantity != floor($quantity) && in_array($unit['normalized'], ['PCS', 'SET', 'LICENSE'], true)) {\n $quantityWarnings[] = [\n 'code' => 'QUANTITY_FRACTIONAL',\n 'severity' => 'warning',\n 'message' => \"A fractional quantity ({$quantity}) is not valid for a countable unit ({$unit['normalized']}).\",\n ];\n }\n\n return [\n 'original_item_name' => $description,\n 'normalized_product_name' => $description,\n 'classification' => null,\n 'category' => (string) ($item['category'] ?? ''),\n 'catalog_key' => $catalog['key'] ?? null,\n 'quantity' => $quantity,\n 'original_unit' => $rawUnit,\n 'normalized_unit' => $unit['normalized'],\n 'supplyable' => true,\n 'pricing_status' => null,\n 'confirmed_specifications' => [],\n 'inferred_specifications' => [],\n 'missing_blocking_specifications' => [],\n 'conditional_questions' => [],\n 'assumptions' => [],\n 'quantity_warnings' => $quantityWarnings,\n 'unit_warnings' => $unit['warnings'],\n 'compatibility_warnings' => [],\n 'recommended_final_description' => $description,\n 'pricing_basis' => '',\n 'confidence_score' => 0,\n ];\n }\n\n // ─────────────────────────────────────────────────────────────────────────\n // Prompt\n // ─────────────────────────────────────────────────────────────────────────\n\n private function buildPrompt(array $records, array $indices, array $project): string\n {\n $payload = [];\n foreach ($indices as $idx) {\n $r = $records[$idx];\n $catalog = $r['catalog_key'] ? ProductSpecCatalog::all()[$r['catalog_key']] ?? null : null;\n\n $payload[] = array_filter([\n 'i' => $idx,\n 'd' => mb_substr($r['original_item_name'], 0, 200),\n 'qty' => $r['quantity'],\n 'unit' => $r['normalized_unit'],\n 'cat' => mb_substr($r['category'], 0, 40),\n // The rubric for this family, so the model audits against fixed\n // criteria instead of improvising a different list each call.\n 'blocking' => $catalog['blocking'] ?? null,\n 'optional' => $catalog['conditional'] ?? null,\n ], fn ($v) => $v !== null && $v !== '');\n }\n\n $ctx = array_filter([\n 'project_name' => $project['name'] ?? null,\n 'project_type' => $project['type'] ?? null,\n 'location' => $project['location'] ?? null,\n 'delivery_date' => $project['delivery_date'] ?? null,\n 'brand_policy' => $project['brand_policy'] ?? null,\n 'quotation_type' => $project['quotation_type'] ?? null,\n ], fn ($v) => $v !== null && $v !== '');\n\n return 'You are a senior procurement engineer, quantity surveyor and technical pre-sales specialist '\n . 'for the Saudi Arabia (Riyadh) construction, MEP, IT, security, furniture and general-supply market. '\n . 'Qualify each BOQ line BEFORE pricing. Think like an experienced engineer, not a form-filler. '\n . ($ctx ? 'PROJECT CONTEXT: ' . json_encode($ctx, JSON_UNESCAPED_UNICODE) . '. ' : '')\n . 'FOR EACH ITEM DO ALL OF THE FOLLOWING: '\n . '(1) CLASSIFY as exactly one of: SUPPLY_PRODUCT, CUSTOM_MANUFACTURED_PRODUCT, SOFTWARE_OR_LICENSE, '\n . 'SERVICE, INSTALLATION, CONSULTATION, UNSUPPORTED_ITEM, UNCLEAR_ITEM. '\n . 'Installation, testing, commissioning, supervision, training, maintenance and consultancy are NOT supply products. '\n . 'If a line mixes supply AND installation, classify it as SUPPLY_PRODUCT and set \"split\":true so the caller separates the service. '\n . '(2) SEPARATE SPECIFICATIONS. Work through the item\\'s \"blocking\" list one entry at a time. '\n . 'Each one lands in exactly ONE of three places: \"cs\" if the description or project context '\n . 'states it; \"is\" if you can apply a defensible market-standard default; \"mb\" if it genuinely '\n . 'cannot be defaulted (a brand, a model number, an electrical rating, a custom dimension). '\n . 'Together, \"cs\" + \"is\" + \"mb\" MUST cover every entry in the \"blocking\" list — none may be skipped. '\n . 'Prefer \"is\" over \"mb\": most specs (processor generation, screen size, OS, warranty, colour '\n . 'temperature, IP rating) have a standard answer for this market, so default them and move on. '\n . 'Every entry in \"is\" MUST also appear as a plain-language sentence in \"as\" (assumptions). '\n . '(3) MISSING BLOCKING SPECS. \"mb\" = ONLY specs without which a defensible price is impossible. '\n . 'Do NOT list something already answered by the description, the project context, the product '\n . 'category, or a safe standard default. An empty \"mb\" is the expected outcome for most items. '\n . '(4) ONE GROUPED QUESTION. If \"mb\" is non-empty, \"q\" = a SINGLE Arabic question asking for all of them together '\n . '(maximum ' . self::MAX_QUESTION_PARTS . ' sub-parts, comma-separated in one sentence). Never one question per field. '\n . 'If \"mb\" is empty, \"q\" = \"\". '\n . '(5) STATUS \"st\": READY_TO_PRICE (all blocking specs confirmed), '\n . 'READY_WITH_ASSUMPTIONS (priceable as a budgetary estimate using stated assumptions), '\n . 'BLOCKED_MISSING_SPECIFICATIONS (a blocking spec is genuinely unknowable), '\n . 'NOT_A_SUPPLY_PRODUCT (service/installation/unsupported). '\n . '(6) \"fd\" = the FULL procurement-grade product description. This is the single most '\n . 'important field: a buyer must be able to source and price the item from \"fd\" ALONE, '\n . 'without seeing the original BOQ line. Write out EVERY spec in the \"blocking\" list for '\n . 'this item — the ones the client stated, plus the ones you inferred — as one comma-separated '\n . 'specification string. Expand vague trade shorthand into the real specification. '\n . 'It must NEVER contain questions, placeholders, or the words \"TBD\"/\"to be confirmed\". '\n . 'EXAMPLE — input \"Laptop for work - Core i7 / 16 GB / 512 GB SSD\" must become: '\n . '\"Business Laptop, Intel Core i7-1355U (13th Gen), 16 GB DDR4 RAM, 512 GB NVMe SSD, '\n . '14-inch FHD (1920x1080) Display, Intel Iris Xe Integrated Graphics, Windows 11 Pro, '\n . '3-Year Manufacturer Warranty\". Note how the generation, GPU, screen size, OS and warranty '\n . 'are all present even though the client never wrote them — each one is listed in \"is\" and '\n . 'restated in \"as\" as an assumption. A description that merely repeats the client\\'s words is WRONG. '\n . '(7) \"pb\" = one short phrase stating the pricing basis (e.g. \"budgetary, standard configuration assumed\"). '\n . '(8) \"cf\" = confidence 0-100 that this description can be priced accurately. '\n . 'HARD RULES: never invent a brand, model number, electrical rating, dimension for a custom-manufactured item, '\n . 'or a fire/safety approval. If such a value is genuinely required and unknown, it belongs in \"mb\", not in \"is\". '\n . 'OUTPUT: return ONLY a compact JSON array, no markdown, no commentary. Each element exactly: '\n . '{\"i\":<index>,\"cl\":\"<classification>\",\"split\":<bool>,\"cs\":{},\"is\":{},\"as\":[],\"mb\":[],\"q\":\"\",\"st\":\"<status>\",\"fd\":\"\",\"pb\":\"\",\"cf\":0} '\n . 'ITEMS: ' . json_encode($payload, JSON_UNESCAPED_UNICODE);\n }\n\n private function buildRequestBody(array $records, array $indices, array $project): array\n {\n return [\n 'model' => (string) config('services.deepseek.model', 'deepseek-chat'),\n 'messages' => [['role' => 'user', 'content' => $this->buildPrompt($records, $indices, $project)]],\n 'temperature' => 0,\n 'max_tokens' => 8192,\n 'user' => 'Qimta_Platform',\n ];\n }\n\n // ─────────────────────────────────────────────────────────────────────────\n // HTTP\n // ─────────────────────────────────────────────────────────────────────────\n\n private function qualifyChunk(array $records, array $indices, array $project): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n\n try {\n $response = Http::timeout(120)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post('https://api.deepseek.com/chat/completions', $this->buildRequestBody($records, $indices, $project));\n\n if (! $response->successful()) {\n Log::warning('ProductSpecEngine: DeepSeek request failed.', ['status' => $response->status()]);\n return $this->fallback($records, $indices);\n }\n\n return $this->apply((string) ($response->json('choices.0.message.content') ?? ''), $records);\n } catch (\\Throwable $e) {\n Log::error('ProductSpecEngine: exception calling DeepSeek.', ['message' => $e->getMessage()]);\n return $this->fallback($records, $indices);\n }\n }\n\n /** @param list<list<int>> $chunks */\n private function qualifyChunksParallel(array $records, array $chunks, array $project): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n $url = 'https://api.deepseek.com/chat/completions';\n\n try {\n $responses = Http::pool(function (Pool $pool) use ($url, $apiKey, $records, $chunks, $project) {\n $reqs = [];\n foreach ($chunks as $ci => $indices) {\n $reqs[] = $pool->as((string) $ci)\n ->timeout(120)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post($url, $this->buildRequestBody($records, $indices, $project));\n }\n return $reqs;\n });\n\n foreach ($responses as $key => $response) {\n $ci = (int) $key;\n\n if (! ($response instanceof Response) || ! $response->successful()) {\n Log::warning('ProductSpecEngine: parallel chunk failed, retrying serially.', ['chunk' => $ci]);\n $records = $this->qualifyChunk($records, $chunks[$ci], $project);\n continue;\n }\n\n $records = $this->apply((string) ($response->json('choices.0.message.content') ?? ''), $records);\n }\n } catch (\\Throwable $e) {\n Log::error('ProductSpecEngine: Http::pool exception, falling back to serial.', ['message' => $e->getMessage()]);\n foreach ($chunks as $indices) {\n $records = $this->qualifyChunk($records, $indices, $project);\n }\n }\n\n return $records;\n }\n\n // ─────────────────────────────────────────────────────────────────────────\n // Response handling\n // ─────────────────────────────────────────────────────────────────────────\n\n private function apply(string $text, array $records): array\n {\n $text = preg_replace('/^```(?:json)?\\s*/i', '', trim($text));\n $text = preg_replace('/```\\s*$/', '', (string) $text);\n\n $data = json_decode((string) $text, true);\n\n if (! is_array($data) || $data === []) {\n Log::warning('ProductSpecEngine: unparseable AI response.', ['preview' => mb_substr($text, 0, 300)]);\n return $records;\n }\n\n foreach ($data as $entry) {\n if (! is_array($entry)) {\n continue;\n }\n\n $i = $entry['i'] ?? null;\n if ($i === null || ! array_key_exists($i, $records)) {\n continue;\n }\n\n $records[$i] = $this->mergeEntry($records[$i], $entry);\n }\n\n return $records;\n }\n\n /** Merge one AI verdict into its record, enforcing the engine's invariants. */\n private function mergeEntry(array $record, array $entry): array\n {\n $classification = in_array($entry['cl'] ?? null, self::CLASSIFICATIONS, true)\n ? $entry['cl']\n : 'UNCLEAR_ITEM';\n\n $supplyable = in_array($classification, self::SUPPLYABLE, true);\n\n $missing = array_values(array_filter(\n array_map(fn ($m) => trim((string) $m), (array) ($entry['mb'] ?? [])),\n fn ($m) => $m !== ''\n ));\n\n $assumptions = array_values(array_filter(\n array_map(fn ($a) => trim((string) $a), (array) ($entry['as'] ?? [])),\n fn ($a) => $a !== ''\n ));\n\n $status = in_array($entry['st'] ?? null, self::STATUSES, true) ? $entry['st'] : null;\n\n // Invariants the model is not allowed to violate, in priority order.\n if (! $supplyable) {\n $status = 'NOT_A_SUPPLY_PRODUCT';\n } elseif ($this->hasError($record['quantity_warnings']) || $this->hasError($record['unit_warnings'])) {\n $status = 'INVALID_QUANTITY_OR_UNIT';\n } elseif ($missing !== []) {\n $status = 'BLOCKED_MISSING_SPECIFICATIONS';\n } elseif ($status === null) {\n $status = $assumptions === [] ? 'READY_TO_PRICE' : 'READY_WITH_ASSUMPTIONS';\n } elseif ($status === 'READY_TO_PRICE' && $assumptions !== []) {\n // Assumptions were applied, so this is budgetary by definition.\n $status = 'READY_WITH_ASSUMPTIONS';\n }\n\n $finalDescription = trim((string) ($entry['fd'] ?? ''));\n if ($finalDescription === '' || $this->looksLikeQuestion($finalDescription)) {\n $finalDescription = $record['original_item_name'];\n }\n\n // The whole point of the pass is a description a buyer can source from.\n // If the model handed back the client's own wording, the line was not\n // actually enriched — surface it rather than letting it pass as qualified.\n if ($this->isNotEnriched($finalDescription, $record['original_item_name'])) {\n Log::info('ProductSpecEngine: description not enriched by the model.', [\n 'item' => mb_substr($record['original_item_name'], 0, 120),\n 'family' => $record['catalog_key'],\n ]);\n }\n\n $question = trim((string) ($entry['q'] ?? ''));\n\n return array_merge($record, [\n 'classification' => $classification,\n 'supplyable' => $supplyable,\n 'split_supply_and_installation' => (bool) ($entry['split'] ?? false),\n 'confirmed_specifications' => is_array($entry['cs'] ?? null) ? $entry['cs'] : [],\n 'inferred_specifications' => is_array($entry['is'] ?? null) ? $entry['is'] : [],\n 'assumptions' => $assumptions,\n 'missing_blocking_specifications' => $missing,\n 'grouped_question' => $missing === [] ? '' : $question,\n 'pricing_status' => $status,\n 'normalized_product_name' => trim((string) ($entry['fd'] ?? '')) ?: $record['original_item_name'],\n 'recommended_final_description' => $finalDescription,\n 'pricing_basis' => mb_substr(trim((string) ($entry['pb'] ?? '')), 0, 255),\n 'confidence_score' => max(0, min(100, (int) ($entry['cf'] ?? 0))),\n ]);\n }\n\n /**\n * When the AI is unreachable, fall back to the deterministic verdict rather\n * than guessing — an unqualified line is safer than a wrongly-qualified one.\n */\n private function fallback(array $records, array $indices): array\n {\n foreach ($indices as $i) {\n if (! array_key_exists($i, $records) || $records[$i]['pricing_status'] !== null) {\n continue;\n }\n\n $records[$i]['classification'] = 'UNCLEAR_ITEM';\n $records[$i]['pricing_status'] = $this->hasError($records[$i]['quantity_warnings'])\n || $this->hasError($records[$i]['unit_warnings'])\n ? 'INVALID_QUANTITY_OR_UNIT'\n : 'BLOCKED_MISSING_SPECIFICATIONS';\n $records[$i]['pricing_basis'] = 'Specification review unavailable; line not qualified.';\n }\n\n return $records;\n }\n\n /** @param list<array{severity?:string}> $warnings */\n private function hasError(array $warnings): bool\n {\n foreach ($warnings as $w) {\n if (($w['severity'] ?? '') === 'error') {\n return true;\n }\n }\n return false;\n }\n\n /**\n * True when the \"enriched\" description is really just the client's own line.\n *\n * A genuine qualification expands trade shorthand into a full specification,\n * so it is materially longer and carries more comma-separated parts. This is\n * a heuristic, used only to flag the case for review — never to block a line.\n */\n private function isNotEnriched(string $final, string $original): bool\n {\n $normalise = static fn (string $s) => mb_strtolower(preg_replace('/\\s+/u', ' ', trim($s)));\n\n if ($normalise($final) === $normalise($original)) {\n return true;\n }\n\n // Fewer than two spec separators means it is still a bare product name.\n return substr_count($final, ',') < 2 && mb_strlen($final) < mb_strlen($original) * 1.5;\n }\n\n /** Guards rule 7: a question must never leak into the final description. */\n private function looksLikeQuestion(string $text): bool\n {\n if (str_contains($text, '?') || str_contains($text, '؟')) {\n return true;\n }\n\n foreach (['tbd', 'to be confirmed', 'to be advised', 'please confirm', 'يرجى تحديد', 'يرجى تأكيد', 'غير محدد'] as $needle) {\n if (mb_stripos($text, $needle) !== false) {\n return true;\n }\n }\n\n return false;\n }\n}\n"}, "ea6c7a169184892a12edda05c08c8a5b44d31c5d": {"t": 1785748347.8740175, "n": 23769, "h": "70b12e5385e62908", "c": "<?php\n\nnamespace App\\Services\\Pricing;\n\nuse Illuminate\\Http\\Client\\Pool;\nuse Illuminate\\Http\\Client\\Response;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\n\n/**\n * Product Specification & Pricing Qualification Engine.\n *\n * Qualifies each BOQ line before any price is attached. For every item it:\n * 1. classifies it (supply product vs service/installation/unclear);\n * 2. normalises the unit and flags impossible ones;\n * 3. separates specs the client confirmed from safe industry defaults;\n * 4. asks only for BLOCKING specs, grouped into one question per item;\n * 5. returns a pricing-readiness status.\n *\n * Division of labour: unit rules and the spec rubric are deterministic (see\n * UnitNormalizer and ProductSpecCatalog) so they never drift between calls; the\n * AI is used only for the judgement that genuinely needs it — reading intent\n * from a free-text description and deciding what is truly missing.\n *\n * Never invents a brand, model, electrical rating, dimension, or approval.\n */\nclass ProductSpecEngine\n{\n /** Items per AI call; small enough that calls can run in parallel. */\n private const CHUNK_SIZE = 8;\n\n /** The spec caps the grouped question at five sub-parts. */\n private const MAX_QUESTION_PARTS = 5;\n\n public const CLASSIFICATIONS = [\n 'SUPPLY_PRODUCT', 'CUSTOM_MANUFACTURED_PRODUCT', 'SOFTWARE_OR_LICENSE',\n 'SERVICE', 'INSTALLATION', 'CONSULTATION', 'UNSUPPORTED_ITEM', 'UNCLEAR_ITEM',\n ];\n\n /** Only these may appear in a supply quotation. */\n public const SUPPLYABLE = [\n 'SUPPLY_PRODUCT', 'CUSTOM_MANUFACTURED_PRODUCT', 'SOFTWARE_OR_LICENSE',\n ];\n\n public const STATUSES = [\n 'READY_TO_PRICE', 'READY_WITH_ASSUMPTIONS', 'BLOCKED_MISSING_SPECIFICATIONS',\n 'INVALID_QUANTITY_OR_UNIT', 'COMPATIBILITY_REVIEW_REQUIRED', 'NOT_A_SUPPLY_PRODUCT',\n ];\n\n /**\n * Qualify a list of BOQ items.\n *\n * @param array<int, array<string, mixed>> $items each with description, quantity, unit, category, brand\n * @param array<string, mixed> $project shared context (name, type, location, delivery date…)\n * @return array<int, array<string, mixed>> one qualified record per input item, same order\n */\n public function qualify(array $items, array $project = []): array\n {\n if (empty($items)) {\n return [];\n }\n\n // Deterministic pass first — it also gives the AI a cleaner starting point.\n $prepared = [];\n foreach ($items as $i => $item) {\n $prepared[$i] = $this->prepare($item);\n }\n\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n Log::warning('ProductSpecEngine: DEEPSEEK_API_KEY not configured; returning deterministic results only.');\n return array_values($prepared);\n }\n\n $chunks = array_chunk(array_keys($prepared), self::CHUNK_SIZE);\n\n $prepared = count($chunks) === 1\n ? $this->qualifyChunk($prepared, $chunks[0], $project)\n : $this->qualifyChunksParallel($prepared, $chunks, $project);\n\n // Project-level cross-item intelligence runs last: it needs every line resolved.\n return array_values(ProjectIntelligence::analyse($prepared, $project));\n }\n\n // ─────────────────────────────────────────────────────────────────────────\n // Deterministic pre-pass\n // ─────────────────────────────────────────────────────────────────────────\n\n /**\n * Normalise a single row's unit against its matched product family.\n *\n * Deterministic and free — no AI call — so it is safe to run over every row\n * at extraction time. Returns the corrected unit, or null to leave as-is.\n *\n * Without this, a printer arrives measured in \"liter/day\" and a UPS in\n * \"carton meter\", because the extractor copies whatever the sheet said.\n */\n public function normalizeUnitFor(string $description, string $rawUnit): ?string\n {\n $catalog = ProductSpecCatalog::match($description);\n if ($catalog === null) {\n return null;\n }\n\n $unit = UnitNormalizer::normalize($rawUnit, $catalog);\n\n $normalized = (string) ($unit['normalized'] ?? '');\n if ($normalized === '' || strcasecmp($normalized, $rawUnit) === 0) {\n return null;\n }\n\n return $normalized;\n }\n\n /**\n * Build the base record: catalog match, normalised unit, quantity sanity.\n * Everything here is decidable without the AI.\n */\n private function prepare(array $item): array\n {\n $description = trim((string) ($item['description'] ?? ''));\n $quantity = (float) ($item['quantity'] ?? 0);\n $rawUnit = (string) ($item['unit'] ?? '');\n\n $catalog = ProductSpecCatalog::match($description);\n $unit = UnitNormalizer::normalize($rawUnit, $catalog);\n\n $quantityWarnings = [];\n if ($quantity <= 0) {\n $quantityWarnings[] = [\n 'code' => 'QUANTITY_NOT_POSITIVE',\n 'severity' => 'error',\n 'message' => 'Quantity must be greater than zero.',\n ];\n } elseif ($quantity != floor($quantity) && in_array($unit['normalized'], ['PCS', 'SET', 'LICENSE'], true)) {\n $quantityWarnings[] = [\n 'code' => 'QUANTITY_FRACTIONAL',\n 'severity' => 'warning',\n 'message' => \"A fractional quantity ({$quantity}) is not valid for a countable unit ({$unit['normalized']}).\",\n ];\n }\n\n return [\n 'original_item_name' => $description,\n 'normalized_product_name' => $description,\n 'classification' => null,\n 'category' => (string) ($item['category'] ?? ''),\n 'catalog_key' => $catalog['key'] ?? null,\n 'quantity' => $quantity,\n 'original_unit' => $rawUnit,\n 'normalized_unit' => $unit['normalized'],\n 'supplyable' => true,\n 'pricing_status' => null,\n 'confirmed_specifications' => [],\n 'inferred_specifications' => [],\n 'missing_blocking_specifications' => [],\n 'conditional_questions' => [],\n 'assumptions' => [],\n 'quantity_warnings' => $quantityWarnings,\n 'unit_warnings' => $unit['warnings'],\n 'compatibility_warnings' => [],\n 'recommended_final_description' => $description,\n 'pricing_basis' => '',\n 'confidence_score' => 0,\n ];\n }\n\n // ─────────────────────────────────────────────────────────────────────────\n // Prompt\n // ─────────────────────────────────────────────────────────────────────────\n\n private function buildPrompt(array $records, array $indices, array $project): string\n {\n $payload = [];\n foreach ($indices as $idx) {\n $r = $records[$idx];\n $catalog = $r['catalog_key'] ? ProductSpecCatalog::all()[$r['catalog_key']] ?? null : null;\n\n $payload[] = array_filter([\n 'i' => $idx,\n 'd' => mb_substr($r['original_item_name'], 0, 200),\n 'qty' => $r['quantity'],\n 'unit' => $r['normalized_unit'],\n 'cat' => mb_substr($r['category'], 0, 40),\n // The rubric for this family, so the model audits against fixed\n // criteria instead of improvising a different list each call.\n 'blocking' => $catalog['blocking'] ?? null,\n 'optional' => $catalog['conditional'] ?? null,\n ], fn ($v) => $v !== null && $v !== '');\n }\n\n $ctx = array_filter([\n 'project_name' => $project['name'] ?? null,\n 'project_type' => $project['type'] ?? null,\n 'location' => $project['location'] ?? null,\n 'delivery_date' => $project['delivery_date'] ?? null,\n 'brand_policy' => $project['brand_policy'] ?? null,\n 'quotation_type' => $project['quotation_type'] ?? null,\n ], fn ($v) => $v !== null && $v !== '');\n\n return 'You are a senior procurement engineer, quantity surveyor and technical pre-sales specialist '\n . 'for the Saudi Arabia (Riyadh) construction, MEP, IT, security, furniture and general-supply market. '\n . 'Qualify each BOQ line BEFORE pricing. Think like an experienced engineer, not a form-filler. '\n . ($ctx ? 'PROJECT CONTEXT: ' . json_encode($ctx, JSON_UNESCAPED_UNICODE) . '. ' : '')\n . 'FOR EACH ITEM DO ALL OF THE FOLLOWING: '\n . '(1) CLASSIFY as exactly one of: SUPPLY_PRODUCT, CUSTOM_MANUFACTURED_PRODUCT, SOFTWARE_OR_LICENSE, '\n . 'SERVICE, INSTALLATION, CONSULTATION, UNSUPPORTED_ITEM, UNCLEAR_ITEM. '\n . 'Installation, testing, commissioning, supervision, training, maintenance and consultancy are NOT supply products. '\n . 'If a line mixes supply AND installation, classify it as SUPPLY_PRODUCT and set \"split\":true so the caller separates the service. '\n . '(2) SEPARATE SPECIFICATIONS. Work through the item\\'s \"blocking\" list one entry at a time. '\n . 'Each one lands in exactly ONE of three places: \"cs\" if the description or project context '\n . 'states it; \"is\" if you can apply a defensible market-standard default; \"mb\" if it genuinely '\n . 'cannot be defaulted (a brand, a model number, an electrical rating, a custom dimension). '\n . 'Together, \"cs\" + \"is\" + \"mb\" MUST cover every entry in the \"blocking\" list — none may be skipped. '\n . 'Prefer \"is\" over \"mb\": most specs (processor generation, screen size, OS, warranty, colour '\n . 'temperature, IP rating) have a standard answer for this market, so default them and move on. '\n . 'Every entry in \"is\" MUST also appear as a plain-language sentence in \"as\" (assumptions). '\n . '(3) MISSING BLOCKING SPECS. \"mb\" = ONLY specs without which a defensible price is impossible. '\n . 'Do NOT list something already answered by the description, the project context, the product '\n . 'category, or a safe standard default. An empty \"mb\" is the expected outcome for most items. '\n . '(4) ONE GROUPED QUESTION. If \"mb\" is non-empty, \"q\" = a SINGLE Arabic question asking for all of them together '\n . '(maximum ' . self::MAX_QUESTION_PARTS . ' sub-parts, comma-separated in one sentence). Never one question per field. '\n . 'If \"mb\" is empty, \"q\" = \"\". '\n . '(5) STATUS \"st\": READY_TO_PRICE (all blocking specs confirmed), '\n . 'READY_WITH_ASSUMPTIONS (priceable as a budgetary estimate using stated assumptions), '\n . 'BLOCKED_MISSING_SPECIFICATIONS (a blocking spec is genuinely unknowable), '\n . 'NOT_A_SUPPLY_PRODUCT (service/installation/unsupported). '\n . '(6) \"fd\" = the FULL procurement-grade product description. This is the single most '\n . 'important field: a buyer must be able to source and price the item from \"fd\" ALONE, '\n . 'without seeing the original BOQ line. Write out EVERY spec in the \"blocking\" list for '\n . 'this item — the ones the client stated, plus the ones you inferred — as one comma-separated '\n . 'specification string. Expand vague trade shorthand into the real specification. '\n . 'It must NEVER contain questions, placeholders, or the words \"TBD\"/\"to be confirmed\". '\n . 'EXAMPLE — input \"Laptop for work - Core i7 / 16 GB / 512 GB SSD\" must become: '\n . '\"Business Laptop, Intel Core i7-1355U (13th Gen), 16 GB DDR4 RAM, 512 GB NVMe SSD, '\n . '14-inch FHD (1920x1080) Display, Intel Iris Xe Integrated Graphics, Windows 11 Pro, '\n . '3-Year Manufacturer Warranty\". Note how the generation, GPU, screen size, OS and warranty '\n . 'are all present even though the client never wrote them — each one is listed in \"is\" and '\n . 'restated in \"as\" as an assumption. A description that merely repeats the client\\'s words is WRONG. '\n . '(7) \"pb\" = one short phrase stating the pricing basis (e.g. \"budgetary, standard configuration assumed\"). '\n . '(8) \"cf\" = confidence 0-100 that this description can be priced accurately. '\n . 'HARD RULES: never invent a brand, model number, electrical rating, dimension for a custom-manufactured item, '\n . 'or a fire/safety approval. If such a value is genuinely required and unknown, it belongs in \"mb\", not in \"is\". '\n . 'OUTPUT: return ONLY a compact JSON array, no markdown, no commentary. Each element exactly: '\n . '{\"i\":<index>,\"cl\":\"<classification>\",\"split\":<bool>,\"cs\":{},\"is\":{},\"as\":[],\"mb\":[],\"q\":\"\",\"st\":\"<status>\",\"fd\":\"\",\"pb\":\"\",\"cf\":0} '\n . 'ITEMS: ' . json_encode($payload, JSON_UNESCAPED_UNICODE);\n }\n\n private function buildRequestBody(array $records, array $indices, array $project): array\n {\n return [\n 'model' => (string) config('services.deepseek.model', 'deepseek-chat'),\n 'messages' => [['role' => 'user', 'content' => $this->buildPrompt($records, $indices, $project)]],\n 'temperature' => 0,\n 'max_tokens' => 8192,\n 'user' => 'Qimta_Platform',\n ];\n }\n\n // ─────────────────────────────────────────────────────────────────────────\n // HTTP\n // ─────────────────────────────────────────────────────────────────────────\n\n private function qualifyChunk(array $records, array $indices, array $project): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n\n try {\n $response = Http::timeout(120)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post('https://api.deepseek.com/chat/completions', $this->buildRequestBody($records, $indices, $project));\n\n if (! $response->successful()) {\n Log::warning('ProductSpecEngine: DeepSeek request failed.', ['status' => $response->status()]);\n return $this->fallback($records, $indices);\n }\n\n return $this->apply((string) ($response->json('choices.0.message.content') ?? ''), $records);\n } catch (\\Throwable $e) {\n Log::error('ProductSpecEngine: exception calling DeepSeek.', ['message' => $e->getMessage()]);\n return $this->fallback($records, $indices);\n }\n }\n\n /** @param list<list<int>> $chunks */\n private function qualifyChunksParallel(array $records, array $chunks, array $project): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n $url = 'https://api.deepseek.com/chat/completions';\n\n try {\n $responses = Http::pool(function (Pool $pool) use ($url, $apiKey, $records, $chunks, $project) {\n $reqs = [];\n foreach ($chunks as $ci => $indices) {\n $reqs[] = $pool->as((string) $ci)\n ->timeout(120)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post($url, $this->buildRequestBody($records, $indices, $project));\n }\n return $reqs;\n });\n\n foreach ($responses as $key => $response) {\n $ci = (int) $key;\n\n if (! ($response instanceof Response) || ! $response->successful()) {\n Log::warning('ProductSpecEngine: parallel chunk failed, retrying serially.', ['chunk' => $ci]);\n $records = $this->qualifyChunk($records, $chunks[$ci], $project);\n continue;\n }\n\n $records = $this->apply((string) ($response->json('choices.0.message.content') ?? ''), $records);\n }\n } catch (\\Throwable $e) {\n Log::error('ProductSpecEngine: Http::pool exception, falling back to serial.', ['message' => $e->getMessage()]);\n foreach ($chunks as $indices) {\n $records = $this->qualifyChunk($records, $indices, $project);\n }\n }\n\n return $records;\n }\n\n // ─────────────────────────────────────────────────────────────────────────\n // Response handling\n // ─────────────────────────────────────────────────────────────────────────\n\n private function apply(string $text, array $records): array\n {\n $text = preg_replace('/^```(?:json)?\\s*/i', '', trim($text));\n $text = preg_replace('/```\\s*$/', '', (string) $text);\n\n $data = json_decode((string) $text, true);\n\n if (! is_array($data) || $data === []) {\n Log::warning('ProductSpecEngine: unparseable AI response.', ['preview' => mb_substr($text, 0, 300)]);\n return $records;\n }\n\n foreach ($data as $entry) {\n if (! is_array($entry)) {\n continue;\n }\n\n $i = $entry['i'] ?? null;\n if ($i === null || ! array_key_exists($i, $records)) {\n continue;\n }\n\n $records[$i] = $this->mergeEntry($records[$i], $entry);\n }\n\n return $records;\n }\n\n /** Merge one AI verdict into its record, enforcing the engine's invariants. */\n private function mergeEntry(array $record, array $entry): array\n {\n $classification = in_array($entry['cl'] ?? null, self::CLASSIFICATIONS, true)\n ? $entry['cl']\n : 'UNCLEAR_ITEM';\n\n $supplyable = in_array($classification, self::SUPPLYABLE, true);\n\n $missing = array_values(array_filter(\n array_map(fn ($m) => trim((string) $m), (array) ($entry['mb'] ?? [])),\n fn ($m) => $m !== ''\n ));\n\n $assumptions = array_values(array_filter(\n array_map(fn ($a) => trim((string) $a), (array) ($entry['as'] ?? [])),\n fn ($a) => $a !== ''\n ));\n\n $status = in_array($entry['st'] ?? null, self::STATUSES, true) ? $entry['st'] : null;\n\n // Invariants the model is not allowed to violate, in priority order.\n if (! $supplyable) {\n $status = 'NOT_A_SUPPLY_PRODUCT';\n } elseif ($this->hasError($record['quantity_warnings']) || $this->hasError($record['unit_warnings'])) {\n $status = 'INVALID_QUANTITY_OR_UNIT';\n } elseif ($missing !== []) {\n $status = 'BLOCKED_MISSING_SPECIFICATIONS';\n } elseif ($status === null) {\n $status = $assumptions === [] ? 'READY_TO_PRICE' : 'READY_WITH_ASSUMPTIONS';\n } elseif ($status === 'READY_TO_PRICE' && $assumptions !== []) {\n // Assumptions were applied, so this is budgetary by definition.\n $status = 'READY_WITH_ASSUMPTIONS';\n }\n\n $finalDescription = trim((string) ($entry['fd'] ?? ''));\n if ($finalDescription === '' || $this->looksLikeQuestion($finalDescription)) {\n $finalDescription = $record['original_item_name'];\n }\n\n // The whole point of the pass is a description a buyer can source from.\n // If the model handed back the client's own wording, the line was not\n // actually enriched — surface it rather than letting it pass as qualified.\n if ($this->isNotEnriched($finalDescription, $record['original_item_name'])) {\n Log::info('ProductSpecEngine: description not enriched by the model.', [\n 'item' => mb_substr($record['original_item_name'], 0, 120),\n 'family' => $record['catalog_key'],\n ]);\n }\n\n $question = trim((string) ($entry['q'] ?? ''));\n\n return array_merge($record, [\n 'classification' => $classification,\n 'supplyable' => $supplyable,\n 'split_supply_and_installation' => (bool) ($entry['split'] ?? false),\n 'confirmed_specifications' => is_array($entry['cs'] ?? null) ? $entry['cs'] : [],\n 'inferred_specifications' => is_array($entry['is'] ?? null) ? $entry['is'] : [],\n 'assumptions' => $assumptions,\n 'missing_blocking_specifications' => $missing,\n 'grouped_question' => $missing === [] ? '' : $question,\n 'pricing_status' => $status,\n 'normalized_product_name' => trim((string) ($entry['fd'] ?? '')) ?: $record['original_item_name'],\n 'recommended_final_description' => $finalDescription,\n 'pricing_basis' => mb_substr(trim((string) ($entry['pb'] ?? '')), 0, 255),\n 'confidence_score' => max(0, min(100, (int) ($entry['cf'] ?? 0))),\n ]);\n }\n\n /**\n * When the AI is unreachable, fall back to the deterministic verdict rather\n * than guessing — an unqualified line is safer than a wrongly-qualified one.\n */\n private function fallback(array $records, array $indices): array\n {\n foreach ($indices as $i) {\n if (! array_key_exists($i, $records) || $records[$i]['pricing_status'] !== null) {\n continue;\n }\n\n $records[$i]['classification'] = 'UNCLEAR_ITEM';\n $records[$i]['pricing_status'] = $this->hasError($records[$i]['quantity_warnings'])\n || $this->hasError($records[$i]['unit_warnings'])\n ? 'INVALID_QUANTITY_OR_UNIT'\n : 'BLOCKED_MISSING_SPECIFICATIONS';\n $records[$i]['pricing_basis'] = 'Specification review unavailable; line not qualified.';\n }\n\n return $records;\n }\n\n /** @param list<array{severity?:string}> $warnings */\n private function hasError(array $warnings): bool\n {\n foreach ($warnings as $w) {\n if (($w['severity'] ?? '') === 'error') {\n return true;\n }\n }\n return false;\n }\n\n /**\n * True when the \"enriched\" description is really just the client's own line.\n *\n * A genuine qualification expands trade shorthand into a full specification,\n * so it is materially longer and carries more comma-separated parts. This is\n * a heuristic, used only to flag the case for review — never to block a line.\n */\n private function isNotEnriched(string $final, string $original): bool\n {\n $normalise = static fn (string $s) => mb_strtolower(preg_replace('/\\s+/u', ' ', trim($s)));\n\n if ($normalise($final) === $normalise($original)) {\n return true;\n }\n\n // Fewer than two spec separators means it is still a bare product name.\n return substr_count($final, ',') < 2 && mb_strlen($final) < mb_strlen($original) * 1.5;\n }\n\n /** Guards rule 7: a question must never leak into the final description. */\n private function looksLikeQuestion(string $text): bool\n {\n if (str_contains($text, '?') || str_contains($text, '؟')) {\n return true;\n }\n\n foreach (['tbd', 'to be confirmed', 'to be advised', 'please confirm', 'يرجى تحديد', 'يرجى تأكيد', 'غير محدد'] as $needle) {\n if (mb_stripos($text, $needle) !== false) {\n return true;\n }\n }\n\n return false;\n }\n}\n"}, "f40b6c77504eb4a1930246680abb3b0ffa5d04bc": {"t": 1785748349.176025, "n": 23771, "h": "5e83c88b1e4d595f", "c": "<?php\n\nnamespace App\\Services\\Pricing;\n\nuse Illuminate\\Http\\Client\\Pool;\nuse Illuminate\\Http\\Client\\Response;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\n\n/**\n * Product Specification & Pricing Qualification Engine.\n *\n * Qualifies each BOQ line before any price is attached. For every item it:\n * 1. classifies it (supply product vs service/installation/unclear);\n * 2. normalises the unit and flags impossible ones;\n * 3. separates specs the client confirmed from safe industry defaults;\n * 4. asks only for BLOCKING specs, grouped into one question per item;\n * 5. returns a pricing-readiness status.\n *\n * Division of labour: unit rules and the spec rubric are deterministic (see\n * UnitNormalizer and ProductSpecCatalog) so they never drift between calls; the\n * AI is used only for the judgement that genuinely needs it — reading intent\n * from a free-text description and deciding what is truly missing.\n *\n * Never invents a brand, model, electrical rating, dimension, or approval.\n */\nclass ProductSpecEngine\n{\n /** Items per AI call; small enough that calls can run in parallel. */\n private const CHUNK_SIZE = 8;\n\n /** The spec caps the grouped question at five sub-parts. */\n private const MAX_QUESTION_PARTS = 5;\n\n public const CLASSIFICATIONS = [\n 'SUPPLY_PRODUCT', 'CUSTOM_MANUFACTURED_PRODUCT', 'SOFTWARE_OR_LICENSE',\n 'SERVICE', 'INSTALLATION', 'CONSULTATION', 'UNSUPPORTED_ITEM', 'UNCLEAR_ITEM',\n ];\n\n /** Only these may appear in a supply quotation. */\n public const SUPPLYABLE = [\n 'SUPPLY_PRODUCT', 'CUSTOM_MANUFACTURED_PRODUCT', 'SOFTWARE_OR_LICENSE',\n ];\n\n public const STATUSES = [\n 'READY_TO_PRICE', 'READY_WITH_ASSUMPTIONS', 'BLOCKED_MISSING_SPECIFICATIONS',\n 'INVALID_QUANTITY_OR_UNIT', 'COMPATIBILITY_REVIEW_REQUIRED', 'NOT_A_SUPPLY_PRODUCT',\n ];\n\n /**\n * Qualify a list of BOQ items.\n *\n * @param array<int, array<string, mixed>> $items each with description, quantity, unit, category, brand\n * @param array<string, mixed> $project shared context (name, type, location, delivery date…)\n * @return array<int, array<string, mixed>> one qualified record per input item, same order\n */\n public function qualify(array $items, array $project = []): array\n {\n if (empty($items)) {\n return [];\n }\n\n // Deterministic pass first — it also gives the AI a cleaner starting point.\n $prepared = [];\n foreach ($items as $i => $item) {\n $prepared[$i] = $this->prepare($item);\n }\n\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n Log::warning('ProductSpecEngine: DEEPSEEK_API_KEY not configured; returning deterministic results only.');\n return array_values($prepared);\n }\n\n $chunks = array_chunk(array_keys($prepared), self::CHUNK_SIZE);\n\n $prepared = count($chunks) === 1\n ? $this->qualifyChunk($prepared, $chunks[0], $project)\n : $this->qualifyChunksParallel($prepared, $chunks, $project);\n\n // Project-level cross-item intelligence runs last: it needs every line resolved.\n return array_values(ProjectIntelligence::analyse($prepared, $project));\n }\n\n // ─────────────────────────────────────────────────────────────────────────\n // Deterministic pre-pass\n // ─────────────────────────────────────────────────────────────────────────\n\n /**\n * Normalise a single row's unit against its matched product family.\n *\n * Deterministic and free — no AI call — so it is safe to run over every row\n * at extraction time. Returns the corrected unit, or null to leave as-is.\n *\n * Without this, a printer arrives measured in \"liter/day\" and a UPS in\n * \"carton meter\", because the extractor copies whatever the sheet said.\n */\n public function normalizeUnitFor(string $description, string $rawUnit): ?string\n {\n $catalog = ProductSpecCatalog::match($description);\n if ($catalog === null) {\n return null;\n }\n\n $unit = UnitNormalizer::normalize($rawUnit, $catalog);\n\n $normalized = (string) ($unit['normalized'] ?? '');\n if ($normalized === '' || strcasecmp($normalized, $rawUnit) === 0) {\n return null;\n }\n\n return $normalized;\n }\n\n /**\n * Build the base record: catalog match, normalised unit, quantity sanity.\n * Everything here is decidable without the AI.\n */\n private function prepare(array $item): array\n {\n $description = trim((string) ($item['description'] ?? ''));\n $quantity = (float) ($item['quantity'] ?? 0);\n $rawUnit = (string) ($item['unit'] ?? '');\n\n $catalog = ProductSpecCatalog::match($description);\n $unit = UnitNormalizer::normalize($rawUnit, $catalog);\n\n $quantityWarnings = [];\n if ($quantity <= 0) {\n $quantityWarnings[] = [\n 'code' => 'QUANTITY_NOT_POSITIVE',\n 'severity' => 'error',\n 'message' => 'Quantity must be greater than zero.',\n ];\n } elseif ($quantity != floor($quantity) && in_array($unit['normalized'], ['PCS', 'SET', 'LICENSE'], true)) {\n $quantityWarnings[] = [\n 'code' => 'QUANTITY_FRACTIONAL',\n 'severity' => 'warning',\n 'message' => \"A fractional quantity ({$quantity}) is not valid for a countable unit ({$unit['normalized']}).\",\n ];\n }\n\n return [\n 'original_item_name' => $description,\n 'normalized_product_name' => $description,\n 'classification' => null,\n 'category' => (string) ($item['category'] ?? ''),\n 'catalog_key' => $catalog['key'] ?? null,\n 'quantity' => $quantity,\n 'original_unit' => $rawUnit,\n 'normalized_unit' => $unit['normalized'],\n 'supplyable' => true,\n 'pricing_status' => null,\n 'confirmed_specifications' => [],\n 'inferred_specifications' => [],\n 'missing_blocking_specifications' => [],\n 'conditional_questions' => [],\n 'assumptions' => [],\n 'quantity_warnings' => $quantityWarnings,\n 'unit_warnings' => $unit['warnings'],\n 'compatibility_warnings' => [],\n 'recommended_final_description' => $description,\n 'pricing_basis' => '',\n 'confidence_score' => 0,\n ];\n }\n\n // ─────────────────────────────────────────────────────────────────────────\n // Prompt\n // ─────────────────────────────────────────────────────────────────────────\n\n private function buildPrompt(array $records, array $indices, array $project): string\n {\n $payload = [];\n foreach ($indices as $idx) {\n $r = $records[$idx];\n $catalog = $r['catalog_key'] ? ProductSpecCatalog::all()[$r['catalog_key']] ?? null : null;\n\n $payload[] = array_filter([\n 'i' => $idx,\n 'd' => mb_substr($r['original_item_name'], 0, 200),\n 'qty' => $r['quantity'],\n 'unit' => $r['normalized_unit'],\n 'cat' => mb_substr($r['category'], 0, 40),\n // The rubric for this family, so the model audits against fixed\n // criteria instead of improvising a different list each call.\n 'blocking' => $catalog['blocking'] ?? null,\n 'optional' => $catalog['conditional'] ?? null,\n ], fn ($v) => $v !== null && $v !== '');\n }\n\n $ctx = array_filter([\n 'project_name' => $project['name'] ?? null,\n 'project_type' => $project['type'] ?? null,\n 'location' => $project['location'] ?? null,\n 'delivery_date' => $project['delivery_date'] ?? null,\n 'brand_policy' => $project['brand_policy'] ?? null,\n 'quotation_type' => $project['quotation_type'] ?? null,\n ], fn ($v) => $v !== null && $v !== '');\n\n return 'You are a senior procurement engineer, quantity surveyor and technical pre-sales specialist '\n . 'for the Saudi Arabia (Riyadh) construction, MEP, IT, security, furniture and general-supply market. '\n . 'Qualify each BOQ line BEFORE pricing. Think like an experienced engineer, not a form-filler. '\n . ($ctx ? 'PROJECT CONTEXT: ' . json_encode($ctx, JSON_UNESCAPED_UNICODE) . '. ' : '')\n . 'FOR EACH ITEM DO ALL OF THE FOLLOWING: '\n . '(1) CLASSIFY as exactly one of: SUPPLY_PRODUCT, CUSTOM_MANUFACTURED_PRODUCT, SOFTWARE_OR_LICENSE, '\n . 'SERVICE, INSTALLATION, CONSULTATION, UNSUPPORTED_ITEM, UNCLEAR_ITEM. '\n . 'Installation, testing, commissioning, supervision, training, maintenance and consultancy are NOT supply products. '\n . 'If a line mixes supply AND installation, classify it as SUPPLY_PRODUCT and set \"split\":true so the caller separates the service. '\n . '(2) SEPARATE SPECIFICATIONS. Work through the item\\'s \"blocking\" list one entry at a time. '\n . 'Each one lands in exactly ONE of three places: \"cs\" if the description or project context '\n . 'states it; \"is\" if you can apply a defensible market-standard default; \"mb\" if it genuinely '\n . 'cannot be defaulted (a brand, a model number, an electrical rating, a custom dimension). '\n . 'Together, \"cs\" + \"is\" + \"mb\" MUST cover every entry in the \"blocking\" list — none may be skipped. '\n . 'Prefer \"is\" over \"mb\": most specs (processor generation, screen size, OS, warranty, colour '\n . 'temperature, IP rating) have a standard answer for this market, so default them and move on. '\n . 'Every entry in \"is\" MUST also appear as a plain-language sentence in \"as\" (assumptions). '\n . '(3) MISSING BLOCKING SPECS. \"mb\" = ONLY specs without which a defensible price is impossible. '\n . 'Do NOT list something already answered by the description, the project context, the product '\n . 'category, or a safe standard default. An empty \"mb\" is the expected outcome for most items. '\n . '(4) ONE GROUPED QUESTION. If \"mb\" is non-empty, \"q\" = a SINGLE Arabic question asking for all of them together '\n . '(maximum ' . self::MAX_QUESTION_PARTS . ' sub-parts, comma-separated in one sentence). Never one question per field. '\n . 'If \"mb\" is empty, \"q\" = \"\". '\n . '(5) STATUS \"st\": READY_TO_PRICE (all blocking specs confirmed), '\n . 'READY_WITH_ASSUMPTIONS (priceable as a budgetary estimate using stated assumptions), '\n . 'BLOCKED_MISSING_SPECIFICATIONS (a blocking spec is genuinely unknowable), '\n . 'NOT_A_SUPPLY_PRODUCT (service/installation/unsupported). '\n . '(6) \"fd\" = the FULL procurement-grade product description. This is the single most '\n . 'important field: a buyer must be able to source and price the item from \"fd\" ALONE, '\n . 'without seeing the original BOQ line. Write out EVERY spec in the \"blocking\" list for '\n . 'this item — the ones the client stated, plus the ones you inferred — as one comma-separated '\n . 'specification string. Expand vague trade shorthand into the real specification. '\n . 'It must NEVER contain questions, placeholders, or the words \"TBD\"/\"to be confirmed\". '\n . 'EXAMPLE — input \"Laptop for work - Core i7 / 16 GB / 512 GB SSD\" must become: '\n . '\"Business Laptop, Intel Core i7-1355U (13th Gen), 16 GB DDR4 RAM, 512 GB NVMe SSD, '\n . '14-inch FHD (1920x1080) Display, Intel Iris Xe Integrated Graphics, Windows 11 Pro, '\n . '3-Year Manufacturer Warranty\". Note how the generation, GPU, screen size, OS and warranty '\n . 'are all present even though the client never wrote them — each one is listed in \"is\" and '\n . 'restated in \"as\" as an assumption. A description that merely repeats the client\\'s words is WRONG. '\n . '(7) \"pb\" = one short phrase stating the pricing basis (e.g. \"budgetary, standard configuration assumed\"). '\n . '(8) \"cf\" = confidence 0-100 that this description can be priced accurately. '\n . 'HARD RULES: never invent a brand, model number, electrical rating, dimension for a custom-manufactured item, '\n . 'or a fire/safety approval. If such a value is genuinely required and unknown, it belongs in \"mb\", not in \"is\". '\n . 'OUTPUT: return ONLY a compact JSON array, no markdown, no commentary. Each element exactly: '\n . '{\"i\":<index>,\"cl\":\"<classification>\",\"split\":<bool>,\"cs\":{},\"is\":{},\"as\":[],\"mb\":[],\"q\":\"\",\"st\":\"<status>\",\"fd\":\"\",\"pb\":\"\",\"cf\":0} '\n . 'ITEMS: ' . json_encode($payload, JSON_UNESCAPED_UNICODE);\n }\n\n private function buildRequestBody(array $records, array $indices, array $project): array\n {\n return [\n 'model' => (string) config('services.deepseek.model', 'deepseek-chat'),\n 'messages' => [['role' => 'user', 'content' => $this->buildPrompt($records, $indices, $project)]],\n 'temperature' => 0.1,\n 'max_tokens' => 8192,\n 'user' => 'Qimta_Platform',\n ];\n }\n\n // ─────────────────────────────────────────────────────────────────────────\n // HTTP\n // ─────────────────────────────────────────────────────────────────────────\n\n private function qualifyChunk(array $records, array $indices, array $project): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n\n try {\n $response = Http::timeout(120)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post('https://api.deepseek.com/chat/completions', $this->buildRequestBody($records, $indices, $project));\n\n if (! $response->successful()) {\n Log::warning('ProductSpecEngine: DeepSeek request failed.', ['status' => $response->status()]);\n return $this->fallback($records, $indices);\n }\n\n return $this->apply((string) ($response->json('choices.0.message.content') ?? ''), $records);\n } catch (\\Throwable $e) {\n Log::error('ProductSpecEngine: exception calling DeepSeek.', ['message' => $e->getMessage()]);\n return $this->fallback($records, $indices);\n }\n }\n\n /** @param list<list<int>> $chunks */\n private function qualifyChunksParallel(array $records, array $chunks, array $project): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n $url = 'https://api.deepseek.com/chat/completions';\n\n try {\n $responses = Http::pool(function (Pool $pool) use ($url, $apiKey, $records, $chunks, $project) {\n $reqs = [];\n foreach ($chunks as $ci => $indices) {\n $reqs[] = $pool->as((string) $ci)\n ->timeout(120)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post($url, $this->buildRequestBody($records, $indices, $project));\n }\n return $reqs;\n });\n\n foreach ($responses as $key => $response) {\n $ci = (int) $key;\n\n if (! ($response instanceof Response) || ! $response->successful()) {\n Log::warning('ProductSpecEngine: parallel chunk failed, retrying serially.', ['chunk' => $ci]);\n $records = $this->qualifyChunk($records, $chunks[$ci], $project);\n continue;\n }\n\n $records = $this->apply((string) ($response->json('choices.0.message.content') ?? ''), $records);\n }\n } catch (\\Throwable $e) {\n Log::error('ProductSpecEngine: Http::pool exception, falling back to serial.', ['message' => $e->getMessage()]);\n foreach ($chunks as $indices) {\n $records = $this->qualifyChunk($records, $indices, $project);\n }\n }\n\n return $records;\n }\n\n // ─────────────────────────────────────────────────────────────────────────\n // Response handling\n // ─────────────────────────────────────────────────────────────────────────\n\n private function apply(string $text, array $records): array\n {\n $text = preg_replace('/^```(?:json)?\\s*/i', '', trim($text));\n $text = preg_replace('/```\\s*$/', '', (string) $text);\n\n $data = json_decode((string) $text, true);\n\n if (! is_array($data) || $data === []) {\n Log::warning('ProductSpecEngine: unparseable AI response.', ['preview' => mb_substr($text, 0, 300)]);\n return $records;\n }\n\n foreach ($data as $entry) {\n if (! is_array($entry)) {\n continue;\n }\n\n $i = $entry['i'] ?? null;\n if ($i === null || ! array_key_exists($i, $records)) {\n continue;\n }\n\n $records[$i] = $this->mergeEntry($records[$i], $entry);\n }\n\n return $records;\n }\n\n /** Merge one AI verdict into its record, enforcing the engine's invariants. */\n private function mergeEntry(array $record, array $entry): array\n {\n $classification = in_array($entry['cl'] ?? null, self::CLASSIFICATIONS, true)\n ? $entry['cl']\n : 'UNCLEAR_ITEM';\n\n $supplyable = in_array($classification, self::SUPPLYABLE, true);\n\n $missing = array_values(array_filter(\n array_map(fn ($m) => trim((string) $m), (array) ($entry['mb'] ?? [])),\n fn ($m) => $m !== ''\n ));\n\n $assumptions = array_values(array_filter(\n array_map(fn ($a) => trim((string) $a), (array) ($entry['as'] ?? [])),\n fn ($a) => $a !== ''\n ));\n\n $status = in_array($entry['st'] ?? null, self::STATUSES, true) ? $entry['st'] : null;\n\n // Invariants the model is not allowed to violate, in priority order.\n if (! $supplyable) {\n $status = 'NOT_A_SUPPLY_PRODUCT';\n } elseif ($this->hasError($record['quantity_warnings']) || $this->hasError($record['unit_warnings'])) {\n $status = 'INVALID_QUANTITY_OR_UNIT';\n } elseif ($missing !== []) {\n $status = 'BLOCKED_MISSING_SPECIFICATIONS';\n } elseif ($status === null) {\n $status = $assumptions === [] ? 'READY_TO_PRICE' : 'READY_WITH_ASSUMPTIONS';\n } elseif ($status === 'READY_TO_PRICE' && $assumptions !== []) {\n // Assumptions were applied, so this is budgetary by definition.\n $status = 'READY_WITH_ASSUMPTIONS';\n }\n\n $finalDescription = trim((string) ($entry['fd'] ?? ''));\n if ($finalDescription === '' || $this->looksLikeQuestion($finalDescription)) {\n $finalDescription = $record['original_item_name'];\n }\n\n // The whole point of the pass is a description a buyer can source from.\n // If the model handed back the client's own wording, the line was not\n // actually enriched — surface it rather than letting it pass as qualified.\n if ($this->isNotEnriched($finalDescription, $record['original_item_name'])) {\n Log::info('ProductSpecEngine: description not enriched by the model.', [\n 'item' => mb_substr($record['original_item_name'], 0, 120),\n 'family' => $record['catalog_key'],\n ]);\n }\n\n $question = trim((string) ($entry['q'] ?? ''));\n\n return array_merge($record, [\n 'classification' => $classification,\n 'supplyable' => $supplyable,\n 'split_supply_and_installation' => (bool) ($entry['split'] ?? false),\n 'confirmed_specifications' => is_array($entry['cs'] ?? null) ? $entry['cs'] : [],\n 'inferred_specifications' => is_array($entry['is'] ?? null) ? $entry['is'] : [],\n 'assumptions' => $assumptions,\n 'missing_blocking_specifications' => $missing,\n 'grouped_question' => $missing === [] ? '' : $question,\n 'pricing_status' => $status,\n 'normalized_product_name' => trim((string) ($entry['fd'] ?? '')) ?: $record['original_item_name'],\n 'recommended_final_description' => $finalDescription,\n 'pricing_basis' => mb_substr(trim((string) ($entry['pb'] ?? '')), 0, 255),\n 'confidence_score' => max(0, min(100, (int) ($entry['cf'] ?? 0))),\n ]);\n }\n\n /**\n * When the AI is unreachable, fall back to the deterministic verdict rather\n * than guessing — an unqualified line is safer than a wrongly-qualified one.\n */\n private function fallback(array $records, array $indices): array\n {\n foreach ($indices as $i) {\n if (! array_key_exists($i, $records) || $records[$i]['pricing_status'] !== null) {\n continue;\n }\n\n $records[$i]['classification'] = 'UNCLEAR_ITEM';\n $records[$i]['pricing_status'] = $this->hasError($records[$i]['quantity_warnings'])\n || $this->hasError($records[$i]['unit_warnings'])\n ? 'INVALID_QUANTITY_OR_UNIT'\n : 'BLOCKED_MISSING_SPECIFICATIONS';\n $records[$i]['pricing_basis'] = 'Specification review unavailable; line not qualified.';\n }\n\n return $records;\n }\n\n /** @param list<array{severity?:string}> $warnings */\n private function hasError(array $warnings): bool\n {\n foreach ($warnings as $w) {\n if (($w['severity'] ?? '') === 'error') {\n return true;\n }\n }\n return false;\n }\n\n /**\n * True when the \"enriched\" description is really just the client's own line.\n *\n * A genuine qualification expands trade shorthand into a full specification,\n * so it is materially longer and carries more comma-separated parts. This is\n * a heuristic, used only to flag the case for review — never to block a line.\n */\n private function isNotEnriched(string $final, string $original): bool\n {\n $normalise = static fn (string $s) => mb_strtolower(preg_replace('/\\s+/u', ' ', trim($s)));\n\n if ($normalise($final) === $normalise($original)) {\n return true;\n }\n\n // Fewer than two spec separators means it is still a bare product name.\n return substr_count($final, ',') < 2 && mb_strlen($final) < mb_strlen($original) * 1.5;\n }\n\n /** Guards rule 7: a question must never leak into the final description. */\n private function looksLikeQuestion(string $text): bool\n {\n if (str_contains($text, '?') || str_contains($text, '؟')) {\n return true;\n }\n\n foreach (['tbd', 'to be confirmed', 'to be advised', 'please confirm', 'يرجى تحديد', 'يرجى تأكيد', 'غير محدد'] as $needle) {\n if (mb_stripos($text, $needle) !== false) {\n return true;\n }\n }\n\n return false;\n }\n}\n"}, "b000be05fdee9824c438a8df6d9aadbaaf8efb60": {"t": 1785748350.412268, "n": 22864, "h": "c430cbe35b37df8a", "c": "<?php\n\nnamespace App\\Services\\Pricing;\n\nuse Illuminate\\Http\\Client\\Pool;\nuse Illuminate\\Http\\Client\\Response;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\n\n/**\n * Product Specification & Pricing Qualification Engine.\n *\n * Qualifies each BOQ line before any price is attached. For every item it:\n * 1. classifies it (supply product vs service/installation/unclear);\n * 2. normalises the unit and flags impossible ones;\n * 3. separates specs the client confirmed from safe industry defaults;\n * 4. asks only for BLOCKING specs, grouped into one question per item;\n * 5. returns a pricing-readiness status.\n *\n * Division of labour: unit rules and the spec rubric are deterministic (see\n * UnitNormalizer and ProductSpecCatalog) so they never drift between calls; the\n * AI is used only for the judgement that genuinely needs it — reading intent\n * from a free-text description and deciding what is truly missing.\n *\n * Never invents a brand, model, electrical rating, dimension, or approval.\n */\nclass ProductSpecEngine\n{\n /** Items per AI call; small enough that calls can run in parallel. */\n private const CHUNK_SIZE = 8;\n\n /** The spec caps the grouped question at five sub-parts. */\n private const MAX_QUESTION_PARTS = 5;\n\n public const CLASSIFICATIONS = [\n 'SUPPLY_PRODUCT', 'CUSTOM_MANUFACTURED_PRODUCT', 'SOFTWARE_OR_LICENSE',\n 'SERVICE', 'INSTALLATION', 'CONSULTATION', 'UNSUPPORTED_ITEM', 'UNCLEAR_ITEM',\n ];\n\n /** Only these may appear in a supply quotation. */\n public const SUPPLYABLE = [\n 'SUPPLY_PRODUCT', 'CUSTOM_MANUFACTURED_PRODUCT', 'SOFTWARE_OR_LICENSE',\n ];\n\n public const STATUSES = [\n 'READY_TO_PRICE', 'READY_WITH_ASSUMPTIONS', 'BLOCKED_MISSING_SPECIFICATIONS',\n 'INVALID_QUANTITY_OR_UNIT', 'COMPATIBILITY_REVIEW_REQUIRED', 'NOT_A_SUPPLY_PRODUCT',\n ];\n\n /**\n * Qualify a list of BOQ items.\n *\n * @param array<int, array<string, mixed>> $items each with description, quantity, unit, category, brand\n * @param array<string, mixed> $project shared context (name, type, location, delivery date…)\n * @return array<int, array<string, mixed>> one qualified record per input item, same order\n */\n public function qualify(array $items, array $project = []): array\n {\n if (empty($items)) {\n return [];\n }\n\n // Deterministic pass first — it also gives the AI a cleaner starting point.\n $prepared = [];\n foreach ($items as $i => $item) {\n $prepared[$i] = $this->prepare($item);\n }\n\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n Log::warning('ProductSpecEngine: DEEPSEEK_API_KEY not configured; returning deterministic results only.');\n return array_values($prepared);\n }\n\n $chunks = array_chunk(array_keys($prepared), self::CHUNK_SIZE);\n\n $prepared = count($chunks) === 1\n ? $this->qualifyChunk($prepared, $chunks[0], $project)\n : $this->qualifyChunksParallel($prepared, $chunks, $project);\n\n // Project-level cross-item intelligence runs last: it needs every line resolved.\n return array_values(ProjectIntelligence::analyse($prepared, $project));\n }\n\n // ─────────────────────────────────────────────────────────────────────────\n // Deterministic pre-pass\n // ─────────────────────────────────────────────────────────────────────────\n\n /**\n * Build the base record: catalog match, normalised unit, quantity sanity.\n * Everything here is decidable without the AI.\n */\n private function prepare(array $item): array\n {\n $description = trim((string) ($item['description'] ?? ''));\n $quantity = (float) ($item['quantity'] ?? 0);\n $rawUnit = (string) ($item['unit'] ?? '');\n\n $catalog = ProductSpecCatalog::match($description);\n $unit = UnitNormalizer::normalize($rawUnit, $catalog);\n\n $quantityWarnings = [];\n if ($quantity <= 0) {\n $quantityWarnings[] = [\n 'code' => 'QUANTITY_NOT_POSITIVE',\n 'severity' => 'error',\n 'message' => 'Quantity must be greater than zero.',\n ];\n } elseif ($quantity != floor($quantity) && in_array($unit['normalized'], ['PCS', 'SET', 'LICENSE'], true)) {\n $quantityWarnings[] = [\n 'code' => 'QUANTITY_FRACTIONAL',\n 'severity' => 'warning',\n 'message' => \"A fractional quantity ({$quantity}) is not valid for a countable unit ({$unit['normalized']}).\",\n ];\n }\n\n return [\n 'original_item_name' => $description,\n 'normalized_product_name' => $description,\n 'classification' => null,\n 'category' => (string) ($item['category'] ?? ''),\n 'catalog_key' => $catalog['key'] ?? null,\n 'quantity' => $quantity,\n 'original_unit' => $rawUnit,\n 'normalized_unit' => $unit['normalized'],\n 'supplyable' => true,\n 'pricing_status' => null,\n 'confirmed_specifications' => [],\n 'inferred_specifications' => [],\n 'missing_blocking_specifications' => [],\n 'conditional_questions' => [],\n 'assumptions' => [],\n 'quantity_warnings' => $quantityWarnings,\n 'unit_warnings' => $unit['warnings'],\n 'compatibility_warnings' => [],\n 'recommended_final_description' => $description,\n 'pricing_basis' => '',\n 'confidence_score' => 0,\n ];\n }\n\n // ─────────────────────────────────────────────────────────────────────────\n // Prompt\n // ─────────────────────────────────────────────────────────────────────────\n\n private function buildPrompt(array $records, array $indices, array $project): string\n {\n $payload = [];\n foreach ($indices as $idx) {\n $r = $records[$idx];\n $catalog = $r['catalog_key'] ? ProductSpecCatalog::all()[$r['catalog_key']] ?? null : null;\n\n $payload[] = array_filter([\n 'i' => $idx,\n 'd' => mb_substr($r['original_item_name'], 0, 200),\n 'qty' => $r['quantity'],\n 'unit' => $r['normalized_unit'],\n 'cat' => mb_substr($r['category'], 0, 40),\n // The rubric for this family, so the model audits against fixed\n // criteria instead of improvising a different list each call.\n 'blocking' => $catalog['blocking'] ?? null,\n 'optional' => $catalog['conditional'] ?? null,\n ], fn ($v) => $v !== null && $v !== '');\n }\n\n $ctx = array_filter([\n 'project_name' => $project['name'] ?? null,\n 'project_type' => $project['type'] ?? null,\n 'location' => $project['location'] ?? null,\n 'delivery_date' => $project['delivery_date'] ?? null,\n 'brand_policy' => $project['brand_policy'] ?? null,\n 'quotation_type' => $project['quotation_type'] ?? null,\n ], fn ($v) => $v !== null && $v !== '');\n\n return 'You are a senior procurement engineer, quantity surveyor and technical pre-sales specialist '\n . 'for the Saudi Arabia (Riyadh) construction, MEP, IT, security, furniture and general-supply market. '\n . 'Qualify each BOQ line BEFORE pricing. Think like an experienced engineer, not a form-filler. '\n . ($ctx ? 'PROJECT CONTEXT: ' . json_encode($ctx, JSON_UNESCAPED_UNICODE) . '. ' : '')\n . 'FOR EACH ITEM DO ALL OF THE FOLLOWING: '\n . '(1) CLASSIFY as exactly one of: SUPPLY_PRODUCT, CUSTOM_MANUFACTURED_PRODUCT, SOFTWARE_OR_LICENSE, '\n . 'SERVICE, INSTALLATION, CONSULTATION, UNSUPPORTED_ITEM, UNCLEAR_ITEM. '\n . 'Installation, testing, commissioning, supervision, training, maintenance and consultancy are NOT supply products. '\n . 'If a line mixes supply AND installation, classify it as SUPPLY_PRODUCT and set \"split\":true so the caller separates the service. '\n . '(2) SEPARATE SPECIFICATIONS. Work through the item\\'s \"blocking\" list one entry at a time. '\n . 'Each one lands in exactly ONE of three places: \"cs\" if the description or project context '\n . 'states it; \"is\" if you can apply a defensible market-standard default; \"mb\" if it genuinely '\n . 'cannot be defaulted (a brand, a model number, an electrical rating, a custom dimension). '\n . 'Together, \"cs\" + \"is\" + \"mb\" MUST cover every entry in the \"blocking\" list — none may be skipped. '\n . 'Prefer \"is\" over \"mb\": most specs (processor generation, screen size, OS, warranty, colour '\n . 'temperature, IP rating) have a standard answer for this market, so default them and move on. '\n . 'Every entry in \"is\" MUST also appear as a plain-language sentence in \"as\" (assumptions). '\n . '(3) MISSING BLOCKING SPECS. \"mb\" = ONLY specs without which a defensible price is impossible. '\n . 'Do NOT list something already answered by the description, the project context, the product '\n . 'category, or a safe standard default. An empty \"mb\" is the expected outcome for most items. '\n . '(4) ONE GROUPED QUESTION. If \"mb\" is non-empty, \"q\" = a SINGLE Arabic question asking for all of them together '\n . '(maximum ' . self::MAX_QUESTION_PARTS . ' sub-parts, comma-separated in one sentence). Never one question per field. '\n . 'If \"mb\" is empty, \"q\" = \"\". '\n . '(5) STATUS \"st\": READY_TO_PRICE (all blocking specs confirmed), '\n . 'READY_WITH_ASSUMPTIONS (priceable as a budgetary estimate using stated assumptions), '\n . 'BLOCKED_MISSING_SPECIFICATIONS (a blocking spec is genuinely unknowable), '\n . 'NOT_A_SUPPLY_PRODUCT (service/installation/unsupported). '\n . '(6) \"fd\" = the FULL procurement-grade product description. This is the single most '\n . 'important field: a buyer must be able to source and price the item from \"fd\" ALONE, '\n . 'without seeing the original BOQ line. Write out EVERY spec in the \"blocking\" list for '\n . 'this item — the ones the client stated, plus the ones you inferred — as one comma-separated '\n . 'specification string. Expand vague trade shorthand into the real specification. '\n . 'It must NEVER contain questions, placeholders, or the words \"TBD\"/\"to be confirmed\". '\n . 'EXAMPLE — input \"Laptop for work - Core i7 / 16 GB / 512 GB SSD\" must become: '\n . '\"Business Laptop, Intel Core i7-1355U (13th Gen), 16 GB DDR4 RAM, 512 GB NVMe SSD, '\n . '14-inch FHD (1920x1080) Display, Intel Iris Xe Integrated Graphics, Windows 11 Pro, '\n . '3-Year Manufacturer Warranty\". Note how the generation, GPU, screen size, OS and warranty '\n . 'are all present even though the client never wrote them — each one is listed in \"is\" and '\n . 'restated in \"as\" as an assumption. A description that merely repeats the client\\'s words is WRONG. '\n . '(7) \"pb\" = one short phrase stating the pricing basis (e.g. \"budgetary, standard configuration assumed\"). '\n . '(8) \"cf\" = confidence 0-100 that this description can be priced accurately. '\n . 'HARD RULES: never invent a brand, model number, electrical rating, dimension for a custom-manufactured item, '\n . 'or a fire/safety approval. If such a value is genuinely required and unknown, it belongs in \"mb\", not in \"is\". '\n . 'OUTPUT: return ONLY a compact JSON array, no markdown, no commentary. Each element exactly: '\n . '{\"i\":<index>,\"cl\":\"<classification>\",\"split\":<bool>,\"cs\":{},\"is\":{},\"as\":[],\"mb\":[],\"q\":\"\",\"st\":\"<status>\",\"fd\":\"\",\"pb\":\"\",\"cf\":0} '\n . 'ITEMS: ' . json_encode($payload, JSON_UNESCAPED_UNICODE);\n }\n\n private function buildRequestBody(array $records, array $indices, array $project): array\n {\n return [\n 'model' => (string) config('services.deepseek.model', 'deepseek-chat'),\n 'messages' => [['role' => 'user', 'content' => $this->buildPrompt($records, $indices, $project)]],\n 'temperature' => 0.1,\n 'max_tokens' => 8192,\n 'user' => 'Qimta_Platform',\n ];\n }\n\n // ─────────────────────────────────────────────────────────────────────────\n // HTTP\n // ─────────────────────────────────────────────────────────────────────────\n\n private function qualifyChunk(array $records, array $indices, array $project): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n\n try {\n $response = Http::timeout(120)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post('https://api.deepseek.com/chat/completions', $this->buildRequestBody($records, $indices, $project));\n\n if (! $response->successful()) {\n Log::warning('ProductSpecEngine: DeepSeek request failed.', ['status' => $response->status()]);\n return $this->fallback($records, $indices);\n }\n\n return $this->apply((string) ($response->json('choices.0.message.content') ?? ''), $records);\n } catch (\\Throwable $e) {\n Log::error('ProductSpecEngine: exception calling DeepSeek.', ['message' => $e->getMessage()]);\n return $this->fallback($records, $indices);\n }\n }\n\n /** @param list<list<int>> $chunks */\n private function qualifyChunksParallel(array $records, array $chunks, array $project): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n $url = 'https://api.deepseek.com/chat/completions';\n\n try {\n $responses = Http::pool(function (Pool $pool) use ($url, $apiKey, $records, $chunks, $project) {\n $reqs = [];\n foreach ($chunks as $ci => $indices) {\n $reqs[] = $pool->as((string) $ci)\n ->timeout(120)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post($url, $this->buildRequestBody($records, $indices, $project));\n }\n return $reqs;\n });\n\n foreach ($responses as $key => $response) {\n $ci = (int) $key;\n\n if (! ($response instanceof Response) || ! $response->successful()) {\n Log::warning('ProductSpecEngine: parallel chunk failed, retrying serially.', ['chunk' => $ci]);\n $records = $this->qualifyChunk($records, $chunks[$ci], $project);\n continue;\n }\n\n $records = $this->apply((string) ($response->json('choices.0.message.content') ?? ''), $records);\n }\n } catch (\\Throwable $e) {\n Log::error('ProductSpecEngine: Http::pool exception, falling back to serial.', ['message' => $e->getMessage()]);\n foreach ($chunks as $indices) {\n $records = $this->qualifyChunk($records, $indices, $project);\n }\n }\n\n return $records;\n }\n\n // ─────────────────────────────────────────────────────────────────────────\n // Response handling\n // ─────────────────────────────────────────────────────────────────────────\n\n private function apply(string $text, array $records): array\n {\n $text = preg_replace('/^```(?:json)?\\s*/i', '', trim($text));\n $text = preg_replace('/```\\s*$/', '', (string) $text);\n\n $data = json_decode((string) $text, true);\n\n if (! is_array($data) || $data === []) {\n Log::warning('ProductSpecEngine: unparseable AI response.', ['preview' => mb_substr($text, 0, 300)]);\n return $records;\n }\n\n foreach ($data as $entry) {\n if (! is_array($entry)) {\n continue;\n }\n\n $i = $entry['i'] ?? null;\n if ($i === null || ! array_key_exists($i, $records)) {\n continue;\n }\n\n $records[$i] = $this->mergeEntry($records[$i], $entry);\n }\n\n return $records;\n }\n\n /** Merge one AI verdict into its record, enforcing the engine's invariants. */\n private function mergeEntry(array $record, array $entry): array\n {\n $classification = in_array($entry['cl'] ?? null, self::CLASSIFICATIONS, true)\n ? $entry['cl']\n : 'UNCLEAR_ITEM';\n\n $supplyable = in_array($classification, self::SUPPLYABLE, true);\n\n $missing = array_values(array_filter(\n array_map(fn ($m) => trim((string) $m), (array) ($entry['mb'] ?? [])),\n fn ($m) => $m !== ''\n ));\n\n $assumptions = array_values(array_filter(\n array_map(fn ($a) => trim((string) $a), (array) ($entry['as'] ?? [])),\n fn ($a) => $a !== ''\n ));\n\n $status = in_array($entry['st'] ?? null, self::STATUSES, true) ? $entry['st'] : null;\n\n // Invariants the model is not allowed to violate, in priority order.\n if (! $supplyable) {\n $status = 'NOT_A_SUPPLY_PRODUCT';\n } elseif ($this->hasError($record['quantity_warnings']) || $this->hasError($record['unit_warnings'])) {\n $status = 'INVALID_QUANTITY_OR_UNIT';\n } elseif ($missing !== []) {\n $status = 'BLOCKED_MISSING_SPECIFICATIONS';\n } elseif ($status === null) {\n $status = $assumptions === [] ? 'READY_TO_PRICE' : 'READY_WITH_ASSUMPTIONS';\n } elseif ($status === 'READY_TO_PRICE' && $assumptions !== []) {\n // Assumptions were applied, so this is budgetary by definition.\n $status = 'READY_WITH_ASSUMPTIONS';\n }\n\n $finalDescription = trim((string) ($entry['fd'] ?? ''));\n if ($finalDescription === '' || $this->looksLikeQuestion($finalDescription)) {\n $finalDescription = $record['original_item_name'];\n }\n\n // The whole point of the pass is a description a buyer can source from.\n // If the model handed back the client's own wording, the line was not\n // actually enriched — surface it rather than letting it pass as qualified.\n if ($this->isNotEnriched($finalDescription, $record['original_item_name'])) {\n Log::info('ProductSpecEngine: description not enriched by the model.', [\n 'item' => mb_substr($record['original_item_name'], 0, 120),\n 'family' => $record['catalog_key'],\n ]);\n }\n\n $question = trim((string) ($entry['q'] ?? ''));\n\n return array_merge($record, [\n 'classification' => $classification,\n 'supplyable' => $supplyable,\n 'split_supply_and_installation' => (bool) ($entry['split'] ?? false),\n 'confirmed_specifications' => is_array($entry['cs'] ?? null) ? $entry['cs'] : [],\n 'inferred_specifications' => is_array($entry['is'] ?? null) ? $entry['is'] : [],\n 'assumptions' => $assumptions,\n 'missing_blocking_specifications' => $missing,\n 'grouped_question' => $missing === [] ? '' : $question,\n 'pricing_status' => $status,\n 'normalized_product_name' => trim((string) ($entry['fd'] ?? '')) ?: $record['original_item_name'],\n 'recommended_final_description' => $finalDescription,\n 'pricing_basis' => mb_substr(trim((string) ($entry['pb'] ?? '')), 0, 255),\n 'confidence_score' => max(0, min(100, (int) ($entry['cf'] ?? 0))),\n ]);\n }\n\n /**\n * When the AI is unreachable, fall back to the deterministic verdict rather\n * than guessing — an unqualified line is safer than a wrongly-qualified one.\n */\n private function fallback(array $records, array $indices): array\n {\n foreach ($indices as $i) {\n if (! array_key_exists($i, $records) || $records[$i]['pricing_status'] !== null) {\n continue;\n }\n\n $records[$i]['classification'] = 'UNCLEAR_ITEM';\n $records[$i]['pricing_status'] = $this->hasError($records[$i]['quantity_warnings'])\n || $this->hasError($records[$i]['unit_warnings'])\n ? 'INVALID_QUANTITY_OR_UNIT'\n : 'BLOCKED_MISSING_SPECIFICATIONS';\n $records[$i]['pricing_basis'] = 'Specification review unavailable; line not qualified.';\n }\n\n return $records;\n }\n\n /** @param list<array{severity?:string}> $warnings */\n private function hasError(array $warnings): bool\n {\n foreach ($warnings as $w) {\n if (($w['severity'] ?? '') === 'error') {\n return true;\n }\n }\n return false;\n }\n\n /**\n * True when the \"enriched\" description is really just the client's own line.\n *\n * A genuine qualification expands trade shorthand into a full specification,\n * so it is materially longer and carries more comma-separated parts. This is\n * a heuristic, used only to flag the case for review — never to block a line.\n */\n private function isNotEnriched(string $final, string $original): bool\n {\n $normalise = static fn (string $s) => mb_strtolower(preg_replace('/\\s+/u', ' ', trim($s)));\n\n if ($normalise($final) === $normalise($original)) {\n return true;\n }\n\n // Fewer than two spec separators means it is still a bare product name.\n return substr_count($final, ',') < 2 && mb_strlen($final) < mb_strlen($original) * 1.5;\n }\n\n /** Guards rule 7: a question must never leak into the final description. */\n private function looksLikeQuestion(string $text): bool\n {\n if (str_contains($text, '?') || str_contains($text, '؟')) {\n return true;\n }\n\n foreach (['tbd', 'to be confirmed', 'to be advised', 'please confirm', 'يرجى تحديد', 'يرجى تأكيد', 'غير محدد'] as $needle) {\n if (mb_stripos($text, $needle) !== false) {\n return true;\n }\n }\n\n return false;\n }\n}\n"}, "22e72149f360cbbde8fe4f431392184a4b450afe": {"t": 1785748352.129806, "n": 19857, "h": "6482ba295ee50ddb", "c": "<?php\n\nnamespace App\\Services\\Pricing;\n\nuse Illuminate\\Http\\Client\\Pool;\nuse Illuminate\\Http\\Client\\Response;\nuse Illuminate\\Support\\Facades\\Http;\nuse Illuminate\\Support\\Facades\\Log;\n\n/**\n * Product Specification & Pricing Qualification Engine.\n *\n * Qualifies each BOQ line before any price is attached. For every item it:\n * 1. classifies it (supply product vs service/installation/unclear);\n * 2. normalises the unit and flags impossible ones;\n * 3. separates specs the client confirmed from safe industry defaults;\n * 4. asks only for BLOCKING specs, grouped into one question per item;\n * 5. returns a pricing-readiness status.\n *\n * Division of labour: unit rules and the spec rubric are deterministic (see\n * UnitNormalizer and ProductSpecCatalog) so they never drift between calls; the\n * AI is used only for the judgement that genuinely needs it — reading intent\n * from a free-text description and deciding what is truly missing.\n *\n * Never invents a brand, model, electrical rating, dimension, or approval.\n */\nclass ProductSpecEngine\n{\n /** Items per AI call; small enough that calls can run in parallel. */\n private const CHUNK_SIZE = 8;\n\n /** The spec caps the grouped question at five sub-parts. */\n private const MAX_QUESTION_PARTS = 5;\n\n public const CLASSIFICATIONS = [\n 'SUPPLY_PRODUCT', 'CUSTOM_MANUFACTURED_PRODUCT', 'SOFTWARE_OR_LICENSE',\n 'SERVICE', 'INSTALLATION', 'CONSULTATION', 'UNSUPPORTED_ITEM', 'UNCLEAR_ITEM',\n ];\n\n /** Only these may appear in a supply quotation. */\n public const SUPPLYABLE = [\n 'SUPPLY_PRODUCT', 'CUSTOM_MANUFACTURED_PRODUCT', 'SOFTWARE_OR_LICENSE',\n ];\n\n public const STATUSES = [\n 'READY_TO_PRICE', 'READY_WITH_ASSUMPTIONS', 'BLOCKED_MISSING_SPECIFICATIONS',\n 'INVALID_QUANTITY_OR_UNIT', 'COMPATIBILITY_REVIEW_REQUIRED', 'NOT_A_SUPPLY_PRODUCT',\n ];\n\n /**\n * Qualify a list of BOQ items.\n *\n * @param array<int, array<string, mixed>> $items each with description, quantity, unit, category, brand\n * @param array<string, mixed> $project shared context (name, type, location, delivery date…)\n * @return array<int, array<string, mixed>> one qualified record per input item, same order\n */\n public function qualify(array $items, array $project = []): array\n {\n if (empty($items)) {\n return [];\n }\n\n // Deterministic pass first — it also gives the AI a cleaner starting point.\n $prepared = [];\n foreach ($items as $i => $item) {\n $prepared[$i] = $this->prepare($item);\n }\n\n $apiKey = (string) config('services.deepseek.key', '');\n if ($apiKey === '') {\n Log::warning('ProductSpecEngine: DEEPSEEK_API_KEY not configured; returning deterministic results only.');\n return array_values($prepared);\n }\n\n $chunks = array_chunk(array_keys($prepared), self::CHUNK_SIZE);\n\n $prepared = count($chunks) === 1\n ? $this->qualifyChunk($prepared, $chunks[0], $project)\n : $this->qualifyChunksParallel($prepared, $chunks, $project);\n\n // Project-level cross-item intelligence runs last: it needs every line resolved.\n return array_values(ProjectIntelligence::analyse($prepared, $project));\n }\n\n // ─────────────────────────────────────────────────────────────────────────\n // Deterministic pre-pass\n // ─────────────────────────────────────────────────────────────────────────\n\n /**\n * Build the base record: catalog match, normalised unit, quantity sanity.\n * Everything here is decidable without the AI.\n */\n private function prepare(array $item): array\n {\n $description = trim((string) ($item['description'] ?? ''));\n $quantity = (float) ($item['quantity'] ?? 0);\n $rawUnit = (string) ($item['unit'] ?? '');\n\n $catalog = ProductSpecCatalog::match($description);\n $unit = UnitNormalizer::normalize($rawUnit, $catalog);\n\n $quantityWarnings = [];\n if ($quantity <= 0) {\n $quantityWarnings[] = [\n 'code' => 'QUANTITY_NOT_POSITIVE',\n 'severity' => 'error',\n 'message' => 'Quantity must be greater than zero.',\n ];\n } elseif ($quantity != floor($quantity) && in_array($unit['normalized'], ['PCS', 'SET', 'LICENSE'], true)) {\n $quantityWarnings[] = [\n 'code' => 'QUANTITY_FRACTIONAL',\n 'severity' => 'warning',\n 'message' => \"A fractional quantity ({$quantity}) is not valid for a countable unit ({$unit['normalized']}).\",\n ];\n }\n\n return [\n 'original_item_name' => $description,\n 'normalized_product_name' => $description,\n 'classification' => null,\n 'category' => (string) ($item['category'] ?? ''),\n 'catalog_key' => $catalog['key'] ?? null,\n 'quantity' => $quantity,\n 'original_unit' => $rawUnit,\n 'normalized_unit' => $unit['normalized'],\n 'supplyable' => true,\n 'pricing_status' => null,\n 'confirmed_specifications' => [],\n 'inferred_specifications' => [],\n 'missing_blocking_specifications' => [],\n 'conditional_questions' => [],\n 'assumptions' => [],\n 'quantity_warnings' => $quantityWarnings,\n 'unit_warnings' => $unit['warnings'],\n 'compatibility_warnings' => [],\n 'recommended_final_description' => $description,\n 'pricing_basis' => '',\n 'confidence_score' => 0,\n ];\n }\n\n // ─────────────────────────────────────────────────────────────────────────\n // Prompt\n // ─────────────────────────────────────────────────────────────────────────\n\n private function buildPrompt(array $records, array $indices, array $project): string\n {\n $payload = [];\n foreach ($indices as $idx) {\n $r = $records[$idx];\n $catalog = $r['catalog_key'] ? ProductSpecCatalog::all()[$r['catalog_key']] ?? null : null;\n\n $payload[] = array_filter([\n 'i' => $idx,\n 'd' => mb_substr($r['original_item_name'], 0, 200),\n 'qty' => $r['quantity'],\n 'unit' => $r['normalized_unit'],\n 'cat' => mb_substr($r['category'], 0, 40),\n // The rubric for this family, so the model audits against fixed\n // criteria instead of improvising a different list each call.\n 'blocking' => $catalog['blocking'] ?? null,\n 'optional' => $catalog['conditional'] ?? null,\n ], fn ($v) => $v !== null && $v !== '');\n }\n\n $ctx = array_filter([\n 'project_name' => $project['name'] ?? null,\n 'project_type' => $project['type'] ?? null,\n 'location' => $project['location'] ?? null,\n 'delivery_date' => $project['delivery_date'] ?? null,\n 'brand_policy' => $project['brand_policy'] ?? null,\n 'quotation_type' => $project['quotation_type'] ?? null,\n ], fn ($v) => $v !== null && $v !== '');\n\n return 'You are a senior procurement engineer, quantity surveyor and technical pre-sales specialist '\n . 'for the Saudi Arabia (Riyadh) construction, MEP, IT, security, furniture and general-supply market. '\n . 'Qualify each BOQ line BEFORE pricing. Think like an experienced engineer, not a form-filler. '\n . ($ctx ? 'PROJECT CONTEXT: ' . json_encode($ctx, JSON_UNESCAPED_UNICODE) . '. ' : '')\n . 'FOR EACH ITEM DO ALL OF THE FOLLOWING: '\n . '(1) CLASSIFY as exactly one of: SUPPLY_PRODUCT, CUSTOM_MANUFACTURED_PRODUCT, SOFTWARE_OR_LICENSE, '\n . 'SERVICE, INSTALLATION, CONSULTATION, UNSUPPORTED_ITEM, UNCLEAR_ITEM. '\n . 'Installation, testing, commissioning, supervision, training, maintenance and consultancy are NOT supply products. '\n . 'If a line mixes supply AND installation, classify it as SUPPLY_PRODUCT and set \"split\":true so the caller separates the service. '\n . '(2) SEPARATE SPECIFICATIONS. \"cs\" = specs the description or project context actually states. '\n . '\"is\" = safe industry-standard defaults you applied. Every entry in \"is\" MUST also appear as a plain-language sentence in \"as\" (assumptions). '\n . '(3) MISSING BLOCKING SPECS. \"mb\" = ONLY specs without which a defensible price is impossible. '\n . 'Use the per-item \"blocking\" list as your rubric. Do NOT list something already answered by the description, '\n . 'the project context, the product category, or a safe standard default. '\n . '(4) ONE GROUPED QUESTION. If \"mb\" is non-empty, \"q\" = a SINGLE Arabic question asking for all of them together '\n . '(maximum ' . self::MAX_QUESTION_PARTS . ' sub-parts, comma-separated in one sentence). Never one question per field. '\n . 'If \"mb\" is empty, \"q\" = \"\". '\n . '(5) STATUS \"st\": READY_TO_PRICE (all blocking specs confirmed), '\n . 'READY_WITH_ASSUMPTIONS (priceable as a budgetary estimate using stated assumptions), '\n . 'BLOCKED_MISSING_SPECIFICATIONS (a blocking spec is genuinely unknowable), '\n . 'NOT_A_SUPPLY_PRODUCT (service/installation/unsupported). '\n . '(6) \"fd\" = the clean final product description containing ONLY confirmed and assumed specifications. '\n . 'It must NEVER contain questions, placeholders, or the words \"TBD\"/\"to be confirmed\". '\n . '(7) \"pb\" = one short phrase stating the pricing basis (e.g. \"budgetary, standard configuration assumed\"). '\n . '(8) \"cf\" = confidence 0-100 that this description can be priced accurately. '\n . 'HARD RULES: never invent a brand, model number, electrical rating, dimension for a custom-manufactured item, '\n . 'or a fire/safety approval. If such a value is genuinely required and unknown, it belongs in \"mb\", not in \"is\". '\n . 'OUTPUT: return ONLY a compact JSON array, no markdown, no commentary. Each element exactly: '\n . '{\"i\":<index>,\"cl\":\"<classification>\",\"split\":<bool>,\"cs\":{},\"is\":{},\"as\":[],\"mb\":[],\"q\":\"\",\"st\":\"<status>\",\"fd\":\"\",\"pb\":\"\",\"cf\":0} '\n . 'ITEMS: ' . json_encode($payload, JSON_UNESCAPED_UNICODE);\n }\n\n private function buildRequestBody(array $records, array $indices, array $project): array\n {\n return [\n 'model' => (string) config('services.deepseek.model', 'deepseek-chat'),\n 'messages' => [['role' => 'user', 'content' => $this->buildPrompt($records, $indices, $project)]],\n 'temperature' => 0.1,\n 'max_tokens' => 8192,\n 'user' => 'Qimta_Platform',\n ];\n }\n\n // ─────────────────────────────────────────────────────────────────────────\n // HTTP\n // ─────────────────────────────────────────────────────────────────────────\n\n private function qualifyChunk(array $records, array $indices, array $project): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n\n try {\n $response = Http::timeout(120)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post('https://api.deepseek.com/chat/completions', $this->buildRequestBody($records, $indices, $project));\n\n if (! $response->successful()) {\n Log::warning('ProductSpecEngine: DeepSeek request failed.', ['status' => $response->status()]);\n return $this->fallback($records, $indices);\n }\n\n return $this->apply((string) ($response->json('choices.0.message.content') ?? ''), $records);\n } catch (\\Throwable $e) {\n Log::error('ProductSpecEngine: exception calling DeepSeek.', ['message' => $e->getMessage()]);\n return $this->fallback($records, $indices);\n }\n }\n\n /** @param list<list<int>> $chunks */\n private function qualifyChunksParallel(array $records, array $chunks, array $project): array\n {\n $apiKey = (string) config('services.deepseek.key', '');\n $url = 'https://api.deepseek.com/chat/completions';\n\n try {\n $responses = Http::pool(function (Pool $pool) use ($url, $apiKey, $records, $chunks, $project) {\n $reqs = [];\n foreach ($chunks as $ci => $indices) {\n $reqs[] = $pool->as((string) $ci)\n ->timeout(120)\n ->withHeaders(['Authorization' => 'Bearer ' . $apiKey])\n ->post($url, $this->buildRequestBody($records, $indices, $project));\n }\n return $reqs;\n });\n\n foreach ($responses as $key => $response) {\n $ci = (int) $key;\n\n if (! ($response instanceof Response) || ! $response->successful()) {\n Log::warning('ProductSpecEngine: parallel chunk failed, retrying serially.', ['chunk' => $ci]);\n $records = $this->qualifyChunk($records, $chunks[$ci], $project);\n continue;\n }\n\n $records = $this->apply((string) ($response->json('choices.0.message.content') ?? ''), $records);\n }\n } catch (\\Throwable $e) {\n Log::error('ProductSpecEngine: Http::pool exception, falling back to serial.', ['message' => $e->getMessage()]);\n foreach ($chunks as $indices) {\n $records = $this->qualifyChunk($records, $indices, $project);\n }\n }\n\n return $records;\n }\n\n // ─────────────────────────────────────────────────────────────────────────\n // Response handling\n // ─────────────────────────────────────────────────────────────────────────\n\n private function apply(string $text, array $records): array\n {\n $text = preg_replace('/^```(?:json)?\\s*/i', '', trim($text));\n $text = preg_replace('/```\\s*$/', '', (string) $text);\n\n $data = json_decode((string) $text, true);\n\n if (! is_array($data) || $data === []) {\n Log::warning('ProductSpecEngine: unparseable AI response.', ['preview' => mb_substr($text, 0, 300)]);\n return $records;\n }\n\n foreach ($data as $entry) {\n if (! is_array($entry)) {\n continue;\n }\n\n $i = $entry['i'] ?? null;\n if ($i === null || ! array_key_exists($i, $records)) {\n continue;\n }\n\n $records[$i] = $this->mergeEntry($records[$i], $entry);\n }\n\n return $records;\n }\n\n /** Merge one AI verdict into its record, enforcing the engine's invariants. */\n private function mergeEntry(array $record, array $entry): array\n {\n $classification = in_array($entry['cl'] ?? null, self::CLASSIFICATIONS, true)\n ? $entry['cl']\n : 'UNCLEAR_ITEM';\n\n $supplyable = in_array($classification, self::SUPPLYABLE, true);\n\n $missing = array_values(array_filter(\n array_map(fn ($m) => trim((string) $m), (array) ($entry['mb'] ?? [])),\n fn ($m) => $m !== ''\n ));\n\n $assumptions = array_values(array_filter(\n array_map(fn ($a) => trim((string) $a), (array) ($entry['as'] ?? [])),\n fn ($a) => $a !== ''\n ));\n\n $status = in_array($entry['st'] ?? null, self::STATUSES, true) ? $entry['st'] : null;\n\n // Invariants the model is not allowed to violate, in priority order.\n if (! $supplyable) {\n $status = 'NOT_A_SUPPLY_PRODUCT';\n } elseif ($this->hasError($record['quantity_warnings']) || $this->hasError($record['unit_warnings'])) {\n $status = 'INVALID_QUANTITY_OR_UNIT';\n } elseif ($missing !== []) {\n $status = 'BLOCKED_MISSING_SPECIFICATIONS';\n } elseif ($status === null) {\n $status = $assumptions === [] ? 'READY_TO_PRICE' : 'READY_WITH_ASSUMPTIONS';\n } elseif ($status === 'READY_TO_PRICE' && $assumptions !== []) {\n // Assumptions were applied, so this is budgetary by definition.\n $status = 'READY_WITH_ASSUMPTIONS';\n }\n\n $finalDescription = trim((string) ($entry['fd'] ?? ''));\n if ($finalDescription === '' || $this->looksLikeQuestion($finalDescription)) {\n $finalDescription = $record['original_item_name'];\n }\n\n $question = trim((string) ($entry['q'] ?? ''));\n\n return array_merge($record, [\n 'classification' => $classification,\n 'supplyable' => $supplyable,\n 'split_supply_and_installation' => (bool) ($entry['split'] ?? false),\n 'confirmed_specifications' => is_array($entry['cs'] ?? null) ? $entry['cs'] : [],\n 'inferred_specifications' => is_array($entry['is'] ?? null) ? $entry['is'] : [],\n 'assumptions' => $assumptions,\n 'missing_blocking_specifications' => $missing,\n 'grouped_question' => $missing === [] ? '' : $question,\n 'pricing_status' => $status,\n 'normalized_product_name' => trim((string) ($entry['fd'] ?? '')) ?: $record['original_item_name'],\n 'recommended_final_description' => $finalDescription,\n 'pricing_basis' => mb_substr(trim((string) ($entry['pb'] ?? '')), 0, 255),\n 'confidence_score' => max(0, min(100, (int) ($entry['cf'] ?? 0))),\n ]);\n }\n\n /**\n * When the AI is unreachable, fall back to the deterministic verdict rather\n * than guessing — an unqualified line is safer than a wrongly-qualified one.\n */\n private function fallback(array $records, array $indices): array\n {\n foreach ($indices as $i) {\n if (! array_key_exists($i, $records) || $records[$i]['pricing_status'] !== null) {\n continue;\n }\n\n $records[$i]['classification'] = 'UNCLEAR_ITEM';\n $records[$i]['pricing_status'] = $this->hasError($records[$i]['quantity_warnings'])\n || $this->hasError($records[$i]['unit_warnings'])\n ? 'INVALID_QUANTITY_OR_UNIT'\n : 'BLOCKED_MISSING_SPECIFICATIONS';\n $records[$i]['pricing_basis'] = 'Specification review unavailable; line not qualified.';\n }\n\n return $records;\n }\n\n /** @param list<array{severity?:string}> $warnings */\n private function hasError(array $warnings): bool\n {\n foreach ($warnings as $w) {\n if (($w['severity'] ?? '') === 'error') {\n return true;\n }\n }\n return false;\n }\n\n /** Guards rule 7: a question must never leak into the final description. */\n private function looksLikeQuestion(string $text): bool\n {\n if (str_contains($text, '?') || str_contains($text, '؟')) {\n return true;\n }\n\n foreach (['tbd', 'to be confirmed', 'to be advised', 'please confirm', 'يرجى تحديد', 'يرجى تأكيد', 'غير محدد'] as $needle) {\n if (mb_stripos($text, $needle) !== false) {\n return true;\n }\n }\n\n return false;\n }\n}\n"}}}} |