HEX
Server: nginx/1.18.0
System: Linux mail.dakarash.co.id 5.15.0-164-generic #174-Ubuntu SMP Fri Nov 14 20:25:16 UTC 2025 x86_64
User: www-data (33)
PHP: 8.1.2-1ubuntu2.23
Disabled: NONE
Upload Files
File: /home/django/apps/cargochains/sales/signals.py
# sales/signals.py
from decimal import Decimal
from django.db import transaction
from django.db.models.signals import pre_save, post_save, post_delete
from django.dispatch import receiver
from django.utils import timezone

from .models import SalesQuotationLine, SalesOrder
from projects.models import Project, ProjectCategory


# ======================================================================
# A) Recalculate total quotation whenever quotation lines change
# ======================================================================

def _recalc_header(instance):
    header = instance.sales_quotation
    if getattr(header, "id", None):
        header.recalc_totals()

@receiver(post_save, sender=SalesQuotationLine)
def _line_saved(sender, instance, **kwargs):
    _recalc_header(instance)

@receiver(post_delete, sender=SalesQuotationLine)
def _line_deleted(sender, instance, **kwargs):
    _recalc_header(instance)


# ======================================================================
# B) Auto-generate Project(s) per ORDER LINE when SalesOrder goes DRAFT โ†’ CONFIRMED
# ======================================================================

CONFIRM_STATES = {"CONFIRMED"}

CATEGORY_MAP = {
    "REGULAR SHIPMENT": ("REGULAR", "Regular Shipment"),
    "PROJECT SHIPMENT": ("PROJECT", "Project Shipment"),
    "SHIP CHARTER":     ("CHARTER", "Ship Charter"),
    "SHIP MANAGEMENT":  ("MGMT", "Ship Management"),
    "AGENCY":           ("AGENCY", "Agency"),
}

def _ensure_category_for(order) -> ProjectCategory:
    """
    - Jika order berasal dari Freight Quotation โ‡’ Regular Shipment
    - Jika tidak, mapping berdasarkan sales_service.name
    - Fallback "SO" / "Sales Order"
    """
    if getattr(order, "sales_quotation_id", None):
        code, name = ("REGULAR", "Regular Shipment")
        cat, _ = ProjectCategory.objects.get_or_create(code=code, defaults={"name": name})
        return cat

    service_name = ""
    if getattr(order, "sales_service", None):
        service_name = (getattr(order.sales_service, "name", "") or "").upper().strip()

    code, name = CATEGORY_MAP.get(service_name, ("SO", "Sales Order"))
    cat, _ = ProjectCategory.objects.get_or_create(code=code, defaults={"name": name})
    return cat


def _project_ref_for_line(order, line) -> str:
    """Ref unik per line agar idempotent: <SO-number>-L<line.id>."""
    return f"{order.number}-L{line.id}"

def _project_exists_for_line(order, line) -> bool:
    return Project.objects.filter(ref_number=_project_ref_for_line(order, line)).exists()


def _od_string_from_line(line) -> str:
    """Nama origin/destination dari line."""
    origin = getattr(line.origin, "name", None) or str(line.origin)
    dest   = getattr(line.destination, "name", None) or str(line.destination)
    return f"{origin or '-'} - {dest or '-'}"


def _build_project_name_for_line(category_obj: ProjectCategory, order, line) -> str:
    cat = category_obj.name
    od  = _od_string_from_line(line)
    svc = getattr(order.sales_service, "name", None) if getattr(order, "sales_service", None) else None
    return f"{cat}: {od} ยท {svc}" if svc else f"{cat}: {od}"


def _extract_order_value(order):
    """
    Ambil grand total & currency dari SalesOrder (beberapa kemungkinan nama field).
    Return: (Decimal amount, currency_code:str)
    """
    candidates_amount = [
        getattr(order, "grand_total", None),
        getattr(order, "total_grand", None),
        getattr(order, "total_amount", None),
        getattr(order, "amount_total", None),
        getattr(order, "total", None),
        getattr(order, "grand_total_idr", None),
    ]
    amount = next((a for a in candidates_amount if a is not None), Decimal("0.00"))

    cur_obj = getattr(order, "currency", None)
    currency_code = getattr(cur_obj, "code", None) or getattr(order, "currency_code", None) or "IDR"
    return (amount or Decimal("0.00")), currency_code


def _extract_line_value(order, line, default_amount_per_line: Decimal, currency_code: str):
    """
    Ambil nilai per line jika tersedia; jika tidak, fallback = grand_total / jumlah line.
    Boleh override currency jika line punya currency sendiri.
    """
    candidates_amount = [
        getattr(line, "grand_total", None),
        getattr(line, "total_amount", None),
        getattr(line, "amount_total", None),
        getattr(line, "line_total", None),
        getattr(line, "total", None),
    ]
    amount = next((a for a in candidates_amount if a is not None), None)
    if amount is None:
        amount = default_amount_per_line

    line_cur = getattr(line, "currency", None)
    if line_cur is not None:
        currency_code = getattr(line_cur, "code", None) or str(line_cur) or currency_code

    return amount or Decimal("0.00"), currency_code or "IDR"


def _build_project_payload_for_line(order, line):
    """
    Susun payload Project untuk 1 order line:
    - Nama, kategori, status, start_date, deskripsi
    - Nilai project (amount + currency)
    - Ref unik per line
    """
    cat = _ensure_category_for(order)
    name = _build_project_name_for_line(cat, order, line)

    qref = ""
    if getattr(order, "sales_quotation", None):
        qnum = getattr(order.sales_quotation, "number", "")
        if qnum:
            qref = f" from Quotation {qnum}"

    grand_amount, grand_curr = _extract_order_value(order)
    line_count = max(order.lines.count(), 1)
    default_per_line = (grand_amount / line_count) if line_count > 0 else Decimal("0.00")
    value_amount, value_curr = _extract_line_value(order, line, default_per_line, grand_curr)

    # start_date = tanggal konfirmasi (pakai confirmed_at kalau ada)
    start_date = timezone.localdate()
    if hasattr(order, "confirmed_at") and order.confirmed_at:
        try:
            start_date = order.confirmed_at.date()
        except Exception:
            pass

    return dict(
        category=cat,
        ref_number=_project_ref_for_line(order, line),
        name=name,
        status=Project.STATUS_CONFIRMED,           # seragam dengan Sales
        start_date=start_date,
        description=f"Auto-generated{qref} for Sales Order {order.number} (Line {line.id})",
        value_amount=value_amount,
        value_currency_code=value_curr,
    )


@receiver(pre_save, sender=SalesOrder)
def create_projects_per_line_when_so_confirmed(sender, instance: SalesOrder, **kwargs):
    """
    Saat SalesOrder transisi DRAFT โ†’ CONFIRMED:
    - Buat Project per LINE (1 line = 1 project).
    - Idempotent via ref_number "<SO>-L<line.id>".
    """
    if not instance.pk:
        return

    try:
        old = sender.objects.only("status", "number").get(pk=instance.pk)
    except sender.DoesNotExist:
        return

    old_status = (old.status or "").upper()
    new_status = (instance.status or "").upper()
    if not (old_status == "DRAFT" and new_status in CONFIRM_STATES):
        return

    lines_qs = instance.lines.select_related("origin", "destination")
    if not lines_qs.exists():
        return

    with transaction.atomic():
        for line in lines_qs:
            if _project_exists_for_line(instance, line):
                continue
            payload = _build_project_payload_for_line(instance, line)
            Project.objects.create(**payload)