From 9dce3efcd49f529012a22fe996e7cc4726c61652 Mon Sep 17 00:00:00 2001 From: Sung Cho Date: Sat, 18 Oct 2025 21:08:41 -0700 Subject: [PATCH] Squashed commit of the following: commit 989319906069c1cecf7ed5bd8cee454d87ebcd3f Author: Sung Cho Date: Sat Oct 18 20:59:51 2025 -0700 download spreadsheet commit b4b833a9a0e8f04daf2896f131f4ea7398702612 Author: Sung Cho Date: Sat Oct 18 20:53:50 2025 -0700 get spreadsheet with formulas --- pdm.lock | 27 +++++++++++- pyproject.toml | 2 +- src/bill/calculator.py | 68 +++++++++++++++++++++++++++++- src/ui/payments.py | 10 ++--- tests/test_calculator.py | 89 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 188 insertions(+), 8 deletions(-) diff --git a/pdm.lock b/pdm.lock index b118ff6..76a314b 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "dev"] strategy = ["inherit_metadata"] lock_version = "4.5.0" -content_hash = "sha256:6b12ac78919e92f706bebe27056a2293aa6c1c1f177b043a03e95e9fbac8380e" +content_hash = "sha256:3020ed60c55c5a4259c6c4cb154ab9b3c27eabe6e7320938a5e894f5ea1e216e" [[metadata.targets]] requires_python = "==3.12.*" @@ -279,6 +279,17 @@ files = [ {file = "distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed"}, ] +[[package]] +name = "et-xmlfile" +version = "2.0.0" +requires_python = ">=3.8" +summary = "An implementation of lxml.xmlfile for the standard library" +groups = ["default"] +files = [ + {file = "et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa"}, + {file = "et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54"}, +] + [[package]] name = "executing" version = "2.2.0" @@ -645,6 +656,20 @@ files = [ {file = "openai-1.99.6.tar.gz", hash = "sha256:f48f4239b938ef187062f3d5199a05b69711d8b600b9a9b6a3853cd271799183"}, ] +[[package]] +name = "openpyxl" +version = "3.1.5" +requires_python = ">=3.8" +summary = "A Python library to read/write Excel 2010 xlsx/xlsm files" +groups = ["default"] +dependencies = [ + "et-xmlfile", +] +files = [ + {file = "openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2"}, + {file = "openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050"}, +] + [[package]] name = "packaging" version = "25.0" diff --git a/pyproject.toml b/pyproject.toml index 45add0e..8390f0b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ description = "Split Bill Web Site" authors = [ {name = "Sung Cho", email = "sung.w.cho@protonmail.com"}, ] -dependencies = ["flask", "pillow", "openai", "pydantic", "click"] +dependencies = ["flask", "pillow", "openai", "pydantic", "click", "openpyxl"] requires-python = "==3.12.*" readme = "README.md" license = {text = "MIT"} diff --git a/src/bill/calculator.py b/src/bill/calculator.py index d954fbb..16e693c 100644 --- a/src/bill/calculator.py +++ b/src/bill/calculator.py @@ -1,8 +1,9 @@ import csv -from io import StringIO +from io import StringIO, BytesIO from typing import Iterable from bill.person import Person from bill.receipts import Items, Item +from openpyxl import Workbook class Calculator: @@ -195,3 +196,68 @@ def get_person_shares(get_share): output.seek(0) return output.getvalue() + + def get_shares_spreadsheet(self): + """ + Get an Excel spreadsheet (BytesIO) with formulas for calculating shares. + """ + workbook = Workbook() + worksheet = workbook.active + worksheet.title = "Bill Split" + + fieldnames = ( + ["Item", "Receipt"] + [person.name for person in self.persons] + ["Check"] + ) + + worksheet.append(fieldnames) + current_row = 2 + + for item in self.items.items: + row_data = [item.name, item.price] + for person in self.persons: + share = self.get_person_share(item, person) + row_data.append(share if share else None) + row_data.append(None) + worksheet.append(row_data) + current_row += 1 + + subtotal_row = current_row + subtotal = self.items.get_sum() + person_count = len(self.persons) + last_person_col = chr(ord("C") + person_count - 1) + + row_data = ["Subtotal", subtotal] + for col_idx in range(person_count): + col_letter = chr(ord("C") + col_idx) + formula = f"=SUM({col_letter}2:{col_letter}{current_row - 1})" + row_data.append(formula) + row_data.append(f"=SUM(C{current_row}:{last_person_col}{current_row})") + worksheet.append(row_data) + current_row += 1 + + for extra in self.extras.items: + row_data = [extra.name, extra.price] + for col_idx in range(person_count): + col_letter = chr(ord("C") + col_idx) + formula = ( + f"=({col_letter}{subtotal_row}/$B${subtotal_row})*$B{current_row}" + ) + row_data.append(formula) + row_data.append(f"=SUM(C{current_row}:{last_person_col}{current_row})") + worksheet.append(row_data) + current_row += 1 + + total_row = current_row + row_data = ["Total"] + row_data.append(f"=SUM(B{subtotal_row}:B{current_row - 1})") + for col_idx in range(person_count): + col_letter = chr(ord("C") + col_idx) + formula = f"=SUM({col_letter}{subtotal_row}:{col_letter}{current_row - 1})" + row_data.append(formula) + row_data.append(f"=SUM(C{total_row}:{last_person_col}{total_row})") + worksheet.append(row_data) + + output = BytesIO() + workbook.save(output) + output.seek(0) + return output diff --git a/src/ui/payments.py b/src/ui/payments.py index c3f5210..dbea842 100644 --- a/src/ui/payments.py +++ b/src/ui/payments.py @@ -72,7 +72,7 @@ def payments_page_view(): @payments_page.route("/payments/download", methods=["GET"]) -def download_csv(): +def download_spreadsheet(): items = get_current_items(session) extras = get_current_extras(session) persons = get_current_persons(session) @@ -80,13 +80,13 @@ def download_csv(): calculator = Calculator(persons=persons, items=items, extras=extras) timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") - filename = f"{timestamp}.csv" + filename = f"{timestamp}.xlsx" - csv_content = calculator.get_shares_csv() + spreadsheet_bytes = calculator.get_shares_spreadsheet() return Response( - csv_content, - mimetype="text/csv", + spreadsheet_bytes.getvalue(), + mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", headers={"Content-Disposition": f"attachment; filename={filename}"}, ) diff --git a/tests/test_calculator.py b/tests/test_calculator.py index aa97c10..d2071f4 100644 --- a/tests/test_calculator.py +++ b/tests/test_calculator.py @@ -3,6 +3,7 @@ from bill.person import Person from bill.receipts import Item, Items from tests.utils import EXPECTED_ITEMS +from openpyxl import load_workbook SERVICE_CHARGE = "Service Charge" TAX = "Tax" @@ -225,3 +226,91 @@ def test_person_update_item(): person.update_item(5) assert person.items == [1, 7, 9], "Item 5 should be removed again" + + +def test_spreadsheet(calculator, sample_persons): + spreadsheet_bytes = calculator.get_shares_spreadsheet() + + workbook = load_workbook(spreadsheet_bytes) + worksheet = workbook.active + + header = [cell.value for cell in worksheet[1]] + expected_header = ["Item", "Receipt", "A", "B", "C", "Check"] + assert header == expected_header + + assert worksheet["A2"].value == "GL-Domaine Amido Cotes Du Rhone" + assert worksheet["B2"].value == 13.00 + assert worksheet["C2"].value == 6.50 + assert worksheet["D2"].value is None + assert worksheet["E2"].value == 6.50 + + assert worksheet["A16"].value == "Shahi tukda" + assert worksheet["B16"].value == 15.00 + assert worksheet["C16"].value == 5.00 + assert worksheet["D16"].value == 5.00 + assert worksheet["E16"].value == 5.00 + + assert worksheet["A17"].value == "Jus d Manguir" + assert worksheet["B17"].value == 9.00 + assert worksheet["C17"].value == 4.50 + assert worksheet["D17"].value == 4.50 + assert worksheet["E17"].value is None + + subtotal_row = 18 + assert worksheet[f"A{subtotal_row}"].value == "Subtotal" + subtotal = EXPECTED_ITEMS.get_sum() + assert worksheet[f"B{subtotal_row}"].value == subtotal + + assert worksheet[f"C{subtotal_row}"].value == "=SUM(C2:C17)" + assert worksheet[f"D{subtotal_row}"].value == "=SUM(D2:D17)" + assert worksheet[f"E{subtotal_row}"].value == "=SUM(E2:E17)" + assert worksheet[f"F{subtotal_row}"].value == "=SUM(C18:E18)" + + service_charge_row = 19 + assert worksheet[f"A{service_charge_row}"].value == SERVICE_CHARGE + service_charge = subtotal * SERVICE_CHARGE_RATIO + assert worksheet[f"B{service_charge_row}"].value == service_charge + + assert ( + worksheet[f"C{service_charge_row}"].value + == f"=(C{subtotal_row}/$B${subtotal_row})*$B{service_charge_row}" + ) + assert ( + worksheet[f"D{service_charge_row}"].value + == f"=(D{subtotal_row}/$B${subtotal_row})*$B{service_charge_row}" + ) + assert ( + worksheet[f"E{service_charge_row}"].value + == f"=(E{subtotal_row}/$B${subtotal_row})*$B{service_charge_row}" + ) + assert ( + worksheet[f"F{service_charge_row}"].value + == f"=SUM(C{service_charge_row}:E{service_charge_row})" + ) + + tax_row = 20 + assert worksheet[f"A{tax_row}"].value == TAX + tax = subtotal * TAX_RATIO + assert worksheet[f"B{tax_row}"].value == tax + + assert ( + worksheet[f"C{tax_row}"].value + == f"=(C{subtotal_row}/$B${subtotal_row})*$B{tax_row}" + ) + assert ( + worksheet[f"D{tax_row}"].value + == f"=(D{subtotal_row}/$B${subtotal_row})*$B{tax_row}" + ) + assert ( + worksheet[f"E{tax_row}"].value + == f"=(E{subtotal_row}/$B${subtotal_row})*$B{tax_row}" + ) + assert worksheet[f"F{tax_row}"].value == f"=SUM(C{tax_row}:E{tax_row})" + + total_row = 21 + assert worksheet[f"A{total_row}"].value == "Total" + assert worksheet[f"B{total_row}"].value == f"=SUM(B{subtotal_row}:B{tax_row})" + assert worksheet[f"C{total_row}"].value == f"=SUM(C{subtotal_row}:C{tax_row})" + assert worksheet[f"D{total_row}"].value == f"=SUM(D{subtotal_row}:D{tax_row})" + assert worksheet[f"E{total_row}"].value == f"=SUM(E{subtotal_row}:E{tax_row})" + assert worksheet[f"F{total_row}"].value == f"=SUM(C{total_row}:E{total_row})"