def add_total(group): global group_subtotals_added if not group.get("groups") and group.get("tasks"): items.row( "", b( "{} {} - {}".format(_("Total"), group["code"], group["name"]), font), "", "", "", money(group["progress"]), ) items.row_style("FONTNAME", 0, -1, font.bold) items.row_style("ALIGNMENT", -1, -1, "RIGHT") items.row_style("SPAN", 1, 4) items.row_style("VALIGN", 0, -1, "BOTTOM") items.row("") totals.row( b( "{} {} - {}".format(_("Total"), group["code"], group["name"]), font), money(group["progress"]), ) group_subtotals_added = True
def add_task(task): items.row(p(task["code"], font), p(task["name"], font)) items.row_style("SPAN", 1, -2) if task["qty"] is not None: items.row( "", "", ubrdecimal(task["complete"]), p(task["unit"], font), money(task["price"]), money(task["progress"]), ) items.row_style("ALIGNMENT", 1, -1, "RIGHT") items.keep_previous_n_rows_together(2) else: items.row("", p(task["description"], font)) items.row_style("SPAN", 1, -1) for li in task["lineitems"]: items.row( "", p(li["name"], font), ubrdecimal(li["qty"]), p(li["unit"], font), money(li["price"]), money(li["estimate"]), ) items.row_style("ALIGNMENT", 2, -1, "RIGHT")
def get_subtotal_context(self, group, **kwargs): if "total" not in kwargs: if isinstance(group["progress"], Amount): kwargs["total"] = money(group["progress"].net) else: kwargs["total"] = money(group["progress"]) return super().get_subtotal_context(group, **kwargs)
def get_payments(self): table = create_invoice_table(self.invoice)[1:] for idx, row in enumerate(table): txn = row[4] if row[0] == "progress": title = _("Project progress") elif row[0] == "payment": pay_day = parse_date(txn["date"]) title = _("Payment on {date}").format(date=pay_day) elif row[0] == "discount": title = _("Discount applied") elif row[0] == "unpaid": title = _("Open claim from prior invoices") elif row[0] == "debit": title = _("This Invoice") else: raise NotImplementedError() yield "normal", title, money(row[1]), money(row[2]), money(row[3]) if self.payment_details and row[0] == "payment": for job in txn["jobs"].values(): yield "small", job["name"], money( job["payment_applied_net"]), money( job["payment_applied_tax"]), money( job["payment_applied_gross"]) if self.payment_details and row[0] == "discount": for job in txn["jobs"].values(): yield "small", job["name"], money( job["discount_applied_net"]), money( job["discount_applied_tax"]), money( job["discount_applied_gross"])
def get_task_context(self, task, **kwargs): if task["qty"] is None: return super().get_task_context(task, qty=None, total=money(task["progress"]), show_description=True, **kwargs) else: return super().get_task_context(task, qty=ubrdecimal(task["complete"]), total=money(task["progress"]), show_description=False, **kwargs)
def collate_payments(invoice, available_width): t = TableFormatter([0, 1, 1, 1], available_width, debug=DEBUG_DOCUMENT) t.style.append(("LEFTPADDING", (0, 0), (0, -1), 0)) t.style.append(("RIGHTPADDING", (-1, 0), (-1, -1), 0)) t.style.append(("BOTTOMPADDING", (0, 1), (-1, -1), 3 * mm)) t.style.append(("ALIGNMENT", (0, 0), (0, -1), "LEFT")) t.style.append(("ALIGNMENT", (1, 0), (-1, -1), "RIGHT")) t.style.append(("VALIGN", (0, 0), (-1, -1), "BOTTOM")) t.style.append(("LINEBELOW", (0, 0), (-1, 0), 0.25, colors.black)) t.style.append(("LINEAFTER", (0, 0), (-2, -1), 0.25, colors.black)) t.row("", _("gross pay"), _("consideration"), _("VAT")) t.row_style("FONTNAME", 0, -1, font.bold) if len(invoice["transactions"]) > 0: t.row( _("Job Performance"), money(invoice["total_gross"]), money(invoice["total_base"]), money(invoice["total_tax"]), ) billable_amount = invoice["total_gross"] for payment in invoice["transactions"]: row = [ "", money(payment["amount"]), money(payment["amount_base"]), money(payment["amount_tax"]), ] if payment["type"] == "payment": received_on = date_format(payment["transacted_on"], use_l10n=True) row[0] = Paragraph( _("Your Payment on") + " " + received_on, fonts["OpenSans"]["Normal"]) elif payment["type"] == "discount": row[0] = _("Discount Applied") billable_amount += payment["amount"] t.row(*row) t.row( _("Billable Total"), money(billable_amount), money(billable_amount / (TAX_RATE + Decimal("1"))), money(billable_amount / (TAX_RATE + Decimal("1")) * TAX_RATE), ) t.row_style("FONTNAME", 0, -1, font.bold) return t.get_table(ContinuationTable, repeatRows=1)
def get_lineitem_context(self, lineitem, **kwargs): return super().get_lineitem_context( lineitem, qty=ubrdecimal(lineitem["qty"]), total=money(lineitem["estimate"]), **kwargs )
def get_lineitem_context(self, lineitem, **kwargs): # lineitem.get("new_key", lineitem["old_key"]) is a workaround to support old JSON and fixed JSON return super().get_lineitem_context( lineitem, qty=ubrdecimal(lineitem.get("expended", lineitem["qty"])), total=money(lineitem.get("progress", lineitem["estimate"])), **kwargs)
def get_subtotal_context(self, group, **kwargs): total = group["estimate"] if isinstance(group["estimate"], Amount): total = group["estimate"].net return super().get_subtotal_context(group, total=money(total), **kwargs)
def subtotal_job(self, job): if job["invoiced"].net == job["progress"].net: yield from super().subtotal_job(job) else: yield self.render.subtotal_html, self.get_subtotal_context( job, total=money(job["invoiced"].net), offset=False )
def _show_table(self): from systori.lib.templatetags.customformatting import money for entry in self._entries: acct, value = entry[1].account, entry[1].value try: acct_name = acct.code + " " + acct.job.name except ObjectDoesNotExist: acct_name = acct.code + " " + acct.name if entry[0] == "debit": print(" {:<25} {:>10} {:>10}".format(acct_name, money(value), "")) else: print(" {:<25} {:>10} {:>10}".format(acct_name, "", money(value))) print( " {:>25} {:>10} {:>10}".format( "total:", money(self._total("debit")), money(self._total("credit")) ) )
def get_lineitem_context(self, lineitem, **kwargs): kwargs.update( { "name": lineitem["name"], "unit": lineitem["unit"], "price": money(lineitem["price"]), } ) return kwargs
def get_task_context(self, task, **kwargs): total = money(task["estimate"]) if task["is_provisional"]: total = _("Optional") elif task.get("variant_group") and task["variant_serial"] != 0: total = _("Alternative") return super().get_task_context( task, qty=ubrdecimal(task["qty"]), total=total, show_description=not self.render.only_task_names, **kwargs)
def iterate_tasks(self, group): for task in group.get("tasks", []): task_context = self.get_task_context(task) yield self.render.group_html, task_context if task_context["qty"] is not None: yield self.render.lineitem_html, { "name": "", "qty": task_context["qty"], "unit": task["unit"], "price": money(task["price"]), "total": task_context["total"], } else: for lineitem in task["lineitems"]: yield self.render.lineitem_html, self.get_lineitem_context(lineitem)
def iterate_job(self, job): if job["invoiced"].net == job["progress"].net: yield from super().iterate_job(job) else: yield self.render.group_html, { "code": job["code"], "name": job["name"], "bold_name": True, "description": job["description"], "show_description": True, } debits = self.document["job_debits"].get(str(job["job.id"]), []) last_debit = None total = Decimal() for debit in debits: last_debit = debit total += debit["amount"].net entry_date = parse_date(debit["date"]) if debit["entry_type"] == Entry.WORK_DEBIT: title = _("Work completed on {date}").format( date=entry_date) elif debit["entry_type"] == Entry.FLAT_DEBIT: title = _("Flat invoice on {date}").format(date=entry_date) else: # adjustment, etc title = _("Adjustment on {date}").format(date=entry_date) # yield self.render.debit_html, { # 'title': title, # 'total': money(debit['amount'].net) # } if last_debit: entry_date = parse_date(last_debit["date"]) yield self.render.debit_html, { "title": _("Performance until {date}").format(date=entry_date), "total": money(total), }
def migrate_accounts(company): from systori.apps.project.models import Project from systori.apps.accounting.models import ( Account, Transaction, Entry, create_account_for_job, ) from systori.apps.accounting.constants import TAX_RATE, SKR03_INCOME_CODE from systori.apps.accounting.report import get_transactions_for_jobs from systori.apps.accounting import workflow from systori.apps.task.models import Job from systori.apps.document.models import Invoice from systori.lib.templatetags.customformatting import money for project in Project.objects.without_template(): project.account.code = int(project.account.code) + 1000 project.account.save() for job in Job.objects.all(): job.account = create_account_for_job(job) job.save() for project in Project.objects.without_template().order_by("id"): if not project.invoices.exists(): if project.account.entries.exists(): if SHOW_SKIPPED: print("Project #{} - {}".format(project.id, project.name)) print(" !!! Has entries but no invoices. !!!") print("") else: if SHOW_SKIPPED: print("Project #{} - {}".format(project.id, project.name)) print(" !!! No invoices and no entries. !!!") print("") continue no_json = False for invoice in project.invoices.all(): if not invoice.json: no_json = True if SHOW_SKIPPED: print("Project #{} - {}".format(project.id, project.name)) print(" !!! Old invoices, no json. !!!") print("") break if no_json: for invoice in project.invoices.all(): if not invoice.json: invoice.json = { "debit_net": 0, "debit_tax": 0, "debit_gross": 0 } invoice.save() continue print("\nProject #{} - {}".format(project.id, project.name)) final_debits = [] parent_invoice = None total_invoices = project.invoices.count() for i_invoice, invoice in enumerate(project.invoices.all()): if i_invoice < total_invoices - 1: invoice.status = invoice.SENT else: invoice.status = invoice.DRAFT if not parent_invoice: parent_invoice = invoice else: invoice.parent = parent_invoice print("\n Invoice #{} - {} - {}".format( invoice.id, invoice.invoice_no, invoice.document_date.isoformat())) invoice_amount = Decimal(0.0) pre_tax_invoice = Decimal(0.0) if invoice.id == 47: pass match_criteria = [ { "recorded_on__startswith": invoice.created_on.isoformat(" ")[:19] }, { "recorded_on__startswith": invoice.created_on.isoformat(" ")[:18] }, # {'recorded_on__gte': invoice.created_on-timedelta(minutes=2), # 'recorded_on__lte': invoice.created_on+timedelta(minutes=2)}, ] print(" Finding corresponding transaction...") print(" >{}".format(invoice.created_on.isoformat(" "))) old_transaction = None for criteria in match_criteria: for key, val in criteria.items(): print(" ?{} :{}".format(val, key)) matches = (Transaction.objects.filter( entries__account=project.account).filter( **criteria).distinct()) if matches.count() == 1: old_transaction = matches.get() print(" !{}".format(old_transaction.recorded_on)) break elif matches.count() == 0: print(" No matches.") elif matches.count() > 1: print(" Multiple matches:") for match in matches: print(" {}".format(match.recorded_on)) invoice.json["is_final"] = False if old_transaction: for entry in old_transaction.entries.all(): if entry.account.code == SKR03_INCOME_CODE: invoice.json["is_final"] = True break invoice.json["debits"] = [] debits = [] for json_job in invoice.json.pop("jobs"): job = project.jobs.get(name=json_job["name"], job_code=int(json_job["code"])) json_job["job.id"] = job.id json_job["is_invoiced"] = True json_job["flat_amount"] = 0.0 json_job["is_flat"] = False pre_tax_amount = Decimal(0.0) for json_taskgroup in json_job["taskgroups"]: pre_tax_amount += Decimal(json_taskgroup["total"]) amount = round(pre_tax_amount * Decimal(1.19), 2) already_debited = job.account.debits().total amount -= already_debited if amount < Decimal(0.0): amount = Decimal(0.0) invoice_amount += amount pre_tax_invoice += round(amount / (1 + TAX_RATE), 2) json_job["debit_amount"] = amount json_job["debit_net"] = round(amount / (1 + TAX_RATE), 2) json_job["debit_tax"] = amount - json_job["debit_net"] json_job["debit_comment"] = "" json_job["debited"] = already_debited json_job["balance"] = 0.0 # we don't have payments yet json_job["estimate"] = round( job.estimate_total * (1 + TAX_RATE), 2) json_job["itemized"] = round( job.billable_total * (1 + TAX_RATE), 2) invoice.json["debits"].append(json_job) if amount > Decimal(0.0): print(" {:<50} {:>15} {:>15}".format( job.name, money(amount), money(pre_tax_amount))) if invoice.json["is_final"] or amount > Decimal(0.0): debits.append((job, amount, False)) print(" {:<50} {:>15} {:>15}".format("", "-" * 10, "-" * 10)) print(" {:<50} {:>15} {:>15}".format("", money(invoice_amount), money(pre_tax_invoice))) if round(invoice.amount, 2) != round(invoice_amount, 2): # i've manually checked these invoices - lex # for Demo project we just do the fix without checking if (invoice.id in [ 31, 37, 38, 46, 47, 51, 54, 55, 56, 60, 62, 63, 67, 69, 70, 71 ] or company.name == "Demo"): # 37, 46, 54, 55 - rounding errors, off by one penny # 56, 60, 63, 69, 71 - all these had the 'balance' remaining instead of how much was actually debited # 31, 38 - debit was correct but invoice had wrong amount, not sure why # 67, 70 - amounts slightly off # 51 - not even sure what happened here but i think the new invoice_amount is correct # 62 - off by $6, probably work completed was reduced since last invoice/payment invoice.amount = invoice_amount else: raise ArithmeticError("{} != {}".format( money(round(invoice.amount, 2)), money(round(invoice_amount, 2)), )) if invoice.json["is_final"]: final_debits.append((invoice, debits)) else: invoice.transaction = workflow.partial_debit( debits, invoice.document_date) invoice.json["version"] = "1.2" invoice.json["id"] = invoice.id invoice.json["title"] = invoice.json.get("title", "") invoice.json["debit_gross"] = invoice_amount invoice.json["debit_net"] = pre_tax_invoice invoice.json["debit_tax"] = invoice_amount - pre_tax_invoice invoice.json["debited_gross"] = invoice.json.pop("total_gross") invoice.json["debited_net"] = invoice.json.pop("total_base") invoice.json["debited_tax"] = invoice.json.pop("total_tax") invoice.json["balance_net"] = invoice.json.pop("balance_base") invoice.save() for payment in project.account.payments().all(): transaction = payment.transaction entries = transaction.entries.all() print("\n Converting Payment Transaction #{} (entries: {}) - {}". format(transaction.id, len(entries), money(abs(payment.amount)))) bank_entry, project_entry, promised_entry, partial_entry, tax_entry, discount_entry, discount_promised_entry, cash_discount_entry = ( (None, ) * 8) if len(entries) == 2: bank_entry = entries[0] project_entry = entries[1] elif len(entries) == 5: if entries[0].account.account_type == Account.ASSET: if (entries[3].account.code == "8736" ): # payment + discount on final invoice bank_entry = entries[0] project_entry = entries[1] discount_entry = entries[2] cash_discount_entry = entries[3] tax_entry = entries[4] else: bank_entry = entries[0] project_entry = entries[1] promised_entry = entries[2] partial_entry = entries[3] tax_entry = entries[4] else: # old style promised_entry = entries[0] partial_entry = entries[1] tax_entry = entries[2] bank_entry = entries[3] project_entry = entries[4] elif len(entries) == 7: if entries[0].account.account_type == Account.ASSET: bank_entry = entries[0] project_entry = entries[1] promised_entry = entries[2] partial_entry = entries[3] tax_entry = entries[4] discount_entry = entries[5] discount_promised_entry = entries[6] else: # old style promised_entry = entries[0] partial_entry = entries[1] tax_entry = entries[2] bank_entry = entries[3] project_entry = entries[4] discount_promised_entry = entries[5] discount_entry = entries[6] else: raise NotImplementedError( "Dono what to do with this many entries...") assert bank_entry.account.account_type == Account.ASSET assert project_entry.account.id == project.account.id assert promised_entry is None or promised_entry.account.code == "1710" assert partial_entry is None or partial_entry.account.code == "1718" assert (cash_discount_entry is None or cash_discount_entry.account.code == "8736") assert tax_entry is None or tax_entry.account.code == "1776" assert (discount_entry is None or discount_entry.account.id == project.account.id) assert (discount_promised_entry is None or discount_promised_entry.account.code == "1710") if transaction.id == 121: # this payment is supposed to be a refund, or we're not sure # will need to be fixed later continue if transaction.id == 121: pass transaction.id = None # start new transaction from previous transaction.transaction_type = transaction.PAYMENT transaction.debit(bank_entry.account, bank_entry.amount) # lets try paying previous invoice invoice = (Invoice.objects.filter( project=project, document_date__lt=transaction.transacted_on).order_by( "-document_date").first()) if invoice and invoice.amount >= ( bank_entry.amount + (-discount_entry.amount if discount_entry else 0) - Decimal(.01)): print( "\n Applying payment to previous invoice #{} - {} - {} - {}..." .format( invoice.id, invoice.invoice_no, invoice.document_date, money(invoice.amount), )) remaining_payment_amount = payment_amount = Decimal( abs(bank_entry.amount)) remaining_discount_amount = discount_amount = Decimal(0.0) discount_percent = 0.0 if discount_entry: remaining_discount_amount = discount_amount = Decimal( abs(discount_entry.amount)) discount_percent = round( remaining_discount_amount / (remaining_payment_amount + remaining_discount_amount), 3, ) print("\n Discount: {} ({}%)\n".format( money(discount_amount), round(discount_percent * 100, 1))) job_credits_sum = Decimal(0.0) non_zero_debits = [ debit for debit in invoice.json["debits"] if debit["debit_amount"] > 0.0 ] last_debit_idx = len(non_zero_debits) - 1 for debit_idx, debit in enumerate(non_zero_debits): job = Job.objects.get(id=debit["job.id"]) debit_amount = Decimal(debit["debit_amount"]) if (job.account.balance >= (remaining_payment_amount + remaining_discount_amount) <= debit_amount or last_debit_idx == debit_idx): job_credit = remaining_payment_amount job_discount = remaining_discount_amount else: if (debit_amount > job.account.balance < (remaining_payment_amount + remaining_discount_amount)): job_credit = job.account.balance else: job_credit = debit_amount if discount_entry: job_discount = round(job_credit * discount_percent, 2) job_credit -= job_discount assert (job_credit + job_discount - Decimal("0.01")) <= job.account.balance job_income = round(job_credit / (1 + TAX_RATE), 2) job_credits_sum += job_credit transaction.credit(job.account, job_credit, entry_type=Entry.PAYMENT, job=job) transaction.debit(Account.objects.get(code="1710"), job_credit, job=job) transaction.credit(Account.objects.get(code="1718"), job_income, job=job) transaction.credit( Account.objects.get(code="1776"), round(job_credit - job_income, 2), job=job, ) if discount_entry: transaction.credit( job.account, job_discount, entry_type=Entry.DISCOUNT, job=job, ) transaction.debit(Account.objects.get(code="1710"), job_discount, job=job) remaining_payment_amount -= job_credit remaining_discount_amount -= job_discount if not remaining_payment_amount: break else: # paying previous invoice didn't work, so lets go back to splitting the payment project_balance = project.balance sorted_jobs = [( round(job.account.balance / project_balance, 3), job.account.balance, job, ) for job in project.jobs.all() if job.account.balance > Decimal(0.0)] sorted_jobs.sort() print("\n Splitting payment into job accounts...") percent_total = Decimal(0.0) for job in sorted_jobs: percent_total += job[0] print(" {:>5}% {}".format(round(job[0] * 100, 1), job[2].name)) print(" {:>5}".format("-" * 6)) print(" {:>5}%\n".format(round(percent_total * 100, 1))) remaining_payment_amount = payment_amount = Decimal( abs(project_entry.amount)) remaining_discount_amount = discount_amount = Decimal(0.0) discount_percent = 0.0 if discount_entry: remaining_discount_amount = discount_amount = Decimal( abs(discount_entry.amount)) discount_percent = round( remaining_discount_amount / (remaining_payment_amount + remaining_discount_amount), 3, ) print("\n Discount: {} ({}%)\n".format( money(discount_amount), round(discount_percent * 100, 1))) job_credits_sum = Decimal(0.0) last_job_idx = len(sorted_jobs) - 1 for idx, (job_percent, job_balance, job) in enumerate(sorted_jobs): # TODO: Add support final payments. if idx == last_job_idx: # use whatever is left on last job assert job_balance >= (remaining_payment_amount + remaining_discount_amount) job_credit = remaining_payment_amount job_discount = remaining_discount_amount else: job_credit = round(payment_amount * job_percent, 2) job_discount = round(discount_amount * job_percent, 2) assert (round( 1 - job_credit / (job_credit + job_discount), 3) == discount_percent) job_income = round(job_credit / (1 + TAX_RATE), 2) job_credits_sum += job_credit transaction.credit(job.account, job_credit, entry_type=Entry.PAYMENT, job=job) transaction.debit(Account.objects.get(code="1710"), job_credit, job=job) transaction.credit(Account.objects.get(code="1718"), job_income, job=job) transaction.credit( Account.objects.get(code="1776"), round(job_credit - job_income, 2), job=job, ) if discount_entry: transaction.credit( job.account, job_discount, entry_type=Entry.DISCOUNT, job=job, ) transaction.debit(Account.objects.get(code="1710"), job_discount, job=job) remaining_payment_amount -= job_credit remaining_discount_amount -= job_discount print("\n {:<70} {:>15} {:>15}".format( "New transaction entries...", "debits", "credits")) for entry in transaction._entries: entry_title = entry[1].account.code + " " if entry[1].entry_type in [Entry.PAYMENT, Entry.DISCOUNT]: entry_title += entry[1].account.job.name else: entry_title += entry[1].account.name if entry[1].is_debit(): print(" {:<70} {:>15} {:>15}".format( entry_title, money(abs(entry[1].amount)), "")) else: print(" {:<70} {:>15} {:>15}".format( entry_title, "", money(abs(entry[1].amount)))) print(" {:<70} {:>15} {:>15}".format("", "-" * 10, "-" * 10)) print(" {:<70} {:>15} {:>15}".format( "", money(transaction._total("debit")), money(transaction._total("credit")), )) # lets make sure we exactly used up the entire amount assert remaining_payment_amount == 0.0 assert remaining_discount_amount == 0.0 # other checks... assert transaction._total( "debit") == payment_amount * 2 + discount_amount assert job_credits_sum == payment_amount # save() also checks that all debits == all credits transaction.save() for invoice, debits in final_debits: invoice.transaction = workflow.final_debit(debits, invoice.document_date) invoice.save() # Now that we have invoices and payments migrated to the new system we can # generate the new transaction history tables... print("\n\n Calculating transaction histories....") project = Project.objects.get(id=project.id) for invoice in project.invoices.all(): jobs = Job.objects.filter( id__in=[debit["job.id"] for debit in invoice.json["debits"]]) invoice.json["transactions"] = get_transactions_for_jobs( jobs, invoice.document_date) invoice.save() for project in Project.objects.without_template(): account = project.account project.account = None project.save() account.delete()
def collate_itemized_listing(invoice, font, available_width): # Itemized Listing Table items = TableFormatter([1, 0, 1, 1, 1, 1], available_width, font, debug=DEBUG_DOCUMENT) items.style.append(("LEFTPADDING", (0, 0), (-1, -1), 0)) items.style.append(("RIGHTPADDING", (-1, 0), (-1, -1), 0)) items.style.append(("VALIGN", (0, 0), (-1, -1), "TOP")) items.style.append(("LINEABOVE", (0, "splitfirst"), (-1, "splitfirst"), 0.25, colors.black)) items.row(_("Pos."), _("Description"), _("Amount"), "", _("Price"), _("Total")) items.row_style("ALIGNMENT", 2, -1, "RIGHT") # Totals Table totals = TableFormatter([0, 1], available_width, font, debug=DEBUG_DOCUMENT) totals.style.append(("RIGHTPADDING", (-1, 0), (-1, -1), 0)) totals.style.append(("LEFTPADDING", (0, 0), (0, -1), 0)) totals.style.append(("FONTNAME", (0, 0), (-1, -1), font.bold.fontName)) totals.style.append(("ALIGNMENT", (0, 0), (-1, -1), "RIGHT")) if DEBUG_DOCUMENT: items.style.append(("GRID", (0, 0), (-1, -1), 0.5, colors.grey)) totals.style.append(("GRID", (0, 0), (-1, -1), 0.5, colors.grey)) group_subtotals_added = False def add_total(group): global group_subtotals_added if not group.get("groups") and group.get("tasks"): items.row( "", b( "{} {} - {}".format(_("Total"), group["code"], group["name"]), font), "", "", "", money(group["progress"]), ) items.row_style("FONTNAME", 0, -1, font.bold) items.row_style("ALIGNMENT", -1, -1, "RIGHT") items.row_style("SPAN", 1, 4) items.row_style("VALIGN", 0, -1, "BOTTOM") items.row("") totals.row( b( "{} {} - {}".format(_("Total"), group["code"], group["name"]), font), money(group["progress"]), ) group_subtotals_added = True def add_task(task): items.row(p(task["code"], font), p(task["name"], font)) items.row_style("SPAN", 1, -2) if task["qty"] is not None: items.row( "", "", ubrdecimal(task["complete"]), p(task["unit"], font), money(task["price"]), money(task["progress"]), ) items.row_style("ALIGNMENT", 1, -1, "RIGHT") items.keep_previous_n_rows_together(2) else: items.row("", p(task["description"], font)) items.row_style("SPAN", 1, -1) for li in task["lineitems"]: items.row( "", p(li["name"], font), ubrdecimal(li["qty"]), p(li["unit"], font), money(li["price"]), money(li["estimate"]), ) items.row_style("ALIGNMENT", 2, -1, "RIGHT") def traverse(parent, depth): items.row(b(parent["code"], font), b(parent["name"], font)) items.row_style("SPAN", 1, -1) items.keep_next_n_rows_together(2) for group in parent.get("groups", []): traverse(group, depth + 1) add_total(group) for task in parent.get("tasks", []): add_task(task) add_total(parent) for job in invoice["jobs"]: items.row(b(job["code"], font), b(job["name"], font)) items.row_style("SPAN", 1, -1) if job["invoiced"].net == job["progress"].net: for group in job.get("groups", []): traverse(group, 1) for task in job.get("tasks", []): add_task(task) else: debits = invoice["job_debits"].get(str(job["job.id"]), []) for debit in debits: entry_date = date_format( date(*map(int, debit["date"].split("-"))), use_l10n=True) if debit["entry_type"] == Entry.WORK_DEBIT: title = _("Work completed on {date}").format( date=entry_date) elif debit["entry_type"] == Entry.FLAT_DEBIT: title = _("Flat invoice on {date}").format(date=entry_date) else: # adjustment, etc title = _("Adjustment on {date}").format(date=entry_date) items.row("", title, "", "", "", money(debit["amount"].net)) items.row_style("SPAN", 1, -2) items.row_style("ALIGNMENT", -1, -1, "RIGHT") if not group_subtotals_added: # taskgroup subtotals are added if there is only 1 job *and* it is itemized # in all other cases we're going to show the job total totals.row( b("{} {} - {}".format(_("Total"), job["code"], job["name"]), font), money(job["invoiced"].net), ) totals.row(_("Total without VAT"), money(invoice["invoiced"].net)) totals.row_style("LINEABOVE", 0, 1, 0.25, colors.black) return [ items.get_table(ContinuationTable, repeatRows=1), Spacer(0, 4 * mm), totals.get_table(), ]