18.03.26
This commit is contained in:
parent
1522c25819
commit
a8079a455f
100
README.md
100
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
|
||||
26
app/__init__.py
Normal file
26
app/__init__.py
Normal 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
76
app/db.py
Normal 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
83
app/models.py
Normal 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
366
app/routes.py
Normal 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
132
app/static/css/style.css
Normal 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
33
app/templates/base.html
Normal 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>
|
||||
30
app/templates/categories.html
Normal file
30
app/templates/categories.html
Normal 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 %}
|
||||
17
app/templates/category_form.html
Normal file
17
app/templates/category_form.html
Normal 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
38
app/templates/index.html
Normal 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 %}
|
||||
54
app/templates/inventory.html
Normal file
54
app/templates/inventory.html
Normal 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 %}
|
||||
34
app/templates/inventory_form.html
Normal file
34
app/templates/inventory_form.html
Normal 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 %}
|
||||
45
app/templates/issue_detail.html
Normal file
45
app/templates/issue_detail.html
Normal 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 %}
|
||||
97
app/templates/issue_form.html
Normal file
97
app/templates/issue_form.html
Normal 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
42
app/templates/issues.html
Normal 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 %}
|
||||
43
app/templates/partner_detail.html
Normal file
43
app/templates/partner_detail.html
Normal 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 %}
|
||||
25
app/templates/partner_form.html
Normal file
25
app/templates/partner_form.html
Normal 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 %}
|
||||
46
app/templates/partners.html
Normal file
46
app/templates/partners.html
Normal 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 %}
|
||||
134
app/templates/return_select_issue.html
Normal file
134
app/templates/return_select_issue.html
Normal 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 %}
|
||||
30
app/templates/returns.html
Normal file
30
app/templates/returns.html
Normal 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
39
app/utils.py
Normal 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
BIN
instance/sport_inventory.db
Normal file
Binary file not shown.
BIN
requirements.txt
Normal file
BIN
requirements.txt
Normal file
Binary file not shown.
6
run.py
Normal file
6
run.py
Normal 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
66
schema.sql
Normal 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
0
tests/__init__.py
Normal file
11
tests/test_calculation.py
Normal file
11
tests/test_calculation.py
Normal 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
108
tests/test_integration.py
Normal 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
0
tests/test_partners.py
Normal file
0
tests/test_return_orders.py
Normal file
0
tests/test_return_orders.py
Normal file
49
tests/test_validation.py
Normal file
49
tests/test_validation.py
Normal 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()
|
||||
Loading…
Reference in a new issue