From e2a41f3ed4383e18822a2ff5f0936e5a064854b2 Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Tue, 12 May 2026 10:32:52 +0000 Subject: [PATCH] Autosave: 20260512-103252 --- complete_schema.sql | 1 + db/migrations/20260216_add_stock_tables.sql | 1 + ...512_add_last_sale_price_to_stock_items.sql | 2 + db/schema.sql | 1 + index.php | 273 ++++++++++++++++-- ...sales_purchases_invoice_actions_script.php | 4 +- .../sales_purchases_invoice_form_helpers.php | 67 ++++- pages/sales_purchases_logic.php | 4 +- pages/sales_purchases_save_logic.php | 16 + 9 files changed, 345 insertions(+), 24 deletions(-) create mode 100644 db/migrations/20260512_add_last_sale_price_to_stock_items.sql diff --git a/complete_schema.sql b/complete_schema.sql index ba7506d..55ee3e4 100644 --- a/complete_schema.sql +++ b/complete_schema.sql @@ -705,6 +705,7 @@ CREATE TABLE IF NOT EXISTS `stock_items` ( `sku` varchar(100) DEFAULT NULL, `purchase_price` decimal(15,3) DEFAULT 0.000, `sale_price` decimal(15,3) DEFAULT 0.000, + `last_sale_price` decimal(15,3) DEFAULT 0.000, `stock_quantity` decimal(15,2) DEFAULT 0.00, `min_stock_level` decimal(15,2) DEFAULT 0.00, `expiry_date` date DEFAULT NULL, diff --git a/db/migrations/20260216_add_stock_tables.sql b/db/migrations/20260216_add_stock_tables.sql index 95995ef..706a342 100644 --- a/db/migrations/20260216_add_stock_tables.sql +++ b/db/migrations/20260216_add_stock_tables.sql @@ -23,6 +23,7 @@ CREATE TABLE IF NOT EXISTS stock_items ( sku VARCHAR(100) UNIQUE, purchase_price DECIMAL(15, 2) DEFAULT 0.00, sale_price DECIMAL(15, 2) DEFAULT 0.00, + last_sale_price DECIMAL(15, 3) DEFAULT 0.000, stock_quantity DECIMAL(15, 2) DEFAULT 0.00, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (category_id) REFERENCES stock_categories(id) ON DELETE SET NULL, diff --git a/db/migrations/20260512_add_last_sale_price_to_stock_items.sql b/db/migrations/20260512_add_last_sale_price_to_stock_items.sql new file mode 100644 index 0000000..96d6da0 --- /dev/null +++ b/db/migrations/20260512_add_last_sale_price_to_stock_items.sql @@ -0,0 +1,2 @@ +-- Add an admin-managed minimum sale price limit to stock items. +ALTER TABLE stock_items ADD COLUMN last_sale_price DECIMAL(15,3) DEFAULT 0.000 AFTER sale_price; diff --git a/db/schema.sql b/db/schema.sql index 7649ffe..e072c3b 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -925,6 +925,7 @@ CREATE TABLE `stock_items` ( `sku` varchar(100) DEFAULT NULL, `purchase_price` decimal(15,3) DEFAULT 0.000, `sale_price` decimal(15,3) DEFAULT 0.000, + `last_sale_price` decimal(15,3) DEFAULT 0.000, `stock_quantity` decimal(15,2) DEFAULT 0.00, `min_stock_level` decimal(15,2) DEFAULT 0.00, `expiry_date` date DEFAULT NULL, diff --git a/index.php b/index.php index 145e9a2..5dbd70a 100644 --- a/index.php +++ b/index.php @@ -901,7 +901,7 @@ if (!function_exists('sales_purchases_load_logic')) { $inv['total_in_words'] = numberToWordsOMR($inv['total_with_vat']); if ($type === 'sale') { - $item_stmt = db()->prepare('SELECT ii.*, i.name_en, i.name_ar, i.vat_rate FROM invoice_items ii LEFT JOIN stock_items i ON ii.item_id = i.id WHERE ii.invoice_id = ?'); + $item_stmt = db()->prepare('SELECT ii.*, i.name_en, i.name_ar, i.vat_rate, i.last_sale_price FROM invoice_items ii LEFT JOIN stock_items i ON ii.item_id = i.id WHERE ii.invoice_id = ?'); $item_stmt->execute([$inv['id']]); $inv['items'] = $item_stmt->fetchAll(PDO::FETCH_ASSOC); } else { @@ -912,7 +912,7 @@ if (!function_exists('sales_purchases_load_logic')) { } unset($inv); - $items_list_raw = db()->query('SELECT i.id, i.name_en, i.name_ar, i.sale_price, i.purchase_price, i.stock_quantity, i.vat_rate, i.is_promotion, i.promotion_start, i.promotion_end, i.promotion_percent FROM stock_items i ORDER BY i.name_en ASC')->fetchAll(PDO::FETCH_ASSOC); + $items_list_raw = db()->query('SELECT i.id, i.name_en, i.name_ar, i.sale_price, i.last_sale_price, i.purchase_price, i.stock_quantity, i.vat_rate, i.is_promotion, i.promotion_start, i.promotion_end, i.promotion_percent FROM stock_items i ORDER BY i.name_en ASC')->fetchAll(PDO::FETCH_ASSOC); foreach ($items_list_raw as &$item) { $item['sale_price'] = getPromotionalPrice($item); } @@ -1861,7 +1861,7 @@ if (isset($_GET['action']) || isset($_POST['action'])) { // Render Card HTML ?> -
+
<?= htmlspecialchars($p['name_en']) ?> @@ -1909,6 +1909,7 @@ if (isset($_GET['action']) || isset($_POST['action'])) { $p['original_price'] = (float)$p['sale_price']; $p['sale_price'] = getPromotionalPrice($p); $p['price'] = (float)$p['sale_price']; + $p['lastSalePrice'] = (float)($p['last_sale_price'] ?? 0); $p['nameEn'] = $p['name_en']; $p['nameAr'] = $p['name_ar']; $p['vatRate'] = $p['vat_rate']; @@ -2008,7 +2009,12 @@ if (isset($_GET['action']) || isset($_POST['action'])) { $items = []; } foreach ($items as $itemIndex => $item) { + $itemId = (int)($item['id'] ?? 0); + $itemPrice = max(0, (float)($item['price'] ?? 0)); + $items[$itemIndex]['id'] = $itemId; $items[$itemIndex]['qty'] = normalize_quantity($item['qty'] ?? 0); + $items[$itemIndex]['price'] = $itemPrice; + assertEditedSalePriceWithinLastSalePrice($itemId, $itemPrice); } $total_amount = (float)($_POST['total_amount'] ?? 0); $tax_amount = (float)($_POST['tax_amount'] ?? 0); @@ -2180,7 +2186,7 @@ if (isset($_GET['action']) || isset($_POST['action'])) { $invoice['party_name'] = $partyName !== '' ? $partyName : '---'; $invoice['paid_amount'] = (float)($invoice['paid_amount'] ?? 0); - $stmtItems = db()->prepare("SELECT li.*, i.name_en, i.name_ar, i.sku, i.vat_rate, i.stock_quantity, i.purchase_price FROM $itemTable li LEFT JOIN stock_items i ON li.item_id = i.id WHERE li.$fkColumn = ?"); + $stmtItems = db()->prepare("SELECT li.*, i.name_en, i.name_ar, i.sku, i.vat_rate, i.stock_quantity, i.purchase_price, i.last_sale_price FROM $itemTable li LEFT JOIN stock_items i ON li.item_id = i.id WHERE li.$fkColumn = ?"); $stmtItems->execute([$invoice_id]); $invoice['items'] = $stmtItems->fetchAll(PDO::FETCH_ASSOC); @@ -2199,7 +2205,7 @@ if (isset($_GET['action']) || isset($_POST['action'])) { LEFT JOIN stock_items i ON pi.item_id = i.id WHERE pi.purchase_id = ?"); } else { - $stmt = db()->prepare("SELECT ii.*, i.name_en, i.name_ar, i.sku, i.vat_rate, i.stock_quantity + $stmt = db()->prepare("SELECT ii.*, i.name_en, i.name_ar, i.sku, i.vat_rate, i.stock_quantity, i.last_sale_price FROM invoice_items ii LEFT JOIN stock_items i ON ii.item_id = i.id WHERE ii.invoice_id = ?"); @@ -2956,6 +2962,73 @@ function getPromotionalPrice($item) { return $price; } +function updateItemLastSalePrice($itemId, $price): void { + // Compatibility no-op: last_sale_price is admin-managed only. + // It represents the minimum allowed sale price and must not be changed by POS/invoice sales. + return; +} + +function assertEditedSalePriceWithinLastSalePrice($itemId, $price, $lockedAllowedPrice = null): void { + // last_sale_price is an admin-managed minimum sale price floor. + // When it is left at 0, cashier/manual price edits are locked to the item sale price. + $itemId = (int)$itemId; + $price = (float)$price; + if ($itemId <= 0 || !db_column_exists('stock_items', 'last_sale_price')) { + return; + } + + $stmt = db()->prepare("SELECT name_en, name_ar, sale_price, last_sale_price, is_promotion, promotion_start, promotion_end, promotion_percent FROM stock_items WHERE id = ? LIMIT 1"); + $stmt->execute([$itemId]); + $item = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$item) { + return; + } + + $itemName = trim((string)($item['name_en'] ?? '')); + if ($itemName === '') { + $itemName = trim((string)($item['name_ar'] ?? '')); + } + if ($itemName === '') { + $itemName = 'item #' . $itemId; + } + + global $lang; + $isArabic = (($lang ?? 'en') === 'ar'); + $lastSalePrice = (float)($item['last_sale_price'] ?? 0); + + if ($lastSalePrice <= 0) { + $allowedLockedPrices = [ + (float)($item['sale_price'] ?? 0), + (float)getPromotionalPrice($item), + ]; + if ($lockedAllowedPrice !== null) { + $allowedLockedPrices[] = (float)$lockedAllowedPrice; + } + + foreach ($allowedLockedPrices as $allowedPrice) { + if (abs($price - (float)$allowedPrice) <= 0.0005) { + return; + } + } + + if ($isArabic) { + throw new Exception('لا يمكن تغيير سعر الصنف ' . $itemName . ' لأن آخر سعر بيع / الحد الأدنى مضبوط على 0.000. يرجى تحديد حد أدنى للسعر أولاً.'); + } + + throw new Exception('Price for ' . $itemName . ' cannot be changed because Last Sale Price / minimum sale price is 0.000. Set a minimum price limit first.'); + } + + if ($price >= ($lastSalePrice - 0.0005)) { + return; + } + + if ($isArabic) { + throw new Exception('لا يمكن بيع الصنف ' . $itemName . ' بسعر أقل من آخر سعر بيع / الحد الأدنى (' . number_format($lastSalePrice, 3) . ').'); + } + + throw new Exception('Price for ' . $itemName . ' cannot be below Last Sale Price / minimum sale price (OMR ' . number_format($lastSalePrice, 3) . ').'); +} + // --- Inventory & Core Handlers --- if (isset($_POST['add_item'])) { $name_en = $_POST['name_en'] ?? ''; @@ -2968,6 +3041,10 @@ function getPromotionalPrice($item) { redirectWithMessage($sku_error, 'index.php?page=items'); } $sale_price = (float)($_POST['sale_price'] ?? 0); + $last_sale_price = (float)($_POST['last_sale_price'] ?? 0); + if ($sale_price > 0 && $last_sale_price > ($sale_price + 0.0005)) { + redirectWithMessage('Last Sale Price (Minimum) cannot be higher than Sale Price.', 'index.php?page=items'); + } $purchase_price = (float)($_POST['purchase_price'] ?? 0); $stock_quantity = normalize_quantity($_POST['stock_quantity'] ?? 0); $min_stock_level = normalize_quantity($_POST['min_stock_level'] ?? 0); @@ -2986,8 +3063,29 @@ function getPromotionalPrice($item) { if (move_uploaded_file($_FILES['image']['tmp_name'], $filename)) $image_path = $filename; } $current_oid = current_outlet_id(); - $stmt = db()->prepare("INSERT INTO stock_items (outlet_id, name_en, name_ar, category_id, unit_id, supplier_id, sku, sale_price, purchase_price, stock_quantity, min_stock_level, image_path, vat_rate, expiry_date, is_promotion, promotion_start, promotion_end, promotion_percent) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"); - $stmt->execute([$current_oid, $name_en, $name_ar, $category_id, $unit_id, $supplier_id, $sku, $sale_price, $purchase_price, $stock_quantity, $min_stock_level, $image_path, $vat_rate, $expiry_date, $is_promotion, $promotion_start, $promotion_end, $promotion_percent]); + [$stockItemInsertSql, $stockItemInsertValues] = db_insert_sql_for_existing_columns('stock_items', [ + 'outlet_id' => $current_oid, + 'name_en' => $name_en, + 'name_ar' => $name_ar, + 'category_id' => $category_id, + 'unit_id' => $unit_id, + 'supplier_id' => $supplier_id, + 'sku' => $sku, + 'sale_price' => $sale_price, + 'last_sale_price' => $last_sale_price, + 'purchase_price' => $purchase_price, + 'stock_quantity' => $stock_quantity, + 'min_stock_level' => $min_stock_level, + 'image_path' => $image_path, + 'vat_rate' => $vat_rate, + 'expiry_date' => $expiry_date, + 'is_promotion' => $is_promotion, + 'promotion_start' => $promotion_start, + 'promotion_end' => $promotion_end, + 'promotion_percent' => $promotion_percent, + ]); + $stmt = db()->prepare($stockItemInsertSql); + $stmt->execute($stockItemInsertValues); $new_item_id = db()->lastInsertId(); redirectWithMessage("Item added successfully!"); } @@ -3004,6 +3102,10 @@ function getPromotionalPrice($item) { redirectWithMessage($sku_error, 'index.php?page=items'); } $sale_price = (float)($_POST['sale_price'] ?? 0); + $last_sale_price = (float)($_POST['last_sale_price'] ?? 0); + if ($sale_price > 0 && $last_sale_price > ($sale_price + 0.0005)) { + redirectWithMessage('Last Sale Price (Minimum) cannot be higher than Sale Price.', 'index.php?page=items'); + } $purchase_price = (float)($_POST['purchase_price'] ?? 0); $stock_quantity = normalize_quantity($_POST['stock_quantity'] ?? 0); $min_stock_level = normalize_quantity($_POST['min_stock_level'] ?? 0); @@ -3014,8 +3116,32 @@ function getPromotionalPrice($item) { $promotion_end = !empty($_POST['promotion_end']) ? $_POST['promotion_end'] : null; $promotion_percent = (float)($_POST['promotion_percent'] ?? 0); // Update stock_items - $stmt = db()->prepare("UPDATE stock_items SET name_en = ?, name_ar = ?, category_id = ?, unit_id = ?, supplier_id = ?, sku = ?, sale_price = ?, purchase_price = ?, stock_quantity = ?, min_stock_level = ?, vat_rate = ?, expiry_date = ?, is_promotion = ?, promotion_start = ?, promotion_end = ?, promotion_percent = ? WHERE id = ?"); - $stmt->execute([$name_en, $name_ar, $category_id, $unit_id, $supplier_id, $sku, $sale_price, $purchase_price, $stock_quantity, $min_stock_level, $vat_rate, $expiry_date, $is_promotion, $promotion_start, $promotion_end, $promotion_percent, $id]); + $stockItemUpdateValues = [ + 'name_en' => $name_en, + 'name_ar' => $name_ar, + 'category_id' => $category_id, + 'unit_id' => $unit_id, + 'supplier_id' => $supplier_id, + 'sku' => $sku, + 'sale_price' => $sale_price, + 'purchase_price' => $purchase_price, + 'stock_quantity' => $stock_quantity, + 'min_stock_level' => $min_stock_level, + 'vat_rate' => $vat_rate, + 'expiry_date' => $expiry_date, + 'is_promotion' => $is_promotion, + 'promotion_start' => $promotion_start, + 'promotion_end' => $promotion_end, + 'promotion_percent' => $promotion_percent, + ]; + if (db_column_exists('stock_items', 'last_sale_price')) { + $stockItemUpdateValues['last_sale_price'] = $last_sale_price; + } + $stockItemUpdateSet = implode(', ', array_map(static fn(string $column): string => "`{$column}` = ?", array_keys($stockItemUpdateValues))); + $stockItemUpdateParams = array_values($stockItemUpdateValues); + $stockItemUpdateParams[] = $id; + $stmt = db()->prepare("UPDATE stock_items SET {$stockItemUpdateSet} WHERE id = ?"); + $stmt->execute($stockItemUpdateParams); if (isset($_FILES['image']) && $_FILES['image']['error'] === 0) { $ext = pathinfo($_FILES['image']['name'], PATHINFO_EXTENSION); $filename = 'uploads/item_' . $id . '_' . time() . '.' . $ext; @@ -7432,6 +7558,7 @@ runtime_debug_mark('page:rendering', ['page' => (string)$page]); Category Supplier Sale Price + Last Sale Price (Minimum) Stock Level Expiry VAT @@ -7472,6 +7599,9 @@ runtime_debug_mark('page:rendering', ['page' => (string)$page]);
Incl. VAT:
+ + +
@@ -7526,6 +7656,7 @@ runtime_debug_mark('page:rendering', ['page' => (string)$page]); Category Supplier Sale PriceOMR + Last Sale Price (Minimum)OMR Stock Level VAT Rate% @@ -7559,8 +7690,9 @@ runtime_debug_mark('page:rendering', ['page' => (string)$page]);
-
-
+
+
Admin-only minimum selling price; POS and sales cannot sell below this value.
+
@@ -7838,7 +7970,7 @@ runtime_debug_mark('page:rendering', ['page' => (string)$page]);
-
+
<?= htmlspecialchars($p['name_en']) ?> @@ -8156,17 +8288,28 @@ runtime_debug_mark('page:rendering', ['page' => (string)$page]); const purchasePrice = (product.purchasePrice !== undefined && product.purchasePrice !== null) ? (parseFloat(product.purchasePrice) || 0) : (parseFloat(product.purchase_price) || 0); - const normalizedProduct = {...product, price: unitPrice, purchasePrice}; + const lastSalePrice = (product.lastSalePrice !== undefined && product.lastSalePrice !== null) + ? (parseFloat(product.lastSalePrice) || 0) + : (parseFloat(product.last_sale_price) || 0); + const normalizedProduct = {...product, price: unitPrice, originalPrice: unitPrice, purchasePrice, lastSalePrice, priceEdited: false}; - const existing = this.items.find(item => item.id === product.id); + const existing = this.items.find(item => Number(item.id) === Number(product.id)); if (existing) { if (!allowZeroStock && (existing.qty + addQty) > currentStock) { Swal.fire(posT('Error', 'خطأ'), posT('Insufficient stock!', 'المخزون غير كافٍ!'), 'error'); return; } existing.qty = normalizeQuantity(existing.qty + addQty); - existing.price = unitPrice; + if (!Number.isFinite(parseFloat(existing.price))) { + existing.price = unitPrice; + } existing.purchasePrice = purchasePrice; + if (!Number.isFinite(parseFloat(existing.lastSalePrice))) { + existing.lastSalePrice = lastSalePrice; + } + if (!Number.isFinite(parseFloat(existing.originalPrice))) { + existing.originalPrice = unitPrice; + } } else { if (!allowZeroStock && currentStock < addQty) { Swal.fire(posT('Error', 'خطأ'), posT('Insufficient stock!', 'المخزون غير كافٍ!'), 'error'); @@ -8194,7 +8337,7 @@ runtime_debug_mark('page:rendering', ['page' => (string)$page]); }, updateQty(id, delta) { if (!this.items) return; - const item = this.items.find(i => i.id === id); + const item = this.items.find(i => Number(i.id) === Number(id)); if (item) { const allowZeroStock = (typeof companySettings !== 'undefined' && String(companySettings.allow_zero_stock_sell) === '1'); const currentStock = normalizeQuantity(item.stock_quantity); @@ -8209,6 +8352,82 @@ runtime_debug_mark('page:rendering', ['page' => (string)$page]); else this.render(); } }, + updatePrice(id, value) { + if (!this.items) return; + const item = this.items.find(i => Number(i.id) === Number(id)); + if (!item) return; + + const nextPrice = parseFloat(value); + const previousPrice = parseFloat(item.price) || 0; + if (!Number.isFinite(nextPrice) || nextPrice < 0) { + Swal.fire(posT('Invalid price', 'سعر غير صالح'), posT('Please enter a valid price.', 'يرجى إدخال سعر صالح.'), 'error'); + this.render(); + return; + } + + const lastSalePrice = parseFloat(item.lastSalePrice ?? item.last_sale_price ?? 0) || 0; + const originalPrice = parseFloat(item.originalPrice ?? item.original_price ?? previousPrice) || 0; + + if (lastSalePrice <= 0 && Math.abs(nextPrice - originalPrice) > 0.0005) { + Swal.fire( + posT('Price locked', 'السعر مقفل'), + posIsArabic + ? 'لا يمكن تغيير السعر لأن آخر سعر بيع / الحد الأدنى مضبوط على 0.000. يرجى تحديد حد أدنى للسعر أولاً.' + : 'Price changes are disabled because Last Sale Price / minimum sale price is 0.000. Set a minimum price limit first.', + 'error' + ); + item.price = originalPrice; + item.priceEdited = false; + this.render(); + return; + } + + if (lastSalePrice > 0 && nextPrice < (lastSalePrice - 0.0005)) { + Swal.fire( + posT('Price rejected', 'تم رفض السعر'), + posIsArabic + ? `لا يمكن أن يكون السعر أقل من آخر سعر بيع / الحد الأدنى (${lastSalePrice.toFixed(3)}).` + : `Price cannot be below Last Sale Price / minimum sale price (OMR ${lastSalePrice.toFixed(3)}).`, + 'error' + ); + item.price = previousPrice; + this.render(); + return; + } + + item.price = nextPrice; + item.priceEdited = Math.abs(nextPrice - originalPrice) > 0.0005; + this.render(); + }, + validatePriceEdits() { + if (!Array.isArray(this.items)) return true; + for (const item of this.items) { + const lastSalePrice = parseFloat(item.lastSalePrice ?? item.last_sale_price ?? 0) || 0; + const price = parseFloat(item.price) || 0; + const originalPrice = parseFloat(item.originalPrice ?? item.original_price ?? price) || 0; + if (lastSalePrice <= 0 && Math.abs(price - originalPrice) > 0.0005) { + Swal.fire( + posT('Price locked', 'السعر مقفل'), + posIsArabic + ? 'لا يمكن تغيير السعر لأن آخر سعر بيع / الحد الأدنى مضبوط على 0.000. يرجى تحديد حد أدنى للسعر أولاً.' + : 'Price changes are disabled because Last Sale Price / minimum sale price is 0.000. Set a minimum price limit first.', + 'error' + ); + return false; + } + if (lastSalePrice > 0 && price < (lastSalePrice - 0.0005)) { + Swal.fire( + posT('Price rejected', 'تم رفض السعر'), + posIsArabic + ? `لا يمكن أن يكون السعر أقل من آخر سعر بيع / الحد الأدنى (${lastSalePrice.toFixed(3)}).` + : `Price cannot be below Last Sale Price / minimum sale price (OMR ${lastSalePrice.toFixed(3)}).`, + 'error' + ); + return false; + } + } + return true; + }, clear() { this.items = []; this.discount = null; @@ -8466,6 +8685,14 @@ runtime_debug_mark('page:rendering', ['page' => (string)$page]); const price = parseFloat(item.price) || 0; const qty = normalizeQuantity(item.qty); const itemTotal = price * qty; + const lastSalePrice = parseFloat(item.lastSalePrice ?? item.last_sale_price ?? 0) || 0; + const priceLocked = lastSalePrice <= 0; + const priceHelp = priceLocked + ? `
${posT('Price locked until minimum is set', 'السعر مقفل حتى يتم تحديد الحد الأدنى')}
` + : `
${posT('Minimum price', 'الحد الأدنى')}: ${lastSalePrice.toFixed(3)}
`; + const priceLockAttr = priceLocked ? ' readonly' : ''; + const priceClass = priceLocked ? ' bg-light' : ''; + const priceMinAttr = priceLocked ? '0' : lastSalePrice.toFixed(3); subtotal += itemTotal; const vatRate = (item.vatRate !== undefined && item.vatRate !== null) ? item.vatRate : 0; const itemVat = itemTotal * (vatRate / (100 + vatRate)); @@ -8480,7 +8707,11 @@ runtime_debug_mark('page:rendering', ['page' => (string)$page]);
${nameHtml}
-
${price.toFixed(3)} ${posT('VAT', 'الضريبة')} ${parseFloat(vatRate || 0).toFixed(2)}%
+
+ + ${priceHelp} +
+
${posT('VAT', 'الضريبة')} ${parseFloat(vatRate || 0).toFixed(2)}%
@@ -8534,6 +8765,7 @@ runtime_debug_mark('page:rendering', ['page' => (string)$page]); }, async checkout() { if (this.items.length === 0) return; + if (!this.validatePriceEdits()) return; const customerSelect = document.getElementById('posCustomer'); const customerName = customerSelect.options[customerSelect.selectedIndex].text; @@ -8676,6 +8908,7 @@ runtime_debug_mark('page:rendering', ['page' => (string)$page]); Swal.fire(, , 'error'); return; } + if (!this.validatePriceEdits()) return; // If there's an amount in the input and payments are not enough, add it const remainingBefore = this.getRemaining(); @@ -8949,6 +9182,7 @@ runtime_debug_mark('page:rendering', ['page' => (string)$page]); nameEn: card.dataset.nameEn, nameAr: card.dataset.nameAr, price: parseFloat(card.dataset.price), + lastSalePrice: parseFloat(card.dataset.lastSalePrice) || 0, purchasePrice: parseFloat(card.dataset.purchasePrice) || 0, sku: card.dataset.sku, stock_quantity: parseFloat(card.dataset.stockQuantity), @@ -12036,8 +12270,9 @@ runtime_debug_mark('page:rendering', ['page' => (string)$page]);
-
-
+
+
Admin-only minimum selling price; POS and sales cannot sell below this value.
+
diff --git a/pages/sales_purchases_invoice_actions_script.php b/pages/sales_purchases_invoice_actions_script.php index 37f21bd..b7607b0 100644 --- a/pages/sales_purchases_invoice_actions_script.php +++ b/pages/sales_purchases_invoice_actions_script.php @@ -42,11 +42,13 @@ sku: item.sku || '', vat_rate: item.vat_rate || 0, purchase_price: item.purchase_price || 0, + last_sale_price: item.last_sale_price || 0, stock_quantity: item.stock_quantity || 0 }, tableBody, null, null, grandTotalEl, subtotalEl, totalVatEl, { quantity: item.quantity, unit_price: item.unit_price, - purchase_price: item.purchase_price || 0 + purchase_price: item.purchase_price || 0, + last_sale_price: item.last_sale_price || 0 }); }); }; diff --git a/pages/sales_purchases_invoice_form_helpers.php b/pages/sales_purchases_invoice_form_helpers.php index 32a33fe..b20f2a9 100644 --- a/pages/sales_purchases_invoice_form_helpers.php +++ b/pages/sales_purchases_invoice_form_helpers.php @@ -254,6 +254,18 @@ ${text}` : title); const purchasePrice = customData && customData.purchase_price !== undefined && customData.purchase_price !== null ? customData.purchase_price : (item.purchase_price || 0); + const lastSalePrice = parseFloat((customData && customData.last_sale_price !== undefined ? customData.last_sale_price : (item.last_sale_price ?? item.lastSalePrice ?? 0)) || 0) || 0; + const defaultSalePrice = parseFloat(item.sale_price || price || 0) || 0; + const rowUnitPrice = parseFloat(price || 0) || 0; + const priceMaxAttr = ''; + const priceLocked = invoiceType === 'sale' && lastSalePrice <= 0; + const priceReadonlyAttr = priceLocked ? ' readonly' : ''; + const priceReadonlyClass = priceLocked ? ' bg-light' : ''; + const priceHelp = invoiceType === 'sale' + ? (priceLocked + ? `
Price locked until Last Sale Price / minimum is set.
` + : `
Minimum price: ${lastSalePrice.toFixed(3)}
`) + : ''; row.innerHTML = ` @@ -261,11 +273,12 @@ ${text}` : title); +
${item.name_en}
${item.name_ar} (${item.sku})
- + ${priceHelp} @@ -311,6 +324,51 @@ ${text}` : title); if (grandTotalEl) grandTotalEl.textContent = 'OMR ' + grandTotal.toFixed(3); } + function showInvoicePriceLimitError(title, message) { + if (window.Swal) { + Swal.fire(title, message, 'error'); + return; + } + alert(message); + } + + function enforceInvoicePriceLimit(row, input) { + if (invoiceType !== 'sale' || !input) return true; + const lastSalePrice = parseFloat(input.dataset.lastSalePrice || row.querySelector('.item-last-sale-price')?.value || 0) || 0; + const nextPrice = parseFloat(input.value); + if (!Number.isFinite(nextPrice) || nextPrice < 0) return true; + + const defaultPrice = parseFloat(input.dataset.defaultPrice || 0) || 0; + const lockedPrice = parseFloat(input.dataset.lockedPrice || input.dataset.previousPrice || defaultPrice || 0) || 0; + + if (lastSalePrice <= 0) { + if (Math.abs(nextPrice - lockedPrice) <= 0.0005) { + input.dataset.previousPrice = nextPrice.toFixed(3); + return true; + } + + showInvoicePriceLimitError( + 'Price locked', + 'Price changes are disabled because Last Sale Price / minimum sale price is 0.000. Set a minimum price limit first.' + ); + input.value = lockedPrice.toFixed(3); + return false; + } + + if (nextPrice >= (lastSalePrice - 0.0005)) { + input.dataset.previousPrice = nextPrice.toFixed(3); + return true; + } + + showInvoicePriceLimitError( + 'Price rejected', + `Price cannot be below Last Sale Price / minimum sale price (${lastSalePrice.toFixed(3)}).` + ); + const previousPrice = parseFloat(input.dataset.previousPrice || defaultPrice || lastSalePrice || 0) || 0; + input.value = Math.max(previousPrice, lastSalePrice).toFixed(3); + return false; + } + function attachRowListeners(row, tableBody, grandTotalEl, subtotalEl, totalVatEl) { row.querySelector('.item-qty').addEventListener('input', function() { const allowZeroStock = (typeof companySettings !== 'undefined' && String(companySettings.allow_zero_stock_sell) === '1'); @@ -324,7 +382,12 @@ ${text}` : title); } recalculate(tableBody, grandTotalEl, subtotalEl, totalVatEl); }); - row.querySelector('.item-price').addEventListener('input', () => recalculate(tableBody, grandTotalEl, subtotalEl, totalVatEl)); + const priceInput = row.querySelector('.item-price'); + priceInput.addEventListener('input', () => recalculate(tableBody, grandTotalEl, subtotalEl, totalVatEl)); + priceInput.addEventListener('change', function() { + enforceInvoicePriceLimit(row, this); + recalculate(tableBody, grandTotalEl, subtotalEl, totalVatEl); + }); row.querySelector('.remove-row').addEventListener('click', function() { row.remove(); recalculate(tableBody, grandTotalEl, subtotalEl, totalVatEl); diff --git a/pages/sales_purchases_logic.php b/pages/sales_purchases_logic.php index d138091..a794e48 100644 --- a/pages/sales_purchases_logic.php +++ b/pages/sales_purchases_logic.php @@ -130,7 +130,7 @@ $inv['total_in_words'] = numberToWordsOMR($inv['total_with_vat']); if ($type === 'sale') { - $item_stmt = db()->prepare("SELECT ii.*, i.name_en, i.name_ar, i.sku, i.vat_rate, i.stock_quantity FROM invoice_items ii LEFT JOIN stock_items i ON ii.item_id = i.id WHERE ii.invoice_id = ?"); + $item_stmt = db()->prepare("SELECT ii.*, i.name_en, i.name_ar, i.sku, i.vat_rate, i.stock_quantity, i.last_sale_price FROM invoice_items ii LEFT JOIN stock_items i ON ii.item_id = i.id WHERE ii.invoice_id = ?"); $item_stmt->execute([$inv['id']]); $inv['items'] = $item_stmt->fetchAll(PDO::FETCH_ASSOC); } else { @@ -141,7 +141,7 @@ } unset($inv); - $oid = current_outlet_id(); $items_list_raw = db()->query("SELECT i.id, i.name_en, i.name_ar, i.sale_price, i.purchase_price, i.stock_quantity, i.vat_rate, i.is_promotion, i.promotion_start, i.promotion_end, i.promotion_percent FROM stock_items i ORDER BY i.name_en ASC")->fetchAll(PDO::FETCH_ASSOC); + $oid = current_outlet_id(); $items_list_raw = db()->query("SELECT i.id, i.name_en, i.name_ar, i.sale_price, i.last_sale_price, i.purchase_price, i.stock_quantity, i.vat_rate, i.is_promotion, i.promotion_start, i.promotion_end, i.promotion_percent FROM stock_items i ORDER BY i.name_en ASC")->fetchAll(PDO::FETCH_ASSOC); foreach ($items_list_raw as &$item) { $item['sale_price'] = getPromotionalPrice($item); } diff --git a/pages/sales_purchases_save_logic.php b/pages/sales_purchases_save_logic.php index 6b84d07..9469985 100644 --- a/pages/sales_purchases_save_logic.php +++ b/pages/sales_purchases_save_logic.php @@ -34,6 +34,9 @@ if (!$item_id) continue; $qty = normalize_quantity($qtys[$i] ?? 0); $price = (float)$prices[$i]; + if ($type === 'sale') { + assertEditedSalePriceWithinLastSalePrice($item_id, $price); + } $subtotal = $qty * $price; $stmtVat = $db->prepare("SELECT vat_rate FROM stock_items WHERE id = ?"); @@ -89,6 +92,9 @@ if (!$item_id) continue; $qty = normalize_quantity($qtys[$i] ?? 0); $price = (float)$prices[$i]; + if ($type === 'sale') { + assertEditedSalePriceWithinLastSalePrice($item_id, $price); + } $subtotal = $qty * $price; $stmtVat = $db->prepare("SELECT vat_rate FROM stock_items WHERE id = ?"); @@ -160,6 +166,16 @@ if (!$item_id) continue; $qty = normalize_quantity($qtys[$i] ?? 0); $price = (float)$prices[$i]; + if ($type === 'sale') { + $existingLockedPrice = null; + $existingPriceStmt = $db->prepare("SELECT unit_price FROM $item_table WHERE $fk_col = ? AND item_id = ? LIMIT 1"); + $existingPriceStmt->execute([$id, $item_id]); + $existingStoredPrice = $existingPriceStmt->fetchColumn(); + if ($existingStoredPrice !== false) { + $existingLockedPrice = (float)$existingStoredPrice; + } + assertEditedSalePriceWithinLastSalePrice($item_id, $price, $existingLockedPrice); + } $subtotal = $qty * $price; $stmtVat = $db->prepare("SELECT vat_rate FROM stock_items WHERE id = ?");