TNCSC_Odoo/scripts/migrate_opening_balances.py
metatroncubeswdev 9a24691c37
Some checks failed
CI / Brand-leak check (push) Has been cancelled
CI / flake8 / pylint-odoo / manifest completeness (push) Has been cancelled
CI / Install all modules with --test-enable (push) Has been cancelled
feat: Phase 8 TNCSC data migration scripts (members, students, opening balances)
Adds migrate_students.py (parent+child partner upsert, current-term
enrollment by school level code) and migrate_opening_balances.py (one
posted, balance-checked journal entry dated the last fiscal year-end).
All three Phase 8 scripts (incl. the existing migrate_members.py) verified
against a live Odoo 19 instance: fresh tncsc_migration_test DB with
tncsc_deployment installed, dry-run + real run + idempotency re-run each,
confirmed via RPC no duplicate records were created. See HANDOFF.md for
the full verification log and why a fresh DB was used instead of
communityos_dev.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 01:47:34 -04:00

206 lines
7.5 KiB
Python

#!/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 - <account
name>").
- 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())