"""Business rules for commodities/goods nomenclatures."""
import logging
from datetime import date
from datetime import datetime
from datetime import timedelta
from django.utils.timezone import make_aware
from common.business_rules import BusinessRule
from common.business_rules import DescriptionsRules
from common.business_rules import FootnoteApplicability
from common.business_rules import NoOverlapping
from common.business_rules import PreventDeleteIfInUse
from common.business_rules import ValidityPeriodContained
from common.business_rules import ValidityStartDateRules
from common.business_rules import only_applicable_after
from common.business_rules import skip_when_deleted
from common.business_rules import skip_when_not_deleted
from common.models.trackedmodel import TrackedModel
from common.util import TaricDateRange
from common.util import validity_range_contains_range
[docs]class NIG1(NoOverlapping):
"""The validity period of the goods nomenclature must not overlap any other
goods nomenclature with the same SID."""
[docs]class NIG2(BusinessRule):
"""The validity period of the goods nomenclature must be within the validity
period of the product line above in the hierarchy."""
# Note that a complete reading of this rule implies that a commodity's
# parent should span the commodity but also that the commodity should span
# all of it's children. However, the latter case is often broken by the EU
# and not enforced by CDS. Therefore, we only check the parent case.
#
# Note that this means that running this business rule against a child can
# therefore result in a violation. We think this is a feature and not a bug
# because it allows parents to shift around without having to re-send all of
# the data associated with the rest of the tree.
def __init__(self, transaction=None):
super().__init__(transaction)
self.logger = logging.getLogger(type(self).__name__)
def parent_spans_child(self, parent, child) -> bool:
parent_validity = parent.indented_goods_nomenclature.version_at(
self.transaction,
).valid_between
child_validity = child.indented_goods_nomenclature.version_at(
self.transaction,
).valid_between
return validity_range_contains_range(parent_validity, child_validity)
def parents_span_childs_future(self, parents, child):
if len(parents) == 0:
raise Exception("No parents")
# get all date ranges
parents_validity = []
for parent in parents:
parents_validity.append(
parent.indented_goods_nomenclature.version_at(
self.transaction,
).valid_between,
)
# sort by start date so any gaps will be obvious
parents_validity.sort(key=lambda daterange: daterange.lower)
child_validity = child.indented_goods_nomenclature.version_at(
self.transaction,
).valid_between
if (
not child_validity.upper_inf
and child_validity.upper < datetime.today().date()
):
return True
multi_parent_validity = None
for parent_validity in parents_validity:
if not multi_parent_validity:
multi_parent_validity = parent_validity
else:
if parent_validity.overlaps(multi_parent_validity):
multi_parent_validity = TaricDateRange.merge_ranges(
multi_parent_validity,
parent_validity,
)
multi_parent_validity = TaricDateRange(
datetime.today().date(),
multi_parent_validity.upper,
)
child_start_date = child_validity.lower
if child_start_date < datetime.today().date():
child_start_date = datetime.today().date()
child_validity = TaricDateRange(child_start_date, child_validity.upper)
return validity_range_contains_range(multi_parent_validity, child_validity)
[docs] def validate(self, indent):
from commodities.models.dc import Commodity
from commodities.models.dc import get_chapter_collection
try:
good = indent.indented_goods_nomenclature.version_at(self.transaction)
except TrackedModel.DoesNotExist:
self.logger.warning(
"Goods nomenclature %s no longer exists at transaction %s "
"but indent %s is still referring to it.",
indent.indented_goods_nomenclature,
self.transaction,
indent,
)
return
commodity = Commodity(obj=good, indent_obj=indent)
collection = get_chapter_collection(good)
snapshot = collection.get_snapshot(
self.transaction,
make_aware(datetime.today()),
)
potential_parents = snapshot.get_potential_parents(commodity)
if len(potential_parents) == 0:
return
if not self.parents_span_childs_future(potential_parents, indent):
raise self.violation(indent)
[docs]@skip_when_deleted
@only_applicable_after(date(2010, 1, 1))
class NIG5(BusinessRule):
"""
When creating a goods nomenclature code, an origin must exist.
This rule is only applicable to update extractions.
"""
[docs] def validate(self, good):
"""
Almost all goods nomenclatures must have an origin, excluding two
scenarios:
1) They are a top level code (depth/indent of 1)
2) They were made before 2010-01-01 (legacy data)
Therefore check for these two conditions, and if neither are met ensure an origin exists.
"""
# The proper method of checking for root codes is to examine the indents
# of the code and see whether the code has any ancestors or not.
# However, this is slow and troublesome, because in theory the indent
# can change over time so it's not clear which indent we should be
# examining if there are multiple (except root codes should only ever
# have 1).
#
# So instead we just check whether the code is a chapter code i.e. it
# ends with eight zeroes. All and only root codes should have this
# property.
from commodities.models.orm import GoodsNomenclatureOrigin
if good.code.is_chapter:
return
if (
GoodsNomenclatureOrigin.objects.filter(new_goods_nomenclature__sid=good.sid)
.approved_up_to_transaction(self.transaction)
.exists()
):
return
raise self.violation(
model=good,
message="Non top-level goods must have an origin specified.",
)
[docs]@skip_when_not_deleted
class NIG5_origin(BusinessRule):
"""
related to NIG5:
MIG5 text:
When creating a goods nomenclature code, an origin must exist.
This rule is only applicable to update extractions.
When an origin is deleted, the goods nomenclature should be validated, if it has not been deleted
then it should have an origin, provided it's not a chapter and was created after 1/1/2010
"""
[docs] def validate(self, origin):
"""
Only used when deleting an origin.
Verify:
* It has an origin after deletion of this origin
* or that the goods code is a chapter
* or it was created before 1/1/2010
* or the code was deleted in same or previous transaction
If it does not meet these criteria it's a validation fail
"""
from commodities.models.orm import GoodsNomenclature
from commodities.models.orm import GoodsNomenclatureOrigin
if origin.new_goods_nomenclature.valid_between.lower < date(2021, 1, 1):
return
# is it a chapter?
if origin.new_goods_nomenclature.code.is_chapter:
return
if (
GoodsNomenclatureOrigin.objects.approved_up_to_transaction(
origin.transaction,
)
.filter(new_goods_nomenclature__sid=origin.new_goods_nomenclature.sid)
.exists()
):
return
# verify the goods nomenclature exists in the same transaction, it will not if it has been deleted
if not (
GoodsNomenclature.objects.approved_up_to_transaction(
origin.transaction,
)
.filter(sid=origin.new_goods_nomenclature.sid)
.exists()
):
return
raise self.violation(
model=origin,
message="Non top-level goods must have an origin specified. None remain if this origin is deleted",
)
[docs]class NIG7(BusinessRule):
"""The origin must be applicable the day before the start date of the new
code entered."""
[docs] def validate(self, origin):
"""
By default the upper bound is exclusive whilst the lower bound is
inclusive.
So we just need to make sure the bounds match.
"""
origin_range = origin.derived_from_goods_nomenclature.valid_between
starts_on = origin.new_goods_nomenclature.valid_between.lower
day_before_start = starts_on - timedelta(days=1)
if day_before_start not in origin_range:
raise self.violation(
model=origin,
message=(
'The "derived from" code, if entered, must be a goods '
"nomenclature which exists and is applicable the day before the "
"start date of the new code entered. "
f"Origin {origin_range} is not applicable on {day_before_start}."
),
)
[docs]@skip_when_deleted
class NIG10(BusinessRule):
"""The successor must be applicable the day after the end date of the old
code."""
[docs] def validate(self, successor):
ends_on = successor.replaced_goods_nomenclature.valid_between.upper
successor_range = successor.absorbed_into_goods_nomenclature.valid_between
if ends_on is None:
raise self.violation(
model=successor,
message=(
"A successor can only be added for goods nomenclature codes with a "
"closing date."
),
)
day_after_end = ends_on + timedelta(days=1)
if day_after_end not in successor_range:
raise self.violation(
model=successor,
message=(
'The "absorbed by" code, if entered, must be a goods nomenclature '
"which exists and is applicable the day after the closing date."
f"Successor {successor_range} is not applicable on {day_after_end}."
),
)
[docs]@skip_when_deleted
class NIG11(ValidityStartDateRules):
"""
At least one indent record is mandatory.
The start date of the first indentation must be equal to the start date of
the nomenclature. No two associated indentations may have the same start
date. The start date must be less than or equal to the end date of the
nomenclature.
"""
model_name = "goods nomenclature"
item_name = "indent"
def get_objects(self, good):
GoodsNomenclatureIndent = good.indents.model
return GoodsNomenclatureIndent.objects.filter(
indented_goods_nomenclature__sid=good.sid,
).approved_up_to_transaction(self.transaction)
[docs]class NIG12(DescriptionsRules):
"""
At least one description is mandatory.
The start date of the first description period must be equal to the start
date of the nomenclature. No two associated description periods may have the
same start date. The start date must be less than or equal to the end date
of the nomenclature.
"""
model_name = "goods nomenclature"
[docs]class NIG18(FootnoteApplicability):
"""Footnotes with a footnote type for which the application type = "CN
footnotes" must be linked to CN lines (all codes up to 8 digits)."""
applicable_field = "goods_nomenclature"
[docs]class NIG22(ValidityPeriodContained):
"""The period of the association with a footnote must be within the validity
period of the nomenclature."""
container_field_name = "goods_nomenclature"
[docs]class NIG23(ValidityPeriodContained):
"""The period of the association with a footnote must be within the validity
period of the footnote."""
container_field_name = "associated_footnote"
[docs]class NIG24(BusinessRule):
"""When the same footnote is associated more than once with the same
nomenclature then there may be no overlap in their association periods."""
[docs] def validate(self, association):
# XXX does this handle versions?
overlapping = (
type(association)
.objects.filter(
associated_footnote__footnote_id=association.associated_footnote.footnote_id,
associated_footnote__footnote_type__footnote_type_id=association.associated_footnote.footnote_type.footnote_type_id,
goods_nomenclature__sid=association.goods_nomenclature.sid,
valid_between__overlap=association.valid_between,
)
.approved_up_to_transaction(association.transaction)
.exclude(
id=association.pk,
)
)
if overlapping.exists():
raise self.violation(association)
[docs]class NIG30(ValidityPeriodContained):
"""When a goods nomenclature is used in a goods measure then the validity
period of the goods nomenclature must span the validity period of the goods
measure."""
contained_field_name = "measures"
[docs]class NIG31(NIG30):
"""When a goods nomenclature is used in an additional nomenclature measure
then the validity period of the goods nomenclature must span the validity
period of the additional nomenclature measure."""
extra_filters = {
"additional_code__isnull": False,
}
[docs]class NIG34(PreventDeleteIfInUse):
"""A goods nomenclature cannot be deleted if it is used in a goods
measure."""
[docs]@skip_when_not_deleted
class NIG35(BusinessRule):
"""A goods nomenclature cannot be deleted if it is used in an additional
nomenclature measure."""
# XXX this is redundant - NIG34 will be violated first
def has_violation(self, good):
return (
good.measures.model.objects.filter(
goods_nomenclature__sid=good.sid,
additional_code__isnull=False,
)
.approved_up_to_transaction(
self.transaction,
)
.exists()
)
[docs] def validate(self, good):
if self.has_violation(good):
raise self.violation(good)