Модуль 2 · Язык фильтров

Справочник по MongoDB · модуль 2 · язык фильтров

2.9

2.9

Скрипт целиком:
подбор кандидатов

Операторы модуля 2 разбирались по одному. Здесь собрана готовая программа: она подбирает кандидатов под требования вакансии, показывает, сколько резюме проходит каждое требование по отдельности, ищет подходящие вакансии, считает собеседования за неделю и проверяет данные на пропуски и неверные типы. Глава разбирает скрипт по частям и приводит его целиком вместе с выводом.

Категория
Готовая программа
Уровень
Итоговая глава модуля
Связанные темы
Все главы модуля 2, практическая работа ПР-02
Зачем это знать
Собрать из отдельных операторов рабочий отбор

Как запускать примеры этой главы

БазыГлава только читает данные — сбрасывать базы не нужно.
Архивmongodb-glava-2.9-python.zip, 58 КБmongodb-glava-2.9-ruby.zip, 57 КБmongodb-glava-2.9-go.zip, 61 КБmongodb-glava-2.9-cpp.zip, 59 КБ — все примеры главы готовыми программами, базы и стенд в Docker. В README.md — запуск в Docker, если Compass нет или он не подключается, и на установленном сервере с Compass, а также что должна напечатать каждая программа.
Файлlessons/ch2_9.py — пример вставляется в конец файлаlessons/ch2_9.rb — пример вставляется в конец файлаlessons/ch2_9.go — пример внутрь скобок в конце main, функции и типы — выше mainlessons/ch2_9.cpp — пример внутрь скобок в конце main, функции — выше main
Запускcd lessons, затем python ch2_9.pycd lessons, затем python3 ch2_9.py · в Docker: docker compose run --rm python lessons/ch2_9.pycd lessons, затем ruby ch2_9.rb · в Docker: docker compose run --rm ruby lessons/ch2_9.rbcd lessons, затем go run ch2_9.go · в Docker: docker compose run --rm go lessons/ch2_9.gocd lessons, затем g++ -std=c++17 ch2_9.cpp -o prog $(pkg-config --cflags --libs libmongocxx1) && ./prog · в Docker: docker compose run --rm cpp lessons/ch2_9.cpp
ПорядокКаждый пример вставляется вместо предыдущего и запускается один раз, по порядку, — так получены все выводы главы. Подробно — глава 1.0, §10.

§1Что делает скрипт

Скрипт hh_search.py решает задачу рекрутера: по требованиям вакансии найти подходящие резюме, показать, на каком требовании отсеиваются кандидаты, подобрать вакансии под ожидание по зарплате, посчитать собеседования за неделю и проверить, всё ли в порядке с самими данными.

Восемь разделов отчёта — восемь групп операторов модуля 2.

Раздел отчётаКакие операторыГлава
1. Требованиязначения, из которых собираются фильтры—
2. Каждое требование по отдельности$all, $or, $lte, $elemMatch, $exists2.2–2.6
3. Все требования сразуте же условия в одном фильтре, сортировка2.1, 2.4
4. Должности с Junior$regex и $options2.7
5. Вакансии под ожиданиеточечная нотация и диапазон2.1, 2.2
6. Собеседования за неделюпериод дат полуинтервалом2.2
7. Проверка данных$exists, $type, $size, $expr, $ne2.5, 2.6, 2.8
8. Кто не прошёл отборпроверка на стороне программы—

Скрипт только читает данные: сбрасывать базы перед запуском не нужно.

§2Требования и подключение

Начало скрипта — подключение и коллекции базы hh. Требования вакансии вынесены в отдельные переменные: список навыков, город, потолок по зарплате и минимальный срок работы. Дальше в фильтрах стоят только эти имена, поэтому поменять требования можно в одном месте, не трогая запросы.

Python · PyMongoподключение и требования
import os
from datetime import datetime

from pymongo import MongoClient

client = MongoClient(os.environ.get("MONGO_URI", "mongodb://localhost:27017/"))
hh = client["hh"]
resumes = hh["resumes"]
vacancies = hh["vacancies"]
interviews = hh["interviews"]


def title(text):
    print("\n" + text)
    print("─" * len(text))

# ── 1. Требования вакансии ────────────────────────────────────────────────
NAVYKI = ["Python", "SQL"]
GOROD = "Ярославль"
POTOLOK = 100000
OPYT_MESYACEV = 6

title("1. Требования")
print(f"  навыки: {' и '.join(NAVYKI)}")
print(f"  город: {GOROD} или готовность к переезду")
print(f"  зарплата: не больше {POTOLOK}")
print(f"  опыт: место работы от {OPYT_MESYACEV} месяцев")
C++ · mongocxxподключение и требования
#include <chrono>
#include <cstdlib>
#include <iostream>
#include <string>
#include <vector>

#include <bsoncxx/builder/basic/array.hpp>
#include <bsoncxx/builder/basic/document.hpp>
#include <bsoncxx/builder/basic/kvp.hpp>
#include <bsoncxx/json.hpp>
#include <bsoncxx/types.hpp>
#include <mongocxx/client.hpp>
#include <mongocxx/collection.hpp>
#include <mongocxx/instance.hpp>
#include <mongocxx/options/find.hpp>
#include <mongocxx/uri.hpp>

using bsoncxx::builder::basic::kvp;
using bsoncxx::builder::basic::make_array;
using bsoncxx::builder::basic::make_document;

int simvolov(const std::string& text) {
    int n = 0;
    for (unsigned char c : text)
        if ((c & 0xC0) != 0x80) ++n;
    return n;
}

std::string pad(const std::string& text, int width) {
    int add = width - simvolov(text);
    return add > 0 ? text + std::string(add, ' ') : text;
}

std::string right(const std::string& text, int width) {
    int add = width - simvolov(text);
    return add > 0 ? std::string(add, ' ') + text : text;
}

void title(const std::string& text) {
    std::cout << "\n" << text << "\n";
    for (int i = 0; i < simvolov(text); ++i) std::cout << "─";
    std::cout << std::endl;
}

// Строки массива через запятую — для навыков в таблице.
std::string cherez_zapyatuyu(const bsoncxx::array::view& massiv) {
    std::string out;
    for (auto&& element : massiv) {
        if (!out.empty()) out += ", ";
        out += std::string(element.get_string().value);
    }
    return out;
}

