61 Commits

Author SHA1 Message Date
Your Name
31f1fcecc4 feat: add Docker Compose setup with bind-mount appdata directory
- Dockerfile builds image with app source in /app_src
- docker-entrypoint.sh syncs code from image on each start, inits DB
- docker-compose.yml mounts ./appdata:/app for easy backup
- backend/init_db.py for safe DB init (no drop_all)
- backend/database.py supports DATABASE_URL env var
- .dockerignore excludes venv, __pycache__, *.db, .git

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 12:15:37 +03:00
Your Name
67e7ab6f94 style: increase button padding via .btn override
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 17:05:40 +03:00
Your Name
913e23b966 style: add padding to buttons so text is not cramped
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 17:04:41 +03:00
Your Name
7219fbdada style: revert button color to original #E07D54
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 17:03:48 +03:00
Your Name
92fc020d94 style: change button color to black (#000000)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 17:01:25 +03:00
Your Name
dca8a0dbcf style: replace blue buttons with original #E07D54 color
Override Bootstrap btn-primary and btn-outline-primary to match the original project's button color.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 16:59:22 +03:00
Your Name
61d12f4972 feat: vendor Vue 3 and Bootstrap JS locally
Remove CDN dependencies to ensure the app works without internet access.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 16:56:26 +03:00
Your Name
2ae18dea27 feat: add home page with search/unassigned tables; center login card
- Add home view as default: search by inv number, unassigned equipment
  table with auditory assignment, search results (assigned) table
- Add inv_number query param to GET /oboruds/ for backend search
- Center login card vertically and horizontally via flexbox

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 16:50:52 +03:00
Your Name
1950bd4d45 refactor: reorganize navbar - group equipment views and admin views into dropdowns
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 15:18:26 +03:00
Your Name
9491909f24 fix aud search on center 2026-05-11 15:12:34 +03:00
Your Name
54534ee490 add proverka func 2026-05-11 13:16:48 +03:00
Danamir
bef4af4644 Add word break style 2026-01-22 23:10:16 +01:00
Danamir
35bd29c223 feat: add all equipment view with sorting, print functionality, and UI improvements
- Add "Всё оборудование" menu item with equipment sorted by inventory number
- Add row numbering in all equipment view
- Add print functionality for auditory view with "Проверено" column
- Hide unnecessary columns (quantity, type, owner) when printing
- Make cards full-width and responsive (container-fluid)
- Consolidate CSS styles from static/css/index.css to frontend/styles.css
- Fix text wrapping in "Расположение" column
- Add sort_by_inv parameter to /oboruds/ API endpoint

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 22:48:27 +01:00
Danamir
e2ff0f9a05 fix: make optional fields nullable in schema, add text wrap for location field
- Make type_id, aud_id, invNumber optional in OborudBase schema
- Make type optional in OborudRead to support NULL values after migration
- Add CSS word-wrap for raspologenie column to prevent text overflow

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 22:22:33 +01:00
Danamir
e428e7f762 add new ui 2025-11-10 11:28:49 +03:00
Danamir
3f91dc91ec feat(frontend): Vue (CDN) UI matching templates design; reuse /static CSS and Bootstrap; mount /static in FastAPI 2025-11-10 08:45:25 +03:00
Danamir
779c256e7b feat(frontend): mount static UI at /app with simple auditories/equipment browser 2025-11-10 08:40:46 +03:00
Danamir
d686b26465 update requirements 2025-11-10 08:37:23 +03:00
Danamir
08e979ecb2 uppdate 2025-11-10 08:35:07 +03:00
Danamir
72f1d53051 Resolve .gitignore merge conflict; consolidate ignore rules 2025-11-10 08:30:45 +03:00
Danamir
b1e0693131 fix run 2025-11-10 08:25:56 +03:00
Danamir
86713fc75f uncommited changes 2025-11-10 08:11:46 +03:00
Danamir
c24a1fa8c6 Last coommit before refactoring 2025-08-04 10:33:21 +03:00
1c33775f92 fixed table 2024-05-22 15:58:16 +03:00
43ab114e1a fixed table 2024-05-22 13:42:52 +03:00
946ad5c31f add sorting func 2024-05-18 20:58:55 +03:00
Your Name
7b956d89bf merge head by alembic 2024-05-06 08:29:58 -04:00
0891abc0e1 add fumc for login 2024-05-06 15:23:24 +03:00
08393f6685 Merge branch 'main' of https://git.danamir.su/danamir/asuinventory 2024-05-06 15:15:23 +03:00
9cde4e2c7d add readexcell func 2024-05-06 15:14:47 +03:00
Your Name
0a60a16344 bug fix 2024-04-09 23:52:19 +03:00
Your Name
496ef3fa9d bug fix 2024-04-09 23:51:48 +03:00
Your Name
3494e5d17c Merge branch 'main' of https://git.danamir.su/danamir/asuinventory 2024-04-08 10:49:58 +03:00
Your Name
29b0070260 add reload page 2024-04-08 10:47:54 +03:00
Your Name
2e9cc918d5 update db schema 2024-04-05 01:26:46 +03:00
Your Name
d943894ae8 all done2 2024-04-04 22:55:38 +03:00
Your Name
1c901ddb00 all done 2024-04-04 22:51:45 +03:00
Your Name
f9a188c927 add data to db 2024-04-04 20:00:26 +03:00
Your Name
1fce4b41c0 add modal fucnc 2024-04-04 19:31:07 +03:00
Your Name
7ccbba06c1 add click on row 2024-04-04 12:48:43 +03:00
Your Name
63115f0328 asdsadsa 2024-04-03 23:28:55 +03:00
cb862c63a6 add new func 2024-04-03 22:50:16 +03:00
927d8d75a9 edit db scheme 2024-04-03 22:37:57 +03:00
509a3ee913 edit gitignore 2024-04-03 22:31:08 +03:00
86f0f9d977 db check 2024-04-03 22:24:02 +03:00
0c4bf4b9fd 12333 2024-04-03 20:05:36 +03:00
d71945ecb2 11111 2024-04-03 20:02:05 +03:00
3cd5fe63b5 12311 2024-04-03 20:01:50 +03:00
0c6001b297 Resolve merge conflict by incorporating both suggestions 2024-04-03 19:58:22 +03:00
682b6c4ffd 1112 2024-04-03 19:57:28 +03:00
Your Name
4c6166c907 add to model 2024-04-02 23:09:27 +03:00
Your Name
2c51bdc695 Merge branch 'dev' 2024-04-02 23:07:44 +03:00
87c4ebe33e db schema update 2024-04-02 16:34:06 +03:00
Your Name
bf93bf0fdb local changes 2024-04-01 22:37:14 +03:00
Your Name
027b1dc855 1111 2024-04-01 22:35:26 +03:00
4a47746e9d merge 2024-04-01 15:21:43 +03:00
12a63278f7 Merge branch 'dev' 2024-04-01 15:17:53 +03:00
Your Name
965695a693 add raspologenie and vde numbers 2024-03-31 23:33:26 +03:00
Your Name
b47e9391ba add bug 2024-03-27 09:45:57 +03:00
Your Name
5259dc5292 sss 2024-03-26 23:03:26 +03:00
Your Name
a6eea28071 add funce 2024-03-26 23:02:37 +03:00
92 changed files with 3163 additions and 1943 deletions

View File

@@ -0,0 +1,17 @@
{
"permissions": {
"allow": [
"Bash(backend\\\\venv\\\\Scripts\\\\python.exe -m uvicorn backend.main:app --reload)",
"Bash(python:*)",
"Bash(backend/venv/Scripts/python.exe:*)",
"Bash(backend/venv/Scripts/pip.exe list:*)",
"Bash(backend/venv/Scripts/pip.exe install:*)",
"Bash(backend/venv/Scripts/uvicorn.exe backend.main:app)",
"Bash(curl -s http://localhost:8000/ping)",
"Bash(curl -s http://127.0.0.1:8000/ping)",
"Bash(taskkill /F /IM python.exe)",
"Bash(netstat -ano)",
"Bash(taskkill:*)"
]
}
}

13
.dockerignore Normal file
View File

@@ -0,0 +1,13 @@
venv/
backend/venv/
**/__pycache__/
*.pyc
*.pyo
*.db
.git/
.gitignore
.vscode/
.idea/
data/
*.xls
*.xlsx

23
.gitignore vendored
View File

@@ -1,12 +1,21 @@
*.csv
.vscode .vscode
instance .idea
venv/ venv/
__pycache__ instance/
123
*.csv # Ignore Python bytecode caches everywhere
__pycache__/
**/__pycache__/
# Migrations and DB files
migrations/
*.db *.db
# Data and temp files
*.csv
c*.txt c*.txt
migrations/* # Legacy specific ignores (if present)
.idea backend/venv
backend/__pycache__
backend/routeres/__pycache__

15
Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM python:3.11-slim
COPY requirements.txt /app_src/requirements.txt
RUN pip install --no-cache-dir -r /app_src/requirements.txt
COPY backend/ /app_src/backend/
COPY frontend/ /app_src/frontend/
COPY docker-entrypoint.sh /app_src/docker-entrypoint.sh
RUN chmod +x /app_src/docker-entrypoint.sh
WORKDIR /app
EXPOSE 8000
ENTRYPOINT ["/app_src/docker-entrypoint.sh"]

142
INSPECTION_GUIDE.md Normal file
View File

@@ -0,0 +1,142 @@
# Руководство по использованию системы проверки оборудования
## Обзор
Система проверки оборудования позволяет проводить инвентаризацию с использованием штрихкод-сканера или ручного ввода инвентарных номеров.
## Возможности
- ✅ Проверка оборудования по аудиториям или всего подряд
- ✅ Поддержка штрихкод-сканеров (работают как клавиатура)
- ✅ Автоматическое обновление времени при повторном сканировании
- ✅ Сохранение неизвестных номеров
- ✅ Отслеживание прогресса в реальном времени
- ✅ История всех проверок
- ✅ Доступ для всех авторизованных пользователей (включая viewer)
## Как использовать
### 1. Начать новую проверку
1. Войдите в систему
2. Перейдите в раздел "Проверка" в меню
3. (Опционально) Выберите аудиторию для проверки
- Если аудитория не выбрана, будет проверяться всё оборудование
4. Нажмите "Начать проверку"
### 2. Сканирование оборудования
После начала проверки:
1. Фокус автоматически установлен на поле ввода
2. Отсканируйте штрихкод или введите инвентарный номер вручную
3. Нажмите Enter (или сканер сделает это автоматически)
4. Система покажет результат:
-**Зелёное уведомление** - оборудование найдено
-**Красное уведомление** - номер не найден (сохранён в неизвестные)
5. Продолжайте сканирование следующих позиций
### 3. Отслеживание прогресса
Во время проверки отображается:
- **Проверено** - количество отсканированных позиций
- **Всего** - ожидаемое количество оборудования
- **Не найдено** - количество неизвестных штрихкодов
- **Прогресс** - процент завершения
### 4. Завершение проверки
1. После завершения сканирования нажмите "Завершить проверку"
2. Подтвердите действие
3. Проверка будет сохранена в историю
### 5. Просмотр истории
1. На главном экране проверок нажмите "Загрузить историю"
2. Выберите нужную проверку
3. Нажмите "Детали" для просмотра отчёта
## Работа со сканером штрихкодов
### Подключение
1. Подключите USB штрихкод-сканер к компьютеру
2. Сканер работает как клавиатура - не требует драйверов
3. Откройте страницу проверки в браузере
### Сканирование
1. Убедитесь, что фокус на поле ввода
2. Наведите сканер на штрихкод
3. Нажмите кнопку сканирования
4. Сканер введёт номер и нажмёт Enter автоматически
### Советы
- После каждого сканирования фокус автоматически возвращается на поле ввода
- Можно сканировать штрихкоды быстро один за другим
- Уведомления о результатах исчезают автоматически через 3 секунды
## Особенности
### Повторное сканирование
- При повторном сканировании одного и того же оборудования обновляется только время проверки
- В таблице остаётся одна запись на единицу оборудования
### Неизвестные номера
- Если номер не найден в базе данных, он сохраняется в список неизвестных
- Неизвестные номера отображаются отдельным списком
- Это помогает выявить ошибки маркировки или новое оборудование
### Доступ
- Все авторизованные пользователи могут проводить проверки
- Проверки можно просматривать, но нельзя удалять
- Каждая проверка привязана к пользователю, который её создал
## API Endpoints
Система предоставляет следующие API эндпоинты:
- `POST /inspections/sessions` - Начать новую проверку
- `POST /inspections/sessions/{id}/check` - Сканировать штрихкод
- `GET /inspections/sessions/{id}` - Получить статистику проверки
- `POST /inspections/sessions/{id}/complete` - Завершить проверку
- `GET /inspections/sessions` - Список всех проверок
- `GET /inspections/sessions/{id}/records` - Детальный отчёт
Полная документация доступна по адресу: http://localhost:8000/docs
## Устранение неполадок
### Сканер не работает
1. Проверьте USB подключение
2. Убедитесь, что фокус на поле ввода
3. Попробуйте отсканировать в текстовый редактор для проверки
4. Проверьте настройки сканера (должен добавлять Enter в конце)
### Оборудование не находится
1. Проверьте инвентарный номер в базе данных
2. Убедитесь, что штрихкод читается правильно
3. Проверьте список неизвестных штрихкодов
### Прогресс не обновляется
1. Нажмите кнопку "Обновить данные"
2. Проверьте подключение к серверу
3. Обновите страницу браузера
## База данных
Система использует три новые таблицы:
1. **inspection_sessions** - Сессии проверок
2. **inspection_records** - Записи о проверенном оборудовании
3. **unknown_barcodes** - Неизвестные штрихкоды
Таблицы создаются автоматически при запуске скрипта `create_inspection_tables.py`.

BIN
VedomostMOL.xls Normal file

Binary file not shown.

BIN
VedomostMOL.xlsx Normal file

Binary file not shown.

View File

274
app.py
View File

@@ -1,274 +0,0 @@
# -*- coding: utf-8 -*-
from flask import Flask, render_template, redirect, url_for, request, jsonify
from models import db, Oboruds, Auditory, Zametki
from flask_migrate import Migrate
from datetime import *
import csv
import random
from urllib.parse import unquote
app = Flask(__name__)
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///project.db"
app.jinja_env.auto_reload = True
app.config['TEMPLATES_AUTO_RELOAD'] = True
app.secret_key = '6523e58bc0eec42c31b9635d5e0dfc23b6d119b73e633bf3a5284c79bb4a1ede'
db.init_app(app)
migrate = Migrate(app, db)
@app.route("/", methods=['GET', 'POST'])
def index():
results = []
results1 = []
all_aud = db.session.query(Auditory).all()
auds = []
for item in all_aud:
auds.append(item.audnazvanie)
if request.method == 'POST':
p = request.form.get('srch').strip()
all_aud = db.session.query(Auditory).all()
s = db.session.query(Oboruds).filter(
Oboruds.invNumber.contains(p)).first()
if s:
for item in all_aud:
auds.append(item.audnazvanie)
if s.aud_id is None:
results.append([s.invNumber, s.nazvanie])
else:
results1.append(s.invNumber)
results1.append(s.nazvanie)
aud = db.session.get(Auditory, s.aud_id)
results1.append(aud.audnazvanie)
return render_template('index.html', aud=all_aud, results=results, res1=results1)
return render_template('index.html', aud=all_aud, results=results, res1=results1)
@app.route("/perenos", methods=['GET', 'POST'])
def perenos():
audid = request.args.get('audid')
invnomer = request.args.get('invnum')
ob = db.session.query(Oboruds).filter_by(invNumber=invnomer).first()
ob.aud_id = audid
db.session.commit()
return jsonify({'success': True}, 200, {'ContentType': 'application/json'})
@app.route("/addaud", methods=['GET', 'POST'])
def addAud():
return render_template('addaud.html', methods=['GET', 'POST'])
@app.route('/searchonaud', methods=['GET', 'POST'])
def searchonaud():
all_aud = db.session.query(Auditory).all()
res = []
if request.method == 'GET' and request.args.get('auditory'):
audid = request.args.get('auditory')
q = db.session.query(Auditory, Oboruds).filter(
Auditory.id == Oboruds.aud_id
).filter(Auditory.id == audid
).order_by(Oboruds.invNumber).all()
results = []
for auditory, oboruds in q:
results.append({
'num_ved': oboruds.numberved,
'auditory_id': auditory.id,
'auditory_name': auditory.audnazvanie,
'inv_number': oboruds.invNumber,
'oboruds_id': oboruds.nazvanie,
'raspolog': oboruds.raspologenie,
})
return jsonify(results)
else:
return render_template('searchonaud.html', aud=all_aud, res=res)
@app.route("/addaudtodb", methods=['GET', 'POST'])
def addaudtodb():
if request.method == 'POST':
aud = request.form.get('auditory')
db.session.add(Auditory(audnazvanie=aud))
db.session.commit()
return redirect(url_for('addAud'))
@app.route("/addoborudtodb", methods=['GET', 'POST'])
def addoborudtodb():
res = []
if request.method == 'POST':
audid = request.form.get('auditory')
invnomer = request.form.get('invnomer')
ob = db.session.query(Oboruds).filter_by(invNumber=invnomer).first()
ob.aud_id = audid
db.session.commit()
return redirect(url_for('index'))
@app.route('/all')
def alloborud():
return render_template('all.html')
@app.route('/getall')
def getall():
oboruds = Oboruds.query.all()
oboruds_json = [{
'id': oborud.id,
'invNumber': oborud.invNumber,
'nazvanie': oborud.nazvanie,
'raspologenie': oborud.raspologenie,
'numberved': oborud.numberved,
'balancenumber': oborud.balancenumber,
'kolichestvo': oborud.kolichestvo,
'balancenumber': oborud.balancenumber,
'aud_id': oborud.aud_id
} for oborud in oboruds]
return jsonify(oboruds_json)
@app.route('/updateduplicate', methods=['GET', 'POST'])
def updateduplicate():
if request.method == 'POST':
aud = request.form.get('auditory_dubl')
@app.route('/vneaud', methods=['GET', 'POST'])
def vneaud():
res = []
data = db.session.query(Oboruds).filter(Oboruds.aud_id == None).all()
ak = db.session.query(Oboruds).all()
for dt in data:
res.append([dt.invNumber, dt.nazvanie, dt.typeBalanse])
return render_template('vneaud.html', res=res, kolvo=len(data), all_kol=len(ak))
@app.route('/zametki', methods=['GET', 'POST'])
def zametki():
zam = db.session.query(Zametki).filter(Zametki.rmdt == None).all()
if request.method == 'POST':
textzam = request.form.get('textzam')
timeadd = datetime.now(timezone.utc)
if len(textzam) > 0:
db.session.add(Zametki(txtzam=textzam, created_date=timeadd))
db.session.commit()
return redirect(url_for('zametki'))
return render_template('zametki.html', zam=zam)
@app.route('/zamrm', methods=['GET', 'POST'])
def js2():
zmid = request.args.get('zmid')
ob = db.session.query(Zametki).filter_by(id=zmid).first()
ob.rmdt = datetime.now(timezone.utc)
db.session.commit()
return jsonify({'success': True}), 200, {'ContentType': 'application/json'}
@app.route('/zamsearch', methods=['GET', 'POST'])
def zamsearch():
p = request.form.get('srch')
searchedZam = db.session.query(Zametki).filter(
Zametki.txtzam.contains(p)).ll()
zam = []
for item in searchedZam:
zam.append([item.txtzam, item.created_date])
return render_template('zametki.html', zam=zam)
@app.route('/addraspved', methods=['GET', 'POST'])
def addraspved():
if request.method == 'POST':
query_string = request.data.decode()
print(query_string)
un_query_string = unquote(unquote(query_string)).split(',')
print(un_query_string)
ob = db.session.query(Oboruds).filter_by(invNumber=un_query_string[0]).first()
ob.raspologenie=un_query_string[2]
ob.numberved = un_query_string[1]
db.session.commit()
db.session.close()
return jsonify({'success': True}, 200, {'ContentType': 'application/json'})
# ==================================================================================
def ranomraspr():
with app.app_context():
while len(db.session.query(Oboruds).filter(Oboruds.aud_id == None).all()) > 0:
audid = random.choice(db.session.query(Auditory).all())
oborud = random.choice(db.session.query(Oboruds).filter(Oboruds.aud_id == None).all())
oborud.aud_id = audid.id
db.session.commit()
def createdb():
with app.app_context():
db.create_all()
auds = ['519', '521', '521a', '522', '523',
'601л',
'602л',
'603л',
'604л',
'605л',
'606л',
'607л',
'608л',
'609л',
'610л',
'611л',
'612л',
'613л',
'616л',
'617л',
'618л',
'619л',
'620л',
'621л',
'622л',
'626л',
'627л',
'703л',
'710л',
'713л']
for aud in auds:
db.session.add(Auditory(audnazvanie=aud))
db.session.commit()
with open('inventMavrin.csv', encoding='cp1251') as csv_file:
csv_reader = csv.reader(csv_file, delimiter=';')
for row in csv_reader:
db.session.add(
Oboruds(invNumber=row[0], nazvanie=row[1], typeBalanse='баланс'))
db.session.commit()
if __name__ == '__main__':
#ranomraspr()
#createdb()
app.run(debug=True, host='0.0.0.0', port='3800')

View File

@@ -0,0 +1,11 @@
{
"permissions": {
"allow": [
"Bash(.venvScriptsactivate)",
"Bash(python:*)",
"Bash(venv/Scripts/python.exe:*)",
"Bash(timeout 5 tail:*)",
"Bash(curl:*)"
]
}
}

2
backend/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
"""Backend package initializer."""

View File

@@ -0,0 +1,28 @@
"""
Скрипт для создания таблиц системы проверок оборудования.
Создаёт только новые таблицы, не затрагивая существующие.
"""
from backend.database import engine
from backend.models import Base, InspectionSession, InspectionRecord, UnknownBarcode
def create_inspection_tables():
"""Создать таблицы для системы проверок"""
print("Creating inspection tables...")
# Создать только новые таблицы
Base.metadata.create_all(bind=engine, tables=[
InspectionSession.__table__,
InspectionRecord.__table__,
UnknownBarcode.__table__
])
print("Inspection tables created successfully!")
print("- inspection_sessions")
print("- inspection_records")
print("- unknown_barcodes")
if __name__ == "__main__":
create_inspection_tables()

26
backend/create_new_db.py Normal file
View File

@@ -0,0 +1,26 @@
from sqlalchemy.orm import sessionmaker
from backend.models import Base, User
from backend.database import engine
from backend.security import get_password_hash
def recreate_db_with_admin():
# Drop and recreate all tables
Base.metadata.drop_all(bind=engine)
Base.metadata.create_all(bind=engine)
# Seed default admin user (username: admin, password: admin)
Session = sessionmaker(bind=engine)
db = Session()
try:
if not db.query(User).filter(User.username == 'admin').first():
db.add(User(username='admin', password_hash=get_password_hash('admin'), role='admin'))
db.commit()
finally:
db.close()
if __name__ == "__main__":
recreate_db_with_admin()

18
backend/database.py Normal file
View File

@@ -0,0 +1,18 @@
# backend/database.py
import os
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, scoped_session
SQLALCHEMY_DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./app.db")
connect_args = {"check_same_thread": False} if SQLALCHEMY_DATABASE_URL.startswith("sqlite") else {}
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args=connect_args)
SessionLocal = scoped_session(sessionmaker(autocommit=False, autoflush=False, bind=engine))
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

27
backend/init_db.py Normal file
View File

@@ -0,0 +1,27 @@
from sqlalchemy.orm import sessionmaker
from backend.models import Base, User
from backend.database import engine
from backend.security import get_password_hash
def init_db():
Base.metadata.create_all(bind=engine)
Session = sessionmaker(bind=engine)
db = Session()
try:
if not db.query(User).filter(User.username == 'admin').first():
db.add(User(
username='admin',
password_hash=get_password_hash('admin'),
role='admin',
))
db.commit()
print("Created default admin user (login: admin / password: admin)")
finally:
db.close()
if __name__ == "__main__":
init_db()

57
backend/main.py Normal file
View File

@@ -0,0 +1,57 @@
# backend/main.py
from fastapi import FastAPI
from fastapi.responses import RedirectResponse
from starlette.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
from backend.routers.equipment_types import equipment_types
from backend.routers.auditories import auditories
from backend.routers.oboruds import oboruds
from backend.routers.components import components
from backend.routers.rashodniki import consumables
from backend.routers.zametki import zametki
from backend.routers.auth import auth
from backend.routers.owners import owners
from backend.routers.inspections import inspections
app = FastAPI()
# Для фронтенда Vue.js
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # заменить на ['http://localhost:5173'] для безопасности
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/ping")
def ping():
return {"message": "pong"}
# Serve frontend
app.mount("/app", StaticFiles(directory="frontend", html=True), name="frontend")
@app.get("/")
def root():
return RedirectResponse(url="/app/")
@app.get("/login")
def login_page():
return RedirectResponse(url="/app/login.html")
# Подключение роутов
app.include_router(equipment_types)
app.include_router(auditories)
app.include_router(oboruds)
app.include_router(components)
app.include_router(consumables)
app.include_router(zametki)
app.include_router(auth)
app.include_router(owners)
app.include_router(inspections)

89
backend/migrate_data.py Normal file
View File

@@ -0,0 +1,89 @@
import sys
from sqlalchemy import create_engine, text, inspect
from sqlalchemy.orm import sessionmaker
from backend.database import SessionLocal as NewSession, engine as new_engine
from backend import models
from pathlib import Path
OLD_DB_URL = "sqlite:///./instance/project.-10-11-25.db"
old_engine = create_engine(OLD_DB_URL, connect_args={"check_same_thread": False})
OldSession = sessionmaker(bind=old_engine)
old_db = OldSession()
new_db = NewSession()
def log(msg: str):
print(f"[INFO] {msg}", file=sys.stderr)
def ensure_schema():
"""Ensure new DB has required tables/columns (owners table and oboruds.owner_id)."""
# Create any missing tables defined in models (e.g., owners)
models.Base.metadata.create_all(bind=new_engine)
# If oboruds.owner_id is missing (older DB), add the column (SQLite allows simple ALTER)
try:
inspector = inspect(new_engine)
cols = [c["name"] for c in inspector.get_columns("oboruds")]
if "owner_id" not in cols:
with new_engine.begin() as conn:
conn.execute(text("ALTER TABLE oboruds ADD COLUMN owner_id INTEGER"))
log("Добавлен столбец oboruds.owner_id")
except Exception as e:
log(f"Предупреждение: проверка/добавление owner_id не выполнена: {e}")
def migrate():
ensure_schema()
log("Запуск переноса данных из old_app.db → app.db")
# Тип оборудования по умолчанию
log("Добавление типа оборудования по умолчанию: 'Неизвестно'")
default_type = models.EquipmentType(name="Неизвестно")
new_db.add(default_type)
new_db.commit()
# Перенос аудиторий
log("Перенос аудиторий...")
auditory_map = {}
aud_rows = old_db.execute(text("SELECT id, audnazvanie FROM auditory")).fetchall()
for aud in aud_rows:
new_aud = models.Auditory(audnazvanie=aud.audnazvanie)
new_db.add(new_aud)
new_db.flush()
auditory_map[aud.id] = new_aud.id
log(f" → перенесено: {len(aud_rows)}")
# Перенос оборудования
log("Перенос оборудования...")
ob_rows = old_db.execute(text("SELECT * FROM oboruds")).fetchall()
for ob in ob_rows:
new_ob = models.Oboruds(
invNumber=ob.invNumber,
nazvanie=ob.nazvanie,
raspologenie=ob.raspologenie,
numberppasu=ob.numberppasu,
kolichestvo=ob.kolichestvo,
aud_id=auditory_map.get(ob.aud_id),
type_id=default_type.id
)
new_db.add(new_ob)
log(f" → перенесено: {len(ob_rows)}")
# Перенос заметок
log("Перенос заметок...")
z_rows = old_db.execute(text("SELECT * FROM zametki")).fetchall()
for z in z_rows:
new_z = models.Zametki(
txtzam=z.txtzam,
created_date=z.created_date,
rmdt=z.rmdt
)
new_db.add(new_z)
log(f" → перенесено: {len(z_rows)}")
new_db.commit()
log("✅ Перенос завершён успешно.")
if __name__ == "__main__":
migrate()

144
backend/models.py Normal file
View File

@@ -0,0 +1,144 @@
# backend/models.py
from sqlalchemy import Column, Integer, String, ForeignKey, DateTime, UniqueConstraint
from sqlalchemy.orm import relationship, declarative_base
import datetime
Base = declarative_base()
class User(Base):
__tablename__ = 'users'
id = Column(Integer, primary_key=True)
username = Column(String(150), unique=True, nullable=False)
password_hash = Column(String(255), nullable=False)
role = Column(String(50), nullable=False, default='viewer') # 'admin' | 'editor' | 'viewer'
class Auditory(Base):
__tablename__ = 'auditories'
id = Column(Integer, primary_key=True)
audnazvanie = Column(String)
oboruds = relationship("Oboruds", back_populates="auditory")
class EquipmentType(Base):
__tablename__ = 'equipment_types'
id = Column(Integer, primary_key=True)
name = Column(String, unique=True, nullable=False)
oboruds = relationship("Oboruds", back_populates="type")
class Owner(Base):
__tablename__ = 'owners'
id = Column(Integer, primary_key=True)
name = Column(String, unique=True, nullable=False)
oboruds = relationship("Oboruds", back_populates="owner")
class Oboruds(Base):
__tablename__ = 'oboruds'
id = Column(Integer, primary_key=True)
invNumber = Column(Integer)
nazvanie = Column(String(500))
raspologenie = Column(String(200))
numberppasu = Column(String(100))
kolichestvo = Column(Integer)
aud_id = Column(Integer, ForeignKey("auditories.id"))
auditory = relationship("Auditory", back_populates="oboruds")
type_id = Column(Integer, ForeignKey("equipment_types.id"))
type = relationship("EquipmentType", back_populates="oboruds")
owner_id = Column(Integer, ForeignKey("owners.id"))
owner = relationship("Owner", back_populates="oboruds")
components = relationship("Component", back_populates="oborud")
consumables = relationship("Consumable", back_populates="oborud")
class Component(Base):
__tablename__ = 'components'
id = Column(Integer, primary_key=True)
name = Column(String, nullable=False)
description = Column(String)
oborud_id = Column(Integer, ForeignKey("oboruds.id"))
oborud = relationship("Oboruds", back_populates="components")
class Consumable(Base):
__tablename__ = 'consumables'
id = Column(Integer, primary_key=True)
name = Column(String, nullable=False)
description = Column(String)
oborud_id = Column(Integer, ForeignKey("oboruds.id"))
oborud = relationship("Oboruds", back_populates="consumables")
class Zametki(Base):
__tablename__ = 'zametki'
id = Column(Integer, primary_key=True)
txtzam = Column(String(10000))
created_date = Column(DateTime, default=datetime.datetime.utcnow)
rmdt = Column(DateTime)
class InspectionSession(Base):
__tablename__ = 'inspection_sessions'
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
started_at = Column(DateTime, nullable=False)
completed_at = Column(DateTime, nullable=True)
aud_id = Column(Integer, ForeignKey("auditories.id"), nullable=True) # null = всё подряд
# Relationships
user = relationship("User")
auditory = relationship("Auditory")
records = relationship("InspectionRecord", back_populates="session")
unknown_barcodes = relationship("UnknownBarcode", back_populates="session")
class InspectionRecord(Base):
__tablename__ = 'inspection_records'
id = Column(Integer, primary_key=True)
session_id = Column(Integer, ForeignKey("inspection_sessions.id"), nullable=False)
oborud_id = Column(Integer, ForeignKey("oboruds.id"), nullable=False)
checked_at = Column(DateTime, nullable=False) # обновляется при повторном сканировании
# Relationships
session = relationship("InspectionSession", back_populates="records")
oborud = relationship("Oboruds")
# Уникальное ограничение: одна запись на оборудование в рамках сессии
__table_args__ = (UniqueConstraint('session_id', 'oborud_id', name='_session_oborud_uc'),)
class UnknownBarcode(Base):
__tablename__ = 'unknown_barcodes'
id = Column(Integer, primary_key=True)
session_id = Column(Integer, ForeignKey("inspection_sessions.id"), nullable=False)
barcode = Column(String(100), nullable=False)
scanned_at = Column(DateTime, nullable=False)
# Relationships
session = relationship("InspectionSession", back_populates="unknown_barcodes")

BIN
backend/requirements.txt Normal file

Binary file not shown.

View File

@@ -0,0 +1,2 @@
"""Routers package initializer."""

View File

@@ -0,0 +1,19 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from .. import models, schemas, database
from ..security import require_roles
auditories = APIRouter(prefix="/auditories", tags=["auditories"])
@auditories.post("/", response_model=schemas.AuditoryRead, dependencies=[Depends(require_roles(["admin", "editor"]))])
def create_auditory(item: schemas.AuditoryCreate, db: Session = Depends(database.get_db)):
obj = models.Auditory(**item.dict())
db.add(obj)
db.commit()
db.refresh(obj)
return obj
@auditories.get("/", response_model=list[schemas.AuditoryRead])
def list_auditories(db: Session = Depends(database.get_db)):
return db.query(models.Auditory).all()

63
backend/routers/auth.py Normal file
View File

@@ -0,0 +1,63 @@
from datetime import timedelta
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from .. import schemas, database
from ..security import authenticate_user, create_access_token, get_password_hash, require_roles
from ..models import User
auth = APIRouter(prefix="/auth", tags=["auth"])
@auth.post("/token", response_model=schemas.Token)
def login_for_access_token(
form_data: OAuth2PasswordRequestForm = Depends(),
db: Session = Depends(database.get_db),
):
user = authenticate_user(db, form_data.username, form_data.password)
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect username or password")
access_token_expires = timedelta(minutes=60)
access_token = create_access_token(data={"sub": user.username, "role": user.role}, expires_delta=access_token_expires)
return {"access_token": access_token, "token_type": "bearer"}
@auth.post("/users", response_model=schemas.UserRead, dependencies=[Depends(require_roles(["admin"]))])
def create_user(item: schemas.UserCreate, db: Session = Depends(database.get_db)):
if db.query(User).filter(User.username == item.username).first():
raise HTTPException(status_code=400, detail="Username already exists")
obj = User(username=item.username, password_hash=get_password_hash(item.password), role=item.role)
db.add(obj)
db.commit()
db.refresh(obj)
return obj
@auth.post("/users/admin", response_model=schemas.UserRead, dependencies=[Depends(require_roles(["admin"]))])
def create_admin_user(item: schemas.UserCreate, db: Session = Depends(database.get_db)):
if db.query(User).filter(User.username == item.username).first():
raise HTTPException(status_code=400, detail="Username already exists")
obj = User(username=item.username, password_hash=get_password_hash(item.password), role="admin")
db.add(obj)
db.commit()
db.refresh(obj)
return obj
@auth.get("/users", response_model=list[schemas.UserRead], dependencies=[Depends(require_roles(["admin"]))])
def list_users(db: Session = Depends(database.get_db)):
return db.query(User).all()
@auth.patch("/users/{user_id}/role", response_model=schemas.UserRead, dependencies=[Depends(require_roles(["admin"]))])
def update_user_role(user_id: int, payload: schemas.UserRoleUpdate, db: Session = Depends(database.get_db)):
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
user.role = payload.role
db.commit()
db.refresh(user)
return user

View File

@@ -0,0 +1,19 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from .. import models, schemas, database
from ..security import require_roles
components = APIRouter(prefix="/components", tags=["components"])
@components.post("/", response_model=schemas.ComponentRead, dependencies=[Depends(require_roles(["admin", "editor"]))])
def create_component(item: schemas.ComponentCreate, db: Session = Depends(database.get_db)):
obj = models.Component(**item.dict())
db.add(obj)
db.commit()
db.refresh(obj)
return obj
@components.get("/", response_model=list[schemas.ComponentRead])
def list_components(db: Session = Depends(database.get_db)):
return db.query(models.Component).all()

View File

@@ -0,0 +1,18 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from .. import models, schemas, database
from ..security import require_roles
equipment_types = APIRouter(prefix="/equipment-types", tags=["equipment_types"])
@equipment_types.post("/", response_model=schemas.EquipmentTypeRead, dependencies=[Depends(require_roles(["admin", "editor"]))])
def create_equipment_type(item: schemas.EquipmentTypeCreate, db: Session = Depends(database.get_db)):
obj = models.EquipmentType(**item.dict())
db.add(obj)
db.commit()
db.refresh(obj)
return obj
@equipment_types.get("/", response_model=list[schemas.EquipmentTypeRead])
def list_equipment_types(db: Session = Depends(database.get_db)):
return db.query(models.EquipmentType).all()

View File

@@ -0,0 +1,214 @@
from datetime import datetime, timezone
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from sqlalchemy import func
from .. import schemas, database, models
from ..security import get_current_user
inspections = APIRouter(prefix="/inspections", tags=["inspections"])
@inspections.post("/sessions", response_model=schemas.InspectionSessionRead)
async def create_inspection_session(
payload: schemas.InspectionSessionCreate,
db: Session = Depends(database.get_db),
current_user: models.User = Depends(get_current_user)
):
"""Начать новую сессию проверки"""
session = models.InspectionSession(
user_id=current_user.id,
started_at=datetime.now(timezone.utc),
aud_id=payload.aud_id
)
db.add(session)
db.commit()
db.refresh(session)
return session
class CheckBarcodeRequest(schemas.BaseModel):
inv_number: str
@inspections.post("/sessions/{session_id}/check", response_model=schemas.CheckBarcodeResponse)
async def check_barcode(
session_id: int,
payload: CheckBarcodeRequest,
db: Session = Depends(database.get_db),
current_user: models.User = Depends(get_current_user)
):
"""Отсканировать штрихкод"""
inv_number = payload.inv_number
# Проверка существования сессии
session = db.query(models.InspectionSession).filter(models.InspectionSession.id == session_id).first()
if not session:
raise HTTPException(status_code=404, detail="Session not found")
# Проверка, что сессия не завершена
if session.completed_at is not None:
raise HTTPException(status_code=400, detail="Session already completed")
# Поиск оборудования по invNumber (точное совпадение)
# invNumber может быть строкой или числом, преобразуем для поиска
try:
inv_num_int = int(inv_number)
oborud = db.query(models.Oboruds).filter(models.Oboruds.invNumber == inv_num_int).first()
except ValueError:
oborud = None
if oborud:
# Оборудование найдено - создаём/обновляем запись (UPSERT)
record = db.query(models.InspectionRecord).filter_by(
session_id=session_id,
oborud_id=oborud.id
).first()
if record:
# Обновить время проверки
record.checked_at = datetime.now(timezone.utc)
else:
# Создать новую запись
record = models.InspectionRecord(
session_id=session_id,
oborud_id=oborud.id,
checked_at=datetime.now(timezone.utc)
)
db.add(record)
db.commit()
db.refresh(oborud)
return schemas.CheckBarcodeResponse(
status="found",
equipment=schemas.OborudRead.model_validate(oborud),
message=f"Оборудование найдено: {oborud.nazvanie}"
)
else:
# Оборудование не найдено - сохранить в неизвестные штрихкоды
unknown = models.UnknownBarcode(
session_id=session_id,
barcode=inv_number,
scanned_at=datetime.now(timezone.utc)
)
db.add(unknown)
db.commit()
return schemas.CheckBarcodeResponse(
status="not_found",
equipment=None,
message=f"Оборудование с номером {inv_number} не найдено"
)
@inspections.get("/sessions/{session_id}", response_model=schemas.InspectionSessionStats)
async def get_inspection_session_stats(
session_id: int,
db: Session = Depends(database.get_db),
current_user: models.User = Depends(get_current_user)
):
"""Получить статистику сессии проверки"""
session = db.query(models.InspectionSession).filter(models.InspectionSession.id == session_id).first()
if not session:
raise HTTPException(status_code=404, detail="Session not found")
# Подсчёт статистики
total_checked = db.query(models.InspectionRecord).filter(
models.InspectionRecord.session_id == session_id
).count()
total_unknown = db.query(models.UnknownBarcode).filter(
models.UnknownBarcode.session_id == session_id
).count()
# Подсчёт ожидаемого количества оборудования
if session.aud_id:
# Проверка по аудитории
total_expected = db.query(models.Oboruds).filter(
models.Oboruds.aud_id == session.aud_id
).count()
else:
# Проверка всего оборудования
total_expected = db.query(models.Oboruds).count()
# Расчёт прогресса
progress_percent = round((total_checked / total_expected * 100), 2) if total_expected > 0 else 0.0
return schemas.InspectionSessionStats(
session=schemas.InspectionSessionRead.model_validate(session),
total_expected=total_expected,
total_checked=total_checked,
total_unknown=total_unknown,
progress_percent=progress_percent
)
@inspections.post("/sessions/{session_id}/complete", response_model=schemas.InspectionSessionRead)
async def complete_inspection_session(
session_id: int,
db: Session = Depends(database.get_db),
current_user: models.User = Depends(get_current_user)
):
"""Завершить сессию проверки"""
session = db.query(models.InspectionSession).filter(models.InspectionSession.id == session_id).first()
if not session:
raise HTTPException(status_code=404, detail="Session not found")
if session.completed_at is not None:
raise HTTPException(status_code=400, detail="Session already completed")
session.completed_at = datetime.now(timezone.utc)
db.commit()
db.refresh(session)
return session
@inspections.get("/sessions", response_model=list[schemas.InspectionSessionRead])
async def list_inspection_sessions(
user_id: Optional[int] = None,
aud_id: Optional[int] = None,
db: Session = Depends(database.get_db),
current_user: models.User = Depends(get_current_user)
):
"""Получить список всех сессий проверок (история)"""
query = db.query(models.InspectionSession)
if user_id is not None:
query = query.filter(models.InspectionSession.user_id == user_id)
if aud_id is not None:
query = query.filter(models.InspectionSession.aud_id == aud_id)
# Сортировка по дате (новые сверху)
sessions = query.order_by(models.InspectionSession.started_at.desc()).all()
return sessions
@inspections.get("/sessions/{session_id}/records", response_model=schemas.InspectionDetailReport)
async def get_inspection_session_records(
session_id: int,
db: Session = Depends(database.get_db),
current_user: models.User = Depends(get_current_user)
):
"""Получить детальный отчёт по сессии проверки"""
session = db.query(models.InspectionSession).filter(models.InspectionSession.id == session_id).first()
if not session:
raise HTTPException(status_code=404, detail="Session not found")
# Получить все записи проверок с информацией об оборудовании
records = db.query(models.InspectionRecord).filter(
models.InspectionRecord.session_id == session_id
).order_by(models.InspectionRecord.checked_at.desc()).all()
# Получить неизвестные штрихкоды
unknown_barcodes = db.query(models.UnknownBarcode).filter(
models.UnknownBarcode.session_id == session_id
).order_by(models.UnknownBarcode.scanned_at.desc()).all()
return schemas.InspectionDetailReport(
records=[schemas.InspectionRecordRead.model_validate(r) for r in records],
unknown_barcodes=[schemas.UnknownBarcodeRead.model_validate(ub) for ub in unknown_barcodes]
)

View File

@@ -0,0 +1,54 @@
from fastapi import APIRouter, Depends, HTTPException
from typing import Optional
from sqlalchemy.orm import Session
from .. import models, schemas, database
from ..security import require_roles
oboruds = APIRouter(prefix="/oboruds", tags=["oboruds"])
@oboruds.post("/", response_model=schemas.OborudRead, dependencies=[Depends(require_roles(["admin", "editor"]))])
def create_oborud(item: schemas.OborudCreate, db: Session = Depends(database.get_db)):
obj = models.Oboruds(**item.dict())
db.add(obj)
db.commit()
db.refresh(obj)
return obj
@oboruds.get("/", response_model=list[schemas.OborudRead])
def list_oboruds(aud_id: Optional[int] = None, sort_by_inv: bool = False, unassigned: bool = False, inv_number: Optional[int] = None, db: Session = Depends(database.get_db)):
query = db.query(models.Oboruds)
if unassigned:
query = query.filter(models.Oboruds.aud_id == None)
elif aud_id is not None:
query = query.filter(models.Oboruds.aud_id == aud_id)
if inv_number is not None:
query = query.filter(models.Oboruds.invNumber == inv_number)
if sort_by_inv:
query = query.order_by(models.Oboruds.invNumber.asc())
return query.all()
@oboruds.get("/stats")
def oboruds_stats(db: Session = Depends(database.get_db)):
total = db.query(models.Oboruds).count()
unassigned = db.query(models.Oboruds).filter(models.Oboruds.aud_id == None).count()
return {"total": total, "unassigned": unassigned}
@oboruds.get("/{oborud_id}", response_model=schemas.OborudRead)
def get_oborud(oborud_id: int, db: Session = Depends(database.get_db)):
obj = db.query(models.Oboruds).filter(models.Oboruds.id == oborud_id).first()
if not obj:
raise HTTPException(status_code=404, detail="Oborud not found")
return obj
@oboruds.patch("/{oborud_id}", response_model=schemas.OborudRead, dependencies=[Depends(require_roles(["admin", "editor"]))])
def update_oborud(oborud_id: int, payload: schemas.OborudUpdate, db: Session = Depends(database.get_db)):
obj = db.query(models.Oboruds).filter(models.Oboruds.id == oborud_id).first()
if not obj:
raise HTTPException(status_code=404, detail="Oborud not found")
data = payload.dict(exclude_unset=True)
for k, v in data.items():
setattr(obj, k, v)
db.commit()
db.refresh(obj)
return obj

22
backend/routers/owners.py Normal file
View File

@@ -0,0 +1,22 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from .. import models, schemas, database
from ..security import require_roles
owners = APIRouter(prefix="/owners", tags=["owners"])
@owners.post("/", response_model=schemas.OwnerRead, dependencies=[Depends(require_roles(["admin", "editor"]))])
def create_owner(item: schemas.OwnerCreate, db: Session = Depends(database.get_db)):
obj = models.Owner(**item.dict())
db.add(obj)
db.commit()
db.refresh(obj)
return obj
@owners.get("/", response_model=list[schemas.OwnerRead])
def list_owners(db: Session = Depends(database.get_db)):
return db.query(models.Owner).all()

View File

@@ -0,0 +1,18 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from .. import models, schemas, database
from ..security import require_roles
consumables = APIRouter(prefix="/consumables", tags=["consumables"])
@consumables.post("/", response_model=schemas.ConsumableRead, dependencies=[Depends(require_roles(["admin", "editor"]))])
def create_consumable(item: schemas.ConsumableCreate, db: Session = Depends(database.get_db)):
obj = models.Consumable(**item.dict())
db.add(obj)
db.commit()
db.refresh(obj)
return obj
@consumables.get("/", response_model=list[schemas.ConsumableRead])
def list_consumables(db: Session = Depends(database.get_db)):
return db.query(models.Consumable).all()

View File

@@ -0,0 +1,32 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from datetime import datetime, timezone
from .. import models, schemas, database
from ..security import require_roles
zametki = APIRouter(prefix="/zametki", tags=["zametki"])
@zametki.post("/", response_model=schemas.ZametkaRead, dependencies=[Depends(require_roles(["admin", "editor"]))])
def create_zametka(item: schemas.ZametkaCreate, db: Session = Depends(database.get_db)):
obj = models.Zametki(txtzam=item.txtzam, created_date=datetime.now(timezone.utc))
db.add(obj)
db.commit()
db.refresh(obj)
return obj
@zametki.get("/", response_model=list[schemas.ZametkaRead])
def list_zametki(active_only: bool = True, db: Session = Depends(database.get_db)):
query = db.query(models.Zametki)
if active_only:
query = query.filter(models.Zametki.rmdt == None)
return query.order_by(models.Zametki.created_date.desc()).all()
@zametki.patch("/{zametka_id}/resolve", response_model=schemas.ZametkaRead, dependencies=[Depends(require_roles(["admin", "editor"]))])
def resolve_zametka(zametka_id: int, db: Session = Depends(database.get_db)):
obj = db.query(models.Zametki).filter(models.Zametki.id == zametka_id).first()
if not obj:
raise HTTPException(status_code=404, detail="Zametka not found")
obj.rmdt = datetime.now(timezone.utc)
db.commit()
db.refresh(obj)
return obj

217
backend/schemas.py Normal file
View File

@@ -0,0 +1,217 @@
# backend/schemas.py
from pydantic import BaseModel
from typing import Optional, List, Literal
from datetime import datetime
# === Equipment Type ===
class EquipmentTypeBase(BaseModel):
name: str
class EquipmentTypeCreate(EquipmentTypeBase):
pass
class EquipmentTypeRead(EquipmentTypeBase):
id: int
class Config:
from_attributes = True
# === Component ===
class ComponentBase(BaseModel):
name: str
description: Optional[str] = None
class ComponentCreate(ComponentBase):
oborud_id: int
class ComponentRead(ComponentBase):
id: int
class Config:
from_attributes = True
# === Consumable ===
class ConsumableBase(BaseModel):
name: str
description: Optional[str] = None
class ConsumableCreate(ConsumableBase):
oborud_id: int
class ConsumableRead(ConsumableBase):
id: int
class Config:
from_attributes = True
# === Owner ===
class OwnerBase(BaseModel):
name: str
class OwnerCreate(OwnerBase):
pass
class OwnerRead(OwnerBase):
id: int
class Config:
from_attributes = True
# === Oborud ===
class OborudBase(BaseModel):
invNumber: Optional[int] = None
nazvanie: str
raspologenie: Optional[str] = None
numberppasu: Optional[str] = None
kolichestvo: Optional[int] = None
aud_id: Optional[int] = None
type_id: Optional[int] = None
owner_id: Optional[int] = None
class OborudCreate(OborudBase):
pass
class OborudUpdate(BaseModel):
invNumber: Optional[int] = None
nazvanie: Optional[str] = None
raspologenie: Optional[str] = None
numberppasu: Optional[str] = None
kolichestvo: Optional[int] = None
aud_id: Optional[int] = None
type_id: Optional[int] = None
owner_id: Optional[int] = None
class OborudRead(OborudBase):
id: int
type: Optional[EquipmentTypeRead] = None
owner: Optional[OwnerRead] = None
components: List[ComponentRead] = []
consumables: List[ConsumableRead] = []
class Config:
from_attributes = True
# === Auditory ===
class AuditoryBase(BaseModel):
audnazvanie: str
class AuditoryCreate(AuditoryBase):
pass
class AuditoryRead(AuditoryBase):
id: int
class Config:
from_attributes = True
# === Zametka ===
class ZametkaBase(BaseModel):
txtzam: str
rmdt: Optional[datetime] = None
class ZametkaCreate(ZametkaBase):
pass
class ZametkaRead(ZametkaBase):
id: int
created_date: Optional[datetime] = None
class Config:
from_attributes = True
# === Auth/User ===
class Token(BaseModel):
access_token: str
token_type: str = "bearer"
class TokenData(BaseModel):
username: Optional[str] = None
role: Optional[str] = None
Role = Literal["admin", "editor", "viewer"]
class UserBase(BaseModel):
username: str
role: Role = "viewer"
class UserCreate(UserBase):
password: str
class UserRead(UserBase):
id: int
class Config:
from_attributes = True
class UserRoleUpdate(BaseModel):
role: Role
# === Inspection System ===
# InspectionSession
class InspectionSessionBase(BaseModel):
aud_id: Optional[int] = None
class InspectionSessionCreate(InspectionSessionBase):
pass
class InspectionSessionRead(InspectionSessionBase):
id: int
user_id: int
started_at: datetime
completed_at: Optional[datetime] = None
class Config:
from_attributes = True
# InspectionRecord
class InspectionRecordRead(BaseModel):
id: int
session_id: int
oborud_id: int
checked_at: datetime
oborud: Optional[OborudRead] = None
class Config:
from_attributes = True
# UnknownBarcode
class UnknownBarcodeRead(BaseModel):
id: int
session_id: int
barcode: str
scanned_at: datetime
class Config:
from_attributes = True
# Ответ на сканирование
class CheckBarcodeResponse(BaseModel):
status: Literal["found", "not_found"]
equipment: Optional[OborudRead] = None
message: str
# Статистика сессии
class InspectionSessionStats(BaseModel):
session: InspectionSessionRead
total_expected: int # всего оборудования (в аудитории или всего)
total_checked: int # проверено уникальных позиций
total_unknown: int # неизвестных штрихкодов
progress_percent: float
# Детальный отчёт
class InspectionDetailReport(BaseModel):
records: List[InspectionRecordRead]
unknown_barcodes: List[UnknownBarcodeRead]

79
backend/security.py Normal file
View File

@@ -0,0 +1,79 @@
import os
from datetime import datetime, timedelta, timezone
from typing import Optional, Callable, Iterable
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from passlib.context import CryptContext
from sqlalchemy.orm import Session
from . import models
from .database import get_db
SECRET_KEY = os.getenv("SECRET_KEY", "dev-secret-change-me")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "60"))
# Use pbkdf2_sha256 to avoid external bcrypt backend issues
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token")
def verify_password(plain_password: str, password_hash: str) -> bool:
return pwd_context.verify(plain_password, password_hash)
def get_password_hash(password: str) -> str:
return pwd_context.hash(password)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
to_encode = data.copy()
expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES))
to_encode.update({"exp": expire})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
def get_user_by_username(db: Session, username: str) -> Optional[models.User]:
return db.query(models.User).filter(models.User.username == username).first()
def authenticate_user(db: Session, username: str, password: str) -> Optional[models.User]:
user = get_user_by_username(db, username)
if not user:
return None
if not verify_password(password, user.password_hash):
return None
return user
async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)) -> models.User:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub")
if username is None:
raise credentials_exception
except JWTError:
raise credentials_exception
user = get_user_by_username(db, username=username)
if user is None:
raise credentials_exception
return user
def require_roles(allowed_roles: Iterable[str]) -> Callable[[models.User], models.User]:
allowed = set(allowed_roles)
def _dependency(user: models.User = Depends(get_current_user)) -> models.User:
if user.role not in allowed:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions")
return user
return _dependency

10
docker-compose.yml Normal file
View File

@@ -0,0 +1,10 @@
services:
app:
build: .
ports:
- "8000:8000"
volumes:
- ./appdata:/app
environment:
- DATABASE_URL=sqlite:////app/data/app.db
restart: unless-stopped

11
docker-entrypoint.sh Normal file
View File

@@ -0,0 +1,11 @@
#!/bin/sh
set -e
# Sync app code from image on every start (keeps code fresh after rebuilds)
rm -rf /app/backend /app/frontend
cp -r /app_src/backend /app_src/frontend /app/
mkdir -p /app/data
python -m backend.init_db
exec uvicorn backend.main:app --host 0.0.0.0 --port 8000

668
frontend/app.js Normal file
View File

@@ -0,0 +1,668 @@
const { createApp } = Vue;
const api = {
auds: "/auditories/",
oboruds: (audId) => `/oboruds/?aud_id=${encodeURIComponent(audId)}`,
allOboruds: "/oboruds/?sort_by_inv=true",
owners: "/owners/",
zametki: "/zametki/",
};
async function fetchJSON(url) {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
createApp({
data() {
return {
view: 'home',
auditories: [],
selectedAudId: '',
oboruds: [],
allOboruds: [],
unassignedOboruds: [],
totalOboruds: 0,
status: '',
error: '',
printTitle: '',
// home view
homeSearch: '',
homeUnassigned: [],
homeSearchResults: [],
homeSearchDone: false,
// auth/user management
token: '',
role: '',
users: [],
newAdminUsername: '',
newAdminPassword: '',
newAudName: '',
owners: [],
newOwnerName: '',
zametki: [],
newZametkaText: '',
equipmentTypes: [],
newTypeName: '',
newEquipment: {
invNumber: null,
nazvanie: '',
raspologenie: '',
kolichestvo: 1,
aud_id: '',
type_id: '',
owner_id: ''
},
// Inspection
activeInspection: null,
inspectionAudId: '',
scannedBarcode: '',
lastScanResult: null,
inspectionStats: {
total_checked: 0,
total_expected: 0,
total_unknown: 0,
progress_percent: 0
},
checkedEquipment: [],
unknownBarcodes: [],
inspectionHistory: []
};
},
computed: {
isAuth() { return !!this.token; },
isAdmin() { return this.role === 'admin'; },
isEditor() { return this.role === 'editor'; },
canEdit() { return this.isAdmin || this.isEditor; },
},
methods: {
async showHome() {
this.view = 'home';
this.status = '';
this.error = '';
this.homeSearch = '';
this.homeSearchResults = [];
this.homeSearchDone = false;
await this.loadHomeUnassigned();
},
async loadHomeUnassigned() {
this.status = 'Загрузка нераспределённого оборудования…';
this.error = '';
try {
const data = await fetchJSON('/oboruds/?unassigned=true&sort_by_inv=true');
this.homeUnassigned = data.map(it => ({ ...it, selectedAudId: '' }));
this.status = '';
} catch (e) {
console.error(e);
this.error = 'Не удалось загрузить данные';
this.status = '';
}
},
async doHomeSearch() {
const q = this.homeSearch.trim();
if (!q) {
this.status = 'Введите инвентарный номер';
return;
}
this.status = 'Поиск…';
this.error = '';
this.homeSearchDone = false;
try {
const data = await fetchJSON(`/oboruds/?inv_number=${encodeURIComponent(q)}`);
this.homeSearchResults = data.filter(it => it.aud_id).map(it => ({ ...it, selectedAudId: '' }));
this.homeSearchDone = true;
this.status = '';
} catch (e) {
console.error(e);
this.error = 'Не удалось выполнить поиск';
this.status = '';
}
},
async assignToAuditory(item) {
if (!item.selectedAudId) {
this.status = 'Выберите аудиторию';
return;
}
try {
this.status = 'Сохранение…';
this.error = '';
await this.fetchAuth(`/oboruds/${item.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ aud_id: item.selectedAudId }),
});
await this.loadHomeUnassigned();
this.status = 'Сохранено';
} catch (e) {
console.error(e);
this.error = 'Не удалось назначить аудиторию';
this.status = '';
}
},
logout() {
try {
localStorage.removeItem('access_token');
localStorage.removeItem('role');
} catch {}
this.token = '';
this.role = '';
this.users = [];
this.status = '';
this.error = '';
this.view = 'byAud';
// опционально: редирект на страницу логина
// window.location.href = '/login';
},
authHeaders() {
const h = {};
if (this.token) h['Authorization'] = `Bearer ${this.token}`;
return h;
},
async fetchAuth(url, options = {}) {
const opt = { ...options, headers: { ...(options.headers||{}), ...this.authHeaders() } };
const res = await fetch(url, opt);
if (!res.ok) {
const text = await res.text();
throw new Error(`HTTP ${res.status}: ${text}`);
}
return res.json();
},
async loadAuditories() {
this.status = 'Загрузка аудиторий…';
this.error = '';
try {
this.auditories = await fetchJSON(api.auds);
this.status = '';
} catch (e) {
console.error(e);
this.error = 'Не удалось загрузить аудитории';
this.status = '';
}
},
async loadOboruds() {
if (!this.selectedAudId) {
this.error = '';
this.status = 'Выберите аудиторию';
return;
}
this.status = 'Загрузка оборудования…';
this.error = '';
try {
this.oboruds = await fetchJSON(api.oboruds(this.selectedAudId));
// init selected owner helper field
this.oboruds.forEach(o => { o.selectedOwnerId = o.owner?.id || ''; });
this.status = '';
} catch (e) {
console.error(e);
this.error = 'Не удалось загрузить оборудование';
this.status = '';
}
},
async loadAllOboruds() {
this.status = 'Загрузка всего оборудования…';
this.error = '';
try {
this.allOboruds = await fetchJSON(api.allOboruds);
this.status = `Загружено ${this.allOboruds.length} записей`;
} catch (e) {
console.error(e);
this.error = 'Не удалось загрузить оборудование';
this.status = '';
}
},
async showAllEquipment() {
this.view = 'allEquipment';
await this.loadAllOboruds();
},
async loadUnassigned() {
this.status = 'Загрузка нераспределённого оборудования…';
this.error = '';
try {
const [oboruds, stats] = await Promise.all([
fetchJSON('/oboruds/?unassigned=true&sort_by_inv=true'),
fetchJSON('/oboruds/stats')
]);
this.unassignedOboruds = oboruds;
this.totalOboruds = stats.total;
this.status = '';
} catch (e) {
console.error(e);
this.error = 'Не удалось загрузить данные';
this.status = '';
}
},
async showUnassigned() {
this.view = 'unassigned';
await this.loadUnassigned();
},
getAuditoryName(audId) {
if (!audId) return '';
const aud = this.auditories.find(a => a.id === audId);
return aud ? aud.audnazvanie : '';
},
printPage() {
const aud = this.auditories.find(a => a.id === this.selectedAudId);
this.printTitle = 'Аудитория № ' + (aud ? aud.audnazvanie : '');
this.$nextTick(() => {
window.print();
});
},
printAllEquipment() {
window.print();
},
async loadZametki() {
this.status = 'Загрузка заметок…';
this.error = '';
try {
this.zametki = await fetchJSON(api.zametki);
this.status = '';
} catch (e) {
console.error(e);
this.error = 'Не удалось загрузить заметки';
this.status = '';
}
},
async showZametki() {
this.view = 'zametki';
await this.loadZametki();
},
async createZametka() {
if (!this.newZametkaText.trim()) {
this.status = 'Введите текст заметки';
return;
}
try {
this.status = 'Добавление заметки…';
await this.fetchAuth(api.zametki, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ txtzam: this.newZametkaText }),
});
this.newZametkaText = '';
await this.loadZametki();
this.status = 'Заметка добавлена';
} catch (e) {
console.error(e);
this.error = 'Не удалось добавить заметку';
this.status = '';
}
},
async resolveZametka(id) {
try {
this.status = 'Отметка заметки как решённой…';
await this.fetchAuth(`/zametki/${id}/resolve`, { method: 'PATCH' });
await this.loadZametki();
this.status = 'Заметка отмечена как решённая';
} catch (e) {
console.error(e);
this.error = 'Не удалось отметить заметку';
this.status = '';
}
},
formatDate(dateStr) {
if (!dateStr) return '';
const d = new Date(dateStr);
return d.toLocaleString('ru-RU');
},
async loadEquipmentTypes() {
try {
this.equipmentTypes = await fetchJSON('/equipment-types/');
} catch (e) {
console.error(e);
}
},
async createEquipmentType() {
if (!this.newTypeName.trim()) {
this.status = 'Введите название типа';
return;
}
try {
this.status = 'Добавление типа…';
await this.fetchAuth('/equipment-types/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: this.newTypeName }),
});
this.newTypeName = '';
await this.loadEquipmentTypes();
this.status = 'Тип добавлен';
} catch (e) {
console.error(e);
this.error = 'Не удалось добавить тип';
this.status = '';
}
},
async createEquipment() {
if (!this.newEquipment.nazvanie.trim()) {
this.status = 'Введите название оборудования';
return;
}
try {
this.status = 'Добавление оборудования…';
const data = {
nazvanie: this.newEquipment.nazvanie,
invNumber: this.newEquipment.invNumber || null,
raspologenie: this.newEquipment.raspologenie || null,
kolichestvo: this.newEquipment.kolichestvo || null,
aud_id: this.newEquipment.aud_id || null,
type_id: this.newEquipment.type_id || null,
owner_id: this.newEquipment.owner_id || null,
};
await this.fetchAuth('/oboruds/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
this.newEquipment = { invNumber: null, nazvanie: '', raspologenie: '', kolichestvo: 1, aud_id: '', type_id: '', owner_id: '' };
this.status = 'Оборудование добавлено';
} catch (e) {
console.error(e);
this.error = 'Не удалось добавить оборудование';
this.status = '';
}
},
async saveOwner(item) {
try {
this.status = 'Сохранение владельца…';
await this.fetchAuth(`/oboruds/${item.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ owner_id: item.selectedOwnerId || null }),
});
this.status = 'Сохранено';
} catch (e) {
console.error(e);
this.error = 'Не удалось сохранить владельца';
this.status = '';
}
},
async loadOwners() {
try {
this.status = this.status || 'Загрузка владельцев…';
const data = await fetchJSON(api.owners);
this.owners = data;
this.status = '';
} catch (e) {
console.error(e);
this.error = 'Не удалось загрузить владельцев';
this.status = '';
}
},
async createOwner() {
if (!this.newOwnerName) {
this.status = 'Укажите имя владельца';
return;
}
try {
this.status = 'Добавление владельца…';
await this.fetchAuth(api.owners, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: this.newOwnerName }),
});
this.newOwnerName = '';
await this.loadOwners();
// refresh table owner names if visible
if (this.selectedAudId) await this.loadOboruds();
this.status = 'Владелец добавлен';
} catch (e) {
console.error(e);
this.error = 'Не удалось добавить владельца';
this.status = '';
}
},
async loadUsers() {
try {
this.status = 'Загрузка пользователей…';
this.error = '';
this.users = await this.fetchAuth('/auth/users');
this.status = '';
} catch (e) {
console.error(e);
this.error = 'Не удалось загрузить пользователей';
this.status = '';
}
},
async createAdmin() {
if (!this.newAdminUsername || !this.newAdminPassword) {
this.status = 'Укажите логин и пароль';
return;
}
try {
this.status = 'Создание администратора…';
this.error = '';
await this.fetchAuth('/auth/users/admin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: this.newAdminUsername, password: this.newAdminPassword }),
});
this.newAdminUsername = '';
this.newAdminPassword = '';
await this.loadUsers();
this.status = 'Администратор создан';
} catch (e) {
console.error(e);
this.error = 'Не удалось создать администратора';
this.status = '';
}
},
async createAuditory() {
if (!this.newAudName) {
this.status = 'Укажите название аудитории';
return;
}
try {
this.status = 'Добавление аудитории…';
this.error = '';
await this.fetchAuth('/auditories/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ audnazvanie: this.newAudName }),
});
this.newAudName = '';
await this.loadAuditories();
this.status = 'Аудитория добавлена';
} catch (e) {
console.error(e);
this.error = 'Не удалось добавить аудиторию';
this.status = '';
}
},
// Inspection methods
async showInspection() {
this.view = 'inspection';
this.status = '';
this.error = '';
},
async startInspection() {
try {
this.status = 'Начало проверки…';
this.error = '';
const response = await this.fetchAuth('/inspections/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
aud_id: this.inspectionAudId || null
})
});
this.activeInspection = response;
await this.loadInspectionStats();
this.status = 'Проверка начата';
// Фокус на поле ввода
this.$nextTick(() => {
if (this.$refs.barcodeInput) {
this.$refs.barcodeInput.focus();
}
});
} catch (e) {
console.error(e);
this.error = 'Не удалось начать проверку: ' + e.message;
this.status = '';
}
},
async checkBarcode() {
if (!this.scannedBarcode.trim()) return;
try {
this.status = 'Проверка штрихкода…';
const response = await this.fetchAuth(`/inspections/sessions/${this.activeInspection.id}/check`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
inv_number: this.scannedBarcode
})
});
this.lastScanResult = response;
// Очистить поле и обновить данные
this.scannedBarcode = '';
await this.loadInspectionStats();
await this.loadInspectionDetails();
this.status = '';
// Автоматически скрыть уведомление через 3 секунды
setTimeout(() => {
this.lastScanResult = null;
}, 3000);
// Вернуть фокус на поле ввода
this.$nextTick(() => {
if (this.$refs.barcodeInput) {
this.$refs.barcodeInput.focus();
}
});
} catch (e) {
console.error(e);
this.error = 'Не удалось проверить штрихкод: ' + e.message;
this.status = '';
}
},
async loadInspectionStats() {
try {
const response = await this.fetchAuth(`/inspections/sessions/${this.activeInspection.id}`);
this.inspectionStats = response;
} catch (e) {
console.error(e);
this.error = 'Не удалось загрузить статистику';
}
},
async loadInspectionDetails() {
try {
const response = await this.fetchAuth(`/inspections/sessions/${this.activeInspection.id}/records`);
this.checkedEquipment = response.records;
this.unknownBarcodes = response.unknown_barcodes;
} catch (e) {
console.error(e);
this.error = 'Не удалось загрузить детали проверки';
}
},
async refreshInspectionData() {
this.status = 'Обновление данных…';
await this.loadInspectionStats();
await this.loadInspectionDetails();
this.status = 'Данные обновлены';
},
async completeInspection() {
if (!confirm('Завершить проверку?')) return;
try {
this.status = 'Завершение проверки…';
await this.fetchAuth(`/inspections/sessions/${this.activeInspection.id}/complete`, {
method: 'POST'
});
this.activeInspection = null;
this.inspectionStats = {
total_checked: 0,
total_expected: 0,
total_unknown: 0,
progress_percent: 0
};
this.checkedEquipment = [];
this.unknownBarcodes = [];
this.lastScanResult = null;
this.status = 'Проверка завершена';
} catch (e) {
console.error(e);
this.error = 'Не удалось завершить проверку: ' + e.message;
this.status = '';
}
},
cancelInspection() {
if (confirm('Прервать проверку без сохранения?')) {
this.activeInspection = null;
this.inspectionStats = {
total_checked: 0,
total_expected: 0,
total_unknown: 0,
progress_percent: 0
};
this.checkedEquipment = [];
this.unknownBarcodes = [];
this.lastScanResult = null;
this.status = 'Проверка отменена';
}
},
async loadInspectionHistory() {
try {
this.status = 'Загрузка истории проверок…';
this.error = '';
this.inspectionHistory = await this.fetchAuth('/inspections/sessions');
this.status = `Загружено ${this.inspectionHistory.length} проверок`;
} catch (e) {
console.error(e);
this.error = 'Не удалось загрузить историю проверок: ' + e.message;
this.status = '';
}
},
async viewHistoryDetails(sessionId) {
try {
this.status = 'Загрузка деталей проверки…';
const [stats, details] = await Promise.all([
this.fetchAuth(`/inspections/sessions/${sessionId}`),
this.fetchAuth(`/inspections/sessions/${sessionId}/records`)
]);
// Показать активную проверку с данными истории
this.activeInspection = stats.session;
this.inspectionStats = stats;
this.checkedEquipment = details.records;
this.unknownBarcodes = details.unknown_barcodes;
this.status = '';
} catch (e) {
console.error(e);
this.error = 'Не удалось загрузить детали: ' + e.message;
this.status = '';
}
}
},
mounted() {
// read auth from localStorage
try {
this.token = localStorage.getItem('access_token') || '';
this.role = localStorage.getItem('role') || '';
} catch {}
this.loadAuditories();
this.loadOwners();
this.loadEquipmentTypes();
this.loadHomeUnassigned();
if (this.isAdmin) {
this.loadUsers();
}
}
}).mount('#app');

7
frontend/bootstrap.bundle.min.js vendored Normal file

File diff suppressed because one or more lines are too long

738
frontend/index.html Normal file
View File

@@ -0,0 +1,738 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>АСУ Инвентаризация</title>
<link rel="stylesheet" href="/app/bootstrap.min.css" />
<link rel="stylesheet" href="/app/styles.css" />
</head>
<body>
<header class="no-print">
<h1>
<a href="/app/">АСУ Инвентаризация</a>
</h1>
</header>
<div id="app">
<div class="row no-print">
<nav class="no-print navbar navbar-expand-lg navbar-light">
<div class="container-fluid">
<a class="navbar-brand" href="#" @click.prevent="showHome">Главная</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item"><a class="nav-link" href="#" @click.prevent="showHome">Главная</a></li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">Оборудование</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="#" @click.prevent="view='byAud'">По аудитории</a></li>
<li><a class="dropdown-item" href="#" @click.prevent="showAllEquipment">Всё оборудование</a></li>
<li><a class="dropdown-item" href="#" @click.prevent="showUnassigned">Не распределено</a></li>
</ul>
</li>
<li class="nav-item" v-if="isAuth"><a class="nav-link" href="#" @click.prevent="showInspection">Проверка</a></li>
<li class="nav-item dropdown" v-if="isAdmin">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">Просмотр</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="#" @click.prevent="view='users'">Пользователи</a></li>
<li><a class="dropdown-item" href="#" @click.prevent="view='audManage'">Аудитории</a></li>
</ul>
</li>
<li class="nav-item"><a class="nav-link" href="#" @click.prevent="showZametki">Заметки</a></li>
<li class="nav-item dropdown" v-if="canEdit">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">Добавить</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="#" @click.prevent="view='addEquipment'">Оборудование</a></li>
<li><a class="dropdown-item" href="#" @click.prevent="view='addAuditory'">Аудиторию</a></li>
<li><a class="dropdown-item" href="#" @click.prevent="view='addType'">Тип оборудования</a></li>
<li><a class="dropdown-item" href="#" @click.prevent="view='addOwner'">Владельца</a></li>
<li v-if="isAdmin"><hr class="dropdown-divider"></li>
<li v-if="isAdmin"><a class="dropdown-item" href="#" @click.prevent="view='addUser'">Пользователя</a></li>
</ul>
</li>
<li class="nav-item" v-if="isAuth"><a class="nav-link" href="/docs" target="_blank">API Docs</a></li>
</ul>
<span class="navbar-text ms-auto" v-if="isAuth">Роль: {{ role }}</span>
<a class="btn btn-outline-primary ms-2" v-if="!isAuth" href="/login">Войти</a>
<button class="btn btn-outline-secondary ms-2" v-else @click.prevent="logout">Выйти</button>
</div>
</div>
</nav>
</div>
<div class="container-fluid px-4">
<!-- Главная страница -->
<div v-if="view==='home'">
<div class="row">
<div class="card col-md-6 col-10">
<div class="card-body">
<form @submit.prevent="doHomeSearch">
<div class="d-flex align-items-center gap-2">
<input type="text" class="form-control w-auto" v-model="homeSearch" placeholder="инвентарный номер" />
<button class="btn btn-primary" type="submit">Найти</button>
</div>
</form>
</div>
</div>
</div>
<div class="row mt-3">
<div class="card col-md-10 col-12">
<div class="card-body">
<h3 class="card-title">Нераспределённые</h3>
<div class="status" :class="{error: !!error}">{{ status }}</div>
<div class="table-responsive">
<table class="table datatable">
<thead>
<tr>
<th scope="col">Инв. номер</th>
<th scope="col">Название</th>
<th scope="col">Аудитория</th>
</tr>
</thead>
<tbody>
<tr v-for="it in homeUnassigned" :key="it.id">
<td>{{ it.invNumber ?? '' }}</td>
<td>{{ it.nazvanie ?? '' }}</td>
<td>
<template v-if="canEdit">
<select class="form-select form-select-sm d-inline w-auto" v-model="it.selectedAudId">
<option value="">— выберите —</option>
<option v-for="a in auditories" :key="a.id" :value="a.id">{{ a.audnazvanie }}</option>
</select>
<button class="btn btn-sm btn-primary ms-2" @click="assignToAuditory(it)">Назначить</button>
</template>
<template v-else></template>
</td>
</tr>
<tr v-if="homeUnassigned.length === 0">
<td colspan="3" class="text-muted text-center">Нераспределённого оборудования нет</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="row mt-3" v-if="homeSearchResults.length > 0">
<div class="card col-md-10 col-12">
<div class="card-body">
<h3 class="card-title">Распределённые</h3>
<div class="table-responsive">
<table class="table datatable">
<thead>
<tr>
<th scope="col">Инв. номер</th>
<th scope="col">Название</th>
<th scope="col">Аудитория исходная</th>
<th scope="col" v-if="canEdit">Аудитория переноса</th>
</tr>
</thead>
<tbody>
<tr v-for="it in homeSearchResults" :key="it.id">
<td>{{ it.invNumber ?? '' }}</td>
<td>{{ it.nazvanie ?? '' }}</td>
<td>{{ getAuditoryName(it.aud_id) }}</td>
<td v-if="canEdit">
<select class="form-select form-select-sm d-inline w-auto" v-model="it.selectedAudId">
<option value="">— не менять —</option>
<option v-for="a in auditories" :key="a.id" :value="a.id">{{ a.audnazvanie }}</option>
</select>
<button class="btn btn-sm btn-outline-primary ms-2" :disabled="!it.selectedAudId" @click="assignToAuditory(it)">Перенести</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="row mt-2" v-if="homeSearchDone && homeSearchResults.length === 0">
<div class="col-12 text-center text-muted">Ничего не найдено по запросу «{{ homeSearch }}»</div>
</div>
</div>
<div v-if="view==='byAud'" class="row">
<div class="card col-12">
<div class="card-body">
<h3 class="card-title no-print">Оборудование по аудитории</h3>
<h2 class="print-only print-title">{{ printTitle }}</h2>
<div class="mb-2 d-flex align-items-center justify-content-center gap-2 no-print">
<label for="aud-select" class="mb-0">Аудитория:</label>
<select id="aud-select" class="form-select w-auto" v-model="selectedAudId">
<option value="">— выберите аудиторию —</option>
<option v-for="a in auditories" :key="a.id" :value="a.id">{{ a.audnazvanie }}</option>
</select>
<button class="btn btn-primary" @click="loadOboruds">Показать</button>
<button class="btn btn-secondary" @click="printPage" :disabled="!oboruds.length">Печать</button>
</div>
<div class="status no-print" :class="{error: !!error}">{{ status }}</div>
<div class="table-responsive">
<table class="table datatable">
<thead>
<tr>
<th scope="col">ID</th>
<th scope="col">Инв. номер</th>
<th scope="col">Название</th>
<th scope="col">Расположение</th>
<th scope="col" class="no-print">Кол-во</th>
<th scope="col" class="no-print">Тип</th>
<th scope="col" class="no-print">Владелец</th>
<th scope="col" class="print-only">Проверено</th>
</tr>
</thead>
<tbody>
<tr v-for="it in oboruds" :key="it.id">
<td>{{ it.id }}</td>
<td class="inv">{{ it.invNumber ?? '' }}</td>
<td>{{ it.nazvanie ?? '' }}</td>
<td class="rasp">{{ it.raspologenie ?? '' }}</td>
<td class="no-print">{{ it.kolichestvo ?? '' }}</td>
<td class="no-print">{{ it.type?.name ?? '' }}</td>
<td class="no-print">
<template v-if="canEdit">
<select class="form-select form-select-sm d-inline w-auto" v-model="it.selectedOwnerId">
<option value="">— нет —</option>
<option v-for="ow in owners" :key="ow.id" :value="ow.id">{{ ow.name }}</option>
</select>
<button class="btn btn-sm btn-outline-primary ms-2" @click="saveOwner(it)">Сохранить</button>
</template>
<template v-else>
{{ it.owner?.name ?? '' }}
</template>
</td>
<td class="print-only"></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div v-if="view==='allEquipment'" class="row">
<div class="card col-12">
<div class="card-body">
<h3 class="card-title no-print">Всё оборудование (по инв. номеру)</h3>
<h2 class="print-only print-title">Всё оборудование</h2>
<div class="mb-2 no-print">
<button class="btn btn-secondary" @click="printAllEquipment" :disabled="!allOboruds.length">Печать</button>
</div>
<div class="status no-print" :class="{error: !!error}">{{ status }}</div>
<div class="table-responsive">
<table class="table datatable">
<thead>
<tr>
<th scope="col" class="num-col"></th>
<th scope="col" class="inv-col">Инв. номер</th>
<th scope="col">Название</th>
<th scope="col" class="aud-col">Аудитория</th>
<th scope="col">Расположение</th>
<th scope="col" class="no-print">Кол-во</th>
<th scope="col" class="no-print">Владелец</th>
</tr>
</thead>
<tbody>
<tr v-for="(it, index) in allOboruds" :key="it.id">
<td class="num-col">{{ index + 1 }}</td>
<td class="inv-col">{{ it.invNumber ?? '' }}</td>
<td>{{ it.nazvanie ?? '' }}</td>
<td class="aud-col">{{ getAuditoryName(it.aud_id) }}</td>
<td class="rasp">{{ it.raspologenie ?? '' }}</td>
<td class="no-print">{{ it.kolichestvo ?? '' }}</td>
<td class="no-print">{{ it.owner?.name ?? '' }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div v-if="view==='users'" class="row">
<div class="card col-12">
<div class="card-body">
<h3 class="card-title">Администрирование пользователей</h3>
<div v-if="role!=='admin'" class="alert alert-warning">Недостаточно прав. Войдите как администратор.</div>
<div v-else>
<h5 class="mt-2">Создать пользователя-админа</h5>
<div class="row g-2 align-items-end">
<div class="col-auto">
<label class="form-label">Логин</label>
<input class="form-control" v-model="newAdminUsername" placeholder="username" />
</div>
<div class="col-auto">
<label class="form-label">Пароль</label>
<input type="password" class="form-control" v-model="newAdminPassword" placeholder="password" />
</div>
<div class="col-auto">
<button class="btn btn-success" @click="createAdmin">Создать админа</button>
</div>
</div>
<div class="status mt-2" :class="{error: !!error}">{{ status }}</div>
<h5 class="mt-4">Пользователи</h5>
<div class="table-responsive">
<table class="table datatable">
<thead>
<tr>
<th>ID</th>
<th>Логин</th>
<th>Роль</th>
</tr>
</thead>
<tbody>
<tr v-for="u in users" :key="u.id">
<td>{{ u.id }}</td>
<td>{{ u.username }}</td>
<td>{{ u.role }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<div v-if="view==='audManage'" class="row">
<div class="card col-12">
<div class="card-body">
<h3 class="card-title">Управление аудиториями</h3>
<div v-if="role!=='admin'" class="alert alert-warning">Недостаточно прав. Войдите как администратор.</div>
<div v-else>
<h5 class="mt-2">Добавить аудиторию</h5>
<div class="row g-2 align-items-end">
<div class="col-auto">
<label class="form-label">Название аудитории</label>
<input class="form-control" v-model="newAudName" placeholder="например, 519" />
</div>
<div class="col-auto">
<button class="btn btn-success" @click="createAuditory">Добавить</button>
</div>
</div>
<div class="status mt-2" :class="{error: !!error}">{{ status }}</div>
<h5 class="mt-4">Список аудиторий</h5>
<div class="table-responsive">
<table class="table datatable">
<thead>
<tr>
<th>ID</th>
<th>Название</th>
</tr>
</thead>
<tbody>
<tr v-for="a in auditories" :key="a.id">
<td>{{ a.id }}</td>
<td>{{ a.audnazvanie }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<div v-if="view==='owners'" class="row">
<div class="card col-12">
<div class="card-body">
<h3 class="card-title">Управление владельцами</h3>
<div v-if="!canEdit" class="alert alert-warning">Недостаточно прав. Войдите как администратор или редактор.</div>
<div v-else>
<h5 class="mt-2">Добавить владельца</h5>
<div class="row g-2 align-items-end">
<div class="col-auto">
<label class="form-label">Имя владельца</label>
<input class="form-control" v-model="newOwnerName" placeholder="например, Иванов И.И." />
</div>
<div class="col-auto">
<button class="btn btn-success" @click="createOwner">Добавить</button>
</div>
</div>
<div class="status mt-2" :class="{error: !!error}">{{ status }}</div>
<h5 class="mt-4">Список владельцев</h5>
<div class="table-responsive">
<table class="table datatable">
<thead>
<tr>
<th>ID</th>
<th>Имя</th>
</tr>
</thead>
<tbody>
<tr v-for="o in owners" :key="o.id">
<td>{{ o.id }}</td>
<td>{{ o.name }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<div v-if="view==='unassigned'" class="row">
<div class="card col-12">
<div class="card-body">
<h3 class="card-title">Не распределено: {{ unassignedOboruds.length }} из {{ totalOboruds }}</h3>
<div class="status" :class="{error: !!error}">{{ status }}</div>
<div class="table-responsive">
<table class="table datatable">
<thead>
<tr>
<th scope="col"></th>
<th scope="col">Инв. номер</th>
<th scope="col">Название</th>
<th scope="col">Владелец</th>
</tr>
</thead>
<tbody>
<tr v-for="(it, index) in unassignedOboruds" :key="it.id">
<td>{{ index + 1 }}</td>
<td class="inv">{{ it.invNumber ?? '' }}</td>
<td>{{ it.nazvanie ?? '' }}</td>
<td>{{ it.owner?.name ?? '' }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div v-if="view==='zametki'" class="row">
<div class="card col-12">
<div class="card-body">
<h3 class="card-title">Заметки</h3>
<div v-if="canEdit" class="mb-4">
<h5>Добавить заметку</h5>
<div class="row g-2 align-items-end">
<div class="col-md-8">
<textarea class="form-control" v-model="newZametkaText" rows="3" placeholder="Текст заметки..."></textarea>
</div>
<div class="col-auto">
<button class="btn btn-success" @click="createZametka">Добавить</button>
</div>
</div>
</div>
<div class="status" :class="{error: !!error}">{{ status }}</div>
<h5>Активные заметки</h5>
<div v-if="zametki.length === 0" class="text-muted">Нет активных заметок</div>
<div v-for="z in zametki" :key="z.id" class="card mb-2">
<div class="card-body">
<p class="card-text" style="white-space: pre-wrap;">{{ z.txtzam }}</p>
<small class="text-muted">{{ formatDate(z.created_date) }}</small>
<button v-if="canEdit" class="btn btn-sm btn-outline-success float-end" @click="resolveZametka(z.id)">Решено</button>
</div>
</div>
</div>
</div>
</div>
<!-- Добавить оборудование -->
<div v-if="view==='addEquipment'" class="row">
<div class="card col-12 col-md-8">
<div class="card-body">
<h3 class="card-title">Добавить оборудование</h3>
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Инв. номер</label>
<input type="number" class="form-control" v-model="newEquipment.invNumber" />
</div>
<div class="col-md-8">
<label class="form-label">Название</label>
<input type="text" class="form-control" v-model="newEquipment.nazvanie" required />
</div>
<div class="col-md-4">
<label class="form-label">Аудитория</label>
<select class="form-select" v-model="newEquipment.aud_id">
<option value="">— не выбрано —</option>
<option v-for="a in auditories" :key="a.id" :value="a.id">{{ a.audnazvanie }}</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label">Тип</label>
<select class="form-select" v-model="newEquipment.type_id">
<option value="">— не выбрано —</option>
<option v-for="t in equipmentTypes" :key="t.id" :value="t.id">{{ t.name }}</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label">Владелец</label>
<select class="form-select" v-model="newEquipment.owner_id">
<option value="">— не выбрано —</option>
<option v-for="o in owners" :key="o.id" :value="o.id">{{ o.name }}</option>
</select>
</div>
<div class="col-md-8">
<label class="form-label">Расположение</label>
<input type="text" class="form-control" v-model="newEquipment.raspologenie" />
</div>
<div class="col-md-4">
<label class="form-label">Количество</label>
<input type="number" class="form-control" v-model="newEquipment.kolichestvo" />
</div>
</div>
<div class="status mt-3" :class="{error: !!error}">{{ status }}</div>
<button class="btn btn-success mt-3" @click="createEquipment">Добавить</button>
</div>
</div>
</div>
<!-- Добавить аудиторию -->
<div v-if="view==='addAuditory'" class="row">
<div class="card col-12 col-md-6">
<div class="card-body">
<h3 class="card-title">Добавить аудиторию</h3>
<div class="mb-3">
<label class="form-label">Название аудитории</label>
<input type="text" class="form-control" v-model="newAudName" placeholder="например, 519" />
</div>
<div class="status" :class="{error: !!error}">{{ status }}</div>
<button class="btn btn-success" @click="createAuditory">Добавить</button>
</div>
</div>
</div>
<!-- Добавить тип оборудования -->
<div v-if="view==='addType'" class="row">
<div class="card col-12 col-md-6">
<div class="card-body">
<h3 class="card-title">Добавить тип оборудования</h3>
<div class="mb-3">
<label class="form-label">Название типа</label>
<input type="text" class="form-control" v-model="newTypeName" placeholder="например, Компьютер" />
</div>
<div class="status" :class="{error: !!error}">{{ status }}</div>
<button class="btn btn-success" @click="createEquipmentType">Добавить</button>
<h5 class="mt-4">Существующие типы</h5>
<ul class="list-group">
<li class="list-group-item" v-for="t in equipmentTypes" :key="t.id">{{ t.name }}</li>
</ul>
</div>
</div>
</div>
<!-- Добавить владельца -->
<div v-if="view==='addOwner'" class="row">
<div class="card col-12 col-md-6">
<div class="card-body">
<h3 class="card-title">Добавить владельца</h3>
<div class="mb-3">
<label class="form-label">Имя владельца</label>
<input type="text" class="form-control" v-model="newOwnerName" placeholder="например, Иванов И.И." />
</div>
<div class="status" :class="{error: !!error}">{{ status }}</div>
<button class="btn btn-success" @click="createOwner">Добавить</button>
<h5 class="mt-4">Существующие владельцы</h5>
<ul class="list-group">
<li class="list-group-item" v-for="o in owners" :key="o.id">{{ o.name }}</li>
</ul>
</div>
</div>
</div>
<!-- Добавить пользователя -->
<div v-if="view==='addUser'" class="row">
<div class="card col-12 col-md-6">
<div class="card-body">
<h3 class="card-title">Добавить пользователя</h3>
<div class="mb-3">
<label class="form-label">Логин</label>
<input type="text" class="form-control" v-model="newAdminUsername" />
</div>
<div class="mb-3">
<label class="form-label">Пароль</label>
<input type="password" class="form-control" v-model="newAdminPassword" />
</div>
<div class="status" :class="{error: !!error}">{{ status }}</div>
<button class="btn btn-success" @click="createAdmin">Создать администратора</button>
</div>
</div>
</div>
<!-- Проверка оборудования -->
<div v-if="view==='inspection'" class="row">
<div class="card col-12">
<div class="card-body">
<h3 class="card-title">Проверка оборудования</h3>
<!-- Блок 1: Начать проверку (если нет активной сессии) -->
<div v-if="!activeInspection">
<h5>Начать новую проверку</h5>
<div class="row g-2 align-items-end mb-3">
<div class="col-auto">
<label class="form-label">Аудитория (необязательно)</label>
<select class="form-select" v-model="inspectionAudId">
<option value="">— Всё оборудование —</option>
<option v-for="a in auditories" :key="a.id" :value="a.id">{{ a.audnazvanie }}</option>
</select>
</div>
<div class="col-auto">
<button class="btn btn-success" @click="startInspection">Начать проверку</button>
</div>
</div>
<h5 class="mt-4">История проверок</h5>
<button class="btn btn-secondary mb-3" @click="loadInspectionHistory">Загрузить историю</button>
<div v-if="inspectionHistory.length > 0" class="table-responsive">
<table class="table datatable">
<thead>
<tr>
<th>ID</th>
<th>Начата</th>
<th>Завершена</th>
<th>Аудитория</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
<tr v-for="sess in inspectionHistory" :key="sess.id">
<td>{{ sess.id }}</td>
<td>{{ formatDate(sess.started_at) }}</td>
<td>{{ sess.completed_at ? formatDate(sess.completed_at) : '—' }}</td>
<td>{{ sess.aud_id ? getAuditoryName(sess.aud_id) : 'Всё' }}</td>
<td>
<button class="btn btn-sm btn-primary" @click="viewHistoryDetails(sess.id)">Детали</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Блок 2: Активная проверка -->
<div v-else>
<div class="alert alert-info">
<strong>Активная проверка</strong><br>
<span v-if="activeInspection.aud_id">Аудитория: {{ getAuditoryName(activeInspection.aud_id) }}</span>
<span v-else>Всё оборудование</span><br>
Начата: {{ formatDate(activeInspection.started_at) }}
</div>
<!-- Статистика прогресса -->
<div class="row mb-3">
<div class="col-md-3">
<div class="card text-center">
<div class="card-body">
<h5 class="card-title">{{ inspectionStats.total_checked }}</h5>
<p class="card-text">Проверено</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card text-center">
<div class="card-body">
<h5 class="card-title">{{ inspectionStats.total_expected }}</h5>
<p class="card-text">Всего</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card text-center">
<div class="card-body">
<h5 class="card-title">{{ inspectionStats.total_unknown }}</h5>
<p class="card-text">Не найдено</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card text-center bg-success text-white">
<div class="card-body">
<h5 class="card-title">{{ inspectionStats.progress_percent }}%</h5>
<p class="card-text">Прогресс</p>
</div>
</div>
</div>
</div>
<!-- Поле для сканирования -->
<div class="mb-3">
<label class="form-label">Сканируйте штрихкод или введите инв. номер</label>
<input
ref="barcodeInput"
type="text"
class="form-control form-control-lg"
v-model="scannedBarcode"
@keyup.enter="checkBarcode"
placeholder="Отсканируйте или введите номер..."
autofocus
/>
</div>
<!-- Последний результат сканирования -->
<div v-if="lastScanResult" class="alert" :class="lastScanResult.status === 'found' ? 'alert-success' : 'alert-danger'">
{{ lastScanResult.message }}
<div v-if="lastScanResult.equipment">
<strong>{{ lastScanResult.equipment.nazvanie }}</strong>
</div>
</div>
<!-- Кнопки действий -->
<div class="mt-3 mb-4">
<button class="btn btn-primary me-2" @click="refreshInspectionData">Обновить данные</button>
<button class="btn btn-success me-2" @click="completeInspection">Завершить проверку</button>
<button class="btn btn-secondary" @click="cancelInspection">Отменить</button>
</div>
<!-- Таблица проверенного оборудования (в реальном времени) -->
<h5 class="mt-4">Проверенное оборудование</h5>
<div class="table-responsive">
<table class="table datatable">
<thead>
<tr>
<th>Инв. номер</th>
<th>Название</th>
<th>Аудитория</th>
<th>Проверено</th>
</tr>
</thead>
<tbody>
<tr v-for="rec in checkedEquipment" :key="rec.id">
<td>{{ rec.oborud?.invNumber }}</td>
<td>{{ rec.oborud?.nazvanie }}</td>
<td>{{ getAuditoryName(rec.oborud?.aud_id) }}</td>
<td>{{ formatDate(rec.checked_at) }}</td>
</tr>
</tbody>
</table>
</div>
<!-- Неизвестные штрихкоды -->
<div v-if="unknownBarcodes.length > 0">
<h5 class="mt-4 text-danger">Неизвестные штрихкоды</h5>
<ul class="list-group">
<li class="list-group-item" v-for="ub in unknownBarcodes" :key="ub.id">
{{ ub.barcode }} — {{ formatDate(ub.scanned_at) }}
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="/app/vue.global.prod.js"></script>
<script src="/app/bootstrap.bundle.min.js"></script>
<script src="/app/app.js" defer></script>
</body>
</html>

46
frontend/login.html Normal file
View File

@@ -0,0 +1,46 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Вход — АСУ Инвентаризация</title>
<link rel="stylesheet" href="/app/bootstrap.min.css" />
<link rel="stylesheet" href="/app/styles.css" />
<style>
body { display: flex; flex-direction: column; min-height: 100vh; }
main { flex: 1; display: flex; align-items: center; justify-content: center; width: 100%; }
.login-card { max-width: 420px; width: 100%; margin: 0; }
.muted { color: #6c757d; font-size: 0.9rem; }
</style>
</head>
<body>
<header>
<h1><a href="/app/">АСУ Инвентаризация</a></h1>
<h2>Авторизация</h2>
</header>
<main>
<div class="card login-card">
<div class="card-body">
<h5 class="card-title mb-3">Вход</h5>
<form id="login-form">
<div class="mb-3 text-start">
<label for="username" class="form-label">Логин</label>
<input type="text" id="username" class="form-control" autocomplete="username" required />
</div>
<div class="mb-3 text-start">
<label for="password" class="form-label">Пароль</label>
<input type="password" id="password" class="form-control" autocomplete="current-password" required />
</div>
<div id="status" class="muted mb-2"></div>
<button type="submit" class="btn btn-primary w-100">Войти</button>
</form>
<div class="muted mt-3">Демо: admin / admin (после инициализации БД)</div>
</div>
</div>
</main>
<script src="/app/login.js" defer></script>
</body>
</html>

58
frontend/login.js Normal file
View File

@@ -0,0 +1,58 @@
function setStatus(msg, type = "info") {
const el = document.getElementById("status");
el.textContent = msg || "";
el.style.color = type === 'error' ? '#b91c1c' : type === 'ok' ? '#16a34a' : '';
}
function decodeRoleFromJWT(token) {
try {
const payload = JSON.parse(atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')));
return payload.role || null;
} catch { return null; }
}
async function login(username, password) {
const params = new URLSearchParams();
params.set('username', username);
params.set('password', password);
try {
const res = await fetch('/auth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString(),
});
if (!res.ok) {
const txt = await res.text();
throw new Error(`Ошибка входа (${res.status}): ${txt}`);
}
const data = await res.json();
const token = data.access_token;
if (!token) throw new Error('Токен не получен');
localStorage.setItem('access_token', token);
const role = decodeRoleFromJWT(token);
if (role) localStorage.setItem('role', role);
return { token, role };
} catch (e) {
throw e;
}
}
document.addEventListener('DOMContentLoaded', () => {
const form = document.getElementById('login-form');
form.addEventListener('submit', async (ev) => {
ev.preventDefault();
setStatus('Входим…');
const username = document.getElementById('username').value.trim();
const password = document.getElementById('password').value;
try {
const { role } = await login(username, password);
setStatus(`Успешный вход${role ? ' (' + role + ')' : ''}`, 'ok');
// Небольшая задержка для визуального отклика
setTimeout(() => { window.location.href = '/app/'; }, 400);
} catch (e) {
console.error(e);
setStatus(e.message || 'Ошибка авторизации', 'error');
}
});
});

250
frontend/styles.css Normal file
View File

@@ -0,0 +1,250 @@
* {
margin: 0;
padding: 0;
}
body {
background-color: #E2F3FD;
min-width: 580px;
}
.row {
text-align: center;
display: flex;
justify-content: center;
border: 1px;
}
header {
text-align: center;
background-color: #6A90B6;
padding: 2px;
}
a:hover {
text-decoration: none;
color: #041322;
}
a {
color: #041322;
}
.container {
margin: 10px;
}
button {
background-color: #E07D54;
margin-top: 5px;
margin-bottom: 5px;
padding: 6px 14px;
}
.btn {
padding: 8px 20px;
}
.btn-sm {
padding: 5px 12px;
}
.btn-primary {
background-color: #E07D54;
border-color: #E07D54;
color: #fff;
}
.btn-primary:hover, .btn-primary:focus, .btn-primary:active {
background-color: #c86a3d;
border-color: #c86a3d;
color: #fff;
}
.btn-outline-primary {
color: #E07D54;
border-color: #E07D54;
}
.btn-outline-primary:hover, .btn-outline-primary:focus, .btn-outline-primary:active {
background-color: #E07D54;
border-color: #E07D54;
color: #fff;
}
.card {
margin: 10px;
border-radius: 15px;
border-color: #E07D54;
}
h5 {
text-align: center;
}
.hidden-column {
display: none;
}
nav {
width: 100%;
}
table {
word-break: break-all;
border-collapse: separate !important;
}
.table td {
font-size: 14px;
padding: 0;
max-width: 10rem;
word-break: break-all;
border-collapse: separate !important;
}
.aud {
width: 110px;
}
td.aud-col, th.aud-col {
width: 70px;
min-width: 70px;
max-width: 70px;
}
td.num-col, th.num-col {
width: 40px;
min-width: 40px;
max-width: 40px;
text-align: center;
}
td.inv-col, th.inv-col {
width: auto;
}
.inv {
width: 400px;
}
.rasp {
width: 250px;
max-width: 250px;
word-break: break-word;
word-wrap: break-word;
overflow-wrap: break-word;
white-space: normal !important;
}
#modal_matcenn {
margin-left: 20px;
}
.datatable {
background-color: whitesmoke;
}
.print-only {
display: none;
}
.print-title {
text-align: center;
margin-bottom: 20px;
}
.datatable th:nth-child(7) {
width: 200px;
}
@media print {
* {
font-family: "Times New Roman", Times, serif;
}
body {
margin: 0;
padding: 0;
background-color: transparent;
}
a {
text-decoration: none;
border: none;
}
@page {
size: A4;
margin: 10mm;
}
@page :first {
margin-top: 10mm;
}
html, body {
height: 100%;
}
title {
display: none;
}
.card {
border: none;
width: 100%;
}
.no-print {
display: none !important;
}
.print-only {
display: table-cell !important;
}
h2.print-only {
display: block !important;
}
.table {
border: 1px solid #E07D54;
margin-top: 20px;
font-size: 12pt;
}
.table th, .table td {
border: 1px solid #E07D54;
padding: 5px;
font-size: 12pt;
word-break: normal;
word-wrap: break-word;
overflow-wrap: break-word;
white-space: normal;
}
td.inv-col, th.inv-col {
width: 100px;
min-width: 100px;
max-width: 100px;
}
table.rs-table-bordered {
border: 1px solid #E07D54;
margin-top: 20px;
font-size: 14pt;
}
table.rs-table-bordered > thead > tr > th {
border: 1px solid #E07D54;
padding: 2px;
font-size: 14pt;
}
table.rs-table-bordered > tbody > tr > td {
border: 1px solid #E07D54;
padding: 10px;
font-size: 14pt;
}
}

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
Single-database configuration for Flask.

View File

@@ -1,52 +0,0 @@
# A generic, single database configuration.
[alembic]
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
script_location = .
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic,flask_migrate
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[logger_flask_migrate]
level = INFO
handlers =
qualname = flask_migrate
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

View File

@@ -1,113 +0,0 @@
import logging
from logging.config import fileConfig
from flask import current_app
from alembic import context
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(config.config_file_name)
logger = logging.getLogger('alembic.env')
def get_engine():
try:
# this works with Flask-SQLAlchemy<3 and Alchemical
return current_app.extensions['migrate'].db.get_engine()
except (TypeError, AttributeError):
# this works with Flask-SQLAlchemy>=3
return current_app.extensions['migrate'].db.engine
def get_engine_url():
try:
return get_engine().url.render_as_string(hide_password=False).replace(
'%', '%%')
except AttributeError:
return str(get_engine().url).replace('%', '%%')
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
config.set_main_option('sqlalchemy.url', get_engine_url())
target_db = current_app.extensions['migrate'].db
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def get_metadata():
if hasattr(target_db, 'metadatas'):
return target_db.metadatas[None]
return target_db.metadata
def run_migrations_offline():
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url, target_metadata=get_metadata(), literal_binds=True
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
# this callback is used to prevent an auto-migration from being generated
# when there are no changes to the schema
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
def process_revision_directives(context, revision, directives):
if getattr(config.cmd_opts, 'autogenerate', False):
script = directives[0]
if script.upgrade_ops.is_empty():
directives[:] = []
logger.info('No changes in schema detected.')
conf_args = current_app.extensions['migrate'].configure_args
if conf_args.get("process_revision_directives") is None:
conf_args["process_revision_directives"] = process_revision_directives
connectable = get_engine()
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=get_metadata(),
**conf_args
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -1,24 +0,0 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View File

@@ -1,32 +0,0 @@
"""empty message
Revision ID: 256c3a3e91a2
Revises: 4f95d12a8352
Create Date: 2024-03-13 16:01:01.559719
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '256c3a3e91a2'
down_revision = '4f95d12a8352'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('oboruds', schema=None) as batch_op:
batch_op.drop_column('somepl')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('oboruds', schema=None) as batch_op:
batch_op.add_column(sa.Column('somepl', sa.VARCHAR(length=10), nullable=True))
# ### end Alembic commands ###

View File

@@ -1,32 +0,0 @@
"""empty message
Revision ID: 4f95d12a8352
Revises: 50f85881169e
Create Date: 2024-03-13 15:57:33.119683
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '4f95d12a8352'
down_revision = '50f85881169e'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('oboruds', schema=None) as batch_op:
batch_op.add_column(sa.Column('somepl', sa.String(length=10), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('oboruds', schema=None) as batch_op:
batch_op.drop_column('somepl')
# ### end Alembic commands ###

View File

@@ -1,24 +0,0 @@
"""empty message
Revision ID: 50f85881169e
Revises: b24baa0d98e6
Create Date: 2024-03-13 15:55:03.770084
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '50f85881169e'
down_revision = 'b24baa0d98e6'
branch_labels = None
depends_on = None
def upgrade():
pass
def downgrade():
pass

View File

@@ -1,32 +0,0 @@
"""empty message
Revision ID: 873defe09f22
Revises: ec6bbcd361bd
Create Date: 2024-03-13 22:34:36.718676
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '873defe09f22'
down_revision = 'ec6bbcd361bd'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('oboruds', schema=None) as batch_op:
batch_op.add_column(sa.Column('raspologenie', sa.String(length=200), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('oboruds', schema=None) as batch_op:
batch_op.drop_column('raspologenie')
# ### end Alembic commands ###

View File

@@ -1,32 +0,0 @@
"""empty message
Revision ID: 885bdd7b5161
Revises: be7c94c549e5
Create Date: 2024-04-02 23:03:59.401369
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '885bdd7b5161'
down_revision = 'be7c94c549e5'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('oboruds', schema=None) as batch_op:
batch_op.add_column(sa.Column('kolichestvo', sa.Integer(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('oboruds', schema=None) as batch_op:
batch_op.drop_column('kolichestvo')
# ### end Alembic commands ###

View File

@@ -1,32 +0,0 @@
"""empty message
Revision ID: 8e838956713f
Revises: 256c3a3e91a2
Create Date: 2024-03-20 19:03:51.112016
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '8e838956713f'
down_revision = '256c3a3e91a2'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('oboruds', schema=None) as batch_op:
batch_op.add_column(sa.Column('numberved', sa.String(length=100), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('oboruds', schema=None) as batch_op:
batch_op.drop_column('numberved')
# ### end Alembic commands ###

View File

@@ -1,24 +0,0 @@
"""empty message
Revision ID: b24baa0d98e6
Revises: 873defe09f22, b2a61aef79e9
Create Date: 2024-03-13 15:42:47.733687
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'b24baa0d98e6'
down_revision = ('873defe09f22', 'b2a61aef79e9')
branch_labels = None
depends_on = None
def upgrade():
pass
def downgrade():
pass

View File

@@ -1,24 +0,0 @@
"""empty message
Revision ID: b2a61aef79e9
Revises: ec6bbcd361bd
Create Date: 2024-03-13 01:48:30.093937
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'b2a61aef79e9'
down_revision = 'ec6bbcd361bd'
branch_labels = None
depends_on = None
def upgrade():
pass
def downgrade():
pass

View File

@@ -1,34 +0,0 @@
"""empty message
Revision ID: be7c94c549e5
Revises: 8e838956713f
Create Date: 2024-04-01 15:09:52.082987
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'be7c94c549e5'
down_revision = '8e838956713f'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('oboruds', schema=None) as batch_op:
batch_op.add_column(sa.Column('balancenumber', sa.String(length=30), nullable=True))
batch_op.drop_column('typeBalanse')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('oboruds', schema=None) as batch_op:
batch_op.add_column(sa.Column('typeBalanse', sa.VARCHAR(length=30), nullable=True))
batch_op.drop_column('balancenumber')
# ### end Alembic commands ###

View File

@@ -1,44 +0,0 @@
"""empty message
Revision ID: ec6bbcd361bd
Revises:
Create Date: 2024-03-13 08:22:23.761783
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'ec6bbcd361bd'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('oboruds', schema=None) as batch_op:
batch_op.drop_column('duplicate')
with op.batch_alter_table('zametki', schema=None) as batch_op:
batch_op.alter_column('txtzam',
existing_type=sa.TEXT(length=10000),
type_=sa.String(length=10000),
existing_nullable=True)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('zametki', schema=None) as batch_op:
batch_op.alter_column('txtzam',
existing_type=sa.String(length=10000),
type_=sa.TEXT(length=10000),
existing_nullable=True)
with op.batch_alter_table('oboruds', schema=None) as batch_op:
batch_op.add_column(sa.Column('duplicate', sa.BOOLEAN(), nullable=True))
# ### end Alembic commands ###

View File

@@ -1,37 +0,0 @@
from flask_sqlalchemy import SQLAlchemy
import datetime
db = SQLAlchemy()
class Auditory(db.Model):
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
audnazvanie=db.Column(db.String)
oboruds = db.relationship('Oboruds')
class Oboruds(db.Model):
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
invNumber=db.Column(db.Integer)
nazvanie=db.Column(db.String(500))
balancenumber = db.Column(db.String(30))
raspologenie = db.Column(db.String(200))
numberved = db.Column(db.String(100))
numberppasu = db.Column(db.String(100))
kolichestvo = db.Column(db.Integer)
aud_id = db.Column(db.Integer, db.ForeignKey(Auditory.id))
class Zametki(db.Model):
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
txtzam=db.Column(db.String(10000))
created_date = db.Column(db.DateTime, default=datetime.datetime.utcnow)
rmdt = db.Column(db.DateTime)

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@@ -1,167 +0,0 @@
* {
margin: 0;
padding: 0;
}
body {
background-color: #E2F3FD;
min-width: 580px;
}
.row{
text-align: center;
display: flex;
justify-content: center;
border: 1px;
}
header {
text-align: center;
background-color: #6A90B6;
padding: 2px;
}
a:hover {
text-decoration: none;
color: #041322;
}
a{
color: #041322;
}
.container {
margin: 10px;
}
button {
background-color: #E07D54;
margin-top: 5px;
margin-bottom: 5px;
}
.card {
width: 200px;
margin: 10px;
border-radius: 15px;
border-color: #E07D54;
}
h5 {
text-align: center;
}
.hidden-column{
display: none;
}
nav{
width:100%;
}
.table{
word-break: break-all;
}
.aud{
width: 110px;
}
.inv{
width: 400px;
}
.rasp{
max-width: 200px;
word-break: break-word;
}
#modal_matcenn{
margin-left: 20px;
}
.datatable{
background-color: whitesmoke;
}
@media print {
*{
font-family: "Times New Roman", Times, serif;
}
body {
margin: 0;
padding: 0;
background-color: transparent;
}
a {
text-decoration: none;
border: none;
}
@page {
size: A4; /* или letter, legal, tabloid, etc. */
margin: 1cm; /* Устанавливаем поля */
align-items: center;
}
.card {
border: none;
width: 100%;
}
.no-print {
display: none;
}
table.rs-table-bordered{
border:1px solid #000000;
margin-top:20px;
font-size: 14pt;
}
table.rs-table-bordered > thead > tr > th{
border:1px solid #000000;
padding: 2px;
font-size: 14pt;
}
table.rs-table-bordered > tbody > tr > td{
border:1px solid #000000;
padding: 10px;
font-size: 14pt;
}
}

