aboutsummaryrefslogtreecommitdiffstats
path: root/servo/models/order.py
diff options
context:
space:
mode:
Diffstat (limited to 'servo/models/order.py')
-rw-r--r--servo/models/order.py1156
1 files changed, 1156 insertions, 0 deletions
diff --git a/servo/models/order.py b/servo/models/order.py
new file mode 100644
index 0000000..d4f1fe3
--- /dev/null
+++ b/servo/models/order.py
@@ -0,0 +1,1156 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2013, First Party Software
+# All rights reserved.
+
+# Redistribution and use in source and binary forms, with or without modification,
+# are permitted provided that the following conditions are met:
+
+# 1. Redistributions of source code must retain the above copyright notice,
+# this list of conditions and the following disclaimer.
+
+# 2. Redistributions in binary form must reproduce the above copyright notice,
+# this list of conditions and the following disclaimer in the documentation
+# and/or other materials provided with the distribution.
+
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT
+# OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
+# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
+# WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
+# SUCH DAMAGE.
+
+from datetime import timedelta
+from django.db import models, IntegrityError
+
+from django.conf import settings
+from django.utils import timezone
+from django.utils.translation import ugettext_lazy as _
+
+from django.contrib.sites.managers import CurrentSiteManager
+
+from django.contrib.contenttypes.fields import GenericRelation
+
+from django.dispatch import receiver
+from django.core.urlresolvers import reverse
+from django.db.models.signals import pre_save, post_save, post_delete
+
+from servo import defaults
+from servo.lib.shorturl import encode_url
+
+from servo.models.common import Tag, Location, Event, Configuration, GsxAccount
+from servo.models.product import *
+from servo.models.customer import Customer
+from servo.models.device import Device
+from servo.models.queue import Queue, Status, QueueStatus
+
+
+class Order(models.Model):
+ site = models.ForeignKey(
+ Site,
+ editable=False,
+ default=defaults.site_id
+ )
+ code = models.CharField(max_length=8, unique=True, null=True)
+ url_code = models.CharField(max_length=8, unique=True, null=True)
+ # Device description or something else
+ description = models.CharField(max_length=128, default="")
+ status_icon = models.CharField(max_length=16, default="undefined")
+
+ priority = models.IntegerField(
+ default=Queue.PRIO_NORMAL,
+ choices=Queue.PRIORITIES,
+ verbose_name=_("priority")
+ )
+
+ created_at = models.DateTimeField(auto_now_add=True)
+ created_by = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ null=True,
+ related_name="created_orders",
+ on_delete=models.SET_NULL
+ )
+
+ started_at = models.DateTimeField(null=True)
+ started_by = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ null=True,
+ related_name="started_orders",
+ on_delete=models.SET_NULL
+ )
+
+ closed_at = models.DateTimeField(null=True)
+ closed_by = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ null=True,
+ related_name='closed_orders',
+ on_delete=models.SET_NULL
+ )
+ followed_by = models.ManyToManyField(
+ settings.AUTH_USER_MODEL,
+ related_name="followed_orders"
+ )
+
+ tags = models.ManyToManyField(Tag, verbose_name="tags")
+ events = GenericRelation(Event)
+
+ user = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ null=True,
+ on_delete=models.PROTECT
+ )
+
+ checkin_location = models.ForeignKey(
+ Location,
+ null=True,
+ related_name='+',
+ on_delete=models.SET_NULL
+ )
+
+ location = models.ForeignKey(Location, on_delete=models.PROTECT)
+ checkout_location = models.ForeignKey(
+ Location,
+ null=True,
+ related_name='+',
+ on_delete=models.SET_NULL
+ )
+
+ place = models.CharField(default='', max_length=128)
+ customer = models.ForeignKey(
+ Customer,
+ null=True,
+ related_name='orders',
+ on_delete=models.SET_NULL
+ )
+ customer_name = models.CharField(max_length=128, default='')
+
+ devices = models.ManyToManyField(Device, through="OrderDevice")
+ products = models.ManyToManyField(Product, through="ServiceOrderItem")
+
+ queue = models.ForeignKey(
+ Queue,
+ null=True,
+ verbose_name=_("queue"),
+ on_delete=models.SET_NULL
+ )
+ status = models.ForeignKey(
+ QueueStatus,
+ null=True,
+ verbose_name=_("status"),
+ on_delete=models.SET_NULL
+ )
+
+ statuses = models.ManyToManyField(
+ Status,
+ through="OrderStatus",
+ related_name="orders"
+ )
+
+ STATE_QUEUED = 0 # order hasn't been started
+ STATE_OPEN = 1 # order is being worked on
+ STATE_CLOSED = 2 # order is closed
+
+ STATES = (
+ (STATE_QUEUED, _("Unassigned")),
+ (STATE_OPEN, _("Open")),
+ (STATE_CLOSED, _("Closed"))
+ )
+
+ state = models.IntegerField(default=0, choices=STATES)
+
+ status_name = models.CharField(max_length=128, default="")
+ status_started_at = models.DateTimeField(null=True)
+ status_limit_green = models.DateTimeField(null=True) # turn yellow after this
+ status_limit_yellow = models.DateTimeField(null=True) # turn red after this
+
+ objects = CurrentSiteManager()
+ api_fields = ('status_name', 'status_description',)
+
+ def apply_rules(self):
+ pass
+
+ def get_accessories(self):
+ return Accessory.objects.filter(order=self)
+
+ def set_accessories(self, accs, device):
+ for a in accs:
+ a = Accessory(name=a, order=self, device=device)
+ a.save()
+
+ def add_tag(self, tag, user):
+ from servo.tasks import apply_rules
+
+ if not isinstance(tag, Tag):
+ tag = Tag.objects.get(pk=tag)
+
+ self.tags.add(tag)
+
+ event = Event(content_object=self)
+ event.description = str(tag.pk)
+ event.action = "set_tag"
+ event.triggered_by = user
+
+ apply_rules(event)
+
+ def set_tags(self, tags, user):
+ return [self.add_tag(t, user) for t in tags]
+
+ def check_in(self, user):
+ """
+ Checks this Order in through the check-in process
+ """
+ queue_id = Configuration.conf('checkin_queue')
+ self.set_queue(queue_id, user)
+
+ def can_order_products(self):
+ return self.products.count() > 0 and self.is_editable
+
+ def duplicate(self, user):
+ new_order = Order(customer=self.customer, created_by=user)
+ new_order.save()
+ new_order.set_queue(self.queue_id, user)
+
+ for d in self.devices.all():
+ new_order.add_device(d, user)
+
+ return new_order
+
+ def get_print_template(self, kind="confirmation"):
+ template = "orders/print_%s.html" % kind
+
+ if self.queue:
+ queue = self.queue
+
+ if kind == "confirmation" and queue.order_template:
+ template = queue.order_template.name
+ if kind == "quote" and queue.quote_template:
+ template = queue.quote_template.name
+ if kind == "receipt" and queue.receipt_template:
+ template = queue.receipt_template.name
+ if kind == "dispatch" and queue.dispatch_template:
+ template = queue.dispatch_template.name
+
+ return template
+
+ def get_repairs(self):
+ # Returns the active GSX repairs for this SO
+ return self.repair_set.exclude(submitted_at=None)
+
+ def get_repair(self):
+ # Returns the latest GSX repair for this SO
+ try:
+ return self.get_repairs().latest()
+ except Exception:
+ return
+
+ def get_similar(self, status, state):
+ # Returns a queryset of "similar" cases
+ if self.user is None:
+ return Order.objects.filter(status=status)
+
+ return Order.objects.filter(user=self.user)
+
+ def get_footer(self):
+ footer = self.code
+ repair = self.get_repair()
+ if repair:
+ footer += ' (%s)' % repair.confirmation
+ return footer
+
+ def add_device(self, device, user):
+ try:
+ OrderDevice.objects.create(order=self, device=device)
+ event = _(u'%s added') % device.description
+ self.notify('device_added', event, user)
+ return event
+ except IntegrityError:
+ raise ValueError(_("This device has already been added to this order"))
+
+ def add_device_sn(self, sn, user):
+ """
+ Adds device to order using serial number
+ """
+ sn = sn.upper()
+ try:
+ device = Device.objects.get(sn=sn)
+ except Device.DoesNotExist:
+ device = Device.from_gsx(sn)
+ device.save()
+
+ self.add_device(device, user)
+ return device
+
+ def remove_device(self, device, user):
+ OrderDevice.objects.filter(order=self, device=device).delete()
+ msg = _(u'%s removed') % device.description
+ self.notify('device_removed', msg, user)
+ return msg
+
+ def get_available_users(self, user):
+ """
+ Returns a list of users available to work on this order
+ """
+ if self.queue:
+ return self.queue.user_set.filter(is_active=True)
+
+ return user.location.user_set.filter(is_active=True)
+
+ def get_title(self):
+ """
+ Returns a human-readable title for this order, based on various criteria
+ """
+ from django.utils.timesince import timesince
+ now = timezone.now()
+ moment_seconds = 120
+
+ if self.closed_at:
+ if (now - self.closed_at).seconds < moment_seconds:
+ return _("Closed a moment ago")
+ return _(u"Closed for %(time)s") % {'time': timesince(self.closed_at)}
+
+ if self.status and self.status_started_at is not None:
+ if (now - self.status_started_at).seconds < moment_seconds:
+ return _(u"%s a moment ago") % self.status.status.title
+ delta = timesince(self.status_started_at)
+ d = {'status': self.status.status.title, 'time': delta}
+ return _("%(status)s for %(time)s" % d)
+
+ if self.user is None:
+ if (now - self.created_at).seconds < moment_seconds:
+ return _("Created a moment ago")
+ return _("Unassigned for %(delta)s") % {'delta': timesince(self.created_at)}
+
+ if self.started_at and self.user is not None:
+ if (now - self.started_at).seconds < moment_seconds:
+ return _("Started a moment ago")
+ return _("Open for %(delta)s") % {'delta': timesince(self.started_at)}
+
+ def get_place(self):
+ return self.place or _("Select place")
+
+ def get_status(self):
+ return self.status or _("Select status")
+
+ def get_user_name(self):
+ if self.user is not None:
+ return self.user.get_full_name()
+
+ def get_user(self):
+ return self.user or _("Select user")
+
+ def get_queue(self):
+ return self.queue or _("Select queue")
+
+ def get_queue_url(self):
+ return reverse("orders-index")
+
+ def get_queue_title(self):
+ if self.queue:
+ return self.queue.title
+ else:
+ return _("Orders")
+
+ def is_item_complete(self, item):
+ try:
+ return self.checklistitemvalue_set.get(item=item)
+ except Exception:
+ return False
+
+ def close(self, user):
+ self.notify("close_order", _(u"Order %s closed") % self.code, user)
+ self.closed_by = user
+ self.closed_at = timezone.now()
+ self.state = self.STATE_CLOSED
+ self.save()
+
+ if Configuration.autocomplete_repairs():
+ for r in self.repair_set.filter(completed_at=None):
+ r.close(user)
+
+ if self.queue and self.queue.status_closed:
+ self.set_status(self.queue.status_closed, user)
+
+ def reopen(self, user):
+ self.state = Order.STATE_OPEN
+ self.closed_at = None
+ self.save()
+ msg = _("Order %s reopened") % self.code
+ self.notify("reopen", msg, user)
+ return msg
+
+ def notes(self):
+ return self.note_set.all()
+
+ def reported_notes(self):
+ return self.note_set.filter(is_reported=True)
+
+ @property
+ def can_create_carryin(self):
+ return self.customer and self.queue and self.is_editable
+
+ def get_status_name(self):
+ try:
+ return self.status.status.title
+ except Exception:
+ pass
+
+ def get_status_description(self):
+ try:
+ return self.status.status.description
+ except Exception:
+ pass
+
+ def get_status_id(self):
+ """
+ Returns "real" status ID of this order (regardless of queue)
+ """
+ if self.status:
+ return self.status.status.id
+
+ def get_next(self):
+ try:
+ result = Order.objects.filter(pk__gt=self.pk, queue=self.queue)
+ return result[0].pk
+ except Exception:
+ pass
+
+ def get_prev(self):
+ try:
+ result = Order.objects.filter(pk__lt=self.pk, queue=self.queue)
+ return result[0].pk
+ except Exception:
+ pass
+
+ def get_color(self):
+ color = "undefined"
+ if self.status:
+ now = timezone.now()
+ if now > self.status_limit_yellow:
+ color = "danger"
+ if now < self.status_limit_yellow:
+ color = "warning"
+ if now < self.status_limit_green:
+ color = "success"
+
+ return color
+
+ def get_status_img(self):
+ color = self.get_color()
+ return "images/status_%s_16.png" % color
+
+ def set_property(self, key, value):
+ pass
+
+ def set_location(self, new_location, user):
+ self.location = new_location
+ msg = _(u"Order %s moved to %s") % (self.code, new_location.title)
+ self.notify("set_location", msg, user)
+ self.save()
+ return msg
+
+ def set_checkin_location(self, new_location, user):
+ pass
+
+ def notify(self, action, message, user):
+ """
+ Notifies this order of an event
+ This is also the hub for automation handling
+ """
+ if self.is_closed:
+ return
+
+ e = Event(content_object=self, action=action)
+ e.description = message
+ e.triggered_by = user
+ e.save()
+
+ for f in self.followed_by.exclude(pk=user.pk).exclude(should_notify=False):
+ e.notify_users.add(f)
+
+ if action == "product_arrived":
+ if self.queue and self.queue.status_products_received:
+ new_status = self.queue.status_products_received
+ self.set_status(new_status, user)
+
+ def set_status(self, new_status, user):
+ """
+ Sets status of this order to new_status
+ Status can only be set if order belongs to a queue!
+ """
+ if self.is_closed:
+ return # fail silently
+
+ if self.queue is None:
+ raise ValueError(_('Order must belong to a queue to set status'))
+
+ if isinstance(new_status, QueueStatus):
+ status = new_status
+ else:
+ if int(new_status) == 0:
+ return self.unset_status(user)
+
+ status = QueueStatus.objects.get(pk=new_status)
+
+ self.status = status
+ self.status_name = status.status.title
+ self.status_started_at = timezone.now()
+
+ self.status_limit_green = status.get_green_limit()
+ self.status_limit_yellow = status.get_yellow_limit()
+ self.save()
+
+ # Set up the OrderStatus
+ OrderStatus.create(self, status, user)
+
+ # trigger the notification
+ self.notify("set_status", self.status_name, user)
+
+ def unset_status(self, user):
+ if self.is_closed:
+ return # fail silently
+
+ self.status = None
+ self.status_started_at = None
+ self.status_limit_green = None
+ self.status_limit_yellow = None
+ self.save()
+
+ self.notify("set_status", _('Status unassigned'), user)
+
+ def set_queue(self, queue_id, user):
+ if self.is_closed:
+ return
+
+ if queue_id in (None, ''):
+ queue_id = 0
+
+ if isinstance(queue_id, Queue):
+ queue = queue_id
+ else:
+ if int(queue_id) == 0:
+ return self.unset_queue(user)
+
+ queue = Queue.objects.get(pk=queue_id)
+
+ self.queue = queue
+ self.priority = queue.priority
+ self.notify('set_queue', queue.title, user)
+
+ if queue.gsx_soldto:
+ gsx_account = GsxAccount.get_account(self.location, queue)
+ for i in self.repair_set.filter(completed_at=None):
+ i.gsx_account = gsx_account
+ i.save()
+
+ if queue.status_created:
+ self.set_status(queue.status_created, user)
+ else:
+ self.save()
+
+ def unset_queue(self, user):
+ self.queue = None
+ self.notify('set_queue', _('Removed from queue'), user)
+ self.save()
+
+ def add_follower(self, follower):
+ if follower in self.followed_by.all():
+ return
+
+ self.followed_by.add(follower)
+
+ def remove_follower(self, follower):
+ if self.state == self.STATE_CLOSED:
+ raise ValueError(_('Closed orders cannot be modified'))
+
+ self.followed_by.remove(follower)
+
+ def toggle_follower(self, follower):
+ if follower in order.followed_by.all():
+ self.remove_follower(user)
+ else:
+ self.add_follower(follower)
+
+ def set_user(self, new_user, current_user):
+ """
+ Sets the assignee of this order to new_user
+ """
+ if self.state == self.STATE_CLOSED:
+ raise ValueError(_('Closed orders cannot be modified'))
+
+ state = self.STATE_OPEN
+
+ if new_user is None:
+ state = self.STATE_QUEUED
+ event = _("Order unassigned")
+ self.remove_follower(self.user)
+ else:
+ data = {'order': self.code, 'user': new_user.get_full_name()}
+ event = _(u"Order %(order)s assigned to %(user)s") % data
+ # The assignee should also be a follower
+ self.add_follower(new_user)
+
+ self.user = new_user
+ self.state = state
+
+ self.notify("set_user", event, current_user)
+
+ if self.user is not None:
+ self.location = new_user.location
+ if self.started_by is None:
+ self.started_by = new_user
+ self.started_at = timezone.now()
+ queue = self.queue
+ if queue and queue.status_assigned:
+ self.set_status(queue.status_assigned, current_user)
+
+ self.save()
+
+ def customer_id(self):
+ return self.customer.id
+
+ def customer_list(self):
+ """
+ Returns this order's customer wrapped in a list for easier
+ tree recursion
+ """
+ return [self.customer]
+
+ def customer_tree(self):
+ if self.customer is None:
+ return '0'
+ else:
+ return self.customer.tree_id
+
+ def has_devices(self):
+ return self.devices.all().count() > 0
+
+ def device_name(self):
+ if self.devices.count():
+ return self.devices.all()[0].description
+
+ def set_customer(self, new_customer):
+ self.customer = new_customer
+ self.save()
+
+ def get_customer_name(self):
+ try:
+ return self.customer.fullname
+ except AttributeError:
+ pass
+
+ def device_slug(self):
+ try:
+ return self.devices.all()[0].slug
+ except Exception:
+ return None
+
+ def net_total(self):
+ total = 0
+
+ for p in self.serviceorderitem_set.filter(should_report=True):
+ total += p.price_notax() * p.amount
+
+ return total
+
+ def gross_total(self):
+ total = 0
+
+ for p in self.serviceorderitem_set.filter(should_report=True):
+ total += p.price * p.amount
+
+ return total
+
+ def total_tax(self):
+ return self.gross_total() - self.net_total()
+
+ def add_product(self, product, amount, user):
+ """
+ Adds this product to the Service Order with stock price
+ """
+ oi = ServiceOrderItem(order=self, created_by=user)
+ oi.product = product
+ oi.code = product.code
+ oi.title = product.title
+ oi.description = product.description
+ oi.amount = amount
+
+ oi.price_category = 'stock'
+ oi.price = product.price_sales_stock
+
+ oi.save()
+
+ self.notify("product_added", _('Product %s added') % oi.title, user)
+
+ return oi
+
+ def remove_product(self, oi, user):
+ oi.delete()
+ msg = _('Product %s removed from order') % oi.title
+ self.notify("product_removed", msg, user)
+ return msg
+
+ @property
+ def can_dispatch(self):
+ undispatched = self.products.filter(dispatched=False)
+ return undispatched.count() > 0 and self.is_editable
+
+ @property
+ def can_close(self):
+ return self.is_editable and not self.can_dispatch
+
+ def dispatch(self, invoice, products):
+ """
+ Dispatch these products from the inventory with this invoice
+ """
+ invoice.dispatch(products)
+ if self.queue and self.queue.status_dispatched:
+ self.set_status(self.queue.status_dispatched, invoice.created_by)
+
+ def total_margin(self):
+ """
+ Calculates the total margin for this Service Order
+ """
+ total_purchase_price = 0
+ for p in self.serviceorderitem_set.filter(should_report=True):
+ total_purchase_price += p.get_product_price('purchase') * p.amount
+
+ return (self.net_total() - Decimal(total_purchase_price))
+
+ @property
+ def products(self):
+ return self.serviceorderitem_set.filter(should_report=True)
+
+ @property
+ def serialized_products(self):
+ return self.products.filter(product__is_serialized=True)
+
+ def get_parts(self):
+ """
+ Returns the GSX parts that can be ordered for this SRO
+ """
+ return [x for x in self.products.all() if x.product.is_apple_part]
+
+ @property
+ def has_parts(self):
+ return len(self.get_parts()) > 0
+
+ def get_devices(self):
+ return self.devices.filter(orderdevice__should_report=True)
+
+ def get_first_device(self):
+ try:
+ return self.get_devices().first()
+ except Exception:
+ pass
+
+ @property
+ def is_editable(self):
+ return self.closed_at is None
+
+ @property
+ def is_closed(self):
+ return self.closed_at is not None
+
+ @property
+ def has_products(self):
+ return self.products.count() > 0
+
+ def has_accessories(self):
+ return self.accessory_set.all().count() > 0
+
+ def get_accessories(self):
+ return self.accessory_set.values_list('name', flat=True)
+
+ class Meta:
+ app_label = 'servo'
+ ordering = ('-priority', 'id',)
+
+ permissions = (
+ ("change_user", _("Can set assignee")),
+ ("change_status", _("Can change status")),
+ ("follow_order", _("Can follow order")),
+ ("copy_order", _("Can copy order")),
+ ("batch_process", _("Can batch process")),
+ )
+
+ def get_absolute_url(self):
+ return reverse("orders-edit", args=[self.pk])
+
+ def __unicode__(self):
+ return self.code
+
+
+class AbstractOrderItem(models.Model):
+ """
+ The base class for order lines (purchase, sales)
+ """
+ product = models.ForeignKey(Product, on_delete=models.PROTECT)
+ code = models.CharField(blank=True, default='', max_length=128)
+ title = models.CharField(max_length=128, verbose_name=_("title"))
+ description = models.TextField(
+ blank=True,
+ default='',
+ verbose_name=_("description")
+ )
+
+ created_at = models.DateTimeField(auto_now_add=True)
+ created_by = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ null=True,
+ editable=False,
+ on_delete=models.SET_NULL
+ )
+
+ amount = models.IntegerField(default=1, verbose_name=_("amount"))
+ sn = models.CharField(
+ blank=True,
+ default="",
+ max_length=32,
+ verbose_name=_("KGB Serial Number")
+ )
+
+ def price_notax(self):
+ """
+ Returns the price of this OI w/o VAT
+ """
+ from decimal import ROUND_05UP
+ vat_pct = self.product.pct_vat
+ return (self.price/Decimal((100+vat_pct)/100)).quantize(Decimal('1.00'))
+
+ def total_gross(self):
+ return self.price * self.amount
+
+ def total_net(self):
+ return self.price_notax() * self.amount
+
+ def total_tax(self):
+ """
+ Returns the amount of VAT paid for this POI
+ """
+ return (self.price - self.price_notax()) * self.amount
+
+ class Meta:
+ abstract = True
+
+
+class ServiceOrderItem(AbstractOrderItem):
+ """
+ A product that has been added to a Service Order
+ """
+ order = models.ForeignKey(Order)
+
+ dispatched = models.BooleanField(
+ default=False,
+ verbose_name=_("dispatched")
+ )
+ should_report = models.BooleanField(
+ default=True,
+ verbose_name=_("report")
+ )
+ price = models.DecimalField(
+ max_digits=8,
+ decimal_places=2,
+ verbose_name=_('sales price')
+ )
+
+ replaced_at = models.DateTimeField(null=True)
+ replaced_by = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ null=True,
+ editable=False,
+ related_name='replaced_parts'
+ )
+
+ kbb_sn = models.CharField(
+ blank=True,
+ default="",
+ max_length=32,
+ verbose_name=_("KBB Serial Number")
+ )
+
+ imei = models.CharField(
+ blank=True,
+ default="",
+ max_length=35,
+ verbose_name=_("IMEI")
+ )
+
+ PRICE_CATEGORIES = (
+ ('warranty', _("Warranty")),
+ ('exchange', _("Exchange Price")),
+ ('stock', _("Stock Price")),
+ )
+
+ price_category = models.CharField(
+ max_length=32,
+ choices=PRICE_CATEGORIES,
+ default=PRICE_CATEGORIES[0],
+ verbose_name=_("Price category")
+ )
+
+ comptia_code = models.CharField(
+ blank=True,
+ default="",
+ max_length=4,
+ verbose_name=_("symptom code")
+ )
+ comptia_modifier = models.CharField(
+ blank=True,
+ default="",
+ max_length=1,
+ verbose_name=_("symptom modifier")
+ )
+
+ def can_create_device(self):
+ pt = self.product.part_type
+ return pt == 'REPLACEMENT' and self.sn
+
+ def comptia_choices(self):
+ if self.product is not None:
+ from servo.models.parts import symptom_codes
+ return symptom_codes(self.product.component_code)
+
+ def get_comptia_code_display(self):
+ for i in self.comptia_choices():
+ if i[0] == self.comptia_code:
+ return i[1]
+
+ def get_comptia_modifier_display(self):
+ if self.comptia_modifier:
+ from servo.models.parts import symptom_modifiers
+ for m in symptom_modifiers():
+ if m[0] == self.comptia_modifier:
+ return m[1]
+
+ def get_part(self):
+ return self.servicepart_set.latest()
+
+ def get_poitem(self):
+ return self.purchaseorderitem_set.latest()
+
+ def get_repair(self):
+ return self.order.repair_set.latest()
+
+ def reserve_product(self):
+ location = self.order.location
+ inventory = Inventory.objects.get(location=location, product=self.product)
+ inventory.amount_reserved += self.amount
+ inventory.save()
+
+ def get_purchase_price(self):
+ """
+ Returns the purchase price of this SOIs Product
+ """
+ return self.product.get_price(self.price_category, "purchase")
+
+ def get_product_price(self, kind):
+ return self.product.get_price(category=self.price_category, kind=kind)
+
+ def save(self, *args, **kwargs):
+ self.sn = self.sn.upper()
+ self.kbb_sn = self.kbb_sn.upper()
+ return super(ServiceOrderItem, self).save(*args, **kwargs)
+
+ @property
+ def is_abused(self):
+ return self.price_category == 'stock'
+
+ @property
+ def is_warranty(self):
+ return self.price_category == 'warranty'
+
+ def __unicode__(self):
+ return self.code
+
+ class Meta:
+ app_label = "servo"
+ ordering = ('id',)
+ get_latest_by = "id"
+
+
+class OrderStatus(models.Model):
+ """
+ The M/M statuses of an order
+ """
+ order = models.ForeignKey(Order)
+ status = models.ForeignKey(Status)
+
+ started_at = models.DateTimeField(auto_now_add=True)
+ finished_at = models.DateTimeField(null=True)
+
+ started_by = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ related_name='+'
+ )
+ finished_by = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ null=True,
+ related_name='+'
+ )
+
+ green_limit = models.DateTimeField(null=True)
+ yellow_limit = models.DateTimeField(null=True)
+
+ BADGES = (
+ ('undefined', 'undefined'),
+ ('success', 'success'),
+ ('warning', 'warning'),
+ ('danger', 'danger'),
+ )
+
+ badge = models.CharField(choices=BADGES, default=BADGES[0][0], max_length=16)
+ duration = models.IntegerField(default=0)
+
+ @classmethod
+ def create(cls, order, queue_status, user):
+ """
+ Set status or order to queue_status.status
+ """
+ new_status = queue_status.status
+ os = cls(order=order, status=new_status)
+ os.started_by = user
+ #os.started_at = timezone.now()
+
+ os.green_limit = queue_status.get_green_limit()
+ os.yellow_limit = queue_status.get_yellow_limit()
+
+ os.save()
+
+ prev = os.get_previous()
+
+ if prev is None:
+ return
+
+ # set color of previous OS
+ if prev.finished_by is None:
+ prev.finished_by = user
+ prev.finished_at = timezone.now()
+ prev.duration = (prev.finished_at - prev.started_at).total_seconds()
+
+ prev.badge = prev.get_badge()
+ prev.save()
+
+ def get_badge(self):
+ now = timezone.now()
+ badge = "undefined"
+ if self.yellow_limit and now > self.yellow_limit:
+ badge = "danger"
+ if self.yellow_limit and now < self.yellow_limit:
+ badge = "warning"
+ if self.green_limit and now < self.green_limit:
+ badge = "success"
+
+ return badge
+
+ def get_next(self):
+ statuses = self.order.orderstatus_set
+ return statuses.filter(started_at__gt=self.started_at).order_by('id').first()
+
+ def get_previous(self):
+ statuses = self.order.orderstatus_set
+ return statuses.filter(started_at__lt=self.started_at).order_by('id').last()
+
+ def __unicode__(self):
+ return self.status.title
+
+ class Meta:
+ app_label = "servo"
+ ordering = ('-started_at',)
+ get_latest_by = "started_at"
+
+
+class OrderDevice(models.Model):
+ """
+ A device attached to a service order
+ """
+ order = models.ForeignKey(Order)
+ device = models.ForeignKey(Device)
+ should_report = models.BooleanField(default=True)
+
+ def is_repeat_service(self):
+ from django.utils import timezone
+ created_at = self.order.created_at
+ tlimit = timezone.now() - timedelta(days=30)
+ orders = Order.objects.filter(orderdevice__device=self.device,
+ created_at__lt=created_at,
+ created_at__gte=tlimit)
+
+ return orders.exclude(pk=self.order.pk).count() > 0
+
+ class Meta:
+ app_label = "servo"
+ unique_together = ('order', 'device', ) # Can't add the same device more than once
+
+
+class Accessory(models.Model):
+ """
+ An accessory that came with the device in this Service Order
+ """
+ name = models.TextField()
+ qty = models.IntegerField(default=1)
+ device = models.ForeignKey(Device)
+ order = models.ForeignKey(Order)
+
+ def __unicode__(self):
+ return self.name
+
+ class Meta:
+ app_label = "servo"
+
+
+@receiver(pre_save, sender=Order)
+def trigger_order_presave(sender, instance, **kwargs):
+ instance.customer_name = ''
+ if instance.customer is not None:
+ instance.customer_name = instance.customer.fullname
+
+ location = instance.created_by.location
+
+ if instance.checkin_location is None:
+ instance.checkin_location = location
+
+ if instance.location_id is None:
+ instance.location = location
+
+ if instance.checkout_location is None:
+ instance.checkout_location = location
+
+@receiver(post_save, sender=Order)
+def trigger_order_created(sender, instance, created, **kwargs):
+ if created:
+ instance.url_code = encode_url(instance.id).upper()
+ instance.code = settings.INSTALL_ID + str(instance.id).rjust(6, '0')
+ description = _('Order %s created') % instance.code
+ instance.notify('created', description, instance.created_by)
+ instance.save()
+
+
+@receiver(post_save, sender=OrderDevice)
+def trigger_orderdevice_saved(sender, instance, created, **kwargs):
+ order = instance.order
+ device = instance.device
+ order.description = device.description
+
+ if order.queue is None:
+ pass # @TODO try to autoasign case to right queue...
+
+ order.save()
+
+
+@receiver(post_delete, sender=OrderDevice)
+def trigger_device_removed(sender, instance, **kwargs):
+ try:
+ order = instance.order
+ except Order.DoesNotExist:
+ return # Means the whole order was deleted, not just the device
+ devices = order.devices.all()
+ if devices.count() > 0:
+ order.description = devices[0].description
+ else:
+ order.description = ''
+
+ order.save()