int main() {
    mongocxx::instance instance{};
    const char* env = std::getenv("MONGO_URI");
    mongocxx::client client{mongocxx::uri{env ? env : "mongodb://localhost:27017"}};

    auto hh = client["hh"];
    auto resumes = hh["resumes"];
    auto vacancies = hh["vacancies"];
    auto interviews = hh["interviews"];

    // Требования вакансии.
    const std::vector<std::string> navyki = {"Python", "SQL"};
    const std::string gorod = "Ярославль";
    const int potolok = 100000;
    const int opyt_mesyacev = 6;

    // ── 1. Требования ─────────────────────────────────────────────────
    title("1. Требования");

    std::cout << "  навыки: " << navyki[0] << " и " << navyki[1] << std::endl;
    std::cout << "  город: " << gorod << " или готовность к переезду" << std::endl;
    std::cout << "  зарплата: не больше " << potolok << std::endl;
    std::cout << "  опыт: место работы от " << opyt_mesyacev << " месяцев" << std::endl;

    auto spisok_navykov = make_array(navyki[0], navyki[1]);
    auto gorod_ili_pereezd = make_array(make_document(kvp("city", gorod)),
                                        make_document(kvp("ready_to_move", true)));
    auto opyt = make_document(kvp("$elemMatch",
                    make_document(kvp("months", make_document(kvp("$gte", opyt_mesyacev))))));
Go · mongo-driverподключение и требования
package main

import (
	"context"
	"fmt"
	"log"
	"os"
	"strings"
	"time"
	"unicode/utf8"

	"go.mongodb.org/mongo-driver/v2/bson"
	"go.mongodb.org/mongo-driver/v2/mongo"
	"go.mongodb.org/mongo-driver/v2/mongo/options"
)

// Resume — поля резюме, которые читает отбор.
type Resume struct {
	Fio         string   `bson:"fio"`
	Position    string   `bson:"position"`
	City        string   `bson:"city"`
	Salary      int      `bson:"salary"`
	ReadyToMove bool     `bson:"ready_to_move"`
	Skills      []string `bson:"skills"`
	Experience  []struct {
		Company string `bson:"company"`
		Role    string `bson:"role"`
		Months  int    `bson:"months"`
	} `bson:"experience"`
}

// Vacancy — вакансия с вилкой зарплаты во вложенном документе.
type Vacancy struct {
	Title   string `bson:"title"`
	Company string `bson:"company"`
	City    string `bson:"city"`
	Salary  struct {
		From int `bson:"from"`
		To   int `bson:"to"`
	} `bson:"salary"`
}

func pad(text string, width int) string {
	if n := utf8.RuneCountInString(text); n < width {
		return text + strings.Repeat(" ", width-n)
	}
	return text
}

func right(text string, width int) string {
	if n := utf8.RuneCountInString(text); n < width {
		return strings.Repeat(" ", width-n) + text
	}
	return text
}

func title(text string) {
	fmt.Println("\n" + text)
	fmt.Println(strings.Repeat("─", utf8.RuneCountInString(text)))
}

// Требования вакансии.
var (
	navyki       = []string{"Python", "SQL"}
	gorod        = "Ярославль"
	potolok      = 100000
	opytMesyacev = 6
)

func main() {
	uri := os.Getenv("MONGO_URI")
	if uri == "" {
		uri = "mongodb://localhost:27017"
	}
	client, err := mongo.Connect(options.Client().ApplyURI(uri))
	if err != nil {
		log.Fatal(err)
	}
	ctx := context.Background()
	defer client.Disconnect(ctx)

	hh := client.Database("hh")
	resumes := hh.Collection("resumes")
	vacancies := hh.Collection("vacancies")
	interviews := hh.Collection("interviews")

	// ── 1. Требования ─────────────────────────────────────────────────
	title("1. Требования")

	fmt.Printf("  навыки: %s\n", strings.Join(navyki, " и "))
	fmt.Printf("  город: %s или готовность к переезду\n", gorod)
	fmt.Printf("  зарплата: не больше %d\n", potolok)
	fmt.Printf("  опыт: место работы от %d месяцев\n", opytMesyacev)

	spisokNavykov := bson.A{}
	for _, n := range navyki {
		spisokNavykov = append(spisokNavykov, n)
	}
	gorodIliPereezd := bson.A{
		bson.D{{Key: "city", Value: gorod}},
		bson.D{{Key: "ready_to_move", Value: true}},
	}
	opyt := bson.D{{Key: "$elemMatch", Value: bson.D{
		{Key: "months", Value: bson.D{{Key: "$gte", Value: opytMesyacev}}}}}}
Ruby · mongoподключение и требования
require "mongo"

Mongo::Logger.logger.level = Logger::WARN

client = Mongo::Client.new(ENV.fetch("MONGO_URI", "mongodb://localhost:27017/"))
hh = client.use("hh").database
resumes = hh[:resumes]
vacancies = hh[:vacancies]
interviews = hh[:interviews]

def title(text)
  puts "\n" + text
  puts "─" * text.length
end

def pad(text, width)
  text.to_s.ljust(width)
end

# ── 1. Требования вакансии ────────────────────────────────────────────────
NAVYKI = ["Python", "SQL"].freeze
GOROD = "Ярославль"
POTOLOK = 100_000
OPYT_MESYACEV = 6

title("1. Требования")
puts "  навыки: #{NAVYKI.join(' и ')}"
puts "  город: #{GOROD} или готовность к переезду"
puts "  зарплата: не больше #{POTOLOK}"
puts "  опыт: место работы от #{OPYT_MESYACEV} месяцев"

Так пишут отбор в рабочем коде: условия приходят из формы или настроек, а фильтр собирается из них. В главе 2.2 §7 разобрано, почему значение, пришедшее извне, безопаснее подставлять через $eq, если оно может оказаться документом.

§3Каждое требование по отдельности

Прежде чем соединять условия, смотрят, сколько резюме проходит каждое из них. Тогда видно, какое требование отсекает больше всего кандидатов. Условия сложены в список парами «название — фильтр», и скрипт перебирает его одним циклом.

Python · PyMongoсчётчики по каждому требованию
# ── 2. Каждое требование по отдельности ───────────────────────────────────
title("2. Сколько резюме проходит каждое требование")

kriterii = [
    ("навыки $all", {"skills": {"$all": NAVYKI}}),
    ("город или переезд $or", {"$or": [{"city": GOROD}, {"ready_to_move": True}]}),
    ("зарплата $lte", {"salary": {"$lte": POTOLOK}}),
    ("опыт $elemMatch", {"experience": {"$elemMatch": {"months": {"$gte": OPYT_MESYACEV}}}}),
    ("есть контакты $exists", {"contacts": {"$exists": True}}),
]
for nazvanie, filtr in kriterii:
    print(f"  {nazvanie:24} {resumes.count_documents(filtr):2} из {resumes.count_documents({})}")
C++ · mongocxxсчётчики по каждому требованию
    // ── 2. Каждое требование по отдельности ───────────────────────────
    title("2. Сколько резюме проходит каждое требование");

    auto vsego = resumes.count_documents(make_document());
    std::vector<std::pair<std::string, bsoncxx::document::value>> kriterii;
    kriterii.emplace_back("навыки $all",
        make_document(kvp("skills", make_document(kvp("$all", spisok_navykov)))));
    kriterii.emplace_back("город или переезд $or",
        make_document(kvp("$or", gorod_ili_pereezd)));
    kriterii.emplace_back("зарплата $lte",
        make_document(kvp("salary", make_document(kvp("$lte", potolok)))));
    kriterii.emplace_back("опыт $elemMatch", make_document(kvp("experience", opyt)));
    kriterii.emplace_back("есть контакты $exists",
        make_document(kvp("contacts", make_document(kvp("$exists", true)))));
    for (const auto& kriteriy : kriterii) {
        auto skolko = resumes.count_documents(kriteriy.second.view());
        std::cout << "  " << pad(kriteriy.first, 24) << " " << right(std::to_string(skolko), 2)
                  << " из " << vsego << std::endl;
    }
Go · mongo-driverсчётчики по каждому требованию
	// ── 2. Каждое требование по отдельности ───────────────────────────
	title("2. Сколько резюме проходит каждое требование")

	vsego, _ := resumes.CountDocuments(ctx, bson.D{})
	kriterii := []struct {
		nazvanie string
		filtr    bson.D
	}{
		{"навыки $all", bson.D{{Key: "skills", Value: bson.D{{Key: "$all", Value: spisokNavykov}}}}},
		{"город или переезд $or", bson.D{{Key: "$or", Value: gorodIliPereezd}}},
		{"зарплата $lte", bson.D{{Key: "salary", Value: bson.D{{Key: "$lte", Value: potolok}}}}},
		{"опыт $elemMatch", bson.D{{Key: "experience", Value: opyt}}},
		{"есть контакты $exists", bson.D{{Key: "contacts", Value: bson.D{{Key: "$exists", Value: true}}}}},
	}
	for _, k := range kriterii {
		skolko, _ := resumes.CountDocuments(ctx, k.filtr)
		fmt.Printf("  %s %s из %d\n", pad(k.nazvanie, 24), right(fmt.Sprint(skolko), 2), vsego)
	}
Ruby · mongoсчётчики по каждому требованию
# ── 2. Каждое требование по отдельности ───────────────────────────────────
title("2. Сколько резюме проходит каждое требование")

kriterii = [
  ["навыки $all", { "skills" => { "$all" => NAVYKI } }],
  ["город или переезд $or", { "$or" => [{ "city" => GOROD }, { "ready_to_move" => true }] }],
  ["зарплата $lte", { "salary" => { "$lte" => POTOLOK } }],
  ["опыт $elemMatch", { "experience" => { "$elemMatch" => { "months" => { "$gte" => OPYT_MESYACEV } } } }],
  ["есть контакты $exists", { "contacts" => { "$exists" => true } }],
]
vsego = resumes.count_documents({})
kriterii.each do |nazvanie, filtr|
  puts "  #{pad(nazvanie, 24)} #{resumes.count_documents(filtr).to_s.rjust(2)} из #{vsego}"
end

Пять фильтров подряд — пять операторов из разных глав: $all требует оба навыка, $or допускает город или переезд, $lte ограничивает зарплату, $elemMatch ищет место работы нужной длины, $exists проверяет, что контакты заполнены. Каждый вызов count_documents возвращает число, и отчёт печатает его рядом с общим количеством резюме.

§4Все требования сразу

Теперь те же условия соединяются в один фильтр. Пары верхнего уровня работают как «и», а $or стоит среди них обычной парой и даёт выбор только между своими условиями (глава 2.4 §3).

Python · PyMongoодин фильтр из всех требований
# ── 3. Все требования сразу ───────────────────────────────────────────────
title("3. Кандидаты, проходящие все требования")

filtr = {
    "skills": {"$all": NAVYKI},
    "salary": {"$lte": POTOLOK},
    "experience": {"$elemMatch": {"months": {"$gte": OPYT_MESYACEV}}},
    "$or": [{"city": GOROD}, {"ready_to_move": True}],
}
print(f"  {'ФИО':18} {'город':16} {'зарплата':>9}  навыки")
for doc in resumes.find(filtr).sort("salary", 1):
    print(f"  {doc['fio']:18} {doc['city']:16} {doc['salary']:>9}  {', '.join(doc['skills'])}")
print(f"  подошло: {resumes.count_documents(filtr)}")
C++ · mongocxxодин фильтр из всех требований
    // ── 3. Все требования сразу ───────────────────────────────────────
    title("3. Кандидаты, проходящие все требования");

    auto filtr = make_document(
        kvp("skills", make_document(kvp("$all", spisok_navykov))),
        kvp("salary", make_document(kvp("$lte", potolok))),
        kvp("experience", opyt),
        kvp("$or", gorod_ili_pereezd));

    std::cout << "  " << pad("ФИО", 18) << " " << pad("город", 16) << " "
              << right("зарплата", 9) << "  навыки" << std::endl;
    mongocxx::options::find options;
    options.sort(make_document(kvp("salary", 1)));
    for (auto&& doc : resumes.find(filtr.view(), options)) {
        std::cout << "  " << pad(std::string(doc["fio"].get_string().value), 18)
                  << " " << pad(std::string(doc["city"].get_string().value), 16)
                  << " " << right(std::to_string(doc["salary"].get_int32().value), 9)
                  << "  " << cherez_zapyatuyu(doc["skills"].get_array().value) << std::endl;
    }
    std::cout << "  подошло: " << resumes.count_documents(filtr.view()) << std::endl;
Go · mongo-driverодин фильтр из всех требований
	// ── 3. Все требования сразу ───────────────────────────────────────
	title("3. Кандидаты, проходящие все требования")

	filtr := bson.D{
		{Key: "skills", Value: bson.D{{Key: "$all", Value: spisokNavykov}}},
		{Key: "salary", Value: bson.D{{Key: "$lte", Value: potolok}}},
		{Key: "experience", Value: opyt},
		{Key: "$or", Value: gorodIliPereezd},
	}
	fmt.Printf("  %s %s %s  навыки\n", pad("ФИО", 18), pad("город", 16), right("зарплата", 9))
	cursor, _ := resumes.Find(ctx, filtr, options.Find().SetSort(bson.D{{Key: "salary", Value: 1}}))
	var podoshli []Resume
	cursor.All(ctx, &podoshli)
	for _, r := range podoshli {
		fmt.Printf("  %s %s %s  %s\n", pad(r.Fio, 18), pad(r.City, 16),
			right(fmt.Sprint(r.Salary), 9), strings.Join(r.Skills, ", "))
	}
	fmt.Println("  подошло:", len(podoshli))
Ruby · mongoодин фильтр из всех требований
# ── 3. Все требования сразу ───────────────────────────────────────────────
title("3. Кандидаты, проходящие все требования")

filtr = {
  "skills" => { "$all" => NAVYKI },
  "salary" => { "$lte" => POTOLOK },
  "experience" => { "$elemMatch" => { "months" => { "$gte" => OPYT_MESYACEV } } },
  "$or" => [{ "city" => GOROD }, { "ready_to_move" => true }],
}
puts "  #{pad('ФИО', 18)} #{pad('город', 16)} #{'зарплата'.rjust(9)}  навыки"
resumes.find(filtr).sort({ "salary" => 1 }).each do |doc|
  puts "  #{pad(doc['fio'], 18)} #{pad(doc['city'], 16)} #{doc['salary'].to_s.rjust(9)}  #{doc['skills'].join(', ')}"
end
puts "  подошло: #{resumes.count_documents(filtr)}"

Результат выводится таблицей: ФИО, город, зарплата и навыки в одну строку. Сортировка по зарплате ставит наверх тех, кто просит меньше. Под таблицей — число подошедших, его берёт тот же фильтр через count_documents.

§5Текст и подбор вакансий

Четвёртый раздел ищет по части строки: шаблон ^junior с параметром "i" находит должности, которые начинаются со слова «Junior» в любом регистре. Пятый подбирает вакансии под ожидание кандидата: нижняя граница вилки не выше ожидания, верхняя — не ниже.

Python · PyMongoшаблон и вилка зарплаты
# ── 4. Поиск по части строки ──────────────────────────────────────────────
title("4. Должности, начинающиеся с Junior")

for doc in resumes.find({"position": {"$regex": "^junior", "$options": "i"}},
                        {"_id": 0, "fio": 1, "position": 1}):
    print(f"  {doc['fio']:18} {doc['position']}")

# ── 5. Подходящие вакансии ────────────────────────────────────────────────
title("5. Вакансии под ожидание 90 000")

OZHIDANIE = 90000
vilka = {"salary.from": {"$lte": OZHIDANIE}, "salary.to": {"$gte": OZHIDANIE}}
for doc in vacancies.find(vilka, {"_id": 0, "title": 1, "company": 1, "city": 1, "salary": 1}):
    zarplata = doc["salary"]
    print(f"  {doc['title']:26} {doc['company']:16} {doc['city']:16} "
          f"{zarplata['from']}–{zarplata['to']}")
print(f"  подходит вакансий: {vacancies.count_documents(vilka)}")
C++ · mongocxxшаблон и вилка зарплаты
    // ── 4. Поиск по части строки ──────────────────────────────────────
    title("4. Должности, начинающиеся с Junior");

    auto shablon = make_document(kvp("position",
        make_document(kvp("$regex", "^junior"), kvp("$options", "i"))));
    for (auto&& doc : resumes.find(shablon.view())) {
        std::cout << "  " << pad(std::string(doc["fio"].get_string().value), 18)
                  << " " << std::string(doc["position"].get_string().value) << std::endl;
    }

    // ── 5. Подходящие вакансии ────────────────────────────────────────
    title("5. Вакансии под ожидание 90 000");

    const int ozhidanie = 90000;
    auto vilka = make_document(
        kvp("salary.from", make_document(kvp("$lte", ozhidanie))),
        kvp("salary.to", make_document(kvp("$gte", ozhidanie))));
    for (auto&& doc : vacancies.find(vilka.view())) {
        auto zarplata = doc["salary"].get_document().value;
        std::cout << "  " << pad(std::string(doc["title"].get_string().value), 26)
                  << " " << pad(std::string(doc["company"].get_string().value), 16)
                  << " " << pad(std::string(doc["city"].get_string().value), 16)
                  << " " << zarplata["from"].get_int32().value << "–"
                  << zarplata["to"].get_int32().value << std::endl;
    }
    std::cout << "  подходит вакансий: " << vacancies.count_documents(vilka.view()) << std::endl;
Go · mongo-driverшаблон и вилка зарплаты
	// ── 4. Поиск по части строки ──────────────────────────────────────
	title("4. Должности, начинающиеся с Junior")

	cursor, _ = resumes.Find(ctx, bson.D{{Key: "position", Value: bson.D{
		{Key: "$regex", Value: "^junior"}, {Key: "$options", Value: "i"}}}})
	var junior []Resume
	cursor.All(ctx, &junior)
	for _, r := range junior {
		fmt.Printf("  %s %s\n", pad(r.Fio, 18), r.Position)
	}

	// ── 5. Подходящие вакансии ────────────────────────────────────────
	title("5. Вакансии под ожидание 90 000")

	ozhidanie := 90000
	vilka := bson.D{
		{Key: "salary.from", Value: bson.D{{Key: "$lte", Value: ozhidanie}}},
		{Key: "salary.to", Value: bson.D{{Key: "$gte", Value: ozhidanie}}},
	}
	cursor, _ = vacancies.Find(ctx, vilka)
	var podhodyat []Vacancy
	cursor.All(ctx, &podhodyat)
	for _, v := range podhodyat {
		fmt.Printf("  %s %s %s %d–%d\n", pad(v.Title, 26), pad(v.Company, 16), pad(v.City, 16),
			v.Salary.From, v.Salary.To)
	}
	fmt.Println("  подходит вакансий:", len(podhodyat))
Ruby · mongoшаблон и вилка зарплаты
# ── 4. Поиск по части строки ──────────────────────────────────────────────
title("4. Должности, начинающиеся с Junior")

resumes.find({ "position" => { "$regex" => "^junior", "$options" => "i" } },
             projection: { "_id" => 0, "fio" => 1, "position" => 1 })
       .each { |doc| puts "  #{pad(doc['fio'], 18)} #{doc['position']}" }

# ── 5. Подходящие вакансии ────────────────────────────────────────────────
title("5. Вакансии под ожидание 90 000")

OZHIDANIE = 90_000
vilka = { "salary.from" => { "$lte" => OZHIDANIE }, "salary.to" => { "$gte" => OZHIDANIE } }
vacancies.find(vilka, projection: { "_id" => 0, "title" => 1, "company" => 1, "city" => 1, "salary" => 1 })
         .each do |doc|
  zarplata = doc["salary"]
  puts "  #{pad(doc['title'], 26)} #{pad(doc['company'], 16)} #{pad(doc['city'], 16)} " \
       "#{zarplata['from']}–#{zarplata['to']}"
end
puts "  подходит вакансий: #{vacancies.count_documents(vilka)}"

Вилка лежит во вложенном документе, поэтому условия стоят на именах с точкой — salary.from и salary.to. В выводе обе границы печатаются через тире: значения берутся из того же вложенного документа.

§6Собеседования за неделю

Шестой раздел считает собеседования за неделю с 21 по 27 сентября. Период задан полуинтервалом: от начала 21-го числа до начала 28-го, иначе события последнего дня потерялись бы (глава 2.2 §4).

Python · PyMongoпериод дат и разбивка по этапам
# ── 6. Собеседования за неделю ────────────────────────────────────────────
title("6. Собеседования с 21 по 27 сентября")

nedelya = {"when": {"$gte": datetime(2026, 9, 21), "$lt": datetime(2026, 9, 28)}}
print(f"  всего за неделю: {interviews.count_documents(nedelya)}")
for etap in ("скрининг", "техническое", "финальное"):
    print(f"    {etap:12} {interviews.count_documents({**nedelya, 'stage': etap}):2}")
print(f"  очно: {interviews.count_documents({**nedelya, 'format': 'очно'})}, "
      f"видеозвонком: {interviews.count_documents({**nedelya, 'format': 'видеозвонок'})}")
C++ · mongocxxпериод дат и разбивка по этапам
    // ── 6. Собеседования за неделю ────────────────────────────────────
    title("6. Собеседования с 21 по 27 сентября");

    auto sep21 = bsoncxx::types::b_date{std::chrono::milliseconds{1789948800000}};  // 21.09.2026 00:00 UTC
    auto sep28 = bsoncxx::types::b_date{std::chrono::milliseconds{1790553600000}};  // 28.09.2026 00:00 UTC
    auto nedelya = make_document(kvp("when", make_document(kvp("$gte", sep21), kvp("$lt", sep28))));
    std::cout << "  всего за неделю: " << interviews.count_documents(nedelya.view()) << std::endl;
    for (const std::string& etap : {"скрининг", "техническое", "финальное"}) {
        auto filtr_etapa = make_document(
            kvp("when", make_document(kvp("$gte", sep21), kvp("$lt", sep28))),
            kvp("stage", etap));
        std::cout << "    " << pad(etap, 12) << " "
                  << right(std::to_string(interviews.count_documents(filtr_etapa.view())), 2) << std::endl;
    }
    auto ochno = make_document(kvp("when", make_document(kvp("$gte", sep21), kvp("$lt", sep28))),
                               kvp("format", "очно"));
    auto video = make_document(kvp("when", make_document(kvp("$gte", sep21), kvp("$lt", sep28))),
                               kvp("format", "видеозвонок"));
    std::cout << "  очно: " << interviews.count_documents(ochno.view())
              << ", видеозвонком: " << interviews.count_documents(video.view()) << std::endl;
Go · mongo-driverпериод дат и разбивка по этапам
	// ── 6. Собеседования за неделю ────────────────────────────────────
	title("6. Собеседования с 21 по 27 сентября")

	sep21 := time.Date(2026, 9, 21, 0, 0, 0, 0, time.UTC)
	sep28 := time.Date(2026, 9, 28, 0, 0, 0, 0, time.UTC)
	nedelya := bson.D{{Key: "when", Value: bson.D{{Key: "$gte", Value: sep21}, {Key: "$lt", Value: sep28}}}}
	zaNedelyu, _ := interviews.CountDocuments(ctx, nedelya)
	fmt.Println("  всего за неделю:", zaNedelyu)
	// К общему фильтру добавляем условие копией: append по общему срезу
	// может перезаписать данные соседних вызовов.
	sUsloviem := func(key, value string) bson.D {
		return append(append(bson.D{}, nedelya...), bson.E{Key: key, Value: value})
	}
	for _, etap := range []string{"скрининг", "техническое", "финальное"} {
		skolko, _ := interviews.CountDocuments(ctx, sUsloviem("stage", etap))
		fmt.Printf("    %s %s\n", pad(etap, 12), right(fmt.Sprint(skolko), 2))
	}
	ochno, _ := interviews.CountDocuments(ctx, sUsloviem("format", "очно"))
	video, _ := interviews.CountDocuments(ctx, sUsloviem("format", "видеозвонок"))
	fmt.Printf("  очно: %d, видеозвонком: %d\n", ochno, video)
Ruby · mongoпериод дат и разбивка по этапам
# ── 6. Собеседования за неделю ────────────────────────────────────────────
title("6. Собеседования с 21 по 27 сентября")

nedelya = { "when" => { "$gte" => Time.utc(2026, 9, 21), "$lt" => Time.utc(2026, 9, 28) } }
puts "  всего за неделю: #{interviews.count_documents(nedelya)}"
["скрининг", "техническое", "финальное"].each do |etap|
  puts "    #{pad(etap, 12)} #{interviews.count_documents(nedelya.merge('stage' => etap)).to_s.rjust(2)}"
end
puts "  очно: #{interviews.count_documents(nedelya.merge('format' => 'очно'))}, " \
     "видеозвонком: #{interviews.count_documents(nedelya.merge('format' => 'видеозвонок'))}"

К периоду добавляются условия по этапу и формату — те же пары фильтра, соединённые по «и». Общее число и суммы по этапам считаются отдельными запросами, поэтому их можно сверить: скрининг, техническое и финальное вместе дают столько же, сколько строка «всего за неделю».

§7Проверка данных

Седьмой раздел ищет дефекты в самих данных: пропущенные поля, значения не того типа, пустые массивы и несогласованные границы вилки. Такие проверки запускают по расписанию или перед выгрузкой, чтобы ошибка не дошла до отчёта.

Python · PyMongoпроверки данных одним списком
# ── 7. Проверка данных ────────────────────────────────────────────────────
title("7. Проверка данных")

proverki = [
    ("резюме без даты обновления", resumes, {"updated": {"$exists": False}}),
    ("зарплата записана не числом", resumes, {"salary": {"$not": {"$type": "number"}}}),
    ("пустой список мест работы", resumes, {"experience": {"$size": 0}}),
    ("нет ни одного навыка", resumes, {"skills.0": {"$exists": False}}),
    ("перевёрнутая вилка", vacancies, {"$expr": {"$gt": ["$salary.from", "$salary.to"]}}),
    ("вакансия закрыта", vacancies, {"is_open": {"$ne": True}}),
]
for nazvanie, col, filtr in proverki:
    skolko = col.count_documents(filtr)
    print(f"  {nazvanie:30} {skolko:2}" + ("  ← проверить" if skolko else ""))
C++ · mongocxxпроверки данных одним списком
    // ── 7. Проверка данных ────────────────────────────────────────────
    title("7. Проверка данных");

    std::vector<std::tuple<std::string, mongocxx::collection, bsoncxx::document::value>> proverki;
    proverki.emplace_back("резюме без даты обновления", resumes,
        make_document(kvp("updated", make_document(kvp("$exists", false)))));
    proverki.emplace_back("зарплата записана не числом", resumes,
        make_document(kvp("salary", make_document(kvp("$not", make_document(kvp("$type", "number")))))));
    proverki.emplace_back("пустой список мест работы", resumes,
        make_document(kvp("experience", make_document(kvp("$size", 0)))));
    proverki.emplace_back("нет ни одного навыка", resumes,
        make_document(kvp("skills.0", make_document(kvp("$exists", false)))));
    proverki.emplace_back("перевёрнутая вилка", vacancies,
        make_document(kvp("$expr", make_document(kvp("$gt", make_array("$salary.from", "$salary.to"))))));
    proverki.emplace_back("вакансия закрыта", vacancies,
        make_document(kvp("is_open", make_document(kvp("$ne", true)))));
    for (auto& proverka : proverki) {
        auto skolko = std::get<1>(proverka).count_documents(std::get<2>(proverka).view());
        std::cout << "  " << pad(std::get<0>(proverka), 30) << " " << right(std::to_string(skolko), 2)
                  << (skolko ? "  ← проверить" : "") << std::endl;
    }
Go · mongo-driverпроверки данных одним списком
	// ── 7. Проверка данных ────────────────────────────────────────────
	title("7. Проверка данных")

	proverki := []struct {
		nazvanie string
		col      *mongo.Collection
		filtr    bson.D
	}{
		{"резюме без даты обновления", resumes,
			bson.D{{Key: "updated", Value: bson.D{{Key: "$exists", Value: false}}}}},
		{"зарплата записана не числом", resumes,
			bson.D{{Key: "salary", Value: bson.D{{Key: "$not", Value: bson.D{{Key: "$type", Value: "number"}}}}}}},
		{"пустой список мест работы", resumes,
			bson.D{{Key: "experience", Value: bson.D{{Key: "$size", Value: 0}}}}},
		{"нет ни одного навыка", resumes,
			bson.D{{Key: "skills.0", Value: bson.D{{Key: "$exists", Value: false}}}}},
		{"перевёрнутая вилка", vacancies,
			bson.D{{Key: "$expr", Value: bson.D{{Key: "$gt", Value: bson.A{"$salary.from", "$salary.to"}}}}}},
		{"вакансия закрыта", vacancies,
			bson.D{{Key: "is_open", Value: bson.D{{Key: "$ne", Value: true}}}}},
	}
	for _, p := range proverki {
		skolko, _ := p.col.CountDocuments(ctx, p.filtr)
		pometka := ""
		if skolko > 0 {
			pometka = "  ← проверить"
		}
		fmt.Printf("  %s %s%s\n", pad(p.nazvanie, 30), right(fmt.Sprint(skolko), 2), pometka)
	}
Ruby · mongoпроверки данных одним списком
# ── 7. Проверка данных ────────────────────────────────────────────────────
title("7. Проверка данных")

proverki = [
  ["резюме без даты обновления", resumes, { "updated" => { "$exists" => false } }],
  ["зарплата записана не числом", resumes, { "salary" => { "$not" => { "$type" => "number" } } }],
  ["пустой список мест работы", resumes, { "experience" => { "$size" => 0 } }],
  ["нет ни одного навыка", resumes, { "skills.0" => { "$exists" => false } }],
  ["перевёрнутая вилка", vacancies, { "$expr" => { "$gt" => ["$salary.from", "$salary.to"] } }],
  ["вакансия закрыта", vacancies, { "is_open" => { "$ne" => true } }],
]
proverki.each do |nazvanie, col, filtr_proverki|
  skolko = col.count_documents(filtr_proverki)
  puts "  #{pad(nazvanie, 30)} #{skolko.to_s.rjust(2)}#{skolko.zero? ? '' : '  ← проверить'}"
end

Каждая строка — отдельный оператор из модуля 2: $exists находит резюме без даты обновления, $not с $type — зарплату, записанную не числом, $size — пустой список мест работы, "skills.0" с $exists — резюме без единого навыка, $expr — перевёрнутую вилку. Ноль в строке означает, что такого дефекта в базе нет; ненулевое число помечается стрелкой.

§8Что считает программа

Фильтр отвечает на вопрос «какие документы подходят», но не объясняет, почему документ не подошёл. Последний раздел отчёта перебирает все резюме в программе и для каждого собирает список причин отказа.

Python · PyMongoпричины отказа на стороне программы
# ── 8. Кто не подошёл и почему ────────────────────────────────────────────
title("8. Кто не прошёл отбор")

for doc in resumes.find({}, {"_id": 0, "fio": 1, "skills": 1, "salary": 1, "city": 1,
                             "ready_to_move": 1, "experience": 1}):
    prichiny = []
    if not set(NAVYKI) <= set(doc.get("skills", [])):
        prichiny.append("нет навыков")
    if doc["salary"] > POTOLOK:
        prichiny.append("зарплата выше потолка")
    if doc["city"] != GOROD and not doc.get("ready_to_move"):
        prichiny.append("другой город без переезда")
    if not any(m.get("months", 0) >= OPYT_MESYACEV for m in doc.get("experience", [])):
        prichiny.append("мало опыта")
    if prichiny:
        print(f"  {doc['fio']:18} {'; '.join(prichiny)}")

client.close()
C++ · mongocxxпричины отказа на стороне программы
    // ── 8. Кто не подошёл и почему ────────────────────────────────────
    title("8. Кто не прошёл отбор");

    for (auto&& doc : resumes.find(make_document())) {
        std::vector<std::string> prichiny;
        for (const auto& nuzhen : navyki) {
            bool est = false;
            for (auto&& navyk : doc["skills"].get_array().value)
                if (std::string(navyk.get_string().value) == nuzhen) est = true;
            if (!est) { prichiny.push_back("нет навыков"); break; }
        }
        if (doc["salary"].get_int32().value > potolok) prichiny.push_back("зарплата выше потолка");
        bool gotov = doc["ready_to_move"] && doc["ready_to_move"].get_bool().value;
        if (std::string(doc["city"].get_string().value) != gorod && !gotov)
            prichiny.push_back("другой город без переезда");
        bool hvatit_opyta = false;
        if (doc["experience"])
            for (auto&& mesto : doc["experience"].get_array().value)
                if (mesto.get_document().value["months"].get_int32().value >= opyt_mesyacev)
                    hvatit_opyta = true;
        if (!hvatit_opyta) prichiny.push_back("мало опыта");
        if (!prichiny.empty()) {
            std::cout << "  " << pad(std::string(doc["fio"].get_string().value), 18) << " ";
            for (size_t i = 0; i < prichiny.size(); ++i)
                std::cout << (i ? "; " : "") << prichiny[i];
            std::cout << std::endl;
        }
    }
}
Go · mongo-driverпричины отказа на стороне программы
	// ── 8. Кто не подошёл и почему ────────────────────────────────────
	title("8. Кто не прошёл отбор")

	cursor, _ = resumes.Find(ctx, bson.D{})
	var vse []Resume
	cursor.All(ctx, &vse)
	for _, r := range vse {
		var prichiny []string
		for _, nuzhen := range navyki {
			est := false
			for _, navyk := range r.Skills {
				if navyk == nuzhen {
					est = true
				}
			}
			if !est {
				prichiny = append(prichiny, "нет навыков")
				break
			}
		}
		if r.Salary > potolok {
			prichiny = append(prichiny, "зарплата выше потолка")
		}
		if r.City != gorod && !r.ReadyToMove {
			prichiny = append(prichiny, "другой город без переезда")
		}
		hvatitOpyta := false
		for _, mesto := range r.Experience {
			if mesto.Months >= opytMesyacev {
				hvatitOpyta = true
			}
		}
		if !hvatitOpyta {
			prichiny = append(prichiny, "мало опыта")
		}
		if len(prichiny) > 0 {
			fmt.Printf("  %s %s\n", pad(r.Fio, 18), strings.Join(prichiny, "; "))
		}
	}
}
Ruby · mongoпричины отказа на стороне программы
# ── 8. Кто не подошёл и почему ────────────────────────────────────────────
title("8. Кто не прошёл отбор")

