حسابداری برای توسعه دهندگان جنگو: دوره های حسابداری

Summarize this content to 400 words in Persian Lang
مقدمه
به عنوان یک توسعه دهنده جنگو، احتمالاً با سیستم هایی کار کرده اید که داده های زمانی را مدیریت می کنند، اما دوره های حسابداری متفاوت است. سیستمی را تصور کنید که در آن باید اطمینان حاصل کنید که تمام عملیات مالی به درستی در دوره های زمانی خاص سازماندهی شده اند و زمانی که یک دوره بسته می شود، داده ها تغییر ناپذیر هستند.
این آموزش شما را در پیاده سازی یک سیستم دوره حسابداری قوی که یکپارچگی داده های مالی را تضمین می کند، تنها با استفاده از ادمین جنگو راهنمایی می کند. شما یاد خواهید گرفت که باز، عملیات و بسته شدن دوره های حسابداری را به همراه تمام اعتبارسنجی های لازم برای حفظ یکپارچگی داده ها مدیریت کنید.
پس از تکمیل، شما قادر خواهید بود سیستمی را پیاده سازی کنید که به اصول اولیه حسابداری پایبند باشد و در عین حال بهترین شیوه های توسعه را در جنگو حفظ کنید.
پیش نیازها
پایتون 3.12
جنگو 5.0
دانش اولیه مدل ها در جنگو
SQLite یا PostgreSQL (توصیه می شود)
مفاهیم کلیدی
دوره حسابداری: معامله نهایی
یک دوره حسابداری را به عنوان یک معامله پایگاه داده در مقیاس بزرگ در نظر بگیرید:
باز کردن مانند شروع یک معامله است (BEGIN TRANSACTION)
عملیات مانند پرس و جو در تراکنش هستند
بستن مانند اجرای آن است COMMIT
ایالات دوره
مشابه حالت های یک شی در حافظه:
DRAFT: به عنوان یک شیء نمونه سازی شده اما ذخیره نشده است
ACTIVE: به عنوان یک شی در پایگاه داده باقی ماند
CLOSED: به عنوان یک شیء تغییرناپذیر
پیاده سازی
مدل های پایه
from django.db import models
from django.core.exceptions import ValidationError
from django.utils import timezone
from decimal import Decimal
from django.db.models import Sum
from datetime import datetime
class AccountingPeriod(models.Model):
“””
Representa un período contable con su ciclo de vida completo.
Similar a una transacción de base de datos a gran escala.
“””
STATUS_CHOICES = [
(‘DRAFT’, ‘Borrador’),
(‘ACTIVE’, ‘Activo’),
(‘CLOSED’, ‘Cerrado’),
]
name = models.CharField(
max_length=100,
unique=True,
help_text=”Ejemplo: ‘Enero 2024′”
)
start_date = models.DateField()
end_date = models.DateField()
status = models.CharField(
max_length=10,
choices=STATUS_CHOICES,
default=’DRAFT’
)
initial_balance = models.DecimalField(
max_digits=15,
decimal_places=2,
default=Decimal(‘0.00’)
)
created_at = models.DateTimeField(auto_now_add=True)
closed_at = models.DateTimeField(null=True, blank=True)
closing_notes = models.TextField(blank=True)
class Meta:
ordering = [‘-start_date’]
constraints = [
models.CheckConstraint(
check=models.Q(end_date__gte=models.F(‘start_date’)),
name=’valid_period_dates’
)
]
def clean(self):
self._validate_dates()
self._validate_status_transition()
self._validate_overlapping()
def _validate_dates(self):
“””Valida la coherencia de las fechas del período.”””
if self.start_date and self.end_date:
if self.start_date > self.end_date:
raise ValidationError(
‘La fecha de inicio debe ser anterior a la fecha de fin.’
)
def _validate_status_transition(self):
“””Valida las transiciones de estado permitidas.”””
if not self.pk:
return
old_instance = AccountingPeriod.objects.get(pk=self.pk)
valid_transitions = {
‘DRAFT’: [‘ACTIVE’],
‘ACTIVE’: [‘CLOSED’],
‘CLOSED’: []
}
if (self.status != old_instance.status and
self.status not in valid_transitions[old_instance.status]):
raise ValidationError(
f’Transición de estado inválida: {old_instance.status} -> {self.status}’
)
def _validate_overlapping(self):
“””Previene la superposición de períodos contables.”””
overlapping = AccountingPeriod.objects.exclude(pk=self.pk).filter(
models.Q(start_date__lte=self.end_date) &
models.Q(end_date__gte=self.start_date)
)
if overlapping.exists():
raise ValidationError(
‘El período se superpone con períodos existentes.’
)
def activate(self):
“””Activa un período en estado DRAFT.”””
if self.status != ‘DRAFT’:
raise ValidationError(‘Solo se pueden activar períodos en borrador.’)
self.status = ‘ACTIVE’
self.save()
def can_close(self):
“””
Verifica si el período puede ser cerrado.
Retorna (bool, str) donde bool indica si se puede cerrar
y str contiene el mensaje de error si no se puede.
“””
if self.status != ‘ACTIVE’:
return False, ‘Solo se pueden cerrar períodos activos.’
# Verificar balance
if not self.is_balanced():
return False, ‘El período no está balanceado.’
# Verificar entradas sin procesar
if self.has_pending_entries():
return False, ‘Existen entradas pendientes de procesar.’
return True, ”
def close(self, closing_notes=”):
“””
Cierra el período si todas las validaciones pasan.
“””
can_close, message = self.can_close()
if not can_close:
raise ValidationError(message)
self.status = ‘CLOSED’
self.closed_at = timezone.now()
self.closing_notes = closing_notes
self.save()
def is_balanced(self):
“””
Verifica si los débitos y créditos están balanceados.
“””
totals = self.entries.aggregate(
total_debit=Sum(‘debit_amount’),
total_credit=Sum(‘credit_amount’)
)
total_debit = totals[‘total_debit’] or Decimal(‘0.00’)
total_credit = totals[‘total_credit’] or Decimal(‘0.00’)
return abs(total_debit – total_credit) < Decimal(‘0.01′)
def has_pending_entries(self):
“””
Verifica si hay entradas pendientes de procesar.
“””
return self.entries.filter(status=’PENDING’).exists()
def __str__(self):
return f”{self.name} ({self.get_status_display()})”
class AccountingEntry(models.Model):
“””
Representa una entrada contable individual dentro de un período.
“””
STATUS_CHOICES = [
(‘PENDING’, ‘Pendiente’),
(‘PROCESSED’, ‘Procesado’),
(‘REJECTED’, ‘Rechazado’),
]
period = models.ForeignKey(
AccountingPeriod,
on_delete=models.PROTECT,
related_name=’entries’
)
date = models.DateField()
description = models.CharField(max_length=200)
debit_amount = models.DecimalField(
max_digits=15,
decimal_places=2,
default=Decimal(‘0.00’)
)
credit_amount = models.DecimalField(
max_digits=15,
decimal_places=2,
default=Decimal(‘0.00′)
)
status = models.CharField(
max_length=10,
choices=STATUS_CHOICES,
default=’PENDING’
)
created_at = models.DateTimeField(auto_now_add=True)
processed_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = [‘-date’, ‘-created_at’]
def clean(self):
self._validate_period()
self._validate_amounts()
self._validate_date()
def _validate_period(self):
“””Valida que el período esté activo y la entrada pueda ser modificada.”””
if not self.period_id:
self.period = self._get_appropriate_period()
if self.period.status == ‘CLOSED’:
raise ValidationError(
‘No se pueden crear o modificar entradas en períodos cerrados.’
)
def _validate_amounts(self):
“””Valida que los montos sean coherentes.”””
if self.debit_amount < 0 or self.credit_amount < 0:
raise ValidationError(
‘Los montos no pueden ser negativos.’
)
if self.debit_amount == 0 and self.credit_amount == 0:
raise ValidationError(
‘Al menos un monto debe ser mayor a cero.’
)
def _validate_date(self):
“””Valida que la fecha esté dentro del período.”””
if not (self.period.start_date <= self.date <= self.period.end_date):
raise ValidationError(
‘La fecha debe estar dentro del período contable.’
)
def _get_appropriate_period(self):
“””Obtiene el período activo adecuado para la fecha de la entrada.”””
period = AccountingPeriod.objects.filter(
start_date__lte=self.date,
end_date__gte=self.date,
status=’ACTIVE’
).first()
if not period:
raise ValidationError(
‘No existe un período activo para la fecha especificada.’
)
return period
def process(self):
“””Procesa la entrada contable.”””
if self.status != ‘PENDING’:
raise ValidationError(‘Solo se pueden procesar entradas pendientes.’)
self.status = ‘PROCESSED’
self.processed_at = timezone.now()
self.save()
def reject(self):
“””Rechaza la entrada contable.”””
if self.status != ‘PENDING’:
raise ValidationError(‘Solo se pueden rechazar entradas pendientes.’)
self.status = ‘REJECTED’
self.processed_at = timezone.now()
self.save()
def __str__(self):
return f”{self.date} – {self.description}”
وارد حالت تمام صفحه شوید
از حالت تمام صفحه خارج شوید
تنظیمات مدیریت
from django.contrib import admin
from django.utils.html import format_html
from django.contrib import messages
@admin.register(AccountingPeriod)
class AccountingPeriodAdmin(admin.ModelAdmin):
list_display = [
‘name’,
‘start_date’,
‘end_date’,
‘status_display’,
‘balance_display’,
‘created_at’
]
list_filter = [‘status’]
search_fields = [‘name’]
readonly_fields = [‘closed_at’]
def status_display(self, obj):
colors = {
‘DRAFT’: ‘#6c757d’,
‘ACTIVE’: ‘#28a745’,
‘CLOSED’: ‘#dc3545’,
}
return format_html(
‘”color: {}; font-weight: bold;”>{}’,
colors[obj.status],
obj.get_status_display()
)
status_display.short_description = ‘Estado’
def balance_display(self, obj):
if obj.is_balanced():
return format_html(
‘”color: #28a745;”>Balanceado’
)
return format_html(
‘”color: #dc3545;”>Desbalanceado’
)
balance_display.short_description = ‘Balance’
def get_readonly_fields(self, request, obj=None):
if obj and obj.status == ‘CLOSED’:
return [f.name for f in obj._meta.fields]
return self.readonly_fields
actions = [‘activate_periods’, ‘close_periods’]
def activate_periods(self, request, queryset):
for period in queryset:
try:
period.activate()
self.message_user(
request,
f’Período {period.name} activado exitosamente.’,
messages.SUCCESS
)
except ValidationError as e:
self.message_user(
request,
f”Error al activar {period.name}: {str(e)}”,
messages.ERROR
)
activate_periods.short_description = “Activar períodos seleccionados”
def close_periods(self, request, queryset):
for period in queryset:
try:
period.close()
self.message_user(
request,
f’Período {period.name} cerrado exitosamente.’,
messages.SUCCESS
)
except ValidationError as e:
self.message_user(
request,
f”Error al cerrar {period.name}: {str(e)}”,
messages.ERROR
)
close_periods.short_description = “Cerrar períodos seleccionados”
@admin.register(AccountingEntry)
class AccountingEntryAdmin(admin.ModelAdmin):
list_display = [
‘date’,
‘description’,
‘debit_amount’,
‘credit_amount’,
‘status_display’,
‘period’
]
list_filter = [‘period’, ‘status’, ‘date’]
search_fields = [‘description’]
actions = [‘process_entries’, ‘reject_entries’]
def status_display(self, obj):
colors = {
‘PENDING’: ‘#ffc107’,
‘PROCESSED’: ‘#28a745’,
‘REJECTED’: ‘#dc3545’,
}
return format_html(
‘”color: {}; font-weight: bold;”>{}’,
colors[obj.status],
obj.get_status_display()
)
status_display.short_description = ‘Estado’
def get_readonly_fields(self, request, obj=None):
if obj and (obj.status != ‘PENDING’ or obj.period.status == ‘CLOSED’):
return [f.name for f in obj._meta.fields]
return []
def has_delete_permission(self, request, obj=None):
if obj and (obj.status != ‘PENDING’ or obj.period.status == ‘CLOSED’):
return False
return super().has_delete_permission(request, obj)
def process_entries(self, request, queryset):
for entry in queryset:
try:
entry.process()
self.message_user(
request,
f’Entrada {entry} procesada exitosamente.’,
messages.SUCCESS
)
except ValidationError as e:
self.message_user(
request,
f”Error al procesar entrada {entry}: {str(e)}”,
messages.ERROR
)
process_entries.short_description = “Procesar entradas seleccionadas”
## Tests Unitarios
وارد حالت تمام صفحه شوید
از حالت تمام صفحه خارج شوید
پایتوناز django.test import TestCaseاز django.core.exceptions import ValidationErrorاز واردات اعشاری اعشاریاز تاریخ واردات تاریخ، timedeltaاز .models import AccountingPeriod, AccountingEntry
کلاس AccountingPeriodTests (TestCase):def setUp(self):self.period = AccountingPeriod.objects.create(name = “دوره تست”،start_date=date(2024, 1, 1),end_date=date(2024, 1, 31),status=”DRAFT”)
def test_period_lifecycle(self):
“””Test del ciclo de vida completo de un período”””
# Verificar estado inicial
self.assertEqual(self.period.status, ‘DRAFT’)
# Activar período
self.period.activate()
self.assertEqual(self.period.status, ‘ACTIVE’)
# Crear entradas balanceadas
AccountingEntry.objects.create(
period=self.period,
date=date(2024, 1, 15),
description=”Test Entry 1″,
debit_amount=Decimal(‘100.00’),
credit_amount=Decimal(‘0.00’),
status=”PROCESSED”
)
AccountingEntry.objects.create(
period=self.period,
date=date(2024, 1, 15),
description=”Test Entry 2″,
debit_amount=Decimal(‘0.00’),
credit_amount=Decimal(‘100.00’),
status=”PROCESSED”
)
# Cerrar período
self.period.close()
self.assertEqual(self.period.status, ‘CLOSED’)
self.assertIsNotNone(self.period.closed_at)
def test_overlapping_periods(self):
“””Test de validación de períodos superpuestos”””
with self.assertRaises(ValidationError):
AccountingPeriod.objects.create(
name=”Overlapping Period”,
start_date=date(2024, 1, 15),
end_date=date(2024, 2, 15),
status=”DRAFT”
)
def test_invalid_status_transition(self):
“””Test de transiciones de estado inválidas”””
# No se puede pasar directamente de DRAFT a CLOSED
self.period.status=”CLOSED”
with self.assertRaises(ValidationError):
self.period.save()
# No se puede reabrir un período cerrado
self.period.activate()
self.period.close()
self.period.status=”ACTIVE”
with self.assertRaises(ValidationError):
self.period.save()
وارد حالت تمام صفحه شوید
از حالت تمام صفحه خارج شوید
کلاس AccountingEntryTests (TestCase):def setUp(self):self.period = AccountingPeriod.objects.create(name = “دوره تست”،start_date=date(2024, 1, 1),end_date=date(2024, 1, 31),وضعیت “فعال”)
def test_entry_validation(self):
“””Test de validaciones básicas de entradas”””
# Entrada con montos negativos
with self.assertRaises(ValidationError):
AccountingEntry.objects.create(
period=self.period,
date=date(2024, 1, 15),
description=”Invalid Entry”,
debit_amount=Decimal(‘-100.00’),
credit_amount=Decimal(‘0.00’)
)
# Entrada fuera del período
with self.assertRaises(ValidationError):
AccountingEntry.objects.create(
period=self.period,
date=date(2024, 2, 1),
description=”Invalid Entry”,
debit_amount=Decimal(‘100.00’),
credit_amount=Decimal(‘0.00’)
)
def test_entry_processing(self):
“””Test del procesamiento de entradas”””
entry = AccountingEntry.objects.create(
period=self.period,
date=date(2024, 1, 15),
description=”Test Entry”,
debit_amount=Decimal(‘100.00’),
credit_amount=Decimal(‘0.00’)
)
# Procesar entrada
entry.process()
self.assertEqual(entry.status, ‘PROCESSED’)
self.assertIsNotNone(entry.processed_at)
# No se puede procesar una entrada ya procesada
with self.assertRaises(ValidationError):
entry.process()
def test_closed_period_modifications(self):
“””Test de modificaciones en período cerrado”””
self.period.close()
# No se pueden crear nuevas entradas
with self.assertRaises(ValidationError):
AccountingEntry.objects.create(
period=self.period,
date=date(2024, 1, 15),
description=”Test Entry”,
debit_amount=Decimal(‘100.00’),
credit_amount=Decimal(‘0.00’)
)
وارد حالت تمام صفحه شوید
از حالت تمام صفحه خارج شوید
## Ejemplo Práctico: Sistema de Transferencias
وارد حالت تمام صفحه شوید
از حالت تمام صفحه خارج شوید
پایتون
از واردات اعشاری اعشاریاز تاریخ واردات تاریخ
def transfer_between_accounts(period_id: int,تاریخ: تاریخ،مقدار: اعشاری،توضیحات: خ) -> تاپل:”””با ایجاد دو ورودی حسابداری، بین حساب ها انتقال ایجاد کنید.
Returns:
tuple: (debit_entry, credit_entry)
“””
try:
period = AccountingPeriod.objects.get(id=period_id)
# Crear entrada de débito
debit_entry = AccountingEntry.objects.create(
period=period,
date=date,
description=f”DÉBITO – {description}”,
debit_amount=amount,
credit_amount=Decimal(‘0.00’)
)
# Crear entrada de crédito
credit_entry = AccountingEntry.objects.create(
period=period,
date=date,
description=f”CRÉDITO – {description}”,
debit_amount=Decimal(‘0.00’),
credit_amount=amount
)
# Procesar ambas entradas
debit_entry.process()
credit_entry.process()
return debit_entry, credit_entry
except AccountingPeriod.DoesNotExist:
raise ValidationError(“Período contable no encontrado”)
except Exception as e:
raise ValidationError(f”Error en la transferencia: {str(e)}”)
وارد حالت تمام صفحه شوید
از حالت تمام صفحه خارج شوید
سعی کنید:period = AccountingPeriod.objects.get(شروع_تاریخ_lte=date(2024, 1, 15),پایان_تاریخ_gte=date(2024، 1، 15)،وضعیت “فعال”)
debit, credit = transfer_between_accounts(
period_id=period.id,
date=date(2024, 1, 15),
amount=Decimal(‘1000.00’),
description=”Transferencia entre cuentas”
)
print(f”Transferencia exitosa: {debit.id}, {credit.id}”)
وارد حالت تمام صفحه شوید
از حالت تمام صفحه خارج شوید
به جز ValidationError به صورت e:print(f”خطا: {str(e)}”)
## Queries Útiles
وارد حالت تمام صفحه شوید
از حالت تمام صفحه خارج شوید
پایتون
def check_period_balance(period_id: int) -> dict:period = AccountingPeriod.objects.get(id=period_id)مجموع = period.entries.aggregate(total_debit=Sum('debit_amount'),total_credit=Sum('credit_amount'))
return {
‘period_name’: period.name,
‘total_debit’: totals[‘total_debit’] or Decimal(‘0.00’),
‘total_credit’: totals[‘total_credit’] or Decimal(‘0.00’),
‘is_balanced’: period.is_balanced()
}
وارد حالت تمام صفحه شوید
از حالت تمام صفحه خارج شوید
def get_pending_entries(period_id: int) -> QuerySet:بازگشت AccountingEntry.objects.filter(period_id=period_id،وضعیت “در انتظار”)
def get_period_summary(period_id: int) -> dict:period = AccountingPeriod.objects.get(id=period_id)entries_summary = period.entries.aggregate(total_entries=Count('id'),pending_entries=Count('id', filter=Q(status=”PENDING”))processed_entries=Count('id', filter=Q(status=”PROCESSED”))rejected_entries=Count('id', filter=Q(status=”REJECTED”))total_debit=Sum('debit_amount'),total_credit=Sum('credit_amount'))
return {
‘period_name’: period.name,
‘status’: period.get_status_display(),
‘duration’: (period.end_date – period.start_date).days + 1,
‘total_entries’: entries_summary[‘total_entries’],
‘pending_entries’: entries_summary[‘pending_entries’],
‘processed_entries’: entries_summary[‘processed_entries’],
‘rejected_entries’: entries_summary[‘rejected_entries’],
‘total_debit’: entries_summary[‘total_debit’] or Decimal(‘0.00’),
‘total_credit’: entries_summary[‘total_credit’] or Decimal(‘0.00’)
}
وارد حالت تمام صفحه شوید
از حالت تمام صفحه خارج شوید
## Mejores Prácticas
1. **Validaciones de Seguridad**
– Usar `PROTECT` en foreign keys para prevenir eliminación en cascada
– Implementar validaciones a nivel de modelo
– Mantener registro de auditoría de cambios importantes
2. **Manejo de Errores**
– Usar excepciones específicas y mensajes claros
– Implementar rollback en operaciones complejas
– Registrar errores críticos en logs
3. **Patrones de Diseño**
– State Pattern para estados de período y entradas
– Factory Method para crear entradas relacionadas
– Observer Pattern para auditoría y notificaciones
## Conclusión
Este sistema proporciona una base sólida para manejar períodos contables en Django, con:
– Validaciones robustas para mantener la integridad de los datos
– Flujo claro de estados para períodos y entradas
– Sistema extensible para diferentes tipos de operaciones contables
وارد حالت تمام صفحه شوید
از حالت تمام صفحه خارج شوید
مقدمه
به عنوان یک توسعه دهنده جنگو، احتمالاً با سیستم هایی کار کرده اید که داده های زمانی را مدیریت می کنند، اما دوره های حسابداری متفاوت است. سیستمی را تصور کنید که در آن باید اطمینان حاصل کنید که تمام عملیات مالی به درستی در دوره های زمانی خاص سازماندهی شده اند و زمانی که یک دوره بسته می شود، داده ها تغییر ناپذیر هستند.
این آموزش شما را در پیاده سازی یک سیستم دوره حسابداری قوی که یکپارچگی داده های مالی را تضمین می کند، تنها با استفاده از ادمین جنگو راهنمایی می کند. شما یاد خواهید گرفت که باز، عملیات و بسته شدن دوره های حسابداری را به همراه تمام اعتبارسنجی های لازم برای حفظ یکپارچگی داده ها مدیریت کنید.
پس از تکمیل، شما قادر خواهید بود سیستمی را پیاده سازی کنید که به اصول اولیه حسابداری پایبند باشد و در عین حال بهترین شیوه های توسعه را در جنگو حفظ کنید.
پیش نیازها
- پایتون 3.12
- جنگو 5.0
- دانش اولیه مدل ها در جنگو
- SQLite یا PostgreSQL (توصیه می شود)
مفاهیم کلیدی
دوره حسابداری: معامله نهایی
یک دوره حسابداری را به عنوان یک معامله پایگاه داده در مقیاس بزرگ در نظر بگیرید:
- باز کردن مانند شروع یک معامله است (
BEGIN TRANSACTION
) - عملیات مانند پرس و جو در تراکنش هستند
- بستن مانند اجرای آن است
COMMIT
ایالات دوره
مشابه حالت های یک شی در حافظه:
-
DRAFT
: به عنوان یک شیء نمونه سازی شده اما ذخیره نشده است -
ACTIVE
: به عنوان یک شی در پایگاه داده باقی ماند -
CLOSED
: به عنوان یک شیء تغییرناپذیر
پیاده سازی
مدل های پایه
from django.db import models
from django.core.exceptions import ValidationError
from django.utils import timezone
from decimal import Decimal
from django.db.models import Sum
from datetime import datetime
class AccountingPeriod(models.Model):
"""
Representa un período contable con su ciclo de vida completo.
Similar a una transacción de base de datos a gran escala.
"""
STATUS_CHOICES = [
('DRAFT', 'Borrador'),
('ACTIVE', 'Activo'),
('CLOSED', 'Cerrado'),
]
name = models.CharField(
max_length=100,
unique=True,
help_text="Ejemplo: 'Enero 2024'"
)
start_date = models.DateField()
end_date = models.DateField()
status = models.CharField(
max_length=10,
choices=STATUS_CHOICES,
default='DRAFT'
)
initial_balance = models.DecimalField(
max_digits=15,
decimal_places=2,
default=Decimal('0.00')
)
created_at = models.DateTimeField(auto_now_add=True)
closed_at = models.DateTimeField(null=True, blank=True)
closing_notes = models.TextField(blank=True)
class Meta:
ordering = ['-start_date']
constraints = [
models.CheckConstraint(
check=models.Q(end_date__gte=models.F('start_date')),
name='valid_period_dates'
)
]
def clean(self):
self._validate_dates()
self._validate_status_transition()
self._validate_overlapping()
def _validate_dates(self):
"""Valida la coherencia de las fechas del período."""
if self.start_date and self.end_date:
if self.start_date > self.end_date:
raise ValidationError(
'La fecha de inicio debe ser anterior a la fecha de fin.'
)
def _validate_status_transition(self):
"""Valida las transiciones de estado permitidas."""
if not self.pk:
return
old_instance = AccountingPeriod.objects.get(pk=self.pk)
valid_transitions = {
'DRAFT': ['ACTIVE'],
'ACTIVE': ['CLOSED'],
'CLOSED': []
}
if (self.status != old_instance.status and
self.status not in valid_transitions[old_instance.status]):
raise ValidationError(
f'Transición de estado inválida: {old_instance.status} -> {self.status}'
)
def _validate_overlapping(self):
"""Previene la superposición de períodos contables."""
overlapping = AccountingPeriod.objects.exclude(pk=self.pk).filter(
models.Q(start_date__lte=self.end_date) &
models.Q(end_date__gte=self.start_date)
)
if overlapping.exists():
raise ValidationError(
'El período se superpone con períodos existentes.'
)
def activate(self):
"""Activa un período en estado DRAFT."""
if self.status != 'DRAFT':
raise ValidationError('Solo se pueden activar períodos en borrador.')
self.status = 'ACTIVE'
self.save()
def can_close(self):
"""
Verifica si el período puede ser cerrado.
Retorna (bool, str) donde bool indica si se puede cerrar
y str contiene el mensaje de error si no se puede.
"""
if self.status != 'ACTIVE':
return False, 'Solo se pueden cerrar períodos activos.'
# Verificar balance
if not self.is_balanced():
return False, 'El período no está balanceado.'
# Verificar entradas sin procesar
if self.has_pending_entries():
return False, 'Existen entradas pendientes de procesar.'
return True, ''
def close(self, closing_notes=''):
"""
Cierra el período si todas las validaciones pasan.
"""
can_close, message = self.can_close()
if not can_close:
raise ValidationError(message)
self.status = 'CLOSED'
self.closed_at = timezone.now()
self.closing_notes = closing_notes
self.save()
def is_balanced(self):
"""
Verifica si los débitos y créditos están balanceados.
"""
totals = self.entries.aggregate(
total_debit=Sum('debit_amount'),
total_credit=Sum('credit_amount')
)
total_debit = totals['total_debit'] or Decimal('0.00')
total_credit = totals['total_credit'] or Decimal('0.00')
return abs(total_debit - total_credit) < Decimal('0.01')
def has_pending_entries(self):
"""
Verifica si hay entradas pendientes de procesar.
"""
return self.entries.filter(status='PENDING').exists()
def __str__(self):
return f"{self.name} ({self.get_status_display()})"
class AccountingEntry(models.Model):
"""
Representa una entrada contable individual dentro de un período.
"""
STATUS_CHOICES = [
('PENDING', 'Pendiente'),
('PROCESSED', 'Procesado'),
('REJECTED', 'Rechazado'),
]
period = models.ForeignKey(
AccountingPeriod,
on_delete=models.PROTECT,
related_name='entries'
)
date = models.DateField()
description = models.CharField(max_length=200)
debit_amount = models.DecimalField(
max_digits=15,
decimal_places=2,
default=Decimal('0.00')
)
credit_amount = models.DecimalField(
max_digits=15,
decimal_places=2,
default=Decimal('0.00')
)
status = models.CharField(
max_length=10,
choices=STATUS_CHOICES,
default='PENDING'
)
created_at = models.DateTimeField(auto_now_add=True)
processed_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ['-date', '-created_at']
def clean(self):
self._validate_period()
self._validate_amounts()
self._validate_date()
def _validate_period(self):
"""Valida que el período esté activo y la entrada pueda ser modificada."""
if not self.period_id:
self.period = self._get_appropriate_period()
if self.period.status == 'CLOSED':
raise ValidationError(
'No se pueden crear o modificar entradas en períodos cerrados.'
)
def _validate_amounts(self):
"""Valida que los montos sean coherentes."""
if self.debit_amount < 0 or self.credit_amount < 0:
raise ValidationError(
'Los montos no pueden ser negativos.'
)
if self.debit_amount == 0 and self.credit_amount == 0:
raise ValidationError(
'Al menos un monto debe ser mayor a cero.'
)
def _validate_date(self):
"""Valida que la fecha esté dentro del período."""
if not (self.period.start_date <= self.date <= self.period.end_date):
raise ValidationError(
'La fecha debe estar dentro del período contable.'
)
def _get_appropriate_period(self):
"""Obtiene el período activo adecuado para la fecha de la entrada."""
period = AccountingPeriod.objects.filter(
start_date__lte=self.date,
end_date__gte=self.date,
status='ACTIVE'
).first()
if not period:
raise ValidationError(
'No existe un período activo para la fecha especificada.'
)
return period
def process(self):
"""Procesa la entrada contable."""
if self.status != 'PENDING':
raise ValidationError('Solo se pueden procesar entradas pendientes.')
self.status = 'PROCESSED'
self.processed_at = timezone.now()
self.save()
def reject(self):
"""Rechaza la entrada contable."""
if self.status != 'PENDING':
raise ValidationError('Solo se pueden rechazar entradas pendientes.')
self.status = 'REJECTED'
self.processed_at = timezone.now()
self.save()
def __str__(self):
return f"{self.date} - {self.description}"
تنظیمات مدیریت
from django.contrib import admin
from django.utils.html import format_html
from django.contrib import messages
@admin.register(AccountingPeriod)
class AccountingPeriodAdmin(admin.ModelAdmin):
list_display = [
'name',
'start_date',
'end_date',
'status_display',
'balance_display',
'created_at'
]
list_filter = ['status']
search_fields = ['name']
readonly_fields = ['closed_at']
def status_display(self, obj):
colors = {
'DRAFT': '#6c757d',
'ACTIVE': '#28a745',
'CLOSED': '#dc3545',
}
return format_html(
'"color: {}; font-weight: bold;">{}',
colors[obj.status],
obj.get_status_display()
)
status_display.short_description = 'Estado'
def balance_display(self, obj):
if obj.is_balanced():
return format_html(
'"color: #28a745;">Balanceado'
)
return format_html(
'"color: #dc3545;">Desbalanceado'
)
balance_display.short_description = 'Balance'
def get_readonly_fields(self, request, obj=None):
if obj and obj.status == 'CLOSED':
return [f.name for f in obj._meta.fields]
return self.readonly_fields
actions = ['activate_periods', 'close_periods']
def activate_periods(self, request, queryset):
for period in queryset:
try:
period.activate()
self.message_user(
request,
f'Período {period.name} activado exitosamente.',
messages.SUCCESS
)
except ValidationError as e:
self.message_user(
request,
f"Error al activar {period.name}: {str(e)}",
messages.ERROR
)
activate_periods.short_description = "Activar períodos seleccionados"
def close_periods(self, request, queryset):
for period in queryset:
try:
period.close()
self.message_user(
request,
f'Período {period.name} cerrado exitosamente.',
messages.SUCCESS
)
except ValidationError as e:
self.message_user(
request,
f"Error al cerrar {period.name}: {str(e)}",
messages.ERROR
)
close_periods.short_description = "Cerrar períodos seleccionados"
@admin.register(AccountingEntry)
class AccountingEntryAdmin(admin.ModelAdmin):
list_display = [
'date',
'description',
'debit_amount',
'credit_amount',
'status_display',
'period'
]
list_filter = ['period', 'status', 'date']
search_fields = ['description']
actions = ['process_entries', 'reject_entries']
def status_display(self, obj):
colors = {
'PENDING': '#ffc107',
'PROCESSED': '#28a745',
'REJECTED': '#dc3545',
}
return format_html(
'"color: {}; font-weight: bold;">{}',
colors[obj.status],
obj.get_status_display()
)
status_display.short_description = 'Estado'
def get_readonly_fields(self, request, obj=None):
if obj and (obj.status != 'PENDING' or obj.period.status == 'CLOSED'):
return [f.name for f in obj._meta.fields]
return []
def has_delete_permission(self, request, obj=None):
if obj and (obj.status != 'PENDING' or obj.period.status == 'CLOSED'):
return False
return super().has_delete_permission(request, obj)
def process_entries(self, request, queryset):
for entry in queryset:
try:
entry.process()
self.message_user(
request,
f'Entrada {entry} procesada exitosamente.',
messages.SUCCESS
)
except ValidationError as e:
self.message_user(
request,
f"Error al procesar entrada {entry}: {str(e)}",
messages.ERROR
)
process_entries.short_description = "Procesar entradas seleccionadas"
## Tests Unitarios
پایتون
از django.test import TestCase
از django.core.exceptions import ValidationError
از واردات اعشاری اعشاری
از تاریخ واردات تاریخ، timedelta
از .models import AccountingPeriod, AccountingEntry
کلاس AccountingPeriodTests (TestCase):
def setUp(self):
self.period = AccountingPeriod.objects.create(
name = “دوره تست”،
start_date=date(2024, 1, 1),
end_date=date(2024, 1, 31),
status=”DRAFT”
)
def test_period_lifecycle(self):
"""Test del ciclo de vida completo de un período"""
# Verificar estado inicial
self.assertEqual(self.period.status, 'DRAFT')
# Activar período
self.period.activate()
self.assertEqual(self.period.status, 'ACTIVE')
# Crear entradas balanceadas
AccountingEntry.objects.create(
period=self.period,
date=date(2024, 1, 15),
description="Test Entry 1",
debit_amount=Decimal('100.00'),
credit_amount=Decimal('0.00'),
status="PROCESSED"
)
AccountingEntry.objects.create(
period=self.period,
date=date(2024, 1, 15),
description="Test Entry 2",
debit_amount=Decimal('0.00'),
credit_amount=Decimal('100.00'),
status="PROCESSED"
)
# Cerrar período
self.period.close()
self.assertEqual(self.period.status, 'CLOSED')
self.assertIsNotNone(self.period.closed_at)
def test_overlapping_periods(self):
"""Test de validación de períodos superpuestos"""
with self.assertRaises(ValidationError):
AccountingPeriod.objects.create(
name="Overlapping Period",
start_date=date(2024, 1, 15),
end_date=date(2024, 2, 15),
status="DRAFT"
)
def test_invalid_status_transition(self):
"""Test de transiciones de estado inválidas"""
# No se puede pasar directamente de DRAFT a CLOSED
self.period.status="CLOSED"
with self.assertRaises(ValidationError):
self.period.save()
# No se puede reabrir un período cerrado
self.period.activate()
self.period.close()
self.period.status="ACTIVE"
with self.assertRaises(ValidationError):
self.period.save()
کلاس AccountingEntryTests (TestCase):
def setUp(self):
self.period = AccountingPeriod.objects.create(
name = “دوره تست”،
start_date=date(2024, 1, 1),
end_date=date(2024, 1, 31),
وضعیت “فعال”
)
def test_entry_validation(self):
"""Test de validaciones básicas de entradas"""
# Entrada con montos negativos
with self.assertRaises(ValidationError):
AccountingEntry.objects.create(
period=self.period,
date=date(2024, 1, 15),
description="Invalid Entry",
debit_amount=Decimal('-100.00'),
credit_amount=Decimal('0.00')
)
# Entrada fuera del período
with self.assertRaises(ValidationError):
AccountingEntry.objects.create(
period=self.period,
date=date(2024, 2, 1),
description="Invalid Entry",
debit_amount=Decimal('100.00'),
credit_amount=Decimal('0.00')
)
def test_entry_processing(self):
"""Test del procesamiento de entradas"""
entry = AccountingEntry.objects.create(
period=self.period,
date=date(2024, 1, 15),
description="Test Entry",
debit_amount=Decimal('100.00'),
credit_amount=Decimal('0.00')
)
# Procesar entrada
entry.process()
self.assertEqual(entry.status, 'PROCESSED')
self.assertIsNotNone(entry.processed_at)
# No se puede procesar una entrada ya procesada
with self.assertRaises(ValidationError):
entry.process()
def test_closed_period_modifications(self):
"""Test de modificaciones en período cerrado"""
self.period.close()
# No se pueden crear nuevas entradas
with self.assertRaises(ValidationError):
AccountingEntry.objects.create(
period=self.period,
date=date(2024, 1, 15),
description="Test Entry",
debit_amount=Decimal('100.00'),
credit_amount=Decimal('0.00')
)
## Ejemplo Práctico: Sistema de Transferencias
پایتون
از واردات اعشاری اعشاری
از تاریخ واردات تاریخ
def transfer_between_accounts(
period_id: int,
تاریخ: تاریخ،
مقدار: اعشاری،
توضیحات: خ
) -> تاپل:
“””
با ایجاد دو ورودی حسابداری، بین حساب ها انتقال ایجاد کنید.
Returns:
tuple: (debit_entry, credit_entry)
"""
try:
period = AccountingPeriod.objects.get(id=period_id)
# Crear entrada de débito
debit_entry = AccountingEntry.objects.create(
period=period,
date=date,
description=f"DÉBITO - {description}",
debit_amount=amount,
credit_amount=Decimal('0.00')
)
# Crear entrada de crédito
credit_entry = AccountingEntry.objects.create(
period=period,
date=date,
description=f"CRÉDITO - {description}",
debit_amount=Decimal('0.00'),
credit_amount=amount
)
# Procesar ambas entradas
debit_entry.process()
credit_entry.process()
return debit_entry, credit_entry
except AccountingPeriod.DoesNotExist:
raise ValidationError("Período contable no encontrado")
except Exception as e:
raise ValidationError(f"Error en la transferencia: {str(e)}")
سعی کنید:
period = AccountingPeriod.objects.get(
شروع_تاریخ_lte=date(2024, 1, 15),
پایان_تاریخ_gte=date(2024، 1، 15)،
وضعیت “فعال”
)
debit, credit = transfer_between_accounts(
period_id=period.id,
date=date(2024, 1, 15),
amount=Decimal('1000.00'),
description="Transferencia entre cuentas"
)
print(f"Transferencia exitosa: {debit.id}, {credit.id}")
به جز ValidationError به صورت e:
print(f”خطا: {str(e)}”)
## Queries Útiles
پایتون
def check_period_balance(period_id: int) -> dict:
period = AccountingPeriod.objects.get(id=period_id)
مجموع = period.entries.aggregate(
total_debit=Sum('debit_amount'),
total_credit=Sum('credit_amount')
)
return {
'period_name': period.name,
'total_debit': totals['total_debit'] or Decimal('0.00'),
'total_credit': totals['total_credit'] or Decimal('0.00'),
'is_balanced': period.is_balanced()
}
def get_pending_entries(period_id: int) -> QuerySet:
بازگشت AccountingEntry.objects.filter(
period_id=period_id،
وضعیت “در انتظار”
)
def get_period_summary(period_id: int) -> dict:
period = AccountingPeriod.objects.get(id=period_id)
entries_summary = period.entries.aggregate(
total_entries=Count('id'),
pending_entries=Count('id', filter=Q(status=”PENDING”))
processed_entries=Count('id', filter=Q(status=”PROCESSED”))
rejected_entries=Count('id', filter=Q(status=”REJECTED”))
total_debit=Sum('debit_amount'),
total_credit=Sum('credit_amount')
)
return {
'period_name': period.name,
'status': period.get_status_display(),
'duration': (period.end_date - period.start_date).days + 1,
'total_entries': entries_summary['total_entries'],
'pending_entries': entries_summary['pending_entries'],
'processed_entries': entries_summary['processed_entries'],
'rejected_entries': entries_summary['rejected_entries'],
'total_debit': entries_summary['total_debit'] or Decimal('0.00'),
'total_credit': entries_summary['total_credit'] or Decimal('0.00')
}
## Mejores Prácticas
1. **Validaciones de Seguridad**
- Usar `PROTECT` en foreign keys para prevenir eliminación en cascada
- Implementar validaciones a nivel de modelo
- Mantener registro de auditoría de cambios importantes
2. **Manejo de Errores**
- Usar excepciones específicas y mensajes claros
- Implementar rollback en operaciones complejas
- Registrar errores críticos en logs
3. **Patrones de Diseño**
- State Pattern para estados de período y entradas
- Factory Method para crear entradas relacionadas
- Observer Pattern para auditoría y notificaciones
## Conclusión
Este sistema proporciona una base sólida para manejar períodos contables en Django, con:
- Validaciones robustas para mantener la integridad de los datos
- Flujo claro de estados para períodos y entradas
- Sistema extensible para diferentes tipos de operaciones contables