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;
|
||||
}
|
||||
|
||||
$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
@@ -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');
|
||||
}
|
||||
|
||||
Binary file not shown.
+1
-1
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user