You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

402 lines
12 KiB

  1. # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
  2. # License: GNU General Public License v3. See license.txt
  3. import copy
  4. import json
  5. import frappe
  6. from frappe import _
  7. from frappe.utils import cstr, flt
  8. class ItemVariantExistsError(frappe.ValidationError):
  9. pass
  10. class InvalidItemAttributeValueError(frappe.ValidationError):
  11. pass
  12. class ItemTemplateCannotHaveStock(frappe.ValidationError):
  13. pass
  14. @frappe.whitelist()
  15. def get_variant(template, args=None, variant=None, manufacturer=None, manufacturer_part_no=None):
  16. """Validates Attributes and their Values, then looks for an exactly
  17. matching Item Variant
  18. :param item: Template Item
  19. :param args: A dictionary with "Attribute" as key and "Attribute Value" as value
  20. """
  21. item_template = frappe.get_doc("Item", template)
  22. if item_template.variant_based_on == "Manufacturer" and manufacturer:
  23. return make_variant_based_on_manufacturer(item_template, manufacturer, manufacturer_part_no)
  24. else:
  25. if isinstance(args, str):
  26. args = json.loads(args)
  27. if not args:
  28. frappe.throw(_("Please specify at least one attribute in the Attributes table"))
  29. return find_variant(template, args, variant)
  30. def make_variant_based_on_manufacturer(template, manufacturer, manufacturer_part_no):
  31. """Make and return a new variant based on manufacturer and
  32. manufacturer part no"""
  33. from frappe.model.naming import append_number_if_name_exists
  34. variant = frappe.new_doc("Item")
  35. copy_attributes_to_variant(template, variant)
  36. variant.manufacturer = manufacturer
  37. variant.manufacturer_part_no = manufacturer_part_no
  38. variant.item_code = append_number_if_name_exists("Item", template.name)
  39. return variant
  40. def validate_item_variant_attributes(item, args=None):
  41. if isinstance(item, str):
  42. item = frappe.get_doc("Item", item)
  43. if not args:
  44. args = {d.attribute.lower(): d.attribute_value for d in item.attributes}
  45. attribute_values, numeric_values = get_attribute_values(item)
  46. for attribute, value in args.items():
  47. if not value:
  48. continue
  49. if attribute.lower() in numeric_values:
  50. numeric_attribute = numeric_values[attribute.lower()]
  51. validate_is_incremental(numeric_attribute, attribute, value, item.name)
  52. else:
  53. attributes_list = attribute_values.get(attribute.lower(), [])
  54. validate_item_attribute_value(attributes_list, attribute, value, item.name, from_variant=True)
  55. def validate_is_incremental(numeric_attribute, attribute, value, item):
  56. from_range = numeric_attribute.from_range
  57. to_range = numeric_attribute.to_range
  58. increment = numeric_attribute.increment
  59. if increment == 0:
  60. # defensive validation to prevent ZeroDivisionError
  61. frappe.throw(_("Increment for Attribute {0} cannot be 0").format(attribute))
  62. is_in_range = from_range <= flt(value) <= to_range
  63. precision = max(len(cstr(v).split(".")[-1].rstrip("0")) for v in (value, increment))
  64. # avoid precision error by rounding the remainder
  65. remainder = flt((flt(value) - from_range) % increment, precision)
  66. is_incremental = remainder == 0 or remainder == increment
  67. if not (is_in_range and is_incremental):
  68. frappe.throw(
  69. _(
  70. "Value for Attribute {0} must be within the range of {1} to {2} in the increments of {3} for Item {4}"
  71. ).format(attribute, from_range, to_range, increment, item),
  72. InvalidItemAttributeValueError,
  73. title=_("Invalid Attribute"),
  74. )
  75. def validate_item_attribute_value(
  76. attributes_list, attribute, attribute_value, item, from_variant=True
  77. ):
  78. allow_rename_attribute_value = frappe.db.get_single_value(
  79. "Item Variant Settings", "allow_rename_attribute_value"
  80. )
  81. if allow_rename_attribute_value:
  82. pass
  83. elif attribute_value not in attributes_list:
  84. if from_variant:
  85. frappe.throw(
  86. _("{0} is not a valid Value for Attribute {1} of Item {2}.").format(
  87. frappe.bold(attribute_value), frappe.bold(attribute), frappe.bold(item)
  88. ),
  89. InvalidItemAttributeValueError,
  90. title=_("Invalid Value"),
  91. )
  92. else:
  93. msg = _("The value {0} is already assigned to an existing Item {1}.").format(
  94. frappe.bold(attribute_value), frappe.bold(item)
  95. )
  96. msg += "<br>" + _(
  97. "To still proceed with editing this Attribute Value, enable {0} in Item Variant Settings."
  98. ).format(frappe.bold("Allow Rename Attribute Value"))
  99. frappe.throw(msg, InvalidItemAttributeValueError, title=_("Edit Not Allowed"))
  100. def get_attribute_values(item):
  101. if not frappe.flags.attribute_values:
  102. attribute_values = {}
  103. numeric_values = {}
  104. for t in frappe.get_all("Item Attribute Value", fields=["parent", "attribute_value"]):
  105. attribute_values.setdefault(t.parent.lower(), []).append(t.attribute_value)
  106. for t in frappe.get_all(
  107. "Item Variant Attribute",
  108. fields=["attribute", "from_range", "to_range", "increment"],
  109. filters={"numeric_values": 1, "parent": item.variant_of},
  110. ):
  111. numeric_values[t.attribute.lower()] = t
  112. frappe.flags.attribute_values = attribute_values
  113. frappe.flags.numeric_values = numeric_values
  114. return frappe.flags.attribute_values, frappe.flags.numeric_values
  115. def find_variant(template, args, variant_item_code=None):
  116. conditions = [
  117. """(iv_attribute.attribute={0} and iv_attribute.attribute_value={1})""".format(
  118. frappe.db.escape(key), frappe.db.escape(cstr(value))
  119. )
  120. for key, value in args.items()
  121. ]
  122. conditions = " or ".join(conditions)
  123. from erpnext.e_commerce.variant_selector.utils import get_item_codes_by_attributes
  124. possible_variants = [
  125. i for i in get_item_codes_by_attributes(args, template) if i != variant_item_code
  126. ]
  127. for variant in possible_variants:
  128. variant = frappe.get_doc("Item", variant)
  129. if len(args.keys()) == len(variant.get("attributes")):
  130. # has the same number of attributes and values
  131. # assuming no duplication as per the validation in Item
  132. match_count = 0
  133. for attribute, value in args.items():
  134. for row in variant.attributes:
  135. if row.attribute == attribute and row.attribute_value == cstr(value):
  136. # this row matches
  137. match_count += 1
  138. break
  139. if match_count == len(args.keys()):
  140. return variant.name
  141. @frappe.whitelist()
  142. def create_variant(item, args):
  143. if isinstance(args, str):
  144. args = json.loads(args)
  145. template = frappe.get_doc("Item", item)
  146. variant = frappe.new_doc("Item")
  147. variant.variant_based_on = "Item Attribute"
  148. variant_attributes = []
  149. for d in template.attributes:
  150. variant_attributes.append({"attribute": d.attribute, "attribute_value": args.get(d.attribute)})
  151. variant.set("attributes", variant_attributes)
  152. copy_attributes_to_variant(template, variant)
  153. make_variant_item_code(template.item_code, template.item_name, variant)
  154. return variant
  155. @frappe.whitelist()
  156. def enqueue_multiple_variant_creation(item, args):
  157. # There can be innumerable attribute combinations, enqueue
  158. if isinstance(args, str):
  159. variants = json.loads(args)
  160. total_variants = 1
  161. for key in variants:
  162. total_variants *= len(variants[key])
  163. if total_variants >= 600:
  164. frappe.throw(_("Please do not create more than 500 items at a time"))
  165. return
  166. if total_variants < 10:
  167. return create_multiple_variants(item, args)
  168. else:
  169. frappe.enqueue(
  170. "erpnext.controllers.item_variant.create_multiple_variants",
  171. item=item,
  172. args=args,
  173. now=frappe.flags.in_test,
  174. )
  175. return "queued"
  176. def create_multiple_variants(item, args):
  177. count = 0
  178. if isinstance(args, str):
  179. args = json.loads(args)
  180. args_set = generate_keyed_value_combinations(args)
  181. for attribute_values in args_set:
  182. if not get_variant(item, args=attribute_values):
  183. variant = create_variant(item, attribute_values)
  184. variant.save()
  185. count += 1
  186. return count
  187. def generate_keyed_value_combinations(args):
  188. """
  189. From this:
  190. args = {"attr1": ["a", "b", "c"], "attr2": ["1", "2"], "attr3": ["A"]}
  191. To this:
  192. [
  193. {u'attr1': u'a', u'attr2': u'1', u'attr3': u'A'},
  194. {u'attr1': u'b', u'attr2': u'1', u'attr3': u'A'},
  195. {u'attr1': u'c', u'attr2': u'1', u'attr3': u'A'},
  196. {u'attr1': u'a', u'attr2': u'2', u'attr3': u'A'},
  197. {u'attr1': u'b', u'attr2': u'2', u'attr3': u'A'},
  198. {u'attr1': u'c', u'attr2': u'2', u'attr3': u'A'}
  199. ]
  200. """
  201. # Return empty list if empty
  202. if not args:
  203. return []
  204. # Turn `args` into a list of lists of key-value tuples:
  205. # [
  206. # [(u'attr2', u'1'), (u'attr2', u'2')],
  207. # [(u'attr3', u'A')],
  208. # [(u'attr1', u'a'), (u'attr1', u'b'), (u'attr1', u'c')]
  209. # ]
  210. key_value_lists = [[(key, val) for val in args[key]] for key in args.keys()]
  211. # Store the first, but as objects
  212. # [{u'attr2': u'1'}, {u'attr2': u'2'}]
  213. results = key_value_lists.pop(0)
  214. results = [{d[0]: d[1]} for d in results]
  215. # Iterate the remaining
  216. # Take the next list to fuse with existing results
  217. for l in key_value_lists:
  218. new_results = []
  219. for res in results:
  220. for key_val in l:
  221. # create a new clone of object in result
  222. obj = copy.deepcopy(res)
  223. # to be used with every incoming new value
  224. obj[key_val[0]] = key_val[1]
  225. # and pushed into new_results
  226. new_results.append(obj)
  227. results = new_results
  228. return results
  229. def copy_attributes_to_variant(item, variant):
  230. # copy non no-copy fields
  231. exclude_fields = [
  232. "naming_series",
  233. "item_code",
  234. "item_name",
  235. "published_in_website",
  236. "opening_stock",
  237. "variant_of",
  238. "valuation_rate",
  239. ]
  240. if item.variant_based_on == "Manufacturer":
  241. # don't copy manufacturer values if based on part no
  242. exclude_fields += ["manufacturer", "manufacturer_part_no"]
  243. allow_fields = [d.field_name for d in frappe.get_all("Variant Field", fields=["field_name"])]
  244. if "variant_based_on" not in allow_fields:
  245. allow_fields.append("variant_based_on")
  246. for field in item.meta.fields:
  247. # "Table" is part of `no_value_field` but we shouldn't ignore tables
  248. if (field.reqd or field.fieldname in allow_fields) and field.fieldname not in exclude_fields:
  249. if variant.get(field.fieldname) != item.get(field.fieldname):
  250. if field.fieldtype == "Table":
  251. variant.set(field.fieldname, [])
  252. for d in item.get(field.fieldname):
  253. row = copy.deepcopy(d)
  254. if row.get("name"):
  255. row.name = None
  256. variant.append(field.fieldname, row)
  257. else:
  258. variant.set(field.fieldname, item.get(field.fieldname))
  259. variant.variant_of = item.name
  260. if "description" not in allow_fields:
  261. if not variant.description:
  262. variant.description = ""
  263. else:
  264. if item.variant_based_on == "Item Attribute":
  265. if variant.attributes:
  266. attributes_description = item.description + " "
  267. for d in variant.attributes:
  268. attributes_description += "<div>" + d.attribute + ": " + cstr(d.attribute_value) + "</div>"
  269. if attributes_description not in variant.description:
  270. variant.description = attributes_description
  271. def make_variant_item_code(template_item_code, template_item_name, variant):
  272. """Uses template's item code and abbreviations to make variant's item code"""
  273. if variant.item_code:
  274. return
  275. abbreviations = []
  276. for attr in variant.attributes:
  277. item_attribute = frappe.db.sql(
  278. """select i.numeric_values, v.abbr
  279. from `tabItem Attribute` i left join `tabItem Attribute Value` v
  280. on (i.name=v.parent)
  281. where i.name=%(attribute)s and (v.attribute_value=%(attribute_value)s or i.numeric_values = 1)""",
  282. {"attribute": attr.attribute, "attribute_value": attr.attribute_value},
  283. as_dict=True,
  284. )
  285. if not item_attribute:
  286. continue
  287. # frappe.throw(_('Invalid attribute {0} {1}').format(frappe.bold(attr.attribute),
  288. # frappe.bold(attr.attribute_value)), title=_('Invalid Attribute'),
  289. # exc=InvalidItemAttributeValueError)
  290. abbr_or_value = (
  291. cstr(attr.attribute_value) if item_attribute[0].numeric_values else item_attribute[0].abbr
  292. )
  293. abbreviations.append(abbr_or_value)
  294. if abbreviations:
  295. variant.item_code = "{0}-{1}".format(template_item_code, "-".join(abbreviations))
  296. variant.item_name = "{0}-{1}".format(template_item_name, "-".join(abbreviations))
  297. @frappe.whitelist()
  298. def create_variant_doc_for_quick_entry(template, args):
  299. variant_based_on = frappe.db.get_value("Item", template, "variant_based_on")
  300. args = json.loads(args)
  301. if variant_based_on == "Manufacturer":
  302. variant = get_variant(template, **args)
  303. else:
  304. existing_variant = get_variant(template, args)
  305. if existing_variant:
  306. return existing_variant
  307. else:
  308. variant = create_variant(template, args=args)
  309. variant.name = variant.item_code
  310. validate_item_variant_attributes(variant, args)
  311. return variant.as_dict()