Pull to refresh

Как я решил демоэкзамен по разработке: система учёта партнёров на Flask + PostgreSQL

Level of difficultyEasy

Категории:

  • Python

  • Flask

  • PostgreSQL

  • SQL

  • Образование

  • Веб-разработка

В рамках демоэкзамена по модулю «Разработка, администрирование и защита баз данных» мне нужно было реализовать подсистему для работы с партнёрами, включая расчёт скидок, вывод истории продаж и учёт параметров продукции.

В этой статье я расскажу, как я реализовал это задание на Python с использованием Flask, PostgreSQL и HTML-шаблонов.

Постановка задачи

Необходимо было реализовать веб-приложение с такими функциями:

  • Список партнёров (название, тип, директор, телефон, рейтинг, скидка)

  • Добавление и редактирование данных о партнёре

  • Просмотр истории продаж конкретного партнёра

  • Реализация метода расчёта необходимого материала для продукции

  • Визуальный интерфейс, соответствующий руководству по стилю

Структура базы данных

Используемые таблицы:

  • partners — данные о партнёрах

  • partner_types — типы партнёров

  • products — продукция

  • sales — таблица продаж

  • product_types — для коэффициента расчёта материала

  • material_types — для процента брака

Схема связей:

  • partners.type_id → partner_types.id

  • sales.partner_id → partners.id

  • sales.product_id → products.id

  • products.type_id → product_types.id

Скрипт создания БД create_db.sql

-- Типы партнёров
CREATE TABLE partner_types (
    id SERIAL PRIMARY KEY,
    name VARCHAR(100) NOT NULL
);

-- Партнёры
CREATE TABLE partners (
    id SERIAL PRIMARY KEY,
    name VARCHAR(150) NOT NULL,
    type_id INTEGER REFERENCES partner_types(id),
    address TEXT,
    director VARCHAR(100),
    phone VARCHAR(20),
    email VARCHAR(100),
    inn VARCHAR(20) UNIQUE,
    rating INTEGER CHECK (rating >= 0),
    logo_path VARCHAR(255)
);

-- Типы продукции
CREATE TABLE product_types (
    id SERIAL PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    coefficient NUMERIC(5, 2)
);

-- Продукция
CREATE TABLE products (
    id SERIAL PRIMARY KEY,
    name VARCHAR(150),
    product_type_id INTEGER REFERENCES product_types(id),
    min_price NUMERIC(10, 2) NOT NULL
);

-- История продаж
CREATE TABLE sales (
    id SERIAL PRIMARY KEY,
    partner_id INTEGER REFERENCES partners(id),
    product_id INTEGER REFERENCES products(id),
    quantity INTEGER CHECK (quantity > 0),
    sale_date DATE NOT NULL DEFAULT CURRENT_DATE
);

-- Типы материалов
CREATE TABLE material_types (
    id SERIAL PRIMARY KEY,
    name VARCHAR(100),
    defect_percent NUMERIC(5, 2)
);

-- Продукция, которую продаёт партнёр (import)
CREATE TABLE partner_products (
    id SERIAL PRIMARY KEY,
    partner_id INTEGER REFERENCES partners(id),
    product_id INTEGER REFERENCES products(id)
);

Вставка данных в БД

import pandas as pd
import psycopg2

# Подключение к БД
conn = psycopg2.connect(
    dbname="postgres",
    user="postgres",
    password="",
    host="localhost",
    port=5429
)
cur = conn.cursor()

# Очистка всех таблиц
cur.execute("""
    TRUNCATE TABLE
        sales,
        partner_products,
        partners,
        partner_types,
        products,
        product_types,
        material_types
    RESTART IDENTITY CASCADE
""")
conn.commit()
print("Все таблицы очищены")

def get_safe(row, column_name, default=None):
    return row.get(column_name, default)

def normalize_columns(df):
    df.columns = [c.strip().lower() for c in df.columns]
    return df

# === 1. Импорт partner_types ===
partner_types = ["ООО", "ЗАО", "ПАО", "ОАО"]
for pt in partner_types:
    cur.execute("INSERT INTO partner_types (name) VALUES (%s) ON CONFLICT DO NOTHING", (pt,))
