"""Tabular data formats"""
from contextlib import contextmanager
from dataclasses import dataclass, field, fields
from datetime import date
from decimal import Decimal
from operator import itemgetter
from typing import Callable, ClassVar, Iterable, List, Mapping
from ..api.vat import VatObligationStatus, VatSubmission
from ..cli import Command
from ..cli.vat import VatBox, VatCommand, format_vat_return
__all__ = [
'TabularTypeParser',
'TabularDataClass',
'tabulardataclass',
'TabularNoData',
'TabularRowReader',
'TabularReader',
'TabularColumn',
'TabularCommand',
'TabularVatReturn',
'TabularVatSubmitCommand',
]
ZERO = Decimal('0.00')
[docs]@dataclass
class TabularTypeParser:
"""Tabular data type parser"""
pytype: type
"""Target Python type"""
parse: Callable = None
"""Value parser for this target Python type"""
def __post_init__(self):
if self.parse is None:
self.parse = self.pytype
[docs]class TabularDataClass:
"""Tabular data class"""
TypeParser = TabularTypeParser
"""Type parser class"""
__parsers: ClassVar[Mapping[str, Callable]]
"""Type parsers for each dataclass field"""
[docs] @classmethod
def build_parsers(cls):
"""Construct type parsers for each dataclass field"""
cls.__parsers = {
f.name: cls.TypeParser(f.type).parse
for f in fields(cls)
}
[docs] @classmethod
def from_tabular(cls, **kwargs):
"""Construct Python object from tabular data"""
parse = cls.__parsers
return cls(**{k: parse[k](v) for k, v in kwargs.items()})
[docs]def tabulardataclass(cls):
"""Tabular data class decorator"""
cls = dataclass(cls)
cls.build_parsers()
return cls
[docs]@tabulardataclass
class TabularNoData(TabularDataClass):
"""Empty tabular data"""
[docs]@dataclass
class TabularRowReader:
"""Tabular data row reader"""
Row: type
"""Row data class"""
headings: List[str]
"""Input column headings"""
mapping: Mapping[str, str] = field(default_factory=dict)
"""Mapping from row data class field names to input column headings"""
getters: Mapping[str, Callable] = field(default_factory=dict)
"""Item getters for each row data class field present in input columns"""
def __post_init__(self):
for f in fields(self.Row):
heading = self.mapping.get(f.name, f.name)
if heading in self.headings:
# pylint: disable=unsupported-assignment-operation
self.getters[f.name] = itemgetter(self.headings.index(heading))
def __call__(self, row):
"""Construct row data class instance from input row data"""
# pylint: disable=no-member
return self.Row.from_tabular(**{
k: v(row) for k, v in self.getters.items()
})
[docs]@dataclass
class TabularReader:
"""Tabular data reader"""
data: Iterable
"""Input data"""
Row: type
"""Row class"""
headings: List[str] = None
"""Input column headings"""
mapping: Mapping[str, str] = field(default_factory=dict)
"""Mapping from output row field names to input column headings"""
RowReader: ClassVar[type] = TabularRowReader
"""Data row reader class"""
def __iter__(self):
it = iter(self.data)
headings = (
self.headings if self.headings is not None else
[str(x) for x in next(it)]
)
reader = self.RowReader(self.Row, headings, mapping=self.mapping)
return (reader(row) for row in it)
[docs]@dataclass
class TabularColumn:
"""Tabular data column"""
name: str
"""Column name"""
description: str = None
"""Column description"""
dest: str = None
"""Argument parser destination"""
def __post_init__(self):
if self.dest is None:
self.dest = '%s_column' % self.name
[docs]class TabularCommand(Command):
"""Tabular data processing command"""
Reader: ClassVar[type] = TabularReader
"""Data reader class"""
Row: ClassVar[type] = TabularNoData
"""Row data class"""
columns: ClassVar[List[TabularColumn]] = []
"""List of column definitions"""
[docs] @classmethod
def init_parser(cls, parser):
super().init_parser(parser)
for column in cls.columns:
option = '--%s' % column.dest.replace('_', '-')
description = column.description or column.name
parser.add_argument(option, dest=column.dest, default=column.name,
help="Column heading for %s" % description)
[docs] @contextmanager
def data(self):
"""Read input data"""
yield [()]
[docs] @contextmanager
def reader(self):
"""Construct data reader"""
mapping = {x.name: getattr(self.args, x.dest) for x in self.columns}
with self.data() as data:
yield self.Reader(data, self.Row, mapping=mapping)
[docs]@tabulardataclass
class TabularVatReturn(TabularDataClass):
"""VAT return from tabular data"""
end: date
vat_sales: Decimal = ZERO
vat_acquisitions: Decimal = ZERO
vat_reclaimed: Decimal = ZERO
total_sales: Decimal = ZERO
total_purchases: Decimal = ZERO
total_supplies: Decimal = ZERO
total_acquisitions: Decimal = ZERO
[docs] def submission(self, period_key, finalise=False):
"""Construct VAT submission"""
total_vat_due = self.vat_sales + self.vat_acquisitions
net_vat_due = abs(total_vat_due - self.vat_reclaimed)
return VatSubmission(
period_key=period_key,
vat_due_sales=self.vat_sales,
vat_due_acquisitions=self.vat_acquisitions,
total_vat_due=total_vat_due,
vat_reclaimed_curr_period=self.vat_reclaimed,
net_vat_due=net_vat_due,
total_value_sales_ex_vat=int(self.total_sales),
total_value_purchases_ex_vat=int(self.total_purchases),
total_value_goods_supplied_ex_vat=int(self.total_supplies),
total_acquisitions_ex_vat=int(self.total_acquisitions),
finalised=finalise,
)
[docs]class TabularVatSubmitCommand(TabularCommand, VatCommand):
"""Submit VAT return(s) from tabular data"""
Row = TabularVatReturn
columns = [
TabularColumn('end', "end date"),
TabularColumn('vat_sales', VatBox.BOX1.value),
TabularColumn('vat_acquisitions', VatBox.BOX2.value),
TabularColumn('vat_reclaimed', VatBox.BOX4.value),
TabularColumn('total_sales', VatBox.BOX6.value),
TabularColumn('total_purchases', VatBox.BOX7.value),
TabularColumn('total_supplies', VatBox.BOX8.value),
TabularColumn('total_acquisitions', VatBox.BOX9.value),
]
[docs] @classmethod
def init_parser(cls, parser):
super().init_parser(parser)
parser.add_argument('--finalise', action='store_true',
help="Finalise return")
[docs] def execute(self, client):
# Get outstanding obligations, indexed by end date
obligations = {
x.end: x for x in client.obligations(
vrn=self.args.vrn, scenario=self.args.scenario,
status=VatObligationStatus.OPEN
).obligations
# The sandbox API will stupidly ignore all search filters
# (such as the stated obligation status) when using a
# named test scenario, so filter the results.
if x.status == VatObligationStatus.OPEN
}
# Construct submissions for which an open obligation exists
with self.reader() as reader:
submissions = [
x.submission(obligations[x.end].period_key,
finalise=self.args.finalise)
for x in reader
if x.end in obligations
]
# Construct command output
output = [
line for submission in submissions for line in
format_vat_return(submission, draft=not submission.finalised)
]
# Submit if applicable
if self.args.finalise:
for submission in submissions:
client.submit(submission, vrn=self.args.vrn)
return output