import sys import os import argparse import openpyxl from datetime import datetime, date from dateutil.relativedelta import relativedelta # Constants CUTOFF_DATE = date(2025, 10, 31) def main(): parser = argparse.ArgumentParser(description="Import Fixed Assets to Odoo") parser.add_argument("odoo_bin_path", help="Path to odoo-bin executable") parser.add_argument("conf_path", help="Path to odoo.conf") parser.add_argument("excel_path", help="Path to the Excel file") parser.add_argument("db_name", help="Database name") args = parser.parse_args() odoo_bin_path = os.path.abspath(args.odoo_bin_path) conf_path = os.path.abspath(args.conf_path) excel_path = os.path.abspath(args.excel_path) db_name = args.db_name # Add Odoo to sys.path odoo_root = os.path.dirname(odoo_bin_path) if odoo_root not in sys.path: sys.path.append(odoo_root) # Change CWD to config directory to handle relative paths in config (like addons_path) os.chdir(os.path.dirname(conf_path)) try: import odoo from odoo import api, SUPERUSER_ID except ImportError: print(f"Error: Could not import 'odoo' module from {odoo_root}. Make sure odoo-bin path is correct.") sys.exit(1) print(f"Initializing Odoo Environment for database: {db_name}...") try: odoo.tools.config.parse_config(['-c', conf_path]) registry = odoo.registry(db_name) except Exception as e: print(f"Error initializing Odoo: {e}") return with registry.cursor() as cr: env = api.Environment(cr, SUPERUSER_ID, {}) print("Connected to Odoo.") try: ensure_models(env) process_import(env, excel_path) cr.commit() print("Changes committed to database.") except Exception as e: cr.rollback() print(f"An error occurred during import. Transaction rolled back.\nError: {e}") import traceback traceback.print_exc() def ensure_models(env): missing_models = { 'PERALATAN DAPUR': { 'fallback': 'Peralatan Dapur', 'name': 'Peralatan Dapur', 'method_number': 60, 'method_period': '1' }, 'PATENT & MERK': { 'fallback': 'Asset Tidak Berwujud', 'name': 'Asset Tidak Berwujud', 'method_number': 60, 'method_period': '1' } } for key, config in missing_models.items(): exists = env['account.asset'].search([('state', '=', 'model'), ('name', '=ilike', config['name'])], limit=1) if exists: continue print(f"Creating missing model '{config['name']}'...") fallback = env['account.asset'].search([('state', '=', 'model'), ('name', '=ilike', config['fallback'])], limit=1) vals = { 'name': config['name'], 'state': 'model', 'active': True, } if fallback: vals.update({ 'method': fallback.method, 'method_number': fallback.method_number, 'method_period': fallback.method_period, 'prorata_computation_type': 'constant_periods', 'method_progress_factor': fallback.method_progress_factor, 'account_asset_id': fallback.account_asset_id.id, 'account_depreciation_id': fallback.account_depreciation_id.id, 'account_depreciation_expense_id': fallback.account_depreciation_expense_id.id, 'journal_id': fallback.journal_id.id, 'analytic_distribution': fallback.analytic_distribution, }) else: any_model = env['account.asset'].search([('state', '=', 'model')], limit=1) if any_model: vals.update({ 'account_asset_id': any_model.account_asset_id.id, 'account_depreciation_id': any_model.account_depreciation_id.id, 'account_depreciation_expense_id': any_model.account_depreciation_expense_id.id, 'journal_id': any_model.journal_id.id, }) vals.update({ 'method': 'linear', 'method_number': config['method_number'], 'method_period': config['method_period'], 'prorata_computation_type': 'constant_periods', }) # Ensure 'constant_periods' even if inherited from fallback vals['prorata_computation_type'] = 'constant_periods' env['account.asset'].create(vals) print(f"Created model '{config['name']}'.") def process_import(env, excel_file): print(f"Reading Excel file: {excel_file}...") wb = openpyxl.load_workbook(excel_file, data_only=True) ws = wb.active current_category_name = None count = 0 models_cache = {} skipped_count = 0 for row_idx, row in enumerate(ws.iter_rows(min_row=10, values_only=True), start=10): col_0 = row[0] col_1 = row[1] col_name = row[2] if col_0 and isinstance(col_0, str) and not col_1 and not col_name: current_category_name = col_0.strip() print(f"\n--- Category Detected: {current_category_name} ---") continue if not col_name: continue acquisition_date = row[7] if isinstance(acquisition_date, datetime): acquisition_date = acquisition_date.date() if not isinstance(acquisition_date, date): continue # Check if asset is newer than cutoff if acquisition_date > CUTOFF_DATE: if skipped_count < 5: # Limit detailed skip logs print(f"Skipping '{col_name}': Acquired {acquisition_date} (After Cutoff)") skipped_count += 1 continue original_value = row[13] # Column N Saldo Akhir if not isinstance(original_value, (int, float)): original_value = 0.0 if not current_category_name: continue model_name_search = current_category_name if current_category_name == 'PERALATAN DAPUR': model_name_search = 'Peralatan Dapur' elif current_category_name == 'PATENT & MERK': model_name_search = 'Asset Tidak Berwujud' model = models_cache.get(model_name_search) if not model: model = env['account.asset'].search([ ('state', '=', 'model'), ('name', '=ilike', model_name_search) ], limit=1) if not model: print(f"SKIPPING: Asset Model '{model_name_search}' not found.") continue models_cache[model_name_search] = model # Calculate Logic # Determine total duration in months # OVERRIDE: Enforce specific durations for certain categories DURATION_OVERRIDES = { 'RENOVASI BANGUNAN': 60, 'PATENT & MERK': 120, 'PERALATAN DAPUR': 60, } effective_method_number = model.method_number if current_category_name in DURATION_OVERRIDES: effective_method_number = DURATION_OVERRIDES[current_category_name] period_multiple = int(model.method_period) # 1 or 12 total_months = effective_method_number * period_multiple # Calculated Depr # Calculate months passed from acquisition to CUTOFF_DATE delta = relativedelta(CUTOFF_DATE, acquisition_date) months_passed = delta.years * 12 + delta.months + (1 if delta.days > 0 else 0) # Include current month if any days passed # Ensure months_passed does not exceed total_months months_passed = min(months_passed, total_months) # Calculate accumulated depreciation up to CUTOFF_DATE monthly_depr = original_value / total_months if total_months > 0 else 0 accum_depr_oct31 = monthly_depr * months_passed # Clamp values if accum_depr_oct31 < 0: accum_depr_oct31 = 0.0 if accum_depr_oct31 > original_value: accum_depr_oct31 = original_value vals = { 'name': col_name, 'asset_code': col_1, # Added Asset Code 'original_value': original_value, 'acquisition_date': acquisition_date, 'prorata_date': acquisition_date.replace(day=1), # FORCE FIRST OF MONTH 'model_id': model.id, 'method': model.method, 'method_number': effective_method_number, 'method_period': model.method_period, 'prorata_computation_type': 'constant_periods', # FORCE CONSTANT PERIODS 'already_depreciated_amount_import': accum_depr_oct31, 'state': 'draft', 'account_asset_id': model.account_asset_id.id, 'account_depreciation_id': model.account_depreciation_id.id, 'account_depreciation_expense_id': model.account_depreciation_expense_id.id, 'journal_id': model.journal_id.id, 'method_progress_factor': model.method_progress_factor, 'analytic_distribution': model.analytic_distribution, } asset = env['account.asset'].create(vals) # 9. Compute Board and Set to Running asset.compute_depreciation_board() # 10. Clean up Historical Moves # To strictly preserve Opening Balance, we must NOT have any draft moves # dated before or on the Cutoff Date. We assume the 'already_depreciated_amount_import' # covers the past. Any "catch-up" entries generated by Odoo for the past look like # expenses in prior years, which affects Retained Earnings. We delete them. historical_moves = asset.depreciation_move_ids.filtered(lambda m: m.date <= CUTOFF_DATE) if historical_moves: print(f" Removing {len(historical_moves)} historical catch-up moves for {asset.name}...") historical_moves.unlink() asset.write({'state': 'open'}) # FIX: Explicitly post the depreciation moves like Odoo's validate() method does asset.depreciation_move_ids.filtered(lambda move: move.state != 'posted')._post() print(f"Imported: {asset.name} | Val: {original_value} | Oct31: {accum_depr_oct31:,.2f}") count += 1 print(f"\nTotal Assets Imported: {count}") if __name__ == "__main__": main()