feat: predict expiry date from product history when adding items
- PHP: new 'expiry_history' action computes avg shelf life (expiry_date - added_at)
from inventory table for the same product_id (last 730 days, valid entries only)
- JS: _fetchExpiryHistoryAndUpdate() fires async after showAddForm() renders
and replaces the rule-based estimate with the historical average if available
- Labeled with '📊 storico' badge on the estimate line (tooltip shows sample count)
- recalculateAddExpiry() and selectPurchaseType('new') both honour window._historyExpiryDays
- Vacuum-sealed multiplier still applied on top of historical base
- Falls back silently to rule-based estimateExpiryDays when no history exists
This commit is contained in:
@@ -194,6 +194,10 @@ try {
|
||||
ttsProxy();
|
||||
break;
|
||||
|
||||
case 'expiry_history':
|
||||
getExpiryHistory($db);
|
||||
break;
|
||||
|
||||
default:
|
||||
http_response_code(404);
|
||||
echo json_encode(['error' => 'Unknown action: ' . $action]);
|
||||
@@ -255,6 +259,36 @@ function ttsProxy() {
|
||||
|
||||
// ===== CLIENT LOG =====
|
||||
|
||||
// ===== EXPIRY HISTORY =====
|
||||
function getExpiryHistory($db): void {
|
||||
$productId = (int)($_GET['product_id'] ?? $_POST['product_id'] ?? 0);
|
||||
if (!$productId) {
|
||||
echo json_encode(['avg_days' => null, 'count' => 0]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Compute average shelf life (expiry_date - added_at) for this product
|
||||
// Only use entries where expiry_date is clearly in the future relative to added_at
|
||||
$stmt = $db->prepare("
|
||||
SELECT ROUND(AVG(CAST(JULIANDAY(expiry_date) - JULIANDAY(added_at) AS REAL))) AS avg_days,
|
||||
COUNT(*) AS count
|
||||
FROM inventory
|
||||
WHERE product_id = ?
|
||||
AND expiry_date IS NOT NULL
|
||||
AND expiry_date > date(added_at)
|
||||
AND added_at >= date('now', '-730 days')
|
||||
");
|
||||
$stmt->execute([$productId]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$row || !$row['count'] || $row['avg_days'] === null) {
|
||||
echo json_encode(['avg_days' => null, 'count' => 0]);
|
||||
return;
|
||||
}
|
||||
|
||||
echo json_encode(['avg_days' => (int)$row['avg_days'], 'count' => (int)$row['count']]);
|
||||
}
|
||||
|
||||
function clientLog(): void {
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$logFile = __DIR__ . '/../data/client_debug.log';
|
||||
|
||||
@@ -2531,6 +2531,17 @@ body {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.history-badge {
|
||||
font-size: 0.75rem;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 1px 7px;
|
||||
vertical-align: middle;
|
||||
font-weight: 500;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
|
||||
+43
-7
@@ -3231,6 +3231,9 @@ function showAddForm() {
|
||||
vacuumCb.checked = false;
|
||||
document.getElementById('add-vacuum-hint').style.display = 'none';
|
||||
}
|
||||
// Reset historical expiry for this product; will be fetched async
|
||||
window._historyExpiryDays = null;
|
||||
window._historyExpiryCount = 0;
|
||||
// Store base expiry for vacuum recalculation
|
||||
window._addBaseExpiryDays = estimatedDays;
|
||||
|
||||
@@ -3258,6 +3261,10 @@ function showAddForm() {
|
||||
`;
|
||||
|
||||
showPage('add');
|
||||
// After rendering, fetch history-based expiry prediction
|
||||
if (currentProduct && currentProduct.id) {
|
||||
_fetchExpiryHistoryAndUpdate(currentProduct.id);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleVacuumSealed() {
|
||||
@@ -3277,16 +3284,17 @@ function recalculateAddExpiry() {
|
||||
const loc = document.getElementById('add-location')?.value || '';
|
||||
const isVacuum = document.getElementById('add-vacuum-sealed')?.checked;
|
||||
|
||||
let days = estimateExpiryDays(currentProduct, loc);
|
||||
if (isVacuum) days = getVacuumExpiryDays(days);
|
||||
const baseDays = window._historyExpiryDays ?? estimateExpiryDays(currentProduct, loc);
|
||||
let days = isVacuum ? getVacuumExpiryDays(baseDays) : baseDays;
|
||||
|
||||
window._addBaseExpiryDays = estimateExpiryDays(currentProduct, loc);
|
||||
window._addBaseExpiryDays = baseDays;
|
||||
|
||||
const newDate = addDays(days);
|
||||
const newLabel = formatEstimatedExpiry(days);
|
||||
|
||||
let suffix = '';
|
||||
if (loc === 'freezer' && isVacuum) suffix = ' (freezer + sotto vuoto)';
|
||||
if (window._historyExpiryDays) suffix = ' (da storico)';
|
||||
else if (loc === 'freezer' && isVacuum) suffix = ' (freezer + sotto vuoto)';
|
||||
else if (loc === 'freezer') suffix = ' (freezer)';
|
||||
else if (isVacuum) suffix = ' (sotto vuoto)';
|
||||
|
||||
@@ -3298,6 +3306,33 @@ function recalculateAddExpiry() {
|
||||
if (dateEl) dateEl.textContent = formatDate(newDate);
|
||||
}
|
||||
|
||||
async function _fetchExpiryHistoryAndUpdate(productId) {
|
||||
try {
|
||||
const res = await fetch(`api/index.php?action=expiry_history&product_id=${encodeURIComponent(productId)}`);
|
||||
const data = await res.json();
|
||||
if (data.avg_days && data.avg_days > 0 && data.count >= 1) {
|
||||
window._historyExpiryDays = data.avg_days;
|
||||
window._historyExpiryCount = data.count;
|
||||
// Update the displayed date and label
|
||||
const loc = document.getElementById('add-location')?.value || '';
|
||||
const isVacuum = document.getElementById('add-vacuum-sealed')?.checked;
|
||||
let days = isVacuum ? getVacuumExpiryDays(data.avg_days) : data.avg_days;
|
||||
const newDate = addDays(days);
|
||||
const newLabel = formatEstimatedExpiry(days);
|
||||
const suffix = ` <span class="history-badge" title="Media da ${data.count} insertiment${data.count === 1 ? 'o' : 'i'} precedent${data.count === 1 ? 'e' : 'i'}">📊 storico</span>`;
|
||||
const expiryInput = document.getElementById('add-expiry');
|
||||
const estimateEl = document.querySelector('.expiry-estimate-label');
|
||||
const dateEl = document.querySelector('.expiry-estimate-date');
|
||||
if (expiryInput) expiryInput.value = newDate;
|
||||
if (estimateEl) estimateEl.innerHTML = `Scadenza stimata: <strong>${newLabel}${suffix}</strong>`;
|
||||
if (dateEl) dateEl.textContent = formatDate(newDate);
|
||||
window._addBaseExpiryDays = data.avg_days;
|
||||
}
|
||||
} catch (e) {
|
||||
// silently fall back to rule-based estimate
|
||||
}
|
||||
}
|
||||
|
||||
function getVacuumExpiryDays(baseDays) {
|
||||
// Vacuum sealing extends shelf life significantly
|
||||
if (baseDays <= 7) return Math.round(baseDays * 3); // very fresh: 3x (e.g., 3→9, 7→21)
|
||||
@@ -3392,12 +3427,13 @@ function selectPurchaseType(btn, type) {
|
||||
// Recalculate fresh expiry based on current location/vacuum
|
||||
const loc = document.getElementById('add-location')?.value || '';
|
||||
const isVacuum = document.getElementById('add-vacuum-sealed')?.checked;
|
||||
let days = estimateExpiryDays(currentProduct, loc);
|
||||
if (isVacuum) days = getVacuumExpiryDays(days);
|
||||
const baseDays = window._historyExpiryDays ?? estimateExpiryDays(currentProduct, loc);
|
||||
let days = isVacuum ? getVacuumExpiryDays(baseDays) : baseDays;
|
||||
const estimatedDate = addDays(days);
|
||||
const estimateLabel = formatEstimatedExpiry(days);
|
||||
let suffix = '';
|
||||
if (loc === 'freezer' && isVacuum) suffix = ' (freezer + sotto vuoto)';
|
||||
if (window._historyExpiryDays) suffix = ` <span class="history-badge" title="Media da ${window._historyExpiryCount} inserimento/i precedente/i">📊 storico</span>`;
|
||||
else if (loc === 'freezer' && isVacuum) suffix = ' (freezer + sotto vuoto)';
|
||||
else if (loc === 'freezer') suffix = ' (freezer)';
|
||||
else if (isVacuum) suffix = ' (sotto vuoto)';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user