resumes.find({}, projection: { "_id" => 0, "fio" => 1, "skills" => 1, "salary" => 1,
                               "city" => 1, "ready_to_move" => 1, "experience" => 1 }).each do |doc|
  prichiny = []
  prichiny << "нет навыков" unless (NAVYKI - (doc["skills"] || [])).empty?
  prichiny << "зарплата выше потолка" if doc["salary"] > POTOLOK
  prichiny << "другой город без переезда" if doc["city"] != GOROD && !doc["ready_to_move"]
  prichiny << "мало опыта" unless (doc["experience"] || []).any? { |m| (m["months"] || 0) >= OPYT_MESYACEV }
  puts "  #{pad(doc['fio'], 18)} #{prichiny.join('; ')}" unless prichiny.empty?
end

client.close

Здесь нет запросов с условиями: скрипт читает все девять резюме и сравнивает поля средствами языка. Так делают, когда к результату нужно объяснение — например, чтобы показать рекрутеру, какое требование смягчить. На больших коллекциях так не поступают: туда приходит слишком много документов, и отбор оставляют серверу.

§9Скрипт целиком

Ниже — тот же файл без пропусков, а под ним вывод его запуска на стенде курса.

Python · PyMongoscripts/hh_search
"""Подбор кандидатов: что умеет язык фильтров из модуля 2.

Скрипт решает прикладную задачу на базе hh: подобрать кандидатов под
требования вакансии, посмотреть, сколько резюме проходит каждое требование
по отдельности, найти подходящие вакансии и проверить данные на пропуски,
неверные типы и несогласованные поля.

    cd scripts
    python3 hh_search.py

    docker compose run --rm python scripts/hh_search.py    # в контейнере
"""

