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>
206 lines
7.5 KiB
Python
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())
|