Commit 413f6fd1 authored by DevPilot's avatar DevPilot

fix(accounting): ض.ق.م المدخلات اتنقلت تحت الالتزامات + أداة وكشف تحويل الدليل للشجرة الجديدة

ض.ق.م:
- حساب «ضريبة القيمة المضافة — مدخلات» كان asset تحت الأرصدة المدينة،
  وكان حساب نظامي فالشاشة مكانتش بتسمح بتعديله. اتعمل بديله
  2308040418 تحت «جاري مصلحة الضرائب» بنوع liability، واتنقل له القيد
  والربط ورصيد الحساب، والقديم اتأرشف بملاحظة توضّح راح فين.
- دلوقتي مفيش ولا حساب ض.ق.م تحت الأصول — كل الضرايب متجمّعة تحت
  الالتزامات في مكان واحد.
- الـ fallback في الكود (procurement:input_tax) اتظبط على الكود الجديد،
  والقاعدة اللي كانت بتجبر «مدخلات» تبقى أصول اتشالت من أداة الاستيراد
  عشان تتماشى مع القرار.
- الميزان 513,122,428.03 مدين = دائن، ما اتغيّرش.

تحويل الدليل بالكامل للشجرة الجديدة:
- اتعملت أداة tools/coa_switch_to_new_tree.py بتنقل كل حساب قديم لما
  يقابله في الشجرة الجديدة مع قيوده وربطه، وبتأرشف القديم — من غير ما
  تمسح ولا تغيّر مبلغ أي قيد.
- تشغيلها كعرض بس أظهر إن التحويل **مينفعش يتم دلوقتي**: من ٢١٠ حساب
  شغّال، ١٠ بس موجودين في الشجرة الجديدة و٢ ليهم مقابل واضح، والباقي
  ١٩٨ ملهمش مقابل — وفيهم الحسابات اللي ماسكة الأرصدة كلها فعلياً
  (الصندوق ٩٥٢ قيد، الأقساط ٢٤٥، عضويات جديدة ١١٩، أعضاء النادي مدينون،
  البنوك، الأصول الثابتة، حقوق الملكية). تنفيذ التحويل كان هيركنهم في
  حساب «تحتاج إعادة تصنيف» ويضيّع معنى الميزانية.
- السبب إن ملف الشجرة تشغيلي (خزن الموظفين، الأكاديميات، الإيجارات) ومش
  شامل البنود النظامية دي، فمش بديل كامل للدليل الحالي.
- اتعمل كشف docs/ربط_الحسابات_القديمة_بالشجرة_الجديدة.xlsx فيه الـ٢٠٩
  حساب بأرصدتها وعدد قيودها وعمود فاضي لتحديد المقابل. أول ما يترجع
  متملّي، الأداة بتخلّص التحويل في خطوة واحدة.