import os
from datetime import datetime

from pymongo import MongoClient

client = MongoClient(os.environ.get("MONGO_URI", "mongodb://localhost:27017/"))
hh = client["hh"]
resumes = hh["resumes"]
vacancies = hh["vacancies"]
interviews = hh["interviews"]


def title(text):
    print("\n" + text)
    print("─" * len(text))


# ── 1. Требования вакансии ────────────────────────────────────────────────
NAVYKI = ["Python", "SQL"]
GOROD = "Ярославль"
POTOLOK = 100000
OPYT_MESYACEV = 6

title("1. Требования")
print(f"  навыки: {' и '.join(NAVYKI)}")
print(f"  город: {GOROD} или готовность к переезду")
print(f"  зарплата: не больше {POTOLOK}")
print(f"  опыт: место работы от {OPYT_MESYACEV} месяцев")

# ── 2. Каждое требование по отдельности ───────────────────────────────────
title("2. Сколько резюме проходит каждое требование")

kriterii = [
    ("навыки $all", {"skills": {"$all": NAVYKI}}),
    ("город или переезд $or", {"$or": [{"city": GOROD}, {"ready_to_move": True}]}),
    ("зарплата $lte", {"salary": {"$lte": POTOLOK}}),
    ("опыт $elemMatch", {"experience": {"$elemMatch": {"months": {"$gte": OPYT_MESYACEV}}}}),
    ("есть контакты $exists", {"contacts": {"$exists": True}}),
]
for nazvanie, filtr in kriterii:
    print(f"  {nazvanie:24} {resumes.count_documents(filtr):2} из {resumes.count_documents({})}")

# ── 3. Все требования сразу ───────────────────────────────────────────────
title("3. Кандидаты, проходящие все требования")

filtr = {
    "skills": {"$all": NAVYKI},
    "salary": {"$lte": POTOLOK},
    "experience": {"$elemMatch": {"months": {"$gte": OPYT_MESYACEV}}},
    "$or": [{"city": GOROD}, {"ready_to_move": True}],
}
print(f"  {'ФИО':18} {'город':16} {'зарплата':>9}  навыки")
for doc in resumes.find(filtr).sort("salary", 1):
    print(f"  {doc['fio']:18} {doc['city']:16} {doc['salary']:>9}  {', '.join(doc['skills'])}")
print(f"  подошло: {resumes.count_documents(filtr)}")

# ── 4. Поиск по части строки ──────────────────────────────────────────────
title("4. Должности, начинающиеся с Junior")

for doc in resumes.find({"position": {"$regex": "^junior", "$options": "i"}},
                        {"_id": 0, "fio": 1, "position": 1}):
    print(f"  {doc['fio']:18} {doc['position']}")

# ── 5. Подходящие вакансии ────────────────────────────────────────────────
title("5. Вакансии под ожидание 90 000")

OZHIDANIE = 90000
vilka = {"salary.from": {"$lte": OZHIDANIE}, "salary.to": {"$gte": OZHIDANIE}}
for doc in vacancies.find(vilka, {"_id": 0, "title": 1, "company": 1, "city": 1, "salary": 1}):
    zarplata = doc["salary"]
    print(f"  {doc['title']:26} {doc['company']:16} {doc['city']:16} "
          f"{zarplata['from']}–{zarplata['to']}")
print(f"  подходит вакансий: {vacancies.count_documents(vilka)}")

# ── 6. Собеседования за неделю ────────────────────────────────────────────
title("6. Собеседования с 21 по 27 сентября")

nedelya = {"when": {"$gte": datetime(2026, 9, 21), "$lt": datetime(2026, 9, 28)}}
print(f"  всего за неделю: {interviews.count_documents(nedelya)}")
for etap in ("скрининг", "техническое", "финальное"):
    print(f"    {etap:12} {interviews.count_documents({**nedelya, 'stage': etap}):2}")
