浏览代码

can split shipments

Bernn 1 周之前
父节点
当前提交
e0f03511f1
共有 5 个文件被更改,包括 172 次插入3 次删除
  1. 1 1
      PDQ.php
  2. 7 0
      assets/app.css
  3. 29 0
      assets/app.js
  4. 97 0
      bin/jobs_partial_ship.php
  5. 38 2
      lib/render.php

+ 1 - 1
PDQ.php

@@ -15,7 +15,7 @@ window.PDQ = {
     actor: 'ICG',
     audience: 'ICG',
     vendors: <?= json_encode(array_map(fn($v) => ['slug' => $v['slug'], 'name' => $v['name']], $vendors)) ?>,
-    pollMs: 120000
+    pollMs: 60000
 };
 </script>
 <script src="assets/app.js" defer></script>

+ 7 - 0
assets/app.css

@@ -66,6 +66,12 @@ tr.status-finished { background: rgba(76, 175, 80, 0.08); }
 tr.status-shipped  { background: rgba(33, 150, 243, 0.08); }
 tr.status-received { background: rgba(0, 0, 0, 0.04); color: var(--c-muted); }
 
+.status-date {
+    font-size: 0.75rem;
+    color: var(--c-muted);
+    margin-top: 0.1rem;
+}
+
 .ack-new {
     background: var(--c-warn);
     color: #000;
@@ -109,6 +115,7 @@ input.inline-edit {
 .btn-ack    { background: var(--c-warn); color: #000; }
 .btn-finish { background: var(--c-ok); }
 .btn-ship   { background: var(--c-info); }
+.btn-partial{ background: #ff9800; }
 .btn-receive{ background: var(--c-accent-strong); }
 .btn-add    { background: var(--c-accent-strong); padding: 0.5rem 1.25rem; font-size: 1rem; }
 .btn-post   { background: var(--c-accent-strong); padding: 0.5rem 1rem; font-size: 1rem; }

+ 29 - 0
assets/app.js

@@ -71,6 +71,8 @@
     const row = btn.closest('tr');
     const jobId = row.dataset.jobId;
     const action = btn.dataset.action;
+    if (action === 'partial_ship') return onPartialShip(btn, row, jobId);
+
     btn.disabled = true;
     postForm('bin/jobs_update.php', { job_id: jobId, action })
       .then(r => r.text().then(t => ({ ok: r.ok, body: t })))
@@ -83,6 +85,33 @@
       .catch(err => alert('Network error: ' + err.message));
   }
 
+  function onPartialShip(btn, row, jobId) {
+    const currentQty = parseInt(btn.dataset.qty, 10);
+    const raw = window.prompt(
+      'How many are shipping now?' + (currentQty ? ' (out of ' + currentQty + ')' : '')
+    );
+    if (raw === null) return;
+    const partial = parseInt(String(raw).trim(), 10);
+    if (!Number.isInteger(partial) || partial <= 0) {
+      alert('Enter a whole number greater than zero.');
+      return;
+    }
+    if (currentQty && partial >= currentQty) {
+      alert('Partial quantity must be less than ' + currentQty + '. Use Mark Shipped to ship the whole job.');
+      return;
+    }
+    btn.disabled = true;
+    postForm('bin/jobs_partial_ship.php', { job_id: jobId, partial_qty: partial })
+      .then(r => r.text().then(t => ({ ok: r.ok, body: t })))
+      .then(res => {
+        if (!res.ok || res.body.trim() !== 'Success') {
+          alert('Partial ship failed: ' + res.body);
+        }
+        return reloadTable();
+      })
+      .catch(err => alert('Network error: ' + err.message));
+  }
+
   function beginEdit(e) {
     const span = this;
     if (span.querySelector('input')) return;

+ 97 - 0
bin/jobs_partial_ship.php

@@ -0,0 +1,97 @@
+<?php
+require_once __DIR__ . '/../lib/identity.php';
+require_once __DIR__ . '/../lib/jobs.php';
+
+[$actor, $vendor_id] = resolve_request_actor();
+
+if ($actor === 'ICG') {
+    http_response_code(403);
+    echo 'Partial ship is a vendor action';
+    return;
+}
+
+$pdo = db();
+$job_id  = (int) ($_POST['job_id'] ?? 0);
+$partial = (int) ($_POST['partial_qty'] ?? 0);
+
+if ($job_id <= 0 || $partial <= 0) {
+    http_response_code(400);
+    echo 'Bad parameters';
+    return;
+}
+
+$stmt = $pdo->prepare('SELECT * FROM jobs WHERE id = ?');
+$stmt->execute([$job_id]);
+$job = $stmt->fetch();
+if (!$job) {
+    http_response_code(404);
+    echo 'Job not found';
+    return;
+}
+
+if ((int) $job['vendor_id'] !== $vendor_id) {
+    http_response_code(403);
+    echo 'Wrong vendor';
+    return;
+}
+
+if (!in_array($job['status'], ['', 'Finished'], true)) {
+    http_response_code(400);
+    echo 'Can only partial-ship from open or finished status';
+    return;
+}
+
+$current_qty = (int) $job['qty'];
+if ($partial >= $current_qty) {
+    http_response_code(400);
+    echo 'Partial quantity must be less than current quantity (' . $current_qty . ')';
+    return;
+}
+
+// BEGIN IMMEDIATE + retry mirrors the apply_job_change pattern.
+$attempts = 0;
+while (true) {
+    $attempts++;
+    try {
+        $pdo->exec('BEGIN IMMEDIATE');
+        break;
+    } catch (PDOException $e) {
+        if ($attempts >= 50 || !is_busy_error($e)) throw $e;
+        usleep(100000);
+    }
+}
+
+try {
+    $remaining = $current_qty - $partial;
+
+    // The shipped portion as its own row.
+    $ins = $pdo->prepare(
+        "INSERT INTO jobs (vendor_id, job, material, description, qty, due_date, ack, status)
+         VALUES (?, ?, ?, ?, ?, ?, '', 'Shipped')"
+    );
+    $ins->execute([
+        $job['vendor_id'], $job['job'], $job['material'], $job['description'],
+        $partial, $job['due_date']
+    ]);
+    $new_id = (int) $pdo->lastInsertId();
+
+    // Deduct from the original; keep its current status.
+    $upd = $pdo->prepare(
+        "UPDATE jobs SET qty = ?, updated_at = datetime('now') WHERE id = ?"
+    );
+    $upd->execute([$remaining, $job_id]);
+
+    // Audit trail: new row's status, and the qty deduction on the original.
+    $hist = $pdo->prepare(
+        'INSERT INTO job_history (job_id, field, old_value, new_value, actor)
+         VALUES (?, ?, ?, ?, ?)'
+    );
+    $hist->execute([$new_id, 'status', '',                   'Shipped',           $actor]);
+    $hist->execute([$job_id, 'qty',    (string) $current_qty, (string) $remaining, $actor]);
+
+    $pdo->exec('COMMIT');
+    echo 'Success';
+} catch (Throwable $e) {
+    $pdo->exec('ROLLBACK');
+    throw $e;
+}

+ 38 - 2
lib/render.php

@@ -24,7 +24,13 @@ function render_jobs_table(string $audience, ?int $vendor_id = null): string {
                CASE WHEN j.due_date IS NULL THEN ''
                     ELSE CAST(CAST(strftime('%m', j.due_date) AS INTEGER) AS TEXT) || '-' ||
                          CAST(CAST(strftime('%d', j.due_date) AS INTEGER) AS TEXT)
-               END AS due_short
+               END AS due_short,
+               (SELECT MAX(changed_at) FROM job_history
+                 WHERE job_id = j.id AND field = 'status' AND new_value = 'Finished') AS finished_at,
+               (SELECT MAX(changed_at) FROM job_history
+                 WHERE job_id = j.id AND field = 'status' AND new_value = 'Shipped') AS shipped_at,
+               (SELECT MAX(changed_at) FROM job_history
+                 WHERE job_id = j.id AND field = 'status' AND new_value = 'Received') AS received_at
         FROM jobs j
         JOIN vendors v ON v.id = j.vendor_id
         WHERE $where
@@ -75,7 +81,7 @@ function render_jobs_table(string $audience, ?int $vendor_id = null): string {
                 <td class="center"><?= render_due($r, $editable) ?></td>
                 <?php if ($audience === 'ICG'): ?><td><?= h($r['vendor_name']) ?></td><?php endif; ?>
                 <td class="center <?= h($ackClass) ?>"><?= $r['ack'] === 'new' ? 'NEW' : '' ?></td>
-                <td class="center"><?= h($r['status']) ?></td>
+                <td class="center"><?= render_status($r, $audience) ?></td>
                 <td class="center actions"><?= render_actions($r, $audience) ?></td>
             </tr>
         <?php endforeach; ?>
@@ -102,6 +108,33 @@ function render_due(array $r, bool $editable): string {
         . $display . '</span>';
 }
 
+function render_status(array $r, string $audience): string {
+    $out = h($r['status']);
+    $stamp = null;
+    if ($audience === 'ICG') {
+        if ($r['status'] === 'Finished')    $stamp = $r['finished_at'] ?? null;
+        elseif ($r['status'] === 'Shipped') $stamp = $r['shipped_at']  ?? null;
+    } else { // vendor
+        if ($r['status'] === 'Received')    $stamp = $r['received_at'] ?? null;
+    }
+    if ($stamp) {
+        $out .= '<div class="status-date">' . h(format_short_date($stamp)) . '</div>';
+    }
+    return $out;
+}
+
+// Convert a UTC 'YYYY-MM-DD HH:MM:SS' from datetime('now') into 'M/D' in the
+// server's local timezone for display.
+function format_short_date(?string $ts): string {
+    if (!$ts) return '';
+    try {
+        $dt = new DateTimeImmutable($ts . ' UTC');
+        return $dt->setTimezone(new DateTimeZone(date_default_timezone_get()))->format('n/j');
+    } catch (Exception $e) {
+        return '';
+    }
+}
+
 function render_actions(array $r, string $audience): string {
     $btns = [];
     if ($audience === 'vendor') {
@@ -113,6 +146,9 @@ function render_actions(array $r, string $audience): string {
         } elseif ($r['status'] === 'Finished') {
             $btns[] = '<button class="btn btn-ship" data-action="mark_shipped">Mark Shipped</button>';
         }
+        if (in_array($r['status'], ['', 'Finished'], true) && (int) $r['qty'] > 1) {
+            $btns[] = '<button class="btn btn-partial" data-action="partial_ship" data-qty="' . (int) $r['qty'] . '">Partial Ship</button>';
+        }
     } else { // ICG
         if ($r['status'] === 'Shipped') {
             $btns[] = '<button class="btn btn-receive" data-action="mark_received">Mark Received</button>';