From 8b3e4e72af5e464184e7afda05a73b83644dc593 Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Thu, 9 Apr 2026 09:46:40 +0000 Subject: [PATCH] Autosave: 20260409-094640 --- accounting.php | 340 +++++++++++++ app.php | 1074 +++++++++++++++++++++++++++++++++++++++++ assets/css/custom.css | 728 +++++++++++++++------------- assets/js/main.js | 135 ++++-- customers.php | 205 ++++++++ healthz.php | 20 + index.php | 387 +++++++++------ login.php | 58 +++ logout.php | 7 + manufacturing.php | 241 +++++++++ print_order.php | 109 +++++ products.php | 194 ++++++++ purchases.php | 171 +++++++ reports.php | 263 ++++++++++ sales_orders.php | 375 ++++++++++++++ stock_movements.php | 38 ++ suppliers.php | 108 +++++ 17 files changed, 3941 insertions(+), 512 deletions(-) create mode 100644 accounting.php create mode 100644 app.php create mode 100644 customers.php create mode 100644 healthz.php create mode 100644 login.php create mode 100644 logout.php create mode 100644 manufacturing.php create mode 100644 print_order.php create mode 100644 products.php create mode 100644 purchases.php create mode 100644 reports.php create mode 100644 sales_orders.php create mode 100644 stock_movements.php create mode 100644 suppliers.php diff --git a/accounting.php b/accounting.php new file mode 100644 index 0000000..61ec496 --- /dev/null +++ b/accounting.php @@ -0,0 +1,340 @@ + (int)$customer['id'], + 'customer_name' => $customer['title'], + 'amount' => $amount, + 'payment_method' => $method, + 'payment_method_label' => payment_method_label($method), + 'notes' => $notes, + 'created_date' => date('Y-m-d H:i'), + 'created_by' => current_user()['username'] ?? 'system', + ], + 'posted' + ); + set_flash('success', 'تم ترحيل مقبوضات العميل بنجاح.'); + redirect('accounting.php'); + } + } + + if ($action === 'supplier_payment') { + $supplierId = (int)($_POST['supplier_id'] ?? 0); + $amount = (float)($_POST['amount'] ?? 0); + $method = (string)($_POST['payment_method'] ?? 'bank_transfer'); + $notes = trim((string)($_POST['notes'] ?? '')); + $supplier = fetch_record('supplier', $supplierId); + + if (!$supplier) { + $errors[] = 'اختر موردًا صحيحًا لتسجيل الدفعة.'; + } + if ($amount <= 0) { + $errors[] = 'أدخل مبلغ دفعة أكبر من صفر.'; + } + if (!array_key_exists($method, payment_method_options())) { + $errors[] = 'طريقة السداد غير صحيحة.'; + } + + if (!$errors && $supplier) { + create_record( + 'supplier_payment', + 'دفعة إلى ' . $supplier['title'], + next_code('SPAY', 'supplier_payment'), + [ + 'supplier_id' => (int)$supplier['id'], + 'supplier_name' => $supplier['title'], + 'amount' => $amount, + 'payment_method' => $method, + 'payment_method_label' => payment_method_label($method), + 'notes' => $notes, + 'created_date' => date('Y-m-d H:i'), + 'created_by' => current_user()['username'] ?? 'system', + ], + 'posted' + ); + set_flash('success', 'تم ترحيل دفعة المورد بنجاح.'); + redirect('accounting.php'); + } + } + + if ($action === 'expense_entry') { + $title = trim((string)($_POST['title'] ?? '')); + $category = (string)($_POST['category'] ?? 'operations'); + $amount = (float)($_POST['amount'] ?? 0); + $notes = trim((string)($_POST['notes'] ?? '')); + + if ($title === '') { + $errors[] = 'أدخل اسم المصروف.'; + } + if ($amount <= 0) { + $errors[] = 'أدخل مبلغ مصروف أكبر من صفر.'; + } + if (!array_key_exists($category, expense_category_options())) { + $errors[] = 'تصنيف المصروف غير صحيح.'; + } + + if (!$errors) { + create_record( + 'expense_entry', + $title, + next_code('EXP', 'expense_entry'), + [ + 'category' => $category, + 'category_label' => expense_category_label($category), + 'amount' => $amount, + 'notes' => $notes, + 'created_date' => date('Y-m-d H:i'), + 'created_by' => current_user()['username'] ?? 'system', + ], + 'posted' + ); + set_flash('success', 'تم ترحيل المصروف بنجاح.'); + redirect('accounting.php'); + } + } +} + +$counts = fetch_counts(); +$summary = accounting_summary(); +$customerStatements = customer_statement_rows(); +$supplierStatements = supplier_statement_rows(); +$customerPayments = fetch_records('customer_payment'); +$supplierPayments = fetch_records('supplier_payment'); +$expenses = fetch_records('expense_entry'); +$activity = recent_accounting_activity(10); +$customers = customer_dataset(); +$suppliers = supplier_dataset(); + +render_header('المحاسبة والتقارير المالية', 'إدارة المقبوضات والمدفوعات والمصروفات وكشوف حساب العملاء والموردين مع مؤشرات ربح وتدفق نقدي.', 'accounting'); +?> +
+
+
+ Accounting & Statements +

محاسبة تشغيلية مرتبطة بالمبيعات والمشتريات.

+

تسحب هذه الصفحة الذمم المدينة من فواتير المبيعات، والذمم الدائنة من أوامر الشراء المستلمة، ثم تخصم منها المقبوضات والمدفوعات والمصروفات لتكوين صورة مالية فورية.

+
+
+
+
الربح المتوقع
+
+
صافي التدفق النقدي:
+
المقبوضات اليوم:
+
+
+
+
+ +
+
فواتير المبيعات
فاتورة
+
مشتريات مستلمة
مرتبطة بالموردين
+
مقبوضات العملاء
حركة
+
مدفوعات الموردين
حركة
+
المصروفات
قيد
+
ذمم العملاء
قابلة للتحصيل
+
ذمم الموردين
واجب سدادها
+
صافي التدفق النقدي
مقبوضات - مدفوعات - مصروفات
+
+ + +
+ + +
+
+
+

تسجيل مقبوضات عميل

تُخصم تلقائيًا من رصيد العميل المستحق.

+
+ + +
+ + +
+
+
+
+
+
+ +
+
+
+
+
+

تسجيل دفعة مورد

تُخصم من الذمم الدائنة على المورد مباشرة.

+
+ + +
+ + +
+
+
+
+
+
+ +
+
+
+
+
+

إضافة مصروف

يؤثر مباشرة على الربح المتوقع والتدفق النقدي.

+
+ + +
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+

كشف حساب العملاء

الرصيد = فواتير المبيعات - المقبوضات المسجلة.

+ +
+ + + + + + + + + + + + +
العميلالفواتيرالمقبوضاتالرصيد
+
+ +
لا توجد بيانات عملاء لعرض كشف الحساب.
+ +
+
+
+
+

كشف حساب الموردين

الرصيد = مشتريات مستلمة - مدفوعات الموردين.

+ +
+ + + + + + + + + + + + +
الموردالمشترياتالمدفوعاتالرصيد
+
+ +
لا توجد بيانات موردين لعرض كشف الحساب.
+ +
+
+
+ +
+
+
+

آخر نشاط محاسبي

يشمل المقبوضات والمدفوعات والمصروفات في سجل سريع.

+ +
+ +
+
+ +
+
+
+ +
+
+
+ +
+ +
لا توجد قيود محاسبية مرحّلة بعد.
+ +
+
+
+
+

ملخص القيود المرحّلة

آخر الحركات المالية المسجلة يدويًا داخل النظام.