View File

@@ -1,35 +0,0 @@
function getAllData(){
$.ajax({
url: "/getall",
type: "get",
contentType: 'application/json',
dataType: 'json',
success: function(response){
console.log(response)
let data = response;
$('#datatable tbody').empty();
$.each(response, function(index, item) {
$('#datatable tbody').append(
'<tr>' +
'<td>' + item.invNumber + '</td>' +
'<td>' + item.nazvanie + '</td>' +
'<td>' + item.raspologenie + '</td>' +
'<td>' + item.numberved + '</td>' +
'<td>' + item.buhnumberpp + '</td>' +
'<td>' + item.kolichestvo + '</td>' +
'<td>' + item.balancenumber + '</td>' +
'</tr>'
);
})
},
})
}
$(document).ready(function(){
getAllData()
})

View File

@@ -1,35 +0,0 @@
function getData(){
const audid = document.getElementById('auditory')
$.ajax({
url: "/getall",
type: "get",
contentType: 'application/json',
dataType: 'json',
success: function(response){
var data = response;
const table = document.getElementById('datatable')
table.innerHTML = ''
var headTable = '<tr> <td >Номер в Инв. вед</td> <td id="invnomer">Инв. номер</td><td>Название</td><td class="no-print aud">Аудитория</td> <td >Расположение</td> <td id="proverka"class="hidden-column"> Проверено</td> </tr>'
table.innerHTML += headTable
var tr =""
data.forEach(element => {
tr += '<tr onclick="tableclick(this)">'
tr += '<td>' + element.num_ved + '</td>'
tr += '<td clas="inv">' + element.inv_number + '</td>'
tr += '<td>' + element.oboruds_id + '</td>'
tr += '<td class="no-print">' + element.auditory_name + '</td>'
tr += '<td class="rasp">' +element.raspolog + '</td>'
tr += '<td>' + '\n' + '</td>'
tr += '</tr>'
});
table.innerHTML += tr
}
})
}

