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/job/models/job_orders.py
from django.db import models
from django.db.models import PROTECT
from django.conf import settings
from django.db.models import Q

from core.models.services import Service
from core.models.currencies import Currency
from core.models.payment_terms import  PaymentTerm
from core.models.taxes import  Tax


from core.utils.numbering import get_next_number
from partners.models import Partner
from partners.models import Customer
from decimal import Decimal
from django.utils import timezone
from django.core.exceptions import ValidationError
from django.utils import timezone
from django.utils.safestring import mark_safe
from geo.models import Location
from django.db import transaction
from django.utils import formats
from django_summernote.fields import SummernoteTextField
from django.db.models import Sum

class JobOrderQuerySet(models.QuerySet):
    def visible(self):
        return self.exclude(status="QUOTATION")
    
class TimeStampedModel(models.Model):
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        abstract = True


class JobOrder(TimeStampedModel):

    objects = JobOrderQuerySet.as_manager()          # default
    all_objects = models.Manager()       
    
    number = models.CharField("Job No", max_length=30, unique=True)
    order_number = models.CharField(
        max_length=30, 
        null=True,
        blank=True,
        help_text="Customer reference eg. PO#",
        
    )

    job_date = models.DateField("Date")
    shp_date = models.DateField("Date",blank=True,null=True)

    service = models.ForeignKey(
        Service,
        on_delete=PROTECT,
        related_name="jobs_services",
        verbose_name="Service",
    )

    customer = models.ForeignKey(
        Customer,
        on_delete=PROTECT,
        related_name="customer_jobs",
        verbose_name="Customer",
        
    )

    origin = models.ForeignKey(
        Location,
        null=True,
        blank=True,
        on_delete=models.PROTECT,
        related_name="job_orders_origin",
    )
    destination = models.ForeignKey(
        Location,
        null=True,
        blank=True,
        on_delete=models.PROTECT,
        related_name="job_orders_destination",
    )

    shipper_name = models.CharField(max_length=255, blank=True)
    consignee_name = models.CharField(max_length=255, blank=True)
    

    cargo_description = models.TextField( 
        blank=True,
        help_text="Goods or matrials description.",
    )
    cargo_dimension = models.TextField( 
        blank=True,
        help_text="Goods or matrial package and dimensions",
    )
    customer_note = SummernoteTextField(
       
        blank=True,
        verbose_name="Customer Notes",
        help_text="Type your attension for your customer"
    )
    sla_note = models.TextField(
       
        blank=True,
        verbose_name="Description",
        help_text="Types your service level agreements."
    )

    term_conditions = SummernoteTextField(blank=True)
    
    quantity = models.DecimalField(
        max_digits=10,
        decimal_places=2,
        null=True,
        blank=True,
        help_text="Jumlah / volume cargo (opsional).",
    )

    pickup = models.TextField(
        "Pick Up",
        blank=True,
        help_text="Alamat / lokasi penjemputan.",
    )

    delivery = models.TextField(
        "Delivery",
        blank=True,
        help_text="Alamat / lokasi pengantaran.",
    )

    pic = models.CharField(
        "PIC",
        max_length=100,
        blank=True,
        help_text="Nama PIC.",
    )

    payment_term = models.ForeignKey(
        PaymentTerm,
        on_delete=PROTECT,
        related_name="jobs_payments",
        verbose_name="Payment Term",
    )

    currency = models.ForeignKey(
        Currency,
        on_delete=PROTECT,
        default=1,  # sesuaikan default ID om
         related_name="job_order_currency",
    )

    qty = models.DecimalField(
        max_digits=12,
        decimal_places=2,
        default=0,
    )

    price = models.DecimalField(
        max_digits=14,
        decimal_places=2,
        default=0,
    )

    taxes = models.ManyToManyField(
        Tax,
        blank=True,
        related_name="job_taxes",
    )



    total_amount = models.DecimalField(
        max_digits=14,
        decimal_places=2,
        default=0,
    )


    is_tax = models.BooleanField(default=False, verbose_name="Apply Tax 1.1 (VAT)")
    is_pph = models.BooleanField(default=False, verbose_name="Apply PPH")

    tax_amount = models.DecimalField(
        max_digits=12,
        decimal_places=2,
        default=0,
    )


    kurs_idr = models.DecimalField(
        max_digits=12,
        decimal_places=2,
        default=1,
    )

    total_in_idr = models.DecimalField(
        max_digits=12,
        decimal_places=2,
        default=0,
    )

    down_payment_percent = models.DecimalField(
        max_digits=5,
        decimal_places=2,
        null=True,
        blank=True
    )

    is_proforma = models.BooleanField(default=False)
    pph_amount = models.DecimalField(
        max_digits=12,
        decimal_places=2,
        null=True,
        blank=True,
        default=0,
    )

    grand_total = models.DecimalField(
        max_digits=14,
        decimal_places=2,
        default=0,
    )


    sales_user = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=PROTECT,
        related_name="job_order_sales_user",
       
        null=True,
        blank=True,
        
    )


    remarks_internal = models.TextField(
        blank=True,
        help_text="Catatan internal untuk tim.",
    )


    is_invoiced = models.BooleanField(
        default=False,
        help_text="Sudah dibuat invoice"
    )

    ST_QUOTATION = "QUOTATION"  
    ST_DRAFT = "DRAFT"
    ST_IN_COSTING = "IN_COSTING"
    ST_IN_PROGRESS= "IN_PROGRESS"
    ST_ON_HOLD = "ON_HOLD"
    ST_CANCELLED = "CANCELLED"
    ST_COMPLETED = "COMPLETED"

    STATUS_CHOICES = [
        (ST_QUOTATION, "Quotation"),
        (ST_DRAFT, "Draft"),
        (ST_IN_COSTING, "In Costing"),
        (ST_IN_PROGRESS, "In Progress"),
        (ST_ON_HOLD, "On Hold"),
        (ST_CANCELLED, "Cancelled"),
        (ST_COMPLETED, "Completed"),
    ]


    status = models.CharField(
        max_length=20,
        choices=STATUS_CHOICES,
        default=ST_DRAFT,
        db_index=True,
    )

    # DISCOUNT
    DISCOUNT_TYPE_CHOICES = [
        ("PERCENT", "Percent"),
        ("AMOUNT", "Fixed Amount"),
    ]
    discount_type = models.CharField(
        max_length=10,
        choices=DISCOUNT_TYPE_CHOICES,
        default="PERCENT",
    )

    discount_value = models.DecimalField(max_digits=18, decimal_places=2, null=True, blank=True)
    discount_notes = models.TextField(blank=True, default="")

    class JobSource(models.TextChoices):
      
        CUSTOMER_PO = "CUSTOMER_PO", "Customer PO"
        EMAIL = "EMAIL", "Email"

    job_source = models.CharField(
        max_length=20,
        choices=JobSource.choices,
        null=True,
        blank=True
    )
    
    bank_transfer_info = models.TextField(
        null=True,
        blank=True
    )
    

    # audit transisi
    confirmed_at = models.DateTimeField(null=True, blank=True)
    confirmed_by = models.ForeignKey(
        settings.AUTH_USER_MODEL, on_delete=PROTECT,
        null=True, blank=True, related_name="+"
    )

    hold_at = models.DateTimeField(null=True, blank=True)
    hold_reason = models.CharField(max_length=255, blank=True, null=True)

    completed_at = models.DateTimeField(null=True, blank=True)
    completed_by = models.ForeignKey(
        settings.AUTH_USER_MODEL, on_delete=PROTECT,
        null=True, blank=True, related_name="+"
    )

    cancelled_at = models.DateTimeField(null=True, blank=True)
    cancelled_by = models.ForeignKey(
        settings.AUTH_USER_MODEL, on_delete=PROTECT,
        null=True, blank=True, related_name="+"
    )
    cancel_reason = models.CharField(max_length=255, blank=True, null=True)

    complete_journal = models.ForeignKey(
        "accounting.Journal",
        on_delete=models.PROTECT,
        null=True,
        blank=True,
        related_name="+",
    )

    

    STATUS_COLORS = {
        ST_DRAFT: "secondary",
        ST_IN_COSTING: "primary",
        ST_IN_PROGRESS: "info",
        ST_ON_HOLD: "warning",
        ST_COMPLETED: "success",
        ST_CANCELLED: "danger",
    }


        

    @property
    def job_status_label(self):
        color = self.STATUS_COLORS.get(self.status, "secondary")
        label = self.get_status_display()
        return mark_safe(
            f'<span class="badge text-bg-{color}">{label}</span>'
        )
    
    @property
    def job_status_label_print (self):
        label = self.get_status_display()
        return mark_safe(
            f'<span class="label-print">{label}</span>'
        )
    
    @property
    def is_cost_locked(self):
        return self.status in {
            self.ST_IN_PROGRESS,
            self.ST_COMPLETED,
            self.ST_CANCELLED,
        }
    

    # job.models.job_orders.JobOrder

    from decimal import Decimal

    @property
    def discount_amount(self):
        if not self.discount_type or not self.discount_value:
            return Decimal("0")

        base = self.total_amount or Decimal("0")

        if self.discount_type == "AMOUNT":
            return min(self.discount_value, base)

        if self.discount_type == "PERCENT":
            pct = min(self.discount_value, Decimal("100"))
            return (base * pct / Decimal("100")).quantize(Decimal("0.01"))

        return Decimal("0")


    @property
    def down_payment_amount(self):
        percent = self.down_payment_percent or Decimal("0.00")
        base = self.grand_total or Decimal("0.00")

        if percent <= 0:
            return Decimal("0.00")

        return (base * percent) / Decimal("100")

    @property
    def remaining_balance(self):
        remaining = (self.grand_total or Decimal("0.00")) - self.down_payment_amount
        return remaining if remaining > 0 else Decimal("0.00")
    
    @property
    def subtotal_after_discount(self):
        total = self.total_amount or Decimal("0.00")
        discount = self.discount_amount or Decimal("0.00")
        return total - discount

    @property
    def total_invoiced(self):
        total = self.invoices.filter(
            invoice_type__in=["DP", "FINAL"]
        ).aggregate(
            total=Sum("total_amount")
        )["total"]
        return total or Decimal("0.00")
    
    @property
    def remaining_invoiceable(self):
        base = self.grand_total or Decimal("0.00")
        remaining = base - self.total_invoiced
        return remaining if remaining > 0 else Decimal("0.00")

    @property
    def has_dp(self):
        return bool(self.down_payment_percent and self.down_payment_percent > 0)

    @property
    def has_dp_invoice(self):
        return self.invoices.filter(invoice_type="DP").exists()

    @property
    def has_final_invoice(self):
        return self.invoices.filter(invoice_type="FINAL").exists()

    @property
    def can_generate_dp(self):
        return (
            self.status == self.ST_IN_COSTING
            and self.has_dp
            and not self.has_dp_invoice
        )

    @property
    def can_generate_final(self):
        return (
            self.status == self.ST_IN_PROGRESS
            and self.remaining_invoiceable > 0
            and (not self.has_dp or self.has_dp_invoice)
            and not self.has_final_invoice
        )


    # --- helpers transisi (opsional tapi saya rekomendasikan) ---
    

    # ===============================
    # CONFIRM (DRAFT → IN_COSTING)
    # ===============================
    def can_confirm(self):
        return self.status == self.ST_DRAFT


    def confirm(self, user):
        if not self.can_confirm():
            raise ValidationError("Job is not in DRAFT.")

        self.status = self.ST_IN_COSTING
        self.confirmed_at = timezone.now()
        self.confirmed_by = user

        # reset hold data
        self.hold_at = None
        self.hold_reason = ""


    # ===============================
    # START PROGRESS (IN_COSTING → IN_PROGRESS)
    # ===============================
    def can_start_progress(self):
        return self.status == self.ST_IN_COSTING


    def start_progress(self, user):
        if not self.can_start_progress():
            raise ValidationError("Job is not in IN_COSTING.")

        # 🔥 RULE BARU: jika ada DP, harus sudah dibuat
        if self.has_dp and not self.has_dp_invoice:
            raise ValidationError(
                "Down Payment invoice must be generated before moving to In Progress."
            )

        self.status = self.ST_IN_PROGRESS

        # reset hold data
        self.hold_at = None
        self.hold_reason = ""


    # ===============================
    # HOLD
    # ===============================
    def can_hold(self):
        return self.status == self.ST_IN_PROGRESS


    def hold(self, user, reason: str):
        if not self.can_hold():
            raise ValidationError("Job is not IN_PROGRESS.")

        if not (reason or "").strip():
            raise ValidationError("Hold reason is required.")

        self.status = self.ST_ON_HOLD
        self.hold_at = timezone.now()
        self.hold_reason = reason.strip()


    # ===============================
    # RESUME
    # ===============================
    def can_resume(self):
        return self.status == self.ST_ON_HOLD


    def resume(self, user):
        if not self.can_resume():
            raise ValidationError("Job is not ON_HOLD.")

        self.status = self.ST_IN_PROGRESS


    # ===============================
    # CANCEL
    # ===============================
    def can_cancel(self):
        return self.status in {
            self.ST_DRAFT,
            self.ST_IN_COSTING,
            self.ST_IN_PROGRESS,
            self.ST_ON_HOLD,
        }


    def cancel(self, user, reason: str):
        if not self.can_cancel():
            raise ValidationError("Job cannot be cancelled.")

        if not (reason or "").strip():
            raise ValidationError("Cancel reason is required.")

        self.status = self.ST_CANCELLED
        self.cancelled_at = timezone.now()
        self.cancelled_by = user
        self.cancel_reason = reason.strip()


    # ===============================
    # COMPLETE (IN_PROGRESS → COMPLETED)
    # ===============================
    def complete(self, user):
        if self.status != self.ST_IN_PROGRESS:
            raise ValidationError("Job hanya bisa di-complete dari status In Progress.")

        if self.complete_journal_id:
            raise ValidationError("Job ini sudah pernah dibuat jurnal Complete.")

        costs = self.job_costs.filter(is_active=True)

        if not costs.exists():
            raise ValidationError("Tidak bisa Complete: belum ada job cost.")

        self.status = self.ST_COMPLETED
        self.completed_at = timezone.now()
        self.completed_by = user
        self.save(update_fields=["status", "completed_at", "completed_by"])

        # auto posting COGS
        from job.services.posting import ensure_job_costing_posted
        ensure_job_costing_posted(self)

    class Meta:
      
        ordering = ["-job_date", "-id"]
        db_table = "job_orders"
        verbose_name = "JobOrder"
        verbose_name_plural = "JobOrders"

    def __str__(self):
        return self.number


    def save(self, *args, **kwargs):

        is_new = self.pk is None

        # ===== STATUS TRANSITION CHECK =====
        if not is_new:
            old = type(self).objects.get(pk=self.pk)

            # Jika status berubah
            if old.status != self.status:

                # Rule: tidak boleh masuk IN_PROGRESS jika DP belum dibuat
                if self.status == self.ST_IN_PROGRESS:
                    if self.has_dp and not self.has_dp_invoice:
                        raise ValidationError(
                            "Down Payment invoice must be generated before moving to In Progress."
                        )

        # ===== AUTO NUMBERING (existing logic) =====
        if is_new and not (self.number or "").strip():
            self.number = get_next_number("job", "JOB_ORDER")

        super().save(*args, **kwargs)

    @classmethod
    def visible(cls):
        # sesuai desain om: job status QUOTATION tidak muncul di list normal
        return cls.objects.exclude(status=cls.ST_QUOTATION)
    
    def clean(self):
        super().clean()

        # Rule: tidak boleh masuk IN_PROGRESS jika DP belum dibuat
        if self.status == self.ST_IN_PROGRESS:
            if self.has_dp and not self.has_dp_invoice:
                raise ValidationError(
                    "Down Payment invoice must be generated before moving to In Progress."
                )
        
    def convert_from_quotation(self, *, user=None, job_date=None):
        self.status = self.ST_DRAFT

        self.job_date = job_date or timezone.localdate()

        if user is not None and hasattr(self, "sales_user_id"):
            self.sales_user = user

        # selalu set nomor final saat convert
        self.number = get_next_number("job", "JOB_ORDER")

        update_fields = ["status", "job_date", "number"]
        if user is not None and hasattr(self, "sales_user_id"):
            update_fields.append("sales_user")

        self.save(update_fields=update_fields)


    @property
    def service_code(self) -> str:
        return (getattr(self.service, "code", "") or "").upper()

    @property
    def service_kind(self) -> str:
        if self.service_code.endswith("_SEA"):
            return "SEA"
        if self.service_code.endswith("_AIR"):
            return "AIR"
        return ""

    @property
    def service_display(self) -> str:
         name = getattr(self.service, "name", "") or "-"
         return f"{name} Services"


    @property
    def route_display(self) -> str:
        o = getattr(self.origin, "name", "") if self.origin else ""
        d = getattr(self.destination, "name", "") if self.destination else ""
        if o and d:
            return f"{o} - {d}"
        return o or d or "-"
    
    @property
    def d2d_display(self) -> str:
        lines = []
        if self.pickup:
            lines.append(f"Pick Up: {self.pickup}")
        if self.delivery:
            lines.append(f"Delivery: {self.delivery}")
        return "\n".join(lines)


    @property
    def is_d2d(self) -> bool:
        # sesuaikan logic deteksi D2D di project om:
        # opsi A: Service punya code
        code = (getattr(self.service, "code", "") or "").upper()
        if code:
            return code == "D2D"

        # opsi B fallback: cek nama service
        name = (getattr(self.service, "name", "") or "").upper()
        return "D2D" in name or "DOOR" in name

    @property
    def etd_display(self) -> str:
        if not self.shp_date:
            return "-"
        return formats.date_format(self.shp_date, "d-m-Y")


    @property
    def etd_display(self) -> str:
        if not self.shp_date:
            return "-"
        return formats.date_format(self.shp_date, "d-m-Y")

    @property
    def print_description(self) -> str:
        lines = []

        svc_name =   self.service_display

        # ===== P2P SEA / AIR =====
        if self.service_code in ("P2P_SEA", "P2P_AIR"):
            kind = self.service_kind
            header = f"{svc_name} ({kind})" if kind else svc_name
            lines.append(header)
            lines.append(self.route_display)

        # ===== D2D =====
        elif self.service_code == "D2D":
            lines.append(f"{svc_name} (D2D)")
            d2d = self.d2d_display
            if d2d:
                lines.append(d2d)

        # ===== fallback =====
        else:
            lines.append(svc_name)
            lines.append(self.route_display)

        # ===== ETD =====
        lines.append(f"ETD: {self.etd_display}")

        return "\n".join([l for l in lines if l and l.strip()])

    from decimal import Decimal


    @property
    def tax_rate_display(self) -> str:
        """
        Output contoh:
        - 1.1%
        - PPN 1.1%
        - PPN 1.1%, PPh 23 2%
        """
        items = self.taxes.all()
        if not items:
            return "-"

        parts = []
        for t in items:
            name = getattr(t, "name", "") or getattr(t, "code", "") or "Tax"

            # coba beberapa kemungkinan nama field rate
            rate = (
                getattr(t, "rate", None)
                or getattr(t, "percent", None)
                or getattr(t, "percentage", None)
                or getattr(t, "value", None)
            )

            if rate is None:
                parts.append(name)
                continue

            if isinstance(rate, Decimal):
                rate_str = str(rate.normalize())
            else:
                rate_str = str(rate).strip()

            parts.append(f"{name} {rate_str}%")

        return ", ".join(parts)

    @property
    def ppn_rate_display(self) -> str:
        ppn = self.taxes.filter(group="PPN").order_by("id").first()
        if not ppn:
            return "-"

        r = ppn.rate
        if isinstance(r, Decimal):
            r = r.normalize()
        return f"{r}%" 

    @property
    def ppn_label_rate_display(self) -> str:
        qs = self.taxes.filter(group="PPN").values_list("rate", flat=True)
        rates = list(qs)
        if not rates:
            return "-"

        out = []
        for r in rates:
            out.append(str(r.normalize() if hasattr(r, "normalize") else r))

        return f"PPN {' + '.join([x + '%' for x in out])}"


    
class JobOrderAttachment(TimeStampedModel):
    job_order = models.ForeignKey(
        "JobOrder",
        on_delete=models.CASCADE,
        related_name="job_order_attachments",
        verbose_name="Job Order Attachment",
    )
    file = models.FileField(
        upload_to="job_orders/%Y/%m/",
        verbose_name="File",
    )
    description = models.CharField(
        max_length=255,
        blank=True,
        verbose_name="Description",
        help_text="Keterangan singkat file, misal: PO Customer, Kontrak, dll."
    )
   
    uploaded_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.SET_NULL,
        null=True,
        blank=True,
        related_name="job_order_user_attachments",
    )


    class Meta:
        verbose_name = "Job Order Attachment"
        verbose_name_plural = "Job Order Attachments"
        ordering = ["-created_at"]

    def __str__(self):
        return f"{self.job_order.number} - {self.filename}"

    @property
    def filename(self):
        return self.file.name.split("/")[-1]