+ +
+ + + + + + + + + + + + + +
النوعالمرجعالطرفالتفصيلالمبلغ
'مقبوضات', 'supplier_payment' => 'مدفوعات', default => 'مصروف' }) ?>
+
+ +
لم يتم تسجيل مقبوضات أو مدفوعات أو مصروفات بعد.
+ +
+
+
+ diff --git a/app.php b/app.php new file mode 100644 index 0000000..c0a8e90 --- /dev/null +++ b/app.php @@ -0,0 +1,1074 @@ + $type, 'message' => $message]; +} + +function get_flash(): ?array { + if (empty($_SESSION['flash'])) { + return null; + } + $flash = $_SESSION['flash']; + unset($_SESSION['flash']); + return is_array($flash) ? $flash : null; +} + +function json_payload(?string $payload): array { + if (!$payload) { + return []; + } + $decoded = json_decode($payload, true); + return is_array($decoded) ? $decoded : []; +} + +function parse_csv_list(string $raw): array { + return array_values(array_filter(array_map('trim', explode(',', $raw)), static fn ($v) => $v !== '')); +} + +function role_options(): array { + return [ + 'admin' => 'Admin', + 'sales' => 'Sales', + 'purchasing' => 'Purchasing', + 'production' => 'Production', + 'accounting' => 'Accounting', + ]; +} + +function role_label(string $role): string { + $labels = [ + 'admin' => 'مدير النظام', + 'sales' => 'المبيعات', + 'purchasing' => 'المشتريات', + 'production' => 'الإنتاج', + 'accounting' => 'المحاسبة', + ]; + return $labels[$role] ?? $role; +} + +function ensure_schema(): void { + db()->exec( + "CREATE TABLE IF NOT EXISTS erp_records ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + record_type VARCHAR(40) NOT NULL, + title VARCHAR(190) NOT NULL, + code VARCHAR(80) NOT NULL, + status VARCHAR(40) NOT NULL DEFAULT 'active', + payload LONGTEXT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_record_type (record_type), + KEY idx_record_type_status (record_type, status), + KEY idx_code (code) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ); + + db()->exec( + "CREATE TABLE IF NOT EXISTS app_users ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(80) NOT NULL UNIQUE, + full_name VARCHAR(190) NOT NULL, + role VARCHAR(40) NOT NULL, + password_hash VARCHAR(255) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'active', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_role (role), + KEY idx_status (status) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ); + + seed_demo_data(); +} + +function next_code(string $prefix, string $type): string { + $stmt = db()->prepare("SELECT COUNT(*) FROM erp_records WHERE record_type = :record_type"); + $stmt->execute(['record_type' => $type]); + $count = (int)$stmt->fetchColumn() + 1; + return sprintf('%s-%04d', $prefix, $count); +} + +function create_record(string $type, string $title, string $code, array $payload = [], string $status = 'active'): int { + $stmt = db()->prepare( + "INSERT INTO erp_records (record_type, title, code, status, payload) + VALUES (:record_type, :title, :code, :status, :payload)" + ); + $stmt->execute([ + 'record_type' => $type, + 'title' => $title, + 'code' => $code, + 'status' => $status, + 'payload' => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + ]); + + return (int)db()->lastInsertId(); +} + +function update_record_payload(int $id, array $payload, ?string $status = null): void { + if ($status !== null) { + $stmt = db()->prepare("UPDATE erp_records SET payload = :payload, status = :status WHERE id = :id"); + $stmt->execute([ + 'payload' => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'status' => $status, + 'id' => $id, + ]); + return; + } + + $stmt = db()->prepare("UPDATE erp_records SET payload = :payload WHERE id = :id"); + $stmt->execute([ + 'payload' => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'id' => $id, + ]); +} + +function fetch_records(string $type): array { + $stmt = db()->prepare("SELECT * FROM erp_records WHERE record_type = :record_type ORDER BY id DESC"); + $stmt->execute(['record_type' => $type]); + $rows = $stmt->fetchAll(); + foreach ($rows as &$row) { + $row['payload_data'] = json_payload($row['payload'] ?? null); + } + return $rows; +} + +function recent_records(string $type, int $limit = 5): array { + $stmt = db()->prepare("SELECT * FROM erp_records WHERE record_type = :record_type ORDER BY id DESC LIMIT :limit"); + $stmt->bindValue(':record_type', $type, PDO::PARAM_STR); + $stmt->bindValue(':limit', max(1, $limit), PDO::PARAM_INT); + $stmt->execute(); + $rows = $stmt->fetchAll(); + foreach ($rows as &$row) { + $row['payload_data'] = json_payload($row['payload'] ?? null); + } + return $rows; +} + +function fetch_record(string $type, int $id): ?array { + $stmt = db()->prepare("SELECT * FROM erp_records WHERE record_type = :record_type AND id = :id LIMIT 1"); + $stmt->execute(['record_type' => $type, 'id' => $id]); + $row = $stmt->fetch(); + if (!$row) { + return null; + } + $row['payload_data'] = json_payload($row['payload'] ?? null); + return $row; +} + +function record_count(string $type): int { + $stmt = db()->prepare("SELECT COUNT(*) FROM erp_records WHERE record_type = :record_type"); + $stmt->execute(['record_type' => $type]); + return (int)$stmt->fetchColumn(); +} + +function today_record_count(string $type): int { + $stmt = db()->prepare("SELECT COUNT(*) FROM erp_records WHERE record_type = :record_type AND DATE(created_at) = CURDATE()"); + $stmt->execute(['record_type' => $type]); + return (int)$stmt->fetchColumn(); +} + +function record_created_date(array $record): string { + return substr((string)($record['created_at'] ?? ''), 0, 10); +} + +function record_matches_date_range(array $record, ?string $startDate = null, ?string $endDate = null): bool { + $createdDate = record_created_date($record); + if ($createdDate === '') { + return false; + } + if ($startDate && $createdDate < $startDate) { + return false; + } + if ($endDate && $createdDate > $endDate) { + return false; + } + return true; +} + +function filter_records_by_date(array $records, ?string $startDate = null, ?string $endDate = null): array { + return array_values(array_filter($records, static fn (array $record): bool => record_matches_date_range($record, $startDate, $endDate))); +} + +function fetch_counts(): array { + $types = ['customer', 'supplier', 'product', 'sales_quote', 'sales_order', 'delivery_note', 'sales_invoice', 'purchase_order', 'manufacturing_order', 'stock_movement', 'customer_payment', 'supplier_payment', 'expense_entry']; + $counts = []; + foreach ($types as $type) { + $counts[$type] = record_count($type); + } + return $counts; +} + +function low_stock_products(int $limit = 6): array { + $rows = []; + foreach (fetch_records('product') as $product) { + $payload = $product['payload_data']; + $stock = (float)($payload['stock_qty'] ?? 0); + $threshold = (float)($payload['reorder_level'] ?? 0); + if ($stock <= $threshold) { + $rows[] = $product; + } + } + usort($rows, static function (array $a, array $b): int { + return (float)($a['payload_data']['stock_qty'] ?? 0) <=> (float)($b['payload_data']['stock_qty'] ?? 0); + }); + return array_slice($rows, 0, $limit); +} + +function low_stock_count(): int { + return count(low_stock_products(999)); +} + +function total_sales_value(): float { + $total = 0.0; + foreach (fetch_records('sales_order') as $order) { + $total += (float)($order['payload_data']['grand_total'] ?? 0); + } + return $total; +} + +function total_purchase_value(): float { + $total = 0.0; + foreach (fetch_records('purchase_order') as $order) { + $total += (float)($order['payload_data']['grand_total'] ?? 0); + } + return $total; +} + +function today_sales_value(): float { + $total = 0.0; + foreach (recent_records('sales_order', 200) as $order) { + if (substr((string)($order['created_at'] ?? ''), 0, 10) === date('Y-m-d')) { + $total += (float)($order['payload_data']['grand_total'] ?? 0); + } + } + return $total; +} + +function today_purchase_value(): float { + $total = 0.0; + foreach (recent_records('purchase_order', 200) as $order) { + if (substr((string)($order['created_at'] ?? ''), 0, 10) === date('Y-m-d')) { + $total += (float)($order['payload_data']['grand_total'] ?? 0); + } + } + return $total; +} + +function total_sales_invoice_value(): float { + $total = 0.0; + foreach (fetch_records('sales_invoice') as $invoice) { + $total += (float)($invoice['payload_data']['grand_total'] ?? 0); + } + return $total; +} + +function received_purchase_total(): float { + $total = 0.0; + foreach (fetch_records('purchase_order') as $order) { + if ((string)($order['status'] ?? '') === 'received') { + $total += (float)($order['payload_data']['grand_total'] ?? 0); + } + } + return $total; +} + +function total_customer_receipts(): float { + $total = 0.0; + foreach (fetch_records('customer_payment') as $payment) { + $total += (float)($payment['payload_data']['amount'] ?? 0); + } + return $total; +} + +function total_supplier_payments(): float { + $total = 0.0; + foreach (fetch_records('supplier_payment') as $payment) { + $total += (float)($payment['payload_data']['amount'] ?? 0); + } + return $total; +} + +function total_expense_value(): float { + $total = 0.0; + foreach (fetch_records('expense_entry') as $expense) { + $total += (float)($expense['payload_data']['amount'] ?? 0); + } + return $total; +} + +function today_amount_by_type(string $type, string $field = 'amount'): float { + $total = 0.0; + foreach (recent_records($type, 200) as $row) { + if (substr((string)($row['created_at'] ?? ''), 0, 10) === date('Y-m-d')) { + $total += (float)($row['payload_data'][$field] ?? 0); + } + } + return $total; +} + +function receivable_total(): float { + return total_sales_invoice_value() - total_customer_receipts(); +} + +function payable_total(): float { + return received_purchase_total() - total_supplier_payments(); +} + +function expected_profit_value(): float { + return total_sales_invoice_value() - received_purchase_total() - total_expense_value(); +} + +function net_cashflow_value(): float { + return total_customer_receipts() - total_supplier_payments() - total_expense_value(); +} + +function payment_method_options(): array { + return [ + 'bank_transfer' => 'تحويل بنكي', + 'cash' => 'نقدي', + 'card' => 'بطاقة', + 'cheque' => 'شيك', + ]; +} + +function payment_method_label(string $method): string { + $labels = payment_method_options(); + return $labels[$method] ?? $method; +} + +function expense_category_options(): array { + return [ + 'operations' => 'تشغيل', + 'logistics' => 'لوجستيات', + 'salary' => 'رواتب', + 'marketing' => 'تسويق', + 'utilities' => 'مرافق', + 'other' => 'أخرى', + ]; +} + +function expense_category_label(string $category): string { + $labels = expense_category_options(); + return $labels[$category] ?? $category; +} + +function accounting_summary(): array { + $salesInvoices = total_sales_invoice_value(); + $purchaseCommitments = received_purchase_total(); + $customerReceipts = total_customer_receipts(); + $supplierPayments = total_supplier_payments(); + $expenses = total_expense_value(); + + return [ + 'sales_invoices' => $salesInvoices, + 'purchase_commitments' => $purchaseCommitments, + 'customer_receipts' => $customerReceipts, + 'supplier_payments' => $supplierPayments, + 'expenses' => $expenses, + 'receivables' => $salesInvoices - $customerReceipts, + 'payables' => $purchaseCommitments - $supplierPayments, + 'expected_profit' => $salesInvoices - $purchaseCommitments - $expenses, + 'net_cashflow' => $customerReceipts - $supplierPayments - $expenses, + 'today_receipts' => today_amount_by_type('customer_payment'), + 'today_supplier_payments' => today_amount_by_type('supplier_payment'), + 'today_expenses' => today_amount_by_type('expense_entry'), + ]; +} + +function customer_statement_rows(): array { + $rows = []; + foreach (customer_dataset() as $customer) { + $rows[$customer['id']] = [ + 'id' => $customer['id'], + 'name' => $customer['name'], + 'code' => $customer['code'], + 'invoices' => 0.0, + 'payments' => 0.0, + 'balance' => 0.0, + 'last_activity' => null, + ]; + } + + foreach (fetch_records('sales_invoice') as $invoice) { + $payload = $invoice['payload_data']; + $customerId = (int)($payload['customer_id'] ?? 0); + if (!isset($rows[$customerId])) { + continue; + } + $rows[$customerId]['invoices'] += (float)($payload['grand_total'] ?? 0); + $rows[$customerId]['last_activity'] = max((string)($rows[$customerId]['last_activity'] ?? ''), (string)($invoice['created_at'] ?? '')); + } + + foreach (fetch_records('customer_payment') as $payment) { + $payload = $payment['payload_data']; + $customerId = (int)($payload['customer_id'] ?? 0); + if (!isset($rows[$customerId])) { + continue; + } + $rows[$customerId]['payments'] += (float)($payload['amount'] ?? 0); + $rows[$customerId]['last_activity'] = max((string)($rows[$customerId]['last_activity'] ?? ''), (string)($payment['created_at'] ?? '')); + } + + foreach ($rows as &$row) { + $row['balance'] = $row['invoices'] - $row['payments']; + } + unset($row); + + usort($rows, static function (array $a, array $b): int { + return abs($b['balance']) <=> abs($a['balance']); + }); + return array_values($rows); +} + +function supplier_statement_rows(): array { + $rows = []; + foreach (supplier_dataset() as $supplier) { + $rows[$supplier['id']] = [ + 'id' => $supplier['id'], + 'name' => $supplier['name'], + 'code' => $supplier['code'], + 'purchases' => 0.0, + 'payments' => 0.0, + 'balance' => 0.0, + 'last_activity' => null, + ]; + } + + foreach (fetch_records('purchase_order') as $order) { + if ((string)($order['status'] ?? '') !== 'received') { + continue; + } + $payload = $order['payload_data']; + $supplierId = (int)($payload['supplier_id'] ?? 0); + if (!isset($rows[$supplierId])) { + continue; + } + $rows[$supplierId]['purchases'] += (float)($payload['grand_total'] ?? 0); + $rows[$supplierId]['last_activity'] = max((string)($rows[$supplierId]['last_activity'] ?? ''), (string)($order['created_at'] ?? '')); + } + + foreach (fetch_records('supplier_payment') as $payment) { + $payload = $payment['payload_data']; + $supplierId = (int)($payload['supplier_id'] ?? 0); + if (!isset($rows[$supplierId])) { + continue; + } + $rows[$supplierId]['payments'] += (float)($payload['amount'] ?? 0); + $rows[$supplierId]['last_activity'] = max((string)($rows[$supplierId]['last_activity'] ?? ''), (string)($payment['created_at'] ?? '')); + } + + foreach ($rows as &$row) { + $row['balance'] = $row['purchases'] - $row['payments']; + } + unset($row); + + usort($rows, static function (array $a, array $b): int { + return abs($b['balance']) <=> abs($a['balance']); + }); + return array_values($rows); +} + +function recent_accounting_activity(int $limit = 8): array { + $rows = []; + foreach (['customer_payment', 'supplier_payment', 'expense_entry'] as $type) { + foreach (fetch_records($type) as $row) { + $row['payload_data'] = $row['payload_data'] ?? json_payload($row['payload'] ?? null); + $rows[] = $row; + } + } + usort($rows, static function (array $a, array $b): int { + return strcmp((string)($b['created_at'] ?? ''), (string)($a['created_at'] ?? '')); + }); + return array_slice($rows, 0, $limit); +} + +function inventory_totals(): array { + $rawQty = 0.0; + $finishedQty = 0.0; + $all = fetch_records('product'); + foreach ($all as $product) { + $payload = $product['payload_data']; + $qty = (float)($payload['stock_qty'] ?? 0); + $category = (string)($payload['category'] ?? ''); + if ($category === 'مواد خام') { + $rawQty += $qty; + } else { + $finishedQty += $qty; + } + } + return [ + 'raw_qty' => $rawQty, + 'finished_qty' => $finishedQty, + 'all_qty' => $rawQty + $finishedQty, + 'sku_count' => count($all), + ]; +} + +function customer_dataset(): array { + $rows = []; + foreach (fetch_records('customer') as $customer) { + $payload = $customer['payload_data']; + $rows[] = [ + 'id' => (int)$customer['id'], + 'name' => $customer['title'], + 'code' => $customer['code'], + 'branches' => array_values($payload['branches'] ?? []), + 'allowed_skus' => array_values($payload['allowed_skus'] ?? []), + 'price_overrides' => $payload['price_overrides'] ?? [], + ]; + } + return $rows; +} + +function product_dataset(): array { + $rows = []; + foreach (fetch_records('product') as $product) { + $payload = $product['payload_data']; + $rows[] = [ + 'id' => (int)$product['id'], + 'name' => $product['title'], + 'code' => $product['code'], + 'sku' => $payload['sku'] ?? $product['code'], + 'unit' => $payload['unit'] ?? 'وحدة', + 'stock_qty' => (float)($payload['stock_qty'] ?? 0), + 'sale_price' => (float)($payload['sale_price'] ?? 0), + 'category' => $payload['category'] ?? '', + ]; + } + return $rows; +} + +function supplier_dataset(): array { + $rows = []; + foreach (fetch_records('supplier') as $supplier) { + $payload = $supplier['payload_data']; + $rows[] = [ + 'id' => (int)$supplier['id'], + 'name' => $supplier['title'], + 'code' => $supplier['code'], + 'supplied_skus' => array_values($payload['supplied_skus'] ?? []), + 'phone' => $payload['phone'] ?? '', + 'email' => $payload['email'] ?? '', + ]; + } + return $rows; +} + + + +function raw_material_dataset(): array { + return array_values(array_filter(product_dataset(), static fn (array $product): bool => ($product['category'] ?? '') === 'مواد خام')); +} + +function finished_product_dataset(): array { + return array_values(array_filter(product_dataset(), static fn (array $product): bool => ($product['category'] ?? '') !== 'مواد خام')); +} + +function manufacturing_quality_options(): array { + return [ + 'accepted' => 'مطابق', + 'rework' => 'بحاجة إعادة معالجة', + 'rejected' => 'مرفوض', + ]; +} + +function manufacturing_quality_label(string $quality): string { + $labels = manufacturing_quality_options(); + return $labels[$quality] ?? $quality; +} + +function manufacturing_output_qty(array $payload): float { + return (string)($payload['quality_status'] ?? '') === 'rejected' ? 0.0 : (float)($payload['finished_qty'] ?? 0); +} + +function sales_document_types(): array { + return [ + 'sales_quote' => ['label' => 'عرض سعر', 'prefix' => 'QT'], + 'sales_order' => ['label' => 'أمر بيع', 'prefix' => 'SO'], + 'delivery_note' => ['label' => 'أمر تسليم', 'prefix' => 'DN'], + 'sales_invoice' => ['label' => 'فاتورة', 'prefix' => 'INV'], + ]; +} + +function sales_document_label(string $type): string { + $types = sales_document_types(); + return $types[$type]['label'] ?? $type; +} + +function sales_document_prefix(string $type): string { + $types = sales_document_types(); + return $types[$type]['prefix'] ?? strtoupper(substr($type, 0, 3)); +} + +function sales_document_record_types(): array { + return array_keys(sales_document_types()); +} + +function fetch_sales_document_by_id(int $id): ?array { + $stmt = db()->prepare("SELECT * FROM erp_records WHERE id = :id LIMIT 1"); + $stmt->execute(['id' => $id]); + $row = $stmt->fetch(); + if (!$row || !in_array((string)$row['record_type'], sales_document_record_types(), true)) { + return null; + } + $row['payload_data'] = json_payload($row['payload'] ?? null); + return $row; +} + +function fetch_sales_documents(): array { + $types = sales_document_record_types(); + $placeholders = implode(',', array_fill(0, count($types), '?')); + $stmt = db()->prepare("SELECT * FROM erp_records WHERE record_type IN ($placeholders) ORDER BY id DESC"); + $stmt->execute($types); + $rows = $stmt->fetchAll(); + foreach ($rows as &$row) { + $row['payload_data'] = json_payload($row['payload'] ?? null); + } + return $rows; +} + +function sales_document_conversion_targets(string $type): array { + return match ($type) { + 'sales_quote' => ['sales_order'], + 'sales_order' => ['delivery_note', 'sales_invoice'], + 'delivery_note' => ['sales_invoice'], + default => [], + }; +} + +function sales_document_child_exists(int $sourceId, string $targetType): bool { + foreach (fetch_records($targetType) as $record) { + if ((int)($record['payload_data']['source_document_id'] ?? 0) === $sourceId) { + return true; + } + } + return false; +} + +function sales_document_children(int $sourceId): array { + $children = []; + foreach (fetch_sales_documents() as $record) { + if ((int)($record['payload_data']['source_document_id'] ?? 0) === $sourceId) { + $children[] = $record; + } + } + return $children; +} + +function movement_direction_label(float $qtyChange): string { + return $qtyChange >= 0 ? 'إضافة' : 'خصم'; +} + +function movement_badge_class(float $qtyChange): string { + return $qtyChange >= 0 ? 'text-bg-success' : 'text-bg-danger'; +} + +function order_status_label(string $status): string { + return match ($status) { + 'confirmed' => 'مؤكد', + 'draft' => 'مسودة', + 'received' => 'مستلم', + 'completed' => 'مكتمل', + 'in_progress' => 'قيد التنفيذ', + 'active' => 'نشط', + 'posted' => 'مرحل', + default => $status, + }; +} + +function status_badge_class(string $status): string { + return match ($status) { + 'confirmed', 'received', 'completed', 'posted' => 'text-bg-dark', + 'in_progress' => 'text-bg-warning', + 'draft' => 'text-bg-secondary', + default => 'text-bg-light', + }; +} + +function format_money(float $amount): string { + return number_format($amount, 2) . ' ر.س'; +} + +function insert_user(string $username, string $fullName, string $role, string $password): void { + $stmt = db()->prepare( + "INSERT INTO app_users (username, full_name, role, password_hash, status) + VALUES (:username, :full_name, :role, :password_hash, 'active')" + ); + $stmt->execute([ + 'username' => $username, + 'full_name' => $fullName, + 'role' => $role, + 'password_hash' => password_hash($password, PASSWORD_BCRYPT), + ]); +} + +function default_account_credentials(): array { + return [ + ['username' => 'admin', 'password' => 'ChangeMe@123', 'role' => 'admin'], + ['username' => 'sales', 'password' => 'ChangeMe@123', 'role' => 'sales'], + ['username' => 'purchasing', 'password' => 'ChangeMe@123', 'role' => 'purchasing'], + ['username' => 'production', 'password' => 'ChangeMe@123', 'role' => 'production'], + ['username' => 'accounting', 'password' => 'ChangeMe@123', 'role' => 'accounting'], + ]; +} + +function seed_demo_data(): void { + $userCount = (int)db()->query("SELECT COUNT(*) FROM app_users")->fetchColumn(); + if ($userCount === 0) { + insert_user('admin', 'مدير النظام', 'admin', 'ChangeMe@123'); + insert_user('sales', 'موظف المبيعات', 'sales', 'ChangeMe@123'); + insert_user('purchasing', 'موظف المشتريات', 'purchasing', 'ChangeMe@123'); + insert_user('production', 'مشرف الإنتاج', 'production', 'ChangeMe@123'); + insert_user('accounting', 'المحاسب', 'accounting', 'ChangeMe@123'); + } + + $stmt = db()->prepare("SELECT COUNT(*) FROM erp_records WHERE record_type = :record_type"); + + $stmt->execute(['record_type' => 'customer']); + if ((int)$stmt->fetchColumn() === 0) { + create_record('customer', 'شركة النخبة للتوزيع', 'CUS-0001', [ + 'phone' => '0500000001', + 'email' => 'sales@nakhba.local', + 'branches' => ['الرياض', 'جدة'], + 'allowed_skus' => ['P-100', 'P-200'], + 'price_overrides' => ['P-100' => 95, 'P-200' => 145], + 'notes' => 'عميل جملة مع أسعار خاصة وخيارات أصناف محددة.', + ]); + create_record('customer', 'مؤسسة المدار التجارية', 'CUS-0002', [ + 'phone' => '0500000002', + 'email' => 'ops@almadar.local', + 'branches' => ['الدمام'], + 'allowed_skus' => ['P-100', 'P-300'], + 'price_overrides' => ['P-300' => 42], + 'notes' => 'عميل تجزئة بفرع واحد.', + ]); + } + + $stmt->execute(['record_type' => 'supplier']); + if ((int)$stmt->fetchColumn() === 0) { + create_record('supplier', 'مصنع التوريد الحديث', 'SUP-0001', [ + 'phone' => '0500000300', + 'email' => 'supply@modern.local', + 'supplied_skus' => ['RAW-001', 'P-100'], + 'notes' => 'مورد أساسي للخامات وبعض مواد التعبئة.', + ]); + create_record('supplier', 'الشركة العربية للمواد', 'SUP-0002', [ + 'phone' => '0500000301', + 'email' => 'proc@arabic.local', + 'supplied_skus' => ['RAW-001', 'P-200', 'P-300'], + 'notes' => 'مورد بديل للحالات العاجلة.', + ]); + } + + $stmt->execute(['record_type' => 'product']); + if ((int)$stmt->fetchColumn() === 0) { + create_record('product', 'مادة خام – بودرة أساسية', 'RAW-001', [ + 'sku' => 'RAW-001', + 'unit' => 'كجم', + 'category' => 'مواد خام', + 'stock_qty' => 180, + 'reorder_level' => 40, + 'sale_price' => 0, + 'notes' => 'مخزون خام للتصنيع.', + ]); + create_record('product', 'منتج نهائي – عبوة 1 لتر', 'P-100', [ + 'sku' => 'P-100', + 'unit' => 'عبوة', + 'category' => 'منتج نهائي', + 'stock_qty' => 64, + 'reorder_level' => 12, + 'sale_price' => 110, + 'notes' => 'يباع للعملاء مع تسعير خاص لبعض الحسابات.', + ]); + create_record('product', 'منتج نهائي – عبوة 5 لتر', 'P-200', [ + 'sku' => 'P-200', + 'unit' => 'عبوة', + 'category' => 'منتج نهائي', + 'stock_qty' => 28, + 'reorder_level' => 10, + 'sale_price' => 160, + 'notes' => 'من أكثر الأصناف استخدامًا في أوامر البيع.', + ]); + create_record('product', 'منتج نهائي – عبوة تجريبية', 'P-300', [ + 'sku' => 'P-300', + 'unit' => 'عبوة', + 'category' => 'منتج نهائي', + 'stock_qty' => 9, + 'reorder_level' => 8, + 'sale_price' => 48, + 'notes' => 'صنف منخفض المخزون يظهر في التنبيهات.', + ]); + } +} + +function current_user(): ?array { + static $cached = null; + static $loaded = false; + if ($loaded) { + return $cached; + } + $loaded = true; + $userId = (int)($_SESSION['user_id'] ?? 0); + if ($userId <= 0) { + return $cached = null; + } + $stmt = db()->prepare("SELECT id, username, full_name, role, status FROM app_users WHERE id = :id LIMIT 1"); + $stmt->execute(['id' => $userId]); + $user = $stmt->fetch(); + if (!$user || ($user['status'] ?? '') !== 'active') { + unset($_SESSION['user_id']); + return $cached = null; + } + return $cached = $user; +} + +function login_attempt(string $username, string $password): ?string { + $stmt = db()->prepare("SELECT * FROM app_users WHERE username = :username LIMIT 1"); + $stmt->execute(['username' => $username]); + $user = $stmt->fetch(); + if (!$user || ($user['status'] ?? '') !== 'active' || !password_verify($password, (string)$user['password_hash'])) { + return 'بيانات الدخول غير صحيحة.'; + } + session_regenerate_id(true); + $_SESSION['user_id'] = (int)$user['id']; + return null; +} + +function logout_user(): void { + $_SESSION = []; + if (ini_get('session.use_cookies')) { + $params = session_get_cookie_params(); + setcookie(session_name(), '', time() - 42000, $params['path'], $params['domain'], (bool)$params['secure'], (bool)$params['httponly']); + } + session_destroy(); +} + +function is_logged_in(): bool { + return current_user() !== null; +} + +function permission_map(): array { + return [ + 'dashboard' => ['admin', 'sales', 'purchasing', 'production', 'accounting'], + 'customers' => ['admin', 'sales'], + 'products' => ['admin', 'sales', 'purchasing', 'production', 'accounting'], + 'orders' => ['admin', 'sales', 'accounting'], + 'manufacturing' => ['admin', 'production'], + 'suppliers' => ['admin', 'purchasing'], + 'purchases' => ['admin', 'purchasing', 'accounting'], + 'stock' => ['admin', 'sales', 'purchasing', 'production', 'accounting'], + 'documents' => ['admin', 'sales', 'purchasing', 'accounting'], + 'accounting' => ['admin', 'accounting'], + 'reports' => ['admin', 'sales', 'purchasing', 'production', 'accounting'], + ]; +} + +function can_access(string $area): bool { + $user = current_user(); + if (!$user) { + return false; + } + if (($user['role'] ?? '') === 'admin') { + return true; + } + $map = permission_map(); + return in_array((string)$user['role'], $map[$area] ?? [], true); +} + +function require_login(): void { + if (!is_logged_in()) { + set_flash('warning', 'سجّل الدخول أولًا للوصول إلى النظام.'); + redirect('login.php'); + } +} + +function require_permission(string $area): void { + require_login(); + if (!can_access($area)) { + http_response_code(403); + render_header('غير مصرح', 'ليس لديك صلاحية للوصول إلى هذه الصفحة.', 'dashboard'); + echo '

غير مصرح بالوصول

الدور الحالي لا يملك صلاحية لهذه الصفحة. عد إلى لوحة التحكم أو سجّل الدخول بحساب مناسب.

'; + render_footer(); + exit; + } +} + +function active_nav(string $key, string $active): string { + return $key === $active ? 'active' : ''; +} + +function nav_items(): array { + return [ + ['key' => 'dashboard', 'label' => 'لوحة التحكم', 'href' => 'index.php', 'area' => 'dashboard'], + ['key' => 'customers', 'label' => 'العملاء', 'href' => 'customers.php', 'area' => 'customers'], + ['key' => 'suppliers', 'label' => 'الموردون', 'href' => 'suppliers.php', 'area' => 'suppliers'], + ['key' => 'products', 'label' => 'الأصناف', 'href' => 'products.php', 'area' => 'products'], + ['key' => 'orders', 'label' => 'المبيعات', 'href' => 'sales_orders.php', 'area' => 'orders'], + ['key' => 'manufacturing', 'label' => 'التصنيع', 'href' => 'manufacturing.php', 'area' => 'manufacturing'], + ['key' => 'purchases', 'label' => 'المشتريات', 'href' => 'purchases.php', 'area' => 'purchases'], + ['key' => 'accounting', 'label' => 'المحاسبة', 'href' => 'accounting.php', 'area' => 'accounting'], + ['key' => 'reports', 'label' => 'التقارير', 'href' => 'reports.php', 'area' => 'reports'], + ['key' => 'stock', 'label' => 'حركات المخزون', 'href' => 'stock_movements.php', 'area' => 'stock'], + ]; +} + +function adjust_product_stock(array $product, float $qtyChange, string $movementType, string $referenceCode, string $referenceType, int $referenceId = 0, string $notes = ''): array { + $payload = $product['payload_data']; + $before = (float)($payload['stock_qty'] ?? 0); + $after = $before + $qtyChange; + if ($after < 0) { + throw new RuntimeException('Insufficient stock.'); + } + $payload['stock_qty'] = $after; + update_record_payload((int)$product['id'], $payload); + + $movementPayload = [ + 'product_id' => (int)$product['id'], + 'product_name' => $product['title'], + 'sku' => $payload['sku'] ?? $product['code'], + 'unit' => $payload['unit'] ?? 'وحدة', + 'qty_change' => $qtyChange, + 'qty_before' => $before, + 'qty_after' => $after, + 'movement_type' => $movementType, + 'reference_code' => $referenceCode, + 'reference_type' => $referenceType, + 'reference_id' => $referenceId, + 'notes' => $notes, + 'created_by' => current_user()['username'] ?? 'system', + ]; + + create_record( + 'stock_movement', + (($qtyChange >= 0 ? 'إضافة ' : 'خصم ') . $product['title']), + next_code('MOV', 'stock_movement'), + $movementPayload, + 'confirmed' + ); + + return ['before' => $before, 'after' => $after]; +} + +function render_header(string $pageTitle, string $pageDescription, string $active = 'dashboard'): void { + $projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? $pageDescription; + $projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? ''; + $projectName = app_name(); + $user = current_user(); + ?> + + + + + + <?= e($pageTitle) ?> | <?= e($projectName) ?> + + + + + + + + + + + + + + + +
+
+ + + + + +
+
+ + + + + { - const chatForm = document.getElementById('chat-form'); - const chatInput = document.getElementById('chat-input'); - const chatMessages = document.getElementById('chat-messages'); + const toastEls = document.querySelectorAll('.toast'); + toastEls.forEach((el) => new bootstrap.Toast(el).show()); - const appendMessage = (text, sender) => { - const msgDiv = document.createElement('div'); - msgDiv.classList.add('message', sender); - msgDiv.textContent = text; - chatMessages.appendChild(msgDiv); - chatMessages.scrollTop = chatMessages.scrollHeight; + if (!window.erpData) { + return; + } + + const customerSelect = document.getElementById('customerSelect'); + const branchSelect = document.getElementById('branchSelect'); + const productSelect = document.getElementById('productSelect'); + const qtyInput = document.getElementById('qtyInput'); + const unitPricePreview = document.getElementById('unitPricePreview'); + const stockPreview = document.getElementById('stockPreview'); + const subtotalPreview = document.getElementById('subtotalPreview'); + const vatPreview = document.getElementById('vatPreview'); + const grandPreview = document.getElementById('grandPreview'); + const allowedHint = document.getElementById('allowedHint'); + + if (!customerSelect || !branchSelect || !productSelect || !qtyInput) { + return; + } + + const customers = window.erpData.customers || []; + const products = window.erpData.products || []; + const currency = (value) => `${Number(value).toFixed(2)} ر.س`; + + const getCustomer = () => customers.find((item) => String(item.id) === customerSelect.value); + const getProduct = () => products.find((item) => String(item.id) === productSelect.value); + + const renderBranches = () => { + const customer = getCustomer(); + branchSelect.innerHTML = ''; + if (!customer) { + branchSelect.innerHTML = ''; + return; + } + (customer.branches || []).forEach((branch, index) => { + const option = document.createElement('option'); + option.value = branch; + option.textContent = branch; + if (index === 0) option.selected = true; + branchSelect.appendChild(option); + }); }; - chatForm.addEventListener('submit', async (e) => { - e.preventDefault(); - const message = chatInput.value.trim(); - if (!message) return; + const filterProducts = () => { + const customer = getCustomer(); + const allowedSkus = customer?.allowed_skus || []; + Array.from(productSelect.options).forEach((option) => { + if (!option.value) { + option.hidden = false; + return; + } + const product = products.find((item) => String(item.id) === option.value); + if (!product) { + option.hidden = false; + return; + } + const allowed = allowedSkus.length === 0 || allowedSkus.includes(product.sku); + option.hidden = !allowed; + }); - appendMessage(message, 'visitor'); - chatInput.value = ''; - - try { - const response = await fetch('api/chat.php', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ message }) - }); - const data = await response.json(); - - // Artificial delay for realism - setTimeout(() => { - appendMessage(data.reply, 'bot'); - }, 500); - } catch (error) { - console.error('Error:', error); - appendMessage("Sorry, something went wrong. Please try again.", 'bot'); + const selectedProduct = getProduct(); + if (selectedProduct && selectedProduct.sku && allowedSkus.length && !allowedSkus.includes(selectedProduct.sku)) { + productSelect.value = ''; } + + allowedHint.textContent = allowedSkus.length + ? `الأصناف المسموح بها: ${allowedSkus.join('، ')}` + : 'هذا العميل لا يملك تقييدًا على الأصناف.'; + }; + + const refreshSummary = () => { + const customer = getCustomer(); + const product = getProduct(); + const qty = Number(qtyInput.value || 0); + if (!product) { + unitPricePreview.value = currency(0); + stockPreview.textContent = '—'; + subtotalPreview.textContent = currency(0); + vatPreview.textContent = currency(0); + grandPreview.textContent = currency(0); + return; + } + + const overrides = customer?.price_overrides || {}; + const unitPrice = overrides[product.sku] !== undefined ? Number(overrides[product.sku]) : Number(product.sale_price || 0); + const subtotal = qty * unitPrice; + const vat = subtotal * 0.15; + const grand = subtotal + vat; + + unitPricePreview.value = currency(unitPrice); + stockPreview.textContent = `${product.stock_qty} ${product.unit}`; + subtotalPreview.textContent = currency(subtotal); + vatPreview.textContent = currency(vat); + grandPreview.textContent = currency(grand); + }; + + customerSelect.addEventListener('change', () => { + renderBranches(); + filterProducts(); + refreshSummary(); }); + + productSelect.addEventListener('change', refreshSummary); + qtyInput.addEventListener('input', refreshSummary); + + renderBranches(); + filterProducts(); + refreshSummary(); }); diff --git a/customers.php b/customers.php new file mode 100644 index 0000000..9b59a5c --- /dev/null +++ b/customers.php @@ -0,0 +1,205 @@ + $phone, + 'email' => $email, + 'branches' => $branches, + 'allowed_skus' => $allowedSkus, + 'price_overrides' => $priceOverrides, + 'notes' => $notes, + ]); + set_flash('success', 'تمت إضافة العميل بنجاح.'); + redirect('customers.php'); + } +} + +$customers = fetch_records('customer'); +$detail = isset($_GET['id']) ? fetch_record('customer', (int)$_GET['id']) : null; +render_header('إدارة العملاء', 'تعريف العملاء وفروعهم وأسعارهم الخاصة والأصناف المسموح بها.', 'customers'); +?> +
+
+
+
+
+

إضافة عميل

+

عرّف الفروع والأسعار الخاصة لتظهر تلقائيًا عند إنشاء أمر البيع.

+
+
+ +
+ +
+ +
+ +
+ +
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
افصل الفروع بفاصلة.
+
+
+ + +
+
+ + +
كل سطر بصيغة SKU:PRICE.
+
+
+ + +
+ +
+
+
+
+
+
+
+

سجل العملاء

+

اختر عميلًا لعرض الفروع والأسعار الخاصة والأصناف المتاحة له.

+
+
+
+ + + + + + + + + + + + + + + + + + + +
العميلالفروعالأصنافتفاصيل
+
+
+
عرض
+
+
+ +
+
+
+

تفاصيل العميل

+
+
+ +
+
+ اسم العميل + +
+
+ رقم العميل + +
+
+ بيانات التواصل + +
+
+ الفروع + +
+
+
+
+
+
+
الأصناف المسموح بها
+
+
+
+
+
+
الأسعار الخاصة
+ + $price): ?> +
+ + +
+ + +
لا توجد أسعار خاصة.
+ +
+
+
+
+ +
اختر عميلًا من الجدول لعرض التفاصيل.
+ +
+
+
+ diff --git a/healthz.php b/healthz.php new file mode 100644 index 0000000..c835588 --- /dev/null +++ b/healthz.php @@ -0,0 +1,20 @@ +query('SELECT 1'); + echo json_encode([ + 'status' => 'ok', + 'app' => app_name(), + 'time' => date('c'), + 'db' => 'connected', + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); +} catch (Throwable $e) { + http_response_code(500); + echo json_encode([ + 'status' => 'error', + 'time' => date('c'), + 'db' => 'failed', + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); +} diff --git a/index.php b/index.php index 7205f3d..b3e22f8 100644 --- a/index.php +++ b/index.php @@ -1,150 +1,247 @@ - - - - - - New Style - - - - - - - - - - - - - - - - - - - - - -
-
-

Analyzing your requirements and generating your website…

-
- Loading… +if (!is_logged_in()) { + render_header('بوابة ERP', 'نظام ERP ويب على PHP + MySQL مع تسجيل دخول وصلاحيات ومخزون ومشتريات ومبيعات وتصنيع ومحاسبة.', 'dashboard'); + ?> +
+
+
+ ERP Access Portal +

نظام ERP ويب جاهز كبداية تشغيلية متكاملة.

+

تم تجهيز نواة تشغيلية تشمل تسجيل الدخول، الصلاحيات، العملاء، الموردين، الأصناف، المشتريات، المبيعات، التصنيع، المحاسبة، وسجل حركات المخزون. ادخل بحساب مناسب لفتح لوحة التحكم.

+ +
+
+
+
حسابات تجريبية
+ +
/
+ +

هذه الحسابات لأغراض البداية فقط.

+
+
+
+
+ 0 ? round(($inventory['raw_qty'] / $inventory['all_qty']) * 100, 1) : 0; +$finishedPercent = $inventory['all_qty'] > 0 ? round(($inventory['finished_qty'] / $inventory['all_qty']) * 100, 1) : 0; +$totalManufactured = 0.0; +foreach (fetch_records('manufacturing_order') as $order) { + $totalManufactured += (float)($order['payload_data']['produced_qty'] ?? 0); +} + +render_header('لوحة ERP المركزية', 'لوحة تشغيل يومية للعملاء والموردين والأصناف والمبيعات والمشتريات والتصنيع والمحاسبة وحركات المخزون.', 'dashboard'); +?> +
+
+
+ Phase 5 ERP Foundation +

تشغيل يومي موحّد مع تقارير فورية لكل أقسام الـ ERP.

+

مرحبًا — هذه النسخة تدعم تسجيل الدخول والصلاحيات، الموردين، أوامر الشراء، مسار المبيعات الكامل، أوامر التصنيع، المحاسبة التشغيلية، وسجل حركات المخزون، مع صفحة تقارير جديدة لتجميع الأداء حسب الفترة.

+ -

AI is collecting your requirements and applying the first changes.

-

This page will update automatically as the plan is implemented.

-

Runtime: PHP — UTC

-
- - - +
+
+
الدور الحالي
+
+

إجمالي السجلات التشغيلية:

+
آخر تحديث: UTC
+
+
+ + + +
+
+
+
+
+

التقارير التنفيذية

+

افتح صفحة التقارير لفلترة النتائج حسب التاريخ ومراجعة المبيعات والمشتريات والتصنيع والمحاسبة من شاشة واحدة.

+
+ + فتح التقارير + +
+
+
+
+ +
+
العملاء
سجل العملاء
+
الموردون
ربط المشتريات
+
الأصناف
مواد خام ونهائية
+
أوامر التصنيع
اليوم
+
مبيعات اليوم
+
مشتريات اليوم
+
المقبوضات
اليوم
+
المصروفات
اليوم
+
+ +
+
+
+

الصورة التشغيلية والمالية

مخزون، ذمم، ربح متوقع، وتدفق نقدي في ملخص واحد.

+
+
إجمالي المخزون
صنف نشط
+
إجمالي الإنتاج المرحّل
ناتج أوامر التصنيع المكتملة
+
ذمم العملاء
من فواتير المبيعات
+
ذمم الموردين
من المشتريات المستلمة
+
الربح المتوقع
فواتير - مشتريات - مصروفات
+
صافي التدفق النقدي
المقبوضات - المدفوعات - المصروفات
+
+
+
الخامات (%)
+
+
المنتجات النهائية (%)
+
+
+
+
+
+
+

تنبيهات المخزون المنخفض

الأصناف الأقرب لإعادة الطلب.

+ +
+ +
+
+
المتوفر: — حد الطلب:
+
+ +
+ +
لا توجد أصناف منخفضة حاليًا.
+ +
+
+
+ +
+
+
+

آخر أوامر البيع

أحدث عمليات البيع المؤكدة.

+ +
+ + + + + + + +
الرقمالعميلالصنفالإجمالي
+
+ +
لا توجد أوامر بيع بعد.
+ +
+
+
+
+

آخر أوامر الشراء

أحدث الأوامر الواردة من الموردين.

+ +
+ + + + + + + +
الرقمالموردالصنفالإجمالي
+
+ +
لا توجد أوامر شراء بعد.
+ +
+
+
+ +
+
+
+

آخر أوامر التصنيع

ملخص العمليات الإنتاجية المرحّلة.

+ +
+ +
+
+
+
+ +
+ +
لا توجد أوامر تصنيع بعد.
+ +
+
+
+
+

أحدث حركات المخزون

تشمل المبيعات والمشتريات والتصنيع في سجل واحد.

+ +
+ +
+
+
/ بعد الحركة
+
+ +
+ +
لا توجد حركات مخزون بعد.
+ +
+
+
+
+

آخر نشاط محاسبي

مقبوضات ومدفوعات ومصروفات مرحّلة.

+ +
+ +
+
+
+
+ +
+ +
لا توجد قيود محاسبية بعد.
+ +
+
+
+ diff --git a/login.php b/login.php new file mode 100644 index 0000000..1f4fbbd --- /dev/null +++ b/login.php @@ -0,0 +1,58 @@ + +
+
+
+
+
+ Secure Access +

تسجيل الدخول إلى ERP

+

كلمات المرور محفوظة بالتشفير bcrypt، والوصول محكوم حسب الدور.

+
+
+ +
+ +
+ +
+ + +
+
+ + +
+ +
+
+
حسابات تجريبية جاهزة
+ +
/
+ +
للاستخدام الأولي فقط — غيّر كلمات المرور لاحقًا عند إضافة إدارة المستخدمين.
+
+
+
+
+ diff --git a/logout.php b/logout.php new file mode 100644 index 0000000..e9a4402 --- /dev/null +++ b/logout.php @@ -0,0 +1,7 @@ + 0 && $rawProductId === $finishedProductId) { + $errors[] = 'لا يمكن أن تكون المادة الخام والمنتج النهائي نفس الصنف.'; + } + if ($finishedQty <= 0 || $actualRawQty <= 0) { + $errors[] = 'أدخل كميات صحيحة للإنتاج والاستهلاك الفعلي.'; + } + if (!array_key_exists($qualityStatus, $qualityOptions)) { + $errors[] = 'حالة الجودة غير صحيحة.'; + } + if (!in_array($status, ['draft', 'completed'], true)) { + $errors[] = 'حالة أمر التصنيع غير صحيحة.'; + } + + if (!$errors && $rawProduct && $finishedProduct) { + $rawPayload = $rawProduct['payload_data']; + $finishedPayload = $finishedProduct['payload_data']; + $producedQty = $qualityStatus === 'rejected' ? 0.0 : $finishedQty; + + db()->beginTransaction(); + try { + $manufacturingId = create_record('manufacturing_order', 'أمر تصنيع ' . $finishedProduct['title'], next_code('MO', 'manufacturing_order'), [ + 'raw_product_id' => (int)$rawProduct['id'], + 'raw_product_name' => $rawProduct['title'], + 'raw_sku' => $rawPayload['sku'] ?? $rawProduct['code'], + 'raw_unit' => $rawPayload['unit'] ?? 'وحدة', + 'finished_product_id' => (int)$finishedProduct['id'], + 'finished_product_name' => $finishedProduct['title'], + 'finished_sku' => $finishedPayload['sku'] ?? $finishedProduct['code'], + 'finished_unit' => $finishedPayload['unit'] ?? 'وحدة', + 'finished_qty' => $finishedQty, + 'actual_raw_qty' => $actualRawQty, + 'produced_qty' => $producedQty, + 'quality_status' => $qualityStatus, + 'quality_label' => manufacturing_quality_label($qualityStatus), + 'conversion_ratio' => $actualRawQty > 0 ? round($finishedQty / $actualRawQty, 4) : 0, + 'notes' => $notes, + 'created_date' => date('Y-m-d H:i'), + 'completed_at' => $status === 'completed' ? date('Y-m-d H:i') : null, + 'created_by' => current_user()['username'] ?? 'system', + ], $status); + + $order = fetch_record('manufacturing_order', $manufacturingId); + if ($status === 'completed' && $order) { + $rawStock = adjust_product_stock($rawProduct, -$actualRawQty, 'manufacturing_consume', $order['code'], 'manufacturing_order', $manufacturingId, 'استهلاك خامات لأمر التصنيع ' . $order['code']); + $payload = $order['payload_data']; + $payload['raw_stock_after'] = $rawStock['after']; + + if ($producedQty > 0) { + $finishedStock = adjust_product_stock($finishedProduct, $producedQty, 'manufacturing_output', $order['code'], 'manufacturing_order', $manufacturingId, 'إضافة إنتاج نهائي لأمر التصنيع ' . $order['code']); + $payload['finished_stock_after'] = $finishedStock['after']; + } else { + $currentFinishedStock = (float)($finishedPayload['stock_qty'] ?? 0); + $payload['finished_stock_after'] = $currentFinishedStock; + } + + update_record_payload($manufacturingId, $payload, 'completed'); + } + + db()->commit(); + set_flash('success', $status === 'completed' ? 'تم إكمال أمر التصنيع وتحديث المخزون تلقائيًا.' : 'تم حفظ أمر التصنيع كمسودة.'); + redirect('manufacturing.php?id=' . $manufacturingId); + } catch (Throwable $e) { + db()->rollBack(); + $errors[] = 'تعذر حفظ أمر التصنيع، تأكد من توفر المخزون الخام ثم حاول مرة أخرى.'; + } + } +} + +$rawMaterials = raw_material_dataset(); +$finishedProducts = finished_product_dataset(); +$orders = fetch_records('manufacturing_order'); +$detail = isset($_GET['id']) ? fetch_record('manufacturing_order', (int)$_GET['id']) : null; +$todayCount = today_record_count('manufacturing_order'); +$completedCount = 0; +$draftCount = 0; +$totalProducedQty = 0.0; +foreach ($orders as $order) { + $payload = $order['payload_data']; + if (($order['status'] ?? '') === 'completed') { + $completedCount++; + } + if (($order['status'] ?? '') === 'draft') { + $draftCount++; + } + $totalProducedQty += (float)($payload['produced_qty'] ?? 0); +} + +render_header('التصنيع', 'تسجيل أوامر تصنيع تخصم الخامات وتضيف المنتجات النهائية تلقائيًا مع متابعة الجودة.', 'manufacturing'); +?> +
+
أوامر التصنيع
إجمالي السجل
+
اليوم
أوامر اليوم
+
مكتملة
تم ترحيلها للمخزون
+
الإنتاج الناتج
إجمالي الكمية المقبولة
+
+ +
+
+
+

إنشاء أمر تصنيع

اختر الخامة والمنتج النهائي وسجّل الكمية الفعلية المستخدمة وحالة الجودة.

+ +
يلزم وجود مادة خام ومنتج نهائي واحد على الأقل في صفحة الأصناف قبل بدء التصنيع.
+ + +
+ +
+ +
+ + +
+
+ + +
+
+
+
+
+
+
+ + +
+
+ + +
+
+
+ + +
+ +
+
+
+
+
+

سجل أوامر التصنيع

الأوامر المكتملة تخصم الخامات وتضيف المنتجات النهائية مباشرة.

+ +
+ + + + + + + + + + + + + +
الرقمالمنتج النهائيالخامةالناتجالحالة
+
+ +
لا توجد أوامر تصنيع بعد.
+ +
+
+

تفاصيل أمر التصنيع

يعرض أثر العملية على المخزون الفعلي لكل صنف.

+ +
+
رقم الأمر
+
الحالة
+
الجودة
+
المنشئ
+
+
+
المادة الخام
+
الاستهلاك الفعلي
+
المخزون بعد الخصم
+
+
+
المنتج النهائي
+
الكمية المنتجة
+
الكمية المرحلة فعليًا
+
المخزون بعد الإضافة
+
+
+
تاريخ الإنشاء
+
تاريخ الإكمال
+
نسبة التحويل
+
مسودات
+
+
+ +
اختر أمر تصنيع من الجدول لعرض التفاصيل.
+ +
+
+
+ diff --git a/print_order.php b/print_order.php new file mode 100644 index 0000000..4238396 --- /dev/null +++ b/print_order.php @@ -0,0 +1,109 @@ + 'عرض سعر قابل للطباعة والحفظ PDF من المتصفح', + 'sales_order' => 'أمر بيع مؤكد وقابل للطباعة أو الحفظ PDF', + 'delivery_note' => 'أمر تسليم جاهز للطباعة مع بيانات العميل والصنف', + 'sales_invoice' => 'فاتورة مبيعات أولية قابلة للطباعة أو الحفظ PDF', + default => 'مستند مبيعات قابل للطباعة', +}; +$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? ('Printable ' . $documentLabel); +$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? ''; +?> + + + + + + <?= e($document['code']) ?> | <?= e(app_name()) ?> + + + + + + + + + + + + + + +
+ +
+ + diff --git a/products.php b/products.php new file mode 100644 index 0000000..598970a --- /dev/null +++ b/products.php @@ -0,0 +1,194 @@ + $sku, + 'unit' => $unit, + 'category' => $category, + 'stock_qty' => (float)$stockQty, + 'reorder_level' => (float)$reorder, + 'sale_price' => (float)$salePrice, + 'notes' => $notes, + ]); + set_flash('success', 'تمت إضافة الصنف بنجاح.'); + redirect('products.php'); + } +} + +$products = fetch_records('product'); +$detail = isset($_GET['id']) ? fetch_record('product', (int)$_GET['id']) : null; +render_header('الأصناف والمخزون', 'إدارة الأصناف الجاهزة للبيع ومتابعة المخزون الحالي وحدود إعادة الطلب.', 'products'); +?> +
+
+
+
+
+

