From a8079a455f542adbd211d5341f4431c4431476d2 Mon Sep 17 00:00:00 2001 From: Zver Date: Wed, 18 Mar 2026 13:10:29 +0500 Subject: [PATCH] 18.03.26 --- README.md | 100 +------ app/__init__.py | 26 ++ app/db.py | 76 +++++ app/models.py | 83 ++++++ app/routes.py | 366 +++++++++++++++++++++++++ app/static/css/style.css | 132 +++++++++ app/templates/base.html | 33 +++ app/templates/categories.html | 30 ++ app/templates/category_form.html | 17 ++ app/templates/index.html | 38 +++ app/templates/inventory.html | 54 ++++ app/templates/inventory_form.html | 34 +++ app/templates/issue_detail.html | 45 +++ app/templates/issue_form.html | 97 +++++++ app/templates/issues.html | 42 +++ app/templates/partner_detail.html | 43 +++ app/templates/partner_form.html | 25 ++ app/templates/partners.html | 46 ++++ app/templates/return_select_issue.html | 134 +++++++++ app/templates/returns.html | 30 ++ app/utils.py | 39 +++ instance/sport_inventory.db | Bin 0 -> 36864 bytes requirements.txt | Bin 0 -> 188 bytes run.py | 6 + schema.sql | 66 +++++ tests/__init__.py | 0 tests/test_calculation.py | 11 + tests/test_integration.py | 108 ++++++++ tests/test_partners.py | 0 tests/test_return_orders.py | 0 tests/test_validation.py | 49 ++++ 31 files changed, 1640 insertions(+), 90 deletions(-) create mode 100644 app/__init__.py create mode 100644 app/db.py create mode 100644 app/models.py create mode 100644 app/routes.py create mode 100644 app/static/css/style.css create mode 100644 app/templates/base.html create mode 100644 app/templates/categories.html create mode 100644 app/templates/category_form.html create mode 100644 app/templates/index.html create mode 100644 app/templates/inventory.html create mode 100644 app/templates/inventory_form.html create mode 100644 app/templates/issue_detail.html create mode 100644 app/templates/issue_form.html create mode 100644 app/templates/issues.html create mode 100644 app/templates/partner_detail.html create mode 100644 app/templates/partner_form.html create mode 100644 app/templates/partners.html create mode 100644 app/templates/return_select_issue.html create mode 100644 app/templates/returns.html create mode 100644 app/utils.py create mode 100644 instance/sport_inventory.db create mode 100644 requirements.txt create mode 100644 run.py create mode 100644 schema.sql create mode 100644 tests/__init__.py create mode 100644 tests/test_calculation.py create mode 100644 tests/test_integration.py create mode 100644 tests/test_partners.py create mode 100644 tests/test_return_orders.py create mode 100644 tests/test_validation.py diff --git a/README.md b/README.md index f5a44ec..efd6af5 100644 --- a/README.md +++ b/README.md @@ -1,93 +1,13 @@ -# SPORT_INVENTARY +# Спортивный инвентарь +Веб-приложение для учёта выдачи и возврата спортивного инвентаря с расчётом стоимости возврата на основе износа и сезонности. +## Установка и запуск -## Getting started - -To make it easy for you to get started with GitLab, here's a list of recommended next steps. - -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)! - -## Add your files - -- [ ] [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. +1. Убедитесь, что установлен Python 3.8 или выше. +2. Клонируйте репозиторий или создайте папку `sport_inventory` и скопируйте все файлы. +3. Создайте виртуальное окружение и активируйте его: + ```bash + python -m venv .venv + .venv\Scripts\activate # Windows + source .venv/bin/activate # Linux/Mac \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..07e42ac --- /dev/null +++ b/app/__init__.py @@ -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 \ No newline at end of file diff --git a/app/db.py b/app/db.py new file mode 100644 index 0000000..88137ba --- /dev/null +++ b/app/db.py @@ -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() \ No newline at end of file diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..9727aba --- /dev/null +++ b/app/models.py @@ -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'' + +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'' + +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'' + +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'' + +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'' + +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'' + +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'' \ No newline at end of file diff --git a/app/routes.py b/app/routes.py new file mode 100644 index 0000000..acfafa2 --- /dev/null +++ b/app/routes.py @@ -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/') +def partner_detail(id): + partner = Partners.query.get_or_404(id) + return render_template('partner_detail.html', partner=partner) + + +@bp.route('/partners//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//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//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//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//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/') +def issue_detail(id): + issue = IssueOrders.query.get_or_404(id) + return render_template('issue_detail.html', issue=issue) + + +@bp.route('/issues//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/') +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}) \ No newline at end of file diff --git a/app/static/css/style.css b/app/static/css/style.css new file mode 100644 index 0000000..ffd59e1 --- /dev/null +++ b/app/static/css/style.css @@ -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; +} \ No newline at end of file diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..2d8cd6e --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,33 @@ + + + + + + Спортивный инвентарь + + + + +
+ + +
+
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} + {% endwith %} + {% block content %}{% endblock %} +
+ + \ No newline at end of file diff --git a/app/templates/categories.html b/app/templates/categories.html new file mode 100644 index 0000000..e1326ce --- /dev/null +++ b/app/templates/categories.html @@ -0,0 +1,30 @@ +{% extends "base.html" %} +{% block content %} +

