|
- <?php
-
- declare(strict_types=1);
-
- /**
- * PrintStream → Kanban importer.
- *
- * Runs on a cron schedule (see docker/crontab).
- * For each board with import_from_printstream = 1:
- * - Parses printstream_job_name into filter tokens (one per line)
- * - Queries PrintStream SQL Server for open jobs matching each token
- * - Inserts new cards (first column, first lane) or refreshes PrintStream
- * fields on cards that already exist for this board
- */
-
- require_once __DIR__ . '/../vendor/autoload.php';
-
- // ── Load .env ─────────────────────────────────────────────────────────────────
- (static function (): void {
- $file = __DIR__ . '/../.env';
- if (!file_exists($file)) {
- return;
- }
- foreach (file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
- $line = trim($line);
- if ($line === '' || $line[0] === '#' || !str_contains($line, '=')) {
- continue;
- }
- [$name, $value] = explode('=', $line, 2);
- $name = trim($name);
- $value = trim($value);
- if ($name !== '' && getenv($name) === false) {
- putenv("{$name}={$value}");
- $_ENV[$name] = $value;
- }
- }
- })();
-
- // ── Helpers ───────────────────────────────────────────────────────────────────
- function log_msg(string $msg): void
- {
- echo '[' . date('Y-m-d H:i:s') . '] ' . $msg . PHP_EOL;
- }
-
- function safe_str(mixed $val): string
- {
- return $val === null ? '' : (string) $val;
- }
-
- // ── Cron scheduling / enable check ───────────────────────────────────────────
- // Pass --force (e.g. from the admin "Run Now" button) to bypass timing/enabled.
- $force = in_array('--force', $argv ?? []);
- $settingsFile = __DIR__ . '/../storage/cron-settings.json';
-
- $cronSettings = [];
- if (file_exists($settingsFile)) {
- $cronSettings = json_decode((string) file_get_contents($settingsFile), true) ?? [];
- }
-
- $cronEnabled = (bool) ($cronSettings['enabled'] ?? true);
- $intervalMinutes = (int) ($cronSettings['interval_minutes'] ?? 30);
- $lastRun = $cronSettings['last_run'] ?? null;
-
- if (!$cronEnabled && !$force) {
- log_msg('Import disabled via admin settings. Exiting.');
- exit(0);
- }
-
- if (!$force && $lastRun !== null) {
- $elapsed = time() - (int) strtotime($lastRun);
- if ($elapsed < $intervalMinutes * 60) {
- exit(0); // silent — not time yet, avoids log noise every minute
- }
- }
-
- // Record start time before connecting so concurrent ticks skip.
- $cronSettings['last_run'] = date('Y-m-d H:i:s');
- @file_put_contents($settingsFile, json_encode($cronSettings, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
-
- // ── Connect to kanban SQLite DB ───────────────────────────────────────────────
- $kanban = database();
-
- // ── Connect to PrintStream SQL Server (FreeTDS via ODBC) ─────────────────────
- $psCfg = require __DIR__ . '/../config/printstream.php';
-
- if ($psCfg['host'] === '') {
- log_msg('ERROR: PRINTSTREAM_HOST is not set. Aborting.');
- exit(1);
- }
-
- $dsn = sprintf(
- 'odbc:Driver={FreeTDS};Server=%s;Port=%d;Database=%s;UID=%s;PWD=%s;TDS_Version=7.4',
- $psCfg['host'],
- $psCfg['port'],
- $psCfg['database'],
- $psCfg['user'],
- $psCfg['password']
- );
-
- try {
- $ps = new PDO($dsn, null, null, [
- PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
- ]);
- } catch (PDOException $e) {
- log_msg('ERROR: Cannot connect to PrintStream: ' . $e->getMessage());
- exit(1);
- }
-
- log_msg('Connected to PrintStream (' . $psCfg['host'] . '/' . $psCfg['database'] . ')');
-
- // ── PrintStream queries ───────────────────────────────────────────────────────
- // pdo_odbc uses SQLPrepare/SQLExecute for every call — SQLPrepare maps to
- // sp_prepare in SQL Server which REJECTS CTEs (error 8180). ATTR_EMULATE_PREPARES
- // is ignored by pdo_odbc. Solution: flatten to plain JOINs (no CTEs) and embed
- // the token value directly after escaping single quotes (admin-controlled data).
-
- // Main job query — flat JOINs, no CTEs.
- // Token appended as: WHERE e2.[DETAILS1] LIKE '%escaped_token%'
- $psSqlBase = "
- SELECT DISTINCT
- s.[JOB NUMBER] AS JobNumber,
- d.[NAMES] AS CustomerName,
- e2.[DETAILS1] AS JobName,
- e2.[FINAL DELIVERY] AS FinalDelivery,
- e2.[ORIG_Qty1] AS Quantity,
- CONCAT(ISNULL(e2.[DETAILS2],''),ISNULL(e2.[DETAILS3],''),ISNULL(e2.[DETAILS4],''),
- ISNULL(e2.[DETAILS5],''),ISNULL(e2.[DETAILS6],''),ISNULL(e2.[DETAILS7],''),
- ISNULL(e2.[DETAILS8],''),ISNULL(e2.[DETAILS9],''),ISNULL(e2.[DETAILS10],'')) AS Notes,
- e2.[ORIG_QuoteNo] AS QuoteNo
- FROM dbo.SCHEDFIL AS s
- JOIN dbo.ESTIMATE AS e ON e.[DATAFLEX RECNUM ONE] = s.[ESTIMATE RECNUM]
- JOIN dbo.CostCenters AS cc ON cc.[Code] = s.[COST CENTRE]
- LEFT JOIN dbo.DEBTOR AS d ON e.[DEBTOR] = d.[AC NO]
- LEFT JOIN dbo.ESTIMATE AS e2 ON s.[JOB NUMBER] = e2.[JOB NUMBER]
- WHERE ISNULL(s.[COMPLETED],'') <> 'Y'
- AND ISNULL(s.[CLOSED OUT],'') <> 'Y'
- AND ISNULL(e.[CLOSED OUT],'') <> 'Y'
- AND e.[DELIVERY DATE] > DATEADD(DAY, -365, CAST(GETDATE() AS date))
- AND s.[FILETYPE] IN ('D','Q','X','I','W','R','T','P')
- AND (s.[STATUS] IN ('*','6','7','0','1','2','3','4','5',' ') OR s.[STATUS] IS NULL)
- AND e2.[DETAILS1] LIKE ";
-
- // Notes query — fetches all MDP notes for a list of QuoteNo values in one round-trip.
- // PHP concatenates lines per QuoteNo. Token placeholder: __QUOTENOS__
- $psNotesSqlBase = "
- SELECT n.[RELATED TO] AS QuoteNo, ISNULL(CAST(n.[NOTE] AS VARCHAR(4000)),'') AS NoteLine
- FROM dbo.NOTES AS n
- WHERE n.[MODULE] = 'MDP'
- AND n.[RELATED TO] IN (__QUOTENOS__)
- ORDER BY n.[RELATED TO], TRY_CAST(n.[LINE NO] AS INT)
- ";
-
- // ── Process boards ────────────────────────────────────────────────────────────
- $boards = $kanban->query(
- 'SELECT id, printstream_job_name FROM boards WHERE import_from_printstream = 1'
- );
-
- if (empty($boards)) {
- log_msg('No boards with import_from_printstream enabled. Nothing to do.');
- exit(0);
- }
-
- $totalBoards = 0;
- $totalCreated = 0;
- $totalUpdated = 0;
-
- foreach ($boards as $boardRow) {
- $boardId = (int) $boardRow['id'];
- $filterMemo = safe_str($boardRow['printstream_job_name']);
- $totalBoards++;
-
- log_msg("Board ID {$boardId}:");
-
- try {
- $firstCol = $kanban->first(
- 'SELECT id FROM board_columns WHERE board_id = :b ORDER BY position ASC LIMIT 1',
- ['b' => $boardId]
- );
- $firstLane = $kanban->first(
- 'SELECT id FROM swim_lanes WHERE board_id = :b ORDER BY position ASC LIMIT 1',
- ['b' => $boardId]
- );
- } catch (\Throwable $e) {
- log_msg(' ERROR querying kanban DB: ' . $e->getMessage());
- continue;
- }
-
- if ($firstCol === null || $firstLane === null) {
- log_msg(' No columns or swim lanes — skipping.');
- continue;
- }
-
- $firstColId = (int) $firstCol['id'];
- $firstLaneId = (int) $firstLane['id'];
-
- // Parse filter tokens (one per line, case-insensitive, deduplicated)
- $tokens = [];
- foreach (preg_split('/\r?\n/', $filterMemo) as $line) {
- $tok = trim($line);
- if ($tok !== '') {
- $tokens[strtolower($tok)] = $tok;
- }
- }
-
- if (empty($tokens)) {
- log_msg(' No job name filter configured — skipping.');
- continue;
- }
-
- // Query PrintStream for each token, collect unique jobs
- $jobs = []; // keyed by strtolower(jobNumber)
-
- foreach ($tokens as $tok) {
- try {
- $escaped = str_replace("'", "''", $tok);
- $sql = $psSqlBase . "'%" . $escaped . "%'";
- $stmt = $ps->query($sql);
- $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
- } catch (\Throwable $e) {
- log_msg(" WARNING: PrintStream query failed for '{$tok}': " . get_class($e) . ': ' . $e->getMessage());
- continue;
- }
-
- foreach ($rows as $row) {
- $jn = safe_str($row['JobNumber'] ?? '');
- if ($jn === '') {
- continue;
- }
- $key = strtolower($jn);
- if (!isset($jobs[$key])) {
- $rawDate = $row['FinalDelivery'] ?? null;
- $deliveryDate = null;
- if ($rawDate !== null && $rawDate !== '') {
- $parsed = date_create((string) $rawDate);
- $deliveryDate = $parsed ? $parsed->format('Y-m-d') : null;
- }
-
- $jobs[$key] = [
- 'job_number' => $jn,
- 'customer_name' => safe_str($row['CustomerName'] ?? ''),
- 'job_name' => safe_str($row['JobName'] ?? ''),
- 'delivery_date' => $deliveryDate,
- 'quantity' => safe_str($row['Quantity'] ?? ''),
- 'notes' => safe_str($row['Notes'] ?? ''),
- 'full_note' => '',
- '_quote_no' => safe_str($row['QuoteNo'] ?? ''),
- ];
- }
- }
- }
-
- log_msg(' Found ' . count($jobs) . ' unique open job(s) matching filters.');
-
- // Fetch FullNote for all jobs in one batch query, concatenate lines in PHP
- if (!empty($jobs)) {
- $quoteNos = array_filter(array_column(array_values($jobs), '_quote_no'));
- if (!empty($quoteNos)) {
- try {
- $inList = implode(',', array_map(fn($q) => "'" . str_replace("'", "''", $q) . "'", $quoteNos));
- $notesSql = str_replace('__QUOTENOS__', $inList, $psNotesSqlBase);
- $notesStmt = $ps->query($notesSql);
- $noteRows = $notesStmt->fetchAll(PDO::FETCH_ASSOC);
-
- // Group note lines by QuoteNo and concatenate
- $notesByQuote = [];
- foreach ($noteRows as $nr) {
- $qn = (string) ($nr['QuoteNo'] ?? '');
- $notesByQuote[$qn] = ($notesByQuote[$qn] ?? '') . (string) ($nr['NoteLine'] ?? '');
- }
-
- // Map FullNote back to jobs via _quote_no
- foreach ($jobs as &$job) {
- $qn = $job['_quote_no'];
- if ($qn !== '' && isset($notesByQuote[$qn])) {
- $job['full_note'] = $notesByQuote[$qn];
- }
- }
- unset($job);
- } catch (\Throwable $e) {
- log_msg(' WARNING: FullNote query failed (notes will be empty): ' . $e->getMessage());
- }
- }
- }
-
- $now = date('Y-m-d H:i:s');
-
- foreach ($jobs as $job) {
- unset($job['_quote_no']);
- $existing = $kanban->first(
- 'SELECT COUNT(*) AS cnt FROM cards WHERE board_id = :b AND job_number = :jn',
- ['b' => $boardId, 'jn' => $job['job_number']]
- );
- $alreadyExists = (int) ($existing['cnt'] ?? 0) > 0;
-
- if (!$alreadyExists) {
- // Determine next position in target cell
- $maxPos = $kanban->first(
- 'SELECT MAX(position) AS m FROM cards WHERE column_id = :c AND swim_lane_id = :l',
- ['c' => $firstColId, 'l' => $firstLaneId]
- );
- $nextPos = ((int) ($maxPos['m'] ?? -1)) + 1;
-
- $kanban->execute(
- 'INSERT INTO cards
- (board_id, column_id, swim_lane_id, job_number, job_name, customer_name,
- delivery_date, quantity, notes, full_note, position,
- created_at, created_by, updated_at, updated_by)
- VALUES
- (:board_id, :column_id, :swim_lane_id, :job_number, :job_name, :customer_name,
- :delivery_date, :quantity, :notes, :full_note, :position,
- :created_at, :created_by, :updated_at, :updated_by)',
- [
- 'board_id' => $boardId,
- 'column_id' => $firstColId,
- 'swim_lane_id' => $firstLaneId,
- 'job_number' => $job['job_number'],
- 'job_name' => $job['job_name'],
- 'customer_name' => $job['customer_name'],
- 'delivery_date' => $job['delivery_date'],
- 'quantity' => $job['quantity'] !== '' ? $job['quantity'] : null,
- 'notes' => $job['notes'],
- 'full_note' => $job['full_note'],
- 'position' => $nextPos,
- 'created_at' => $now,
- 'created_by' => 'printstream-import',
- 'updated_at' => $now,
- 'updated_by' => 'printstream-import',
- ]
- );
-
- log_msg(' + ' . $job['job_number'] . ' — ' . $job['job_name']);
- $totalCreated++;
- } else {
- // Refresh PrintStream fields without touching column/lane/position
- $kanban->execute(
- 'UPDATE cards
- SET job_name = :job_name, customer_name = :customer_name,
- delivery_date = :delivery_date, quantity = :quantity,
- notes = :notes, full_note = :full_note,
- updated_at = :updated_at, updated_by = :updated_by
- WHERE board_id = :board_id AND job_number = :job_number',
- [
- 'job_name' => $job['job_name'],
- 'customer_name' => $job['customer_name'],
- 'delivery_date' => $job['delivery_date'],
- 'quantity' => $job['quantity'] !== '' ? $job['quantity'] : null,
- 'notes' => $job['notes'],
- 'full_note' => $job['full_note'],
- 'updated_at' => $now,
- 'updated_by' => 'printstream-import',
- 'board_id' => $boardId,
- 'job_number' => $job['job_number'],
- ]
- );
-
- $totalUpdated++;
- }
- }
- }
-
- $ps = null;
-
- log_msg('');
- log_msg('Import complete.');
- log_msg(" Boards processed : {$totalBoards}");
- log_msg(" Cards created : {$totalCreated}");
- log_msg(" Cards updated : {$totalUpdated}");
|