View File

@@ -1,32 +0,0 @@
$( document ).ready(function() {
$(".updatebtn").click(function(){
var audid = document.getElementById('auditory_dubl').value;
var invnum = document.getElementById('invnomer').textContent;
console.log('start perenos')
$.ajax({
url: "/perenos",
type: 'get',
contentType: 'application/json',
dataType: 'json',
data: {'audid':audid,
'invnum':invnum
},
error: function(error){
console.log(error);
}
})
invnomer.textContent=''
nazvanie.textContent=''
aud.textContent=''
auditory_dubl.selectedIndex = 0;
})
})

View File

View File

@@ -1,25 +0,0 @@
$("#printbutton").click(function(){
let aud = document.getElementById('auditory')
let audtext = aud.options[aud.selectedIndex].text;
const h2 = document.querySelector('h2');
h2.textContent = "Аудитория № " + audtext
document.getElementById("datatable").className="rs-table-bordered px-3"
let column = document.getElementById("proverka")
column.classList.remove("hidden-column")
window.print()
document.getElementById("datatable").className="table"
h2.textContent='распределение мат. ценностей'
column.classList.add("hidden-column")
})

View File

@@ -1,5 +0,0 @@
$("#printallbutton").click(function(){
console.log("aaaaaaa")
})

View File