Категории инвентаря

+➕ Добавить категорию + + + + + + {% for cat in categories %} + + + + + + + {% else %} + + + + {% endfor %} + +
IDНазваниеОписаниеДействия
{{ cat.id }}{{ cat.name }}{{ cat.description }} + ✏️ +
+ + +
+
Нет категорий
+{% endblock %} \ No newline at end of file diff --git a/app/templates/category_form.html b/app/templates/category_form.html new file mode 100644 index 0000000..4b761fa --- /dev/null +++ b/app/templates/category_form.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} +{% block content %} +

{% if category %}Редактирование категории{% else %}Новая категория{% endif %}

+
+ +
+ + +
+
+ + +
+ + Отмена +
+{% endblock %} \ No newline at end of file diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 0000000..05763cc --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,38 @@ +{% extends "base.html" %} +{% block content %} +

Панель управления

+
+
+

Активных выдач

+

{{ active_issues }}

+
+
+

Партнёров

+

{{ partners_count }}

+
+
+

Ожидаемая сумма

+

{{ expected_total }} ₽

+
+
+ +

Последние операции

+
+
+

Выдачи

+
    + {% for issue in recent_issues %} +
  • {{ issue.issue_date.strftime('%d.%m.%Y') }} — {{ issue.partner.name }}
  • + {% endfor %} +
+
+
+

Возвраты

+
    + {% for ret in recent_returns %} +
  • {{ ret.return_date.strftime('%d.%m.%Y') }} — {{ ret.total_amount }} ₽
  • + {% endfor %} +
+
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/inventory.html b/app/templates/inventory.html new file mode 100644 index 0000000..1044a08 --- /dev/null +++ b/app/templates/inventory.html @@ -0,0 +1,54 @@ +{% extends "base.html" %} +{% block content %} +

Инвентарь

+➕ Добавить инвентарь + + +
+
+ + +
+
+ + +
+
+ + + + + + + {% for item in items %} + + + + + + + + + {% else %} + + + + {% endfor %} + +
IDНаименованиеКатегорияБазовая ценаСезонДействия
{{ item.id }}{{ item.name }}{{ item.category.name }}{{ item.base_price }} ₽{{ 'Зима' if item.season_type == 'winter' else 'Лето' }} + ✏️ +
+ + +
+
Нет инвентаря
+{% endblock %} \ No newline at end of file diff --git a/app/templates/inventory_form.html b/app/templates/inventory_form.html new file mode 100644 index 0000000..b30913f --- /dev/null +++ b/app/templates/inventory_form.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} +{% block content %} +