parent 3dc33af2
......@@ -1438,7 +1438,7 @@ final class AccountingIntegrationService
// the legacy codes here could never post. These pointers resolve to the
// postable leaves and stay re-mappable from the UI.
$inventoryAccountId = PostingRouter::accountFor('procurement:inventory_receipt', AccountCodes::INVENTORY, 'accrual');
$inputTaxAccountId = PostingRouter::accountFor('procurement:input_tax', '12041106', 'accrual');
$inputTaxAccountId = PostingRouter::accountFor('procurement:input_tax', '2308040418', 'accrual');
$apAccountId = PostingRouter::accountFor('procurement:payable', '230601002', 'accrual');
if ($inventoryAccountId === null || $apAccountId === null) {
......
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
تحويل الدليل بالكامل للشجرة الجديدة، ونقل كل اللي فات عليها.
الفكرة: الشجرة اللي في ملف Accounts تبقى هي الدليل الفعلي. كل حساب قديم
عليه أرصدة أو مربوط بقاعدة ترحيل بيتنقل لما يقابله في الشجرة الجديدة،
وقيوده بتتحوّل معاه، وبعدين الحساب القديم بيتأرشف.
قاعدتين ثابتتين:
١) مفيش قيد بيتمسح ولا بيتغيّر مبلغه — بيتغيّر الحساب اللي معلّق عليه بس،
فإجمالي المدين والدائن لازم يفضل زي ما هو بالظبط.
٢) الحساب اللي مفيش له مقابل واضح في الشجرة الجديدة **مبيتخمّنش**.
بيتنقل تحت «أرصدة قديمة — تحتاج إعادة تصنيف» عشان يبقى ظاهر قدام
المحاسب ويعيد تصنيفه بإيده، بدل ما يتحط في حساب غلط بالتخمين.
python3 tools/coa_switch_to_new_tree.py "ملف.xlsx" # عرض الخطة
python3 tools/coa_switch_to_new_tree.py "ملف.xlsx" --apply # التنفيذ
"""
import argparse
import os
import re
import sys
import pymysql
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(ROOT, 'tools'))
from import_coa_tree import norm, toks, read_paths, ROOT_MAP, semantic_type # noqa: E402
PARK_CODE = '1999'
PARK_NAME = 'أرصدة قديمة — تحتاج إعادة تصنيف'
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('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)
# ── ١. أسماء الشجرة الجديدة (اللي اتعملت بالاستيراد) ──
paths = read_paths(args.xlsx)
new_names = {}
for p in paths:
leaf = p[-1]
atype = semantic_type(leaf, ROOT_MAP[p[0]][1])
new_names.setdefault((norm(leaf), atype), p)
cur.execute("SELECT id,account_code,name_ar,account_type,is_header,is_archived,parent_id "
"FROM chart_of_accounts")
coa = cur.fetchall()
by_id = {a['id']: a for a in coa}
# حسابات الشجرة الجديدة = اللي اسمها موجود في الملف ومش مؤرشف
new_tree = {}
for a in coa:
if a['is_archived']:
continue
k = (norm(a['name_ar']), a['account_type'])
if k in new_names:
new_tree.setdefault(k, a)
# ── ٢. الحسابات اللي شغالة فعلاً ──
cur.execute("""SELECT coa.*,
(SELECT COUNT(*) FROM journal_entry_lines l WHERE l.account_id=coa.id) posts,
(SELECT COUNT(*) FROM revenue_posting_rule_lines l WHERE l.account_id=coa.id) rules,
(SELECT COUNT(*) FROM bank_accounts b WHERE b.gl_account_id=coa.id) banks,
(SELECT COUNT(*) FROM treasuries t WHERE t.gl_account_id=coa.id) treas
FROM chart_of_accounts coa
WHERE coa.is_archived=0 AND (
coa.id IN (SELECT account_id FROM journal_entry_lines)
OR coa.id IN (SELECT account_id FROM revenue_posting_rule_lines)
OR coa.id IN (SELECT gl_account_id FROM bank_accounts WHERE gl_account_id IS NOT NULL)
OR coa.id IN (SELECT gl_account_id FROM treasuries WHERE gl_account_id IS NOT NULL)
OR coa.is_system=1)""")
in_use = cur.fetchall()
mapped, parked, already = [], [], []
for a in in_use:
key = (norm(a['name_ar']), a['account_type'])
tgt = new_tree.get(key)
if tgt and tgt['id'] == a['id']:
already.append(a) # هو نفسه جزء من الشجرة الجديدة
elif tgt:
mapped.append((a, tgt)) # له مقابل واضح
else:
parked.append(a) # محتاج إعادة تصنيف بإيد المحاسب
print(f"حسابات شغالة: {len(in_use)}")
print(f" أصلاً في الشجرة الجديدة: {len(already)}")
print(f" ليها مقابل واضح (هتتنقل): {len(mapped)}")
print(f" محتاجة إعادة تصنيف (هتتركن): {len(parked)}")
print("\n=== هتتنقل ===")
for a, t in mapped[:25]:
print(f" {a['account_code']:12} {a['name_ar'][:30]:32} -> {t['account_code']:12} {t['name_ar'][:28]}")
if len(mapped) > 25:
print(f" ... و{len(mapped)-25} كمان")
print("\n=== هتتركن تحت «أرصدة قديمة» (عيّنة) ===")
for a in parked[:20]:
print(f" {a['account_code']:12} {a['account_type']:10} {a['name_ar'][:34]:36} قيود={a['posts']:4} قواعد={a['rules']}")
if len(parked) > 20:
print(f" ... و{len(parked)-20} كمان")
if not args.apply:
print("\n(عرض فقط — استخدم --apply للتنفيذ)")
return 0
# ── ٣. التنفيذ ──
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""")
before = cur.fetchone()
w = conn.cursor()
conn.begin()
try:
for a, t in mapped:
w.execute("UPDATE journal_entry_lines SET account_id=%s WHERE account_id=%s", (t['id'], a['id']))
w.execute("UPDATE revenue_posting_rule_lines SET account_id=%s WHERE account_id=%s", (t['id'], a['id']))
w.execute("UPDATE bank_accounts SET gl_account_id=%s WHERE gl_account_id=%s", (t['id'], a['id']))
w.execute("UPDATE treasuries SET gl_account_id=%s WHERE gl_account_id=%s", (t['id'], a['id']))
w.execute("UPDATE account_balances SET account_id=%s WHERE account_id=%s", (t['id'], a['id']))
w.execute("UPDATE chart_of_accounts SET is_system=0, is_archived=1, is_active=0, archived_at=NOW(), "
"name_ar=CONCAT(name_ar,' (اندمج في ',%s,')') WHERE id=%s", (t['account_code'], a['id']))
if a['is_system']:
w.execute("UPDATE chart_of_accounts SET is_system=1 WHERE id=%s", (t['id'],))
conn.commit()
except Exception as ex:
conn.rollback()
print('ROLLBACK:', ex, file=sys.stderr)
return 1
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()
print(f"\nالميزان قبل: {before['d']:,.2f} = {before['cr']:,.2f}")
print(f"الميزان بعد: {after['d']:,.2f} = {after['cr']:,.2f}")
print("لم يتغيّر ✔" if before == after else "⚠ اتغيّر — راجع فوراً")
return 0
if __name__ == '__main__':
sys.exit(main())
......@@ -83,8 +83,9 @@ def toks(s):
# الملف نفسه ممكن يحط بند تحت جذر غلط. ضريبة «المدخلات» مثلاً أصل (بتسترد
# من المصلحة) مش التزام، حتى لو الملف حاططها تحت الالتزامات. القواعد دي
# بتصحّح النوع حسب معنى الاسم، وبتمنع إن غلطة في الملف تدخل الدليل.
# ملاحظة: ضريبة المدخلات متصنّفة التزام هنا بقرار صريح من إدارة الحسابات،
# عشان كل الضرايب تتجمّع تحت «جاري مصلحة الضرائب» في مكان واحد.
SEMANTIC_TYPE = [
(('مدخلات',), 'asset'),
(('ضريبة مؤجلة', 'أصول ضريبة'), 'asset'),
(('سلف من الغير', 'سلف الغير'), 'liability'),
]
......
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