| @@ -0,0 +1 @@ | |||||
| __pycache__ | |||||
| @@ -0,0 +1,61 @@ | |||||
| = Invoice | |||||
| This is my quick and dirty invoice system I am going to use for my invoices | |||||
| in year 2012. | |||||
| The program is higly experimental and users are expected to know at least a | |||||
| bit of Python coding. Feedback is appreciated. | |||||
| Pavel Šimerda <pavlix@pavlix.net> | |||||
| == License | |||||
| This version of the source code is released into public domain, future releases | |||||
| may adopt a BSD-like license. | |||||
| == Requirements | |||||
| * Python 3.2 | |||||
| * python3-templite 0.5.1 | |||||
| * pdflatex | |||||
| == Installation | |||||
| You don't need to install, just make a symlink to the invoice script | |||||
| in ~/bin or /usr/bin/local. | |||||
| You will need to make the following symlinks or directories in ~/.invoice/ | |||||
| * data (the database) | |||||
| * data/$YEAR (current year) | |||||
| * tmp | |||||
| * output | |||||
| * output/$YEAR | |||||
| Use the code or error messages in case this list is incomplete. Use -D for | |||||
| debugging. | |||||
| == User configuration | |||||
| For now an empthy ~/.invoice/config should do. | |||||
| == Usage | |||||
| Export EDITOR and PDF_VIEWER environment variables to choose your favourite | |||||
| text editor and PDF viewer. Defaults are 'vim' and 'xdg-open'. | |||||
| Manage companies: | |||||
| invoice new-company <id> | |||||
| invoice edit-company <id> | |||||
| invoice delete-company <id> | |||||
| Manage invoices: | |||||
| invoice new <company-id> | |||||
| invoice edit [<number>] | |||||
| invoice delete [<number>] | |||||
| Generate and display invoice PDF: | |||||
| invoice show [<number>] | |||||
| @@ -0,0 +1,13 @@ | |||||
| #!/usr/bin/python3 | |||||
| import os, sys | |||||
| base_dir = sys.path[0] | |||||
| sys.path[0] = os.path.join(base_dir, "lib") | |||||
| print(sys.path) | |||||
| import invoice.cli | |||||
| if __name__ == '__main__': | |||||
| invoice.cli.Application( | |||||
| template_path = os.path.join(base_dir, "templates") | |||||
| ).run() | |||||
| @@ -0,0 +1,215 @@ | |||||
| #!/usr/bin/python3 | |||||
| import os, sys, argparse, datetime, subprocess | |||||
| import invoice.db | |||||
| import logging | |||||
| log = logging.getLogger() | |||||
| class SanityCheckError(Exception): | |||||
| pass | |||||
| class Application: | |||||
| my_company = "my-company" | |||||
| editor = os.environ.get("EDITOR") or "vim" | |||||
| viewer = os.environ.get("PAGER") or "less" | |||||
| tex_program = "pdflatex" | |||||
| pdf_program = "xdg-open" | |||||
| def __init__(self, template_path): | |||||
| self._parse_args() | |||||
| self.year = self.args.__dict__.pop("year") | |||||
| self.user_path = os.path.expanduser(self.args.__dict__.pop("user_data")) | |||||
| self.method = self.args.__dict__.pop("method") | |||||
| self.data_path = os.path.join(self.user_path, "data", "{year}", "data", "{directory}") | |||||
| self.tmp_path = os.path.join(self.user_path, "tmp") | |||||
| self.output_path = os.path.join(self.user_path, "output", "{year}") | |||||
| self.template_path = template_path | |||||
| self.db = invoice.db.Database( | |||||
| year = self.year, | |||||
| data_path = self.data_path) | |||||
| def _parse_args(self): | |||||
| parser = argparse.ArgumentParser( | |||||
| description = "Pavel Šimerda's invoice CLI application.", | |||||
| conflict_handler = "resolve") | |||||
| parser.add_argument("--year", "-Y", action="store") | |||||
| parser.add_argument("--user-data", "-d", action="store") | |||||
| parser.add_argument("--debug", "-D", action="store_const", dest="log_level", const=logging.DEBUG) | |||||
| #parser.add_argument("--verbose", "-v", action="store_const", dest="log_level", const=logging.INFO) | |||||
| #parser.add_argument("--config", "-C", action="store") | |||||
| parser.set_defaults( | |||||
| year = datetime.date.today().year, | |||||
| user_data = "~/.invoice", | |||||
| log_level = logging.INFO) | |||||
| subparsers = parser.add_subparsers(title="subcommands", | |||||
| description="valid subcommands", | |||||
| help="additional help") | |||||
| for list_ in "invoices", "companies": | |||||
| for action in "list", "new", "edit", "show", "delete": | |||||
| suffix = '' | |||||
| if list_ == "companies": | |||||
| suffix = "-companies" if action=="list" else "-company" | |||||
| method = getattr(self, "do_"+(action+suffix).replace("-", "_")) | |||||
| subparser = subparsers.add_parser(action+suffix, help=method.__doc__) | |||||
| if action == "new": | |||||
| subparser.add_argument("name" if suffix else "company_name") | |||||
| if action in ("show", "edit", "delete"): | |||||
| subparser.add_argument("selector", nargs="?") | |||||
| if action == "delete": | |||||
| subparser.add_argument("--force", "-f", action="store_true") | |||||
| subparser.set_defaults(method=method) | |||||
| self.args = parser.parse_args() | |||||
| log.setLevel(self.args.__dict__.pop("log_level")) | |||||
| log.debug("Arguments: {}".format(self.args)) | |||||
| def run(self): | |||||
| try: | |||||
| self.method(**vars(self.args)) | |||||
| except (SanityCheckError) as error: | |||||
| print("Error: {} Use '--force' to suppress this check.".format(error), file=sys.stderr) | |||||
| if log.isEnabledFor(logging.DEBUG): | |||||
| raise | |||||
| except invoice.db.DatabaseError as error: | |||||
| print("Error: {}".format(error), file=sys.stderr) | |||||
| if log.isEnabledFor(logging.DEBUG): | |||||
| raise | |||||
| def do_list(self): | |||||
| """List invoices.""" | |||||
| for item in sorted(self.db.invoices): | |||||
| print(item) | |||||
| def do_new(self, company_name): | |||||
| """Create and edit a new invoice.""" | |||||
| item = self.db.invoices.new(company_name) | |||||
| self._edit(item._path) | |||||
| def do_edit(self, selector): | |||||
| """Edit invoice in external editor. | |||||
| The external editor is determined by EDITOR environment variable | |||||
| using 'vim' as the default. Item is edited in-place. | |||||
| """ | |||||
| if selector: | |||||
| item = self.db.invoices[selector] | |||||
| else: | |||||
| item = self.db.invoices.last() | |||||
| self._edit(item._path) | |||||
| def _edit(self, path): | |||||
| log.debug("Editing file: {}".format(path)) | |||||
| assert os.path.exists(path) | |||||
| subprocess.call((self.editor, path)) | |||||
| def do_show(self, selector): | |||||
| """Generate and view a PDF invoice. | |||||
| This requires Tempita 0.5. | |||||
| """ | |||||
| import tempita | |||||
| if selector: | |||||
| invoice = self.db.invoices[selector] | |||||
| else: | |||||
| invoice = self.db.invoices.last() | |||||
| issuer = self.db.companies[self.my_company] | |||||
| customer = self.db.companies[invoice.company_name] | |||||
| invoice_data = invoice.data() | |||||
| issuer_data = issuer.data() | |||||
| customer_data = customer.data() | |||||
| tmp_path = self.tmp_path.format(year=self.year) | |||||
| output_path = self.output_path.format(year=self.year) | |||||
| log.debug("tmp_path={}".format(tmp_path)) | |||||
| tex_template = os.path.join(self.template_path, "invoice.tex") | |||||
| tex_file = os.path.join(tmp_path, "{}.tex".format(invoice._name)) | |||||
| tmp_pdf_file = os.path.join(tmp_path, "{}.pdf".format(invoice._name)) | |||||
| pdf_file = os.path.join(output_path, "{}.pdf".format(invoice._name)) | |||||
| log.debug("Invoice: {}".format(invoice_data._data)) | |||||
| log.debug("Issuer: {}".format(issuer_data._data)) | |||||
| log.debug("Customer: {}".format(customer_data._data)) | |||||
| log.debug("Creating TeX invoice...") | |||||
| self._check_path(self.tmp_path) | |||||
| result = tempita.Template(open(tex_template).read()).substitute( | |||||
| invoice=invoice_data, issuer=issuer_data, customer=customer_data) | |||||
| open(tex_file, "w").write(str(result)) | |||||
| assert(os.path.exists(tex_file)) | |||||
| log.debug("Creating PDF invoice...") | |||||
| if subprocess.call((self.tex_program, "{}.tex".format(invoice._name)), cwd=tmp_path) != 0: | |||||
| raise GenerationError("PDF generation failed.") | |||||
| assert(os.path.exists(tmp_pdf_file)) | |||||
| log.debug("Moving PDF file to the output directory...") | |||||
| self._check_path(output_path) | |||||
| os.rename(tmp_pdf_file, pdf_file) | |||||
| assert(os.path.exists(pdf_file)) | |||||
| log.debug("Running PDF viewer...") | |||||
| subprocess.call((self.pdf_program, pdf_file)) | |||||
| def _check_path(self, path): | |||||
| if not os.path.exists(path): | |||||
| raise LookupError("Directory doesn't exist: {}".format(path)) | |||||
| def do_delete(self, selector, force): | |||||
| """List invoices.""" | |||||
| if selector: | |||||
| invoice = self.db.invoices[selector] | |||||
| else: | |||||
| invoice = self.db.invoices.last() | |||||
| if not force: | |||||
| raise SanityCheckError("It is not recommended to delete invoices.") | |||||
| invoice.delete() | |||||
| def do_list_companies(self): | |||||
| """List companies.""" | |||||
| for item in sorted(self.db.companies): | |||||
| print(item) | |||||
| def do_new_company(self, name): | |||||
| """Create and edit a new company.""" | |||||
| item = self.db.companies.new(name) | |||||
| self._edit(item._path) | |||||
| def do_edit_company(self, selector): | |||||
| """Edit company in external editor. | |||||
| The external editor is determined by EDITOR environment variable | |||||
| using 'vim' as the default. Item is edited in-place. | |||||
| """ | |||||
| item = self.db.companies[selector] | |||||
| self._edit(item._path) | |||||
| def do_show_company(self, selector): | |||||
| """View company in external editor. | |||||
| The external viewer is determined by PAGER environment variable | |||||
| using 'less' as the default. | |||||
| """ | |||||
| item = self.db.companies[selector] | |||||
| self._show(item._path) | |||||
| def _show(self, path): | |||||
| log.debug("Viewing file: {}".format(path)) | |||||
| assert os.path.exists(path) | |||||
| subprocess.call((self.viewer, path)) | |||||
| def do_delete_company(self, selector, force): | |||||
| """Delete a company.""" | |||||
| company = self.db.companies[selector] | |||||
| if not force: | |||||
| invoices = self.db.invoices.select({"company_name": company._name}) | |||||
| if invoices: | |||||
| for invoice in invoices: | |||||
| log.info("Dependent invoice: {}".format(invoice)) | |||||
| raise SanityCheckError("This company is used by some invoices. You should not delete it.") | |||||
| company.delete() | |||||
| @@ -0,0 +1,8 @@ | |||||
| from .base import DatabaseError | |||||
| class Database: | |||||
| def __init__(self, **config): | |||||
| from . import companies | |||||
| from . import invoices | |||||
| self.companies = companies.Companies(db=self, **config) | |||||
| self.invoices = invoices.Invoices(db=self, **config) | |||||
| @@ -0,0 +1,193 @@ | |||||
| #!/usr/bin/python3 | |||||
| import os, sys, re, time, datetime | |||||
| import logging | |||||
| log = logging.getLogger() | |||||
| class DatabaseError(Exception): | |||||
| pass | |||||
| class ItemNotFoundError(DatabaseError, LookupError): | |||||
| pass | |||||
| class ItemNameCheckError(DatabaseError, ValueError): | |||||
| pass | |||||
| class ItemExistsError(DatabaseError): | |||||
| pass | |||||
| class List: | |||||
| """Base class for database lists. | |||||
| This class provides a real-time view to a file-based database. It can be | |||||
| used as an iterable with a little bit of magic (like indexing by a | |||||
| dictionary of matching attributes). | |||||
| """ | |||||
| def __init__(self, year, data_path, db=None): | |||||
| self._year = year | |||||
| self._path = os.path.expanduser(data_path.format( | |||||
| year=year, directory=self._directory)) | |||||
| self._db = db | |||||
| log.debug("{}: {}".format(self.__class__.__name__, self._path)) | |||||
| def _item_class(self): | |||||
| """Returns class object used to instantiate items. | |||||
| Override in subclasses. | |||||
| """ | |||||
| return Item | |||||
| def _item_name(self): | |||||
| return self._item_class().__name__.lower() | |||||
| def __iter__(self): | |||||
| for name in os.listdir(self._path): | |||||
| match = self._regex.match(name) | |||||
| if match: | |||||
| yield self._item_class()(self, year=self._year, **match.groupdict()) | |||||
| def last(self): | |||||
| return max(iter(self), key=lambda item: item._name) | |||||
| def __contains__(self, selector): | |||||
| return bool(self._select(selector)) | |||||
| def __getitem__(self, selector): | |||||
| items = self._select(selector) | |||||
| assert(len(items) < 2) | |||||
| if not items: | |||||
| raise ItemNotFoundError("{} '{}' not found.".format(self._item_class().__name__, selector)) | |||||
| item = items[0] | |||||
| log.debug("Found matching item: {}".format(item)) | |||||
| return item | |||||
| def select(self, selector=None): | |||||
| return sorted(self._select(selector)) | |||||
| def _select(self, selector): | |||||
| """Return a list of items matching 'name', 'number' or other attributes.""" | |||||
| if selector == None: | |||||
| selector = {} | |||||
| if isinstance(selector, str): | |||||
| selector = {"name": selector} | |||||
| if isinstance(selector, int): | |||||
| selector = {"number": number} | |||||
| log.debug("Selecting: {}".format(selector)) | |||||
| assert isinstance(selector, dict) | |||||
| return [item for item in self if all(getattr(item, key) == selector[key] for key in selector)] | |||||
| def new(self, name): | |||||
| """Create a new item in this list. | |||||
| Keyword arguments: | |||||
| name -- filesystem name of the new item | |||||
| edit -- edit the item after creation | |||||
| Returns | |||||
| """ | |||||
| log.info("Creating {}: {}".format(self._item_name(), name)) | |||||
| if not self._regex.match(name): | |||||
| raise ItemNameCheckError("Name {} doesn't match {} regex.".format(name, self._item_name())) | |||||
| if name in self: | |||||
| raise ItemExistsError("Item {} of type {} already exists.".format(name, self._item_name())) | |||||
| self._new(os.path.join(self._path, name)) | |||||
| return self[name] | |||||
| def _new(self, path): | |||||
| log.debug("Creating {} file: {}".format(self._item_name(), path)) | |||||
| stream = os.fdopen(os.open(path, os.O_WRONLY|os.O_EXCL|os.O_CREAT, 0o644), "w") | |||||
| stream.write(self.data_template) | |||||
| stream.close() | |||||
| class Item: | |||||
| """Base class for database list items.""" | |||||
| def __init__(self, list_, **selector): | |||||
| self._list = list_ | |||||
| self._selector = selector | |||||
| self._postprocess() | |||||
| self._name = self._list._template.format(**selector) | |||||
| self._path = os.path.join(list_._path, self._name) | |||||
| log.debug("{!r}".format(self)) | |||||
| def _postprocess(self): | |||||
| """Postprocess the _selector attribute. | |||||
| Override in subclasses. | |||||
| """ | |||||
| def __lt__(self, other): | |||||
| return self._name < other._name | |||||
| def __repr__(self): | |||||
| return "{}({!r}, **{})".format(self.__class__.__name__, self._name, self._selector) | |||||
| def __str__(self): | |||||
| return self._name | |||||
| def __getattr__(self, key): | |||||
| return self._selector[key] | |||||
| def delete(self): | |||||
| log.info("Deleting: {}".format(self)) | |||||
| path = self._path | |||||
| newpath = path + "~" | |||||
| log.debug("Renaming file {} to {}.".format(path, newpath)) | |||||
| assert os.path.exists(path) | |||||
| os.rename(path, newpath) | |||||
| def data(self): | |||||
| """Return item's data. | |||||
| Override in subclasses. | |||||
| """ | |||||
| return self._data_class()(self) | |||||
| def _data_class(self): | |||||
| return Data | |||||
| class Data: | |||||
| """Base class for database list item data objects.""" | |||||
| _fields = [] | |||||
| _multivalue_fields = [] | |||||
| _line_regex = re.compile(r"^([A-Z][a-zA-Z-]*):\s+(.*?)\s+$") | |||||
| def __init__(self, item): | |||||
| self._item = item | |||||
| self._parse(open(self._item._path)) | |||||
| self._postprocess() | |||||
| def __getattr__(self, key): | |||||
| return self._data[key] | |||||
| def _parse(self, stream): | |||||
| self._data = self._item._selector.copy() | |||||
| for f in self._fields: | |||||
| self._data[f] = None | |||||
| for f in self._multivalue_fields: | |||||
| self._data[f] = [] | |||||
| for n, line in enumerate(stream): | |||||
| match = self._line_regex.match(line) | |||||
| if not match: | |||||
| log.warning("Ignoring {}:{}: {}".format(n, self._item._name, line)) | |||||
| continue | |||||
| key, value = match.groups() | |||||
| key = key.lower().replace("-", "_") | |||||
| if key in self._fields: | |||||
| self._data[key] = value | |||||
| elif key in self._multivalue_fields: | |||||
| self._data[key].append(value) | |||||
| else: | |||||
| log.warning("Key ignored: {}".format(key)) | |||||
| def _postprocess(self): | |||||
| """Postprocess item data. | |||||
| Override in subclasses. | |||||
| """ | |||||
| def rename_key(self, oldkey, newkey): | |||||
| """Convenience function mainly intended for subclasses.""" | |||||
| if not self.__dict__.get(newkey) and oldkey in self._data: | |||||
| self._data[newkey] = self._data[oldkey] | |||||
| del self._data[oldkey] | |||||
| @@ -0,0 +1,42 @@ | |||||
| #!/usr/bin/python3 | |||||
| import os, sys, re, time, datetime | |||||
| import logging | |||||
| log = logging.getLogger() | |||||
| from invoice.db.base import * | |||||
| class Companies(List): | |||||
| """Company list. | |||||
| When editing data files, you can use the following directives: | |||||
| Name -- full company name | |||||
| Address -- company address, repeat to get multiple lines | |||||
| Number -- identification number | |||||
| Comment -- additional information that you want to see on the invoice | |||||
| """ | |||||
| _directory = "companies" | |||||
| _regex = re.compile("^(?P<name>[a-z0-9-]+)$") | |||||
| _template = "{name}" | |||||
| _data_template = """\ | |||||
| Name: | |||||
| Address: | |||||
| Address: | |||||
| Number: | |||||
| """ | |||||
| def _item_class(self): | |||||
| return Company | |||||
| class Company(Item): | |||||
| def _data_class(self): | |||||
| return CompanyData | |||||
| class CompanyData(Data): | |||||
| _fields = ["name", "number", "ic", "bank_account"] | |||||
| _multivalue_fields = ["address", "comment"] | |||||
| def _postprocess(self): | |||||
| self.rename_key("ic", "number") | |||||
| self.rename_key("comment", "comments") | |||||
| @@ -0,0 +1,109 @@ | |||||
| #!/usr/bin/python3 | |||||
| import os, sys, re, time, datetime | |||||
| import logging | |||||
| log = logging.getLogger() | |||||
| from invoice.db.base import * | |||||
| class Invoices(List): | |||||
| """Company invoice. | |||||
| When editing data files, you can use the following | |||||
| directives: | |||||
| Item -- price, followed by ':' and description | |||||
| Due -- Due date YYYY-MM-DD | |||||
| Note -- a note at the and of the invoice | |||||
| """ | |||||
| data_template = """\ | |||||
| Item: 0000: Item summary | |||||
| """ | |||||
| _directory = "income" | |||||
| _regex = re.compile("^(?P<date>[0-9]{8})-(?P<number>[0-9]{3})-(?P<company_name>[a-z0-9-]+)$") | |||||
| _template = "{date}-{number:03}-{company_name}" | |||||
| def _item_class(self): | |||||
| return Invoice | |||||
| def _select(self, selector): | |||||
| if isinstance(selector, str): | |||||
| match = self._regex.match(selector) | |||||
| if not match: | |||||
| raise ItemNotFoundError("Item not found: {}".format(selector)) | |||||
| selector = match.groupdict() | |||||
| selector["number"] = int(selector["number"]) | |||||
| return super(Invoices, self)._select(selector) | |||||
| def new(self, company_name): | |||||
| if company_name not in self._db.companies: | |||||
| raise ItemNotFoundError("Company '{}' not found.".format(company_name)) | |||||
| try: | |||||
| number = max(item.number for item in self) + 1 | |||||
| except ValueError: | |||||
| number = 1 | |||||
| date = time.strftime("%Y%m%d") | |||||
| name = self._template.format(**vars()) | |||||
| return super(Invoices, self).new(name) | |||||
| class Invoice(Item): | |||||
| def _data_class(self): | |||||
| return InvoiceData | |||||
| def _postprocess(self): | |||||
| self._selector["number"] = int(self.number) | |||||
| class InvoiceData(Data): | |||||
| _fields = ["payment"] | |||||
| _multivalue_fields = ["item", "address", "note"] | |||||
| _date_regex = re.compile(r"^(\d{4})-?(\d{2})-?(\d{2})$") | |||||
| _item_regex = re.compile(r"^(\d+)[:;]\s*(.*)$") | |||||
| _number_template = "{year}{number:03}" | |||||
| def _parse_date(self, date): | |||||
| match = self._date_regex.match(self.date) | |||||
| if not match: | |||||
| raise ValueError("Bad date format: {}".format(date)) | |||||
| return datetime.date(*(int(f) for f in match.groups())) | |||||
| def _postprocess(self): | |||||
| log.debug(self._data) | |||||
| self._postprocess_number() | |||||
| self._postprocess_items() | |||||
| self._postprocess_dates() | |||||
| self.rename_key("note", "notes") | |||||
| def _postprocess_number(self): | |||||
| self._data["number"] = self._number_template.format( | |||||
| year = self.year, | |||||
| number = self.number) | |||||
| def _postprocess_items(self): | |||||
| items = [] | |||||
| for item in self.item: | |||||
| match = self._item_regex.match(item) | |||||
| if not match: | |||||
| raise ValueError("Bad item format: {}".format(item)) | |||||
| price, description = match.groups() | |||||
| items.append((description, int(price))) | |||||
| del self._data["item"] | |||||
| self._data["items"] = items | |||||
| self._data["sum"] = sum(item[1] for item in items) | |||||
| def _postprocess_dates(self): | |||||
| date = self._parse_date(self._item.date) | |||||
| if "due" in self._data: | |||||
| try: | |||||
| due = self._parse_date(self.due) | |||||
| except ValueError: | |||||
| try: | |||||
| due = datetime.timedelta(int(re.sub("^+", "", self.due))) | |||||
| except ValueError: | |||||
| raise ValueError("Bad due format: {}".format(self.due)) | |||||
| else: | |||||
| due = datetime.timedelta(14) | |||||
| if isinstance(due, datetime.timedelta): | |||||
| due += date | |||||
| self._data["date"] = date | |||||
| self._data["due"] = due | |||||
| @@ -0,0 +1,82 @@ | |||||
| \documentclass[10pt]{article} | |||||
| \usepackage[utf8]{inputenc} | |||||
| \usepackage[czech]{babel} | |||||
| \usepackage{a4wide} | |||||
| \usepackage{tabularx} | |||||
| \renewcommand{\familydefault}{\sfdefault} | |||||
| \setlength{\extrarowheight}{3pt} | |||||
| \begin{document} | |||||
| \footnotesize | |||||
| \begin{center} | |||||
| \begin{tabularx}{\textwidth}{|XXXX|} | |||||
| \cline{3-4} | |||||
| \multicolumn{2}{X}{} & \multicolumn{2}{|X|}{} \\ | |||||
| \multicolumn{2}{X}{} & \multicolumn{2}{|l|}{\large Faktura: \hfill {{invoice.number}}} \\ | |||||
| \multicolumn{2}{X}{} & \multicolumn{2}{|X|}{} \\ | |||||
| \hline | |||||
| & & & \\ | |||||
| \bf Dodavatel: & & \bf Odběratel & \\[1em] | |||||
| \multicolumn{2}{|l}{\large\bf {{issuer.name}}} & \multicolumn{2}{l|}{\large\bf {{customer.name}}} \\ | |||||
| {{py: | |||||
| a1 = issuer.address[:] | |||||
| a2 = customer.address[:] | |||||
| a1 += (len(a2)-len(a1))*[""] | |||||
| a2 += (len(a1)-len(a2))*[""] | |||||
| }} | |||||
| {{for f1, f2 in zip(a1, a2)}} | |||||
| \multicolumn{2}{|l}{\large {{f1}}} & \multicolumn{2}{l|}{\large {{f2}}} \\ | |||||
| {{endfor}} | |||||
| & & & \\ | |||||
| \multicolumn{2}{|l}{IČ: {{issuer.number}}} & \multicolumn{2}{l|}{IČ: {{customer.number}}} \\ | |||||
| & & & \\ | |||||
| {{py: | |||||
| a1 = issuer.comments[:] | |||||
| a2 = customer.comments[:] | |||||
| a1 += (len(a2)-len(a1))*[""] | |||||
| a2 += (len(a1)-len(a2))*[""] | |||||
| }} | |||||
| {{for f1, f2 in zip(a1, a2)}} | |||||
| \multicolumn{2}{|l}{ {{f1}}} & \multicolumn{2}{l|}{ {{f2}}} \\ | |||||
| {{endfor}} | |||||
| & & & \\ | |||||
| \hline | |||||
| & & & \\ | |||||
| \bf Platební podmínky: & & & \\[1em] | |||||
| \large Forma úhrady: & \large {{"hotově" if invoice.payment=="cash" else "převodem"}} & \large Datum vystavení: & \multicolumn{1}{r|}{\large {{invoice.date.strftime("%d.%m.%Y")}}} \\ | |||||
| \large Číslo účtu: & \large {{issuer.bank_account}} & \multicolumn{2}{l|}{\large\bf Datum splatnosti: \hfill {{invoice.due.strftime("%d.%m.%Y")}}} \\ | |||||
| \large Variabilní symbol: & \large {{invoice.number}} & & \\ | |||||
| {{if invoice.notes}} | |||||
| & & & \\ | |||||
| \hline | |||||
| & & & \\ | |||||
| \bf Poznámky: & & & \\[1em] | |||||
| {{for note in invoice.notes}} | |||||
| \multicolumn{4}{|l|}{\large {{note}}} \\ | |||||
| {{endfor}} | |||||
| {{endif}} | |||||
| & & & \\ | |||||
| \hline | |||||
| & & & \\ | |||||
| \bf Fakturujeme vám: & & & \\[1em] | |||||
| {{for item in invoice.items}} | |||||
| \multicolumn{4}{|l|}{\normalsize {{item[0]}} \hfill {{item[1]}} Kč} \\ | |||||
| {{endfor}} | |||||
| & & & \\ | |||||
| \hline | |||||
| & & & \\ | |||||
| \large\bf Celkem k úhradě: & & & \multicolumn{1}{r|}{\large\bf {{invoice.sum}} Kč} \\ | |||||
| & & & \\ | |||||
| \hline | |||||
| \end{tabularx} | |||||
| \end{center} | |||||
| \end{document} | |||||