إضافة صنف

+

هذا السجل يستخدم لاحقًا في المبيعات والمشتريات والتصنيع.

+
+
+ +
+ +
+ +
+ +
+ +
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+ +
+
+
+
+
+
+
+

جدول الأصناف

+

يعرض الكمية الحالية، التنبيه، والسعر الأساسي.

+
+
+
+ + + + + + + + + + + + + + + + + + + + + +
الصنفالفئةالمخزونالسعرتفاصيل
+
+
+
+ + منخفض + عرض
+
+
+
+
+
+

تفاصيل الصنف

+
+
+ +
+
+ اسم الصنف + +
+
+ SKU + +
+
+ المخزون الحالي + +
+
+ حد إعادة الطلب + +
+
+
+
+
+
+
الفئة
+
+
+
+
+
+
سعر البيع
+
+
+
+
+
+ +
اختر صنفًا من الجدول لعرض تفاصيله.
+ +
+
+
+ diff --git a/purchases.php b/purchases.php new file mode 100644 index 0000000..032bbfd --- /dev/null +++ b/purchases.php @@ -0,0 +1,171 @@ +beginTransaction(); + try { + $purchaseId = create_record('purchase_order', 'أمر شراء ' . $supplier['title'], next_code('PO', 'purchase_order'), [ + 'supplier_id' => (int)$supplier['id'], + 'supplier_name' => $supplier['title'], + 'product_id' => (int)$product['id'], + 'product_name' => $product['title'], + 'sku' => $sku, + 'unit' => $productPayload['unit'] ?? 'وحدة', + 'qty' => $qty, + 'unit_cost' => $unitCost, + 'subtotal' => $subtotal, + 'vat' => $vat, + 'grand_total' => $grand, + 'notes' => $notes, + 'created_date' => date('Y-m-d H:i'), + 'received_at' => $status === 'received' ? date('Y-m-d H:i') : null, + 'created_by' => current_user()['username'] ?? 'system', + ], $status); + + $purchase = fetch_record('purchase_order', $purchaseId); + if ($status === 'received' && $purchase) { + $stock = adjust_product_stock($product, $qty, 'purchase_receive', $purchase['code'], 'purchase_order', $purchaseId, 'استلام شراء من المورد ' . $supplier['title']); + $payload = $purchase['payload_data']; + $payload['stock_after'] = $stock['after']; + update_record_payload($purchaseId, $payload, 'received'); + } + + db()->commit(); + set_flash('success', $status === 'received' ? 'تم إنشاء أمر الشراء واستلامه وتحديث المخزون.' : 'تم حفظ أمر الشراء كمسودة.'); + redirect('purchases.php?id=' . $purchaseId); + } catch (Throwable $e) { + db()->rollBack(); + $errors[] = 'تعذر حفظ أمر الشراء، حاول مرة أخرى.'; + } + } +} + +$suppliers = supplier_dataset(); +$products = product_dataset(); +$orders = fetch_records('purchase_order'); +$detail = isset($_GET['id']) ? fetch_record('purchase_order', (int)$_GET['id']) : null; +render_header('أوامر الشراء', 'إنشاء أوامر شراء وربطها بالموردين وتحديث المخزون عند الاستلام.', 'purchases'); +?> +
+
+
+