print(f"  очно: {interviews.count_documents({**nedelya, 'format': 'очно'})}, "
      f"видеозвонком: {interviews.count_documents({**nedelya, 'format': 'видеозвонок'})}")

# ── 7. Проверка данных ────────────────────────────────────────────────────
title("7. Проверка данных")

proverki = [
    ("резюме без даты обновления", resumes, {"updated": {"$exists": False}}),
    ("зарплата записана не числом", resumes, {"salary": {"$not": {"$type": "number"}}}),
    ("пустой список мест работы", resumes, {"experience": {"$size": 0}}),
    ("нет ни одного навыка", resumes, {"skills.0": {"$exists": False}}),
    ("перевёрнутая вилка", vacancies, {"$expr": {"$gt": ["$salary.from", "$salary.to"]}}),
    ("вакансия закрыта", vacancies, {"is_open": {"$ne": True}}),
]
for nazvanie, col, filtr in proverki:
    skolko = col.count_documents(filtr)
    print(f"  {nazvanie:30} {skolko:2}" + ("  ← проверить" if skolko else ""))

# ── 8. Кто не подошёл и почему ────────────────────────────────────────────
title("8. Кто не прошёл отбор")

for doc in resumes.find({}, {"_id": 0, "fio": 1, "skills": 1, "salary": 1, "city": 1,
                             "ready_to_move": 1, "experience": 1}):
    prichiny = []
    if not set(NAVYKI) <= set(doc.get("skills", [])):
        prichiny.append("нет навыков")
    if doc["salary"] > POTOLOK:
        prichiny.append("зарплата выше потолка")
    if doc["city"] != GOROD and not doc.get("ready_to_move"):
        prichiny.append("другой город без переезда")
    if not any(m.get("months", 0) >= OPYT_MESYACEV for m in doc.get("experience", [])):
        prichiny.append("мало опыта")
    if prichiny:
        print(f"  {doc['fio']:18} {'; '.join(prichiny)}")

client.close()
C++ · mongocxxscripts/hh_search
// Подбор кандидатов: что умеет язык фильтров из модуля 2.
//
// Скрипт решает прикладную задачу на базе hh: подобрать кандидатов под
// требования вакансии, посмотреть, сколько резюме проходит каждое требование
// по отдельности, найти подходящие вакансии и проверить данные на пропуски,
// неверные типы и несогласованные поля.
//
//     cd scripts
//     g++ -std=c++17 hh_search.cpp -o hh_search $(pkg-config --cflags --libs libmongocxx1) && ./hh_search
//
//     docker compose run --rm cpp scripts/hh_search.cpp    # в контейнере

#include <chrono>
#include <cstdlib>
#include <iostream>
#include <string>
#include <vector>

#include <bsoncxx/builder/basic/array.hpp>
#include <bsoncxx/builder/basic/document.hpp>
#include <bsoncxx/builder/basic/kvp.hpp>
#include <bsoncxx/json.hpp>
#include <bsoncxx/types.hpp>
#include <mongocxx/client.hpp>
#include <mongocxx/collection.hpp>
#include <mongocxx/instance.hpp>
#include <mongocxx/options/find.hpp>
#include <mongocxx/uri.hpp>

using bsoncxx::builder::basic::kvp;
using bsoncxx::builder::basic::make_array;
using bsoncxx::builder::basic::make_document;

int simvolov(const std::string& text) {
    int n = 0;
    for (unsigned char c : text)
        if ((c & 0xC0) != 0x80) ++n;
    return n;
}

std::string pad(const std::string& text, int width) {
    int add = width - simvolov(text);
    return add > 0 ? text + std::string(add, ' ') : text;
}

std::string right(const std::string& text, int width) {
    int add = width - simvolov(text);
    return add > 0 ? std::string(add, ' ') + text : text;
}

void title(const std::string& text) {
    std::cout << "\n" << text << "\n";
    for (int i = 0; i < simvolov(text); ++i) std::cout << "─";
    std::cout << std::endl;
}

// Строки массива через запятую — для навыков в таблице.
std::string cherez_zapyatuyu(const bsoncxx::array::view& massiv) {
    std::string out;
    for (auto&& element : massiv) {
        if (!out.empty()) out += ", ";
        out += std::string(element.get_string().value);
    }
    return out;
}

int main() {
    mongocxx::instance instance{};
    const char* env = std::getenv("MONGO_URI");
    mongocxx::client client{mongocxx::uri{env ? env : "mongodb://localhost:27017"}};

    auto hh = client["hh"];
    auto resumes = hh["resumes"];
    auto vacancies = hh["vacancies"];
    auto interviews = hh["interviews"];

    // Требования вакансии.
    const std::vector<std::string> navyki = {"Python", "SQL"};
    const std::string gorod = "Ярославль";
    const int potolok = 100000;
    const int opyt_mesyacev = 6;

    // ── 1. Требования ─────────────────────────────────────────────────
    title("1. Требования");

    std::cout << "  навыки: " << navyki[0] << " и " << navyki[1] << std::endl;
    std::cout << "  город: " << gorod << " или готовность к переезду" << std::endl;
    std::cout << "  зарплата: не больше " << potolok << std::endl;
    std::cout << "  опыт: место работы от " << opyt_mesyacev << " месяцев" << std::endl;

    auto spisok_navykov = make_array(navyki[0], navyki[1]);
    auto gorod_ili_pereezd = make_array(make_document(kvp("city", gorod)),
                                        make_document(kvp("ready_to_move", true)));
    auto opyt = make_document(kvp("$elemMatch",
                    make_document(kvp("months", make_document(kvp("$gte", opyt_mesyacev))))));

    // ── 2. Каждое требование по отдельности ───────────────────────────
    title("2. Сколько резюме проходит каждое требование");

    auto vsego = resumes.count_documents(make_document());
    std::vector<std::pair<std::string, bsoncxx::document::value>> kriterii;
    kriterii.emplace_back("навыки $all",
        make_document(kvp("skills", make_document(kvp("$all", spisok_navykov)))));
    kriterii.emplace_back("город или переезд $or",
        make_document(kvp("$or", gorod_ili_pereezd)));
    kriterii.emplace_back("зарплата $lte",
        make_document(kvp("salary", make_document(kvp("$lte", potolok)))));
    kriterii.emplace_back("опыт $elemMatch", make_document(kvp("experience", opyt)));
    kriterii.emplace_back("есть контакты $exists",
        make_document(kvp("contacts", make_document(kvp("$exists", true)))));
    for (const auto& kriteriy : kriterii) {
        auto skolko = resumes.count_documents(kriteriy.second.view());
        std::cout << "  " << pad(kriteriy.first, 24) << " " << right(std::to_string(skolko), 2)
                  << " из " << vsego << std::endl;
    }

    // ── 3. Все требования сразу ───────────────────────────────────────
    title("3. Кандидаты, проходящие все требования");

    auto filtr = make_document(
        kvp("skills", make_document(kvp("$all", spisok_navykov))),
        kvp("salary", make_document(kvp("$lte", potolok))),
        kvp("experience", opyt),
        kvp("$or", gorod_ili_pereezd));

    std::cout << "  " << pad("ФИО", 18) << " " << pad("город", 16) << " "
              << right("зарплата", 9) << "  навыки" << std::endl;
    mongocxx::options::find options;
    options.sort(make_document(kvp("salary", 1)));
    for (auto&& doc : resumes.find(filtr.view(), options)) {
        std::cout << "  " << pad(std::string(doc["fio"].get_string().value), 18)
                  << " " << pad(std::string(doc["city"].get_string().value), 16)
                  << " " << right(std::to_string(doc["salary"].get_int32().value), 9)
                  << "  " << cherez_zapyatuyu(doc["skills"].get_array().value) << std::endl;
    }
    std::cout << "  подошло: " << resumes.count_documents(filtr.view()) << std::endl;

    // ── 4. Поиск по части строки ──────────────────────────────────────
    title("4. Должности, начинающиеся с Junior");

    auto shablon = make_document(kvp("position",
        make_document(kvp("$regex", "^junior"), kvp("$options", "i"))));
    for (auto&& doc : resumes.find(shablon.view())) {
        std::cout << "  " << pad(std::string(doc["fio"].get_string().value), 18)
                  << " " << std::string(doc["position"].get_string().value) << std::endl;
    }

    // ── 5. Подходящие вакансии ────────────────────────────────────────
    title("5. Вакансии под ожидание 90 000");

    const int ozhidanie = 90000;
    auto vilka = make_document(
        kvp("salary.from", make_document(kvp("$lte", ozhidanie))),
        kvp("salary.to", make_document(kvp("$gte", ozhidanie))));
    for (auto&& doc : vacancies.find(vilka.view())) {
        auto zarplata = doc["salary"].get_document().value;
        std::cout << "  " << pad(std::string(doc["title"].get_string().value), 26)
                  << " " << pad(std::string(doc["company"].get_string().value), 16)
                  << " " << pad(std::string(doc["city"].get_string().value), 16)
                  << " " << zarplata["from"].get_int32().value << "–"
                  << zarplata["to"].get_int32().value << std::endl;
    }
    std::cout << "  подходит вакансий: " << vacancies.count_documents(vilka.view()) << std::endl;

    // ── 6. Собеседования за неделю ────────────────────────────────────
    title("6. Собеседования с 21 по 27 сентября");

    auto sep21 = bsoncxx::types::b_date{std::chrono::milliseconds{1789948800000}};  // 21.09.2026 00:00 UTC
    auto sep28 = bsoncxx::types::b_date{std::chrono::milliseconds{1790553600000}};  // 28.09.2026 00:00 UTC
    auto nedelya = make_document(kvp("when", make_document(kvp("$gte", sep21), kvp("$lt", sep28))));
    std::cout << "  всего за неделю: " << interviews.count_documents(nedelya.view()) << std::endl;
    for (const std::string& etap : {"скрининг", "техническое", "финальное"}) {
        auto filtr_etapa = make_document(
            kvp("when", make_document(kvp("$gte", sep21), kvp("$lt", sep28))),
            kvp("stage", etap));
        std::cout << "    " << pad(etap, 12) << " "
                  << right(std::to_string(interviews.count_documents(filtr_etapa.view())), 2) << std::endl;
    }
    auto ochno = make_document(kvp("when", make_document(kvp("$gte", sep21), kvp("$lt", sep28))),
                               kvp("format", "очно"));
    auto video = make_document(kvp("when", make_document(kvp("$gte", sep21), kvp("$lt", sep28))),
                               kvp("format", "видеозвонок"));
    std::cout << "  очно: " << interviews.count_documents(ochno.view())
              << ", видеозвонком: " << interviews.count_documents(video.view()) << std::endl;

    // ── 7. Проверка данных ────────────────────────────────────────────
    title("7. Проверка данных");

    std::vector<std::tuple<std::string, mongocxx::collection, bsoncxx::document::value>> proverki;
    proverki.emplace_back("резюме без даты обновления", resumes,
        make_document(kvp("updated", make_document(kvp("$exists", false)))));
    proverki.emplace_back("зарплата записана не числом", resumes,
        make_document(kvp("salary", make_document(kvp("$not", make_document(kvp("$type", "number")))))));
    proverki.emplace_back("пустой список мест работы", resumes,
        make_document(kvp("experience", make_document(kvp("$size", 0)))));
    proverki.emplace_back("нет ни одного навыка", resumes,
        make_document(kvp("skills.0", make_document(kvp("$exists", false)))));
    proverki.emplace_back("перевёрнутая вилка", vacancies,
        make_document(kvp("$expr", make_document(kvp("$gt", make_array("$salary.from", "$salary.to"))))));
    proverki.emplace_back("вакансия закрыта", vacancies,
        make_document(kvp("is_open", make_document(kvp("$ne", true)))));
    for (auto& proverka : proverki) {
        auto skolko = std::get<1>(proverka).count_documents(std::get<2>(proverka).view());
        std::cout << "  " << pad(std::get<0>(proverka), 30) << " " << right(std::to_string(skolko), 2)
                  << (skolko ? "  ← проверить" : "") << std::endl;
    }

    // ── 8. Кто не подошёл и почему ────────────────────────────────────
    title("8. Кто не прошёл отбор");

    for (auto&& doc : resumes.find(make_document())) {
        std::vector<std::string> prichiny;
        for (const auto& nuzhen : navyki) {
            bool est = false;
            for (auto&& navyk : doc["skills"].get_array().value)
                if (std::string(navyk.get_string().value) == nuzhen) est = true;
            if (!est) { prichiny.push_back("нет навыков"); break; }
        }
        if (doc["salary"].get_int32().value > potolok) prichiny.push_back("зарплата выше потолка");
        bool gotov = doc["ready_to_move"] && doc["ready_to_move"].get_bool().value;
        if (std::string(doc["city"].get_string().value) != gorod && !gotov)
            prichiny.push_back("другой город без переезда");
        bool hvatit_opyta = false;
        if (doc["experience"])
            for (auto&& mesto : doc["experience"].get_array().value)
                if (mesto.get_document().value["months"].get_int32().value >= opyt_mesyacev)
                    hvatit_opyta = true;
        if (!hvatit_opyta) prichiny.push_back("мало опыта");
        if (!prichiny.empty()) {
            std::cout << "  " << pad(std::string(doc["fio"].get_string().value), 18) << " ";
            for (size_t i = 0; i < prichiny.size(); ++i)
                std::cout << (i ? "; " : "") << prichiny[i];
            std::cout << std::endl;
        }
    }
}
Go · mongo-driverscripts/hh_search
// Подбор кандидатов: что умеет язык фильтров из модуля 2.
//
// Скрипт решает прикладную задачу на базе hh: подобрать кандидатов под
// требования вакансии, посмотреть, сколько резюме проходит каждое требование
// по отдельности, найти подходящие вакансии и проверить данные на пропуски,
// неверные типы и несогласованные поля.
//
//	cd scripts
//	go run hh_search.go
//
//	docker compose run --rm go scripts/hh_search.go    # в контейнере
package main