@@ -1,125 +0,0 @@
function getData(){
const audid = document.getElementById('auditory')
$.ajax({
url: "/searchonaud",
type: "get",
contentType: 'application/json',
dataType: 'json',
data: {
auditory: audid.value
},
success: function(response){
var data = response;
const table = document.getElementById('datatable')
table.innerHTML = ''
var headTable = '<tr> <td >Номер в Инв. вед</td> <td id="invnomer">Инв. номер</td><td>Название</td><td class="no-print aud">Аудитория</td> <td >Расположение</td> <td id="proverka"class="hidden-column"> Проверено</td> </tr>'
table.innerHTML += headTable
var tr =""
data.forEach(element => {
tr += '<tr onclick="tableclick(this)">'
tr += '<td>' + element.num_ved + '</td>'
tr += '<td clas="inv">' + element.inv_number + '</td>'
tr += '<td>' + element.oboruds_id + '</td>'
tr += '<td class="no-print">' + element.auditory_name + '</td>'
tr += '<td class="rasp">' +element.raspolog + '</td>'
tr += '<td>' + '\n' + '</td>'
tr += '</tr>'
});
table.innerHTML += tr
}
})
}
$("#searchbutton").click(function(){
getData();
})
$('#modalsavetodb').click(function(){
let rasp = document.getElementById('modal_rapolog')
let vedomost = document.getElementById('modal_vednumber')
let invnom = document.getElementById('modal_invnom')
let matcen = document.getElementById('modal_matcenn')
let changeddata = new Array()
changeddata[0] = invnom.text
changeddata[1] = vedomost.value
changeddata[2] = rasp.value
let sendData = changeddata.join(',')
changeddata = []
$.ajax({
url: "/addraspved",
type: "POST",
contentType: "application/json;charset=utf-8",
dataType: "json",
data: sendData,
success: function(){
/*
rasp='',
vedomost = '',
invnom = '',
matcen = '',
changeddata = []
*/
$('#getmodal').modal('hide').data( 'bs.modal', null );
getData();
}
})
})
function tableclick(tableRow){
let nomved = tableRow.childNodes[0].innerHTML;
let invnomer = tableRow.childNodes[1].innerHTML;
let nazvanie = tableRow.childNodes[2].innerHTML;
let raspolog = tableRow.childNodes[4].innerHTML;
$('#getmodal').modal('show')
let rasp = document.getElementById('modal_rapolog')
let vedomost = document.getElementById('modal_vednumber')
let invnom = document.getElementById('modal_invnom')
let matcen = document.getElementById('modal_matcenn')
invnom.innerText = invnomer
matcen.innerText = nazvanie.substring(0,15)
if (nomved.length >0){
vedomost.value = nomved
}
if(raspolog.length>0){
rasp.value = raspolog
}
$("#mimodal").on('hidden.bs.modal', function () {
$(this).data('bs.modal', null);
});
$('#modalclose').click(function(){
$('#getmodal').modal('hide').data( 'bs.modal', null );
} )
}

