From 63b0fc6269b38edf7234b9f151b80d81f614c0a3 Mon Sep 17 00:00:00 2001 From: Filipp Lepalaan Date: Tue, 4 Aug 2015 10:11:24 +0300 Subject: Initial commit First public commit --- servo/models/product.py | 654 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 654 insertions(+) create mode 100644 servo/models/product.py (limited to 'servo/models/product.py') diff --git a/servo/models/product.py b/servo/models/product.py new file mode 100644 index 0000000..b3bd787 --- /dev/null +++ b/servo/models/product.py @@ -0,0 +1,654 @@ +# -*- 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. + +import re +from os.path import basename + +from django.db import models +from django.db import connection +from django.conf import settings +from django.core.files import File +from django.core.cache import cache +from decimal import Decimal, ROUND_CEILING +from django.core.urlresolvers import reverse + +from django.contrib.contenttypes.fields import GenericRelation + +from django.contrib.sites.managers import CurrentSiteManager + +from django.contrib.sites.models import Site +from django.utils.translation import ugettext_lazy as _ + +from mptt.models import MPTTModel, TreeForeignKey +from mptt.managers import TreeManager +from gsxws import comptia, parts, validate + +from servo import defaults +from servo.lib.shorturl import from_time +from servo.models import Configuration, Location, TaggedItem + + +def get_margin(price=0.0): + """ + Returns the proper margin % for this price + """ + price = Decimal(price) + margin = defaults.margin() + + try: + return Decimal(margin) + except Exception: + ranges = margin.split(';') + for r in ranges: + m = re.search(r'(\d+)\-(\d+)=(\d+)', r) + p_min, p_max, margin = m.groups() + if Decimal(p_min) <= price <= Decimal(p_max): + return Decimal(margin) + + return Decimal(margin) + + +def default_vat(): + conf = Configuration.conf() + return conf.get("pct_vat", 0.0) + + +def inventory_totals(): + """ + Returns the total purchase and sales value + of our inventory + """ + cursor = connection.cursor() + sql = """SELECT SUM(price_purchase_stock*total_amount) AS a, + SUM(price_sales_stock*total_amount) AS b + FROM servo_product + WHERE part_type != 'SERVICE'""" + cursor.execute(sql) + + for k, v in cursor.fetchall(): + return (k, v) + + +class DeviceGroup(models.Model): + """ + This links products with devices. + The title should match a device's description field + """ + title = models.CharField(max_length=128, unique=True) + + class Meta: + app_label = "servo" + + +class AbstractBaseProduct(models.Model): + code = models.CharField( + unique=True, + max_length=32, + default=from_time, + verbose_name=_("code") + ) + + subst_code = models.CharField( + default='', + max_length=32, + editable=False, + verbose_name=_("Substituted (new) code of this part") + ) + + title = models.CharField( + max_length=255, + default=_("New Product"), + verbose_name=_("Title") + ) + description = models.TextField( + default='', + blank=True, + verbose_name=_("Description") + ) + pct_vat = models.DecimalField( + max_digits=4, + decimal_places=2, + default=default_vat, + verbose_name=_("VAT %") + ) + + fixed_price = models.BooleanField( + default=False, + help_text=_("Don't update price when recalculating prices or importing parts") + ) + + price_purchase_exchange = models.DecimalField( + default=0, + max_digits=8, + decimal_places=2, + verbose_name=_("Purchase price") + ) + + pct_margin_exchange = models.DecimalField( + max_digits=4, + decimal_places=2, + default=get_margin, + verbose_name=_("Margin %") + ) + price_notax_exchange = models.DecimalField( + default=0, + max_digits=8, + decimal_places=2, + verbose_name=_("Net price"), + help_text=_("Purchase price + margin %") + ) + price_sales_exchange = models.DecimalField( + default=0, + max_digits=8, + decimal_places=2, + verbose_name=_("Sales price"), + help_text=_("Purchase price + margin % + shipping + VAT %") + ) + + price_purchase_stock = models.DecimalField( + default=0, + max_digits=8, + decimal_places=2, + verbose_name=_("Purchase price") + ) + pct_margin_stock = models.DecimalField( + max_digits=4, + decimal_places=2, + default=get_margin, + verbose_name=_("Margin %") + ) + price_notax_stock = models.DecimalField( + default=0, + max_digits=8, + decimal_places=2, + verbose_name=_("Net price"), + help_text=_("Purchase price + margin %") + ) + price_sales_stock = models.DecimalField( + default=0, + max_digits=8, + decimal_places=2, + verbose_name=_("Sales price"), + help_text=_("Purchase price + margin % + shipping + VAT %") + ) + + is_serialized = models.BooleanField( + default=False, + verbose_name=_('is serialized'), + help_text=_("Product has a serial number") + ) + + class Meta: + app_label = 'servo' + abstract = True + + +class Product(AbstractBaseProduct): + + site = models.ForeignKey( + Site, + editable=False, + default=defaults.site_id + ) + warranty_period = models.PositiveIntegerField( + default=0, + verbose_name=_("Warranty (months)") + ) + shelf = models.CharField( + default='', + blank=True, + max_length=8, + verbose_name=_("Shelf code") + ) + brand = models.CharField( + default='', + blank=True, + max_length=32, + verbose_name=_("Brand") + ) + categories = models.ManyToManyField( + "ProductCategory", + blank=True, + verbose_name=_("Categories") + ) + device_models = models.ManyToManyField( + "DeviceGroup", + blank=True, + verbose_name=_("device models") + ) + tags = GenericRelation(TaggedItem) + photo = models.ImageField( + null=True, + blank=True, + upload_to="products", + verbose_name=_("photo") + ) + + shipping = models.FloatField(default=0, verbose_name=_('shipping')) + + # component code is used to identify Apple parts + component_code = models.CharField( + blank=True, + default='', + max_length=1, + choices=comptia.GROUPS, + verbose_name=_("component group") + ) + + labour_tier = models.CharField(max_length=15, blank=True, default='') + + # We need this to call the correct GSX SN Update API + PART_TYPES = ( + ('ADJUSTMENT', _("Adjustment")), + ('MODULE', _("Module")), + ('REPLACEMENT', _("Replacement")), + ('SERVICE', _("Service")), + ('SERVICE CONTRACT', _("Service Contract")), + ('OTHER', _("Other")), + ) + + part_type = models.CharField( + max_length=18, + default='OTHER', + choices=PART_TYPES, + verbose_name=_("part type") + ) + + eee_code = models.CharField( + blank=True, + default='', + max_length=256, + verbose_name=_("EEE code") + ) + + total_amount = models.IntegerField(editable=False, default=0) + + def get_pick_url(self, order, device=None): + pk = self.pk or self.code + return '/orders/%d/devices/%d/' + + def is_service(self): + return self.part_type == 'SERVICE' + + def get_warranty_display(self): + if self.warranty_period: + return _("%d months") % self.warranty_period + + def can_order_from_gsx(self): + return self.component_code and self.part_type in ("MODULE", "REPLACEMENT", "OTHER",) + + def can_update_price(self): + return self.can_order_from_gsx() and not self.fixed_price + + def update_price(self, new_product=None): + """ + Updates part's price info from GSX or to match new_product + """ + if new_product is None: + part = parts.Part(partNumber=self.code).lookup() + new_product = Product.from_gsx(part) + + self.price_purchase_exchange = new_product.price_purchase_exchange + self.price_purchase_stock = new_product.price_purchase_stock + + self.title = new_product.title + + self.component_code = new_product.component_code + + self.price_notax_stock = new_product.price_notax_stock + self.price_notax_exchange = new_product.price_notax_exchange + + self.pct_margin_stock = new_product.pct_margin_stock + self.pct_margin_exchange = new_product.pct_margin_exchange + + self.price_sales_stock = new_product.price_sales_stock + self.price_sales_exchange = new_product.price_sales_exchange + + self.save() + + def calculate_price(self, price, shipping=0.0): + """ + Calculates price and returns it w/ and w/o tax + """ + conf = Configuration.conf() + shipping = shipping or 0.0 + + if not isinstance(shipping, Decimal): + shipping = Decimal(shipping) + + margin = get_margin(price) + vat = Decimal(conf.get("pct_vat", 0.0)) + + # TWOPLACES = Decimal(10) ** -2 # same as Decimal('0.01') + # @TODO: make rounding configurable! + wo_tax = ((price*100)/(100-margin)+shipping).to_integral_exact(rounding=ROUND_CEILING) + with_tax = (wo_tax*(vat+100)/100).to_integral_exact(rounding=ROUND_CEILING) + + return wo_tax, with_tax + + def set_stock_sales_price(self): + if not self.price_purchase_stock or self.fixed_price: + return + + purchase_sp = self.price_purchase_stock + sp, vat_sp = self.calculate_price(purchase_sp, self.shipping) + self.price_notax_stock = sp + self.price_sales_stock = vat_sp + + def set_exchange_sales_price(self): + if not self.price_purchase_exchange or self.fixed_price: + return + + purchase_ep = self.price_purchase_exchange + ep, vat_ep = self.calculate_price(purchase_ep, self.shipping) + self.price_notax_exhcange = ep + self.price_sales_exchange = vat_ep + + @property + def is_apple_part(self): + return validate(self.code, 'partNumber') + + @classmethod + def from_gsx(cls, part): + """ + Creates a Servo Product from GSX partDetail. + We don't do GSX lookups here since we can't + determine the correct GSX Account at this point. + """ + conf = Configuration.conf() + + try: + shipping = Decimal(conf.get("shipping_cost")) + except TypeError: + shipping = Decimal(0.0) + + part_number = part.originalPartNumber or part.partNumber + product = Product(code=part_number) + product.title = part.partDescription + + if part.originalPartNumber: + product.subst_code = part.partNumber + + if part.stockPrice and not product.fixed_price: + # calculate stock price + purchase_sp = part.stockPrice or 0.0 + purchase_sp = Decimal(purchase_sp) + sp, vat_sp = product.calculate_price(purchase_sp, shipping) + product.pct_margin_stock = get_margin(purchase_sp) + product.price_notax_stock = sp + product.price_sales_stock = vat_sp + # @TODO: make rounding configurable + product.price_purchase_stock = purchase_sp.to_integral_exact( + rounding=ROUND_CEILING + ) + + try: + # calculate exchange price + purchase_ep = part.exchangePrice or 0.0 + purchase_ep = Decimal(purchase_ep) + + if purchase_ep > 0 and not product.fixed_price: + ep, vat_ep = product.calculate_price(purchase_ep, shipping) + product.price_notax_exchange = ep + product.price_sales_exchange = vat_ep + product.pct_margin_exchange = Configuration.get_margin(purchase_ep) + # @TODO: make rounding configurable + product.price_purchase_exchange = purchase_ep.to_integral_exact( + rounding=ROUND_CEILING + ) + except AttributeError: + pass # Not all parts have exchange prices + + product.brand = "Apple" + product.shipping = shipping + product.warranty_period = 3 + + product.labour_tier = part.laborTier + product.part_type = part.partType.upper() + + # eee and componentCode are sometimes missing + if part.eeeCode: + product.eee_code = str(part.eeeCode).strip() + if part.componentCode: + product.component_code = str(part.componentCode).strip() + + product.is_serialized = part.isSerialized + return product + + @classmethod + def from_cache(cls, code): + data = cache.get(code) + return cls.from_gsx(data) + + def get_photo(self): + try: + return self.photo.url + except ValueError: + from django.conf import settings + return "%simages/na.gif" % settings.STATIC_URL + + def tax(self): + return self.price_sales - self.price_notax + + def latest_date_sold(self): + return '-' + + def latest_date_ordered(self): + return '-' + + def latest_date_arrived(self): + return '-' + + def sell(self, amount, location): + """ + Deduct product from inventory with specified location + """ + track_inventory = Configuration.track_inventory() + + if self.part_type == "SERVICE" or not track_inventory: + return + + try: + inventory = Inventory.objects.get(product=self, location=location) + inventory.amount_stocked = inventory.amount_stocked - amount + inventory.save() + except Inventory.DoesNotExist: + raise ValueError(_(u"Product %s not found in inventory.") % self.code) + + def get_relative_url(self): + if self.pk is None: + return "code/%s/" % self.code + + return self.pk + + def get_absolute_url(self): + if self.pk is None: + return reverse("products-view_product", kwargs={'code': self.code}) + return reverse("products-view_product", kwargs={'pk': self.pk}) + + def get_amount_stocked(self, user): + """ + Returns the amount of this product in the same location as the user. + Caches the result for faster access later. + """ + amount = 0 + track_inventory = Configuration.track_inventory() + + if not track_inventory: + return 0 + + if self.part_type == "SERVICE" or not self.pk: + return 0 + + cache_key = "product_%d_amount_stocked" % self.pk + + if cache.get(cache_key): + return cache.get(cache_key) + + location = user.get_location() + + try: + inventory = Inventory.objects.get(product=self, location=location) + amount = inventory.amount_stocked + except Inventory.DoesNotExist: + pass + + cache.set(cache_key, amount) + return amount + + def get_price(self, category=None, kind="sales"): + """ + Returns price of product in specific price category + of the specified kind (sales or purchase) + """ + prices = dict( + warranty=0.0, + exchange=float(getattr(self, "price_%s_exchange" % kind)), + stock=float(getattr(self, "price_%s_stock" % kind)) + ) + + return prices.get(category) if category else prices + + def get_pk(self): + return self.pk or Product.objects.get(code=self.code).pk + + def update_photo(self): + if self.component_code and not self.photo: + try: + part = parts.Part(partNumber=self.code) + result = part.fetch_image() + filename = basename(result) + self.photo.save(filename, File(open(result))) + except Exception, e: + print e + + def __unicode__(self): + return u'%s %s' % (self.code, self.title) + + class Meta: + ordering = ('-id',) + app_label = 'servo' + permissions = ( + ("change_amount", _("Can change product amount")), + ) + + +class ProductCategory(MPTTModel): + + site = models.ForeignKey( + Site, + editable=False, + default=defaults.site_id + ) + + title = models.CharField(max_length=255, default=_("New Category")) + slug = models.SlugField(null=True, editable=False) + parent = TreeForeignKey( + 'self', + null=True, + blank=True, + related_name='children' + ) + + objects = TreeManager() + on_site = CurrentSiteManager() + + def get_products(self): + return Product.objects.filter( + categories__lft__gte=self.lft, + categories__rght__lte=self.rght, + categories__tree_id=self.tree_id + ) + + def get_product_count(self): + count = self.product_set.count() + return count if count > 0 else "" + + def get_absolute_url(self): + return "/sales/products/%s/" % self.slug + + def save(self, *args, **kwargs): + from django.utils.text import slugify + self.slug = slugify(self.title[:50]) + return super(ProductCategory, self).save(*args, **kwargs) + + def __unicode__(self): + return self.title + + class Meta: + app_label = "servo" + get_latest_by = "id" + ordering = ("-title",) + unique_together = ("title", "site", ) + + +class Inventory(models.Model): + """ + Inventory tracks how much of Product X is in Location Y + """ + product = models.ForeignKey(Product) + location = models.ForeignKey(Location) + + amount_minimum = models.PositiveIntegerField( + default=0, + verbose_name=_("minimum amount") + ) + amount_reserved = models.PositiveIntegerField( + default=0, + verbose_name=_("reserved amount") + ) + amount_stocked = models.IntegerField( + default=0, + verbose_name=_("stocked amount") + ) + amount_ordered = models.PositiveIntegerField( + default=0, + verbose_name=_("ordered amount") + ) + + def save(self, *args, **kwargs): + super(Inventory, self).save(*args, **kwargs) + total_amount = 0 + for i in self.product.inventory_set.all(): + total_amount += i.amount_stocked + self.product.total_amount = total_amount + self.product.save() + + class Meta: + app_label = "servo" + unique_together = ('product', 'location',) + + +class ShippingMethod(models.Model): + """ + How the contents of an order should be shipped + """ + title = models.CharField(max_length=128, default=_('New Shipping Method')) + description = models.TextField(default='', blank=True) + notify_email = models.EmailField(null=True, blank=True) + + class Meta: + app_label = "servo" -- cgit v1.2.3