print("Импорт partner_types завершён")

# === 2. Импорт product_types ===
df_types = pd.read_excel("Product_type_import.xlsx")
df_types = normalize_columns(df_types)
for _, row in df_types.iterrows():
    cur.execute("""
        INSERT INTO product_types (name, coefficient)
        VALUES (%s, %s)
    """, (
        get_safe(row, "тип продукции"),
        get_safe(row, "коэффициент типа продукции")
    ))
print("Импорт product_types завершён")

# === 3. Импорт products ===
df_products = pd.read_excel("Products_import.xlsx")
df_products = normalize_columns(df_products)
for _, row in df_products.iterrows():
    cur.execute("""
        INSERT INTO products (name, min_price)
        VALUES (%s, %s)
    """, (
        str(get_safe(row, "наименование продукции")).strip(),
        get_safe(row, "минимальная стоимость для партнера")
    ))
print("Импорт products завершён")

# === 4. Импорт material_types ===
df_materials = pd.read_excel("Material_type_import.xlsx")
df_materials = normalize_columns(df_materials)
for _, row in df_materials.iterrows():
    cur.execute("""
        INSERT INTO material_types (name, defect_percent)
        VALUES (%s, %s)
    """, (
        get_safe(row, "тип материала"),
        get_safe(row, "процент брака материала") or get_safe(row, "процент брака")
    ))
print("Импорт material_types завершён")

# === 5. Импорт partners ===
df_partners = pd.read_excel("Partners_import.xlsx")
df_partners = normalize_columns(df_partners)
cur.execute("SELECT id, name FROM partner_types")
type_map = {name.lower(): id for id, name in cur.fetchall()}

for _, row in df_partners.iterrows():
    type_name = str(get_safe(row, "тип партнера")).strip().lower()
    type_id = type_map.get(type_name)

    if not type_id:
        print(f" Не найден тип партнёра: {type_name}, строка пропущена")
        continue

    cur.execute("""
        INSERT INTO partners (name, type_id, address, director, phone, email, inn, rating)
        VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
    """, (
        str(get_safe(row, "наименование партнера")).strip(),
        type_id,
        get_safe(row, "юридический адрес партнера"),
        get_safe(row, "директор"),
        get_safe(row, "телефон партнера"),
        get_safe(row, "электронная почта партнера"),
        get_safe(row, "инн"),
        get_safe(row, "рейтинг")
    ))
print("Импорт partners завершён")

# === 6. Импорт sales ===
df_sales = pd.read_excel("Partner_products_import.xlsx")
df_sales = normalize_columns(df_sales)

# Маппинг названий на ID
cur.execute("SELECT id, name FROM partners")
partner_map = {name.strip().lower(): id for id, name in cur.fetchall()}
cur.execute("SELECT id, name FROM products")
product_map = {name.strip().lower(): id for id, name in cur.fetchall()}

for _, row in df_sales.iterrows():
    partner_name = str(row.get("наименование партнера", "")).strip().lower()
    product_name = str(row.get("продукция", "")).strip().lower()
    quantity = row.get("количество продукции", 0)
    sale_date = row.get("дата продажи")

    partner_id = partner_map.get(partner_name)
    product_id = product_map.get(product_name)

    if not partner_id or not product_id:
        print(f" Пропущена строка: партнёр={partner_name}, продукция={product_name}")
        continue

    cur.execute("""
        INSERT INTO sales (partner_id, product_id, quantity, sale_date)
        VALUES (%s, %s, %s, %s)
    """, (partner_id, product_id, quantity, sale_date))
print("Импорт sales завершён")

# === 7. Импорт partner_products ===
for _, row in df_sales.iterrows():
    partner_name = str(row.get("наименование партнера", "")).strip().lower()
    product_name = str(row.get("продукция", "")).strip().lower()

    partner_id = partner_map.get(partner_name)
    product_id = product_map.get(product_name)

    if not partner_id or not product_id:
        print(f" Пропущена связка: {partner_name} ⇔ {product_name}")
        continue

    cur.execute("""
        INSERT INTO partner_products (partner_id, product_id)
        VALUES (%s, %s)
    """, (partner_id, product_id))
print("Импорт partner_products завершён")

