Нет описания

models.py 16KB

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