This commit is contained in:
Alex Shevchuk
2025-08-18 17:12:04 +03:00
commit d84487d238
157 changed files with 160686 additions and 0 deletions

View 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
}
}