1
This commit is contained in:
344
internal/integration/vkusvill.go
Normal file
344
internal/integration/vkusvill.go
Normal file
@@ -0,0 +1,344 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
rmodel "git-molva.ru/Molva/molva-backend/services/api_gateway/internal/request_model"
|
||||
)
|
||||
|
||||
const (
|
||||
vkusvillMolvaUtmSource = "molva"
|
||||
|
||||
dmYFormat = "02.01.2006" // datetime format stored in DB
|
||||
YmdFormat = "2006-01-02" // datetime format used in vkusvill
|
||||
)
|
||||
|
||||
var (
|
||||
// TODO: use reflect to set json tags
|
||||
vkusvillVacancyIdFieldKey = "vacancy_id"
|
||||
|
||||
vkusvillNeededFields = map[string]struct{}{
|
||||
vkusvillVacancyIdFieldKey: {},
|
||||
}
|
||||
)
|
||||
|
||||
type (
|
||||
VkusvillSecretsConfig struct {
|
||||
ApiToken string
|
||||
}
|
||||
)
|
||||
|
||||
type (
|
||||
vkusvillSourceLid struct {
|
||||
Url string `json:"url"`
|
||||
}
|
||||
|
||||
vkusvillSaveCandidateRequest struct {
|
||||
Status int `json:"status"` // see status mapping
|
||||
LastName string `json:"last_name"` // required
|
||||
Name string `json:"name"` // required
|
||||
SecondName string `json:"second_name"` // required
|
||||
Birthday string `json:"birthday"` // YYYY-mm-dd
|
||||
Phone []string `json:"phone"`
|
||||
VacancyId int `json:"vacancy_id"` // see vacancy to id mapping
|
||||
CityJob string `json:"city_job"` // unsupported now
|
||||
MetroArea string `json:"metro_area"` // unsupported now
|
||||
UtmSource string `json:"utm_source"`
|
||||
SourceLid vkusvillSourceLid `json:"source_lid"`
|
||||
}
|
||||
)
|
||||
|
||||
func (c *client) handleVkusvillIntegration(
|
||||
ctx context.Context,
|
||||
request HandleVacancy,
|
||||
vacancyExtraFields *VacancyExtraFields,
|
||||
companyMetadata *CompanyMetadata,
|
||||
companyExtraFieldsTemplate *CompanyExtraFieldsTemplate,
|
||||
) error {
|
||||
host, ok := (*companyMetadata)["host"]
|
||||
if !ok {
|
||||
return fmt.Errorf("host not found in metadata")
|
||||
}
|
||||
|
||||
method, ok := (*companyMetadata)["method"]
|
||||
if !ok {
|
||||
return fmt.Errorf("request method not found in metadata")
|
||||
}
|
||||
|
||||
lastName, name, secondName := splitName(request.Candidate.Name)
|
||||
|
||||
extraFields, err := parseVacancyExtraFields(vacancyExtraFields, companyExtraFieldsTemplate)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error extracting vacancy id: %w", err)
|
||||
}
|
||||
|
||||
vacancyId, ok := extraFields[vkusvillVacancyIdFieldKey].(int)
|
||||
if !ok {
|
||||
return fmt.Errorf("vacancy id field is not of expected type (int)")
|
||||
}
|
||||
|
||||
sourceUrl, err := url.Parse(request.SourceLid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error parsing source lid: %w", err)
|
||||
}
|
||||
|
||||
query := sourceUrl.Query()
|
||||
// TODO: think what we potentially want (maybe request id for tracing etc.)
|
||||
query.Set("candidate_id", request.Candidate.Id)
|
||||
|
||||
sourceUrl.RawQuery = query.Encode()
|
||||
|
||||
saveCandidateReq := vkusvillSaveCandidateRequest{
|
||||
Status: 0,
|
||||
LastName: lastName,
|
||||
Name: name,
|
||||
SecondName: secondName,
|
||||
Birthday: formatDate(request.Candidate.Birthday, dmYFormat, YmdFormat),
|
||||
Phone: []string{request.Candidate.Phone},
|
||||
VacancyId: vacancyId,
|
||||
CityJob: "",
|
||||
MetroArea: "",
|
||||
UtmSource: vkusvillMolvaUtmSource,
|
||||
SourceLid: vkusvillSourceLid{
|
||||
Url: sourceUrl.String(),
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := c.vkusvillSaveCandidate(method, host, &saveCandidateReq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error making request: %w", err)
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
// TODO: think about following:
|
||||
// у Вкусвилла ошибки возвращаются в теле ответа. Соответственно, нужно
|
||||
// а) проверить наличие ошибки
|
||||
// б) если ошибка есть, создать алерт об этом (например, в админку). Вообще надо подумать над этим сценарием
|
||||
|
||||
return c.vkusvillHandleResponse(resp)
|
||||
}
|
||||
|
||||
type (
|
||||
VkusvillSaveCandidateResponse struct {
|
||||
//nolint:tagliatelle // used in vkusvill
|
||||
Error bool `json:"ERR"`
|
||||
//nolint:tagliatelle // used in vkusvill
|
||||
Data json.RawMessage `json:"DATA"`
|
||||
}
|
||||
|
||||
VkusvillErrorMessage struct {
|
||||
Field string `json:"field"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
VkusvillErrorDetails struct {
|
||||
Data []VkusvillErrorMessage
|
||||
}
|
||||
|
||||
VkusvillSuccessDetails struct {
|
||||
// TODO: maybe add more fields
|
||||
}
|
||||
)
|
||||
|
||||
func (r *VkusvillSaveCandidateResponse) GetErrorDetails() (*VkusvillErrorDetails, error) {
|
||||
var errDetails VkusvillErrorDetails
|
||||
|
||||
if err := json.Unmarshal(r.Data, &errDetails); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &errDetails, nil
|
||||
}
|
||||
|
||||
func (r *VkusvillSaveCandidateResponse) GetSuccessDetails() (*VkusvillSuccessDetails, error) {
|
||||
var successDetails VkusvillSuccessDetails
|
||||
|
||||
if err := json.Unmarshal(r.Data, &successDetails); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &successDetails, nil
|
||||
}
|
||||
|
||||
func (c *client) vkusvillHandleResponse(resp *http.Response) error {
|
||||
const handlerName = "integration.vkusvillHandleResponse"
|
||||
|
||||
var vkusvillResp VkusvillSaveCandidateResponse
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&vkusvillResp); err != nil {
|
||||
return fmt.Errorf("error decoding vkusvill response: %w", err)
|
||||
}
|
||||
|
||||
if vkusvillResp.Error {
|
||||
errDetails, err := vkusvillResp.GetErrorDetails()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error getting vkusvill error details: %w", err)
|
||||
}
|
||||
|
||||
return fmt.Errorf("error saving candidate in vkusvill HRM: %v", errDetails)
|
||||
}
|
||||
|
||||
successDetails, err := vkusvillResp.GetSuccessDetails()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error getting vkusvill success details: %w", err)
|
||||
}
|
||||
|
||||
// TODO: remove after testing
|
||||
c.logger.Debug("vkusvill response body",
|
||||
slog.String("body", string(vkusvillResp.Data)),
|
||||
slog.String("handler", handlerName))
|
||||
|
||||
c.logger.Debug("vkusvill success details",
|
||||
slog.Any("success_details", successDetails),
|
||||
slog.String("handler", handlerName))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func splitName(name string) (string, string, string) {
|
||||
splittedName := strings.SplitN(name, " ", 3)
|
||||
|
||||
var (
|
||||
lastName, firstName, secondName string
|
||||
)
|
||||
|
||||
if len(splittedName) > 1 {
|
||||
lastName = splittedName[0]
|
||||
}
|
||||
|
||||
if len(splittedName) > 2 {
|
||||
firstName = splittedName[1]
|
||||
}
|
||||
|
||||
if len(splittedName) > 3 {
|
||||
secondName = splittedName[2]
|
||||
}
|
||||
|
||||
return lastName, firstName, secondName
|
||||
}
|
||||
|
||||
func formatDate(date, fromFmt, toFmt string) string {
|
||||
parsed, err := time.Parse(fromFmt, date)
|
||||
if err != nil {
|
||||
return date
|
||||
}
|
||||
|
||||
return parsed.Format(toFmt)
|
||||
}
|
||||
|
||||
//nolint:gocognit // TODO: refactor
|
||||
func parseVacancyExtraFields(
|
||||
vacancyExtraFields *VacancyExtraFields,
|
||||
companyExtraFieldsTemplate *CompanyExtraFieldsTemplate,
|
||||
) (map[string]any, error) {
|
||||
resMap := make(map[string]any)
|
||||
|
||||
for _, vacXField := range vacancyExtraFields.Fields {
|
||||
if _, ok := vkusvillNeededFields[vacXField.SystemName]; !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if len(vacXField.Value) == 0 {
|
||||
// TODO: Maybe here we need to check whether corresponding field template
|
||||
// has required flag set to true
|
||||
continue
|
||||
}
|
||||
|
||||
if vacXField.IsValidValue {
|
||||
// get corresponding value from company template
|
||||
for _, comFieldTemplate := range companyExtraFieldsTemplate.Fields {
|
||||
if comFieldTemplate.SystemName != vacXField.SystemName {
|
||||
continue
|
||||
}
|
||||
|
||||
if vacXField.MultipleChoice {
|
||||
var (
|
||||
nameValMap = make(map[string]string, len(comFieldTemplate.ValidValues))
|
||||
res = make([]any, len(vacXField.Value))
|
||||
err error
|
||||
)
|
||||
|
||||
for _, validVal := range comFieldTemplate.ValidValues {
|
||||
nameValMap[validVal.DisplayName] = validVal.Value
|
||||
}
|
||||
|
||||
for i, val := range vacXField.Value {
|
||||
res[i], err = convertFieldtype(vacXField.Type, nameValMap[val])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error converting field value: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
resMap[vacXField.SystemName] = res
|
||||
} else {
|
||||
for _, validVal := range comFieldTemplate.ValidValues {
|
||||
if validVal.DisplayName != vacXField.Value[0] {
|
||||
continue
|
||||
}
|
||||
|
||||
res, err := convertFieldtype(vacXField.Type, validVal.Value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error converting field value: %w", err)
|
||||
}
|
||||
|
||||
resMap[vacXField.SystemName] = res
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if vacXField.MultipleChoice {
|
||||
var (
|
||||
res = make([]any, len(vacXField.Value))
|
||||
err error
|
||||
)
|
||||
|
||||
for i, v := range vacXField.Value {
|
||||
res[i], err = convertFieldtype(vacXField.Type, v)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error converting field value: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
resMap[vacXField.SystemName] = res
|
||||
} else {
|
||||
res, err := convertFieldtype(vacXField.Type, vacXField.Value[0])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error converting field value: %w", err)
|
||||
}
|
||||
|
||||
resMap[vacXField.SystemName] = res
|
||||
}
|
||||
}
|
||||
|
||||
return resMap, nil
|
||||
}
|
||||
|
||||
func VkusvillGetCandidateStatus(status int) (rmodel.SubmissionStatus, error) {
|
||||
switch status {
|
||||
case 0: // Статус не установлен
|
||||
return rmodel.SubStatusUnspecified, nil
|
||||
case 666: // Архивный
|
||||
return rmodel.SubStatusCancelled, nil
|
||||
case 49: // Без вакансии
|
||||
return rmodel.SubStatusCancelled, nil
|
||||
case 50: // На рассмотрении
|
||||
return rmodel.SubStatusOnInterview, nil
|
||||
case 51: // Отклоненный
|
||||
return rmodel.SubStatusRejected, nil
|
||||
case 115: // Зарезервированный
|
||||
return rmodel.SubStatusApproved, nil
|
||||
default:
|
||||
return rmodel.SubStatusUnspecified, ErrUnknownCandidateStatus
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user