إنشاء أمر شراء

اختر المورد والصنف ثم قرر هل هو استلام مباشر أم مسودة معلقة.

+ +
+ +
+ +
+ + +
+
+ + +
+
+
+
+
+
+
+ + +
+ +
+
+
+
+
+

سجل أوامر الشراء

الأوامر المستلمة ترفع المخزون تلقائيًا، والمسودات تبقى دون تأثير.

+ +
+ + + + + + + + + + + + + +
الرقمالموردالصنفالإجماليالحالة
+
+ +
لا توجد أوامر شراء بعد.
+ +
+
+

تفاصيل أمر الشراء

+ +
+
رقم الأمر
+
الحالة
+
المورد
+
تاريخ الإنشاء
+
+
+
الصنف
+
الكمية
+
سعر الشراء
+
الإجمالي
+
المخزون بعد الاستلام
+
+
+ +
اختر أمر شراء من الجدول لعرض تفاصيله.
+ +
+
+
+ diff --git a/reports.php b/reports.php new file mode 100644 index 0000000..15c8b15 --- /dev/null +++ b/reports.php @@ -0,0 +1,263 @@ + $endDate) { + [$startDate, $endDate] = [$endDate, $startDate]; +} + +$salesInvoices = filter_records_by_date(fetch_records('sales_invoice'), $startDate, $endDate); +$purchaseOrders = filter_records_by_date(fetch_records('purchase_order'), $startDate, $endDate); +$receivedPurchases = array_values(array_filter($purchaseOrders, static fn (array $row): bool => (string)($row['status'] ?? '') === 'received')); +$manufacturingOrders = filter_records_by_date(fetch_records('manufacturing_order'), $startDate, $endDate); +$completedManufacturing = array_values(array_filter($manufacturingOrders, static fn (array $row): bool => (string)($row['status'] ?? '') === 'completed')); +$customerPayments = filter_records_by_date(fetch_records('customer_payment'), $startDate, $endDate); +$supplierPayments = filter_records_by_date(fetch_records('supplier_payment'), $startDate, $endDate); +$expenses = filter_records_by_date(fetch_records('expense_entry'), $startDate, $endDate); +$stockMovements = filter_records_by_date(fetch_records('stock_movement'), $startDate, $endDate); + +$salesTotal = 0.0; +$topCustomers = []; +foreach ($salesInvoices as $invoice) { + $payload = $invoice['payload_data']; + $amount = (float)($payload['grand_total'] ?? 0); + $salesTotal += $amount; + $customerName = (string)($payload['customer_name'] ?? 'عميل غير محدد'); + if (!isset($topCustomers[$customerName])) { + $topCustomers[$customerName] = 0.0; + } + $topCustomers[$customerName] += $amount; +} +arsort($topCustomers); +$topCustomers = array_slice($topCustomers, 0, 5, true); + +$purchaseTotal = 0.0; +foreach ($receivedPurchases as $purchase) { + $purchaseTotal += (float)($purchase['payload_data']['grand_total'] ?? 0); +} + +$manufacturedQty = 0.0; +foreach ($completedManufacturing as $order) { + $manufacturedQty += (float)($order['payload_data']['produced_qty'] ?? 0); +} + +$receiptsTotal = 0.0; +foreach ($customerPayments as $payment) { + $receiptsTotal += (float)($payment['payload_data']['amount'] ?? 0); +} + +$supplierPaymentsTotal = 0.0; +foreach ($supplierPayments as $payment) { + $supplierPaymentsTotal += (float)($payment['payload_data']['amount'] ?? 0); +} + +$expensesTotal = 0.0; +$expenseBreakdown = []; +foreach ($expenses as $expense) { + $payload = $expense['payload_data']; + $amount = (float)($payload['amount'] ?? 0); + $expensesTotal += $amount; + $category = expense_category_label((string)($payload['category'] ?? 'other')); + if (!isset($expenseBreakdown[$category])) { + $expenseBreakdown[$category] = 0.0; + } + $expenseBreakdown[$category] += $amount; +} +arsort($expenseBreakdown); + +$productActivity = []; +foreach ($stockMovements as $movement) { + $payload = $movement['payload_data']; + $productName = (string)($payload['product_name'] ?? 'صنف غير محدد'); + if (!isset($productActivity[$productName])) { + $productActivity[$productName] = 0.0; + } + $productActivity[$productName] += abs((float)($payload['qty_change'] ?? 0)); +} +arsort($productActivity); +$productActivity = array_slice($productActivity, 0, 5, true); + +$profitEstimate = $salesTotal - $purchaseTotal - $expensesTotal; +$netCashflow = $receiptsTotal - $supplierPaymentsTotal - $expensesTotal; + +render_header('التقارير التنفيذية', 'تقارير ERP تشغيلية موحدة للمبيعات والمشتريات والتصنيع والمحاسبة مع فلترة حسب التاريخ.', 'reports'); +?> +
+
+
+

