Commit c9a8e115 authored by DevPilot's avatar DevPilot

feat(accounting): شجرة Accounts V0.1 بقت هي الدليل — تطعيم التاريخ كله جواها وأرشفة الميت

تنفيذ قرار اعتماد شجرة الملف كدليل الحسابات الفعلي، من غير ما قيد واحد
يتمسح أو يتغيّر مبلغه (tools/coa_adopt_new_tree.py):

- الحسابات القديمة اللي عليها أرصدة أو ربط اتطعّمت جوه مجموعات الشجرة
  الجديدة بنفس كودها ونفس id (نقل parent_id بس) — فكل الـ fallbacks
  الكودية في محرك الترحيل فضلت شغالة زي ما هي. ١٠٤ تطعيمة: الصناديق
  والخزن تحت «الخزنة الرئيسية كاش»، البنوك القديمة تحت «البنوك»، شيكات
  التحصيل والمرتدة وأوراق القبض تحت «أوراق قبض»، مديني الأعضاء تحت
  «الحسابات المدينة»، الضرايب كلها تحت «أرصدة دائنة حكومية»، الدائنون
  تحت «دائنون وأرصدة دائنة أخرى»، إيرادات العضويات والأقساط تحت
  «إيرادات عضويات»، الغرامات تحت «غرامات»، الفوائد تحت «الفوائد
  البنكية»، وباقي إيرادات النادي تحت «ايرادات مختلفة اخرى»، ومصاريف
  الأنشطة القديمة اتدمجت في «مصاريف الاكاديميات» بأسماء الملف.
- ١٢ مكرر اتشال (ورقة جديدة فاضية ليها قريب قديم حي — القديم اخد اسم
  الملف ومكانه واحتفظ بتاريخه).
- ٧ هيدرات كان عليها قيود مباشرة — قيودها اتنقلت لورقة «رصيد سابق»
  تحتها، فقاعدة «الترحيل على الأوراق بس» بقت سليمة ١٠٠٪ لأول مرة.
- ٤٧٩ حساب قديم ميت (من غير أي قيود/ربط/بنوك/خزن) اتأرشف — مع حماية
  صريحة لأي كود متشفّر في كود الـ PHP كـ fallback عشان الترحيل
  الاحتياطي ما يتكسرش.

الدليل من ٩٨٧ لـ ٤٩٥ حساب نشط، كلهم جوه هيكل الملف.

التحقق النهائي:
  الميزان 513,130,428.03 مدين = دائن (ما اتغيّرش)
  قواعد الترحيل ٩٤/٩٤ سليمة · أيتام صفر · هيدرات عليها قيود صفر
  أكواد مكررة صفر · ولا بنك أو خزنة مربوطين بحساب مؤرشف
  كل شاشات المحاسبة والتقارير والقوائم بترد 200 على الموقع الحي
