Autosave: 20260512-103252

This commit is contained in:
Flatlogic Bot 2026-05-12 10:32:52 +00:00
parent 14ab1bb84c
commit e2a41f3ed4
9 changed files with 345 additions and 24 deletions

View File

@ -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,

View File

@ -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,

View File

@ -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;

View File

@ -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,

273
index.php
View File

@ -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
?>
<div class="product-card" data-id="<?= $p['id'] ?>" data-name-en="<?= htmlspecialchars($p['name_en']) ?>" data-name-ar="<?= htmlspecialchars($p['name_ar']) ?>" data-price="<?= $p['sale_price'] ?>" data-purchase-price="<?= (float)$p['purchase_price'] ?>" data-sku="<?= htmlspecialchars($p['sku']) ?>" data-stock-quantity="<?= format_quantity($p['stock_quantity']) ?>" data-vat-rate="<?= $p['vat_rate'] ?>">
<div class="product-card" data-id="<?= $p['id'] ?>" data-name-en="<?= htmlspecialchars($p['name_en']) ?>" data-name-ar="<?= htmlspecialchars($p['name_ar']) ?>" data-price="<?= $p['sale_price'] ?>" data-last-sale-price="<?= number_format((float)($p['last_sale_price'] ?? 0), 3, '.', '') ?>" data-purchase-price="<?= (float)$p['purchase_price'] ?>" data-sku="<?= htmlspecialchars($p['sku']) ?>" data-stock-quantity="<?= format_quantity($p['stock_quantity']) ?>" data-vat-rate="<?= $p['vat_rate'] ?>">
<?php if ($p['image_path']): ?>
<img src="<?= htmlspecialchars($p['image_path']) ?>" alt="<?= htmlspecialchars($p['name_en']) ?>">
<?php else: ?>
@ -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]);
<th data-en="Category" data-ar="الفئة">Category</th>
<th data-en="Supplier" data-ar="المورد">Supplier</th>
<th data-en="Sale Price" data-ar="سعر البيع">Sale Price</th>
<th data-en="Last Sale Price (Minimum)" data-ar="آخر سعر بيع (حد أدنى)">Last Sale Price (Minimum)</th>
<th data-en="Stock Level" data-ar="المخزون">Stock Level</th>
<th data-en="Expiry" data-ar="تاريخ الانتهاء">Expiry</th>
<th data-en="VAT" data-ar="الضريبة">VAT</th>
@ -7472,6 +7599,9 @@ runtime_debug_mark('page:rendering', ['page' => (string)$page]);
<div class="small text-muted" data-en="Incl. VAT: <?= number_format((float)$itemBarcodePrice, 3) ?>" data-ar="شامل الضريبة: <?= number_format((float)$itemBarcodePrice, 3) ?>">Incl. VAT: <?= number_format((float)$itemBarcodePrice, 3) ?></div>
</div>
</td>
<td class="text-end">
<strong><?= number_format((float)($item['last_sale_price'] ?? 0), 3) ?></strong>
</td>
<td>
<div class="text-end">
<strong><?= format_quantity($item['stock_quantity']) ?></strong>
@ -7526,6 +7656,7 @@ runtime_debug_mark('page:rendering', ['page' => (string)$page]);
<tr><th class="text-muted" data-en="Category" data-ar="الفئة">Category</th><td><?= htmlspecialchars($item['cat_en'] ?? '---') ?></td></tr>
<tr><th class="text-muted" data-en="Supplier" data-ar="المورد">Supplier</th><td><?= htmlspecialchars($item['supplier_name'] ?? '---') ?></td></tr>
<tr><th class="text-muted">Sale Price</th><td>OMR <?= number_format((float)$item['sale_price'], 3) ?></td></tr>
<tr><th class="text-muted">Last Sale Price (Minimum)</th><td>OMR <?= number_format((float)($item['last_sale_price'] ?? 0), 3) ?></td></tr>
<tr><th class="text-muted">Stock Level</th><td><?= format_quantity($item['stock_quantity']) ?></td></tr>
<tr><th class="text-muted">VAT Rate</th><td><?= number_format((float)$item['vat_rate'], 2) ?>%</td></tr>
</table>
@ -7559,8 +7690,9 @@ runtime_debug_mark('page:rendering', ['page' => (string)$page]);
<div class="col-md-4"><label class="form-label" data-en="Category" data-ar="الفئة">Category</label><select name="category_id" class="form-select"><option value="" data-en="---" data-ar="---">---</option><?php foreach ($data['categories'] ?? [] as $c): ?><option value="<?= $c['id'] ?>" <?= $c['id'] == $item['category_id'] ? 'selected' : '' ?> data-en="<?= htmlspecialchars(localized_option_label($c, 'en')) ?>" data-ar="<?= htmlspecialchars(localized_option_label($c, 'ar')) ?>"><?= htmlspecialchars(localized_option_label($c)) ?></option><?php endforeach; ?></select></div>
<div class="col-md-4"><label class="form-label" data-en="Unit" data-ar="الوحدة">Unit</label><select name="unit_id" class="form-select"><option value="" data-en="---" data-ar="---">---</option><?php foreach ($data['units'] ?? [] as $u): ?><option value="<?= $u['id'] ?>" <?= $u['id'] == $item['unit_id'] ? 'selected' : '' ?> data-en="<?= htmlspecialchars(localized_option_label($u, 'en')) ?>" data-ar="<?= htmlspecialchars(localized_option_label($u, 'ar')) ?>"><?= htmlspecialchars(localized_option_label($u)) ?></option><?php endforeach; ?></select></div>
<div class="col-md-4"><label class="form-label" data-en="Supplier" data-ar="المورد">Supplier</label><select name="supplier_id" class="form-select"><option value="">---</option><?php foreach ($data['suppliers'] ?? [] as $s): ?><option value="<?= $s['id'] ?>" <?= $s['id'] == $item['supplier_id'] ? 'selected' : '' ?>><?= htmlspecialchars($s['name']) ?></option><?php endforeach; ?></select></div>
<div class="col-md-4"><label class="form-label" data-en="Sale Price" data-ar="سعر البيع">Sale Price</label><input type="number" step="0.001" name="sale_price" class="form-control" value="<?= (float)$item['sale_price'] ?>"></div>
<div class="col-md-4"><label class="form-label" data-en="Purchase Price" data-ar="سعر الشراء">Purchase Price</label><input type="number" step="0.001" name="purchase_price" class="form-control" value="<?= (float)$item['purchase_price'] ?>"></div>
<div class="col-md-4"><label class="form-label" data-en="Sale Price" data-ar="سعر البيع">Sale Price</label><input type="number" step="0.001" min="0" name="sale_price" class="form-control" value="<?= (float)$item['sale_price'] ?>"></div>
<div class="col-md-4"><label class="form-label" data-en="Last Sale Price (Minimum)" data-ar="آخر سعر بيع (حد أدنى)">Last Sale Price (Minimum)</label><input type="number" step="0.001" min="0" name="last_sale_price" class="form-control" value="<?= number_format((float)($item['last_sale_price'] ?? 0), 3, '.', '') ?>"><div class="form-text" data-en="Admin-only minimum selling price; POS and sales cannot sell below this value." data-ar="حد أدنى للبيع يحدده المدير؛ لا يمكن البيع في نقاط البيع أو المبيعات بأقل منه.">Admin-only minimum selling price; POS and sales cannot sell below this value.</div></div>
<div class="col-md-4"><label class="form-label" data-en="Purchase Price" data-ar="سعر الشراء">Purchase Price</label><input type="number" step="0.001" min="0" name="purchase_price" class="form-control" value="<?= (float)$item['purchase_price'] ?>"></div>
<div class="col-md-4"><label class="form-label" data-en="Stock Qty" data-ar="كمية المخزون">Stock Qty</label><input type="number" step="<?= quantity_step() ?>" name="stock_quantity" class="form-control" value="<?= format_quantity($item['stock_quantity']) ?>"></div>
<div class="col-md-4"><label class="form-label" data-en="Min Stock Level" data-ar="الحد الأدنى للمخزون">Min Stock Level</label><input type="number" step="<?= quantity_step() ?>" name="min_stock_level" class="form-control" value="<?= format_quantity($item['min_stock_level']) ?>"></div>
<div class="col-md-4"><label class="form-label" data-en="VAT Rate (%)" data-ar="ضريبة القيمة المضافة (%)">VAT Rate (%)</label><input type="number" step="0.01" name="vat_rate" class="form-control" value="<?= number_format((float)$item['vat_rate'], 2, '.', '') ?>"></div>
@ -7838,7 +7970,7 @@ runtime_debug_mark('page:rendering', ['page' => (string)$page]);
</div>
<div class="product-grid" id="productGrid">
<?php foreach ($products as $p): ?>
<div class="product-card" data-id="<?= $p['id'] ?>" data-name-en="<?= htmlspecialchars($p['name_en']) ?>" data-name-ar="<?= htmlspecialchars($p['name_ar']) ?>" data-price="<?= $p['sale_price'] ?>" data-purchase-price="<?= (float)$p['purchase_price'] ?>" data-sku="<?= htmlspecialchars($p['sku']) ?>" data-stock-quantity="<?= format_quantity($p['stock_quantity']) ?>" data-vat-rate="<?= $p['vat_rate'] ?>">
<div class="product-card" data-id="<?= $p['id'] ?>" data-name-en="<?= htmlspecialchars($p['name_en']) ?>" data-name-ar="<?= htmlspecialchars($p['name_ar']) ?>" data-price="<?= $p['sale_price'] ?>" data-last-sale-price="<?= number_format((float)($p['last_sale_price'] ?? 0), 3, '.', '') ?>" data-purchase-price="<?= (float)$p['purchase_price'] ?>" data-sku="<?= htmlspecialchars($p['sku']) ?>" data-stock-quantity="<?= format_quantity($p['stock_quantity']) ?>" data-vat-rate="<?= $p['vat_rate'] ?>">
<?php if ($p['image_path']): ?>
<img src="<?= htmlspecialchars($p['image_path']) ?>" alt="<?= htmlspecialchars($p['name_en']) ?>">
<?php else: ?>
@ -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
? `<div class="form-text smaller text-muted mt-1">${posT('Price locked until minimum is set', 'السعر مقفل حتى يتم تحديد الحد الأدنى')}</div>`
: `<div class="form-text smaller text-muted mt-1">${posT('Minimum price', 'الحد الأدنى')}: ${lastSalePrice.toFixed(3)}</div>`;
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]);
<div class="cart-item">
<div class="flex-grow-1">
<div class="small">${nameHtml}</div>
<div class="text-muted smaller"><?= __('currency') ?> ${price.toFixed(3)} <span class="badge bg-light text-dark smaller">${posT('VAT', 'الضريبة')} ${parseFloat(vatRate || 0).toFixed(2)}%</span></div>
<div class="mt-1" style="max-width: 220px;">
<input type="number" step="0.001" min="${priceMinAttr}" class="form-control form-control-sm text-end pos-cart-price-input${priceClass}" value="${price.toFixed(3)}" onchange="cart.updatePrice(${item.id}, this.value)" aria-label="${posT('Unit price', 'سعر الوحدة')}"${priceLockAttr}>
${priceHelp}
</div>
<div class="text-muted smaller mt-1"><span class="badge bg-light text-dark smaller">${posT('VAT', 'الضريبة')} ${parseFloat(vatRate || 0).toFixed(2)}%</span></div>
</div>
<div class="qty-controls mx-3">
<button class="qty-btn" onclick="cart.updateQty(${item.id}, -1)">-</button>
@ -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]);
<div class="col-md-4"><label class="form-label" data-en="Category" data-ar="الفئة">Category</label><select name="category_id" class="form-select"><option value="" data-en="---" data-ar="---">---</option><?php foreach ($data['categories'] ?? [] as $c): ?><option value="<?= $c['id'] ?>" data-en="<?= htmlspecialchars(localized_option_label($c, 'en')) ?>" data-ar="<?= htmlspecialchars(localized_option_label($c, 'ar')) ?>"><?= htmlspecialchars(localized_option_label($c)) ?></option><?php endforeach; ?></select></div>
<div class="col-md-4"><label class="form-label" data-en="Unit" data-ar="الوحدة">Unit</label><select name="unit_id" class="form-select"><option value="" data-en="---" data-ar="---">---</option><?php foreach ($data['units'] ?? [] as $u): ?><option value="<?= $u['id'] ?>" data-en="<?= htmlspecialchars(localized_option_label($u, 'en')) ?>" data-ar="<?= htmlspecialchars(localized_option_label($u, 'ar')) ?>"><?= htmlspecialchars(localized_option_label($u)) ?></option><?php endforeach; ?></select></div>
<div class="col-md-4"><label class="form-label" data-en="Supplier" data-ar="المورد">Supplier</label><select name="supplier_id" class="form-select"><option value="">---</option><?php foreach ($data['suppliers'] ?? [] as $s): ?><option value="<?= $s['id'] ?>"><?= htmlspecialchars($s['name']) ?></option><?php endforeach; ?></select></div>
<div class="col-md-4"><label class="form-label" data-en="Sale Price" data-ar="سعر البيع">Sale Price</label><input type="number" step="0.001" name="sale_price" class="form-control" value="0.000"></div>
<div class="col-md-4"><label class="form-label" data-en="Purchase Price" data-ar="سعر الشراء">Purchase Price</label><input type="number" step="0.001" name="purchase_price" class="form-control" value="0.000"></div>
<div class="col-md-4"><label class="form-label" data-en="Sale Price" data-ar="سعر البيع">Sale Price</label><input type="number" step="0.001" min="0" name="sale_price" class="form-control" value="0.000"></div>
<div class="col-md-4"><label class="form-label" data-en="Last Sale Price (Minimum)" data-ar="آخر سعر بيع (حد أدنى)">Last Sale Price (Minimum)</label><input type="number" step="0.001" min="0" name="last_sale_price" class="form-control" value="0.000"><div class="form-text" data-en="Admin-only minimum selling price; POS and sales cannot sell below this value." data-ar="حد أدنى للبيع يحدده المدير؛ لا يمكن البيع في نقاط البيع أو المبيعات بأقل منه.">Admin-only minimum selling price; POS and sales cannot sell below this value.</div></div>
<div class="col-md-4"><label class="form-label" data-en="Purchase Price" data-ar="سعر الشراء">Purchase Price</label><input type="number" step="0.001" min="0" name="purchase_price" class="form-control" value="0.000"></div>
<div class="col-md-4"><label class="form-label" data-en="Initial Stock" data-ar="المخزون الحالي">Initial Stock</label><input type="number" step="<?= quantity_step() ?>" name="stock_quantity" class="form-control" value="0.00"></div>
<div class="col-md-4"><label class="form-label" data-en="Min Stock Level" data-ar="الحد الأدنى للمخزون">Min Stock Level</label><input type="number" step="<?= quantity_step() ?>" name="min_stock_level" class="form-control" value="0.00"></div>
<div class="col-md-4"><label class="form-label" data-en="VAT Rate (%)" data-ar="ضريبة القيمة المضافة (%)">VAT Rate (%)</label><input type="number" step="0.01" name="vat_rate" class="form-control" value="0"></div>