View File

@@ -1,47 +0,0 @@
function tableclick(x){
// let data = document.getElementById(x.rowIndex)
let datas = x.innerText.split('\t')
invnom.innerText=datas[1]+"\t"
matcen.innerText=datas[2]
if (datas[4].length>0){
rasp.value=datas[4];
}
if (datas[0].length>0){
vedomost.value=datas[0]
}
$('#modalclose').click(function(){
$('#getmodal').modal('hide');
} )
$('#modalsavetodb').click(function(){
$.ajax({
url: "/addraspved",
type: "POST",
contentType: "application/json;charset=utf-8",
dataType: "json",
data: {
rasp: rasp.value,
ved: vedomost.value,
inv: invnomer
},
success:function() {
rasp.value = '';
vedomost.value= '';
data=[];
rasp
$('#getmodal').modal('hide');
getData();
}
})
})}

View File

@@ -1,15 +0,0 @@
$( document ).ready(function() {
$(".reshbtn").click(function(){
var zmid = this.id
$.ajax({
url: "/zamrm",
type: 'get',
contentType: 'application/json',
dataType: 'json',
data: {'zmid':zmid},
})
var parent = document.getElementById('zambody')
var child = document.getElementById(zmid)
parent.removeChild(child)
})
})

View File

@@ -1,24 +0,0 @@
{% extends 'base.html' %}
{% block content %}
<div class="row">
<div class="card col-md-10 col-10">
<div class="card-body">
<h5 class="card-title">Добавить аудиторию</h5>
<form method='POST' action="/addaudtodb" class="card" >
<input type="text" class="form-control" name="auditory">
<button> Добавить</button>
</form>
</div>
</div>
</div>
{%endblock%}