parent 413f6fd1
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
اعتماد شجرة Accounts V0.1 كدليل الحسابات الفعلي.
المبدأ: الشجرة الجديدة هي الهيكل. الحسابات القديمة اللي عليها أرصدة أو ربط
بتتطعّم جوّه المجموعة الجديدة الصح **بنفس كودها ونفس id** — يعني ولا قيد
بيتنقل ولا fallback كودي في المحرك بيتكسر. المكرر الفاضي بيتشال، والميت
بيتأرشف، والنتيجة شجرة واحدة هي بتاعة الملف ومعاها التاريخ كله في مكانه.
المراحل:
١) فك قيود الهيدرات: أي حساب رئيسي عليه قيود مباشرة، قيوده بتتنقل لورقة
«رصيد سابق» تحته — عشان قاعدة «الترحيل على الأوراق بس» تتصلح للأبد.
٢) إزالة المكرر: ورقة جديدة (من الاستيراد) فاضية تماماً وليها قريب قديم
حي بنفس المعنى — الجديدة بتتشال والقديمة بتاخد اسم الملف وتتطعّم.
٣) التطعيم: نقل الحسابات الحية تحت مجموعات الشجرة الجديدة (parent_id بس).
٤) إعادة حساب المستويات.
٥) الأرشفة: أي حساب مش من الشجرة الجديدة، ومفيش عليه ولا تحته أي بيانات،
بيتأرشف — فالشاشة تعرض شجرة الملف بس.
٦) تحقق: الميزان، قواعد الترحيل، الأيتام، الأكواد الثابتة في الكود.
python3 tools/coa_adopt_new_tree.py # عرض
python3 tools/coa_adopt_new_tree.py --apply # تنفيذ
"""
import argparse
import os
import re
import sys
from collections import defaultdict
import pymysql
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
NEW_TREE_SINCE = '2026-09-12' # كل اللي اتعمل من ساعة اعتماد شجرة الملف
# ── التطعيم: كود الحساب/الهيدر القديم -> كود المجموعة الجديدة ──
GRAFT = {
# الخزن والنقدية
'12060101': '1201', '12060102': '1201', '12060103': '1201',
'12060104': '1201', '12060105': '1201',
# البنوك
'12060201': '1210', '12060202': '121002', '12060203': '1210',
'12060204': '1210', '12060304': '1213',
# أوراق القبض والشيكات
'1207': '120302', '120302004': '120302',
'120301005': '120302',
# الحسابات المدينة
'120301003': '1211', '120301004': '1211', '120301006': '1211',
'120402': '1211', '120404': '1211', '120411': '1211', '120412': '1211',
'120209': '1212',
# مشاريع تحت التنفيذ
'11030102': '1104',
# الفائض ومجمعات الإهلاك
'210201': '2104', '230101': '2314',
# دائنون وأرصدة دائنة أخرى (هيدرات بتتنقل بعيالها)
'230601002': '2312', '230601003': '2312', '23060201': '2312',
'230801': '2312', '230802': '2312', '230803': '2312',
'230810': '2312', '230811': '2312', '230813': '2312', '230814': '2312',
'230815': '2312', '230816': '2312', '230817': '2312', '230821': '2312',
# أرصدة دائنة حكومية (الضرايب كلها)
'230804': '2313', '230806': '2313', '230812': '2313',
# المرتبات
'310101': '3102', '310103': '3102', '310104': '3102',
# مصاريف إدارية وعمومية (الهيدر القديم بعياله كله)
'3329': '3331',
# إيرادات العضويات
'410501': '4101', '410502': '4101', '410503': '4101', '410504': '4101',
'410505': '4101', '410506': '4101', '410507': '4101', '410508': '4101',
'410510': '4101',
# الغرامات (الكود بيتحدد بالاسم وقت التشغيل)
# مجموعة الغرامات في الشجرة الجديدة اتبنت على 4209 نفسه
'410509': '4209', '410511': '4209', '410512': '4209',
# الإيجارات
'410521': '4106', '4206': '4106', '410302': '4106',
# الفوائد البنكية
'420501': '4107', '420502': '4107', '410301': '4107',
# الباقي من إيرادات النادي القديمة -> ايرادات مختلفة اخرى
'410513': '4215', '410514': '4215', '410515': '4215', '410516': '4215',
'410517': '4215', '410518': '4215', '410519': '4215', '410520': '4215',
'410522': '4215', '410523': '4215', '410524': '4215', '410525': '4215',
'410526': '4215', '410527': '4215', '410528': '4215', '410529': '4215',
'410530': '4215', '410531': '4215', '410532': '4215', '410533': '4215',
'410534': '4215', '410535': '4215', '410537': '4215', '410538': '4215',
'410540': '4215',
# مصاريف الأنشطة القديمة -> مصاريف الأكاديميات
'332920': '3105', '332921': '3105', '332922': '3105', '332923': '3105',
'332924': '3105', '332925': '3105', '332926': '3105', '332928': '3105',
'332931': '3105', '332934': '3105', '332941': '3105',
# شركات التشغيل
'332916': '3103', '332917': '3103', '332918': '3103', '332929': '3103',
'332932': '3103', '332942': '3103',
}
# دمج يدوي: (كود جديد فاضي يتشال، كود قديم حي ياخد اسمه ومكانه)
MANUAL_DEDUP = [
('12030201', '120302004'), # اوراق القبض مستلمة من العملاء
('12030202', '120301005'), # شيكات مرتدة
('121113', '120404'), # عهد
('121004', '12060201'), # البنك الاهلى
]
# دمج قديم-في-قديم: نفس البنك نفس العملة مسجّل مرتين
OLD_MERGE = [
('12060301', '12060201'), # البنك الأهلي جنيه (توفير) -> (جاري)
]
# هيدر 1103 القديم: قيوده بتروح لابنه 11030102 وبيتأرشف
FOLD_HEADER_INTO_CHILD = [('1103', '11030102')]
ROOTS_KEEP = {'1', '11', '12', '2', '21', '22', '23', '3', '31', '32', '33', '34', '4', '41', '42'}
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
['ايراد', 'ايرادات', 'مصاريف', 'مصروفات', 'مصروف', 'اكاديمية', 'اكاديميه',
'نشاط', 'كرة', 'كره', 'على', 'علي', 'عن', 'من', 'في', 'فى', 'مع']}
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 main():
ap = argparse.ArgumentParser()
ap.add_argument('--apply', action='store_true')
args = ap.parse_args()
A = args.apply
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)
def load():
cur.execute("""SELECT c.*,
(SELECT COUNT(*) FROM journal_entry_lines l WHERE l.account_id=c.id) posts,
(SELECT COUNT(*) FROM revenue_posting_rule_lines l WHERE l.account_id=c.id) rules,
(SELECT COUNT(*) FROM bank_accounts b WHERE b.gl_account_id=c.id) banks,
(SELECT COUNT(*) FROM treasuries t WHERE t.gl_account_id=c.id) treas
FROM chart_of_accounts c WHERE c.is_archived=0""")
rows = cur.fetchall()
return rows, {r['account_code']: r for r in rows}, {r['id']: r for r in rows}
rows, by_code, by_id = load()
cur.execute("""SELECT COALESCE(SUM(jel.debit),0) d, COALESCE(SUM(jel.credit),0) cr
FROM journal_entry_lines jel JOIN journal_entries je ON je.id=jel.journal_entry_id
WHERE je.status='posted' AND je.is_archived=0""")
baseline = cur.fetchone()
print(f"الميزان المرجعي: {baseline['d']:,.2f} = {baseline['cr']:,.2f}\n")
w = conn.cursor()
conn.begin()
actions = defaultdict(int)
try:
graft = dict(GRAFT)
# ── ١) قيود على هيدرات -> ورقة «رصيد سابق» ──
cur.execute("""SELECT DISTINCT c.id,c.account_code,c.name_ar,c.account_type,c.account_nature,c.level
FROM chart_of_accounts c JOIN journal_entry_lines l ON l.account_id=c.id
WHERE c.is_header=1 AND c.is_archived=0""")
for h in cur.fetchall():
if any(h['account_code'] == f[0] for f in FOLD_HEADER_INTO_CHILD):
continue
# أصغر كود حر تحته
cur.execute("SELECT account_code FROM chart_of_accounts WHERE account_code LIKE %s",
(h['account_code'] + '%',))
used = {r['account_code'] for r in cur.fetchall()}
leaf_code = next(h['account_code'] + str(i).zfill(2) for i in range(90, 100)
if h['account_code'] + str(i).zfill(2) not in used)
print(f"هيدر عليه قيود: {h['account_code']} {h['name_ar'][:30]} -> ورقة {leaf_code}")
actions['header_fix'] += 1
if A:
w.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,'فرعي',0,1,NOW(),NOW())""",
(leaf_code, 'رصيد سابق — ' + h['name_ar'], h['account_type'],
h['account_nature'], h['id'], (h['level'] or 1) + 1))
w.execute("UPDATE journal_entry_lines SET account_id=%s WHERE account_id=%s",
(w.lastrowid, h['id']))
w.execute("UPDATE account_balances SET account_id=%s WHERE account_id=%s",
(w.lastrowid, h['id']))
# 1103 -> قيوده في ابنه
for hcode, ccode in FOLD_HEADER_INTO_CHILD:
h, ch = by_code.get(hcode), by_code.get(ccode)
if h and ch:
print(f"طي الهيدر {hcode} في ابنه {ccode}")
actions['fold'] += 1
if A:
w.execute("UPDATE journal_entry_lines SET account_id=%s WHERE account_id=%s", (ch['id'], h['id']))
w.execute("UPDATE account_balances SET account_id=%s WHERE account_id=%s", (ch['id'], h['id']))
# ── ٢) إزالة المكرر ──
newbies = [r for r in rows if str(r['created_at']) >= NEW_TREE_SINCE
and not r['posts'] and not r['rules'] and not r['banks'] and not r['treas']
and not r['is_header']]
olds = [r for r in rows if str(r['created_at']) < NEW_TREE_SINCE]
dedup = list(MANUAL_DEDUP)
for n in newbies:
nt = toks(n['name_ar'])
if not nt:
continue
twin = next((o for o in olds if o['account_type'] == n['account_type']
and not o['is_header'] and toks(o['name_ar']) == nt
and (o['posts'] or o['rules'] or o['banks'] or o['treas'])), None)
if twin and (n['account_code'], twin['account_code']) not in dedup:
dedup.append((n['account_code'], twin['account_code']))
for new_code, old_code in dedup:
n, o = by_code.get(new_code), by_code.get(old_code)
if not n or not o or n['id'] == o['id']:
continue
print(f"مكرر: يتشال {new_code} «{n['name_ar'][:26]}» وياخد مكانه {old_code} «{o['name_ar'][:26]}»")
actions['dedup'] += 1
graft.setdefault(old_code, by_id[n['parent_id']]['account_code'] if n['parent_id'] in by_id else None)
if A:
w.execute("UPDATE chart_of_accounts SET name_ar=%s WHERE id=%s", (n['name_ar'], o['id']))
w.execute("DELETE FROM chart_of_accounts WHERE id=%s AND is_header=0", (n['id'],))
# دمج قديم في قديم
for src_code, dst_code in OLD_MERGE:
s_, d_ = by_code.get(src_code), by_code.get(dst_code)
if not s_ or not d_:
continue
print(f"دمج {src_code} في {dst_code}")
actions['merge'] += 1
if A:
for t, col in (('journal_entry_lines', 'account_id'),
('revenue_posting_rule_lines', 'account_id'),
('account_balances', 'account_id'),
('bank_accounts', 'gl_account_id'),
('treasuries', 'gl_account_id')):
w.execute(f"UPDATE {t} SET {col}=%s WHERE {col}=%s", (d_['id'], s_['id']))
w.execute("""UPDATE chart_of_accounts SET is_archived=1,is_active=0,archived_at=NOW(),
name_ar=CONCAT(name_ar,' (اندمج في ',%s,')') WHERE id=%s""",
(dst_code, s_['id']))
# ── ٣) التطعيم ──
for old_code, parent_code in graft.items():
o, p = by_code.get(old_code), by_code.get(parent_code) if parent_code else None
if not o or not p or o['parent_id'] == p['id']:
continue
print(f"تطعيم {old_code} «{o['name_ar'][:30]}» تحت {parent_code} «{p['name_ar'][:24]}»")
actions['graft'] += 1
if A:
w.execute("UPDATE chart_of_accounts SET parent_id=%s WHERE id=%s", (p['id'], o['id']))
if not o['is_header'] and parent_code in ('121002',):
pass
# الأب لازم يبقى هيدر لو بقى له عيال
w.execute("UPDATE chart_of_accounts SET is_header=1 WHERE id=%s AND is_header=0", (p['id'],))
# 120411 بقت من غير عيال مستخدمين -> ورقة
if A:
w.execute("""UPDATE chart_of_accounts SET is_header=0 WHERE account_code='120411'
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM chart_of_accounts c2
WHERE c2.parent_id=chart_of_accounts.id AND c2.is_archived=0) x)""")
if A:
conn.commit()
conn.begin()
# ── ٤) إعادة حساب المستويات ──
if A:
for _ in range(8):
w.execute("""UPDATE chart_of_accounts c JOIN chart_of_accounts p ON p.id=c.parent_id
SET c.level=p.level+1 WHERE c.is_archived=0 AND c.level<>p.level+1""")
if w.rowcount == 0:
break
conn.commit()
conn.begin()
# ── ٥) الأرشفة: closure الحفاظ ──
rows2, by_code2, by_id2 = load()
keep = set()
def keep_up(rid):
g = 0
while rid and rid in by_id2 and g < 20:
if rid in keep:
break
keep.add(rid)
rid = by_id2[rid]['parent_id']
g += 1
# أي كود متشفّر في كود الـ PHP كـ fallback ما يتأرشفش — عشان الترحيل
# الاحتياطي ما يلاقيش الحساب مقفول في وشه
import json as _json
try:
php_codes = set(_json.load(open('/tmp/php_referenced_codes.json')))
except Exception:
php_codes = set()
for r in rows2:
if r['account_code'] in php_codes:
keep_up(r['id'])
if r['posts'] or r['rules'] or r['banks'] or r['treas'] or r['is_system']:
keep_up(r['id'])
elif str(r['created_at']) >= NEW_TREE_SINCE:
keep_up(r['id'])
elif r['account_code'] in ROOTS_KEEP:
keep.add(r['id'])
to_archive = [r for r in rows2 if r['id'] not in keep]
print(f"\nهيتأرشف من القديم الميت: {len(to_archive)} حساب")
for r in to_archive[:15]:
print(f" {r['account_code']:12} {r['name_ar'][:40]}")
if len(to_archive) > 15:
print(f" ... و{len(to_archive)-15} كمان")
actions['archive'] = len(to_archive)
if A and to_archive:
ids = [r['id'] for r in to_archive]
for i in range(0, len(ids), 200):
chunk = ids[i:i + 200]
ph = ','.join(['%s'] * len(chunk))
w.execute(f"UPDATE chart_of_accounts SET is_archived=1,is_active=0,archived_at=NOW() "
f"WHERE id IN ({ph})", chunk)
conn.commit()
elif A:
conn.commit()
except Exception as ex:
conn.rollback()
import traceback
traceback.print_exc()
print('ROLLBACK:', ex, file=sys.stderr)
return 1
# ── ٦) تحقق ──
print("\n=== الملخص ===")
for k, v in actions.items():
print(f" {k}: {v}")
if A:
cur.execute("""SELECT COALESCE(SUM(jel.debit),0) d, COALESCE(SUM(jel.credit),0) cr
FROM journal_entry_lines jel JOIN journal_entries je ON je.id=jel.journal_entry_id
WHERE je.status='posted' AND je.is_archived=0""")
after = cur.fetchone()
ok = after == baseline
print(f"\nالميزان بعد: {after['d']:,.2f} = {after['cr']:,.2f} — {'ما اتغيّرش ✔' if ok else '⚠ اتغيّر!'}")
cur.execute("""SELECT COUNT(*) n FROM revenue_posting_rule_lines l
JOIN chart_of_accounts c ON c.id=l.account_id
WHERE c.is_archived=0 AND c.is_header=0 AND c.is_active=1""")
okr = cur.fetchone()['n']
cur.execute("SELECT COUNT(*) n FROM revenue_posting_rule_lines")
print(f"قواعد الترحيل: {okr}/{cur.fetchone()['n']}")
cur.execute("""SELECT COUNT(*) n FROM chart_of_accounts c LEFT JOIN chart_of_accounts p ON p.id=c.parent_id
WHERE c.is_archived=0 AND c.parent_id IS NOT NULL AND (p.id IS NULL OR p.is_archived=1)""")
print(f"أيتام (أب مؤرشف/مفقود): {cur.fetchone()['n']}")
cur.execute("""SELECT COUNT(DISTINCT c.id) n FROM chart_of_accounts c
JOIN journal_entry_lines l ON l.account_id=c.id WHERE c.is_header=1 AND c.is_archived=0""")
print(f"هيدرات عليها قيود: {cur.fetchone()['n']}")
cur.execute("SELECT COUNT(*) n FROM chart_of_accounts WHERE is_archived=0")
print(f"حسابات نشطة: {cur.fetchone()['n']}")
return 0 if ok else 1
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