{% if item %}Редактирование инвентаря{% else %}Новый инвентарь{% endif %}

+
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + Отмена +
+{% endblock %} \ No newline at end of file diff --git a/app/templates/issue_detail.html b/app/templates/issue_detail.html new file mode 100644 index 0000000..81e03e1 --- /dev/null +++ b/app/templates/issue_detail.html @@ -0,0 +1,45 @@ +{% extends "base.html" %} +{% block content %} +

Выдача #{{ issue.id }}

+
+

Партнёр: {{ issue.partner.name }}

+

Дата выдачи: {{ issue.issue_date.strftime('%d.%m.%Y %H:%M') }}

+

Примечания: {{ issue.notes or '—' }}

+

Статус: + {% if issue.return_order %} + Закрыта возвратом #{{ issue.return_order.id }} от {{ issue.return_order.return_date.strftime('%d.%m.%Y') }} + {% else %} + Открыта + {% endif %} +

+
+ +

Позиции выдачи

+ + + + + + + + + + {% for item in issue.items %} + + + + + + {% else %} + + + + {% endfor %} + +
ТоварКоличествоБазовая цена
{{ item.inventory.name }}{{ item.quantity }}{{ item.inventory.base_price }} ₽
Нет позиций
+ +Назад к списку выдач +{% if not issue.return_order %} + Редактировать выдачу +{% endif %} +{% endblock %} \ No newline at end of file diff --git a/app/templates/issue_form.html b/app/templates/issue_form.html new file mode 100644 index 0000000..6ecb258 --- /dev/null +++ b/app/templates/issue_form.html @@ -0,0 +1,97 @@ +{% extends "base.html" %} +{% block content %} +

{% if issue %}Редактирование выдачи{% else %}Новая выдача{% endif %}

+
+ +
+ + +
+
+ + +
+ +

Позиции

+
+ {% if issue %} + {% for item in issue.items %} +
+
+ + +
+
+ + +
+ +
+ {% endfor %} + {% else %} +
+
+ + +
+
+ + +
+ +
+ {% endif %} +
+ +

+ + Отмена +
+ + +{% endblock %} \ No newline at end of file diff --git a/app/templates/issues.html b/app/templates/issues.html new file mode 100644 index 0000000..7b0cb7c --- /dev/null +++ b/app/templates/issues.html @@ -0,0 +1,42 @@ +{% extends "base.html" %} +{% block content %} +

Выдачи инвентаря

+➕ Новая выдача + + + + + + + + + + + + {% for issue in issues %} + + + + + + + + {% else %} + + + + {% endfor %} + +
IDПартнёрДатаСтатусДействия
{{ issue.id }}{{ issue.partner.name }}{{ issue.issue_date.strftime('%d.%m.%Y %H:%M') }} + {% if issue.return_order %} + 🔒 Закрыта + {% else %} + 🟢 Открыта + {% endif %} + + 🔍 + {% if not issue.return_order %} + ✏️ + {% endif %} +
Нет выдач
+{% endblock %} \ No newline at end of file diff --git a/app/templates/partner_detail.html b/app/templates/partner_detail.html new file mode 100644 index 0000000..0943c91 --- /dev/null +++ b/app/templates/partner_detail.html @@ -0,0 +1,43 @@ +{% extends "base.html" %} +{% block content %} +

{{ partner.name }}

+
+

Телефон: {{ partner.phone }}

+

Email: {{ partner.email }}

+

Адрес: {{ partner.address }}

+
+Редактировать +Назад к списку + +

История выдач

