Initial commit: standalone Django accounting API with admin JWT authorization
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1
accounting/__init__.py
Normal file
1
accounting/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
6
accounting/apps.py
Normal file
6
accounting/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AccountingConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'accounting'
|
||||
211
accounting/migrations/0001_initial.py
Normal file
211
accounting/migrations/0001_initial.py
Normal file
@@ -0,0 +1,211 @@
|
||||
# Generated by Django 4.2.7 on 2026-06-30 09:26
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Account',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('code', models.CharField(db_index=True, max_length=20, unique=True)),
|
||||
('name', models.CharField(max_length=150)),
|
||||
('type', models.CharField(choices=[('asset', 'Asset'), ('liability', 'Liability'), ('equity', 'Equity'), ('income', 'Income'), ('expense', 'Expense')], max_length=20)),
|
||||
('is_group', models.BooleanField(default=False)),
|
||||
('opening_balance', models.FloatField(default=0)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='children', to='accounting.account')),
|
||||
],
|
||||
options={
|
||||
'db_table': 'accounting_accounts',
|
||||
'ordering': ['code'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Employee',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('code', models.CharField(blank=True, default='', max_length=50)),
|
||||
('name', models.CharField(max_length=150)),
|
||||
('position', models.CharField(blank=True, default='', max_length=120)),
|
||||
('base_salary', models.FloatField(default=0)),
|
||||
('insurance_no', models.CharField(blank=True, default='', max_length=50)),
|
||||
('hire_date', models.DateField(blank=True, null=True)),
|
||||
('active', models.BooleanField(default=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'accounting_employees',
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Invoice',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('kind', models.CharField(choices=[('sale', 'Sale'), ('purchase', 'Purchase')], max_length=20)),
|
||||
('number', models.PositiveIntegerField(default=0)),
|
||||
('date', models.DateField()),
|
||||
('status', models.CharField(choices=[('draft', 'Draft'), ('confirmed', 'Confirmed'), ('paid', 'Paid')], default='draft', max_length=20)),
|
||||
('note', models.TextField(blank=True, default='')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'accounting_invoices',
|
||||
'ordering': ['-date', '-number'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Party',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('kind', models.CharField(choices=[('customer', 'Customer'), ('supplier', 'Supplier')], max_length=20)),
|
||||
('name', models.CharField(max_length=150)),
|
||||
('phone', models.CharField(blank=True, default='', max_length=30)),
|
||||
('economic_code', models.CharField(blank=True, default='', max_length=50)),
|
||||
('address', models.TextField(blank=True, default='')),
|
||||
('opening_balance', models.FloatField(default=0)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'accounting_parties',
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Product',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('code', models.CharField(db_index=True, max_length=50)),
|
||||
('name', models.CharField(max_length=150)),
|
||||
('unit', models.CharField(blank=True, default='', max_length=30)),
|
||||
('sale_price', models.FloatField(default=0)),
|
||||
('purchase_price', models.FloatField(default=0)),
|
||||
('stock', models.FloatField(default=0)),
|
||||
('reorder_level', models.FloatField(default=0)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'accounting_products',
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Voucher',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('number', models.PositiveIntegerField(default=0)),
|
||||
('date', models.DateField()),
|
||||
('description', models.TextField(blank=True, default='')),
|
||||
('status', models.CharField(choices=[('draft', 'Draft'), ('posted', 'Posted')], default='draft', max_length=20)),
|
||||
('source', models.CharField(blank=True, db_index=True, default='', max_length=120)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'accounting_vouchers',
|
||||
'ordering': ['-date', '-number'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='VoucherLine',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('description', models.CharField(blank=True, default='', max_length=255)),
|
||||
('debit', models.FloatField(default=0)),
|
||||
('credit', models.FloatField(default=0)),
|
||||
('account', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='voucher_lines', to='accounting.account')),
|
||||
('voucher', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lines', to='accounting.voucher')),
|
||||
],
|
||||
options={
|
||||
'db_table': 'accounting_voucher_lines',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TreasuryTxn',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('number', models.PositiveIntegerField(default=0)),
|
||||
('kind', models.CharField(choices=[('receipt', 'Receipt'), ('payment', 'Payment')], max_length=20)),
|
||||
('method', models.CharField(choices=[('cash', 'Cash'), ('bank', 'Bank'), ('cheque', 'Cheque')], default='bank', max_length=20)),
|
||||
('date', models.DateField()),
|
||||
('amount', models.FloatField(default=0)),
|
||||
('reference', models.CharField(blank=True, default='', max_length=120)),
|
||||
('description', models.TextField(blank=True, default='')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('party', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='treasury_txns', to='accounting.party')),
|
||||
],
|
||||
options={
|
||||
'db_table': 'accounting_treasury_txns',
|
||||
'ordering': ['-date', '-number'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Payslip',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('period', models.CharField(blank=True, default='', max_length=20)),
|
||||
('base_salary', models.FloatField(default=0)),
|
||||
('overtime', models.FloatField(default=0)),
|
||||
('bonus', models.FloatField(default=0)),
|
||||
('deductions', models.FloatField(default=0)),
|
||||
('insurance', models.FloatField(default=0)),
|
||||
('tax', models.FloatField(default=0)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('employee', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='payslips', to='accounting.employee')),
|
||||
],
|
||||
options={
|
||||
'db_table': 'accounting_payslips',
|
||||
'ordering': ['-period'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='InvoiceLine',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('quantity', models.FloatField(default=0)),
|
||||
('unit_price', models.FloatField(default=0)),
|
||||
('discount', models.FloatField(default=0)),
|
||||
('tax_rate', models.FloatField(default=0)),
|
||||
('invoice', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lines', to='accounting.invoice')),
|
||||
('product', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='invoice_lines', to='accounting.product')),
|
||||
],
|
||||
options={
|
||||
'db_table': 'accounting_invoice_lines',
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='invoice',
|
||||
name='party',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='invoices', to='accounting.party'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='CompanySettings',
|
||||
fields=[
|
||||
('id', models.PositiveSmallIntegerField(default=1, primary_key=True, serialize=False)),
|
||||
('name', models.CharField(blank=True, default='', max_length=150)),
|
||||
('economic_code', models.CharField(blank=True, default='', max_length=50)),
|
||||
('fiscal_year', models.CharField(blank=True, default='', max_length=20)),
|
||||
('base_currency', models.CharField(blank=True, default='تومان', max_length=20)),
|
||||
('tax_rate', models.FloatField(default=0)),
|
||||
('address', models.TextField(blank=True, default='')),
|
||||
('phone', models.CharField(blank=True, default='', max_length=40)),
|
||||
('bank_account', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='accounting.account')),
|
||||
('customer_payable_account', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='accounting.account')),
|
||||
('owner_payable_account', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='accounting.account')),
|
||||
('owner_share_account', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='accounting.account')),
|
||||
('sales_income_account', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='accounting.account')),
|
||||
],
|
||||
options={
|
||||
'db_table': 'accounting_company_settings',
|
||||
},
|
||||
),
|
||||
]
|
||||
27
accounting/migrations/0002_seed_chart_of_accounts.py
Normal file
27
accounting/migrations/0002_seed_chart_of_accounts.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from django.db import migrations
|
||||
|
||||
from accounting.seeding import seed_default_data
|
||||
|
||||
|
||||
def seed(apps, schema_editor):
|
||||
Account = apps.get_model('accounting', 'Account')
|
||||
CompanySettings = apps.get_model('accounting', 'CompanySettings')
|
||||
seed_default_data(Account, CompanySettings)
|
||||
|
||||
|
||||
def unseed(apps, schema_editor):
|
||||
Account = apps.get_model('accounting', 'Account')
|
||||
CompanySettings = apps.get_model('accounting', 'CompanySettings')
|
||||
CompanySettings.objects.all().delete()
|
||||
Account.objects.all().delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounting', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(seed, unseed),
|
||||
]
|
||||
1
accounting/migrations/__init__.py
Normal file
1
accounting/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
284
accounting/models.py
Normal file
284
accounting/models.py
Normal file
@@ -0,0 +1,284 @@
|
||||
import uuid
|
||||
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Account(models.Model):
|
||||
"""A node in the chart of accounts (کدینگ حسابها)."""
|
||||
|
||||
ACCOUNT_TYPES = [
|
||||
('asset', 'Asset'),
|
||||
('liability', 'Liability'),
|
||||
('equity', 'Equity'),
|
||||
('income', 'Income'),
|
||||
('expense', 'Expense'),
|
||||
]
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
code = models.CharField(max_length=20, unique=True, db_index=True)
|
||||
name = models.CharField(max_length=150)
|
||||
type = models.CharField(max_length=20, choices=ACCOUNT_TYPES)
|
||||
parent = models.ForeignKey(
|
||||
'self', on_delete=models.SET_NULL, null=True, blank=True, related_name='children'
|
||||
)
|
||||
is_group = models.BooleanField(default=False)
|
||||
opening_balance = models.FloatField(default=0)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'accounting_accounts'
|
||||
ordering = ['code']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.code} - {self.name}"
|
||||
|
||||
|
||||
class Party(models.Model):
|
||||
"""A trading partner (customer or supplier) used by invoices and treasury."""
|
||||
|
||||
PARTY_KINDS = [
|
||||
('customer', 'Customer'),
|
||||
('supplier', 'Supplier'),
|
||||
]
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
kind = models.CharField(max_length=20, choices=PARTY_KINDS)
|
||||
name = models.CharField(max_length=150)
|
||||
phone = models.CharField(max_length=30, blank=True, default='')
|
||||
economic_code = models.CharField(max_length=50, blank=True, default='')
|
||||
address = models.TextField(blank=True, default='')
|
||||
opening_balance = models.FloatField(default=0)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'accounting_parties'
|
||||
ordering = ['name']
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class Product(models.Model):
|
||||
"""An inventory item."""
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
code = models.CharField(max_length=50, db_index=True)
|
||||
name = models.CharField(max_length=150)
|
||||
unit = models.CharField(max_length=30, blank=True, default='')
|
||||
sale_price = models.FloatField(default=0)
|
||||
purchase_price = models.FloatField(default=0)
|
||||
stock = models.FloatField(default=0)
|
||||
reorder_level = models.FloatField(default=0)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'accounting_products'
|
||||
ordering = ['name']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.code} - {self.name}"
|
||||
|
||||
|
||||
class Voucher(models.Model):
|
||||
"""A double-entry journal voucher (سند حسابداری)."""
|
||||
|
||||
STATUS_CHOICES = [
|
||||
('draft', 'Draft'),
|
||||
('posted', 'Posted'),
|
||||
]
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
number = models.PositiveIntegerField(default=0)
|
||||
date = models.DateField()
|
||||
description = models.TextField(blank=True, default='')
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='draft')
|
||||
source = models.CharField(max_length=120, blank=True, default='', db_index=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'accounting_vouchers'
|
||||
ordering = ['-date', '-number']
|
||||
|
||||
def __str__(self):
|
||||
return f"Voucher #{self.number}"
|
||||
|
||||
|
||||
class VoucherLine(models.Model):
|
||||
"""A single debit/credit line within a voucher."""
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
voucher = models.ForeignKey(Voucher, on_delete=models.CASCADE, related_name='lines')
|
||||
account = models.ForeignKey(Account, on_delete=models.PROTECT, related_name='voucher_lines')
|
||||
description = models.CharField(max_length=255, blank=True, default='')
|
||||
debit = models.FloatField(default=0)
|
||||
credit = models.FloatField(default=0)
|
||||
|
||||
class Meta:
|
||||
db_table = 'accounting_voucher_lines'
|
||||
|
||||
def __str__(self):
|
||||
return f"Line {self.account_id} D:{self.debit} C:{self.credit}"
|
||||
|
||||
|
||||
class Invoice(models.Model):
|
||||
"""A sales or purchase invoice (فاکتور)."""
|
||||
|
||||
INVOICE_KINDS = [
|
||||
('sale', 'Sale'),
|
||||
('purchase', 'Purchase'),
|
||||
]
|
||||
STATUS_CHOICES = [
|
||||
('draft', 'Draft'),
|
||||
('confirmed', 'Confirmed'),
|
||||
('paid', 'Paid'),
|
||||
]
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
kind = models.CharField(max_length=20, choices=INVOICE_KINDS)
|
||||
number = models.PositiveIntegerField(default=0)
|
||||
party = models.ForeignKey(
|
||||
Party, on_delete=models.SET_NULL, null=True, blank=True, related_name='invoices'
|
||||
)
|
||||
date = models.DateField()
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='draft')
|
||||
note = models.TextField(blank=True, default='')
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'accounting_invoices'
|
||||
ordering = ['-date', '-number']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.kind} invoice #{self.number}"
|
||||
|
||||
|
||||
class InvoiceLine(models.Model):
|
||||
"""A line item within an invoice."""
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
invoice = models.ForeignKey(Invoice, on_delete=models.CASCADE, related_name='lines')
|
||||
product = models.ForeignKey(
|
||||
Product, on_delete=models.PROTECT, null=True, blank=True, related_name='invoice_lines'
|
||||
)
|
||||
quantity = models.FloatField(default=0)
|
||||
unit_price = models.FloatField(default=0)
|
||||
discount = models.FloatField(default=0)
|
||||
tax_rate = models.FloatField(default=0)
|
||||
|
||||
class Meta:
|
||||
db_table = 'accounting_invoice_lines'
|
||||
|
||||
def __str__(self):
|
||||
return f"Line {self.product_id} x{self.quantity}"
|
||||
|
||||
|
||||
class TreasuryTxn(models.Model):
|
||||
"""A treasury receipt or payment (دریافت/پرداخت)."""
|
||||
|
||||
KIND_CHOICES = [
|
||||
('receipt', 'Receipt'),
|
||||
('payment', 'Payment'),
|
||||
]
|
||||
METHOD_CHOICES = [
|
||||
('cash', 'Cash'),
|
||||
('bank', 'Bank'),
|
||||
('cheque', 'Cheque'),
|
||||
]
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
number = models.PositiveIntegerField(default=0)
|
||||
kind = models.CharField(max_length=20, choices=KIND_CHOICES)
|
||||
method = models.CharField(max_length=20, choices=METHOD_CHOICES, default='bank')
|
||||
date = models.DateField()
|
||||
party = models.ForeignKey(
|
||||
Party, on_delete=models.SET_NULL, null=True, blank=True, related_name='treasury_txns'
|
||||
)
|
||||
amount = models.FloatField(default=0)
|
||||
reference = models.CharField(max_length=120, blank=True, default='')
|
||||
description = models.TextField(blank=True, default='')
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'accounting_treasury_txns'
|
||||
ordering = ['-date', '-number']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.kind} #{self.number}"
|
||||
|
||||
|
||||
class Employee(models.Model):
|
||||
"""A payroll employee."""
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
code = models.CharField(max_length=50, blank=True, default='')
|
||||
name = models.CharField(max_length=150)
|
||||
position = models.CharField(max_length=120, blank=True, default='')
|
||||
base_salary = models.FloatField(default=0)
|
||||
insurance_no = models.CharField(max_length=50, blank=True, default='')
|
||||
hire_date = models.DateField(null=True, blank=True)
|
||||
active = models.BooleanField(default=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'accounting_employees'
|
||||
ordering = ['name']
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class Payslip(models.Model):
|
||||
"""A monthly payslip for an employee (فیش حقوقی)."""
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
employee = models.ForeignKey(Employee, on_delete=models.CASCADE, related_name='payslips')
|
||||
period = models.CharField(max_length=20, blank=True, default='')
|
||||
base_salary = models.FloatField(default=0)
|
||||
overtime = models.FloatField(default=0)
|
||||
bonus = models.FloatField(default=0)
|
||||
deductions = models.FloatField(default=0)
|
||||
insurance = models.FloatField(default=0)
|
||||
tax = models.FloatField(default=0)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'accounting_payslips'
|
||||
ordering = ['-period']
|
||||
|
||||
def __str__(self):
|
||||
return f"Payslip {self.employee_id} - {self.period}"
|
||||
|
||||
|
||||
class CompanySettings(models.Model):
|
||||
"""Singleton holding company info and the auto-voucher account mapping."""
|
||||
|
||||
id = models.PositiveSmallIntegerField(primary_key=True, default=1)
|
||||
name = models.CharField(max_length=150, blank=True, default='')
|
||||
economic_code = models.CharField(max_length=50, blank=True, default='')
|
||||
fiscal_year = models.CharField(max_length=20, blank=True, default='')
|
||||
base_currency = models.CharField(max_length=20, blank=True, default='تومان')
|
||||
tax_rate = models.FloatField(default=0)
|
||||
address = models.TextField(blank=True, default='')
|
||||
phone = models.CharField(max_length=40, blank=True, default='')
|
||||
|
||||
bank_account = models.ForeignKey(
|
||||
Account, on_delete=models.SET_NULL, null=True, blank=True, related_name='+'
|
||||
)
|
||||
owner_payable_account = models.ForeignKey(
|
||||
Account, on_delete=models.SET_NULL, null=True, blank=True, related_name='+'
|
||||
)
|
||||
customer_payable_account = models.ForeignKey(
|
||||
Account, on_delete=models.SET_NULL, null=True, blank=True, related_name='+'
|
||||
)
|
||||
owner_share_account = models.ForeignKey(
|
||||
Account, on_delete=models.SET_NULL, null=True, blank=True, related_name='+'
|
||||
)
|
||||
sales_income_account = models.ForeignKey(
|
||||
Account, on_delete=models.SET_NULL, null=True, blank=True, related_name='+'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = 'accounting_company_settings'
|
||||
|
||||
def __str__(self):
|
||||
return self.name or 'Company Settings'
|
||||
11
accounting/permissions.py
Normal file
11
accounting/permissions.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from rest_framework.permissions import BasePermission
|
||||
|
||||
|
||||
class IsAdminAccount(BasePermission):
|
||||
"""Allows access only to authenticated staff/superuser accounts."""
|
||||
|
||||
message = 'دسترسی غیرمجاز. فقط مدیران میتوانند به این بخش دسترسی داشته باشند.'
|
||||
|
||||
def has_permission(self, request, view):
|
||||
user = request.user
|
||||
return bool(user and user.is_authenticated and (user.is_staff or user.is_superuser))
|
||||
103
accounting/seeding.py
Normal file
103
accounting/seeding.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""Default chart of accounts and company settings (zeroed opening balances).
|
||||
|
||||
Shared by the initial data migration and the runtime "reset" endpoint so both
|
||||
produce an identical clean starting point with no demo business data.
|
||||
"""
|
||||
|
||||
# (code, name, type, is_group, parent_code)
|
||||
DEFAULT_ACCOUNTS = [
|
||||
('10', 'داراییهای جاری', 'asset', True, None),
|
||||
('1001', 'صندوق', 'asset', False, '10'),
|
||||
('1002', 'بانک ملت', 'asset', False, '10'),
|
||||
('1003', 'بانک سامان', 'asset', False, '10'),
|
||||
('1004', 'حسابهای دریافتنی (مشتریان)', 'asset', False, '10'),
|
||||
('1005', 'موجودی کالا', 'asset', False, '10'),
|
||||
('1006', 'پیشپرداختها', 'asset', False, '10'),
|
||||
|
||||
('15', 'داراییهای ثابت', 'asset', True, None),
|
||||
('1501', 'اموال و تجهیزات', 'asset', False, '15'),
|
||||
('1502', 'استهلاک انباشته', 'asset', False, '15'),
|
||||
|
||||
('20', 'بدهیهای جاری', 'liability', True, None),
|
||||
('2001', 'حسابهای پرداختنی (تأمینکنندگان)', 'liability', False, '20'),
|
||||
('2002', 'مالیات بر ارزش افزوده پرداختنی', 'liability', False, '20'),
|
||||
('2003', 'حقوق پرداختنی', 'liability', False, '20'),
|
||||
('2004', 'بیمه پرداختنی', 'liability', False, '20'),
|
||||
('2005', 'بدهی به مالکان (کیف پول)', 'liability', False, '20'),
|
||||
('2006', 'بدهی به مشتریان (کیف پول)', 'liability', False, '20'),
|
||||
|
||||
('30', 'حقوق صاحبان سهام', 'equity', True, None),
|
||||
('3001', 'سرمایه', 'equity', False, '30'),
|
||||
('3002', 'سود انباشته', 'equity', False, '30'),
|
||||
|
||||
('40', 'درآمدها', 'income', True, None),
|
||||
('4001', 'درآمد فروش', 'income', False, '40'),
|
||||
('4002', 'درآمد خدمات', 'income', False, '40'),
|
||||
|
||||
('50', 'هزینهها', 'expense', True, None),
|
||||
('5001', 'بهای تمامشده کالای فروشرفته', 'expense', False, '50'),
|
||||
('5002', 'هزینه حقوق و دستمزد', 'expense', False, '50'),
|
||||
('5003', 'هزینه اجاره', 'expense', False, '50'),
|
||||
('5004', 'هزینه آب، برق و گاز', 'expense', False, '50'),
|
||||
('5005', 'هزینه تبلیغات', 'expense', False, '50'),
|
||||
('5006', 'هزینه استهلاک', 'expense', False, '50'),
|
||||
('5007', 'سهم مالکان از فروش', 'expense', False, '50'),
|
||||
]
|
||||
|
||||
DEFAULT_SETTINGS = {
|
||||
'name': 'مجموعه تفریحی فانزون',
|
||||
'economic_code': '',
|
||||
'fiscal_year': '1404',
|
||||
'base_currency': 'تومان',
|
||||
'tax_rate': 10,
|
||||
'address': '',
|
||||
'phone': '',
|
||||
}
|
||||
|
||||
# Mapping of CompanySettings FK attribute -> account code.
|
||||
SETTINGS_ACCOUNT_MAP = {
|
||||
'bank_account_id': '1002',
|
||||
'owner_payable_account_id': '2005',
|
||||
'customer_payable_account_id': '2006',
|
||||
'owner_share_account_id': '5007',
|
||||
'sales_income_account_id': '4001',
|
||||
}
|
||||
|
||||
|
||||
def create_default_accounts(Account):
|
||||
"""Creates the standard chart of accounts with zeroed balances.
|
||||
|
||||
Returns a dict mapping account code -> created Account instance.
|
||||
"""
|
||||
by_code = {}
|
||||
# First pass: create all accounts without parents.
|
||||
for code, name, type_, is_group, _parent in DEFAULT_ACCOUNTS:
|
||||
by_code[code] = Account.objects.create(
|
||||
code=code, name=name, type=type_, is_group=is_group, opening_balance=0,
|
||||
)
|
||||
# Second pass: wire up parents.
|
||||
for code, _name, _type, _is_group, parent_code in DEFAULT_ACCOUNTS:
|
||||
if parent_code:
|
||||
account = by_code[code]
|
||||
account.parent = by_code[parent_code]
|
||||
account.save(update_fields=['parent'])
|
||||
return by_code
|
||||
|
||||
|
||||
def ensure_company_settings(CompanySettings, accounts_by_code):
|
||||
"""Creates (or resets) the singleton settings with the default mapping."""
|
||||
defaults = dict(DEFAULT_SETTINGS)
|
||||
for attr, code in SETTINGS_ACCOUNT_MAP.items():
|
||||
account = accounts_by_code.get(code)
|
||||
defaults[attr] = account.id if account else None
|
||||
CompanySettings.objects.update_or_create(id=1, defaults=defaults)
|
||||
|
||||
|
||||
def seed_default_data(Account, CompanySettings):
|
||||
"""Seeds chart of accounts + settings if no accounts exist yet."""
|
||||
if Account.objects.exists():
|
||||
accounts_by_code = {a.code: a for a in Account.objects.all()}
|
||||
else:
|
||||
accounts_by_code = create_default_accounts(Account)
|
||||
ensure_company_settings(CompanySettings, accounts_by_code)
|
||||
return accounts_by_code
|
||||
220
accounting/serializers.py
Normal file
220
accounting/serializers.py
Normal file
@@ -0,0 +1,220 @@
|
||||
from django.db import transaction
|
||||
from rest_framework import serializers
|
||||
|
||||
from .models import (
|
||||
Account,
|
||||
CompanySettings,
|
||||
Employee,
|
||||
Invoice,
|
||||
InvoiceLine,
|
||||
Party,
|
||||
Payslip,
|
||||
Product,
|
||||
TreasuryTxn,
|
||||
Voucher,
|
||||
VoucherLine,
|
||||
)
|
||||
|
||||
|
||||
class AccountSerializer(serializers.ModelSerializer):
|
||||
parentId = serializers.PrimaryKeyRelatedField(
|
||||
source='parent', queryset=Account.objects.all(), allow_null=True, required=False
|
||||
)
|
||||
isGroup = serializers.BooleanField(source='is_group', required=False)
|
||||
openingBalance = serializers.FloatField(source='opening_balance', required=False)
|
||||
|
||||
class Meta:
|
||||
model = Account
|
||||
fields = ['id', 'code', 'name', 'type', 'parentId', 'isGroup', 'openingBalance']
|
||||
|
||||
|
||||
class PartySerializer(serializers.ModelSerializer):
|
||||
economicCode = serializers.CharField(source='economic_code', required=False, allow_blank=True)
|
||||
openingBalance = serializers.FloatField(source='opening_balance', required=False)
|
||||
|
||||
class Meta:
|
||||
model = Party
|
||||
fields = ['id', 'kind', 'name', 'phone', 'economicCode', 'address', 'openingBalance']
|
||||
|
||||
|
||||
class ProductSerializer(serializers.ModelSerializer):
|
||||
salePrice = serializers.FloatField(source='sale_price', required=False)
|
||||
purchasePrice = serializers.FloatField(source='purchase_price', required=False)
|
||||
reorderLevel = serializers.FloatField(source='reorder_level', required=False)
|
||||
|
||||
class Meta:
|
||||
model = Product
|
||||
fields = ['id', 'code', 'name', 'unit', 'salePrice', 'purchasePrice', 'stock', 'reorderLevel']
|
||||
|
||||
|
||||
class VoucherLineSerializer(serializers.ModelSerializer):
|
||||
# Lenient: accepts client-generated ids (ignored on write, recreated server-side).
|
||||
id = serializers.CharField(required=False)
|
||||
accountId = serializers.PrimaryKeyRelatedField(source='account', queryset=Account.objects.all())
|
||||
|
||||
class Meta:
|
||||
model = VoucherLine
|
||||
fields = ['id', 'accountId', 'description', 'debit', 'credit']
|
||||
|
||||
|
||||
class VoucherSerializer(serializers.ModelSerializer):
|
||||
lines = VoucherLineSerializer(many=True)
|
||||
|
||||
class Meta:
|
||||
model = Voucher
|
||||
fields = ['id', 'number', 'date', 'description', 'status', 'source', 'lines']
|
||||
|
||||
@transaction.atomic
|
||||
def create(self, validated_data):
|
||||
lines = validated_data.pop('lines', [])
|
||||
voucher = Voucher.objects.create(**validated_data)
|
||||
for line in lines:
|
||||
line.pop('id', None)
|
||||
VoucherLine.objects.create(voucher=voucher, **line)
|
||||
return voucher
|
||||
|
||||
@transaction.atomic
|
||||
def update(self, instance, validated_data):
|
||||
lines = validated_data.pop('lines', None)
|
||||
for attr, value in validated_data.items():
|
||||
setattr(instance, attr, value)
|
||||
instance.save()
|
||||
if lines is not None:
|
||||
instance.lines.all().delete()
|
||||
for line in lines:
|
||||
line.pop('id', None)
|
||||
VoucherLine.objects.create(voucher=instance, **line)
|
||||
return instance
|
||||
|
||||
|
||||
class InvoiceLineSerializer(serializers.ModelSerializer):
|
||||
# Lenient: accepts client-generated ids (ignored on write, recreated server-side).
|
||||
id = serializers.CharField(required=False)
|
||||
productId = serializers.PrimaryKeyRelatedField(
|
||||
source='product', queryset=Product.objects.all(), allow_null=True, required=False
|
||||
)
|
||||
unitPrice = serializers.FloatField(source='unit_price', required=False)
|
||||
taxRate = serializers.FloatField(source='tax_rate', required=False)
|
||||
|
||||
class Meta:
|
||||
model = InvoiceLine
|
||||
fields = ['id', 'productId', 'quantity', 'unitPrice', 'discount', 'taxRate']
|
||||
|
||||
|
||||
class InvoiceSerializer(serializers.ModelSerializer):
|
||||
partyId = serializers.PrimaryKeyRelatedField(
|
||||
source='party', queryset=Party.objects.all(), allow_null=True, required=False
|
||||
)
|
||||
lines = InvoiceLineSerializer(many=True)
|
||||
|
||||
class Meta:
|
||||
model = Invoice
|
||||
fields = ['id', 'kind', 'number', 'partyId', 'date', 'status', 'note', 'lines']
|
||||
|
||||
@transaction.atomic
|
||||
def create(self, validated_data):
|
||||
lines = validated_data.pop('lines', [])
|
||||
invoice = Invoice.objects.create(**validated_data)
|
||||
for line in lines:
|
||||
line.pop('id', None)
|
||||
InvoiceLine.objects.create(invoice=invoice, **line)
|
||||
return invoice
|
||||
|
||||
@transaction.atomic
|
||||
def update(self, instance, validated_data):
|
||||
lines = validated_data.pop('lines', None)
|
||||
for attr, value in validated_data.items():
|
||||
setattr(instance, attr, value)
|
||||
instance.save()
|
||||
if lines is not None:
|
||||
instance.lines.all().delete()
|
||||
for line in lines:
|
||||
line.pop('id', None)
|
||||
InvoiceLine.objects.create(invoice=instance, **line)
|
||||
return instance
|
||||
|
||||
|
||||
class TreasuryTxnSerializer(serializers.ModelSerializer):
|
||||
partyId = serializers.PrimaryKeyRelatedField(
|
||||
source='party', queryset=Party.objects.all(), allow_null=True, required=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = TreasuryTxn
|
||||
fields = ['id', 'number', 'kind', 'method', 'date', 'partyId', 'amount', 'reference', 'description']
|
||||
|
||||
|
||||
class EmployeeSerializer(serializers.ModelSerializer):
|
||||
baseSalary = serializers.FloatField(source='base_salary', required=False)
|
||||
insuranceNo = serializers.CharField(source='insurance_no', required=False, allow_blank=True)
|
||||
hireDate = serializers.DateField(source='hire_date', required=False, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = Employee
|
||||
fields = ['id', 'code', 'name', 'position', 'baseSalary', 'insuranceNo', 'hireDate', 'active']
|
||||
|
||||
|
||||
class PayslipSerializer(serializers.ModelSerializer):
|
||||
employeeId = serializers.PrimaryKeyRelatedField(source='employee', queryset=Employee.objects.all())
|
||||
baseSalary = serializers.FloatField(source='base_salary', required=False)
|
||||
|
||||
class Meta:
|
||||
model = Payslip
|
||||
fields = [
|
||||
'id', 'employeeId', 'period', 'baseSalary', 'overtime', 'bonus',
|
||||
'deductions', 'insurance', 'tax',
|
||||
]
|
||||
|
||||
|
||||
class CompanySettingsSerializer(serializers.ModelSerializer):
|
||||
economicCode = serializers.CharField(source='economic_code', required=False, allow_blank=True)
|
||||
fiscalYear = serializers.CharField(source='fiscal_year', required=False, allow_blank=True)
|
||||
baseCurrency = serializers.CharField(source='base_currency', required=False, allow_blank=True)
|
||||
taxRate = serializers.FloatField(source='tax_rate', required=False)
|
||||
voucherAccounts = serializers.SerializerMethodField()
|
||||
|
||||
_MAPPING_FIELDS = {
|
||||
'bankAccountId': 'bank_account_id',
|
||||
'ownerPayableAccountId': 'owner_payable_account_id',
|
||||
'customerPayableAccountId': 'customer_payable_account_id',
|
||||
'ownerShareAccountId': 'owner_share_account_id',
|
||||
'salesIncomeAccountId': 'sales_income_account_id',
|
||||
}
|
||||
|
||||
class Meta:
|
||||
model = CompanySettings
|
||||
fields = [
|
||||
'name', 'economicCode', 'fiscalYear', 'baseCurrency',
|
||||
'taxRate', 'address', 'phone', 'voucherAccounts',
|
||||
]
|
||||
|
||||
def get_voucherAccounts(self, obj):
|
||||
def to_id(value):
|
||||
return str(value) if value else ''
|
||||
|
||||
return {
|
||||
'bankAccountId': to_id(obj.bank_account_id),
|
||||
'ownerPayableAccountId': to_id(obj.owner_payable_account_id),
|
||||
'customerPayableAccountId': to_id(obj.customer_payable_account_id),
|
||||
'ownerShareAccountId': to_id(obj.owner_share_account_id),
|
||||
'salesIncomeAccountId': to_id(obj.sales_income_account_id),
|
||||
}
|
||||
|
||||
def _apply_voucher_accounts(self, instance):
|
||||
mapping = self.initial_data.get('voucherAccounts')
|
||||
if not isinstance(mapping, dict):
|
||||
return
|
||||
valid_ids = {str(v) for v in Account.objects.values_list('id', flat=True)}
|
||||
for camel_key, model_attr in self._MAPPING_FIELDS.items():
|
||||
if camel_key not in mapping:
|
||||
continue
|
||||
raw = mapping.get(camel_key)
|
||||
setattr(instance, model_attr, raw if raw in valid_ids else None)
|
||||
|
||||
@transaction.atomic
|
||||
def update(self, instance, validated_data):
|
||||
for attr, value in validated_data.items():
|
||||
setattr(instance, attr, value)
|
||||
self._apply_voucher_accounts(instance)
|
||||
instance.save()
|
||||
return instance
|
||||
31
accounting/urls.py
Normal file
31
accounting/urls.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from django.urls import include, path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from .views import (
|
||||
AccountViewSet,
|
||||
CompanySettingsView,
|
||||
EmployeeViewSet,
|
||||
InvoiceViewSet,
|
||||
PartyViewSet,
|
||||
PayslipViewSet,
|
||||
ProductViewSet,
|
||||
TreasuryTxnViewSet,
|
||||
VoucherViewSet,
|
||||
reset_accounting_data,
|
||||
)
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'accounts', AccountViewSet, basename='accounting-account')
|
||||
router.register(r'parties', PartyViewSet, basename='accounting-party')
|
||||
router.register(r'products', ProductViewSet, basename='accounting-product')
|
||||
router.register(r'vouchers', VoucherViewSet, basename='accounting-voucher')
|
||||
router.register(r'invoices', InvoiceViewSet, basename='accounting-invoice')
|
||||
router.register(r'treasury', TreasuryTxnViewSet, basename='accounting-treasury')
|
||||
router.register(r'employees', EmployeeViewSet, basename='accounting-employee')
|
||||
router.register(r'payslips', PayslipViewSet, basename='accounting-payslip')
|
||||
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
path('settings/', CompanySettingsView.as_view(), name='accounting-settings'),
|
||||
path('reset/', reset_accounting_data, name='accounting-reset'),
|
||||
]
|
||||
129
accounting/views.py
Normal file
129
accounting/views.py
Normal file
@@ -0,0 +1,129 @@
|
||||
from django.db import transaction
|
||||
from rest_framework import status, viewsets
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from .models import (
|
||||
Account,
|
||||
CompanySettings,
|
||||
Employee,
|
||||
Invoice,
|
||||
Party,
|
||||
Payslip,
|
||||
Product,
|
||||
TreasuryTxn,
|
||||
Voucher,
|
||||
)
|
||||
from .permissions import IsAdminAccount
|
||||
from .seeding import seed_default_data
|
||||
from .serializers import (
|
||||
AccountSerializer,
|
||||
CompanySettingsSerializer,
|
||||
EmployeeSerializer,
|
||||
InvoiceSerializer,
|
||||
PartySerializer,
|
||||
PayslipSerializer,
|
||||
ProductSerializer,
|
||||
TreasuryTxnSerializer,
|
||||
VoucherSerializer,
|
||||
)
|
||||
|
||||
|
||||
class _AdminModelViewSet(viewsets.ModelViewSet):
|
||||
"""Base viewset restricting access to admin accounts."""
|
||||
|
||||
permission_classes = [IsAdminAccount]
|
||||
|
||||
|
||||
class AccountViewSet(_AdminModelViewSet):
|
||||
queryset = Account.objects.all()
|
||||
serializer_class = AccountSerializer
|
||||
pagination_class = None
|
||||
|
||||
|
||||
class PartyViewSet(_AdminModelViewSet):
|
||||
queryset = Party.objects.all()
|
||||
serializer_class = PartySerializer
|
||||
pagination_class = None
|
||||
|
||||
|
||||
class ProductViewSet(_AdminModelViewSet):
|
||||
queryset = Product.objects.all()
|
||||
serializer_class = ProductSerializer
|
||||
pagination_class = None
|
||||
|
||||
|
||||
class VoucherViewSet(_AdminModelViewSet):
|
||||
queryset = Voucher.objects.prefetch_related('lines').all()
|
||||
serializer_class = VoucherSerializer
|
||||
pagination_class = None
|
||||
|
||||
|
||||
class InvoiceViewSet(_AdminModelViewSet):
|
||||
queryset = Invoice.objects.prefetch_related('lines').all()
|
||||
serializer_class = InvoiceSerializer
|
||||
pagination_class = None
|
||||
|
||||
|
||||
class TreasuryTxnViewSet(_AdminModelViewSet):
|
||||
queryset = TreasuryTxn.objects.all()
|
||||
serializer_class = TreasuryTxnSerializer
|
||||
pagination_class = None
|
||||
|
||||
|
||||
class EmployeeViewSet(_AdminModelViewSet):
|
||||
queryset = Employee.objects.all()
|
||||
serializer_class = EmployeeSerializer
|
||||
pagination_class = None
|
||||
|
||||
|
||||
class PayslipViewSet(_AdminModelViewSet):
|
||||
queryset = Payslip.objects.all()
|
||||
serializer_class = PayslipSerializer
|
||||
pagination_class = None
|
||||
|
||||
|
||||
class CompanySettingsView(APIView):
|
||||
"""Read/update the singleton company settings."""
|
||||
|
||||
permission_classes = [IsAdminAccount]
|
||||
|
||||
def _get_instance(self):
|
||||
instance, _created = CompanySettings.objects.get_or_create(id=1)
|
||||
if instance.bank_account_id is None and Account.objects.exists():
|
||||
# Backfill the mapping for an empty/legacy settings row.
|
||||
seed_default_data(Account, CompanySettings)
|
||||
instance.refresh_from_db()
|
||||
return instance
|
||||
|
||||
def get(self, request):
|
||||
serializer = CompanySettingsSerializer(self._get_instance())
|
||||
return Response(serializer.data)
|
||||
|
||||
def patch(self, request):
|
||||
serializer = CompanySettingsSerializer(
|
||||
self._get_instance(), data=request.data, partial=True
|
||||
)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
serializer.save()
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
@permission_classes([IsAdminAccount])
|
||||
@transaction.atomic
|
||||
def reset_accounting_data(request):
|
||||
"""Wipes all accounting data and recreates a clean, zeroed starting point."""
|
||||
Voucher.objects.all().delete()
|
||||
Invoice.objects.all().delete()
|
||||
TreasuryTxn.objects.all().delete()
|
||||
Payslip.objects.all().delete()
|
||||
Employee.objects.all().delete()
|
||||
Product.objects.all().delete()
|
||||
Party.objects.all().delete()
|
||||
CompanySettings.objects.all().delete()
|
||||
Account.objects.all().delete()
|
||||
|
||||
seed_default_data(Account, CompanySettings)
|
||||
return Response({'message': 'اطلاعات حسابداری بازنشانی شد.'}, status=status.HTTP_200_OK)
|
||||
Reference in New Issue
Block a user