import (
	"context"
	"fmt"
	"log"
	"os"
	"strings"
	"time"
	"unicode/utf8"

	"go.mongodb.org/mongo-driver/v2/bson"
	"go.mongodb.org/mongo-driver/v2/mongo"
	"go.mongodb.org/mongo-driver/v2/mongo/options"
)

// Resume — поля резюме, которые читает отбор.
type Resume struct {
	Fio         string   `bson:"fio"`
	Position    string   `bson:"position"`
	City        string   `bson:"city"`
	Salary      int      `bson:"salary"`
	ReadyToMove bool     `bson:"ready_to_move"`
	Skills      []string `bson:"skills"`
	Experience  []struct {
		Company string `bson:"company"`
		Role    string `bson:"role"`
		Months  int    `bson:"months"`
	} `bson:"experience"`
}

// Vacancy — вакансия с вилкой зарплаты во вложенном документе.
type Vacancy struct {
	Title   string `bson:"title"`
	Company string `bson:"company"`
	City    string `bson:"city"`
	Salary  struct {
		From int `bson:"from"`
		To   int `bson:"to"`
	} `bson:"salary"`
}

func pad(text string, width int) string {
	if n := utf8.RuneCountInString(text); n < width {
		return text + strings.Repeat(" ", width-n)
	}
	return text
}

func right(text string, width int) string {
	if n := utf8.RuneCountInString(text); n < width {
		return strings.Repeat(" ", width-n) + text
	}
	return text
}

func title(text string) {
	fmt.Println("\n" + text)
	fmt.Println(strings.Repeat("─", utf8.RuneCountInString(text)))
}

// Требования вакансии.
var (
	navyki       = []string{"Python", "SQL"}
	gorod        = "Ярославль"
	potolok      = 100000
	opytMesyacev = 6
)

func main() {
	uri := os.Getenv("MONGO_URI")
	if uri == "" {
		uri = "mongodb://localhost:27017"
	}
	client, err := mongo.Connect(options.Client().ApplyURI(uri))
	if err != nil {
		log.Fatal(err)
	}
	ctx := context.Background()
	defer client.Disconnect(ctx)

	hh := client.Database("hh")
	resumes := hh.Collection("resumes")
	vacancies := hh.Collection("vacancies")
	interviews := hh.Collection("interviews")

	// ── 1. Требования ─────────────────────────────────────────────────
	title("1. Требования")

	fmt.Printf("  навыки: %s\n", strings.Join(navyki, " и "))
	fmt.Printf("  город: %s или готовность к переезду\n", gorod)
	fmt.Printf("  зарплата: не больше %d\n", potolok)
	fmt.Printf("  опыт: место работы от %d месяцев\n", opytMesyacev)

	spisokNavykov := bson.A{}
	for _, n := range navyki {
		spisokNavykov = append(spisokNavykov, n)
	}
	gorodIliPereezd := bson.A{
		bson.D{{Key: "city", Value: gorod}},
		bson.D{{Key: "ready_to_move", Value: true}},
	}
	opyt := bson.D{{Key: "$elemMatch", Value: bson.D{
		{Key: "months", Value: bson.D{{Key: "$gte", Value: opytMesyacev}}}}}}

	// ── 2. Каждое требование по отдельности ───────────────────────────
	title("2. Сколько резюме проходит каждое требование")

	vsego, _ := resumes.CountDocuments(ctx, bson.D{})
	kriterii := []struct {
		nazvanie string
		filtr    bson.D
	}{
		{"навыки $all", bson.D{{Key: "skills", Value: bson.D{{Key: "$all", Value: spisokNavykov}}}}},
		{"город или переезд $or", bson.D{{Key: "$or", Value: gorodIliPereezd}}},
		{"зарплата $lte", bson.D{{Key: "salary", Value: bson.D{{Key: "$lte", Value: potolok}}}}},
		{"опыт $elemMatch", bson.D{{Key: "experience", Value: opyt}}},
		{"есть контакты $exists", bson.D{{Key: "contacts", Value: bson.D{{Key: "$exists", Value: true}}}}},
	}
	for _, k := range kriterii {
		skolko, _ := resumes.CountDocuments(ctx, k.filtr)
		fmt.Printf("  %s %s из %d\n", pad(k.nazvanie, 24), right(fmt.Sprint(skolko), 2), vsego)
	}

	// ── 3. Все требования сразу ───────────────────────────────────────
	title("3. Кандидаты, проходящие все требования")

	filtr := bson.D{
		{Key: "skills", Value: bson.D{{Key: "$all", Value: spisokNavykov}}},
		{Key: "salary", Value: bson.D{{Key: "$lte", Value: potolok}}},
		{Key: "experience", Value: opyt},
		{Key: "$or", Value: gorodIliPereezd},
	}
	fmt.Printf("  %s %s %s  навыки\n", pad("ФИО", 18), pad("город", 16), right("зарплата", 9))
	cursor, _ := resumes.Find(ctx, filtr, options.Find().SetSort(bson.D{{Key: "salary", Value: 1}}))
	var podoshli []Resume
	cursor.All(ctx, &podoshli)
	for _, r := range podoshli {
		fmt.Printf("  %s %s %s  %s\n", pad(r.Fio, 18), pad(r.City, 16),
			right(fmt.Sprint(r.Salary), 9), strings.Join(r.Skills, ", "))
	}
	fmt.Println("  подошло:", len(podoshli))

	// ── 4. Поиск по части строки ──────────────────────────────────────
	title("4. Должности, начинающиеся с Junior")

	cursor, _ = resumes.Find(ctx, bson.D{{Key: "position", Value: bson.D{
		{Key: "$regex", Value: "^junior"}, {Key: "$options", Value: "i"}}}})
	var junior []Resume
	cursor.All(ctx, &junior)
	for _, r := range junior {
		fmt.Printf("  %s %s\n", pad(r.Fio, 18), r.Position)
	}

	// ── 5. Подходящие вакансии ────────────────────────────────────────
	title("5. Вакансии под ожидание 90 000")

	ozhidanie := 90000
	vilka := bson.D{
		{Key: "salary.from", Value: bson.D{{Key: "$lte", Value: ozhidanie}}},
		{Key: "salary.to", Value: bson.D{{Key: "$gte", Value: ozhidanie}}},
	}
	cursor, _ = vacancies.Find(ctx, vilka)
	var podhodyat []Vacancy
	cursor.All(ctx, &podhodyat)
	for _, v := range podhodyat {
		fmt.Printf("  %s %s %s %d–%d\n", pad(v.Title, 26), pad(v.Company, 16), pad(v.City, 16),
			v.Salary.From, v.Salary.To)
	}
	fmt.Println("  подходит вакансий:", len(podhodyat))

	// ── 6. Собеседования за неделю ────────────────────────────────────
	title("6. Собеседования с 21 по 27 сентября")

	sep21 := time.Date(2026, 9, 21, 0, 0, 0, 0, time.UTC)
	sep28 := time.Date(2026, 9, 28, 0, 0, 0, 0, time.UTC)
	nedelya := bson.D{{Key: "when", Value: bson.D{{Key: "$gte", Value: sep21}, {Key: "$lt", Value: sep28}}}}
	zaNedelyu, _ := interviews.CountDocuments(ctx, nedelya)
	fmt.Println("  всего за неделю:", zaNedelyu)
	// К общему фильтру добавляем условие копией: append по общему срезу
	// может перезаписать данные соседних вызовов.
	sUsloviem := func(key, value string) bson.D {
		return append(append(bson.D{}, nedelya...), bson.E{Key: key, Value: value})
	}
	for _, etap := range []string{"скрининг", "техническое", "финальное"} {
		skolko, _ := interviews.CountDocuments(ctx, sUsloviem("stage", etap))
		fmt.Printf("    %s %s\n", pad(etap, 12), right(fmt.Sprint(skolko), 2))
	}
	ochno, _ := interviews.CountDocuments(ctx, sUsloviem("format", "очно"))
	video, _ := interviews.CountDocuments(ctx, sUsloviem("format", "видеозвонок"))
	fmt.Printf("  очно: %d, видеозвонком: %d\n", ochno, video)

	// ── 7. Проверка данных ────────────────────────────────────────────
	title("7. Проверка данных")

	proverki := []struct {
		nazvanie string
		col      *mongo.Collection
		filtr    bson.D
	}{
		{"резюме без даты обновления", resumes,
			bson.D{{Key: "updated", Value: bson.D{{Key: "$exists", Value: false}}}}},
		{"зарплата записана не числом", resumes,
			bson.D{{Key: "salary", Value: bson.D{{Key: "$not", Value: bson.D{{Key: "$type", Value: "number"}}}}}}},
		{"пустой список мест работы", resumes,
			bson.D{{Key: "experience", Value: bson.D{{Key: "$size", Value: 0}}}}},
		{"нет ни одного навыка", resumes,
			bson.D{{Key: "skills.0", Value: bson.D{{Key: "$exists", Value: false}}}}},
		{"перевёрнутая вилка", vacancies,
			bson.D{{Key: "$expr", Value: bson.D{{Key: "$gt", Value: bson.A{"$salary.from", "$salary.to"}}}}}},
		{"вакансия закрыта", vacancies,
			bson.D{{Key: "is_open", Value: bson.D{{Key: "$ne", Value: true}}}}},
	}
	for _, p := range proverki {
		skolko, _ := p.col.CountDocuments(ctx, p.filtr)
		pometka := ""
		if skolko > 0 {
			pometka = "  ← проверить"
		}
		fmt.Printf("  %s %s%s\n", pad(p.nazvanie, 30), right(fmt.Sprint(skolko), 2), pometka)
	}

	// ── 8. Кто не подошёл и почему ────────────────────────────────────
	title("8. Кто не прошёл отбор")

	cursor, _ = resumes.Find(ctx, bson.D{})
	var vse []Resume
	cursor.All(ctx, &vse)
	for _, r := range vse {
		var prichiny []string
		for _, nuzhen := range navyki {
			est := false
			for _, navyk := range r.Skills {
				if navyk == nuzhen {
					est = true
				}
			}
			if !est {
				prichiny = append(prichiny, "нет навыков")
				break
			}
		}
		if r.Salary > potolok {
			prichiny = append(prichiny, "зарплата выше потолка")
		}
		if r.City != gorod && !r.ReadyToMove {
			prichiny = append(prichiny, "другой город без переезда")
		}
		hvatitOpyta := false
		for _, mesto := range r.Experience {
			if mesto.Months >= opytMesyacev {
				hvatitOpyta = true
			}
		}
		if !hvatitOpyta {
			prichiny = append(prichiny, "мало опыта")
		}
		if len(prichiny) > 0 {
			fmt.Printf("  %s %s\n", pad(r.Fio, 18), strings.Join(prichiny, "; "))
		}
	}
}
Ruby · mongoscripts/hh_search
# Подбор кандидатов: что умеет язык фильтров из модуля 2.
#
# Скрипт решает прикладную задачу на базе hh: подобрать кандидатов под
# требования вакансии, посмотреть, сколько резюме проходит каждое требование
# по отдельности, найти подходящие вакансии и проверить данные на пропуски,
# неверные типы и несогласованные поля.
#
#     cd scripts
#     ruby hh_search.rb
#
#     docker compose run --rm ruby scripts/hh_search.rb    # в контейнере