التقارير التنفيذية

+

شاشة موحدة لمراجعة أداء الفترة بين و.

+
+
+
+
+ + +
+
+ + +
+
+ +
+
+
+ +
+
فواتير المبيعات
فاتورة خلال الفترة
+
مشتريات مستلمة
أمر شراء مستلم
+
إنتاج مكتمل
أمر تصنيع مكتمل
+
حركات المخزون
إجمالي الإضافات والخصومات
+
مقبوضات العملاء
حركة قبض
+
مدفوعات الموردين
حركة دفع
+
المصروفات
قيد مصروف
+
صافي النقدية
مقبوضات - مدفوعات - مصروفات
+
+ +
+
+
+

أحدث فواتير المبيعات

آخر 8 فواتير داخل الفترة المحددة.

+ +
+ + + + + + + + + + + + +
الرقمالعميلالإجماليالتاريخ
+
+ +
لا توجد فواتير مبيعات ضمن الفترة المحددة.
+ +
+ +
+

المشتريات والتصنيع

ملخص آخر الأوامر المستلمة والمكتملة.

+
+
+
+
أوامر شراء مستلمة
+ + +
+ + +
لا توجد مشتريات مستلمة في هذه الفترة.
+ +
+
+
+
+
أوامر تصنيع مكتملة
+ + +
+ + +
لا توجد أوامر تصنيع مكتملة في هذه الفترة.
+ +
+
+
+
+ +
+

