Nenhuma Descrição

models.py 17KB

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