# Завершение
conn.commit()
cur.close()
conn.close()
print("Импорт завершён успешно!")

Основной backend:

app.py

from flask import Flask, render_template, request, redirect, url_for, flash
import psycopg2
from partner_discount import calculate_discount
from material_calc import calculate_required_material, get_product_coef, get_material_defect_percent

app = Flask(__name__)
app.secret_key = "secret-key"

def get_connection():
    return psycopg2.connect(
        dbname="postgres",
        user="postgres",
        password="",
        host="localhost",
        port=5429
    )

@app.route("/")
def index():
    conn = get_connection()
    cur = conn.cursor()

    cur.execute("""
        SELECT p.id, p.name, pt.name, p.director, p.phone, p.rating,
               COALESCE(SUM(s.quantity * pr.min_price), 0) as total_sales
        FROM partners p
        JOIN partner_types pt ON p.type_id = pt.id
        LEFT JOIN sales s ON s.partner_id = p.id
        LEFT JOIN products pr ON s.product_id = pr.id
        GROUP BY p.id, pt.name
    """)
    result = cur.fetchall()

    partners = []
    for pid, name, type_name, director, phone, rating, total_sales in result:
        partners.append({
            "id": pid,
            "name": name,
            "type": type_name,
            "director": director,
            "phone": phone,
            "rating": rating,
            "discount": calculate_discount(total_sales)
        })

    cur.close()
    conn.close()

    return render_template("index.html", partners=partners)

@app.route("/partner/add", methods=["GET", "POST"])
def add_partner():
    if request.method == "POST":
        try:
            name = request.form['name']
            type_id = int(request.form['type_id'])
            address = request.form['address']
            director = request.form['director']
            phone = request.form['phone']
            email = request.form['email']
            inn = request.form['inn']
            rating = int(request.form['rating'])

            if rating < 0:
                raise ValueError("Рейтинг должен быть неотрицательным")

            conn = get_connection()
            cur = conn.cursor()
            cur.execute("""
                INSERT INTO partners (name, type_id, address, director, phone, email, inn, rating)
                VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
            """, (name, type_id, address, director, phone, email, inn, rating))
            conn.commit()
            cur.close()
            conn.close()
            return redirect(url_for("index"))
        except Exception as e:
            flash("Ошибка: " + str(e), "error")
            return redirect(url_for("add_partner"))

    return render_template("edit_partner.html", mode="add", partner=None)

@app.route("/partner/<int:id>/edit", methods=["GET", "POST"])
def edit_partner(id):
    conn = get_connection()
    cur = conn.cursor()

    if request.method == "POST":
        try:
            name = request.form['name']
            type_id = int(request.form['type_id'])
            address = request.form['address']
            director = request.form['director']
            phone = request.form['phone']
            email = request.form['email']
            inn = request.form['inn']
            rating = int(request.form['rating'])

            if rating < 0:
                raise ValueError("Рейтинг должен быть неотрицательным")

            cur.execute("""
                UPDATE partners SET name=%s, type_id=%s, address=%s, director=%s,
                    phone=%s, email=%s, inn=%s, rating=%s WHERE id=%s
            """, (name, type_id, address, director, phone, email, inn, rating, id))
            conn.commit()
            return redirect(url_for("index"))
        except Exception as e:
            flash("Ошибка: " + str(e), "error")
            return redirect(url_for("edit_partner", id=id))

    cur.execute("SELECT name, type_id, address, director, phone, email, inn, rating FROM partners WHERE id=%s", (id,))
    row = cur.fetchone()
    partner = {
        'name': row[0],
        'type_id': row[1],
        'address': row[2],
        'director': row[3],
        'phone': row[4],
        'email': row[5],
        'inn': row[6],
        'rating': row[7]
    }
    cur.close()
    conn.close()
    return render_template("edit_partner.html", mode="edit", partner=partner)