View File

@@ -1,36 +0,0 @@
{% extends 'base.html' %}
{% block content %}
<div class="row col-12">
<div class=" card col-11">
<h3 class=" no-print"> Все мат. ценности </h3>
</div>
<div class="row col-12">
<button class="button" id="printallbutton"> Печать </button>
</div>
</div>
<div class="row">
<div class="card col-md-11" >
<table id="datable" class="datable table pagebreak" >
<thead>
<tr>
<th scope="col">№ п/п</th>
<th scope="col">№ п/п вед</th>
<th scope="col">Инв. номер</th>
<th scope="col">Название</th>
<th scope="col">Кол-во</th>
<th scope="col">Счёт</th>
<th scope="col">Аудитория</th>
</tr>
</thead>
</table>
</div>
</div>
<script src="{{url_for('static', filename='js/allmatc.js') }}"></script>
{% endblock %}

View File

@@ -1,147 +0,0 @@
{% extends 'base.html' %}
{% block content %}
<!-- Modal -->
<div class="modal fade" id="getmodal" tabindex="-1" role="dialog" aria-labelledby="exampleModalCenterTitle" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-body" id="textarea">
<div class="row">
<a id="modal_invnom"> </a><a id="modal_matcenn"></a>
</div>
<div class="row">
№ из ведомости
<input type="text" class="form-control" id ='modal_vednumber' placeholder="Номер из ведомости">
</div>
<div class="row">
Количество
<input type="text" class="form-control" id ='modal_kolvo' placeholder="Количество">
</div>
<div class="row">
Балансовый счёт
<input type="text" class="form-control" id ='modal_balance' placeholder="Балансовый счёт">
</div>
<div class="row">
Расположение
<input type="text" class="form-control" id ='modal_rapolog' placeholder="Введите расположение">
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal" id="modalclose">Закрыть</button>
<button type="button" class="btn btn-primary" id="modalsavetodb" >Сохранить изменения</button>
</div>
</div>
</div>
</div>
</div>
<!-- Modal2 -->
<div class="modal fade" id="addmodal" tabindex="-1" role="dialog" aria-labelledby="exampleModalCenterTitle" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-body" id="textarea">
<div class="row">
<a id="modal_invnom"> </a><a id="modal2_matcenn"></a>
</div>
<div class="row">
№ из ведомости
<input type="text" class="form-control" id ='modal2_vednumber' placeholder="Номер из ведомости">
<div class="row">
Инвентарный номер
<input type="text" class="form-control" id ='modal2_invnom' placeholder="Инвентарный номер">
</div>
</div>
<div class="row">
Название
<input type="text" class="form-control" id ='modal2_nazvanie' placeholder="Название">
</div>
<div class="row">
Количество
<input type="text" class="form-control" id ='modal2_kolvo' placeholder="Количество">
</div>
<div class="row">
Балансовый счёт
<input type="text" class="form-control" id ='modal2_balance' placeholder="Балансовый счёт">
</div>
<div class="row">
Расположение
<input type="text" class="form-control" id ='modal2_rapolog' placeholder="Введите расположение">
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal" id="modal2close">Закрыть</button>
<button type="button" class="btn btn-primary" id="modal2savetodb" >Сохранить изменения</button>
</div>
</div>
</div>
</div>
</div>
<div class="row">
<h3 id ='123' class=" no-print"> Все мат. ценности </h3>
</div>
<div class="row col-12">
<button class="button" id="printallbutton"> Печать </button>
</div>
<div class="row col-12">
<button class="button" id="addoborud"> Добавить </button>
</div>
<div class="row">
<div class="card col-md-11 table-responsive">
<table id="alldatatable" class="alldatable table pagebreak" >
<thead>
<tr>
<th scope="col"><br>п/п <br>АСУ</th>
<th scope="col">№ п/п <br>вед</th>
<th scope="col">Инв. номер</th>
<th scope="col">Название</th>
<th scope="col">Кол-во</th>
<th scope="col">Счёт</th>
<th scope="col">Ауд - я</th>
<th scope="col">Расположение</th>
</tr>
</thead>
<tr>
</tr>
</table>
</div>
</div>
<script src="{{url_for('static', filename='js/allmatc.js') }}"></script>
{% endblock %}

