Нема описа

models.py 20KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480
  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 django.utils.translation import gettext_lazy as _
  9. from orgs.models import Organization
  10. from markdownfield.models import MarkdownField, RenderedMarkdownField
  11. from markdownfield.validators import VALIDATOR_STANDARD
  12. User = get_user_model()
  13. class TimestampedModel(models.Model):
  14. created_at = models.DateTimeField(auto_now_add=True)
  15. updated_at = models.DateTimeField(auto_now=True)
  16. class Meta:
  17. abstract = True
  18. ## Organization and related tenancy models moved to orgs.models
  19. class MaterialCategory(TimestampedModel):
  20. organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="material_categories")
  21. # Limit to a curated, preset set of category names
  22. CATEGORY_CHOICES = (
  23. ("Plastics", "Plastics"),
  24. ("Metals", "Metals"),
  25. ("Paper", "Paper"),
  26. ("Glass", "Glass"),
  27. ("Electronics", "Electronics"),
  28. ("Wood", "Wood"),
  29. ("Rubber", "Rubber"),
  30. ("Textiles", "Textiles"),
  31. ("Organic", "Organic"),
  32. ("Mixed", "Mixed"),
  33. )
  34. name = models.CharField(max_length=255, choices=CATEGORY_CHOICES)
  35. class Meta:
  36. unique_together = ("organization", "name")
  37. def __str__(self):
  38. return self.name
  39. class ProvidedService(TimestampedModel):
  40. organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="services")
  41. TITLE_CHOICES = (
  42. ("Pickup & Logistics", _("Pickup & Logistics")),
  43. ("Material Sorting", _("Material Sorting")),
  44. ("Weighing & Ticketing", _("Weighing & Ticketing")),
  45. ("Invoicing & Payouts", _("Invoicing & Payouts")),
  46. ("Reporting & Analytics", _("Reporting & Analytics")),
  47. ("Marketplace & Bidding", _("Marketplace & Bidding")),
  48. ("Compliance & Audits", _("Compliance & Audits")),
  49. ("Consulting & Training", _("Consulting & Training")),
  50. )
  51. title = models.CharField(max_length=100, choices=TITLE_CHOICES)
  52. # Short summary text
  53. description = models.TextField()
  54. # Long-form markdown body
  55. body = MarkdownField(rendered_field="body_html", validator=VALIDATOR_STANDARD, blank=True, null=True)
  56. body_html = RenderedMarkdownField(blank=True, null=True)
  57. display_order = models.PositiveIntegerField(default=0)
  58. is_enabled = models.BooleanField(default=True)
  59. IMAGE_NAME_MAP = {
  60. "Pickup & Logistics": "pickup-logistics.png",
  61. "Material Sorting": "material-sorting.png",
  62. "Weighing & Ticketing": "weighing-ticketing.png",
  63. "Invoicing & Payouts": "invoicing-payouts.png",
  64. "Reporting & Analytics": "reporting-analytics.png",
  65. "Marketplace & Bidding": "marketplace-bidding.png",
  66. "Compliance & Audits": "compliance-audits.png",
  67. "Consulting & Training": "consulting-training.png",
  68. }
  69. @property
  70. def image_name(self) -> str:
  71. return self.IMAGE_NAME_MAP.get(self.title, "default-service.png")
  72. class Meta:
  73. ordering = ["display_order"]
  74. def __str__(self) -> str:
  75. return self.title
  76. class Material(TimestampedModel):
  77. organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="materials")
  78. # Preset category choices (no FK)
  79. CATEGORY_CHOICES = MaterialCategory.CATEGORY_CHOICES
  80. category = models.CharField(max_length=64, choices=CATEGORY_CHOICES)
  81. name = models.CharField(max_length=255)
  82. code = models.CharField(max_length=64, blank=True)
  83. # unit choices keep MVP simple; conversions out of scope for now
  84. UNIT_KG = "kg"
  85. UNIT_LB = "lb"
  86. UNIT_PCS = "pcs"
  87. UNIT_CHOICES = (
  88. (UNIT_KG, "Kilogram"),
  89. (UNIT_LB, "Pound"),
  90. (UNIT_PCS, "Pieces"),
  91. )
  92. default_unit = models.CharField(max_length=8, choices=UNIT_CHOICES, default=UNIT_KG)
  93. class Meta:
  94. unique_together = ("organization", "name")
  95. def __str__(self):
  96. return self.name
  97. class MaterialImage(TimestampedModel):
  98. material = models.ForeignKey(Material, on_delete=models.CASCADE, related_name="images")
  99. image = models.ImageField(upload_to="materials/%Y/%m/")
  100. caption = models.CharField(max_length=255, blank=True)
  101. display_order = models.PositiveIntegerField(default=0)
  102. class Meta:
  103. ordering = ["display_order", "id"]
  104. def __str__(self) -> str:
  105. return self.caption or f"MaterialImage #{self.id}"
  106. class PriceList(TimestampedModel):
  107. organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="price_lists")
  108. name = models.CharField(max_length=255)
  109. currency_code = models.CharField(max_length=8, default="USD")
  110. effective_from = models.DateField(null=True, blank=True)
  111. effective_to = models.DateField(null=True, blank=True)
  112. def __str__(self):
  113. return f"{self.name} ({self.currency_code})"
  114. class PriceListItem(TimestampedModel):
  115. DIRECTION_BUY = "buy" # we pay the customer
  116. DIRECTION_SELL = "sell" # we invoice the customer
  117. DIRECTION_CHOICES = (
  118. (DIRECTION_BUY, "Buy from customer"),
  119. (DIRECTION_SELL, "Sell to customer"),
  120. )
  121. price_list = models.ForeignKey(PriceList, on_delete=models.CASCADE, related_name="items")
  122. material = models.ForeignKey(Material, on_delete=models.PROTECT, related_name="price_items")
  123. unit = models.CharField(max_length=8, choices=Material.UNIT_CHOICES, default=Material.UNIT_KG)
  124. unit_price = models.DecimalField(max_digits=12, decimal_places=2)
  125. direction = models.CharField(max_length=8, choices=DIRECTION_CHOICES, default=DIRECTION_SELL)
  126. class Meta:
  127. unique_together = ("price_list", "material", "unit", "direction")
  128. class Customer(TimestampedModel):
  129. organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="customers")
  130. name = models.CharField(max_length=255)
  131. email = models.EmailField(blank=True)
  132. phone = models.CharField(max_length=64, blank=True)
  133. billing_address = models.TextField(blank=True)
  134. price_list = models.ForeignKey(PriceList, on_delete=models.SET_NULL, null=True, blank=True, related_name="customers")
  135. def __str__(self):
  136. return self.name
  137. class CustomerSite(TimestampedModel):
  138. customer = models.ForeignKey(Customer, on_delete=models.CASCADE, related_name="sites")
  139. name = models.CharField(max_length=255, blank=True)
  140. address = models.TextField()
  141. contact_name = models.CharField(max_length=255, blank=True)
  142. contact_phone = models.CharField(max_length=64, blank=True)
  143. contact_email = models.EmailField(blank=True)
  144. def __str__(self):
  145. return self.name or f"Site {self.id}"
  146. class ServiceAgreement(TimestampedModel):
  147. customer = models.ForeignKey(Customer, on_delete=models.CASCADE, related_name="agreements")
  148. site = models.ForeignKey(CustomerSite, on_delete=models.SET_NULL, null=True, blank=True, related_name="agreements")
  149. price_list = models.ForeignKey(PriceList, on_delete=models.PROTECT, related_name="agreements")
  150. valid_from = models.DateField(null=True, blank=True)
  151. valid_to = models.DateField(null=True, blank=True)
  152. class PickupOrder(TimestampedModel):
  153. STATUS_REQUESTED = "requested"
  154. STATUS_SCHEDULED = "scheduled"
  155. STATUS_EN_ROUTE = "en_route"
  156. STATUS_COLLECTING = "collecting"
  157. STATUS_AT_FACILITY = "at_facility"
  158. STATUS_WEIGHED = "weighed"
  159. STATUS_INVOICED = "invoiced"
  160. STATUS_COMPLETED = "completed"
  161. STATUS_CANCELED = "canceled"
  162. STATUS_CHOICES = (
  163. (STATUS_REQUESTED, "Requested"),
  164. (STATUS_SCHEDULED, "Scheduled"),
  165. (STATUS_EN_ROUTE, "En Route"),
  166. (STATUS_COLLECTING, "Collecting"),
  167. (STATUS_AT_FACILITY, "At Facility"),
  168. (STATUS_WEIGHED, "Weighed"),
  169. (STATUS_INVOICED, "Invoiced"),
  170. (STATUS_COMPLETED, "Completed"),
  171. (STATUS_CANCELED, "Canceled"),
  172. )
  173. organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="pickup_orders")
  174. customer = models.ForeignKey(Customer, on_delete=models.PROTECT, related_name="pickup_orders")
  175. site = models.ForeignKey(CustomerSite, on_delete=models.PROTECT, related_name="pickup_orders")
  176. status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_REQUESTED, db_index=True)
  177. scheduled_at = models.DateTimeField(null=True, blank=True)
  178. assigned_driver = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name="assigned_pickups")
  179. created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name="created_pickups")
  180. completed_at = models.DateTimeField(null=True, blank=True)
  181. notes = models.TextField(blank=True)
  182. def __str__(self):
  183. return f"Pickup #{self.id} - {self.customer.name}"
  184. class Meta:
  185. permissions = (
  186. ("assign_driver", "Can assign driver to pickup"),
  187. ("set_pickup_status", "Can set pickup status"),
  188. ("create_weigh_ticket", "Can create weigh ticket for pickup"),
  189. ("generate_invoice", "Can generate invoice for pickup"),
  190. )
  191. class PickupItem(TimestampedModel):
  192. pickup = models.ForeignKey(PickupOrder, on_delete=models.CASCADE, related_name="items")
  193. material = models.ForeignKey(Material, on_delete=models.PROTECT)
  194. estimated_qty = models.DecimalField(max_digits=12, decimal_places=3, null=True, blank=True)
  195. unit = models.CharField(max_length=8, choices=Material.UNIT_CHOICES, default=Material.UNIT_KG)
  196. class WeighTicket(TimestampedModel):
  197. pickup = models.ForeignKey(PickupOrder, on_delete=models.CASCADE, related_name="weigh_tickets")
  198. ticket_number = models.CharField(max_length=64, blank=True)
  199. gross_weight = models.DecimalField(max_digits=12, decimal_places=3)
  200. tare_weight = models.DecimalField(max_digits=12, decimal_places=3)
  201. net_weight = models.DecimalField(max_digits=12, decimal_places=3)
  202. unit = models.CharField(max_length=8, choices=Material.UNIT_CHOICES, default=Material.UNIT_KG)
  203. recorded_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name="weigh_tickets")
  204. recorded_at = models.DateTimeField(default=timezone.now)
  205. # Finalization controls
  206. is_finalized = models.BooleanField(default=False)
  207. finalized_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name="finalized_tickets")
  208. finalized_at = models.DateTimeField(null=True, blank=True)
  209. class Meta:
  210. permissions = (
  211. ("generate_invoice", "Can generate invoice from weigh ticket"),
  212. ("edit_weigh_ticket", "Can edit weigh ticket weights"),
  213. )
  214. class WeighLine(TimestampedModel):
  215. ticket = models.ForeignKey(WeighTicket, on_delete=models.CASCADE, related_name="lines")
  216. material = models.ForeignKey(Material, on_delete=models.PROTECT)
  217. quantity = models.DecimalField(max_digits=12, decimal_places=3)
  218. unit = models.CharField(max_length=8, choices=Material.UNIT_CHOICES, default=Material.UNIT_KG)
  219. # Billing models have moved to the `billing` app
  220. class Document(TimestampedModel):
  221. organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="documents")
  222. file = models.FileField(upload_to="documents/%Y/%m/%d/")
  223. kind = models.CharField(max_length=64, blank=True)
  224. # Generic attachment to any model instance
  225. content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
  226. object_id = models.PositiveIntegerField()
  227. content_object = GenericForeignKey("content_type", "object_id")
  228. uploaded_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True)
  229. class AuditLog(TimestampedModel):
  230. organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="audit_logs")
  231. user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True)
  232. action = models.CharField(max_length=64)
  233. content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
  234. object_id = models.PositiveIntegerField()
  235. content_object = GenericForeignKey("content_type", "object_id")
  236. metadata = models.JSONField(default=dict, blank=True)
  237. class Meta:
  238. indexes = [
  239. models.Index(fields=["organization", "created_at"]),
  240. models.Index(fields=["action", "created_at"]),
  241. ]
  242. class ScrapListing(TimestampedModel):
  243. TYPE_OPEN = "open"
  244. TYPE_SEALED = "sealed"
  245. AUCTION_CHOICES = ((TYPE_OPEN, "Open"), (TYPE_SEALED, "Sealed"))
  246. STATUS_DRAFT = "draft"
  247. STATUS_OPEN = "open"
  248. STATUS_CLOSED = "closed"
  249. STATUS_AWARDED = "awarded"
  250. STATUS_CANCELED = "canceled"
  251. STATUS_CHOICES = (
  252. (STATUS_DRAFT, "Draft"),
  253. (STATUS_OPEN, "Open"),
  254. (STATUS_CLOSED, "Closed"),
  255. (STATUS_AWARDED, "Awarded"),
  256. (STATUS_CANCELED, "Canceled"),
  257. )
  258. organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="scrap_listings")
  259. customer = models.ForeignKey(Customer, on_delete=models.PROTECT, related_name="scrap_listings")
  260. site = models.ForeignKey(CustomerSite, on_delete=models.PROTECT, related_name="scrap_listings")
  261. title = models.CharField(max_length=255)
  262. description = models.TextField(blank=True)
  263. auction_type = models.CharField(max_length=16, choices=AUCTION_CHOICES, default=TYPE_OPEN)
  264. currency_code = models.CharField(max_length=8, default="USD")
  265. reserve_price = models.DecimalField(max_digits=14, decimal_places=2, null=True, blank=True)
  266. min_increment = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True)
  267. starts_at = models.DateTimeField(null=True, blank=True)
  268. ends_at = models.DateTimeField(null=True, blank=True)
  269. status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_DRAFT, db_index=True)
  270. is_public = models.BooleanField(default=True)
  271. created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name="created_listings")
  272. def __str__(self):
  273. return f"Listing #{self.id} - {self.title}"
  274. class Meta:
  275. permissions = (
  276. ("open_listing", "Can open scrap listing"),
  277. ("close_listing", "Can close scrap listing"),
  278. ("award_listing", "Can award scrap listing"),
  279. )
  280. class ScrapListingItem(TimestampedModel):
  281. listing = models.ForeignKey(ScrapListing, on_delete=models.CASCADE, related_name="items")
  282. material = models.ForeignKey(Material, on_delete=models.PROTECT)
  283. quantity_estimate = models.DecimalField(max_digits=12, decimal_places=3)
  284. unit = models.CharField(max_length=8, choices=Material.UNIT_CHOICES, default=Material.UNIT_KG)
  285. class ScrapBid(TimestampedModel):
  286. STATUS_ACTIVE = "active"
  287. STATUS_RETRACTED = "retracted"
  288. STATUS_ACCEPTED = "accepted"
  289. STATUS_REJECTED = "rejected"
  290. STATUS_CHOICES = (
  291. (STATUS_ACTIVE, "Active"),
  292. (STATUS_RETRACTED, "Retracted"),
  293. (STATUS_ACCEPTED, "Accepted"),
  294. (STATUS_REJECTED, "Rejected"),
  295. )
  296. listing = models.ForeignKey(ScrapListing, on_delete=models.CASCADE, related_name="bids")
  297. bidder_org = models.ForeignKey(Organization, on_delete=models.PROTECT, related_name="bids")
  298. bidder_user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name="bids")
  299. price_total = models.DecimalField(max_digits=14, decimal_places=2)
  300. message = models.TextField(blank=True)
  301. status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_ACTIVE)
  302. class Meta:
  303. indexes = [models.Index(fields=["listing", "price_total"])]
  304. class ScrapAward(TimestampedModel):
  305. listing = models.OneToOneField(ScrapListing, on_delete=models.CASCADE, related_name="award")
  306. winning_bid = models.ForeignKey(ScrapBid, on_delete=models.PROTECT, related_name="awards")
  307. pickup = models.ForeignKey(PickupOrder, on_delete=models.SET_NULL, null=True, blank=True, related_name="awards")
  308. notes = models.TextField(blank=True)
  309. class ScrapListingInvite(TimestampedModel):
  310. """Invite a specific organization (and optionally a user) to bid on a listing.
  311. When a listing is not public, only invited orgs may place bids.
  312. """
  313. listing = models.ForeignKey(ScrapListing, on_delete=models.CASCADE, related_name="invites")
  314. invited_org = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="listing_invites")
  315. invited_user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name="listing_invites")
  316. class Meta:
  317. unique_together = ("listing", "invited_org")
  318. # Carbon accounting models ---------------------------------------------------
  319. class MaterialEmissionFactor(TimestampedModel):
  320. """Emission factor for a given material per unit.
  321. Values are in kgCO2e per given unit (default kg). Factors can be
  322. organization-specific and time-bounded via effective dates.
  323. """
  324. organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="material_factors")
  325. material = models.ForeignKey(Material, on_delete=models.CASCADE, related_name="emission_factors")
  326. unit = models.CharField(max_length=8, choices=Material.UNIT_CHOICES, default=Material.UNIT_KG)
  327. kgco2e_per_unit = models.DecimalField(max_digits=16, decimal_places=6)
  328. source = models.CharField(max_length=255, blank=True)
  329. effective_from = models.DateField(null=True, blank=True)
  330. effective_to = models.DateField(null=True, blank=True)
  331. class Meta:
  332. indexes = [
  333. models.Index(fields=["organization", "material", "unit"]),
  334. ]
  335. unique_together = ()
  336. def __str__(self) -> str: # pragma: no cover - simple display
  337. return f"{self.material.name} {self.kgco2e_per_unit} kgCO2e/{self.unit}"
  338. class CarbonEvent(TimestampedModel):
  339. """Calculated carbon impact event (e.g., from a weigh ticket line).
  340. Events flow through an approvals queue before contributing to monthly
  341. CarbonBalance aggregates.
  342. """
  343. STATUS_PENDING = "pending"
  344. STATUS_APPROVED = "approved"
  345. STATUS_REJECTED = "rejected"
  346. STATUS_CHOICES = (
  347. (STATUS_PENDING, "Pending"),
  348. (STATUS_APPROVED, "Approved"),
  349. (STATUS_REJECTED, "Rejected"),
  350. )
  351. organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="carbon_events")
  352. weigh_ticket = models.ForeignKey(WeighTicket, on_delete=models.SET_NULL, null=True, blank=True, related_name="carbon_events")
  353. # Optional linkage to a specific line for traceability
  354. weigh_line = models.ForeignKey(WeighLine, on_delete=models.SET_NULL, null=True, blank=True, related_name="carbon_events")
  355. material = models.ForeignKey(Material, on_delete=models.SET_NULL, null=True, blank=True)
  356. quantity = models.DecimalField(max_digits=14, decimal_places=3)
  357. unit = models.CharField(max_length=8, choices=Material.UNIT_CHOICES, default=Material.UNIT_KG)
  358. kgco2e = models.DecimalField(max_digits=18, decimal_places=6)
  359. event_date = models.DateTimeField(default=timezone.now, db_index=True)
  360. status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_PENDING, db_index=True)
  361. approved_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name="approved_carbon_events")
  362. approved_at = models.DateTimeField(null=True, blank=True)
  363. notes = models.TextField(blank=True)
  364. class Meta:
  365. indexes = [
  366. models.Index(fields=["organization", "status"]),
  367. models.Index(fields=["organization", "event_date"]),
  368. ]
  369. unique_together = ("weigh_line",)
  370. class CarbonBalance(TimestampedModel):
  371. """Monthly aggregation of approved CarbonEvents per organization."""
  372. organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="carbon_balances")
  373. year = models.PositiveSmallIntegerField()
  374. month = models.PositiveSmallIntegerField()
  375. events_count = models.PositiveIntegerField(default=0)
  376. approved_kgco2e = models.DecimalField(max_digits=18, decimal_places=6, default=0)
  377. class Meta:
  378. unique_together = ("organization", "year", "month")
  379. indexes = [models.Index(fields=["organization", "year", "month"])]
  380. def __str__(self) -> str: # pragma: no cover - simple display
  381. return f"{self.organization.code} {self.year}-{self.month:02d}"