@app.route("/partner/<int:id>/sales")
def partner_sales(id):
    conn = get_connection()
    cur = conn.cursor()

    cur.execute("SELECT name FROM partners WHERE id = %s", (id,))
    partner_name = cur.fetchone()
    if not partner_name:
        flash("Партнёр не найден", "error")
        return redirect(url_for("index"))

    cur.execute("""
        SELECT pr.name, s.quantity, s.sale_date
        FROM sales s
        JOIN products pr ON s.product_id = pr.id
        WHERE s.partner_id = %s
        ORDER BY s.sale_date DESC
    """, (id,))
    sales = cur.fetchall()
    cur.close()
    conn.close()

    return render_template("partner_sales.html", partner_name=partner_name[0], sales=sales)

if __name__ == "__main__":
    app.run(debug=True)

Расчёт скидки: partner_discount.py

def calculate_discount(total_sales: float) -> int:
    if total_sales < 10_000:
        return 0
    elif total_sales < 50_000:
        return 5
    elif total_sales < 300_000:
        return 10
    return 15

Расчёт необходимого материала: material_calc.py

def get_product_coef(product_type_id):
    conn = get_connection()
    cur = conn.cursor()
    cur.execute("SELECT coefficient FROM product_types WHERE id = %s", (product_type_id,))
    row = cur.fetchone()
    cur.close()
    conn.close()
    return row[0] if row else None

def get_material_defect_percent(material_type_id):
    conn = get_connection()
    cur = conn.cursor()
    cur.execute("SELECT defect_percent FROM material_types WHERE id = %s", (material_type_id,))
    row = cur.fetchone()
    cur.close()
    conn.close()
    return row[0] if row else None


def calculate_required_material(product_type_id: int, material_type_id: int,
                                 quantity: int, param1: float, param2: float) -> int:
    if quantity <= 0 or param1 <= 0 or param2 <= 0:
        return -1

    coef = get_product_coef(product_type_id)
    defect_percent = get_material_defect_percent(material_type_id)

    if coef == -1 or defect_percent == -1:
        return -1

    base_amount = quantity * param1 * param2 * coef
    total_with_defect = base_amount * (1 + defect_percent / 100)
    return int(round(total_with_defect))

HTML-шаблоны

Главная: index.html

<!DOCTYPE html>
<html lang="ru">
  <head>
    <meta charset="UTF-8" />
    <title>Список партнёров | Мастер пол</title>
    <link rel="icon" href="/static/favicon.ico" />
    <style>
      body {
        font-family: "Segoe UI", sans-serif;
        background-color: #ffffff;
        margin: 0;
        padding: 0;
      }
      .history-btn {
        background-color: #67ba80;
        color: white;
        text-decoration: none;
        padding: 6px 12px;
        font-size: 14px;
        border-radius: 5px;
        display: inline-block;
        transition: background-color 0.3s ease;
      }
      .actions {
        display: flex;
        flex-direction: column;
        align-items: flex-end;
        justify-content: center;
        gap: 6px;
      }

      .discount {
        font-size: 18px;
        font-weight: bold;
        color: #67ba80;
      }

      .history-btn:hover {
        background-color: #5aa16f;
      }

      header {
        background-color: #f4e8d3;
        padding: 20px;
        display: flex;
        align-items: center;
        justify-content: space-between;
      }
      header img {
        height: 50px;
        margin-right: 20px;
      }
      header h1 {
        font-size: 24px;
        margin: 0;
        flex-grow: 1;
      }
      .container {
        max-width: 800px;
        margin: 20px auto;
        padding: 0 20px;
      }
      .card {
        border: 1px solid #ccc;
        border-radius: 8px;
        padding: 15px 20px;
        background-color: #f4e8d3;
        display: flex;
        justify-content: space-between;
        margin-bottom: 15px;
        cursor: pointer;
      }
      .card .info {
        line-height: 1.6;
      }
      .card .discount {
        font-size: 24px;
        font-weight: bold;
        color: #67ba80;
        align-self: center;
      }
      .add-btn {
        background-color: #67ba80;
        color: white;
        border: none;
        padding: 10px 20px;
        font-size: 16px;
        border-radius: 6px;
        cursor: pointer;
        margin-bottom: 20px;
      }
      .add-btn:hover {
        background-color: #5aa16f;
      }
    </style>
  </head>
  <body>
    <header>
      <div style="display: flex; align-items: center">
        <img src="/static/logo.png" alt="Логотип Мастер пол" />
        <h1>Список партнёров</h1>
      </div>
      <a href="{{ url_for('add_partner') }}">
        <button class="add-btn">Добавить партнёра</button>
      </a>
    </header>
    <div class="container">
      {% for partner in partners %}
      <div class="card">
        <div
          class="info"
          onclick="window.location.href='/partner/{{ partner.id }}/edit'"
        >
          <strong>{{ partner.type }} | {{ partner.name }}</strong><br />
          Директор {{ partner.director }}<br />
          {{ partner.phone }}<br />
          Рейтинг: {{ partner.rating }}
        </div>
        <div class="actions">
          <a
            href="{{ url_for('partner_sales', id=partner.id) }}"
            class="history-btn"
          >
            📊 История продаж
          </a>
          <div class="discount">{{ partner.discount }}%</div>
        </div>
      </div>

      {% endfor %}
    </div>
  </body>
