RecordFileService.php 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474
  1. <?php
  2. namespace App\Services;
  3. use Illuminate\Support\Facades\Storage;
  4. use Illuminate\Support\Facades\Log;
  5. use Illuminate\Http\UploadedFile;
  6. use Exception;
  7. class RecordFileService
  8. {
  9. /**
  10. * The storage disk to use for file operations
  11. */
  12. private $disk;
  13. /**
  14. * Allowed file extensions for record attachments
  15. */
  16. private const ALLOWED_EXTENSIONS = ['jpg', 'jpeg', 'png', 'pdf', 'doc', 'docx', 'xml', 'txt', 'csv', 'xls', 'xlsx'];
  17. /**
  18. * Maximum file size in KB
  19. */
  20. private const MAX_FILE_SIZE = 10240; // 10MB
  21. /**
  22. * Constructor
  23. */
  24. public function __construct()
  25. {
  26. $this->disk = Storage::disk('s3');
  27. }
  28. /**
  29. * Upload record attachment file
  30. *
  31. * @param UploadedFile $attachmentFile
  32. * @param int $recordId
  33. * @param string $recordType ('IN' or 'OUT')
  34. * @return string
  35. * @throws Exception
  36. */
  37. public function uploadAttachment(UploadedFile $attachmentFile, int $recordId, string $recordType = 'OUT'): string
  38. {
  39. try {
  40. $currentClient = session('currentClient', 'iao');
  41. // Validate file
  42. $this->validateAttachmentFile($attachmentFile);
  43. // Create filename
  44. $originalName = pathinfo($attachmentFile->getClientOriginalName(), PATHINFO_FILENAME);
  45. $extension = strtolower($attachmentFile->getClientOriginalExtension());
  46. $timestamp = time();
  47. $filename = 'attachment_' . $recordId . '_' . $timestamp . '_' . substr(md5($originalName), 0, 8) . '.' . $extension;
  48. // Upload to S3
  49. $s3Path = $currentClient . '/records/' . strtolower($recordType) . '/' . $recordId . '/attachments/' . $filename;
  50. $uploaded = $this->disk->putFileAs(
  51. $currentClient . '/records/' . strtolower($recordType) . '/' . $recordId . '/attachments',
  52. $attachmentFile,
  53. $filename,
  54. 'private'
  55. );
  56. if (!$uploaded) {
  57. throw new Exception('Failed to upload attachment to S3: ' . $originalName);
  58. }
  59. Log::info("Record attachment uploaded", [
  60. 'record_id' => $recordId,
  61. 'record_type' => $recordType,
  62. 'path' => $s3Path,
  63. 'original_name' => $attachmentFile->getClientOriginalName(),
  64. 'size' => $attachmentFile->getSize()
  65. ]);
  66. return $s3Path;
  67. } catch (Exception $e) {
  68. Log::error("Error uploading record attachment", [
  69. 'record_id' => $recordId,
  70. 'record_type' => $recordType,
  71. 'error' => $e->getMessage()
  72. ]);
  73. throw $e;
  74. }
  75. }
  76. /**
  77. * Upload XML receipt file (for electronic invoice imports)
  78. *
  79. * @param UploadedFile $xmlFile
  80. * @param int $recordId
  81. * @param string $recordType
  82. * @return string
  83. * @throws Exception
  84. */
  85. public function uploadXmlReceipt(UploadedFile $xmlFile, int $recordId, string $recordType = 'OUT'): string
  86. {
  87. try {
  88. $currentClient = session('currentClient', 'iao');
  89. // Validate XML file
  90. $this->validateXmlFile($xmlFile);
  91. // Create filename
  92. $originalName = pathinfo($xmlFile->getClientOriginalName(), PATHINFO_FILENAME);
  93. $extension = strtolower($xmlFile->getClientOriginalExtension());
  94. $timestamp = time();
  95. $filename = 'xml_receipt_' . $recordId . '_' . $timestamp . '_' . substr(md5($originalName), 0, 8) . '.' . $extension;
  96. // Upload to S3
  97. $s3Path = $currentClient . '/records/' . strtolower($recordType) . '/' . $recordId . '/xml/' . $filename;
  98. $uploaded = $this->disk->putFileAs(
  99. $currentClient . '/records/' . strtolower($recordType) . '/' . $recordId . '/xml',
  100. $xmlFile,
  101. $filename,
  102. 'private'
  103. );
  104. if (!$uploaded) {
  105. throw new Exception('Failed to upload XML receipt to S3: ' . $originalName);
  106. }
  107. Log::info("XML receipt uploaded", [
  108. 'record_id' => $recordId,
  109. 'record_type' => $recordType,
  110. 'path' => $s3Path,
  111. 'original_name' => $xmlFile->getClientOriginalName(),
  112. 'size' => $xmlFile->getSize()
  113. ]);
  114. return $s3Path;
  115. } catch (Exception $e) {
  116. Log::error("Error uploading XML receipt", [
  117. 'record_id' => $recordId,
  118. 'record_type' => $recordType,
  119. 'error' => $e->getMessage()
  120. ]);
  121. throw $e;
  122. }
  123. }
  124. /**
  125. * Get attachment file URL for display/download
  126. *
  127. * @param string $filePath
  128. * @param string $expiresIn
  129. * @return string|null
  130. */
  131. public function getAttachmentUrl(string $filePath, string $expiresIn = '+1 hour'): ?string
  132. {
  133. if (!$filePath) {
  134. return null;
  135. }
  136. // Handle legacy local paths - return asset URL
  137. if (!$this->isS3Path($filePath)) {
  138. return asset('storage/' . $filePath);
  139. }
  140. try {
  141. if (!$this->disk->exists($filePath)) {
  142. Log::warning("Attachment file not found", ['path' => $filePath]);
  143. return null;
  144. }
  145. return $this->disk->temporaryUrl($filePath, now()->add($expiresIn));
  146. } catch (Exception $e) {
  147. Log::error("Error generating attachment URL", [
  148. 'path' => $filePath,
  149. 'error' => $e->getMessage()
  150. ]);
  151. return null;
  152. }
  153. }
  154. /**
  155. * Delete attachment file from S3
  156. *
  157. * @param string $filePath
  158. * @return bool
  159. */
  160. public function deleteAttachment(string $filePath): bool
  161. {
  162. // Don't try to delete local files
  163. if (!$this->isS3Path($filePath)) {
  164. return false;
  165. }
  166. try {
  167. if ($this->disk->exists($filePath)) {
  168. $this->disk->delete($filePath);
  169. Log::info("Attachment file deleted", ['path' => $filePath]);
  170. return true;
  171. }
  172. return false;
  173. } catch (Exception $e) {
  174. Log::error("Error deleting attachment file", [
  175. 'path' => $filePath,
  176. 'error' => $e->getMessage()
  177. ]);
  178. return false;
  179. }
  180. }
  181. /**
  182. * Check if path is S3 path
  183. */
  184. private function isS3Path(string $path): bool
  185. {
  186. return strpos($path, '/records/') !== false ||
  187. strpos($path, session('currentClient', 'iao')) === 0;
  188. }
  189. /**
  190. * Validate the uploaded attachment file
  191. *
  192. * @param UploadedFile $attachmentFile
  193. * @throws Exception
  194. */
  195. private function validateAttachmentFile(UploadedFile $attachmentFile): void
  196. {
  197. // Check if file is valid
  198. if (!$attachmentFile->isValid()) {
  199. throw new Exception('Invalid file upload');
  200. }
  201. // Check file size
  202. if ($attachmentFile->getSize() > (self::MAX_FILE_SIZE * 1024)) {
  203. throw new Exception('File size exceeds maximum allowed size of ' . self::MAX_FILE_SIZE . 'KB');
  204. }
  205. // Check file extension
  206. $extension = strtolower($attachmentFile->getClientOriginalExtension());
  207. if (!in_array($extension, self::ALLOWED_EXTENSIONS)) {
  208. throw new Exception('File type not allowed. Allowed types: ' . implode(', ', self::ALLOWED_EXTENSIONS));
  209. }
  210. // Check mime type for additional security
  211. $mimeType = $attachmentFile->getMimeType();
  212. $allowedMimeTypes = [
  213. 'image/jpeg', 'image/png',
  214. 'application/pdf',
  215. 'application/msword',
  216. 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
  217. 'application/xml', 'text/xml',
  218. 'text/plain',
  219. 'text/csv',
  220. 'application/vnd.ms-excel',
  221. 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
  222. ];
  223. if (!in_array($mimeType, $allowedMimeTypes)) {
  224. throw new Exception('Invalid file type');
  225. }
  226. }
  227. /**
  228. * Validate XML file specifically
  229. *
  230. * @param UploadedFile $xmlFile
  231. * @throws Exception
  232. */
  233. private function validateXmlFile(UploadedFile $xmlFile): void
  234. {
  235. // Check if file is valid
  236. if (!$xmlFile->isValid()) {
  237. throw new Exception('Invalid XML file upload');
  238. }
  239. // Check file size
  240. if ($xmlFile->getSize() > (self::MAX_FILE_SIZE * 1024)) {
  241. throw new Exception('XML file size exceeds maximum allowed size of ' . self::MAX_FILE_SIZE . 'KB');
  242. }
  243. // Check file extension
  244. $extension = strtolower($xmlFile->getClientOriginalExtension());
  245. if ($extension !== 'xml') {
  246. throw new Exception('Only XML files are allowed for receipt uploads');
  247. }
  248. // Check mime type
  249. $mimeType = $xmlFile->getMimeType();
  250. $allowedMimeTypes = ['application/xml', 'text/xml'];
  251. if (!in_array($mimeType, $allowedMimeTypes)) {
  252. throw new Exception('Invalid XML file type');
  253. }
  254. }
  255. /**
  256. * Create record folder structure in S3
  257. *
  258. * @param int $recordId
  259. * @param string $recordType
  260. */
  261. public function createRecordFolders(int $recordId, string $recordType = 'OUT'): void
  262. {
  263. try {
  264. $currentClient = session('currentClient', 'iao');
  265. $folders = [
  266. $currentClient . '/records/' . strtolower($recordType) . '/' . $recordId . '/attachments/.gitkeep',
  267. $currentClient . '/records/' . strtolower($recordType) . '/' . $recordId . '/xml/.gitkeep'
  268. ];
  269. foreach ($folders as $folder) {
  270. if (!$this->disk->exists($folder)) {
  271. $this->disk->put($folder, '');
  272. }
  273. }
  274. Log::info("Created record folder structure", [
  275. 'record_id' => $recordId,
  276. 'record_type' => $recordType
  277. ]);
  278. } catch (Exception $e) {
  279. Log::error("Error creating record folders", [
  280. 'record_id' => $recordId,
  281. 'record_type' => $recordType,
  282. 'error' => $e->getMessage()
  283. ]);
  284. }
  285. }
  286. /**
  287. * Get attachment file info
  288. *
  289. * @param string $filePath
  290. * @return array|null
  291. */
  292. public function getAttachmentInfo(string $filePath): ?array
  293. {
  294. if (!$filePath) {
  295. return null;
  296. }
  297. // Handle legacy local paths
  298. if (!$this->isS3Path($filePath)) {
  299. $localPath = storage_path('app/public/' . $filePath);
  300. if (file_exists($localPath)) {
  301. return [
  302. 'path' => $filePath,
  303. 'name' => basename($filePath),
  304. 'size' => filesize($localPath),
  305. 'last_modified' => filemtime($localPath),
  306. 'url' => $this->getAttachmentUrl($filePath),
  307. 'exists' => true,
  308. 'storage_type' => 'local'
  309. ];
  310. }
  311. return null;
  312. }
  313. if (!$this->disk->exists($filePath)) {
  314. return null;
  315. }
  316. try {
  317. return [
  318. 'path' => $filePath,
  319. 'name' => basename($filePath),
  320. 'size' => $this->disk->size($filePath),
  321. 'last_modified' => $this->disk->lastModified($filePath),
  322. 'url' => $this->getAttachmentUrl($filePath),
  323. 'exists' => true,
  324. 'storage_type' => 's3'
  325. ];
  326. } catch (Exception $e) {
  327. Log::error("Error getting attachment info", [
  328. 'path' => $filePath,
  329. 'error' => $e->getMessage()
  330. ]);
  331. return null;
  332. }
  333. }
  334. /**
  335. * List all attachments for a record
  336. *
  337. * @param int $recordId
  338. * @param string $recordType
  339. * @return array
  340. */
  341. public function listRecordAttachments(int $recordId, string $recordType = 'OUT'): array
  342. {
  343. try {
  344. $currentClient = session('currentClient', 'iao');
  345. $attachmentPath = $currentClient . '/records/' . strtolower($recordType) . '/' . $recordId . '/attachments';
  346. $files = $this->disk->files($attachmentPath);
  347. return array_filter($files, function($file) {
  348. $filename = basename($file);
  349. return strpos($filename, 'attachment_') === 0;
  350. });
  351. } catch (Exception $e) {
  352. Log::error("Error listing record attachments", [
  353. 'record_id' => $recordId,
  354. 'record_type' => $recordType,
  355. 'error' => $e->getMessage()
  356. ]);
  357. return [];
  358. }
  359. }
  360. /**
  361. * Store XML receipt files for batch import
  362. *
  363. * @param array $xmlFiles
  364. * @param string $batchId
  365. * @return array
  366. */
  367. public function storeXmlBatch(array $xmlFiles, string $batchId): array
  368. {
  369. $storedFiles = [];
  370. $currentClient = session('currentClient', 'iao');
  371. foreach ($xmlFiles as $index => $xmlFile) {
  372. try {
  373. $this->validateXmlFile($xmlFile);
  374. $originalName = pathinfo($xmlFile->getClientOriginalName(), PATHINFO_FILENAME);
  375. $extension = strtolower($xmlFile->getClientOriginalExtension());
  376. $timestamp = time();
  377. $filename = 'batch_' . $batchId . '_' . $index . '_' . $timestamp . '_' . substr(md5($originalName), 0, 8) . '.' . $extension;
  378. $s3Path = $currentClient . '/imports/xml_batch/' . $batchId . '/' . $filename;
  379. $uploaded = $this->disk->putFileAs(
  380. $currentClient . '/imports/xml_batch/' . $batchId,
  381. $xmlFile,
  382. $filename,
  383. 'private'
  384. );
  385. if ($uploaded) {
  386. $storedFiles[] = [
  387. 'original_name' => $xmlFile->getClientOriginalName(),
  388. 's3_path' => $s3Path,
  389. 'local_path' => $xmlFile->getRealPath(),
  390. 'index' => $index
  391. ];
  392. Log::info("XML file stored for batch processing", [
  393. 'batch_id' => $batchId,
  394. 'original_name' => $xmlFile->getClientOriginalName(),
  395. 's3_path' => $s3Path
  396. ]);
  397. }
  398. } catch (Exception $e) {
  399. Log::error("Error storing XML file for batch", [
  400. 'batch_id' => $batchId,
  401. 'file_name' => $xmlFile->getClientOriginalName(),
  402. 'error' => $e->getMessage()
  403. ]);
  404. }
  405. }
  406. return $storedFiles;
  407. }
  408. }