#!/usr/bin/env python3 """Post TNCSC's opening balances as a single journal entry dated the last fiscal year-end, in the company's Miscellaneous Operations journal. Input CSV columns (source system export - typically a trial balance dump; adjust the header names below to match the real export once available): account_code,debit,credit,label,partner_email - account_code: required, must match an existing account.account code for the target company. - debit / credit: each optional but exactly one of the two must be a positive number per row (standard opening-balance convention: never both, never neither). - label: optional line label (defaults to "Opening Balance - "). - partner_email: optional, links the line to a res.partner (useful for AR/AP opening balances). Looked up by email; left blank if not found or not given. The CSV must balance: total debit must equal total credit across all rows, or the script refuses to post anything (this is checked before any Odoo write happens). Behaviour: - Idempotent: the entry is created with a fixed ref ("Opening Balances Import") in the Miscellaneous Operations journal. If a posted entry with that ref already exists in that journal dated the same day, the script does nothing and reports it as already done - re-running never creates a duplicate. - The entry date is the most recently completed fiscal year-end before today, computed from the company's fiscal year configuration, unless --date overrides it. - Draft entry is created first, then posted (action_post) - so a failure partway through leaves nothing posted. Usage: python scripts/migrate_opening_balances.py \\ --url https://your-odoo-host --db communityos_dev \\ --username admin --password admin \\ --csv data/raw/opening_balances.csv \\ [--date 2025-12-31] [--dry-run] """ import argparse import csv import sys from datetime import date from pathlib import Path from _migration_common import authenticate, execute_kw, normalize_email OPENING_BALANCE_REF = 'Opening Balances Import' def read_rows(csv_path): with open(csv_path, newline='', encoding='utf-8') as handle: yield from csv.DictReader(handle) def parse_amount(value): value = (value or '').strip() if not value: return 0.0 return float(value.replace(',', '')) def last_fiscal_year_end(last_day, last_month, today): candidate = date(today.year, last_month, last_day) if candidate >= today: candidate = date(today.year - 1, last_month, last_day) return candidate.isoformat() def main(): parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument('--url', required=True) parser.add_argument('--db', required=True) parser.add_argument('--username', required=True) parser.add_argument('--password', required=True) parser.add_argument('--csv', required=True, type=Path) parser.add_argument('--date', help="Override the entry date (YYYY-MM-DD); default is the last fiscal year-end") parser.add_argument('--dry-run', action='store_true') args = parser.parse_args() if not args.csv.exists(): print(f"CSV file not found: {args.csv}", file=sys.stderr) return 1 uid = authenticate(args.url, args.db, args.username, args.password) def rpc(model, method, call_args, kwargs=None): return execute_kw(args.url, args.db, uid, args.password, model, method, call_args, kwargs) company = rpc('res.users', 'read', [[uid], ['company_id']])[0]['company_id'] company_id = company[0] company_info = rpc('res.company', 'read', [[company_id], ['fiscalyear_last_day', 'fiscalyear_last_month']])[0] entry_date = args.date or last_fiscal_year_end( company_info['fiscalyear_last_day'], int(company_info['fiscalyear_last_month']), date.today(), ) account_by_code = {} for account in rpc('account.account', 'search_read', [[('company_ids', 'in', [company_id])]], {'fields': ['code']}): account_by_code[account['code']] = account['id'] lines = [] errors = [] total_debit = 0.0 total_credit = 0.0 for row_number, row in enumerate(read_rows(args.csv), start=2): code = (row.get('account_code') or '').strip() if not code: errors.append(f"row {row_number}: missing account_code") continue account_id = account_by_code.get(code) if not account_id: errors.append(f"row {row_number}: unknown account_code '{code}'") continue try: debit = parse_amount(row.get('debit')) credit = parse_amount(row.get('credit')) except ValueError: errors.append(f"row {row_number}: debit/credit must be numeric") continue if bool(debit) == bool(credit): errors.append(f"row {row_number}: exactly one of debit/credit must be positive (got debit={debit}, credit={credit})") continue partner_email = normalize_email(row.get('partner_email')) partner_id = False if partner_email: partner_ids = rpc('res.partner', 'search', [[('email', '=', partner_email)]], {'limit': 1}) partner_id = partner_ids[0] if partner_ids else False label = (row.get('label') or '').strip() vals = { 'account_id': account_id, 'name': label or f"Opening Balance - {code}", 'debit': debit, 'credit': credit, } if partner_id: vals['partner_id'] = partner_id lines.append(vals) total_debit += debit total_credit += credit if errors: print("Refusing to post - CSV has errors:", file=sys.stderr) for error in errors: print(f" {error}", file=sys.stderr) return 1 if not lines: print("No valid rows found - nothing to post.", file=sys.stderr) return 1 if round(total_debit - total_credit, 2) != 0.0: print( f"Refusing to post - out of balance: total debit={total_debit:.2f}, total credit={total_credit:.2f}", file=sys.stderr, ) return 1 if args.dry_run: print(f"DRY-RUN would post {len(lines)} lines dated {entry_date}, " f"total debit={total_debit:.2f}, total credit={total_credit:.2f}") return 0 journal_ids = rpc('account.journal', 'search', [[ ('type', '=', 'general'), ('company_id', '=', company_id), ]], {'limit': 1}) if not journal_ids: print("No general-type journal found for this company.", file=sys.stderr) return 1 journal_id = journal_ids[0] existing = rpc('account.move', 'search', [[ ('ref', '=', OPENING_BALANCE_REF), ('journal_id', '=', journal_id), ('date', '=', entry_date), ('state', '=', 'posted'), ]], {'limit': 1}) if existing: print(f"Already done: posted entry {existing[0]} with ref '{OPENING_BALANCE_REF}' " f"dated {entry_date} already exists. Nothing to do.") return 0 move_id = rpc('account.move', 'create', [{ 'move_type': 'entry', 'journal_id': journal_id, 'date': entry_date, 'ref': OPENING_BALANCE_REF, 'line_ids': [(0, 0, line) for line in lines], }]) rpc('account.move', 'action_post', [[move_id]]) print(f"Done. Posted account.move id={move_id} dated {entry_date}, " f"{len(lines)} lines, total debit={total_debit:.2f}, total credit={total_credit:.2f}") return 0 if __name__ == '__main__': sys.exit(main())