</html>

Форма добавления/редактирования: edit_partner.html

<!DOCTYPE html>
<html lang="ru">
  <head>
    <meta charset="UTF-8" />
    <title>
      {{ 'Редактировать партнёра' if mode == 'edit' else 'Добавить партнёра' }}
      | Мастер пол
    </title>
    <link rel="icon" href="/static/favicon.ico" />
    <style>
      body {
        font-family: "Segoe UI", sans-serif;
        background-color: #ffffff;
        margin: 0;
        padding: 0;
      }
      header {
        background-color: #f4e8d3;
        padding: 20px;
        display: flex;
        align-items: center;
      }
      header img {
        height: 50px;
        margin-right: 20px;
      }
      header h1 {
        font-size: 24px;
        margin: 0;
      }
      .container {
        max-width: 600px;
        margin: 30px auto;
        padding: 20px;
        background-color: #f4e8d3;
        border-radius: 8px;
      }
      label {
        display: block;
        margin-top: 10px;
        font-weight: bold;
      }
      input,
      select {
        width: 100%;
        padding: 8px;
        margin-top: 4px;
        border-radius: 4px;
        border: 1px solid #ccc;
        font-size: 14px;
      }
      button {
        margin-top: 20px;
        padding: 10px 20px;
        background-color: #67ba80;
        color: white;
        border: none;
        border-radius: 6px;
        font-size: 16px;
        cursor: pointer;
      }
      button:hover {
        background-color: #57a86e;
      }
      .back-link {
        display: inline-block;
        margin-top: 20px;
        text-decoration: none;
        color: #333;
      }
    </style>
  </head>
  <body>
    <header>
      <img src="/static/logo.png" alt="Логотип Мастер пол" />
      <h1>
        {{ 'Редактировать партнёра' if mode == 'edit' else 'Добавить партнёра'
        }}
      </h1>
    </header>
    <div class="container">
      <form method="POST">
        <label>Наименование:</label>
        <input
          name="name"
          value="{{ partner.name if partner else '' }}"
          required
        />

        <label>Тип партнёра (id):</label>
        <input
          name="type_id"
          type="number"
          value="{{ partner.type_id if partner else '' }}"
          required
        />

        <label>Юридический адрес:</label>
        <input
          name="address"
          value="{{ partner.address if partner else '' }}"
        />

        <label>ФИО директора:</label>
        <input
          name="director"
          value="{{ partner.director if partner else '' }}"
        />

        <label>Телефон:</label>
        <input name="phone" value="{{ partner.phone if partner else '' }}" />

        <label>Email:</label>
        <input
          name="email"
          type="email"
          value="{{ partner.email if partner else '' }}"
        />

        <label>ИНН:</label>
        <input name="inn" value="{{ partner.inn if partner else '' }}" />

        <label>Рейтинг (целое ≥ 0):</label>
        <input
          name="rating"
          type="number"
          min="0"
          value="{{ partner.rating if partner else 0 }}"
          required
        />

        <button type="submit">Сохранить</button>
      </form>
      <a href="{{ url_for('index') }}" class="back-link"
        >← Назад к списку партнёров</a
      >
    </div>
  </body>
</html>

История продаж: partner_sales.html

