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']) ?>](<?= htmlspecialchars($p['image_path']) ?>)
@@ -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: = number_format((float)$itemBarcodePrice, 3) ?>
+
+ = number_format((float)($item['last_sale_price'] ?? 0), 3) ?>
+ |
= format_quantity($item['stock_quantity']) ?>
@@ -7526,6 +7656,7 @@ runtime_debug_mark('page:rendering', ['page' => (string)$page]);
| Category | = htmlspecialchars($item['cat_en'] ?? '---') ?> |
| Supplier | = htmlspecialchars($item['supplier_name'] ?? '---') ?> |
| Sale Price | OMR = number_format((float)$item['sale_price'], 3) ?> |
|---|
+ | Last Sale Price (Minimum) | OMR = number_format((float)($item['last_sale_price'] ?? 0), 3) ?> |
| Stock Level | = format_quantity($item['stock_quantity']) ?> |
| VAT Rate | = number_format((float)$item['vat_rate'], 2) ?>% |
|---|
@@ -7559,8 +7690,9 @@ runtime_debug_mark('page:rendering', ['page' => (string)$page]);
-
-
+
+
+
@@ -7838,7 +7970,7 @@ runtime_debug_mark('page:rendering', ['page' => (string)$page]);
-
+
![<?= htmlspecialchars($p['name_en']) ?>](<?= htmlspecialchars($p['image_path']) ?>)
@@ -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}
- = __('currency') ?> ${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(= json_encode($lang === 'ar' ? 'خطأ' : 'Error', JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>, = json_encode($lang === 'ar' ? 'السلة فارغة' : 'Cart is empty', JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>, '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]);
-
-
+
+
+
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 = ?");
|