الحركة المالية

آخر المقبوضات والمدفوعات والمصروفات خلال الفترة.

+ + strcmp((string)($b['created_at'] ?? ''), (string)($a['created_at'] ?? ''))); ?> + + +
+ + + + + + + + + + + + +
النوعالوصفالقيمةالتاريخ
'مقبوض', 'supplier_payment' => 'مدفوع', 'expense_entry' => 'مصروف', default => (string)$row['record_type'] }) ?>
+
+ +
لا توجد حركة مالية ضمن الفترة المحددة.
+ +
+
+ +
+
+

مؤشرات الربحية

+
+
الربح المتوقع
+
صافي التدفق النقدي
+
المبيعات - المشتريات
+
إجمالي المصروفات
+
+
+ +
+

أفضل العملاء

حسب قيمة الفواتير داخل الفترة.

+ +
+ $amount): ?> +
+ +
+ +
لا توجد بيانات مبيعات كافية لحساب أفضل العملاء.
+ +
+ +
+

أكثر الأصناف حركة

حسب إجمالي الكميات المتحركة في المخزون.

+ +
+ $qty): ?> +
+ +
+ +
لا توجد حركات مخزون ضمن الفترة المحددة.
+ +
+ +
+

تحليل المصروفات

تجميع حسب الفئة.

