You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

368 lines
15KB

  1. <?php
  2. declare(strict_types=1);
  3. /**
  4. * PrintStream → Kanban importer.
  5. *
  6. * Runs on a cron schedule (see docker/crontab).
  7. * For each board with import_from_printstream = 1:
  8. * - Parses printstream_job_name into filter tokens (one per line)
  9. * - Queries PrintStream SQL Server for open jobs matching each token
  10. * - Inserts new cards (first column, first lane) or refreshes PrintStream
  11. * fields on cards that already exist for this board
  12. */
  13. require_once __DIR__ . '/../vendor/autoload.php';
  14. // ── Load .env ─────────────────────────────────────────────────────────────────
  15. (static function (): void {
  16. $file = __DIR__ . '/../.env';
  17. if (!file_exists($file)) {
  18. return;
  19. }
  20. foreach (file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
  21. $line = trim($line);
  22. if ($line === '' || $line[0] === '#' || !str_contains($line, '=')) {
  23. continue;
  24. }
  25. [$name, $value] = explode('=', $line, 2);
  26. $name = trim($name);
  27. $value = trim($value);
  28. if ($name !== '' && getenv($name) === false) {
  29. putenv("{$name}={$value}");
  30. $_ENV[$name] = $value;
  31. }
  32. }
  33. })();
  34. // ── Helpers ───────────────────────────────────────────────────────────────────
  35. function log_msg(string $msg): void
  36. {
  37. echo '[' . date('Y-m-d H:i:s') . '] ' . $msg . PHP_EOL;
  38. }
  39. function safe_str(mixed $val): string
  40. {
  41. return $val === null ? '' : (string) $val;
  42. }
  43. // ── Cron scheduling / enable check ───────────────────────────────────────────
  44. // Pass --force (e.g. from the admin "Run Now" button) to bypass timing/enabled.
  45. $force = in_array('--force', $argv ?? []);
  46. $settingsFile = __DIR__ . '/../storage/cron-settings.json';
  47. $cronSettings = [];
  48. if (file_exists($settingsFile)) {
  49. $cronSettings = json_decode((string) file_get_contents($settingsFile), true) ?? [];
  50. }
  51. $cronEnabled = (bool) ($cronSettings['enabled'] ?? true);
  52. $intervalMinutes = (int) ($cronSettings['interval_minutes'] ?? 30);
  53. $lastRun = $cronSettings['last_run'] ?? null;
  54. if (!$cronEnabled && !$force) {
  55. log_msg('Import disabled via admin settings. Exiting.');
  56. exit(0);
  57. }
  58. if (!$force && $lastRun !== null) {
  59. $elapsed = time() - (int) strtotime($lastRun);
  60. if ($elapsed < $intervalMinutes * 60) {
  61. exit(0); // silent — not time yet, avoids log noise every minute
  62. }
  63. }
  64. // Record start time before connecting so concurrent ticks skip.
  65. $cronSettings['last_run'] = date('Y-m-d H:i:s');
  66. @file_put_contents($settingsFile, json_encode($cronSettings, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
  67. // ── Connect to kanban SQLite DB ───────────────────────────────────────────────
  68. $kanban = database();
  69. // ── Connect to PrintStream SQL Server (FreeTDS via ODBC) ─────────────────────
  70. $psCfg = require __DIR__ . '/../config/printstream.php';
  71. if ($psCfg['host'] === '') {
  72. log_msg('ERROR: PRINTSTREAM_HOST is not set. Aborting.');
  73. exit(1);
  74. }
  75. $dsn = sprintf(
  76. 'odbc:Driver={FreeTDS};Server=%s;Port=%d;Database=%s;UID=%s;PWD=%s;TDS_Version=7.4',
  77. $psCfg['host'],
  78. $psCfg['port'],
  79. $psCfg['database'],
  80. $psCfg['user'],
  81. $psCfg['password']
  82. );
  83. try {
  84. $ps = new PDO($dsn, null, null, [
  85. PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
  86. ]);
  87. } catch (PDOException $e) {
  88. log_msg('ERROR: Cannot connect to PrintStream: ' . $e->getMessage());
  89. exit(1);
  90. }
  91. log_msg('Connected to PrintStream (' . $psCfg['host'] . '/' . $psCfg['database'] . ')');
  92. // ── PrintStream queries ───────────────────────────────────────────────────────
  93. // pdo_odbc uses SQLPrepare/SQLExecute for every call — SQLPrepare maps to
  94. // sp_prepare in SQL Server which REJECTS CTEs (error 8180). ATTR_EMULATE_PREPARES
  95. // is ignored by pdo_odbc. Solution: flatten to plain JOINs (no CTEs) and embed
  96. // the token value directly after escaping single quotes (admin-controlled data).
  97. // Main job query — flat JOINs, no CTEs.
  98. // Token appended as: WHERE e2.[DETAILS1] LIKE '%escaped_token%'
  99. $psSqlBase = "
  100. SELECT DISTINCT
  101. s.[JOB NUMBER] AS JobNumber,
  102. d.[NAMES] AS CustomerName,
  103. e2.[DETAILS1] AS JobName,
  104. e2.[FINAL DELIVERY] AS FinalDelivery,
  105. e2.[ORIG_Qty1] AS Quantity,
  106. CONCAT(ISNULL(e2.[DETAILS2],''),ISNULL(e2.[DETAILS3],''),ISNULL(e2.[DETAILS4],''),
  107. ISNULL(e2.[DETAILS5],''),ISNULL(e2.[DETAILS6],''),ISNULL(e2.[DETAILS7],''),
  108. ISNULL(e2.[DETAILS8],''),ISNULL(e2.[DETAILS9],''),ISNULL(e2.[DETAILS10],'')) AS Notes,
  109. e2.[ORIG_QuoteNo] AS QuoteNo
  110. FROM dbo.SCHEDFIL AS s
  111. JOIN dbo.ESTIMATE AS e ON e.[DATAFLEX RECNUM ONE] = s.[ESTIMATE RECNUM]
  112. JOIN dbo.CostCenters AS cc ON cc.[Code] = s.[COST CENTRE]
  113. LEFT JOIN dbo.DEBTOR AS d ON e.[DEBTOR] = d.[AC NO]
  114. LEFT JOIN dbo.ESTIMATE AS e2 ON s.[JOB NUMBER] = e2.[JOB NUMBER]
  115. WHERE ISNULL(s.[COMPLETED],'') <> 'Y'
  116. AND ISNULL(s.[CLOSED OUT],'') <> 'Y'
  117. AND ISNULL(e.[CLOSED OUT],'') <> 'Y'
  118. AND e.[DELIVERY DATE] > DATEADD(DAY, -365, CAST(GETDATE() AS date))
  119. AND s.[FILETYPE] IN ('D','Q','X','I','W','R','T','P')
  120. AND (s.[STATUS] IN ('*','6','7','0','1','2','3','4','5',' ') OR s.[STATUS] IS NULL)
  121. AND e2.[DETAILS1] LIKE ";
  122. // Notes query — fetches all MDP notes for a list of QuoteNo values in one round-trip.
  123. // PHP concatenates lines per QuoteNo. Token placeholder: __QUOTENOS__
  124. $psNotesSqlBase = "
  125. SELECT n.[RELATED TO] AS QuoteNo, ISNULL(n.[NOTE],'') AS NoteLine
  126. FROM dbo.NOTES AS n
  127. WHERE n.[MODULE] = 'MDP'
  128. AND n.[RELATED TO] IN (__QUOTENOS__)
  129. ORDER BY n.[RELATED TO], TRY_CAST(n.[LINE NO] AS INT)
  130. ";
  131. // ── Process boards ────────────────────────────────────────────────────────────
  132. $boards = $kanban->query(
  133. 'SELECT id, printstream_job_name FROM boards WHERE import_from_printstream = 1'
  134. );
  135. if (empty($boards)) {
  136. log_msg('No boards with import_from_printstream enabled. Nothing to do.');
  137. exit(0);
  138. }
  139. $totalBoards = 0;
  140. $totalCreated = 0;
  141. $totalUpdated = 0;
  142. foreach ($boards as $boardRow) {
  143. $boardId = (int) $boardRow['id'];
  144. $filterMemo = safe_str($boardRow['printstream_job_name']);
  145. $totalBoards++;
  146. log_msg("Board ID {$boardId}:");
  147. try {
  148. $firstCol = $kanban->first(
  149. 'SELECT id FROM board_columns WHERE board_id = :b ORDER BY position ASC LIMIT 1',
  150. ['b' => $boardId]
  151. );
  152. $firstLane = $kanban->first(
  153. 'SELECT id FROM swim_lanes WHERE board_id = :b ORDER BY position ASC LIMIT 1',
  154. ['b' => $boardId]
  155. );
  156. } catch (\Throwable $e) {
  157. log_msg(' ERROR querying kanban DB: ' . $e->getMessage());
  158. continue;
  159. }
  160. if ($firstCol === null || $firstLane === null) {
  161. log_msg(' No columns or swim lanes — skipping.');
  162. continue;
  163. }
  164. $firstColId = (int) $firstCol['id'];
  165. $firstLaneId = (int) $firstLane['id'];
  166. // Parse filter tokens (one per line, case-insensitive, deduplicated)
  167. $tokens = [];
  168. foreach (preg_split('/\r?\n/', $filterMemo) as $line) {
  169. $tok = trim($line);
  170. if ($tok !== '') {
  171. $tokens[strtolower($tok)] = $tok;
  172. }
  173. }
  174. if (empty($tokens)) {
  175. log_msg(' No job name filter configured — skipping.');
  176. continue;
  177. }
  178. // Query PrintStream for each token, collect unique jobs
  179. $jobs = []; // keyed by strtolower(jobNumber)
  180. foreach ($tokens as $tok) {
  181. try {
  182. $escaped = str_replace("'", "''", $tok);
  183. $sql = $psSqlBase . "'%" . $escaped . "%'";
  184. $stmt = $ps->query($sql);
  185. $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
  186. } catch (\Throwable $e) {
  187. log_msg(" WARNING: PrintStream query failed for '{$tok}': " . get_class($e) . ': ' . $e->getMessage());
  188. continue;
  189. }
  190. foreach ($rows as $row) {
  191. $jn = safe_str($row['JobNumber'] ?? '');
  192. if ($jn === '') {
  193. continue;
  194. }
  195. $key = strtolower($jn);
  196. if (!isset($jobs[$key])) {
  197. $rawDate = $row['FinalDelivery'] ?? null;
  198. $deliveryDate = null;
  199. if ($rawDate !== null && $rawDate !== '') {
  200. $parsed = date_create((string) $rawDate);
  201. $deliveryDate = $parsed ? $parsed->format('Y-m-d') : null;
  202. }
  203. $jobs[$key] = [
  204. 'job_number' => $jn,
  205. 'customer_name' => safe_str($row['CustomerName'] ?? ''),
  206. 'job_name' => safe_str($row['JobName'] ?? ''),
  207. 'delivery_date' => $deliveryDate,
  208. 'quantity' => safe_str($row['Quantity'] ?? ''),
  209. 'notes' => safe_str($row['Notes'] ?? ''),
  210. 'full_note' => '',
  211. '_quote_no' => safe_str($row['QuoteNo'] ?? ''),
  212. ];
  213. }
  214. }
  215. }
  216. log_msg(' Found ' . count($jobs) . ' unique open job(s) matching filters.');
  217. // Fetch FullNote for all jobs in one batch query, concatenate lines in PHP
  218. if (!empty($jobs)) {
  219. $quoteNos = array_filter(array_column(array_values($jobs), '_quote_no'));
  220. if (!empty($quoteNos)) {
  221. try {
  222. $inList = implode(',', array_map(fn($q) => "'" . str_replace("'", "''", $q) . "'", $quoteNos));
  223. $notesSql = str_replace('__QUOTENOS__', $inList, $psNotesSqlBase);
  224. $notesStmt = $ps->query($notesSql);
  225. $noteRows = $notesStmt->fetchAll(PDO::FETCH_ASSOC);
  226. // Group note lines by QuoteNo and concatenate
  227. $notesByQuote = [];
  228. foreach ($noteRows as $nr) {
  229. $qn = (string) ($nr['QuoteNo'] ?? '');
  230. $notesByQuote[$qn] = ($notesByQuote[$qn] ?? '') . (string) ($nr['NoteLine'] ?? '');
  231. }
  232. // Map FullNote back to jobs via _quote_no
  233. foreach ($jobs as &$job) {
  234. $qn = $job['_quote_no'];
  235. if ($qn !== '' && isset($notesByQuote[$qn])) {
  236. $job['full_note'] = $notesByQuote[$qn];
  237. }
  238. }
  239. unset($job);
  240. } catch (\Throwable $e) {
  241. log_msg(' WARNING: FullNote query failed (notes will be empty): ' . $e->getMessage());
  242. }
  243. }
  244. }
  245. $now = date('Y-m-d H:i:s');
  246. foreach ($jobs as $job) {
  247. unset($job['_quote_no']);
  248. $existing = $kanban->first(
  249. 'SELECT COUNT(*) AS cnt FROM cards WHERE board_id = :b AND job_number = :jn',
  250. ['b' => $boardId, 'jn' => $job['job_number']]
  251. );
  252. $alreadyExists = (int) ($existing['cnt'] ?? 0) > 0;
  253. if (!$alreadyExists) {
  254. // Determine next position in target cell
  255. $maxPos = $kanban->first(
  256. 'SELECT MAX(position) AS m FROM cards WHERE column_id = :c AND swim_lane_id = :l',
  257. ['c' => $firstColId, 'l' => $firstLaneId]
  258. );
  259. $nextPos = ((int) ($maxPos['m'] ?? -1)) + 1;
  260. $kanban->execute(
  261. 'INSERT INTO cards
  262. (board_id, column_id, swim_lane_id, job_number, job_name, customer_name,
  263. delivery_date, quantity, notes, full_note, position,
  264. created_at, created_by, updated_at, updated_by)
  265. VALUES
  266. (:board_id, :column_id, :swim_lane_id, :job_number, :job_name, :customer_name,
  267. :delivery_date, :quantity, :notes, :full_note, :position,
  268. :created_at, :created_by, :updated_at, :updated_by)',
  269. [
  270. 'board_id' => $boardId,
  271. 'column_id' => $firstColId,
  272. 'swim_lane_id' => $firstLaneId,
  273. 'job_number' => $job['job_number'],
  274. 'job_name' => $job['job_name'],
  275. 'customer_name' => $job['customer_name'],
  276. 'delivery_date' => $job['delivery_date'],
  277. 'quantity' => $job['quantity'] !== '' ? $job['quantity'] : null,
  278. 'notes' => $job['notes'],
  279. 'full_note' => $job['full_note'],
  280. 'position' => $nextPos,
  281. 'created_at' => $now,
  282. 'created_by' => 'printstream-import',
  283. 'updated_at' => $now,
  284. 'updated_by' => 'printstream-import',
  285. ]
  286. );
  287. log_msg(' + ' . $job['job_number'] . ' — ' . $job['job_name']);
  288. $totalCreated++;
  289. } else {
  290. // Refresh PrintStream fields without touching column/lane/position
  291. $kanban->execute(
  292. 'UPDATE cards
  293. SET job_name = :job_name, customer_name = :customer_name,
  294. delivery_date = :delivery_date, quantity = :quantity,
  295. notes = :notes, full_note = :full_note,
  296. updated_at = :updated_at, updated_by = :updated_by
  297. WHERE board_id = :board_id AND job_number = :job_number',
  298. [
  299. 'job_name' => $job['job_name'],
  300. 'customer_name' => $job['customer_name'],
  301. 'delivery_date' => $job['delivery_date'],
  302. 'quantity' => $job['quantity'] !== '' ? $job['quantity'] : null,
  303. 'notes' => $job['notes'],
  304. 'full_note' => $job['full_note'],
  305. 'updated_at' => $now,
  306. 'updated_by' => 'printstream-import',
  307. 'board_id' => $boardId,
  308. 'job_number' => $job['job_number'],
  309. ]
  310. );
  311. $totalUpdated++;
  312. }
  313. }
  314. }
  315. $ps = null;
  316. log_msg('');
  317. log_msg('Import complete.');
  318. log_msg(" Boards processed : {$totalBoards}");
  319. log_msg(" Cards created : {$totalCreated}");
  320. log_msg(" Cards updated : {$totalUpdated}");

Powered by TurnKey Linux.