Nenhuma Descrição

models.py 15KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344
  1. from __future__ import annotations
  2. from django.conf import settings
  3. from django.contrib.auth import get_user_model
  4. from django.contrib.contenttypes.fields import GenericForeignKey
  5. from django.contrib.contenttypes.models import ContentType
  6. from django.db import models
  7. from django.utils import timezone
  8. from orgs.models import Organization
  9. User = get_user_model()
  10. class TimestampedModel(models.Model):
  11. created_at = models.DateTimeField(auto_now_add=True)
  12. updated_at = models.DateTimeField(auto_now=True)
  13. class Meta:
  14. abstract = True
  15. ## Organization and related tenancy models moved to orgs.models
  16. class MaterialCategory(TimestampedModel):
  17. organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="material_categories")
  18. name = models.CharField(max_length=255)
  19. class Meta:
  20. unique_together = ("organization", "name")
  21. def __str__(self):
  22. return self.name
  23. class Material(TimestampedModel):
  24. organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="materials")
  25. category = models.ForeignKey(MaterialCategory, on_delete=models.PROTECT, related_name="materials")
  26. name = models.CharField(max_length=255)
  27. code = models.CharField(max_length=64, blank=True)
  28. # unit choices keep MVP simple; conversions out of scope for now
  29. UNIT_KG = "kg"
  30. UNIT_LB = "lb"
  31. UNIT_PCS = "pcs"
  32. UNIT_CHOICES = (
  33. (UNIT_KG, "Kilogram"),
  34. (UNIT_LB, "Pound"),
  35. (UNIT_PCS, "Pieces"),
  36. )
  37. default_unit = models.CharField(max_length=8, choices=UNIT_CHOICES, default=UNIT_KG)
  38. class Meta:
  39. unique_together = ("organization", "name")
  40. def __str__(self):
  41. return self.name
  42. class PriceList(TimestampedModel):
  43. organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="price_lists")
  44. name = models.CharField(max_length=255)
  45. currency_code = models.CharField(max_length=8, default="USD")
  46. effective_from = models.DateField(null=True, blank=True)
  47. effective_to = models.DateField(null=True, blank=True)
  48. def __str__(self):
  49. return f"{self.name} ({self.currency_code})"
  50. class PriceListItem(TimestampedModel):
  51. DIRECTION_BUY = "buy" # we pay the customer
  52. DIRECTION_SELL = "sell" # we invoice the customer
  53. DIRECTION_CHOICES = (
  54. (DIRECTION_BUY, "Buy from customer"),
  55. (DIRECTION_SELL, "Sell to customer"),
  56. )
  57. price_list = models.ForeignKey(PriceList, on_delete=models.CASCADE, related_name="items")
  58. material = models.ForeignKey(Material, on_delete=models.PROTECT, related_name="price_items")
  59. unit = models.CharField(max_length=8, choices=Material.UNIT_CHOICES, default=Material.UNIT_KG)
  60. unit_price = models.DecimalField(max_digits=12, decimal_places=2)
  61. direction = models.CharField(max_length=8, choices=DIRECTION_CHOICES, default=DIRECTION_SELL)
  62. class Meta:
  63. unique_together = ("price_list", "material", "unit", "direction")
  64. class Customer(TimestampedModel):
  65. organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="customers")
  66. name = models.CharField(max_length=255)
  67. email = models.EmailField(blank=True)
  68. phone = models.CharField(max_length=64, blank=True)
  69. billing_address = models.TextField(blank=True)
  70. price_list = models.ForeignKey(PriceList, on_delete=models.SET_NULL, null=True, blank=True, related_name="customers")
  71. def __str__(self):
  72. return self.name
  73. class CustomerSite(TimestampedModel):
  74. customer = models.ForeignKey(Customer, on_delete=models.CASCADE, related_name="sites")
  75. name = models.CharField(max_length=255, blank=True)
  76. address = models.TextField()
  77. contact_name = models.CharField(max_length=255, blank=True)
  78. contact_phone = models.CharField(max_length=64, blank=True)
  79. contact_email = models.EmailField(blank=True)
  80. def __str__(self):
  81. return self.name or f"Site {self.id}"
  82. class ServiceAgreement(TimestampedModel):
  83. customer = models.ForeignKey(Customer, on_delete=models.CASCADE, related_name="agreements")
  84. site = models.ForeignKey(CustomerSite, on_delete=models.SET_NULL, null=True, blank=True, related_name="agreements")
  85. price_list = models.ForeignKey(PriceList, on_delete=models.PROTECT, related_name="agreements")
  86. valid_from = models.DateField(null=True, blank=True)
  87. valid_to = models.DateField(null=True, blank=True)
  88. class PickupOrder(TimestampedModel):
  89. STATUS_REQUESTED = "requested"
  90. STATUS_SCHEDULED = "scheduled"
  91. STATUS_EN_ROUTE = "en_route"
  92. STATUS_COLLECTING = "collecting"
  93. STATUS_AT_FACILITY = "at_facility"
  94. STATUS_WEIGHED = "weighed"
  95. STATUS_INVOICED = "invoiced"
  96. STATUS_COMPLETED = "completed"
  97. STATUS_CANCELED = "canceled"
  98. STATUS_CHOICES = (
  99. (STATUS_REQUESTED, "Requested"),
  100. (STATUS_SCHEDULED, "Scheduled"),
  101. (STATUS_EN_ROUTE, "En Route"),
  102. (STATUS_COLLECTING, "Collecting"),
  103. (STATUS_AT_FACILITY, "At Facility"),
  104. (STATUS_WEIGHED, "Weighed"),
  105. (STATUS_INVOICED, "Invoiced"),
  106. (STATUS_COMPLETED, "Completed"),
  107. (STATUS_CANCELED, "Canceled"),
  108. )
  109. organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="pickup_orders")
  110. customer = models.ForeignKey(Customer, on_delete=models.PROTECT, related_name="pickup_orders")
  111. site = models.ForeignKey(CustomerSite, on_delete=models.PROTECT, related_name="pickup_orders")
  112. status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_REQUESTED, db_index=True)
  113. scheduled_at = models.DateTimeField(null=True, blank=True)
  114. assigned_driver = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name="assigned_pickups")
  115. created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name="created_pickups")
  116. completed_at = models.DateTimeField(null=True, blank=True)
  117. notes = models.TextField(blank=True)
  118. def __str__(self):
  119. return f"Pickup #{self.id} - {self.customer.name}"
  120. class PickupItem(TimestampedModel):
  121. pickup = models.ForeignKey(PickupOrder, on_delete=models.CASCADE, related_name="items")
  122. material = models.ForeignKey(Material, on_delete=models.PROTECT)
  123. estimated_qty = models.DecimalField(max_digits=12, decimal_places=3, null=True, blank=True)
  124. unit = models.CharField(max_length=8, choices=Material.UNIT_CHOICES, default=Material.UNIT_KG)
  125. class WeighTicket(TimestampedModel):
  126. pickup = models.OneToOneField(PickupOrder, on_delete=models.CASCADE, related_name="weigh_ticket")
  127. ticket_number = models.CharField(max_length=64, blank=True)
  128. gross_weight = models.DecimalField(max_digits=12, decimal_places=3)
  129. tare_weight = models.DecimalField(max_digits=12, decimal_places=3)
  130. net_weight = models.DecimalField(max_digits=12, decimal_places=3)
  131. unit = models.CharField(max_length=8, choices=Material.UNIT_CHOICES, default=Material.UNIT_KG)
  132. recorded_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name="weigh_tickets")
  133. recorded_at = models.DateTimeField(default=timezone.now)
  134. class WeighLine(TimestampedModel):
  135. ticket = models.ForeignKey(WeighTicket, on_delete=models.CASCADE, related_name="lines")
  136. material = models.ForeignKey(Material, on_delete=models.PROTECT)
  137. quantity = models.DecimalField(max_digits=12, decimal_places=3)
  138. unit = models.CharField(max_length=8, choices=Material.UNIT_CHOICES, default=Material.UNIT_KG)
  139. class Invoice(TimestampedModel):
  140. STATUS_DRAFT = "draft"
  141. STATUS_ISSUED = "issued"
  142. STATUS_PAID = "paid"
  143. STATUS_VOID = "void"
  144. STATUS_CHOICES = (
  145. (STATUS_DRAFT, "Draft"),
  146. (STATUS_ISSUED, "Issued"),
  147. (STATUS_PAID, "Paid"),
  148. (STATUS_VOID, "Void"),
  149. )
  150. organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="invoices")
  151. customer = models.ForeignKey(Customer, on_delete=models.PROTECT, related_name="invoices")
  152. pickup = models.ForeignKey(PickupOrder, on_delete=models.SET_NULL, null=True, blank=True, related_name="invoices")
  153. currency_code = models.CharField(max_length=8, default="USD")
  154. total_amount = models.DecimalField(max_digits=14, decimal_places=2, default=0)
  155. status = models.CharField(max_length=8, choices=STATUS_CHOICES, default=STATUS_DRAFT, db_index=True)
  156. issued_at = models.DateTimeField(null=True, blank=True)
  157. due_at = models.DateTimeField(null=True, blank=True)
  158. class InvoiceLine(TimestampedModel):
  159. invoice = models.ForeignKey(Invoice, on_delete=models.CASCADE, related_name="lines")
  160. description = models.CharField(max_length=255)
  161. material = models.ForeignKey(Material, on_delete=models.SET_NULL, null=True, blank=True)
  162. quantity = models.DecimalField(max_digits=12, decimal_places=3)
  163. unit = models.CharField(max_length=8, choices=Material.UNIT_CHOICES, default=Material.UNIT_KG)
  164. unit_price = models.DecimalField(max_digits=12, decimal_places=2)
  165. line_total = models.DecimalField(max_digits=14, decimal_places=2)
  166. class Payment(TimestampedModel):
  167. invoice = models.ForeignKey(Invoice, on_delete=models.CASCADE, related_name="payments")
  168. amount = models.DecimalField(max_digits=14, decimal_places=2)
  169. currency_code = models.CharField(max_length=8, default="USD")
  170. received_at = models.DateTimeField(default=timezone.now)
  171. reference = models.CharField(max_length=128, blank=True)
  172. class Payout(TimestampedModel):
  173. organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="payouts")
  174. customer = models.ForeignKey(Customer, on_delete=models.PROTECT, related_name="payouts")
  175. pickup = models.ForeignKey(PickupOrder, on_delete=models.SET_NULL, null=True, blank=True, related_name="payouts")
  176. amount = models.DecimalField(max_digits=14, decimal_places=2)
  177. currency_code = models.CharField(max_length=8, default="USD")
  178. paid_at = models.DateTimeField(default=timezone.now)
  179. reference = models.CharField(max_length=128, blank=True)
  180. class Document(TimestampedModel):
  181. organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="documents")
  182. file = models.FileField(upload_to="documents/%Y/%m/%d/")
  183. kind = models.CharField(max_length=64, blank=True)
  184. # Generic attachment to any model instance
  185. content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
  186. object_id = models.PositiveIntegerField()
  187. content_object = GenericForeignKey("content_type", "object_id")
  188. uploaded_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True)
  189. class AuditLog(TimestampedModel):
  190. organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="audit_logs")
  191. user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True)
  192. action = models.CharField(max_length=64)
  193. content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
  194. object_id = models.PositiveIntegerField()
  195. content_object = GenericForeignKey("content_type", "object_id")
  196. metadata = models.JSONField(default=dict, blank=True)
  197. class Meta:
  198. indexes = [
  199. models.Index(fields=["organization", "created_at"]),
  200. models.Index(fields=["action", "created_at"]),
  201. ]
  202. class ScrapListing(TimestampedModel):
  203. TYPE_OPEN = "open"
  204. TYPE_SEALED = "sealed"
  205. AUCTION_CHOICES = ((TYPE_OPEN, "Open"), (TYPE_SEALED, "Sealed"))
  206. STATUS_DRAFT = "draft"
  207. STATUS_OPEN = "open"
  208. STATUS_CLOSED = "closed"
  209. STATUS_AWARDED = "awarded"
  210. STATUS_CANCELED = "canceled"
  211. STATUS_CHOICES = (
  212. (STATUS_DRAFT, "Draft"),
  213. (STATUS_OPEN, "Open"),
  214. (STATUS_CLOSED, "Closed"),
  215. (STATUS_AWARDED, "Awarded"),
  216. (STATUS_CANCELED, "Canceled"),
  217. )
  218. organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="scrap_listings")
  219. customer = models.ForeignKey(Customer, on_delete=models.PROTECT, related_name="scrap_listings")
  220. site = models.ForeignKey(CustomerSite, on_delete=models.PROTECT, related_name="scrap_listings")
  221. title = models.CharField(max_length=255)
  222. description = models.TextField(blank=True)
  223. auction_type = models.CharField(max_length=16, choices=AUCTION_CHOICES, default=TYPE_OPEN)
  224. currency_code = models.CharField(max_length=8, default="USD")
  225. reserve_price = models.DecimalField(max_digits=14, decimal_places=2, null=True, blank=True)
  226. min_increment = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True)
  227. starts_at = models.DateTimeField(null=True, blank=True)
  228. ends_at = models.DateTimeField(null=True, blank=True)
  229. status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_DRAFT, db_index=True)
  230. is_public = models.BooleanField(default=True)
  231. created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name="created_listings")
  232. def __str__(self):
  233. return f"Listing #{self.id} - {self.title}"
  234. class ScrapListingItem(TimestampedModel):
  235. listing = models.ForeignKey(ScrapListing, on_delete=models.CASCADE, related_name="items")
  236. material = models.ForeignKey(Material, on_delete=models.PROTECT)
  237. quantity_estimate = models.DecimalField(max_digits=12, decimal_places=3)
  238. unit = models.CharField(max_length=8, choices=Material.UNIT_CHOICES, default=Material.UNIT_KG)
  239. class ScrapBid(TimestampedModel):
  240. STATUS_ACTIVE = "active"
  241. STATUS_RETRACTED = "retracted"
  242. STATUS_ACCEPTED = "accepted"
  243. STATUS_REJECTED = "rejected"
  244. STATUS_CHOICES = (
  245. (STATUS_ACTIVE, "Active"),
  246. (STATUS_RETRACTED, "Retracted"),
  247. (STATUS_ACCEPTED, "Accepted"),
  248. (STATUS_REJECTED, "Rejected"),
  249. )
  250. listing = models.ForeignKey(ScrapListing, on_delete=models.CASCADE, related_name="bids")
  251. bidder_org = models.ForeignKey(Organization, on_delete=models.PROTECT, related_name="bids")
  252. bidder_user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name="bids")
  253. price_total = models.DecimalField(max_digits=14, decimal_places=2)
  254. message = models.TextField(blank=True)
  255. status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_ACTIVE)
  256. class Meta:
  257. indexes = [models.Index(fields=["listing", "price_total"])]
  258. class ScrapAward(TimestampedModel):
  259. listing = models.OneToOneField(ScrapListing, on_delete=models.CASCADE, related_name="award")
  260. winning_bid = models.ForeignKey(ScrapBid, on_delete=models.PROTECT, related_name="awards")
  261. pickup = models.ForeignKey(PickupOrder, on_delete=models.SET_NULL, null=True, blank=True, related_name="awards")
  262. notes = models.TextField(blank=True)
  263. class ScrapListingInvite(TimestampedModel):
  264. """Invite a specific organization (and optionally a user) to bid on a listing.
  265. When a listing is not public, only invited orgs may place bids.
  266. """
  267. listing = models.ForeignKey(ScrapListing, on_delete=models.CASCADE, related_name="invites")
  268. invited_org = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="listing_invites")
  269. invited_user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name="listing_invites")
  270. class Meta:
  271. unique_together = ("listing", "invited_org")