diff --git a/edit_sale.php b/edit_sale.php
index 0155786..c82a02b 100644
--- a/edit_sale.php
+++ b/edit_sale.php
@@ -9,6 +9,9 @@ if ($editSaleId > 0) {
$stmt->execute([':id' => $editSaleId]);
$editSale = $stmt->fetch();
}
+if ($editSale) {
+ $editSale['items'] = json_decode((string) ($editSale['items_json'] ?? '[]'), true) ?: [];
+}
if (!$editSale) {
die(tr('الفاتورة غير موجودة.', 'Invoice not found.'));
}
@@ -96,42 +99,62 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if ($error === '') {
$cashierName = current_lang() === 'ar' ? $user['name_ar'] : $user['name_en'];
- $stmt = db()->prepare('UPDATE sales_orders SET
- branch_code = :branch_code,
- customer_id = :customer_id,
- customer_name = :customer_name,
- payment_method = :payment_method,
- payment_status = :payment_status,
- paid_amount = :paid_amount,
- due_amount = :due_amount,
- items_json = :items_json,
- item_count = :item_count,
- subtotal = :subtotal,
- vat_amount = :vat_amount,
- total_amount = :total_amount,
- status = :status,
- notes = :notes
- WHERE id = :id');
- $stmt->execute([
- ':branch_code' => $branchCode,
- ':customer_id' => $customerId,
- ':customer_name' => $customerName !== '' ? $customerName : null,
- ':payment_method' => $paymentMethod,
- ':payment_status' => $paymentMeta['payment_status'],
- ':paid_amount' => $paymentMeta['paid_amount'],
- ':due_amount' => $paymentMeta['due_amount'],
- ':items_json' => json_encode($normalized, JSON_UNESCAPED_UNICODE),
- ':item_count' => $itemCount,
- ':subtotal' => $subtotal,
- ':vat_amount' => $totalVat,
- ':total_amount' => $totalAmount,
- ':status' => $saleStatus,
- ':notes' => $notes !== '' ? $notes : null,
- ':id' => $editSaleId,
- ]);
- set_flash('success', tr('تم تحديث الفاتورة بنجاح.', 'Invoice updated successfully.'));
- redirect_to('sale.php', ['id' => $editSaleId]);
+ db()->beginTransaction();
+ try {
+ sync_order_stock_reservation(
+ $editSale['items'] ?? [],
+ (string) ($editSale['status'] ?? 'completed'),
+ $normalized,
+ $saleStatus
+ );
+
+ $stmt = db()->prepare('UPDATE sales_orders SET
+ branch_code = :branch_code,
+ customer_id = :customer_id,
+ customer_name = :customer_name,
+ payment_method = :payment_method,
+ payment_status = :payment_status,
+ paid_amount = :paid_amount,
+ due_amount = :due_amount,
+ items_json = :items_json,
+ item_count = :item_count,
+ subtotal = :subtotal,
+ vat_amount = :vat_amount,
+ total_amount = :total_amount,
+ status = :status,
+ notes = :notes
+ WHERE id = :id');
+ $stmt->execute([
+ ':branch_code' => $branchCode,
+ ':customer_id' => $customerId,
+ ':customer_name' => $customerName !== '' ? $customerName : null,
+ ':payment_method' => $paymentMethod,
+ ':payment_status' => $paymentMeta['payment_status'],
+ ':paid_amount' => $paymentMeta['paid_amount'],
+ ':due_amount' => $paymentMeta['due_amount'],
+ ':items_json' => json_encode($normalized, JSON_UNESCAPED_UNICODE),
+ ':item_count' => $itemCount,
+ ':subtotal' => $subtotal,
+ ':vat_amount' => $totalVat,
+ ':total_amount' => $totalAmount,
+ ':status' => $saleStatus,
+ ':notes' => $notes !== '' ? $notes : null,
+ ':id' => $editSaleId,
+ ]);
+
+ db()->commit();
+ } catch (Throwable $e) {
+ if (db()->inTransaction()) {
+ db()->rollBack();
+ }
+ $error = tr('تعذر تحديث الفاتورة.', 'Could not update the invoice.');
+ }
+
+ if ($error === '') {
+ set_flash('success', tr('تم تحديث الفاتورة بنجاح.', 'Invoice updated successfully.'));
+ redirect_to('sale.php', ['id' => $editSaleId]);
+ }
}
}
}
diff --git a/includes/app.php b/includes/app.php
index 569693c..275a9ce 100644
--- a/includes/app.php
+++ b/includes/app.php
@@ -60,6 +60,35 @@ try {
('wablas_daily_auto_last_date', '')");
@file_put_contents($flagFileV5, '1');
}
+
+ $flagFileV6 = sys_get_temp_dir() . '/.schema_migrated_v6_' . md5(__DIR__);
+ if (!file_exists($flagFileV6)) {
+ $pdo = db();
+ $orderItemsStmt = $pdo->query("SELECT items_json FROM sales_orders WHERE status = 'order'");
+ $reservedBySku = [];
+ foreach ($orderItemsStmt->fetchAll(PDO::FETCH_ASSOC) as $orderRow) {
+ $orderItems = json_decode((string) ($orderRow['items_json'] ?? '[]'), true) ?: [];
+ foreach ($orderItems as $item) {
+ $sku = (string) ($item['sku'] ?? '');
+ $qty = (int) ($item['qty'] ?? 0);
+ if ($sku === '' || $qty <= 0) {
+ continue;
+ }
+ $reservedBySku[$sku] = ($reservedBySku[$sku] ?? 0) + $qty;
+ }
+ }
+
+ if ($reservedBySku !== []) {
+ $adjustStmt = $pdo->prepare("UPDATE items SET base_stock = base_stock - :qty WHERE sku = :sku");
+ foreach ($reservedBySku as $sku => $qty) {
+ $adjustStmt->bindValue(':qty', $qty, PDO::PARAM_INT);
+ $adjustStmt->bindValue(':sku', $sku);
+ $adjustStmt->execute();
+ }
+ }
+
+ @file_put_contents($flagFileV6, '1');
+ }
} catch (\Throwable $e) {}
@@ -349,7 +378,7 @@ function require_auth(): array
return $user;
}
-function get_app_modules(): array { return ["pos" => ["name_ar" => "نقاط البيع", "name_en" => "POS", "actions" => ["show", "add"]], "normal_sale" => ["name_ar" => "بيع عادي", "name_en" => "Normal Sale", "actions" => ["show", "add"]], "sales" => ["name_ar" => "المبيعات", "name_en" => "Sales", "actions" => ["show", "edit", "del"]], "purchases" => ["name_ar" => "المشتريات", "name_en" => "Purchases", "actions" => ["show", "add", "edit", "del"]], "stock" => ["name_ar" => "المخزون", "name_en" => "Stock", "actions" => ["show", "add", "edit", "del"]], "reports" => ["name_ar" => "التقارير", "name_en" => "Reports", "actions" => ["show"]], "customers" => ["name_ar" => "العملاء", "name_en" => "Customers", "actions" => ["show", "add", "edit", "del"]], "suppliers" => ["name_ar" => "الموردين", "name_en" => "Suppliers", "actions" => ["show", "add", "edit", "del"]], "categories" => ["name_ar" => "التصنيفات", "name_en" => "Categories", "actions" => ["show", "add", "edit", "del"]], "units" => ["name_ar" => "الوحدات", "name_en" => "Units", "actions" => ["show", "add", "edit", "del"]], "users" => ["name_ar" => "المستخدمين", "name_en" => "Users", "actions" => ["show", "add", "edit", "del"]], "settings" => ["name_ar" => "الإعدادات", "name_en" => "Settings", "actions" => ["show", "edit"]], "expense_categories" => ["name_ar" => "تصنيفات المصروفات", "name_en" => "Expense Categories", "actions" => ["show", "add", "edit", "del"]], "expenses" => ["name_ar" => "المصروفات", "name_en" => "Expenses", "actions" => ["show", "add", "edit", "del"]]]; } function has_permission(string $m, string $a = "show"): bool { $u = current_user(); if (!$u) return false; if ($u["role"] === "owner") return true; $p = !empty($u["permissions"]) ? (is_array($u["permissions"]) ? $u["permissions"] : json_decode($u["permissions"], true)) : []; return !empty($p[$m][$a]); } function require_permission(string $m, string $a = "show"): array { $u = require_auth(); if (!has_permission($m, $a)) { set_flash("warning", tr("ليس لديك صلاحية.", "You do not have permission.")); redirect_to("index.php"); } return $u; }
+function get_app_modules(): array { return ["pos" => ["name_ar" => "نقاط البيع", "name_en" => "POS", "actions" => ["show", "add"]], "normal_sale" => ["name_ar" => "فاتورة", "name_en" => "Invoice", "actions" => ["show", "add"]], "sales" => ["name_ar" => "المبيعات", "name_en" => "Sales", "actions" => ["show", "edit", "del"]], "purchases" => ["name_ar" => "المشتريات", "name_en" => "Purchases", "actions" => ["show", "add", "edit", "del"]], "stock" => ["name_ar" => "المخزون", "name_en" => "Stock", "actions" => ["show", "add", "edit", "del"]], "reports" => ["name_ar" => "التقارير", "name_en" => "Reports", "actions" => ["show"]], "customers" => ["name_ar" => "العملاء", "name_en" => "Customers", "actions" => ["show", "add", "edit", "del"]], "suppliers" => ["name_ar" => "الموردين", "name_en" => "Suppliers", "actions" => ["show", "add", "edit", "del"]], "categories" => ["name_ar" => "التصنيفات", "name_en" => "Categories", "actions" => ["show", "add", "edit", "del"]], "units" => ["name_ar" => "الوحدات", "name_en" => "Units", "actions" => ["show", "add", "edit", "del"]], "users" => ["name_ar" => "المستخدمين", "name_en" => "Users", "actions" => ["show", "add", "edit", "del"]], "settings" => ["name_ar" => "الإعدادات", "name_en" => "Settings", "actions" => ["show", "edit"]], "expense_categories" => ["name_ar" => "تصنيفات المصروفات", "name_en" => "Expense Categories", "actions" => ["show", "add", "edit", "del"]], "expenses" => ["name_ar" => "المصروفات", "name_en" => "Expenses", "actions" => ["show", "add", "edit", "del"]]]; } function has_permission(string $m, string $a = "show"): bool { $u = current_user(); if (!$u) return false; if ($u["role"] === "owner") return true; $p = !empty($u["permissions"]) ? (is_array($u["permissions"]) ? $u["permissions"] : json_decode($u["permissions"], true)) : []; return !empty($p[$m][$a]); } function require_permission(string $m, string $a = "show"): array { $u = require_auth(); if (!has_permission($m, $a)) { set_flash("warning", tr("ليس لديك صلاحية.", "You do not have permission.")); redirect_to("index.php"); } return $u; }
function require_roles(array $roles): array
{
$user = require_auth();
@@ -455,7 +484,7 @@ function currency(float $amount): string
function sale_mode_label(string $mode): string
{
- return $mode === 'normal' ? tr('بيع عادي', 'Normal Sale') : tr('بيع نقاط البيع', 'POS Sale');
+ return $mode === 'normal' ? tr('فاتورة', 'Invoice') : tr('بيع نقاط البيع', 'POS Sale');
}
function round_money(float $amount): float
@@ -556,19 +585,32 @@ function apply_sale_payment(int $saleId, float $paymentAmount, bool $completeOrd
$newPaidAmount = round_money($summary['paid_amount'] + $appliedAmount);
$newDueAmount = max(0.0, round_money((float) $sale['total_amount'] - $newPaidAmount));
$newPaymentStatus = $newDueAmount <= 0.0005 ? 'paid' : 'partial';
- $newSaleStatus = (string) ($sale['status'] ?? 'completed');
+ $oldSaleStatus = (string) ($sale['status'] ?? 'completed');
+ $newSaleStatus = $oldSaleStatus;
if ($completeOrderWhenPaid && $newDueAmount <= 0.0005 && $newSaleStatus === 'order') {
$newSaleStatus = 'completed';
}
- $stmt = db()->prepare('UPDATE sales_orders SET paid_amount = :paid_amount, due_amount = :due_amount, payment_status = :payment_status, status = :status WHERE id = :id');
- $stmt->execute([
- ':paid_amount' => $newPaidAmount,
- ':due_amount' => $newDueAmount,
- ':payment_status' => $newPaymentStatus,
- ':status' => $newSaleStatus,
- ':id' => $saleId,
- ]);
+ db()->beginTransaction();
+ try {
+ sync_order_stock_reservation($sale['items'] ?? [], $oldSaleStatus, $sale['items'] ?? [], $newSaleStatus);
+
+ $stmt = db()->prepare('UPDATE sales_orders SET paid_amount = :paid_amount, due_amount = :due_amount, payment_status = :payment_status, status = :status WHERE id = :id');
+ $stmt->execute([
+ ':paid_amount' => $newPaidAmount,
+ ':due_amount' => $newDueAmount,
+ ':payment_status' => $newPaymentStatus,
+ ':status' => $newSaleStatus,
+ ':id' => $saleId,
+ ]);
+
+ db()->commit();
+ } catch (Throwable $e) {
+ if (db()->inTransaction()) {
+ db()->rollBack();
+ }
+ throw $e;
+ }
return [
'applied_amount' => $appliedAmount,
@@ -1191,33 +1233,93 @@ function create_sale(array $data): int
{
ensure_sales_table();
- $stmt = db()->prepare('INSERT INTO sales_orders
- (receipt_no, sale_mode, branch_code, cashier_username, cashier_name, role_name, customer_id, customer_name, payment_method, payment_status, items_json, item_count, subtotal, vat_amount, total_amount, paid_amount, due_amount, status, notes, sale_date)
- VALUES
- (:receipt_no, :sale_mode, :branch_code, :cashier_username, :cashier_name, :role_name, :customer_id, :customer_name, :payment_method, :payment_status, :items_json, :item_count, :subtotal, :vat_amount, :total_amount, :paid_amount, :due_amount, :status, :notes, NOW())');
+ db()->beginTransaction();
+ try {
+ $stmt = db()->prepare('INSERT INTO sales_orders
+ (receipt_no, sale_mode, branch_code, cashier_username, cashier_name, role_name, customer_id, customer_name, payment_method, payment_status, items_json, item_count, subtotal, vat_amount, total_amount, paid_amount, due_amount, status, notes, sale_date)
+ VALUES
+ (:receipt_no, :sale_mode, :branch_code, :cashier_username, :cashier_name, :role_name, :customer_id, :customer_name, :payment_method, :payment_status, :items_json, :item_count, :subtotal, :vat_amount, :total_amount, :paid_amount, :due_amount, :status, :notes, NOW())');
- $stmt->bindValue(':receipt_no', $data['receipt_no']);
- $stmt->bindValue(':sale_mode', $data['sale_mode']);
- $stmt->bindValue(':branch_code', $data['branch_code']);
- $stmt->bindValue(':cashier_username', $data['cashier_username']);
- $stmt->bindValue(':cashier_name', $data['cashier_name']);
- $stmt->bindValue(':role_name', $data['role_name']);
- $stmt->bindValue(':customer_id', $data['customer_id'] ?? null, PDO::PARAM_INT);
- $stmt->bindValue(':customer_name', $data['customer_name']);
- $stmt->bindValue(':payment_method', $data['payment_method']);
- $stmt->bindValue(':payment_status', $data['payment_status'] ?? 'paid');
- $stmt->bindValue(':items_json', json_encode($data['items'], JSON_UNESCAPED_UNICODE));
- $stmt->bindValue(':item_count', $data['item_count'], PDO::PARAM_INT);
- $stmt->bindValue(':subtotal', $data['subtotal']);
- $stmt->bindValue(':vat_amount', $data['vat_amount'] ?? 0.0);
- $stmt->bindValue(':total_amount', $data['total_amount']);
- $stmt->bindValue(':paid_amount', $data['paid_amount'] ?? $data['total_amount']);
- $stmt->bindValue(':due_amount', $data['due_amount'] ?? 0.0);
- $stmt->bindValue(':status', $data['status'] ?? 'completed');
- $stmt->bindValue(':notes', $data['notes']);
- $stmt->execute();
+ $stmt->bindValue(':receipt_no', $data['receipt_no']);
+ $stmt->bindValue(':sale_mode', $data['sale_mode']);
+ $stmt->bindValue(':branch_code', $data['branch_code']);
+ $stmt->bindValue(':cashier_username', $data['cashier_username']);
+ $stmt->bindValue(':cashier_name', $data['cashier_name']);
+ $stmt->bindValue(':role_name', $data['role_name']);
+ $stmt->bindValue(':customer_id', $data['customer_id'] ?? null, PDO::PARAM_INT);
+ $stmt->bindValue(':customer_name', $data['customer_name']);
+ $stmt->bindValue(':payment_method', $data['payment_method']);
+ $stmt->bindValue(':payment_status', $data['payment_status'] ?? 'paid');
+ $stmt->bindValue(':items_json', json_encode($data['items'], JSON_UNESCAPED_UNICODE));
+ $stmt->bindValue(':item_count', $data['item_count'], PDO::PARAM_INT);
+ $stmt->bindValue(':subtotal', $data['subtotal']);
+ $stmt->bindValue(':vat_amount', $data['vat_amount'] ?? 0.0);
+ $stmt->bindValue(':total_amount', $data['total_amount']);
+ $stmt->bindValue(':paid_amount', $data['paid_amount'] ?? $data['total_amount']);
+ $stmt->bindValue(':due_amount', $data['due_amount'] ?? 0.0);
+ $stmt->bindValue(':status', $data['status'] ?? 'completed');
+ $stmt->bindValue(':notes', $data['notes']);
+ $stmt->execute();
- return (int) db()->lastInsertId();
+ $saleId = (int) db()->lastInsertId();
+ sync_order_stock_reservation([], 'completed', $data['items'] ?? [], (string) ($data['status'] ?? 'completed'));
+
+ db()->commit();
+ return $saleId;
+ } catch (Throwable $e) {
+ if (db()->inTransaction()) {
+ db()->rollBack();
+ }
+ throw $e;
+ }
+}
+
+function sale_item_quantities(array $items): array
+{
+ $quantities = [];
+ foreach ($items as $item) {
+ $sku = (string) ($item['sku'] ?? '');
+ $qty = (int) ($item['qty'] ?? 0);
+ if ($sku === '' || $qty <= 0) {
+ continue;
+ }
+ $quantities[$sku] = ($quantities[$sku] ?? 0) + $qty;
+ }
+
+ return $quantities;
+}
+
+function adjust_item_base_stock(array $stockDeltaBySku): void
+{
+ if ($stockDeltaBySku === []) {
+ return;
+ }
+
+ $stmt = db()->prepare('UPDATE items SET base_stock = base_stock + :stock_delta WHERE sku = :sku');
+ foreach ($stockDeltaBySku as $sku => $delta) {
+ if ($sku === '' || $delta === 0) {
+ continue;
+ }
+ $stmt->bindValue(':stock_delta', $delta, PDO::PARAM_INT);
+ $stmt->bindValue(':sku', (string) $sku);
+ $stmt->execute();
+ }
+}
+
+function sync_order_stock_reservation(array $oldItems, string $oldStatus, array $newItems, string $newStatus): void
+{
+ $previousReserved = $oldStatus === 'order' ? sale_item_quantities($oldItems) : [];
+ $nextReserved = $newStatus === 'order' ? sale_item_quantities($newItems) : [];
+
+ $stockDeltaBySku = [];
+ foreach ($previousReserved as $sku => $qty) {
+ $stockDeltaBySku[$sku] = ($stockDeltaBySku[$sku] ?? 0) + $qty;
+ }
+ foreach ($nextReserved as $sku => $qty) {
+ $stockDeltaBySku[$sku] = ($stockDeltaBySku[$sku] ?? 0) - $qty;
+ }
+
+ adjust_item_base_stock($stockDeltaBySku);
}
function base_sales_query_filters(array &$params, ?string $mode = null, ?string $branch = null): string
@@ -1386,6 +1488,10 @@ function stock_snapshot(): array
$catalog = catalog();
$sold = [];
foreach (fetch_all_sales_for_scope() as $sale) {
+ if ((string) ($sale['status'] ?? 'completed') === 'order') {
+ continue;
+ }
+
foreach ($sale['items'] as $item) {
$sku = (string) ($item['sku'] ?? '');
$sold[$sku] = ($sold[$sku] ?? 0) + (int) ($item['qty'] ?? 0);
@@ -1420,7 +1526,7 @@ function module_cards(): array
{
return [
['title_ar' => 'نقاط البيع', 'title_en' => 'POS Sale', 'path' => 'pos.php', 'desc_ar' => 'إتمام البيع السريع مع تحديث السجل.', 'desc_en' => 'Fast checkout with instant sales logging.'],
- ['title_ar' => 'بيع عادي', 'title_en' => 'Normal Sale', 'path' => 'normal_sale.php', 'desc_ar' => 'فاتورة يدوية مع العميل والملاحظات.', 'desc_en' => 'Manual invoice flow with customer details and notes.'],
+ ['title_ar' => 'فاتورة', 'title_en' => 'Invoice', 'path' => 'normal_sale.php', 'desc_ar' => 'فاتورة يدوية مع العميل والملاحظات.', 'desc_en' => 'Manual invoice flow with customer details and notes.'],
['title_ar' => 'المبيعات', 'title_en' => 'Sales Ledger', 'path' => 'sales.php', 'desc_ar' => 'قائمة الفواتير مع التفاصيل والفرز.', 'desc_en' => 'Invoice list with filters and detail views.'],
['title_ar' => 'المخزون', 'title_en' => 'Stock', 'path' => 'stock.php', 'desc_ar' => 'قراءة فورية للمخزون الحالي والتنبيهات.', 'desc_en' => 'Live stock snapshot and low-stock indicators.'],
['title_ar' => 'المشتريات', 'title_en' => 'Purchases', 'path' => 'purchases.php', 'desc_ar' => 'واجهة مبدئية لاستلام الموردين بين الفروع.', 'desc_en' => 'Starter receiving board for suppliers and branches.'],
diff --git a/includes/header.php b/includes/header.php
index 1308962..f9070aa 100644
--- a/includes/header.php
+++ b/includes/header.php
@@ -96,17 +96,23 @@ $isPublic = !empty($forcePublic) || !isset($user) || !$user;
-
+