View File

@@ -1,28 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width">
<link rel="stylesheet" href="{{ url_for('static',filename='css/bootstrap.min.css')}}">
<link rel="stylesheet" href="{{ url_for('static',filename='css/index.css')}}">
<title class="no-print">Инвентаризация</title>
</head>
{% include 'head.html' %}
<div class="row no-print">
{% include 'navbar.html' %}
</div>
<body>
{% block content %} {% endblock %}
{% include 'js.html' %}
</body>
</html>

View File

@@ -1,13 +0,0 @@
<header >
<h1>
<a href="/">Инвентаризация кафедры <br>
"Автоматизированные системы управления"</br> </a>
</h1>
<h2>
распределение мат. ценностей
</h2>
</header>

View File

@@ -1,84 +0,0 @@
{% extends 'base.html' %}
{% block content %}
<div class="row">
<div class="card col-md-6 col-10" >
<div class="card-body">
<form method="POST" action="/">
<input type="text" name="srch" placeholder="инвентарный номер">
<button> Найти </button>
</form>
</div>
</div>
</div>
<div class="row">
<div class="card col-md-10 col-10">
<div class="card-body">
<h3 class="card-title"> Нераспределённые </h3>
<form method="POST" action="/addoborudtodb">
<table class="table" name="table" col-md-10>
<thead>
<tr>
<th scope="col">Инв. номер</th>
<th scope="col">Название</th>
<th scope="col">Аудитория</th>
</tr>
{% for item in results: %}
<tr>
<td> <input type="hidden" name="invnomer" value="{{ item[0] }}"> {{ item[0] }} </td>
<td> {{ item[1] }} </td>
<td> <select name="auditory" id="auditory">
{% for item in aud: %}
<option name="optauditory" value="{{item.id}}">{{ item.audnazvanie }}</option>
{% endfor %}
</select>
</td>
</td>
</tr>
{% endfor %}
</table>
<button> Обновить</button>
</form>
</div>
</div>
</div>
<div class="row">
<div class="card col-md-10 col-10">
<div class="card-body">
<h3 class="card-title"> Распределённые </h3>
<table class="table" col-md-10>
<thead>
<tr>
<th scope="col">Инв. номер</th>
<th scope="col">Название</th>
<th scope="col">Аудитория исходная</th>
<th scope="col">Аудитория переноса</th>
</tr>
<td id="invnomer"> {{res1[0]}} </td>
<td id="nazvanie"> {{res1[1]}} </td>
<td id="aud"> {{res1[2]}} </td>
<td>
<select name="auditory" id="auditory_dubl">
{% for item in aud: %}
<option name="optauditory" value="{{item.id}}">{{ item.audnazvanie }}</option>
{% endfor %}
</select>
</td>
</table>
<button class="updatebtn" > Перенести </button>
</div>
</div>
</div>
{%endblock%}

