diff --git a/debian/control b/debian/control index 3968f89..60b7b91 100644 --- a/debian/control +++ b/debian/control @@ -11,6 +11,7 @@ Build-Depends: golang-github-julienschmidt-httprouter-dev, golang-github-leonelquinteros-gotext-dev, golang-golang-x-text-dev, + golang-github-tealeg-xlsx-dev, postgresql-all (>= 217~), sqitch, pgtap, diff --git a/deploy/import_contact.sql b/deploy/import_contact.sql new file mode 100644 index 0000000..a5374a3 --- /dev/null +++ b/deploy/import_contact.sql @@ -0,0 +1,193 @@ +-- Deploy numerus:import_contact to pg +-- requires: schema_numerus +-- requires: roles +-- requires: contact +-- requires: contact_web +-- requires: contact_phone +-- requires: contact_email +-- requires: contact_iban +-- requires: contact_swift +-- requires: contact_tax_details + +begin; + +set search_path to numerus, public; + +create or replace function begin_import_contacts() returns name as +$$ +create temporary table imported_contact ( + contact_id integer + , name text not null default '' + , vatin text not null default '' + , email text not null default '' + , phone text not null default '' + , web text not null default '' + , address text not null default '' + , city text not null default '' + , province text not null default '' + , postal_code text not null default '' + , country_code text not null default '' + , iban text not null default '' + , bic text not null default '' + , tags text not null default '' +); +select 'imported_contact'::name; +$$ +language sql; + +revoke execute on function begin_import_contacts() from public; +grant execute on function begin_import_contacts() to invoicer; +grant execute on function begin_import_contacts() to admin; + +create or replace function end_import_contacts(company_id integer) returns integer as +$$ +declare + imported integer; +begin + update imported_contact + set country_code = upper(trim(country_code)) + , name = trim(name) + , vatin = trim(vatin) + , email = trim(email) + , phone = trim(phone) + , web = trim(web) + , address = trim(address) + , city = trim(city) + , province = trim(province) + , postal_code = trim(postal_code) + , iban = trim(iban) + , bic = trim(bic) + , tags = lower(trim(regexp_replace(regexp_replace(tags, '[^\sA-Za-z0-9-]', '', 'g'), '\s\s+', ' ', 'g'))) + ; + + update imported_contact + set contact_id = tax_details.contact_id + from contact_tax_details as tax_details + join contact using (contact_id) + where contact.company_id = end_import_contacts.company_id + and tax_details.vatin::text = imported_contact.country_code || imported_contact.vatin + ; + + update imported_contact + set contact_id = nextval('contact_contact_id_seq'::regclass) + where length(trim(name)) > 1 + and contact_id is null + ; + + insert into contact (contact_id, company_id, name, tags) + select contact_id, end_import_contacts.company_id, name, string_to_array(tags, ' ')::tag_name[] + from imported_contact + where contact_id is not null + on conflict (contact_id) do update + set tags = array_cat(contact.tags, excluded.tags) + ; + + -- TODO: use pg_input_is_valid with PostgreSQL 16 + create or replace function pg_temp.input_is_valid(input text, typename text) returns bool as + $func$ + begin + begin + execute format('select %L::%s', input, typename); + return true; + exception when others then + return false; + end; + end; + $func$ + language plpgsql + immutable; + + insert into contact_tax_details (contact_id, business_name, vatin, address, city, province, postal_code, country_code) + select contact_id, imported_contact.name, (country_code || vatin)::vatin, address, city, province, postal_code, country_code + from imported_contact + join country using (country_code) + where contact_id is not null + and length(address) > 1 + and length(city) > 1 + and length(province) > 1 + and postal_code ~ postal_code_regex + and pg_temp.input_is_valid(country_code || vatin, 'vatin') + on conflict (contact_id) do update + set business_name = excluded.business_name + , vatin = excluded.vatin + , address = excluded.address + , city = excluded.city + , province = excluded.province + , postal_code = excluded.postal_code + , country_code = excluded.country_code + ; + + insert into contact_email (contact_id, email) + select contact_id, email::email + from imported_contact + where contact_id is not null + and pg_temp.input_is_valid(email, 'email') + on conflict (contact_id) do update + set email = excluded.email + ; + + insert into contact_web (contact_id, uri) + select contact_id, web::uri + from imported_contact + where contact_id is not null + and pg_temp.input_is_valid(web, 'uri') + and length(web) > 1 + on conflict (contact_id) do update + set uri = excluded.uri + ; + + insert into contact_iban (contact_id, iban) + select contact_id, iban::iban + from imported_contact + where contact_id is not null + and pg_temp.input_is_valid(iban, 'iban') + on conflict (contact_id) do update + set iban = excluded.iban + ; + + insert into contact_swift (contact_id, bic) + select contact_id, bic::bic + from imported_contact + where contact_id is not null + and pg_temp.input_is_valid(bic, 'bic') + on conflict (contact_id) do update + set bic = excluded.bic + ; + + -- TODO: use pg_input_is_valid with PostgreSQL 16 + create or replace function pg_temp.phone_is_valid(phone text, country text) returns bool as + $func$ + begin + begin + perform parse_packed_phone_number(phone, country); + return true; + exception when others then + return false; + end; + end; + $func$ + language plpgsql + immutable; + + insert into contact_phone (contact_id, phone) + select contact_id, parse_packed_phone_number(phone, case when country_code = '' then 'ES' else country_code end) + from imported_contact + where contact_id is not null + and pg_temp.phone_is_valid(phone, case when country_code = '' then 'ES' else country_code end) + on conflict (contact_id) do update + set phone = excluded.phone + ; + + select count(*) from imported_contact where contact_id is not null into imported; + return imported; + + drop table imported_contact; +end +$$ +language plpgsql; + +revoke execute on function end_import_contacts(integer) from public; +grant execute on function end_import_contacts(integer) to invoicer; +grant execute on function end_import_contacts(integer) to admin; + +commit; diff --git a/go.mod b/go.mod index 693a9fa..075fdc0 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/jackc/pgx/v4 v4.15.0 github.com/julienschmidt/httprouter v1.3.0 github.com/leonelquinteros/gotext v1.5.0 + github.com/tealeg/xlsx v0.0.0-20181024002044-dbf71b6a931e golang.org/x/text v0.7.0 ) diff --git a/go.sum b/go.sum index ed28e31..592b363 100644 --- a/go.sum +++ b/go.sum @@ -70,9 +70,11 @@ github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8 github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/leonelquinteros/gotext v1.5.0 h1:ODY7LzLpZWWSJdAHnzhreOr6cwLXTAmc914FOauSkBM= github.com/leonelquinteros/gotext v1.5.0/go.mod h1:OCiUVHuhP9LGFBQ1oAmdtNCHJCiHiQA8lf4nAifHkr0= @@ -112,6 +114,8 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/tealeg/xlsx v0.0.0-20181024002044-dbf71b6a931e h1:0AoAjM/7iqEZwTsWhk3nm9+H5mocFnh6dCGUaIOSTDQ= +github.com/tealeg/xlsx v0.0.0-20181024002044-dbf71b6a931e/go.mod h1:uxu5UY2ovkuRPWKQ8Q7JG0JbSivrISjdPzZQKeo74mA= github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= @@ -184,6 +188,7 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s= diff --git a/pkg/contacts.go b/pkg/contacts.go index 79c8732..41558b8 100644 --- a/pkg/contacts.go +++ b/pkg/contacts.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "github.com/julienschmidt/httprouter" + "github.com/tealeg/xlsx" "html/template" "net/http" "strings" @@ -41,8 +42,12 @@ func IndexContacts(w http.ResponseWriter, r *http.Request, _ httprouter.Params) func GetContactForm(w http.ResponseWriter, r *http.Request, params httprouter.Params) { locale := getLocale(r) conn := getConn(r) - form := newContactForm(r.Context(), conn, locale) slug := params[0].Value + if slug == "import" { + ServeImportPage(w, r, params) + return + } + form := newContactForm(r.Context(), conn, locale) if slug == "new" { w.WriteHeader(http.StatusOK) mustRenderNewContactForm(w, r, form) @@ -501,3 +506,92 @@ func HandleUpdateContactTags(w http.ResponseWriter, r *http.Request, params http } mustRenderStandaloneTemplate(w, r, "tags/view.gohtml", form) } + +func ServeImportPage(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + form := newContactImportForm(getLocale(r), getCompany(r)) + mustRenderMainTemplate(w, r, "contacts/import.gohtml", form) +} + +type contactImportForm struct { + locale *Locale + company *Company + File *FileField +} + +func newContactImportForm(locale *Locale, company *Company) *contactImportForm { + return &contactImportForm{ + locale: locale, + company: company, + File: &FileField{ + Name: "file", + Label: pgettext("input", "Holded Excel file", locale), + MaxSize: 1 << 20, + Required: true, + }, + } +} + +func (form *contactImportForm) Parse(r *http.Request) error { + if err := r.ParseMultipartForm(form.File.MaxSize); err != nil { + return err + } + if err := form.File.FillValue(r); err != nil { + return err + } + return nil +} + +func HandleImportContacts(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + locale := getLocale(r) + company := mustGetCompany(r) + form := newContactImportForm(locale, company) + if err := form.Parse(r); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if err := verifyCsrfTokenValid(r); err != nil { + http.Error(w, err.Error(), http.StatusForbidden) + return + } + workbook, err := xlsx.OpenBinary(form.File.Content) + if err != nil { + panic(err) + } + conn := getConn(r) + tx := conn.MustBegin(r.Context()) + defer tx.MustRollback(r.Context()) + relation := tx.MustGetText(r.Context(), "select begin_import_contacts()") + columns := []string{"name", "vatin", "email", "phone", "address", "city", "postal_code", "province", "country_code", "iban", "bic", "tags"} + for _, sheet := range workbook.Sheets { + tx.MustCopyFrom(r.Context(), relation, columns, len(sheet.Rows)-4, func(idx int) ([]interface{}, error) { + row := sheet.Rows[idx+4] + var values []interface{} + if len(row.Cells) < 23 { + values = []interface{}{"", "", "", "", "", "", "", "", "", "", "", ""} + } else { + phone := row.Cells[5].String() // mobile + if phone == "" { + phone = row.Cells[4].String() // landline + } + values = []interface{}{ + row.Cells[1].String(), + row.Cells[2].String(), + row.Cells[3].String(), + phone, + row.Cells[6].String(), + row.Cells[7].String(), + row.Cells[8].String(), + row.Cells[9].String(), + row.Cells[11].String(), + row.Cells[19].String(), + row.Cells[20].String(), + row.Cells[22].String(), + } + } + return values, nil + }) + } + tx.MustExec(r.Context(), "select end_import_contacts($1)", company.Id) + tx.MustCommit(r.Context()) + htmxRedirect(w, r, companyURI(company, "/contacts")) +} diff --git a/pkg/db.go b/pkg/db.go index 4ba06e5..1fb3308 100644 --- a/pkg/db.go +++ b/pkg/db.go @@ -143,6 +143,14 @@ func (tx *Tx) MustExec(ctx context.Context, sql string, args ...interface{}) { } } +func (tx *Tx) MustGetText(ctx context.Context, sql string, args ...interface{}) string { + var result string + if err := tx.QueryRow(ctx, sql, args...).Scan(&result); err != nil { + panic(err) + } + return result +} + func (tx *Tx) MustGetInteger(ctx context.Context, sql string, args ...interface{}) int { var result int if err := tx.QueryRow(ctx, sql, args...).Scan(&result); err != nil { @@ -159,8 +167,8 @@ func (tx *Tx) MustGetIntegerOrDefault(ctx context.Context, def int, sql string, return result } -func (tx *Tx) MustCopyFrom(ctx context.Context, tableName string, columns []string, rows [][]interface{}) int64 { - copied, err := tx.CopyFrom(ctx, pgx.Identifier{tableName}, columns, pgx.CopyFromRows(rows)) +func (tx *Tx) MustCopyFrom(ctx context.Context, tableName string, columns []string, length int, next func(int) ([]interface{}, error)) int64 { + copied, err := tx.CopyFrom(ctx, pgx.Identifier{tableName}, columns, pgx.CopyFromSlice(length, next)) if err != nil { panic(err) } diff --git a/pkg/form.go b/pkg/form.go index 44eb801..0d2610a 100644 --- a/pkg/form.go +++ b/pkg/form.go @@ -325,6 +325,7 @@ type FileField struct { OriginalFileName string ContentType string Content []byte + Required bool Errors []error } diff --git a/pkg/router.go b/pkg/router.go index 87f4492..2241e41 100644 --- a/pkg/router.go +++ b/pkg/router.go @@ -18,6 +18,7 @@ func NewRouter(db *Db) http.Handler { companyRouter.DELETE("/payment-method/:paymentMethodId", HandleDeletePaymentMethod) companyRouter.GET("/contacts", IndexContacts) companyRouter.POST("/contacts", HandleAddContact) + companyRouter.POST("/contacts/import", HandleImportContacts) companyRouter.GET("/contacts/:slug", GetContactForm) companyRouter.PUT("/contacts/:slug", HandleUpdateContact) companyRouter.PUT("/contacts/:slug/tags", HandleUpdateContactTags) diff --git a/po/ca.po b/po/ca.po index 722befb..051477d 100644 --- a/po/ca.po +++ b/po/ca.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: numerus\n" "Report-Msgid-Bugs-To: jordi@tandem.blog\n" -"POT-Creation-Date: 2023-07-02 01:58+0200\n" +"POT-Creation-Date: 2023-07-02 23:47+0200\n" "PO-Revision-Date: 2023-01-18 17:08+0100\n" "Last-Translator: jordi fita mas \n" "Language-Team: Catalan \n" @@ -30,10 +30,11 @@ msgstr "Afegeix productes a la factura" #: web/template/quotes/index.gohtml:9 web/template/quotes/view.gohtml:9 #: web/template/quotes/edit.gohtml:9 web/template/contacts/new.gohtml:9 #: web/template/contacts/index.gohtml:9 web/template/contacts/edit.gohtml:10 -#: web/template/profile.gohtml:9 web/template/expenses/new.gohtml:10 -#: web/template/expenses/index.gohtml:10 web/template/expenses/edit.gohtml:10 -#: web/template/tax-details.gohtml:9 web/template/products/new.gohtml:9 -#: web/template/products/index.gohtml:9 web/template/products/edit.gohtml:10 +#: web/template/contacts/import.gohtml:8 web/template/profile.gohtml:9 +#: web/template/expenses/new.gohtml:10 web/template/expenses/index.gohtml:10 +#: web/template/expenses/edit.gohtml:10 web/template/tax-details.gohtml:9 +#: web/template/products/new.gohtml:9 web/template/products/index.gohtml:9 +#: web/template/products/edit.gohtml:10 msgctxt "title" msgid "Home" msgstr "Inici" @@ -134,7 +135,7 @@ msgid "New invoice" msgstr "Nova factura" #: web/template/invoices/index.gohtml:43 web/template/dashboard.gohtml:23 -#: web/template/quotes/index.gohtml:43 web/template/contacts/index.gohtml:34 +#: web/template/quotes/index.gohtml:43 web/template/contacts/index.gohtml:36 #: web/template/expenses/index.gohtml:36 web/template/products/index.gohtml:34 msgctxt "action" msgid "Filter" @@ -167,7 +168,7 @@ msgid "Status" msgstr "Estat" #: web/template/invoices/index.gohtml:54 web/template/quotes/index.gohtml:54 -#: web/template/contacts/index.gohtml:43 web/template/expenses/index.gohtml:46 +#: web/template/contacts/index.gohtml:45 web/template/expenses/index.gohtml:46 #: web/template/products/index.gohtml:41 msgctxt "title" msgid "Tags" @@ -186,7 +187,7 @@ msgid "Download" msgstr "Descàrrega" #: web/template/invoices/index.gohtml:57 web/template/quotes/index.gohtml:57 -#: web/template/contacts/index.gohtml:44 web/template/expenses/index.gohtml:49 +#: web/template/contacts/index.gohtml:46 web/template/expenses/index.gohtml:49 #: web/template/products/index.gohtml:43 msgctxt "title" msgid "Actions" @@ -199,7 +200,7 @@ msgstr "Selecciona factura %v" #: web/template/invoices/index.gohtml:119 web/template/invoices/view.gohtml:19 #: web/template/quotes/index.gohtml:119 web/template/quotes/view.gohtml:22 -#: web/template/contacts/index.gohtml:74 web/template/expenses/index.gohtml:88 +#: web/template/contacts/index.gohtml:76 web/template/expenses/index.gohtml:88 #: web/template/products/index.gohtml:72 msgctxt "action" msgid "Edit" @@ -444,31 +445,37 @@ msgstr "Nou contacte" #: web/template/contacts/new.gohtml:10 web/template/contacts/index.gohtml:2 #: web/template/contacts/index.gohtml:10 web/template/contacts/edit.gohtml:11 +#: web/template/contacts/import.gohtml:9 msgctxt "title" msgid "Contacts" msgstr "Contactes" #: web/template/contacts/index.gohtml:15 msgctxt "action" +msgid "Import" +msgstr "Importa" + +#: web/template/contacts/index.gohtml:17 +msgctxt "action" msgid "New contact" msgstr "Nou contacte" -#: web/template/contacts/index.gohtml:40 web/template/expenses/index.gohtml:43 +#: web/template/contacts/index.gohtml:42 web/template/expenses/index.gohtml:43 msgctxt "title" msgid "Contact" msgstr "Contacte" -#: web/template/contacts/index.gohtml:41 +#: web/template/contacts/index.gohtml:43 msgctxt "title" msgid "Email" msgstr "Correu-e" -#: web/template/contacts/index.gohtml:42 +#: web/template/contacts/index.gohtml:44 msgctxt "title" msgid "Phone" msgstr "Telèfon" -#: web/template/contacts/index.gohtml:84 +#: web/template/contacts/index.gohtml:86 msgid "No contacts added yet." msgstr "No hi ha cap contacte." @@ -477,6 +484,11 @@ msgctxt "title" msgid "Edit Contact “%s”" msgstr "Edició del contacte «%s»" +#: web/template/contacts/import.gohtml:2 web/template/contacts/import.gohtml:10 +msgctxt "title" +msgid "Import Contacts" +msgstr "Importació de contactes" + #: web/template/login.gohtml:2 web/template/login.gohtml:15 msgctxt "title" msgid "Login" @@ -648,7 +660,7 @@ msgctxt "title" msgid "Edit Product “%s”" msgstr "Edició del producte «%s»" -#: pkg/login.go:37 pkg/company.go:127 pkg/profile.go:40 pkg/contacts.go:257 +#: pkg/login.go:37 pkg/company.go:127 pkg/profile.go:40 pkg/contacts.go:262 msgctxt "input" msgid "Email" msgstr "Correu-e" @@ -662,7 +674,7 @@ msgstr "Contrasenya" msgid "Email can not be empty." msgstr "No podeu deixar el correu-e en blanc." -#: pkg/login.go:71 pkg/company.go:284 pkg/profile.go:90 pkg/contacts.go:399 +#: pkg/login.go:71 pkg/company.go:284 pkg/profile.go:90 pkg/contacts.go:404 msgid "This value is not a valid email. It should be like name@domain.com." msgstr "Aquest valor no és un correu-e vàlid. Hauria de ser similar a nom@domini.cat." @@ -675,44 +687,44 @@ msgid "Invalid user or password." msgstr "Nom d’usuari o contrasenya incorrectes." #: pkg/products.go:164 pkg/products.go:263 pkg/quote.go:823 pkg/invoices.go:909 -#: pkg/contacts.go:135 pkg/contacts.go:243 +#: pkg/contacts.go:140 pkg/contacts.go:248 msgctxt "input" msgid "Name" msgstr "Nom" #: pkg/products.go:169 pkg/products.go:290 pkg/quote.go:174 pkg/quote.go:630 #: pkg/expenses.go:188 pkg/expenses.go:347 pkg/invoices.go:174 -#: pkg/invoices.go:657 pkg/invoices.go:1208 pkg/contacts.go:140 -#: pkg/contacts.go:343 +#: pkg/invoices.go:657 pkg/invoices.go:1208 pkg/contacts.go:145 +#: pkg/contacts.go:348 msgctxt "input" msgid "Tags" msgstr "Etiquetes" #: pkg/products.go:173 pkg/quote.go:178 pkg/expenses.go:351 pkg/invoices.go:178 -#: pkg/contacts.go:144 +#: pkg/contacts.go:149 msgctxt "input" msgid "Tags Condition" msgstr "Condició de les etiquetes" #: pkg/products.go:177 pkg/quote.go:182 pkg/expenses.go:355 pkg/invoices.go:182 -#: pkg/contacts.go:148 +#: pkg/contacts.go:153 msgctxt "tag condition" msgid "All" msgstr "Totes" #: pkg/products.go:178 pkg/expenses.go:356 pkg/invoices.go:183 -#: pkg/contacts.go:149 +#: pkg/contacts.go:154 msgid "Invoices must have all the specified labels." msgstr "Les factures han de tenir totes les etiquetes." #: pkg/products.go:182 pkg/quote.go:187 pkg/expenses.go:360 pkg/invoices.go:187 -#: pkg/contacts.go:153 +#: pkg/contacts.go:158 msgctxt "tag condition" msgid "Any" msgstr "Qualsevol" #: pkg/products.go:183 pkg/expenses.go:361 pkg/invoices.go:188 -#: pkg/contacts.go:154 +#: pkg/contacts.go:159 msgid "Invoices must have at least one of the specified labels." msgstr "Les factures han de tenir com a mínim una de les etiquetes." @@ -732,7 +744,7 @@ msgid "Taxes" msgstr "Imposts" #: pkg/products.go:309 pkg/quote.go:919 pkg/profile.go:92 pkg/invoices.go:1005 -#: pkg/contacts.go:392 +#: pkg/contacts.go:397 msgid "Name can not be empty." msgstr "No podeu deixar el nom en blanc." @@ -759,47 +771,47 @@ msgctxt "input" msgid "Trade name" msgstr "Nom comercial" -#: pkg/company.go:118 pkg/contacts.go:249 +#: pkg/company.go:118 pkg/contacts.go:254 msgctxt "input" msgid "Phone" msgstr "Telèfon" -#: pkg/company.go:136 pkg/contacts.go:265 +#: pkg/company.go:136 pkg/contacts.go:270 msgctxt "input" msgid "Web" msgstr "Web" -#: pkg/company.go:144 pkg/contacts.go:277 +#: pkg/company.go:144 pkg/contacts.go:282 msgctxt "input" msgid "Business name" msgstr "Nom i cognoms" -#: pkg/company.go:154 pkg/contacts.go:287 +#: pkg/company.go:154 pkg/contacts.go:292 msgctxt "input" msgid "VAT number" msgstr "DNI / NIF" -#: pkg/company.go:160 pkg/contacts.go:293 +#: pkg/company.go:160 pkg/contacts.go:298 msgctxt "input" msgid "Address" msgstr "Adreça" -#: pkg/company.go:169 pkg/contacts.go:302 +#: pkg/company.go:169 pkg/contacts.go:307 msgctxt "input" msgid "City" msgstr "Població" -#: pkg/company.go:175 pkg/contacts.go:308 +#: pkg/company.go:175 pkg/contacts.go:313 msgctxt "input" msgid "Province" msgstr "Província" -#: pkg/company.go:181 pkg/contacts.go:314 +#: pkg/company.go:181 pkg/contacts.go:319 msgctxt "input" msgid "Postal code" msgstr "Codi postal" -#: pkg/company.go:190 pkg/contacts.go:323 +#: pkg/company.go:190 pkg/contacts.go:328 msgctxt "input" msgid "Country" msgstr "País" @@ -834,23 +846,23 @@ msgctxt "input" msgid "Legal disclaimer" msgstr "Nota legal" -#: pkg/company.go:271 pkg/contacts.go:375 +#: pkg/company.go:271 pkg/contacts.go:380 msgid "Selected country is not valid." msgstr "Heu seleccionat un país que no és vàlid." -#: pkg/company.go:275 pkg/contacts.go:378 +#: pkg/company.go:275 pkg/contacts.go:383 msgid "Business name can not be empty." msgstr "No podeu deixar el nom i els cognoms en blanc." -#: pkg/company.go:276 pkg/contacts.go:379 +#: pkg/company.go:276 pkg/contacts.go:384 msgid "Business name must have at least two letters." msgstr "Nom i cognoms han de tenir com a mínim dues lletres." -#: pkg/company.go:277 pkg/contacts.go:380 +#: pkg/company.go:277 pkg/contacts.go:385 msgid "VAT number can not be empty." msgstr "No podeu deixar el DNI o NIF en blanc." -#: pkg/company.go:278 pkg/contacts.go:381 +#: pkg/company.go:278 pkg/contacts.go:386 msgid "This value is not a valid VAT number." msgstr "Aquest valor no és un DNI o NIF vàlid." @@ -858,31 +870,31 @@ msgstr "Aquest valor no és un DNI o NIF vàlid." msgid "Phone can not be empty." msgstr "No podeu deixar el telèfon en blanc." -#: pkg/company.go:281 pkg/contacts.go:396 +#: pkg/company.go:281 pkg/contacts.go:401 msgid "This value is not a valid phone number." msgstr "Aquest valor no és un telèfon vàlid." -#: pkg/company.go:287 pkg/contacts.go:402 +#: pkg/company.go:287 pkg/contacts.go:407 msgid "This value is not a valid web address. It should be like https://domain.com/." msgstr "Aquest valor no és una adreça web vàlida. Hauria de ser similar a https://domini.cat/." -#: pkg/company.go:289 pkg/contacts.go:383 +#: pkg/company.go:289 pkg/contacts.go:388 msgid "Address can not be empty." msgstr "No podeu deixar l’adreça en blanc." -#: pkg/company.go:290 pkg/contacts.go:384 +#: pkg/company.go:290 pkg/contacts.go:389 msgid "City can not be empty." msgstr "No podeu deixar la població en blanc." -#: pkg/company.go:291 pkg/contacts.go:385 +#: pkg/company.go:291 pkg/contacts.go:390 msgid "Province can not be empty." msgstr "No podeu deixar la província en blanc." -#: pkg/company.go:292 pkg/contacts.go:387 +#: pkg/company.go:292 pkg/contacts.go:392 msgid "Postal code can not be empty." msgstr "No podeu deixar el codi postal en blanc." -#: pkg/company.go:293 pkg/contacts.go:388 +#: pkg/company.go:293 pkg/contacts.go:393 msgid "This value is not a valid postal code." msgstr "Aquest valor no és un codi postal vàlid." @@ -1248,33 +1260,38 @@ msgstr "DD/MM/YYYY" msgid "Invoice product ID must be a number greater than zero." msgstr "L’ID del producte de factura ha de ser un número major a zero." -#: pkg/contacts.go:273 +#: pkg/contacts.go:278 msgctxt "input" msgid "Need to input tax details" msgstr "Necessito poder facturar aquest contacte" -#: pkg/contacts.go:333 +#: pkg/contacts.go:338 msgctxt "input" msgid "IBAN" msgstr "IBAN" -#: pkg/contacts.go:338 +#: pkg/contacts.go:343 msgctxt "bic" msgid "BIC" msgstr "BIC" -#: pkg/contacts.go:393 +#: pkg/contacts.go:398 msgid "Name must have at least two letters." msgstr "El nom ha de tenir com a mínim dues lletres." -#: pkg/contacts.go:405 +#: pkg/contacts.go:410 msgid "This values is not a valid IBAN." msgstr "Aquest valor no és un IBAN vàlid." -#: pkg/contacts.go:408 +#: pkg/contacts.go:413 msgid "This values is not a valid BIC." msgstr "Aquest valor no és un BIC vàlid." +#: pkg/contacts.go:527 +msgctxt "input" +msgid "Holded Excel file" +msgstr "Fitxer Excel del Holded" + #~ msgctxt "action" #~ msgid "Update contact" #~ msgstr "Actualitza contacte" diff --git a/po/es.po b/po/es.po index e4edb97..af4fc40 100644 --- a/po/es.po +++ b/po/es.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: numerus\n" "Report-Msgid-Bugs-To: jordi@tandem.blog\n" -"POT-Creation-Date: 2023-07-02 01:58+0200\n" +"POT-Creation-Date: 2023-07-02 23:47+0200\n" "PO-Revision-Date: 2023-01-18 17:45+0100\n" "Last-Translator: jordi fita mas \n" "Language-Team: Spanish \n" @@ -30,10 +30,11 @@ msgstr "Añadir productos a la factura" #: web/template/quotes/index.gohtml:9 web/template/quotes/view.gohtml:9 #: web/template/quotes/edit.gohtml:9 web/template/contacts/new.gohtml:9 #: web/template/contacts/index.gohtml:9 web/template/contacts/edit.gohtml:10 -#: web/template/profile.gohtml:9 web/template/expenses/new.gohtml:10 -#: web/template/expenses/index.gohtml:10 web/template/expenses/edit.gohtml:10 -#: web/template/tax-details.gohtml:9 web/template/products/new.gohtml:9 -#: web/template/products/index.gohtml:9 web/template/products/edit.gohtml:10 +#: web/template/contacts/import.gohtml:8 web/template/profile.gohtml:9 +#: web/template/expenses/new.gohtml:10 web/template/expenses/index.gohtml:10 +#: web/template/expenses/edit.gohtml:10 web/template/tax-details.gohtml:9 +#: web/template/products/new.gohtml:9 web/template/products/index.gohtml:9 +#: web/template/products/edit.gohtml:10 msgctxt "title" msgid "Home" msgstr "Inicio" @@ -134,7 +135,7 @@ msgid "New invoice" msgstr "Nueva factura" #: web/template/invoices/index.gohtml:43 web/template/dashboard.gohtml:23 -#: web/template/quotes/index.gohtml:43 web/template/contacts/index.gohtml:34 +#: web/template/quotes/index.gohtml:43 web/template/contacts/index.gohtml:36 #: web/template/expenses/index.gohtml:36 web/template/products/index.gohtml:34 msgctxt "action" msgid "Filter" @@ -167,7 +168,7 @@ msgid "Status" msgstr "Estado" #: web/template/invoices/index.gohtml:54 web/template/quotes/index.gohtml:54 -#: web/template/contacts/index.gohtml:43 web/template/expenses/index.gohtml:46 +#: web/template/contacts/index.gohtml:45 web/template/expenses/index.gohtml:46 #: web/template/products/index.gohtml:41 msgctxt "title" msgid "Tags" @@ -186,7 +187,7 @@ msgid "Download" msgstr "Descargar" #: web/template/invoices/index.gohtml:57 web/template/quotes/index.gohtml:57 -#: web/template/contacts/index.gohtml:44 web/template/expenses/index.gohtml:49 +#: web/template/contacts/index.gohtml:46 web/template/expenses/index.gohtml:49 #: web/template/products/index.gohtml:43 msgctxt "title" msgid "Actions" @@ -199,7 +200,7 @@ msgstr "Seleccionar factura %v" #: web/template/invoices/index.gohtml:119 web/template/invoices/view.gohtml:19 #: web/template/quotes/index.gohtml:119 web/template/quotes/view.gohtml:22 -#: web/template/contacts/index.gohtml:74 web/template/expenses/index.gohtml:88 +#: web/template/contacts/index.gohtml:76 web/template/expenses/index.gohtml:88 #: web/template/products/index.gohtml:72 msgctxt "action" msgid "Edit" @@ -444,31 +445,37 @@ msgstr "Nuevo contacto" #: web/template/contacts/new.gohtml:10 web/template/contacts/index.gohtml:2 #: web/template/contacts/index.gohtml:10 web/template/contacts/edit.gohtml:11 +#: web/template/contacts/import.gohtml:9 msgctxt "title" msgid "Contacts" msgstr "Contactos" #: web/template/contacts/index.gohtml:15 msgctxt "action" +msgid "Import" +msgstr "Importar" + +#: web/template/contacts/index.gohtml:17 +msgctxt "action" msgid "New contact" msgstr "Nuevo contacto" -#: web/template/contacts/index.gohtml:40 web/template/expenses/index.gohtml:43 +#: web/template/contacts/index.gohtml:42 web/template/expenses/index.gohtml:43 msgctxt "title" msgid "Contact" msgstr "Contacto" -#: web/template/contacts/index.gohtml:41 +#: web/template/contacts/index.gohtml:43 msgctxt "title" msgid "Email" msgstr "Correo-e" -#: web/template/contacts/index.gohtml:42 +#: web/template/contacts/index.gohtml:44 msgctxt "title" msgid "Phone" msgstr "Teléfono" -#: web/template/contacts/index.gohtml:84 +#: web/template/contacts/index.gohtml:86 msgid "No contacts added yet." msgstr "No hay contactos." @@ -477,6 +484,11 @@ msgctxt "title" msgid "Edit Contact “%s”" msgstr "Edición del contacto «%s»" +#: web/template/contacts/import.gohtml:2 web/template/contacts/import.gohtml:10 +msgctxt "title" +msgid "Import Contacts" +msgstr "Importación de contactos" + #: web/template/login.gohtml:2 web/template/login.gohtml:15 msgctxt "title" msgid "Login" @@ -648,7 +660,7 @@ msgctxt "title" msgid "Edit Product “%s”" msgstr "Edición del producto «%s»" -#: pkg/login.go:37 pkg/company.go:127 pkg/profile.go:40 pkg/contacts.go:257 +#: pkg/login.go:37 pkg/company.go:127 pkg/profile.go:40 pkg/contacts.go:262 msgctxt "input" msgid "Email" msgstr "Correo-e" @@ -662,7 +674,7 @@ msgstr "Contraseña" msgid "Email can not be empty." msgstr "No podéis dejar el correo-e en blanco." -#: pkg/login.go:71 pkg/company.go:284 pkg/profile.go:90 pkg/contacts.go:399 +#: pkg/login.go:71 pkg/company.go:284 pkg/profile.go:90 pkg/contacts.go:404 msgid "This value is not a valid email. It should be like name@domain.com." msgstr "Este valor no es un correo-e válido. Tiene que ser parecido a nombre@dominio.es." @@ -675,44 +687,44 @@ msgid "Invalid user or password." msgstr "Nombre de usuario o contraseña inválido." #: pkg/products.go:164 pkg/products.go:263 pkg/quote.go:823 pkg/invoices.go:909 -#: pkg/contacts.go:135 pkg/contacts.go:243 +#: pkg/contacts.go:140 pkg/contacts.go:248 msgctxt "input" msgid "Name" msgstr "Nombre" #: pkg/products.go:169 pkg/products.go:290 pkg/quote.go:174 pkg/quote.go:630 #: pkg/expenses.go:188 pkg/expenses.go:347 pkg/invoices.go:174 -#: pkg/invoices.go:657 pkg/invoices.go:1208 pkg/contacts.go:140 -#: pkg/contacts.go:343 +#: pkg/invoices.go:657 pkg/invoices.go:1208 pkg/contacts.go:145 +#: pkg/contacts.go:348 msgctxt "input" msgid "Tags" msgstr "Etiquetes" #: pkg/products.go:173 pkg/quote.go:178 pkg/expenses.go:351 pkg/invoices.go:178 -#: pkg/contacts.go:144 +#: pkg/contacts.go:149 msgctxt "input" msgid "Tags Condition" msgstr "Condición de las etiquetas" #: pkg/products.go:177 pkg/quote.go:182 pkg/expenses.go:355 pkg/invoices.go:182 -#: pkg/contacts.go:148 +#: pkg/contacts.go:153 msgctxt "tag condition" msgid "All" msgstr "Todas" #: pkg/products.go:178 pkg/expenses.go:356 pkg/invoices.go:183 -#: pkg/contacts.go:149 +#: pkg/contacts.go:154 msgid "Invoices must have all the specified labels." msgstr "Las facturas deben tener todas las etiquetas." #: pkg/products.go:182 pkg/quote.go:187 pkg/expenses.go:360 pkg/invoices.go:187 -#: pkg/contacts.go:153 +#: pkg/contacts.go:158 msgctxt "tag condition" msgid "Any" msgstr "Cualquiera" #: pkg/products.go:183 pkg/expenses.go:361 pkg/invoices.go:188 -#: pkg/contacts.go:154 +#: pkg/contacts.go:159 msgid "Invoices must have at least one of the specified labels." msgstr "Las facturas deben tener como mínimo una de las etiquetas." @@ -732,7 +744,7 @@ msgid "Taxes" msgstr "Impuestos" #: pkg/products.go:309 pkg/quote.go:919 pkg/profile.go:92 pkg/invoices.go:1005 -#: pkg/contacts.go:392 +#: pkg/contacts.go:397 msgid "Name can not be empty." msgstr "No podéis dejar el nombre en blanco." @@ -759,47 +771,47 @@ msgctxt "input" msgid "Trade name" msgstr "Nombre comercial" -#: pkg/company.go:118 pkg/contacts.go:249 +#: pkg/company.go:118 pkg/contacts.go:254 msgctxt "input" msgid "Phone" msgstr "Teléfono" -#: pkg/company.go:136 pkg/contacts.go:265 +#: pkg/company.go:136 pkg/contacts.go:270 msgctxt "input" msgid "Web" msgstr "Web" -#: pkg/company.go:144 pkg/contacts.go:277 +#: pkg/company.go:144 pkg/contacts.go:282 msgctxt "input" msgid "Business name" msgstr "Nombre y apellidos" -#: pkg/company.go:154 pkg/contacts.go:287 +#: pkg/company.go:154 pkg/contacts.go:292 msgctxt "input" msgid "VAT number" msgstr "DNI / NIF" -#: pkg/company.go:160 pkg/contacts.go:293 +#: pkg/company.go:160 pkg/contacts.go:298 msgctxt "input" msgid "Address" msgstr "Dirección" -#: pkg/company.go:169 pkg/contacts.go:302 +#: pkg/company.go:169 pkg/contacts.go:307 msgctxt "input" msgid "City" msgstr "Población" -#: pkg/company.go:175 pkg/contacts.go:308 +#: pkg/company.go:175 pkg/contacts.go:313 msgctxt "input" msgid "Province" msgstr "Provincia" -#: pkg/company.go:181 pkg/contacts.go:314 +#: pkg/company.go:181 pkg/contacts.go:319 msgctxt "input" msgid "Postal code" msgstr "Código postal" -#: pkg/company.go:190 pkg/contacts.go:323 +#: pkg/company.go:190 pkg/contacts.go:328 msgctxt "input" msgid "Country" msgstr "País" @@ -834,23 +846,23 @@ msgctxt "input" msgid "Legal disclaimer" msgstr "Nota legal" -#: pkg/company.go:271 pkg/contacts.go:375 +#: pkg/company.go:271 pkg/contacts.go:380 msgid "Selected country is not valid." msgstr "Habéis escogido un país que no es válido." -#: pkg/company.go:275 pkg/contacts.go:378 +#: pkg/company.go:275 pkg/contacts.go:383 msgid "Business name can not be empty." msgstr "No podéis dejar el nombre y los apellidos en blanco." -#: pkg/company.go:276 pkg/contacts.go:379 +#: pkg/company.go:276 pkg/contacts.go:384 msgid "Business name must have at least two letters." msgstr "El nombre y los apellidos deben contener como mínimo dos letras." -#: pkg/company.go:277 pkg/contacts.go:380 +#: pkg/company.go:277 pkg/contacts.go:385 msgid "VAT number can not be empty." msgstr "No podéis dejar el DNI o NIF en blanco." -#: pkg/company.go:278 pkg/contacts.go:381 +#: pkg/company.go:278 pkg/contacts.go:386 msgid "This value is not a valid VAT number." msgstr "Este valor no es un DNI o NIF válido." @@ -858,31 +870,31 @@ msgstr "Este valor no es un DNI o NIF válido." msgid "Phone can not be empty." msgstr "No podéis dejar el teléfono en blanco." -#: pkg/company.go:281 pkg/contacts.go:396 +#: pkg/company.go:281 pkg/contacts.go:401 msgid "This value is not a valid phone number." msgstr "Este valor no es un teléfono válido." -#: pkg/company.go:287 pkg/contacts.go:402 +#: pkg/company.go:287 pkg/contacts.go:407 msgid "This value is not a valid web address. It should be like https://domain.com/." msgstr "Este valor no es una dirección web válida. Tiene que ser parecida a https://dominio.es/." -#: pkg/company.go:289 pkg/contacts.go:383 +#: pkg/company.go:289 pkg/contacts.go:388 msgid "Address can not be empty." msgstr "No podéis dejar la dirección en blanco." -#: pkg/company.go:290 pkg/contacts.go:384 +#: pkg/company.go:290 pkg/contacts.go:389 msgid "City can not be empty." msgstr "No podéis dejar la población en blanco." -#: pkg/company.go:291 pkg/contacts.go:385 +#: pkg/company.go:291 pkg/contacts.go:390 msgid "Province can not be empty." msgstr "No podéis dejar la provincia en blanco." -#: pkg/company.go:292 pkg/contacts.go:387 +#: pkg/company.go:292 pkg/contacts.go:392 msgid "Postal code can not be empty." msgstr "No podéis dejar el código postal en blanco." -#: pkg/company.go:293 pkg/contacts.go:388 +#: pkg/company.go:293 pkg/contacts.go:393 msgid "This value is not a valid postal code." msgstr "Este valor no es un código postal válido válido." @@ -1248,33 +1260,38 @@ msgstr "DD/MM/YYYY" msgid "Invoice product ID must be a number greater than zero." msgstr "El ID de producto de factura tiene que ser un número mayor a cero." -#: pkg/contacts.go:273 +#: pkg/contacts.go:278 msgctxt "input" msgid "Need to input tax details" msgstr "Necesito facturar este contacto" -#: pkg/contacts.go:333 +#: pkg/contacts.go:338 msgctxt "input" msgid "IBAN" msgstr "IBAN" -#: pkg/contacts.go:338 +#: pkg/contacts.go:343 msgctxt "bic" msgid "BIC" msgstr "BIC" -#: pkg/contacts.go:393 +#: pkg/contacts.go:398 msgid "Name must have at least two letters." msgstr "El nombre debe contener como mínimo dos letras." -#: pkg/contacts.go:405 +#: pkg/contacts.go:410 msgid "This values is not a valid IBAN." msgstr "Este valor no es un IBAN válido." -#: pkg/contacts.go:408 +#: pkg/contacts.go:413 msgid "This values is not a valid BIC." msgstr "Este valor no es un BIC válido." +#: pkg/contacts.go:527 +msgctxt "input" +msgid "Holded Excel file" +msgstr "Archivo Excel de Holded" + #~ msgctxt "action" #~ msgid "Update contact" #~ msgstr "Actualizar contacto" diff --git a/revert/import_contact.sql b/revert/import_contact.sql new file mode 100644 index 0000000..4af5fcb --- /dev/null +++ b/revert/import_contact.sql @@ -0,0 +1,8 @@ +-- Revert numerus:import_contact from pg + +begin; + +drop function if exists numerus.end_import_contacts(integer); +drop function if exists numerus.begin_import_contacts(); + +commit; diff --git a/sqitch.plan b/sqitch.plan index b7c0852..aeb96cc 100644 --- a/sqitch.plan +++ b/sqitch.plan @@ -111,3 +111,4 @@ bic [schema_numerus] 2023-07-01T22:46:30Z jordi fita mas # A contact_swift [schema_numerus roles contact bic] 2023-07-01T23:03:13Z jordi fita mas # Add relation for contact’s SWIFT-BIC add_contact [add_contact@v0 tax_details contact_web contact_email contact_phone contact_iban contact_swift] 2023-06-29T11:10:15Z jordi fita mas # Change add contact to accept a tax_detail parameter and use the new relations for web, email, phone, iban, and swift edit_contact [edit_contact@v0 tax_details contact_web contact_email contact_phone contact_iban contact_swift] 2023-06-29T11:50:41Z jordi fita mas # Change edit_contact to require tax_details parameter and to use new relations for web, email, phone, iban, and swift +import_contact [schema_numerus roles contact contact_web contact_phone contact_email contact_iban contact_swift contact_tax_details] 2023-07-02T18:50:22Z jordi fita mas # Add functions to massively import customer data diff --git a/test/import_contact.sql b/test/import_contact.sql new file mode 100644 index 0000000..5b3d383 --- /dev/null +++ b/test/import_contact.sql @@ -0,0 +1,189 @@ +-- Test import_contact +set client_min_messages to warning; +create extension if not exists pgtap; +reset client_min_messages; + +begin; + +select plan(27); + +set search_path to numerus, auth, public; + +select has_function('numerus', 'begin_import_contacts', array []::name[]); +select function_lang_is('numerus', 'begin_import_contacts', array []::name[], 'sql'); +select function_returns('numerus', 'begin_import_contacts', array []::name[], 'name'); +select isnt_definer('numerus', 'begin_import_contacts', array []::name[]); +select volatility_is('numerus', 'begin_import_contacts', array []::name[], 'volatile'); +select function_privs_are('numerus', 'begin_import_contacts', array []::name[], 'guest', array []::text[]); +select function_privs_are('numerus', 'begin_import_contacts', array []::name[], 'invoicer', array ['EXECUTE']); +select function_privs_are('numerus', 'begin_import_contacts', array []::name[], 'admin', array ['EXECUTE']); +select function_privs_are('numerus', 'begin_import_contacts', array []::name[], 'authenticator', array []::text[]); + +select has_function('numerus', 'end_import_contacts', array ['integer']); +select function_lang_is('numerus', 'end_import_contacts', array ['integer'], 'plpgsql'); +select function_returns('numerus', 'end_import_contacts', array ['integer'], 'integer'); +select isnt_definer('numerus', 'end_import_contacts', array ['integer']); +select volatility_is('numerus', 'end_import_contacts', array ['integer'], 'volatile'); +select function_privs_are('numerus', 'end_import_contacts', array ['integer'], 'guest', array []::text[]); +select function_privs_are('numerus', 'end_import_contacts', array ['integer'], 'invoicer', array ['EXECUTE']); +select function_privs_are('numerus', 'end_import_contacts', array ['integer'], 'admin', array ['EXECUTE']); +select function_privs_are('numerus', 'end_import_contacts', array ['integer'], 'authenticator', array []::text[]); + + +set client_min_messages to warning; +truncate contact_swift cascade; +truncate contact_iban cascade; +truncate contact_web cascade; +truncate contact_email cascade; +truncate contact_phone cascade; +truncate contact_tax_details cascade; +truncate contact cascade; +truncate payment_method cascade; +truncate company cascade; +reset client_min_messages; + +set constraints "company_default_payment_method_id_fkey" deferred; + +insert into company (company_id, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, country_code, currency_code, default_payment_method_id) +values (1, 'Company 2', 'XX123', '', '555-555-555', 'a@a', '', '', '', '', '', 'ES', 'EUR', 111) +; + +insert into payment_method (payment_method_id, company_id, name, instructions) +values (111, 1, 'cash', 'cash') + , (112, 1, 'bank', 'send money to my bank account') +; + +set constraints "company_default_payment_method_id_fkey" immediate; + +insert into contact (contact_id, company_id, slug, name, tags) +values (12, 1, '7ac3ae0e-b0c1-4206-a19b-0be20835edd4', 'Contact 1', '{tag1}') + , (13, 1, 'b57b980b-247b-4be4-a0b7-03a7819c53ae', 'Contact 2', '{tag2}') +; + +insert into contact_tax_details (contact_id, business_name, vatin, address, city, province, postal_code, country_code) +values (12, 'Contact 1 Ltd', 'ES41414141L', 'One Road', 'One City', 'One Province', '17001', 'ES') + , (13, 'Contact 2 Ltd', 'ES42424242Y', 'Two Road', 'Two City', 'Two Province', '17002', 'ES') +; + +insert into contact_phone (contact_id, phone) +values (12, '777-777-777') + , (13, '888-888-888') +; + +insert into contact_email (contact_id, email) +values (12, 'c@c') + , (13, 'd@d') +; + +insert into contact_web (contact_id, uri) +values (12, 'https://1/') + , (13, 'https://2/') +; + +insert into contact_iban (contact_id, iban) +values (12, 'NL04RABO9373475770') + , (13, 'NL17RABO4416709382') +; + +insert into contact_swift (contact_id, bic) +values (12, 'ABNANL2A') + , (13, 'ARBNNL22') +; + + +select is( begin_import_contacts(), 'imported_contact', 'Should return the name of the relation to import' ); + +insert into imported_contact (name, vatin, email, phone, web, address, city, province, postal_code, country_code, iban, bic, tags) +values ('Contact 1 S.L.', '41414141L', 'a@a', '111-111-111', 'https://a/', 'Fake St., 123', 'Fake City', 'Fake province', '17000', 'ES', 'NL73INGB9691012820', 'EMCFNLKEX30', '#updated') -- valid updated contact + , ('Contact 2 Ltd', '42424242Y', 'd@d', '888-888-888', 'https://2/', '', '', '', '', 'ES', 'NL17RABO4416709382', 'ARBNNL22', '') -- valid existing contact, with same data but missing taxt details; leave what we already had + , ('Contact 3', '43434343Q', 'e@e', '999-999-999', 'invalid uri', 'Three Road', 'Three City', 'Three Province', '17003', 'FR', 'NL77INGB8674905641', 'EMCFNLKEXXX', '#new') -- valid new contact + , ('Contact 4.1', '44444444B', 'invalid email', '000-000-000', '', 'Four Road', 'Four City', 'Four Province', '17004', 'ES', 'invalid iban', 'EMCFNLKEX20', '#missing #details #vatin') -- invalid vatin: no tax details added + , ('Contact 4.2', '44444444A', 'f@f', 'invalid phone', '', '', 'Four City', 'Four Province', '17004', 'ES', 'NL50RABO9661117578', 'invalid bic', '#missing #details #street') -- invalid street: no tax details added + , ('Contact 4.3', '44444444A', '', '', 'https://4/', 'Four Road', '', 'Four Province', '17004', 'ES', '', '' , '#missing #details #city #$$$$') -- invalid city: no tax details added + , ('Contact 4.4', '44444444A', '', '', '', 'Four Road', 'Four City', '', '17004', 'ES', '', '' , '#missing #details #Pro$vince') -- invalid province: no tax details added + , ('Contact 4.5', '44444444A', '', '', '', 'Four Road', 'Four City', 'Four Province', '', 'ES', '', '' , '#missing #det/ails #postal code') -- invalid postal code: no tax details added + , ('Contact 4.6', '44444444A', '', '', '', 'Four Road', 'Four City', 'Four Province', '17004', '', '', '' , '#mis-sing #details #country') -- invalid country code: no tax details added + , ('Contact 5', '', '', '', '', '', '', '', '', '', '', '', 'just name') -- valid new contact with just a name + , (' ', '44444444A', 'valid@email.com', '111 111 111', 'https://3/', 'Fake St., 123', 'City', 'Province', '17486', 'ES', 'NL04RABO9373475770', 'ARBNNL22', 'tag1 tag2') -- contact with invalid name — not added +; + +select is( end_import_contacts(1), 10, 'Should have imported all contacts with mostly correct data' ); + +select bag_eq( + $$ select company_id, name, tags from contact $$, + $$ values (1, 'Contact 1', '{tag1,updated}'::tag_name[]) + , (1, 'Contact 2', '{tag2}'::tag_name[]) + , (1, 'Contact 3', '{new}'::tag_name[]) + , (1, 'Contact 4.1', '{missing,details,vatin}'::tag_name[]) + , (1, 'Contact 4.2', '{missing,details,street}'::tag_name[]) + , (1, 'Contact 4.3', '{missing,details,city}'::tag_name[]) + , (1, 'Contact 4.4', '{missing,details,province}'::tag_name[]) + , (1, 'Contact 4.5', '{missing,details,postal,code}'::tag_name[]) + , (1, 'Contact 4.6', '{mis-sing,details,country}'::tag_name[]) + , (1, 'Contact 5', '{just,name}'::tag_name[]) + $$, + 'Should have created all contacts' +); + +select bag_eq( + $$ select name, business_name, vatin::text, address, city, province, postal_code, country_code::text from contact join contact_tax_details using (contact_id) $$, + $$ values ('Contact 1', 'Contact 1 S.L.', 'ES41414141L', 'Fake St., 123', 'Fake City', 'Fake province', '17000', 'ES') + , ('Contact 2', 'Contact 2 Ltd', 'ES42424242Y', 'Two Road', 'Two City', 'Two Province', '17002', 'ES') + , ('Contact 3', 'Contact 3', 'FR43434343Q', 'Three Road', 'Three City', 'Three Province', '17003', 'FR') + $$, + 'Should have created all contacts’ tax details' +); + +select bag_eq( + $$ select name, phone::text from contact join contact_phone using (contact_id) $$, + $$ values ('Contact 1', '+34 111111111') + , ('Contact 2', '+34 888 88 88 88') + , ('Contact 3', '+33 9 99 99 99 99') + , ('Contact 4.1', '+34 000000000') + $$, + 'Should have created all contacts’ phone' +); + +select bag_eq( + $$ select name, email::text from contact join contact_email using (contact_id) $$, + $$ values ('Contact 1', 'a@a') + , ('Contact 2', 'd@d') + , ('Contact 3', 'e@e') + , ('Contact 4.2', 'f@f') + $$, + 'Should have created all contacts’ email' +); + +select bag_eq( + $$ select name, uri::text from contact join contact_web using (contact_id) $$, + $$ values ('Contact 1', 'https://a/') + , ('Contact 2', 'https://2/') + , ('Contact 4.3', 'https://4/') + $$, + 'Should have created all contacts’ web' +); + +select bag_eq( + $$ select name, iban::text from contact join contact_iban using (contact_id) $$, + $$ values ('Contact 1', 'NL73INGB9691012820') + , ('Contact 2', 'NL17RABO4416709382') + , ('Contact 3', 'NL77INGB8674905641') + , ('Contact 4.2', 'NL50RABO9661117578') + $$, + 'Should have created all contacts’ IBAN' +); + +select bag_eq( + $$ select name, bic::text from contact join contact_swift using (contact_id) $$, + $$ values ('Contact 1', 'EMCFNLKEX30') + , ('Contact 2', 'ARBNNL22') + , ('Contact 3', 'EMCFNLKEXXX') + , ('Contact 4.1', 'EMCFNLKEX20') + $$, + 'Should have created all contacts’ BIC' +); + +select * +from finish(); + +rollback; diff --git a/verify/import_contact.sql b/verify/import_contact.sql new file mode 100644 index 0000000..6fe989c --- /dev/null +++ b/verify/import_contact.sql @@ -0,0 +1,8 @@ +-- Verify numerus:import_contact on pg + +begin; + +select has_function_privilege('numerus.begin_import_contacts()', 'execute'); +select has_function_privilege('numerus.end_import_contacts(integer)', 'execute'); + +rollback; diff --git a/web/template/contacts/import.gohtml b/web/template/contacts/import.gohtml new file mode 100644 index 0000000..f73d72f --- /dev/null +++ b/web/template/contacts/import.gohtml @@ -0,0 +1,26 @@ +{{ define "title" -}} + {{( pgettext "Import Contacts" "title" )}} +{{- end }} + +{{ define "breadcrumbs" -}} + +{{- end }} + +{{ define "content" }} + {{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.contactImportForm*/ -}} +
+ {{ csrfToken }} + + {{ template "file-field" .File }} + +
+ +
+
+{{- end }} diff --git a/web/template/contacts/index.gohtml b/web/template/contacts/index.gohtml index 038c0dd..a7227a7 100644 --- a/web/template/contacts/index.gohtml +++ b/web/template/contacts/index.gohtml @@ -11,6 +11,8 @@

{{ template "filters-toggle" }} + {{( pgettext "Import" "action" )}} {{( pgettext "New contact" "action" )}}

diff --git a/web/template/form.gohtml b/web/template/form.gohtml index 7e6a5b7..f71fbd3 100644 --- a/web/template/form.gohtml +++ b/web/template/form.gohtml @@ -32,7 +32,7 @@ {{ define "file-field" -}} {{- /*gotype: dev.tandem.ws/tandem/numerus/pkg.FileField*/ -}}
- + {{- if .Errors }}