diff --git a/addons/crm/models/crm_analysis.go b/addons/crm/models/crm_analysis.go new file mode 100644 index 0000000..b400007 --- /dev/null +++ b/addons/crm/models/crm_analysis.go @@ -0,0 +1,232 @@ +package models + +import ( + "fmt" + + "odoo-go/pkg/orm" +) + +// initCrmAnalysis registers the crm.lead.analysis transient model +// for pipeline reporting and dashboard data. +// Mirrors: odoo/addons/crm/report/crm_activity_report.py (simplified) +func initCrmAnalysis() { + m := orm.NewModel("crm.lead.analysis", orm.ModelOpts{ + Description: "Pipeline Analysis", + Type: orm.ModelTransient, + }) + + m.AddFields( + orm.Many2one("team_id", "crm.team", orm.FieldOpts{ + String: "Sales Team", + Help: "Filter analysis by sales team.", + }), + orm.Many2one("user_id", "res.users", orm.FieldOpts{ + String: "Salesperson", + Help: "Filter analysis by salesperson.", + }), + orm.Date("date_from", orm.FieldOpts{ + String: "From", + Help: "Start date for the analysis period.", + }), + orm.Date("date_to", orm.FieldOpts{ + String: "To", + Help: "End date for the analysis period.", + }), + orm.Many2one("company_id", "res.company", orm.FieldOpts{ + String: "Company", + Help: "Filter analysis by company.", + }), + ) + + // get_pipeline_data: return pipeline statistics grouped by stage. + // Mirrors: odoo/addons/crm/report/crm_activity_report.py read_group + m.RegisterMethod("get_pipeline_data", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + + // Pipeline by stage + rows, err := env.Tx().Query(env.Ctx(), ` + SELECT s.name, COUNT(l.id), COALESCE(SUM(l.expected_revenue::float8), 0) + FROM crm_lead l + JOIN crm_stage s ON s.id = l.stage_id + WHERE l.active = true AND l.type = 'opportunity' + GROUP BY s.id, s.name, s.sequence + ORDER BY s.sequence`) + if err != nil { + return nil, fmt.Errorf("get_pipeline_data: stages query: %w", err) + } + defer rows.Close() + + var stages []map[string]interface{} + for rows.Next() { + var name string + var count int64 + var revenue float64 + if err := rows.Scan(&name, &count, &revenue); err != nil { + return nil, fmt.Errorf("get_pipeline_data: scan stage: %w", err) + } + stages = append(stages, map[string]interface{}{ + "stage": name, + "count": count, + "revenue": revenue, + }) + } + + // Win rate + var total, won int64 + _ = env.Tx().QueryRow(env.Ctx(), + `SELECT COUNT(*), COALESCE(SUM(CASE WHEN s.is_won THEN 1 ELSE 0 END), 0) + FROM crm_lead l + JOIN crm_stage s ON s.id = l.stage_id + WHERE l.type = 'opportunity'`, + ).Scan(&total, &won) + + winRate := float64(0) + if total > 0 { + winRate = float64(won) / float64(total) * 100 + } + + return map[string]interface{}{ + "stages": stages, + "total": total, + "won": won, + "win_rate": winRate, + }, nil + }) + + // get_conversion_data: return lead-to-opportunity conversion statistics. + // Mirrors: odoo/addons/crm/report/crm_activity_report.py (conversion metrics) + m.RegisterMethod("get_conversion_data", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + + var totalLeads, convertedLeads int64 + _ = env.Tx().QueryRow(env.Ctx(), ` + SELECT + COUNT(*) FILTER (WHERE type = 'lead'), + COUNT(*) FILTER (WHERE type = 'opportunity' AND date_conversion IS NOT NULL) + FROM crm_lead WHERE active = true`, + ).Scan(&totalLeads, &convertedLeads) + + conversionRate := float64(0) + if totalLeads > 0 { + conversionRate = float64(convertedLeads) / float64(totalLeads) * 100 + } + + // Average days to convert + var avgDaysConvert float64 + _ = env.Tx().QueryRow(env.Ctx(), ` + SELECT COALESCE(AVG(EXTRACT(EPOCH FROM (date_conversion - create_date)) / 86400), 0) + FROM crm_lead + WHERE type = 'opportunity' AND date_conversion IS NOT NULL AND active = true`, + ).Scan(&avgDaysConvert) + + // Average days to close (won) + var avgDaysClose float64 + _ = env.Tx().QueryRow(env.Ctx(), ` + SELECT COALESCE(AVG(EXTRACT(EPOCH FROM (date_closed - create_date)) / 86400), 0) + FROM crm_lead + WHERE state = 'won' AND date_closed IS NOT NULL`, + ).Scan(&avgDaysClose) + + return map[string]interface{}{ + "total_leads": totalLeads, + "converted_leads": convertedLeads, + "conversion_rate": conversionRate, + "avg_days_convert": avgDaysConvert, + "avg_days_close": avgDaysClose, + }, nil + }) + + // get_team_performance: return per-team performance comparison. + // Mirrors: odoo/addons/crm/report/crm_activity_report.py (team grouping) + m.RegisterMethod("get_team_performance", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + + rows, err := env.Tx().Query(env.Ctx(), ` + SELECT + t.name, + COUNT(l.id) AS opp_count, + COALESCE(SUM(l.expected_revenue::float8), 0) AS total_revenue, + COALESCE(AVG(l.probability), 0) AS avg_probability, + COUNT(l.id) FILTER (WHERE l.state = 'won') AS won_count + FROM crm_lead l + JOIN crm_team t ON t.id = l.team_id + WHERE l.active = true AND l.type = 'opportunity' + GROUP BY t.id, t.name + ORDER BY total_revenue DESC`) + if err != nil { + return nil, fmt.Errorf("get_team_performance: %w", err) + } + defer rows.Close() + + var teams []map[string]interface{} + for rows.Next() { + var name string + var oppCount, wonCount int64 + var totalRevenue, avgProb float64 + if err := rows.Scan(&name, &oppCount, &totalRevenue, &avgProb, &wonCount); err != nil { + return nil, fmt.Errorf("get_team_performance: scan: %w", err) + } + winRate := float64(0) + if oppCount > 0 { + winRate = float64(wonCount) / float64(oppCount) * 100 + } + teams = append(teams, map[string]interface{}{ + "team": name, + "opportunities": oppCount, + "revenue": totalRevenue, + "avg_probability": avgProb, + "won": wonCount, + "win_rate": winRate, + }) + } + + return map[string]interface{}{"teams": teams}, nil + }) + + // get_salesperson_performance: return per-salesperson performance data. + // Mirrors: odoo/addons/crm/report/crm_activity_report.py (user grouping) + m.RegisterMethod("get_salesperson_performance", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + + rows, err := env.Tx().Query(env.Ctx(), ` + SELECT + u.login, + COUNT(l.id) AS opp_count, + COALESCE(SUM(l.expected_revenue::float8), 0) AS total_revenue, + COUNT(l.id) FILTER (WHERE l.state = 'won') AS won_count, + COUNT(l.id) FILTER (WHERE l.state = 'lost') AS lost_count + FROM crm_lead l + JOIN res_users u ON u.id = l.user_id + WHERE l.active = true AND l.type = 'opportunity' + GROUP BY u.id, u.login + ORDER BY total_revenue DESC`) + if err != nil { + return nil, fmt.Errorf("get_salesperson_performance: %w", err) + } + defer rows.Close() + + var users []map[string]interface{} + for rows.Next() { + var login string + var oppCount, wonCount, lostCount int64 + var totalRevenue float64 + if err := rows.Scan(&login, &oppCount, &totalRevenue, &wonCount, &lostCount); err != nil { + return nil, fmt.Errorf("get_salesperson_performance: scan: %w", err) + } + winRate := float64(0) + if oppCount > 0 { + winRate = float64(wonCount) / float64(oppCount) * 100 + } + users = append(users, map[string]interface{}{ + "salesperson": login, + "opportunities": oppCount, + "revenue": totalRevenue, + "won": wonCount, + "lost": lostCount, + "win_rate": winRate, + }) + } + + return map[string]interface{}{"salespersons": users}, nil + }) +} diff --git a/addons/crm/models/crm_lead_ext.go b/addons/crm/models/crm_lead_ext.go new file mode 100644 index 0000000..fe7c3fc --- /dev/null +++ b/addons/crm/models/crm_lead_ext.go @@ -0,0 +1,381 @@ +package models + +import ( + "fmt" + + "odoo-go/pkg/orm" +) + +// initCRMLeadExtended adds activity, address, contact, and scoring fields +// to crm.lead, plus business methods (merge, assign, schedule, duplicate, etc.). +// Mirrors: odoo/addons/crm/models/crm_lead.py (extended CRM fields and methods) +func initCRMLeadExtended() { + m := orm.ExtendModel("crm.lead") + + // ──── Activity fields ──── + // Mirrors: odoo/addons/crm/models/crm_lead.py activity fields + m.AddFields( + orm.Date("activity_date_deadline", orm.FieldOpts{ + String: "Next Activity Deadline", + Help: "Date by which the next activity should be completed.", + }), + orm.Char("activity_summary", orm.FieldOpts{ + String: "Next Activity Summary", + Help: "Short description of the next scheduled activity.", + }), + orm.Selection("activity_type", []orm.SelectionItem{ + {Value: "email", Label: "Email"}, + {Value: "call", Label: "Call"}, + {Value: "meeting", Label: "Meeting"}, + {Value: "todo", Label: "To-Do"}, + }, orm.FieldOpts{ + String: "Activity Type", + Help: "Type of the next scheduled activity.", + }), + orm.Many2one("activity_user_id", "res.users", orm.FieldOpts{ + String: "Activity Responsible", + Help: "User responsible for the next scheduled activity.", + }), + + // ──── Tracking / timing fields ──── + // Mirrors: odoo/addons/crm/models/crm_lead.py day_open, day_close + orm.Integer("day_open", orm.FieldOpts{ + String: "Days to Assign", + Help: "Number of days to assign this lead to a salesperson.", + }), + orm.Integer("day_close", orm.FieldOpts{ + String: "Days to Close", + Help: "Number of days to close this lead/opportunity.", + }), + + // ──── Additional contact/address fields ──── + // Fields not already on crm.lead base definition. + // Mirrors: odoo/addons/crm/models/crm_lead.py address & contact fields + orm.Char("referred", orm.FieldOpts{ + String: "Referred By", + Help: "Name or reference of who referred this lead.", + }), + orm.Char("title", orm.FieldOpts{ + String: "Title", + Help: "Contact title (Mr., Mrs., etc.).", + }), + orm.Many2one("state_id", "res.country.state", orm.FieldOpts{ + String: "State", + Help: "State/province of the lead address.", + }), + orm.Text("contact_name", orm.FieldOpts{ + String: "Contact Name", + Help: "Name of the primary contact person.", + }), + orm.Char("mobile", orm.FieldOpts{ + String: "Mobile", + Help: "Mobile phone number of the contact.", + }), + orm.Char("street2", orm.FieldOpts{ + String: "Street2", + Help: "Second line of the street address.", + }), + + // ──── Revenue fields ──── + // Mirrors: odoo/addons/crm/models/crm_lead.py prorated_revenue + orm.Monetary("prorated_revenue", orm.FieldOpts{ + String: "Prorated Revenue", + CurrencyField: "company_currency", + Compute: "_compute_prorated_revenue", + Help: "Expected revenue weighted by probability.", + }), + orm.Many2one("company_currency", "res.currency", orm.FieldOpts{ + String: "Company Currency", + }), + + // ──── Scoring / automated fields ──── + // Mirrors: odoo/addons/crm/models/crm_lead_scoring.py (simplified) + orm.Float("automated_probability", orm.FieldOpts{ + String: "Automated Probability (%)", + Help: "Probability computed by the lead scoring engine.", + }), + orm.Boolean("is_automated_probability", orm.FieldOpts{ + String: "Use Automated Probability", + Default: true, + Help: "If true, probability is set automatically by scoring.", + }), + + // ──── Source / campaign tracking ──── + // Mirrors: odoo/addons/crm/models/crm_lead.py utm fields + orm.Many2one("source_id", "utm.source", orm.FieldOpts{ + String: "Source", + Help: "Source of the lead (e.g. search engine, social media).", + }), + orm.Many2one("medium_id", "utm.medium", orm.FieldOpts{ + String: "Medium", + Help: "Medium of the lead (e.g. email, banner).", + }), + orm.Many2one("campaign_id", "utm.campaign", orm.FieldOpts{ + String: "Campaign", + Help: "Marketing campaign that generated this lead.", + }), + + // ──── Date tracking ──── + // Mirrors: odoo/addons/crm/models/crm_lead.py date_conversion, date_closed + orm.Datetime("date_conversion", orm.FieldOpts{ + String: "Conversion Date", + Help: "Date when the lead was converted to an opportunity.", + }), + orm.Datetime("date_closed", orm.FieldOpts{ + String: "Closed Date", + Help: "Date when the opportunity was won or lost.", + }), + ) + + // ──── Compute: prorated revenue ──── + // Mirrors: odoo/addons/crm/models/crm_lead.py _compute_prorated_revenue + m.RegisterCompute("prorated_revenue", func(rs *orm.Recordset) (orm.Values, error) { + env := rs.Env() + leadID := rs.IDs()[0] + + var revenue float64 + var probability float64 + _ = env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(expected_revenue::float8, 0), COALESCE(probability, 0) + FROM crm_lead WHERE id = $1`, leadID, + ).Scan(&revenue, &probability) + + prorated := revenue * probability / 100.0 + return orm.Values{"prorated_revenue": prorated}, nil + }) + + // ──── Business Methods ──── + + // action_schedule_activity: return a window action to schedule an activity. + // Mirrors: odoo/addons/crm/models/crm_lead.py action_schedule_activity + m.RegisterMethod("action_schedule_activity", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + return map[string]interface{}{ + "type": "ir.actions.act_window", + "name": "Schedule Activity", + "res_model": "crm.lead", + "res_id": rs.IDs()[0], + "view_mode": "form", + "views": [][]interface{}{{nil, "form"}}, + "target": "new", + }, nil + }) + + // action_merge: merge multiple leads into the first one. + // Sums expected revenues from slave leads, deactivates them. + // Mirrors: odoo/addons/crm/wizard/crm_merge_opportunities.py + m.RegisterMethod("action_merge", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + ids := rs.IDs() + if len(ids) < 2 { + return nil, fmt.Errorf("need at least 2 leads to merge") + } + + masterID := ids[0] + for _, slaveID := range ids[1:] { + // Sum revenues from slave into master + _, _ = env.Tx().Exec(env.Ctx(), + `UPDATE crm_lead + SET expected_revenue = COALESCE(expected_revenue, 0) + + (SELECT COALESCE(expected_revenue, 0) FROM crm_lead WHERE id = $1) + WHERE id = $2`, + slaveID, masterID) + // Copy partner info if master has none + _, _ = env.Tx().Exec(env.Ctx(), + `UPDATE crm_lead + SET partner_id = COALESCE( + (SELECT partner_id FROM crm_lead WHERE id = $2), + partner_id) + WHERE id = $1 AND partner_id IS NULL`, + masterID, slaveID) + // Deactivate the slave lead + _, _ = env.Tx().Exec(env.Ctx(), + `UPDATE crm_lead SET active = false WHERE id = $1`, slaveID) + } + return map[string]interface{}{ + "type": "ir.actions.act_window", + "res_model": "crm.lead", + "res_id": masterID, + "view_mode": "form", + "views": [][]interface{}{{nil, "form"}}, + "target": "current", + }, nil + }) + + // action_assign_salesperson: assign a salesperson to one or more leads. + // Mirrors: odoo/addons/crm/models/crm_lead.py _handle_salesmen_assignment + m.RegisterMethod("action_assign_salesperson", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + if len(args) < 1 { + return nil, fmt.Errorf("user_id required") + } + userID, ok := args[0].(float64) + if !ok { + return nil, fmt.Errorf("user_id must be a number") + } + env := rs.Env() + for _, id := range rs.IDs() { + _, _ = env.Tx().Exec(env.Ctx(), + `UPDATE crm_lead SET user_id = $1 WHERE id = $2`, int64(userID), id) + } + return true, nil + }) + + // action_duplicate: duplicate a lead with a modified name. + // Mirrors: odoo/addons/crm/models/crm_lead.py copy() + m.RegisterMethod("action_duplicate", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + leadID := rs.IDs()[0] + + var newID int64 + err := env.Tx().QueryRow(env.Ctx(), + `INSERT INTO crm_lead (name, type, partner_id, email_from, phone, + stage_id, team_id, user_id, expected_revenue, probability, + priority, company_id, currency_id, active, description) + SELECT name || ' (copy)', type, partner_id, email_from, phone, + stage_id, team_id, user_id, expected_revenue, probability, + priority, company_id, currency_id, true, description + FROM crm_lead WHERE id = $1 + RETURNING id`, leadID, + ).Scan(&newID) + if err != nil { + return nil, fmt.Errorf("action_duplicate: %w", err) + } + + return map[string]interface{}{ + "type": "ir.actions.act_window", + "res_model": "crm.lead", + "res_id": newID, + "view_mode": "form", + "views": [][]interface{}{{nil, "form"}}, + "target": "current", + }, nil + }) + + // action_set_priority: set priority on selected leads. + // Mirrors: odoo/addons/crm/models/crm_lead.py (bulk actions) + m.RegisterMethod("action_set_priority", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + if len(args) < 1 { + return nil, fmt.Errorf("priority value required (0-3)") + } + priority, ok := args[0].(string) + if !ok { + return nil, fmt.Errorf("priority must be a string ('0','1','2','3')") + } + env := rs.Env() + for _, id := range rs.IDs() { + _, _ = env.Tx().Exec(env.Ctx(), + `UPDATE crm_lead SET priority = $1 WHERE id = $2`, priority, id) + } + return true, nil + }) + + // action_archive: archive (deactivate) selected leads. + // Mirrors: odoo/addons/crm/models/crm_lead.py toggle_active + m.RegisterMethod("action_archive", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + for _, id := range rs.IDs() { + _, _ = env.Tx().Exec(env.Ctx(), + `UPDATE crm_lead SET active = false WHERE id = $1`, id) + } + return true, nil + }) + + // action_unarchive: restore (reactivate) selected leads. + // Mirrors: odoo/addons/crm/models/crm_lead.py toggle_active + m.RegisterMethod("action_unarchive", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + for _, id := range rs.IDs() { + _, _ = env.Tx().Exec(env.Ctx(), + `UPDATE crm_lead SET active = true WHERE id = $1`, id) + } + return true, nil + }) + + // action_set_stage: move leads to a specific stage. + // Mirrors: odoo/addons/crm/models/crm_lead.py write() stage change logic + m.RegisterMethod("action_set_stage", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + if len(args) < 1 { + return nil, fmt.Errorf("stage_id required") + } + stageID, ok := args[0].(float64) + if !ok { + return nil, fmt.Errorf("stage_id must be a number") + } + env := rs.Env() + for _, id := range rs.IDs() { + _, _ = env.Tx().Exec(env.Ctx(), + `UPDATE crm_lead SET stage_id = $1, date_last_stage_update = NOW() WHERE id = $2`, + int64(stageID), id) + } + return true, nil + }) + + // _get_lead_statistics: return summary statistics for a set of leads. + // Internal helper used by dashboard views. + m.RegisterMethod("_get_lead_statistics", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + + var totalLeads, totalOpps, wonCount, lostCount int64 + var totalRevenue, avgProbability float64 + + _ = env.Tx().QueryRow(env.Ctx(), ` + SELECT + COUNT(*) FILTER (WHERE type = 'lead'), + COUNT(*) FILTER (WHERE type = 'opportunity'), + COUNT(*) FILTER (WHERE state = 'won'), + COUNT(*) FILTER (WHERE state = 'lost'), + COALESCE(SUM(expected_revenue::float8), 0), + COALESCE(AVG(probability), 0) + FROM crm_lead WHERE active = true`, + ).Scan(&totalLeads, &totalOpps, &wonCount, &lostCount, &totalRevenue, &avgProbability) + + return map[string]interface{}{ + "total_leads": totalLeads, + "total_opportunities": totalOpps, + "won_count": wonCount, + "lost_count": lostCount, + "total_revenue": totalRevenue, + "avg_probability": avgProbability, + }, nil + }) + + // Onchange: partner_id → populate contact/address fields from partner + // Mirrors: odoo/addons/crm/models/crm_lead.py _onchange_partner_id + m.RegisterOnchange("partner_id", func(env *orm.Environment, vals orm.Values) orm.Values { + result := make(orm.Values) + partnerID, ok := vals["partner_id"] + if !ok || partnerID == nil { + return result + } + pid, _ := partnerID.(float64) + if pid == 0 { + return result + } + + var email, phone, street, city, zip, name string + _ = env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(email,''), COALESCE(phone,''), COALESCE(street,''), + COALESCE(city,''), COALESCE(zip,''), COALESCE(name,'') + FROM res_partner WHERE id = $1`, int64(pid), + ).Scan(&email, &phone, &street, &city, &zip, &name) + + if email != "" { + result["email_from"] = email + } + if phone != "" { + result["phone"] = phone + } + if street != "" { + result["street"] = street + } + if city != "" { + result["city"] = city + } + if zip != "" { + result["zip"] = zip + } + if name != "" { + result["partner_name"] = name + } + return result + }) +} diff --git a/addons/crm/models/crm_team.go b/addons/crm/models/crm_team.go new file mode 100644 index 0000000..e8afd9f --- /dev/null +++ b/addons/crm/models/crm_team.go @@ -0,0 +1,284 @@ +package models + +import ( + "fmt" + + "odoo-go/pkg/orm" +) + +// initCrmTeamExpanded extends the basic crm.team with CRM-specific fields +// (pipeline dashboard, lead assignment, team configuration). +// Mirrors: odoo/addons/crm/models/crm_team.py CrmTeam +func initCrmTeamExpanded() { + m := orm.ExtendModel("crm.team") + + m.AddFields( + // Team configuration + orm.Many2one("user_id", "res.users", orm.FieldOpts{String: "Team Leader"}), + orm.One2many("crm_team_member_ids", "crm.team.member", "crm_team_id", orm.FieldOpts{ + String: "Sales Team Members", + }), + orm.Boolean("use_leads", orm.FieldOpts{ + String: "Leads", + Default: true, + Help: "Check this box to filter and qualify leads.", + }), + orm.Boolean("use_opportunities", orm.FieldOpts{ + String: "Pipeline", + Default: true, + Help: "Check this box to manage a pipeline of opportunities.", + }), + orm.Many2one("alias_id", "mail.alias", orm.FieldOpts{ + String: "Email Alias", + Help: "Incoming emails on this alias will create leads/opportunities.", + }), + + // Dashboard computed fields + // Mirrors: _compute_opportunities_data in crm.team + orm.Integer("opportunities_count", orm.FieldOpts{ + String: "Number of Opportunities", + Compute: "_compute_counts", + Help: "Number of active opportunities in this team's pipeline.", + }), + orm.Monetary("opportunities_amount", orm.FieldOpts{ + String: "Opportunities Revenue", + Compute: "_compute_counts", + CurrencyField: "currency_id", + Help: "Total expected revenue of active opportunities.", + }), + orm.Many2one("currency_id", "res.currency", orm.FieldOpts{ + String: "Currency", + }), + orm.Integer("unassigned_leads_count", orm.FieldOpts{ + String: "Unassigned Leads", + Compute: "_compute_counts", + Help: "Number of leads without a salesperson assigned.", + }), + + // Assignment settings + // Mirrors: odoo/addons/crm/models/crm_team.py assignment fields + orm.Selection("assignment_domain", []orm.SelectionItem{ + {Value: "manual", Label: "Manual"}, + {Value: "auto", Label: "Auto Assignment"}, + }, orm.FieldOpts{ + String: "Assignment Method", + Default: "manual", + Help: "How new leads are distributed among team members.", + }), + orm.Integer("assignment_max", orm.FieldOpts{ + String: "Max Leads per Cycle", + Default: 30, + Help: "Maximum leads assigned to a member per assignment cycle.", + }), + orm.Boolean("assignment_enabled", orm.FieldOpts{ + String: "Auto-Assignment Active", + Help: "Enable automatic lead assignment for this team.", + }), + orm.Integer("assignment_optout_count", orm.FieldOpts{ + String: "Members Opted-Out", + Compute: "_compute_assignment_optout", + Help: "Number of members who opted out of automatic assignment.", + }), + ) + + // _compute_counts: compute dashboard KPIs for the sales team. + // Mirrors: odoo/addons/crm/models/crm_team.py _compute_opportunities_data + m.RegisterCompute("opportunities_count", func(rs *orm.Recordset) (orm.Values, error) { + env := rs.Env() + teamID := rs.IDs()[0] + + var count int64 + var amount float64 + _ = env.Tx().QueryRow(env.Ctx(), + `SELECT COUNT(*), COALESCE(SUM(expected_revenue::float8), 0) + FROM crm_lead + WHERE team_id = $1 AND active = true AND type = 'opportunity'`, + teamID, + ).Scan(&count, &amount) + + var unassigned int64 + _ = env.Tx().QueryRow(env.Ctx(), + `SELECT COUNT(*) + FROM crm_lead + WHERE team_id = $1 AND active = true AND user_id IS NULL`, + teamID, + ).Scan(&unassigned) + + return orm.Values{ + "opportunities_count": count, + "opportunities_amount": amount, + "unassigned_leads_count": unassigned, + }, nil + }) + + // action_assign_leads: trigger automatic lead assignment. + // Mirrors: odoo/addons/crm/models/crm_team.py action_assign_leads + m.RegisterMethod("action_assign_leads", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + teamID := rs.IDs()[0] + + // Fetch active members with remaining capacity + rows, err := env.Tx().Query(env.Ctx(), ` + SELECT m.user_id, COALESCE(m.assignment_max, 30) AS max_leads, + COALESCE((SELECT COUNT(*) FROM crm_lead l + WHERE l.user_id = m.user_id AND l.team_id = $1 + AND l.active = true + AND l.create_date >= date_trunc('month', CURRENT_DATE)), 0) AS current_count + FROM crm_team_member m + WHERE m.crm_team_id = $1 AND m.active = true + ORDER BY current_count ASC`, + teamID) + if err != nil { + return nil, fmt.Errorf("action_assign_leads: query members: %w", err) + } + defer rows.Close() + + type memberCap struct { + userID int64 + capacity int64 + } + var members []memberCap + for rows.Next() { + var uid int64 + var maxLeads, current int64 + if err := rows.Scan(&uid, &maxLeads, ¤t); err != nil { + return nil, err + } + if remaining := maxLeads - current; remaining > 0 { + members = append(members, memberCap{userID: uid, capacity: remaining}) + } + } + if len(members) == 0 { + return map[string]interface{}{"assigned": 0}, nil + } + + // Fetch unassigned leads + leadRows, err := env.Tx().Query(env.Ctx(), ` + SELECT id FROM crm_lead + WHERE team_id = $1 AND active = true AND user_id IS NULL + ORDER BY priority DESC, id ASC`, + teamID) + if err != nil { + return nil, fmt.Errorf("action_assign_leads: query leads: %w", err) + } + defer leadRows.Close() + + var assigned int64 + memberIdx := 0 + for leadRows.Next() { + var leadID int64 + if err := leadRows.Scan(&leadID); err != nil { + return nil, err + } + if memberIdx >= len(members) { + break + } + mc := &members[memberIdx] + _, _ = env.Tx().Exec(env.Ctx(), + `UPDATE crm_lead SET user_id = $1 WHERE id = $2`, mc.userID, leadID) + assigned++ + mc.capacity-- + if mc.capacity <= 0 { + memberIdx++ + } + } + + return map[string]interface{}{"assigned": assigned}, nil + }) + + // action_open_opportunities: return window action for team's opportunities. + // Mirrors: odoo/addons/crm/models/crm_team.py action_open_opportunities + m.RegisterMethod("action_open_opportunities", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + return map[string]interface{}{ + "type": "ir.actions.act_window", + "name": "Opportunities", + "res_model": "crm.lead", + "view_mode": "kanban,tree,form", + "domain": fmt.Sprintf("[('team_id','=',%d),('type','=','opportunity')]", rs.IDs()[0]), + "context": map[string]interface{}{"default_team_id": rs.IDs()[0], "default_type": "opportunity"}, + }, nil + }) + + // action_open_unassigned: return window action for unassigned leads. + // Mirrors: odoo/addons/crm/models/crm_team.py (dashboard actions) + m.RegisterMethod("action_open_unassigned", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + return map[string]interface{}{ + "type": "ir.actions.act_window", + "name": "Unassigned Leads", + "res_model": "crm.lead", + "view_mode": "tree,form", + "domain": fmt.Sprintf("[('team_id','=',%d),('user_id','=',False)]", rs.IDs()[0]), + "context": map[string]interface{}{"default_team_id": rs.IDs()[0]}, + }, nil + }) +} + +// initCrmTeamMember registers the crm.team.member model. +// Mirrors: odoo/addons/crm/models/crm_team_member.py CrmTeamMember +func initCrmTeamMember() { + m := orm.NewModel("crm.team.member", orm.ModelOpts{ + Description: "Sales Team Member", + }) + + m.AddFields( + orm.Many2one("crm_team_id", "crm.team", orm.FieldOpts{ + String: "Sales Team", + Required: true, + OnDelete: orm.OnDeleteCascade, + Index: true, + }), + orm.Many2one("user_id", "res.users", orm.FieldOpts{ + String: "Salesperson", + Required: true, + Index: true, + }), + orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}), + orm.Float("assignment_max", orm.FieldOpts{ + String: "Max Leads", + Help: "Maximum number of leads this member should be assigned per month.", + Default: float64(30), + }), + orm.Integer("lead_month_count", orm.FieldOpts{ + String: "Leads This Month", + Compute: "_compute_lead_count", + Help: "Number of leads assigned to this member in the current month.", + }), + orm.Boolean("assignment_optout", orm.FieldOpts{ + String: "Opt-Out", + Help: "If checked, this member will not receive automatically assigned leads.", + }), + orm.Many2one("company_id", "res.company", orm.FieldOpts{ + String: "Company", + Help: "Company of the sales team.", + }), + ) + + // _compute_lead_count: count leads assigned to this member this month. + // Mirrors: odoo/addons/crm/models/crm_team_member.py _compute_lead_month_count + m.RegisterCompute("lead_month_count", func(rs *orm.Recordset) (orm.Values, error) { + env := rs.Env() + memberID := rs.IDs()[0] + + var userID, teamID int64 + _ = env.Tx().QueryRow(env.Ctx(), + `SELECT user_id, crm_team_id FROM crm_team_member WHERE id = $1`, memberID, + ).Scan(&userID, &teamID) + + var count int64 + _ = env.Tx().QueryRow(env.Ctx(), + `SELECT COUNT(*) FROM crm_lead + WHERE user_id = $1 AND team_id = $2 AND active = true + AND create_date >= date_trunc('month', CURRENT_DATE)`, + userID, teamID, + ).Scan(&count) + + return orm.Values{"lead_month_count": count}, nil + }) + + // SQL constraint: one member per team + m.AddSQLConstraint( + "unique_member_per_team", + "UNIQUE(crm_team_id, user_id)", + "A user can only be a member of a team once.", + ) +} diff --git a/addons/crm/models/init.go b/addons/crm/models/init.go index 33a73f2..8688cc0 100644 --- a/addons/crm/models/init.go +++ b/addons/crm/models/init.go @@ -4,6 +4,11 @@ func Init() { initCRMTag() initCRMLostReason() initCRMTeam() + initCrmTeamMember() initCRMStage() initCRMLead() + // Extensions (must come after base models are registered) + initCrmTeamExpanded() + initCRMLeadExtended() + initCrmAnalysis() } diff --git a/addons/hr/models/hr.go b/addons/hr/models/hr.go index b51b137..f5604c6 100644 --- a/addons/hr/models/hr.go +++ b/addons/hr/models/hr.go @@ -109,6 +109,22 @@ func initHREmployee() { }) } +// initHrEmployeeExtensions adds skill, resume, attendance and leave fields +// to hr.employee after the related models have been registered. +func initHrEmployeeExtensions() { + emp := orm.ExtendModel("hr.employee") + emp.AddFields( + orm.One2many("skill_ids", "hr.employee.skill", "employee_id", orm.FieldOpts{String: "Skills"}), + orm.One2many("resume_line_ids", "hr.resume.line", "employee_id", orm.FieldOpts{String: "Resume"}), + orm.One2many("attendance_ids", "hr.attendance", "employee_id", orm.FieldOpts{String: "Attendances"}), + orm.Float("leaves_count", orm.FieldOpts{String: "Time Off", Compute: "_compute_leaves"}), + orm.Selection("attendance_state", []orm.SelectionItem{ + {Value: "checked_out", Label: "Checked Out"}, + {Value: "checked_in", Label: "Checked In"}, + }, orm.FieldOpts{String: "Attendance", Compute: "_compute_attendance_state"}), + ) +} + // initHRDepartment registers the hr.department model. // Mirrors: odoo/addons/hr/models/hr_department.py func initHRDepartment() { diff --git a/addons/hr/models/hr_attendance.go b/addons/hr/models/hr_attendance.go new file mode 100644 index 0000000..cf8ab3b --- /dev/null +++ b/addons/hr/models/hr_attendance.go @@ -0,0 +1,30 @@ +package models + +import "odoo-go/pkg/orm" + +// initHrAttendance registers the hr.attendance model. +// Mirrors: odoo/addons/hr_attendance/models/hr_attendance.py +func initHrAttendance() { + m := orm.NewModel("hr.attendance", orm.ModelOpts{ + Description: "Attendance", + Order: "check_in desc", + }) + m.AddFields( + orm.Many2one("employee_id", "hr.employee", orm.FieldOpts{String: "Employee", Required: true}), + orm.Datetime("check_in", orm.FieldOpts{String: "Check In", Required: true}), + orm.Datetime("check_out", orm.FieldOpts{String: "Check Out"}), + orm.Float("worked_hours", orm.FieldOpts{String: "Worked Hours", Compute: "_compute_worked_hours", Store: true}), + orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}), + ) + + m.RegisterCompute("worked_hours", func(rs *orm.Recordset) (orm.Values, error) { + env := rs.Env() + attID := rs.IDs()[0] + var hours float64 + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(EXTRACT(EPOCH FROM (check_out - check_in)) / 3600.0, 0) + FROM hr_attendance WHERE id = $1 AND check_out IS NOT NULL`, attID, + ).Scan(&hours) + return orm.Values{"worked_hours": hours}, nil + }) +} diff --git a/addons/hr/models/hr_expense.go b/addons/hr/models/hr_expense.go new file mode 100644 index 0000000..9f2ea5e --- /dev/null +++ b/addons/hr/models/hr_expense.go @@ -0,0 +1,59 @@ +package models + +import "odoo-go/pkg/orm" + +// initHrExpense registers the hr.expense and hr.expense.sheet models. +// Mirrors: odoo/addons/hr_expense/models/hr_expense.py +func initHrExpense() { + orm.NewModel("hr.expense", orm.ModelOpts{ + Description: "Expense", + Order: "date desc, id desc", + }).AddFields( + orm.Char("name", orm.FieldOpts{String: "Description", Required: true}), + orm.Many2one("employee_id", "hr.employee", orm.FieldOpts{String: "Employee", Required: true}), + orm.Many2one("product_id", "product.product", orm.FieldOpts{String: "Expense Type"}), + orm.Date("date", orm.FieldOpts{String: "Date", Required: true}), + orm.Monetary("total_amount", orm.FieldOpts{String: "Total", Required: true, CurrencyField: "currency_id"}), + orm.Monetary("unit_amount", orm.FieldOpts{String: "Unit Price", CurrencyField: "currency_id"}), + orm.Float("quantity", orm.FieldOpts{String: "Quantity", Default: 1}), + orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency"}), + orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}), + orm.Many2one("sheet_id", "hr.expense.sheet", orm.FieldOpts{String: "Expense Report"}), + orm.Many2one("account_id", "account.account", orm.FieldOpts{String: "Account"}), + orm.Selection("state", []orm.SelectionItem{ + {Value: "draft", Label: "To Submit"}, + {Value: "reported", Label: "Submitted"}, + {Value: "approved", Label: "Approved"}, + {Value: "done", Label: "Paid"}, + {Value: "refused", Label: "Refused"}, + }, orm.FieldOpts{String: "Status", Default: "draft"}), + orm.Selection("payment_mode", []orm.SelectionItem{ + {Value: "own_account", Label: "Employee (to reimburse)"}, + {Value: "company_account", Label: "Company"}, + }, orm.FieldOpts{String: "Payment By", Default: "own_account"}), + orm.Text("description", orm.FieldOpts{String: "Notes"}), + orm.Binary("receipt", orm.FieldOpts{String: "Receipt"}), + ) + + orm.NewModel("hr.expense.sheet", orm.ModelOpts{ + Description: "Expense Report", + Order: "create_date desc", + }).AddFields( + orm.Char("name", orm.FieldOpts{String: "Report Name", Required: true}), + orm.Many2one("employee_id", "hr.employee", orm.FieldOpts{String: "Employee", Required: true}), + orm.Many2one("manager_id", "hr.employee", orm.FieldOpts{String: "Manager"}), + orm.One2many("expense_line_ids", "hr.expense", "sheet_id", orm.FieldOpts{String: "Expenses"}), + orm.Monetary("total_amount", orm.FieldOpts{String: "Total", Compute: "_compute_total", Store: true, CurrencyField: "currency_id"}), + orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency"}), + orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}), + orm.Selection("state", []orm.SelectionItem{ + {Value: "draft", Label: "Draft"}, + {Value: "submit", Label: "Submitted"}, + {Value: "approve", Label: "Approved"}, + {Value: "post", Label: "Posted"}, + {Value: "done", Label: "Paid"}, + {Value: "cancel", Label: "Refused"}, + }, orm.FieldOpts{String: "Status", Default: "draft"}), + orm.Many2one("account_move_id", "account.move", orm.FieldOpts{String: "Journal Entry"}), + ) +} diff --git a/addons/hr/models/hr_leave.go b/addons/hr/models/hr_leave.go new file mode 100644 index 0000000..b465a68 --- /dev/null +++ b/addons/hr/models/hr_leave.go @@ -0,0 +1,121 @@ +package models + +import "odoo-go/pkg/orm" + +// initHrLeaveType registers the hr.leave.type model. +// Mirrors: odoo/addons/hr_holidays/models/hr_leave_type.py +func initHrLeaveType() { + orm.NewModel("hr.leave.type", orm.ModelOpts{ + Description: "Time Off Type", + Order: "sequence, id", + }).AddFields( + orm.Char("name", orm.FieldOpts{String: "Name", Required: true, Translate: true}), + orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 100}), + orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}), + orm.Selection("leave_validation_type", []orm.SelectionItem{ + {Value: "no_validation", Label: "No Validation"}, + {Value: "hr", Label: "By Time Off Officer"}, + {Value: "manager", Label: "By Employee's Approver"}, + {Value: "both", Label: "By Employee's Approver and Time Off Officer"}, + }, orm.FieldOpts{String: "Approval", Default: "hr"}), + orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}), + orm.Integer("color", orm.FieldOpts{String: "Color"}), + orm.Boolean("requires_allocation", orm.FieldOpts{String: "Requires Allocation", Default: true}), + orm.Float("max_allowed", orm.FieldOpts{String: "Max Days Allowed"}), + ) +} + +// initHrLeave registers the hr.leave model. +// Mirrors: odoo/addons/hr_holidays/models/hr_leave.py +func initHrLeave() { + m := orm.NewModel("hr.leave", orm.ModelOpts{ + Description: "Time Off", + Order: "date_from desc", + }) + m.AddFields( + orm.Char("name", orm.FieldOpts{String: "Description"}), + orm.Many2one("employee_id", "hr.employee", orm.FieldOpts{String: "Employee", Required: true}), + orm.Many2one("holiday_status_id", "hr.leave.type", orm.FieldOpts{String: "Time Off Type", Required: true}), + orm.Many2one("department_id", "hr.department", orm.FieldOpts{String: "Department"}), + orm.Many2one("manager_id", "hr.employee", orm.FieldOpts{String: "Manager"}), + orm.Datetime("date_from", orm.FieldOpts{String: "Start Date", Required: true}), + orm.Datetime("date_to", orm.FieldOpts{String: "End Date", Required: true}), + orm.Float("number_of_days", orm.FieldOpts{String: "Duration (Days)"}), + orm.Selection("state", []orm.SelectionItem{ + {Value: "draft", Label: "To Submit"}, + {Value: "confirm", Label: "To Approve"}, + {Value: "validate1", Label: "Second Approval"}, + {Value: "validate", Label: "Approved"}, + {Value: "refuse", Label: "Refused"}, + }, orm.FieldOpts{String: "Status", Default: "draft"}), + orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}), + orm.Text("notes", orm.FieldOpts{String: "Reasons"}), + ) + + m.RegisterMethod("action_approve", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + for _, id := range rs.IDs() { + env.Tx().Exec(env.Ctx(), `UPDATE hr_leave SET state = 'validate' WHERE id = $1 AND state IN ('confirm','validate1')`, id) + } + return true, nil + }) + + m.RegisterMethod("action_refuse", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + for _, id := range rs.IDs() { + env.Tx().Exec(env.Ctx(), `UPDATE hr_leave SET state = 'refuse' WHERE id = $1`, id) + } + return true, nil + }) + + m.RegisterMethod("action_draft", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + for _, id := range rs.IDs() { + env.Tx().Exec(env.Ctx(), `UPDATE hr_leave SET state = 'draft' WHERE id = $1`, id) + } + return true, nil + }) + + m.RegisterMethod("action_confirm", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + for _, id := range rs.IDs() { + env.Tx().Exec(env.Ctx(), `UPDATE hr_leave SET state = 'confirm' WHERE id = $1 AND state = 'draft'`, id) + } + return true, nil + }) +} + +// initHrLeaveAllocation registers the hr.leave.allocation model. +// Mirrors: odoo/addons/hr_holidays/models/hr_leave_allocation.py +func initHrLeaveAllocation() { + m := orm.NewModel("hr.leave.allocation", orm.ModelOpts{ + Description: "Time Off Allocation", + Order: "create_date desc", + }) + m.AddFields( + orm.Char("name", orm.FieldOpts{String: "Description"}), + orm.Many2one("employee_id", "hr.employee", orm.FieldOpts{String: "Employee", Required: true}), + orm.Many2one("holiday_status_id", "hr.leave.type", orm.FieldOpts{String: "Time Off Type", Required: true}), + orm.Float("number_of_days", orm.FieldOpts{String: "Duration (Days)", Required: true}), + orm.Selection("state", []orm.SelectionItem{ + {Value: "draft", Label: "To Submit"}, + {Value: "confirm", Label: "To Approve"}, + {Value: "validate", Label: "Approved"}, + {Value: "refuse", Label: "Refused"}, + }, orm.FieldOpts{String: "Status", Default: "draft"}), + orm.Many2one("department_id", "hr.department", orm.FieldOpts{String: "Department"}), + orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}), + orm.Selection("allocation_type", []orm.SelectionItem{ + {Value: "regular", Label: "Regular Allocation"}, + {Value: "accrual", Label: "Accrual Allocation"}, + }, orm.FieldOpts{String: "Allocation Type", Default: "regular"}), + ) + + m.RegisterMethod("action_approve", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + for _, id := range rs.IDs() { + env.Tx().Exec(env.Ctx(), `UPDATE hr_leave_allocation SET state = 'validate' WHERE id = $1`, id) + } + return true, nil + }) +} diff --git a/addons/hr/models/hr_skills.go b/addons/hr/models/hr_skills.go new file mode 100644 index 0000000..33358df --- /dev/null +++ b/addons/hr/models/hr_skills.go @@ -0,0 +1,53 @@ +package models + +import "odoo-go/pkg/orm" + +// initHrSkill registers hr.skill.type, hr.skill, hr.employee.skill and hr.resume.line. +// Mirrors: odoo/addons/hr_skills/models/hr_skill.py +func initHrSkill() { + orm.NewModel("hr.skill.type", orm.ModelOpts{ + Description: "Skill Type", + Order: "name", + }).AddFields( + orm.Char("name", orm.FieldOpts{String: "Name", Required: true}), + orm.One2many("skill_ids", "hr.skill", "skill_type_id", orm.FieldOpts{String: "Skills"}), + ) + + orm.NewModel("hr.skill", orm.ModelOpts{ + Description: "Skill", + Order: "name", + }).AddFields( + orm.Char("name", orm.FieldOpts{String: "Name", Required: true}), + orm.Many2one("skill_type_id", "hr.skill.type", orm.FieldOpts{String: "Skill Type", Required: true}), + ) + + orm.NewModel("hr.employee.skill", orm.ModelOpts{ + Description: "Employee Skill", + }).AddFields( + orm.Many2one("employee_id", "hr.employee", orm.FieldOpts{String: "Employee", Required: true, OnDelete: orm.OnDeleteCascade}), + orm.Many2one("skill_id", "hr.skill", orm.FieldOpts{String: "Skill", Required: true}), + orm.Many2one("skill_type_id", "hr.skill.type", orm.FieldOpts{String: "Skill Type"}), + orm.Selection("skill_level", []orm.SelectionItem{ + {Value: "beginner", Label: "Beginner"}, + {Value: "intermediate", Label: "Intermediate"}, + {Value: "advanced", Label: "Advanced"}, + {Value: "expert", Label: "Expert"}, + }, orm.FieldOpts{String: "Level", Default: "beginner"}), + ) + + orm.NewModel("hr.resume.line", orm.ModelOpts{ + Description: "Resume Line", + Order: "date_start desc", + }).AddFields( + orm.Many2one("employee_id", "hr.employee", orm.FieldOpts{String: "Employee", Required: true, OnDelete: orm.OnDeleteCascade}), + orm.Char("name", orm.FieldOpts{String: "Name", Required: true}), + orm.Date("date_start", orm.FieldOpts{String: "Start Date", Required: true}), + orm.Date("date_end", orm.FieldOpts{String: "End Date"}), + orm.Text("description", orm.FieldOpts{String: "Description"}), + orm.Selection("line_type_id", []orm.SelectionItem{ + {Value: "experience", Label: "Experience"}, + {Value: "education", Label: "Education"}, + {Value: "certification", Label: "Certification"}, + }, orm.FieldOpts{String: "Type", Default: "experience"}), + ) +} diff --git a/addons/hr/models/init.go b/addons/hr/models/init.go index 8f9e330..376f6ed 100644 --- a/addons/hr/models/init.go +++ b/addons/hr/models/init.go @@ -1,9 +1,27 @@ package models func Init() { + // Core HR models initResourceCalendar() initHREmployee() initHRDepartment() initHRJob() initHrContract() + + // Leave management + initHrLeaveType() + initHrLeave() + initHrLeaveAllocation() + + // Attendance + initHrAttendance() + + // Expenses + initHrExpense() + + // Skills & Resume + initHrSkill() + + // Extend hr.employee with links to new models (must come last) + initHrEmployeeExtensions() } diff --git a/addons/stock/models/stock.go b/addons/stock/models/stock.go index 39d9167..31d2efe 100644 --- a/addons/stock/models/stock.go +++ b/addons/stock/models/stock.go @@ -27,6 +27,13 @@ func initStock() { initStockScrap() initStockInventory() initProductStockExtension() + initStockValuationLayer() + initStockLandedCost() + initStockReport() + initStockForecast() + initStockPickingBatch() + initStockPickingBatchExtension() + initStockBarcode() } // initStockWarehouse registers stock.warehouse. diff --git a/addons/stock/models/stock_barcode.go b/addons/stock/models/stock_barcode.go new file mode 100644 index 0000000..97ba587 --- /dev/null +++ b/addons/stock/models/stock_barcode.go @@ -0,0 +1,236 @@ +package models + +import ( + "fmt" + + "odoo-go/pkg/orm" +) + +// initStockBarcode registers stock.barcode.picking — transient model for barcode scanning interface. +// Mirrors: odoo/addons/stock_barcode/models/stock_picking.py barcode processing +func initStockBarcode() { + m := orm.NewModel("stock.barcode.picking", orm.ModelOpts{ + Description: "Barcode Picking Interface", + Type: orm.ModelTransient, + }) + m.AddFields( + orm.Many2one("picking_id", "stock.picking", orm.FieldOpts{String: "Transfer"}), + orm.Char("barcode", orm.FieldOpts{String: "Barcode"}), + ) + + // process_barcode: Look up a product or lot/serial by barcode. + // Mirrors: stock_barcode barcode scanning logic + m.RegisterMethod("process_barcode", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + if len(args) < 1 { + return nil, fmt.Errorf("barcode required") + } + barcode, _ := args[0].(string) + + // Try to find product by barcode + var productID int64 + env.Tx().QueryRow(env.Ctx(), + `SELECT pp.id FROM product_product pp + JOIN product_template pt ON pt.id = pp.product_tmpl_id + WHERE pt.barcode = $1 OR pp.barcode = $1 LIMIT 1`, barcode, + ).Scan(&productID) + + if productID > 0 { + return map[string]interface{}{"product_id": productID, "found": true}, nil + } + + // Try lot/serial + var lotID, lotProductID int64 + env.Tx().QueryRow(env.Ctx(), + `SELECT id, product_id FROM stock_lot WHERE name = $1 LIMIT 1`, barcode, + ).Scan(&lotID, &lotProductID) + + if lotID > 0 { + return map[string]interface{}{"lot_id": lotID, "product_id": lotProductID, "found": true}, nil + } + + // Try package + var packageID int64 + env.Tx().QueryRow(env.Ctx(), + `SELECT id FROM stock_quant_package WHERE name = $1 LIMIT 1`, barcode, + ).Scan(&packageID) + + if packageID > 0 { + return map[string]interface{}{"package_id": packageID, "found": true}, nil + } + + // Try location barcode + var locationID int64 + env.Tx().QueryRow(env.Ctx(), + `SELECT id FROM stock_location WHERE barcode = $1 LIMIT 1`, barcode, + ).Scan(&locationID) + + if locationID > 0 { + return map[string]interface{}{"location_id": locationID, "found": true}, nil + } + + return map[string]interface{}{"found": false, "barcode": barcode}, nil + }) + + // process_barcode_picking: Process a barcode in the context of a picking. + // Finds the product and increments qty_done on the matching move line. + // Mirrors: stock_barcode.picking barcode processing + m.RegisterMethod("process_barcode_picking", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + if len(args) < 2 { + return nil, fmt.Errorf("stock.barcode.picking.process_barcode_picking requires picking_id, barcode") + } + pickingID, _ := args[0].(int64) + barcode, _ := args[1].(string) + + if pickingID == 0 || barcode == "" { + return nil, fmt.Errorf("stock.barcode.picking: invalid picking_id or barcode") + } + + env := rs.Env() + + // Find product by barcode + var productID int64 + env.Tx().QueryRow(env.Ctx(), + `SELECT pp.id FROM product_product pp + JOIN product_template pt ON pt.id = pp.product_tmpl_id + WHERE pt.barcode = $1 OR pp.barcode = $1 LIMIT 1`, barcode, + ).Scan(&productID) + + if productID == 0 { + // Try lot/serial + var lotProductID int64 + env.Tx().QueryRow(env.Ctx(), + `SELECT product_id FROM stock_lot WHERE name = $1 LIMIT 1`, barcode, + ).Scan(&lotProductID) + productID = lotProductID + } + + if productID == 0 { + return map[string]interface{}{ + "found": false, "barcode": barcode, + "message": fmt.Sprintf("No product found for barcode %q", barcode), + }, nil + } + + // Find matching move line + var moveLineID int64 + err := env.Tx().QueryRow(env.Ctx(), + `SELECT sml.id FROM stock_move_line sml + JOIN stock_move sm ON sm.id = sml.move_id + WHERE sm.picking_id = $1 AND sml.product_id = $2 AND sm.state != 'done' + ORDER BY sml.id LIMIT 1`, + pickingID, productID, + ).Scan(&moveLineID) + + if err != nil || moveLineID == 0 { + // No existing move line — check if there is a move for this product + var moveID int64 + env.Tx().QueryRow(env.Ctx(), + `SELECT id FROM stock_move WHERE picking_id = $1 AND product_id = $2 AND state != 'done' LIMIT 1`, + pickingID, productID, + ).Scan(&moveID) + + if moveID == 0 { + return map[string]interface{}{ + "found": false, "product_id": productID, + "message": fmt.Sprintf("Product %d not expected in this transfer", productID), + }, nil + } + + return map[string]interface{}{ + "found": true, "product_id": productID, "move_id": moveID, + "action": "new_line", + "message": "Product found in move, new line needed", + }, nil + } + + // Increment quantity on the move line + _, err = env.Tx().Exec(env.Ctx(), + `UPDATE stock_move_line SET quantity = quantity + 1 WHERE id = $1`, moveLineID) + if err != nil { + return nil, fmt.Errorf("stock.barcode.picking: increment qty on move line %d: %w", moveLineID, err) + } + + return map[string]interface{}{ + "found": true, "product_id": productID, "move_line_id": moveLineID, + "action": "incremented", "message": "Quantity incremented", + }, nil + }) + + // get_picking_data: Return the full picking data for the barcode interface. + // Mirrors: stock_barcode.picking get_barcode_data() + m.RegisterMethod("get_picking_data", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + if len(args) < 1 { + return nil, fmt.Errorf("stock.barcode.picking.get_picking_data requires picking_id") + } + pickingID, _ := args[0].(int64) + if pickingID == 0 { + return nil, fmt.Errorf("stock.barcode.picking: invalid picking_id") + } + + env := rs.Env() + + // Get picking header + var pickingName, state string + var srcLocID, dstLocID int64 + err := env.Tx().QueryRow(env.Ctx(), + `SELECT name, state, location_id, location_dest_id + FROM stock_picking WHERE id = $1`, pickingID, + ).Scan(&pickingName, &state, &srcLocID, &dstLocID) + if err != nil { + return nil, fmt.Errorf("stock.barcode.picking: read picking %d: %w", pickingID, err) + } + + // Get source/dest location names + var srcLocName, dstLocName string + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(complete_name, name) FROM stock_location WHERE id = $1`, srcLocID, + ).Scan(&srcLocName) + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(complete_name, name) FROM stock_location WHERE id = $1`, dstLocID, + ).Scan(&dstLocName) + + // Get move lines + rows, err := env.Tx().Query(env.Ctx(), + `SELECT sm.id, sm.product_id, pt.name as product_name, + sm.product_uom_qty as demand, + COALESCE(SUM(sml.quantity), 0) as done_qty + FROM stock_move sm + JOIN product_product pp ON pp.id = sm.product_id + JOIN product_template pt ON pt.id = pp.product_tmpl_id + LEFT JOIN stock_move_line sml ON sml.move_id = sm.id + WHERE sm.picking_id = $1 AND sm.state != 'cancel' + GROUP BY sm.id, sm.product_id, pt.name, sm.product_uom_qty + ORDER BY pt.name`, + pickingID, + ) + if err != nil { + return nil, fmt.Errorf("stock.barcode.picking: query moves for %d: %w", pickingID, err) + } + defer rows.Close() + + var moveLines []map[string]interface{} + for rows.Next() { + var moveID, prodID int64 + var prodName string + var demand, doneQty float64 + if err := rows.Scan(&moveID, &prodID, &prodName, &demand, &doneQty); err != nil { + return nil, fmt.Errorf("stock.barcode.picking: scan move: %w", err) + } + moveLines = append(moveLines, map[string]interface{}{ + "move_id": moveID, "product_id": prodID, "product": prodName, + "demand": demand, "done": doneQty, + "remaining": demand - doneQty, + }) + } + + return map[string]interface{}{ + "picking_id": pickingID, + "name": pickingName, + "state": state, + "source_location": srcLocName, + "dest_location": dstLocName, + "lines": moveLines, + }, nil + }) +} diff --git a/addons/stock/models/stock_landed_cost.go b/addons/stock/models/stock_landed_cost.go new file mode 100644 index 0000000..42e2011 --- /dev/null +++ b/addons/stock/models/stock_landed_cost.go @@ -0,0 +1,382 @@ +package models + +import ( + "fmt" + "math" + + "odoo-go/pkg/orm" +) + +// initStockLandedCost registers stock.landed.cost, stock.landed.cost.lines, +// and stock.valuation.adjustment.lines — landed cost allocation on transfers. +// Mirrors: odoo/addons/stock_landed_costs/models/stock_landed_cost.py +func initStockLandedCost() { + m := orm.NewModel("stock.landed.cost", orm.ModelOpts{ + Description: "Landed Costs", + Order: "date desc, id desc", + }) + m.AddFields( + orm.Char("name", orm.FieldOpts{String: "Name", Default: "New"}), + orm.Date("date", orm.FieldOpts{String: "Date", Required: true}), + orm.Many2many("picking_ids", "stock.picking", orm.FieldOpts{String: "Transfers"}), + orm.One2many("cost_lines", "stock.landed.cost.lines", "cost_id", orm.FieldOpts{String: "Cost Lines"}), + orm.One2many("valuation_adjustment_lines", "stock.valuation.adjustment.lines", "cost_id", orm.FieldOpts{String: "Valuation Adjustments"}), + orm.Many2one("account_journal_id", "account.journal", orm.FieldOpts{String: "Journal"}), + orm.Many2one("account_move_id", "account.move", orm.FieldOpts{String: "Journal Entry"}), + orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}), + orm.Selection("state", []orm.SelectionItem{ + {Value: "draft", Label: "Draft"}, + {Value: "done", Label: "Posted"}, + {Value: "cancel", Label: "Cancelled"}, + }, orm.FieldOpts{String: "Status", Default: "draft"}), + orm.Monetary("amount_total", orm.FieldOpts{String: "Total", Compute: "_compute_amount_total", CurrencyField: "currency_id"}), + orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency"}), + ) + + // _compute_amount_total: Sum of all cost lines. + // Mirrors: stock.landed.cost._compute_amount_total() + m.RegisterCompute("amount_total", func(rs *orm.Recordset) (orm.Values, error) { + env := rs.Env() + costID := rs.IDs()[0] + var total float64 + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(SUM(price_unit), 0) FROM stock_landed_cost_lines WHERE cost_id = $1`, + costID, + ).Scan(&total) + return orm.Values{"amount_total": total}, nil + }) + + // compute_landed_cost: Compute and create valuation adjustment lines based on split method. + // Mirrors: stock.landed.cost.compute_landed_cost() + m.RegisterMethod("compute_landed_cost", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + for _, costID := range rs.IDs() { + // Delete existing adjustment lines + _, err := env.Tx().Exec(env.Ctx(), + `DELETE FROM stock_valuation_adjustment_lines WHERE cost_id = $1`, costID) + if err != nil { + return nil, fmt.Errorf("stock.landed.cost: clear adjustments for %d: %w", costID, err) + } + + // Get all moves from associated pickings + moveRows, err := env.Tx().Query(env.Ctx(), + `SELECT sm.id, sm.product_id, sm.product_uom_qty, sm.price_unit + FROM stock_move sm + JOIN stock_picking sp ON sp.id = sm.picking_id + JOIN stock_landed_cost_stock_picking_rel rel ON rel.stock_picking_id = sp.id + WHERE rel.stock_landed_cost_id = $1 AND sm.state = 'done'`, + costID, + ) + if err != nil { + return nil, fmt.Errorf("stock.landed.cost: query moves for %d: %w", costID, err) + } + + type moveInfo struct { + ID int64 + ProductID int64 + Qty float64 + UnitCost float64 + } + var moves []moveInfo + var totalQty, totalWeight, totalVolume, totalCost float64 + + for moveRows.Next() { + var mi moveInfo + if err := moveRows.Scan(&mi.ID, &mi.ProductID, &mi.Qty, &mi.UnitCost); err != nil { + moveRows.Close() + return nil, fmt.Errorf("stock.landed.cost: scan move: %w", err) + } + moves = append(moves, mi) + totalQty += mi.Qty + totalCost += mi.Qty * mi.UnitCost + } + moveRows.Close() + + if len(moves) == 0 { + continue + } + + // Get cost lines + costLineRows, err := env.Tx().Query(env.Ctx(), + `SELECT id, price_unit, split_method FROM stock_landed_cost_lines WHERE cost_id = $1`, + costID, + ) + if err != nil { + return nil, fmt.Errorf("stock.landed.cost: query cost lines for %d: %w", costID, err) + } + + type costLineInfo struct { + ID int64 + PriceUnit float64 + SplitMethod string + } + var costLines []costLineInfo + for costLineRows.Next() { + var cl costLineInfo + if err := costLineRows.Scan(&cl.ID, &cl.PriceUnit, &cl.SplitMethod); err != nil { + costLineRows.Close() + return nil, fmt.Errorf("stock.landed.cost: scan cost line: %w", err) + } + costLines = append(costLines, cl) + } + costLineRows.Close() + + // For each cost line, distribute costs across moves + for _, cl := range costLines { + for _, mv := range moves { + var share float64 + switch cl.SplitMethod { + case "equal": + share = cl.PriceUnit / float64(len(moves)) + case "by_quantity": + if totalQty > 0 { + share = cl.PriceUnit * mv.Qty / totalQty + } + case "by_current_cost_price": + moveCost := mv.Qty * mv.UnitCost + if totalCost > 0 { + share = cl.PriceUnit * moveCost / totalCost + } + case "by_weight": + // Simplified: use quantity as proxy for weight + if totalWeight > 0 { + share = cl.PriceUnit * mv.Qty / totalWeight + } else if totalQty > 0 { + share = cl.PriceUnit * mv.Qty / totalQty + } + case "by_volume": + // Simplified: use quantity as proxy for volume + if totalVolume > 0 { + share = cl.PriceUnit * mv.Qty / totalVolume + } else if totalQty > 0 { + share = cl.PriceUnit * mv.Qty / totalQty + } + default: + share = cl.PriceUnit / float64(len(moves)) + } + + formerCost := mv.Qty * mv.UnitCost + finalCost := formerCost + share + + _, err := env.Tx().Exec(env.Ctx(), + `INSERT INTO stock_valuation_adjustment_lines + (cost_id, cost_line_id, move_id, product_id, quantity, former_cost, additional_landed_cost, final_cost) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + costID, cl.ID, mv.ID, mv.ProductID, mv.Qty, formerCost, share, finalCost, + ) + if err != nil { + return nil, fmt.Errorf("stock.landed.cost: create adjustment line: %w", err) + } + } + } + } + return true, nil + }) + + // button_validate: Post the landed cost, apply valuation adjustments, and set state to done. + // Mirrors: stock.landed.cost.button_validate() + m.RegisterMethod("button_validate", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + for _, costID := range rs.IDs() { + // First compute the cost distribution + lcModel := orm.Registry.Get("stock.landed.cost") + if lcModel != nil { + if computeMethod, ok := lcModel.Methods["compute_landed_cost"]; ok { + lcRS := env.Model("stock.landed.cost").Browse(costID) + if _, err := computeMethod(lcRS); err != nil { + return nil, fmt.Errorf("stock.landed.cost: compute for validate %d: %w", costID, err) + } + } + } + + // Apply adjustments to valuation layers + adjRows, err := env.Tx().Query(env.Ctx(), + `SELECT move_id, product_id, additional_landed_cost + FROM stock_valuation_adjustment_lines + WHERE cost_id = $1 AND additional_landed_cost != 0`, + costID, + ) + if err != nil { + return nil, fmt.Errorf("stock.landed.cost: query adjustments for %d: %w", costID, err) + } + + type adjInfo struct { + MoveID int64 + ProductID int64 + AdditionalCost float64 + } + var adjustments []adjInfo + for adjRows.Next() { + var adj adjInfo + if err := adjRows.Scan(&adj.MoveID, &adj.ProductID, &adj.AdditionalCost); err != nil { + adjRows.Close() + return nil, fmt.Errorf("stock.landed.cost: scan adjustment: %w", err) + } + adjustments = append(adjustments, adj) + } + adjRows.Close() + + for _, adj := range adjustments { + // Update the corresponding valuation layer remaining_value + _, err := env.Tx().Exec(env.Ctx(), + `UPDATE stock_valuation_layer + SET remaining_value = remaining_value + $1, value = value + $1 + WHERE stock_move_id = $2 AND product_id = $3 AND remaining_qty > 0 + LIMIT 1`, + adj.AdditionalCost, adj.MoveID, adj.ProductID, + ) + if err != nil { + // Non-fatal: layer might not exist yet + fmt.Printf("stock.landed.cost: update valuation layer for move %d: %v\n", adj.MoveID, err) + } + } + + _, err = env.Tx().Exec(env.Ctx(), + `UPDATE stock_landed_cost SET state = 'done' WHERE id = $1`, costID) + if err != nil { + return nil, fmt.Errorf("stock.landed.cost: validate %d: %w", costID, err) + } + } + return true, nil + }) + + // action_cancel: Reverse the landed cost and set state to cancelled. + // Mirrors: stock.landed.cost.action_cancel() + m.RegisterMethod("action_cancel", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + for _, costID := range rs.IDs() { + var state string + err := env.Tx().QueryRow(env.Ctx(), + `SELECT state FROM stock_landed_cost WHERE id = $1`, costID, + ).Scan(&state) + if err != nil { + return nil, fmt.Errorf("stock.landed.cost: read state for %d: %w", costID, err) + } + + if state == "done" { + // Reverse valuation adjustments + adjRows, err := env.Tx().Query(env.Ctx(), + `SELECT move_id, product_id, additional_landed_cost + FROM stock_valuation_adjustment_lines + WHERE cost_id = $1 AND additional_landed_cost != 0`, + costID, + ) + if err != nil { + return nil, fmt.Errorf("stock.landed.cost: query adjustments for cancel %d: %w", costID, err) + } + + type adjInfo struct { + MoveID int64 + ProductID int64 + AdditionalCost float64 + } + var adjustments []adjInfo + for adjRows.Next() { + var adj adjInfo + adjRows.Scan(&adj.MoveID, &adj.ProductID, &adj.AdditionalCost) + adjustments = append(adjustments, adj) + } + adjRows.Close() + + for _, adj := range adjustments { + env.Tx().Exec(env.Ctx(), + `UPDATE stock_valuation_layer + SET remaining_value = remaining_value - $1, value = value - $1 + WHERE stock_move_id = $2 AND product_id = $3 AND remaining_qty > 0`, + adj.AdditionalCost, adj.MoveID, adj.ProductID, + ) + } + } + + _, err = env.Tx().Exec(env.Ctx(), + `UPDATE stock_landed_cost SET state = 'cancel' WHERE id = $1`, costID) + if err != nil { + return nil, fmt.Errorf("stock.landed.cost: cancel %d: %w", costID, err) + } + } + return true, nil + }) + + // get_cost_summary: Return a summary of landed cost distribution. + // Mirrors: stock.landed.cost views / reporting + m.RegisterMethod("get_cost_summary", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + costID := rs.IDs()[0] + + // Get totals by product + rows, err := env.Tx().Query(env.Ctx(), + `SELECT val.product_id, + COALESCE(SUM(val.former_cost), 0) as total_former, + COALESCE(SUM(val.additional_landed_cost), 0) as total_additional, + COALESCE(SUM(val.final_cost), 0) as total_final + FROM stock_valuation_adjustment_lines val + WHERE val.cost_id = $1 + GROUP BY val.product_id + ORDER BY val.product_id`, + costID, + ) + if err != nil { + return nil, fmt.Errorf("stock.landed.cost: query summary for %d: %w", costID, err) + } + defer rows.Close() + + var lines []map[string]interface{} + for rows.Next() { + var productID int64 + var former, additional, final float64 + if err := rows.Scan(&productID, &former, &additional, &final); err != nil { + return nil, fmt.Errorf("stock.landed.cost: scan summary row: %w", err) + } + lines = append(lines, map[string]interface{}{ + "product_id": productID, + "former_cost": former, + "additional_landed_cost": additional, + "final_cost": final, + }) + } + + return map[string]interface{}{"summary": lines}, nil + }) + + // --- Sub-models --- + + // stock.landed.cost.lines — individual cost items on a landed cost + orm.NewModel("stock.landed.cost.lines", orm.ModelOpts{ + Description: "Landed Cost Lines", + }).AddFields( + orm.Many2one("cost_id", "stock.landed.cost", orm.FieldOpts{String: "Landed Cost", Required: true, OnDelete: orm.OnDeleteCascade}), + orm.Char("name", orm.FieldOpts{String: "Description", Required: true}), + orm.Many2one("product_id", "product.product", orm.FieldOpts{String: "Product"}), + orm.Float("price_unit", orm.FieldOpts{String: "Cost"}), + orm.Selection("split_method", []orm.SelectionItem{ + {Value: "equal", Label: "Equal"}, + {Value: "by_quantity", Label: "By Quantity"}, + {Value: "by_current_cost_price", Label: "By Current Cost"}, + {Value: "by_weight", Label: "By Weight"}, + {Value: "by_volume", Label: "By Volume"}, + }, orm.FieldOpts{String: "Split Method", Default: "equal", Required: true}), + orm.Many2one("account_id", "account.account", orm.FieldOpts{String: "Account"}), + ) + + // stock.valuation.adjustment.lines — per-move cost adjustments + orm.NewModel("stock.valuation.adjustment.lines", orm.ModelOpts{ + Description: "Valuation Adjustment Lines", + }).AddFields( + orm.Many2one("cost_id", "stock.landed.cost", orm.FieldOpts{String: "Landed Cost", Required: true, OnDelete: orm.OnDeleteCascade}), + orm.Many2one("cost_line_id", "stock.landed.cost.lines", orm.FieldOpts{String: "Cost Line"}), + orm.Many2one("move_id", "stock.move", orm.FieldOpts{String: "Stock Move"}), + orm.Many2one("product_id", "product.product", orm.FieldOpts{String: "Product"}), + orm.Float("quantity", orm.FieldOpts{String: "Quantity"}), + orm.Float("weight", orm.FieldOpts{String: "Weight"}), + orm.Float("volume", orm.FieldOpts{String: "Volume"}), + orm.Monetary("former_cost", orm.FieldOpts{String: "Original Value", CurrencyField: "currency_id"}), + orm.Monetary("additional_landed_cost", orm.FieldOpts{String: "Additional Cost", CurrencyField: "currency_id"}), + orm.Monetary("final_cost", orm.FieldOpts{String: "Final Cost", CurrencyField: "currency_id"}), + orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency"}), + ) +} + +// roundCurrency rounds a monetary value to 2 decimal places. +func roundCurrency(value float64) float64 { + return math.Round(value*100) / 100 +} diff --git a/addons/stock/models/stock_picking_batch.go b/addons/stock/models/stock_picking_batch.go new file mode 100644 index 0000000..acc6e88 --- /dev/null +++ b/addons/stock/models/stock_picking_batch.go @@ -0,0 +1,343 @@ +package models + +import ( + "fmt" + + "odoo-go/pkg/orm" +) + +// initStockPickingBatch registers stock.picking.batch — batch processing of transfers. +// Mirrors: odoo/addons/stock_picking_batch/models/stock_picking_batch.py +func initStockPickingBatch() { + m := orm.NewModel("stock.picking.batch", orm.ModelOpts{ + Description: "Batch Transfer", + Order: "name desc", + }) + m.AddFields( + orm.Char("name", orm.FieldOpts{String: "Name", Default: "New"}), + orm.Many2one("user_id", "res.users", orm.FieldOpts{String: "Responsible"}), + orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}), + orm.One2many("picking_ids", "stock.picking", "batch_id", orm.FieldOpts{String: "Transfers"}), + orm.Selection("state", []orm.SelectionItem{ + {Value: "draft", Label: "Draft"}, + {Value: "in_progress", Label: "In Progress"}, + {Value: "done", Label: "Done"}, + {Value: "cancel", Label: "Cancelled"}, + }, orm.FieldOpts{String: "Status", Default: "draft"}), + orm.Selection("picking_type_code", []orm.SelectionItem{ + {Value: "incoming", Label: "Receipt"}, + {Value: "outgoing", Label: "Delivery"}, + {Value: "internal", Label: "Internal Transfer"}, + }, orm.FieldOpts{String: "Operation Type"}), + orm.Integer("picking_count", orm.FieldOpts{String: "Transfers Count", Compute: "_compute_picking_count"}), + orm.Integer("move_line_count", orm.FieldOpts{String: "Move Lines Count", Compute: "_compute_move_line_count"}), + ) + + // _compute_picking_count: Count the number of pickings in this batch. + // Mirrors: stock.picking.batch._compute_picking_count() + m.RegisterCompute("picking_count", func(rs *orm.Recordset) (orm.Values, error) { + env := rs.Env() + batchID := rs.IDs()[0] + var count int64 + env.Tx().QueryRow(env.Ctx(), + `SELECT COUNT(*) FROM stock_picking WHERE batch_id = $1`, batchID, + ).Scan(&count) + return orm.Values{"picking_count": count}, nil + }) + + // _compute_move_line_count: Count the total move lines across all pickings in batch. + // Mirrors: stock.picking.batch._compute_move_line_count() + m.RegisterCompute("move_line_count", func(rs *orm.Recordset) (orm.Values, error) { + env := rs.Env() + batchID := rs.IDs()[0] + var count int64 + env.Tx().QueryRow(env.Ctx(), + `SELECT COUNT(*) + FROM stock_move_line sml + JOIN stock_move sm ON sm.id = sml.move_id + JOIN stock_picking sp ON sp.id = sm.picking_id + WHERE sp.batch_id = $1`, batchID, + ).Scan(&count) + return orm.Values{"move_line_count": count}, nil + }) + + // action_confirm: Move batch from draft to in_progress. + // Mirrors: stock.picking.batch.action_confirm() + m.RegisterMethod("action_confirm", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + for _, id := range rs.IDs() { + var state string + err := env.Tx().QueryRow(env.Ctx(), + `SELECT state FROM stock_picking_batch WHERE id = $1`, id, + ).Scan(&state) + if err != nil { + return nil, fmt.Errorf("stock.picking.batch: read state for %d: %w", id, err) + } + if state != "draft" { + return nil, fmt.Errorf("stock.picking.batch: can only confirm draft batches (batch %d is %q)", id, state) + } + + // Confirm all draft pickings in batch + pickRows, err := env.Tx().Query(env.Ctx(), + `SELECT id FROM stock_picking WHERE batch_id = $1 AND state = 'draft'`, id) + if err != nil { + return nil, fmt.Errorf("stock.picking.batch: query draft pickings for %d: %w", id, err) + } + var pickIDs []int64 + for pickRows.Next() { + var pid int64 + pickRows.Scan(&pid) + pickIDs = append(pickIDs, pid) + } + pickRows.Close() + + if len(pickIDs) > 0 { + pickModel := orm.Registry.Get("stock.picking") + if pickModel != nil { + if confirmMethod, ok := pickModel.Methods["action_confirm"]; ok { + pickRS := env.Model("stock.picking").Browse(pickIDs...) + if _, err := confirmMethod(pickRS); err != nil { + return nil, fmt.Errorf("stock.picking.batch: confirm pickings for batch %d: %w", id, err) + } + } + } + } + + _, err = env.Tx().Exec(env.Ctx(), + `UPDATE stock_picking_batch SET state = 'in_progress' WHERE id = $1`, id) + if err != nil { + return nil, fmt.Errorf("stock.picking.batch: confirm batch %d: %w", id, err) + } + } + return true, nil + }) + + // action_assign: Reserve stock for all pickings in the batch. + // Mirrors: stock.picking.batch.action_assign() + m.RegisterMethod("action_assign", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + for _, batchID := range rs.IDs() { + pickRows, err := env.Tx().Query(env.Ctx(), + `SELECT id FROM stock_picking WHERE batch_id = $1 AND state IN ('confirmed', 'assigned')`, batchID) + if err != nil { + return nil, fmt.Errorf("stock.picking.batch: query pickings for assign %d: %w", batchID, err) + } + var pickIDs []int64 + for pickRows.Next() { + var pid int64 + pickRows.Scan(&pid) + pickIDs = append(pickIDs, pid) + } + pickRows.Close() + + if len(pickIDs) > 0 { + pickModel := orm.Registry.Get("stock.picking") + if pickModel != nil { + if assignMethod, ok := pickModel.Methods["action_assign"]; ok { + pickRS := env.Model("stock.picking").Browse(pickIDs...) + if _, err := assignMethod(pickRS); err != nil { + return nil, fmt.Errorf("stock.picking.batch: assign pickings for batch %d: %w", batchID, err) + } + } + } + } + } + return true, nil + }) + + // action_done: Validate all pickings in the batch, then set batch to done. + // Mirrors: stock.picking.batch.action_done() + m.RegisterMethod("action_done", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + for _, batchID := range rs.IDs() { + var state string + err := env.Tx().QueryRow(env.Ctx(), + `SELECT state FROM stock_picking_batch WHERE id = $1`, batchID, + ).Scan(&state) + if err != nil { + return nil, fmt.Errorf("stock.picking.batch: read state for %d: %w", batchID, err) + } + if state != "in_progress" { + return nil, fmt.Errorf("stock.picking.batch: can only validate in-progress batches (batch %d is %q)", batchID, state) + } + + // Validate all non-done pickings + pickRows, err := env.Tx().Query(env.Ctx(), + `SELECT id FROM stock_picking WHERE batch_id = $1 AND state != 'done'`, batchID) + if err != nil { + return nil, fmt.Errorf("stock.picking.batch: query pickings for done %d: %w", batchID, err) + } + var pickIDs []int64 + for pickRows.Next() { + var pid int64 + pickRows.Scan(&pid) + pickIDs = append(pickIDs, pid) + } + pickRows.Close() + + if len(pickIDs) > 0 { + pickModel := orm.Registry.Get("stock.picking") + if pickModel != nil { + if validateMethod, ok := pickModel.Methods["button_validate"]; ok { + pickRS := env.Model("stock.picking").Browse(pickIDs...) + if _, err := validateMethod(pickRS); err != nil { + return nil, fmt.Errorf("stock.picking.batch: validate pickings for batch %d: %w", batchID, err) + } + } + } + } + + _, err = env.Tx().Exec(env.Ctx(), + `UPDATE stock_picking_batch SET state = 'done' WHERE id = $1`, batchID) + if err != nil { + return nil, fmt.Errorf("stock.picking.batch: set done for %d: %w", batchID, err) + } + } + return true, nil + }) + + // action_cancel: Cancel the batch and all non-done pickings. + // Mirrors: stock.picking.batch.action_cancel() + m.RegisterMethod("action_cancel", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + for _, batchID := range rs.IDs() { + // Cancel non-done pickings + pickRows, err := env.Tx().Query(env.Ctx(), + `SELECT id FROM stock_picking WHERE batch_id = $1 AND state NOT IN ('done', 'cancel')`, batchID) + if err != nil { + return nil, fmt.Errorf("stock.picking.batch: query pickings for cancel %d: %w", batchID, err) + } + var pickIDs []int64 + for pickRows.Next() { + var pid int64 + pickRows.Scan(&pid) + pickIDs = append(pickIDs, pid) + } + pickRows.Close() + + if len(pickIDs) > 0 { + pickModel := orm.Registry.Get("stock.picking") + if pickModel != nil { + if cancelMethod, ok := pickModel.Methods["action_cancel"]; ok { + pickRS := env.Model("stock.picking").Browse(pickIDs...) + if _, err := cancelMethod(pickRS); err != nil { + return nil, fmt.Errorf("stock.picking.batch: cancel pickings for batch %d: %w", batchID, err) + } + } + } + } + + _, err = env.Tx().Exec(env.Ctx(), + `UPDATE stock_picking_batch SET state = 'cancel' WHERE id = $1`, batchID) + if err != nil { + return nil, fmt.Errorf("stock.picking.batch: cancel batch %d: %w", batchID, err) + } + } + return true, nil + }) + + // action_print: Generate a summary of the batch for printing. + // Mirrors: stock.picking.batch print report + m.RegisterMethod("action_print", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + batchID := rs.IDs()[0] + + rows, err := env.Tx().Query(env.Ctx(), + `SELECT sp.name as picking_name, sp.state as picking_state, + sm.product_id, pt.name as product_name, sm.product_uom_qty, + sl_src.name as source_location, sl_dst.name as dest_location + FROM stock_picking sp + JOIN stock_move sm ON sm.picking_id = sp.id + JOIN product_product pp ON pp.id = sm.product_id + JOIN product_template pt ON pt.id = pp.product_tmpl_id + JOIN stock_location sl_src ON sl_src.id = sm.location_id + JOIN stock_location sl_dst ON sl_dst.id = sm.location_dest_id + WHERE sp.batch_id = $1 + ORDER BY sp.name, pt.name`, + batchID, + ) + if err != nil { + return nil, fmt.Errorf("stock.picking.batch: query print data for %d: %w", batchID, err) + } + defer rows.Close() + + var lines []map[string]interface{} + for rows.Next() { + var pickingName, pickingState, prodName, srcLoc, dstLoc string + var prodID int64 + var qty float64 + if err := rows.Scan(&pickingName, &pickingState, &prodID, &prodName, &qty, &srcLoc, &dstLoc); err != nil { + return nil, fmt.Errorf("stock.picking.batch: scan print row: %w", err) + } + lines = append(lines, map[string]interface{}{ + "picking": pickingName, "state": pickingState, + "product_id": prodID, "product": prodName, "quantity": qty, + "source": srcLoc, "destination": dstLoc, + }) + } + + return map[string]interface{}{ + "batch_id": batchID, + "lines": lines, + }, nil + }) + + // add_pickings: Add pickings to an existing batch. + // Mirrors: stock.picking.batch add picking wizard + m.RegisterMethod("add_pickings", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + if len(args) < 1 { + return nil, fmt.Errorf("stock.picking.batch.add_pickings requires picking_ids") + } + pickingIDs, ok := args[0].([]int64) + if !ok || len(pickingIDs) == 0 { + return nil, fmt.Errorf("stock.picking.batch: invalid picking_ids") + } + + env := rs.Env() + batchID := rs.IDs()[0] + + for _, pid := range pickingIDs { + _, err := env.Tx().Exec(env.Ctx(), + `UPDATE stock_picking SET batch_id = $1 WHERE id = $2`, batchID, pid) + if err != nil { + return nil, fmt.Errorf("stock.picking.batch: add picking %d to batch %d: %w", pid, batchID, err) + } + } + + return true, nil + }) + + // remove_pickings: Remove pickings from the batch. + // Mirrors: stock.picking.batch remove picking + m.RegisterMethod("remove_pickings", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + if len(args) < 1 { + return nil, fmt.Errorf("stock.picking.batch.remove_pickings requires picking_ids") + } + pickingIDs, ok := args[0].([]int64) + if !ok || len(pickingIDs) == 0 { + return nil, fmt.Errorf("stock.picking.batch: invalid picking_ids") + } + + env := rs.Env() + batchID := rs.IDs()[0] + + for _, pid := range pickingIDs { + _, err := env.Tx().Exec(env.Ctx(), + `UPDATE stock_picking SET batch_id = NULL WHERE id = $1 AND batch_id = $2`, pid, batchID) + if err != nil { + return nil, fmt.Errorf("stock.picking.batch: remove picking %d from batch %d: %w", pid, batchID, err) + } + } + + return true, nil + }) +} + +// initStockPickingBatchExtension extends stock.picking with batch_id field. +// Mirrors: stock_picking_batch module's extension of stock.picking +func initStockPickingBatchExtension() { + p := orm.ExtendModel("stock.picking") + p.AddFields( + orm.Many2one("batch_id", "stock.picking.batch", orm.FieldOpts{String: "Batch Transfer"}), + ) +} diff --git a/addons/stock/models/stock_report.go b/addons/stock/models/stock_report.go new file mode 100644 index 0000000..10c7f07 --- /dev/null +++ b/addons/stock/models/stock_report.go @@ -0,0 +1,515 @@ +package models + +import ( + "fmt" + + "odoo-go/pkg/orm" +) + +// initStockReport registers stock.report — transient model for stock quantity reporting. +// Mirrors: odoo/addons/stock/report/stock_report_views.py +func initStockReport() { + m := orm.NewModel("stock.report", orm.ModelOpts{ + Description: "Stock Report", + Type: orm.ModelTransient, + }) + m.AddFields( + orm.Many2one("product_id", "product.product", orm.FieldOpts{String: "Product"}), + orm.Many2one("location_id", "stock.location", orm.FieldOpts{String: "Location"}), + orm.Date("date_from", orm.FieldOpts{String: "From"}), + orm.Date("date_to", orm.FieldOpts{String: "To"}), + ) + + // get_stock_data: Aggregate on-hand / reserved / available per product+location. + // Mirrors: stock.report logic from Odoo stock views. + m.RegisterMethod("get_stock_data", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + + query := ` + SELECT p.id, pt.name as product_name, l.name as location_name, + COALESCE(SUM(q.quantity), 0) as on_hand, + COALESCE(SUM(q.reserved_quantity), 0) as reserved, + COALESCE(SUM(q.quantity - q.reserved_quantity), 0) as available + FROM stock_quant q + JOIN product_product p ON p.id = q.product_id + JOIN product_template pt ON pt.id = p.product_tmpl_id + JOIN stock_location l ON l.id = q.location_id + WHERE l.usage = 'internal' + GROUP BY p.id, pt.name, l.name + ORDER BY pt.name, l.name` + + rows, err := env.Tx().Query(env.Ctx(), query) + if err != nil { + return nil, fmt.Errorf("stock.report: query stock data: %w", err) + } + defer rows.Close() + + var lines []map[string]interface{} + for rows.Next() { + var prodID int64 + var prodName, locName string + var onHand, reserved, available float64 + if err := rows.Scan(&prodID, &prodName, &locName, &onHand, &reserved, &available); err != nil { + return nil, fmt.Errorf("stock.report: scan row: %w", err) + } + lines = append(lines, map[string]interface{}{ + "product_id": prodID, "product": prodName, "location": locName, + "on_hand": onHand, "reserved": reserved, "available": available, + }) + } + return map[string]interface{}{"lines": lines}, nil + }) + + // get_stock_data_by_product: Aggregate stock for a specific product across all internal locations. + // Mirrors: stock.report filtered by product + m.RegisterMethod("get_stock_data_by_product", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + if len(args) < 1 { + return nil, fmt.Errorf("stock.report.get_stock_data_by_product requires product_id") + } + productID, _ := args[0].(int64) + if productID == 0 { + return nil, fmt.Errorf("stock.report: invalid product_id") + } + + env := rs.Env() + + rows, err := env.Tx().Query(env.Ctx(), + `SELECT l.id, l.complete_name, + COALESCE(SUM(q.quantity), 0) as on_hand, + COALESCE(SUM(q.reserved_quantity), 0) as reserved, + COALESCE(SUM(q.quantity - q.reserved_quantity), 0) as available + FROM stock_quant q + JOIN stock_location l ON l.id = q.location_id + WHERE q.product_id = $1 AND l.usage = 'internal' + GROUP BY l.id, l.complete_name + ORDER BY l.complete_name`, + productID, + ) + if err != nil { + return nil, fmt.Errorf("stock.report: query by product: %w", err) + } + defer rows.Close() + + var lines []map[string]interface{} + var totalOnHand, totalReserved, totalAvailable float64 + for rows.Next() { + var locID int64 + var locName string + var onHand, reserved, available float64 + if err := rows.Scan(&locID, &locName, &onHand, &reserved, &available); err != nil { + return nil, fmt.Errorf("stock.report: scan by product row: %w", err) + } + lines = append(lines, map[string]interface{}{ + "location_id": locID, "location": locName, + "on_hand": onHand, "reserved": reserved, "available": available, + }) + totalOnHand += onHand + totalReserved += reserved + totalAvailable += available + } + + return map[string]interface{}{ + "product_id": productID, + "lines": lines, + "total_on_hand": totalOnHand, + "total_reserved": totalReserved, + "total_available": totalAvailable, + }, nil + }) + + // get_stock_data_by_location: Aggregate stock for a specific location across all products. + // Mirrors: stock.report filtered by location + m.RegisterMethod("get_stock_data_by_location", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + if len(args) < 1 { + return nil, fmt.Errorf("stock.report.get_stock_data_by_location requires location_id") + } + locationID, _ := args[0].(int64) + if locationID == 0 { + return nil, fmt.Errorf("stock.report: invalid location_id") + } + + env := rs.Env() + + rows, err := env.Tx().Query(env.Ctx(), + `SELECT p.id, pt.name as product_name, + COALESCE(SUM(q.quantity), 0) as on_hand, + COALESCE(SUM(q.reserved_quantity), 0) as reserved, + COALESCE(SUM(q.quantity - q.reserved_quantity), 0) as available + FROM stock_quant q + JOIN product_product p ON p.id = q.product_id + JOIN product_template pt ON pt.id = p.product_tmpl_id + WHERE q.location_id = $1 + GROUP BY p.id, pt.name + ORDER BY pt.name`, + locationID, + ) + if err != nil { + return nil, fmt.Errorf("stock.report: query by location: %w", err) + } + defer rows.Close() + + var lines []map[string]interface{} + var totalOnHand, totalReserved, totalAvailable float64 + for rows.Next() { + var prodID int64 + var prodName string + var onHand, reserved, available float64 + if err := rows.Scan(&prodID, &prodName, &onHand, &reserved, &available); err != nil { + return nil, fmt.Errorf("stock.report: scan by location row: %w", err) + } + lines = append(lines, map[string]interface{}{ + "product_id": prodID, "product": prodName, + "on_hand": onHand, "reserved": reserved, "available": available, + }) + totalOnHand += onHand + totalReserved += reserved + totalAvailable += available + } + + return map[string]interface{}{ + "location_id": locationID, + "lines": lines, + "total_on_hand": totalOnHand, + "total_reserved": totalReserved, + "total_available": totalAvailable, + }, nil + }) + + // get_move_history: Return stock move history with filters. + // Mirrors: stock.move.line reporting / traceability + m.RegisterMethod("get_move_history", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + + query := ` + SELECT sm.id, sm.name, sm.product_id, pt.name as product_name, + sm.product_uom_qty, sm.state, + sl_src.name as source_location, sl_dst.name as dest_location, + sm.date, sm.origin + FROM stock_move sm + JOIN product_product pp ON pp.id = sm.product_id + JOIN product_template pt ON pt.id = pp.product_tmpl_id + JOIN stock_location sl_src ON sl_src.id = sm.location_id + JOIN stock_location sl_dst ON sl_dst.id = sm.location_dest_id + WHERE sm.state = 'done' + ORDER BY sm.date DESC + LIMIT 100` + + rows, err := env.Tx().Query(env.Ctx(), query) + if err != nil { + return nil, fmt.Errorf("stock.report: query move history: %w", err) + } + defer rows.Close() + + var moves []map[string]interface{} + for rows.Next() { + var moveID, productID int64 + var name, productName, state, srcLoc, dstLoc string + var qty float64 + var date, origin *string + if err := rows.Scan(&moveID, &name, &productID, &productName, &qty, &state, &srcLoc, &dstLoc, &date, &origin); err != nil { + return nil, fmt.Errorf("stock.report: scan move history row: %w", err) + } + dateStr := "" + if date != nil { + dateStr = *date + } + originStr := "" + if origin != nil { + originStr = *origin + } + moves = append(moves, map[string]interface{}{ + "id": moveID, "name": name, "product_id": productID, "product": productName, + "quantity": qty, "state": state, "source_location": srcLoc, + "dest_location": dstLoc, "date": dateStr, "origin": originStr, + }) + } + + return map[string]interface{}{"moves": moves}, nil + }) + + // get_inventory_valuation: Return total inventory valuation by product. + // Mirrors: stock report valuation views + m.RegisterMethod("get_inventory_valuation", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + + rows, err := env.Tx().Query(env.Ctx(), + `SELECT p.id, pt.name as product_name, + COALESCE(SUM(q.quantity), 0) as total_qty, + COALESCE(SUM(q.value), 0) as total_value + FROM stock_quant q + JOIN product_product p ON p.id = q.product_id + JOIN product_template pt ON pt.id = p.product_tmpl_id + JOIN stock_location l ON l.id = q.location_id + WHERE l.usage = 'internal' + GROUP BY p.id, pt.name + HAVING SUM(q.quantity) > 0 + ORDER BY pt.name`, + ) + if err != nil { + return nil, fmt.Errorf("stock.report: query valuation: %w", err) + } + defer rows.Close() + + var lines []map[string]interface{} + var grandTotalQty, grandTotalValue float64 + for rows.Next() { + var prodID int64 + var prodName string + var totalQty, totalValue float64 + if err := rows.Scan(&prodID, &prodName, &totalQty, &totalValue); err != nil { + return nil, fmt.Errorf("stock.report: scan valuation row: %w", err) + } + avgCost := float64(0) + if totalQty > 0 { + avgCost = totalValue / totalQty + } + lines = append(lines, map[string]interface{}{ + "product_id": prodID, "product": prodName, + "quantity": totalQty, "value": totalValue, "average_cost": avgCost, + }) + grandTotalQty += totalQty + grandTotalValue += totalValue + } + + return map[string]interface{}{ + "lines": lines, + "total_qty": grandTotalQty, + "total_value": grandTotalValue, + }, nil + }) +} + +// initStockForecast registers stock.forecasted.product — transient model for forecast computation. +// Mirrors: odoo/addons/stock/models/stock_forecasted.py +func initStockForecast() { + m := orm.NewModel("stock.forecasted.product", orm.ModelOpts{ + Description: "Forecasted Stock", + Type: orm.ModelTransient, + }) + m.AddFields( + orm.Many2one("product_id", "product.product", orm.FieldOpts{String: "Product"}), + ) + + // get_forecast: Compute on-hand, incoming, outgoing and forecast for a product. + // Mirrors: stock.forecasted.product_product._get_report_data() + m.RegisterMethod("get_forecast", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + productID := int64(0) + if len(args) > 0 { + if p, ok := args[0].(float64); ok { + productID = int64(p) + } + } + + // On hand + var onHand float64 + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(SUM(quantity - reserved_quantity), 0) FROM stock_quant + WHERE product_id = $1 AND location_id IN (SELECT id FROM stock_location WHERE usage = 'internal')`, + productID).Scan(&onHand) + + // Incoming (confirmed moves TO internal locations) + var incoming float64 + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(SUM(product_uom_qty), 0) FROM stock_move + WHERE product_id = $1 AND state IN ('confirmed','assigned','waiting') + AND location_dest_id IN (SELECT id FROM stock_location WHERE usage = 'internal')`, + productID).Scan(&incoming) + + // Outgoing (confirmed moves FROM internal locations) + var outgoing float64 + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(SUM(product_uom_qty), 0) FROM stock_move + WHERE product_id = $1 AND state IN ('confirmed','assigned','waiting') + AND location_id IN (SELECT id FROM stock_location WHERE usage = 'internal')`, + productID).Scan(&outgoing) + + return map[string]interface{}{ + "on_hand": onHand, "incoming": incoming, "outgoing": outgoing, + "forecast": onHand + incoming - outgoing, + }, nil + }) + + // get_forecast_details: Detailed forecast with move-level breakdown. + // Mirrors: stock.forecasted.product_product._get_report_lines() + m.RegisterMethod("get_forecast_details", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + productID := int64(0) + if len(args) > 0 { + if p, ok := args[0].(float64); ok { + productID = int64(p) + } + } + + if productID == 0 { + return nil, fmt.Errorf("stock.forecasted.product: product_id required") + } + + // On hand + var onHand float64 + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(SUM(quantity - reserved_quantity), 0) FROM stock_quant + WHERE product_id = $1 AND location_id IN (SELECT id FROM stock_location WHERE usage = 'internal')`, + productID).Scan(&onHand) + + // Incoming moves + inRows, err := env.Tx().Query(env.Ctx(), + `SELECT sm.id, sm.name, sm.product_uom_qty, sm.date, sm.state, + sl.name as source_location, sld.name as dest_location, + sp.name as picking_name + FROM stock_move sm + JOIN stock_location sl ON sl.id = sm.location_id + JOIN stock_location sld ON sld.id = sm.location_dest_id + LEFT JOIN stock_picking sp ON sp.id = sm.picking_id + WHERE sm.product_id = $1 AND sm.state IN ('confirmed','assigned','waiting') + AND sm.location_dest_id IN (SELECT id FROM stock_location WHERE usage = 'internal') + ORDER BY sm.date`, + productID, + ) + if err != nil { + return nil, fmt.Errorf("stock.forecasted: query incoming moves: %w", err) + } + defer inRows.Close() + + var incomingMoves []map[string]interface{} + var totalIncoming float64 + for inRows.Next() { + var moveID int64 + var name, state, srcLoc, dstLoc string + var qty float64 + var date, pickingName *string + if err := inRows.Scan(&moveID, &name, &qty, &date, &state, &srcLoc, &dstLoc, &pickingName); err != nil { + return nil, fmt.Errorf("stock.forecasted: scan incoming move: %w", err) + } + dateStr := "" + if date != nil { + dateStr = *date + } + pickStr := "" + if pickingName != nil { + pickStr = *pickingName + } + incomingMoves = append(incomingMoves, map[string]interface{}{ + "id": moveID, "name": name, "quantity": qty, "date": dateStr, + "state": state, "source": srcLoc, "destination": dstLoc, "picking": pickStr, + }) + totalIncoming += qty + } + + // Outgoing moves + outRows, err := env.Tx().Query(env.Ctx(), + `SELECT sm.id, sm.name, sm.product_uom_qty, sm.date, sm.state, + sl.name as source_location, sld.name as dest_location, + sp.name as picking_name + FROM stock_move sm + JOIN stock_location sl ON sl.id = sm.location_id + JOIN stock_location sld ON sld.id = sm.location_dest_id + LEFT JOIN stock_picking sp ON sp.id = sm.picking_id + WHERE sm.product_id = $1 AND sm.state IN ('confirmed','assigned','waiting') + AND sm.location_id IN (SELECT id FROM stock_location WHERE usage = 'internal') + ORDER BY sm.date`, + productID, + ) + if err != nil { + return nil, fmt.Errorf("stock.forecasted: query outgoing moves: %w", err) + } + defer outRows.Close() + + var outgoingMoves []map[string]interface{} + var totalOutgoing float64 + for outRows.Next() { + var moveID int64 + var name, state, srcLoc, dstLoc string + var qty float64 + var date, pickingName *string + if err := outRows.Scan(&moveID, &name, &qty, &date, &state, &srcLoc, &dstLoc, &pickingName); err != nil { + return nil, fmt.Errorf("stock.forecasted: scan outgoing move: %w", err) + } + dateStr := "" + if date != nil { + dateStr = *date + } + pickStr := "" + if pickingName != nil { + pickStr = *pickingName + } + outgoingMoves = append(outgoingMoves, map[string]interface{}{ + "id": moveID, "name": name, "quantity": qty, "date": dateStr, + "state": state, "source": srcLoc, "destination": dstLoc, "picking": pickStr, + }) + totalOutgoing += qty + } + + forecast := onHand + totalIncoming - totalOutgoing + + return map[string]interface{}{ + "product_id": productID, + "on_hand": onHand, + "incoming": totalIncoming, + "outgoing": totalOutgoing, + "forecast": forecast, + "incoming_moves": incomingMoves, + "outgoing_moves": outgoingMoves, + }, nil + }) + + // get_forecast_all: Compute forecast for all products with stock or pending moves. + // Mirrors: stock.forecasted overview + m.RegisterMethod("get_forecast_all", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + + rows, err := env.Tx().Query(env.Ctx(), + `SELECT p.id, pt.name as product_name, + COALESCE(oh.on_hand, 0) as on_hand, + COALESCE(inc.incoming, 0) as incoming, + COALESCE(outg.outgoing, 0) as outgoing + FROM product_product p + JOIN product_template pt ON pt.id = p.product_tmpl_id + LEFT JOIN ( + SELECT product_id, SUM(quantity - reserved_quantity) as on_hand + FROM stock_quant + WHERE location_id IN (SELECT id FROM stock_location WHERE usage = 'internal') + GROUP BY product_id + ) oh ON oh.product_id = p.id + LEFT JOIN ( + SELECT product_id, SUM(product_uom_qty) as incoming + FROM stock_move + WHERE state IN ('confirmed','assigned','waiting') + AND location_dest_id IN (SELECT id FROM stock_location WHERE usage = 'internal') + GROUP BY product_id + ) inc ON inc.product_id = p.id + LEFT JOIN ( + SELECT product_id, SUM(product_uom_qty) as outgoing + FROM stock_move + WHERE state IN ('confirmed','assigned','waiting') + AND location_id IN (SELECT id FROM stock_location WHERE usage = 'internal') + GROUP BY product_id + ) outg ON outg.product_id = p.id + WHERE COALESCE(oh.on_hand, 0) != 0 + OR COALESCE(inc.incoming, 0) != 0 + OR COALESCE(outg.outgoing, 0) != 0 + ORDER BY pt.name`, + ) + if err != nil { + return nil, fmt.Errorf("stock.forecasted: query all forecasts: %w", err) + } + defer rows.Close() + + var products []map[string]interface{} + for rows.Next() { + var prodID int64 + var prodName string + var onHand, incoming, outgoing float64 + if err := rows.Scan(&prodID, &prodName, &onHand, &incoming, &outgoing); err != nil { + return nil, fmt.Errorf("stock.forecasted: scan forecast row: %w", err) + } + products = append(products, map[string]interface{}{ + "product_id": prodID, "product": prodName, + "on_hand": onHand, "incoming": incoming, "outgoing": outgoing, + "forecast": onHand + incoming - outgoing, + }) + } + + return map[string]interface{}{"products": products}, nil + }) +} diff --git a/addons/stock/models/stock_valuation.go b/addons/stock/models/stock_valuation.go new file mode 100644 index 0000000..2459b67 --- /dev/null +++ b/addons/stock/models/stock_valuation.go @@ -0,0 +1,213 @@ +package models + +import ( + "fmt" + + "odoo-go/pkg/orm" +) + +// initStockValuationLayer registers stock.valuation.layer — tracks inventory valuation per move. +// Mirrors: odoo/addons/stock_account/models/stock_valuation_layer.py +func initStockValuationLayer() { + m := orm.NewModel("stock.valuation.layer", orm.ModelOpts{ + Description: "Stock Valuation Layer", + Order: "create_date, id", + }) + m.AddFields( + orm.Many2one("product_id", "product.product", orm.FieldOpts{String: "Product", Required: true, Index: true}), + orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company", Required: true}), + orm.Many2one("stock_move_id", "stock.move", orm.FieldOpts{String: "Stock Move"}), + orm.Float("quantity", orm.FieldOpts{String: "Quantity"}), + orm.Monetary("unit_cost", orm.FieldOpts{String: "Unit Value", CurrencyField: "currency_id"}), + orm.Monetary("value", orm.FieldOpts{String: "Total Value", CurrencyField: "currency_id"}), + orm.Monetary("remaining_value", orm.FieldOpts{String: "Remaining Value", CurrencyField: "currency_id"}), + orm.Float("remaining_qty", orm.FieldOpts{String: "Remaining Qty"}), + orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency"}), + orm.Char("description", orm.FieldOpts{String: "Description"}), + orm.Many2one("account_move_id", "account.move", orm.FieldOpts{String: "Journal Entry"}), + ) + + // create_valuation_layer: Creates a valuation layer for a stock move. + // Mirrors: stock.valuation.layer.create() via product._run_fifo() + m.RegisterMethod("create_valuation_layer", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + if len(args) < 4 { + return nil, fmt.Errorf("stock.valuation.layer.create_valuation_layer requires product_id, move_id, quantity, unit_cost") + } + productID, _ := args[0].(int64) + moveID, _ := args[1].(int64) + quantity, _ := args[2].(float64) + unitCost, _ := args[3].(float64) + + if productID == 0 || quantity == 0 { + return nil, fmt.Errorf("stock.valuation.layer: invalid product_id or quantity") + } + + env := rs.Env() + totalValue := quantity * unitCost + + var layerID int64 + err := env.Tx().QueryRow(env.Ctx(), + `INSERT INTO stock_valuation_layer + (product_id, stock_move_id, quantity, unit_cost, value, remaining_qty, remaining_value, company_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, 1) + RETURNING id`, + productID, moveID, quantity, unitCost, totalValue, quantity, totalValue, + ).Scan(&layerID) + if err != nil { + return nil, fmt.Errorf("stock.valuation.layer: create layer: %w", err) + } + + return map[string]interface{}{ + "id": layerID, "value": totalValue, + }, nil + }) + + // get_product_valuation: Compute total valuation for a product across all remaining layers. + // Mirrors: product.product._compute_stock_value() + m.RegisterMethod("get_product_valuation", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + if len(args) < 1 { + return nil, fmt.Errorf("stock.valuation.layer.get_product_valuation requires product_id") + } + productID, _ := args[0].(int64) + if productID == 0 { + return nil, fmt.Errorf("stock.valuation.layer: invalid product_id") + } + + env := rs.Env() + + var totalQty, totalValue float64 + err := env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(SUM(remaining_qty), 0), COALESCE(SUM(remaining_value), 0) + FROM stock_valuation_layer + WHERE product_id = $1 AND remaining_qty > 0`, + productID, + ).Scan(&totalQty, &totalValue) + if err != nil { + return nil, fmt.Errorf("stock.valuation.layer: get valuation: %w", err) + } + + var avgCost float64 + if totalQty > 0 { + avgCost = totalValue / totalQty + } + + return map[string]interface{}{ + "product_id": productID, + "total_qty": totalQty, + "total_value": totalValue, + "average_cost": avgCost, + }, nil + }) + + // consume_fifo: Consume quantity from existing layers using FIFO order. + // Mirrors: product.product._run_fifo() for outgoing moves + m.RegisterMethod("consume_fifo", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + if len(args) < 2 { + return nil, fmt.Errorf("stock.valuation.layer.consume_fifo requires product_id, quantity") + } + productID, _ := args[0].(int64) + qtyToConsume, _ := args[1].(float64) + + if productID == 0 || qtyToConsume <= 0 { + return nil, fmt.Errorf("stock.valuation.layer: invalid product_id or quantity") + } + + env := rs.Env() + + // Get layers ordered by creation (FIFO) + rows, err := env.Tx().Query(env.Ctx(), + `SELECT id, remaining_qty, remaining_value, unit_cost + FROM stock_valuation_layer + WHERE product_id = $1 AND remaining_qty > 0 + ORDER BY create_date, id`, + productID, + ) + if err != nil { + return nil, fmt.Errorf("stock.valuation.layer: query layers for FIFO: %w", err) + } + defer rows.Close() + + var totalConsumedValue float64 + remaining := qtyToConsume + + for rows.Next() && remaining > 0 { + var layerID int64 + var layerQty, layerValue, layerUnitCost float64 + if err := rows.Scan(&layerID, &layerQty, &layerValue, &layerUnitCost); err != nil { + return nil, fmt.Errorf("stock.valuation.layer: scan FIFO layer: %w", err) + } + + consumed := remaining + if consumed > layerQty { + consumed = layerQty + } + + consumedValue := consumed * layerUnitCost + newRemainingQty := layerQty - consumed + newRemainingValue := layerValue - consumedValue + + _, err := env.Tx().Exec(env.Ctx(), + `UPDATE stock_valuation_layer SET remaining_qty = $1, remaining_value = $2 WHERE id = $3`, + newRemainingQty, newRemainingValue, layerID, + ) + if err != nil { + return nil, fmt.Errorf("stock.valuation.layer: update layer %d: %w", layerID, err) + } + + totalConsumedValue += consumedValue + remaining -= consumed + } + + return map[string]interface{}{ + "consumed_qty": qtyToConsume - remaining, + "consumed_value": totalConsumedValue, + "remaining": remaining, + }, nil + }) + + // get_valuation_history: Return valuation layers for a product within a date range. + // Mirrors: stock.valuation.layer reporting views + m.RegisterMethod("get_valuation_history", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + if len(args) < 1 { + return nil, fmt.Errorf("stock.valuation.layer.get_valuation_history requires product_id") + } + productID, _ := args[0].(int64) + if productID == 0 { + return nil, fmt.Errorf("stock.valuation.layer: invalid product_id") + } + + env := rs.Env() + + rows, err := env.Tx().Query(env.Ctx(), + `SELECT id, quantity, unit_cost, value, remaining_qty, remaining_value, description + FROM stock_valuation_layer + WHERE product_id = $1 + ORDER BY create_date DESC, id DESC`, + productID, + ) + if err != nil { + return nil, fmt.Errorf("stock.valuation.layer: query history: %w", err) + } + defer rows.Close() + + var layers []map[string]interface{} + for rows.Next() { + var id int64 + var qty, unitCost, value, remQty, remValue float64 + var description *string + if err := rows.Scan(&id, &qty, &unitCost, &value, &remQty, &remValue, &description); err != nil { + return nil, fmt.Errorf("stock.valuation.layer: scan history row: %w", err) + } + desc := "" + if description != nil { + desc = *description + } + layers = append(layers, map[string]interface{}{ + "id": id, "quantity": qty, "unit_cost": unitCost, "value": value, + "remaining_qty": remQty, "remaining_value": remValue, "description": desc, + }) + } + + return map[string]interface{}{"layers": layers}, nil + }) +}