This commit is contained in:
parent 1522c25819
commit a8079a455f
31 changed files with 1640 additions and 90 deletions

100
README.md
View file

@ -1,93 +1,13 @@
# SPORT_INVENTARY # Спортивный инвентарь
Веб-приложение для учёта выдачи и возврата спортивного инвентаря с расчётом стоимости возврата на основе износа и сезонности.
## Установка и запуск
## Getting started 1. Убедитесь, что установлен Python 3.8 или выше.
2. Клонируйте репозиторий или создайте папку `sport_inventory` и скопируйте все файлы.
To make it easy for you to get started with GitLab, here's a list of recommended next steps. 3. Создайте виртуальное окружение и активируйте его:
```bash
Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)! python -m venv .venv
.venv\Scripts\activate # Windows
## Add your files source .venv/bin/activate # Linux/Mac
- [ ] [Create](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#create-a-file) or [upload](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#upload-a-file) files
- [ ] [Add files using the command line](https://docs.gitlab.com/topics/git/add_files/#add-files-to-a-git-repository) or push an existing Git repository with the following command:
```
cd existing_repo
git remote add origin http://192.168.0.185/Yaroslavtsev/sport_inventary.git
git branch -M main
git push -uf origin main
```
## Integrate with your tools
- [ ] [Set up project integrations](http://192.168.0.185/Yaroslavtsev/sport_inventary/-/settings/integrations)
## Collaborate with your team
- [ ] [Invite team members and collaborators](https://docs.gitlab.com/ee/user/project/members/)
- [ ] [Create a new merge request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html)
- [ ] [Automatically close issues from merge requests](https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically)
- [ ] [Enable merge request approvals](https://docs.gitlab.com/ee/user/project/merge_requests/approvals/)
- [ ] [Set auto-merge](https://docs.gitlab.com/user/project/merge_requests/auto_merge/)
## Test and Deploy
Use the built-in continuous integration in GitLab.
- [ ] [Get started with GitLab CI/CD](https://docs.gitlab.com/ee/ci/quick_start/)
- [ ] [Analyze your code for known vulnerabilities with Static Application Security Testing (SAST)](https://docs.gitlab.com/ee/user/application_security/sast/)
- [ ] [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/ee/topics/autodevops/requirements.html)
- [ ] [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/ee/user/clusters/agent/)
- [ ] [Set up protected environments](https://docs.gitlab.com/ee/ci/environments/protected_environments.html)
***
# Editing this README
When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thanks to [makeareadme.com](https://www.makeareadme.com/) for this template.
## Suggestions for a good README
Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information.
## Name
Choose a self-explaining name for your project.
## Description
Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors.
## Badges
On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge.
## Visuals
Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method.
## Installation
Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection.
## Usage
Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README.
## Support
Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc.
## Roadmap
If you have ideas for releases in the future, it is a good idea to list them in the README.
## Contributing
State if you are open to contributions and what your requirements are for accepting them.
For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self.
You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser.
## Authors and acknowledgment
Show your appreciation to those who have contributed to the project.
## License
For open source projects, say how it is licensed.
## Project status
If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers.

26
app/__init__.py Normal file
View file

@ -0,0 +1,26 @@
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_wtf.csrf import CSRFProtect
import os
db = SQLAlchemy()
csrf = CSRFProtect()
def create_app():
app = Flask(__name__)
app.config['SECRET_KEY'] = 'you-will-never-guess'
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///' + os.path.join(os.path.abspath(os.path.dirname(__file__)), '..', 'instance', 'sport_inventory.db')
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
db.init_app(app)
csrf.init_app(app)
from . import routes
app.register_blueprint(routes.bp)
with app.app_context():
db.create_all()
from .db import init_test_data
init_test_data()
return app

76
app/db.py Normal file
View file

@ -0,0 +1,76 @@
from . import db
from .models import Categories, Inventory, Partners, IssueOrders, IssueOrderItems, ReturnOrders, ReturnOrderItems
from datetime import datetime, timedelta
from .utils import calculate_item_price
def init_test_data():
if Categories.query.first() is not None:
return
# Категории
cat1 = Categories(name='Мячи', description='Футбольные, баскетбольные, волейбольные мячи')
cat2 = Categories(name='Лыжи', description='Горные и беговые лыжи')
cat3 = Categories(name='Ракетки', description='Теннисные и бадминтонные ракетки')
db.session.add_all([cat1, cat2, cat3])
db.session.commit()
# Инвентарь (все названия строго по ТЗ)
inv1 = Inventory(category_id=cat1.id, name='Футбольный мяч', base_price=1500.00, season_type='summer')
inv2 = Inventory(category_id=cat1.id, name='Баскетбольный мяч', base_price=2000.00, season_type='summer')
inv3 = Inventory(category_id=cat2.id, name='Горные лыжи', base_price=5000.00, season_type='winter')
inv4 = Inventory(category_id=cat2.id, name='Беговые лыжи', base_price=3500.00, season_type='winter')
inv5 = Inventory(category_id=cat3.id, name='Теннисная ракетка', base_price=3000.00, season_type='summer')
db.session.add_all([inv1, inv2, inv3, inv4, inv5])
db.session.commit()
# Партнеры
p1 = Partners(name='Иванов Иван', phone='+7(999)123-45-67', email='ivan@mail.ru', address='ул. Ленина, 1')
p2 = Partners(name='ООО "СпортМастер"', phone='+7(495)111-22-33', email='info@sportmaster.ru', address='Москва, Тверская, 10')
p3 = Partners(name='Петров Петр', phone='+7(911)444-55-66', email='petr@yandex.ru', address='СПб, Невский, 20')
db.session.add_all([p1, p2, p3])
db.session.commit()
# Выдача 1 (открыта)
issue1 = IssueOrders(partner_id=p1.id, issue_date=datetime.now() - timedelta(days=5), notes='Первая выдача')
db.session.add(issue1)
db.session.commit()
item1 = IssueOrderItems(issue_order_id=issue1.id, inventory_id=inv1.id, quantity=3)
item2 = IssueOrderItems(issue_order_id=issue1.id, inventory_id=inv3.id, quantity=2)
db.session.add_all([item1, item2])
db.session.commit()
# Выдача 2 (уже закрыта возвратом)
issue2 = IssueOrders(partner_id=p2.id, issue_date=datetime.now() - timedelta(days=10), notes='Вторая выдача')
db.session.add(issue2)
db.session.commit()
item3 = IssueOrderItems(issue_order_id=issue2.id, inventory_id=inv2.id, quantity=5)
item4 = IssueOrderItems(issue_order_id=issue2.id, inventory_id=inv4.id, quantity=1)
db.session.add_all([item3, item4])
db.session.commit()
# Возврат для второй выдачи
return2 = ReturnOrders(issue_order_id=issue2.id, return_date=datetime.now() - timedelta(days=1), notes='Возврат')
db.session.add(return2)
db.session.commit()
total = 0
for issue_item in [item3, item4]:
price = calculate_item_price(
base_price=issue_item.inventory.base_price,
quantity=issue_item.quantity,
wear_level='medium',
season_type=issue_item.inventory.season_type,
return_date=return2.return_date
)
ret_item = ReturnOrderItems(
return_order_id=return2.id,
inventory_id=issue_item.inventory_id,
quantity=issue_item.quantity,
wear_level='medium',
calculated_price=price
)
db.session.add(ret_item)
total += price
return2.total_amount = round(total, 2)
db.session.commit()

83
app/models.py Normal file
View file

@ -0,0 +1,83 @@
from . import db
from datetime import datetime
class Categories(db.Model):
__tablename__ = 'Categories'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False)
description = db.Column(db.Text)
inventory_items = db.relationship('Inventory', backref='category', lazy=True)
def __repr__(self):
return f'<Category {self.name}>'
class Inventory(db.Model):
__tablename__ = 'Inventory'
id = db.Column(db.Integer, primary_key=True)
category_id = db.Column(db.Integer, db.ForeignKey('Categories.id'), nullable=False)
name = db.Column(db.String(200), nullable=False)
base_price = db.Column(db.Numeric(10,2), nullable=False)
season_type = db.Column(db.String(10), nullable=False) # 'winter' или 'summer'
def __repr__(self):
return f'<Inventory {self.name}>'
class Partners(db.Model):
__tablename__ = 'Partners'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(200), nullable=False)
phone = db.Column(db.String(20))
email = db.Column(db.String(100))
address = db.Column(db.Text)
issue_orders = db.relationship('IssueOrders', backref='partner', lazy=True)
def __repr__(self):
return f'<Partner {self.name}>'
class IssueOrders(db.Model):
__tablename__ = 'IssueOrders'
id = db.Column(db.Integer, primary_key=True)
partner_id = db.Column(db.Integer, db.ForeignKey('Partners.id'), nullable=False)
issue_date = db.Column(db.DateTime, default=datetime.now)
notes = db.Column(db.Text)
items = db.relationship('IssueOrderItems', backref='issue_order', lazy=True, cascade='all, delete-orphan')
return_order = db.relationship('ReturnOrders', backref='issue_order', uselist=False, cascade='all, delete-orphan')
def __repr__(self):
return f'<IssueOrder {self.id} for {self.partner.name}>'
class IssueOrderItems(db.Model):
__tablename__ = 'IssueOrderItems'
id = db.Column(db.Integer, primary_key=True)
issue_order_id = db.Column(db.Integer, db.ForeignKey('IssueOrders.id'), nullable=False)
inventory_id = db.Column(db.Integer, db.ForeignKey('Inventory.id'), nullable=False)
quantity = db.Column(db.Integer, nullable=False, default=1)
inventory = db.relationship('Inventory')
def __repr__(self):
return f'<IssueOrderItem {self.id} qty={self.quantity}>'
class ReturnOrders(db.Model):
__tablename__ = 'ReturnOrders'
id = db.Column(db.Integer, primary_key=True)
issue_order_id = db.Column(db.Integer, db.ForeignKey('IssueOrders.id'), nullable=False, unique=True)
return_date = db.Column(db.DateTime, default=datetime.now)
total_amount = db.Column(db.Numeric(10,2))
notes = db.Column(db.Text)
items = db.relationship('ReturnOrderItems', backref='return_order', lazy=True, cascade='all, delete-orphan')
def __repr__(self):
return f'<ReturnOrder {self.id} amount={self.total_amount}>'
class ReturnOrderItems(db.Model):
__tablename__ = 'ReturnOrderItems'
id = db.Column(db.Integer, primary_key=True)
return_order_id = db.Column(db.Integer, db.ForeignKey('ReturnOrders.id'), nullable=False)
inventory_id = db.Column(db.Integer, db.ForeignKey('Inventory.id'), nullable=False)
quantity = db.Column(db.Integer, nullable=False)
wear_level = db.Column(db.String(10), nullable=False) # 'low', 'medium', 'high'
calculated_price = db.Column(db.Numeric(10,2))
inventory = db.relationship('Inventory')
def __repr__(self):
return f'<ReturnOrderItem {self.id} wear={self.wear_level}>'

366
app/routes.py Normal file
View file

@ -0,0 +1,366 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
from . import db, csrf
from .models import Partners, Inventory, Categories, IssueOrders, IssueOrderItems, ReturnOrders, ReturnOrderItems
from .utils import calculate_item_price, get_current_season_name
from datetime import datetime
bp = Blueprint('main', __name__)
# Главная
@bp.route('/')
def index():
active_issues = IssueOrders.query.filter(~IssueOrders.return_order.has()).count()
partners_count = Partners.query.count()
expected_total = 0
for issue in IssueOrders.query.filter(~IssueOrders.return_order.has()).all():
for item in issue.items:
expected_total += float(item.inventory.base_price) * item.quantity
recent_issues = IssueOrders.query.order_by(IssueOrders.issue_date.desc()).limit(5).all()
recent_returns = ReturnOrders.query.order_by(ReturnOrders.return_date.desc()).limit(5).all()
return render_template('index.html',
active_issues=active_issues,
partners_count=partners_count,
expected_total=round(expected_total, 2),
recent_issues=recent_issues,
recent_returns=recent_returns)
# Партнёры
@bp.route('/partners')
def partners():
search = request.args.get('search', '')
query = Partners.query
if search:
query = query.filter(
(Partners.name.contains(search)) | (Partners.phone.contains(search))
)
all_partners = query.all()
return render_template('partners.html', partners=all_partners)
@bp.route('/partners/new', methods=['GET', 'POST'])
def partner_new():
if request.method == 'POST':
name = request.form['name']
phone = request.form['phone'].strip()
email = request.form['email']
address = request.form['address']
# Валидация телефона
if phone:
digits = ''.join(filter(str.isdigit, phone))
if len(digits) < 10 or len(digits) > 15:
flash('Телефон должен содержать от 10 до 15 цифр (например, +7(999)123-45-67)', 'danger')
# Создаём временный объект для возврата в форму
temp_partner = Partners(name=name, phone=phone, email=email, address=address)
return render_template('partner_form.html', partner=temp_partner)
p = Partners(name=name, phone=phone, email=email, address=address)
db.session.add(p)
db.session.commit()
flash('Партнёр добавлен', 'success')
return redirect(url_for('main.partners'))
return render_template('partner_form.html')
@bp.route('/partners/<int:id>')
def partner_detail(id):
partner = Partners.query.get_or_404(id)
return render_template('partner_detail.html', partner=partner)
@bp.route('/partners/<int:id>/edit', methods=['GET', 'POST'])
def partner_edit(id):
partner = Partners.query.get_or_404(id)
if request.method == 'POST':
name = request.form['name']
phone = request.form['phone'].strip()
email = request.form['email']
address = request.form['address']
# Сначала обновляем поля (ещё не коммитим)
partner.name = name
partner.phone = phone
partner.email = email
partner.address = address
# Валидация телефона
if phone:
digits = ''.join(filter(str.isdigit, phone))
if len(digits) < 10 or len(digits) > 15:
flash('Телефон должен содержать от 10 до 15 цифр (например, +7(999)123-45-67)', 'danger')
return render_template('partner_form.html', partner=partner)
db.session.commit()
flash('Данные обновлены', 'success')
return redirect(url_for('main.partner_detail', id=id))
return render_template('partner_form.html', partner=partner)
# Инвентарь
@bp.route('/inventory')
def inventory():
category_id = request.args.get('category', type=int)
season = request.args.get('season', '')
query = Inventory.query
if category_id:
query = query.filter_by(category_id=category_id)
if season in ['winter', 'summer']:
query = query.filter_by(season_type=season)
items = query.all()
categories = Categories.query.all()
return render_template('inventory.html',
items=items,
categories=categories,
selected_category=category_id,
selected_season=season)
@bp.route('/inventory/new', methods=['GET', 'POST'])
def inventory_new():
if request.method == 'POST':
name = request.form['name']
category_id = request.form['category_id']
base_price = request.form['base_price']
season_type = request.form['season_type']
inv = Inventory(name=name, category_id=category_id, base_price=base_price, season_type=season_type)
db.session.add(inv)
db.session.commit()
flash('Инвентарь добавлен', 'success')
return redirect(url_for('main.inventory'))
categories = Categories.query.all()
return render_template('inventory_form.html', categories=categories)
@bp.route('/inventory/<int:id>/edit', methods=['GET', 'POST'])
def inventory_edit(id):
inv = Inventory.query.get_or_404(id)
if request.method == 'POST':
inv.name = request.form['name']
inv.category_id = request.form['category_id']
inv.base_price = request.form['base_price']
inv.season_type = request.form['season_type']
db.session.commit()
flash('Изменения сохранены', 'success')
return redirect(url_for('main.inventory'))
categories = Categories.query.all()
return render_template('inventory_form.html', item=inv, categories=categories)
@bp.route('/inventory/<int:id>/delete', methods=['POST'])
def inventory_delete(id):
inv = Inventory.query.get_or_404(id)
if IssueOrderItems.query.filter_by(inventory_id=id).first() or ReturnOrderItems.query.filter_by(
inventory_id=id).first():
flash('Нельзя удалить инвентарь, который был выдан', 'danger')
else:
db.session.delete(inv)
db.session.commit()
flash('Инвентарь удалён', 'success')
return redirect(url_for('main.inventory'))
# Категории
@bp.route('/categories')
def categories():
cats = Categories.query.all()
return render_template('categories.html', categories=cats)
@bp.route('/categories/new', methods=['GET', 'POST'])
def category_new():
if request.method == 'POST':
name = request.form['name']
description = request.form['description']
cat = Categories(name=name, description=description)
db.session.add(cat)
db.session.commit()
flash('Категория добавлена', 'success')
return redirect(url_for('main.categories'))
return render_template('category_form.html')
@bp.route('/categories/<int:id>/edit', methods=['GET', 'POST'])
def category_edit(id):
cat = Categories.query.get_or_404(id)
if request.method == 'POST':
cat.name = request.form['name']
cat.description = request.form['description']
db.session.commit()
flash('Категория обновлена', 'success')
return redirect(url_for('main.categories'))
return render_template('category_form.html', category=cat)
@bp.route('/categories/<int:id>/delete', methods=['POST'])
def category_delete(id):
cat = Categories.query.get_or_404(id)
if Inventory.query.filter_by(category_id=id).first():
flash('Нельзя удалить категорию, в которой есть инвентарь', 'danger')
else:
db.session.delete(cat)
db.session.commit()
flash('Категория удалена', 'success')
return redirect(url_for('main.categories'))
# Выдачи
@bp.route('/issues')
def issues():
issues = IssueOrders.query.all()
return render_template('issues.html', issues=issues)
@bp.route('/issues/new', methods=['GET', 'POST'])
def issue_new():
if request.method == 'POST':
partner_id = request.form['partner_id']
notes = request.form.get('notes', '')
issue = IssueOrders(partner_id=partner_id, notes=notes)
db.session.add(issue)
db.session.commit()
inventory_ids = request.form.getlist('inventory_id[]')
quantities = request.form.getlist('quantity[]')
for inv_id, qty in zip(inventory_ids, quantities):
if inv_id and qty:
item = IssueOrderItems(issue_order_id=issue.id, inventory_id=int(inv_id), quantity=int(qty))
db.session.add(item)
db.session.commit()
flash('Выдача оформлена', 'success')
return redirect(url_for('main.issues'))
partners = Partners.query.all()
inventory = Inventory.query.all()
return render_template('issue_form.html', partners=partners, inventory=inventory)
@bp.route('/issues/<int:id>')
def issue_detail(id):
issue = IssueOrders.query.get_or_404(id)
return render_template('issue_detail.html', issue=issue)
@bp.route('/issues/<int:id>/edit', methods=['GET', 'POST'])
def issue_edit(id):
issue = IssueOrders.query.get_or_404(id)
if issue.return_order:
flash('Нельзя редактировать закрытую выдачу', 'danger')
return redirect(url_for('main.issue_detail', id=id))
if request.method == 'POST':
issue.notes = request.form.get('notes', '')
IssueOrderItems.query.filter_by(issue_order_id=id).delete()
inventory_ids = request.form.getlist('inventory_id[]')
quantities = request.form.getlist('quantity[]')
for inv_id, qty in zip(inventory_ids, quantities):
if inv_id and qty:
item = IssueOrderItems(issue_order_id=id, inventory_id=int(inv_id), quantity=int(qty))
db.session.add(item)
db.session.commit()
flash('Выдача обновлена', 'success')
return redirect(url_for('main.issue_detail', id=id))
partners = Partners.query.all()
inventory = Inventory.query.all()
return render_template('issue_form.html', issue=issue, partners=partners, inventory=inventory)
# Возвраты
@bp.route('/returns')
def returns():
returns = ReturnOrders.query.all()
return render_template('returns.html', returns=returns)
@bp.route('/returns/new', methods=['GET', 'POST'])
def return_new():
if request.method == 'POST':
issue_id = request.form['issue_id']
# Проверка на существующий возврат
existing_return = ReturnOrders.query.filter_by(issue_order_id=issue_id).first()
if existing_return:
flash('Возврат по этой выдаче уже оформлен', 'danger')
return redirect(url_for('main.return_new'))
# Получаем список ID позиций
items_data = request.form.getlist('item_id[]')
if not items_data:
flash('Нет позиций для возврата', 'danger')
return redirect(url_for('main.return_new'))
# Собираем степени износа для каждой позиции
wear_dict = {}
for item_id in items_data:
wear = request.form.get(f'wear_{item_id}')
if not wear:
flash(f'Не указана степень износа для позиции', 'danger')
return redirect(url_for('main.return_new'))
wear_dict[item_id] = wear
return_date = datetime.now()
notes = request.form.get('notes', '')
# Создаём возврат (получаем ID, но не коммитим)
ret = ReturnOrders(issue_order_id=issue_id, return_date=return_date, notes=notes)
db.session.add(ret)
db.session.flush() # чтобы получить ID возврата
total = 0
for item_id in items_data:
issue_item = IssueOrderItems.query.get(int(item_id))
wear = wear_dict[item_id]
price = calculate_item_price(
base_price=issue_item.inventory.base_price,
quantity=issue_item.quantity,
wear_level=wear,
season_type=issue_item.inventory.season_type,
return_date=return_date
)
ret_item = ReturnOrderItems(
return_order_id=ret.id,
inventory_id=issue_item.inventory_id,
quantity=issue_item.quantity,
wear_level=wear,
calculated_price=price
)
db.session.add(ret_item)
total += price
ret.total_amount = round(total, 2)
db.session.commit()
flash('Возврат оформлен', 'success')
return redirect(url_for('main.returns'))
open_issues = IssueOrders.query.filter(~IssueOrders.return_order.has()).all()
current_season = get_current_season_name()
return render_template('return_select_issue.html', issues=open_issues, current_season=current_season)
@bp.route('/returns/get_issue_items/<int:issue_id>')
def get_issue_items(issue_id):
issue = IssueOrders.query.get_or_404(issue_id)
items = []
for item in issue.items:
items.append({
'id': item.id,
'inventory_id': item.inventory_id,
'inventory_name': item.inventory.name,
'quantity': item.quantity,
'base_price': float(item.inventory.base_price),
'season_type': item.inventory.season_type
})
return jsonify(items)
@csrf.exempt
@bp.route('/returns/calculate_preview', methods=['POST'])
def calculate_preview():
data = request.get_json()
base_price = float(data['base_price'])
quantity = int(data['quantity'])
wear_level = data['wear_level']
season_type = data['season_type']
return_date = datetime.now()
price = calculate_item_price(base_price, quantity, wear_level, season_type, return_date)
return jsonify({'calculated_price': price})

132
app/static/css/style.css Normal file
View file

@ -0,0 +1,132 @@
body {
font-family: 'Century Gothic', sans-serif;
background-color: #F0FFF0; /* медовый */
margin: 0;
padding: 0;
color: #333;
}
header {
background-color: #98FB98; /* светло-зелёный */
padding: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
}
.logo {
font-size: 1.5rem;
font-weight: bold;
color: #32CD32; /* лаймовый */
}
nav a {
background-color: #98FB98;
padding: 0.5rem 1rem;
border-radius: 4px;
margin: 0 5px;
color: #333;
text-decoration: none;
font-weight: 500;
display: inline-block;
transition: background-color 0.3s;
}
nav a:hover {
background-color: #32CD32;
color: white;
}
main {
padding: 2rem;
}
table {
width: 100%;
border-collapse: collapse;
background-color: white;
margin-bottom: 1rem;
}
th {
background-color: #98FB98;
padding: 0.75rem;
text-align: left;
}
td {
padding: 0.5rem;
border-bottom: 1px solid #ddd;
}
tr:nth-child(even) {
background-color: #f2f2f2;
}
.btn {
background-color: #32CD32;
color: white;
padding: 0.5rem 1rem;
text-decoration: none;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
display: inline-block;
}
.btn:hover {
background-color: #2eb82e;
}
.btn-secondary {
background-color: #98FB98;
color: #333;
}
.card {
background-color: #98FB98;
padding: 1rem;
border-radius: 8px;
margin-bottom: 1rem;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.25rem;
font-weight: bold;
}
.form-group input, .form-group select, .form-group textarea {
width: 100%;
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 4px;
font-family: inherit;
}
.radio-group {
display: flex;
gap: 1rem;
align-items: center;
}
@media (max-width: 768px) {
header {
flex-direction: column;
align-items: flex-start;
}
nav {
margin-top: 1rem;
}
nav a {
display: inline-block;
margin: 5px 10px 5px 0;
}
}
.flash {
padding: 1rem;
margin-bottom: 1rem;
border-radius: 4px;
}
.flash.success {
background-color: #d4edda;
color: #155724;
}
.flash.danger {
background-color: #f8d7da;
color: #721c24;
}
.filter-form {
background-color: #98FB98;
padding: 1rem;
border-radius: 8px;
margin-bottom: 1rem;
}

33
app/templates/base.html Normal file
View file

@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Спортивный инвентарь</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<link href="https://fonts.googleapis.com/css2?family=Century+Gothic&display=swap" rel="stylesheet">
</head>
<body>
<header>
<div class="logo">🏅 СпортИнвентарь</div>
<nav>
<a href="{{ url_for('main.index') }}">Главная</a>
<a href="{{ url_for('main.partners') }}">Партнёры</a>
<a href="{{ url_for('main.inventory') }}">Инвентарь</a>
<a href="{{ url_for('main.categories') }}">Категории</a>
<a href="{{ url_for('main.issues') }}">Выдачи</a>
<a href="{{ url_for('main.returns') }}">Возвраты</a>
</nav>
</header>
<main>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="flash {{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</main>
</body>
</html>

View file

@ -0,0 +1,30 @@
{% extends "base.html" %}
{% block content %}
<h1>Категории инвентаря</h1>
<a href="{{ url_for('main.category_new') }}" class="btn">➕ Добавить категорию</a>
<table>
<thead>
<tr><th>ID</th><th>Название</th><th>Описание</th><th>Действия</th></tr>
</thead>
<tbody>
{% for cat in categories %}
<tr>
<td>{{ cat.id }}</td>
<td>{{ cat.name }}</td>
<td>{{ cat.description }}</td>
<td>
<a href="{{ url_for('main.category_edit', id=cat.id) }}" class="btn btn-secondary">✏️</a>
<form action="{{ url_for('main.category_delete', id=cat.id) }}" method="post" style="display:inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn" onclick="return confirm('Удалить категорию?')">🗑️</button>
</form>
</td>
</tr>
{% else %}
<tr>
<td colspan="4">Нет категорий</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}

View file

@ -0,0 +1,17 @@
{% extends "base.html" %}
{% block content %}
<h1>{% if category %}Редактирование категории{% else %}Новая категория{% endif %}</h1>
<form method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="form-group">
<label>Название</label>
<input type="text" name="name" value="{{ category.name if category else '' }}" required>
</div>
<div class="form-group">
<label>Описание</label>
<textarea name="description">{{ category.description if category else '' }}</textarea>
</div>
<button type="submit" class="btn">Сохранить</button>
<a href="{{ url_for('main.categories') }}" class="btn btn-secondary">Отмена</a>
</form>
{% endblock %}

38
app/templates/index.html Normal file
View file

@ -0,0 +1,38 @@
{% extends "base.html" %}
{% block content %}
<h1>Панель управления</h1>
<div style="display: flex; gap: 1rem; flex-wrap: wrap;">
<div class="card" style="flex:1; min-width:200px;">
<h3>Активных выдач</h3>
<p style="font-size:2rem;">{{ active_issues }}</p>
</div>
<div class="card" style="flex:1; min-width:200px;">
<h3>Партнёров</h3>
<p style="font-size:2rem;">{{ partners_count }}</p>
</div>
<div class="card" style="flex:1; min-width:200px;">
<h3>Ожидаемая сумма</h3>
<p style="font-size:2rem;">{{ expected_total }} ₽</p>
</div>
</div>
<h2>Последние операции</h2>
<div style="display: flex; gap: 2rem; flex-wrap: wrap;">
<div style="flex:1;">
<h3>Выдачи</h3>
<ul>
{% for issue in recent_issues %}
<li>{{ issue.issue_date.strftime('%d.%m.%Y') }} — {{ issue.partner.name }}</li>
{% endfor %}
</ul>
</div>
<div style="flex:1;">
<h3>Возвраты</h3>
<ul>
{% for ret in recent_returns %}
<li>{{ ret.return_date.strftime('%d.%m.%Y') }} — {{ ret.total_amount }} ₽</li>
{% endfor %}
</ul>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,54 @@
{% extends "base.html" %}
{% block content %}
<h1>Инвентарь</h1>
<a href="{{ url_for('main.inventory_new') }}" class="btn">➕ Добавить инвентарь</a>
<!-- Фильтры -->
<form method="get" class="filter-form" style="margin: 1rem 0; display: flex; gap: 1rem; flex-wrap: wrap;">
<div>
<label for="category">Категория:</label>
<select name="category" id="category" onchange="this.form.submit()">
<option value="">Все</option>
{% for cat in categories %}
<option value="{{ cat.id }}" {% if selected_category == cat.id %}selected{% endif %}>{{ cat.name }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="season">Сезон:</label>
<select name="season" id="season" onchange="this.form.submit()">
<option value="">Все</option>
<option value="winter" {% if selected_season == 'winter' %}selected{% endif %}>Зима</option>
<option value="summer" {% if selected_season == 'summer' %}selected{% endif %}>Лето</option>
</select>
</div>
</form>
<table>
<thead>
<tr><th>ID</th><th>Наименование</th><th>Категория</th><th>Базовая цена</th><th>Сезон</th><th>Действия</th></tr>
</thead>
<tbody>
{% for item in items %}
<tr>
<td>{{ item.id }}</td>
<td>{{ item.name }}</td>
<td>{{ item.category.name }}</td>
<td>{{ item.base_price }} ₽</td>
<td>{{ 'Зима' if item.season_type == 'winter' else 'Лето' }}</td>
<td>
<a href="{{ url_for('main.inventory_edit', id=item.id) }}" class="btn btn-secondary">✏️</a>
<form action="{{ url_for('main.inventory_delete', id=item.id) }}" method="post" style="display:inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn" onclick="return confirm('Удалить этот инвентарь?')">🗑️</button>
</form>
</td>
</tr>
{% else %}
<tr>
<td colspan="6">Нет инвентаря</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}

View file

@ -0,0 +1,34 @@
{% extends "base.html" %}
{% block content %}
<h1>{% if item %}Редактирование инвентаря{% else %}Новый инвентарь{% endif %}</h1>
<form method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="form-group">
<label>Наименование</label>
<input type="text" name="name" value="{{ item.name if item else '' }}" required>
</div>
<div class="form-group">
<label>Категория</label>
<select name="category_id" required>
{% for cat in categories %}
<option value="{{ cat.id }}" {% if item and item.category_id == cat.id %}selected{% endif %}>{{ cat.name }}</option>
{% else %}
<option value="" disabled>Сначала добавьте категорию</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>Базовая цена (₽)</label>
<input type="number" step="0.01" min="0.01" name="base_price" value="{{ item.base_price if item else '' }}" required>
</div>
<div class="form-group">
<label>Сезонный тип</label>
<select name="season_type" required>
<option value="winter" {% if item and item.season_type == 'winter' %}selected{% endif %}>Зима</option>
<option value="summer" {% if item and item.season_type == 'summer' %}selected{% endif %}>Лето</option>
</select>
</div>
<button type="submit" class="btn">Сохранить</button>
<a href="{{ url_for('main.inventory') }}" class="btn btn-secondary">Отмена</a>
</form>
{% endblock %}

View file

@ -0,0 +1,45 @@
{% extends "base.html" %}
{% block content %}
<h1>Выдача #{{ issue.id }}</h1>
<div class="card">
<p><strong>Партнёр:</strong> <a href="{{ url_for('main.partner_detail', id=issue.partner.id) }}">{{ issue.partner.name }}</a></p>
<p><strong>Дата выдачи:</strong> {{ issue.issue_date.strftime('%d.%m.%Y %H:%M') }}</p>
<p><strong>Примечания:</strong> {{ issue.notes or '—' }}</p>
<p><strong>Статус:</strong>
{% if issue.return_order %}
Закрыта возвратом #{{ issue.return_order.id }} от {{ issue.return_order.return_date.strftime('%d.%m.%Y') }}
{% else %}
Открыта
{% endif %}
</p>
</div>
<h2>Позиции выдачи</h2>
<table>
<thead>
<tr>
<th>Товар</th>
<th>Количество</th>
<th>Базовая цена</th>
</tr>
</thead>
<tbody>
{% for item in issue.items %}
<tr>
<td>{{ item.inventory.name }}</td>
<td>{{ item.quantity }}</td>
<td>{{ item.inventory.base_price }} ₽</td>
</tr>
{% else %}
<tr>
<td colspan="3">Нет позиций</td>
</tr>
{% endfor %}
</tbody>
</table>
<a href="{{ url_for('main.issues') }}" class="btn btn-secondary">Назад к списку выдач</a>
{% if not issue.return_order %}
<a href="{{ url_for('main.issue_edit', id=issue.id) }}" class="btn">Редактировать выдачу</a>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,97 @@
{% extends "base.html" %}
{% block content %}
<h1>{% if issue %}Редактирование выдачи{% else %}Новая выдача{% endif %}</h1>
<form method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="form-group">
<label>Партнёр</label>
<select name="partner_id" required>
{% for p in partners %}
<option value="{{ p.id }}" {% if issue and issue.partner_id == p.id %}selected{% endif %}>{{ p.name }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>Примечания</label>
<textarea name="notes">{{ issue.notes if issue else '' }}</textarea>
</div>
<h3>Позиции</h3>
<div id="items">
{% if issue %}
{% for item in issue.items %}
<div class="card item-row">
<div class="form-group">
<label>Товар</label>
<select name="inventory_id[]" required>
{% for inv in inventory %}
<option value="{{ inv.id }}" {% if inv.id == item.inventory_id %}selected{% endif %}>{{ inv.name }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>Количество</label>
<input type="number" name="quantity[]" value="{{ item.quantity }}" min="1" required>
</div>
<button type="button" class="btn btn-secondary remove-item">Удалить</button>
</div>
{% endfor %}
{% else %}
<div class="card item-row">
<div class="form-group">
<label>Товар</label>
<select name="inventory_id[]" required>
{% for inv in inventory %}
<option value="{{ inv.id }}">{{ inv.name }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>Количество</label>
<input type="number" name="quantity[]" value="1" min="1" required>
</div>
<button type="button" class="btn btn-secondary remove-item">Удалить</button>
</div>
{% endif %}
</div>
<button type="button" id="add-item" class="btn btn-secondary">➕ Добавить позицию</button>
<br><br>
<button type="submit" class="btn">Сохранить</button>
<a href="{{ url_for('main.issues') }}" class="btn btn-secondary">Отмена</a>
</form>
<script>
document.getElementById('add-item').addEventListener('click', function() {
const div = document.createElement('div');
div.className = 'card item-row';
div.innerHTML = `
<div class="form-group">
<label>Товар</label>
<select name="inventory_id[]" required>
{% for inv in inventory %}
<option value="{{ inv.id }}">{{ inv.name }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>Количество</label>
<input type="number" name="quantity[]" value="1" min="1" required>
</div>
<button type="button" class="btn btn-secondary remove-item">Удалить</button>
`;
document.getElementById('items').appendChild(div);
attachRemove();
});
function attachRemove() {
document.querySelectorAll('.remove-item').forEach(btn => {
btn.removeEventListener('click', removeHandler);
btn.addEventListener('click', removeHandler);
});
}
function removeHandler(e) {
e.target.closest('.item-row').remove();
}
attachRemove();
</script>
{% endblock %}

42
app/templates/issues.html Normal file
View file

@ -0,0 +1,42 @@
{% extends "base.html" %}
{% block content %}
<h1>Выдачи инвентаря</h1>
<a href="{{ url_for('main.issue_new') }}" class="btn">➕ Новая выдача</a>
<table>
<thead>
<tr>
<th>ID</th>
<th>Партнёр</th>
<th>Дата</th>
<th>Статус</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{% for issue in issues %}
<tr>
<td>{{ issue.id }}</td>
<td><a href="{{ url_for('main.partner_detail', id=issue.partner.id) }}">{{ issue.partner.name }}</a></td>
<td>{{ issue.issue_date.strftime('%d.%m.%Y %H:%M') }}</td>
<td>
{% if issue.return_order %}
<span style="color: gray;">🔒 Закрыта</span>
{% else %}
<span style="color: green;">🟢 Открыта</span>
{% endif %}
</td>
<td>
<a href="{{ url_for('main.issue_detail', id=issue.id) }}" class="btn btn-secondary">🔍</a>
{% if not issue.return_order %}
<a href="{{ url_for('main.issue_edit', id=issue.id) }}" class="btn btn-secondary">✏️</a>
{% endif %}
</td>
</tr>
{% else %}
<tr>
<td colspan="5">Нет выдач</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}

View file

@ -0,0 +1,43 @@
{% extends "base.html" %}
{% block content %}
<h1>{{ partner.name }}</h1>
<div class="card">
<p><strong>Телефон:</strong> {{ partner.phone }}</p>
<p><strong>Email:</strong> {{ partner.email }}</p>
<p><strong>Адрес:</strong> {{ partner.address }}</p>
</div>
<a href="{{ url_for('main.partner_edit', id=partner.id) }}" class="btn">Редактировать</a>
<a href="{{ url_for('main.partners') }}" class="btn btn-secondary">Назад к списку</a>
<h2>История выдач</h2>
<table>
<thead>
<tr>
<th>ID выдачи</th>
<th>Дата</th>
<th>Количество позиций</th>
<th>Статус</th>
</tr>
</thead>
<tbody>
{% for issue in partner.issue_orders %}
<tr>
<td><a href="{{ url_for('main.issue_detail', id=issue.id) }}">{{ issue.id }}</a></td>
<td>{{ issue.issue_date.strftime('%d.%m.%Y %H:%M') }}</td>
<td>{{ issue.items|length }}</td>
<td>
{% if issue.return_order %}
Закрыта (возврат #{{ issue.return_order.id }})
{% else %}
Открыта
{% endif %}
</td>
</tr>
{% else %}
<tr>
<td colspan="4">У этого партнёра пока нет выдач</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}

View file

@ -0,0 +1,25 @@
{% extends "base.html" %}
{% block content %}
<h1>{% if partner %}Редактирование партнёра{% else %}Новый партнёр{% endif %}</h1>
<form method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="form-group">
<label>Наименование</label>
<input type="text" name="name" value="{{ partner.name if partner else '' }}" required>
</div>
<div class="form-group">
<label>Телефон</label>
<input type="text" name="phone" value="{{ partner.phone if partner else '' }}" placeholder="+7(999)123-45-67">
</div>
<div class="form-group">
<label>Email</label>
<input type="email" name="email" value="{{ partner.email if partner else '' }}" required>
</div>
<div class="form-group">
<label>Адрес</label>
<textarea name="address">{{ partner.address if partner else '' }}</textarea>
</div>
<button type="submit" class="btn">Сохранить</button>
<a href="{{ url_for('main.partners') }}" class="btn btn-secondary">Отмена</a>
</form>
{% endblock %}

View file

@ -0,0 +1,46 @@
{% extends "base.html" %}
{% block content %}
<h1>Партнёры</h1>
<!-- Форма поиска (по ТЗ) -->
<form method="get" action="{{ url_for('main.partners') }}" style="margin-bottom: 1rem; display: flex; gap: 0.5rem;">
<input type="text" name="search" placeholder="Поиск по наименованию или телефону" value="{{ request.args.get('search', '') }}" style="flex: 1; padding: 0.5rem;">
<button type="submit" class="btn">Найти</button>
{% if request.args.get('search') %}
<a href="{{ url_for('main.partners') }}" class="btn btn-secondary">Сбросить</a>
{% endif %}
</form>
<a href="{{ url_for('main.partner_new') }}" class="btn">➕ Добавить партнёра</a>
<table>
<thead>
<tr>
<th>ID</th>
<th>Наименование</th>
<th>Телефон</th>
<th>Email</th>
<th>Адрес</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{% for p in partners %}
<tr>
<td>{{ p.id }}</td>
<td><a href="{{ url_for('main.partner_detail', id=p.id) }}">{{ p.name }}</a></td>
<td>{{ p.phone }}</td>
<td>{{ p.email }}</td>
<td>{{ p.address }}</td>
<td>
<a href="{{ url_for('main.partner_edit', id=p.id) }}" class="btn btn-secondary">✏️</a>
</td>
</tr>
{% else %}
<tr>
<td colspan="6">Партнёры не найдены</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}

View file

@ -0,0 +1,134 @@
{% extends "base.html" %}
{% block content %}
<h1>Оформление возврата</h1>
<!-- Блок подсказки о сезонном коэффициенте (по ТЗ) -->
<div class="card" style="background-color: #e6ffe6; margin-bottom: 1rem;">
<p>
<strong>Сейчас {{ current_season }}:</strong>
{% if current_season == 'зима' %}
Зимние товары — наценка 20%, летние — скидка 20%.
{% elif current_season == 'лето' %}
Летние товары — наценка 20%, зимние — скидка 20%.
{% else %}
Межсезонье — коэффициент 1.0 для всех товаров.
{% endif %}
</p>
</div>
<!-- Выбор выдачи -->
<div class="card">
{% if issues %}
<form id="selectIssueForm">
<div class="form-group">
<label for="issue_id">Выберите выдачу:</label>
<select id="issue_id" name="issue_id" required>
<option value="">-- Выберите --</option>
{% for issue in issues %}
<option value="{{ issue.id }}">{{ issue.issue_date.strftime('%d.%m.%Y') }} - {{ issue.partner.name }}</option>
{% endfor %}
</select>
</div>
</form>
{% else %}
<p>Нет открытых выдач для оформления возврата.</p>
<a href="{{ url_for('main.issues') }}" class="btn">Перейти к выдачам</a>
{% endif %}
</div>
<!-- Контейнер для позиций выбранной выдачи -->
<div id="itemsContainer" style="display:none;">
<h2>Позиции возврата</h2>
<div id="loading" style="display:none;">Загрузка...</div>
<form id="returnForm" method="POST" action="{{ url_for('main.return_new') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="issue_id" id="hidden_issue_id">
<div id="itemsList"></div>
<div class="form-group">
<label for="notes">Примечания</label>
<textarea name="notes" id="notes" rows="3"></textarea>
</div>
<p><strong>Общая сумма: <span id="totalAmount">0.00</span> ₽</strong></p>
<button type="submit" class="btn">Оформить возврат</button>
</form>
</div>
<script>
document.getElementById('issue_id')?.addEventListener('change', function() {
var issueId = this.value;
if (!issueId) {
document.getElementById('itemsContainer').style.display = 'none';
return;
}
document.getElementById('hidden_issue_id').value = issueId;
document.getElementById('loading').style.display = 'block';
document.getElementById('itemsContainer').style.display = 'block';
fetch('/returns/get_issue_items/' + issueId)
.then(response => response.json())
.then(items => {
document.getElementById('loading').style.display = 'none';
var html = '';
items.forEach(item => {
html += `
<div class="card" data-item-id="${item.id}">
<h3>${item.inventory_name}</h3>
<p>Количество: ${item.quantity}</p>
<p>Базовая стоимость: ${item.base_price} ₽</p>
<input type="hidden" name="item_id[]" value="${item.id}">
<div class="form-group">
<label>Степень износа:</label>
<div class="radio-group">
<label><input type="radio" name="wear_${item.id}" value="low" checked> Малый</label>
<label><input type="radio" name="wear_${item.id}" value="medium"> Средний</label>
<label><input type="radio" name="wear_${item.id}" value="high"> Большой</label>
</div>
</div>
<p>Стоимость позиции: <span class="item-price" id="price_${item.id}">0.00</span> ₽</p>
</div>
`;
});
document.getElementById('itemsList').innerHTML = html;
attachWearListeners(items);
});
});
function attachWearListeners(items) {
items.forEach(item => {
var radios = document.getElementsByName('wear_' + item.id);
radios.forEach(radio => {
radio.addEventListener('change', function() {
updateItemPrice(item);
});
});
updateItemPrice(item);
});
}
function updateItemPrice(item) {
var selectedWear = document.querySelector('input[name="wear_' + item.id + '"]:checked').value;
fetch('/returns/calculate_preview', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
base_price: item.base_price,
quantity: item.quantity,
wear_level: selectedWear,
season_type: item.season_type
})
})
.then(response => response.json())
.then(data => {
document.getElementById('price_' + item.id).innerText = data.calculated_price.toFixed(2);
updateTotal();
});
}
function updateTotal() {
var total = 0;
document.querySelectorAll('.item-price').forEach(span => {
total += parseFloat(span.innerText) || 0;
});
document.getElementById('totalAmount').innerText = total.toFixed(2);
}
</script>
{% endblock %}

View file

@ -0,0 +1,30 @@
{% extends "base.html" %}
{% block content %}
<h1>Возвраты</h1>
<a href="{{ url_for('main.return_new') }}" class="btn">➕ Оформить возврат</a>
<table>
<thead>
<tr>
<th>ID возврата</th>
<th>Дата возврата</th>
<th>Сумма</th>
<th>Примечания</th>
</tr>
</thead>
<tbody>
{% for r in returns %}
<tr>
<td>{{ r.id }}</td>
<td>{{ r.return_date.strftime('%d.%m.%Y %H:%M') }}</td>
<td>{{ r.total_amount }} ₽</td>
<td>{{ r.notes or '—' }}</td>
</tr>
{% else %}
<tr>
<td colspan="4">Нет оформленных возвратов</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}

39
app/utils.py Normal file
View file

@ -0,0 +1,39 @@
from datetime import datetime
def get_season_coeff(season_type: str, return_date: datetime = None) -> float:
if return_date is None:
return_date = datetime.now()
month = return_date.month
if month in [12, 1, 2]:
current_season = 'winter'
elif month in [6, 7, 8]:
current_season = 'summer'
else:
return 1.0
if season_type == current_season:
return 1.2
else:
return 0.8
def get_wear_coeff(wear_level: str) -> float:
coeffs = {
'low': 1.1,
'medium': 1.3,
'high': 1.5
}
return coeffs.get(wear_level, 1.0)
def calculate_item_price(base_price, quantity, wear_level, season_type, return_date=None):
base_total = float(base_price) * quantity
wear_coeff = get_wear_coeff(wear_level)
season_coeff = get_season_coeff(season_type, return_date)
return round(base_total * wear_coeff * season_coeff, 2)
def get_current_season_name():
month = datetime.now().month
if month in [12, 1, 2]:
return "зима"
elif month in [6, 7, 8]:
return "лето"
else:
return "межсезонье"

BIN
instance/sport_inventory.db Normal file

Binary file not shown.

BIN
requirements.txt Normal file

Binary file not shown.

6
run.py Normal file
View file

@ -0,0 +1,6 @@
from app import create_app
app = create_app()
if __name__ == '__main__':
app.run(debug=True)

66
schema.sql Normal file
View file

@ -0,0 +1,66 @@
-- Таблица Categories
CREATE TABLE Categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(100) NOT NULL,
description TEXT
);
-- Таблица Inventory
CREATE TABLE Inventory (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category_id INTEGER NOT NULL,
name VARCHAR(200) NOT NULL,
base_price DECIMAL(10,2) NOT NULL,
season_type VARCHAR(10) NOT NULL,
FOREIGN KEY (category_id) REFERENCES Categories(id) ON DELETE RESTRICT
);
-- Таблица Partners
CREATE TABLE Partners (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(200) NOT NULL,
phone VARCHAR(20),
email VARCHAR(100),
address TEXT
);
-- Таблица IssueOrders
CREATE TABLE IssueOrders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
partner_id INTEGER NOT NULL,
issue_date DATETIME NOT NULL,
notes TEXT,
FOREIGN KEY (partner_id) REFERENCES Partners(id) ON DELETE RESTRICT
);
-- Таблица IssueOrderItems
CREATE TABLE IssueOrderItems (
id INTEGER PRIMARY KEY AUTOINCREMENT,
issue_order_id INTEGER NOT NULL,
inventory_id INTEGER NOT NULL,
quantity INTEGER NOT NULL CHECK (quantity > 0),
FOREIGN KEY (issue_order_id) REFERENCES IssueOrders(id) ON DELETE CASCADE,
FOREIGN KEY (inventory_id) REFERENCES Inventory(id) ON DELETE RESTRICT
);
-- Таблица ReturnOrders
CREATE TABLE ReturnOrders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
issue_order_id INTEGER NOT NULL UNIQUE,
return_date DATETIME NOT NULL,
total_amount DECIMAL(10,2),
notes TEXT,
FOREIGN KEY (issue_order_id) REFERENCES IssueOrders(id) ON DELETE CASCADE
);
-- Таблица ReturnOrderItems
CREATE TABLE ReturnOrderItems (
id INTEGER PRIMARY KEY AUTOINCREMENT,
return_order_id INTEGER NOT NULL,
inventory_id INTEGER NOT NULL,
quantity INTEGER NOT NULL CHECK (quantity > 0),
wear_level VARCHAR(10) NOT NULL,
calculated_price DECIMAL(10,2),
FOREIGN KEY (return_order_id) REFERENCES ReturnOrders(id) ON DELETE CASCADE,
FOREIGN KEY (inventory_id) REFERENCES Inventory(id) ON DELETE RESTRICT
);

0
tests/__init__.py Normal file
View file

11
tests/test_calculation.py Normal file
View file

@ -0,0 +1,11 @@
import unittest
from app.utils import calculate_item_price
from datetime import datetime
class TestCalculation(unittest.TestCase):
def test_winter_skis(self):
price = calculate_item_price(5000, 2, 'medium', 'winter', datetime(2026, 1, 15))
self.assertAlmostEqual(price, 15600.0, places=2)
if __name__ == '__main__':
unittest.main()

108
tests/test_integration.py Normal file
View file

@ -0,0 +1,108 @@
import unittest
from app import create_app, db
from app.models import Partners, Categories, Inventory, IssueOrders, IssueOrderItems, ReturnOrders, ReturnOrderItems
from app.utils import calculate_item_price
from datetime import datetime
class TestIntegration(unittest.TestCase):
def setUp(self):
self.app = create_app()
self.app.config['TESTING'] = True
self.app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///:memory:'
self.ctx = self.app.app_context()
self.ctx.push()
db.create_all()
# Создаём тестовые данные
self.partner = Partners(name='Тестовый партнёр', email='test@test.ru')
db.session.add(self.partner)
self.category = Categories(name='Тестовая категория')
db.session.add(self.category)
db.session.commit()
self.inventory = Inventory(
category_id=self.category.id,
name='Тестовый товар',
base_price=1000.0,
season_type='summer'
)
db.session.add(self.inventory)
db.session.commit()
self.issue = IssueOrders(
partner_id=self.partner.id,
issue_date=datetime.now(),
notes='Тестовая выдача'
)
db.session.add(self.issue)
db.session.commit()
self.issue_item = IssueOrderItems(
issue_order_id=self.issue.id,
inventory_id=self.inventory.id,
quantity=3
)
db.session.add(self.issue_item)
db.session.commit()
def tearDown(self):
db.session.remove()
db.drop_all()
self.ctx.pop()
def test_create_return_with_calculation(self):
"""Тест создания возврата с расчётом стоимости"""
# Создаём возврат
return_date = datetime.now()
ret = ReturnOrders(
issue_order_id=self.issue.id,
return_date=return_date,
notes='Тестовый возврат'
)
db.session.add(ret)
db.session.commit()
# Рассчитываем стоимость позиции
price = calculate_item_price(
base_price=self.inventory.base_price,
quantity=self.issue_item.quantity,
wear_level='medium',
season_type=self.inventory.season_type,
return_date=return_date
)
ret_item = ReturnOrderItems(
return_order_id=ret.id,
inventory_id=self.inventory.id,
quantity=self.issue_item.quantity,
wear_level='medium',
calculated_price=price
)
db.session.add(ret_item)
ret.total_amount = round(price, 2)
db.session.commit()
# Проверяем, что возврат создан и сумма рассчитана
saved_return = ReturnOrders.query.get(ret.id)
self.assertIsNotNone(saved_return)
self.assertEqual(saved_return.total_amount, round(price, 2))
# Проверяем, что выдача теперь закрыта (есть возврат)
self.assertIsNotNone(self.issue.return_order)
def test_return_prevents_duplicate(self):
"""Тест запрета повторного возврата для одной выдачи"""
# Создаём первый возврат
ret1 = ReturnOrders(issue_order_id=self.issue.id, return_date=datetime.now())
db.session.add(ret1)
db.session.commit()
# Пытаемся создать второй возврат для той же выдачи
ret2 = ReturnOrders(issue_order_id=self.issue.id, return_date=datetime.now())
db.session.add(ret2)
with self.assertRaises(Exception):
db.session.commit()
db.session.rollback()
if __name__ == '__main__':
unittest.main()

0
tests/test_partners.py Normal file
View file

View file

49
tests/test_validation.py Normal file
View file

@ -0,0 +1,49 @@
import unittest
from app import create_app, db
from app.models import Partners, Categories, Inventory, IssueOrders, IssueOrderItems, ReturnOrders, ReturnOrderItems
from datetime import datetime
class TestValidation(unittest.TestCase):
def setUp(self):
self.app = create_app()
self.app.config['TESTING'] = True
self.app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///:memory:'
self.ctx = self.app.app_context()
self.ctx.push()
db.create_all()
# Создаем тестовые данные
self.p = Partners(name='Test', email='t@t.ru')
db.session.add(self.p)
self.cat = Categories(name='TestCat')
db.session.add(self.cat)
db.session.commit()
self.inv = Inventory(category_id=self.cat.id, name='Test', base_price=100, season_type='summer')
db.session.add(self.inv)
db.session.commit()
self.issue = IssueOrders(partner_id=self.p.id, issue_date=datetime.now())
db.session.add(self.issue)
db.session.commit()
self.item = IssueOrderItems(issue_order_id=self.issue.id, inventory_id=self.inv.id, quantity=2)
db.session.add(self.item)
db.session.commit()
def tearDown(self):
db.session.remove()
db.drop_all()
self.ctx.pop()
def test_cannot_create_return_twice(self):
"""Проверка, что нельзя создать второй возврат для одной выдачи"""
ret1 = ReturnOrders(issue_order_id=self.issue.id, return_date=datetime.now())
db.session.add(ret1)
db.session.commit()
ret2 = ReturnOrders(issue_order_id=self.issue.id, return_date=datetime.now())
db.session.add(ret2)
with self.assertRaises(Exception):
db.session.commit()
db.session.rollback()
if __name__ == '__main__':
unittest.main()