require "mongo"

Mongo::Logger.logger.level = Logger::WARN

client = Mongo::Client.new(ENV.fetch("MONGO_URI", "mongodb://localhost:27017/"))
hh = client.use("hh").database
resumes = hh[:resumes]
vacancies = hh[:vacancies]
interviews = hh[:interviews]

def title(text)
  puts "\n" + text
  puts "─" * text.length
end

def pad(text, width)
  text.to_s.ljust(width)
end

# ── 1. Требования вакансии ────────────────────────────────────────────────
NAVYKI = ["Python", "SQL"].freeze
GOROD = "Ярославль"
POTOLOK = 100_000
OPYT_MESYACEV = 6

title("1. Требования")
puts "  навыки: #{NAVYKI.join(' и ')}"
puts "  город: #{GOROD} или готовность к переезду"
puts "  зарплата: не больше #{POTOLOK}"
puts "  опыт: место работы от #{OPYT_MESYACEV} месяцев"

# ── 2. Каждое требование по отдельности ───────────────────────────────────
title("2. Сколько резюме проходит каждое требование")

kriterii = [
  ["навыки $all", { "skills" => { "$all" => NAVYKI } }],
  ["город или переезд $or", { "$or" => [{ "city" => GOROD }, { "ready_to_move" => true }] }],
  ["зарплата $lte", { "salary" => { "$lte" => POTOLOK } }],
  ["опыт $elemMatch", { "experience" => { "$elemMatch" => { "months" => { "$gte" => OPYT_MESYACEV } } } }],
  ["есть контакты $exists", { "contacts" => { "$exists" => true } }],
]
vsego = resumes.count_documents({})
kriterii.each do |nazvanie, filtr|
  puts "  #{pad(nazvanie, 24)} #{resumes.count_documents(filtr).to_s.rjust(2)} из #{vsego}"
end

# ── 3. Все требования сразу ───────────────────────────────────────────────
title("3. Кандидаты, проходящие все требования")

filtr = {
  "skills" => { "$all" => NAVYKI },
  "salary" => { "$lte" => POTOLOK },
  "experience" => { "$elemMatch" => { "months" => { "$gte" => OPYT_MESYACEV } } },
  "$or" => [{ "city" => GOROD }, { "ready_to_move" => true }],
}
puts "  #{pad('ФИО', 18)} #{pad('город', 16)} #{'зарплата'.rjust(9)}  навыки"
resumes.find(filtr).sort({ "salary" => 1 }).each do |doc|
  puts "  #{pad(doc['fio'], 18)} #{pad(doc['city'], 16)} #{doc['salary'].to_s.rjust(9)}  #{doc['skills'].join(', ')}"
end
puts "  подошло: #{resumes.count_documents(filtr)}"

# ── 4. Поиск по части строки ──────────────────────────────────────────────
title("4. Должности, начинающиеся с Junior")

resumes.find({ "position" => { "$regex" => "^junior", "$options" => "i" } },
             projection: { "_id" => 0, "fio" => 1, "position" => 1 })
       .each { |doc| puts "  #{pad(doc['fio'], 18)} #{doc['position']}" }

# ── 5. Подходящие вакансии ────────────────────────────────────────────────
title("5. Вакансии под ожидание 90 000")

OZHIDANIE = 90_000
vilka = { "salary.from" => { "$lte" => OZHIDANIE }, "salary.to" => { "$gte" => OZHIDANIE } }
vacancies.find(vilka, projection: { "_id" => 0, "title" => 1, "company" => 1, "city" => 1, "salary" => 1 })
         .each do |doc|
  zarplata = doc["salary"]
  puts "  #{pad(doc['title'], 26)} #{pad(doc['company'], 16)} #{pad(doc['city'], 16)} " \
       "#{zarplata['from']}–#{zarplata['to']}"
end
puts "  подходит вакансий: #{vacancies.count_documents(vilka)}"

# ── 6. Собеседования за неделю ────────────────────────────────────────────
title("6. Собеседования с 21 по 27 сентября")

nedelya = { "when" => { "$gte" => Time.utc(2026, 9, 21), "$lt" => Time.utc(2026, 9, 28) } }
puts "  всего за неделю: #{interviews.count_documents(nedelya)}"
["скрининг", "техническое", "финальное"].each do |etap|
  puts "    #{pad(etap, 12)} #{interviews.count_documents(nedelya.merge('stage' => etap)).to_s.rjust(2)}"
end
puts "  очно: #{interviews.count_documents(nedelya.merge('format' => 'очно'))}, " \
     "видеозвонком: #{interviews.count_documents(nedelya.merge('format' => 'видеозвонок'))}"

# ── 7. Проверка данных ────────────────────────────────────────────────────
title("7. Проверка данных")

proverki = [
  ["резюме без даты обновления", resumes, { "updated" => { "$exists" => false } }],
  ["зарплата записана не числом", resumes, { "salary" => { "$not" => { "$type" => "number" } } }],
  ["пустой список мест работы", resumes, { "experience" => { "$size" => 0 } }],
  ["нет ни одного навыка", resumes, { "skills.0" => { "$exists" => false } }],
  ["перевёрнутая вилка", vacancies, { "$expr" => { "$gt" => ["$salary.from", "$salary.to"] } }],
  ["вакансия закрыта", vacancies, { "is_open" => { "$ne" => true } }],
]
proverki.each do |nazvanie, col, filtr_proverki|
  skolko = col.count_documents(filtr_proverki)
  puts "  #{pad(nazvanie, 30)} #{skolko.to_s.rjust(2)}#{skolko.zero? ? '' : '  ← проверить'}"
end

# ── 8. Кто не подошёл и почему ────────────────────────────────────────────
title("8. Кто не прошёл отбор")

resumes.find({}, projection: { "_id" => 0, "fio" => 1, "skills" => 1, "salary" => 1,
                               "city" => 1, "ready_to_move" => 1, "experience" => 1 }).each do |doc|
  prichiny = []
  prichiny << "нет навыков" unless (NAVYKI - (doc["skills"] || [])).empty?
  prichiny << "зарплата выше потолка" if doc["salary"] > POTOLOK
  prichiny << "другой город без переезда" if doc["city"] != GOROD && !doc["ready_to_move"]
  prichiny << "мало опыта" unless (doc["experience"] || []).any? { |m| (m["months"] || 0) >= OPYT_MESYACEV }
  puts "  #{pad(doc['fio'], 18)} #{prichiny.join('; ')}" unless prichiny.empty?
end

client.close
Вывод в терминалрезультат запуска
1. Требования
─────────────
  навыки: Python и SQL
  город: Ярославль или готовность к переезду
  зарплата: не больше 100000
  опыт: место работы от 6 месяцев

2. Сколько резюме проходит каждое требование
────────────────────────────────────────────
  навыки $all               4 из 9
  город или переезд $or     8 из 9
  зарплата $lte             5 из 9
  опыт $elemMatch           6 из 9
  есть контакты $exists     8 из 9

3. Кандидаты, проходящие все требования
───────────────────────────────────────
  ФИО                город             зарплата  навыки
  Пётр Ковалёв       Ярославль            70000  Python, SQL, Git, Docker
  подошло: 1

4. Должности, начинающиеся с Junior
───────────────────────────────────
  Анна Белова        Junior Python-разработчик
  Пётр Ковалёв       Junior Python-разработчик
  Дарья Нечаева      Junior Frontend-разработчик
  Марк Ефремов       Junior Python-разработчик

5. Вакансии под ожидание 90 000
───────────────────────────────
  Junior Python-разработчик  Тензор           Ярославль        60000–90000
  Тестировщик                МедСофт          Казань           70000–95000
  Frontend-разработчик       Retail Lab       Санкт-Петербург  90000–140000
  Инженер сопровождения БД   ЯрКом            Ярославль        80000–120000
  подходит вакансий: 4

6. Собеседования с 21 по 27 сентября
────────────────────────────────────
  всего за неделю: 22
    скрининг      4
    техническое   7
    финальное    11
  очно: 12, видеозвонком: 10

7. Проверка данных
──────────────────
  резюме без даты обновления      1  ← проверить
  зарплата записана не числом     0
  пустой список мест работы       1  ← проверить
  нет ни одного навыка            0
  перевёрнутая вилка              0
  вакансия закрыта                0

8. Кто не прошёл отбор
──────────────────────
  Анна Белова        нет навыков; мало опыта
  Алина Дроздова     зарплата выше потолка
  Игорь Самойлов     нет навыков; зарплата выше потолка
  Дарья Нечаева      нет навыков; мало опыта
  Тимур Валиев       нет навыков; зарплата выше потолка; другой город без переезда
  Ксения Лапина      мало опыта
  Марк Ефремов       нет навыков
  Ольга Пирогова     зарплата выше потолка
Вывод в терминалрезультат запуска
1. Требования
─────────────
  навыки: Python и SQL
  город: Ярославль или готовность к переезду
  зарплата: не больше 100000
  опыт: место работы от 6 месяцев

2. Сколько резюме проходит каждое требование
────────────────────────────────────────────
  навыки $all               4 из 9
  город или переезд $or     8 из 9
  зарплата $lte             5 из 9
  опыт $elemMatch           6 из 9
  есть контакты $exists     8 из 9

3. Кандидаты, проходящие все требования
───────────────────────────────────────
  ФИО                город             зарплата  навыки
  Пётр Ковалёв       Ярославль            70000  Python, SQL, Git, Docker
  подошло: 1

4. Должности, начинающиеся с Junior
───────────────────────────────────
  Анна Белова        Junior Python-разработчик
  Пётр Ковалёв       Junior Python-разработчик
  Дарья Нечаева      Junior Frontend-разработчик
  Марк Ефремов       Junior Python-разработчик

