Commit c9dfacbc authored by DevPilot's avatar DevPilot

feat(accounting): استيراد شجرة الحسابات Accounts V0.1 + تصحيح ربط ض.ق.م الإيجارات

أداة استيراد جديدة (tools/import_coa_tree.py) بتدخّل شجرة الحسابات من ملف
Excel على الدليل القائم بمبدأ **الإضافة بس**:
- الحساب الموجود بنفس الاسم ونفس النوع بيتساب زي ما هو ومبيتعملش منه نسخة.
- الناقص بس هو اللي بيتعمل، تحت الأب الصح في الشجرة القائمة.
- مفيش حساب قائم بيتغيّر أو يتمسح، ومفيش قيد بيتنقل.

التحقق من نوع الحساب أساسي في الأداة: بند إيراد في الملف ما بيتربطش بحساب
أصول حتى لو الاسم متطابق. ده منع ٦ ربطات غلط كانت هتحصل، منها:
  «ايراد العضويات النقدية» (إيراد) كان هيتربط بـ «حساب وسيط — عضويات
  نقدية» (أصول)، و«مصاريف أكاديمية السباحة» (مصروف) بحساب إيراد الأكاديمية.
في الحالات دي بيتعمل حساب جديد بالنوع الصح وبيتسجّل تنبيه.

النتيجة: ٢٥٧ مسار في الملف — ٦٤ كانوا موجودين، ٢٣٤ حساب/مجموعة اتعملوا.
الدليل من ٧٥٥ إلى ٩٨٩ حساب.

التحقق بعد الاستيراد:
  الميزان 513,135,477.03 مدين = دائن (ما اتغيّرش)
  أكواد مكررة: صفر · حسابات بأب مفقود: صفر
  قواعد الترحيل: ٩٤/٩٤ سليمة

وإصلاح ربط كان اتكسر في فصل ض.ق.م: القاعدة rental:output_tax كانت بتشاور
على 23080404 اللي بقى حساب رئيسي، فاتحوّلت لـ 2308040405 «ض.ق.م —
الإيجارات ١٪»، والـ fallback في الكود اتظبط معاها.

