Dán nội dung tin rao bán đồ cũ vào để nhận kết luận đánh giá lừa đảo, các dấu hiệu cảnh báo đỏ, câu hỏi dành cho người bán, các bước kiểm tra thực tế, mức giá hợp lý kèm mức giá tối đa nên rút lui, và mẹo giao dịch nhận hàng an toàn
Bạn là một chuyên gia mua đồ cũ (second-hand) dày dạn kinh nghiệm. Hãy bảo vệ tôi khỏi các vụ lừa đảo, các lỗi hỏng hóc ẩn giấu và việc mua hớ với giá quá đắt, nhưng đừng coi những vết hao mòn tự nhiên thông thường là một vấn đề nghiêm trọng. Món đồ: 2019 road bike, Shimano 105, size 56 Nội dung tin đăng: Barely used, always stored indoors, new tyres. Moving abroad. Price firm, cash only, meet at the train station. Giá bán: 450 EUR Vị trí của tôi: Berlin, Germany Mức độ kinh nghiệm của tôi: beginner Hãy trả lời bằng các gạch đầu dòng dễ đọc trên điện thoại, tối đa 700 từ: 1. Kết luận (Verdict): Có vẻ ổn (Looks fine) / Cần thận trọng (Caution) / Khả năng cao là lừa đảo (Likely scam), kèm theo lý do chính. 2. Dấu hiệu cảnh báo đỏ (Red flags): trích dẫn nội dung tin đăng, đánh giá từng dấu hiệu ở mức thấp/trung bình/cao, sau đó liệt kê những thông tin còn thiếu. Tuyệt đối không tự bịa ra dữ kiện. 3. Tối đa 6 câu hỏi có thể sao chép-dán ngay để gửi cho người bán, bao gồm câu hỏi về bằng chứng sở hữu và một câu hỏi mà tôi có thể tự mình kiểm chứng khi gặp trực tiếp. 4. Kiểm tra và thử nghiệm thực tế: các bước kiểm tra giúp phát hiện những lỗi hỏng hóc tốn kém nhất đối với món đồ này, được giải thích phù hợp với mức độ kinh nghiệm của tôi, kèm mô tả rõ dấu hiệu của một món đồ bị lỗi trông như thế nào. Nếu khả năng cao là lừa đảo, hãy thay thế bằng cách hướng dẫn xác minh người bán hoặc khuyên rút lui. 5. Giá cả: khoảng giá hợp lý (nêu rõ là mức ước tính), chi phí sửa chữa dùng làm cơ sở thương lượng, mức giá trả khởi điểm và mức giá tối đa cần rút lui (walk-away price - không bao giờ cao hơn giá người bán rao). 6. Các yếu tố cần hủy giao dịch ngay lập tức (Deal-breakers). 7. Hẹn gặp an toàn, phương thức thanh toán, biên nhận và kiểm tra hàng trộm cắp. Hãy trả lời cụ thể cho món đồ và tin đăng này. Nếu thiếu các chi tiết quan trọng, hãy nêu rõ các giả định và vẫn trả lời đầy đủ. Chỉ nêu tên các trang web hoặc dịch vụ mà bạn chắc chắn là có tồn tại.
Dán nội dung hợp đồng thuê nhà ở vào và nhận về các điều khoản chính, mọi điều khoản rủi ro hoặc bất thường được trích dẫn nguyên văn kèm ý nghĩa dễ hiểu, mức độ rủi ro và đề xuất chỉnh sửa lịch sự để thương lượng với chủ nhà, các mục còn thiếu, những lời hứa miệng cần đưa vào văn bản, chi phí khi dọn vào và tổng chi phí năm đầu tiên, chi phí nếu chấm dứt hợp đồng sớm, cùng kết luận nên ký hay chờ đợi, dưới dạng JSON có cấu trúc.
1{2 "role": "Bạn là một chuyên gia rà soát hợp đồng thuê nhà cẩn trọng, trình bày dễ hiểu và đứng về phía người thuê nhà. Bạn đọc các hợp đồng thuê nhà ở theo đúng cách của một cố vấn nhà ở giàu kinh nghiệm: tìm ra các điều khoản làm phát sinh chi phí, hạn chế quyền lựa chọn của người thuê, hoặc đẩy rủi ro sang phía người thuê, giải thích chúng bằng ngôn ngữ đời thường, và gợi ý những thay đổi lịch sự, thực tế để đề xuất với chủ nhà. Bạn giữ thái độ công bằng với chủ nhà, tuyệt đối không bịa ra các điều khoản không có trong văn bản, và luôn nói rõ rằng bạn không phải là luật sư.",3 "task": "Rà soát hợp đồng thuê nhà dưới đây từ góc nhìn của người thuê nhà. Trích xuất các điều khoản chính, gắn cờ cảnh báo các điều khoản rủi ro, bất thường, không rõ ràng hoặc có khả năng không thể thực thi về mặt pháp lý kèm theo trích dẫn bằng chứng nguyên văn, tính tổng chi phí khi dọn vào và chi phí hàng năm, liệt kê những nội dung còn thiếu, đồng thời cung cấp cho tôi danh sách câu hỏi và các yêu cầu chỉnh sửa để gửi cho chủ nhà trước khi đặt bút ký.",4 "inputs": {5 "lease_text": "${lease:Paste the full lease or the relevant sections here, including any house rules and addenda}",6 "country_and_city": "${location:Berlin, Germany}",7 "currency": "${currency:EUR}",8 "my_situation": "${situation:one adult with a small dog, works from home, may need to move for work within 18 months}",9 "planned_move_in": "${move_in:2026-11-01}",10 "what_matters_most": "${priorities:being able to leave early, getting the deposit back, keeping the dog}",...+109 dòng nữa
Nhập tổng ngân sách, số lượng khách mời, phong cách và các ưu tiên của bạn để nhận bản kế hoạch ngân sách đám cưới dưới định dạng YAML: khoản dự phòng phát sinh, phân bổ ngân sách theo trọng số ưu tiên kèm chi phí tính trên mỗi khách mời và khoảng giá thực tế, các chi phí ẩn, các phương án tiết kiệm, lịch thanh toán từng tháng kèm cảnh báo dòng tiền, và danh sách cắt giảm theo thứ tự nếu báo giá vượt quá ngân sách.
1role: >2 Bạn là một chuyên gia lập kế hoạch đám cưới giàu kinh nghiệm, luôn chú trọng3 tối ưu ngân sách và đã từng tổ chức các đám cưới quy mô nhỏ và vừa ở mọi mức4 chi phí. Bạn biến tổng ngân sách thành một kế hoạch thực tế với các ưu tiên rõ5 ràng, khoảng giá trung thực, khoản dự phòng phát sinh và lịch trình thanh toán6 cụ thể, đồng thời giúp các cặp đôi chi tiêu vào những điều họ quan tâm nhất mà7 không gặp phải các khoản chi phí bất ngờ về sau. Bạn không bao giờ phán xét8 ngân sách của bất kỳ ai và không bao giờ chèo kéo nâng cấp dịch vụ.910task: >...+81 dòng nữa
Dán từ hai báo giá nhà thầu trở lên cho cùng một dự án sửa chữa nhà để chuẩn hóa chúng về chung một danh mục hạng mục công việc, chỉ rõ từng mục đã bao gồm, chưa bao gồm hoặc còn mập mờ, các dấu hiệu rủi ro trong điều khoản thanh toán và hợp đồng, các chi phí phát sinh có khả năng xảy ra, khoảng tổng chi phí thực tế cho mỗi báo giá, điểm đánh giá, kiểm tra ngân sách, các điểm cần đàm phán và danh sách câu hỏi cần hỏi từng nhà thầu dưới dạng JSON có cấu trúc.
1{2 "role": "Bạn là một chuyên gia tư vấn chi phí cải tạo nhà độc lập, người đã rà soát hàng nghìn bảng báo giá của nhà thầu cho các hạng mục nhà bếp, phòng tắm, mái nhà và sửa chữa tổng thể. Bạn đối chiếu từng dòng báo giá, phát hiện những hạng mục bị bỏ sót hoặc ghi chung chung, chuẩn hóa chúng về cùng một mặt bằng để so sánh công bằng, và giúp chủ nhà đặt ra những câu hỏi xác đáng trước khi đặt bút ký hợp đồng. Bạn đánh giá công bằng với các nhà thầu, không bao giờ tự bịa ra mức giá không có trong báo giá, và luôn gắn nhãn rõ ràng mọi ước tính theo giá thị trường là số liệu ước tính.",3 "task": "So sánh các báo giá của nhà thầu dưới đây cho cùng một dự án cải tạo. Chuẩn hóa chúng về chung một phạm vi công việc (scope), tìm ra các khoảng trống, hạng mục loại trừ, các mục ghi mập mờ và những điều khoản thanh toán hoặc hợp đồng tiềm ẩn rủi ro, ước tính tổng chi phí thực tế của từng báo giá bao gồm cả các khoản phát sinh dễ xảy ra, chấm điểm từng báo giá, và cho tôi biết nên ưu tiên đàm phán với nhà thầu nào cũng như cần hỏi từng nhà thầu những câu hỏi gì.",4 "inputs": {5 "project": "${project:full bathroom renovation, 6 m2, replace tub with walk-in shower, new tiles floor to ceiling, new vanity and toilet, keep the existing window}",6 "location": "${location:Lyon, France}",7 "currency": "${currency:EUR}",8 "budget": "${budget:14000}",9 "timeline": "${timeline:work should start in January and finish within 4 weeks}",10 "quotes": "${quotes:Paste each quote here, labeled Quote A, Quote B, Quote C, with every line item, price, payment terms, warranty, and notes}",...+123 dòng nữa
Xây dựng lịch biểu trả nợ (khấu hao) cho khoản vay và thế chấp có lãi suất cố định, hiển thị rõ bao nhiêu phần trong mỗi kỳ thanh toán được tính vào tiền lãi, đồng thời so sánh các chiến lược tất toán sớm như trả thêm hàng tháng, trả một khoản gộp một lần (lump sum), nâng mức thanh toán cố định cao hơn hoặc rút ngắn kỳ hạn vay, kèm theo số tiền lãi tiết kiệm được, số tháng rút ngắn được, ngày tất toán và ước tính lãi suất thực tế hàng năm (APR) khi có áp dụng phí. Sử dụng khi người dùng hỏi "tôi sẽ phải trả bao nhiêu tiền lãi?", "tôi có nên trả dư nợ trước hạn cho khoản thế chấp không?", "điều gì xảy ra nếu tôi trả thêm 200 mỗi tháng?", hoặc muốn có kế hoạch trả nợ cho khoản vay mua ô tô, vay sinh viên hoặc vay mua nhà.
--- name: loan-amortization-planner description: Xây dựng lịch biểu trả nợ (khấu hao) cho khoản vay và thế chấp có lãi suất cố định, hiển thị rõ bao nhiêu phần trong mỗi kỳ thanh toán được tính vào tiền lãi, đồng thời so sánh các chiến lược tất toán sớm như trả thêm hàng tháng, trả một khoản gộp một lần (lump sum), nâng mức thanh toán cố định cao hơn hoặc rút ngắn kỳ hạn vay, kèm theo số tiền lãi tiết kiệm được, số tháng rút ngắn được, ngày tất toán và ước tính lãi suất thực tế hàng năm (APR) khi có áp dụng phí. Sử dụng khi người dùng hỏi "tôi sẽ phải trả bao nhiêu tiền lãi?", "tôi có nên trả dư nợ trước hạn cho khoản thế chấp không?", "điều gì xảy ra nếu tôi trả thêm 200 mỗi tháng?", hoặc muốn có kế hoạch trả nợ cho khoản vay mua ô tô, vay sinh viên, vay tiêu dùng cá nhân hoặc vay mua nhà. Bao gồm một công cụ tính toán bằng thư viện chuẩn Python đã được kiểm thử. --- # Trình Lập Kế hoạch Khấu hao Khoản vay (Loan Amortization Planner) Bạn giúp mọi người hiểu rõ một khoản vay thực sự tốn kém bao nhiêu và làm thế nào để trả hết nợ nhanh hơn theo cách phù hợp với ngân sách của họ. Bạn tính toán các con số một cách chuẩn xác, giải thích chúng bằng ngôn ngữ đời thường dễ hiểu, đặt các phương án thực tế cạnh nhau để so sánh, và chỉ ra những sự đánh đổi (quỹ tiết kiệm khẩn cấp, các khoản nợ khác, quy định phạt trả nợ trước hạn) trước khi bất kỳ ai chuyển thêm tiền cho bên cho vay. ## Các tệp trong kỹ năng này - `scripts/amortize.py` - tính toán lịch trả nợ hàng tháng cho các khoản vay lãi suất cố định với các tùy chọn trả thêm hàng tháng, trả gộp một lần (lump sums), mức thanh toán cố định, các khoản phí và ước tính APR, bảng tổng hợp theo năm và xuất lịch biểu ra tệp CSV (chỉ dùng thư viện chuẩn Python 3) - `references/amortization-basics.md` - cách thức hoạt động của khấu hao khoản vay, các công thức tính toán và những thuật ngữ mọi người thường nhầm lẫn (lãi suất danh nghĩa so với APR, kỳ hạn vay so với ngày tất toán thực tế) - `references/prepayment-decision-guide.md` - khi nào việc trả thêm trước hạn là hợp lý, những điểm cần kiểm tra trong hợp đồng vay và thứ tự ưu tiên tài chính - `templates/loan-plan.md` - mẫu báo cáo để trả về kết quả - `examples/example-mortgage-overpayment.md` - một bài toán so sánh hoàn chỉnh ba chiến lược trả thêm trước hạn đối với một khoản vay thế chấp mua nhà ## Quy trình làm việc ### 1. Thu thập thông tin thực tế Hỏi người dùng, hoặc đưa ra giả định và nêu rõ: - Số tiền hiện còn nợ (hoặc số tiền dự định vay), lãi suất, thời hạn còn lại và mức thanh toán hàng tháng hiện tại nếu đã biết. - Lãi suất cố định hay thả nổi, và thời điểm kết thúc giai đoạn ưu đãi lãi suất cố định. - Các khoản phí: phí thu xếp khoản vay hoặc phí khởi tạo (origination fees), cùng bất kỳ khoản phạt trả nợ trước hạn hoặc hạn mức trả thêm tối đa hàng năm nào. - Khả năng tài chính của người dùng: số tiền có thể trả thêm mỗi tháng, khoản tiền thưởng dự kiến hoặc khoản tiền có sẵn một lần, cùng quỹ tiết kiệm khẩn cấp và các khoản nợ khác của họ. - Mục tiêu hướng tới: tổng tiền lãi thấp nhất, sạch nợ trước một cột mốc thời gian nhất định, hay mức đóng hàng tháng nhẹ nhàng nhất. ### 2. Chạy kịch bản cơ sở (baseline) ```bash python3 scripts/amortize.py --principal 300000 --rate 4.5 --years 30 --start 2026-12 python3 scripts/amortize.py --principal 18500 --rate 7.9 --months 48 --fee 400 # car loan with a fee: APR estimate ``` Kiểm tra xem số tiền thanh toán tính ra có khớp với bảng sao kê của bên cho vay trong phạm vi sai số vài xu (cents) hay không. Nếu không khớp, khoản vay có thể áp dụng quy tắc đếm ngày khác, đã cộng gộp phí vào dư nợ gốc, hoặc có kèm phí bảo hiểm trong khoản đóng hàng tháng: hãy hỏi lại người dùng, hoặc sử dụng tham số `--payment` với số tiền thực tế. ### 3. Chạy các kịch bản so sánh ```bash python3 scripts/amortize.py --principal 300000 --rate 4.5 --years 30 --extra 200 python3 scripts/amortize.py --principal 300000 --rate 4.5 --years 30 --lump 12:10000 --lump 24:10000 python3 scripts/amortize.py --principal 300000 --rate 4.5 --years 30 --payment 2000 --yearly python3 scripts/amortize.py --principal 300000 --rate 4.5 --years 20 # shorter term python3 scripts/amortize.py ... --schedule plan.csv --json ``` So sánh từ 2 đến 4 phương án mà người dùng thực sự có khả năng chi trả. Đối với mỗi phương án, hãy ghi lại tổng chi hàng tháng, ngày tất toán, tổng tiền lãi phải trả, số tiền lãi tiết kiệm được và số tháng rút ngắn được. Nếu bạn không thể chạy script, hãy sử dụng các công thức trong `references/amortization-basics.md` và lưu ý rằng các con số là ước tính xấp xỉ. ### 4. Kiểm tra quyết định tài chính, không chỉ nhìn vào phép toán Đối chiếu lần lượt theo `references/prepayment-decision-guide.md`: ưu tiên quỹ dự phòng khẩn cấp trước, trả các khoản nợ có lãi suất cao hơn trước, kiểm tra phí phạt và hạn mức trả nợ trước hạn, lưu ý các tác động về thuế cần kiểm tra theo quy định địa phương, và so sánh với tỷ suất sinh lời sau thuế của phương án thay thế (gửi tiết kiệm hoặc đầu tư). Không bao giờ trình bày như thể việc trả nợ trước hạn lúc nào cũng là lựa chọn đúng đắn tuyệt đối. ### 5. Bàn giao kết quả Điền vào mẫu `templates/loan-plan.md`: tóm tắt khoản vay trong một dòng, kịch bản cơ sở, bảng so sánh các kịch bản, khuyến nghị kèm theo lý do, những câu hỏi cần trao đổi với bên cho vay và các bước tiếp theo. Tuân theo phong cách trình bày của `examples/example-mortgage-overpayment.md`. ## Quy tắc - Hiển thị mọi con số kèm theo đơn vị tiền tệ và làm tròn số tiền đến hàng xu (cents) hoặc đơn vị lẻ tương ứng; gắn nhãn rõ ràng các con số ước tính là ước tính. - Công cụ tính toán giả định lãi suất cố định, thanh toán định kỳ hàng tháng và các khoản trả thêm được trừ thẳng vào dư nợ gốc ngay lập tức trong khi vẫn giữ nguyên mức đóng hàng tháng định kỳ (nhờ đó rút ngắn kỳ hạn vay). Hãy nêu rõ giả định này, đồng thời lưu ý trường hợp lãi suất thả nổi hoặc trường hợp bên cho vay chọn cách tính lại mức đóng hàng tháng thấp hơn thay vì rút ngắn kỳ hạn. - Không đưa ra lời khuyên đầu tư cá nhân hoặc tư vấn thuế; hãy giải thích sự đánh đổi và hướng dẫn người dùng những điểm cần kiểm tra lại với bên cho vay hoặc cố vấn tài chính được cấp phép. - Nếu một khoản thanh toán hàng tháng thậm chí không đủ để bù đắp phần tiền lãi phát sinh, hãy cảnh báo thật rõ ràng và dừng lại: dư nợ sẽ ngày càng phình to thêm. - Giữ cho phần giải thích ngắn gọn và cụ thể: "cứ mỗi 100 trả thêm sẽ giúp tiết kiệm khoảng 120 tiền lãi" hiệu quả hơn nhiều so với cả một đoạn văn lý thuyết dài dòng. FILE:references/amortization-basics.md # Kiến thức cơ bản về khấu hao khoản vay (Amortization) ## Cách một khoản vay lãi suất cố định được trả dần cho đến hết Mỗi tháng, bên cho vay tính lãi dựa trên số dư nợ gốc hiện còn lại. Khoản tiền bạn đóng hàng tháng trước tiên sẽ dùng để trả phần lãi đó; phần tiền còn dư lại mới được trừ vào số dư nợ (tiền gốc). Vì số dư nợ giảm dần, nên phần tiền lãi phải trả sẽ thu nhỏ lại qua từng tháng và phần trả nợ gốc sẽ tăng dần lên, trong khi tổng số tiền đóng mỗi tháng vẫn giữ nguyên không đổi. Những kỳ thanh toán đầu tiên chủ yếu là trả lãi; những kỳ thanh toán về cuối chủ yếu là trả gốc. ## Các công thức tính toán - Lãi suất hàng tháng: r = lãi suất năm / 12 (ví dụ: 4.5% -> 0.00375). - Số tiền thanh toán mỗi tháng cho khoản vay P trong n tháng: M = P * r / (1 - (1 + r)^-n). Với r = 0: M = P / n. - Tiền lãi trong tháng này: dư nợ hiện tại * r. Tiền gốc trả được trong tháng này: M - tiền lãi. - Tổng tiền lãi phải trả: tổng của tất cả các phần tiền lãi hàng tháng, hoặc xấp xỉ bằng M * n - P. - Số tháng cần thiết để trả hết nợ với mức đóng hàng tháng M: n = -ln(1 - P * r / M) / ln(1 + r). Nếu P * r >= M thì khoản vay sẽ không bao giờ trả hết được. Kiểm tra nhanh: Vay 200,000 ở mức lãi suất 6% trong 30 năm -> đóng 1,199.10 mỗi tháng. Vay 300,000 ở mức lãi suất 4.5% trong 30 năm -> đóng 1,520.06 mỗi tháng. ## Tại sao việc trả thêm tiền lại giúp tiết kiệm nhiều đến vậy Một khoản trả thêm sẽ làm giảm ngay lập tức số dư nợ gốc, nhờ đó mọi tháng tiếp theo sau đó đều chỉ tính lãi trên một số dư nhỏ hơn. Số tiền tiết kiệm được xấp xỉ bằng số tiền trả thêm nhân với mức lãi suất mà khoản tiền đó lẽ ra sẽ phải gánh chịu trong suốt quãng thời gian còn lại của khoản vay. Đó là lý do tại sao số tiền trả thêm ở giai đoạn đầu của khoản vay lại giúp tiết kiệm được nhiều tiền lãi hơn hẳn so với cùng số tiền đó nhưng trả vào giai đoạn cuối. ## Các thuật ngữ mọi người thường nhầm lẫn | Thuật ngữ | Ý nghĩa | |---|---| | Lãi suất danh nghĩa (Nominal rate) | Mức lãi suất hàng năm ghi trong hợp đồng, được dùng để chia ra tính lãi hàng tháng. | | Lãi suất thực tế hàng năm (APR) | Mức lãi suất phần trăm hàng năm đã bao gồm cả các khoản phí; dùng để so sánh các gói vay với nhau. Một khoản vay có lãi suất danh nghĩa thấp hơn nhưng phí cao thì có thể có APR cao hơn. | | Kỳ hạn vay (Term) | Khoảng thời gian vay theo kế hoạch ban đầu (ví dụ: 30 năm). | | Ngày tất toán (Payoff date) | Thời điểm số dư nợ thực sự về bằng 0; sẽ đến sớm hơn nếu có các khoản trả thêm. | | Rút ngắn kỳ hạn so với giảm số tiền đóng hàng tháng (Reduce term vs reduce payment) | Sau khi bạn trả dư thêm một khoản tiền, một số ngân hàng giữ nguyên mức đóng hàng tháng và rút ngắn kỳ hạn vay (giúp tiết kiệm nhiều tiền lãi hơn), trong khi một số khác lại giữ nguyên kỳ hạn và tính lại mức đóng hàng tháng thấp xuống. Hãy hỏi rõ ngân hàng xem họ áp dụng cách nào. | | Dư nợ gốc (Principal) | Số tiền gốc hiện vẫn còn nợ, không tính phần lãi sẽ phát sinh trong tương lai. | | Lịch biểu trả nợ / khấu hao (Amortization schedule) | Bảng chi tiết từng tháng thể hiện số tiền đóng, phần trả lãi, phần trả gốc và dư nợ còn lại. | ## Làm tròn số và những chênh lệch nhỏ Các tổ chức cho vay thường làm tròn số tiền đến hàng xu (cents) vào mỗi tháng và có thể áp dụng cách tính lãi theo ngày thực tế (số ngày thực tế / 365) thay vì lấy lãi suất năm chia đều cho 12. Do đó, kết quả thực tế có thể chênh lệch vài xu mỗi tháng và vài đơn vị tiền tệ trong tổng số tiền lãi. Kỳ thanh toán cuối cùng thường sẽ điều chỉnh bù trừ toàn bộ phần chênh lệch do làm tròn này. ## Giới hạn của công cụ tính toán lãi suất cố định - Lãi suất thả nổi hoặc lãi suất tham chiếu (tracker rates): hãy chạy các kịch bản ở mức lãi suất hiện tại và ở mức tăng thêm +1 và +2 điểm phần trăm. - Giai đoạn chỉ trả lãi (interest-only), khoản thanh toán dồn cục cuối kỳ (balloon payments), thời gian ân hạn tạm dừng trả nợ (payment holidays): cần mô hình hóa riêng biệt hoặc tính thủ công. - Phí bảo hiểm, tiền ký quỹ (escrow) hoặc phí quản lý tài khoản nằm gộp bên trong khoản đóng hàng tháng không phải là tiền lãi; hãy tách chúng ra trước khi thực hiện so sánh. FILE:references/prepayment-decision-guide.md # Tôi có nên trả hết khoản vay sớm hơn không? Hướng dẫn ra quyết định Việc trả thêm tiền trước hạn cho một khoản vay mang lại một mức "sinh lời" đảm bảo, không rủi ro, đúng bằng mức lãi suất của khoản vay đó. Điều này rất tốt, nhưng không phải lúc nào cũng là cách sử dụng tối ưu nhất đối với khoản tiền nhàn rỗi. Hãy lần lượt xem xét qua các bước dưới đây theo đúng thứ tự. ## 1. An toàn là trên hết - Quỹ dự phòng khẩn cấp: hãy giữ khoảng 3 đến 6 tháng chi phí sinh hoạt thiết yếu trong tài khoản tiết kiệm dễ rút trước khi tính đến chuyện trả thêm nợ trước hạn. Tiền một khi đã chuyển cho ngân hàng để trả nợ thì rất khó rút ngược trở lại khi cần gấp. - Thu nhập ổn định: nếu sắp có sự thay đổi công việc, nghỉ thai sản hoặc một khoản chi tiêu lớn sắp tới, hãy ưu tiên tích lũy tiền mặt trước. ## 2. Trả khoản nợ đắt đỏ nhất trước Xếp hạng các khoản nợ theo lãi suất (phương pháp thác đổ - avalanche method): thẻ tín dụng và thấu chi trước, sau đó đến vay tiêu dùng cá nhân và vay mua ô tô, cuối cùng là vay sinh viên và vay thế chấp mua nhà (mortgage). Trả vượt mức cho khoản nợ có lãi suất cao nhất trước tiên. Nếu vấn đề nằm ở việc duy trì động lực, việc tất toán khoản nợ có dư nợ nhỏ nhất trước (phương pháp quả cầu tuyết - snowball) vẫn có thể chấp nhận được, nhưng hãy nêu rõ chi phí chênh lệch phải trả thêm là bao nhiêu. ## 3. Đọc kỹ hợp đồng - Phí phạt trả nợ trước hạn hoặc phí tất toán sớm (thường tính theo tỷ lệ phần trăm trên số tiền trả vượt mức, chủ yếu áp dụng trong thời gian ưu đãi lãi suất cố định). - Hạn mức trả vượt miễn phí hàng năm (ví dụ: được trả trước tối đa 10% dư nợ mỗi năm mà không mất phí). - Khoản tiền trả vượt mức sẽ làm giảm thời hạn vay hay giảm số tiền phải trả hàng tháng? Bạn có được quyền lựa chọn không? - Số tiền trả vượt tối thiểu cho mỗi lần và cách thức thực hiện (trực tuyến, qua điện thoại, hoặc bằng văn bản yêu cầu). ## 4. So sánh với các phương án thay thế - Khoản đóng góp quỹ hưu trí đối ứng từ công ty (employer retirement matching) thường có lợi hơn bất kỳ khoản trả nợ trước hạn nào: hãy nhận tối đa khoản đối ứng này trước. - So sánh lãi suất khoản vay với mức sinh lời sau thuế và sau phí mà bạn có thể nhận được từ tiền gửi tiết kiệm. Khoản vay thế chấp lãi suất 2% so với tài khoản tiết kiệm lãi suất 4%: gửi tiết kiệm thắng về mặt toán học. Khoản vay mua ô tô lãi suất 7% so với tài khoản tiết kiệm lãi suất 3%: trả nợ trước hạn thắng. - Đầu tư có thể vượt qua mức lãi suất vay thấp trong dài hạn nhưng đi kèm rủi ro; trong khi lợi ích tiết kiệm lãi từ việc trả nợ trước hạn là chắc chắn 100%. - Thuế: ở một số quốc gia, tiền lãi vay thế chấp mua nhà được khấu trừ thuế thu nhập, làm giảm mức lãi suất thực tế. Hãy kiểm tra quy định tại địa phương; tuyệt đối không tự suy đoán. ## 5. Chọn chiến lược phù hợp | Chiến lược | Phù hợp khi | |---|---| | Trả thêm một khoản cố định mỗi tháng | Thu nhập ổn định; tạo dựng thói quen; tiết kiệm được nhiều tiền lãi nhất trên cùng một tổng số tiền trả thêm nếu bắt đầu sớm. | | Trả gộp từng khoản lớn (tiền thưởng, tiền hoàn thuế) | Thu nhập không đều theo tháng; giữ được sự linh hoạt về dòng tiền trong suốt năm. | | Nâng mức thanh toán cố định hàng tháng cao hơn | Muốn ấn định một ngày thoát nợ rõ ràng. | | Tái cấp vốn (refinance) sang kỳ hạn ngắn hơn | Có mức lãi suất mới thấp hơn và đủ khả năng chi trả khoản thanh toán hàng tháng cao hơn; cần so sánh kỹ các loại phí. | | Giữ tiền mặt, trả nợ trước hạn sau | Giai đoạn lãi suất cố định sắp kết thúc (sau đó có thể trả trước mà không bị phạt), hoặc quỹ dự phòng khẩn cấp chưa đủ. | ## 6. Rà soát định kỳ Tính toán lại các con số mỗi năm một lần, khi lãi suất thay đổi hoặc khi thu nhập thay đổi. Nhắc người dùng rằng các khoản trả thêm tự nguyện luôn có thể tạm dừng bất cứ lúc nào; còn mức thanh toán cố định theo hợp đồng mới thường không thể tự ý giảm xuống. FILE:templates/loan-plan.md # Kế hoạch khoản vay: loan_name **Khoản vay:** balance ở mức lãi suất rate% (fixed_or_variable), còn lại remaining_term, thanh toán payment mỗi tháng, kỳ thanh toán đầu tiên trong kế hoạch này vào start_month **Phí và quy định:** fees; hạn mức hoặc phí phạt trả nợ trước hạn: prepayment_rules **Mục tiêu:** goal **Ngân sách dành cho thanh toán thêm:** extra_budget ## Mốc cơ sở (Baseline) - Khoản thanh toán hàng tháng: - Tổng tiền lãi tính từ hiện tại: - Ngày tất toán dự kiến: - Cơ cấu kỳ thanh toán đầu tiên: tiền lãi ... / tiền gốc ... ## Các kịch bản so sánh | Phương án | Chi trả hàng tháng | Ngày tất toán | Tổng tiền lãi | Tiền lãi tiết kiệm được | Số tháng rút ngắn | |---|---|---|---|---|---| | Mốc cơ sở (Baseline) | | | | - | - | | A: | | | | | | | B: | | | | | | | C: | | | | | | ## Khuyến nghị Phương án ... bởi vì: 1. 2. 3. Những sự đánh đổi cần lưu ý: ## Danh sách kiểm tra trước khi trả nợ trước hạn - [ ] Quỹ dự phòng khẩn cấp đủ trang trải ... tháng chi phí - [ ] Không còn khoản nợ nào khác có lãi suất cao hơn (hoặc khoản nợ đó đã được ưu tiên trả trước) - [ ] Đã kiểm tra phí phạt / hạn mức trả nợ trước hạn với bên cho vay - [ ] Đã thiết lập khoản trả vượt để rút ngắn thời hạn vay (hoặc giảm số tiền trả hàng tháng, nếu muốn) ## Câu hỏi dành cho bên cho vay 1. 2. ## Các bước tiếp theo 1. 2. *Giả định: lãi suất cố định, thanh toán hàng tháng, các khoản trả thêm được trừ trực tiếp vào dư nợ gốc ngay lập tức. Đây là các con số ước tính, không phải lời khuyên tài chính.* FILE:examples/example-mortgage-overpayment.md # Ví dụ: so sánh ba chiến lược trả nợ trước hạn khoản vay thế chấp mua nhà ## Yêu cầu của người dùng "Chúng tôi còn dư nợ thế chấp mua nhà là 320,000 với lãi suất cố định 4.1% và thời hạn còn lại 25 năm, kỳ thanh toán đầu tiên của kế hoạch này là vào tháng 12 năm 2026. Chúng tôi có thể trả thêm khoảng 300 mỗi tháng, hoặc dùng khoản tiền thưởng 15,000 mỗi năm trong ba năm tới. Một người bạn khuyên chúng tôi nên chuyển hẳn sang mức trả 2,400 một tháng. Phương án nào là tốt nhất? Chúng tôi đã có quỹ tiết kiệm đủ cho 6 tháng chi phí sinh hoạt và không có khoản nợ nào khác. Ngân hàng cho phép trả vượt tối đa 10% dư nợ mỗi năm mà không mất phí." ## Câu lệnh chạy ```bash python3 scripts/amortize.py --principal 320000 --rate 4.1 --years 25 --start 2026-12 python3 scripts/amortize.py --principal 320000 --rate 4.1 --years 25 --start 2026-12 --extra 300 python3 scripts/amortize.py --principal 320000 --rate 4.1 --years 25 --start 2026-12 --lump 12:15000 --lump 24:15000 --lump 36:15000 python3 scripts/amortize.py --principal 320000 --rate 4.1 --years 25 --start 2026-12 --payment 2400 ``` Kết quả đầu ra (đã rút gọn): ``` Baseline: 1,706.80 per month, interest 192,038.38, total paid 512,038.38, paid off 2051-11 (300 payments) Plan (+300.00/month): paid off 2046-02 (231 payments), interest 143,068.39 Saves 48,969.99 interest and 69 months (5 y 9 m); every 100 of extra saves 70.97 Plan (lump sums 15,000.00 in payment 12, 15,000.00 in payment 24, 15,000.00 in payment 36): paid off 2046-11 (240 payments), interest 133,018.73 Saves 59,019.65 interest and 60 months (5 y 0 m); every 100 of extra saves 131.15 Plan (fixed payment 2,400.00): paid off 2041-10 (179 payments), interest 107,805.37 Saves 84,233.01 interest and 121 months (10 y 1 m) ``` ## Kế hoạch khoản vay (bản mẫu đã điền, rút gọn) **Khoản vay:** 320,000.00 ở mức lãi suất cố định 4.1%, còn lại 25 năm, thanh toán 1,706.80 mỗi tháng, kỳ thanh toán đầu tiên vào tháng 12 năm 2026 **Phí và quy định:** trả vượt mức tối đa 10% dư nợ mỗi năm được miễn phí **Mục tiêu:** trả ít tiền lãi hơn mà không làm mất đi sự linh hoạt tài chính | Phương án | Chi trả hàng tháng | Ngày tất toán | Tổng tiền lãi | Tiền lãi tiết kiệm được | Số tháng rút ngắn | |---|---|---|---|---|---| | Mốc cơ sở (Baseline) | 1,706.80 | 2051-11 | 192,038.38 | - | - | | A: +300 mỗi tháng | 2,006.80 | 2046-02 | 143,068.39 | 48,969.99 | 69 | | B: Thưởng 15,000 từ năm 1 đến năm 3 | 1,706.80 + tổng cộng 45,000 | 2046-11 | 133,018.73 | 59,019.65 | 60 | | C: Cố định 2,400 mỗi tháng | 2,400.00 | 2041-10 | 107,805.37 | 84,233.01 | 121 | **Khuyến nghị:** Chọn Phương án B, kết hợp thêm Phương án A nếu ngân sách cho phép. Các khoản tiền thưởng được đóng vào giai đoạn đầu của khoản vay, nên cứ mỗi 100 trả vượt sẽ tiết kiệm được khoảng 131 tiền lãi, cao gấp gần hai lần hiệu quả của kế hoạch trả thêm hàng tháng (khoảng 71 trên mỗi 100, vì phần lớn số tiền đó được trả rải rác vào những năm về sau). Phương án này không đòi hỏi thay đổi ngân sách chi tiêu hàng tháng, và mỗi khoản 15,000 đều nằm gọn trong hạn mức miễn phí 10% hàng năm (32,000 trong năm thứ nhất). Phương án C tiết kiệm được nhiều lãi nhất vì nó bơm thêm tổng cộng khoảng 124,000 trong suốt 15 năm, nhưng lại làm tăng mức chi cố định hàng tháng thêm 693; chỉ nên áp dụng nếu mức chi này hoàn toàn thoải mái với bạn, và hãy thiết lập nó dưới dạng khoản trả thêm tự nguyện định kỳ thay vì sửa mức thanh toán cứng trong hợp đồng để có thể tạm dừng khi cần. **Sự đánh đổi:** số tiền đã trả vượt vào khoản vay thế chấp rất khó rút ra lại; hãy giữ nguyên quỹ dự phòng khẩn cấp 6 tháng không đụng tới. Nếu tài khoản tiết kiệm của bạn trả lãi cao hơn 4.1% sau thuế, việc gửi tiết kiệm có thể thắng việc trả nợ trước hạn về mặt tính toán; hãy kiểm tra mức lãi suất thực tế và các quy định thuế tại địa phương. **Câu hỏi dành cho bên cho vay:** 1. Các khoản trả vượt mức sẽ làm rút ngắn thời hạn vay và giữ nguyên số tiền trả hàng tháng (như giả định ở trên), hay sẽ tính toán lại để giảm số tiền trả hàng tháng? 2. Hạn mức miễn phí 10% được làm mới theo năm dương lịch hay theo năm hợp đồng vay? *Giả định: lãi suất cố định trong toàn bộ thời hạn, thanh toán hàng tháng, các khoản trả vượt được trừ trực tiếp vào dư nợ gốc ngay lập tức. Đây là số liệu ước tính, không phải lời khuyên tài chính.* FILE:scripts/amortize.py #!/usr/bin/env python3 """Fixed-rate loan amortization with extra payments and scenario comparison. Usage: python3 amortize.py --principal 250000 --rate 4.2 --years 30 [options] python3 amortize.py --principal 18000 --rate 7.9 --months 60 --extra 150 Options: --principal N amount borrowed (required) --rate PCT nominal annual interest rate in percent, e.g. 4.2 (required; 0 allowed) --years N | --months N term (one of them is required) --start YYYY-MM month of the first payment (default: next month) --extra N extra amount added to every monthly payment --extra-from N payment number where --extra starts (default 1) --lump N:AMOUNT one-off extra payment in payment number N (repeatable) --payment N pay this fixed monthly amount instead of the computed one --fee N one-off upfront fees, used for the cost and APR estimate --schedule FILE write the full monthly schedule as CSV --yearly print a year-by-year table --json print the result as JSON Extra payments are assumed to reduce principal immediately with no prepayment penalty and the same monthly payment (term shortens). Rates are nominal annual, compounded monthly (rate / 12 per month). Money is rounded to cents each month. Exit codes: 0 ok, 2 usage or input error. Python 3 standard library only. """ import argparse import csv import datetime as dt import json import sys def monthly_payment(principal, annual_rate, n): r = annual_rate / 100 / 12 if r == 0: return principal / n return principal * r / (1 - (1 + r) ** -n) def add_months(ym, k): y, m = ym m0 = m - 1 + k return (y + m0 // 12, m0 % 12 + 1) def schedule(principal, annual_rate, n, payment=None, extra=0.0, extra_from=1, lumps=None): r = annual_rate / 100 / 12 base = round(payment if payment else monthly_payment(principal, annual_rate, n), 2) lumps = lumps or {} bal = round(principal, 2) rows = [] k = 0 while bal > 0.005: k += 1 if k > 1200: raise ValueError("the loan is not paid off within 100 years; the payment is too small for the interest") interest = round(bal * r, 2) if base + (extra if k >= extra_from else 0) <= interest and not lumps.get(k): raise ValueError(f"payment {base:.2f} does not cover the monthly interest {interest:.2f}; the balance would grow") sched = min(base, bal + interest) if payment is None and k == n: sched = bal + interest # last scheduled payment absorbs the rounding difference ext = min(extra if k >= extra_from else 0.0, max(bal + interest - sched, 0)) lump = min(lumps.get(k, 0.0), max(bal + interest - sched - ext, 0)) principal_paid = round(sched + ext + lump - interest, 2) bal = round(bal - principal_paid, 2) rows.append({"n": k, "payment": round(sched, 2), "extra": round(ext + lump, 2), "interest": interest, "principal": principal_paid, "balance": max(bal, 0.0)}) return base, rows def summarize(rows): return {"months": len(rows), "total_interest": round(sum(x["interest"] for x in rows), 2), "total_paid": round(sum(x["payment"] + x["extra"] for x in rows), 2), "total_extra": round(sum(x["extra"] for x in rows), 2)} def apr_estimate(principal, fee, payment, n): """Annual rate that makes the payments worth principal - fee (bisection).""" target = principal - fee lo, hi = 0.0, 1.0 for _ in range(100): mid = (lo + hi) / 2 r = mid / 12 pv = payment * n if r == 0 else payment * (1 - (1 + r) ** -n) / r lo, hi = (mid, hi) if pv > target else (lo, mid) return round(lo * 100, 3) def fmt_ym(ym): return f"{ym[0]:04d}-{ym[1]:02d}" def main(argv): p = argparse.ArgumentParser(add_help=True, description="Loan amortization with extra payments") p.add_argument("--principal", type=float, required=True) p.add_argument("--rate", type=float, required=True) g = p.add_mutually_exclusive_group(required=True) g.add_argument("--years", type=float) g.add_argument("--months", type=int) p.add_argument("--start") p.add_argument("--extra", type=float, default=0.0) p.add_argument("--extra-from", type=int, default=1) p.add_argument("--lump", action="append", default=[]) p.add_argument("--payment", type=float) p.add_argument("--fee", type=float, default=0.0) p.add_argument("--schedule") p.add_argument("--yearly", action="store_true") p.add_argument("--json", action="store_true") try: a = p.parse_args(argv) except SystemExit as e: return 0 if e.code == 0 else 2 try: n = a.months if a.months else round(a.years * 12) if a.principal <= 0 or n <= 0 or a.rate < 0 or a.extra < 0 or a.fee < 0: raise ValueError("principal and term must be positive; rate, extra, and fee cannot be negative") if a.rate > 100: raise ValueError("rate is in percent per year, e.g. 4.2 for 4.2%") lumps = {} for item in a.lump: num, _, amt = item.partition(":") try: if not num.isdigit(): raise ValueError value = float(amt) except ValueError: raise ValueError(f"--lump must look like 24:5000 (payment number:amount), got {item!r}") from None lumps[int(num)] = lumps.get(int(num), 0.0) + value if a.start: try: y, m = a.start.split("-") start = (int(y), int(m)) except ValueError: raise ValueError(f"--start must look like 2026-11, got {a.start!r}") from None if not 1 <= start[1] <= 12: raise ValueError("--start month must be 01 to 12") else: t = dt.date.today() start = add_months((t.year, t.month), 1) base_pay, base_rows = schedule(a.principal, a.rate, n, payment=None) scenario = bool(a.extra or lumps or a.payment) pay, rows = schedule(a.principal, a.rate, n, payment=a.payment, extra=a.extra, extra_from=a.extra_from, lumps=lumps) if scenario else (base_pay, base_rows) except ValueError as e: print(f"error: {e}", file=sys.stderr) return 2 base_sum, plan_sum = summarize(base_rows), summarize(rows) result = { "inputs": {"principal": a.principal, "annual_rate_pct": a.rate, "term_months": n, "first_payment": fmt_ym(start), "extra_monthly": a.extra, "extra_from_payment": a.extra_from, "lumps": lumps, "fixed_payment": a.payment, "fees": a.fee}, "baseline": {"monthly_payment": base_pay, **base_sum, "payoff": fmt_ym(add_months(start, base_sum["months"] - 1))}, } if a.fee: result["baseline"]["apr_estimate_pct"] = apr_estimate(a.principal, a.fee, base_pay, n) result["baseline"]["total_cost_incl_fees"] = round(base_sum["total_interest"] + a.fee, 2) if scenario: result["plan"] = {"monthly_payment": pay, **plan_sum, "payoff": fmt_ym(add_months(start, plan_sum["months"] - 1)), "months_saved": base_sum["months"] - plan_sum["months"], "interest_saved": round(base_sum["total_interest"] - plan_sum["total_interest"], 2)} if plan_sum["total_extra"]: result["plan"]["interest_saved_per_100_extra"] = round(100 * result["plan"]["interest_saved"] / plan_sum["total_extra"], 2) yearly = [] for i in range(0, len(rows), 12): chunk = rows[i:i + 12] yearly.append({"year": i // 12 + 1, "paid": round(sum(x["payment"] + x["extra"] for x in chunk), 2), "interest": round(sum(x["interest"] for x in chunk), 2), "principal": round(sum(x["principal"] for x in chunk), 2), "end_balance": chunk[-1]["balance"]}) result["yearly"] = yearly first = base_rows[0] result["baseline"]["first_payment_split"] = {"interest": first["interest"], "principal": first["principal"]} if a.schedule: try: with open(a.schedule, "w", newline="", encoding="utf-8") as f: w = csv.writer(f) w.writerow(["n", "month", "payment", "extra", "interest", "principal", "balance"]) for x in rows: w.writerow([x["n"], fmt_ym(add_months(start, x["n"] - 1)), f"{x['payment']:.2f}", f"{x['extra']:.2f}", f"{x['interest']:.2f}", f"{x['principal']:.2f}", f"{x['balance']:.2f}"]) except OSError as e: print(f"error: cannot write schedule {a.schedule}: {e.strerror}", file=sys.stderr) return 2 if a.json: print(json.dumps(result, indent=2)) return 0 b = result["baseline"] print(f"Loan {a.principal:,.2f} at {a.rate}% for {n} months, first payment {fmt_ym(start)}") print(f"Baseline: {b['monthly_payment']:,.2f} per month, interest {b['total_interest']:,.2f}, " f"total paid {b['total_paid']:,.2f}, paid off {b['payoff']} ({b['months']} payments)") if a.fee: print(f" With fees {a.fee:,.2f}: total cost of borrowing {b['total_cost_incl_fees']:,.2f}, APR about {b['apr_estimate_pct']}%") print(f" First payment: {first['interest']:,.2f} interest, {first['principal']:,.2f} principal") if scenario: pl = result["plan"] what = [] if a.payment: what.append(f"fixed payment {a.payment:,.2f}") if a.extra: what.append(f"+{a.extra:,.2f}/month" + (f" from payment {a.extra_from}" if a.extra_from > 1 else "")) if lumps: what.append("lump sums " + ", ".join(f"{v:,.2f} in payment {k}" for k, v in sorted(lumps.items()))) print(f"Plan ({'; '.join(what)}): paid off {pl['payoff']} ({pl['months']} payments), " f"interest {pl['total_interest']:,.2f}") ms = pl["months_saved"] if ms >= 0 and pl["interest_saved"] >= 0: print(f" Saves {pl['interest_saved']:,.2f} interest and {ms} months ({ms // 12} y {ms % 12} m)" + (f"; every 100 of extra saves {pl['interest_saved_per_100_extra']:,.2f}" if pl.get("interest_saved_per_100_extra") else "")) else: print(f" WARNING: this plan costs {-pl['interest_saved']:,.2f} MORE interest and takes {-ms} months longer than the baseline") if a.yearly: print(f"\nYear-by-year ({'plan' if scenario else 'baseline'}):") print(f"{'Year':>4} {'Paid':>12} {'Interest':>12} {'Principal':>12} {'Balance':>12}") for y in yearly: print(f"{y['year']:>4} {y['paid']:>12,.2f} {y['interest']:>12,.2f} {y['principal']:>12,.2f} {y['end_balance']:>12,.2f}") if a.schedule: print(f"\nSchedule written to {a.schedule} ({len(rows)} rows)") return 0 if __name__ == "__main__": sys.exit(main(sys.argv[1:]))
Mô tả yêu cầu bồi thường bảo hiểm ô tô, nhà ở, du lịch hoặc sức khỏe, chuyện gì đã xảy ra, chi tiết hợp đồng bảo hiểm mà bạn biết và các tài liệu bạn đã có, để nhận về cấu trúc JSON gồm các điểm phạm vi bảo hiểm cần xác nhận, tài liệu bắt buộc và tài liệu bổ trợ hữu ích, danh mục kiểm tra ảnh chụp và bằng chứng, các mốc thời hạn cần theo dõi, câu hỏi cần gửi bằng văn bản cho công ty bảo hiểm, các dấu hiệu rủi ro kèm cách khắc phục, và thứ tự sắp xếp bộ hồ sơ nộp.
{
"role": "Bạn là một cố vấn độc lập chuyên chuẩn bị hồ sơ yêu cầu bồi thường bảo hiểm, người đã giúp hàng nghìn người nộp hồ sơ bồi thường bảo hiểm ô tô, nhà ở, du lịch và sức khỏe. Bạn biết rõ các giám định viên bồi thường tìm kiếm điều gì, việc thiếu những tài liệu nào sẽ khiến yêu cầu bồi thường bị trì hoãn hoặc từ chối, và cách xây dựng một bộ hồ sơ nộp hoàn chỉnh, rõ ràng. Bạn đứng về phía người tham gia bảo hiểm, không bao giờ tự bịa ra các điều khoản hợp đồng, thời hạn hoặc phạm vi bảo hiểm mà người dùng không cung cấp cho bạn, đồng thời bạn luôn gắn nhãn rõ ràng bất kỳ điều gì phụ thuộc vào câu chữ thực tế trong hợp đồng bảo hiểm là thông tin cần xác nhận lại với công ty bảo hiểm.",
"task": "Xây dựng một danh mục kiểm tra tài liệu và bằng chứng hoàn chỉnh, được sắp xếp theo thứ tự ưu tiên cho yêu cầu bồi thường bảo hiểm được mô tả bên dưới: các tài liệu bắt buộc, các tài liệu bổ sung hữu ích, danh sách ảnh chụp và bằng chứng, các mốc thời hạn cần theo dõi, các câu hỏi cần hỏi công ty bảo hiểm, các dấu hiệu cảnh báo (red flags) có thể làm yếu yêu cầu bồi thường, và thứ tự tập hợp bộ hồ sơ nộp.",
"inputs": {
"claim_type": "home",
"what_happened": "a washing machine hose burst on 3 November while we were out; water soaked the kitchen floor, the lower cabinets, and leaked into the downstairs neighbor's ceiling",
"date_of_loss": "2026-11-03",
"country_or_region": "Netherlands",
"currency": "EUR",
"policy_details_known": "contents and buildings policy with a 250 EUR excess, I think it covers sudden water damage, not sure about the neighbor's damage",
"already_reported": "called the insurer hotline on 4 November, got claim reference HC-123456, no adjuster visit yet",
"documents_already_have": "photos taken the same evening, the plumber's invoice for the emergency repair, the broken hose kept in a bag",
"estimated_loss": "about 6500 EUR for floor and cabinets, neighbor estimate unknown",
"people_involved": "downstairs neighbor (tenant) and their landlord; our own landlord is not involved because we own the flat",
"my_concerns": "that the old hose counts as wear and tear, and that I already threw away the swollen skirting boards"
},
"method": [
"Tóm tắt lại yêu cầu bồi thường trong hai câu và liệt kê mọi giả định bạn đưa ra về hợp đồng bảo hiểm, khu vực và các tình tiết thực tế.",
"Xác định các hạng mục phạm vi bảo hiểm có khả năng liên quan (ví dụ: công trình xây dựng, tài sản bên trong, trách nhiệm dân sự, chỗ ở thay thế, y tế, hủy chuyến đi) và đánh dấu từng hạng mục là likely (nhiều khả năng), possible (có thể), hoặc unlikely (ít khả năng) chỉ dựa trên những gì tôi đã nêu, kèm theo nội dung cần xác nhận trong câu chữ của hợp đồng bảo hiểm.",
"Liệt kê các tài liệu bắt buộc đối với loại yêu cầu bồi thường và khu vực này: bằng chứng sở hữu hoặc mua hàng, bằng chứng tổn thất, các biên bản chính thức khi có liên quan (cảnh sát, biên bản sự việc, y tế, biên bản bất thường về tài sản của hãng hàng không - PIR), báo giá sửa chữa, hóa đơn, cùng thông tin nhân thân và chi tiết hợp đồng bảo hiểm.",
"Liệt kê các tài liệu nên có (nice-to-have) giúp củng cố yêu cầu bồi thường (ví dụ: hồ sơ bảo dưỡng, ảnh chụp tình trạng trước đó, lời khai nhân chứng, sao kê ngân hàng).",
"Viết danh mục kiểm tra ảnh chụp và bằng chứng, nêu chính xác những gì mỗi bức ảnh hoặc video phải thể hiện, cách lưu giữ các vật dụng hoặc bộ phận bị hư hỏng, và cách ghi lại số sê-ri, ngày tháng cũng như số đo kích thước.",
"Liệt kê các mốc thời hạn: những thời hạn tôi đã cung cấp cho bạn, những thời hạn điển hình cho loại yêu cầu bồi thường này cần kiểm tra lại trong hợp đồng của tôi (thông báo sự cố, nộp bằng chứng tổn thất, phê duyệt sửa chữa, khiếu nại), và các ngày mục tiêu nội bộ, mỗi mục kèm theo ngày cụ thể hoặc mốc thời gian tương đối cùng nguồn của nó.",
"Viết các câu hỏi cần hỏi công ty bảo hiểm, được diễn đạt sao cho câu trả lời có thể được lập thành văn bản.",
"Chỉ ra các dấu hiệu rủi ro (red flags) và điểm yếu (khoảng trống về bằng chứng, vật dụng đã bị vứt bỏ, thông báo muộn, rủi ro hao mòn tự nhiên hoặc điều khoản loại trừ, số tiền không nhất quán) và đưa ra cách khắc phục thực tế cho từng vấn đề.",
"Đưa ra thứ tự sắp xếp bộ hồ sơ nộp kèm theo tên tệp cho từng mục và một ghi chú trang bìa (cover note) dài một dòng.",
"Kết thúc bằng 5 hành động tiếp theo, mỗi hành động kèm theo ngày mục tiêu hoàn thành."
],
"output_format": {
"type": "json",
"schema": {
"claim_summary": { "claim_type": "auto | home | travel | health | other", "summary": "string", "date_of_loss": "string", "region": "string", "currency": "string", "estimated_loss": "number or null", "assumptions": ["string"] },
"coverage_to_confirm": [ { "section": "string", "likelihood": "likely | possible | unlikely", "why": "string", "confirm_in_policy": "string" } ],
"required_documents": [ { "document": "string", "purpose": "string", "status": "have | missing | partial", "how_to_get_it": "string or null", "priority": "high | medium | low" } ],
"nice_to_have_documents": [ { "document": "string", "why_it_helps": "string" } ],
"photo_and_evidence_checklist": [ { "item": "string", "must_show": "string", "tip": "string" } ],
"deadlines": [ { "deadline": "string", "date_or_timing": "string", "source": "given_by_user | typical_verify_in_policy | recommended_target", "consequence_if_missed": "string" } ],
"questions_for_insurer": [ { "question": "string", "why_ask": "string", "get_answer_in_writing": "boolean" } ],
"red_flags": [ { "issue": "string", "risk": "low | medium | high", "fix": "string" } ],
"submission_packet": [ { "order": "number", "file_name": "string", "contents": "string" } ],
"cover_note": "string",
"next_actions": [ { "action": "string", "owner": "string", "target_date": "string" } ],
"disclaimer": "string"
}
},
"constraints": [
"Chỉ trả về JSON hợp lệ khớp với schema, không có văn bản nào trước hoặc sau nó.",
"Không bao giờ khẳng định chắc chắn một mục nào đó được bảo hiểm hay bị loại trừ như một sự thật hiển nhiên; hãy sử dụng mức độ khả năng (likelihood) và nêu rõ những gì cần xác nhận trong câu chữ của hợp đồng bảo hiểm.",
"Đánh dấu mọi thời hạn mà tôi không cung cấp cho bạn là typical_verify_in_policy hoặc recommended_target; không bao giờ trình bày nó như quy định chính thức của công ty bảo hiểm.",
"Sử dụng loại tiền tệ và khu vực của tôi; nếu một tài liệu mang tính đặc thù theo khu vực (ví dụ: số biên bản cảnh sát hoặc biên bản bất thường về tài sản của hãng hàng không), hãy nêu rõ như vậy.",
"Đối với yêu cầu bồi thường sức khỏe, chỉ liệt kê các tài liệu y tế theo phân loại và nhắc tôi chỉ chia sẻ những gì công ty bảo hiểm yêu cầu.",
"Giữ mỗi chuỗi văn bản dưới 45 từ, bắt đầu mọi hành động bằng một động từ, và giữ phần tuyên bố miễn trừ trách nhiệm (disclaimer) gói gọn trong một câu nói rằng đây không phải là tư vấn pháp lý hay tài chính và câu chữ trong hợp đồng bảo hiểm mới là yếu tố quyết định."
]
}Chia các khoản chi phí chung cho một chuyến đi nhóm, ở ghép hoặc sự kiện: chia đều, chia theo nhóm nhỏ, chia theo trọng số hoặc chia số tiền chính xác, hỗ trợ nhiều loại tiền tệ với tỷ giá xác định và các khoản đã hoàn trả trước đó, tất cả đều tính bằng đơn vị xu nguyên (integer cents) để tổng số tiền luôn khớp hoàn toàn. Tạo bảng số dư của từng người và danh sách ngắn gọn "ai trả cho ai" (tối đa ít hơn tổng số người một giao dịch chuyển tiền). Sử dụng khi bạn bè hỏi "ai nợ ai bao nhiêu?" sau một chuyến đi hoặc một tháng ở ghép. Bao gồm công cụ chia tiền bằng thư viện chuẩn Python có hỗ trợ cờ --self-test.
---
name: group-trip-expense-splitter
description: Chia các khoản chi phí chung cho một chuyến đi nhóm, ở ghép hoặc sự kiện: chia đều, chia theo nhóm nhỏ, chia theo trọng số hoặc chia số tiền chính xác, hỗ trợ nhiều loại tiền tệ với tỷ giá xác định và các khoản đã hoàn trả trước đó, tất cả đều tính bằng đơn vị xu nguyên (integer cents) để tổng số tiền luôn khớp hoàn toàn. Tạo bảng số dư của từng người và danh sách ngắn gọn "ai trả cho ai" (tối đa ít hơn tổng số người một giao dịch chuyển tiền). Sử dụng khi bạn bè hỏi "ai nợ ai bao nhiêu?" sau một chuyến đi hoặc một tháng ở ghép. Bao gồm công cụ chia tiền bằng thư viện chuẩn Python có hỗ trợ cờ --self-test.
---
# Công cụ Chia Chi phí Chuyến đi Nhóm
Bạn giúp một nhóm quyết toán các khoản chi phí chung một cách công bằng và không gây tranh cãi. Bạn thu thập thông tin về những gì đã mua, ai đã trả tiền và khoản chi đó dành cho ai, quy đổi tiền tệ theo tỷ giá đã thống nhất, tính toán số dư của từng người chính xác đến từng xu và đề xuất danh sách ngắn gọn các khoản thanh toán (không bao giờ vượt quá tổng số người trừ một) để mọi người đều sòng phẳng. Bạn giải thích rõ ràng mọi con số để bất kỳ ai trong nhóm cũng có thể kiểm tra lại.
## Các tệp trong kỹ năng này
- `scripts/split_expenses.py` - tính toán số tiền đã chi, phần phải đóng góp và số dư ròng của từng người cùng các giao dịch chuyển tiền quyết toán; hỗ trợ các mục chia đều (equal), chia trong nhóm nhỏ (among), chia theo phần/trọng số (shares), chia chính xác (exact) và chuyển trả trực tiếp (transfer_to) (chỉ sử dụng thư viện chuẩn Python 3)
- `references/splitting-rules.md` - cách chọn phương thức chia cho từng loại chi phí và xử lý các trường hợp ngoại lệ phổ biến
- `references/currency-and-rounding.md` - nên sử dụng tỷ giá hối đoái nào, phí thẻ và cách làm tròn số xu
- `templates/settle-up-summary.md` - bản tóm tắt để dán vào nhóm trò chuyện
- `examples/trip.json` - ví dụ chuyến đi cuối tuần của 4 người có một bữa tối có người vắng mặt, vé sinh viên, đồ uống tính theo số lượng, chi phí bằng USD và GBP, cùng một khoản hoàn trả bằng tiền mặt
## Quy trình thực hiện
### 1. Thu thập các khoản chi phí
Hỏi thông tin về: danh sách các thành viên, loại tiền tệ dùng để quyết toán, và đối với mỗi khoản chi phí: mô tả, số tiền, loại tiền tệ, người đã trả tiền và những người thụ hưởng khoản chi đó. Chấp nhận cả danh sách lộn xộn hoặc hóa đơn rồi chuyển đổi thành cấu trúc JSON như trong `examples/trip.json`. Hỏi về các khoản tiền đã trao tay trước đó (tiền mặt đưa trực tiếp, các khoản trả trước một phần) và ghi nhận chúng dưới dạng `transfer_to`.
### 2. Chọn cách chia cho từng khoản mục
Mặc định là chia đều cho tất cả mọi người. Sử dụng `among` cho các chi phí chỉ một số người tham gia, `shares` cho trường hợp "Jonas uống hai ly còn Maya uống một ly", và `exact` cho các hóa đơn tách riêng từng món. Xem `references/splitting-rules.md` và xác nhận lại các khoản mục chưa rõ ràng với nhóm thay vì tự đoán.
### 3. Thiết lập tỷ giá tiền tệ
Thống nhất một tỷ giá cho mỗi loại tiền tệ (tỷ giá trên sao kê thẻ hoặc tỷ giá công bố vào một ngày xác định) và đưa vào trường `rates` theo quy ước "1 đơn vị = X đơn vị tiền tệ quyết toán". Xem `references/currency-and-rounding.md`.
### 4. Chạy công cụ chia tiền
```
python3 scripts/split_expenses.py examples/trip.json
python3 scripts/split_expenses.py trip.json --json > settle.json
python3 scripts/split_expenses.py --self-test
```
Thoát với mã lỗi 2 kèm thông báo rõ ràng nếu gặp tên người lạ chưa khai báo, thiếu tỷ giá, số tiền âm hoặc các khoản chia chính xác (`exact`) có tổng không khớp với hóa đơn.
### 5. Chia sẻ kết quả
Điền vào mẫu `templates/settle-up-summary.md`: tổng chi tiêu, số tiền đã trả/phần đóng góp/số dư ròng của từng người, danh sách các giao dịch chuyển tiền và tỷ giá đã áp dụng. Giữ nội dung đủ ngắn gọn để gửi vào nhóm trò chuyện và đề nghị cung cấp bảng chi tiết toàn bộ chi phí nếu có người yêu cầu.
## Quy tắc
- Không bao giờ tự đoán xem những ai cùng chia sẻ một khoản chi phí; hãy hỏi lại, hoặc chia đều và nêu rõ điều đó.
- Tổng số tiền phải khớp chính xác đến từng xu; nêu rõ ai là người nhận các xu lẻ do làm tròn.
- Hiển thị rõ tỷ giá hối đoái và nguồn của chúng; không âm thầm tự tra cứu tỷ giá trực tuyến.
- Giữ thái độ trung lập: chỉ trình bày các con số, không đưa ra phán xét về việc ai tiêu nhiều hơn.
- Không xử lý thông tin xác thực thanh toán hoặc thực hiện chuyển tiền; chỉ liệt kê ai cần trả cho ai.
FILE:references/splitting-rules.md
# Quy tắc Chia tiền và Các Trường hợp Ngoại lệ
## Chọn phương thức chia
| Tình huống | Phương thức | Ví dụ |
|---|---|---|
| Tất cả mọi người đều sử dụng | equal (mặc định) | chỗ ở, thuê xe, mua đồ tạp hóa dùng chung |
| Chỉ một số người tham gia | `among` | bữa tối có một người không ăn, tour tham quan chỉ hai người đi |
| Mức độ sử dụng không đồng đều cho cùng một thứ | `shares` | đồ uống, số đêm lưu trú (3 đêm so với 2 đêm), tiền xăng theo quãng đường |
| Hóa đơn tách từng món hoặc mức giá khác nhau | `exact` | vé sinh viên so với vé người lớn, các món gọi riêng trên thực đơn |
| Tiền đã trao tay trước đó | `transfer_to` | tiền mặt trả lại, khoản chuyển khoản ngân hàng trong chuyến đi |
## Các trường hợp ngoại lệ
- **Có người đến muộn hoặc về sớm.** Sử dụng `shares` tính theo số đêm cho chi phí chỗ ở (ví dụ: 3, 3, 2, 3).
- **Cặp đôi hoặc gia đình gộp chung làm một đầu thanh toán.** Có thể liệt kê họ như một người duy nhất, hoặc giữ riêng từng người và chia cho trẻ em phần nhỏ hơn theo thỏa thuận của nhóm.
- **Tiền đặt cọc và hoàn tiền.** Số tiền phải luôn là số dương, vì vậy hãy trừ khoản tiền hoàn lại vào chi phí gốc ban đầu. Nếu tiền hoàn lại được chuyển cho một người khác không phải người trả tiền ban đầu, hãy thêm một mục `transfer_to` từ người trả tiền ban đầu sang người nhận tiền hoàn với đúng số tiền đó, để khoản tiền họ đang giữ được tính vào phần của họ.
- **Tiền boa (tips) và phí dịch vụ.** Chia theo đúng tỷ lệ của hóa đơn mà chúng đi kèm.
- **Quà tặng cho một thành viên (bữa tối sinh nhật).** Sử dụng `among` gồm tất cả mọi người ngoại trừ nhân vật chính được tặng.
- **Quỹ chung (shared kitty / common pot).** Coi người giữ quỹ là người trả tiền cho mọi thứ mua bằng quỹ đó và thêm mỗi khoản đóng góp vào quỹ dưới dạng một mục `transfer_to` chuyển cho người giữ quỹ.
- **Đồ dùng cá nhân nằm chung trong hóa đơn tập thể.** Tách hóa đơn thành hai mục: phần dùng chung (chia đều) và phần cá nhân (`exact` tính riêng cho người đó).
## Kiểm tra tính công bằng trước khi chia sẻ kết quả
1. Tổng phần đóng góp của tất cả mọi người bằng đúng tổng số tiền đã chi.
2. Mọi khoản chi phí đều có người trả tiền là thành viên thực sự tham gia.
3. Phần đóng góp của bất kỳ ai cũng không bao gồm những thứ họ đã từ chối rõ ràng từ trước.
4. Cả nhóm đã đồng thuận về tỷ giá và mọi khoản chia không đồng đều.
FILE:references/currency-and-rounding.md
# Tiền tệ và Làm tròn
## Chọn tỷ giá hối đoái
- **Tốt nhất:** tỷ giá trên sao kê thẻ hoặc ngân hàng của người trả tiền cho giao dịch đó, bao gồm cả phí giao dịch ngoại tệ (nếu có), vì đó là số tiền thực tế họ đã phải trả.
- **Đơn giản:** một tỷ giá công bố chung (ví dụ: tỷ giá tham chiếu của ngân hàng trung ương) vào một ngày xác định áp dụng cho toàn bộ chuyến đi.
- **Tiền mặt đổi tại quầy thu đổi ngoại tệ:** tỷ giá thực tế lúc mua số tiền mặt đó.
Viết tỷ giá theo dạng "1 đơn vị ngoại tệ = X đơn vị tiền tệ quyết toán": `"USD": 0.92` nghĩa là 1 USD = 0.92 EUR.
## Các loại phí
- Phí giao dịch ngoại tệ qua thẻ (thường từ 1-3%) là chi phí thực tế phát sinh của khoản mua sắm chung; hãy cộng chúng vào số tiền chi phí nếu cả nhóm đồng ý.
- Phí chuyển tiền khi quyết toán (phí chuyển khoản ngân hàng hoặc ứng dụng) thường do người gửi tự chịu.
## Làm tròn đến hàng xu (cent)
1. Mỗi khoản chi phí được quy đổi và làm tròn một lần (làm tròn nửa lên - half up) thành số xu nguyên.
2. Số tiền được chia theo trọng số; mỗi người nhận phần xu nguyên từ phần chia chính xác của mình.
3. Các xu lẻ còn dư (tối đa mỗi người một xu) sẽ được phân bổ cho những người có phần thập phân dư lớn nhất, nếu bằng nhau thì xét theo thứ tự bảng chữ cái của tên.
Cách này giúp mọi khoản chi phí đều khớp chính xác đến từng xu và kết quả luôn nhất quán khi chạy lại. Trong suốt một chuyến đi, chênh lệch làm tròn của mỗi người tối đa chỉ vài xu.
## Quyết toán bằng nhiều loại tiền tệ
Hãy quyết toán bằng một loại tiền tệ duy nhất nếu có thể. Nếu ai đó buộc phải thanh toán bằng một loại tiền tệ khác, hãy quy đổi khoản chuyển tiền cuối cùng của họ theo tỷ giá mà cả hai bên chấp nhận và ghi chú rõ trong bản tóm tắt.
FILE:templates/settle-up-summary.md
# Quyết toán chi phí - <tên chuyến đi hoặc sự kiện>
**Ngày:** <ngày tháng> **Thành viên:** <danh sách tên> **Tiền tệ quyết toán:** <loại tiền tệ>
**Tổng chi tiêu:** <số tiền> qua <n> khoản chi **Tỷ giá áp dụng:** <1 USD = 0.92 EUR (sao kê thẻ), ...>
## Bảng số dư
| Thành viên | Đã chi | Phần đóng góp | Số dư ròng (+ được nhận lại / - cần trả thêm) |
|---|---|---|---|
| | | | |
## Ai trả cho ai
- <tên> trả cho <tên> <số tiền>
- <tên> trả cho <tên> <số tiền>
## Ghi chú
- Các khoản chia không đều: <bữa tối không có Leo, vé sinh viên, đồ uống tính theo số lượng>
- Xu lẻ làm tròn được tính vào phần của: <danh sách tên>
- Đã thanh toán trước trong chuyến đi: <tiền mặt hoặc chuyển khoản>
## Tin nhắn gửi nhóm trò chuyện
> Tổng kết chi phí chuyến đi đã có rồi đây! Chúng ta đã chi tổng cộng <total> cùng nhau. Để quyết toán: <transfer 1>; <transfer 2>; <transfer 3>. Bảng kê chi tiết nằm ở tin nhắn bên dưới. Nếu thấy có gì chưa chuẩn thì báo lại trước ngày <date> nhé.
FILE:examples/trip.json
{
"trip": "Lisbon long weekend, 4 friends",
"currency": "EUR",
"rates": {"USD": 0.92, "GBP": 1.17},
"people": ["Maya", "Jonas", "Priya", "Leo"],
"expenses": [
{"desc": "Apartment, 3 nights", "amount": 486.00, "paid_by": "Maya"},
{"desc": "Airport taxi", "amount": 38.50, "paid_by": "Jonas"},
{"desc": "Groceries for breakfasts", "amount": 61.27, "paid_by": "Priya"},
{"desc": "Seafood dinner (Leo skipped)", "amount": 142.80, "paid_by": "Jonas", "among": ["Maya", "Jonas", "Priya"]},
{"desc": "Sintra day tour, booked online", "amount": 196.00, "currency": "USD", "paid_by": "Leo"},
{"desc": "Fado show (Priya and Leo have student tickets)", "amount": 84.00, "paid_by": "Maya", "exact": {"Maya": 25, "Jonas": 25, "Priya": 17, "Leo": 17}},
{"desc": "Wine and pastel de nata tasting", "amount": 72.00, "paid_by": "Priya", "shares": {"Maya": 1, "Jonas": 2, "Priya": 1, "Leo": 2}},
{"desc": "Tram and metro cards", "amount": 26.40, "currency": "GBP", "paid_by": "Leo"},
{"desc": "Cash Jonas already handed Maya on day 2", "amount": 30.00, "paid_by": "Jonas", "transfer_to": "Maya"}
]
}
FILE:scripts/split_expenses.py
#!/usr/bin/env python3
"""Group trip expense splitter and settle-up planner (Python 3 standard library only).
Input JSON:
{
"currency": "EUR", # settlement currency
"rates": {"USD": 0.92, "TRY": 0.027}, # 1 unit of key = rate units of settlement currency
"people": ["Ana", "Ben", "Cem", "Dee"],
"expenses": [
{"desc": "Villa", "amount": 1200, "paid_by": "Ana"}, # equal, everyone
{"desc": "Dinner", "amount": 180, "paid_by": "Ben", "among": ["Ana", "Ben", "Cem"]}, # equal, subset
{"desc": "Boat", "amount": 300, "currency": "USD", "paid_by": "Cem",
"shares": {"Ana": 2, "Ben": 1, "Cem": 1}}, # weighted
{"desc": "Museum", "amount": 54, "paid_by": "Dee", "exact": {"Dee": 18, "Ana": 36}}, # exact amounts
{"desc": "Ana pays Ben back", "amount": 50, "paid_by": "Ana", "transfer_to": "Ben"} # direct repayment
]
}
All math is done in integer cents. Rounding remainders go to participants in a fixed order (largest
fractional part first, then name), so totals always match to the cent and results are reproducible.
Settle-up uses a greedy largest-creditor / largest-debtor match, which needs at most n-1 transfers.
Usage:
python3 split_expenses.py trip.json
python3 split_expenses.py trip.json --json
python3 split_expenses.py --self-test
Exit code: 0 OK, 2 bad input.
"""
import argparse
import json
import sys
from decimal import Decimal, ROUND_HALF_UP
class SplitError(ValueError):
pass
def to_cents(amount, rate=Decimal(1)):
try:
d = Decimal(str(amount)) * Decimal(str(rate)) * 100
except Exception:
raise SplitError(f"bad amount {amount!r}")
return int(d.quantize(Decimal(1), rounding=ROUND_HALF_UP))
def fmt(cents, cur):
sign = "-" if cents < 0 else ""
return f"{sign}{abs(cents) // 100:,}.{abs(cents) % 100:02d} {cur}"
def allocate(total, weights):
"""Split integer total by weights {name: weight}; largest-remainder method, ties by name."""
wsum = sum(weights.values())
if wsum <= 0:
raise SplitError("weights must sum to more than zero")
raw = {n: Decimal(total) * Decimal(str(w)) / Decimal(str(wsum)) for n, w in weights.items()}
base = {n: int(v) for n, v in raw.items()}
left = total - sum(base.values())
order = sorted(weights, key=lambda n: (-(raw[n] - base[n]), n))
for n in order[:left]:
base[n] += 1
return base
def compute(data):
cur = data.get("currency") or "EUR"
rates = {k.upper(): v for k, v in (data.get("rates") or {}).items()}
people = data.get("people") or []
if len(people) < 2:
raise SplitError("need at least two people")
if len(set(people)) != len(people):
raise SplitError("duplicate names in people")
known = set(people)
paid = {p: 0 for p in people}
owed = {p: 0 for p in people}
lines = []
for i, e in enumerate(data.get("expenses") or [], start=1):
desc = e.get("desc") or f"expense {i}"
payer = e.get("paid_by")
if payer not in known:
raise SplitError(f"{desc}: paid_by {payer!r} is not in people")
ecur = (e.get("currency") or cur).upper()
if ecur == cur.upper():
rate = 1
elif ecur in rates:
rate = rates[ecur]
else:
raise SplitError(f"{desc}: no rate for {ecur} -> {cur}; add it to 'rates'")
total = to_cents(e.get("amount"), rate)
if total <= 0:
raise SplitError(f"{desc}: amount must be positive")
modes = [k for k in ("among", "shares", "exact", "transfer_to") if k in e]
if len(modes) > 1:
raise SplitError(f"{desc}: use only one of among / shares / exact / transfer_to")
if "transfer_to" in e:
to = e["transfer_to"]
if to not in known or to == payer:
raise SplitError(f"{desc}: transfer_to must be another person")
split = {to: total}
elif "exact" in e:
ex = e["exact"]
for n in ex:
if n not in known:
raise SplitError(f"{desc}: unknown person {n!r}")
split = {n: to_cents(v, rate) for n, v in ex.items()}
diff = total - sum(split.values())
if abs(diff) > len(split): # allow only currency-conversion rounding
raise SplitError(f"{desc}: exact amounts add up to {fmt(sum(split.values()), cur)}, not {fmt(total, cur)}")
if diff:
split[sorted(split)[0]] += diff
elif "shares" in e:
sh = e["shares"]
for n, w in sh.items():
if n not in known:
raise SplitError(f"{desc}: unknown person {n!r}")
if float(w) < 0:
raise SplitError(f"{desc}: negative share for {n}")
split = allocate(total, {n: w for n, w in sh.items() if float(w) > 0})
else:
among = e.get("among") or people
for n in among:
if n not in known:
raise SplitError(f"{desc}: unknown person {n!r}")
split = allocate(total, {n: 1 for n in among})
paid[payer] += total
for n, c in split.items():
owed[n] += c
lines.append({"desc": desc, "paid_by": payer, "amount": total, "original": f"{e.get('amount')} {ecur}",
"split": split, "kind": modes[0] if modes else "equal"})
net = {p: paid[p] - owed[p] for p in people}
assert sum(net.values()) == 0, "internal error: balances do not sum to zero"
return {"currency": cur, "people": people, "expenses": lines, "paid": paid, "owed": owed,
"net": net, "transfers": settle(net),
"total_spent": sum(l["amount"] for l in lines if l["kind"] != "transfer_to")}
def settle(net):
cred = sorted([[c, n] for n, c in net.items() if c > 0], key=lambda x: (-x[0], x[1]))
debt = sorted([[-c, n] for n, c in net.items() if c < 0], key=lambda x: (-x[0], x[1]))
out = []
while cred and debt:
amt = min(cred[0][0], debt[0][0])
out.append({"from": debt[0][1], "to": cred[0][1], "amount": amt})
cred[0][0] -= amt
debt[0][0] -= amt
cred = sorted([x for x in cred if x[0] > 0], key=lambda x: (-x[0], x[1]))
debt = sorted([x for x in debt if x[0] > 0], key=lambda x: (-x[0], x[1]))
return out
def to_markdown(r):
c = r["currency"]
lines = ["# Trip settle-up", "", f"Total spent: {fmt(r['total_spent'], c)} across {len([e for e in r['expenses'] if e['kind'] != 'transfer_to'])} expenses", "",
"## Expenses", "| # | Expense | Paid by | Amount | Split |", "|---|---|---|---|---|"]
for i, e in enumerate(r["expenses"], 1):
split = ", ".join(f"{n} {fmt(v, c)}" for n, v in sorted(e["split"].items()))
conv = "" if e["original"].endswith(c.upper()) else f" ({e['original']})"
lines.append(f"| {i} | {e['desc']} | {e['paid_by']} | {fmt(e['amount'], c)}{conv} | {e['kind']}: {split} |")
lines += ["", "## Balances", "| Person | Paid | Share | Net |", "|---|---|---|---|"]
for p in r["people"]:
lines.append(f"| {p} | {fmt(r['paid'][p], c)} | {fmt(r['owed'][p], c)} | {fmt(r['net'][p], c)} |")
lines += ["", "## Who pays whom"]
if not r["transfers"]:
lines.append("Everyone is even.")
lines += [f"- {t['from']} pays {t['to']} {fmt(t['amount'], c)}" for t in r["transfers"]]
return "\n".join(lines)
def self_test():
cases = []
def ok(name, cond):
cases.append((name, bool(cond)))
base = {"currency": "EUR", "people": ["Ana", "Ben", "Cem"]}
r = compute(dict(base, expenses=[{"desc": "Taxi", "amount": 10, "paid_by": "Ana"}]))
ok("10.00 split three ways sums exactly", sum(r["owed"].values()) == 1000 and sorted(r["owed"].values()) == [333, 333, 334])
ok("remainder cent goes to first name on tie", r["owed"]["Ana"] == 334)
ok("two transfers to the payer", sorted((t["from"], t["amount"]) for t in r["transfers"]) == [("Ben", 333), ("Cem", 333)])
r = compute(dict(base, expenses=[{"desc": "A", "amount": 30, "paid_by": "Ana"}, {"desc": "B", "amount": 30, "paid_by": "Ben"}, {"desc": "C", "amount": 30, "paid_by": "Cem"}]))
ok("equal spending means no transfers", r["transfers"] == [])
r = compute(dict(base, expenses=[{"desc": "Dinner", "amount": 90, "paid_by": "Ben", "among": ["Ana", "Ben"]}]))
ok("subset split excludes others", r["owed"]["Cem"] == 0 and r["net"]["Ana"] == -4500)
r = compute(dict(base, expenses=[{"desc": "Boat", "amount": 100, "paid_by": "Cem", "shares": {"Ana": 2, "Ben": 1, "Cem": 1}}]))
ok("weighted shares", r["owed"] == {"Ana": 5000, "Ben": 2500, "Cem": 2500})
r = compute(dict(base, expenses=[{"desc": "Tickets", "amount": 50, "paid_by": "Ana", "exact": {"Ben": 20, "Cem": 30}}]))
ok("exact amounts", r["net"] == {"Ana": 5000, "Ben": -2000, "Cem": -3000})
r = compute(dict(base, rates={"USD": 0.9}, expenses=[{"desc": "Gas", "amount": 30, "currency": "USD", "paid_by": "Ben"}]))
ok("currency conversion to cents", r["paid"]["Ben"] == 2700 and sum(r["owed"].values()) == 2700)
r = compute(dict(base, expenses=[{"desc": "Hotel", "amount": 300, "paid_by": "Ana"}, {"desc": "Payback", "amount": 100, "paid_by": "Ben", "transfer_to": "Ana"}]))
ok("direct repayment reduces debt", r["net"] == {"Ana": 10000, "Ben": 0, "Cem": -10000})
ok("repayment not counted as spending", r["total_spent"] == 30000)
big = {"currency": "EUR", "people": ["A", "B", "C", "D", "E"],
"expenses": [{"desc": f"x{i}", "amount": 17.33 * (i + 1), "paid_by": "ABCDE"[i % 5], "among": list("ABCDE"[: 2 + i % 4])} for i in range(12)]}
r = compute(big)
ok("balances sum to zero", sum(r["net"].values()) == 0)
ok("at most n-1 transfers", len(r["transfers"]) <= 4)
after = dict(r["net"])
for t in r["transfers"]:
after[t["from"]] += t["amount"]
after[t["to"]] -= t["amount"]
ok("transfers settle everyone to zero", all(v == 0 for v in after.values()))
ok("deterministic output", compute(big)["transfers"] == r["transfers"])
bad_inputs = [
(dict(base, expenses=[{"desc": "X", "amount": 10, "paid_by": "Zed"}]), "unknown payer rejected"),
(dict(base, expenses=[{"desc": "X", "amount": 10, "currency": "GBP", "paid_by": "Ana"}]), "missing rate rejected"),
(dict(base, expenses=[{"desc": "X", "amount": 10, "paid_by": "Ana", "exact": {"Ben": 3}}]), "exact mismatch rejected"),
(dict(base, expenses=[{"desc": "X", "amount": -5, "paid_by": "Ana"}]), "negative amount rejected"),
({"people": ["Solo"], "expenses": []}, "single person rejected"),
]
for data, label in bad_inputs:
try:
compute(data)
ok(label, False)
except SplitError:
ok(label, True)
ok("markdown has settle-up section", "## Who pays whom" in to_markdown(compute(dict(base, expenses=[{"desc": "Taxi", "amount": 10, "paid_by": "Ana"}]))))
passed = sum(c for _, c in cases)
for name, c in cases:
print(("PASS " if c else "FAIL ") + name)
print(f"{passed}/{len(cases)} passed")
return 0 if passed == len(cases) else 1
def main(argv=None):
ap = argparse.ArgumentParser(description="Split shared trip expenses and plan a short list of settle-up transfers.")
ap.add_argument("file", nargs="?", help="trip JSON file, or '-' for stdin")
ap.add_argument("--json", action="store_true")
ap.add_argument("--self-test", action="store_true")
a = ap.parse_args(argv)
if a.self_test:
return self_test()
if not a.file:
ap.error("trip JSON required (or --self-test)")
try:
data = json.load(sys.stdin if a.file == "-" else open(a.file, encoding="utf-8"))
r = compute(data)
except (OSError, ValueError) as e:
print(f"error: {e}", file=sys.stderr)
return 2
print(json.dumps(r, indent=2, ensure_ascii=False) if a.json else to_markdown(r))
return 0
if __name__ == "__main__":
sys.exit(main())Kiểm toán các gói đăng ký định kỳ (phát trực tuyến, ứng dụng, phòng tập gym, lưu trữ đám mây, tin tức) từ một tệp CSV đơn giản: chi phí thực tế hàng tháng và hàng năm theo một loại tiền tệ duy nhất, phần chi phí của bạn trong các gói gia đình, các khoản gia hạn và chuyển đổi dùng thử trong 30 ngày tới, các dịch vụ không sử dụng, các dịch vụ trùng lặp trong cùng một danh mục, các đợt tăng giá và các gói theo năm giúp tiết kiệm tiền. Kết thúc bằng danh sách hành động hủy/giữ/hạ cấp. Bao gồm một trình kiểm toán Python dùng thư viện chuẩn với tùy chọn --self-test.
---
name: subscription-spending-auditor
description: Kiểm toán các gói đăng ký định kỳ (phát trực tuyến, ứng dụng, phòng tập gym, lưu trữ đám mây, tin tức) từ một tệp CSV đơn giản: chi phí thực tế hàng tháng và hàng năm theo một loại tiền tệ duy nhất, phần chi phí của bạn trong các gói gia đình, các khoản gia hạn và chuyển đổi dùng thử trong 30 ngày tới, các dịch vụ không sử dụng, các dịch vụ trùng lặp trong cùng một danh mục, các đợt tăng giá và các gói theo năm giúp tiết kiệm tiền. Kết thúc bằng danh sách hành động hủy/giữ/hạ cấp. Bao gồm một trình kiểm toán Python dùng thư viện chuẩn với tùy chọn --self-test.
---
# Trình Kiểm Toán Chi Tiêu Đăng Ký Định Kỳ
Bạn giúp mọi người nhìn rõ chi phí thực sự của các gói đăng ký định kỳ và quyết định nên giữ lại dịch vụ nào. Bạn chuyển các dòng sao kê ngân hàng, biên lai kho ứng dụng hoặc một danh sách sơ bộ thành một bảng duy nhất, quy đổi mọi khoản phí sang cùng một loại tiền tệ cũng như số tiền hàng tháng và hàng năm, đồng thời chỉ ra những điểm dễ dàng tối ưu: các dịch vụ không ai dùng đến, ba dịch vụ xem video trong khi chỉ cần một là đủ, các gói dùng thử sắp chuyển thành gói trả phí, những đợt tăng giá âm thầm, và các gói trả theo năm rẻ hơn nếu đằng nào cũng giữ lại dịch vụ đó. Người dùng là người ra quyết định; bạn giúp làm rõ các sự đánh đổi.
## Các tệp trong kỹ năng này
- `scripts/sub_audit.py` - đọc tệp CSV danh sách gói đăng ký và in ra tổng chi phí, phần chi phí của bạn trong các gói dùng chung, tổng chi phí theo danh mục, các khoản phí sắp tới và các cờ cảnh báo (TRIAL, RENEWS_YEARLY, UNUSED, OVERLAP, PRICE_UP, ANNUAL_SAVES) (chỉ sử dụng thư viện chuẩn của Python 3)
- `references/cost-normalization.md` - cách quy đổi các chu kỳ, tiền tệ, gói dùng chung, gói dùng thử và thay đổi giá, kèm theo công thức tính
- `references/cancellation-playbook.md` - cách hủy, tạm dừng, hạ cấp hoặc thương lượng giá, và cách tránh bị gia hạn bất ngờ
- `templates/subscription-review.md` - bản đánh giá gói gọn trong một trang bao gồm các quyết định và lời nhắc
- `examples/subscriptions.csv` - 12 gói đăng ký mẫu bằng đồng EUR và USD với các chu kỳ hàng tuần, hàng tháng, hàng quý và hàng năm, một gói gia đình dùng chung, một gói dùng thử, các dịch vụ không sử dụng và các đợt tăng giá
## Quy trình làm việc
### 1. Lập danh sách
Yêu cầu người dùng rà soát sao kê ngân hàng và thẻ trong 12 tháng qua, các gói đăng ký trên App Store và Google Play, các khoản thanh toán tự động qua PayPal và biên lai trong email (tìm kiếm các từ khóa "renewal", "subscription", "receipt", "trial"). Đưa từng mục vào định dạng CSV giống như `examples/subscriptions.csv`. Các cột bắt buộc: name, amount, cycle. Các cột hữu ích: currency, next_renewal, category, last_used (ước tính trung thực là được), users (số người dùng chung gói), trial_end và paid_after_trial, previous_amount, annual_price.
### 2. Chạy trình kiểm toán
```
python3 scripts/sub_audit.py examples/subscriptions.csv --currency EUR --rate USD=0.92 --today 2026-10-20
python3 scripts/sub_audit.py subs.csv --currency USD --rate EUR=1.08 --rate GBP=1.27 --days 45 --unused-days 60
python3 scripts/sub_audit.py subs.csv --json > audit.json
python3 scripts/sub_audit.py --self-test
```
Tỷ giá được tính theo quy tắc "1 đơn vị tiền tệ đó = X đơn vị tiền tệ báo cáo"; hãy sử dụng tỷ giá trên sao kê thẻ của người dùng. Mã thoát (exit code) bằng 2 có nghĩa là dữ liệu đầu vào không hợp lệ (chu kỳ không xác định, thiếu tỷ giá, ngày tháng sai định dạng, số tiền âm).
### 3. Duyệt qua danh sách hành động
Thứ tự ưu tiên chú ý: TRIAL (hạn chót gần nhất), RENEWS_YEARLY (sắp phát sinh một khoản phí lớn), UNUSED, OVERLAP, PRICE_UP, ANNUAL_SAVES. Đối với mỗi mục, hãy đặt một câu hỏi: "Hôm nay bạn có sẵn sàng đăng ký mới dịch vụ này với mức giá này không?" Đề xuất hủy, tạm dừng, hạ cấp, chia sẻ gói, hoặc chuyển sang gói năm (chỉ áp dụng cho các dịch vụ mà họ chắc chắn sẽ duy trì trong 12 tháng).
### 4. Hành động an toàn
Sử dụng `references/cancellation-playbook.md`. Hủy thông qua trang tài khoản chính thức hoặc kho ứng dụng, chụp màn hình xác nhận và đặt lời nhắc lịch trước 3 ngày khi kết thúc dùng thử cũng như khi gia hạn hàng năm.
### 5. Tóm tắt
Điền vào `templates/subscription-review.md`: tổng chi phí hàng tháng và hàng năm trước và sau khi điều chỉnh, các quyết định đã đưa ra và ngày nhắc nhở.
## Kết quả ví dụ
Đối với `examples/subscriptions.csv` với tỷ giá USD=0.92 vào ngày 2026-10-20: 136.65 EUR/tháng (1,639.80 EUR/năm), phần chi phí của người dùng là 112.66 EUR/tháng, 699.49 EUR/năm nằm ở bốn dịch vụ không sử dụng, một gói dùng thử DesignPro sẽ chuyển đổi vào ngày 2026-10-25 thành 22.99 EUR/tháng, một khoản gia hạn CloudVault trị giá 99.99 EUR vào ngày 2026-11-05, ba dịch vụ video trùng lặp, ba đợt tăng giá trị giá 108 EUR/năm, và 237.87 EUR các khoản phí sẽ phát sinh trong 30 ngày tới.
## Quy tắc
- Không bao giờ hỏi số thẻ, thông tin đăng nhập ngân hàng hoặc mật khẩu; chỉ cần số tiền và ngày tháng là đủ.
- Không tự ý hủy bất kỳ dịch vụ nào hoặc liên hệ với nhà cung cấp thay cho người dùng trừ khi họ yêu cầu; thay vào đó hãy soạn sẵn nội dung tin nhắn cho họ.
- Giữ thái độ khách quan, không phán xét: một số gói đăng ký hoàn toàn xứng đáng với chi phí bỏ ra. Hãy trình bày chi phí và để người dùng tự lựa chọn.
- Các gói dùng thử được tính là 0 trong tổng chi phí hiện tại; luôn hiển thị mức phí sẽ phát sinh sau khi chuyển đổi sang gói trả phí.
FILE:references/cost-normalization.md
# Chuẩn Hóa Chi Phí
## Số lần thu phí mỗi tháng theo chu kỳ
| Chu kỳ | Số lần thu phí mỗi tháng | Chi phí hàng năm cho mức phí 10.00 |
|---|---|---|
| weekly | 4.348125 (365.25 / 12 / 7) | 521.78 |
| biweekly | 2.1740625 | 260.89 |
| monthly | 1 | 120.00 |
| quarterly | 1/3 | 40.00 |
| semiannual | 1/6 | 20.00 |
| yearly | 1/12 | 10.00 |
| every N days | 30.436875 / N | |
chi phí hàng tháng = mức phí x tỷ giá x số lần thu phí mỗi tháng; chi phí hàng năm = chi phí hàng tháng x 12. Việc làm tròn đến hàng xu chỉ thực hiện ở bước cuối cùng để tổng số không bị lệch.
## Tiền tệ
- Sử dụng một loại tiền tệ báo cáo duy nhất và một tỷ giá cho mỗi ngoại tệ, ví dụ: `--rate USD=0.92` (1 USD = 0.92 EUR).
- Nguồn tốt nhất: lấy số tiền đã quy đổi trên sao kê thẻ chia cho số tiền gốc. Con số này đã bao gồm biên độ tỷ giá ngoại hối (FX margin) của thẻ.
- Đề cập riêng các khoản phí giao dịch ngoại tệ nếu thẻ có thu thêm (thường từ 1-3%).
## Gói dùng chung
- `users` = số người cùng chia sẻ gói. Phần của bạn = chi phí / users.
- Hiển thị cả toàn bộ chi phí (số tiền thẻ phải thanh toán) và phần của bạn (số tiền thực tế bạn chịu sau khi những người khác hoàn trả lại). Nếu những người khác thực tế không trả tiền, hãy dùng users = 1.
## Gói dùng thử
- Một gói dùng thử có amount bằng 0, kèm theo `trial_end` và `paid_after_trial`. Các khoản phí phát sinh vào hoặc sau ngày `trial_end` sẽ sử dụng số tiền trả phí trong danh sách các khoản phí sắp tới.
- Tổng chi phí hiển thị chi phí ngày hôm nay (bằng 0) để người dùng thấy điều gì sẽ thay đổi nếu họ không làm gì cả: cờ TRIAL sẽ cho biết mức giá hàng tháng và hàng năm sau khi chuyển đổi.
## Thay đổi giá
- `previous_amount` = mức giá trước đợt tăng giá gần nhất. Tác động hàng năm = (mức mới - mức cũ) x tỷ giá x số lần thu phí mỗi tháng x 12.
- Các nhà cung cấp thường thông báo tăng giá qua email trước 30 ngày; hãy tìm kiếm cụm từ "price change" và "new price".
## Gói năm so với gói tháng
- Gói trả theo năm chỉ giúp tiết kiệm tiền nếu đằng nào dịch vụ đó cũng được duy trì trong suốt 12 tháng. Khoản tiết kiệm = chi phí hàng tháng x 12 - giá gói năm.
- Các gói trả theo năm thường không được hoàn tiền sau một khoảng thời gian ngắn ban đầu; chỉ nên chuyển sang gói năm đối với các dịch vụ được sử dụng hàng tuần.
## Ngày gia hạn
- Nếu `next_renewal` nằm trong quá khứ, ngày này sẽ được cộng dồn theo chu kỳ cho đến khi trùng với ngày hôm nay hoặc một ngày trong tương lai.
- Các ngày cuối tháng sẽ được giới hạn theo số ngày thực tế của tháng: một gói gia hạn vào ngày 31 sẽ gia hạn vào ngày 28 (hoặc 29) tháng 2, sau đó vào ngày 28 hoặc 31 tùy thuộc vào nhà cung cấp; hãy kiểm tra biên lai.
FILE:references/cancellation-playbook.md
# Cẩm Nang Hủy và Hạ Cấp Dịch Vụ
## Nơi thực hiện hủy
| Thanh toán qua | Nơi thực hiện hủy |
|---|---|
| Apple App Store | Settings > tên của bạn > Subscriptions |
| Google Play | Play Store > hồ sơ > Payments & subscriptions > Subscriptions |
| PayPal | Settings > Payments > Manage automatic payments (dừng thanh toán, đồng thời hãy hủy cả bên phía nhà cung cấp) |
| Trực tiếp với dịch vụ | Trang tài khoản hoặc thanh toán trên website; một số dịch vụ yêu cầu nhắn tin hỗ trợ hoặc gọi điện thoại |
| Nhà mạng di động hoặc gói truyền hình | Tài khoản nhà mạng hoặc tổng đài hỗ trợ |
Việc xóa ứng dụng không làm hủy gói đăng ký của ứng dụng đó.
## Các bước thực hiện
1. Kiểm tra ngày gia hạn và xem việc hủy sẽ chấm dứt quyền truy cập ngay lập tức hay vào cuối kỳ thanh toán (thường là cuối kỳ).
2. Xuất mọi dữ liệu bạn muốn giữ lại (ảnh, danh sách phát, ghi chú, lịch sử tập luyện).
3. Hủy qua kênh chính thức nêu trên, không hủy qua các đường dẫn trong những email lạ.
4. Lưu lại bằng chứng: email xác nhận hoặc ảnh chụp màn hình có kèm ngày tháng.
5. Theo dõi kỳ sao kê tiếp theo; nếu vẫn bị trừ tiền sau khi đã hủy, hãy khiếu nại với nhà cung cấp trước, sau đó mới làm việc với bên phát hành thẻ.
## Các lựa chọn thay thế cho việc hủy
- **Tạm dừng (Pause)** (phòng gym, gói nguyên liệu nấu ăn, một số dịch vụ phát trực tuyến) vào những tháng đi du lịch hoặc bận rộn.
- **Hạ cấp (Downgrade)** xuống gói có quảng cáo, gói một màn hình hoặc gói dung lượng lưu trữ nhỏ hơn.
- **Luân phiên (Rotate)** các dịch vụ phát trực tuyến: chỉ giữ một dịch vụ tại một thời điểm và đổi sang dịch vụ khác theo từng tháng để xem các chương trình bạn muốn.
- **Chia sẻ (Share)** gói gia đình với các thành viên trong hộ gia đình, trong phạm vi điều khoản của nhà cung cấp.
- **Yêu cầu (Ask)** ưu đãi giữ chân khách hàng: nhiều dịch vụ sẽ đưa ra mức giảm giá khi bạn bắt đầu thao tác hủy. Hãy xác định trước mức giá tối đa bạn chấp nhận trả trước khi thương lượng.
## Gói dùng thử
- Đặt lời nhắc trước ngày `trial_end` từ 2-3 ngày; nhiều gói dùng thử cho phép hủy ngay sau khi đăng ký mà vẫn giữ nguyên thời gian dùng thử còn lại.
- Sử dụng thẻ ảo có giới hạn hạn mức hoặc thẻ chỉ thanh toán cho một đơn vị chấp nhận thẻ đối với các gói dùng thử nếu ngân hàng có hỗ trợ.
## Mẫu tin nhắn (chat hỗ trợ hoặc email)
> Xin chào, vui lòng hủy gói đăng ký <service> của tôi trên tài khoản <email> có hiệu lực vào cuối kỳ thanh toán hiện tại, và xác nhận qua email rằng sẽ không có khoản phí nào phát sinh thêm. Xin cảm ơn.
## Những thói quen giúp kiểm soát chi phí
- Rà soát lại các gói đăng ký mỗi quý bằng kỹ năng này.
- Duy trì một lịch duy nhất ghi lại tất cả các ngày gia hạn hàng năm.
- Trước khi đăng ký mới, hãy tự hỏi: mình có dùng dịch vụ này ít nhất mỗi tuần một lần không?
FILE:templates/subscription-review.md
# Đánh Giá Gói Đăng Ký - <name> - <date>
**Tiền tệ báo cáo:** <EUR> **Tỷ giá:** <1 USD = 0.92 EUR (sao kê thẻ)>
**Trước khi điều chỉnh:** <monthly> /tháng = <yearly> /năm (phần của bạn <monthly> /tháng)
**Sau khi quyết định:** <monthly> /tháng = <yearly> /năm **Tiết kiệm mỗi năm:** <amount>
## Các quyết định
| Gói đăng ký | Chi phí mỗi năm | Cờ cảnh báo | Quyết định | Hoàn thành |
|---|---|---|---|---|
| | | | giữ / hủy / tạm dừng / hạ cấp / chuyển sang gói năm | [ ] |
## Hạn chót và lời nhắc
| Ngày | Nội dung | Đã đặt lời nhắc |
|---|---|---|
| | hết hạn dùng thử | [ ] |
| | gia hạn hàng năm | [ ] |
## Các khoản phí trong 30 ngày tới
- <date> <name> <amount>
## Ghi chú
- Gói dùng chung: <ai hoàn trả lại khoản nào>
- Các đợt tăng giá ghi nhận được: <dịch vụ giá cũ -> giá mới>
- Lần đánh giá tiếp theo: <ngày, khoảng 3 tháng kể từ bây giờ>
FILE:examples/subscriptions.csv
name,amount,currency,cycle,next_renewal,category,last_used,users,trial_end,previous_amount,annual_price,paid_after_trial
StreamFlix Premium,17.99,EUR,monthly,2026-10-27,Video streaming,2026-10-18,2,,15.99,,
CineBox,8.99,EUR,monthly,2026-11-03,Video streaming,2026-08-02,1,,,89.90,
AnimeNow,6.49,USD,monthly,2026-10-22,Video streaming,2026-07-15,1,,,,
TuneWave Family,19.99,EUR,monthly,2026-11-10,Music,2026-10-19,4,,16.99,,
CloudVault 2 TB,99.99,EUR,yearly,2026-11-05,Cloud storage,2026-10-19,1,,,,
PhotoCloud 200 GB,2.99,EUR,monthly,2026-11-01,Cloud storage,2026-10-10,1,,,29.99,
FitPulse Gym,39.00,EUR,monthly,2026-11-01,Fitness,2026-09-04,1,,35.00,,
Yoga Daily App,59.99,USD,yearly,2027-03-14,Fitness,2026-10-12,1,,,,
NewsLine Digital,4.99,EUR,weekly,2026-10-23,News,2026-10-19,1,,,,
PasswordSafe,36.00,USD,yearly,2027-01-20,Software,2026-10-19,1,,,,
DesignPro Trial,0.00,USD,monthly,2026-10-25,Design,2026-10-02,1,2026-10-25,,,24.99
LangLeap Plus,12.99,EUR,quarterly,2026-12-01,Learning,2026-06-30,1,,,,
FILE:scripts/sub_audit.py
#!/usr/bin/env python3
"""Subscription spending auditor (Python 3 standard library only).
Input CSV columns (header names are case-insensitive; only name, amount, cycle are required):
name, amount, currency, cycle, next_renewal, category, last_used, users, trial_end,
previous_amount, annual_price, paid_after_trial
cycle: weekly, biweekly, monthly, quarterly, semiannual, yearly/annual, or "every N days|weeks|months|years"
(also short forms like 2w, 3m, 1y).
users: how many people share the plan; your share = amount / users (default 1).
trial_end + paid_after_trial: a free trial (amount 0) that converts to paid_after_trial on trial_end.
Reports, all converted to one currency with explicit rates (--rate USD=0.92):
monthly and yearly cost per item and in total, by category, your share of shared plans,
renewals in the next --days days (past dates are rolled forward by the cycle),
flags: UNUSED (last_used older than --unused-days), OVERLAP (several items in one category),
TRIAL (trial ends within --days; amount after trial if given), PRICE_UP (amount > previous_amount),
ANNUAL_SAVES (annual_price cheaper than 12 x monthly), RENEWS_YEARLY (a yearly charge within --days).
Usage:
python3 sub_audit.py subscriptions.csv --currency EUR --rate USD=0.92 --today 2026-10-20
python3 sub_audit.py subscriptions.csv --json
python3 sub_audit.py --self-test
Exit code: 0 OK, 2 bad input.
"""
import argparse
import calendar
import csv
import datetime as dt
import json
import re
import sys
from decimal import Decimal, ROUND_HALF_UP
NAMED = {"weekly": (1, "w"), "biweekly": (2, "w"), "fortnightly": (2, "w"), "monthly": (1, "m"),
"bimonthly": (2, "m"), "quarterly": (3, "m"), "semiannual": (6, "m"), "semiannually": (6, "m"),
"half-yearly": (6, "m"), "yearly": (12, "m"), "annual": (12, "m"), "annually": (12, "m")}
UNIT = {"d": "d", "day": "d", "days": "d", "w": "w", "week": "w", "weeks": "w", "m": "m", "month": "m",
"months": "m", "y": "y", "year": "y", "years": "y"}
PER_MONTH = {"d": Decimal("30.436875"), "w": Decimal("4.348125"), "m": Decimal(1), "y": Decimal(1) / 12}
D2 = Decimal("0.01")
class AuditError(ValueError):
pass
def parse_cycle(s):
s = (s or "").strip().lower()
if s in NAMED:
n, u = NAMED[s]
return n, u
m = re.fullmatch(r"(?:every\s+)?(\d+)\s*([a-z]+)", s)
if m and m.group(2) in UNIT:
n, u = int(m.group(1)), UNIT[m.group(2)]
if n <= 0:
raise AuditError(f"cycle must be positive: {s!r}")
if u == "y":
return n * 12, "m"
return n, u
raise AuditError(f"unknown cycle {s!r} (use weekly, monthly, quarterly, yearly or 'every 2 months')")
def monthly_factor(n, u):
"""How many charges per month for a cycle of n units."""
return PER_MONTH[u] / n
def add_cycle(d, n, u):
if u == "d":
return d + dt.timedelta(days=n)
if u == "w":
return d + dt.timedelta(weeks=n)
m = d.month - 1 + n
y, m = d.year + m // 12, m % 12 + 1
return dt.date(y, m, min(d.day, calendar.monthrange(y, m)[1]))
def parse_date(s, field, name):
s = (s or "").strip()
if not s:
return None
try:
return dt.date.fromisoformat(s)
except ValueError:
raise AuditError(f"{name}: bad {field} date {s!r} (use YYYY-MM-DD)")
def money(s, field, name, required=False):
s = (s or "").strip().replace(",", "") if (s or "").count(",") and "." in (s or "") else (s or "").strip().replace(",", ".")
if not s:
if required:
raise AuditError(f"{name}: {field} is required")
return None
try:
v = Decimal(s)
except Exception:
raise AuditError(f"{name}: bad {field} {s!r}")
if v < 0:
raise AuditError(f"{name}: {field} cannot be negative")
return v
def q(v):
return v.quantize(D2, rounding=ROUND_HALF_UP)
def load(lines):
rd = csv.DictReader(lines)
if not rd.fieldnames:
raise AuditError("empty CSV")
rows = []
for r in rd:
r = {(k or "").strip().lower(): (v or "").strip() for k, v in r.items()}
if not any(r.values()):
continue
if not r.get("name"):
raise AuditError("every row needs a name")
rows.append(r)
if not rows:
raise AuditError("no subscriptions found")
return rows
def audit(rows, currency="EUR", rates=None, today=None, days=30, unused_days=45):
rates = {k.upper(): Decimal(str(v)) for k, v in (rates or {}).items()}
rates[currency.upper()] = Decimal(1)
today = today or dt.date.today()
horizon = today + dt.timedelta(days=days)
items, flags, renewals, raw = [], [], [], {}
for r in rows:
name = r["name"]
cur = (r.get("currency") or currency).upper()
if cur not in rates:
raise AuditError(f"{name}: no rate for {cur}; pass --rate {cur}=<value in {currency}>")
rate = rates[cur]
n, u = parse_cycle(r.get("cycle"))
amount = money(r.get("amount"), "amount", name, required=True)
after_trial = money(r.get("paid_after_trial"), "paid_after_trial", name)
trial_end = parse_date(r.get("trial_end"), "trial_end", name)
users = int(r.get("users") or 1)
if users < 1:
raise AuditError(f"{name}: users must be at least 1")
charge = amount * rate
f = monthly_factor(n, u)
monthly = charge * f
item = {"name": name, "category": r.get("category") or "Other", "currency": cur, "amount": float(amount),
"cycle": r.get("cycle"), "monthly": q(monthly), "yearly": q(monthly * 12), "users": users,
"your_monthly": q(monthly / users), "your_yearly": q(monthly * 12 / users)}
raw[name] = (monthly, monthly / users)
items.append(item)
nxt = parse_date(r.get("next_renewal"), "next_renewal", name)
if nxt:
while nxt < today:
nxt = add_cycle(nxt, n, u)
item["next_renewal"] = nxt.isoformat()
d = nxt
while d <= horizon:
due = charge
if trial_end and d >= trial_end and after_trial is not None:
due = after_trial * rate
renewals.append({"date": d.isoformat(), "name": name, "amount": q(due)})
d = add_cycle(d, n, u)
if (n, u) in ((12, "m"),) or (u == "m" and n >= 12):
if nxt <= horizon:
flags.append({"name": name, "flag": "RENEWS_YEARLY",
"detail": f"{q(charge)} {currency} charge on {nxt.isoformat()}; decide before then"})
last = parse_date(r.get("last_used"), "last_used", name)
if last and (today - last).days > unused_days:
flags.append({"name": name, "flag": "UNUSED",
"detail": f"last used {last.isoformat()} ({(today - last).days} days ago); costs {q(monthly * 12)} {currency}/year"})
if trial_end and today <= trial_end <= horizon:
extra = (f"; then {q(after_trial * rate)} {currency} per charge ({q(after_trial * rate * f * 12)} {currency}/year)"
if after_trial is not None else "")
flags.append({"name": name, "flag": "TRIAL",
"detail": f"trial ends {trial_end.isoformat()} ({(trial_end - today).days} days){extra}; cancel or keep on purpose"})
prev = money(r.get("previous_amount"), "previous_amount", name)
if prev and amount > prev:
pct = (amount - prev) / prev * 100
flags.append({"name": name, "flag": "PRICE_UP",
"detail": f"{prev} -> {amount} {cur} (+{q(pct)}%), +{q((amount - prev) * rate * f * 12)} {currency}/year"})
annual = money(r.get("annual_price"), "annual_price", name)
if annual is not None and not (u == "m" and n == 12):
save = monthly * 12 - annual * rate
if save > 0:
flags.append({"name": name, "flag": "ANNUAL_SAVES",
"detail": f"annual plan {q(annual * rate)} {currency} vs {q(monthly * 12)} {currency} paid {r.get('cycle')}: saves {q(save)} {currency}/year if you keep it 12 months"})
cats = {}
for it in items:
c = cats.setdefault(it["category"], {"count": 0, "monthly": Decimal(0), "names": []})
c["count"] += 1
c["monthly"] += raw[it["name"]][0]
c["names"].append(it["name"])
for cname, c in sorted(cats.items()):
if c["count"] > 1:
flags.append({"name": ", ".join(c["names"]), "flag": "OVERLAP",
"detail": f"{c['count']} subscriptions in {cname} ({q(c['monthly'])} {currency}/month); keep the one you use most"})
order = {"TRIAL": 0, "RENEWS_YEARLY": 1, "UNUSED": 2, "OVERLAP": 3, "PRICE_UP": 4, "ANNUAL_SAVES": 5}
flags.sort(key=lambda x: (order[x["flag"]], x["name"]))
renewals.sort(key=lambda x: (x["date"], x["name"]))
unused_names = {f["name"] for f in flags if f["flag"] == "UNUSED"}
total_m = sum((raw[it["name"]][0] for it in items), Decimal(0))
your_m = sum((raw[it["name"]][1] for it in items), Decimal(0))
return {
"currency": currency, "today": today.isoformat(), "days": days, "rates": {k: str(v) for k, v in rates.items()},
"count": len(items), "monthly_total": q(total_m), "yearly_total": q(total_m * 12),
"your_monthly_total": q(your_m), "your_yearly_total": q(your_m * 12),
"unused_yearly": q(sum((raw[it["name"]][0] * 12 for it in items if it["name"] in unused_names), Decimal(0))),
"next_days_total": q(sum((x["amount"] for x in renewals), Decimal(0))),
"by_category": {k: {"count": v["count"], "monthly": q(v["monthly"])} for k, v in sorted(cats.items(), key=lambda kv: -kv[1]["monthly"])},
"items": sorted(items, key=lambda it: -it["monthly"]), "renewals": renewals, "flags": flags,
}
def to_markdown(r):
c = r["currency"]
out = ["# Subscription audit", "",
f"**As of:** {r['today']} **Subscriptions:** {r['count']} **Currency:** {c} **Rates:** "
+ ", ".join(f"1 {k} = {v} {c}" for k, v in r["rates"].items() if k != c.upper()),
f"**Total:** {r['monthly_total']} {c}/month = {r['yearly_total']} {c}/year "
f"**Your share:** {r['your_monthly_total']} {c}/month = {r['your_yearly_total']} {c}/year",
f"**Unused (yearly cost):** {r['unused_yearly']} {c} **Charges in next {r['days']} days:** {r['next_days_total']} {c}",
"", "## Action list", ""]
out += [f"- **{f['flag']}** {f['name']}: {f['detail']}" for f in r["flags"]] or ["- Nothing to act on."]
out += ["", "## All subscriptions (most expensive first)", "",
"| Name | Category | Charge | Cycle | Per month | Per year | Users | Your year | Next renewal |",
"|---|---|---|---|---|---|---|---|---|"]
for it in r["items"]:
out.append(f"| {it['name']} | {it['category']} | {it['amount']:.2f} {it['currency']} | {it['cycle']} | {it['monthly']} | "
f"{it['yearly']} | {it['users']} | {it['your_yearly']} | {it.get('next_renewal', '')} |")
out += ["", "## By category", "", "| Category | Count | Per month |", "|---|---|---|"]
out += [f"| {k} | {v['count']} | {v['monthly']} |" for k, v in r["by_category"].items()]
out += ["", f"## Charges in the next {r['days']} days", ""]
out += [f"- {x['date']} {x['name']}: {x['amount']} {c}" for x in r["renewals"]] or ["- none"]
return "\n".join(out)
def self_test():
cases = []
def ok(name, cond):
cases.append((name, bool(cond)))
T = dt.date(2026, 10, 20)
hdr = "name,amount,currency,cycle,next_renewal,category,last_used,users,trial_end,previous_amount,annual_price,paid_after_trial"
def run(*lines, **kw):
return audit(load([hdr, *lines]), today=T, **kw)
ok("named cycles", parse_cycle("Quarterly") == (3, "m") and parse_cycle("annual") == (12, "m"))
ok("every N units", parse_cycle("every 2 weeks") == (2, "w") and parse_cycle("1y") == (12, "m"))
ok("month end clamps", add_cycle(dt.date(2026, 1, 31), 1, "m") == dt.date(2026, 2, 28))
ok("leap year clamp", add_cycle(dt.date(2027, 2, 28), 12, "m") == dt.date(2028, 2, 28) and add_cycle(dt.date(2028, 1, 31), 1, "m") == dt.date(2028, 2, 29))
r = run("A,10,EUR,monthly,,X,,,,,,")
ok("monthly 10 = 120/year", r["yearly_total"] == Decimal("120.00"))
r = run("A,120,EUR,yearly,,X,,,,,,")
ok("yearly 120 = 10/month", r["monthly_total"] == Decimal("10.00"))
r = run("A,5,EUR,weekly,,X,,,,,,")
ok("weekly 5 = 260.89/year", r["yearly_total"] == Decimal("260.89") and r["items"][0]["monthly"] == Decimal("21.74"))
r = run("A,1,EUR,monthly,,X,,3,,,,", "B,1,EUR,monthly,,X,,3,,,,", "C,1,EUR,monthly,,X,,3,,,,")
ok("totals use unrounded shares", r["your_monthly_total"] == Decimal("1.00"))
r = run("A,10,USD,monthly,,X,,,,,,", rates={"USD": "0.9"})
ok("currency rate applied", r["monthly_total"] == Decimal("9.00"))
try:
run("A,10,GBP,monthly,,X,,,,,,")
ok("missing rate rejected", False)
except AuditError:
ok("missing rate rejected", True)
r = run("Fam,20,EUR,monthly,,Music,,4,,,,")
ok("shared plan your share", r["your_monthly_total"] == Decimal("5.00") and r["monthly_total"] == Decimal("20.00"))
r = run("Old,10,EUR,monthly,,X,2026-07-01,,,,,")
ok("unused flagged", any(f["flag"] == "UNUSED" for f in r["flags"]) and r["unused_yearly"] == Decimal("120.00"))
r = run("Fresh,10,EUR,monthly,,X,2026-10-01,,,,,")
ok("recent use not flagged", not r["flags"])
r = run("A,10,EUR,monthly,,Video,,,,,,", "B,8,EUR,monthly,,Video,,,,,,")
ok("overlap flagged", [f["flag"] for f in r["flags"]] == ["OVERLAP"])
r = run("T,0,EUR,monthly,2026-10-25,Software,,,2026-10-25,,,24.99")
ok("trial flagged with paid price", r["flags"][0]["flag"] == "TRIAL" and "24.99" in r["flags"][0]["detail"])
ok("trial charge counted in renewals", r["renewals"][0]["amount"] == Decimal("24.99"))
r = run("P,12,EUR,monthly,,X,,,,10,,")
ok("price increase flagged", r["flags"][0]["flag"] == "PRICE_UP" and "+20.00%" in r["flags"][0]["detail"] and "+24.00" in r["flags"][0]["detail"])
r = run("M,10,EUR,monthly,,X,,,,,99,")
ok("annual saving flagged", r["flags"][0]["flag"] == "ANNUAL_SAVES" and "saves 21.00" in r["flags"][0]["detail"])
r = run("M,10,EUR,monthly,,X,,,,,130,")
ok("no saving when annual is pricier", not r["flags"])
r = run("Y,50,EUR,yearly,2026-11-01,X,,,,,,")
ok("yearly renewal soon flagged", r["flags"][0]["flag"] == "RENEWS_YEARLY")
r = run("W,1,EUR,weekly,2026-10-21,X,,,,,,")
ok("weekly renewals listed in window", len(r["renewals"]) == 5 and r["next_days_total"] == Decimal("5.00"))
r = run("Past,10,EUR,monthly,2026-08-15,X,,,,,,")
ok("rolled forward to the future", r["items"][0]["next_renewal"] == "2026-11-15")
for line, label in [("A,-3,EUR,monthly,,X,,,,,,", "negative amount rejected"),
("A,3,EUR,sometimes,,X,,,,,,", "unknown cycle rejected"),
("A,3,EUR,monthly,20-10-2026,X,,,,,,", "bad date rejected"),
("A,3,EUR,monthly,,X,,0,,,,", "zero users rejected")]:
try:
run(line)
ok(label, False)
except AuditError:
ok(label, True)
ok("markdown has action list", "## Action list" in to_markdown(run("A,10,EUR,monthly,,X,,,,,,")))
passed = sum(c for _, c in cases)
for name, c in cases:
print(("PASS " if c else "FAIL ") + name)
print(f"{passed}/{len(cases)} passed")
return 0 if passed == len(cases) else 1
def default(o):
if isinstance(o, Decimal):
return float(o)
raise TypeError(type(o))
def main(argv=None):
ap = argparse.ArgumentParser(description="Audit recurring subscriptions: true yearly cost, unused, overlapping, trials and renewals.")
ap.add_argument("file", nargs="?", help="subscriptions CSV ('-' for stdin)")
ap.add_argument("--currency", default="EUR", help="report currency (default EUR)")
ap.add_argument("--rate", action="append", default=[], metavar="CUR=VALUE",
help="1 CUR = VALUE report-currency units, e.g. USD=0.92 (repeatable)")
ap.add_argument("--today", help="evaluation date YYYY-MM-DD (default: today)")
ap.add_argument("--days", type=int, default=30, help="look-ahead for renewals and trials (default 30)")
ap.add_argument("--unused-days", type=int, default=45, help="flag items not used for this many days (default 45)")
ap.add_argument("--json", action="store_true")
ap.add_argument("--self-test", action="store_true")
a = ap.parse_args(argv)
if a.self_test:
return self_test()
if not a.file:
ap.error("subscriptions CSV required (or --self-test)")
try:
rates = {}
for x in a.rate:
k, _, v = x.partition("=")
if not v:
raise AuditError(f"bad --rate {x!r}, use CUR=VALUE")
rates[k.strip().upper()] = Decimal(v.strip())
lines = sys.stdin.read().splitlines() if a.file == "-" else open(a.file, encoding="utf-8").read().splitlines()
today = dt.date.fromisoformat(a.today) if a.today else None
r = audit(load(lines), a.currency.upper(), rates, today, a.days, a.unused_days)
except (OSError, AuditError, ValueError) as e:
print(f"error: {e}", file=sys.stderr)
return 2
print(json.dumps(r, indent=2, default=default, ensure_ascii=False) if a.json else to_markdown(r))
return 0
if __name__ == "__main__":
sys.exit(main())Tính toán mức đơn giá theo giờ và theo ngày mà một freelancer cần thu dựa trên mức thu nhập thực nhận mong muốn. Tính đủ thuế, chi phí kinh doanh, khoản tiết kiệm, kỳ nghỉ phép, ngày lễ, ngày ốm, tỷ lệ giờ làm tính phí thực tế và khoản đệm rủi ro; hiển thị sự biến đổi của đơn giá theo thời gian tính phí và định giá dự án kèm khoản dự phòng. Cảnh báo các mức giá thấp hơn điểm hòa vốn hoặc mục tiêu, số giờ tính phí thiếu thực tế và mức giá lệch chuẩn thị trường. Bao gồm công cụ tính viết bằng thư viện chuẩn Python có hỗ trợ --self-test.
---
name: freelance-hourly-rate-calculator
description: Tính toán mức đơn giá theo giờ và theo ngày mà một freelancer cần thu dựa trên mức thu nhập thực nhận mong muốn. Tính đủ thuế, chi phí kinh doanh, khoản tiết kiệm, kỳ nghỉ phép, ngày lễ, ngày ốm, tỷ lệ giờ làm tính phí thực tế và khoản đệm rủi ro; hiển thị sự biến đổi của đơn giá theo thời gian tính phí và định giá dự án kèm khoản dự phòng. Cảnh báo các mức giá thấp hơn điểm hòa vốn hoặc mục tiêu, số giờ tính phí thiếu thực tế và mức giá lệch chuẩn thị trường. Bao gồm công cụ tính viết bằng thư viện chuẩn Python có hỗ trợ --self-test.
---
# Công Cụ Tính Đơn Giá Theo Giờ Cho Freelancer
Bạn giúp các freelancer và những người sắp chuyển sang làm freelance tìm ra mức đơn giá đủ để trang trải cuộc sống. Hầu hết mọi người đều định giá quá thấp vì họ lấy mức lương chia cho 2080 giờ mà quên mất thuế, bảo hiểm, trang thiết bị, những ngày nghỉ lễ không lương, ngày nghỉ ốm cũng như số giờ phải dành cho việc tìm kiếm khách hàng và quản trị hành chính. Bạn tính ngược từ mức thu nhập thực nhận (take-home income) mà họ mong muốn để tìm ra đơn giá theo giờ tối thiểu, làm tròn thành mức giá có thể báo cho khách hàng, chỉ rõ mức giá hiện tại thực chất mang lại cho họ bao nhiêu tiền và định giá cho dự án tiếp theo của họ.
## Các tệp trong kỹ năng này
- `scripts/rate_calc.py` - tính toán doanh thu cần thiết, số giờ tính phí (billable hours), đơn giá tối thiểu, đơn giá báo khách, đơn giá theo ngày và đơn giá hòa vốn, bảng tỷ lệ giờ tính phí, khoảng cách chênh lệch của mức giá hiện tại và giá trọn gói dự án (chỉ dùng thư viện chuẩn Python 3)
- `references/rate-math.md` - các công thức tính từng bước, danh sách chi phí điển hình, tỷ lệ giờ tính phí theo loại hình công việc, hướng dẫn ước tính thuế sơ bộ và những sai lầm thường gặp
- `references/pricing-and-raising-rates.md` - cách định giá theo ngày, theo dự án, theo gói duy trì (retainer) và theo giá trị mang lại, cách gửi báo giá và cách tăng giá với khách hàng hiện tại
- `templates/rate-card.md` - bảng báo giá (rate card) gói gọn trong một trang gồm giá theo giờ, theo ngày, giá theo gói và các điều khoản
- `examples/ux-designer-rate-plan.json` - ví dụ về một nhà thiết kế UX tại Berlin muốn thu nhập thực nhận 48.000 EUR và hiện đang tính phí 65 EUR/giờ
## Quy trình thực hiện
### 1. Thu thập các con số
Hãy hỏi người dùng về: mức thu nhập thực nhận mong muốn mỗi năm (sau khi trừ thuế và chi phí kinh doanh), các khoản tiết kiệm muốn để dành riêng (lương hưu, quỹ dự phòng khẩn cấp), mức thuế suất thực tế và tỷ lệ đóng bảo hiểm xã hội (nếu chưa rõ, hãy dùng hướng dẫn ước tính trong `references/rate-math.md` và nhắc họ xác nhận lại với kế toán), mọi chi phí kinh doanh (bảo hiểm, phần mềm, thiết bị, không gian làm việc, kế toán, tiếp thị, đào tạo), số giờ làm việc mỗi tuần, số tuần nghỉ phép, số ngày nghỉ lễ, số ngày nghỉ ốm và học tập nâng cao, cùng tỷ lệ thời gian làm việc thực sự có thể tính phí cho khách hàng. Đồng thời hỏi thêm về mức giá hiện tại của họ, mức giá thị trường phổ biến cho lĩnh vực và khu vực của họ, cũng như ước tính cho một dự án sắp tới nếu có.
### 2. Chạy công cụ tính toán
```
python3 scripts/rate_calc.py examples/ux-designer-rate-plan.json
python3 scripts/rate_calc.py plan.json --round 10 --json
python3 scripts/rate_calc.py --self-test
```
Mã thoát (exit code) 1 nghĩa là có phát hiện mức độ HIGH, 2 nghĩa là dữ liệu đầu vào không hợp lệ.
### 3. Giải thích kết quả
Mở đầu bằng mức đơn giá tối thiểu và mức đơn giá dùng để báo khách, sau đó phân tích chuỗi tính toán từ thu nhập thực nhận đến tổng doanh thu để họ thấy rõ dòng tiền đi đâu. Sử dụng bảng tỷ lệ giờ tính phí để chứng minh lý do vì sao mức 60% thời gian tính phí là con số mặc định thực tế. Nếu mức giá hiện tại quá thấp, hãy chỉ ra khoản thiếu hụt và số giờ tính phí cần thiết để bù đắp khoản đó, sau đó đưa ra các phương án từ `references/pricing-and-raising-rates.md`: ưu tiên tăng giá cho khách hàng mới trước, chuyển sang định giá theo ngày hoặc theo dự án, cắt giảm chi phí hoặc hạ thấp mục tiêu thu nhập.
### 4. Bàn giao
Điền vào mẫu `templates/rate-card.md` với đơn giá theo giờ, theo ngày và theo gói dịch vụ, các điều khoản thanh toán cùng ngày rà soát điều chỉnh giá tiếp theo.
## Ví dụ kết quả
Đối với `examples/ux-designer-rate-plan.json`: 48.000 EUR thu nhập thực nhận + 7.800 EUR khoản để dành = 83.284 EUR lợi nhuận trước mức thuế 33%; + 13.500 EUR chi phí và 8% khoản đệm rủi ro = 105.200 EUR doanh thu. 214 ngày làm việc với 60% thời gian tính phí = 1027 giờ tính phí, do đó mức tối thiểu là 102,41 EUR/giờ: nên báo giá 105 EUR/giờ hoặc 840 EUR/ngày. Với mức giá hiện tại 65 EUR/giờ, thu nhập thực nhận sẽ chỉ đạt 24.311 EUR (thiếu hụt 23.689 EUR), và để đạt được mục tiêu thì cần tới 94,5% thời gian làm việc đều tính phí được (HIGH BELOW_REQUIRED). Dự án thiết kế lại ứng dụng phòng khám dài 120 giờ cộng thêm 20% dự phòng sẽ có giá 15.120 EUR thay vì 9.360 EUR.
## Quy tắc
- Các con số về thuế chỉ là ước tính sơ bộ, không phải tư vấn thuế chuyên nghiệp; hãy nhắc người dùng xác nhận mức thuế suất với kế toán tại địa phương.
- Tuyệt đối không giả định thời gian tính phí vượt quá 70% nếu không có bằng chứng từ dữ liệu theo dõi thời gian thực tế của chính họ.
- Hiển thị rõ các công thức và dữ liệu đầu vào để họ có thể thay đổi một giả định và chạy lại.
- Mức giá thấp hơn thị trường thì có thể tăng lên; nhưng nếu mức tối thiểu cao hơn mặt bằng thị trường thì cần thay đổi gói giải pháp cung cấp chứ không chỉ đơn thuần đưa ra một con số cao hơn.
- Sử dụng đúng đơn vị tiền tệ và bối cảnh thuế mà họ cung cấp; không trộn lẫn các loại tiền tệ.
FILE:references/rate-math.md
# Công Thức Tính Đơn Giá
## Chuỗi công thức chi tiết từng bước
1. **Số tiền cần có sau thuế** = thu nhập thực nhận mục tiêu + các khoản để dành (lương hưu, quỹ khẩn cấp, mục tiêu tiết kiệm)
2. **Lợi nhuận trước thuế** = số tiền cần có sau thuế / (1 - thuế suất thực tế và tỷ lệ đóng bảo hiểm xã hội)
3. **Doanh thu cần thiết** = (lợi nhuận trước thuế + chi phí kinh doanh) / (1 - khoản đệm rủi ro)
4. **Số ngày làm việc** = 260 ngày trong tuần - số tuần nghỉ phép x 5 - số ngày nghỉ lễ - số ngày nghỉ ốm - số ngày đào tạo
5. **Số giờ tính phí (Billable hours)** = số ngày làm việc x số giờ làm mỗi tuần / 5 x tỷ lệ thời gian tính phí
6. **Đơn giá tối thiểu** = doanh thu cần thiết / số giờ tính phí
7. **Đơn giá báo khách** = đơn giá tối thiểu làm tròn lên (theo bội số của 5 hoặc 10); **đơn giá theo ngày** = đơn giá báo khách x số giờ làm mỗi ngày
8. **Đơn giá hòa vốn** = chi phí kinh doanh / (1 - khoản đệm) / số giờ tính phí (chỉ đủ trả các hóa đơn chi phí, bạn không nhận được đồng nào)
Kiểm tra ngược: thu nhập thực nhận tại mức giá R = (R x số giờ tính phí x (1 - khoản đệm) - chi phí) x (1 - thuế) - các khoản để dành.
## Tại sao công thức "lương / 2080" lại sai lầm
2080 = 52 tuần x 40 giờ. Một freelancer không hề được trả tiền cho bất kỳ khoản nào sau đây: kỳ nghỉ phép (20-30 ngày), ngày nghỉ lễ (8-14 ngày), ngày nghỉ ốm (5-10 ngày), thời gian học tập nâng cao (3-10 ngày), và 30-50% thời gian làm việc phải dành cho việc bán hàng, viết đề xuất (proposal), hành chính, xuất hóa đơn và học hỏi. Mức lương 60.000 khi đi làm công ăn lương còn đi kèm với khoản đóng góp hưu trí và bảo hiểm xã hội từ phía công ty, nghỉ phép có lương và trang thiết bị làm việc. Quy tắc ước lượng thông thường là đơn giá theo giờ = tiền lương / 1000 đến 1200, con số này khá sát với kết quả của phép tính đầy đủ.
## Tỷ lệ giờ tính phí theo loại hình công việc
| Tình huống | Tỷ lệ giờ tính phí thực tế |
|---|---|
| Freelancer mới, vẫn đang tìm kiếm khách hàng | 35-50% |
| Kết hợp nhiều dự án, có khách hàng quen | 50-65% |
| Hợp đồng toàn thời gian dài hạn với một khách hàng | 70-85% (nhưng hoạt động bán hàng bị ngưng trệ, và khoảng trống sau khi kết thúc hợp đồng thường kéo dài) |
| Mô hình kiểu agency với khối lượng quản lý tài khoản khách hàng lớn | 45-60% |
Cách đo lường: theo dõi mọi giờ làm việc trong vòng 4-8 tuần, sau đó sử dụng tỷ lệ thực tế thu được.
## Các chi phí kinh doanh điển hình
- Bảo hiểm y tế, bảo hiểm mất khả năng lao động và bảo hiểm trách nhiệm nghề nghiệp (khi phải tự đóng)
- Khoản đóng quỹ hưu trí nếu bắt buộc (hoặc đưa vào mục các khoản để dành)
- Phần cứng tính theo chu kỳ thay mới (laptop 3 năm, điện thoại 2-3 năm), thuê bao phần mềm, dịch vụ đám mây và tên miền
- Không gian làm việc: văn phòng chia sẻ (coworking), phần chi phí văn phòng tại nhà, internet, điện thoại
- Kế toán, phí ngân hàng, phí cổng thanh toán, công cụ xuất hóa đơn
- Tiếp thị: trang web, hồ sơ năng lực (portfolio), phí nền tảng, sự kiện, chi phí đi lại gặp khách hàng
- Đào tạo: khóa học, sách vở, hội thảo
- Phí thành viên hiệp hội nghề nghiệp, giấy phép, mẫu hợp đồng pháp lý
## Thuế suất thực tế và tỷ lệ đóng bảo hiểm (chỉ mang tính tham khảo sơ bộ)
Tỷ lệ thực tế là tổng thuế thu nhập và các khoản đóng bảo hiểm xã hội bắt buộc chia cho lợi nhuận. Con số này phụ thuộc vào quốc gia, khu vực, loại hình pháp lý, hoàn cảnh gia đình và các khoản giảm trừ. Ước tính sơ bộ ban đầu cho một người độc thân có lợi nhuận khoảng 60.000-100.000 tại Châu Âu hoặc Bắc Mỹ thường rơi vào khoảng 25-40%; các chế độ thuế thấp và chương trình thuế khoán có thể thấp hơn nhiều. Luôn luôn xác nhận với kế toán địa phương và thay thế con số ước tính này.
## Khoản đệm rủi ro
Dùng để bù đắp cho các hóa đơn thanh toán chậm hoặc bị nợ xấu, khoảng trống giữa các đợt khách hàng, phần phạm vi công việc phát sinh không thể tính phí và những tháng vắng khách. Đặt ở mức 5% đối với các hợp đồng duy trì (retainer) ổn định, 10-15% đối với công việc theo dự án làm cùng khách hàng mới.
## Những sai lầm thường gặp
- Coi tổng doanh thu gộp là thu nhập bỏ túi (thuế và chi phí sẽ bị trừ đi sau đó)
- Lên kế hoạch như thể 100% số giờ làm việc đều tính phí được
- Quên rằng thuế GTGT (VAT/sales tax) không phải là thu nhập (các mức giá tính ở đây đều chưa bao gồm VAT)
- Không tính chi phí ngày nghỉ vào giá: mỗi tuần nghỉ ngơi đều phải được bù đắp bởi những tuần làm việc
- Giảm giá theo giờ cho các dự án dài hạn mà không có cam kết đảm bảo khối lượng công việc
FILE:references/pricing-and-raising-rates.md
# Các Mô Hình Định Giá Và Cách Tăng Giá
## Lựa chọn mô hình định giá
| Mô hình | Khi nào nên dùng | Cách tính từ đơn giá tối thiểu |
|---|---|---|
| Theo giờ (Hourly) | Phạm vi chưa rõ ràng, công việc hỗ trợ, chỉnh sửa nhỏ | Đơn giá báo khách; tính phí theo từng bước 15 hoặc 30 phút |
| Theo ngày (Day rate) | Buổi workshop, làm việc cắm chốt cùng nhóm khách hàng, hợp đồng ngắn hạn | Đơn giá báo khách x số giờ mỗi ngày; nửa ngày = 55-60% của cả ngày (do chi phí chuyển đổi ngữ cảnh) |
| Theo dự án (Giá cố định) | Đầu mục bàn giao rõ ràng, bạn ước lượng thời gian chuẩn xác | Số giờ ước tính x (1 + tỷ lệ dự phòng) x đơn giá báo khách; liệt kê rõ những gì bao gồm trong gói và số vòng chỉnh sửa |
| Gói duy trì (Retainer) | Công việc diễn ra đều đặn hàng tháng | Số giờ đặt trước mỗi tháng x đơn giá báo khách, thường giảm 5-10% nhờ khối lượng được cam kết; số giờ không dùng hết sẽ hết hạn |
| Dựa trên giá trị (Value-based) | Công việc của bạn tạo ra doanh thu hoặc khoản tiết kiệm đo lường được | 5-20% giá trị mang lại trong năm đầu tiên, tuyệt đối không thấp hơn giá tính theo dự án |
Tỷ lệ dự phòng (contingency) cho giá cố định: 10-15% đối với công việc bạn đã làm nhiều lần, 20-30% đối với loại công việc mới, 30-50% khi yêu cầu chưa rõ ràng hoặc có nhiều bên liên quan phê duyệt.
## Cách gửi báo giá
- Báo một con số cụ thể, không báo một khoảng giá; khoảng giá sẽ luôn bị đàm phán ép xuống mức thấp nhất.
- Khi định giá theo dự án, hãy hiển thị mức giá theo từng gói hoặc hạng mục bàn giao, không hiển thị số giờ.
- Đưa các điều khoản thanh toán vào báo giá: tạm ứng trước 30-50% đối với khách hàng mới, hạn thanh toán trong 14 ngày (net 14) hoặc 30 ngày (net 30), phí trả chậm, và quy định xử lý khi phạm vi công việc thay đổi.
- Nêu rõ đơn giá chưa bao gồm thuế GTGT (VAT/sales tax).
## Khi đơn giá tối thiểu thấp hơn thị trường
Hãy báo giá ít nhất bằng mức trung bình của thị trường cho cấp độ kỹ năng của bạn. Thu phí thấp hơn sẽ phát đi tín hiệu về chất lượng thấp và không để lại khoản dư nào cho những tháng ít việc.
## Khi đơn giá tối thiểu cao hơn thị trường
Chỉ đưa ra một con số cao hơn sẽ không thuyết phục được khách hàng. Các phương án giải quyết:
1. Chuyên môn hóa sâu (theo ngành nghề hoặc vấn đề cụ thể) để cạnh tranh với ít người hơn và có thể định giá cao hơn mặt bằng chung.
2. Đóng gói theo kết quả đầu ra và áp dụng định giá theo dự án hoặc theo giá trị.
3. Cắt giảm những chi phí không giúp mang lại công việc.
4. Tăng tỷ lệ giờ tính phí bằng các hợp đồng duy trì (retainer) và làm việc với ít khách hàng hơn nhưng quy mô lớn hơn.
5. Hạ mục tiêu thu nhập trong năm đầu tiên và lên lộ trình tăng giá theo từng giai đoạn.
## Cách tăng giá với khách hàng hiện tại
- Khách hàng mới áp dụng ngay mức giá mới.
- Khách hàng hiện tại: thông báo trước 30-60 ngày, nêu rõ ngày bắt đầu áp dụng, lý do ngắn gọn (thêm kinh nghiệm, phạm vi công việc, mặt bằng thị trường) và không cần xin lỗi.
- Mỗi lần tăng khoảng 10-20%, mỗi năm một lần hoặc khi bạn đã kín lịch làm việc từ 3 tháng trở lên.
- Đề xuất giữ nguyên mức giá cũ nếu khách hàng mua trả trước một gói giờ làm việc hoặc ký hợp đồng retainer dài hạn hơn khi bạn muốn giữ chân một khách hàng trọng điểm.
Mẫu thông báo tham khảo:
> Kể từ ngày 1 tháng 1, đơn giá của tôi cho các công việc mới sẽ là 105 EUR mỗi giờ (840 EUR mỗi ngày). Các công việc đã thỏa thuận trước đó vẫn giữ nguyên mức giá hiện tại cho đến khi hoàn thành. Cảm ơn sự hợp tác tuyệt vời của quý vị trong năm qua; tôi rất mong chờ được đồng hành trong các dự án tiếp theo.
## Các dấu hiệu cho thấy mức giá đang quá thấp
- Bạn kín lịch làm việc trong nhiều tháng liền và không ai thắc mắc hay phàn nàn về giá cả.
- Bạn thắng thầu gần như mọi bản đề xuất (proposal) gửi đi.
- Bạn không dám nghỉ phép hoặc không đủ khả năng chi trả cho việc học tập nâng cao.
FILE:templates/rate-card.md
# Bảng Báo Giá (Rate Card) - <name>, <role>
**Có hiệu lực từ:** <date> **Kỳ rà soát tiếp theo:** <date + 12 months> **Tiền tệ:** <EUR>, giá chưa bao gồm VAT
## Đơn giá cơ bản
| Dịch vụ | Mức giá |
|---|---|
| Đơn giá theo giờ | <105> mỗi giờ |
| Đơn giá theo ngày (8 giờ) | <840> mỗi ngày |
| Nửa ngày (4 giờ) | <480> |
| Gói duy trì hàng tháng (<20> giờ, giờ không dùng hết sẽ hết hạn) | <2,000> mỗi tháng |
## Các gói dịch vụ trọn gói
| Gói dịch vụ | Bao gồm | Mức giá |
|---|---|---|
| <UX audit> | <đánh giá chuyên sâu 10 màn hình chính, báo cáo sắp xếp theo mức độ ưu tiên, cuộc gọi trao đổi 1 giờ> | <2,400> |
| <App redesign> | <nghiên cứu, wireframe, thiết kế UI cho <n> màn hình, 2 vòng chỉnh sửa, hệ thống thiết kế (design system)> | <15,120> |
## Điều khoản
- Thanh toán: <tạm ứng 40%, phần còn lại khi bàn giao>, hóa đơn thanh toán trong vòng <14> ngày; phí trả chậm <x%> mỗi tháng
- Thay đổi phạm vi công việc: báo giá riêng trước khi bắt đầu thực hiện
- Chỉnh sửa: bao gồm <2> vòng chỉnh sửa, sau đó tính phí theo giờ
- Hủy dịch vụ: <thời gian báo trước / phí hủy>
- Đi lại: <ngoài thành phố tính theo chi phí thực tế + thời gian di chuyển tính bằng 50% đơn giá>
## Các con số hậu trường (riêng tư, không gửi cho khách hàng)
- Đơn giá tối thiểu: <102.41>/giờ ở mức <60>% thời gian tính phí (<1027> giờ/năm)
- Đơn giá hòa vốn: <14.29>/giờ
- Thu nhập thực nhận mục tiêu: <48,000>, doanh thu cần thiết: <105,200>
FILE:examples/ux-designer-rate-plan.json
{
"name": "Deniz, freelance UX/UI designer (Berlin)",
"currency": "EUR",
"target_net_income": 48000,
"set_asides": [
{"name": "retirement", "annual": 6000},
{"name": "emergency fund top-up", "monthly": 150}
],
"tax": {"effective_rate_pct": 33},
"business_costs": [
{"name": "health and care insurance (self-paid)", "monthly": 520},
{"name": "coworking desk", "monthly": 190},
{"name": "software (Figma, Adobe, Notion)", "monthly": 95},
{"name": "laptop and display (3-year write-off)", "annual": 1100},
{"name": "accountant", "monthly": 110},
{"name": "liability insurance", "annual": 280},
{"name": "website, portfolio and domain", "annual": 240},
{"name": "courses and conferences", "annual": 900}
],
"time": {
"hours_per_week": 40,
"vacation_weeks": 5,
"public_holidays_days": 10,
"sick_days": 6,
"training_days": 5,
"billable_pct": 60,
"hours_per_day": 8
},
"risk_buffer_pct": 8,
"current_rate": 65,
"market_rate": {"low": 70, "high": 110},
"project": {"name": "Clinic booking app redesign", "estimated_hours": 120, "contingency_pct": 20}
}
FILE:scripts/rate_calc.py
#!/usr/bin/env python3
"""Freelance hourly rate calculator (Python 3 standard library only).
Works backwards from the take-home income you want to the hourly and day rate you must charge,
counting taxes, business costs, unpaid time and a risk buffer. Compares the result with your
current rate and the market, and prices a project.
Input JSON (amounts per year unless "monthly" is given):
{"name": "...", "currency": "EUR",
"target_net_income": 48000, # take-home after tax and costs
"set_asides": [{"name": "retirement", "annual": 6000}], (optional, after tax)
"tax": {"effective_rate_pct": 32}, # income tax + social contributions on profit
"business_costs": [{"name": "coworking", "monthly": 180}, {"name": "laptop", "annual": 2400}],
"time": {"hours_per_week": 40, "vacation_weeks": 5, "public_holidays_days": 10,
"sick_days": 6, "training_days": 5, "billable_pct": 60, "hours_per_day": 8},
"risk_buffer_pct": 10, # unpaid invoices, gaps between clients
"current_rate": 55, "market_rate": {"low": 60, "high": 95}, (optional)
"project": {"name": "...", "estimated_hours": 120, "contingency_pct": 20}} (optional)
Formulas:
needed after tax = target net + set-asides
profit before tax = needed after tax / (1 - tax rate)
revenue needed = (profit before tax + business costs) / (1 - risk buffer)
working days = 260 - vacation weeks x 5 - holidays - sick days - training days
billable hours = working days x hours per week / 5 x billable %
minimum rate = revenue needed / billable hours; quoted rate = minimum rounded up to
the next --round (default 5); project price rounded up to 10; day rate = quoted x hours per day
break-even rate = business costs / (1 - buffer) / billable hours (no income at all)
net at a rate = (rate x billable hours x (1 - buffer) - costs) x (1 - tax) - set-asides
Findings (severity):
HIGH NO_TAX tax rate missing or 0
HIGH BELOW_BREAK_EVEN current rate does not even cover business costs
HIGH BELOW_REQUIRED current rate below the minimum rate (yearly shortfall shown)
MEDIUM ABOVE_MARKET minimum rate above the market high: niche, value pricing or lower target
MEDIUM BILLABLE_HIGH billable share above 75% of working time
MEDIUM LONG_HOURS more than 50 hours per week
MEDIUM SHORT_VACATION under 3 weeks of vacation
LOW UNDER_MARKET quoted rate below the market low: you can likely charge more
LOW NO_BUFFER risk buffer 0
LOW NO_SICK_DAYS no sick days planned
LOW NO_CONTINGENCY project quote without contingency
Usage:
python3 rate_calc.py plan.json
python3 rate_calc.py plan.json --round 10 --json
python3 rate_calc.py --self-test
Exit code: 0 no HIGH findings, 1 HIGH findings, 2 bad input.
"""
import argparse
import json
import math
import sys
SEV_ORDER = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}
class DataError(ValueError):
pass
def annual(item):
if "annual" in item:
return float(item["annual"])
if "monthly" in item:
return float(item["monthly"]) * 12
raise DataError(f"cost '{item.get('name', '?')}' needs 'annual' or 'monthly'")
def round_up(x, step):
return math.ceil(x / step - 1e-9) * step
def calc(cfg, step=5):
t = cfg.get("time") or {}
hpw = float(t.get("hours_per_week", 40))
hpd = float(t.get("hours_per_day", hpw / 5))
billable = float(t.get("billable_pct", 60)) / 100
vac, hol = float(t.get("vacation_weeks", 4)), float(t.get("public_holidays_days", 10))
sick, train = float(t.get("sick_days", 5)), float(t.get("training_days", 0))
tax = float((cfg.get("tax") or {}).get("effective_rate_pct", 0)) / 100
buffer = float(cfg.get("risk_buffer_pct", 0)) / 100
if "target_net_income" not in cfg:
raise DataError("target_net_income is required")
net = float(cfg["target_net_income"])
if not (0 <= tax < 0.9) or not (0 <= buffer < 0.5):
raise DataError("tax must be 0-90% and risk buffer 0-50%")
if not (0 < billable <= 1) or hpw <= 0:
raise DataError("billable_pct must be 1-100 and hours_per_week > 0")
costs = [{"name": c.get("name", "cost"), "annual": annual(c)} for c in cfg.get("business_costs", [])]
sets = [{"name": s.get("name", "set-aside"), "annual": annual(s)} for s in cfg.get("set_asides", [])]
cost_total = sum(c["annual"] for c in costs)
set_total = sum(s["annual"] for s in sets)
working_days = 260 - vac * 5 - hol - sick - train
if working_days <= 0:
raise DataError("no working days left after vacation, holidays, sick and training days")
avail = working_days * hpw / 5
bill_h = avail * billable
after_tax = net + set_total
pre_tax = after_tax / (1 - tax)
revenue = (pre_tax + cost_total) / (1 - buffer)
min_rate = revenue / bill_h
rate = round_up(min_rate, step)
breakeven = cost_total / (1 - buffer) / bill_h
def net_at(r, hours=bill_h):
return (r * hours * (1 - buffer) - cost_total) * (1 - tax) - set_total
sens = []
for pct in (40, 50, 60, 70, 80):
h = avail * pct / 100
sens.append({"billable_pct": pct, "billable_hours": round(h), "min_rate": round(revenue / h, 2)})
res = {"name": cfg.get("name", "freelancer"), "currency": cfg.get("currency", ""),
"costs": costs, "set_asides": sets, "business_costs": round(cost_total, 2), "set_aside_total": round(set_total, 2),
"target_net_income": net, "profit_before_tax": round(pre_tax, 2), "revenue_needed": round(revenue, 2),
"working_days": working_days, "available_hours": round(avail, 1), "billable_hours": round(bill_h, 1),
"billable_pct": billable * 100, "min_rate": round(min_rate, 2), "quoted_rate": rate,
"day_rate": round_up(rate * hpd, step), "hours_per_day": hpd, "break_even_rate": round(breakeven, 2),
"net_at_quoted": round(net_at(rate), 2), "sensitivity": sens, "findings": []}
findings = res["findings"]
def add(sev, code, msg):
findings.append({"severity": sev, "code": code, "message": msg})
cur = cfg.get("current_rate")
cur_info = None
if cur:
cur = float(cur)
hours_needed = revenue / cur
cur_info = {"rate": cur, "net_income": round(net_at(cur), 2), "shortfall": round(net - net_at(cur), 2),
"billable_hours_needed": round(hours_needed), "billable_pct_needed": round(hours_needed / avail * 100, 1)}
res["current"] = cur_info
if tax == 0:
add("HIGH", "NO_TAX", "no tax rate given; income tax and social contributions usually take 25-45% of profit")
if cur_info and cur < breakeven:
add("HIGH", "BELOW_BREAK_EVEN", f"current rate {cur:g} is below the break-even rate {breakeven:.2f}; every billed hour loses money")
elif cur_info and cur < min_rate:
add("HIGH", "BELOW_REQUIRED", f"current rate {cur:g} is {min_rate - cur:.2f} below the minimum {min_rate:.2f}; take-home falls {cur_info['shortfall']:,.0f} short "
f"(you would need {cur_info['billable_hours_needed']} billable hours, {cur_info['billable_pct_needed']}% of working time)")
m = cfg.get("market_rate") or {}
if m.get("high") and min_rate > float(m["high"]):
add("MEDIUM", "ABOVE_MARKET", f"minimum rate {min_rate:.2f} is above the market high {m['high']}; specialise, price by value, cut costs or lower the target")
elif m.get("low") and rate < float(m["low"]):
add("LOW", "UNDER_MARKET", f"quoted rate {rate:g} is below the market low {m['low']}; quote at least {m['low']}")
if billable > 0.75:
add("MEDIUM", "BILLABLE_HIGH", f"{billable * 100:.0f}% billable leaves too little time for sales, admin and learning; 50-70% is realistic")
if hpw > 50:
add("MEDIUM", "LONG_HOURS", f"{hpw:g} hours per week is not sustainable for a year")
if vac < 3:
add("MEDIUM", "SHORT_VACATION", f"only {vac:g} weeks of vacation planned; nobody pays you for holidays, so price them in")
if buffer == 0:
add("LOW", "NO_BUFFER", "no risk buffer for unpaid invoices and gaps between clients; 5-15% is common")
if sick == 0:
add("LOW", "NO_SICK_DAYS", "no sick days planned; budget at least 5")
p = cfg.get("project")
if p:
hrs = float(p.get("estimated_hours", 0))
cont = float(p.get("contingency_pct", 0)) / 100
res["project"] = {"name": p.get("name", "project"), "estimated_hours": hrs, "contingency_pct": cont * 100,
"quoted_hours": round(hrs * (1 + cont), 1), "price": round_up(hrs * (1 + cont) * rate, 10),
"price_at_current": round_up(hrs * (1 + cont) * cur, 10) if cur else None}
if cont == 0:
add("LOW", "NO_CONTINGENCY", "project estimate has no contingency; add 15-30% for unknowns and revisions")
findings.sort(key=lambda f: SEV_ORDER[f["severity"]])
return res
def money(x, cur=""):
return f"{x:,.0f} {cur}".strip() if abs(x) >= 100 else f"{x:,.2f} {cur}".strip()
def rate_fmt(x, cur=""):
return f"{x:,.2f} {cur}".strip()
def render(r):
c = r["currency"]
out = [f"# Freelance rate plan: {r['name']}", "",
f"Minimum rate {rate_fmt(r['min_rate'], c)}/h -> quote **{rate_fmt(r['quoted_rate'], c)}/h**, day rate {money(r['day_rate'], c)} ({r['hours_per_day']:g} h)",
f"Break-even (covers costs, no income): {rate_fmt(r['break_even_rate'], c)}/h | take-home at the quoted rate: {money(r['net_at_quoted'], c)}/year", "",
"## From take-home to revenue", "| Step | Amount |", "|---|---|",
f"| Target take-home | {money(r['target_net_income'], c)} |",
f"| + set-asides ({', '.join(s['name'] for s in r['set_asides']) or 'none'}) | {money(r['set_aside_total'], c)} |",
f"| = profit before tax | {money(r['profit_before_tax'], c)} |",
f"| + business costs | {money(r['business_costs'], c)} |",
f"| = revenue needed (incl. risk buffer) | {money(r['revenue_needed'], c)} |", "",
"## Time", f"{r['working_days']:g} working days, {r['available_hours']:g} working hours, {r['billable_pct']:g}% billable = **{r['billable_hours']:g} billable hours**", "",
"| Billable share | Billable hours | Minimum rate |", "|---|---|---|"]
for s in r["sensitivity"]:
out.append(f"| {s['billable_pct']}% | {s['billable_hours']} | {rate_fmt(s['min_rate'], c)} |")
out += ["", "## Business costs", "| Cost | Per year |", "|---|---|"]
for k in r["costs"]:
out.append(f"| {k['name']} | {money(k['annual'], c)} |")
if r.get("current"):
cu = r["current"]
out += ["", "## Current rate", f"At {rate_fmt(cu['rate'], c)}/h: take-home {money(cu['net_income'], c)}/year "
f"({'short by ' + money(cu['shortfall'], c) if cu['shortfall'] > 0 else 'target met'}); target needs {cu['billable_hours_needed']} billable hours ({cu['billable_pct_needed']}% of working time)"]
if r.get("project"):
p = r["project"]
out += ["", f"## Project quote: {p['name']}", f"{p['estimated_hours']:g} h + {p['contingency_pct']:g}% contingency = {p['quoted_hours']:g} h x {rate_fmt(r['quoted_rate'], c)} -> **{money(p['price'], c)}**"
+ (f" (at the current rate: {money(p['price_at_current'], c)})" if p.get("price_at_current") else "")]
out += ["", "## Findings"]
if not r["findings"]:
out.append("- none")
for f in r["findings"]:
out.append(f"- {f['severity']} {f['code']}: {f['message']}")
return "\n".join(out) + "\n"
def self_test():
tests, ok = [], 0
def check(name, cond):
nonlocal ok
tests.append(name)
print(("PASS " if cond else "FAIL ") + name)
ok += bool(cond)
base = {"target_net_income": 50000, "tax": {"effective_rate_pct": 30}, "business_costs": [{"name": "x", "annual": 10000}],
"time": {"hours_per_week": 40, "vacation_weeks": 4, "public_holidays_days": 10, "sick_days": 5, "training_days": 5, "billable_pct": 60},
"risk_buffer_pct": 10}
r = calc(base)
check("working days 260-20-10-5-5 = 220", r["working_days"] == 220)
check("billable hours 220 x 8 x 60% = 1056", r["billable_hours"] == 1056)
check("profit before tax 50000/0.7", abs(r["profit_before_tax"] - 71428.57) < 0.01)
check("revenue (71428.57+10000)/0.9", abs(r["revenue_needed"] - 90476.19) < 0.01)
check("minimum rate 90476.19/1056 = 85.68", abs(r["min_rate"] - 85.68) < 0.01)
check("quoted rate rounded up to 90", r["quoted_rate"] == 90)
check("day rate 8 x 90 = 720", r["day_rate"] == 720)
check("break-even 10000/0.9/1056 = 10.52", abs(r["break_even_rate"] - 10.52) < 0.01)
check("net at the minimum rate equals target", abs(((r["min_rate"] * 1056 * 0.9 - 10000) * 0.7) - 50000) < 10)
check("clean plan has no findings", r["findings"] == [])
check("monthly cost x 12", annual({"monthly": 150}) == 1800)
check("set-asides raise the rate", calc({**base, "set_asides": [{"name": "pension", "annual": 7000}]})["min_rate"] > r["min_rate"])
check("sensitivity 50% rate higher than 70%", r["sensitivity"][1]["min_rate"] > r["sensitivity"][3]["min_rate"])
def codes(**over):
c = json.loads(json.dumps(base))
for k, v in over.items():
if isinstance(v, dict) and isinstance(c.get(k), dict):
c[k].update(v)
else:
c[k] = v
return {f["code"] for f in calc(c)["findings"]}
check("no tax HIGH", "NO_TAX" in codes(tax={"effective_rate_pct": 0}))
check("below required HIGH", "BELOW_REQUIRED" in codes(current_rate=60))
check("below break-even HIGH", "BELOW_BREAK_EVEN" in codes(current_rate=8))
check("rate above market MEDIUM", "ABOVE_MARKET" in codes(market_rate={"low": 40, "high": 70}))
check("rate under market LOW", "UNDER_MARKET" in codes(market_rate={"low": 120, "high": 160}))
check("billable 85% MEDIUM", "BILLABLE_HIGH" in codes(time={"billable_pct": 85}))
check("55 h/week MEDIUM", "LONG_HOURS" in codes(time={"hours_per_week": 55}))
check("2 weeks vacation MEDIUM", "SHORT_VACATION" in codes(time={"vacation_weeks": 2}))
check("no buffer LOW", "NO_BUFFER" in codes(risk_buffer_pct=0))
check("no sick days LOW", "NO_SICK_DAYS" in codes(time={"sick_days": 0}))
check("no contingency LOW", "NO_CONTINGENCY" in codes(project={"estimated_hours": 40, "contingency_pct": 0}))
p = calc({**base, "project": {"estimated_hours": 100, "contingency_pct": 20}})["project"]
check("project 120 h x 90 = 10800", p["quoted_hours"] == 120 and p["price"] == 10800)
for bad, label in (({"tax": {"effective_rate_pct": 95}}, "tax 95% rejected"), ({"time": {"billable_pct": 0}}, "billable 0 rejected"),
({"time": {"vacation_weeks": 60}}, "no working days rejected")):
try:
c = json.loads(json.dumps(base))
for k, v in bad.items():
c[k].update(v)
calc(c)
check(label, False)
except DataError:
check(label, True)
print(f"{ok}/{len(tests)} passed")
return 0 if ok == len(tests) else 1
def main():
ap = argparse.ArgumentParser(description="Freelance hourly rate calculator")
ap.add_argument("plan", nargs="?", help="plan JSON file")
ap.add_argument("--round", type=float, default=5, help="round the quoted rate up to this step (default 5)")
ap.add_argument("--json", action="store_true")
ap.add_argument("--self-test", action="store_true")
a = ap.parse_args()
if a.self_test:
return self_test()
if not a.plan:
ap.error("plan file required")
try:
with open(a.plan, encoding="utf-8") as fh:
r = calc(json.load(fh), a.round)
except (OSError, json.JSONDecodeError, DataError, ValueError, TypeError) as e:
print(f"error: {e}", file=sys.stderr)
return 2
print(json.dumps(r, indent=2) if a.json else render(r), end="\n" if a.json else "")
return 1 if any(f["severity"] == "HIGH" for f in r["findings"]) else 0
if __name__ == "__main__":
sys.exit(main())Ước tính chi phí vận hành của từng thiết bị gia dụng dựa trên công suất (watt), số giờ sử dụng mỗi ngày và biểu giá điện của bạn. Đọc tệp CSV, tính toán số kWh và chi phí theo ngày, tháng và năm, điện năng tiêu thụ ở chế độ chờ (tiêu thụ ngầm), tỷ trọng của từng thiết bị và giá điện giờ thấp điểm, so sánh tổng mức ước tính với hóa đơn thực tế, đồng thời xếp hạng các ý tưởng tiết kiệm như bóng đèn LED, giặt ở nhiệt độ 30 C và cắt giảm chế độ chờ theo mức tiết kiệm hàng năm. Bao gồm công cụ ước tính viết bằng thư viện chuẩn Python có hỗ trợ cờ --self-test.
---
name: home-appliance-energy-cost-estimator
description: Ước tính chi phí vận hành của từng thiết bị gia dụng dựa trên công suất (watt), số giờ sử dụng mỗi ngày và biểu giá điện của bạn. Đọc tệp CSV, tính toán số kWh và chi phí theo ngày, tháng và năm, điện năng tiêu thụ ở chế độ chờ (tiêu thụ ngầm), tỷ trọng của từng thiết bị và giá điện giờ thấp điểm, so sánh tổng mức ước tính với hóa đơn thực tế, đồng thời xếp hạng các ý tưởng tiết kiệm như bóng đèn LED, giặt ở nhiệt độ 30 C và cắt giảm chế độ chờ theo mức tiết kiệm hàng năm. Bao gồm công cụ ước tính viết bằng thư viện chuẩn Python có hỗ trợ cờ --self-test.
---
# Công Cụ Ước Tính Chi Phí Điện Năng Thiết Bị Gia Dụng
Bạn giúp các hộ gia đình nhìn rõ tiền điện của họ đang đi về đâu theo từng thiết bị gia dụng, và những thay đổi nhỏ nào thực sự đáng để thực hiện. Bạn quy đổi số watt và thói quen sinh hoạt thành số kWh và tiền bạc, trình bày rõ các phép tính số học, đồng thời giữ cho lời khuyên luôn thiết thực: không phán xét, không gợi ý những nâng cấp đắt đỏ chẳng bao giờ hoàn vốn nổi.
## Các tệp trong kỹ năng này
- `scripts/energy_cost.py` - đọc tệp CSV danh sách thiết bị và biểu giá điện, in ra bảng chi phí, các phát hiện và danh sách ý tưởng tiết kiệm được xếp hạng (chỉ sử dụng thư viện chuẩn của Python 3)
- `references/energy-math.md` - các công thức, chu kỳ hoạt động (duty cycles), chế độ chờ (standby), nhãn năng lượng, biểu giá điện và cách đo mức tiêu thụ thực tế
- `references/typical-wattages.md` - công suất watt khi chạy và khi ở chế độ chờ cùng số giờ sử dụng hàng ngày điển hình của các thiết bị phổ biến, dùng để lấp đầy các thông tin còn thiếu
- `templates/energy-report.md` - mẫu báo cáo hộ gia đình: tổng tiêu thụ, các thiết bị ngốn điện nhất, chế độ chờ, kế hoạch hành động kèm theo mức tiết kiệm và chi phí
- `examples/apartment-appliances.csv` - căn hộ hai người ở với 15 thiết bị (tủ lạnh từ năm 2011, đèn rọi halogen, máy sấy quần áo, máy chơi game ở chế độ chờ, máy sưởi dùng theo mùa)
## Quy trình thực hiện
### 1. Xây dựng danh sách thiết bị
Hỏi danh sách các thiết bị, công suất watt của chúng (trên tem nhãn thông số, sách hướng dẫn sử dụng, hoặc ổ cắm đo điện), thời gian mỗi thiết bị chạy trong một ngày và bao nhiêu ngày một tuần, cùng với giá điện mỗi kWh (cộng với giá giờ thấp điểm và phí cố định hàng ngày nếu có) từ hóa đơn gần đây. Đối với tủ lạnh và tủ đông, hãy ưu tiên chỉ số kWh/năm trên nhãn năng lượng; đối với bất kỳ thiết bị nào có bộ điều chỉnh nhiệt (thermostat), hãy thiết lập chu kỳ hoạt động (duty cycle). Bổ sung các thông tin còn trống từ `references/typical-wattages.md` và nêu rõ những giá trị nào là giả định. Nếu người dùng có hóa đơn tiền điện, hãy hỏi số kWh hàng tháng để đối chiếu chéo.
### 2. Chạy công cụ ước tính
```
python3 scripts/energy_cost.py examples/apartment-appliances.csv --rate 0.32 --offpeak-rate 0.21 --standing-charge 0.40 --bill-kwh 330 --currency EUR
python3 scripts/energy_cost.py my-home.csv --rate 0.15 --currency USD --json
python3 scripts/energy_cost.py --self-test
```
Mã thoát (exit code) bằng 1 có nghĩa là có các phát hiện mức HIGH (chế độ chờ chiếm trên 10% tổng mức sử dụng hoặc con số ước tính lệch xa so với hóa đơn), bằng 2 có nghĩa là dữ liệu đầu vào không hợp lệ.
### 3. Rà soát cùng người dùng
Nếu xuất hiện cảnh báo BILL_GAP, trước tiên hãy sửa lại dữ liệu đầu vào (bỏ sót bình nóng lạnh hoặc hệ thống sưởi điện, sử dụng theo mùa thông qua `months_per_year`, ước tính số giờ quá dư dả). Sau đó điểm qua các thiết bị tiêu thụ điện nhiều nhất, danh sách thiết bị ở chế độ chờ và các ý tưởng tiết kiệm. Các khoản tiết kiệm có thể chồng lấn lên nhau (máy sấy dùng ít đi thì cũng tiết kiệm được ít hơn khi chuyển sang chạy giờ thấp điểm), vì vậy hãy chọn lọc ra một kế hoạch hợp lý thay vì cộng dồn mọi ý tưởng lại. Sử dụng `references/energy-math.md` để giải thích cho bất kỳ con số nào.
### 4. Bàn giao báo cáo
Điền vào mẫu `templates/energy-report.md`: tổng mức tiêu thụ, 5 thiết bị ngốn điện nhất, mức tiêu thụ chế độ chờ, và kế hoạch hành động được phân loại thành các thói quen miễn phí, các giải pháp chi phí thấp (bóng đèn LED, ổ cắm thông minh) và các khoản mua sắm lớn hơn kèm theo số năm hoàn vốn (giá mua / mức tiết kiệm hàng năm).
## Kết quả ví dụ
Đối với tệp `examples/apartment-appliances.csv` ở mức giá EUR 0.32/kWh (giờ thấp điểm 0.21, phí cố định 0.40/ngày): 12.3 kWh/ngày, 373 kWh/tháng, 4479 kWh/năm, tiền điện năng EUR 118.38/tháng hoặc EUR 1420.54/năm (EUR 1566.54 nếu tính cả phí cố định). Sáu bóng đèn rọi halogen là thiết bị tiêu thụ lớn nhất (548 kWh, 12%), tiếp theo là máy sưởi sử dụng trong 5 tháng (479 kWh). Chế độ chờ tiêu tốn 319 kWh (7%), với các phát hiện mức MEDIUM cho TV (12 W) và máy chơi game (15 W). Nằm trong phạm vi chênh lệch 13% so với hóa đơn 330 kWh, do đó không có cảnh báo BILL_GAP. Các ý tưởng hàng đầu: thay đèn rọi LED tiết kiệm khoảng EUR 149/năm, phơi khô tự nhiên một nửa số mẻ giặt tiết kiệm EUR 63, giặt ở nhiệt độ 30 C tiết kiệm EUR 48, giặt đồ vào giờ thấp điểm, cắt giảm chế độ chờ của máy chơi game tiết kiệm EUR 40.
## Quy tắc
- Trình bày công thức tính một lần và nêu rõ các giả định cho mọi giá trị ước đoán.
- Sử dụng biểu giá và đơn vị tiền tệ của người dùng; tuyệt đối không tự mặc định mức giá của một quốc gia mà không nói rõ.
- Con số ước tính không phải là kết quả đo đạc thực tế: hãy đề xuất dùng ổ cắm đo điện cho 3 thiết bị ngốn điện nhất khi các con số đóng vai trò quan trọng.
- Xếp hạng các hành động theo số tiền tiết kiệm được trên mỗi công sức bỏ ra; đưa ra thời gian hoàn vốn tính bằng năm cho bất kỳ giải pháp nào tốn tiền mua sắm.
- An toàn là trên hết: tuyệt đối không bao giờ gợi ý tắt tính năng làm mát của tủ lạnh hoặc tủ đông, thiết bị báo khói, thiết bị y tế hoặc chế độ chống đóng băng để tiết kiệm điện.
FILE:references/energy-math.md
# Toán Học Năng Lượng
## Các công thức cốt lõi
```
kWh per day (running) = watts x quantity x hours per day x duty% / 1000 x days per week / 7 x months per year / 12
kWh per day (standby) = standby watts x quantity x (24 - average hours running) / 1000
kWh per year = kWh per day x 365 kWh per month = kWh per year / 12
cost = kWh x price per kWh (+ standing charge per day x 365, listed separately)
```
Ví dụ: một ấm đun nước 2000 W sử dụng 15 phút mỗi ngày = 2000 x 0.25 / 1000 = 0.5 kWh/ngày = 183 kWh/năm; ở mức giá 0.32 mỗi kWh = 58.40 một năm.
## Số Watt so với các con số khác trên nhãn thiết bị
- W (watt) là thông số bạn cần. kW x 1000 = W.
- VA trên bộ sạc và bộ lưu điện (UPS) là công suất biểu kiến; số watt thực tế thường chỉ bằng 60-90% con số đó.
- mAh hoặc Wh trên pin là dung lượng lưu trữ, không phải công suất tiêu thụ điện.
- Số watt trên nhãn thông số (nameplate watts) là mức tối đa. Máy sưởi, lò nướng, ấm đun nước chạy gần mức tối đa này; TV, máy tính và máy chơi game thường chỉ tiêu thụ khoảng 40-80% con số đó. Một chiếc ổ cắm đo điện sẽ cho biết con số chính xác chỉ sau một ngày.
## Chu kỳ hoạt động (Duty cycle)
Bất kỳ thiết bị nào có bộ điều chỉnh nhiệt (thermostat) hoặc máy nén đều tự động bật và tắt luân phiên. Tỷ lệ thời gian thiết bị thực sự chạy được gọi là chu kỳ hoạt động (duty cycle).
| Thiết bị | Chu kỳ hoạt động điển hình |
|---|---|
| Tủ lạnh, tủ lạnh kèm tủ đông | 30-50% (cao hơn nếu đặt trong căn bếp ấm hoặc gioăng cao su bị hở) |
| Tủ đông nằm ngang | 35-50% |
| Máy sưởi dầu hoặc máy sưởi tấm có bộ điều nhiệt | 50-80% trong thời tiết lạnh |
| Lò nướng khi đã đạt nhiệt độ (sau khi làm nóng trước) | 50-60% |
| Máy giặt (đun nóng nước ở giai đoạn đầu) | 50-70% công suất nhãn trong suốt một chu trình giặt |
| Máy rửa bát | 50-60% công suất nhãn trong suốt một chu trình rửa |
Tốt hơn hết: hãy sử dụng chỉ số kWh/năm trên nhãn năng lượng (nhãn EU, nhãn EnergyGuide của Mỹ), vì con số này đã tính sẵn chu kỳ bật tắt.
## Chế độ chờ (Standby)
Các thiết bị đã "tắt" nhưng vẫn tiêu thụ điện: chế độ khởi động nhanh (instant-on) của TV và máy chơi game (5-15 W), đầu thu truyền hình set-top box (10-20 W), máy in, lò vi sóng có đồng hồ hiển thị (2-3 W), củ sạc cắm sẵn ở ổ điện (0.1-0.5 W). Các thiết bị bật liên tục như bộ định tuyến Wi-Fi được tính là tải đang hoạt động (running load) chứ không phải chế độ chờ, vì vậy hãy liệt kê chúng với thời gian chạy 24 giờ. Mức tiêu thụ chờ 10 W liên tục suốt ngày đêm = 88 kWh/năm. Sử dụng ổ cắm kéo dài có công tắc, ổ cắm thông minh hẹn giờ hoặc tắt tính năng "khởi động nhanh" sẽ loại bỏ được phần lớn lượng điện tiêu thụ này.
## Biểu giá điện (Tariffs)
- Giá đồng nhất (Flat rate): một mức giá duy nhất cho mỗi kWh.
- Giá theo khung giờ (Time of use): giờ thấp điểm có giá rẻ hơn (thường là ban đêm). Đặt `offpeak_pct` bằng tỷ lệ phần trăm đã được sử dụng vào giờ thấp điểm; tập lệnh sẽ tính giá trị tiết kiệm khi chuyển phần còn lại của các phụ tải có thể dịch chuyển giờ chạy (máy giặt, máy rửa bát, máy sấy, bộ sạc xe điện EV, bình nóng lạnh, máy bơm hồ bơi) theo mức chênh lệch (giá tiêu chuẩn - giá thấp điểm) trên mỗi kWh.
- Giá bậc thang (Tiered/block rates): giá điện tăng dần theo mức sử dụng hàng tháng. Hãy sử dụng mức giá cận biên (bậc cao nhất đang dùng) để tính giá trị tiết kiệm.
- Phí cố định (Standing charge - phí thuê bao hàng ngày): vẫn phải trả ngay cả khi mức sử dụng bằng không, vì vậy các biện pháp tiết kiệm điện không bao giờ làm giảm được khoản phí này.
- Thuế và phụ phí: hãy sử dụng mức giá trọn gói trên mỗi kWh từ hóa đơn (tổng chi phí điện năng / tổng số kWh).
## Đối chiếu với hóa đơn tiền điện
So sánh số kWh ước tính hàng tháng với số kWh trên hóa đơn (chứ không so sánh bằng số tiền). Sai số trong phạm vi 25% là mức tốt đối với phương pháp ước tính dựa trên thói quen sử dụng. Các nguyên nhân phổ biến khiến con số ước tính quá thấp: bình đun nước nóng bằng điện (thường chiếm 15-25% lượng điện của một ngôi nhà), hệ thống sưởi hoặc làm mát bằng điện, xe điện (EV), chiếc tủ lạnh thứ hai bị bỏ quên, máy bơm hồ bơi hoặc bể cá. Quá cao: ước tính số giờ sử dụng quá hào phóng, các thiết bị dùng theo mùa lại được tính cho cả năm (hãy dùng `months_per_year`), lấy công suất tối đa trên nhãn để tính cho các thiết bị điện tử.
## Đo đạc thực tế
- Ổ cắm đo điện năng (rẻ tiền): đo số kWh trong vòng một tuần; sau đó nhân với 52.
- Ứng dụng công tơ thông minh hoặc cổng thông tin của công ty điện lực: dữ liệu theo từng giờ sẽ cho thấy phụ tải nền vào ban đêm (mọi thiết bị luôn bật); phụ tải nền x 8760 giờ / 1000 = số kWh/năm không bao giờ tắt.
- Đọc chỉ số công tơ điện trước và sau một lần chạy thiết bị đã biết (ví dụ: bật lò nướng trong một giờ) khi mọi thiết bị khác giữ nguyên trạng thái ổn định.
## Thời gian hoàn vốn (Payback)
số năm hoàn vốn = giá mua / mức tiết kiệm hàng năm. Bóng đèn LED: chỉ vài tháng. Ổ cắm thông minh cho chế độ chờ: 1-2 năm. Máy sấy bơm nhiệt (heat-pump dryer) hoặc tủ lạnh mới: thường mất 5-10 năm, vì vậy chỉ nên đề xuất khi thiết bị cũ đằng nào cũng đã đến lúc cần thay thế.
FILE:references/typical-wattages.md
# Công Suất Watt Và Mức Sử Dụng Điển Hình
Các giá trị khởi đầu để dùng khi người dùng không rõ thông số. Hãy luôn gắn nhãn chúng là các giả định; các mẫu máy thực tế có thể chênh lệch rất nhiều.
| Thiết bị | Công suất chạy (W) | Mức sử dụng điển hình | Chế độ chờ (W) | Ghi chú |
|---|---|---|---|---|
| Tủ lạnh kèm tủ đông | 100-200 | 24 giờ ở mức chu kỳ 30-50% | - | Nhãn năng lượng: 150-350 kWh/năm (loại A-E mới thấp hơn) |
| Tủ đông nằm ngang | 60-120 | 24 giờ ở mức chu kỳ 35-50% | - | Nhãn năng lượng: 150-250 kWh/năm |
| Máy giặt | 1800-2400 | 1-2 giờ mỗi mẻ, 3-5 mẻ/tuần | 0.5-2 | 0.5-1.0 kWh cho mỗi chu trình giặt 40 C |
| Máy sấy quần áo (thông hơi/ngưng tụ) | 2000-3000 | 1-1.5 giờ mỗi mẻ | 0.5-1 | Máy sấy bơm nhiệt chỉ tiêu thụ khoảng một nửa |
| Máy rửa bát | 1200-2000 | 1-2 giờ mỗi chu trình | 0.5-1 | 0.8-1.4 kWh mỗi chu trình; chế độ eco chạy lâu hơn nhưng tốn ít điện hơn |
| Lò nướng điện | 2000-3500 | 0.5-1 giờ | 2-4 (đồng hồ) | Làm nóng trước ở công suất tối đa, sau đó bật tắt theo chu kỳ |
| Bếp từ hoặc bếp hồng ngoại (mỗi vùng nấu) | 1200-2500 | 0.5-1 giờ | 0-2 | |
| Lò vi sóng | 800-1200 | 5-15 phút | 2-4 | |
| Nồi chiên không dầu | 1200-1800 | 15-30 phút | 0 | Tiết kiệm hơn lò nướng khi nấu khẩu phần nhỏ |
| Ấm đun nước siêu tốc | 2000-3000 | Tổng cộng 10-20 phút | 0 | |
| Máy pha cà phê | 1000-1500 | 10-20 phút | 1-3 | Đế giữ ấm sẽ làm tốn thêm khá nhiều điện |
| Bóng đèn sợi đốt / halogen | 40-60 | 3-6 giờ | 0 | Đèn LED tương đương chỉ tốn 5-9 W |
| Bóng đèn LED | 5-12 | 3-6 giờ | 0-0.5 (bóng thông minh) | |
| TV LED 43-65 inch | 60-150 | 3-5 giờ | 0.5-15 | Chế độ khởi động nhanh làm tăng điện năng chờ |
| Máy chơi game (Game console) | 100-220 | 1-3 giờ | 1-15 | Chế độ nghỉ/khởi động tức thì tốn khoảng 10-15 W |
| Máy tính để bàn (PC) + màn hình | 100-400 | 4-8 giờ | 2-5 | PC chơi game khi chạy nặng tốn 300-600 W |
| Máy tính xách tay (Laptop) | 30-70 | 4-8 giờ | 0.5 | |
| Bộ định tuyến Wi-Fi + modem | 8-20 | 24 giờ | - | Luôn bật: liệt kê vào mục phụ tải đang chạy |
| Đầu thu truyền hình / thiết bị phát trực tuyến | 10-20 | 4 giờ | 5-15 | |
| Máy sưởi phòng (quạt sưởi / sưởi dầu) | 1000-2000 | 2-6 giờ vào mùa đông, chu kỳ 50-80% | 0 | Sử dụng tham số months_per_year |
| Điều hòa di động | 800-1500 | 4-8 giờ vào mùa hè | 1-2 | |
| Máy hút ẩm | 200-500 | 6-12 giờ | 1 | |
| Bình nước nóng dùng điện (loại có bình chứa) | 2000-3000 | 2-4 giờ | - | Thường là thiết bị ngốn điện tiềm ẩn lớn nhất |
| Vòi sen nước nóng trực tiếp bằng điện | 7000-10000 | 10-20 phút | 0 | |
| Bộ sạc xe điện EV (tại nhà, 7 kW) | 7000-7400 | tính theo số km di chuyển: ~0.15-0.2 kWh/km | 2-5 | Dùng công thức kWh/năm = số km/năm x 0.17 |
| Máy bơm hồ bơi | 750-1500 | 6-10 giờ trong mùa sử dụng | 0 | Máy bơm biến tần thay đổi tốc độ giúp tiết kiệm khoảng một nửa |
| Bể cá (sưởi + lọc + đèn) | 50-200 | 24 giờ ở mức chu kỳ 30-60% | - | |
Nguồn tham khảo cho các khoảng giá trị: bảng thông số kỹ thuật và nhãn năng lượng của nhà sản xuất, kết hợp với các công cụ tính toán thiết bị gia dụng phổ biến của cơ quan năng lượng quốc gia. Hãy kiểm tra mẫu máy cụ thể của người dùng khi một giá trị có ảnh hưởng trực tiếp đến việc ra quyết định.
FILE:templates/energy-report.md
# Báo cáo điện năng hộ gia đình: <tên hộ gia đình hoặc địa chỉ>
**Biểu giá:** <EUR 0.32/kWh, giờ thấp điểm 0.21 (23:00-07:00), phí cố định 0.40/ngày> **Đối chiếu hóa đơn:** <ước tính 373 kWh/tháng so với hóa đơn 330 (-13%)>
**Tóm tắt:** <một câu: ví dụ "Chiếu sáng và giặt giũ chiếm 40% hóa đơn; thay đèn rọi LED và giặt ở nhiệt độ 30 C giúp tiết kiệm khoảng EUR 200 một năm với chi phí đầu tư EUR 40.">
## Tiền điện đi về đâu
| Thiết bị | kWh/năm | Chi phí/năm | Tỷ trọng | Giả định |
|---|---|---|---|---|
| <Đèn rọi Halogen x6> | <548> | <175.20> | <12%> | <50 W, 5 giờ/ngày> |
| <...> | | | | |
Tổng cộng: <4479 kWh/năm, EUR 1420.54 tiền điện + EUR 146 phí cố định>
## Chế độ chờ (luôn phải trả tiền nhưng không mang lại lợi ích gì)
<319 kWh/năm, khoảng EUR 102. Lớn nhất: máy chơi game 15 W, TV 12 W.>
## Kế hoạch hành động
### Thói quen miễn phí
| Hành động | Tiết kiệm/năm |
|---|---|
| <Giặt ở nhiệt độ 30 C, giặt đủ tải> | <EUR 48> |
| <Chạy máy giặt và máy rửa bát vào giờ thấp điểm> | <EUR 70-110> |
### Giải pháp chi phí thấp
| Hành động | Chi phí | Tiết kiệm/năm | Thời gian hoàn vốn |
|---|---|---|---|
| <6 bóng đèn rọi LED> | <EUR 30> | <EUR 149> | <2-3 tháng> |
| <Ổ cắm thông minh cho TV + máy chơi game> | <EUR 15> | <EUR 68> | <3 tháng> |
### Khoản mua sắm lớn hơn (chỉ thực hiện khi đằng nào cũng cần thay mới)
| Hành động | Chi phí | Tiết kiệm/năm | Thời gian hoàn vốn |
|---|---|---|---|
| <Máy sấy bơm nhiệt> | <EUR 600> | <EUR 63> | <~10 năm> |
## Các bước tiếp theo
- <Đo máy sưởi phòng và tủ lạnh bằng ổ cắm đo điện trong vòng một tuần>
- <Chạy lại công cụ ước tính sau khi thay bóng đèn LED và so sánh hai kỳ hóa đơn tiếp theo>
Các con số là ước tính dựa trên công suất định mức và mức sử dụng điển hình; các khoản tiết kiệm có sự giao thoa và không cộng dồn trực tiếp.
FILE:examples/apartment-appliances.csv
appliance,watts,hours_per_day,days_per_week,quantity,duty_cycle_pct,standby_watts,kwh_per_year,offpeak_pct,months_per_year
Fridge-freezer (2011),,,7,1,,,320,,
Chest freezer,90,24,7,1,45,,,,
Washing machine,2000,1.5,4,1,60,1.5,,,
Tumble dryer,2500,1,3,1,,0.5,,,
Dishwasher,1800,1.5,5,1,55,1,,30,
Electric oven,2400,1,4,1,60,3,,,
Kettle,2200,0.25,7,1,,,,,
Microwave,1100,0.2,7,1,,3,,,
Halogen spotlight,50,5,7,6,,,,,
LED bulb,9,5,7,8,,,,,
TV (55 inch),110,4,7,1,,12,,,
Game console,160,1.5,5,1,,15,,,
Desktop computer,180,6,5,1,,4,,,
Wi-Fi router,10,24,7,1,,,,,
Space heater (bedroom),1500,3,7,1,70,,,,5
FILE:scripts/energy_cost.py
#!/usr/bin/env python3
"""Home appliance energy cost estimator (Python 3 standard library only).
Reads a CSV of appliances and works out daily, monthly and yearly kWh and cost, standby
("vampire") loads, each appliance's share, and savings ideas with estimated yearly savings.
CSV columns (header required; comma or semicolon; only the first three are required):
appliance name, e.g. "Fridge-freezer" (keywords drive the savings ideas)
watts power while running (nameplate or plug-in meter), per unit
hours_per_day hours running on a day it is used
days_per_week days used per week (default 7)
quantity number of identical units (default 1), e.g. 8 bulbs
duty_cycle_pct share of running time the motor/heater is really on (default 100;
fridges ~35-50, thermostatic heaters ~50-70)
standby_watts power when off but plugged in, per unit (default 0)
kwh_per_year energy-label figure per unit; replaces watts x hours when given
offpeak_pct share of running energy already used in off-peak hours (default 0)
months_per_year months in use, for seasonal loads like heaters or air conditioning (default 12)
Formulas:
running kWh/day = watts x quantity x hours x duty% / 1000 x days_per_week / 7 x months / 12
(or kwh_per_year x quantity / 365), averaged over the whole year
standby kWh/day = standby_watts x quantity x (24 - hours x days_per_week / 7) / 1000
month = year / 12, year = day x 365; cost = kWh x rate (off-peak share x --offpeak-rate);
standby is charged at the standard rate; the standing charge is listed separately.
Findings (severity):
HIGH STANDBY_SHARE standby is >= 10% of all estimated kWh
HIGH BILL_GAP estimate differs from --bill-kwh (monthly) by more than 25%
MEDIUM BIG_CONSUMER one appliance uses >= 25% of all kWh
MEDIUM STANDBY_DEVICE a unit draws >= 5 W on standby
LOW CHECK_DUTY_CYCLE fridge/freezer/heater running 24 h at 100% duty (likely overestimated)
LOW HIGH_WATTS > 3500 W for an appliance that is not a known heavy load
Usage:
python3 energy_cost.py appliances.csv --rate 0.32 --currency EUR
python3 energy_cost.py appliances.csv --rate 0.32 --offpeak-rate 0.21 --standing-charge 0.40 --bill-kwh 260 --json
python3 energy_cost.py --self-test
Exit code: 0 no HIGH findings, 1 HIGH findings, 2 bad input.
"""
import argparse
import csv
import io
import json
import sys
SEV_ORDER = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}
HEAVY = ("heater", "oven", "stove", "hob", "cooktop", "dryer", "kettle", " ev ", "charger", "boiler",
"water heater", "sauna", "heat pump", "air conditioner", " ac ", "furnace", "pool")
SHIFTABLE = ("wash", "dishwasher", "dryer", " ev ", "ev charger", "pool", "water heater", "boiler")
# keyword -> (idea, share of that appliance's running cost saved)
IDEAS = [
(("bulb", "lamp", "light"), "replace with LED (about 85% less power for the same light)", 0.85, "watts>=25"),
(("dryer",), "line-dry half the loads or switch to a heat-pump dryer (about 50%)", 0.5, None),
(("water heater", "boiler"), "set to 55-60 C with a timer and insulate the tank (about 10%)", 0.10, None),
(("space heater", "heater", "radiator"), "heat only the room in use and lower the thermostat 1 C (about 10%); a heat pump uses 2-4x less", 0.10, None),
(("fridge", "freezer"), "set 4 C / -18 C, clean coils, check door seals (about 10%); a 15-year-old unit may use twice a new A-class one", 0.10, None),
(("dishwasher",), "use the eco program and skip pre-rinsing with hot water (about 20%)", 0.20, None),
(("washing machine", "washer"), "wash at 30 C instead of 40-60 C and run full loads (about 40%)", 0.40, None),
(("kettle",), "boil only the water you need (about 25%)", 0.25, None),
(("oven",), "use a microwave or air fryer for small meals (about 30%)", 0.30, None),
(("pool",), "run the pump fewer hours or fit a variable-speed pump (about 50%)", 0.50, None),
(("air conditioner", " ac ", "aircon"), "set 1-2 C warmer and clean filters (about 10%)", 0.10, None),
(("dehumidifier",), "use a humidistat at 50-55% instead of running continuously (about 30%)", 0.30, None),
(("desktop", "gaming pc", "computer"), "enable sleep after 15 minutes idle (about 20%)", 0.20, None),
]
class DataError(ValueError):
pass
def num(row, key, default, line):
v = (row.get(key) or "").strip().replace(",", ".") if isinstance(row.get(key), str) else row.get(key)
if v in (None, ""):
if default is None:
raise DataError(f"line {line}: '{key}' is required")
return float(default)
try:
x = float(v)
except ValueError:
raise DataError(f"line {line}: '{key}' must be a number, got '{v}'")
if x < 0:
raise DataError(f"line {line}: '{key}' cannot be negative")
return x
def read_csv(text):
first = text.splitlines()[0] if text.strip() else ""
delim = ";" if first.count(";") > first.count(",") else ","
rd = csv.DictReader(io.StringIO(text), delimiter=delim)
if not rd.fieldnames or not {"appliance", "watts", "hours_per_day"} <= {f.strip().lower() for f in rd.fieldnames}:
if not rd.fieldnames or "kwh_per_year" not in [f.strip().lower() for f in rd.fieldnames]:
raise DataError("CSV needs columns appliance, watts, hours_per_day (or kwh_per_year)")
items = []
for line, raw in enumerate(rd, 2):
row = {(k or "").strip().lower(): v for k, v in raw.items()}
name = (row.get("appliance") or "").strip()
if not name:
continue
label = (row.get("kwh_per_year") or "").strip()
it = {"appliance": name,
"watts": num(row, "watts", 0 if label else None, line),
"hours": num(row, "hours_per_day", 24 if label else None, line),
"days": num(row, "days_per_week", 7, line),
"qty": num(row, "quantity", 1, line),
"duty": num(row, "duty_cycle_pct", 100, line),
"standby_w": num(row, "standby_watts", 0, line),
"label_kwh": num(row, "kwh_per_year", 0, line) if label else None,
"offpeak": num(row, "offpeak_pct", 0, line) / 100,
"months": num(row, "months_per_year", 12, line)}
if it["hours"] > 24:
raise DataError(f"line {line}: hours_per_day {it['hours']:g} > 24")
if it["days"] > 7 or it["duty"] > 100 or it["offpeak"] > 1 or it["months"] > 12:
raise DataError(f"line {line}: days_per_week <= 7, months_per_year <= 12, duty_cycle_pct and offpeak_pct <= 100")
items.append(it)
if not items:
raise DataError("no appliances in the CSV")
return items
def has(name, words):
low = " " + name.lower() + " "
return any(w in low for w in words)
def estimate(items, rate, offpeak_rate=None, standing=0.0, bill_kwh=None):
off = rate if offpeak_rate is None else offpeak_rate
rows, findings, ideas = [], [], []
def add(sev, code, msg):
findings.append({"severity": sev, "code": code, "message": msg})
for it in items:
avg_hours = it["hours"] * it["days"] / 7
if it["label_kwh"] is not None:
run_day = it["label_kwh"] * it["qty"] / 365
else:
run_day = it["watts"] * it["qty"] * avg_hours * it["duty"] / 100 / 1000 * it["months"] / 12
sb_day = it["standby_w"] * it["qty"] * (24 - avg_hours) / 1000
run_cost_y = run_day * 365 * ((1 - it["offpeak"]) * rate + it["offpeak"] * off)
sb_cost_y = sb_day * 365 * rate
rows.append({"appliance": it["appliance"], "qty": it["qty"], "kwh_day": run_day + sb_day,
"kwh_month": (run_day + sb_day) * 365 / 12, "kwh_year": (run_day + sb_day) * 365,
"standby_kwh_year": sb_day * 365, "cost_month": (run_cost_y + sb_cost_y) / 12,
"cost_year": run_cost_y + sb_cost_y, "standby_cost_year": sb_cost_y,
"run_kwh_year": run_day * 365, "run_cost_year": run_cost_y})
name = it["appliance"]
if it["standby_w"] >= 5:
add("MEDIUM", "STANDBY_DEVICE", f"{name}: {it['standby_w']:g} W standby per unit = {sb_day * 365:.0f} kWh/year; a switched power strip or unplugging saves {sb_cost_y:.2f}/year")
if has(name, ("fridge", "freezer", "heater")) and it["label_kwh"] is None and it["hours"] >= 24 and it["duty"] >= 100:
add("LOW", "CHECK_DUTY_CYCLE", f"{name}: 24 h at 100% duty; compressors and thermostats cycle, so set duty_cycle_pct (fridge ~40) or use the energy-label kWh/year")
if it["watts"] > 3500 and not has(name, HEAVY):
add("LOW", "HIGH_WATTS", f"{name}: {it['watts']:g} W is unusually high; check the nameplate (W, not mAh or VA)")
for words, text, share, cond in IDEAS:
if has(name, words) and (cond is None or it["watts"] >= 25):
ideas.append({"appliance": name, "idea": text, "saving_year": run_cost_y * share,
"kwh_year": run_day * 365 * share})
break
if sb_cost_y > 0 and it["standby_w"] >= 1:
ideas.append({"appliance": name, "idea": "cut standby with a switched strip or smart plug", "saving_year": sb_cost_y,
"kwh_year": sb_day * 365})
if offpeak_rate is not None and offpeak_rate < rate and has(name, SHIFTABLE) and it["offpeak"] < 1:
kwh = run_day * 365 * (1 - it["offpeak"])
ideas.append({"appliance": name, "idea": "run in off-peak hours (timer or delay start)",
"saving_year": kwh * (rate - offpeak_rate), "kwh_year": 0.0})
tot_kwh = sum(r["kwh_year"] for r in rows)
tot_sb = sum(r["standby_kwh_year"] for r in rows)
for r in rows:
r["share"] = r["kwh_year"] / tot_kwh if tot_kwh else 0
if r["share"] >= 0.25:
add("MEDIUM", "BIG_CONSUMER", f"{r['appliance']} is {r['share']:.0%} of the estimated use ({r['kwh_year']:.0f} kWh/year); check it first")
if tot_kwh and tot_sb / tot_kwh >= 0.10:
add("HIGH", "STANDBY_SHARE", f"standby is {tot_sb / tot_kwh:.0%} of all use ({tot_sb:.0f} kWh/year, {tot_sb * rate:.2f}/year) - the cheapest saving to get")
if bill_kwh:
est_m = tot_kwh / 12
gap = (bill_kwh - est_m) / bill_kwh
if abs(gap) > 0.25:
add("HIGH", "BILL_GAP", f"estimate {est_m:.0f} kWh/month vs bill {bill_kwh:g} kWh/month ({gap:+.0%}); "
+ ("missing appliances (water heating, electric heating, EV?) or hours too low" if gap > 0 else "hours or watts are too high, or some appliances are not used every day"))
rows.sort(key=lambda r: -r["kwh_year"])
ideas.sort(key=lambda x: -x["saving_year"])
findings.sort(key=lambda f: SEV_ORDER[f["severity"]])
totals = {"kwh_day": tot_kwh / 365, "kwh_month": tot_kwh / 12, "kwh_year": tot_kwh,
"standby_kwh_year": tot_sb, "energy_cost_year": sum(r["cost_year"] for r in rows),
"standing_charge_year": standing * 365}
totals["energy_cost_month"] = totals["energy_cost_year"] / 12
totals["total_cost_year"] = totals["energy_cost_year"] + totals["standing_charge_year"]
totals["total_cost_month"] = totals["total_cost_year"] / 12
totals["savings_year"] = sum(i["saving_year"] for i in ideas)
return rows, totals, findings, ideas
def render(rows, totals, findings, ideas, rate, cur, offpeak_rate, standing, top):
c = (cur + " ") if cur else ""
out = ["# Home appliance energy cost estimate", "",
f"Tariff: {c}{rate:g}/kWh" + (f", off-peak {c}{offpeak_rate:g}/kWh" if offpeak_rate is not None else "")
+ (f", standing charge {c}{standing:g}/day" if standing else ""), "",
f"Total: {totals['kwh_day']:.1f} kWh/day, {totals['kwh_month']:.0f} kWh/month, {totals['kwh_year']:,.0f} kWh/year; "
f"energy {c}{totals['energy_cost_month']:,.2f}/month, {c}{totals['energy_cost_year']:,.2f}/year"
+ (f"; with standing charge {c}{totals['total_cost_year']:,.2f}/year" if standing else ""),
f"Standby: {totals['standby_kwh_year']:.0f} kWh/year ({totals['standby_kwh_year'] / totals['kwh_year']:.0%})" if totals["kwh_year"] else "", "",
"| Appliance | Qty | kWh/day | kWh/month | kWh/year | Standby kWh/yr | Cost/month | Cost/year | Share |",
"|---|---|---|---|---|---|---|---|---|"]
for r in rows:
out.append(f"| {r['appliance']} | {r['qty']:g} | {r['kwh_day']:.2f} | {r['kwh_month']:.1f} | {r['kwh_year']:.0f} | "
f"{r['standby_kwh_year']:.0f} | {r['cost_month']:.2f} | {r['cost_year']:.2f} | {r['share']:.0%} |")
out += ["", "## Findings"] + ([f"- {f['severity']} {f['code']}: {f['message']}" for f in findings] or ["- none"])
out += ["", f"## Savings ideas (top {min(top, len(ideas))}, estimated)"]
for i in ideas[:top]:
out.append(f"- {i['appliance']}: {i['idea']} -> about {c}{i['saving_year']:.2f}/year" + (f", {i['kwh_year']:.0f} kWh" if i["kwh_year"] else ""))
if len(ideas) > top:
out.append(f"- ... {len(ideas) - top} more with --top {len(ideas)}")
out += ["", "Estimates from rated watts and typical use; a plug-in energy meter or the smart meter gives real numbers. Savings overlap, so do not add them all up."]
return "\n".join(out)
def self_test():
results = []
def check(name, cond):
results.append(cond)
print(("PASS " if cond else "FAIL ") + name)
def run(csv_text, **kw):
return estimate(read_csv(csv_text), kw.pop("rate", 0.30), **kw)
rows, t, f, i = run("appliance,watts,hours_per_day\nKettle,2000,0.5\n")
check("1 kWh/day kettle", abs(rows[0]["kwh_day"] - 1.0) < 1e-9)
check("365 kWh/year", abs(t["kwh_year"] - 365) < 1e-9)
check("cost per year at 0.30", abs(t["energy_cost_year"] - 109.5) < 1e-6)
check("month = year/12", abs(t["kwh_month"] - 365 / 12) < 1e-9)
rows, t, f, i = run("appliance,watts,hours_per_day,days_per_week,quantity\nWashing machine,1000,2,3.5,1\nLED bulb,9,5,7,6\n")
w = [r for r in rows if r["appliance"] == "Washing machine"][0]
check("days_per_week scales", abs(w["kwh_day"] - 1.0) < 1e-9)
b = [r for r in rows if r["appliance"] == "LED bulb"][0]
check("quantity scales", abs(b["kwh_day"] - 0.27) < 1e-9)
check("LED idea skipped under 25 W", not any("LED" in x["idea"] for x in i))
rows, t, f, i = run("appliance,watts,hours_per_day,duty_cycle_pct\nFridge,150,24,40\n")
check("duty cycle", abs(rows[0]["kwh_day"] - 1.44) < 1e-9)
rows, t, f, i = run("appliance,watts,hours_per_day,standby_watts\nTV,100,4,10\n")
check("standby hours = 24 - use", abs(rows[0]["standby_kwh_year"] - 10 * 20 / 1000 * 365) < 1e-9)
codes = [x["code"] for x in f]
check("STANDBY_DEVICE", "STANDBY_DEVICE" in codes)
check("STANDBY_SHARE HIGH", "STANDBY_SHARE" in codes)
rows, t, f, i = run("appliance,watts,hours_per_day,kwh_per_year,quantity\nFreezer,,,250,2\n")
check("energy label kWh used", abs(t["kwh_year"] - 500) < 1e-9)
rows, t, f, i = run("appliance,watts,hours_per_day,offpeak_pct\nDishwasher,1200,1,50\n", offpeak_rate=0.15)
check("off-peak share priced", abs(rows[0]["cost_year"] - 1.2 * 365 * (0.5 * 0.30 + 0.5 * 0.15)) < 1e-6)
check("shift idea for rest", any("off-peak" in x["idea"] for x in i))
rows, t, f, i = run("appliance,watts,hours_per_day,months_per_year\nSpace heater,1000,4,6\n")
check("months_per_year seasonal", abs(t["kwh_year"] - 4 * 365 / 2) < 1e-9)
rows, t, f, i = run("appliance,watts,hours_per_day,days_per_week\nDishwasher,1000,1,7\n")
check("dishwasher gets eco idea, not washer idea", "eco" in i[0]["idea"])
rows, t, f, i = run("appliance,watts,hours_per_day\nHeater,2000,6\nTV,80,3\n")
check("BIG_CONSUMER", any(x["code"] == "BIG_CONSUMER" for x in f))
rows, t, f, i = run("appliance,watts,hours_per_day\nKettle,2000,0.5\n", bill_kwh=60)
check("BILL_GAP HIGH", any(x["code"] == "BILL_GAP" for x in f))
rows, t, f, i = run("appliance,watts,hours_per_day\nKettle,2000,0.5\n", bill_kwh=32)
check("no BILL_GAP within 25%", not any(x["code"] == "BILL_GAP" for x in f))
rows, t, f, i = run("appliance,watts,hours_per_day\nFridge,150,24\n")
check("CHECK_DUTY_CYCLE LOW", any(x["code"] == "CHECK_DUTY_CYCLE" for x in f))
rows, t, f, i = run("appliance,watts,hours_per_day\nAquarium pump,5000,1\n")
check("HIGH_WATTS LOW", any(x["code"] == "HIGH_WATTS" for x in f))
rows, t, f, i = run("appliance,watts,hours_per_day,standby_watts\nTelevision,90,4,0.5\nWater heater,2000,3,0\n", offpeak_rate=0.15)
check("keywords: TV not shiftable, water heater gets tank idea", not any(x["appliance"] == "Television" and "off-peak" in x["idea"] for x in i)
and any(x["appliance"] == "Water heater" and "tank" in x["idea"] for x in i))
rows, t, f, i = run("appliance;watts;hours_per_day\nHalogen lamp;60,0;4\n")
check("semicolon + decimal comma", abs(rows[0]["kwh_day"] - 0.24) < 1e-9 and any("LED" in x["idea"] for x in i))
rows, t, f, i = run("appliance,watts,hours_per_day\nTumble dryer,2500,1\n", standing=0.5)
check("standing charge separate", abs(t["standing_charge_year"] - 182.5) < 1e-9 and abs(t["total_cost_year"] - t["energy_cost_year"] - 182.5) < 1e-9)
check("dryer idea 50%", abs(i[0]["saving_year"] - 2.5 * 365 * 0.30 * 0.5) < 1e-6)
for bad, name in (("appliance,watts,hours_per_day\nX,100,25\n", "hours > 24 rejected"),
("appliance,watts,hours_per_day\nX,abc,2\n", "non-number rejected"),
("name,power\nX,100\n", "missing columns rejected"),
("appliance,watts,hours_per_day\nX,-5,2\n", "negative rejected")):
try:
read_csv(bad)
check(name, False)
except DataError:
check(name, True)
print(f"{sum(results)}/{len(results)} passed")
return 0 if all(results) else 1
def main():
ap = argparse.ArgumentParser(description="Home appliance energy cost estimator")
ap.add_argument("csv", nargs="?", help="appliances CSV")
ap.add_argument("--rate", type=float, help="price per kWh (standard rate)")
ap.add_argument("--offpeak-rate", type=float, help="price per kWh in off-peak hours")
ap.add_argument("--standing-charge", type=float, default=0.0, help="fixed charge per day")
ap.add_argument("--currency", default="", help="label, e.g. EUR, USD, TRY")
ap.add_argument("--bill-kwh", type=float, help="actual monthly kWh from a bill, to compare")
ap.add_argument("--top", type=int, default=8, help="savings ideas to show (default 8)")
ap.add_argument("--json", action="store_true")
ap.add_argument("--self-test", action="store_true")
a = ap.parse_args()
if a.self_test:
return self_test()
if not a.csv or a.rate is None:
ap.error("give a CSV and --rate, or --self-test")
try:
with open(a.csv, encoding="utf-8-sig") as fh:
items = read_csv(fh.read())
rows, totals, findings, ideas = estimate(items, a.rate, a.offpeak_rate, a.standing_charge, a.bill_kwh)
except (OSError, DataError) as e:
print(f"error: {e}", file=sys.stderr)
return 2
if a.json:
print(json.dumps({"appliances": rows, "totals": totals, "findings": findings, "ideas": ideas}, indent=2))
else:
print(render(rows, totals, findings, ideas, a.rate, a.currency, a.offpeak_rate, a.standing_charge, a.top))
return 1 if any(f["severity"] == "HIGH" for f in findings) else 0
if __name__ == "__main__":
sys.exit(main())Dán các phát hiện kiểm định nhà và điều khoản giao dịch của bạn để nhận kế hoạch JSON chặt chẽ: kết luận tổng thể, mọi phát hiện được phân loại theo độ an toàn, mức độ khẩn cấp và khoảng chi phí ước tính, các hạng mục cần chuyên gia kiểm tra thêm trước hạn chót, danh sách xếp hạng yêu cầu sửa chữa hoặc khấu trừ gửi người bán, câu hỏi cho người bán và kiểm định viên, chiến lược đàm phán kèm giới hạn rút lui, triển vọng ngân sách 5 năm và lịch bảo trì năm đầu tiên.
1{2 "role": "Cố vấn mua nhà thực tế, điềm tĩnh, giúp người mua nhà lần đầu và người mua có kinh nghiệm biến một báo cáo kiểm định nhà dài dòng, đáng lo ngại thành danh sách ưu tiên rõ ràng về những gì thực sự quan trọng, chi phí dự kiến và những gì cần yêu cầu từ người bán, mà không phóng đại các lỗi nhỏ hay đưa ra lời khuyên pháp lý",3 "task": "Chuyển đổi các phát hiện kiểm định, chi tiết bất động sản và các điều khoản giao dịch của tôi thành một kế hoạch JSON chặt chẽ duy nhất: kết luận tổng thể, mọi phát hiện được sắp xếp theo mức độ an toàn, chi phí và độ khẩn cấp, khoảng chi phí ước tính sơ bộ cho từng hạng mục kèm lý do, những hạng mục nào cần chuyên gia kiểm tra chuyên sâu trước hạn chót, danh sách xếp hạng các yêu cầu sửa chữa hoặc khoản khấu trừ cần đề xuất với người bán, các câu hỏi dành cho người bán và kiểm định viên, chiến lược đàm phán kèm điểm giới hạn rút lui (walk-away line), lịch trình cho đến hạn chót điều khoản dự phòng kiểm định (inspection contingency deadline), và danh sách bảo trì trong năm đầu tiên. Không bao giờ tự bịa ra các phát hiện, số đo, giá cả hoặc quy định địa phương mà tôi không cung cấp.",4 "context": {5 "property": "${property:a 1978 two-story detached house, 160 square meters, wood frame, basement}",6 "location_and_climate": "${location:a suburb with cold snowy winters and humid summers}",7 "purchase_price": "${price:420000}",8 "currency": "${currency:USD}",9 "inspection_findings": "${findings:roof shingles near end of life with some curling; furnace 22 years old, works; water heater 14 years old; double-tapped breakers in the electrical panel; a few ungrounded outlets; minor efflorescence and one hairline crack in the basement wall; slow drain in the upstairs bathroom; missing handrail on basement stairs; GFCI missing in the kitchen; gutters clogged; attic insulation thin}",10 "inspector_notes": "${inspector_notes:recommends an electrician and a roofer evaluate; no active leaks seen}",...+133 dòng nữa
Mô tả sản phẩm bị lỗi hoặc dịch vụ không được cung cấp đúng cam kết và nhận về một kế hoạch JSON chặt chẽ: tóm tắt vụ việc kèm hướng khắc phục cần yêu cầu, dòng thời gian sự việc, danh sách kiểm tra bằng chứng, các thời hạn cần xác nhận chính thức, lộ trình leo thang khiếu nại từ bộ phận hỗ trợ của người bán đến tranh chấp thẻ thanh toán và cơ quan bảo vệ người tiêu dùng, bản nháp tin nhắn lịch sự cho từng bước, kịch bản gọi điện và lịch trình theo dõi tiếp theo.
1{2 "role": "Chuyên viên tư vấn bảo vệ quyền lợi người tiêu dùng điềm tĩnh, làm việc có tổ chức, người giúp khách hàng nhận lại tiền hoàn trả, đổi sản phẩm mới hoặc sửa chữa đối với hàng hóa bị lỗi hay dịch vụ không được cung cấp đúng như cam kết, thông qua việc xây dựng hồ sơ bằng chứng rõ ràng và kế hoạch khiếu nại nâng dần cấp độ một cách lịch sự, không phóng đại, không đe dọa và không đưa ra tư vấn pháp lý",3 "task": "Chuyển đổi thông tin mua hàng của tôi, vấn đề gặp phải và những gì đã diễn ra tính đến hiện tại thành một bản kế hoạch JSON duy nhất tuân thủ cấu trúc nghiêm ngặt: tóm tắt vụ việc kèm theo phương án khắc phục mà tôi nên yêu cầu, dòng thời gian các sự kiện, danh sách kiểm tra bằng chứng nêu rõ những gì tôi đã có và những gì còn thiếu, các mốc thời hạn có thể áp dụng và cần được xác nhận lại, lộ trình khiếu nại từng bước từ bộ phận hỗ trợ của chính người bán cho đến tranh chấp qua thẻ hoặc cổng thanh toán và các cơ quan bảo vệ người tiêu dùng chính thức, các bản nháp tin nhắn soạn sẵn cho từng bước, lịch trình theo dõi nhắc lại, và một kịch bản gọi điện thoại ngắn gọn. Tuyệt đối không tự bịa ra các điều luật, thời hạn, chính sách, mã số hồ sơ hoặc các tình tiết mà tôi không cung cấp.",4 "context": {5 "item_or_service": "${item:a cordless vacuum cleaner}",6 "seller": "${seller:a large online electronics store}",7 "purchase_date": "${purchase_date:5 weeks ago}",8 "price_paid": "${price:249}",9 "currency": "${currency:EUR}",10 "payment_method": "${payment:credit card}",...+120 dòng nữa
Chuẩn bị cho một cuộc trao đổi xin tăng lương, thăng chức hoặc đàm phán lương khi nhận lời mời làm việc (job offer) và nhận về một bảng chuẩn bị định dạng JSON nghiêm ngặt: kiểm tra mức độ sẵn sàng, lập luận chứng minh giá trị bản thân từ những thành tựu thực tế, kế hoạch thu thập dữ liệu thị trường thay vì đoán mò khoảng lương, các con số mức sàn từ chối (walk-away), mức mục tiêu (target) và mức đề xuất (ask), các quyền lợi phi tiền lương có thể thương lượng, kịch bản mở lời, câu trả lời điềm tĩnh trước những lời từ chối/ép giá thường gặp, phương án xử lý khi đối phương im lặng hoặc từ chối, mẫu email phản hồi sau buổi gặp và kế hoạch tập dượt.
1{2 "role": "Chuyên gia huấn luyện đàm phán đãi ngộ điềm tĩnh, thực tế, chuyên giúp mọi người chuẩn bị cho một cuộc trao đổi cụ thể về thu nhập — dù là xin tăng lương với công ty hiện tại hay bước đàm phán lương cho một lời mời làm việc mới — bằng cách xây dựng dẫn chứng thuyết phục, xác định con số rõ ràng, điểm dừng từ chối (walk-away point) và kịch bản lời thoại lịch sự từng chữ một, mà không tự bịa ra dữ liệu thị trường hay khuyến khích việc tung hỏa mù",3 "task": "Chuyển đổi vị trí công việc, hoàn cảnh hiện tại, các bằng chứng thành tích và cuộc trao đổi sắp tới của tôi thành một bảng chuẩn bị duy nhất ở định dạng JSON nghiêm ngặt: tóm tắt tình huống, lập luận chứng minh giá trị bản thân dựa trên bằng chứng, kế hoạch tra cứu dữ liệu thị trường chỉ rõ nơi cần kiểm tra khung lương thay vì đoán mò, các con số mức mục tiêu (target), mức đề xuất (ask) và mức sàn từ chối (walk-away) kèm lý giải hợp lý, các khoản đãi ngộ phi tiền lương có thể đưa ra trao đổi, kịch bản mở lời, câu trả lời cho những phản bác có khả năng xảy ra, phương án ứng phó với sự im lặng hoặc lời từ chối ('không'), bản nháp email gửi sau cuộc họp và kế hoạch tập dượt ngắn gọn. Không bao giờ tự bịa ra các con số lương, chính sách công ty, ngân sách hay những thông tin thực tế mà tôi không cung cấp.",4 "context": {5 "conversation_type": "${conversation_type:raise review with my current manager}",6 "current_or_offered_role": "${role:senior data analyst}",7 "company_type_and_size": "${company:a mid-size software company, about 400 people}",8 "location_and_work_mode": "${location:Lisbon, hybrid 3 days in the office}",9 "current_pay": "${current_pay:42000 base, no bonus}",10 "currency": "${currency:EUR}",...+132 dòng nữa
Nhận lại phần tiền đặt cọc thuê nhà công bằng của bạn với bản kế hoạch JSON chặt chẽ: các quy định địa phương và thời hạn cần xác nhận chính thức, danh sách kiểm tra trả nhà theo từng phòng, nhật ký hình ảnh và bằng chứng có ghi ngày tháng, phân biệt hao mòn tự nhiên và hư hỏng thực tế cho từng vấn đề, đánh giá từng khoản khấu trừ được đề xuất, lộ trình thời gian, thư yêu cầu hoàn trả tiền cọc lịch sự, thư khiếu nại dựa trên dữ kiện thực tế và quy trình leo thang từng bước. Hỗ trợ sắp xếp thông tin, không phải tư vấn pháp lý.
1{2 "role": "Trợ lý hỗ trợ trả nhà điềm tĩnh, có tổ chức đứng về phía người thuê, giúp người thuê nhà nhận lại tối đa số tiền đặt cọc một cách công bằng thông qua việc chuẩn bị căn hộ và bằng chứng trước khi bàn giao chìa khóa, tách biệt hao mòn tự nhiên với hư hỏng thực tế, và soạn thảo các bức thư khiếu nại lịch sự, dựa trên dữ kiện xác thực mà không đưa ra lời khuyên pháp lý hay tự bịa ra luật lệ, thời hạn hoặc số tiền",3 "task": "Chuyển đổi thông tin chỗ thuê của tôi, ngày chuyển đi, tình trạng hiện tại của nơi ở và bất kỳ khoản khấu trừ nào mà chủ nhà đề xuất thành một bản kế hoạch thu hồi tiền đặt cọc duy nhất theo định dạng JSON chặt chẽ: tóm tắt tình huống, các quy định và thời hạn địa phương tôi cần xác nhận chính thức, danh sách kiểm tra trả nhà theo từng phòng, kế hoạch lập nhật ký bằng chứng, đánh giá hao mòn tự nhiên so với hư hỏng thực tế cho từng vấn đề, rà soát từng khoản khấu trừ được đề xuất, mốc thời gian kèm ngày cụ thể, thư yêu cầu hoàn trả tiền cọc lịch sự, thư khiếu nại các khoản khấu trừ không thỏa đáng, lộ trình leo thang xử lý và danh sách ngắn những sai lầm cần tránh. Tuyệt đối không tự bịa ra quy định pháp lý, thời hạn, lãi suất, số tiền hoặc dữ kiện mà tôi không cung cấp.",4 "context": {5 "country_region_city": "${location:Portland, Oregon, USA}",6 "rental_type": "${rental_type:one-bedroom apartment managed by a property company}",7 "lease_dates": "${lease_dates:2 years, ending on the 30th of next month}",8 "deposit_amount": "${deposit:1500}",9 "currency": "${currency:USD}",10 "move_in_records": "${move_in_records:a move-in checklist signed by both sides and about 20 phone photos from move-in day}",...+147 dòng nữa
So sánh các phương án đi làm - ô tô (xăng hoặc xe điện), phương tiện công cộng, xe đạp, xe đạp điện, đi bộ, kết hợp luân phiên theo ngày và chặng gửi xe đi tàu/buýt (park-and-ride) - theo tháng và năm kèm chi phí tiền mặt, thời gian di chuyển trọn gói từ cửa đến cửa, chi phí thời gian tính theo mức giá trị giờ của riêng bạn, chi phí mỗi chuyến và lượng phát thải CO2. Tự động chọn giữa vé tháng và trả tiền từng lượt dựa trên số ngày bạn đi, tách biệt chi phí vận hành xe với chi phí cố định, đồng thời hiển thị mức giá trị hòa vốn của một giờ. Bao gồm bộ tính toán bằng Python sử dụng thư viện chuẩn (stdlib) kèm tùy chọn --self-test.
---
name: commute-cost-comparison-calculator
description: So sánh các phương án đi làm - ô tô (xăng hoặc xe điện), phương tiện công cộng, xe đạp, xe đạp điện, đi bộ, kết hợp luân phiên theo ngày và chặng gửi xe đi tàu/buýt (park-and-ride) - theo tháng và năm kèm chi phí tiền mặt, thời gian di chuyển trọn gói từ cửa đến cửa, chi phí thời gian tính theo mức giá trị giờ của riêng bạn, chi phí mỗi chuyến và lượng phát thải CO2. Tự động chọn giữa vé tháng và trả tiền từng lượt dựa trên số ngày bạn đi, tách biệt chi phí vận hành xe với chi phí cố định, đồng thời hiển thị mức giá trị hòa vốn của một giờ. Bao gồm bộ tính toán bằng Python sử dụng thư viện chuẩn (stdlib) kèm tùy chọn --self-test.
---
# Công Cụ Tính Toán & So Sánh Chi Phí Đi Lại
Bạn giúp người dùng quyết định cách đi làm bằng việc đặt mọi phương án lên cùng một bàn cân: tốn bao nhiêu tiền mặt, lấy đi bao nhiêu thời gian trong cuộc sống của họ, và khoảng thời gian đó đáng giá bao nhiêu đối với họ. Bạn chỉ tính những khoản thực sự thay đổi theo lựa chọn (nhiên liệu, phí gửi xe, giá vé, chi phí xe đạp), loại bỏ các khoản họ đằng nào cũng phải trả trừ khi họ thực sự bán chiếc ô tô đó, và thể hiện sự đánh đổi bằng một con số rõ ràng: "lái xe tiết kiệm được 32 giờ mất đi mỗi năm và tốn thêm $3,697, vì vậy phương án này chỉ đáng giá nếu một giờ của bạn trị giá hơn $115".
## Các tệp trong kỹ năng này
- `scripts/commute_cost.py` - đọc tệp JSON kịch bản (khoảng cách, số ngày đi làm, giá trị thời gian, các phương án) và in ra bảng so sánh, tóm tắt nổi bật, giá trị thời gian hòa vốn và ghi chú cho từng phương án; hỗ trợ hệ mét (km, L/100 km) và hệ Anh-Mỹ (mi, mpg), xe điện (EV), vé tháng phương tiện công cộng có mức trần chi phí ngày và trợ cấp từ công ty, phương án kết hợp luân phiên theo ngày và chuyến đi nhiều chặng (chỉ dùng thư viện chuẩn của Python 3.8+)
- `references/cost-components.md` - mọi trường thông tin của phương án, những khoản cần đưa vào cho từng loại phương tiện, nơi tra cứu số liệu và các khoảng giá trị điển hình
- `references/time-value-and-assumptions.md` - cách chọn giá trị cho một giờ thời gian, thời gian có thể tận dụng trên phương tiện công cộng, những yếu tố chưa nằm trong tổng chi phí (sức khỏe, độ an toàn, thời tiết, sự linh hoạt) và cách chạy kiểm tra độ nhạy
- `templates/commute-comparison.md` - bản tóm tắt quyết định để gửi lại cho người dùng
- `examples/commute.json` - quãng đường đi làm 11 dặm với các phương án lái xe, xe buýt + tàu điện nhẹ (light rail), xe đạp điện, kết hợp xe đạp điện/phương tiện công cộng và gửi xe đi tàu/buýt (park-and-ride)
- `examples/comparison-output.md` - kết quả đầu ra thực tế của công cụ tính toán cho kịch bản đó
## Quy trình thực hiện
### 1. Thu thập thông tin thực tế
Khoảng cách một chiều, số ngày đi làm mỗi tuần (làm việc kết hợp hybrid ảnh hưởng rất lớn) và số tuần làm việc mỗi năm, số phút di chuyển từ cửa đến cửa cho mỗi phương án (bao gồm cả đi bộ, chờ đợi, gửi xe và chuyển tuyến: hãy hỏi thời gian chuyến đi thực tế chứ không chỉ lấy ước tính trên bản đồ), và thông tin theo từng phương tiện: mức tiêu thụ nhiên liệu của ô tô, giá xăng/dầu hoặc giá điện, phí gửi xe và cầu đường, liệu họ có giữ lại chiếc xe dù không lái đi làm hay không; giá vé lẻ phương tiện công cộng, giá vé tháng, mức trần chi phí ngày, trợ cấp của công ty; giá mua xe đạp hoặc xe đạp điện, tuổi thọ dự kiến và chi phí bảo dưỡng. Hãy hỏi xem một giờ thời gian của họ đáng giá bao nhiêu, hoặc đưa ra một mức mặc định (bằng một nửa mức lương theo giờ của họ) và trình bày phân tích độ nhạy.
### 2. Chạy công cụ tính toán
```
python3 scripts/commute_cost.py examples/commute.json
python3 scripts/commute_cost.py scenario.json --days-per-week 3
python3 scripts/commute_cost.py scenario.json --value-of-time 0 --json
python3 scripts/commute_cost.py --self-test
```
Mã thoát 0 = đã in bảng so sánh, 2 = dữ liệu đầu vào không hợp lệ (thiếu trường, tỷ lệ kết hợp cộng lại không bằng 1, loại phương tiện không xác định, giá âm). Nếu không chạy mã, hãy làm theo các công thức trong `references/cost-components.md`.
### 3. Đọc kết quả
- Bảng được sắp xếp theo tổng chi phí (tiền mặt + chi phí thời gian); dòng tiêu đề chỉ ra phương án rẻ nhất về tiền mặt, rẻ nhất khi tính cả thời gian và nhanh nhất.
- **Giá trị thời gian hòa vốn (Break-even value of time)** so sánh từng phương án với phương án rẻ nhất về tiền mặt: một phương án tốn nhiều tiền hơn chỉ đáng chọn nếu một giờ của bạn có giá trị cao hơn con số được hiển thị. "Never" (Không bao giờ) có nghĩa là phương án đó vừa tốn kém hơn vừa không tiết kiệm được thời gian hữu ích.
- Thời gian ngồi trên phương tiện công cộng mà bạn có thể tận dụng (đọc sách, xử lý email) được khấu trừ thông qua `usable_time_share`; cùng trường này cũng có thể dùng để cộng điểm cho thời gian đạp xe nếu việc đó thay thế cho thời gian tập thể dục mà đằng nào bạn cũng thực hiện. Hãy nêu rõ bạn đã áp dụng cách nào.
- Kiểm tra ghi chú về phương tiện công cộng: khi chỉ đi làm 2-3 ngày một tuần, vé tháng thường đắt hơn trả tiền từng lượt; công cụ tính toán sẽ tự chọn cách rẻ hơn và ghi chú rõ điều đó.
### 4. Bàn giao kết quả
Điền vào `templates/commute-comparison.md`: khuyến nghị gói gọn trong một câu, bảng so sánh, các mức giá trị hòa vốn, những yếu tố làm thay đổi câu trả lời (số ngày làm hybrid, giá nhiên liệu, phí gửi xe, giá trị thời gian), những yếu tố chưa được định giá bằng tiền, và gợi ý dùng thử (ví dụ: thử nghiệm phương án kết hợp trong hai tuần trước khi mua sắm bất cứ thứ gì).
## Ví dụ kết quả
11 dặm mỗi chiều, 5 ngày một tuần, 46 tuần, thời gian được định giá ở mức $20/giờ (`examples/commute.json`). Rẻ nhất về tiền mặt: xe buýt + tàu điện nhẹ ở mức $49/tháng sau khi trừ $50 trợ cấp từ công ty (vé tháng $99 rẻ hơn so với $105 tiền mua vé lẻ). Rẻ nhất khi tính cả thời gian: gửi xe đi tàu/buýt - park and ride ($95 tiền mặt, tổng cộng $361/tháng) vì 80 % thời gian chặng đi tàu có thể tận dụng làm việc khác. Lái xe vào trung tâm thành phố là nhanh nhất (28 phút) nhưng tốn $357/tháng với $12/ngày phí gửi xe và chỉ đáng giá nếu một giờ trị giá hơn $115. Xe đạp điện tốn trọn gói $67/tháng và không phát thải, nhưng không tiết kiệm thời gian hữu ích với các thông số này; phương án này sẽ chiến thắng nếu việc đạp xe thay thế cho thời gian tập gym. Xem `examples/comparison-output.md`.
## Quy tắc
- Chỉ tính những chi phí thực sự thay đổi theo lựa chọn; chỉ đưa chi phí bảo hiểm ô tô, đăng kiểm và lãi vay mua xe vào nếu người đó thực sự từ bỏ chiếc xe (`count_fixed`).
- Sử dụng thời gian thực tế từ cửa đến cửa, bao gồm cả đi bộ, chờ đợi, gửi xe và chuyển tuyến.
- Không bao giờ trình bày giá trị thời gian như một sự thật hiển nhiên: đó là lựa chọn riêng của mỗi người; hãy hiển thị ít nhất một lần chạy kiểm tra độ nhạy (ví dụ: `--value-of-time 0` và mức lương theo giờ của họ).
- Giữ các yếu tố sức khỏe, an toàn và thời tiết nằm ngoài tổng số tiền nhưng hãy đề cập đến chúng; không rao giảng đạo đức về việc đi ô tô hay xe đạp.
- Nêu rõ mọi giả định kèm theo con số cụ thể (giá nhiên liệu, phí gửi xe, khấu hao trên mỗi dặm) để người dùng có thể tự điều chỉnh.
- Sử dụng đơn vị tiền tệ và đơn vị đo lường đúng như người dùng đang sử dụng; không trộn lẫn dặm (miles) và kilômét (kilometres) trong cùng một kịch bản.
FILE:references/cost-components.md
# Các thành phần chi phí và trường thông tin phương án
Các trường của kịch bản: `currency` (USD, EUR, GBP, TRY, ...), `units` (`metric`: km, L/100 km, giá mỗi lít; `imperial`: dặm/miles, mpg, giá mỗi gallon Mỹ), `one_way_distance`, `days_per_week` (mặc định là 5), `weeks_per_year` (mặc định là 46: trừ ngày lễ, ngày nghỉ ốm, công tác), `value_of_time_per_hour`, `options`.
Mọi phương án đều có: `name`, `mode`, `minutes_one_way` (từ cửa đến cửa), `distance_one_way` tùy chọn (mặc định lấy theo khoảng cách của kịch bản), `usable_time_share` tùy chọn (0..1).
## Ô tô (`mode: car`)
| Trường | Ý nghĩa | Giá trị điển hình |
|---|---|---|
| `fuel_economy_mpg` / `fuel_l_per_100km` | mức tiêu thụ thực tế trên tuyến đường này (lái xe trong phố tốn hơn thông số nhãn) | 25-35 mpg / 6-9 L/100 km |
| `fuel_price` | tính theo mỗi gallon / mỗi lít | giá tại trạm xăng địa phương |
| `fuel: "diesel"` | sử dụng hệ số phát thải CO2 của dầu diesel | mặc định là xăng (petrol) |
| `kwh_per_100` + `electricity_price` | xe điện thay vì nhiên liệu đốt trong (kWh trên mỗi 100 dặm hoặc km) | 25-35 kWh/100 mi, 16-22 kWh/100 km |
| `grid_kg_co2_per_kwh` | cường độ phát thải của lưới điện để tính CO2 cho xe điện | mặc định 0.4; 0.05 với thủy điện/điện hạt nhân, 0.7 với lưới điện phụ thuộc nhiều vào than đá |
| `maintenance_per_distance` | lốp xe, bảo dưỡng định kỳ, sửa chữa trên mỗi dặm/km | $0.08-0.12/mi, 0.05-0.08/km |
| `depreciation_per_distance` | phần giá trị mất đi chỉ do số dặm/km chạy thêm (không tính hao mòn theo tuổi xe) | $0.05-0.15/mi |
| `parking_per_day`, `tolls_per_day` | tính trên mỗi ngày đi làm | phí gửi xe tại nơi làm việc có thể chiếm tỷ trọng lớn nhất |
| `fixed_per_year` + `count_fixed` | bảo hiểm, đăng kiểm, lãi vay, khấu hao theo tuổi xe; chỉ được tính khi `count_fixed` là true | chỉ đặt là true nếu việc đi làm là lý do duy nhất để sở hữu chiếc xe |
Chi phí nhiên liệu = 2 x khoảng cách x số ngày / mpg x giá (hoặc x L/100 km / 100 x giá). Khí thải CO2: xăng 2.35 kg/L (8.89 kg mỗi gallon Mỹ), dầu diesel 2.68 kg/L (10.18 kg/gal), xe điện tính bằng số kWh x hệ số phát thải lưới điện.
Các mức chi phí trọn gói trên mỗi dặm (định mức khấu trừ thuế theo dặm, báo cáo "Your Driving Costs" của AAA) đã bao gồm cả chi phí cố định; chúng làm phóng đại chi phí cận biên của việc đi làm khi đằng nào người dùng vẫn giữ lại chiếc xe.
## Phương tiện công cộng (`mode: transit`)
| Trường | Ý nghĩa |
|---|---|
| `fare_per_ride`, `rides_per_day` (mặc định là 2) | giá vé lẻ; cộng thêm một lượt cho mỗi lần chuyển tuyến có thu phí (không được miễn phí) |
| `daily_cap` | mức trần giá vé tối đa trong ngày, nếu hệ thống giao thông có áp dụng |
| `monthly_pass` | giá vé tháng; công cụ tính toán sẽ so sánh 12 vé tháng với chi phí trả từng lượt cho số ngày thực tế đi lại và chọn phương án rẻ hơn |
| `subsidy_per_month` | khoản trợ cấp từ công ty hoặc phúc lợi miễn thuế, được khấu trừ trực tiếp (không bao giờ giảm xuống dưới 0) |
| `co2_g_per_passenger_km` | mặc định 80 g; tàu điện 20-40, xe buýt chạy dầu diesel 80-120 ở mức tải trọng trung bình |
| `usable_time_share` | tỷ lệ thời gian chuyến đi bạn có thể đọc sách hoặc làm việc (có chỗ ngồi, không bị căng thẳng vì chuyển tuyến): 0.2-0.8 |
## Xe đạp, xe đạp điện, đi bộ (`mode: bike | ebike | walk`)
| Trường | Ý nghĩa | Giá trị điển hình |
|---|---|---|
| `purchase_price`, `life_years` | phân bổ đều trong suốt vòng đời sử dụng (mặc định 5 năm); không dùng cho đi bộ | xe đạp điện $1,500-3,500 |
| `maintenance_per_year` | lốp, xích, má phanh, quỹ thay pin cho xe đạp điện | $100-300 |
| `gear_per_year` | đèn, khóa, áo mưa, thay mũ bảo hiểm | $50-150 |
| `kwh_per_100`, `electricity_price` | chi phí sạc xe đạp điện | 1-2 kWh trên mỗi 100 dặm |
| `other_per_month` | phí gửi xe đạp, thuê tủ đồ, thẻ phòng tắm | 0-30 |
Chi phí sở hữu được tính toàn bộ ngay cả trong phương án kết hợp: bạn vẫn phải trả tiền mua chiếc xe đạp dù đi 3 hay 5 ngày một tuần.
## Kết hợp (`mode: mix`)
`"shares": {"E-bike": 0.6, "Bus + light rail": 0.4}` chia số ngày đi làm giữa các phương án khác nhau (tổng các tỷ lệ cộng lại bằng 1). Mỗi phần được tính chi phí theo đúng số ngày sử dụng của phần đó, vì vậy phần đi phương tiện công cộng trong 2 ngày một tuần thường sẽ chuyển sang hình thức trả tiền từng lượt. Hãy sử dụng chế độ này cho việc đạp xe phụ thuộc vào thời tiết, lái xe kết hợp (hybrid), hoặc những ngày đi chung xe (carpool).
## Nhiều chặng (`mode: multi`)
`"legs": [ {car leg}, {transit leg} ]` cùng với `transfer_minutes`. Mỗi chặng cần có `distance_one_way` và `minutes_one_way` riêng; chi phí và số phút sẽ được cộng dồn lại. Hãy dùng chế độ này cho hình thức gửi xe đi tàu/buýt (park and ride), xe đạp + tàu hỏa, hoặc đi bộ + xe buýt.
## Lấy số liệu ở đâu
- Mức tiêu thụ nhiên liệu: xem trên đồng hồ hành trình (trip computer) của ô tô trong suốt một tuần đi làm thực tế.
- Phí gửi xe và giá vé: trang bảng giá của đơn vị vận hành; kiểm tra các phúc lợi từ công ty (trợ cấp phương tiện công cộng trước thuế, quy đổi chỗ đỗ xe thành tiền mặt - parking cash-out).
- Thời gian từ cửa đến cửa: bấm giờ một chuyến đi thực tế cho mỗi chiều vào đúng khung giờ cao điểm đi làm; cộng thêm 5 phút dự phòng cho sự dao động khi tìm chỗ đỗ xe hoặc chờ tàu/buýt.
FILE:references/time-value-and-assumptions.md
# Giá trị thời gian và những yếu tố chưa nằm trong tổng chi phí
## Cách chọn giá trị thời gian
- Không có con số nào là đúng tuyệt đối: đó là mức giá trị mà một giờ đi làm thực sự mang lại cho chính người đó.
- Mốc tham chiếu phổ biến: khoảng một nửa mức lương thực nhận theo giờ đối với việc di chuyển cá nhân (hướng dẫn định giá thời gian di chuyển của Bộ Giao thông Vận tải Hoa Kỳ sử dụng mức 50 % thu nhập theo giờ cho các chuyến đi cá nhân tại địa phương; di chuyển công tác được định giá ở mức 100 %).
- Một người hưởng lương cố định, không thể kiếm thêm thu nhập nhờ giảm thời gian đi lại có thể chọn mức giá trị thấp hơn; một người làm nghề tự do (freelancer) có thể tính phí cho những giờ tiết kiệm được sẽ chọn mức cao hơn.
- Luôn trình bày phân tích độ nhạy: `--value-of-time 0` (chỉ tính tiền mặt), mức giá trị đã chọn, và toàn bộ mức lương theo giờ của họ. Nếu thứ hạng các phương án không thay đổi, quyết định đó là vững chắc.
## Thời gian có thể tận dụng
`usable_time_share` khấu trừ khoảng thời gian bạn có thể tận dụng làm việc khác. Hướng dẫn ước tính sơ bộ:
| Tình huống | Tỷ lệ |
|---|---|
| Lái xe, đạp xe giữa dòng giao thông, đứng trên xe buýt đông đúc | 0 |
| Đi xe buýt có chỗ ngồi, các chặng ngắn, phải chuyển tuyến | 0.2-0.4 |
| Đi tàu có chỗ ngồi và bàn làm việc, hơn 20 phút liên tục không phải đổi chuyến | 0.6-0.8 |
| Đạp xe hoặc đi bộ giúp thay thế cho thời gian tập thể dục mà đằng nào bạn cũng làm | 0.3-0.7 (hãy nêu rõ điều này) |
## Những yếu tố không nằm trong tổng số tiền (cần đề cập đến)
- **Sức khỏe:** đạp xe hoặc đi bộ thường xuyên gắn liền với sức khỏe tim mạch tốt hơn; một số người đánh giá rất cao lợi ích này, số khác lại không quan tâm. Hãy sử dụng `usable_time_share` nếu họ muốn quy đổi lợi ích này thành tiền.
- **Độ an toàn:** rủi ro tai nạn giao thông trên mỗi dặm khác nhau tùy theo phương tiện và tuyến đường; một làn đường dành riêng cho xe đạp có rào chắn bảo vệ sẽ làm thay đổi hoàn toàn bức tranh tổng thể.
- **Thời tiết và mùa vụ:** hãy sử dụng phương án kết hợp (ví dụ: 60 % số ngày đi xe đạp) thay vì giả định ngày nào cũng có thể đạp xe.
- **Độ ổn định (Reliability):** một chuyến tàu mất 40 phút với biên độ dao động 10 phút có thể tốt hơn một chuyến lái xe trung bình 30 phút nhưng dao động từ 25 đến 60 phút; hãy hỏi về các khoản phạt hoặc hậu quả khi trễ giờ (ví dụ: đón con muộn tại nhà trẻ).
- **Sự linh hoạt và giá trị quyền chọn:** việc có sẵn ô tô để tạt qua giải quyết việc vặt trên đường, hoặc cho các tình huống khẩn cấp; dịch vụ thuê xe theo giờ hoặc ngân sách đi taxi có thể thay thế cho những ngày hiếm hoi đó (cộng thêm vào mục `other_per_month`).
- **Mức độ căng thẳng và sự thoải mái:** đừng định giá bằng tiền, nhưng hãy hỏi người dùng về yếu tố này.
## Các kịch bản kiểm tra độ nhạy đáng thực hiện
- Làm việc kết hợp (Hybrid work): `--days-per-week 3` thường làm đảo ngược sự lựa chọn giữa vé tháng và trả tiền từng lượt, đồng thời khiến việc sở hữu chiếc ô tô thứ hai trở nên kém kinh tế hơn hẳn.
- Phí gửi xe: sự thay đổi về giá gửi xe (hoặc khoản tiền công ty thưởng khi không dùng chỗ đỗ xe) thường là đòn bẩy đơn lẻ lớn nhất.
- Giá nhiên liệu hoặc giá điện biến động +-25 %.
- Bán chiếc ô tô thứ hai: chạy lại tính toán với `count_fixed: true` cho phương án ô tô để thấy toàn bộ khoản tiền tiết kiệm được.
## Kiểm tra tính hợp lý trước khi trình bày
- Chi phí tiền mặt mỗi chuyến khi lái xe tối thiểu phải bằng chi phí nhiên liệu + phí gửi xe mỗi chuyến.
- Chi phí tiền mặt mỗi tháng cho phương tiện công cộng không được vượt quá giá vé tháng trừ khi vé tháng không bao phủ hết tuyến đường đó.
- Số giờ mỗi tháng = số phút một chiều x 2 x số ngày mỗi tháng / 60.
- Nếu phương án "chiến thắng" phụ thuộc vào một mức giá trị thời gian cách quá xa so với con số người dùng đưa ra, hãy nói rõ điều đó một cách thẳng thắn.
FILE:templates/commute-comparison.md
# Các phương án đi làm: <nhà> đến <nơi làm việc>
**Chuyến đi:** <11 dặm mỗi chiều, 5 ngày/tuần, 46 tuần/năm> **Giá trị thời gian áp dụng:** <$20/giờ (khoảng một nửa lương theo giờ)>
**Khuyến nghị:** <một câu, ví dụ: "Gửi xe đi tàu/buýt (Park and ride): $95/tháng tiền mặt, 40 phút từ cửa đến cửa, phần lớn thời gian đi tàu có thể tận dụng để xử lý email">
## Bảng so sánh (theo tháng)
| Phương án | Tiền mặt | Từ cửa đến cửa | Số giờ | Chi phí thời gian | Tổng cộng | CO2 kg/năm |
|---|---|---|---|---|---|---|
| <Park and ride> | <$95> | <40 min> | <25.6> | <$266> | <$361> | <999> |
| <Bus + light rail> | <$49> | <46 min> | <29.4> | <$411> | <$460> | <651> |
## Khi nào một phương án đắt đỏ hơn mới đáng tiền
| Phương án | Chi phí tăng thêm mỗi năm | Số giờ mất đi được tiết kiệm | Đáng giá nếu một giờ trị giá trên |
|---|---|---|---|
| <Drive downtown> | <$3,697> | <32> | <$115/hour> |
## Những yếu tố sẽ làm thay đổi câu trả lời
- <Làm việc kết hợp (3 ngày): vé tháng phương tiện công cộng không còn có lợi; trả tiền từng lượt ...>
- <Khoản tiền thưởng không dùng chỗ đỗ xe từ công ty sẽ khiến việc lái xe đắt thêm $230/tháng nữa>
- <Nếu chuyến đi bằng xe đạp điện thay thế cho việc tập gym, phương án xe đạp điện sẽ vươn lên ...>
## Những yếu tố chưa được định giá bằng tiền
- <sức khỏe, độ an toàn trên tuyến đường, những ngày thời tiết xấu, độ ổn định giờ giấc, nhu cầu dùng ô tô để đón con đi học>
## Các giả định
- <nhiên liệu $3.40/gal ở mức tiêu thụ 28 mpg; bảo dưỡng $0.10/mi; khấu hao theo số dặm $0.08/mi; phí gửi xe $12/ngày>
- <vé tháng phương tiện công cộng $99, trợ cấp từ công ty $50/tháng, 30 % thời gian đi xe buýt/tàu có thể tận dụng>
- <vẫn giữ ô tô để đi cuối tuần, do đó chi phí bảo hiểm và đăng kiểm không được tính vào>
## Bước tiếp theo
<Thử nghiệm phương án kết hợp trong hai tuần trước khi mua xe đạp điện; bấm giờ thực tế từng chuyến từ cửa đến cửa.>
FILE:examples/commute.json
{
"currency": "USD",
"units": "imperial",
"one_way_distance": 11,
"days_per_week": 5,
"weeks_per_year": 46,
"value_of_time_per_hour": 20,
"options": [
{
"name": "Drive downtown",
"mode": "car",
"minutes_one_way": 28,
"fuel_economy_mpg": 28,
"fuel_price": 3.4,
"maintenance_per_distance": 0.1,
"depreciation_per_distance": 0.08,
"parking_per_day": 12,
"tolls_per_day": 0,
"fixed_per_year": 1900,
"count_fixed": false
},
{
"name": "Bus + light rail",
"mode": "transit",
"minutes_one_way": 46,
"fare_per_ride": 2.75,
"monthly_pass": 99,
"subsidy_per_month": 50,
"usable_time_share": 0.3
},
{
"name": "E-bike",
"mode": "ebike",
"minutes_one_way": 42,
"purchase_price": 2200,
"life_years": 5,
"maintenance_per_year": 250,
"gear_per_year": 100,
"kwh_per_100": 1.5,
"electricity_price": 0.17
},
{
"name": "E-bike 3 days, transit 2 days",
"mode": "mix",
"shares": {"E-bike": 0.6, "Bus + light rail": 0.4}
},
{
"name": "Park and ride",
"mode": "multi",
"transfer_minutes": 6,
"legs": [
{"mode": "car", "distance_one_way": 4, "minutes_one_way": 10, "fuel_economy_mpg": 28, "fuel_price": 3.4,
"maintenance_per_distance": 0.1, "depreciation_per_distance": 0.08, "parking_per_day": 0},
{"mode": "transit", "distance_one_way": 7, "minutes_one_way": 24, "fare_per_ride": 2.75, "monthly_pass": 99,
"subsidy_per_month": 50, "usable_time_share": 0.8}
]
}
]
}
FILE:examples/comparison-output.md
# So sánh chi phí đi làm
Một chiều: 11 dặm Số ngày đi làm: 19.2/tháng (230/năm) Giá trị thời gian: $20.00/giờ Tiền tệ: USD
| Phương án | Tiền mặt / tháng | Tiền mặt / năm | Mỗi chuyến | Từ cửa đến cửa (một chiều) | Số giờ / tháng | Chi phí thời gian / tháng | Tổng cộng / tháng | CO2 kg / năm |
|---|---|---|---|---|---|---|---|---|
| Park and ride | $95.22 | $1,143 | $2.48 | 40 min | 25.6 | $266 | **$361** | 999 |
| Bus + light rail | $49.00 | $588 | $1.28 | 46 min | 29.4 | $411 | **$460** | 651 |
| E-bike 3 days, transit 2 days | $66.48 | $798 | $1.73 | 44 min | 27.9 | $487 | **$553** | 261 |
| E-bike | $66.91 | $803 | $1.75 | 42 min | 26.8 | $537 | **$604** | 0 |
| Drive downtown | $357 | $4,285 | $9.32 | 28 min | 17.9 | $358 | **$715** | 1,607 |
Được sắp xếp theo tổng chi phí (tiền mặt + chi phí thời gian).
## Điểm nổi bật
- Rẻ nhất về tiền mặt: **Bus + light rail** ($49.00/tháng).
- Rẻ nhất khi tính cả thời gian: **Park and ride** ($361/tháng ở mức $20.00/giờ).
- Nhanh nhất: **Drive downtown** (28 phút một chiều, 17.9 giờ/tháng).
## Giá trị thời gian hòa vốn (so với Bus + light rail, phương án rẻ nhất về tiền mặt)
| Phương án | Tiền mặt tăng thêm / năm | Số giờ mất đi tiết kiệm được / năm | Đáng giá nếu một giờ của bạn trị giá hơn |
|---|---|---|---|
| Drive downtown | $3,697 | 32 | $115/hour |
| E-bike | $215 | không có (mất thêm 75 giờ) | không bao giờ xét về chi phí: phương án này không tiết kiệm thời gian hữu ích |
| E-bike 3 days, transit 2 days | $210 | không có (mất thêm 45 giờ) | không bao giờ xét về chi phí: phương án này không tiết kiệm thời gian hữu ích |
| Park and ride | $555 | 87 | $6.35/hour |
## Ghi chú cho từng phương án
- **Drive downtown** (car): mỗi tháng: nhiên liệu/năng lượng $51.20, bảo dưỡng + khấu hao $75.90, phí gửi xe + cầu đường $230; chi phí cố định (bảo hiểm, đăng kiểm) không được tính: đằng nào bạn vẫn giữ lại chiếc xe
- **Bus + light rail** (transit): mỗi tháng: tiền vé $49.00; dùng vé tháng (rẻ hơn mức $105/tháng nếu trả từng lượt cho 19.2 ngày/tháng); đã khấu trừ trợ cấp từ công ty $50.00/tháng
- **E-bike** (ebike): mỗi tháng: tiền xe + bảo dưỡng $65.83, nhiên liệu/năng lượng $1.08; chi phí sở hữu được tính toàn bộ (chúng không giảm đi vào những ngày bạn không đạp xe)
- **E-bike 3 days, transit 2 days** (mix): E-bike 60 % số ngày (11.5/tháng): chi phí sở hữu được tính toàn bộ (chúng không giảm đi vào những ngày bạn không đạp xe); Bus + light rail 40 % số ngày (7.7/tháng): trả tiền từng lượt (mua vé tháng sẽ tốn tới $99.00/tháng cho 7.7 ngày/tháng)
- **Park and ride** (multi): chặng 2 (transit): dùng vé tháng (rẻ hơn mức $105/tháng nếu trả từng lượt cho 19.2 ngày/tháng); chặng 2 (transit): đã khấu trừ trợ cấp từ công ty $50.00/tháng
Chi phí thời gian = số giờ x giá trị thời gian x (1 - tỷ lệ chuyến đi bạn có thể tận dụng để đọc sách hoặc làm việc). Chưa bao gồm: lợi ích sức khỏe, rủi ro tai nạn, những ngày thời tiết xấu và giá trị quyền chọn của việc có sẵn ô tô; xem references/time-value-and-assumptions.md.
FILE:scripts/commute_cost.py
#!/usr/bin/env python3
"""Commute cost comparison calculator (Python 3.8+ standard library only).
Compares commute options - car (fuel or electric), transit, bike, e-bike, walking,
day-share mixes ("bike 3 days, train 2 days") and same-day multi-leg trips ("drive to
the station, then train") - per month and per year: cash cost, time spent, time cost at
your own hourly value, total, cost per trip and CO2. For transit it picks the cheaper of
pay-per-ride (with a daily cap) and a monthly pass for the days you actually ride; for
cars it separates running costs from fixed costs you would keep paying anyway. It also
prints break-even values of time: how much an hour must be worth to you before a faster,
dearer option pays off.
Usage:
python3 commute_cost.py scenario.json [--value-of-time 25] [--days-per-week 3] [--json]
python3 commute_cost.py --self-test
scenario.json: {"currency": "USD", "units": "imperial"|"metric", "one_way_distance": 11,
"days_per_week": 5, "weeks_per_year": 46, "value_of_time_per_hour": 20,
"options": [{"name": "...", "mode": "car|transit|bike|ebike|walk|mix|multi", ...}]}
See references/cost-components.md for every option field.
Exit codes: 0 = comparison printed, 2 = bad input.
"""
import argparse, json, sys
PETROL_CO2 = {"metric": 2.35, "imperial": 8.89} # kg CO2 per litre / per US gallon burned (diesel: 2.68 / 10.18)
DIESEL_CO2 = {"metric": 2.68, "imperial": 10.18}
KM_PER_UNIT = {"metric": 1.0, "imperial": 1.609344}
LABELS = {"energy": "fuel/energy", "running": "maintenance + depreciation", "parking_tolls": "parking + tolls",
"fixed": "fixed costs", "fares": "fares", "ownership": "bike + upkeep", "other": "other"}
MODES = {"car", "transit", "bike", "ebike", "walk", "mix", "multi"}
SYMBOLS = {"USD": "$", "EUR": "€", "GBP": "£", "TRY": "₺", "CAD": "C$", "AUD": "A$", "JPY": "¥", "INR": "₹"}
class InputError(Exception):
pass
def num(o, key, default=None, lo=0.0, hi=None, who=""):
v = o.get(key, default)
if v is None:
raise InputError(f"{who}: '{key}' is required")
if isinstance(v, bool) or not isinstance(v, (int, float)):
raise InputError(f"{who}: '{key}' must be a number")
if v < lo or (hi is not None and v > hi):
raise InputError(f"{who}: '{key}' = {v} is out of range" + (f" ({lo}..{hi})" if hi is not None else f" (>= {lo})"))
return float(v)
def car_cost(o, sc, days, who):
"""Annual cost dict for a car over `days` commute days."""
units = sc["units"]
d = num(o, "distance_one_way", sc["distance"], who=who) * 2 * days # user units per year
if "kwh_per_100" in o:
kwh = d / 100 * num(o, "kwh_per_100", who=who)
energy = kwh * num(o, "electricity_price", who=who)
co2 = kwh * num(o, "grid_kg_co2_per_kwh", 0.4, who=who)
else:
if units == "imperial":
vol = d / num(o, "fuel_economy_mpg", lo=1, who=who)
else:
vol = d / 100 * num(o, "fuel_l_per_100km", lo=0.5, who=who)
energy = vol * num(o, "fuel_price", who=who)
co2 = vol * (DIESEL_CO2 if o.get("fuel") == "diesel" else PETROL_CO2)[units]
running = d * (num(o, "maintenance_per_distance", 0, who=who) + num(o, "depreciation_per_distance", 0, who=who))
daily = days * (num(o, "parking_per_day", 0, who=who) + num(o, "tolls_per_day", 0, who=who))
fixed = num(o, "fixed_per_year", 0, who=who) if o.get("count_fixed", False) else 0.0
return {"energy": energy, "running": running, "parking_tolls": daily, "fixed": fixed,
"co2": co2, "notes": ([] if o.get("count_fixed", False) or not o.get("fixed_per_year") else
["fixed costs (insurance, registration) not counted: you keep the car anyway"])}
def transit_cost(o, sc, days, who):
rides = num(o, "rides_per_day", 2, lo=1, who=who)
fare = num(o, "fare_per_ride", who=who)
cap = o.get("daily_cap")
per_day = fare * rides if cap is None else min(fare * rides, num(o, "daily_cap", who=who))
payg_year = per_day * days
best, note = payg_year, "pay per ride"
if o.get("monthly_pass") is not None:
pass_year = num(o, "monthly_pass", who=who) * 12
months_days = days / 12
if pass_year < payg_year:
best, note = pass_year, f"monthly pass (cheaper than {fmt_plain(payg_year / 12, sc['sym'])}/month pay per ride at {months_days:.1f} days/month)"
else:
note = f"pay per ride (pass would cost {fmt_plain(pass_year / 12, sc['sym'])}/month at {months_days:.1f} days/month)"
subsidy = num(o, "subsidy_per_month", 0, who=who) * 12
best = max(0.0, best - subsidy)
km = num(o, "distance_one_way", sc["distance"], who=who) * 2 * days * KM_PER_UNIT[sc["units"]]
co2 = km * num(o, "co2_g_per_passenger_km", 80, who=who) / 1000
return {"fares": best, "co2": co2, "notes": [note] + ([f"employer subsidy {fmt_plain(subsidy / 12, sc['sym'])}/month deducted"] if subsidy else [])}
def bike_cost(o, sc, days, who, mode):
d = num(o, "distance_one_way", sc["distance"], who=who) * 2 * days
own = 0.0
if mode != "walk":
life = num(o, "life_years", 5, lo=0.5, who=who)
own = num(o, "purchase_price", 0, who=who) / life
own += num(o, "maintenance_per_year", 0, who=who) + num(o, "gear_per_year", 0, who=who)
energy = 0.0
if mode == "ebike":
kwh = d / 100 * num(o, "kwh_per_100", 1.2, who=who)
energy = kwh * num(o, "electricity_price", 0, who=who)
extra = num(o, "other_per_month", 0, who=who) * 12
return {"ownership": own, "energy": energy, "other": extra, "co2": 0.0,
"notes": ["ownership costs counted in full (they do not shrink on days you do not ride)"] if own else []}
def option_cost(o, sc, days, who):
mode = o.get("mode")
if mode == "car":
c = car_cost(o, sc, days, who)
elif mode == "transit":
c = transit_cost(o, sc, days, who)
elif mode in ("bike", "ebike", "walk"):
c = bike_cost(o, sc, days, who, mode)
else:
raise InputError(f"{who}: mode '{mode}' is not allowed here (use car, transit, bike, ebike, walk)")
minutes = num(o, "minutes_one_way", who=who, lo=1)
cash = sum(v for k, v in c.items() if k not in ("co2", "notes"))
return {"cash": cash, "minutes": minutes, "co2": c["co2"], "notes": c["notes"],
"usable": num(o, "usable_time_share", 0, hi=1, who=who),
"breakdown": {k: v for k, v in c.items() if k not in ("co2", "notes")}}
def evaluate(sc_raw, value_override=None, dpw_override=None):
if not isinstance(sc_raw, dict) or not isinstance(sc_raw.get("options"), list) or not sc_raw["options"]:
raise InputError("scenario needs an 'options' list")
units = sc_raw.get("units", "metric")
if units not in KM_PER_UNIT:
raise InputError("units must be 'metric' or 'imperial'")
cur = sc_raw.get("currency", "USD")
sc = {"units": units, "distance": num(sc_raw, "one_way_distance", who="scenario", lo=0.1),
"sym": SYMBOLS.get(cur, f"{cur} ")}
dpw = dpw_override if dpw_override is not None else num(sc_raw, "days_per_week", 5, lo=0.5, hi=7, who="scenario")
wpy = num(sc_raw, "weeks_per_year", 46, lo=1, hi=52, who="scenario")
days = dpw * wpy
vot = value_override if value_override is not None else num(sc_raw, "value_of_time_per_hour", 0, who="scenario")
by_name = {}
for i, o in enumerate(sc_raw["options"]):
if not isinstance(o, dict) or not o.get("name") or o.get("mode") not in MODES:
raise InputError(f"option {i + 1}: needs a 'name' and a 'mode' ({', '.join(sorted(MODES))})")
if o["name"] in by_name:
raise InputError(f"option name '{o['name']}' is used twice")
by_name[o["name"]] = o
results = []
for o in sc_raw["options"]:
who = o["name"]
if o["mode"] == "mix":
shares = o.get("shares")
if not isinstance(shares, dict) or not shares:
raise InputError(f"{who}: mix needs 'shares', e.g. {{\"Bike\": 0.6, \"Transit\": 0.4}}")
tot = sum(shares.values())
if abs(tot - 1) > 0.001:
raise InputError(f"{who}: shares add up to {tot:g}, not 1")
cash = co2 = hours = eff = 0.0
notes, parts = [], []
for nm, sh in shares.items():
if nm not in by_name or by_name[nm]["mode"] in ("mix",):
raise InputError(f"{who}: share '{nm}' must name another non-mix option")
sub = by_name[nm]
part = multi_cost(sub, sc, days * sh, nm) if sub["mode"] == "multi" else option_cost(sub, sc, days * sh, nm)
cash += part["cash"]; co2 += part["co2"]
h = part["minutes"] * 2 * days * sh / 60
hours += h; eff += h * (1 - part["usable"])
parts.append(f"{nm} {sh * 100:.0f} % of days ({days * sh / 12:.1f}/month): {part['notes'][0] if part['notes'] else 'as above'}")
minutes = hours * 60 / (2 * days)
results.append(row(who, "mix", cash, minutes, hours, eff, vot, co2, days, parts))
continue
part = multi_cost(o, sc, days, who) if o["mode"] == "multi" else option_cost(o, sc, days, who)
hours = part["minutes"] * 2 * days / 60
results.append(row(who, o["mode"], part["cash"], part["minutes"], hours, hours * (1 - part["usable"]), vot,
part["co2"], days, part["notes"], part.get("breakdown")))
return {"currency": sc_raw.get("currency", "USD"), "units": units, "one_way_distance": sc["distance"],
"days_per_year": days, "days_per_month": days / 12, "value_of_time_per_hour": vot,
"options": results, "break_even": break_even(results)}
def multi_cost(o, sc, days, who):
legs = o.get("legs")
if not isinstance(legs, list) or len(legs) < 2:
raise InputError(f"{who}: multi needs at least two 'legs'")
cash = co2 = minutes = 0.0
notes, usable_min = [], 0.0
for j, leg in enumerate(legs, 1):
if leg.get("mode") in ("mix", "multi"):
raise InputError(f"{who} leg {j}: legs cannot be mix or multi")
if "distance_one_way" not in leg:
raise InputError(f"{who} leg {j}: each leg needs its own 'distance_one_way'")
p = option_cost(leg, sc, days, f"{who} leg {j}")
cash += p["cash"]; co2 += p["co2"]; minutes += p["minutes"]
usable_min += p["minutes"] * p["usable"]
notes += [f"leg {j} ({leg['mode']}): {n}" for n in p["notes"]]
minutes += num(o, "transfer_minutes", 0, who=who)
return {"cash": cash, "co2": co2, "minutes": minutes, "notes": notes or ["legs summed"],
"usable": usable_min / minutes if minutes else 0.0}
def row(name, mode, cash, minutes, hours, eff_hours, vot, co2, days, notes, breakdown=None):
tcost = eff_hours * vot
return {"name": name, "mode": mode, "cash_year": cash, "cash_month": cash / 12, "minutes_one_way": minutes,
"hours_month": hours / 12, "lost_hours_month": eff_hours / 12, "time_cost_month": tcost / 12, "total_month": (cash + tcost) / 12,
"total_year": cash + tcost, "cost_per_trip": cash / (2 * days), "co2_kg_year": co2,
"notes": notes, "breakdown": breakdown or {}}
def break_even(results):
"""For each option, the hourly value of time at which it ties with the cheapest-cash option.
Uses lost hours (time you cannot use for reading or work), the same basis as the time cost."""
base = min(results, key=lambda r: r["cash_year"])
out = []
for r in results:
if r is base:
continue
extra = r["cash_year"] - base["cash_year"]
hours_saved = (base["lost_hours_month"] - r["lost_hours_month"]) * 12
if hours_saved > 0.01:
out.append({"option": r["name"], "vs": base["name"], "extra_cash_year": extra,
"hours_saved_year": hours_saved, "break_even_per_hour": extra / hours_saved})
else:
out.append({"option": r["name"], "vs": base["name"], "extra_cash_year": extra,
"hours_saved_year": hours_saved, "break_even_per_hour": None})
return out
def fmt_plain(v, sym="$"):
return f"{sym}{v:,.0f}" if abs(v) >= 100 else f"{sym}{v:,.2f}"
def render(res):
sym = SYMBOLS.get(res["currency"], res["currency"] + " ")
f = lambda v: fmt_plain(v, sym)
du = "mi" if res["units"] == "imperial" else "km"
out = [f"# Commute cost comparison", "",
f"One way: {res['one_way_distance']:g} {du} Commute days: {res['days_per_month']:.1f}/month ({res['days_per_year']:.0f}/year) "
f"Value of time: {f(res['value_of_time_per_hour'])}/hour Currency: {res['currency']}", "",
"| Option | Cash / month | Cash / year | Per trip | Door to door (one way) | Hours / month | Time cost / month | Total / month | CO2 kg / year |",
"|---|---|---|---|---|---|---|---|---|"]
rs = res["options"]
for r in sorted(rs, key=lambda r: r["total_month"]):
out.append(f"| {r['name']} | {f(r['cash_month'])} | {f(r['cash_year'])} | {f(r['cost_per_trip'])} | {r['minutes_one_way']:.0f} min | "
f"{r['hours_month']:.1f} | {f(r['time_cost_month'])} | **{f(r['total_month'])}** | {r['co2_kg_year']:,.0f} |")
cheapest = min(rs, key=lambda r: r["cash_year"])
best_total = min(rs, key=lambda r: r["total_year"])
fastest = min(rs, key=lambda r: r["minutes_one_way"])
out += ["", "Sorted by total (cash + time cost).", "",
"## Headline", "",
f"- Cheapest in cash: **{cheapest['name']}** ({f(cheapest['cash_month'])}/month).",
f"- Cheapest once time counts: **{best_total['name']}** ({f(best_total['total_month'])}/month at {f(res['value_of_time_per_hour'])}/hour).",
f"- Fastest: **{fastest['name']}** ({fastest['minutes_one_way']:.0f} min one way, {fastest['hours_month']:.1f} h/month)."]
out += ["", f"## Break-even value of time (vs {cheapest['name']}, the cheapest in cash)", "",
"| Option | Extra cash / year | Lost hours saved / year | Worth it if your hour is worth more than |", "|---|---|---|---|"]
for b in res["break_even"]:
if b["break_even_per_hour"] is None:
verdict = "never on cost: it does not save usable time" if b["extra_cash_year"] > 0 else "always: same cash, no more lost time"
hs = f"{b['hours_saved_year']:.0f}" if b["hours_saved_year"] >= 0 else f"none ({-b['hours_saved_year']:.0f} more lost)"
out.append(f"| {b['option']} | {f(b['extra_cash_year'])} | {hs} | {verdict} |")
else:
out.append(f"| {b['option']} | {f(b['extra_cash_year'])} | {b['hours_saved_year']:.0f} | {f(b['break_even_per_hour'])}/hour |")
out += ["", "## Notes per option", ""]
for r in rs:
bd = ", ".join(f"{LABELS.get(k, k)} {f(v / 12)}" for k, v in r["breakdown"].items() if v) if r["breakdown"] else ""
out.append(f"- **{r['name']}** ({r['mode']}): " + "; ".join(([f"per month: {bd}"] if bd else []) + r["notes"]))
out += ["", "Time cost = hours x value of time x (1 - share of the trip you can use for reading or work). "
"Not included: health benefits, accident risk, weather days and the option value of having a car; "
"see references/time-value-and-assumptions.md."]
return "\n".join(out) + "\n"
def self_test():
base = {"currency": "USD", "units": "metric", "one_way_distance": 10, "days_per_week": 5, "weeks_per_year": 48,
"value_of_time_per_hour": 20}
car = {"name": "Car", "mode": "car", "minutes_one_way": 20, "fuel_l_per_100km": 8, "fuel_price": 1.5,
"maintenance_per_distance": 0.05, "parking_per_day": 5}
tr = {"name": "Transit", "mode": "transit", "minutes_one_way": 40, "fare_per_ride": 2.5, "monthly_pass": 80}
bike = {"name": "Bike", "mode": "bike", "minutes_one_way": 35, "purchase_price": 1000, "life_years": 5, "maintenance_per_year": 100}
def ev(*opts, **kw):
sc = dict(base, options=list(opts)); sc.update(kw.pop("sc", {}))
return evaluate(sc, **kw)
def get(res, n):
return next(r for r in res["options"] if r["name"] == n)
cases = []
def case(name, fn):
try:
ok, err = bool(fn()), ""
except Exception as e: # noqa
ok, err = False, repr(e)
cases.append((name, ok, err))
# car: 240 days * 20 km = 4800 km; fuel 384 L * 1.5 = 576; maint 240; parking 1200 -> 2016/year
case("car annual cash (fuel + maintenance + parking)", lambda: abs(get(ev(car), "Car")["cash_year"] - 2016) < 0.01)
case("car CO2 petrol 2.35 kg/L", lambda: abs(get(ev(car), "Car")["co2_kg_year"] - 384 * 2.35) < 0.01)
case("diesel CO2 factor", lambda: abs(get(ev(dict(car, fuel="diesel")), "Car")["co2_kg_year"] - 384 * 2.68) < 0.01)
case("fixed costs excluded unless count_fixed", lambda: get(ev(dict(car, fixed_per_year=1500)), "Car")["cash_year"] == get(ev(car), "Car")["cash_year"])
case("fixed costs included with count_fixed", lambda: abs(get(ev(dict(car, fixed_per_year=1500, count_fixed=True)), "Car")["cash_year"] - 3516) < 0.01)
case("EV uses kWh and electricity price", lambda: abs(get(ev(dict(car, kwh_per_100=18, electricity_price=0.3, fuel_l_per_100km=None)), "Car")["breakdown"]["energy"] - 4800 / 100 * 18 * 0.3) < 0.01)
case("imperial mpg and $/gal", lambda: abs(get(ev({"name": "Car", "mode": "car", "minutes_one_way": 20, "fuel_economy_mpg": 30, "fuel_price": 3.6}, sc={"units": "imperial"}), "Car")["cash_year"] - 4800 / 30 * 3.6) < 0.01)
case("imperial CO2 8.89 kg/gal", lambda: abs(get(ev({"name": "Car", "mode": "car", "minutes_one_way": 20, "fuel_economy_mpg": 30, "fuel_price": 3.6}, sc={"units": "imperial"}), "Car")["co2_kg_year"] - 160 * 8.89) < 0.01)
# transit: per ride 5/day*240 = 1200 vs pass 960 -> pass
case("transit picks monthly pass when cheaper", lambda: abs(get(ev(tr), "Transit")["cash_year"] - 960) < 0.01)
case("transit picks pay-per-ride at 2 days/week", lambda: abs(get(ev(tr, dpw_override=2), "Transit")["cash_year"] - 5 * 96) < 0.01)
case("daily cap applied", lambda: abs(get(ev(dict(tr, monthly_pass=None, daily_cap=4)), "Transit")["cash_year"] - 4 * 240) < 0.01)
case("employer subsidy deducted, never below 0", lambda: get(ev(dict(tr, subsidy_per_month=100)), "Transit")["cash_year"] == 0)
case("bike ownership spread over life", lambda: abs(get(ev(bike), "Bike")["cash_year"] - 300) < 0.01)
case("ebike electricity", lambda: abs(get(ev(dict(bike, name="E", mode="ebike", kwh_per_100=1.5, electricity_price=0.3)), "E")["breakdown"]["energy"] - 4800 / 100 * 1.5 * 0.3) < 0.01)
case("walk has no purchase cost", lambda: get(ev({"name": "Walk", "mode": "walk", "minutes_one_way": 120, "purchase_price": 999}), "Walk")["cash_year"] == 0)
case("time cost = hours x value", lambda: abs(get(ev(car), "Car")["time_cost_month"] - 20 * 2 * 240 / 60 * 20 / 12) < 0.01)
case("usable time share discounts transit time", lambda: abs(get(ev(dict(tr, usable_time_share=0.5)), "Transit")["time_cost_month"] - 40 * 2 * 240 / 60 * 20 * 0.5 / 12) < 0.01)
case("value-of-time override", lambda: get(ev(car, value_override=0), "Car")["time_cost_month"] == 0)
case("per trip = annual cash / trips", lambda: abs(get(ev(car), "Car")["cost_per_trip"] - 2016 / 480) < 0.001)
# mix: bike 60 % of days, transit 40 % -> transit 96 days: per ride 480 vs pass 960 -> per ride
mixo = {"name": "Mix", "mode": "mix", "shares": {"Bike": 0.6, "Transit": 0.4}}
case("mix: bike ownership full + transit pay-per-ride on its days", lambda: abs(get(ev(bike, tr, mixo), "Mix")["cash_year"] - (300 + 480)) < 0.01)
case("mix: time weighted by days", lambda: abs(get(ev(bike, tr, mixo), "Mix")["minutes_one_way"] - (0.6 * 35 + 0.4 * 40)) < 0.01)
multi = {"name": "P+R", "mode": "multi", "transfer_minutes": 5, "legs": [
{"mode": "car", "distance_one_way": 3, "minutes_one_way": 8, "fuel_l_per_100km": 8, "fuel_price": 1.5, "parking_per_day": 2},
{"mode": "transit", "distance_one_way": 7, "minutes_one_way": 20, "fare_per_ride": 2.5, "monthly_pass": 80}]}
case("multi: legs summed + transfer", lambda: (lambda r: abs(r["minutes_one_way"] - 33) < 0.01 and abs(r["cash_year"] - (1440 * 0.08 * 1.5 + 480 + 960)) < 0.01)(get(ev(multi), "P+R")))
case("break-even: car vs cheapest", lambda: (lambda res: (lambda b: abs(b["break_even_per_hour"] - (2016 - 300) / ((35 - 20) * 2 * 240 / 60)) < 0.01)(next(b for b in res["break_even"] if b["option"] == "Car")))(ev(car, bike)))
case("break-even uses lost hours (usable transit time)", lambda: next(b for b in ev(dict(tr, usable_time_share=0.75, subsidy_per_month=60), bike)["break_even"] if b["option"] == "Bike")["break_even_per_hour"] is None)
case("break-even: slower and dearer -> None", lambda: next(b for b in ev(car, tr, bike)["break_even"] if b["option"] == "Transit")["break_even_per_hour"] is None)
case("render has headline and table", lambda: "Cheapest in cash: **Bike**" in render(ev(car, tr, bike)) and "| Car |" in render(ev(car, tr, bike)))
case("render uses currency symbol", lambda: "€" in render(ev(car, sc={"currency": "EUR"})))
def bad(*opts, **sc):
try:
ev(*opts, sc=sc); return False
except InputError:
return True
case("mix shares must sum to 1", lambda: bad(bike, tr, dict(mixo, shares={"Bike": 0.6, "Transit": 0.6})))
case("mix share must name an option", lambda: bad(bike, dict(mixo, shares={"Bike": 0.5, "Train": 0.5})))
case("unknown mode rejected", lambda: bad({"name": "Boat", "mode": "boat", "minutes_one_way": 5}))
case("duplicate names rejected", lambda: bad(car, car))
case("missing fuel economy rejected", lambda: bad({"name": "Car", "mode": "car", "minutes_one_way": 20, "fuel_price": 1.5}))
case("negative price rejected", lambda: bad(dict(car, fuel_price=-1)))
case("bad units rejected", lambda: bad(car, units="furlongs"))
case("usable share above 1 rejected", lambda: bad(dict(tr, usable_time_share=1.5)))
passed = 0
for name, ok, err in cases:
passed += ok
print(("PASS " if ok else "FAIL ") + name + ("" if ok else f" {err}"))
print(f"{passed}/{len(cases)} passed")
return 0 if passed == len(cases) else 1
def main(argv=None):
ap = argparse.ArgumentParser(description="Compare commute options per month and year, including time value")
ap.add_argument("scenario", nargs="?")
ap.add_argument("--value-of-time", type=float, help="override value_of_time_per_hour")
ap.add_argument("--days-per-week", type=float, help="override days_per_week (e.g. 3 for hybrid work)")
ap.add_argument("--json", action="store_true")
ap.add_argument("--self-test", action="store_true")
a = ap.parse_args(argv)
if a.self_test:
return self_test()
if not a.scenario:
ap.error("give a scenario JSON file, or --self-test")
try:
with open(a.scenario, encoding="utf-8") as fh:
sc = json.load(fh)
if a.days_per_week is not None and not 0.5 <= a.days_per_week <= 7:
raise InputError("--days-per-week must be 0.5..7")
res = evaluate(sc, a.value_of_time, a.days_per_week)
except json.JSONDecodeError as e:
print(f"error: scenario is not valid JSON: {e}", file=sys.stderr)
return 2
except (OSError, InputError, UnicodeDecodeError) as e:
print(f"error: {e}", file=sys.stderr)
return 2
print(json.dumps(res, indent=2) if a.json else render(res), end="" if not a.json else "\n")
return 0
if __name__ == "__main__":
sys.exit(main())