5. Вакансии под ожидание 90 000
───────────────────────────────
  Junior Python-разработчик  Тензор           Ярославль        60000–90000
  Тестировщик                МедСофт          Казань           70000–95000
  Frontend-разработчик       Retail Lab       Санкт-Петербург  90000–140000
  Инженер сопровождения БД   ЯрКом            Ярославль        80000–120000
  подходит вакансий: 4

6. Собеседования с 21 по 27 сентября
────────────────────────────────────
  всего за неделю: 22
    скрининг      4
    техническое   7
    финальное    11
  очно: 12, видеозвонком: 10

7. Проверка данных
──────────────────
  резюме без даты обновления      1  ← проверить
  зарплата записана не числом     0
  пустой список мест работы       1  ← проверить
  нет ни одного навыка            0
  перевёрнутая вилка              0
  вакансия закрыта                0

8. Кто не прошёл отбор
──────────────────────
  Анна Белова        нет навыков; мало опыта
  Алина Дроздова     зарплата выше потолка
  Игорь Самойлов     нет навыков; зарплата выше потолка
  Дарья Нечаева      нет навыков; мало опыта
  Тимур Валиев       нет навыков; зарплата выше потолка; другой город без переезда
  Ксения Лапина      мало опыта
  Марк Ефремов       нет навыков
  Ольга Пирогова     зарплата выше потолка
Вывод в терминалрезультат запуска
1. Требования
─────────────
  навыки: Python и SQL
  город: Ярославль или готовность к переезду
  зарплата: не больше 100000
  опыт: место работы от 6 месяцев

2. Сколько резюме проходит каждое требование
────────────────────────────────────────────
  навыки $all               4 из 9
  город или переезд $or     8 из 9
  зарплата $lte             5 из 9
  опыт $elemMatch           6 из 9
  есть контакты $exists     8 из 9

3. Кандидаты, проходящие все требования
───────────────────────────────────────
  ФИО                город             зарплата  навыки
  Пётр Ковалёв       Ярославль            70000  Python, SQL, Git, Docker
  подошло: 1

4. Должности, начинающиеся с Junior
───────────────────────────────────
  Анна Белова        Junior Python-разработчик
  Пётр Ковалёв       Junior Python-разработчик
  Дарья Нечаева      Junior Frontend-разработчик
  Марк Ефремов       Junior Python-разработчик

5. Вакансии под ожидание 90 000
───────────────────────────────
  Junior Python-разработчик  Тензор           Ярославль        60000–90000
  Тестировщик                МедСофт          Казань           70000–95000
  Frontend-разработчик       Retail Lab       Санкт-Петербург  90000–140000
  Инженер сопровождения БД   ЯрКом            Ярославль        80000–120000
  подходит вакансий: 4

6. Собеседования с 21 по 27 сентября
────────────────────────────────────
  всего за неделю: 22
    скрининг      4
    техническое   7
    финальное    11
  очно: 12, видеозвонком: 10

7. Проверка данных
──────────────────
  резюме без даты обновления      1  ← проверить
  зарплата записана не числом     0
  пустой список мест работы       1  ← проверить
  нет ни одного навыка            0
  перевёрнутая вилка              0
  вакансия закрыта                0

8. Кто не прошёл отбор
──────────────────────
  Анна Белова        нет навыков; мало опыта
  Алина Дроздова     зарплата выше потолка
  Игорь Самойлов     нет навыков; зарплата выше потолка
  Дарья Нечаева      нет навыков; мало опыта
  Тимур Валиев       нет навыков; зарплата выше потолка; другой город без переезда
  Ксения Лапина      мало опыта
  Марк Ефремов       нет навыков
  Ольга Пирогова     зарплата выше потолка
Вывод в терминалрезультат запуска
1. Требования
─────────────
  навыки: Python и SQL
  город: Ярославль или готовность к переезду
  зарплата: не больше 100000
  опыт: место работы от 6 месяцев

2. Сколько резюме проходит каждое требование
────────────────────────────────────────────
  навыки $all               4 из 9
  город или переезд $or     8 из 9
  зарплата $lte             5 из 9
  опыт $elemMatch           6 из 9
  есть контакты $exists     8 из 9

3. Кандидаты, проходящие все требования
───────────────────────────────────────
  ФИО                город             зарплата  навыки
  Пётр Ковалёв       Ярославль            70000  Python, SQL, Git, Docker
  подошло: 1

4. Должности, начинающиеся с Junior
───────────────────────────────────
  Анна Белова        Junior Python-разработчик
  Пётр Ковалёв       Junior Python-разработчик
  Дарья Нечаева      Junior Frontend-разработчик
  Марк Ефремов       Junior Python-разработчик

5. Вакансии под ожидание 90 000
───────────────────────────────
  Junior Python-разработчик  Тензор           Ярославль        60000–90000
  Тестировщик                МедСофт          Казань           70000–95000
  Frontend-разработчик       Retail Lab       Санкт-Петербург  90000–140000
  Инженер сопровождения БД   ЯрКом            Ярославль        80000–120000
  подходит вакансий: 4

6. Собеседования с 21 по 27 сентября
────────────────────────────────────
  всего за неделю: 22
    скрининг      4
    техническое   7
    финальное    11
  очно: 12, видеозвонком: 10

7. Проверка данных
──────────────────
  резюме без даты обновления      1  ← проверить
  зарплата записана не числом     0
  пустой список мест работы       1  ← проверить
  нет ни одного навыка            0
  перевёрнутая вилка              0
  вакансия закрыта                0

8. Кто не прошёл отбор
──────────────────────
  Анна Белова        нет навыков; мало опыта
  Алина Дроздова     зарплата выше потолка
  Игорь Самойлов     нет навыков; зарплата выше потолка
  Дарья Нечаева      нет навыков; мало опыта
  Тимур Валиев       нет навыков; зарплата выше потолка; другой город без переезда
  Ксения Лапина      мало опыта
  Марк Ефремов       нет навыков
  Ольга Пирогова     зарплата выше потолка

§10Как запустить

Скрипт лежит в репозитории практик, в папке scripts. Данные он не меняет, поэтому запускать его можно в любой момент.

Терминал · из корня репозиториязапуск на своём сервере
python3 scripts/hh_search.py
ruby scripts/hh_search.rb
go run scripts/hh_search.go
g++ -std=c++17 scripts/hh_search.cpp -o search $(pkg-config --cflags --libs libmongocxx1) && ./search
Терминал · из корня репозиториязапуск в контейнере
docker compose run --rm python scripts/hh_search.py
docker compose run --rm ruby   scripts/hh_search.rb
docker compose run --rm go     scripts/hh_search.go
docker compose run --rm cpp    scripts/hh_search.cpp

Чтобы подобрать кандидатов под другую вакансию, меняют четыре переменные в начале файла: список навыков, город, потолок зарплаты и срок работы. Остальной код остаётся прежним.

§11Частые ошибки

Что написаноЧто происходитКак правильно
{"skills": НАВЫКИ} для «оба навыка»Массив сравнивается целиком, вместе с порядком: результат пустой{"skills": {"$all": НАВЫКИ}}
Два условия на массив без $elemMatchУсловия выполняются разными элементами массива$elemMatch, как в разделе с опытом
Конец недели задан $lte с датой без времениТеряются события последнего дня$lt с началом следующего дня
Значение из формы подставлено прямо в фильтрДокумент {"$ne": null} вместо строки превращается в условиеПроверить тип или сравнивать через $eq
Все резюме вычитываются, чтобы отобрать их в программеПо сети едут все документы коллекцииОтбор — фильтром на сервере; в программе — только объяснение причин
Что написаноЧто происходитКак правильно
Фильтр собран во временном значении и передан дальшеОбращение к освобождённой памятиДержать auto filtr = make_document(…) живым до запроса
Два условия на массив без $elemMatchУсловия выполняются разными элементами массива$elemMatch, как в разделе с опытом
std::setw для столбцов с кириллицейСтолбцы разъезжаются: в UTF-8 на букву приходится два байтаСчитать ширину по символам — функция pad в скрипте
get_int32 на поле, которого нетПрограмма падает на резюме без поляСначала проверить, что поле есть: if (doc["experience"])
Что написаноЧто происходитКак правильно
append(obshiy, …) к общему фильтруДобавленное условие перезаписывает данные соседнего запросаКопия: append(append(bson.D{}, obshiy…), …)
Два условия на массив без $elemMatchУсловия выполняются разными элементами массива$elemMatch, как в разделе с опытом
%-18s для столбцов с кириллицейСтолбцы разъезжаются: ширина считается в байтахСчитать ширину по символам — функция pad в скрипте
Ошибка запроса присвоена _Пустой результат выглядит как «никто не подошёл»Проверять второе значение, которое возвращает драйвер
Что написаноЧто происходитКак правильно
{ "skills" => НАВЫКИ } для «оба навыка»Массив сравнивается целиком, вместе с порядком: результат пустой{ "skills" => { "$all" => НАВЫКИ } }
Два условия на массив без $elemMatchУсловия выполняются разными элементами массива$elemMatch, как в разделе с опытом
Результат find перебран дваждыЗапрос выполняется заново при каждом обходеСохранить в массив: .to_a
Ключи документа — символы (:skills)Поля не находятся: в документах имена строковыеПисать ключи строками

§12Проверьте себя

Зачем считать каждое требование по отдельности, если нужен итоговый список?

Чтобы видеть, какое требование отсекает кандидатов. В базе курса навыкам отвечают четыре резюме, а всем требованиям сразу — одно: видно, какое требование смягчать в первую очередь.

Почему условие по опыту записано через $elemMatch?

Потому что опыт — массив документов, и без $elemMatch условия проверялись бы на разных местах работы: роль нашлась бы в одном, срок — в другом. $elemMatch требует, чтобы одно место работы отвечало всем условиям сразу.

Что покажет раздел проверки данных, если в резюме появится зарплата строкой?

Строка «зарплата записана не числом» получит единицу и пометку. Фильтр {"salary": {"$not": {"$type": "number"}}} находит любое значение, которое не относится к числовым типам.

Почему последний раздел не использует фильтры?

Фильтр отвечает, какие документы подходят, но не объясняет причину отказа. Чтобы назвать причину, скрипт читает резюме и сравнивает поля в программе. На больших коллекциях так не делают: отбор оставляют серверу.

Как подобрать кандидатов под другую вакансию?

Поменять значения четырёх переменных в начале файла: навыки, город, потолок зарплаты и срок работы. Все фильтры собраны из этих переменных, поэтому запросы менять не нужно.

§13Шпаргалка

Требования — в переменные навыки, город, потолок, срок фильтры собираются из них
Сначала по одному счётчик на каждое требование видно, что отсекает кандидатов
Потом всё сразу пары фильтра — «и» $or — одна из пар
Период дат $gte начало, $lt следующий день
Проверка данных $exists, $type, $size, $expr ноль — дефектов нет
Причина отказа её считает программа, отбор остаётся на сервере

Итог главы

Скрипт собирает операторы модуля 2 в рабочий отбор: требования лежат в переменных, из них собираются фильтры, счётчики показывают, какое требование отсекает кандидатов, один общий фильтр даёт итоговый список. Рядом — поиск по части строки, подбор вакансий по вилке, период дат полуинтервалом и проверка данных на пропуски, типы и несогласованные поля. Причина отказа считается в программе, потому что фильтр её не возвращает. На этом модуль 2 закончен: дальше практическая работа ПР-02, где такой отбор нужно написать самому.

Глава 2.9 · Справочник по MongoDB Автор: Макаров Максим Николаевич