+ +
+ $amount): ?> +
+ +
+ +
لا توجد مصروفات ضمن الفترة المحددة.
+ +
+
+
+ diff --git a/sales_orders.php b/sales_orders.php new file mode 100644 index 0000000..4544f4b --- /dev/null +++ b/sales_orders.php @@ -0,0 +1,375 @@ + (int)$customer['id'], + 'customer_name' => $customer['title'], + 'branch' => $branch, + 'product_id' => (int)$product['id'], + 'product_name' => $product['title'], + 'sku' => $productPayload['sku'] ?? $product['code'], + 'unit' => $productPayload['unit'] ?? 'وحدة', + 'qty' => $qty, + 'unit_price' => $unitPrice, + 'subtotal' => $subtotal, + 'vat' => $vat, + 'grand_total' => $grand, + 'notes' => $notes, + 'document_type' => $documentType, + 'document_label' => sales_document_label($documentType), + 'created_date' => date('Y-m-d H:i'), + 'created_by' => current_user()['username'] ?? 'system', + 'source_document_id' => (int)($source['id'] ?? 0), + 'source_document_code' => $source['code'] ?? null, + 'source_document_type' => $source['record_type'] ?? null, + 'source_document_label' => $source ? sales_document_label((string)$source['record_type']) : null, + 'inventory_effect' => $documentType === 'sales_order' ? 'deducted' : 'none', + ]; +} + +$errors = []; +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + verify_csrf(); + $action = (string)($_POST['form_action'] ?? 'create_document'); + + if ($action === 'create_document') { + $documentType = (string)($_POST['document_type'] ?? 'sales_order'); + $customerId = (int)($_POST['customer_id'] ?? 0); + $branch = trim((string)($_POST['branch'] ?? '')); + $productId = (int)($_POST['product_id'] ?? 0); + $qty = (float)($_POST['qty'] ?? 0); + $notes = trim((string)($_POST['notes'] ?? '')); + + if (!in_array($documentType, ['sales_quote', 'sales_order'], true)) { + $errors[] = 'نوع المستند غير صحيح.'; + } + + $customer = fetch_record('customer', $customerId); + $product = fetch_record('product', $productId); + validate_sales_input($customer, $product, $branch, $qty, $errors); + + if (!$errors && $customer && $product) { + db()->beginTransaction(); + try { + $payload = build_sales_payload($customer, $product, $branch, $qty, $notes, $documentType); + $status = $documentType === 'sales_quote' ? 'draft' : 'confirmed'; + $documentId = create_record( + $documentType, + sales_document_label($documentType) . ' ' . $customer['title'], + next_code(sales_document_prefix($documentType), $documentType), + $payload, + $status + ); + + if ($documentType === 'sales_order') { + $document = fetch_sales_document_by_id($documentId); + if ($document) { + $stock = adjust_product_stock($product, -$qty, 'sales_confirm', $document['code'], 'sales_order', $documentId, 'بيع للعميل ' . $customer['title']); + $payload = $document['payload_data']; + $payload['stock_after'] = $stock['after']; + $payload['inventory_effect'] = 'deducted'; + update_record_payload($documentId, $payload, 'confirmed'); + } + } + + db()->commit(); + set_flash('success', $documentType === 'sales_quote' ? 'تم إنشاء عرض السعر بنجاح.' : 'تم إنشاء أمر البيع وتحديث المخزون بنجاح.'); + redirect('sales_orders.php?id=' . $documentId); + } catch (Throwable $e) { + db()->rollBack(); + $errors[] = $e instanceof RuntimeException ? 'المخزون غير كافٍ لتأكيد أمر البيع.' : 'تعذر حفظ المستند، حاول مرة أخرى.'; + } + } + } + + if ($action === 'convert_document') { + $sourceId = (int)($_POST['source_id'] ?? 0); + $targetType = (string)($_POST['target_type'] ?? ''); + $source = fetch_sales_document_by_id($sourceId); + + if (!$source) { + $errors[] = 'المستند المصدر غير موجود.'; + } elseif (!in_array($targetType, sales_document_conversion_targets((string)$source['record_type']), true)) { + $errors[] = 'التحويل المطلوب غير مسموح لهذه المرحلة.'; + } elseif (sales_document_child_exists($sourceId, $targetType)) { + $errors[] = 'تم إنشاء هذا المستند مسبقًا من نفس المصدر.'; + } + + if (!$errors && $source) { + $payload = $source['payload_data']; + $customer = fetch_record('customer', (int)($payload['customer_id'] ?? 0)); + $product = fetch_record('product', (int)($payload['product_id'] ?? 0)); + $branch = (string)($payload['branch'] ?? ''); + $qty = (float)($payload['qty'] ?? 0); + $notes = trim((string)($payload['notes'] ?? '')); + + validate_sales_input($customer, $product, $branch, $qty, $errors); + + if (!$errors && $customer && $product) { + db()->beginTransaction(); + try { + $newPayload = build_sales_payload($customer, $product, $branch, $qty, $notes, $targetType, $source); + if ($targetType === 'delivery_note') { + $newPayload['delivered_at'] = date('Y-m-d H:i'); + } + if ($targetType === 'sales_invoice') { + $newPayload['invoiced_at'] = date('Y-m-d H:i'); + $newPayload['payment_status'] = 'unpaid'; + } + + $newId = create_record( + $targetType, + sales_document_label($targetType) . ' ' . $customer['title'], + next_code(sales_document_prefix($targetType), $targetType), + $newPayload, + 'confirmed' + ); + + if ($targetType === 'sales_order') { + $document = fetch_sales_document_by_id($newId); + if ($document) { + $stock = adjust_product_stock($product, -$qty, 'sales_convert', $document['code'], 'sales_order', $newId, 'تحويل من عرض سعر إلى أمر بيع للعميل ' . $customer['title']); + $newPayload = $document['payload_data']; + $newPayload['stock_after'] = $stock['after']; + $newPayload['inventory_effect'] = 'deducted'; + update_record_payload($newId, $newPayload, 'confirmed'); + } + } + + db()->commit(); + set_flash('success', 'تم إنشاء ' . sales_document_label($targetType) . ' بنجاح.'); + redirect('sales_orders.php?id=' . $newId); + } catch (Throwable $e) { + db()->rollBack(); + $errors[] = $e instanceof RuntimeException ? 'المخزون غير كافٍ لإتمام التحويل.' : 'تعذر تنفيذ التحويل، حاول مرة أخرى.'; + } + } + } + } +} + +$customers = customer_dataset(); +$products = product_dataset(); +$documents = fetch_sales_documents(); +$detail = isset($_GET['id']) ? fetch_sales_document_by_id((int)$_GET['id']) : null; +$detailChildren = $detail ? sales_document_children((int)$detail['id']) : []; +$summaryCounts = []; +foreach (sales_document_record_types() as $type) { + $summaryCounts[$type] = record_count($type); +} + +render_header('المبيعات والوثائق', 'إدارة عروض الأسعار وأوامر البيع وأوامر التسليم والفواتير وربطها في مسار واحد قابل للطباعة.', 'orders'); +?> +
+
عروض الأسعار
مرحلة التسعير قبل خصم المخزون
+
أوامر البيع
تؤكد البيع وتخصم المخزون
+
أوامر التسليم
جاهزة للطباعة والتسليم
+
الفواتير
مرتبطة بالعميل وبالمحاسبة لاحقًا
+
+ +
+
+
+

