diff --git a/controller.go b/controller.go index e7c581f0..4cbcaa20 100644 --- a/controller.go +++ b/controller.go @@ -104,6 +104,7 @@ func ControllerCommand() *cobra.Command { billingEntityEmailSubject := cmd.Flags().String("billingentity-email-subject", "An APPUiO Billing Entity has been updated", "Subject for billing entity modification update mails") billingEntityCronInterval := cmd.Flags().String("billingentity-email-cron-interval", "@every 1h", "Cron interval for how frequently billing entity update e-mails are sent") + saleOrderCompatMode := cmd.Flags().Bool("sale-order-compatibility-mode", false, "Whether to enable compatibility mode for Sales Orders. If enabled, odoo8 billing entity IDs are used to create sales orders in odoo16.") saleOrderStorage := cmd.Flags().String("sale-order-storage", "none", "Type of sale order storage to use. Valid values are `none` and `odoo16`") saleOrderClientReference := cmd.Flags().String("sale-order-client-reference", "APPUiO Cloud", "Default client reference to add to newly created sales orders.") saleOrderInternalNote := cmd.Flags().String("sale-order-internal-note", "auto-generated by APPUiO Cloud Control API", "Default internal note to add to newly created sales orders.") @@ -195,6 +196,7 @@ func ControllerCommand() *cobra.Command { *saleOrderStorage, *saleOrderClientReference, *saleOrderInternalNote, + *saleOrderCompatMode, oc, ctrl.Options{ Scheme: scheme, @@ -245,6 +247,7 @@ func setupManager( saleOrderStorage string, saleOrderClientReference string, saleOrderInternalNote string, + saleOrderCompatMode bool, odooCredentials saleorder.Odoo16Credentials, opt ctrl.Options, ) (ctrl.Manager, error) { @@ -346,6 +349,7 @@ func setupManager( storage, err := saleorder.NewOdoo16Storage(&odooCredentials, &saleorder.Odoo16Options{ SaleOrderClientReferencePrefix: saleOrderClientReference, SaleOrderInternalNote: saleOrderInternalNote, + Odoo8CompatibilityMode: saleOrderCompatMode, }) if err != nil { return nil, err diff --git a/controllers/saleorder/mock_saleorder/mock.go b/controllers/saleorder/mock_saleorder/mock.go index 0474b282..51365a94 100644 --- a/controllers/saleorder/mock_saleorder/mock.go +++ b/controllers/saleorder/mock_saleorder/mock.go @@ -107,6 +107,21 @@ func (mr *MockOdoo16ClientMockRecorder) CreateSaleOrder(arg0 any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateSaleOrder", reflect.TypeOf((*MockOdoo16Client)(nil).CreateSaleOrder), arg0) } +// FindResPartners mocks base method. +func (m *MockOdoo16Client) FindResPartners(arg0 *odoo.Criteria, arg1 *odoo.Options) (*odoo.ResPartners, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FindResPartners", arg0, arg1) + ret0, _ := ret[0].(*odoo.ResPartners) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FindResPartners indicates an expected call of FindResPartners. +func (mr *MockOdoo16ClientMockRecorder) FindResPartners(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindResPartners", reflect.TypeOf((*MockOdoo16Client)(nil).FindResPartners), arg0, arg1) +} + // Read mocks base method. func (m *MockOdoo16Client) Read(arg0 string, arg1 []int64, arg2 *odoo.Options, arg3 any) error { m.ctrl.T.Helper() diff --git a/controllers/saleorder/saleorder_storage.go b/controllers/saleorder/saleorder_storage.go index 0fe179cc..bba1f17b 100644 --- a/controllers/saleorder/saleorder_storage.go +++ b/controllers/saleorder/saleorder_storage.go @@ -14,6 +14,7 @@ type Odoo16Credentials = odooclient.ClientConfig type Odoo16Options struct { SaleOrderClientReferencePrefix string SaleOrderInternalNote string + Odoo8CompatibilityMode bool } const defaultSaleOrderState = "sale" @@ -26,6 +27,7 @@ type SaleOrderStorage interface { type Odoo16Client interface { Read(string, []int64, *odooclient.Options, interface{}) error CreateSaleOrder(*odooclient.SaleOrder) (int64, error) + FindResPartners(*odooclient.Criteria, *odooclient.Options) (*odooclient.ResPartners, error) } type Odoo16SaleOrderStorage struct { @@ -54,21 +56,42 @@ func (s *Odoo16SaleOrderStorage) CreateSaleOrder(org organizationv1.Organization return "", err } + var beRecord odooclient.ResPartner + fetchPartnerFieldOpts := odooclient.NewOptions().FetchFields( "id", "parent_id", ) - beRecords := []odooclient.ResPartner{} - err = s.client.Read(odooclient.ResPartnerModel, []int64{int64(beID)}, fetchPartnerFieldOpts, &beRecords) - if err != nil { - return "", fmt.Errorf("fetching accounting contact by ID: %w", err) - } + if s.options.Odoo8CompatibilityMode { + odoo8ID := fmt.Sprintf("__export__.res_partner_%d", beID) + + idMatchCriteria := odooclient.NewCriteria().Add("x_odoo_8_ID", "=", odoo8ID) + + r, err := s.client.FindResPartners(idMatchCriteria, fetchPartnerFieldOpts) + if err != nil { + return "", fmt.Errorf("fetching accounting contact by ID: %w", err) + } + + if len(*r) <= 0 { + return "", fmt.Errorf("no results when fetching accounting contact by ID") + } + resPartners := *r + + beRecord = resPartners[0] + } else { + beRecords := []odooclient.ResPartner{} + err = s.client.Read(odooclient.ResPartnerModel, []int64{int64(beID)}, fetchPartnerFieldOpts, &beRecords) + if err != nil { + return "", fmt.Errorf("fetching accounting contact by ID: %w", err) + } + + if len(beRecords) <= 0 { + return "", fmt.Errorf("no results when fetching accounting contact by ID") + } - if len(beRecords) <= 0 { - return "", fmt.Errorf("no results when fetching accounting contact by ID") + beRecord = beRecords[0] } - beRecord := beRecords[0] if beRecord.ParentId == nil { return "", fmt.Errorf("accounting contact %d has no parent", beRecord.Id.Get()) diff --git a/controllers/saleorder/saleorder_storage_test.go b/controllers/saleorder/saleorder_storage_test.go index d1b5390c..24bab326 100644 --- a/controllers/saleorder/saleorder_storage_test.go +++ b/controllers/saleorder/saleorder_storage_test.go @@ -16,6 +16,37 @@ import ( odooclient "github.com/appuio/go-odoo" ) +func TestCreateCompat(t *testing.T) { + ctrl, mock, subject := createStorageCompat(t) + defer ctrl.Finish() + + tn := time.Now() + st, _ := time.Parse(time.RFC3339, "2023-04-18T14:07:55Z") + statusTime := st.Local() + + gomock.InOrder( + mock.EXPECT().FindResPartners(gomock.Any(), gomock.Any()).Return(&odooclient.ResPartners{{ + Id: odooclient.NewInt(456), + CreateDate: odooclient.NewTime(tn), + ParentId: odooclient.NewMany2One(123, ""), + Email: odooclient.NewString("accounting@test.com, notifications@test.com"), + VshnControlApiMetaStatus: odooclient.NewString("{\"conditions\":[{\"type\":\"ConditionFoo\",\"status\":\"False\",\"lastTransitionTime\":\"" + statusTime.Format(time.RFC3339) + "\",\"reason\":\"Whatever\",\"message\":\"Hello World\"}]}"), + }}, nil), + mock.EXPECT().CreateSaleOrder(gomock.Any()).Return(int64(149), nil), + ) + + soid, err := subject.CreateSaleOrder(organizationv1.Organization{ + ObjectMeta: metav1.ObjectMeta{ + Name: "myorg", + }, + Spec: organizationv1.OrganizationSpec{ + BillingEntityRef: "be-123", + }, + }) + require.NoError(t, err) + assert.Equal(t, "149", soid) +} + func TestCreate(t *testing.T) { ctrl, mock, subject := createStorage(t) defer ctrl.Finish() @@ -48,7 +79,7 @@ func TestCreate(t *testing.T) { } func TestGet(t *testing.T) { - ctrl, mock, subject := createStorage(t) + ctrl, mock, subject := createStorageCompat(t) defer ctrl.Finish() gomock.InOrder( @@ -73,6 +104,43 @@ func TestGet(t *testing.T) { assert.Equal(t, "SO149", soid) } +func TestCreateAttributesCompat(t *testing.T) { + ctrl, mock, subject := createStorageCompat(t) + defer ctrl.Finish() + + tn := time.Now() + st, _ := time.Parse(time.RFC3339, "2023-04-18T14:07:55Z") + statusTime := st.Local() + + gomock.InOrder( + mock.EXPECT().FindResPartners(gomock.Any(), gomock.Any()).Return(&odooclient.ResPartners{{ + Id: odooclient.NewInt(456), + CreateDate: odooclient.NewTime(tn), + ParentId: odooclient.NewMany2One(123, ""), + Email: odooclient.NewString("accounting@test.com, notifications@test.com"), + VshnControlApiMetaStatus: odooclient.NewString("{\"conditions\":[{\"type\":\"ConditionFoo\",\"status\":\"False\",\"lastTransitionTime\":\"" + statusTime.Format(time.RFC3339) + "\",\"reason\":\"Whatever\",\"message\":\"Hello World\"}]}"), + }}, nil), + mock.EXPECT().CreateSaleOrder(SaleOrderMatcher{ + PartnerId: int64(123), + PartnerInvoiceId: int64(456), + State: "sale", + ClientOrderRef: "client-ref (myorg)", + InternalNote: "internal-note", + }).Return(int64(149), nil), + ) + + soid, err := subject.CreateSaleOrder(organizationv1.Organization{ + ObjectMeta: metav1.ObjectMeta{ + Name: "myorg", + }, + Spec: organizationv1.OrganizationSpec{ + BillingEntityRef: "be-123", + }, + }) + require.NoError(t, err) + assert.Equal(t, "149", soid) +} + func TestCreateAttributes(t *testing.T) { ctrl, mock, subject := createStorage(t) defer ctrl.Finish() @@ -126,6 +194,20 @@ func (s SaleOrderMatcher) String() string { return fmt.Sprintf("{PartnerId:%d PartnerInvoiceId:%d State:%s ClientOrderRef:%s InternalNote:%s}", s.PartnerId, s.PartnerInvoiceId, s.State, s.ClientOrderRef, s.InternalNote) } +func createStorageCompat(t *testing.T) (*gomock.Controller, *mock_saleorder.MockOdoo16Client, saleorder.SaleOrderStorage) { + ctrl := gomock.NewController(t) + mock := mock_saleorder.NewMockOdoo16Client(ctrl) + + return ctrl, mock, saleorder.NewOdoo16StorageFromClient( + mock, + &saleorder.Odoo16Options{ + SaleOrderClientReferencePrefix: "client-ref", + SaleOrderInternalNote: "internal-note", + Odoo8CompatibilityMode: true, + }, + ) +} + func createStorage(t *testing.T) (*gomock.Controller, *mock_saleorder.MockOdoo16Client, saleorder.SaleOrderStorage) { ctrl := gomock.NewController(t) mock := mock_saleorder.NewMockOdoo16Client(ctrl) @@ -135,6 +217,7 @@ func createStorage(t *testing.T) (*gomock.Controller, *mock_saleorder.MockOdoo16 &saleorder.Odoo16Options{ SaleOrderClientReferencePrefix: "client-ref", SaleOrderInternalNote: "internal-note", + Odoo8CompatibilityMode: false, }, ) }