20260316g: Fix conf anomaly detection + auto-split opened packages
- Fixed isSuspiciousDefaultQty: for conf products, checks package_unit thresholds (375g is fine for g-max=10000, not checked against conf-max=50) - Auto-split on use: when using from a conf product with whole+fractional qty, automatically separates whole confs from opened part e.g. Panna 2.6conf → use 5g → 2conf (sealed) + 0.56conf (opened) - Move modal now moves only the opened row (via opened_id) - Use query prefers fractional rows (opened packages) first - Non-conf products still get standard move-after-use behavior
This commit is contained in:
+58
-3
@@ -624,7 +624,7 @@ function useFromInventory(PDO $db): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$stmt = $db->prepare("SELECT id, quantity FROM inventory WHERE product_id = ? AND location = ? AND quantity > 0");
|
$stmt = $db->prepare("SELECT id, quantity FROM inventory WHERE product_id = ? AND location = ? AND quantity > 0 ORDER BY (quantity != CAST(CAST(quantity AS INTEGER) AS REAL)) DESC, quantity ASC");
|
||||||
$stmt->execute([$productId, $location]);
|
$stmt->execute([$productId, $location]);
|
||||||
$existing = $stmt->fetch();
|
$existing = $stmt->fetch();
|
||||||
|
|
||||||
@@ -638,6 +638,46 @@ function useFromInventory(PDO $db): void {
|
|||||||
$quantity = $existing['quantity'];
|
$quantity = $existing['quantity'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-split conf products: separate whole confs from opened (fractional) part
|
||||||
|
$openedId = null;
|
||||||
|
$stmt2 = $db->prepare("SELECT unit, default_quantity, package_unit FROM products WHERE id = ?");
|
||||||
|
$stmt2->execute([$productId]);
|
||||||
|
$prodInfo = $stmt2->fetch();
|
||||||
|
|
||||||
|
if ($prodInfo && $prodInfo['unit'] === 'conf' && $prodInfo['default_quantity'] > 0 && !$useAll) {
|
||||||
|
$totalQty = (float)$existing['quantity'];
|
||||||
|
$wholeConfs = floor($totalQty + 0.001);
|
||||||
|
$fraction = round($totalQty - $wholeConfs, 6);
|
||||||
|
|
||||||
|
// Has both whole and fractional, and we're using less than or equal to the fractional part
|
||||||
|
if ($wholeConfs >= 1 && $fraction > 0.001 && $quantity <= $fraction + 0.001) {
|
||||||
|
// Split: keep whole confs in original row, create new row for opened part
|
||||||
|
$stmt3 = $db->prepare("UPDATE inventory SET quantity = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?");
|
||||||
|
$stmt3->execute([$wholeConfs, $existing['id']]);
|
||||||
|
|
||||||
|
// Get expiry and vacuum_sealed from original row
|
||||||
|
$stmt3 = $db->prepare("SELECT expiry_date, vacuum_sealed FROM inventory WHERE id = ?");
|
||||||
|
$stmt3->execute([$existing['id']]);
|
||||||
|
$origRow = $stmt3->fetch();
|
||||||
|
|
||||||
|
$newFraction = round($fraction - $quantity, 6);
|
||||||
|
if ($newFraction > 0.001) {
|
||||||
|
$stmt3 = $db->prepare("INSERT INTO inventory (product_id, location, quantity, expiry_date, vacuum_sealed) VALUES (?, ?, ?, ?, ?)");
|
||||||
|
$stmt3->execute([$productId, $location, $newFraction, $origRow['expiry_date'], $origRow['vacuum_sealed'] ?? 0]);
|
||||||
|
$openedId = (int)$db->lastInsertId();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log transaction
|
||||||
|
$type = ($notes === 'Buttato') ? 'waste' : 'out';
|
||||||
|
$stmt3 = $db->prepare("INSERT INTO transactions (product_id, type, quantity, location, notes) VALUES (?, ?, ?, ?, ?)");
|
||||||
|
$stmt3->execute([$productId, $type, $quantity, $location, $notes]);
|
||||||
|
|
||||||
|
$remaining = $newFraction > 0.001 ? $newFraction : 0;
|
||||||
|
// Skip the normal flow — jump to Bring! check and response
|
||||||
|
goto afterDeduct;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$newQty = max(0, $existing['quantity'] - $quantity);
|
$newQty = max(0, $existing['quantity'] - $quantity);
|
||||||
|
|
||||||
if ($newQty <= 0) {
|
if ($newQty <= 0) {
|
||||||
@@ -653,9 +693,22 @@ function useFromInventory(PDO $db): void {
|
|||||||
$stmt = $db->prepare("INSERT INTO transactions (product_id, type, quantity, location, notes) VALUES (?, ?, ?, ?, ?)");
|
$stmt = $db->prepare("INSERT INTO transactions (product_id, type, quantity, location, notes) VALUES (?, ?, ?, ?, ?)");
|
||||||
$stmt->execute([$productId, $type, $quantity, $location, $notes]);
|
$stmt->execute([$productId, $type, $quantity, $location, $notes]);
|
||||||
|
|
||||||
|
$remaining = $newQty;
|
||||||
|
|
||||||
|
// Check if opened part remains (for non-split path)
|
||||||
|
if ($remaining > 0 && $prodInfo && $prodInfo['unit'] === 'conf') {
|
||||||
|
$w = floor($remaining + 0.001);
|
||||||
|
$f = round($remaining - $w, 6);
|
||||||
|
if ($f > 0.001) {
|
||||||
|
$openedId = (int)$existing['id'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
afterDeduct:
|
||||||
|
|
||||||
// Auto-add to Bring! if product is completely finished (no inventory left anywhere)
|
// Auto-add to Bring! if product is completely finished (no inventory left anywhere)
|
||||||
$addedToBring = false;
|
$addedToBring = false;
|
||||||
if ($newQty <= 0) {
|
if ($remaining <= 0) {
|
||||||
$stmt = $db->prepare("SELECT SUM(quantity) as total FROM inventory WHERE product_id = ? AND quantity > 0");
|
$stmt = $db->prepare("SELECT SUM(quantity) as total FROM inventory WHERE product_id = ? AND quantity > 0");
|
||||||
$stmt->execute([$productId]);
|
$stmt->execute([$productId]);
|
||||||
$totalLeft = (float)($stmt->fetchColumn() ?: 0);
|
$totalLeft = (float)($stmt->fetchColumn() ?: 0);
|
||||||
@@ -711,7 +764,9 @@ function useFromInventory(PDO $db): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
echo json_encode(['success' => true, 'remaining' => $newQty, 'added_to_bring' => $addedToBring]);
|
$response = ['success' => true, 'remaining' => $remaining, 'added_to_bring' => $addedToBring];
|
||||||
|
if ($openedId) $response['opened_id'] = $openedId;
|
||||||
|
echo json_encode($response);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateInventory(PDO $db): void {
|
function updateInventory(PDO $db): void {
|
||||||
|
|||||||
+32
-16
@@ -958,10 +958,12 @@ function isSuspiciousQty(qty, unit) {
|
|||||||
return n < t.min || n > t.max;
|
return n < t.min || n > t.max;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSuspiciousDefaultQty(defaultQty, unit) {
|
function isSuspiciousDefaultQty(defaultQty, unit, packageUnit) {
|
||||||
const n = parseFloat(defaultQty);
|
const n = parseFloat(defaultQty);
|
||||||
if (!n || n <= 0) return false;
|
if (!n || n <= 0) return false;
|
||||||
const t = QTY_THRESHOLDS[unit] || QTY_THRESHOLDS['pz'];
|
// For conf products, default_quantity is in package_unit (g, ml, etc.)
|
||||||
|
const checkUnit = (unit === 'conf' && packageUnit) ? packageUnit : unit;
|
||||||
|
const t = QTY_THRESHOLDS[checkUnit] || QTY_THRESHOLDS['pz'];
|
||||||
return n > t.max;
|
return n > t.max;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -988,7 +990,7 @@ async function loadReviewItems() {
|
|||||||
|
|
||||||
const suspicious = items.filter(item => {
|
const suspicious = items.filter(item => {
|
||||||
if (confirmed[item.id]) return false;
|
if (confirmed[item.id]) return false;
|
||||||
return isSuspiciousQty(item.quantity, item.unit) || isSuspiciousDefaultQty(item.default_quantity, item.unit);
|
return isSuspiciousQty(item.quantity, item.unit) || isSuspiciousDefaultQty(item.default_quantity, item.unit, item.package_unit);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (suspicious.length === 0) {
|
if (suspicious.length === 0) {
|
||||||
@@ -1003,7 +1005,7 @@ async function loadReviewItems() {
|
|||||||
const locInfo = LOCATIONS[item.location] || { icon: '📦', label: item.location };
|
const locInfo = LOCATIONS[item.location] || { icon: '📦', label: item.location };
|
||||||
const t = QTY_THRESHOLDS[item.unit] || QTY_THRESHOLDS['pz'];
|
const t = QTY_THRESHOLDS[item.unit] || QTY_THRESHOLDS['pz'];
|
||||||
const suspQty = isSuspiciousQty(item.quantity, item.unit);
|
const suspQty = isSuspiciousQty(item.quantity, item.unit);
|
||||||
const suspDq = isSuspiciousDefaultQty(item.default_quantity, item.unit);
|
const suspDq = isSuspiciousDefaultQty(item.default_quantity, item.unit, item.package_unit);
|
||||||
let warning;
|
let warning;
|
||||||
if (suspDq && !suspQty) warning = '📦 Conf. sospetta';
|
if (suspDq && !suspQty) warning = '📦 Conf. sospetta';
|
||||||
else if (parseFloat(item.quantity) < t.min) warning = '⬇️ Troppo poco';
|
else if (parseFloat(item.quantity) < t.min) warning = '⬇️ Troppo poco';
|
||||||
@@ -3480,10 +3482,10 @@ function selectUseLocation(btn, loc) {
|
|||||||
document.getElementById('use-location').value = loc;
|
document.getElementById('use-location').value = loc;
|
||||||
}
|
}
|
||||||
|
|
||||||
function showMoveAfterUseModal(product, fromLoc, remaining) {
|
function showMoveAfterUseModal(product, fromLoc, remaining, openedId) {
|
||||||
const otherLocs = Object.entries(LOCATIONS).filter(([k]) => k !== fromLoc);
|
const otherLocs = Object.entries(LOCATIONS).filter(([k]) => k !== fromLoc);
|
||||||
const locButtons = otherLocs.map(([k, v]) =>
|
const locButtons = otherLocs.map(([k, v]) =>
|
||||||
`<button type="button" class="loc-btn" onclick="confirmMoveAfterUse(${product.id}, '${fromLoc}', '${k}')">${v.icon} ${v.label}</button>`
|
`<button type="button" class="loc-btn" onclick="confirmMoveAfterUse(${product.id}, '${fromLoc}', '${k}', ${openedId || 0})">${v.icon} ${v.label}</button>`
|
||||||
).join('');
|
).join('');
|
||||||
|
|
||||||
document.getElementById('modal-content').innerHTML = `
|
document.getElementById('modal-content').innerHTML = `
|
||||||
@@ -3492,7 +3494,7 @@ function showMoveAfterUseModal(product, fromLoc, remaining) {
|
|||||||
<button class="modal-close" onclick="closeModal();showPage('dashboard')">✕</button>
|
<button class="modal-close" onclick="closeModal();showPage('dashboard')">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div style="padding:0 16px 16px">
|
<div style="padding:0 16px 16px">
|
||||||
<p style="margin-bottom:12px">Vuoi spostare il resto di <strong>${escapeHtml(product.name)}</strong> in un'altra posizione?</p>
|
<p style="margin-bottom:12px">Vuoi spostare ${openedId ? 'la confezione aperta' : 'il resto'} di <strong>${escapeHtml(product.name)}</strong> in un'altra posizione?</p>
|
||||||
<div class="location-selector">${locButtons}</div>
|
<div class="location-selector">${locButtons}</div>
|
||||||
<button type="button" class="btn btn-secondary full-width" style="margin-top:12px" onclick="closeModal();showPage('dashboard')">No, resta in ${LOCATIONS[fromLoc]?.label || fromLoc}</button>
|
<button type="button" class="btn btn-secondary full-width" style="margin-top:12px" onclick="closeModal();showPage('dashboard')">No, resta in ${LOCATIONS[fromLoc]?.label || fromLoc}</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -3500,23 +3502,37 @@ function showMoveAfterUseModal(product, fromLoc, remaining) {
|
|||||||
document.getElementById('modal-overlay').style.display = 'flex';
|
document.getElementById('modal-overlay').style.display = 'flex';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function confirmMoveAfterUse(productId, fromLoc, toLoc) {
|
async function confirmMoveAfterUse(productId, fromLoc, toLoc, openedId) {
|
||||||
closeModal();
|
closeModal();
|
||||||
showLoading(true);
|
showLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await api('inventory_list');
|
if (openedId) {
|
||||||
const item = (data.inventory || []).find(i => i.product_id == productId && i.location === fromLoc && parseFloat(i.quantity) > 0);
|
// Move only the specific opened row
|
||||||
if (item) {
|
const product = { name: currentProduct?.name || '', category: currentProduct?.category || '' };
|
||||||
const product = { name: item.name || '', category: item.category || '' };
|
|
||||||
let days = estimateExpiryDays(product, toLoc);
|
let days = estimateExpiryDays(product, toLoc);
|
||||||
if (item.vacuum_sealed) days = getVacuumExpiryDays(days);
|
|
||||||
await api('inventory_update', {}, 'POST', {
|
await api('inventory_update', {}, 'POST', {
|
||||||
id: item.id,
|
id: openedId,
|
||||||
location: toLoc,
|
location: toLoc,
|
||||||
expiry_date: addDays(days),
|
expiry_date: addDays(days),
|
||||||
product_id: productId,
|
product_id: productId,
|
||||||
});
|
});
|
||||||
showToast(`📦 Spostato in ${LOCATIONS[toLoc]?.label || toLoc}`, 'success');
|
showToast(`📦 Confezione aperta spostata in ${LOCATIONS[toLoc]?.label || toLoc}`, 'success');
|
||||||
|
} else {
|
||||||
|
// Legacy: move whatever is at fromLoc
|
||||||
|
const data = await api('inventory_list');
|
||||||
|
const item = (data.inventory || []).find(i => i.product_id == productId && i.location === fromLoc && parseFloat(i.quantity) > 0);
|
||||||
|
if (item) {
|
||||||
|
const product = { name: item.name || '', category: item.category || '' };
|
||||||
|
let days = estimateExpiryDays(product, toLoc);
|
||||||
|
if (item.vacuum_sealed) days = getVacuumExpiryDays(days);
|
||||||
|
await api('inventory_update', {}, 'POST', {
|
||||||
|
id: item.id,
|
||||||
|
location: toLoc,
|
||||||
|
expiry_date: addDays(days),
|
||||||
|
product_id: productId,
|
||||||
|
});
|
||||||
|
showToast(`📦 Spostato in ${LOCATIONS[toLoc]?.label || toLoc}`, 'success');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Move error:', e);
|
console.error('Move error:', e);
|
||||||
@@ -3580,7 +3596,7 @@ async function submitUse(e) {
|
|||||||
// If there's remaining quantity, offer to move to another location
|
// If there's remaining quantity, offer to move to another location
|
||||||
const usedFrom = document.getElementById('use-location').value;
|
const usedFrom = document.getElementById('use-location').value;
|
||||||
if (result.remaining > 0) {
|
if (result.remaining > 0) {
|
||||||
showMoveAfterUseModal(currentProduct, usedFrom, result.remaining);
|
showMoveAfterUseModal(currentProduct, usedFrom, result.remaining, result.opened_id);
|
||||||
} else {
|
} else {
|
||||||
showPage('dashboard');
|
showPage('dashboard');
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
+1
-1
@@ -911,6 +911,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="assets/js/app.js?v=20260316f"></script>
|
<script src="assets/js/app.js?v=20260316g"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user