Commit 29874daf authored by DevPilot's avatar DevPilot

docs+tools: ملحق الدليل لأداة تكرار الرقم القومي وتوضيح «برسم التحصيل»

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

- tools/pdf/fonts: خطوط Amiri اتحطّت جوّه المستودع. كانت في /tmp واتمسحت
  مع تنضيف السيرفر، فالدليل ما كانش ينفع يتبني تاني من غيرها.

- tools/pdf/build_addendum.py: بيولّد صفحات ملحق بنفس شكل الدليل ويلزقها
  في آخره — عشان أي أداة جديدة تتوثّق من غير ما نعيد تصوير الـ67 شاشة.

- tools/fix_player_duplicates.py: بينضّف التكرار من خلال نفس الـ routes
  بتاعة الشاشة (نفس CSRF ونفس الكنترولر)، وبيدمج بس المجموعات اللي كل
  الأسماء فيها متطابقة ويسيب الباقي للموظف.
parent 5b2dbb02
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
تنضيف تكرار الرقم القومي في sa_players عن طريق شاشة الأداة نفسها.
بيشتغل على نفس الـ routes اللي الموظف بيستخدمها (نفس CSRF، نفس الكنترولر)،
فاللي بيحصل هنا هو بالظبط اللي بيحصل لما حد يدوس الأزرار في الشاشة.
بيدمج بس المجموعات اللي كل الأسماء فيها متطابقة. أي مجموعة فيها أسماء
مختلفة بيسيبها للموظف — لأن دي غالباً رقم قومي متكتب غلط لشخص تاني،
والدمج فيها هيبوّظ بيانات شخصين.
python3 tools/fix_player_duplicates.py --password '...' [--apply]
من غير --apply بيعرض هيعمل إيه بس (dry run).
"""
import argparse
import os
import re
import sys
import html as htmlmod
import requests
DEFAULT_BASE = 'https://clubmanagement.caprover.al-arcade.com'
def norm_name(s):
s = re.sub(r'\s+', ' ', (s or '')).strip()
for a, b in (('أ', 'ا'), ('إ', 'ا'), ('آ', 'ا'), ('ى', 'ي'), ('ة', 'ه')):
s = s.replace(a, b)
return s
def login(sess, base, user, pwd):
r = sess.get(f'{base}/login', timeout=30)
m = re.search(r'name="_csrf_token"\s+value="([^"]+)"', r.text)
token = m.group(1) if m else ''
r = sess.post(f'{base}/login',
data={'username': user, 'password': pwd, '_csrf_token': token},
timeout=30, allow_redirects=True)
return '/login' not in r.url
def fetch_groups(sess, base):
"""بيقرا الشاشة ويطلّع المجموعات زي ما هي معروضة للموظف."""
r = sess.get(f'{base}/sa/players/duplicates', timeout=45)
r.raise_for_status()
page = r.text
if 'View not found' in page or 'Exception:' in page:
raise RuntimeError('الشاشة بترجع خطأ: ' + re.sub(r'\s+', ' ', page)[:300])
token = ''
m = re.search(r'name="_csrf_token"\s+value="([^"]+)"', page)
if m:
token = m.group(1)
groups = []
for form in re.findall(r'<form method="POST" action="/sa/players/duplicates/merge">(.*?)</form>', page, re.S):
rows = []
for tr in re.findall(r'<tr>(.*?)</tr>', form, re.S):
pid = re.search(r'name="keep_id" value="(\d+)"', tr)
if not pid:
continue
name = re.search(r'<td>\s*<a href="/sa/players/\d+"[^>]*>(.*?)</a>', tr, re.S)
links = re.search(r'<strong style="color:#0D7377;">(\d+)</strong>', tr)
rows.append({
'id': pid.group(1),
'name': htmlmod.unescape(re.sub(r'<[^>]+>', '', name.group(1)).strip()) if name else '',
'links': int(links.group(1)) if links else 0,
})
if len(rows) > 1:
groups.append(rows)
return groups, token
def main():
ap = argparse.ArgumentParser()
ap.add_argument('--base', default=DEFAULT_BASE)
ap.add_argument('--user', default='admin')
ap.add_argument('--password', default=os.environ.get('SMOKE_PASSWORD', ''))
ap.add_argument('--apply', action='store_true', help='نفّذ الدمج فعلاً')
args = ap.parse_args()
if not args.password:
print('set --password or SMOKE_PASSWORD', file=sys.stderr)
return 2
sess = requests.Session()
sess.headers['User-Agent'] = 'club-dupe-fixer/1.0'
if not login(sess, args.base, args.user, args.password):
print('login failed', file=sys.stderr)
return 2
print('logged in\n')
merged = skipped = 0
for _ in range(30):
groups, token = fetch_groups(sess, args.base)
if not groups:
print('مفيش مجموعات مكررة')
break
target = None
for rows in groups:
if len({norm_name(r['name']) for r in rows}) == 1:
target = rows
break
if target is None:
skipped = len(groups)
print(f'\nباقي {skipped} مجموعة كل واحدة فيها أسماء مختلفة — سايبها للموظف يقرر:')
for rows in groups:
names = ' | '.join(f"#{r['id']} {r['name']}" for r in rows)
print(f' {names}')
break
# نحتفظ بالنسخة اللي عليها سجلات أكتر، ولو متساويين بأقدم واحدة
keep = sorted(target, key=lambda r: (-r['links'], int(r['id'])))[0]
losers = [r for r in target if r['id'] != keep['id']]
print(f"دمج «{keep['name']}» — نحتفظ بـ #{keep['id']} ({keep['links']} سجل) "
f"وندمج فيه {', '.join('#' + r['id'] for r in losers)}")
if not args.apply:
print(' (تجربة فقط — استخدم --apply للتنفيذ)')
break
r = sess.post(f'{args.base}/sa/players/duplicates/merge',
data=[('_csrf_token', token), ('keep_id', keep['id'])]
+ [('merge_ids[]', r_['id']) for r_ in losers],
timeout=60, allow_redirects=True)
body = r.text
ok = re.search(r'(تم دمج[^<]{0,90})', body)
err = re.search(r'(فشل الدمج[^<]{0,90}|لا يمكن الدمج[^<]{0,90})', body)
if err:
print(' خطأ:', err.group(1).strip())
break
print(' ->', ok.group(1).strip() if ok else 'تم')
merged += 1
print(f'\nتم دمج {merged} مجموعة، ومتسابة {skipped} للمراجعة اليدوية')
return 0
if __name__ == '__main__':
sys.exit(main())
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
ملحق يتضاف على دليل المحاسبة والأصول والخزينة.
بيتستخدم لما نضيف أداة أو قاعدة جديدة ومش محتاجين نعيد تصوير الدليل كله.
بيتولّد صفحات نصية بنفس شكل الدليل وبتتلزق في آخره.
"""
import os
import sys
import arabic_reshaper
from bidi.algorithm import get_display
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import cm
from reportlab.lib import colors
from reportlab.lib.styles import ParagraphStyle
from reportlab.lib.enums import TA_RIGHT, TA_CENTER
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.platypus import (SimpleDocTemplate, Paragraph, Spacer, Table,
TableStyle, KeepTogether)
from reportlab.pdfbase.pdfmetrics import stringWidth
HERE = os.path.dirname(os.path.abspath(__file__))
F = os.path.join(HERE, 'fonts')
pdfmetrics.registerFont(TTFont('AR', f'{F}/Amiri-Regular.ttf'))
pdfmetrics.registerFont(TTFont('AR-B', f'{F}/Amiri-Bold.ttf'))
resh = arabic_reshaper.ArabicReshaper()
DARK = colors.HexColor('#111827')
GRAY = colors.HexColor('#6B7280')
LINE = colors.HexColor('#E5E7EB')
MARGIN = 2.0 * cm
W = A4[0] - 2 * MARGIN
S = {
'band': ParagraphStyle('b', fontName='AR-B', fontSize=18, leading=32,
textColor=colors.white, alignment=TA_RIGHT),
'h2': ParagraphStyle('h', fontName='AR-B', fontSize=13.5, leading=26,
textColor=DARK, alignment=TA_RIGHT, spaceAfter=2),
'cap': ParagraphStyle('c', fontName='AR', fontSize=10.5, leading=20,
textColor=colors.HexColor('#374151'), alignment=TA_RIGHT, spaceAfter=7),
'note': ParagraphStyle('n', fontName='AR', fontSize=10.5, leading=20,
textColor=colors.HexColor('#92400E'), alignment=TA_RIGHT, spaceAfter=7),
'path': ParagraphStyle('p', fontName='Helvetica', fontSize=8.5, leading=14,
textColor=GRAY, alignment=TA_RIGHT, spaceAfter=6),
}
def ar_wrap(t, style, width):
"""تقسيم لسطور قبل العكس — لو عكسنا الأول السطور بتطلع مقلوبة."""
shaped = resh.reshape(t or '')
lines, cur = [], ''
for w in shaped.split(' '):
trial = (cur + ' ' + w).strip()
if stringWidth(trial, style.fontName, style.fontSize) <= width or not cur:
cur = trial
else:
lines.append(cur)
cur = w
if cur:
lines.append(cur)
return '<br/>'.join(get_display(l) for l in lines)
def P(t, st='cap', width=None):
style = S[st]
if st in ('cap', 'note'):
# الإطار بيضيف 6pt من كل ناحية، وصندوق الملاحظة بيضيف 12pt كمان
return Paragraph(ar_wrap(t, style, (width if width else W) - 16), style)
return Paragraph(get_display(resh.reshape(t or '')), style)
def band(title, hexcolor):
t = Table([[P(title, 'band')]], colWidths=[W])
t.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, -1), colors.HexColor(hexcolor)),
('TOPPADDING', (0, 0), (-1, -1), 12), ('BOTTOMPADDING', (0, 0), (-1, -1), 12),
('LEFTPADDING', (0, 0), (-1, -1), 16), ('RIGHTPADDING', (0, 0), (-1, -1), 16)]))
return t
def note_box(text, bg='#FFFBEB', border='#FDE68A'):
t = Table([[P(text, 'note', width=W - 24)]], colWidths=[W])
t.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, -1), colors.HexColor(bg)),
('BOX', (0, 0), (-1, -1), 0.7, colors.HexColor(border)),
('TOPPADDING', (0, 0), (-1, -1), 10), ('BOTTOMPADDING', (0, 0), (-1, -1), 10),
('LEFTPADDING', (0, 0), (-1, -1), 12), ('RIGHTPADDING', (0, 0), (-1, -1), 12)]))
return t
def footer(canvas, doc):
canvas.saveState()
canvas.setFont('AR', 8)
canvas.setFillColor(GRAY)
canvas.setFont('AR', 7.5)
canvas.drawRightString(A4[0] - MARGIN, 1.05 * cm,
get_display(resh.reshape('دليل المحاسبة والأصول والخزينة — ملحق')))
canvas.restoreState()
SECTIONS = [
('ملحق: أدوات سلامة البيانات', '#BE185D', [
('تكرار الرقم القومي للاعبين', '/sa/players/duplicates', [
('cap', 'نفس الشخص ممكن يكون اتسجّل أكتر من مرة بنفس الرقم القومي — مرة كعضو ومرة '
'كغير عضو مثلاً. كل نسخة بتبقى ليها اشتراكاتها وحضورها وبطاقتها ومديونيتها '
'لوحدها، فصورة اللاعب المالية بتتقسم على نسختين ومحدش واخد باله.'),
('cap', 'الشاشة بتعرض كل مجموعة ليها نفس الرقم القومي، وقدّام كل نسخة عدد السجلات '
'المرتبطة بيها (اشتراكات، حضور، بطاقات، مستندات، تسجيلات). الرقم ده بيقول لك '
'أنهي نسخة عليها الشغل الحقيقي — وهي اللي المفروض تحتفظ بيها.'),
('cap', 'طريقة الاستخدام: اختار النسخة اللي هتفضل من عمود «احتفظ به»، وعلّم على النسخ '
'اللي هتتدمج فيها من عمود «ادمجه»، وبعدين اضغط «دمج المحدد». كل السجلات '
'المرتبطة هتتحوّل للنسخة المختارة، والنسخ التانية هتتأرشف بملاحظة توضّح '
'اتدمجت في مين — مفيش حذف نهائي خالص.'),
('note', 'مهم جداً: مش كل تكرار هو نفس الشخص. أحياناً حد بيكون كتب رقم قومي غلط لشخص '
'تاني، فيبان إن الاتنين ليهم نفس الرقم وهما مختلفين. الشاشة بتنبّهك لما '
'الأسماء تكون مختلفة. في الحالة دي اضغط «شخص مختلف» عشان تفرّغ الرقم الغلط، '
'وبعدين افتح ملف اللاعب وأدخل رقمه الصحيح. متدمجش أبداً أشخاص بأسماء مختلفة.'),
('cap', 'الشاشة كمان بقت وقاية مش علاج بس: شاشة تسجيل لاعب جديد وشاشة التعديل بقوا '
'يرفضوا أي رقم قومي مسجّل للاعب تاني، وبيقولوا لك اسم اللاعب ورقمه عشان تفتح '
'ملفه بدل ما تسجّله من جديد.'),
]),
]),
('ملحق: توضيح مصطلح محاسبي', '#0D7377', [
('«برسم التحصيل» مش رسوم', '/accounting/instruments', [
('cap', 'لما بتسلّم شيك للبنك عشان يحصّله، القيد بيتقال عليه في العُرف البنكي '
'«إيداع ورقة قبض برسم التحصيل». كلمة «برسم» هنا معناها «بغرض» أو «عشان» — '
'يعني الشيك اتسلّم للبنك بغرض تحصيله. مالهاش أي علاقة برسوم أو عمولة.'),
('cap', 'قيد تحصيل الشيك بيكون بقيمة الشيك كاملة من غير أي خصم: '
'من ح/ البنك بقيمة الشيك، إلى ح/ شيكات تحت التحصيل بنفس القيمة. '
'لو النادي دفع عمولة تحصيل للبنك فعلاً، دي بتتسجّل في قيد منفصل على حساب '
'مصاريف بنكية — مش بتتخصم من قيمة الشيك.'),
('note', 'المسمّى اتغيّر في النظام لـ «تسليم ورقة قبض للبنك للتحصيل» عشان يبقى واضح '
'ومحدش يفتكر إن فيه رسم اتخصم.'),
]),
]),
]
def build_addendum(path):
doc = SimpleDocTemplate(path, pagesize=A4,
rightMargin=MARGIN, leftMargin=MARGIN,
topMargin=1.8 * cm, bottomMargin=1.8 * cm)
st = []
for title, color, items in SECTIONS:
st += [band(title, color), Spacer(1, 0.45 * cm)]
for label, route, blocks in items:
blk = [P(label, 'h2'), Paragraph(route, S['path'])]
for kind, text in blocks:
blk.append(note_box(text) if kind == 'note' else P(text, 'cap'))
blk.append(Spacer(1, 0.6 * cm))
st.append(KeepTogether(blk))
doc.build(st, onLaterPages=footer, onFirstPage=footer)
def main():
guide = sys.argv[1] if len(sys.argv) > 1 else \
os.path.join(HERE, '..', '..', 'docs', 'دليل_المحاسبة_والأصول_والخزينة.pdf')
guide = os.path.abspath(guide)
tmp = '/tmp/_addendum.pdf'
build_addendum(tmp)
from pypdf import PdfReader, PdfWriter
w = PdfWriter()
base = PdfReader(guide)
for p in base.pages:
w.add_page(p)
before = len(base.pages)
for p in PdfReader(tmp).pages:
w.add_page(p)
with open(guide, 'wb') as fh:
w.write(fh)
print(f'{guide}\n{before} -> {len(w.pages)} صفحة')
if __name__ == '__main__':
main()
...@@ -18,7 +18,7 @@ from reportlab.pdfbase.ttfonts import TTFont ...@@ -18,7 +18,7 @@ from reportlab.pdfbase.ttfonts import TTFont
from reportlab.platypus import (SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, from reportlab.platypus import (SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle,
PageBreak, HRFlowable, Image as RLImage, KeepTogether) PageBreak, HRFlowable, Image as RLImage, KeepTogether)
F = "/tmp/fin_fonts" F = os.path.join(os.path.dirname(os.path.abspath(__file__)), "fonts")
pdfmetrics.registerFont(TTFont("AR", f"{F}/Amiri-Regular.ttf")) pdfmetrics.registerFont(TTFont("AR", f"{F}/Amiri-Regular.ttf"))
pdfmetrics.registerFont(TTFont("AR-B", f"{F}/Amiri-Bold.ttf")) pdfmetrics.registerFont(TTFont("AR-B", f"{F}/Amiri-Bold.ttf"))
pdfmetrics.registerFont(TTFont("AR-S", f"{F}/Amiri-Bold.ttf")) pdfmetrics.registerFont(TTFont("AR-S", f"{F}/Amiri-Bold.ttf"))
......
...@@ -59,6 +59,12 @@ SECTIONS = [ ...@@ -59,6 +59,12 @@ SECTIONS = [
"بتدوّر على العمليات اللي المفروض يكون ليها أثر محاسبي وما حصلش. أهم جزء فيها الربط المكسور: " "بتدوّر على العمليات اللي المفروض يكون ليها أثر محاسبي وما حصلش. أهم جزء فيها الربط المكسور: "
"لاعب متسجّل برقم مش رقم عضو حقيقي، فالدين مش بيظهر على حساب حد. " "لاعب متسجّل برقم مش رقم عضو حقيقي، فالدين مش بيظهر على حساب حد. "
"زرار «صلّح الربط» بيفتح ملف اللاعب نفسه وتدوّر على العضو بالاسم وتختاره."), "زرار «صلّح الربط» بيفتح ملف اللاعب نفسه وتدوّر على العضو بالاسم وتختاره."),
("/sa/players/duplicates", "تكرار الرقم القومي",
"نفس الشخص ممكن يكون متسجّل أكتر من مرة — مرة كعضو ومرة كغير عضو — وكل نسخة ليها اشتراكاتها "
"وحضورها وبطاقتها لوحدها، فالصورة المالية بتتقسم. الشاشة بتعرض كل مجموعة مكررة وعدد السجلات "
"المرتبطة بكل نسخة عشان تعرف النسخة اللي عليها شغل فعلي. "
"مهم: مش كل تكرار هو نفس الشخص — أحياناً حد كتب رقم قومي غلط لشخص تاني. "
"لو نفس الاسم اختار «دمج»، ولو أشخاص مختلفين اختار «شخص مختلف» عشان تفرّغ الرقم الغلط وتصحّحه بعدين."),
]), ]),
("البنوك والشيكات والأوراق التجارية", "#7C3AED", [ ("البنوك والشيكات والأوراق التجارية", "#7C3AED", [
......
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