+ + + + + + + + + + + {% for issue in partner.issue_orders %} + + + + + + + {% else %} + + + + {% endfor %} + +
ID выдачиДатаКоличество позицийСтатус
{{ issue.id }}{{ issue.issue_date.strftime('%d.%m.%Y %H:%M') }}{{ issue.items|length }} + {% if issue.return_order %} + Закрыта (возврат #{{ issue.return_order.id }}) + {% else %} + Открыта + {% endif %} +
У этого партнёра пока нет выдач
+{% endblock %} \ No newline at end of file diff --git a/app/templates/partner_form.html b/app/templates/partner_form.html new file mode 100644 index 0000000..90474e3 --- /dev/null +++ b/app/templates/partner_form.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} +{% block content %} +

{% if partner %}Редактирование партнёра{% else %}Новый партнёр{% endif %}

+
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + Отмена +
+{% endblock %} \ No newline at end of file diff --git a/app/templates/partners.html b/app/templates/partners.html new file mode 100644 index 0000000..ce3dc98 --- /dev/null +++ b/app/templates/partners.html @@ -0,0 +1,46 @@ +{% extends "base.html" %} +{% block content %} +

Партнёры

+ + +
+ + + {% if request.args.get('search') %} + Сбросить + {% endif %} +
+ +➕ Добавить партнёра + + + + + + + + + + + + + + {% for p in partners %} + + + + + + + + + {% else %} + + + + {% endfor %} + +
IDНаименованиеТелефонEmailАдресДействия
{{ p.id }}{{ p.name }}{{ p.phone }}{{ p.email }}{{ p.address }} + ✏️ +
Партнёры не найдены
+{% endblock %} \ No newline at end of file diff --git a/app/templates/return_select_issue.html b/app/templates/return_select_issue.html new file mode 100644 index 0000000..d757a1d --- /dev/null +++ b/app/templates/return_select_issue.html @@ -0,0 +1,134 @@ +{% extends "base.html" %} +{% block content %} +

Оформление возврата

+ + +
+

+ Сейчас {{ current_season }}: + {% if current_season == 'зима' %} + Зимние товары — наценка 20%, летние — скидка 20%. + {% elif current_season == 'лето' %} + Летние товары — наценка 20%, зимние — скидка 20%. + {% else %} + Межсезонье — коэффициент 1.0 для всех товаров. + {% endif %} +

+
+ + +
+ {% if issues %} +
+
+ + +
+
+ {% else %} +

Нет открытых выдач для оформления возврата.

+ Перейти к выдачам + {% endif %} +
+ + + + + +{% endblock %} \ No newline at end of file diff --git a/app/templates/returns.html b/app/templates/returns.html new file mode 100644 index 0000000..a2c9c3c --- /dev/null +++ b/app/templates/returns.html @@ -0,0 +1,30 @@ +{% extends "base.html" %} +{% block content %} +

Возвраты

