| %s | ' % fusion_field + html += '
|---|
| %s | ' % html_escape(val) + html += '
Showing first %d of %d rows.
' % (len(preview_rows), len(rows)) + + self.preview_html = html + self.state = 'employee_preview' + return self._reopen() + + def action_import_employees(self): + """Create hr.employee + hr.contract records from CSV.""" + self.ensure_one() + self.error_count = 0 + self.warning_count = 0 + rows, _ = self._parse_csv(self.employee_csv) + mapping = self._get_mapping_dict('employee') + inv_map = {v: k for k, v in mapping.items()} + + structure = self.env.ref('fusion_payroll.hr_payroll_structure_canada', raise_if_not_found=False) + struct_type = self.env.ref('fusion_payroll.structure_type_canada', raise_if_not_found=False) + + Employee = self.env['hr.employee'] + Contract = self.env['hr.contract'] + imported = 0 + + PROVINCE_MAP = { + 'alberta': 'AB', 'british columbia': 'BC', 'manitoba': 'MB', + 'new brunswick': 'NB', 'newfoundland': 'NL', 'newfoundland and labrador': 'NL', + 'nova scotia': 'NS', 'northwest territories': 'NT', 'nunavut': 'NU', + 'ontario': 'ON', 'prince edward island': 'PE', 'quebec': 'QC', + 'saskatchewan': 'SK', 'yukon': 'YT', + } + + for i, row in enumerate(rows, start=2): + try: + def g(field): + col = inv_map.get(field) + return (row.get(col, '') or '').strip() if col else '' + + # Build employee name + name = g('name') + if not name: + first = g('first_name') + last = g('last_name') + name = ('%s %s' % (first, last)).strip() + if not name: + self._log('error', _('Row %d: No employee name found, skipping.') % i, i) + continue + + # SIN + sin = g('sin_number').replace('-', '').replace(' ', '') + + # Check for existing employee by SIN + existing = False + if sin and len(sin) == 9: + existing = Employee.search([('sin_number', '=', sin), ('company_id', '=', self.company_id.id)], limit=1) + + # Province mapping + VALID_PROVS = {'AB', 'BC', 'MB', 'NB', 'NL', 'NS', 'NT', 'NU', 'ON', 'PE', 'QC', 'SK', 'YT'} + prov = g('home_province').strip() + if len(prov) > 2: + prov = PROVINCE_MAP.get(prov.lower(), '') + if prov: + prov = prov.upper() + if prov not in VALID_PROVS: + if prov: + self._log('warning', _('Row %d: Unknown province "%s", defaulting to ON.') % (i, prov), i) + prov = 'ON' + + # Pay type + pay_type_raw = g('pay_type').lower() + pay_type = 'salary' if 'sal' in pay_type_raw else 'hourly' + + # Pay schedule + sched_raw = g('pay_schedule').lower() + sched_map = { + 'weekly': 'weekly', 'week': 'weekly', + 'bi-weekly': 'biweekly', 'biweekly': 'biweekly', 'bi weekly': 'biweekly', + 'semi-monthly': 'semi_monthly', 'semi monthly': 'semi_monthly', 'semimonthly': 'semi_monthly', + 'monthly': 'monthly', 'month': 'monthly', + } + pay_schedule = 'biweekly' + for key, val in sched_map.items(): + if key in sched_raw: + pay_schedule = val + break + + # Parse monetary values + def parse_money(s): + if not s: + return 0.0 + return float(s.replace('$', '').replace(',', '').replace('(', '-').replace(')', '').strip() or '0') + + def parse_date(s): + if not s: + return False + for fmt in ('%Y-%m-%d', '%m/%d/%Y', '%d/%m/%Y', '%Y/%m/%d', '%m-%d-%Y'): + try: + return datetime.strptime(s.strip(), fmt).date() + except ValueError: + continue + return False + + vals = { + 'name': name, + 'company_id': self.company_id.id, + 'sin_number': sin or False, + 'home_street': g('home_street') or False, + 'home_street2': g('home_street2') or False, + 'home_city': g('home_city') or False, + 'home_province': prov or 'ON', + 'home_postal_code': g('home_postal_code') or False, + 'hire_date': parse_date(g('hire_date')), + 'pay_type': pay_type, + 'pay_schedule': pay_schedule, + 'hourly_rate': parse_money(g('hourly_rate')) if pay_type == 'hourly' else 0, + 'salary_amount': parse_money(g('salary_amount')) if pay_type == 'salary' else 0, + 'federal_td1_amount': parse_money(g('federal_td1_amount')) or 16452, + 'provincial_claim_amount': parse_money(g('provincial_claim_amount')) or 0, + 'federal_additional_tax': parse_money(g('federal_additional_tax')), + 'vacation_rate': float(g('vacation_rate') or '4'), + 'employee_number': g('employee_number') or False, + } + + payment = g('payment_method').lower() + if 'direct' in payment or 'deposit' in payment: + vals['payment_method'] = 'direct_deposit' + elif 'cheque' in payment or 'check' in payment: + vals['payment_method'] = 'cheque' + + dental = g('t4_dental_code') + if dental and dental[0] in ('1', '2', '3', '4', '5'): + vals['t4_dental_code'] = dental[0] + + email_val = g('email') + if email_val: + vals['work_email'] = email_val + phone_val = g('phone') + if phone_val: + vals['work_phone'] = phone_val + + if existing: + existing.write(vals) + emp = existing + self._log('info', _('Row %d: Updated existing employee %s (SIN match).') % (i, name), i) + else: + emp = Employee.create(vals) + self._log('info', _('Row %d: Created employee %s.') % (i, name), i) + + # Create contract if none exists + if not emp.contract_id: + contract_vals = { + 'name': _('Contract - %s') % name, + 'employee_id': emp.id, + 'company_id': self.company_id.id, + 'state': 'open', + 'date_start': vals.get('hire_date') or date.today(), + } + if pay_type == 'hourly': + contract_vals['wage'] = vals['hourly_rate'] * 2080 / 12 + if 'wage_type' in Contract._fields: + contract_vals['wage_type'] = 'hourly' + if 'hourly_wage' in Contract._fields: + contract_vals['hourly_wage'] = vals['hourly_rate'] + else: + contract_vals['wage'] = vals['salary_amount'] / 12 if vals['salary_amount'] else 0 + if struct_type: + contract_vals['structure_type_id'] = struct_type.id + Contract.create(contract_vals) + + imported += 1 + except Exception as e: + self._log('error', _('Row %d: Error importing employee: %s') % (i, str(e)), i) + + self.employee_count = imported + self.state = 'payslips' if self.migration_type == 'full' else 'ytd' + self.preview_html = False + self._log('info', _('Employee import complete: %d employees imported.') % imported) + return self._reopen() + + # --- STEP 3: PAYSLIP HISTORY IMPORT --- + + def action_upload_payslip_csv(self): + """Parse uploaded payslip CSV and auto-map columns.""" + self.ensure_one() + rows, headers = self._parse_csv(self.payslip_csv) + self._auto_map_columns(headers, PAYSLIP_FIELD_ALIASES, 'payslip') + self.state = 'payslip_map' + self._log('info', _('Payslip CSV uploaded: %d rows, %d columns.') % (len(rows), len(headers))) + return self._reopen() + + def action_preview_payslips(self): + """Show preview of payslip import with per-employee summaries.""" + self.ensure_one() + rows, _ = self._parse_csv(self.payslip_csv) + mapping = self._get_mapping_dict('payslip') + inv_map = {v: k for k, v in mapping.items()} + + employee_totals = {} + for row in rows: + def g(field): + col = inv_map.get(field) + return (row.get(col, '') or '').strip() if col else '' + emp_name = g('employee_name') or g('employee_id_ref') or 'Unknown' + if emp_name not in employee_totals: + employee_totals[emp_name] = {'rows': 0, 'gross': 0, 'net': 0, 'cpp': 0, 'ei': 0, 'tax': 0} + def pm(f): + v = g(f) + if not v: + return 0.0 + return abs(float(v.replace('$', '').replace(',', '').replace('(', '-').replace(')', '').strip() or '0')) + employee_totals[emp_name]['rows'] += 1 + employee_totals[emp_name]['gross'] += pm('GROSS') + employee_totals[emp_name]['net'] += pm('NET') + employee_totals[emp_name]['cpp'] += pm('CPP_EE') + employee_totals[emp_name]['ei'] += pm('EI_EE') + employee_totals[emp_name]['tax'] += pm('FED_TAX') + pm('PROV_TAX') + + html = '| Employee | Payslips | Gross | CPP | EI | Tax | Net | ' + html += '
|---|---|---|---|---|---|---|
| %s | %d | $%s | $%s | $%s | $%s | $%s |
%d total payslips for %d employees.
' % (len(rows), len(employee_totals)) + + self.preview_html = html + self.state = 'payslip_preview' + return self._reopen() + + def action_import_payslips(self): + """Create hr.payslip + hr.payslip.line records from CSV.""" + self.ensure_one() + self.error_count = 0 + self.warning_count = 0 + rows, _ = self._parse_csv(self.payslip_csv) + mapping = self._get_mapping_dict('payslip') + inv_map = {v: k for k, v in mapping.items()} + + structure = self.env.ref('fusion_payroll.hr_payroll_structure_canada', raise_if_not_found=False) + Employee = self.env['hr.employee'] + Payslip = self.env['hr.payslip'] + PayslipLine = self.env['hr.payslip.line'] + + EARNINGS_CODES = {'BASIC', 'OT_PAY', 'STAT_PAY', 'VAC_PAY', 'BONUS_PAY', 'COMMISSION', 'RETRO_PAY', 'SHIFT_PREMIUM'} + DEDUCTION_CODES = {'RRSP', 'UNION_DUES', 'CPP_EE', 'CPP2_EE', 'EI_EE', 'FED_TAX', 'PROV_TAX', 'OHP'} + EMPLOYER_CODES = {'CPP_ER', 'CPP2_ER', 'EI_ER'} + ALL_LINE_CODES = EARNINGS_CODES | DEDUCTION_CODES | EMPLOYER_CODES | {'GROSS', 'NET'} + + # Build employee lookup by name and employee number + employees = Employee.search([('company_id', '=', self.company_id.id)]) + emp_by_name = {e.name.lower().strip(): e for e in employees} + emp_by_number = {} + for e in employees: + if e.employee_number: + emp_by_number[e.employee_number.strip()] = e + + # Get salary rules for line creation + rules = {} + if structure: + for rule in self.env['hr.salary.rule'].search([('struct_id', '=', structure.id)]): + rules[rule.code] = rule + + imported = 0 + for i, row in enumerate(rows, start=2): + try: + def g(field): + col = inv_map.get(field) + return (row.get(col, '') or '').strip() if col else '' + + def parse_money(s): + if not s: + return 0.0 + s = s.replace('$', '').replace(',', '').replace('(', '-').replace(')', '').strip() + return float(s or '0') + + def parse_date(s): + if not s: + return False + for fmt in ('%Y-%m-%d', '%m/%d/%Y', '%d/%m/%Y', '%Y/%m/%d', '%m-%d-%Y'): + try: + return datetime.strptime(s.strip(), fmt).date() + except ValueError: + continue + return False + + # Find employee + emp_name = g('employee_name') + emp_ref = g('employee_id_ref') + emp = None + if emp_ref and emp_ref in emp_by_number: + emp = emp_by_number[emp_ref] + elif emp_name: + emp = emp_by_name.get(emp_name.lower().strip()) + if not emp: + self._log('warning', _('Row %d: Employee "%s" not found, skipping.') % (i, emp_name or emp_ref), i) + continue + + pay_date = parse_date(g('pay_date')) + period_start = parse_date(g('period_start')) + period_end = parse_date(g('period_end')) + + if not pay_date: + self._log('error', _('Row %d: No pay date found, skipping.') % i, i) + continue + + if not period_start: + period_start = pay_date.replace(day=1) + if not period_end: + period_end = pay_date + + # Create payslip + slip_vals = { + 'name': _('QB Import - %s - %s') % (emp.name, pay_date), + 'employee_id': emp.id, + 'company_id': self.company_id.id, + 'date_from': period_start, + 'date_to': period_end, + 'state': 'done', + 'contract_id': emp.contract_id.id if emp.contract_id else False, + } + if structure: + slip_vals['struct_id'] = structure.id + + slip = Payslip.create(slip_vals) + + # Create payslip lines for each mapped salary rule code + for code in ALL_LINE_CODES: + amount = parse_money(g(code)) + if amount == 0: + continue + + # Deductions should be negative + if code in DEDUCTION_CODES and amount > 0: + amount = -amount + + rule = rules.get(code) + if not rule: + rule = self.env['hr.salary.rule'].search([('code', '=', code)], limit=1) + if not rule: + self._log('warning', _('Row %d: No salary rule for code %s, skipping line.') % (i, code), i) + continue + + line_vals = { + 'slip_id': slip.id, + 'name': rule.name, + 'code': code, + 'amount': abs(amount), + 'quantity': 1, + 'rate': 100 if amount >= 0 else -100, + 'total': amount, + 'salary_rule_id': rule.id, + 'category_id': rule.category_id.id, + } + PayslipLine.create(line_vals) + + # Cheque number + chq = g('cheque_number') + if chq: + slip.write({'memo': _('QB Cheque #%s') % chq}) + + imported += 1 + except Exception as e: + self._log('error', _('Row %d: Error importing payslip: %s') % (i, str(e)), i) + + self.payslip_count = imported + self.state = 'ytd' + self.preview_html = False + self._log('info', _('Payslip import complete: %d payslips imported.') % imported) + return self._reopen() + + # --- STEP 4: YTD VERIFICATION --- + + def action_compute_ytd_preview(self): + """Compute YTD totals from imported payslips and display.""" + self.ensure_one() + cutoff = self.cutoff_date or date.today() + year_start = cutoff.replace(month=1, day=1) + + employees = self.env['hr.employee'].search([('company_id', '=', self.company_id.id)]) + + html = '| Employee | YTD Gross | YTD CPP | YTD EI | YTD Fed Tax | YTD Prov Tax | YTD Net | ' + html += '
|---|---|---|---|---|---|---|
| %s | $%s | $%s | $%s | $%s | $%s | $%s |
| Employees Imported | %d |
| Payslips Imported | %d |
| T4 Slips Imported | %d |
| Warnings | %d |
| Errors | %d |
| Total Gross | $%s |
| Total CPP | $%s |
| Total EI | $%s |
| Total Tax | $%s |
| Total Net | $%s |
+ Import your payroll data from QuickBooks +
+Start a new migration to import employees, payslip history, and T4 records.
+