ملاحظة: ٨ حسابات رئيسية عليها قيود وحساب نوعه مختلف عن أبوه — دول موجودين
من قبل الاستيراد (أُنشئوا 2026-05) ومش ناتجين عنه.
parent 67d7a31c
......@@ -1323,7 +1323,7 @@ final class AccountingIntegrationService
$debitAccountId = PostingRouter::accountFor('rental:cash_in', $debitCode, 'collection');
$rentalRevenueId = PostingRouter::accountFor('rental:base', AccountCodes::RENTAL_REVENUE, 'collection');
$serviceRevenueId = PostingRouter::accountFor('rental:utilities', AccountCodes::SERVICE_REVENUE, 'collection');
$vatPayableId = PostingRouter::accountFor('rental:output_tax', '23080404', 'collection');
$vatPayableId = PostingRouter::accountFor('rental:output_tax', '2308040405', 'collection');
$fineRevenueId = PostingRouter::accountFor('rental:late_fee', AccountCodes::FINE_REVENUE, 'collection');
if ($debitAccountId === null || $rentalRevenueId === null) {
......
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
استيراد شجرة الحسابات من ملف Excel إلى دليل الحسابات القائم.
مبدأ الشغل: **إضافة بس**. الحساب اللي موجود بنفس الاسم ونفس النوع بيتساب
زي ما هو ومش بيتعمل منه نسخة تانية، والحساب الناقص بيتعمل تحت الأب الصح.
مفيش أي حساب قائم بيتغيّر أو يتمسح، ومفيش قيد بيتنقل — عشان الأرصدة
والربط الحالي ما يتهزّوش.
التحقق من النوع أساسي: بند إيراد في الملف ما ينفعش يتربط بحساب أصول حتى
لو الاسم متطابق (زي «ايراد العضويات النقدية» مقابل «حساب وسيط — عضويات
نقدية»). الاسم المتطابق بنوع مختلف بيتسجّل كتنبيه ومبيتاخدش.
python3 tools/import_coa_tree.py "ملف.xlsx" # عرض الخطة
python3 tools/import_coa_tree.py "ملف.xlsx" --apply # التنفيذ
"""
import argparse
import os
import re
import sys
import openpyxl
import pymysql
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# جذر الملف -> (بادئة الكود، نوع الحساب، طبيعته)
ROOT_MAP = {
'الإيرادات': ('4', 'revenue', 'credit'),
'الأصول': ('1', 'asset', 'debit'),
'الخصوم والالتزامات': ('2', 'liability', 'credit'),
'المصاريف': ('3', 'expense', 'debit'),
'حسابات وسيطة': ('1', 'asset', 'debit'),
}
# الأب الافتراضي لأي مجموعة جديدة، حسب الجذر واسم المجموعة
DEFAULT_PARENT = {
'الإيرادات': '41',
'الأصول': '12',
'الخصوم والالتزامات': '23',
'المصاريف': '33',
'حسابات وسيطة': '12',
}
PARENT_OVERRIDE = {
('الإيرادات', 'ايرادات مختلفة اخرى'): '42',
('الأصول', 'الأصول الثابتة'): '11',
('الأصول', 'مشاريع تحت التنفيذ'): '11',
('الخصوم والالتزامات', 'الفائض'): '21',
('المصاريف', 'مصاريف الاكاديميات'): '31',
('المصاريف', 'مصاريف التشغيل المختلفة'): '31',
('المصاريف', 'شركات التشغيل'): '31',
('المصاريف', 'المرتبات'): '31',
}
STOP_RAW = ['ايراد', 'ايرادات', 'حساب', 'حسابات', 'مصاريف', 'مصروفات',
'اكاديمية', 'اكاديميه', 'مصروف', 'وسيط', 'قيمة', 'قيمه']
def norm(s):
s = re.sub(r'[^؀-ۿ\w\s]', ' ', s or '')
s = re.sub(r'\s+', ' ', s).strip()
for a, b in (('أ', 'ا'), ('إ', 'ا'), ('آ', 'ا'), ('ى', 'ي'), ('ة', 'ه')):
s = s.replace(a, b)
return s
def stem(w):
return w[2:] if len(w) > 4 and w.startswith('ال') else w
STOP = {stem(norm(w)) for w in STOP_RAW}
def toks(s):
return {stem(w) for w in norm(s).split() if stem(w) not in STOP and len(stem(w)) > 2}
def env():
e = {}
for line in open(os.path.join(ROOT, '.env'), encoding='utf-8'):
line = line.strip()
if line and not line.startswith('#') and '=' in line:
k, v = line.split('=', 1)
e[k.strip()] = v.strip().strip('"').strip("'")
return e
def read_paths(xlsx):
wb = openpyxl.load_workbook(xlsx, data_only=True)
ws = wb['Sheet1'] if 'Sheet1' in wb.sheetnames else wb.worksheets[-1]
out, seen = [], set()
for row in ws.iter_rows(min_row=2, values_only=True):
cells = [('' if x is None else str(x).strip()) for x in row][:5]
path = [x for x in cells if x]
if len(path) < 2 or path[0] not in ROOT_MAP:
continue
key = tuple(path)
if key in seen:
continue
seen.add(key)
out.append(path)
return out
def next_code(cur, parent_code, allocated, width=2):
"""أصغر كود حر تحت أب معيّن — بيحسب حساب الأكواد المحجوزة في نفس الجولة."""
like = parent_code + '_' * width
cur.execute(
"SELECT account_code FROM chart_of_accounts WHERE account_code LIKE %s "
"AND LENGTH(account_code)=%s ORDER BY account_code", (like, len(parent_code) + width))
used = {r['account_code'] for r in cur.fetchall()} | allocated
for i in range(1, 10 ** width):
cand = parent_code + str(i).zfill(width)
if cand not in used:
allocated.add(cand)
return cand
raise RuntimeError('نفدت الأكواد تحت ' + parent_code)
def main():
ap = argparse.ArgumentParser()
ap.add_argument('xlsx')
ap.add_argument('--apply', action='store_true')
args = ap.parse_args()
e = env()
conn = pymysql.connect(host=e['DB_HOST'], port=int(e.get('DB_PORT', 3306)),
user=e['DB_USER'], password=e['DB_PASS'],
database=e['DB_NAME'], charset='utf8mb4')
cur = conn.cursor(pymysql.cursors.DictCursor)
cur.execute("SELECT id,account_code,name_ar,account_type,level,is_header,parent_id "
"FROM chart_of_accounts WHERE is_archived=0")
coa = cur.fetchall()
by_code = {a['account_code']: a for a in coa}
def find(name, atype, headers_only=None):
n, t = norm(name), toks(name)
pool = [a for a in coa if a['account_type'] == atype
and (headers_only is None or bool(a['is_header']) == headers_only)]
for a in pool:
if norm(a['name_ar']) == n:
return a
for a in pool:
if t and toks(a['name_ar']) == t:
return a
return None
paths = read_paths(args.xlsx)
created, reused, clashes = [], [], []
node_cache = {} # tuple(path prefix) -> node
allocated = set() # أكواد اتحجزت في نفس الجولة
def make(name, atype, nature, parent, is_leaf):
code = next_code(cur, parent['account_code'], allocated)
lvl = (parent['level'] or 1) + 1
if args.apply:
cur.execute(
"INSERT INTO chart_of_accounts (account_code,name_ar,name_en,account_type,account_nature,"
"parent_id,level,level_name,is_header,is_active,created_at,updated_at)"
" VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,1,NOW(),NOW())",
(code, name, '', atype, nature, parent['id'], lvl,
'فرعي' if is_leaf else 'مجموعة', 0 if is_leaf else 1))
nid = cur.lastrowid
else:
nid = -1
node = {'id': nid, 'account_code': code, 'name_ar': name, 'account_type': atype,
'level': lvl, 'is_header': 0 if is_leaf else 1}
coa.append(node)
by_code[code] = node
created.append((code, name, 'حساب' if is_leaf else 'مجموعة', parent['account_code']))
return node
conn.begin()
try:
for path in paths:
root = path[0]
code_pre, atype, nature = ROOT_MAP[root]
# الأب الابتدائي = جذر الملف مربوط على الشجرة القائمة
parent = by_code[PARENT_OVERRIDE.get((root, path[1] if len(path) > 1 else ''),
DEFAULT_PARENT[root])]
for depth in range(1, len(path)):
key = tuple(path[:depth + 1])
if key in node_cache:
parent = node_cache[key]
continue
name = path[depth]
is_leaf = depth == len(path) - 1
hit = find(name, atype, headers_only=(False if is_leaf else True))
if hit is None and not is_leaf:
hit = find(name, atype, headers_only=None)
if hit is not None:
node_cache[key] = hit
reused.append((' > '.join(path[:depth + 1]), hit['account_code'], hit['name_ar']))
parent = hit
continue
clash = next((a for a in coa if norm(a['name_ar']) == norm(name)
and a['account_type'] != atype), None)
if clash:
clashes.append((name, atype, clash['account_code'], clash['name_ar'], clash['account_type']))
node = make(name, atype, nature, parent, is_leaf)
node_cache[key] = node
parent = node
if args.apply:
conn.commit()
else:
conn.rollback()
except Exception as ex:
conn.rollback()
print('ROLLBACK:', ex, file=sys.stderr)
return 1
print(f"مسارات الملف: {len(paths)}")
print(f"موجود بالفعل (اتساب زي ما هو): {len(reused)}")
print(f"هيتعمل / اتعمل: {len(created)}")
if clashes:
print(f"\n⚠ أسماء متطابقة بنوع مختلف — اتعمل حساب جديد بدل ما يتربط بالغلط ({len(clashes)}):")
for leaf, want, code, nm, got in clashes[:15]:
print(f" {leaf[:34]:36} المطلوب {want:9} | الموجود {code} {nm[:24]} ({got})")
print("\nعيّنة من الجديد:")
for code, nm, kind, par in created[:25]:
print(f" {code:12} {kind:7} {nm[:38]:40} تحت {par}")
if not args.apply:
print("\n(عرض فقط — استخدم --apply للتنفيذ)")
return 0
if __name__ == '__main__':
sys.exit(main())
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment