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:
dadaloop82
2026-03-16 08:07:17 +00:00
parent 5ca809b31f
commit 134e5dfa4e
4 changed files with 91 additions and 20 deletions
+58 -3
View File
@@ -624,7 +624,7 @@ function useFromInventory(PDO $db): void {
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]);
$existing = $stmt->fetch();
@@ -638,6 +638,46 @@ function useFromInventory(PDO $db): void {
$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);
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->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)
$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->execute([$productId]);
$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 {
+32 -16
View File
@@ -958,10 +958,12 @@ function isSuspiciousQty(qty, unit) {
return n < t.min || n > t.max;
}
function isSuspiciousDefaultQty(defaultQty, unit) {
function isSuspiciousDefaultQty(defaultQty, unit, packageUnit) {
const n = parseFloat(defaultQty);
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;
}
@@ -988,7 +990,7 @@ async function loadReviewItems() {
const suspicious = items.filter(item => {
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) {
@@ -1003,7 +1005,7 @@ async function loadReviewItems() {
const locInfo = LOCATIONS[item.location] || { icon: '📦', label: item.location };
const t = QTY_THRESHOLDS[item.unit] || QTY_THRESHOLDS['pz'];
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;
if (suspDq && !suspQty) warning = '📦 Conf. sospetta';
else if (parseFloat(item.quantity) < t.min) warning = '⬇️ Troppo poco';
@@ -3480,10 +3482,10 @@ function selectUseLocation(btn, 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 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('');
document.getElementById('modal-content').innerHTML = `
@@ -3492,7 +3494,7 @@ function showMoveAfterUseModal(product, fromLoc, remaining) {
<button class="modal-close" onclick="closeModal();showPage('dashboard')">✕</button>
</div>
<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>
<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>
@@ -3500,23 +3502,37 @@ function showMoveAfterUseModal(product, fromLoc, remaining) {
document.getElementById('modal-overlay').style.display = 'flex';
}
async function confirmMoveAfterUse(productId, fromLoc, toLoc) {
async function confirmMoveAfterUse(productId, fromLoc, toLoc, openedId) {
closeModal();
showLoading(true);
try {
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 || '' };
if (openedId) {
// Move only the specific opened row
const product = { name: currentProduct?.name || '', category: currentProduct?.category || '' };
let days = estimateExpiryDays(product, toLoc);
if (item.vacuum_sealed) days = getVacuumExpiryDays(days);
await api('inventory_update', {}, 'POST', {
id: item.id,
id: openedId,
location: toLoc,
expiry_date: addDays(days),
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) {
console.error('Move error:', e);
@@ -3580,7 +3596,7 @@ async function submitUse(e) {
// If there's remaining quantity, offer to move to another location
const usedFrom = document.getElementById('use-location').value;
if (result.remaining > 0) {
showMoveAfterUseModal(currentProduct, usedFrom, result.remaining);
showMoveAfterUseModal(currentProduct, usedFrom, result.remaining, result.opened_id);
} else {
showPage('dashboard');
}
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -911,6 +911,6 @@
</div>
</div>
<script src="assets/js/app.js?v=20260316f"></script>
<script src="assets/js/app.js?v=20260316g"></script>
</body>
</html>