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 بفاصلة.
+
+
+ + +
+ +
+
+
+
+
+

سجل الموردين

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

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

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

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