+➕ Оформить возврат + + + + + + + + + + + + {% for r in returns %} + + + + + + + {% else %} + + + + {% endfor %} + +
ID возвратаДата возвратаСуммаПримечания
{{ r.id }}{{ r.return_date.strftime('%d.%m.%Y %H:%M') }}{{ r.total_amount }} ₽{{ r.notes or '—' }}
Нет оформленных возвратов
+{% endblock %} \ No newline at end of file diff --git a/app/utils.py b/app/utils.py new file mode 100644 index 0000000..cdf324b --- /dev/null +++ b/app/utils.py @@ -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 "межсезонье" \ No newline at end of file diff --git a/instance/sport_inventory.db b/instance/sport_inventory.db new file mode 100644 index 0000000000000000000000000000000000000000..46fb3fcb9175614bd44fd276119b58b38080d984 GIT binary patch literal 36864 zcmeI4U2Gdw7Jz5QKNF`ULxGi*hltyspjeHKXPjj0@Q`?o8?2^j>$+$WNM?wKc2=<+ zX2xZ!)h$o-Uh3%_cGKZjR@re)*>TPP>C#|TJr8kGq5m&^&)xObu15l2?h@eyZb$$LAOR$R1dzbb zjll9ij~EPx`9J8k_SC$d)$NK|I${=dGh^#TtNl2T%BGXKG|44@Ig=)P+l%*-;og8z zAeq@*dODpYvqy4d_QcH02`^=6lJgaMF zeqKMV&yz=!+0-M+?64dSwRB=mo1d%9Yqnm*vvI=5R9m z7&(-Fj0_tE=iI>~*>q-lmYo}Jvm->Z>4WKPdN!3l-U7q|7(1vpo7k$GsT=Pq_J)?i zela*S#Ge~%ADiZJvBZvJ!n)9LJPgaK=-r27%hUq!A`io`=Q4-W43S;7wfVeOELTeQ z4g^bOTenCq{i|Fjo+oBA$4;byPfH(eLfux}LOR1#or$^yx;LbX{@|e@o-;}X{WsP# z^YDM-yjHQx&T+nJu3CAy`KSjjTK3_V4*SI5fdl-xV-D~}6VA2JcIa=#-d6ep$v4lE z9p`%23*lB%*l5Mxohuu|=K0^;wOdT!#e!E1?%T&NOKm)@1})3iiDTEjK>-$sAmCC%deK zC9SA$FLO$?ZJm2cv-BNTxR%w>(lx7G%G+la8dbHgn75isP5o@8lyVI)^leUy6M`5V z9ORd#9Zq$O^*vLzf>kw>#VsFmlU^b8HE;pf9hY;p_B_2tYRhz; z(6=4uaI>}qZsm)Phr26nEBY$E0{%A09{2b;H5^LP_nBJ{=fLMC^@9WIa6*@>lfNf}cASjvb@#u|KjGBtK@fp<@?m)#>1B?d)Et zel#%_0=vVC5{|`;($nQBYoTn~Ma{BxlXdhW=rL!0g;t@Z58xLxoio+ig%Ki0XLx47 zkV@ZTG7xQW4A?qg1|$+8S&4;5!2s1bt(B&n31L>)m`4bG#|dIUu!1!pv-|%(X+I}@ zA$=(QUV2p8-~aEQ;@rnRApsR&(_WdKCvprqHabZ?!q4t20Tof-QIT-06^#hfdE1yL!i2A&DD4S!V~;MtbP`F zx%s|+-=$}b5;*+(AsE4{Os*v#VC(Mxy)U{_4?=Vy0PRC%5KGwMM*r2V+xa4On-4*V zAdmuuoAU+gqQ0+PQ8)iT$4Q?`=cU~DCy5$J00|%gB!C2v01`j~NB{{S0VIF~?ofjZcgz5k-#26H)p)3s10+14JI2M+L6H?hCXUhvx(W$;rB=tTZ%bWpp%=a1tEO zvV?&)P51u)=bZE(>7UZK($~`Gcj~C2-ADilAOR$R1dsp{Kmter2_OL^fCPR{1bTcB zM&NmmCnb8>UO@2nuz&>53xZFC7()OG1wV@z1b7eg{{`t~PWlYKa6KitTBoo{l|~|1iwc6T5Jkw(tFt!{Nmz!3otd-SdZVLYqUEZI zxDb1tIdOOa4^r+VN|!X$^t^q;(xKOXBPSEVsOB3v#4?}z$V-grhvB^c6nWN 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 +); \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_calculation.py b/tests/test_calculation.py new file mode 100644 index 0000000..a7d8e6e --- /dev/null +++ b/tests/test_calculation.py @@ -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() \ No newline at end of file diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..7803f39 --- /dev/null +++ b/tests/test_integration.py @@ -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() \ No newline at end of file diff --git a/tests/test_partners.py b/tests/test_partners.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_return_orders.py b/tests/test_return_orders.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_validation.py b/tests/test_validation.py new file mode 100644 index 0000000..b1c5684 --- /dev/null +++ b/tests/test_validation.py @@ -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() \ No newline at end of file