View File

@@ -1,12 +0,0 @@
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://code.jquery.com/jquery-3.7.1.js"></script>
<script src="{{url_for('static', filename='js/index.js') }}"></script>
<script src="{{url_for('static', filename='js/printall.js') }}"></script>
<script src="{{url_for('static', filename='js/zametki.js') }}"></script>
<script src="{{url_for('static', filename='js/searchonaud.js') }}"></script>
<script src="{{url_for('static', filename='js/print.js') }}"></script>
<script src="{{url_for('static', filename='js/modal.js') }}"></script>

View File

@@ -1,39 +0,0 @@
<div class="row no-print">
<nav class="no-print navbar navbar-expand-lg navbar-light">
<div class="no-print container-fluid">
<button class="no-print navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent"
aria-controls="no-print navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="no-printnavbar-toggler-icon"></span>
</button>
<div class="no-print ollapse navbar-collapse" id="navbarSupportedContent">
<ul class="no-print navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item no-print">
<a class="nav-link no-print" aria-current="page" href="{{ url_for('index') }}">Главная</a>
</li>
<li class="nav-item no-print ">
<a class="nav-link no-print " aria-current="page" href="{{ url_for('alloborud') }}">Вся таблица</a>
</li>
<li class="nav-item no-print">
<a class="nav-link no-print" href="{{url_for('searchonaud')}}">Поаудиторно</a>
</li>
<li class="nav-item no-print">
<a class="nav-link no-print" aria-current="page" href="{{url_for('addAud')}}">Добавить аудиторию</a>
</li>
<li class="nav-item no-print">
<a class="nav-link no-print" aria-current="page" href="{{url_for('vneaud')}}">Не распределено</a>
</li>
<li class="nav-item no-print">
<a class="nav-link no-print" aria-current="page" href="{{url_for('zametki')}}">Заметки</a>
</li>
</ul>
</div>
</div>
</nav>
</div>

View File

@@ -1,49 +0,0 @@
{% extends 'base.html' %}
{% block content %}
<div class="row">
<div class="card col-md-10 col-10">
<div class="card-body">
<form method="POST" action="/search">
<input type="text" name="srch" placeholder="инвентарный номер">
<button> Найти </button>
</form>
</div>
</div>
</div>
<div class="row">
<div class="card col-md-10 col-10">
<div class="card-body">
<h3 class="card-title"> Распределённые </h3>
<form method="POST" action="/updateduplicate">
<table class="table" col-md-10>
<thead>
<tr>
<th scope="col">Инв. номер</th>
<th scope="col">Название</th>
</tr>
</tr>
<td> {{res1[0]}} </td>
</table>
</form>
</div>
</div>
</div>
{%endblock%}

View File

@@ -1,59 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="{{ url_for('static',filename='css/bootstrap.min.css')}}">
<link rel="stylesheet" href="{{ url_for('static',filename='css/index.css')}}">
<title>Инвентаризация</title>
</head>
<body>
<header>
<h1>
Инвентаризация каф АСУ
</h1>
<h2>
Аудитория
</h2>
</header>
<div class="row">
<div class="card col-md-10 col-10">
<div class="card-body">
<h3 class="card-title"> Поаудиторно </h3>
<form method="POST" action="/addoborudtodb">
<table class="table" id="datatable" col-md-10>
<thead>
<tr>
<th scope="col">Инв. номер</th>
<th scope="col">Название</th>
<th scope="col">Аудитория</th>
</tr>
{% for item in res: %}
<tr>
<td> <input type="hidden" name="invnomer" value="{{ item[0] }}"> {{ item[0] }} </td>
<td> {{ item[1] }} </td>
<td>
{{item[2]}}
</td>
</td>
</tr>
{% endfor %}
</table>
</form>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@@ -1,74 +0,0 @@
{% extends 'base.html' %}
{% block content %}
<!-- Modal -->
<div class="modal fade" id="getmodal" tabindex="-1" role="dialog" aria-labelledby="exampleModalCenterTitle" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-body" id="textarea">
<input type="text" class="form-control" id ='vednumber' placeholder="Номер из веломости">
<input type="text" class="form-control" id ='rapolog' placeholder="Введите расположение">
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal" id="modalclose">Закрыть</button>
<button type="button" class="btn btn-primary" id="modalsavetodb" >Сохранить изменения</button>
</div>
</div>
</div>
</div>
<div class="row no-print">
<div class="card col-md-10 col-10">
<div class="card-body">
<select name="auditory" id="auditory">
{% for item in aud: %}
<option name="optauditory" value="{{item.id}}">{{ item.audnazvanie }}</option>
{% endfor %}
</select>
<button id="searchbutton"> Найти </button>
<button id="printbutton"> Печать </button>
</div>
</div>
</div>
<div class="row">
<div class="card col-md-10 col-10">
<div class="card-body">
<h3 class="card-title no-print"> Поаудиторно </h3>
<table class="table" id="datatable" col-md-10>
<td >Номер в Инв. вед</td>
<td >Инв. номер</td>
<td >Название</td>
<td class="no-print">Аудитория</td>
<td >Расположение</td>
{% for item in res: %}
<td> <input type="hidden" name="invnomer" value="{{ item[0] }}"> {{ item[0] }} </td>
<td> {{ item[1] }} </td>
<td class="no-print"> {{item[2]}} </td>
<td id="proverka"> Проверено </td>
{% endfor %}
</table>
</div>
</div>
</div>
</div>
{%endblock%}

View File

@@ -1,34 +0,0 @@
{% extends 'base.html' %}
{% block content %}
<div class="row">
<div class="card col-md-10 col-10">
<div class="card-body">
<form>
<h3 class="card-title"> Не распределено {{ kolvo }} штук из {{ all_kol }} </h3>
<table class="table" col-md-10>
<thead>
<tr>
<th scope="col">Инв. номер</th>
<th scope="col">Название</th>
</tr>
{% for item in res: %}
<tr>
<td> <input type="hidden" name="invnomer" value="{{ item[0] }}"> {{ item[0] }} </td>
<td> {{ item[1] }} </td>
<td> {{ item[2] }} </td>
<td> {{ item[3] }} </td>
</tr>
{% endfor %}
</table>
</form>
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,39 +0,0 @@
{% extends 'base.html' %}
{% block content %}
<div class="row">
<div class="card col-md-8 col-10">
<div class="card-body">
<h3 class="card-title"> Добавление заметки </h3>
<form method="POST" action="/zametki">
<textarea id="textzam" name="textzam" class="col-6"></textarea>
<div class="row">
<button> Добавить </button>
</div>
</form>
</div>
</div>
</div>
<div id="zambody" class="container col-12">
{% for item in zam: %}
<div class="row" id="{{ item.id }}">
<div class="card col-md-6 col-10">
<div class="card-body">
{{ item.txtzam }}
</div>
{{ item.created_date }}
<div class="row">
<button id="{{ item.id }}" class="reshbtn col-4"> Решено </button>
</div>
</div>
</div>
{% endfor %}
</div>
{%endblock%}