<!DOCTYPE html>
<html lang="ru">
  <head>
    <meta charset="UTF-8" />
    <title>История продаж | Мастер пол</title>
    <link rel="icon" href="/static/favicon.ico" />
    <style>
      body {
        font-family: "Segoe UI", sans-serif;
        background-color: #ffffff;
        margin: 0;
      }
      header {
        background-color: #f4e8d3;
        padding: 20px;
        display: flex;
        align-items: center;
      }
      header img {
        height: 50px;
        margin-right: 20px;
      }
      h1 {
        font-size: 24px;
        margin: 0;
      }
      .container {
        max-width: 800px;
        margin: 20px auto;
        padding: 0 20px;
      }
      table {
        width: 100%;
        border-collapse: collapse;
        margin-top: 20px;
      }
      th,
      td {
        border: 1px solid #ccc;
        padding: 10px;
      }
      th {
        background-color: #f4e8d3;
      }
      a.back-link {
        display: inline-block;
        margin-top: 20px;
        text-decoration: none;
        color: #333;
      }
    </style>
  </head>
  <body>
    <header>
      <img src="/static/logo.png" alt="Логотип Мастер пол" />
      <h1>История реализации</h1>
    </header>
    <div class="container">
      <table>
        <thead>
          <tr>
            <th>Продукция</th>
            <th>Количество</th>
            <th>Дата продажи</th>
          </tr>
        </thead>
        <tbody>
          {% for sale in sales %}
          <tr>
            <td>{{ sale.product }}</td>
            <td>{{ sale.quantity }}</td>
            <td>{{ sale.date }}</td>
          </tr>
          {% endfor %}
        </tbody>
      </table>
      <a class="back-link" href="{{ url_for('index') }}">← Назад к партнёрам</a>
    </div>
  </body>
</html>

История покупок history.html

<!DOCTYPE html>
<html lang="ru">
  <head>
    <meta charset="UTF-8" />
    <title>История продаж | Мастер пол</title>
    <link rel="icon" href="/static/favicon.ico" />
    <style>
      body {
        font-family: "Segoe UI", sans-serif;
        background-color: #ffffff;
        margin: 0;
      }
      header {
        background-color: #f4e8d3;
        padding: 20px;
        display: flex;
        align-items: center;
      }
      header img {
        height: 50px;
        margin-right: 20px;
      }
      h1 {
        font-size: 24px;
        margin: 0;
      }
      .container {
        max-width: 800px;
        margin: 20px auto;
        padding: 0 20px;
      }
      table {
        width: 100%;
        border-collapse: collapse;
        margin-top: 20px;
      }
      th,
      td {
        border: 1px solid #ccc;
        padding: 10px;
      }
      th {
        background-color: #f4e8d3;
      }
      a.back-link {
        display: inline-block;
        margin-top: 20px;
        text-decoration: none;
        color: #333;
      }
    </style>
  </head>
  <body>
    <header>
      <img src="/static/logo.png" alt="Логотип Мастер пол" />
      <h1>История реализации</h1>
    </header>
    <div class="container">
      <table>
        <thead>
          <tr>
            <th>Продукция</th>
            <th>Количество</th>
            <th>Дата продажи</th>
          </tr>
        </thead>
        <tbody>
          {% for sale in sales %}
          <tr>
            <td>{{ sale.product }}</td>
            <td>{{ sale.quantity }}</td>
            <td>{{ sale.date }}</td>
          </tr>
          {% endfor %}
        </tbody>
      </table>
      <a class="back-link" href="{{ url_for('index') }}">← Назад к партнёрам</a>
    </div>
  </body>
</html>

Примеры данных

При проверке системы использовались следующие тестовые данные:

  • Партнёр: ООО Паркет, тип: ОАО, рейтинг: 5

  • Продажа: 5 штук продукта по цене 2000 → total_sales = 10000 → скидка 5%

Выводы

В результате реализовано:

  • Полноценное CRUD-приложение

  • Функции расчёта скидок и материала с подключением к БД

  • Простая адаптивная верстка

  • Обработка ошибок и подсказки пользователю

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

Tags:
Hubs:
You can’t comment this publication because its author is not yet a full member of the community. You will be able to contact the author only after he or she has been invited by someone in the community. Until then, author’s username will be hidden by an alias.