إنشاء مستند مبيعات

ابدأ بعرض سعر أو أنشئ أمر بيع مباشر. عند تأكيد أمر البيع يتم خصم المخزون تلقائيًا.

+ +
+ +
+ + +
+ + +
عرض السعر لا يغير المخزون، بينما أمر البيع يخصم الكمية مباشرة.
+
+
+ + +
+
+ + +
+
+ + +
اختر العميل أولًا لمعرفة قيود الأصناف.
+
+
+
+
+
+
+
المخزون المتاح
+
الإجمالي قبل الضريبة0.00 ر.س
+
ضريبة القيمة المضافة 15%0.00 ر.س
+
الإجمالي النهائي0.00 ر.س
+
+
+ +
+
+
+ +
+
+

سجل وثائق المبيعات

المسار الآن يدعم: عرض سعر → أمر بيع → أمر تسليم / فاتورة.

+ +
+ + + + + + + + + + + + + + + +
الرقمالنوعالعميلالصنفالإجماليالمصدرالطباعة
طباعة
+
+ +
لا توجد وثائق مبيعات بعد.
+ +
+ +
+

تفاصيل المستند

+ +
+
رقم المستند
+
النوع
+
الحالة
+
العميل / الفرع
+
تاريخ الإنشاء
+
المصدر
+
+
+
+
الكمية
+
سعر الوحدة
+
الإجمالي قبل الضريبة
+
الضريبة
+
الإجمالي النهائي
+ +
المخزون بعد العملية
+ + +
حالة الدفع
+ +
+ +
+ فتح صفحة الطباعة + عرض الصنف + + +
+ + + + + +
+ + +
+ + +
+
الوثائق المرتبطة
+
+ +
+
+ +
+
+ +
+ +
+
+ + +
+ +
أنشئ مستندًا جديدًا أو اختر واحدًا من الجدول لعرض تفاصيله.
+ +
+
+
+ + diff --git a/stock_movements.php b/stock_movements.php new file mode 100644 index 0000000..309dfcc --- /dev/null +++ b/stock_movements.php @@ -0,0 +1,38 @@ + +
+
+
+

سجل حركات المخزون

+

كل حركة تعرض الكمية قبل وبعد العملية مع المرجع المرتبط بها.

+
+
+ +
+ + + + + + + + + + + + + + +
المرجعالصنفنوع الحركةالتغييرقبل / بعدالمنشئ
+
+ +
لا توجد حركات مخزون بعد. أنشئ أمر شراء أو أمر بيع لتظهر هنا.
+ +
+ diff --git a/suppliers.php b/suppliers.php new file mode 100644 index 0000000..d0a44db --- /dev/null +++ b/suppliers.php @@ -0,0 +1,108 @@ + $phone, + 'email' => $email, + 'supplied_skus' => $suppliedSkus, + 'notes' => $notes, + ]); + set_flash('success', 'تمت إضافة المورد بنجاح.'); + redirect('suppliers.php'); + } +} + +$suppliers = fetch_records('supplier'); +$detail = isset($_GET['id']) ? fetch_record('supplier', (int)$_GET['id']) : null; +render_header('إدارة الموردين', 'إضافة الموردين وربط الأصناف التي يوردونها مع قسم المشتريات.', 'suppliers'); +?> +
+
+
+
+
+

إضافة مورد

+

عرّف المورد وبياناته والأصناف التي يوردها لربطه لاحقًا بأوامر الشراء.

+
+
+ +
+ +
+ +
+ + +
+
+
+
+
+
+ + +
افصل الـ SKU بفاصلة.
+
+
+ + +
+ +
+
+
+
+
+

سجل الموردين

اعرض الموردين وربطهم بالأصناف الموردة.

+
+ + + + + + + + + + + + +
الموردالتواصلالأصنافتفاصيل
عرض
+
+
+
+

تفاصيل المورد

+ +
+
اسم المورد
+
الرقم
+
الهاتف
+
البريد
+
+
الأصناف الموردة
+
+ +
اختر موردًا من الجدول لعرض التفاصيل.
+ +
+
+
+