fix error in gitignore
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# invoicing app
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class InvoicingConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.invoicing"
|
||||
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 5.2.17 on 2026-09-15 09:29
|
||||
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Invoice',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('number', models.CharField(max_length=40, unique=True)),
|
||||
('amount', models.DecimalField(decimal_places=2, max_digits=18)),
|
||||
('currency', models.CharField(default='IRR', max_length=8)),
|
||||
('sync_state', models.CharField(choices=[('pending', 'Pending'), ('processing', 'Processing'), ('synchronized', 'Synchronized'), ('failed', 'Failed'), ('retrying', 'Retrying'), ('cancelled', 'Cancelled')], default='pending', max_length=16)),
|
||||
('odoo_invoice_id', models.CharField(blank=True, max_length=120)),
|
||||
('last_error', models.TextField(blank=True)),
|
||||
('synced_at', models.DateTimeField(blank=True, null=True)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 5.2.17 on 2026-09-15 09:29
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('invoicing', '0001_initial'),
|
||||
('orders', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='invoice',
|
||||
name='order',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='invoices', to='orders.processingorder'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,28 @@
|
||||
from django.db import models
|
||||
|
||||
from apps.core.models import TimeStampedModel, UUIDModel
|
||||
|
||||
|
||||
class Invoice(UUIDModel, TimeStampedModel):
|
||||
class SyncState(models.TextChoices):
|
||||
PENDING = "pending", "Pending"
|
||||
PROCESSING = "processing", "Processing"
|
||||
SYNCHRONIZED = "synchronized", "Synchronized"
|
||||
FAILED = "failed", "Failed"
|
||||
RETRYING = "retrying", "Retrying"
|
||||
CANCELLED = "cancelled", "Cancelled"
|
||||
|
||||
order = models.ForeignKey("orders.ProcessingOrder", on_delete=models.PROTECT, related_name="invoices")
|
||||
number = models.CharField(max_length=40, unique=True)
|
||||
amount = models.DecimalField(max_digits=18, decimal_places=2)
|
||||
currency = models.CharField(max_length=8, default="IRR")
|
||||
sync_state = models.CharField(max_length=16, choices=SyncState.choices, default=SyncState.PENDING)
|
||||
odoo_invoice_id = models.CharField(max_length=120, blank=True)
|
||||
last_error = models.TextField(blank=True)
|
||||
synced_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.number
|
||||
@@ -0,0 +1,13 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.invoicing.models import Invoice
|
||||
|
||||
|
||||
class InvoiceSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Invoice
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"id", "number", "amount", "currency", "sync_state",
|
||||
"odoo_invoice_id", "last_error", "synced_at",
|
||||
]
|
||||
@@ -0,0 +1,30 @@
|
||||
from celery import shared_task
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.integrations.odoo.services import OdooInvoiceService
|
||||
from apps.invoicing.models import Invoice
|
||||
|
||||
|
||||
@shared_task(bind=True, max_retries=5, default_retry_delay=60)
|
||||
def sync_invoice_to_odoo(self, invoice_id):
|
||||
"""Synchronize an invoice to Odoo with automatic retry (idempotent)."""
|
||||
try:
|
||||
invoice = Invoice.objects.get(pk=invoice_id)
|
||||
except Invoice.DoesNotExist:
|
||||
return
|
||||
if invoice.sync_state == Invoice.SyncState.SYNCHRONIZED:
|
||||
return # already synchronized; retries must be idempotent
|
||||
|
||||
invoice.sync_state = Invoice.SyncState.PROCESSING
|
||||
invoice.save(update_fields=["sync_state"])
|
||||
try:
|
||||
result = OdooInvoiceService().sync_invoice(invoice)
|
||||
invoice.odoo_invoice_id = result.get("id", "")
|
||||
invoice.sync_state = Invoice.SyncState.SYNCHRONIZED
|
||||
invoice.synced_at = timezone.now()
|
||||
invoice.save(update_fields=["odoo_invoice_id", "sync_state", "synced_at"])
|
||||
except Exception as exc:
|
||||
invoice.sync_state = Invoice.SyncState.RETRYING
|
||||
invoice.last_error = str(exc)
|
||||
invoice.save(update_fields=["sync_state", "last_error"])
|
||||
raise self.retry(exc=exc)
|
||||
@@ -0,0 +1,8 @@
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from apps.invoicing.views import InvoiceViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register("", InvoiceViewSet, basename="invoice")
|
||||
|
||||
urlpatterns = router.urls
|
||||
@@ -0,0 +1,52 @@
|
||||
from django.utils import timezone
|
||||
from rest_framework import decorators, mixins, response, viewsets
|
||||
|
||||
from apps.core.errors import ApiError
|
||||
from apps.integrations.odoo.services import OdooInvoiceService
|
||||
from apps.invoicing.models import Invoice
|
||||
from apps.invoicing.serializers import InvoiceSerializer
|
||||
from apps.rbac.permissions import ActionPermission
|
||||
|
||||
|
||||
class InvoiceViewSet(
|
||||
mixins.ListModelMixin, mixins.RetrieveModelMixin, mixins.CreateModelMixin, viewsets.GenericViewSet
|
||||
):
|
||||
serializer_class = InvoiceSerializer
|
||||
permission_classes = [ActionPermission]
|
||||
code_map = {
|
||||
"list": "invoice.view",
|
||||
"retrieve": "invoice.view",
|
||||
"create": "invoice.create",
|
||||
"sync": "invoice.create",
|
||||
}
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
qs = Invoice.objects.select_related("order").all()
|
||||
if user.is_superuser:
|
||||
return qs
|
||||
return qs.filter(order__customer__user=user)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
order = serializer.validated_data["order"]
|
||||
total = (order.price_snapshot or {}).get("total")
|
||||
if not total:
|
||||
raise ApiError("NO_PRICE_SNAPSHOT", "Order has no price snapshot.")
|
||||
serializer.save(number=f"INV-{order.order_number}", amount=total, currency=order.currency)
|
||||
|
||||
@decorators.action(detail=True, methods=["post"])
|
||||
def sync(self, request, pk=None):
|
||||
invoice = self.get_object()
|
||||
invoice.sync_state = Invoice.SyncState.PROCESSING
|
||||
invoice.save(update_fields=["sync_state"])
|
||||
try:
|
||||
result = OdooInvoiceService().sync_invoice(invoice)
|
||||
invoice.odoo_invoice_id = result.get("id", "")
|
||||
invoice.sync_state = Invoice.SyncState.SYNCHRONIZED
|
||||
invoice.synced_at = timezone.now()
|
||||
invoice.save(update_fields=["odoo_invoice_id", "sync_state", "synced_at"])
|
||||
except Exception as exc:
|
||||
invoice.sync_state = Invoice.SyncState.FAILED
|
||||
invoice.last_error = str(exc)
|
||||
invoice.save(update_fields=["sync_state", "last_error"])
|
||||
return response.Response(InvoiceSerializer(invoice).data)
|
||||
Reference in New Issue
Block a user