View File

@ -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
});
});
};

View File

@ -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
? `<div class="form-text small text-muted">Price locked until Last Sale Price / minimum is set.</div>`
: `<div class="form-text small text-muted">Minimum price: ${lastSalePrice.toFixed(3)}</div>`)
: '';
row.innerHTML = `
<td>
@ -261,11 +273,12 @@ ${text}` : title);
<input type="hidden" class="item-row-stock" value="${formatQuantity(item.stock_quantity)}">
<input type="hidden" class="item-vat-rate" value="${vatRate}">
<input type="hidden" class="item-purchase-price" value="${parseFloat(purchasePrice || 0).toFixed(3)}">
<input type="hidden" class="item-last-sale-price" value="${lastSalePrice.toFixed(3)}">
<div><strong>${item.name_en}</strong></div>
<div class="small text-muted">${item.name_ar} (${item.sku})</div>
</td>
<td><input type="number" step="0.01" name="quantities[]" class="form-control item-qty" value="${formatQuantity(qty)}" required></td>
<td><input type="number" step="0.001" name="prices[]" class="form-control item-price" value="${price}" required></td>
<td><input type="number" step="0.001" min="0"${priceMaxAttr} name="prices[]" class="form-control item-price${priceReadonlyClass}" value="${price}" data-default-price="${defaultSalePrice.toFixed(3)}" data-locked-price="${rowUnitPrice.toFixed(3)}" data-last-sale-price="${lastSalePrice.toFixed(3)}" data-previous-price="${rowUnitPrice.toFixed(3)}"${priceReadonlyAttr} required>${priceHelp}</td>
<td><input type="text" class="form-control bg-light" value="${parseFloat(vatRate || 0).toFixed(2)}%" readonly></td>
<td><input type="number" step="0.001" class="form-control item-total" value="${(qty * price).toFixed(3)}" readonly></td>
<td><button type="button" class="btn btn-outline-danger btn-sm remove-row"><i class="bi bi-trash"></i></button></td>
@ -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);

View File

@ -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);
}

View File

@ -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 = ?");