|
|
|
|
|
from flask import Flask, render_template_string, request, redirect, url_for, jsonify, flash, send_from_directory |
|
|
import json |
|
|
import os |
|
|
import logging |
|
|
import threading |
|
|
import time |
|
|
from datetime import datetime, timedelta, date |
|
|
import pytz |
|
|
from huggingface_hub import HfApi, hf_hub_download |
|
|
from huggingface_hub.utils import RepositoryNotFoundError, HfHubHTTPError |
|
|
from werkzeug.utils import secure_filename |
|
|
import uuid |
|
|
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP |
|
|
import functools |
|
|
from PIL import Image |
|
|
import io |
|
|
|
|
|
app = Flask(__name__) |
|
|
app.secret_key = os.urandom(24) |
|
|
DATA_FILE = 'data.json' |
|
|
CLIENT_DATA_FILE = 'clients.json' |
|
|
UPLOAD_FOLDER = 'uploads' |
|
|
THUMBNAIL_FOLDER = os.path.join(UPLOAD_FOLDER, 'thumbnails') |
|
|
os.makedirs(UPLOAD_FOLDER, exist_ok=True) |
|
|
os.makedirs(THUMBNAIL_FOLDER, exist_ok=True) |
|
|
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER |
|
|
app.config['THUMBNAIL_FOLDER'] = THUMBNAIL_FOLDER |
|
|
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 |
|
|
|
|
|
HF_TOKEN_WRITE = os.getenv("HF_TOKEN_WRITE", "YOUR_WRITE_TOKEN_HERE") |
|
|
HF_TOKEN_READ = os.getenv("HF_TOKEN_READ", "YOUR_READ_TOKEN_HERE") |
|
|
REPO_ID = "Kgshop/baseansuna" |
|
|
|
|
|
BISHKEK_TZ = pytz.timezone('Asia/Bishkek') |
|
|
|
|
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') |
|
|
|
|
|
data_lock = threading.Lock() |
|
|
client_data_lock = threading.Lock() |
|
|
|
|
|
def get_current_time(): |
|
|
return datetime.now(BISHKEK_TZ) |
|
|
|
|
|
def load_data(): |
|
|
with data_lock: |
|
|
try: |
|
|
logging.info(f"Attempting download of {DATA_FILE} from {REPO_ID}...") |
|
|
hf_hub_download( |
|
|
repo_id=REPO_ID, filename=DATA_FILE, repo_type="dataset", token=HF_TOKEN_READ, |
|
|
local_dir=".", local_dir_use_symlinks=False, force_download=True, |
|
|
) |
|
|
logging.info(f"{DATA_FILE} downloaded successfully from Hugging Face.") |
|
|
except RepositoryNotFoundError: |
|
|
logging.warning(f"Repository {REPO_ID} not found. Checking local {DATA_FILE}.") |
|
|
except HfHubHTTPError as e: |
|
|
if e.response.status_code == 404: |
|
|
logging.warning(f"File {DATA_FILE} not found in repository {REPO_ID}. Checking local file.") |
|
|
else: |
|
|
logging.error(f"HTTP error downloading {DATA_FILE} from Hugging Face: {e}") |
|
|
except Exception as e: |
|
|
logging.error(f"Unknown error downloading {DATA_FILE} from Hugging Face: {e}") |
|
|
|
|
|
try: |
|
|
with open(DATA_FILE, 'r', encoding='utf-8') as file: |
|
|
data = json.load(file) |
|
|
logging.info("Main data loaded successfully from local JSON.") |
|
|
if not isinstance(data, dict): |
|
|
logging.warning(f"{DATA_FILE} is not a dictionary, initializing empty structure.") |
|
|
return initialize_data_structure() |
|
|
default_data = initialize_data_structure() |
|
|
changed = False |
|
|
for key in default_data.keys(): |
|
|
if key not in data: |
|
|
logging.warning(f"Key '{key}' missing in {DATA_FILE}. Initializing with default.") |
|
|
data[key] = default_data[key] |
|
|
changed = True |
|
|
elif not isinstance(data[key], type(default_data[key])): |
|
|
logging.warning(f"Key '{key}' in {DATA_FILE} has wrong type ({type(data[key])} instead of {type(default_data[key])}). Initializing with default.") |
|
|
data[key] = default_data[key] |
|
|
changed = True |
|
|
|
|
|
if 'config' not in data or not isinstance(data['config'], dict): |
|
|
logging.warning(f"'config' key missing or invalid in {DATA_FILE}. Initializing with default.") |
|
|
data['config'] = default_data['config'] |
|
|
changed = True |
|
|
else: |
|
|
for config_key, default_value in default_data['config'].items(): |
|
|
if config_key not in data['config']: |
|
|
logging.warning(f"Config key '{config_key}' missing in {DATA_FILE}['config']. Initializing with default.") |
|
|
data['config'][config_key] = default_value |
|
|
changed = True |
|
|
elif not isinstance(data['config'][config_key], str): |
|
|
logging.warning(f"Config key '{config_key}' in {DATA_FILE}['config'] has wrong type ({type(data['config'][config_key])} instead of str). Attempting conversion to string.") |
|
|
try: |
|
|
data['config'][config_key] = str(data['config'][config_key]) |
|
|
changed = True |
|
|
except Exception: |
|
|
logging.error(f"Failed to convert config value '{config_key}' to string. Setting default.") |
|
|
data['config'][config_key] = default_value |
|
|
changed = True |
|
|
|
|
|
if changed: |
|
|
logging.info(f"Data structure of {DATA_FILE} updated on load. Saving changes...") |
|
|
save_data(data) |
|
|
|
|
|
return data |
|
|
except FileNotFoundError: |
|
|
logging.warning(f"Local file {DATA_FILE} not found. Initializing empty structure.") |
|
|
return initialize_data_structure() |
|
|
except json.JSONDecodeError: |
|
|
logging.error(f"JSON decode error in {DATA_FILE}. Initializing empty structure.") |
|
|
try: |
|
|
bad_file_path = f"{DATA_FILE}.{get_current_time().strftime('%Y%m%d%H%M%S')}.bad" |
|
|
os.rename(DATA_FILE, bad_file_path) |
|
|
logging.info(f"Corrupted file {DATA_FILE} renamed to {bad_file_path}") |
|
|
except Exception as backup_err: |
|
|
logging.error(f"Failed to backup corrupted file {DATA_FILE}: {backup_err}") |
|
|
return initialize_data_structure() |
|
|
except Exception as e: |
|
|
logging.error(f"Unknown error loading local main data: {e}", exc_info=True) |
|
|
return initialize_data_structure() |
|
|
|
|
|
def save_data(data): |
|
|
try: |
|
|
temp_file = DATA_FILE + ".tmp" |
|
|
with open(temp_file, 'w', encoding='utf-8') as file: |
|
|
json.dump(data, file, ensure_ascii=False, indent=4, cls=DecimalEncoder) |
|
|
os.replace(temp_file, DATA_FILE) |
|
|
logging.info(f"Main data successfully saved to local file {DATA_FILE}.") |
|
|
except Exception as e: |
|
|
logging.error(f"Critical error saving main data: {e}", exc_info=True) |
|
|
if os.path.exists(temp_file): |
|
|
try: os.remove(temp_file) |
|
|
except OSError as rm_err: logging.error(f"Failed to remove temporary file {temp_file}: {rm_err}") |
|
|
|
|
|
def load_client_data(): |
|
|
with client_data_lock: |
|
|
try: |
|
|
logging.info(f"Attempting download of {CLIENT_DATA_FILE} from {REPO_ID}...") |
|
|
hf_hub_download( |
|
|
repo_id=REPO_ID, filename=CLIENT_DATA_FILE, repo_type="dataset", token=HF_TOKEN_READ, |
|
|
local_dir=".", local_dir_use_symlinks=False, force_download=True, |
|
|
) |
|
|
logging.info(f"{CLIENT_DATA_FILE} downloaded successfully from Hugging Face.") |
|
|
except RepositoryNotFoundError: logging.warning(f"Repository {REPO_ID} not found. Checking local {CLIENT_DATA_FILE}.") |
|
|
except HfHubHTTPError as e: |
|
|
if e.response.status_code == 404: logging.warning(f"File {CLIENT_DATA_FILE} not found in repository {REPO_ID}. Checking local file.") |
|
|
else: logging.error(f"HTTP error downloading {CLIENT_DATA_FILE} from Hugging Face: {e}") |
|
|
except Exception as e: logging.error(f"Unknown error downloading {CLIENT_DATA_FILE} from Hugging Face: {e}") |
|
|
|
|
|
try: |
|
|
with open(CLIENT_DATA_FILE, 'r', encoding='utf-8') as file: |
|
|
clients = json.load(file) |
|
|
logging.info("Client data loaded successfully from local JSON.") |
|
|
if not isinstance(clients, list): |
|
|
logging.warning(f"{CLIENT_DATA_FILE} is not a list, initializing empty list.") |
|
|
return [] |
|
|
valid_clients = [] |
|
|
changed = False |
|
|
for client in clients: |
|
|
if isinstance(client, dict) and 'id' in client and 'name' in client: |
|
|
client_changed = False |
|
|
if 'history' not in client or not isinstance(client.get('history'), list): |
|
|
logging.warning(f"Incorrect 'history' format for client {client.get('id')} found on load. Initialized as empty list.") |
|
|
client['history'] = [] |
|
|
client_changed = True |
|
|
else: |
|
|
valid_history = [] |
|
|
history_changed = False |
|
|
for record in client['history']: |
|
|
if isinstance(record, dict) and 'timestamp' in record: |
|
|
record_changed = False |
|
|
if 'items' not in record or not isinstance(record.get('items'), list): |
|
|
logging.warning(f"Incorrect 'items' format in history record for client {client.get('id')}, shipment {record.get('shipment_id', 'N/A')}. Initialized as empty list.") |
|
|
record['items'] = [] |
|
|
record_changed = True |
|
|
valid_history.append(record) |
|
|
if record_changed: history_changed = True |
|
|
else: |
|
|
logging.warning(f"Invalid history record found for client {client.get('id')}. Skipped: {record}") |
|
|
history_changed = True |
|
|
if history_changed: |
|
|
client['history'] = valid_history |
|
|
client_changed = True |
|
|
|
|
|
valid_clients.append(client) |
|
|
if client_changed: changed = True |
|
|
else: |
|
|
logging.warning(f"Invalid client record found in {CLIENT_DATA_FILE}. Skipped: {client}") |
|
|
changed = True |
|
|
|
|
|
if changed: |
|
|
logging.info(f"Client data structure in {CLIENT_DATA_FILE} updated on load. Saving changes...") |
|
|
save_client_data(valid_clients) |
|
|
|
|
|
return valid_clients |
|
|
except FileNotFoundError: logging.warning(f"Local file {CLIENT_DATA_FILE} not found. Initializing empty list."); return [] |
|
|
except json.JSONDecodeError: |
|
|
logging.error(f"JSON decode error in {CLIENT_DATA_FILE}. Initializing empty list.") |
|
|
try: |
|
|
bad_file_path = f"{CLIENT_DATA_FILE}.{get_current_time().strftime('%Y%m%d%H%M%S')}.bad" |
|
|
os.rename(CLIENT_DATA_FILE, bad_file_path) |
|
|
logging.info(f"Corrupted file {CLIENT_DATA_FILE} renamed to {bad_file_path}") |
|
|
except Exception as backup_err: |
|
|
logging.error(f"Failed to backup corrupted file {CLIENT_DATA_FILE}: {backup_err}") |
|
|
return [] |
|
|
except Exception as e: logging.error(f"Unknown error loading local client data: {e}", exc_info=True); return [] |
|
|
|
|
|
def save_client_data(clients): |
|
|
if not isinstance(clients, list): |
|
|
logging.error(f"Attempt to save non-list as {CLIENT_DATA_FILE}. Operation cancelled.") |
|
|
return |
|
|
for i, client in enumerate(clients): |
|
|
if not isinstance(client, dict) or 'id' not in client: |
|
|
logging.error(f"Attempt to save invalid client object at index {i} in {CLIENT_DATA_FILE}. Operation cancelled.") |
|
|
return |
|
|
if 'history' in client and not isinstance(client['history'], list): |
|
|
logging.error(f"Attempt to save invalid history (not a list) for client {client.get('id')} in {CLIENT_DATA_FILE}. Operation cancelled.") |
|
|
return |
|
|
if 'history' in client and isinstance(client['history'], list): |
|
|
for j, record in enumerate(client['history']): |
|
|
if not isinstance(record, dict): |
|
|
logging.error(f"Attempt to save invalid history record (not a dict) at index {j} for client {client.get('id')} in {CLIENT_DATA_FILE}. Operation cancelled.") |
|
|
return |
|
|
if 'items' in record and not isinstance(record['items'], list): |
|
|
logging.error(f"Attempt to save invalid items (not a list) in history record {j} for client {client.get('id')} in {CLIENT_DATA_FILE}. Operation cancelled.") |
|
|
return |
|
|
|
|
|
try: |
|
|
temp_file = CLIENT_DATA_FILE + ".tmp" |
|
|
with open(temp_file, 'w', encoding='utf-8') as file: |
|
|
json.dump(clients, file, ensure_ascii=False, indent=4) |
|
|
os.replace(temp_file, CLIENT_DATA_FILE) |
|
|
logging.info(f"Client data successfully saved to local file {CLIENT_DATA_FILE}.") |
|
|
except Exception as e: |
|
|
logging.error(f"Critical error saving client data: {e}", exc_info=True) |
|
|
if os.path.exists(temp_file): |
|
|
try: os.remove(temp_file) |
|
|
except OSError as rm_err: logging.error(f"Failed to remove temporary file {temp_file}: {rm_err}") |
|
|
|
|
|
def initialize_data_structure(): |
|
|
return { |
|
|
'materials': [], 'categories': [], 'cutting_tasks': [], 'sewing_tasks': [], |
|
|
'qc_packing_items': [], 'defect_log': [], 'expenses': [], 'dordoi_shipments': [], |
|
|
'cloud_files': [], 'orders': [], 'advances': [], 'monthly_salaries': {}, |
|
|
'config': {'salary_cutter_per_unit': '0.00', 'salary_sewer_per_unit': '0.00', |
|
|
'salary_packer_per_unit': '0.00', 'margin_per_item': '0.00'} |
|
|
} |
|
|
|
|
|
@functools.lru_cache(maxsize=1) |
|
|
def get_hf_api(): |
|
|
if not HF_TOKEN_WRITE or HF_TOKEN_WRITE == "YOUR_WRITE_TOKEN_HERE": |
|
|
logging.warning("HF_TOKEN_WRITE not set. Uploads to Hugging Face will be disabled.") |
|
|
return None |
|
|
try: return HfApi() |
|
|
except Exception as e: logging.error(f"Error initializing HfApi: {e}"); return None |
|
|
|
|
|
def upload_db_to_hf(filepath=DATA_FILE): |
|
|
api = get_hf_api() |
|
|
if not api: logging.warning(f"HfApi not initialized. Upload of {filepath} to Hugging Face skipped."); return |
|
|
if not os.path.exists(filepath): logging.warning(f"Local file {filepath} not found. Upload to Hugging Face skipped."); return |
|
|
try: |
|
|
filename = os.path.basename(filepath) |
|
|
commit_time = get_current_time().strftime('%Y-%m-%d %H:%M:%S %Z%z') |
|
|
logging.info(f"Starting upload of {filename} to Hugging Face...") |
|
|
api.upload_file( |
|
|
path_or_fileobj=filepath, path_in_repo=filename, repo_id=REPO_ID, repo_type="dataset", |
|
|
token=HF_TOKEN_WRITE, commit_message=f"Automated backup {filename} {commit_time}", |
|
|
run_as_future=True |
|
|
) |
|
|
logging.info(f"Upload of {filename} to Hugging Face initiated.") |
|
|
except RepositoryNotFoundError: logging.error(f"Upload error: Repository {REPO_ID} not found on Hugging Face.") |
|
|
except Exception as e: logging.error(f"Error initiating upload of {filepath} to Hugging Face: {e}") |
|
|
|
|
|
def periodic_backup(): |
|
|
logging.info("Starting periodic backup thread.") |
|
|
while True: |
|
|
backup_interval = 1800 |
|
|
logging.debug(f"Periodic backup sleeping for {backup_interval} seconds...") |
|
|
time.sleep(backup_interval) |
|
|
logging.info("Starting scheduled backup...") |
|
|
try: |
|
|
if os.path.exists(DATA_FILE): |
|
|
upload_db_to_hf(DATA_FILE) |
|
|
else: |
|
|
logging.warning(f"File {DATA_FILE} not found for scheduled backup.") |
|
|
|
|
|
if os.path.exists(CLIENT_DATA_FILE): |
|
|
upload_db_to_hf(CLIENT_DATA_FILE) |
|
|
else: |
|
|
logging.warning(f"File {CLIENT_DATA_FILE} not found for scheduled backup.") |
|
|
logging.info("Scheduled backup completed.") |
|
|
except Exception as e: |
|
|
logging.error(f"Error during scheduled backup: {e}", exc_info=True) |
|
|
|
|
|
class DecimalEncoder(json.JSONEncoder): |
|
|
def default(self, obj): |
|
|
if isinstance(obj, Decimal): return str(obj) |
|
|
return json.JSONEncoder.default(self, obj) |
|
|
|
|
|
def to_decimal(value_str, default='0.00'): |
|
|
if value_str is None or value_str == '': return Decimal(default) |
|
|
try: return Decimal(str(value_str).replace(',', '.')) |
|
|
except InvalidOperation: logging.warning(f"Could not convert '{value_str}' to Decimal. Returned {default}."); return Decimal(default) |
|
|
|
|
|
def parse_iso_datetime(timestamp_str): |
|
|
if not timestamp_str: return None |
|
|
try: |
|
|
try: dt = datetime.fromisoformat(timestamp_str) |
|
|
except ValueError: |
|
|
if '.' in timestamp_str: timestamp_str = timestamp_str.split('.', 1)[0] |
|
|
dt = datetime.fromisoformat(timestamp_str) |
|
|
|
|
|
if dt.tzinfo is None or dt.tzinfo.utcoffset(dt) is None: |
|
|
return pytz.utc.localize(dt).astimezone(BISHKEK_TZ) |
|
|
else: |
|
|
return dt.astimezone(BISHKEK_TZ) |
|
|
except (ValueError, TypeError) as e: |
|
|
logging.warning(f"Failed to parse date: '{timestamp_str}'. Error: {e}") |
|
|
return None |
|
|
|
|
|
def find_item_by_id(item_id, item_list_name): |
|
|
data = load_data() |
|
|
items = data.get(item_list_name, []) |
|
|
if not isinstance(items, list): |
|
|
logging.error(f"Expected list for '{item_list_name}', but got {type(items)}. Returning None.") |
|
|
return None |
|
|
|
|
|
for item in items: |
|
|
if not isinstance(item, dict): |
|
|
logging.warning(f"Found non-dict in list '{item_list_name}': {item}. Skipped.") |
|
|
continue |
|
|
|
|
|
current_item_id = item.get('id') or item.get('log_id') or item.get('file_id') |
|
|
if current_item_id == item_id: |
|
|
item_copy = item.copy() |
|
|
|
|
|
decimal_fields = [] |
|
|
int_fields = [] |
|
|
|
|
|
try: |
|
|
if item_list_name == 'materials': |
|
|
decimal_fields = ['quantity', 'price_per_unit', 'material_per_unit'] |
|
|
int_fields = ['items_per_unit'] |
|
|
elif item_list_name == 'cutting_tasks': |
|
|
decimal_fields = ['fabric_used', 'material_cost', 'cutting_salary_cost'] |
|
|
int_fields = ['cut_items_quantity', 'sewn_quantity'] |
|
|
elif item_list_name == 'sewing_tasks': |
|
|
decimal_fields = ['fittings_cost', 'sewing_salary_cost'] |
|
|
int_fields = ['sewn_quantity', 'qc_packed_quantity', 'qc_defective_quantity'] |
|
|
if 'fittings_consumed' in item_copy and isinstance(item_copy['fittings_consumed'], list): |
|
|
for f in item_copy['fittings_consumed']: |
|
|
if isinstance(f, dict): |
|
|
f['quantity_used'] = int(to_decimal(f.get('quantity_used', '0'))) |
|
|
f['cost'] = to_decimal(f.get('cost', '0.00')) |
|
|
if 'defects_reported' in item_copy and isinstance(item_copy['defects_reported'], list): |
|
|
for d in item_copy['defects_reported']: |
|
|
if isinstance(d, dict): |
|
|
qty_str = d.get('quantity', '0') |
|
|
defect_type = d.get('type') |
|
|
d['cost'] = to_decimal(d.get('cost', '0.00')) |
|
|
if defect_type == 'fabric': |
|
|
d['quantity'] = to_decimal(qty_str) |
|
|
elif defect_type in ['fittings', 'finished_product']: |
|
|
try: d['quantity'] = int(to_decimal(qty_str)) |
|
|
except (InvalidOperation, ValueError): d['quantity'] = 0 |
|
|
else: d['quantity'] = 0 |
|
|
elif item_list_name == 'qc_packing_items': |
|
|
decimal_fields = ['packed_material_cost', 'packed_salary_cost', 'packed_total_cost', 'packed_margin', 'packed_final_price'] |
|
|
int_fields = ['quantity'] |
|
|
elif item_list_name == 'expenses': |
|
|
decimal_fields = ['amount'] |
|
|
elif item_list_name == 'defect_log': |
|
|
decimal_fields = ['cost'] |
|
|
qty_str = item_copy.get('quantity', '0') |
|
|
defect_type = item_copy.get('type') |
|
|
item_copy['cost_dec'] = to_decimal(item_copy.get('cost', '0.00')) |
|
|
if defect_type == 'fabric': |
|
|
qty_dec = to_decimal(qty_str) |
|
|
item_copy['quantity_view'] = f"{qty_dec:.2f}".replace('.', ',') |
|
|
item_copy['quantity_raw'] = qty_dec |
|
|
elif defect_type in ['fittings', 'finished_product']: |
|
|
try: |
|
|
qty_int = int(to_decimal(qty_str)) |
|
|
item_copy['quantity_view'] = str(qty_int) |
|
|
item_copy['quantity_raw'] = qty_int |
|
|
except (InvalidOperation, ValueError): |
|
|
item_copy['quantity_view'] = '0'; item_copy['quantity_raw'] = 0 |
|
|
else: |
|
|
item_copy['quantity_view'] = str(qty_str); item_copy['quantity_raw'] = qty_str |
|
|
|
|
|
for field in decimal_fields: |
|
|
if item_copy.get(field) is not None: |
|
|
item_copy[field] = to_decimal(item_copy.get(field)) |
|
|
else: |
|
|
item_copy[field] = Decimal('0.00') |
|
|
logging.debug(f"Decimal field '{field}' missing in {item_list_name} ID {item_id}. Set to '0.00'.") |
|
|
for field in int_fields: |
|
|
if item_copy.get(field) is not None: |
|
|
item_copy[field] = int(to_decimal(item_copy.get(field, '0'))) |
|
|
else: |
|
|
item_copy[field] = 0 |
|
|
logging.debug(f"Int field '{field}' missing in {item_list_name} ID {item_id}. Set to 0.") |
|
|
|
|
|
except Exception as conversion_error: |
|
|
logging.error(f"Type conversion error for {item_list_name} ID {item_id}: {conversion_error}", exc_info=True) |
|
|
return None |
|
|
|
|
|
return item_copy |
|
|
return None |
|
|
|
|
|
def find_client_by_id(client_id): |
|
|
clients = load_client_data() |
|
|
for client in clients: |
|
|
if client.get('id') == client_id: |
|
|
client_copy = client.copy() |
|
|
if 'history' in client_copy: |
|
|
for record in client_copy['history']: |
|
|
record['timestamp_dt'] = parse_iso_datetime(record.get('timestamp')) |
|
|
return client_copy |
|
|
return None |
|
|
|
|
|
def create_thumbnail(image_path, thumb_path, size=(100, 100)): |
|
|
try: |
|
|
with Image.open(image_path) as img: |
|
|
img.thumbnail(size) |
|
|
try: |
|
|
img.save(thumb_path, "JPEG") |
|
|
logging.info(f"Thumbnail created: {thumb_path}") |
|
|
return os.path.basename(thumb_path) |
|
|
except OSError as e: |
|
|
try: |
|
|
img.save(thumb_path, "PNG") |
|
|
logging.info(f"Thumbnail created (PNG): {thumb_path}") |
|
|
return os.path.basename(thumb_path) |
|
|
except Exception as png_e: |
|
|
logging.error(f"Failed to save thumbnail as PNG for {image_path}: {png_e}") |
|
|
return None |
|
|
except Exception as e: |
|
|
logging.error(f"Failed to save thumbnail as JPEG for {image_path}: {e}") |
|
|
return None |
|
|
|
|
|
except Exception as e: |
|
|
logging.error(f"Error creating thumbnail for {image_path}: {e}") |
|
|
return None |
|
|
|
|
|
def format_currency_py(value): |
|
|
try: |
|
|
number = to_decimal(value) |
|
|
formatted_num = f"{number:,.2f}".replace(",", "TEMP_SPACE").replace(".", ",").replace("TEMP_SPACE", " ") |
|
|
return formatted_num |
|
|
except (InvalidOperation, TypeError, ValueError): |
|
|
return "0,00" |
|
|
|
|
|
def format_integer_py(value): |
|
|
try: |
|
|
number = to_decimal(value).to_integral_value(rounding=ROUND_HALF_UP) |
|
|
return f"{number:,}".replace(",", " ") |
|
|
except (InvalidOperation, TypeError, ValueError): |
|
|
return "0" |
|
|
|
|
|
def getStatusText(statusKey): |
|
|
statusMap = { |
|
|
'pending': 'Ожидает пошива', |
|
|
'completed': 'Завершено', |
|
|
'pending_qc': 'Ожидает ОТК', |
|
|
'packed_ready_to_ship': 'Готово к отправке', |
|
|
'shipped_client': 'Отправлено клиенту', |
|
|
'shipped_dor_doi': 'Отправлено на Дордой', |
|
|
'pending_procurement': 'Ожидает закупа' |
|
|
} |
|
|
return statusMap.get(statusKey, statusKey) |
|
|
|
|
|
def getStatusClass(statusKey): |
|
|
classMap = { |
|
|
'pending': 'status-pending', |
|
|
'completed': 'status-completed', |
|
|
'pending_qc': 'status-pending-qc', |
|
|
'packed_ready_to_ship': 'status-ready-ship', |
|
|
'shipped_client': 'status-shipped-client', |
|
|
'shipped_dor_doi': 'status-shipped-dordoi', |
|
|
'pending_procurement': 'status-pending-procurement' |
|
|
} |
|
|
return classMap.get(statusKey, '') |
|
|
|
|
|
@app.route('/') |
|
|
def index(): |
|
|
return redirect(url_for('admin_panel')) |
|
|
|
|
|
@app.route('/orders', methods=['GET', 'POST']) |
|
|
def orders(): |
|
|
data = load_data() |
|
|
clients_data = load_client_data() |
|
|
|
|
|
fabrics = [m for m in data.get('materials', []) |
|
|
if isinstance(m, dict) and m.get('type') == 'fabric'] |
|
|
fittings = [m for m in data.get('materials', []) |
|
|
if isinstance(m, dict) and m.get('type') == 'fittings'] |
|
|
|
|
|
if request.method == 'POST': |
|
|
try: |
|
|
client_id = request.form.get('client_id') |
|
|
model_name = request.form.get('model_name', '').strip() |
|
|
fabric_name = request.form.get('fabric_name', '').strip() |
|
|
fabric_quantity = request.form.get('fabric_quantity') |
|
|
size_range = request.form.get('size_range', '').strip() |
|
|
items_quantity = request.form.get('items_quantity') |
|
|
prepayment = request.form.get('prepayment', '0') |
|
|
|
|
|
if not all([client_id, model_name, fabric_name, fabric_quantity, items_quantity]): |
|
|
flash("Заполните все обязательные поля заказа.", "danger") |
|
|
return redirect(url_for('orders')) |
|
|
|
|
|
client = find_client_by_id(client_id) |
|
|
if not client: |
|
|
flash("Выбранный клиент не найден.", "danger") |
|
|
return redirect(url_for('orders')) |
|
|
|
|
|
creation_time = get_current_time().isoformat() |
|
|
new_order = { |
|
|
'id': uuid.uuid4().hex, |
|
|
'client_id': client_id, |
|
|
'client_name': client.get('name', 'N/A'), |
|
|
'model_name': model_name, |
|
|
'fabric_name': fabric_name, |
|
|
'fabric_quantity': fabric_quantity, |
|
|
'size_range': size_range, |
|
|
'items_quantity': items_quantity, |
|
|
'prepayment': str(to_decimal(prepayment)), |
|
|
'status': 'pending_procurement', |
|
|
'timestamp_created': creation_time, |
|
|
'timestamp': creation_time, |
|
|
'is_procured': False, |
|
|
'fittings': [] |
|
|
} |
|
|
|
|
|
fitting_names = request.form.getlist('fitting_names[]') |
|
|
fitting_quantities = request.form.getlist('fitting_quantities[]') |
|
|
|
|
|
for i in range(len(fitting_names)): |
|
|
name = fitting_names[i].strip() |
|
|
qty = fitting_quantities[i].strip() |
|
|
if name and qty: |
|
|
new_order['fittings'].append({ |
|
|
'fitting_name': name, |
|
|
'quantity': qty |
|
|
}) |
|
|
|
|
|
if 'orders' not in data: |
|
|
data['orders'] = [] |
|
|
|
|
|
data['orders'].append(new_order) |
|
|
save_data(data) |
|
|
|
|
|
flash(f"Заказ на {items_quantity} ед. '{model_name}' успешно создан.", "success") |
|
|
upload_db_to_hf(DATA_FILE) |
|
|
return redirect(url_for('orders')) |
|
|
|
|
|
except Exception as e: |
|
|
logging.error(f"Ошибка при создании заказа: {e}", exc_info=True) |
|
|
flash(f"Произошла ошибка при создании заказа: {e}", "danger") |
|
|
return redirect(url_for('orders')) |
|
|
|
|
|
orders_list = data.get('orders', []) |
|
|
for order in orders_list: |
|
|
if isinstance(order, dict): |
|
|
fabric = find_item_by_id(order.get('fabric_id'), 'materials') if order.get('fabric_id') else None |
|
|
if fabric: |
|
|
order['fabric_unit'] = fabric.get('unit', 'м') |
|
|
else: |
|
|
order['fabric_unit'] = 'м' |
|
|
|
|
|
if 'fittings' in order and isinstance(order['fittings'], list): |
|
|
for f in order['fittings']: |
|
|
if isinstance(f, dict) and f.get('fitting_id'): |
|
|
fitting = find_item_by_id(f.get('fitting_id'), 'materials') |
|
|
if fitting: |
|
|
f['fitting_name'] = fitting.get('name', 'N/A') |
|
|
elif not isinstance(f, dict) or not f.get('fitting_name'): |
|
|
f['fitting_name'] = f.get('fitting_name', 'N/A') |
|
|
|
|
|
for order in orders_list: |
|
|
if isinstance(order, dict) and not order.get('timestamp_created'): |
|
|
order['timestamp_created'] = order.get('timestamp', '') |
|
|
|
|
|
orders_list.sort(key=lambda x: x.get('timestamp_created', ''), reverse=True) |
|
|
|
|
|
html = BASE_TEMPLATE.replace('__TITLE__', "Заказы").replace('__CONTENT__', ORDERS_CONTENT).replace('__SCRIPTS__', ORDERS_SCRIPTS) |
|
|
return render_template_string(html, |
|
|
clients=clients_data, |
|
|
fabrics=fabrics, |
|
|
fittings=fittings, |
|
|
orders=orders_list, |
|
|
getStatusText=getStatusText, |
|
|
getStatusClass=getStatusClass) |
|
|
|
|
|
@app.route('/orders/edit/<order_id>', methods=['POST']) |
|
|
def edit_order(order_id): |
|
|
data = load_data() |
|
|
orders = data.get('orders', []) |
|
|
order_index = None |
|
|
for i, order in enumerate(orders): |
|
|
if isinstance(order, dict) and order.get('id') == order_id: |
|
|
order_index = i |
|
|
break |
|
|
|
|
|
if order_index is None: |
|
|
flash("Заказ не найден.", "danger") |
|
|
return redirect(url_for('orders')) |
|
|
|
|
|
try: |
|
|
orders[order_index].update({ |
|
|
'model_name': request.form.get('model_name', '').strip(), |
|
|
'fabric_name': request.form.get('fabric_name', '').strip(), |
|
|
'fabric_quantity': request.form.get('fabric_quantity'), |
|
|
'size_range': request.form.get('size_range', '').strip(), |
|
|
'items_quantity': request.form.get('items_quantity'), |
|
|
'prepayment': str(to_decimal(request.form.get('prepayment', '0'))) |
|
|
}) |
|
|
|
|
|
fitting_names = request.form.getlist('fitting_names[]') |
|
|
fitting_quantities = request.form.getlist('fitting_quantities[]') |
|
|
orders[order_index]['fittings'] = [] |
|
|
for i in range(len(fitting_names)): |
|
|
name = fitting_names[i].strip() |
|
|
qty = fitting_quantities[i].strip() |
|
|
if name and qty: |
|
|
orders[order_index]['fittings'].append({ |
|
|
'fitting_name': name, |
|
|
'quantity': qty |
|
|
}) |
|
|
|
|
|
save_data(data) |
|
|
flash("Заказ успешно обновлен.", "success") |
|
|
upload_db_to_hf(DATA_FILE) |
|
|
|
|
|
except Exception as e: |
|
|
logging.error(f"Ошибка при обновлении заказа {order_id}: {e}", exc_info=True) |
|
|
flash(f"Произошла ошибка при обновлении заказа: {e}", "danger") |
|
|
|
|
|
return redirect(url_for('orders')) |
|
|
|
|
|
@app.route('/orders/delete/<order_id>', methods=['POST']) |
|
|
def delete_order(order_id): |
|
|
data = load_data() |
|
|
orders = data.get('orders', []) |
|
|
initial_length = len(orders) |
|
|
deleted_order = next((o for o in orders if isinstance(o, dict) and o.get('id') == order_id), None) |
|
|
|
|
|
if deleted_order and deleted_order.get('status') == 'completed': |
|
|
flash("Нельзя удалить выполненный заказ.", "danger") |
|
|
return redirect(url_for('orders')) |
|
|
|
|
|
updated_orders = [order for order in orders if not (isinstance(order, dict) and order.get('id') == order_id)] |
|
|
|
|
|
if len(updated_orders) < initial_length: |
|
|
data['orders'] = updated_orders |
|
|
save_data(data) |
|
|
flash("Заказ успешно удален.", "success") |
|
|
upload_db_to_hf(DATA_FILE) |
|
|
elif deleted_order: |
|
|
flash("Нельзя удалить выполненный заказ (хотя он был найден).", "danger") |
|
|
else: |
|
|
flash("Заказ не найден.", "warning") |
|
|
|
|
|
return redirect(url_for('orders')) |
|
|
|
|
|
|
|
|
@app.route('/procurement', methods=['GET', 'POST']) |
|
|
def procurement(): |
|
|
data = load_data() |
|
|
categories = data.get('categories', []) |
|
|
pending_orders = [ |
|
|
order for order in data.get('orders', []) |
|
|
if isinstance(order, dict) and |
|
|
order.get('status') == 'pending_procurement' and |
|
|
not order.get('is_procured', False) |
|
|
] |
|
|
|
|
|
if request.method == 'POST': |
|
|
order_id = request.form.get('order_id') |
|
|
if order_id: |
|
|
order_found = False |
|
|
for order in data.get('orders', []): |
|
|
if isinstance(order, dict) and order.get('id') == order_id: |
|
|
order['is_procured'] = True |
|
|
order['status'] = 'pending' |
|
|
order['procurement_timestamp'] = get_current_time().isoformat() |
|
|
order_found = True |
|
|
break |
|
|
if order_found: |
|
|
save_data(data) |
|
|
flash(f"Заказ '{order.get('model_name')}' отмечен как закупленный и готов к раскрою.", "success") |
|
|
upload_db_to_hf(DATA_FILE) |
|
|
else: |
|
|
flash(f"Заказ с ID {order_id} не найден.", "danger") |
|
|
return redirect(url_for('procurement')) |
|
|
|
|
|
try: |
|
|
materials_to_add = [] |
|
|
valid_items_processed = 0 |
|
|
|
|
|
item_names = request.form.getlist('item_name[]') |
|
|
item_quantities = request.form.getlist('item_quantity[]') |
|
|
item_units = request.form.getlist('item_unit[]') |
|
|
item_prices = request.form.getlist('item_price_per_unit[]') |
|
|
item_material_per_unit_list = request.form.getlist('item_per_unit[]') |
|
|
item_types = request.form.getlist('item_type[]') |
|
|
item_categories = request.form.getlist('item_category[]') |
|
|
item_new_categories = request.form.getlist('item_new_category[]') |
|
|
|
|
|
if not item_names or all(not name.strip() for name in item_names): |
|
|
flash("Не добавлено ни одного товара. Заполните хотя бы одну строку.", "warning") |
|
|
return redirect(url_for('procurement')) |
|
|
|
|
|
procurement_time = get_current_time().isoformat() |
|
|
current_materials = data.get('materials', []) |
|
|
fabric_per_unit_index = 0 |
|
|
|
|
|
for i in range(len(item_names)): |
|
|
if i >= len(item_quantities) or i >= len(item_units) or \ |
|
|
i >= len(item_prices) or \ |
|
|
i >= len(item_types) or i >= len(item_categories) or \ |
|
|
i >= len(item_new_categories): |
|
|
logging.warning(f"Inconsistent form data lengths at index {i} in procurement. Skipping row.") |
|
|
flash(f"Ошибка в данных формы для строки {i+1}. Пропущено.", "danger") |
|
|
continue |
|
|
|
|
|
name = item_names[i].strip() |
|
|
quantity_str = item_quantities[i] |
|
|
unit = item_units[i] |
|
|
price_str = item_prices[i] |
|
|
item_type = item_types[i] |
|
|
category = item_categories[i] |
|
|
new_category = item_new_categories[i].strip() |
|
|
|
|
|
if not name and not quantity_str and not price_str and not category and not new_category: |
|
|
continue |
|
|
|
|
|
if not name or not quantity_str or not unit or not price_str or not item_type: |
|
|
flash(f"Ошибка в строке {i+1}: Необходимо заполнить название, количество, единицу измерения, цену за единицу и тип.", "danger") |
|
|
continue |
|
|
|
|
|
try: |
|
|
quantity = to_decimal(quantity_str) |
|
|
price = to_decimal(price_str) |
|
|
except InvalidOperation: |
|
|
flash(f"Ошибка в строке {i+1}: Некорректный формат числа для количества или цены.", "danger") |
|
|
continue |
|
|
if quantity <= 0: |
|
|
flash(f"Ошибка в строке {i+1}: Количество должно быть больше нуля.", "danger") |
|
|
continue |
|
|
if price < 0: |
|
|
flash(f"Ошибка в строке {i+1}: Цена не может быть отрицательной.", "danger") |
|
|
continue |
|
|
|
|
|
material_per_unit = Decimal('0') |
|
|
material_per_unit_str = '' |
|
|
if item_type == 'fabric': |
|
|
if fabric_per_unit_index < len(item_material_per_unit_list): |
|
|
material_per_unit_str = item_material_per_unit_list[fabric_per_unit_index] |
|
|
if material_per_unit_str: |
|
|
try: |
|
|
material_per_unit_val = to_decimal(material_per_unit_str) |
|
|
if material_per_unit_val >= 0: |
|
|
material_per_unit = material_per_unit_val |
|
|
else: |
|
|
flash(f"Предупреждение в строке {i+1}: Отрицательное значение 'Расход на одно изделие', установлено 0.", "warning") |
|
|
except InvalidOperation: |
|
|
flash(f"Предупреждение в строке {i+1}: Некорректное значение 'Расход на одно изделие', установлено 0.", "warning") |
|
|
fabric_per_unit_index += 1 |
|
|
else: |
|
|
logging.warning(f"Procurement: Missing 'item_per_unit' value for fabric row at index {i} (expected list index {fabric_per_unit_index}).") |
|
|
|
|
|
final_category = new_category if new_category else (category if category and category != "__new__" else "Без категории") |
|
|
current_valid_categories = [c for c in categories if isinstance(c, str)] |
|
|
if new_category and final_category not in current_valid_categories: |
|
|
current_valid_categories.append(final_category) |
|
|
categories = current_valid_categories |
|
|
|
|
|
existing_material_index = -1 |
|
|
for idx, mat in enumerate(current_materials): |
|
|
if isinstance(mat, dict) and \ |
|
|
mat.get('name','').lower() == name.lower() and \ |
|
|
mat.get('type') == item_type and \ |
|
|
mat.get('category', 'Без категории') == final_category: |
|
|
existing_material_index = idx |
|
|
break |
|
|
|
|
|
if existing_material_index != -1: |
|
|
existing_material = current_materials[existing_material_index] |
|
|
existing_material['price_per_unit'] = str(price) |
|
|
current_quantity = to_decimal(existing_material.get('quantity', '0')) |
|
|
new_quantity = current_quantity + quantity |
|
|
existing_material['quantity'] = str(new_quantity) |
|
|
existing_material['unit'] = unit |
|
|
existing_material['material_per_unit'] = str(material_per_unit) |
|
|
existing_material['timestamp_last_updated'] = procurement_time |
|
|
logging.info(f"Material '{name}' updated. New qty: {new_quantity}, Price: {price}, Category: {final_category}, MatPerUnit: {material_per_unit}") |
|
|
valid_items_processed += 1 |
|
|
else: |
|
|
new_material = { |
|
|
'id': uuid.uuid4().hex, |
|
|
'name': name, |
|
|
'quantity': str(quantity), |
|
|
'unit': unit, |
|
|
'price_per_unit': str(price), |
|
|
'material_per_unit': str(material_per_unit), |
|
|
'items_per_unit': 0, |
|
|
'type': item_type, |
|
|
'category': final_category, |
|
|
'timestamp_added': procurement_time, |
|
|
'timestamp_last_updated': procurement_time |
|
|
} |
|
|
materials_to_add.append(new_material) |
|
|
logging.info(f"New material '{name}' added. Qty: {quantity}, Price: {price}, Category: {final_category}, MatPerUnit: {material_per_unit}") |
|
|
valid_items_processed += 1 |
|
|
|
|
|
if valid_items_processed > 0 : |
|
|
if materials_to_add: |
|
|
data['materials'].extend(materials_to_add) |
|
|
data['categories'] = sorted(list(set(c for c in categories if isinstance(c, str))), key=str.lower) |
|
|
save_data(data) |
|
|
flash(f"Закуп успешно зарегистрирован! Обработано {valid_items_processed} позиций.", "success") |
|
|
upload_db_to_hf(DATA_FILE) |
|
|
else: |
|
|
flash("Не было добавлено или обновлено ни одной валидной позиции.", "warning") |
|
|
|
|
|
return redirect(url_for('procurement')) |
|
|
|
|
|
except Exception as e: |
|
|
logging.error(f"Ошибка при обработке закупа: {e}", exc_info=True) |
|
|
flash(f"Произошла внутренняя ошибка при обработке закупа: {e}", "danger") |
|
|
return redirect(url_for('procurement')) |
|
|
|
|
|
materials_display = [] |
|
|
for m in data.get('materials', []): |
|
|
if isinstance(m, dict) and 'id' in m: |
|
|
m_data = find_item_by_id(m['id'], 'materials') |
|
|
if m_data: |
|
|
if m_data.get('type') == 'fabric': |
|
|
m_data['quantity_str'] = format_currency_py(m_data.get('quantity', '0.00')) |
|
|
else: |
|
|
m_data['quantity_str'] = format_integer_py(m_data.get('quantity', '0')) |
|
|
m_data['price_str'] = format_currency_py(m_data.get('price_per_unit', '0.00')) |
|
|
materials_display.append(m_data) |
|
|
valid_categories = [c for c in categories if isinstance(c, str)] |
|
|
pending_orders_display = [ |
|
|
order for order in data.get('orders', []) |
|
|
if isinstance(order, dict) and |
|
|
order.get('status') == 'pending_procurement' and |
|
|
not order.get('is_procured', False) |
|
|
] |
|
|
pending_orders_display.sort(key=lambda x: x.get('timestamp_created', ''), reverse=True) |
|
|
|
|
|
html = BASE_TEMPLATE.replace('__TITLE__', "Закуп материалов").replace('__CONTENT__', PROCUREMENT_CONTENT).replace('__SCRIPTS__', PROCUREMENT_SCRIPTS) |
|
|
return render_template_string(html, categories=valid_categories, materials_display=materials_display, orders=pending_orders_display) |
|
|
|
|
|
@app.route('/cutting', methods=['GET', 'POST']) |
|
|
def cutting(): |
|
|
data = load_data() |
|
|
fabrics = [] |
|
|
for m in data.get('materials', []): |
|
|
if isinstance(m, dict) and m.get('type') == 'fabric': |
|
|
if to_decimal(m.get('quantity', '0')) > 0: |
|
|
fabrics.append(m) |
|
|
|
|
|
config = data.get('config', {}) |
|
|
|
|
|
if request.method == 'POST': |
|
|
try: |
|
|
fabric_id = request.form.get('fabric_id') |
|
|
cut_items_quantity_str = request.form.get('cut_items_quantity') |
|
|
fabric_used_str = request.form.get('fabric_used') |
|
|
|
|
|
if not fabric_id or not cut_items_quantity_str or not fabric_used_str: |
|
|
flash("Необходимо выбрать ткань и заполнить все поля.", "danger") |
|
|
return redirect(url_for('cutting')) |
|
|
|
|
|
fabric_material = find_item_by_id(fabric_id, 'materials') |
|
|
if not fabric_material: |
|
|
flash("Выбранная ткань не найдена в базе данных.", "danger") |
|
|
return redirect(url_for('cutting')) |
|
|
|
|
|
try: |
|
|
cut_items_quantity = int(to_decimal(cut_items_quantity_str).to_integral_value()) |
|
|
if cut_items_quantity <= 0: raise ValueError("Количество должно быть > 0") |
|
|
except (InvalidOperation, ValueError): |
|
|
flash("Некорректное количество раскроенных изделий. Введите целое положительное число.", "danger") |
|
|
return redirect(url_for('cutting')) |
|
|
|
|
|
try: |
|
|
fabric_used = to_decimal(fabric_used_str) |
|
|
if fabric_used <= 0: raise ValueError("Расход ткани должен быть > 0") |
|
|
except (InvalidOperation, ValueError): |
|
|
flash("Некорректное значение использованной ткани. Введите положительное число.", "danger") |
|
|
return redirect(url_for('cutting')) |
|
|
|
|
|
available_quantity = fabric_material.get('quantity', Decimal('0.00')) |
|
|
if fabric_used > available_quantity: |
|
|
flash(f"Недостаточно ткани '{fabric_material['name']}'. В наличии: {format_currency_py(available_quantity)} {fabric_material['unit']}, требуется: {format_currency_py(fabric_used)} {fabric_material['unit']}.", "danger") |
|
|
return redirect(url_for('cutting')) |
|
|
|
|
|
price_per_unit = fabric_material.get('price_per_unit', Decimal('0.00')) |
|
|
material_cost = fabric_used * price_per_unit |
|
|
|
|
|
salary_cutter_per_unit = to_decimal(config.get('salary_cutter_per_unit', '0.00')) |
|
|
cutting_salary_cost = Decimal(cut_items_quantity) * salary_cutter_per_unit |
|
|
|
|
|
creation_time = get_current_time().isoformat() |
|
|
cutting_task = { |
|
|
'id': uuid.uuid4().hex, |
|
|
'fabric_id': fabric_id, |
|
|
'fabric_name': fabric_material['name'], |
|
|
'fabric_unit': fabric_material['unit'], |
|
|
'cut_items_quantity': cut_items_quantity, |
|
|
'fabric_used': str(fabric_used), |
|
|
'status': 'pending', |
|
|
'sewn_quantity': 0, |
|
|
'timestamp_created': creation_time, |
|
|
'timestamp_completed': None, |
|
|
'material_cost': str(material_cost), |
|
|
'cutting_salary_cost': str(cutting_salary_cost) |
|
|
} |
|
|
|
|
|
new_available_quantity = available_quantity - fabric_used |
|
|
material_updated = False |
|
|
current_materials = data.get('materials', []) |
|
|
for i, mat in enumerate(current_materials): |
|
|
if isinstance(mat, dict) and mat.get('id') == fabric_id: |
|
|
current_materials[i]['quantity'] = str(new_available_quantity.quantize(Decimal('0.01'))) |
|
|
current_materials[i]['timestamp_last_updated'] = creation_time |
|
|
material_updated = True |
|
|
break |
|
|
|
|
|
if not material_updated: |
|
|
flash(f"Критическая ошибка: не удалось обновить остаток ткани '{fabric_material['name']}'.", "danger") |
|
|
return redirect(url_for('cutting')) |
|
|
|
|
|
if 'cutting_tasks' not in data: data['cutting_tasks'] = [] |
|
|
data['cutting_tasks'].append(cutting_task) |
|
|
save_data(data) |
|
|
|
|
|
flash(f"Задание на раскрой для {cut_items_quantity} ед. из '{fabric_material['name']}' успешно создано. Статус: Ожидает пошива.", "success") |
|
|
upload_db_to_hf(DATA_FILE) |
|
|
return redirect(url_for('cutting')) |
|
|
|
|
|
except Exception as e: |
|
|
logging.error(f"Ошибка при регистрации раскроя: {e}", exc_info=True) |
|
|
flash(f"Произошла внутренняя ошибка при регистрации раскроя: {e}", "danger") |
|
|
return redirect(url_for('cutting')) |
|
|
|
|
|
fabrics_display = [] |
|
|
for f in fabrics: |
|
|
if isinstance(f, dict) and 'id' in f: |
|
|
f_copy = find_item_by_id(f['id'], 'materials') |
|
|
if f_copy: |
|
|
f_copy['quantity_str'] = format_currency_py(f_copy.get('quantity', '0.00')) |
|
|
fabrics_display.append(f_copy) |
|
|
|
|
|
html = BASE_TEMPLATE.replace('__TITLE__', "Раскрой ткани").replace('__CONTENT__', CUTTING_CONTENT).replace('__SCRIPTS__', CUTTING_SCRIPTS) |
|
|
return render_template_string(html, fabrics=fabrics_display) |
|
|
|
|
|
@app.route('/sewing', methods=['GET', 'POST']) |
|
|
def sewing(): |
|
|
data = load_data() |
|
|
config = data.get('config', {}) |
|
|
|
|
|
if request.method == 'POST': |
|
|
try: |
|
|
cutting_task_id = request.form.get('cutting_task_id') |
|
|
product_name = request.form.get('product_name', '').strip() |
|
|
quantity_to_sew_str = request.form.get('quantity_to_sew') |
|
|
fitting_ids = request.form.getlist('fitting_ids[]') |
|
|
fitting_quantities = request.form.getlist('fitting_quantities[]') |
|
|
|
|
|
if not cutting_task_id or not product_name or not quantity_to_sew_str: |
|
|
flash("Необходимо выбрать задание раскроя, указать название изделия и количество для пошива.", "danger") |
|
|
return redirect(url_for('sewing')) |
|
|
|
|
|
cutting_task = find_item_by_id(cutting_task_id, 'cutting_tasks') |
|
|
if not cutting_task: |
|
|
flash("Выбранное задание на раскрой не найдено.", "danger") |
|
|
return redirect(url_for('sewing')) |
|
|
|
|
|
try: |
|
|
quantity_to_sew = int(to_decimal(quantity_to_sew_str).to_integral_value()) |
|
|
except (InvalidOperation, ValueError): |
|
|
flash("Некорректное количество для пошива.", "danger") |
|
|
return redirect(url_for('sewing')) |
|
|
|
|
|
cut_qty = cutting_task.get('cut_items_quantity', 0) |
|
|
already_sewn = cutting_task.get('sewn_quantity', 0) |
|
|
remaining_to_cut = cut_qty - already_sewn |
|
|
|
|
|
if quantity_to_sew <= 0: |
|
|
flash("Количество для пошива должно быть больше нуля.", "danger") |
|
|
return redirect(url_for('sewing')) |
|
|
if quantity_to_sew > remaining_to_cut: |
|
|
flash(f"Нельзя сшить {quantity_to_sew} ед. Доступно только {remaining_to_cut} раскроенных.", "danger") |
|
|
return redirect(url_for('sewing')) |
|
|
|
|
|
fittings_consumed = [] |
|
|
total_fittings_cost = Decimal('0.00') |
|
|
fitting_updates = {} |
|
|
|
|
|
current_materials = data.get('materials', []) |
|
|
|
|
|
for i in range(len(fitting_ids)): |
|
|
fitting_id = fitting_ids[i] |
|
|
qty_str = fitting_quantities[i].strip() |
|
|
|
|
|
if not fitting_id or not qty_str: |
|
|
continue |
|
|
|
|
|
try: |
|
|
quantity_used = int(to_decimal(qty_str).to_integral_value()) |
|
|
if quantity_used <= 0: |
|
|
flash(f"Количество для фурнитуры в строке {i+1} должно быть положительным.", "warning") |
|
|
continue |
|
|
except (InvalidOperation, ValueError): |
|
|
flash(f"Некорректное количество для фурнитуры в строке {i+1}.", "warning") |
|
|
continue |
|
|
|
|
|
fitting_material = None |
|
|
for mat in current_materials: |
|
|
if isinstance(mat, dict) and mat.get('id') == fitting_id: |
|
|
fitting_material = find_item_by_id(fitting_id, 'materials') |
|
|
break |
|
|
|
|
|
if not fitting_material: |
|
|
flash(f"Фурнитура с ID {fitting_id} (строка {i+1}) не найдена.", "danger") |
|
|
return redirect(url_for('sewing')) |
|
|
|
|
|
available_fitting_qty = fitting_material.get('quantity', Decimal('0')) |
|
|
if available_fitting_qty < quantity_used: |
|
|
flash(f"Недостаточно фурнитуры '{fitting_material.get('name')}'. В наличии: {format_integer_py(available_fitting_qty)}, требуется: {quantity_used}.", "danger") |
|
|
return redirect(url_for('sewing')) |
|
|
|
|
|
price_per_fitting = fitting_material.get('price_per_unit', Decimal('0.00')) |
|
|
cost_for_this_fitting = price_per_fitting * Decimal(quantity_used) |
|
|
total_fittings_cost += cost_for_this_fitting |
|
|
|
|
|
fittings_consumed.append({ |
|
|
'fitting_id': fitting_id, |
|
|
'fitting_name': fitting_material.get('name', 'N/A'), |
|
|
'quantity_used': quantity_used, |
|
|
'cost': str(cost_for_this_fitting) |
|
|
}) |
|
|
|
|
|
if fitting_id not in fitting_updates: |
|
|
fitting_updates[fitting_id] = {'decrease_by': 0} |
|
|
fitting_updates[fitting_id]['decrease_by'] += quantity_used |
|
|
|
|
|
sewing_time = get_current_time().isoformat() |
|
|
salary_sewer_per_unit = to_decimal(config.get('salary_sewer_per_unit', '0.00')) |
|
|
sewing_salary_cost = salary_sewer_per_unit * Decimal(quantity_to_sew) |
|
|
|
|
|
new_sewing_task = { |
|
|
'id': uuid.uuid4().hex, |
|
|
'cutting_task_id': cutting_task_id, |
|
|
'product_name': product_name, |
|
|
'sewn_quantity': quantity_to_sew, |
|
|
'fittings_consumed': fittings_consumed, |
|
|
'fittings_cost': str(total_fittings_cost), |
|
|
'sewing_salary_cost': str(sewing_salary_cost), |
|
|
'status': 'pending_qc', |
|
|
'qc_packed_quantity': 0, |
|
|
'qc_defective_quantity': 0, |
|
|
'defects_reported': [], |
|
|
'timestamp_created': sewing_time, |
|
|
'timestamp_completed': None |
|
|
} |
|
|
|
|
|
if 'sewing_tasks' not in data: data['sewing_tasks'] = [] |
|
|
data['sewing_tasks'].append(new_sewing_task) |
|
|
|
|
|
cutting_task_updated = False |
|
|
current_cutting_tasks = data.get('cutting_tasks', []) |
|
|
for i, task in enumerate(current_cutting_tasks): |
|
|
if isinstance(task, dict) and task.get('id') == cutting_task_id: |
|
|
current_cutting_tasks[i]['sewn_quantity'] = int(task.get('sewn_quantity', 0)) + quantity_to_sew |
|
|
if current_cutting_tasks[i]['sewn_quantity'] >= int(task.get('cut_items_quantity', 0)): |
|
|
current_cutting_tasks[i]['status'] = 'completed' |
|
|
current_cutting_tasks[i]['timestamp_completed'] = sewing_time |
|
|
cutting_task_updated = True |
|
|
break |
|
|
|
|
|
if not cutting_task_updated: |
|
|
flash(f"Критическая ошибка: не удалось обновить задание на раскрой {cutting_task_id}.", "danger") |
|
|
|
|
|
materials_updated = False |
|
|
for mat_idx, mat in enumerate(current_materials): |
|
|
if isinstance(mat, dict) and mat.get('id') in fitting_updates: |
|
|
fitting_id = mat.get('id') |
|
|
decrease_by = fitting_updates[fitting_id]['decrease_by'] |
|
|
current_qty = to_decimal(mat.get('quantity', '0')) |
|
|
new_qty = current_qty - Decimal(decrease_by) |
|
|
if new_qty < 0: |
|
|
flash(f"Критическая ошибка: Отрицательный остаток фурнитуры {mat.get('name')}.", "danger") |
|
|
return redirect(url_for('sewing')) |
|
|
current_materials[mat_idx]['quantity'] = str(new_qty.to_integral_value()) |
|
|
current_materials[mat_idx]['timestamp_last_updated'] = sewing_time |
|
|
materials_updated = True |
|
|
|
|
|
save_data(data) |
|
|
flash(f"{quantity_to_sew} ед. изделия '{product_name}' успешно сшито и отправлено на ОТК.", "success") |
|
|
upload_db_to_hf(DATA_FILE) |
|
|
return redirect(url_for('sewing')) |
|
|
|
|
|
except Exception as e: |
|
|
logging.error(f"Ошибка при регистрации пошива: {e}", exc_info=True) |
|
|
flash(f"Произошла внутренняя ошибка при регистрации пошива: {e}", "danger") |
|
|
return redirect(url_for('sewing')) |
|
|
|
|
|
pending_cutting_tasks_raw = [ |
|
|
t for t in data.get('cutting_tasks', []) |
|
|
if isinstance(t, dict) and t.get('status') == 'pending' and int(t.get('cut_items_quantity', 0)) > int(t.get('sewn_quantity', 0)) |
|
|
] |
|
|
pending_cutting_tasks_display = [] |
|
|
for task_raw in pending_cutting_tasks_raw: |
|
|
if 'id' in task_raw: |
|
|
task_data = find_item_by_id(task_raw['id'], 'cutting_tasks') |
|
|
if task_data: |
|
|
task_data['remaining_quantity'] = task_data.get('cut_items_quantity', 0) - task_data.get('sewn_quantity', 0) |
|
|
if task_data['remaining_quantity'] > 0: |
|
|
task_data['fabric_used_str'] = format_currency_py(task_data.get('fabric_used', '0.00')) |
|
|
pending_cutting_tasks_display.append(task_data) |
|
|
pending_cutting_tasks_display.sort(key=lambda x: x.get('timestamp_created', ''), reverse=True) |
|
|
|
|
|
available_fittings_raw = [ |
|
|
m for m in data.get('materials', []) |
|
|
if isinstance(m, dict) and m.get('type') == 'fittings' and int(to_decimal(m.get('quantity', '0'))) > 0 |
|
|
] |
|
|
available_fittings_display = [] |
|
|
for fitting_raw in available_fittings_raw: |
|
|
if 'id' in fitting_raw: |
|
|
fitting_data = find_item_by_id(fitting_raw['id'], 'materials') |
|
|
if fitting_data: |
|
|
fitting_data['quantity_str'] = format_integer_py(fitting_data.get('quantity', '0')) |
|
|
available_fittings_display.append(fitting_data) |
|
|
available_fittings_display.sort(key=lambda x: x.get('name', '').lower()) |
|
|
|
|
|
html = BASE_TEMPLATE.replace('__TITLE__', "Пошив").replace('__CONTENT__', NEW_SEWING_CONTENT).replace('__SCRIPTS__', NEW_SEWING_SCRIPTS) |
|
|
return render_template_string(html, |
|
|
cutting_tasks=pending_cutting_tasks_display, |
|
|
available_fittings=available_fittings_display) |
|
|
|
|
|
|
|
|
@app.route('/qc_packing', methods=['GET', 'POST']) |
|
|
def qc_packing(): |
|
|
data = load_data() |
|
|
pending_qc_tasks = [] |
|
|
for t in data.get('sewing_tasks', []): |
|
|
if isinstance(t, dict) and t.get('status') == 'pending_qc': |
|
|
task_data_full = find_item_by_id(t['id'], 'sewing_tasks') |
|
|
if task_data_full: |
|
|
total_sewn = task_data_full.get('sewn_quantity', 0) |
|
|
already_processed = task_data_full.get('qc_packed_quantity', 0) + task_data_full.get('qc_defective_quantity', 0) |
|
|
remaining = total_sewn - already_processed |
|
|
if remaining > 0: |
|
|
t['remaining_quantity'] = remaining |
|
|
pending_qc_tasks.append(t) |
|
|
|
|
|
config = data.get('config', {}) |
|
|
|
|
|
if request.method == 'POST': |
|
|
try: |
|
|
sewing_task_id = request.form.get('sewing_task_id') |
|
|
quantity_packed_str = request.form.get('quantity_packed') |
|
|
quantity_defective_str = request.form.get('quantity_defective', '0') |
|
|
defect_reason = request.form.get('defect_reason', 'Брак при ОТК/упаковке').strip() |
|
|
|
|
|
if not sewing_task_id: |
|
|
flash("Необходимо выбрать задание на пошив.", "danger") |
|
|
return redirect(url_for('qc_packing')) |
|
|
|
|
|
sewing_task = find_item_by_id(sewing_task_id, 'sewing_tasks') |
|
|
if not sewing_task or sewing_task.get('status') != 'pending_qc': |
|
|
flash("Выбранное задание на пошив не найдено или уже не ожидает ОТК.", "danger") |
|
|
return redirect(url_for('qc_packing')) |
|
|
|
|
|
try: |
|
|
quantity_packed = int(to_decimal(quantity_packed_str).to_integral_value()) if quantity_packed_str else 0 |
|
|
quantity_defective = int(to_decimal(quantity_defective_str).to_integral_value()) if quantity_defective_str else 0 |
|
|
|
|
|
if quantity_packed < 0 or quantity_defective < 0: |
|
|
raise ValueError("Количество не может быть отрицательным") |
|
|
|
|
|
total_processed_now = quantity_packed + quantity_defective |
|
|
if total_processed_now <= 0: |
|
|
flash("Необходимо указать количество упакованных или бракованных изделий (сумма должна быть > 0).", "warning") |
|
|
return redirect(url_for('qc_packing')) |
|
|
|
|
|
total_sewn = sewing_task.get('sewn_quantity', 0) |
|
|
already_packed = sewing_task.get('qc_packed_quantity', 0) |
|
|
already_defective = sewing_task.get('qc_defective_quantity', 0) |
|
|
remaining_to_process = total_sewn - (already_packed + already_defective) |
|
|
|
|
|
if total_processed_now > remaining_to_process: |
|
|
flash(f"Ошибка: Сумма упакованных ({quantity_packed}) и брака ({quantity_defective}) = {total_processed_now}, что превышает остаток изделий для обработки ({remaining_to_process}).", "danger") |
|
|
return redirect(url_for('qc_packing')) |
|
|
|
|
|
except (InvalidOperation, ValueError) as e: |
|
|
flash(f"Некорректное количество упакованных или бракованных изделий: {e}", "danger") |
|
|
return redirect(url_for('qc_packing')) |
|
|
|
|
|
qc_time = get_current_time().isoformat() |
|
|
new_packed_item_entry = None |
|
|
new_defect_log_entry = None |
|
|
|
|
|
cutting_task_id = sewing_task.get('cutting_task_id') |
|
|
cutting_task = find_item_by_id(cutting_task_id, 'cutting_tasks') |
|
|
|
|
|
if not cutting_task: |
|
|
logging.warning(f"Cutting task {cutting_task_id} not found for sewing task {sewing_task_id} during QC cost calculation.") |
|
|
cutting_task = {'material_cost': Decimal('0'), 'cutting_salary_cost': Decimal('0'), 'cut_items_quantity': 1} |
|
|
|
|
|
fabric_cost_total = cutting_task.get('material_cost', Decimal('0')) |
|
|
cutting_salary_total = cutting_task.get('cutting_salary_cost', Decimal('0')) |
|
|
fittings_cost_total = sewing_task.get('fittings_cost', Decimal('0')) |
|
|
sewing_salary_total = sewing_task.get('sewing_salary_cost', Decimal('0')) |
|
|
|
|
|
cut_qty = cutting_task.get('cut_items_quantity', 1) or 1 |
|
|
sewn_qty_from_task = sewing_task.get('sewn_quantity', 1) or 1 |
|
|
|
|
|
fabric_cost_per_item = fabric_cost_total / Decimal(cut_qty) |
|
|
fittings_cost_per_item = fittings_cost_total / Decimal(sewn_qty_from_task) |
|
|
material_cost_per_item = fabric_cost_per_item + fittings_cost_per_item |
|
|
|
|
|
cutting_salary_per_item = cutting_salary_total / Decimal(cut_qty) |
|
|
sewing_salary_per_item = sewing_salary_total / Decimal(sewn_qty_from_task) |
|
|
packing_salary_per_item = to_decimal(config.get('salary_packer_per_unit', '0.00')) |
|
|
|
|
|
if quantity_packed > 0: |
|
|
salary_cost_per_packed_item = cutting_salary_per_item + sewing_salary_per_item + packing_salary_per_item |
|
|
total_cost_per_packed_item = material_cost_per_item + salary_cost_per_packed_item |
|
|
|
|
|
margin_per_item = to_decimal(config.get('margin_per_item', '0.00')) |
|
|
final_price_per_packed_item = total_cost_per_packed_item + margin_per_item |
|
|
|
|
|
packed_batch_material_cost = material_cost_per_item * Decimal(quantity_packed) |
|
|
packed_batch_salary_cost = salary_cost_per_packed_item * Decimal(quantity_packed) |
|
|
packed_batch_total_cost = total_cost_per_packed_item * Decimal(quantity_packed) |
|
|
packed_batch_margin = margin_per_item * Decimal(quantity_packed) |
|
|
packed_batch_final_price = final_price_per_packed_item * Decimal(quantity_packed) |
|
|
|
|
|
new_packed_item_entry = { |
|
|
'id': uuid.uuid4().hex, |
|
|
'sewing_task_id': sewing_task_id, |
|
|
'product_name': sewing_task['product_name'], |
|
|
'quantity': quantity_packed, |
|
|
'timestamp_packed': qc_time, |
|
|
'packed_material_cost': str(packed_batch_material_cost), |
|
|
'packed_salary_cost': str(packed_batch_salary_cost), |
|
|
'packed_total_cost': str(packed_batch_total_cost), |
|
|
'packed_margin': str(packed_batch_margin), |
|
|
'packed_final_price': str(packed_batch_final_price), |
|
|
'status': 'packed_ready_to_ship', |
|
|
'shipment_details': None |
|
|
} |
|
|
|
|
|
if 'qc_packing_items' not in data: data['qc_packing_items'] = [] |
|
|
data['qc_packing_items'].append(new_packed_item_entry) |
|
|
|
|
|
if quantity_defective > 0: |
|
|
salary_cost_per_defective_item = cutting_salary_per_item + sewing_salary_per_item |
|
|
cost_per_defective_item = material_cost_per_item + salary_cost_per_defective_item |
|
|
total_defect_cost = cost_per_defective_item * Decimal(quantity_defective) |
|
|
|
|
|
new_defect_log_entry = { |
|
|
'log_id': uuid.uuid4().hex, |
|
|
'material_id': None, |
|
|
'material_name': f"{sewing_task['product_name']} (готовое изделие)", |
|
|
'quantity': quantity_defective, |
|
|
'unit': 'шт', |
|
|
'type': 'finished_product', |
|
|
'stage': 'qc_packing', |
|
|
'reason': defect_reason if defect_reason else 'Брак при ОТК/упаковке', |
|
|
'cost': str(total_defect_cost), |
|
|
'sewing_task_id': sewing_task_id, |
|
|
'timestamp': qc_time |
|
|
} |
|
|
|
|
|
if 'defect_log' not in data: data['defect_log'] = [] |
|
|
data['defect_log'].append(new_defect_log_entry) |
|
|
logging.info(f"QC defect logged: {quantity_defective} pcs of '{sewing_task['product_name']}' (Total cost: {format_currency_py(total_defect_cost)})") |
|
|
|
|
|
sewing_task_updated = False |
|
|
current_sewing_tasks = data.get('sewing_tasks', []) |
|
|
for i, task in enumerate(current_sewing_tasks): |
|
|
if isinstance(task, dict) and task.get('id') == sewing_task_id: |
|
|
current_sewing_tasks[i]['qc_packed_quantity'] = int(task.get('qc_packed_quantity', 0)) + quantity_packed |
|
|
current_sewing_tasks[i]['qc_defective_quantity'] = int(task.get('qc_defective_quantity', 0)) + quantity_defective |
|
|
|
|
|
total_processed_for_task = current_sewing_tasks[i]['qc_packed_quantity'] + current_sewing_tasks[i]['qc_defective_quantity'] |
|
|
task_sewn_qty = int(task.get('sewn_quantity', 0)) |
|
|
|
|
|
if total_processed_for_task >= task_sewn_qty: |
|
|
current_sewing_tasks[i]['status'] = 'completed' |
|
|
current_sewing_tasks[i]['timestamp_completed'] = qc_time |
|
|
logging.info(f"Sewing task {sewing_task_id} fully processed and completed.") |
|
|
else: |
|
|
current_sewing_tasks[i]['status'] = 'pending_qc' |
|
|
logging.info(f"Sewing task {sewing_task_id} partially processed. Remaining: {task_sewn_qty - total_processed_for_task}") |
|
|
|
|
|
sewing_task_updated = True |
|
|
break |
|
|
|
|
|
if not sewing_task_updated: |
|
|
logging.error(f"Critical error: Failed to find and update sewing task {sewing_task_id}.") |
|
|
flash(f"Критическая ошибка при обновлении задания на пошив {sewing_task_id}.", "danger") |
|
|
|
|
|
save_data(data) |
|
|
|
|
|
flash_message = f"ОТК/Упаковка для '{sewing_task['product_name']}': упаковано {quantity_packed} ед., брак {quantity_defective} ед. " |
|
|
if new_packed_item_entry: |
|
|
flash_message += f"Статус упакованных: Готово к отправке." |
|
|
elif new_defect_log_entry: |
|
|
flash_message += f"Брак зарегистрирован." |
|
|
|
|
|
flash(flash_message, "success") |
|
|
upload_db_to_hf(DATA_FILE) |
|
|
return redirect(url_for('qc_packing')) |
|
|
|
|
|
except Exception as e: |
|
|
logging.error(f"Ошибка при обработке ОТК и упаковки: {e}", exc_info=True) |
|
|
flash(f"Произошла внутренняя ошибка при обработке ОТК и упаковки: {e}", "danger") |
|
|
return redirect(url_for('qc_packing')) |
|
|
|
|
|
tasks_for_template = [] |
|
|
for task in pending_qc_tasks: |
|
|
if isinstance(task, dict) and 'id' in task: |
|
|
if task.get('remaining_quantity', 0) > 0: |
|
|
tasks_for_template.append(task) |
|
|
|
|
|
html = BASE_TEMPLATE.replace('__TITLE__', "ОТК и Упаковка").replace('__CONTENT__', QC_PACKING_CONTENT).replace('__SCRIPTS__', QC_PACKING_SCRIPTS) |
|
|
return render_template_string(html, sewing_tasks=tasks_for_template) |
|
|
|
|
|
|
|
|
@app.route('/clients', methods=['GET', 'POST']) |
|
|
def clients_panel(): |
|
|
if request.method == 'POST': |
|
|
action = request.form.get('action', 'add') |
|
|
clients = load_client_data() |
|
|
|
|
|
if action == 'edit': |
|
|
client_id = request.form.get('client_id') |
|
|
name = request.form.get('client_name', '').strip() |
|
|
phone = request.form.get('client_phone', '').strip() |
|
|
address = request.form.get('client_address', '').strip() |
|
|
|
|
|
if not name or not phone or not client_id: |
|
|
flash("Все обязательные поля должны быть заполнены.", "danger") |
|
|
return redirect(url_for('clients_panel')) |
|
|
|
|
|
client_found = False |
|
|
normalized_phone = ''.join(filter(str.isdigit, phone)) |
|
|
for client in clients: |
|
|
if client.get('id') == client_id: |
|
|
current_phone = ''.join(filter(str.isdigit, client.get('phone', ''))) |
|
|
if normalized_phone != current_phone: |
|
|
if any(''.join(filter(str.isdigit, c.get('phone',''))) == normalized_phone |
|
|
for c in clients if c.get('id') != client_id): |
|
|
flash(f"Телефон {phone} уже используется другим клиентом.", "warning") |
|
|
return redirect(url_for('clients_panel')) |
|
|
|
|
|
client['name'] = name |
|
|
client['phone'] = phone |
|
|
client['address'] = address if address else None |
|
|
client_found = True |
|
|
break |
|
|
|
|
|
if client_found: |
|
|
save_client_data(clients) |
|
|
flash(f"Данные клиента '{name}' обновлены.", "success") |
|
|
upload_db_to_hf(CLIENT_DATA_FILE) |
|
|
else: |
|
|
flash("Клиент не найден.", "danger") |
|
|
return redirect(url_for('clients_panel')) |
|
|
|
|
|
elif action == 'delete': |
|
|
client_id = request.form.get('client_id') |
|
|
if not client_id: |
|
|
flash("ID клиента не указан.", "danger") |
|
|
return redirect(url_for('clients_panel')) |
|
|
|
|
|
client_found = False |
|
|
for i, client in enumerate(clients): |
|
|
if client.get('id') == client_id: |
|
|
if client.get('history', []): |
|
|
flash("Невозможно удалить клиента с историей отправок.", "warning") |
|
|
return redirect(url_for('clients_panel')) |
|
|
deleted_name = client.get('name', 'Неизвестный клиент') |
|
|
del clients[i] |
|
|
client_found = True |
|
|
break |
|
|
|
|
|
if client_found: |
|
|
save_client_data(clients) |
|
|
flash(f"Клиент '{deleted_name}' удален.", "success") |
|
|
upload_db_to_hf(CLIENT_DATA_FILE) |
|
|
else: |
|
|
flash("Клиент не найден.", "danger") |
|
|
return redirect(url_for('clients_panel')) |
|
|
|
|
|
else: |
|
|
name = request.form.get('client_name','').strip() |
|
|
phone = request.form.get('client_phone','').strip() |
|
|
address = request.form.get('client_address','').strip() |
|
|
|
|
|
if not name or not phone: |
|
|
flash("Имя/Название организации и номер телефона обязательны.", "danger") |
|
|
return redirect(url_for('clients_panel')) |
|
|
|
|
|
normalized_phone = ''.join(filter(str.isdigit, phone)) |
|
|
if any(''.join(filter(str.isdigit, c.get('phone',''))) == normalized_phone for c in clients): |
|
|
flash(f"Клиент с похожим номером телефона ({phone}) уже существует в базе.", "warning") |
|
|
return redirect(url_for('clients_panel')) |
|
|
|
|
|
new_client = { |
|
|
'id': uuid.uuid4().hex, |
|
|
'name': name, |
|
|
'phone': phone, |
|
|
'address': address if address else None, |
|
|
'history': [] |
|
|
} |
|
|
clients.append(new_client) |
|
|
save_client_data(clients) |
|
|
flash(f"Клиент '{name}' успешно добавлен.", "success") |
|
|
upload_db_to_hf(CLIENT_DATA_FILE) |
|
|
return redirect(url_for('clients_panel')) |
|
|
|
|
|
try: |
|
|
clients_data = load_client_data() |
|
|
clients_data.sort(key=lambda x: x.get('name','').lower()) |
|
|
|
|
|
for client in clients_data: |
|
|
if 'history' in client: |
|
|
client['history'].sort(key=lambda x: x.get('timestamp',''), reverse=True) |
|
|
for record in client['history']: |
|
|
record['timestamp_dt'] = parse_iso_datetime(record.get('timestamp')) |
|
|
|
|
|
html = BASE_TEMPLATE.replace('__TITLE__', "База клиентов").replace('__CONTENT__', CLIENTS_CONTENT).replace('__SCRIPTS__', CLIENTS_SCRIPTS) |
|
|
return render_template_string(html, clients=clients_data) |
|
|
|
|
|
except Exception as e: |
|
|
logging.error(f"Unexpected error in GET /clients: {e}", exc_info=True) |
|
|
flash("Произошла ошибка при отображении страницы клиентов.", "danger") |
|
|
return redirect(url_for('admin_panel')) |
|
|
|
|
|
@app.route('/admin') |
|
|
def admin_panel(): |
|
|
data = load_data() |
|
|
clients_data = load_client_data() |
|
|
config = data.get('config', {}) |
|
|
|
|
|
all_materials = [m for m_id in [m.get('id') for m in data.get('materials', []) if isinstance(m, dict)] |
|
|
if (m := find_item_by_id(m_id, 'materials')) is not None and to_decimal(m.get('quantity', '0')) > 0] |
|
|
materials_count = len(all_materials) |
|
|
|
|
|
all_cutting_tasks = [t for t_id in [t.get('id') for t in data.get('cutting_tasks', []) if isinstance(t, dict)] if (t := find_item_by_id(t_id, 'cutting_tasks')) is not None] |
|
|
all_sewing_tasks = [s for s_id in [s.get('id') for s in data.get('sewing_tasks', []) if isinstance(s, dict)] if (s := find_item_by_id(s_id, 'sewing_tasks')) is not None] |
|
|
all_packed_items = [p for p_id in [p.get('id') for p in data.get('qc_packing_items', []) if isinstance(p, dict)] if (p := find_item_by_id(p_id, 'qc_packing_items')) is not None] |
|
|
all_defect_log = [d for d_id in [d.get('log_id') for d in data.get('defect_log', []) if isinstance(d, dict)] if (d := find_item_by_id(d_id, 'defect_log')) is not None] |
|
|
all_expenses = [e for e_id in [e.get('id') for e in data.get('expenses', []) if isinstance(e, dict)] if (e := find_item_by_id(e_id, 'expenses')) is not None] |
|
|
dordoi_shipments = data.get('dordoi_shipments', []) |
|
|
for ship in dordoi_shipments: |
|
|
ship['timestamp_dt'] = parse_iso_datetime(ship.get('timestamp')) |
|
|
dordoi_shipments.sort(key=lambda x: x.get('timestamp',''), reverse=True) |
|
|
|
|
|
categories = data.get('categories', []) |
|
|
|
|
|
items_ready_to_ship = [item for item in all_packed_items if item.get('status') == 'packed_ready_to_ship'] |
|
|
items_ready_ship_count = len(items_ready_to_ship) |
|
|
items_ready_ship_qty = sum(item.get('quantity', 0) for item in items_ready_to_ship) |
|
|
|
|
|
total_packed_count = items_ready_ship_qty |
|
|
|
|
|
pending_cutting_count = len([task for task in all_cutting_tasks if task.get('status') == 'pending' and task.get('cut_items_quantity', 0) > task.get('sewn_quantity', 0)]) |
|
|
|
|
|
pending_qc_tasks = [] |
|
|
pending_qc_quantity = 0 |
|
|
for task in all_sewing_tasks: |
|
|
if task.get('status') == 'pending_qc': |
|
|
total_sewn = task.get('sewn_quantity', 0) |
|
|
already_processed = task.get('qc_packed_quantity', 0) + task.get('qc_defective_quantity', 0) |
|
|
remaining = total_sewn - already_processed |
|
|
if remaining > 0: |
|
|
pending_qc_tasks.append(task) |
|
|
pending_qc_quantity += remaining |
|
|
pending_qc_count = len(pending_qc_tasks) |
|
|
|
|
|
|
|
|
total_defect_fabric_m = sum(d.get('quantity_raw', Decimal('0')) for d in all_defect_log if d.get('type') == 'fabric') |
|
|
total_defect_fittings_pcs = sum(d.get('quantity_raw', 0) for d in all_defect_log if d.get('type') == 'fittings') |
|
|
total_defect_finished_pcs = sum(d.get('quantity_raw', 0) for d in all_defect_log if d.get('type') == 'finished_product') |
|
|
total_defect_cost = sum(d.get('cost_dec', Decimal('0')) for d in all_defect_log) |
|
|
|
|
|
config_decimal = {k: to_decimal(v) for k, v in config.items()} |
|
|
|
|
|
html = BASE_TEMPLATE.replace('__TITLE__', "Админ-панель").replace('__CONTENT__', ADMIN_CONTENT).replace('__SCRIPTS__', ADMIN_SCRIPTS) |
|
|
return render_template_string(html, |
|
|
materials=all_materials, |
|
|
cutting_tasks=all_cutting_tasks, |
|
|
sewing_tasks=all_sewing_tasks, |
|
|
packed_items=all_packed_items, |
|
|
items_ready_to_ship=items_ready_to_ship, |
|
|
clients=sorted(clients_data, key=lambda x: x.get('name','').lower()), |
|
|
defect_log=all_defect_log, |
|
|
expenses=all_expenses, |
|
|
dordoi_shipments=dordoi_shipments, |
|
|
categories=categories, |
|
|
config=config_decimal, |
|
|
materials_count=materials_count, |
|
|
pending_cutting_count=pending_cutting_count, |
|
|
pending_qc_count=pending_qc_count, |
|
|
pending_qc_quantity=pending_qc_quantity, |
|
|
total_packed_count=total_packed_count, |
|
|
items_ready_ship_count=items_ready_ship_count, |
|
|
items_ready_ship_qty=items_ready_ship_qty, |
|
|
total_defect_fabric_m=format_currency_py(total_defect_fabric_m), |
|
|
total_defect_fittings_pcs=format_integer_py(total_defect_fittings_pcs), |
|
|
total_defect_finished_pcs=format_integer_py(total_defect_finished_pcs), |
|
|
total_defect_cost=format_currency_py(total_defect_cost) |
|
|
) |
|
|
|
|
|
@app.route('/dispatch_item', methods=['POST']) |
|
|
def dispatch_item(): |
|
|
item_id = request.form.get('item_id') |
|
|
destination_type = request.form.get('destination_type') |
|
|
client_id = request.form.get('client_id') |
|
|
quantity_to_dispatch_str = request.form.get('quantity_to_dispatch') |
|
|
|
|
|
redirect_target = url_for('admin_panel') + '#dispatch-content' |
|
|
|
|
|
if not item_id or not destination_type or not quantity_to_dispatch_str: |
|
|
flash("Ошибка: Не указан ID товара, тип назначения или количество для отправки.", "danger") |
|
|
return redirect(redirect_target) |
|
|
|
|
|
data = load_data() |
|
|
clients = load_client_data() |
|
|
|
|
|
packed_item_to_update = None |
|
|
item_index = -1 |
|
|
packed_items_list = data.get('qc_packing_items', []) |
|
|
|
|
|
for i, item in enumerate(packed_items_list): |
|
|
if isinstance(item, dict) and item.get('id') == item_id and item.get('status') == 'packed_ready_to_ship': |
|
|
packed_item_to_update = item |
|
|
item_index = i |
|
|
break |
|
|
|
|
|
if not packed_item_to_update: |
|
|
flash(f"Ошибка: Товар с ID {item_id}, готовый к отправке, не найден.", "danger") |
|
|
return redirect(redirect_target) |
|
|
|
|
|
try: |
|
|
quantity_to_dispatch = int(to_decimal(quantity_to_dispatch_str).to_integral_value()) |
|
|
current_quantity = int(to_decimal(packed_item_to_update.get('quantity', '0'))) |
|
|
|
|
|
if quantity_to_dispatch <= 0: |
|
|
raise ValueError("Количество должно быть положительным") |
|
|
if quantity_to_dispatch > current_quantity: |
|
|
flash(f"Ошибка: Нельзя отправить {quantity_to_dispatch} шт., так как в наличии только {current_quantity} шт.", "danger") |
|
|
return redirect(redirect_target) |
|
|
except (InvalidOperation, ValueError) as e: |
|
|
flash(f"Некорректное количество для отправки: {e}", "danger") |
|
|
return redirect(redirect_target) |
|
|
|
|
|
dispatch_time_iso = get_current_time().isoformat() |
|
|
client_data_changed = False |
|
|
main_data_changed = False |
|
|
product_name = packed_item_to_update.get('product_name', 'N/A') |
|
|
destination_display_text = '' |
|
|
is_full_dispatch = (quantity_to_dispatch == current_quantity) |
|
|
history_items = [{'product_name': product_name, 'quantity': quantity_to_dispatch}] |
|
|
|
|
|
if destination_type == 'client': |
|
|
if not client_id: |
|
|
flash("Ошибка: Не выбран клиент для отправки.", "danger") |
|
|
return redirect(redirect_target) |
|
|
|
|
|
client_object_to_update = None |
|
|
client_name = "Клиент не найден" |
|
|
for cl in clients: |
|
|
if cl.get('id') == client_id: |
|
|
client_object_to_update = cl |
|
|
client_name = cl.get('name', 'Имя не найдено') |
|
|
break |
|
|
|
|
|
if not client_object_to_update: |
|
|
flash(f"Ошибка: Клиент с ID {client_id} не найден в базе.", "danger") |
|
|
return redirect(redirect_target) |
|
|
|
|
|
history_entry = { |
|
|
'shipment_id': uuid.uuid4().hex, |
|
|
'timestamp': dispatch_time_iso, |
|
|
'items': history_items, |
|
|
'packed_item_id': item_id |
|
|
} |
|
|
if not isinstance(client_object_to_update.get('history'), list): |
|
|
client_object_to_update['history'] = [] |
|
|
client_object_to_update['history'].append(history_entry) |
|
|
client_data_changed = True |
|
|
destination_display_text = f"клиенту '{client_name}'" |
|
|
|
|
|
if is_full_dispatch: |
|
|
packed_item_to_update['status'] = 'shipped_client' |
|
|
packed_item_to_update['shipment_details'] = { |
|
|
'type': destination_type, |
|
|
'timestamp': dispatch_time_iso, |
|
|
'client_id': client_id, |
|
|
'client_name': client_name |
|
|
} |
|
|
main_data_changed = True |
|
|
|
|
|
elif destination_type == 'dor_doi_point': |
|
|
dordoi_entry = { |
|
|
'shipment_id': uuid.uuid4().hex, |
|
|
'timestamp': dispatch_time_iso, |
|
|
'items': history_items, |
|
|
'packed_item_id': item_id |
|
|
} |
|
|
if 'dordoi_shipments' not in data or not isinstance(data['dordoi_shipments'], list): |
|
|
data['dordoi_shipments'] = [] |
|
|
data['dordoi_shipments'].append(dordoi_entry) |
|
|
destination_display_text = "на Торговую точку Дордой" |
|
|
main_data_changed = True |
|
|
|
|
|
if is_full_dispatch: |
|
|
packed_item_to_update['status'] = 'shipped_dor_doi' |
|
|
packed_item_to_update['shipment_details'] = { |
|
|
'type': destination_type, |
|
|
'timestamp': dispatch_time_iso, |
|
|
'destination': 'Торговая точка Дордой' |
|
|
} |
|
|
|
|
|
else: |
|
|
flash("Ошибка: Неверный тип назначения.", "danger") |
|
|
return redirect(redirect_target) |
|
|
|
|
|
if not is_full_dispatch: |
|
|
remaining_quantity = current_quantity - quantity_to_dispatch |
|
|
proportion_remaining = Decimal(remaining_quantity) / Decimal(current_quantity) |
|
|
fields_to_recalculate = [ |
|
|
'packed_material_cost', 'packed_salary_cost', |
|
|
'packed_total_cost', 'packed_margin', 'packed_final_price' |
|
|
] |
|
|
for field in fields_to_recalculate: |
|
|
current_cost = to_decimal(packed_item_to_update.get(field, '0')) |
|
|
remaining_cost = (current_cost * proportion_remaining).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP) |
|
|
packed_item_to_update[field] = str(remaining_cost) |
|
|
|
|
|
packed_item_to_update['quantity'] = remaining_quantity |
|
|
main_data_changed = True |
|
|
logging.info(f"Partial dispatch of item {item_id} ({product_name}): {quantity_to_dispatch} pcs. Remaining: {remaining_quantity} pcs.") |
|
|
|
|
|
if main_data_changed: |
|
|
save_data(data) |
|
|
upload_db_to_hf(DATA_FILE) |
|
|
logging.info(f"Main data saved after dispatching item {item_id}.") |
|
|
|
|
|
if client_data_changed: |
|
|
save_client_data(clients) |
|
|
upload_db_to_hf(CLIENT_DATA_FILE) |
|
|
logging.info(f"Client data {client_id} saved.") |
|
|
|
|
|
flash(f"{quantity_to_dispatch} шт. товара '{product_name}' успешно отправлено {destination_display_text}.", "success") |
|
|
return redirect(redirect_target) |
|
|
|
|
|
@app.route('/advances/delete/<advance_id>', methods=['POST']) |
|
|
def delete_advance(advance_id): |
|
|
data = load_data() |
|
|
advances = data.get('advances', []) |
|
|
initial_length = len(advances) |
|
|
advances[:] = [adv for adv in advances if not (isinstance(adv, dict) and adv.get('id') == advance_id)] |
|
|
|
|
|
if len(advances) < initial_length: |
|
|
data['advances'] = advances |
|
|
save_data(data) |
|
|
flash("Аванс успешно удален.", "success") |
|
|
upload_db_to_hf(DATA_FILE) |
|
|
else: |
|
|
flash("Аванс для удаления не найден.", "warning") |
|
|
return redirect(url_for('advances')) |
|
|
|
|
|
@app.route('/admin/expense/delete/<expense_id>', methods=['POST']) |
|
|
def delete_expense(expense_id): |
|
|
data = load_data() |
|
|
expenses = data.get('expenses', []) |
|
|
initial_length = len(expenses) |
|
|
expenses[:] = [exp for exp in expenses if not (isinstance(exp, dict) and exp.get('id') == expense_id)] |
|
|
|
|
|
if len(expenses) < initial_length: |
|
|
data['expenses'] = expenses |
|
|
save_data(data) |
|
|
flash("Расход успешно удален.", "success") |
|
|
upload_db_to_hf(DATA_FILE) |
|
|
else: |
|
|
flash("Расход для удаления не найден.", "warning") |
|
|
return redirect(url_for('admin_panel') + '#expenses-report-content') |
|
|
|
|
|
@app.route('/advances', methods=['GET', 'POST']) |
|
|
def advances(): |
|
|
data = load_data() |
|
|
if 'advances' not in data: data['advances'] = [] |
|
|
if 'monthly_salaries' not in data: data['monthly_salaries'] = {} |
|
|
|
|
|
if request.method == 'POST': |
|
|
try: |
|
|
employee_name = request.form.get('employee_name', '').strip() |
|
|
role = request.form.get('role', '').strip() |
|
|
amount_str = request.form.get('amount', '0') |
|
|
month_year = get_current_time().strftime('%Y-%m') |
|
|
|
|
|
if month_year not in data['monthly_salaries']: data['monthly_salaries'][month_year] = {} |
|
|
if employee_name not in data['monthly_salaries'][month_year]: |
|
|
data['monthly_salaries'][month_year][employee_name] = {'role': role, 'earned': Decimal('0'), 'advances': Decimal('0'), 'final_payout': Decimal('0')} |
|
|
|
|
|
if not employee_name or not role or not amount_str: |
|
|
flash("Заполните все поля", "danger") |
|
|
return redirect(url_for('advances')) |
|
|
|
|
|
amount = to_decimal(amount_str) |
|
|
if amount <= 0: |
|
|
flash("Сумма аванса должна быть положительной", "danger") |
|
|
return redirect(url_for('advances')) |
|
|
|
|
|
advance = {'id': uuid.uuid4().hex, 'employee_name': employee_name, 'role': role, 'amount': str(amount), 'timestamp': get_current_time().isoformat(), 'is_processed': False} |
|
|
data['advances'].append(advance) |
|
|
save_data(data) |
|
|
flash(f"Аванс {format_currency_py(amount)} сом выдан {employee_name}", "success") |
|
|
upload_db_to_hf(DATA_FILE) |
|
|
except Exception as e: |
|
|
logging.error(f"Ошибка при выдаче аванса: {e}", exc_info=True) |
|
|
flash(f"Ошибка при выдаче аванса: {e}", "danger") |
|
|
|
|
|
advances_list = data.get('advances', []) |
|
|
advances_list.sort(key=lambda x: x.get('timestamp', ''), reverse=True) |
|
|
|
|
|
html = BASE_TEMPLATE.replace('__TITLE__', "Авансы").replace('__CONTENT__', ADVANCES_CONTENT).replace('__SCRIPTS__', ADVANCES_SCRIPTS) |
|
|
return render_template_string(html, advances=advances_list) |
|
|
|
|
|
@app.route('/admin/config/update', methods=['POST']) |
|
|
def update_config(): |
|
|
data = load_data() |
|
|
config = data.get('config', {}) |
|
|
try: |
|
|
config['salary_cutter_per_unit'] = str(to_decimal(request.form.get('salary_cutter'))) |
|
|
config['salary_sewer_per_unit'] = str(to_decimal(request.form.get('salary_sewer'))) |
|
|
config['salary_packer_per_unit'] = str(to_decimal(request.form.get('salary_packer'))) |
|
|
config['margin_per_item'] = str(to_decimal(request.form.get('margin'))) |
|
|
data['config'] = config |
|
|
save_data(data) |
|
|
flash("Настройки зарплат и маржи успешно сохранены.", "success") |
|
|
upload_db_to_hf(DATA_FILE) |
|
|
except InvalidOperation: |
|
|
flash("Ошибка: Введено некорректное числовое значение.", "danger") |
|
|
except Exception as e: |
|
|
logging.error(f"Ошибка при обновлении конфигурации: {e}", exc_info=True) |
|
|
flash(f"Произошла ошибка при сохранении настроек: {e}", "danger") |
|
|
return redirect(url_for('admin_panel')) |
|
|
|
|
|
@app.route('/admin/expense/add', methods=['POST']) |
|
|
def add_expense(): |
|
|
data = load_data() |
|
|
description = request.form.get('expense_description','').strip() |
|
|
amount_str = request.form.get('expense_amount') |
|
|
redirect_target = url_for('admin_panel') + '#expenses-report-content' |
|
|
|
|
|
if not description or not amount_str: |
|
|
flash("Необходимо заполнить описание и сумму расхода.", "warning") |
|
|
return redirect(redirect_target) |
|
|
|
|
|
try: |
|
|
amount = to_decimal(amount_str) |
|
|
if amount <= 0: raise ValueError("Сумма должна быть > 0") |
|
|
except (InvalidOperation, ValueError): |
|
|
flash("Некорректное значение суммы расхода. Введите положительное число.", "warning") |
|
|
return redirect(redirect_target) |
|
|
|
|
|
if 'expenses' not in data or not isinstance(data['expenses'], list): |
|
|
data['expenses'] = [] |
|
|
|
|
|
new_expense = {'id': uuid.uuid4().hex, 'description': description, 'amount': str(amount), 'timestamp': get_current_time().isoformat()} |
|
|
data['expenses'].append(new_expense) |
|
|
save_data(data) |
|
|
flash(f"Расход '{description}' на сумму {format_currency_py(amount)} сом успешно добавлен.", "success") |
|
|
upload_db_to_hf(DATA_FILE) |
|
|
return redirect(redirect_target) |
|
|
|
|
|
@app.route('/admin/category/add', methods=['POST']) |
|
|
def add_category(): |
|
|
data = load_data() |
|
|
categories = data.get('categories', []) |
|
|
new_category_name = request.form.get('new_category_name','').strip() |
|
|
|
|
|
if not new_category_name: |
|
|
flash("Название категории не может быть пустым.", "warning") |
|
|
return redirect(url_for('admin_panel')) |
|
|
|
|
|
current_valid_categories = [c for c in categories if isinstance(c, str)] |
|
|
if new_category_name.lower() not in [c.lower() for c in current_valid_categories]: |
|
|
current_valid_categories.append(new_category_name) |
|
|
data['categories'] = sorted(list(set(current_valid_categories)), key=str.lower) |
|
|
save_data(data) |
|
|
flash(f"Категория '{new_category_name}' успешно добавлена.", "success") |
|
|
upload_db_to_hf(DATA_FILE) |
|
|
else: |
|
|
flash(f"Категория '{new_category_name}' уже существует.", "warning") |
|
|
return redirect(url_for('admin_panel')) |
|
|
|
|
|
@app.route('/admin/category/delete', methods=['POST']) |
|
|
def delete_category(): |
|
|
data = load_data() |
|
|
categories = data.get('categories', []) |
|
|
category_to_delete = request.form.get('category_to_delete') |
|
|
|
|
|
if not category_to_delete: |
|
|
flash("Не выбрана категория для удаления.", "warning") |
|
|
return redirect(url_for('admin_panel')) |
|
|
|
|
|
if category_to_delete == 'Без категории': |
|
|
flash("Нельзя удалить системную категорию 'Без категории'.", "danger") |
|
|
return redirect(url_for('admin_panel')) |
|
|
|
|
|
original_category_name = None |
|
|
category_found = False |
|
|
current_valid_categories = [c for c in categories if isinstance(c, str)] |
|
|
for cat in current_valid_categories: |
|
|
if cat.lower() == category_to_delete.lower(): |
|
|
original_category_name = cat |
|
|
category_found = True |
|
|
break |
|
|
|
|
|
if category_found and original_category_name: |
|
|
current_valid_categories.remove(original_category_name) |
|
|
data['categories'] = sorted(current_valid_categories, key=str.lower) |
|
|
materials_updated_count = 0 |
|
|
current_materials = data.get('materials', []) |
|
|
update_time = get_current_time().isoformat() |
|
|
for mat in current_materials: |
|
|
if isinstance(mat, dict) and mat.get('category', 'Без категории') == original_category_name: |
|
|
mat['category'] = 'Без категории' |
|
|
mat['timestamp_last_updated'] = update_time |
|
|
materials_updated_count += 1 |
|
|
save_data(data) |
|
|
flash(f"Категория '{original_category_name}' успешно удалена.", "success") |
|
|
if materials_updated_count > 0: |
|
|
flash(f"{materials_updated_count} материалов были перенесены в категорию 'Без категории'.", "info") |
|
|
upload_db_to_hf(DATA_FILE) |
|
|
else: |
|
|
flash(f"Категория '{category_to_delete}' не найдена.", "warning") |
|
|
return redirect(url_for('admin_panel')) |
|
|
|
|
|
@app.route('/backup', methods=['POST']) |
|
|
def backup_hf(): |
|
|
files_uploaded_count = 0 |
|
|
try: |
|
|
logging.info("Manual backup to Hugging Face initiated...") |
|
|
with data_lock: |
|
|
if os.path.exists(DATA_FILE): |
|
|
upload_db_to_hf(DATA_FILE) |
|
|
files_uploaded_count += 1 |
|
|
else: |
|
|
flash(f"Local file '{DATA_FILE}' not found for backup.", "warning") |
|
|
with client_data_lock: |
|
|
if os.path.exists(CLIENT_DATA_FILE): |
|
|
upload_db_to_hf(CLIENT_DATA_FILE) |
|
|
files_uploaded_count += 1 |
|
|
else: |
|
|
flash(f"Local file '{CLIENT_DATA_FILE}' not found for backup.", "warning") |
|
|
if files_uploaded_count > 0: |
|
|
flash(f"Backup of {files_uploaded_count} files to Hugging Face initiated.", "success") |
|
|
else: |
|
|
flash("No local files found to initiate backup.", "warning") |
|
|
except Exception as e: |
|
|
logging.error(f"Error during manual backup: {e}", exc_info=True) |
|
|
flash(f"An error occurred during backup: {e}", "danger") |
|
|
return redirect(url_for('admin_panel')) |
|
|
|
|
|
@app.route('/download', methods=['GET']) |
|
|
def download_hf(): |
|
|
downloaded_files = [] |
|
|
errors = [] |
|
|
logging.info("Starting data download from Hugging Face...") |
|
|
try: |
|
|
logging.info(f"Attempting download of {DATA_FILE}...") |
|
|
hf_hub_download(repo_id=REPO_ID, filename=DATA_FILE, repo_type="dataset", token=HF_TOKEN_READ, local_dir=".", local_dir_use_symlinks=False, force_download=True) |
|
|
downloaded_files.append(DATA_FILE) |
|
|
logging.info(f"{DATA_FILE} downloaded successfully.") |
|
|
except RepositoryNotFoundError: msg = f"Repository '{REPO_ID}' not found on Hugging Face."; logging.error(msg); errors.append(msg) |
|
|
except HfHubHTTPError as e: |
|
|
if e.response.status_code == 404: msg = f"File '{DATA_FILE}' not found in repository '{REPO_ID}'."; logging.warning(msg); errors.append(msg) |
|
|
else: msg = f"HTTP error ({e.response.status_code}) downloading {DATA_FILE}: {e}"; logging.error(msg); errors.append(msg) |
|
|
except Exception as e: msg = f"Unknown error downloading {DATA_FILE}: {e}"; logging.error(msg, exc_info=True); errors.append(msg) |
|
|
|
|
|
try: |
|
|
logging.info(f"Attempting download of {CLIENT_DATA_FILE}...") |
|
|
hf_hub_download(repo_id=REPO_ID, filename=CLIENT_DATA_FILE, repo_type="dataset", token=HF_TOKEN_READ, local_dir=".", local_dir_use_symlinks=False, force_download=True) |
|
|
downloaded_files.append(CLIENT_DATA_FILE) |
|
|
logging.info(f"{CLIENT_DATA_FILE} downloaded successfully.") |
|
|
except RepositoryNotFoundError: |
|
|
if not any(f"Repository '{REPO_ID}' not found" in err for err in errors): msg = f"Repository '{REPO_ID}' not found on Hugging Face."; logging.error(msg); errors.append(msg) |
|
|
except HfHubHTTPError as e: |
|
|
if e.response.status_code == 404: msg = f"File '{CLIENT_DATA_FILE}' not found in repository '{REPO_ID}'."; logging.warning(msg); errors.append(msg) |
|
|
else: msg = f"HTTP error ({e.response.status_code}) downloading {CLIENT_DATA_FILE}: {e}"; logging.error(msg); errors.append(msg) |
|
|
except Exception as e: msg = f"Unknown error downloading {CLIENT_DATA_FILE}: {e}"; logging.error(msg, exc_info=True); errors.append(msg) |
|
|
|
|
|
if downloaded_files: flash(f"Files ({', '.join(downloaded_files)}) downloaded successfully and overwritten locally.", "success") |
|
|
if errors: flash("Errors occurred during download: " + "; ".join(errors), "danger") |
|
|
if not downloaded_files and not errors: flash("Failed to initiate file download.", "warning") |
|
|
|
|
|
try: |
|
|
logging.info("Reloading data into memory after download...") |
|
|
load_data() |
|
|
load_client_data() |
|
|
logging.info("In-memory data updated.") |
|
|
except Exception as e: |
|
|
logging.error(f"Error reloading data after download: {e}", exc_info=True) |
|
|
flash("Warning: Files downloaded, but an error occurred updating application data. A restart may be required.", "warning") |
|
|
return redirect(url_for('admin_panel')) |
|
|
|
|
|
@app.route('/reports', methods=['GET']) |
|
|
def reports(): |
|
|
data = load_data() |
|
|
config = data.get('config', {}) |
|
|
now = get_current_time() |
|
|
|
|
|
filter_type = request.args.get('filter', 'month') |
|
|
start_date_str = request.args.get('start_date') |
|
|
end_date_str = request.args.get('end_date') |
|
|
date_str = request.args.get('date') |
|
|
month_str = request.args.get('month') |
|
|
year_str = request.args.get('year') |
|
|
|
|
|
start_date_dt = None |
|
|
end_date_dt = None |
|
|
|
|
|
try: |
|
|
if filter_type == 'custom' and start_date_str and end_date_str: |
|
|
sd = datetime.strptime(start_date_str, '%Y-%m-%d') |
|
|
ed = datetime.strptime(end_date_str, '%Y-%m-%d') |
|
|
start_date_dt = BISHKEK_TZ.localize(sd.replace(hour=0, minute=0, second=0, microsecond=0)) |
|
|
end_date_dt = BISHKEK_TZ.localize(ed.replace(hour=23, minute=59, second=59, microsecond=999999)) |
|
|
elif filter_type == 'day': |
|
|
day_to_use_str = date_str if date_str else now.strftime('%Y-%m-%d') |
|
|
d = datetime.strptime(day_to_use_str, '%Y-%m-%d') |
|
|
start_date_dt = BISHKEK_TZ.localize(d.replace(hour=0, minute=0, second=0, microsecond=0)) |
|
|
end_date_dt = start_date_dt.replace(hour=23, minute=59, second=59, microsecond=999999) |
|
|
elif filter_type == 'week': |
|
|
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) |
|
|
start_date_dt = today_start - timedelta(days=today_start.weekday()) |
|
|
end_date_dt = start_date_dt + timedelta(days=6, hours=23, minutes=59, seconds=59, microseconds=999999) |
|
|
elif filter_type == 'year': |
|
|
year_to_use_str = year_str if year_str else str(now.year) |
|
|
year_int = int(year_to_use_str) |
|
|
start_date_dt = BISHKEK_TZ.localize(datetime(year_int, 1, 1, 0, 0, 0)) |
|
|
end_date_dt = BISHKEK_TZ.localize(datetime(year_int, 12, 31, 23, 59, 59, 999999)) |
|
|
else: |
|
|
month_to_use_str = month_str if month_str else now.strftime('%Y-%m') |
|
|
year, month = map(int, month_to_use_str.split('-')) |
|
|
start_date_dt = BISHKEK_TZ.localize(datetime(year, month, 1, 0, 0, 0)) |
|
|
next_month_start = (start_date_dt.replace(day=28) + timedelta(days=4)).replace(day=1) |
|
|
end_date_dt = (next_month_start - timedelta(microseconds=1)).replace(hour=23, minute=59, second=59, microsecond=999999) |
|
|
|
|
|
if not start_date_dt or not end_date_dt or start_date_dt > end_date_dt: |
|
|
raise ValueError("Invalid time range.") |
|
|
|
|
|
except (ValueError, TypeError) as e: |
|
|
flash(f"Error setting period: {e}. Displaying report for current month.", "warning") |
|
|
filter_type = 'month' |
|
|
start_date_dt = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) |
|
|
next_month_start = (start_date_dt.replace(day=28) + timedelta(days=4)).replace(day=1) |
|
|
end_date_dt = (next_month_start - timedelta(microseconds=1)).replace(hour=23, minute=59, second=59, microsecond=999999) |
|
|
|
|
|
filtered_packed_items = [] |
|
|
all_packed_items_raw = data.get('qc_packing_items', []) |
|
|
for item_raw in all_packed_items_raw: |
|
|
if isinstance(item_raw, dict) and 'id' in item_raw: |
|
|
item_data = find_item_by_id(item_raw['id'], 'qc_packing_items') |
|
|
if not item_data: continue |
|
|
packed_time = parse_iso_datetime(item_data.get('timestamp_packed')) |
|
|
if packed_time and start_date_dt <= packed_time <= end_date_dt: |
|
|
shipment_time = None |
|
|
shipment_details = item_data.get('shipment_details') |
|
|
if shipment_details and shipment_details.get('timestamp'): |
|
|
shipment_time = parse_iso_datetime(shipment_details.get('timestamp')) |
|
|
item_data['shipment_time_dt'] = shipment_time |
|
|
filtered_packed_items.append(item_data) |
|
|
|
|
|
all_defect_log_raw = data.get('defect_log', []) |
|
|
filtered_defects = [] |
|
|
for defect_raw in all_defect_log_raw: |
|
|
if isinstance(defect_raw, dict) and 'log_id' in defect_raw: |
|
|
defect_data = find_item_by_id(defect_raw['log_id'], 'defect_log') |
|
|
if not defect_data: continue |
|
|
defect_time = parse_iso_datetime(defect_data.get('timestamp')) |
|
|
if defect_time and start_date_dt <= defect_time <= end_date_dt: |
|
|
filtered_defects.append(defect_data) |
|
|
|
|
|
all_expenses_raw = data.get('expenses', []) |
|
|
filtered_expenses = [] |
|
|
for expense_raw in all_expenses_raw: |
|
|
if isinstance(expense_raw, dict) and 'id' in expense_raw: |
|
|
expense_data = find_item_by_id(expense_raw['id'], 'expenses') |
|
|
if not expense_data: continue |
|
|
expense_time = parse_iso_datetime(expense_data.get('timestamp')) |
|
|
if expense_time and start_date_dt <= expense_time <= end_date_dt: |
|
|
filtered_expenses.append(expense_data) |
|
|
|
|
|
total_packed_quantity = sum(item.get('quantity', 0) for item in filtered_packed_items) |
|
|
total_revenue = sum(item.get('packed_final_price', Decimal('0')) for item in filtered_packed_items) |
|
|
total_material_cost_packed = sum(item.get('packed_material_cost', Decimal('0')) for item in filtered_packed_items) |
|
|
total_salary_cost_packed = sum(item.get('packed_salary_cost', Decimal('0')) for item in filtered_packed_items) |
|
|
total_cost_packed = total_material_cost_packed + total_salary_cost_packed |
|
|
total_defect_cost = sum(defect.get('cost_dec', Decimal('0')) for defect in filtered_defects) |
|
|
total_expenses_cost = sum(expense.get('amount', Decimal('0')) for expense in filtered_expenses) |
|
|
total_overall_cost = total_cost_packed + total_defect_cost + total_expenses_cost |
|
|
total_profit = total_revenue - total_overall_cost |
|
|
|
|
|
total_cutter_salary = Decimal('0') |
|
|
total_sewer_salary = Decimal('0') |
|
|
total_packer_salary = Decimal('0') |
|
|
cutter_rate = to_decimal(config.get('salary_cutter_per_unit', '0')) |
|
|
sewer_rate = to_decimal(config.get('salary_sewer_per_unit', '0')) |
|
|
packer_rate = to_decimal(config.get('salary_packer_per_unit', '0')) |
|
|
|
|
|
for item in filtered_packed_items: |
|
|
qty = item.get('quantity', 0) |
|
|
if qty > 0: |
|
|
total_cutter_salary += Decimal(qty) * cutter_rate |
|
|
total_sewer_salary += Decimal(qty) * sewer_rate |
|
|
total_packer_salary += Decimal(qty) * packer_rate |
|
|
|
|
|
calculated_total_salary = total_cutter_salary + total_sewer_salary + total_packer_salary |
|
|
if total_packed_quantity > 0 and abs(calculated_total_salary - total_salary_cost_packed) > Decimal('0.01') * total_packed_quantity : |
|
|
logging.warning(f"Calculated salary breakdown ({calculated_total_salary}) differs from total salary in packed items ({total_salary_cost_packed}).") |
|
|
|
|
|
production_summary = {} |
|
|
for item in filtered_packed_items: |
|
|
product_name = item.get('product_name', 'Unknown Product') |
|
|
quantity = item.get('quantity', 0) |
|
|
revenue = item.get('packed_final_price', Decimal('0')) |
|
|
cost = item.get('packed_total_cost', Decimal('0')) |
|
|
profit = revenue - cost |
|
|
if product_name not in production_summary: |
|
|
production_summary[product_name] = {'quantity': 0, 'revenue': Decimal('0'), 'cost': Decimal('0'), 'profit': Decimal('0')} |
|
|
production_summary[product_name]['quantity'] += quantity |
|
|
production_summary[product_name]['revenue'] += revenue |
|
|
production_summary[product_name]['cost'] += cost |
|
|
production_summary[product_name]['profit'] += profit |
|
|
|
|
|
report_data = { |
|
|
'total_packed_qty': total_packed_quantity, 'total_revenue': total_revenue, |
|
|
'total_material_cost': total_material_cost_packed, 'total_salary_cost': total_salary_cost_packed, |
|
|
'total_cost_packed': total_cost_packed, 'total_defect_cost': total_defect_cost, |
|
|
'total_expenses': total_expenses_cost, 'total_overall_cost': total_overall_cost, |
|
|
'total_profit': total_profit, 'total_cutter_salary': total_cutter_salary, |
|
|
'total_sewer_salary': total_sewer_salary, 'total_packer_salary': total_packer_salary, |
|
|
'production_summary': production_summary, 'filtered_packed_items': filtered_packed_items, |
|
|
'filtered_defects': filtered_defects, 'filtered_expenses': filtered_expenses, |
|
|
'start_date': start_date_dt.strftime('%Y-%m-%d'), 'end_date': end_date_dt.strftime('%Y-%m-%d'), |
|
|
'filter_type': filter_type, 'current_day': now.strftime('%Y-%m-%d'), |
|
|
'current_month': now.strftime('%Y-%m'), 'current_year': now.year, |
|
|
'filter_values': request.args |
|
|
} |
|
|
|
|
|
html = BASE_TEMPLATE.replace('__TITLE__', "Отчеты").replace('__CONTENT__', REPORTS_CONTENT).replace('__SCRIPTS__', REPORTS_SCRIPTS) |
|
|
return render_template_string(html, report=report_data) |
|
|
|
|
|
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'pdf', 'txt', 'doc', 'docx', 'xls', 'xlsx'} |
|
|
|
|
|
def allowed_file(filename): |
|
|
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS |
|
|
|
|
|
@app.route('/cloud', methods=['GET', 'POST']) |
|
|
def cloud_storage(): |
|
|
data = load_data() |
|
|
cloud_files = data.get('cloud_files', []) |
|
|
|
|
|
if request.method == 'POST': |
|
|
description = request.form.get('description', '').strip() |
|
|
if 'file' not in request.files: |
|
|
flash('Файл не был выбран.', 'warning') |
|
|
return redirect(url_for('cloud_storage')) |
|
|
file = request.files['file'] |
|
|
if file.filename == '': |
|
|
flash('Файл не был выбран.', 'warning') |
|
|
return redirect(url_for('cloud_storage')) |
|
|
|
|
|
if file and allowed_file(file.filename): |
|
|
original_filename = secure_filename(file.filename) |
|
|
file_ext = original_filename.rsplit('.', 1)[1].lower() |
|
|
stored_filename = f"{uuid.uuid4().hex}.{file_ext}" |
|
|
file_path = os.path.join(app.config['UPLOAD_FOLDER'], stored_filename) |
|
|
thumbnail_filename = None |
|
|
|
|
|
try: |
|
|
file.save(file_path) |
|
|
logging.info(f"File '{original_filename}' saved as '{stored_filename}'") |
|
|
|
|
|
if file_ext in {'png', 'jpg', 'jpeg', 'gif'}: |
|
|
thumb_path = os.path.join(app.config['THUMBNAIL_FOLDER'], stored_filename) |
|
|
thumbnail_filename = create_thumbnail(file_path, thumb_path) |
|
|
|
|
|
file_meta = { |
|
|
'file_id': uuid.uuid4().hex, 'original_filename': original_filename, |
|
|
'stored_filename': stored_filename, 'thumbnail_filename': thumbnail_filename, |
|
|
'description': description, 'timestamp': get_current_time().isoformat(), |
|
|
'size': os.path.getsize(file_path) |
|
|
} |
|
|
cloud_files.append(file_meta) |
|
|
data['cloud_files'] = cloud_files |
|
|
save_data(data) |
|
|
flash(f"Файл '{original_filename}' успешно загружен.", 'success') |
|
|
upload_db_to_hf(DATA_FILE) |
|
|
|
|
|
except Exception as e: |
|
|
logging.error(f"Error saving file or creating thumbnail: {e}", exc_info=True) |
|
|
flash(f"Ошибка при загрузке файла: {e}", 'danger') |
|
|
if os.path.exists(file_path): |
|
|
try: os.remove(file_path) |
|
|
except OSError: pass |
|
|
return redirect(url_for('cloud_storage')) |
|
|
else: |
|
|
flash('Недопустимый тип файла.', 'danger') |
|
|
return redirect(url_for('cloud_storage')) |
|
|
|
|
|
search_query = request.args.get('search', '').lower() |
|
|
if search_query: |
|
|
filtered_files = [ |
|
|
f for f in cloud_files |
|
|
if isinstance(f, dict) and |
|
|
(search_query in f.get('description', '').lower() or \ |
|
|
search_query in f.get('original_filename', '').lower()) |
|
|
] |
|
|
else: |
|
|
filtered_files = [f for f in cloud_files if isinstance(f, dict)] |
|
|
|
|
|
filtered_files.sort(key=lambda x: x.get('timestamp', ''), reverse=True) |
|
|
|
|
|
html = BASE_TEMPLATE.replace('__TITLE__', "Облачное хранилище").replace('__CONTENT__', CLOUD_CONTENT).replace('__SCRIPTS__', CLOUD_SCRIPTS) |
|
|
return render_template_string(html, files=filtered_files, search_query=search_query) |
|
|
|
|
|
@app.route('/download_file/<filename>') |
|
|
def download_file(filename): |
|
|
try: |
|
|
return send_from_directory(app.config['UPLOAD_FOLDER'], filename, as_attachment=True) |
|
|
except FileNotFoundError: |
|
|
flash("Файл не найден.", "danger") |
|
|
return redirect(url_for('cloud_storage')) |
|
|
except Exception as e: |
|
|
logging.error(f"Error downloading file {filename}: {e}", exc_info=True) |
|
|
flash(f"Ошибка при скачивании файла: {e}", "danger") |
|
|
return redirect(url_for('cloud_storage')) |
|
|
|
|
|
@app.route('/thumbnail/<filename>') |
|
|
def get_thumbnail(filename): |
|
|
try: |
|
|
return send_from_directory(app.config['THUMBNAIL_FOLDER'], filename) |
|
|
except FileNotFoundError: |
|
|
return "Thumbnail not found", 404 |
|
|
except Exception as e: |
|
|
logging.error(f"Error serving thumbnail {filename}: {e}") |
|
|
return "Error getting thumbnail", 500 |
|
|
|
|
|
@app.route('/cloud/delete/<file_id>', methods=['POST']) |
|
|
def delete_cloud_file(file_id): |
|
|
data = load_data() |
|
|
cloud_files = data.get('cloud_files', []) |
|
|
file_to_delete = None |
|
|
file_index = -1 |
|
|
|
|
|
for i, f in enumerate(cloud_files): |
|
|
if isinstance(f, dict) and f.get('file_id') == file_id: |
|
|
file_to_delete = f |
|
|
file_index = i |
|
|
break |
|
|
|
|
|
if file_to_delete: |
|
|
stored_filename = file_to_delete.get('stored_filename') |
|
|
thumb_filename = file_to_delete.get('thumbnail_filename') |
|
|
file_path = os.path.join(app.config['UPLOAD_FOLDER'], stored_filename) if stored_filename else None |
|
|
thumb_path = os.path.join(app.config['THUMBNAIL_FOLDER'], thumb_filename) if thumb_filename else None |
|
|
|
|
|
try: |
|
|
del cloud_files[file_index] |
|
|
data['cloud_files'] = cloud_files |
|
|
save_data(data) |
|
|
|
|
|
if file_path and os.path.exists(file_path): |
|
|
os.remove(file_path) |
|
|
logging.info(f"Deleted file: {file_path}") |
|
|
if thumb_path and os.path.exists(thumb_path): |
|
|
os.remove(thumb_path) |
|
|
logging.info(f"Deleted thumbnail: {thumb_path}") |
|
|
|
|
|
flash(f"Файл '{file_to_delete.get('original_filename', 'N/A')}' успешно удален.", 'success') |
|
|
upload_db_to_hf(DATA_FILE) |
|
|
|
|
|
except Exception as e: |
|
|
logging.error(f"Error deleting file {file_id}: {e}", exc_info=True) |
|
|
flash(f"Ошибка при удалении файла: {e}", 'danger') |
|
|
else: |
|
|
flash('Файл для удаления не найден.', 'warning') |
|
|
return redirect(url_for('cloud_storage')) |
|
|
|
|
|
ORDERS_CONTENT = """ |
|
|
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom"> |
|
|
<h1 class="h2">Создание заказа</h1> |
|
|
</div> |
|
|
<div class="card"> |
|
|
<div class="card-body"> |
|
|
<form method="POST" id="order-form"> |
|
|
<div class="row mb-3"> |
|
|
<div class="col-md-6"> |
|
|
<label for="client_id" class="form-label">Клиент</label> |
|
|
<select id="client_id" name="client_id" class="form-select" required> |
|
|
<option value="" disabled selected>-- Выберите клиента --</option> |
|
|
{% for client in clients %} |
|
|
<option value="{{ client.id }}">{{ client.name }} ({{ client.phone }})</option> |
|
|
{% endfor %} |
|
|
</select> |
|
|
</div> |
|
|
<div class="col-md-6"> |
|
|
<label for="model_name" class="form-label">Модель (артикул/название)</label> |
|
|
<input type="text" id="model_name" name="model_name" class="form-control" required> |
|
|
</div> |
|
|
</div> |
|
|
<div class="row mb-3"> |
|
|
<div class="col-md-4"> |
|
|
<label for="fabric_name" class="form-label">Название ткани</label> |
|
|
<input type="text" id="fabric_name" name="fabric_name" class="form-control" required> |
|
|
</div> |
|
|
<div class="col-md-4"> |
|
|
<label for="fabric_quantity" class="form-label">Количество ткани</label> |
|
|
<div class="input-group"> |
|
|
<input type="text" id="fabric_quantity" name="fabric_quantity" class="form-control" required inputmode="decimal"> |
|
|
<span class="input-group-text">м/кг</span> |
|
|
</div> |
|
|
</div> |
|
|
<div class="col-md-4"> |
|
|
<label for="items_quantity" class="form-label">Количество изделий</label> |
|
|
<div class="input-group"> |
|
|
<input type="number" id="items_quantity" name="items_quantity" class="form-control" required min="1" step="1"> |
|
|
<span class="input-group-text">шт.</span> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
<div class="row mb-3"> |
|
|
<div class="col-md-6"> |
|
|
<label for="size_range" class="form-label">Размерный ряд</label> |
|
|
<input type="text" id="size_range" name="size_range" class="form-control" placeholder="напр. 42-52"> |
|
|
</div> |
|
|
<div class="col-md-6"> |
|
|
<label for="prepayment" class="form-label">Аванс</label> |
|
|
<div class="input-group"> |
|
|
<input type="text" id="prepayment" name="prepayment" class="form-control" inputmode="decimal" value="0"> |
|
|
<span class="input-group-text">сом</span> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
<div id="fittings-container" class="mt-4"> |
|
|
<h6 class="mb-3">Фурнитура <small class="text-body-secondary">(необязательно)</small></h6> |
|
|
<div id="fittings-rows"> |
|
|
<div class="row g-2 mb-2 fitting-row align-items-center"> |
|
|
<div class="col-7"> |
|
|
<input type="text" name="fitting_names[]" class="form-control form-control-sm" placeholder="Название фурнитуры"> |
|
|
</div> |
|
|
<div class="col-3"> |
|
|
<input type="number" name="fitting_quantities[]" class="form-control form-control-sm" placeholder="Кол-во" min="1" step="1"> |
|
|
</div> |
|
|
<div class="col-2 text-end"> |
|
|
<button type="button" class="btn btn-sm btn-outline-danger remove-fitting" onclick="removeFitting(this)"><i class="fas fa-trash-alt"></i></button> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
<button type="button" class="btn btn-sm btn-outline-secondary mt-2" onclick="addFitting()"> |
|
|
<i class="fas fa-plus"></i> Добавить |
|
|
</button> |
|
|
</div> |
|
|
<hr class="my-4"> |
|
|
<button type="submit" class="btn btn-primary btn-lg"> |
|
|
<i class="fas fa-save me-2"></i>Создать заказ |
|
|
</button> |
|
|
</form> |
|
|
</div> |
|
|
</div> |
|
|
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 my-3 border-bottom"> |
|
|
<h1 class="h2">Список заказов</h1> |
|
|
</div> |
|
|
<div class="card"> |
|
|
<div class="card-body"> |
|
|
<div class="mb-3"> |
|
|
<input type="text" id="order-search" class="form-control" placeholder="Поиск по клиенту, модели, статусу..."> |
|
|
</div> |
|
|
<div class="table-responsive"> |
|
|
<table class="table table-hover table-bordered" id="orders-table"> |
|
|
<thead class="table-light"> |
|
|
<tr> |
|
|
<th style="width: 5%;" onclick="sortTable(0, 'orders-table')">ID <i class="fas fa-sort"></i></th> |
|
|
<th onclick="sortTable(1, 'orders-table')">Клиент <i class="fas fa-sort"></i></th> |
|
|
<th onclick="sortTable(2, 'orders-table')">Модель <i class="fas fa-sort"></i></th> |
|
|
<th onclick="sortTable(3, 'orders-table')">Ткань <i class="fas fa-sort"></i></th> |
|
|
<th>Кол-во ткани</th> |
|
|
<th>Фурнитура</th> |
|
|
<th onclick="sortTable(6, 'orders-table', true)">Кол-во изд. <i class="fas fa-sort"></i></th> |
|
|
<th onclick="sortTable(8, 'orders-table', true)">Аванс <i class="fas fa-sort"></i></th> |
|
|
<th onclick="sortTable(9, 'orders-table')">Статус <i class="fas fa-sort"></i></th> |
|
|
<th onclick="sortTable(10, 'orders-table')">Создан <i class="fas fa-sort"></i></th> |
|
|
<th style="width: 8%;">Действия</th> |
|
|
</tr> |
|
|
</thead> |
|
|
<tbody> |
|
|
{% for order in orders %} |
|
|
<tr class="order-row" data-search="{{ order.client_name|lower }} {{ order.model_name|lower }} {{ getStatusText(order.status)|lower }}"> |
|
|
<td data-bs-toggle="tooltip" title="{{ order.id }}"><small class="font-monospace">{{ order.id[:8] }}</small></td> |
|
|
<td>{{ order.client_name }}</td> |
|
|
<td>{{ order.model_name }}</td> |
|
|
<td>{{ order.fabric_name }}</td> |
|
|
<td>{{ order.fabric_quantity }}</td> |
|
|
<td> |
|
|
{% if order.fittings %} |
|
|
<ul class="list-unstyled mb-0 small"> |
|
|
{% for f in order.fittings %} |
|
|
<li>{{ f.fitting_name }}: <strong>{{ f.quantity }}</strong> шт.</li> |
|
|
{% endfor %} |
|
|
</ul> |
|
|
{% else %} |
|
|
<span class="text-body-secondary">—</span> |
|
|
{% endif %} |
|
|
</td> |
|
|
<td data-sort="{{ order.items_quantity }}">{{ order.items_quantity }}</td> |
|
|
<td data-sort="{{ order.prepayment }}">{{ format_currency_py(order.prepayment) }} сом</td> |
|
|
<td><span class="badge rounded-pill {{ getStatusClass(order.status) }}">{{ getStatusText(order.status) }}</span></td> |
|
|
<td><span data-bs-toggle="tooltip" title="{{ order.timestamp_created }}">{{ order.timestamp_created[:16]|replace('T', ' ') }}</span></td> |
|
|
<td> |
|
|
<div class="btn-group btn-group-sm"> |
|
|
<button type="button" class="btn btn-outline-primary" data-bs-toggle="modal" data-bs-target="#editOrderModal-{{ order.id }}" title="Редактировать"> |
|
|
<i class="fas fa-edit"></i> |
|
|
</button> |
|
|
{% if order.status != 'completed' %} |
|
|
<form method="POST" action="{{ url_for('delete_order', order_id=order.id) }}" |
|
|
class="d-inline" onsubmit="return confirm('Вы уверены, что хотите удалить заказ \'{{ order.model_name }}\'? Это действие необратимо.');"> |
|
|
<button type="submit" class="btn btn-outline-danger" title="Удалить"> |
|
|
<i class="fas fa-trash"></i> |
|
|
</button> |
|
|
</form> |
|
|
{% endif %} |
|
|
</div> |
|
|
</td> |
|
|
</tr> |
|
|
{% else %} |
|
|
<tr> |
|
|
<td colspan="11" class="text-center text-body-secondary py-4">Заказы отсутствуют.</td> |
|
|
</tr> |
|
|
{% endfor %} |
|
|
<tr class="no-result-row" style="display: none;"><td colspan="11" class="text-center text-body-secondary py-4"></td></tr> |
|
|
</tbody> |
|
|
</table> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
{% for order in orders %} |
|
|
<div class="modal fade" id="editOrderModal-{{ order.id }}" tabindex="-1" aria-labelledby="editOrderModalLabel-{{ order.id }}" aria-hidden="true"> |
|
|
<div class="modal-dialog modal-lg"> |
|
|
<div class="modal-content"> |
|
|
<div class="modal-header"> |
|
|
<h5 class="modal-title" id="editOrderModalLabel-{{ order.id }}">Редактировать Заказ #<span class="font-monospace">{{ order.id[:8] }}</span></h5> |
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> |
|
|
</div> |
|
|
<form method="POST" action="{{ url_for('edit_order', order_id=order.id) }}"> |
|
|
<div class="modal-body"> |
|
|
<div class="row mb-3"> |
|
|
<div class="col-md-6"> |
|
|
<label class="form-label">Клиент</label> |
|
|
<input type="text" class="form-control" value="{{ order.client_name }}" readonly disabled> |
|
|
</div> |
|
|
<div class="col-md-6"> |
|
|
<label for="edit_model_name-{{ order.id }}" class="form-label">Модель</label> |
|
|
<input type="text" id="edit_model_name-{{ order.id }}" name="model_name" class="form-control" value="{{ order.model_name }}" required> |
|
|
</div> |
|
|
</div> |
|
|
<div class="row mb-3"> |
|
|
<div class="col-md-4"> |
|
|
<label for="edit_fabric_name-{{ order.id }}" class="form-label">Название ткани</label> |
|
|
<input type="text" id="edit_fabric_name-{{ order.id }}" name="fabric_name" class="form-control" value="{{ order.fabric_name }}" required> |
|
|
</div> |
|
|
<div class="col-md-4"> |
|
|
<label for="edit_fabric_quantity-{{ order.id }}" class="form-label">Количество ткани</label> |
|
|
<div class="input-group"> |
|
|
<input type="text" id="edit_fabric_quantity-{{ order.id }}" name="fabric_quantity" class="form-control" value="{{ order.fabric_quantity }}" required inputmode="decimal"> |
|
|
<span class="input-group-text">м/кг</span> |
|
|
</div> |
|
|
</div> |
|
|
<div class="col-md-4"> |
|
|
<label for="edit_items_quantity-{{ order.id }}" class="form-label">Количество изделий</label> |
|
|
<div class="input-group"> |
|
|
<input type="number" id="edit_items_quantity-{{ order.id }}" name="items_quantity" class="form-control" value="{{ order.items_quantity }}" required min="1" step="1"> |
|
|
<span class="input-group-text">шт.</span> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
<div class="row mb-3"> |
|
|
<div class="col-md-6"> |
|
|
<label for="edit_size_range-{{ order.id }}" class="form-label">Размерный ряд</label> |
|
|
<input type="text" id="edit_size_range-{{ order.id }}" name="size_range" class="form-control" value="{{ order.size_range }}"> |
|
|
</div> |
|
|
<div class="col-md-6"> |
|
|
<label for="edit_prepayment-{{ order.id }}" class="form-label">Аванс</label> |
|
|
<div class="input-group"> |
|
|
<input type="text" id="edit_prepayment-{{ order.id }}" name="prepayment" class="form-control" value="{{ order.prepayment|string|replace('.', ',') }}" inputmode="decimal"> |
|
|
<span class="input-group-text">сом</span> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
<div id="edit-fittings-container-{{ order.id }}" class="mt-4"> |
|
|
<h6 class="mb-3">Фурнитура</h6> |
|
|
<div id="edit-fittings-rows-{{ order.id }}"> |
|
|
{% for f in order.fittings %} |
|
|
<div class="row g-2 mb-2 edit-fitting-row align-items-center"> |
|
|
<div class="col-7"><input type="text" name="fitting_names[]" class="form-control form-control-sm" placeholder="Название" value="{{ f.fitting_name }}"></div> |
|
|
<div class="col-3"><input type="number" name="fitting_quantities[]" class="form-control form-control-sm" placeholder="Кол-во" min="1" step="1" value="{{ f.quantity }}"></div> |
|
|
<div class="col-2 text-end"><button type="button" class="btn btn-sm btn-outline-danger remove-fitting" onclick="removeFitting(this)"><i class="fas fa-trash-alt"></i></button></div> |
|
|
</div> |
|
|
{% endfor %} |
|
|
{% if not order.fittings %} |
|
|
<div class="row g-2 mb-2 edit-fitting-row align-items-center"> |
|
|
<div class="col-7"><input type="text" name="fitting_names[]" class="form-control form-control-sm" placeholder="Название"></div> |
|
|
<div class="col-3"><input type="number" name="fitting_quantities[]" class="form-control form-control-sm" placeholder="Кол-во" min="1" step="1"></div> |
|
|
<div class="col-2 text-end"><button type="button" class="btn btn-sm btn-outline-danger remove-fitting" onclick="removeFitting(this)"><i class="fas fa-trash-alt"></i></button></div> |
|
|
</div> |
|
|
{% endif %} |
|
|
</div> |
|
|
<button type="button" class="btn btn-sm btn-outline-secondary mt-2" onclick="addFittingEdit('{{ order.id }}')"> |
|
|
<i class="fas fa-plus"></i> Добавить |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
<div class="modal-footer"> |
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button> |
|
|
<button type="submit" class="btn btn-primary">Сохранить изменения</button> |
|
|
</div> |
|
|
</form> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
{% endfor %} |
|
|
""" |
|
|
|
|
|
ORDERS_SCRIPTS = """ |
|
|
<script> |
|
|
function removeFitting(button) { |
|
|
const row = button.closest('.fitting-row, .edit-fitting-row'); |
|
|
const container = row.parentNode; |
|
|
if (container.children.length > 1) { row.remove(); } |
|
|
else { row.querySelectorAll('input').forEach(input => input.value = ''); } |
|
|
} |
|
|
function addFitting() { |
|
|
const container = document.getElementById('fittings-rows'); |
|
|
const firstRow = container.querySelector('.fitting-row'); |
|
|
if (!firstRow) return; |
|
|
const newRow = firstRow.cloneNode(true); |
|
|
newRow.querySelectorAll('input').forEach(input => input.value = ''); |
|
|
container.appendChild(newRow); |
|
|
} |
|
|
function addFittingEdit(orderId) { |
|
|
const container = document.getElementById(`edit-fittings-rows-${orderId}`); |
|
|
const firstRow = container.querySelector('.edit-fitting-row'); |
|
|
if (!firstRow) return; |
|
|
const newRow = firstRow.cloneNode(true); |
|
|
newRow.querySelectorAll('input').forEach(input => input.value = ''); |
|
|
container.appendChild(newRow); |
|
|
} |
|
|
document.addEventListener('DOMContentLoaded', function() { |
|
|
const searchInput = document.getElementById('order-search'); |
|
|
const tableBody = document.getElementById('orders-table')?.querySelector('tbody'); |
|
|
if (searchInput && tableBody) { |
|
|
searchInput.addEventListener('input', function() { |
|
|
const searchTerm = searchInput.value.toLowerCase().trim(); |
|
|
const rows = tableBody.querySelectorAll('tr.order-row'); |
|
|
const noResultRow = tableBody.querySelector('.no-result-row'); |
|
|
let found = false; |
|
|
rows.forEach(row => { |
|
|
const searchData = row.dataset.search || ''; |
|
|
if (searchData.includes(searchTerm)) { row.style.display = ''; found = true; } |
|
|
else { row.style.display = 'none'; } |
|
|
}); |
|
|
if (!found && searchTerm !== '') { |
|
|
if (noResultRow) { |
|
|
noResultRow.querySelector('td').textContent = `Заказы не найдены по запросу "${searchTerm}".`; |
|
|
noResultRow.style.display = ''; |
|
|
} |
|
|
} else if (noResultRow) { |
|
|
noResultRow.style.display = 'none'; |
|
|
} |
|
|
}); |
|
|
} |
|
|
}); |
|
|
</script> |
|
|
""" |
|
|
|
|
|
BASE_TEMPLATE = """ |
|
|
<!DOCTYPE html> |
|
|
<html lang="ru" data-bs-theme="light"> |
|
|
<head> |
|
|
<meta charset="UTF-8"> |
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
|
<title>__TITLE__ - КШП</title> |
|
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous"> |
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"> |
|
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css"> |
|
|
<link rel="preconnect" href="https://fonts.googleapis.com"> |
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> |
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"> |
|
|
<style> |
|
|
:root { |
|
|
--bs-body-font-family: 'Inter', sans-serif; |
|
|
--sidebar-width: 260px; |
|
|
--sidebar-bg: #111827; |
|
|
--sidebar-link-color: #9ca3af; |
|
|
--sidebar-link-hover-color: #ffffff; |
|
|
--sidebar-link-active-color: #ffffff; |
|
|
--sidebar-link-active-bg: #374151; |
|
|
--topbar-height: 60px; |
|
|
--main-bg: #f3f4f6; |
|
|
--card-bg: #ffffff; |
|
|
--card-border-color: #e5e7eb; |
|
|
--text-color: #1f2937; |
|
|
--text-muted-color: #6b7280; |
|
|
--bs-primary-rgb: 79, 70, 229; |
|
|
--bs-link-color-rgb: 79, 70, 229; |
|
|
} |
|
|
|
|
|
[data-bs-theme="dark"] { |
|
|
--sidebar-bg: #1f2937; |
|
|
--main-bg: #111827; |
|
|
--card-bg: #1f2937; |
|
|
--card-border-color: #374151; |
|
|
--bs-body-color: #d1d5db; |
|
|
--bs-body-bg: var(--main-bg); |
|
|
--bs-secondary-bg: #374151; |
|
|
--bs-tertiary-bg: #1f2937; |
|
|
--bs-border-color: #374151; |
|
|
--bs-heading-color: #ffffff; |
|
|
--text-color: #f9fafb; |
|
|
--text-muted-color: #9ca3af; |
|
|
} |
|
|
|
|
|
body { |
|
|
background-color: var(--main-bg); |
|
|
color: var(--text-color); |
|
|
transition: background-color 0.3s, color 0.3s; |
|
|
} |
|
|
|
|
|
.sidebar { |
|
|
position: fixed; top: 0; left: 0; |
|
|
width: var(--sidebar-width); height: 100vh; |
|
|
background-color: var(--sidebar-bg); |
|
|
color: white; padding-top: 1.5rem; |
|
|
transition: transform 0.3s ease-in-out; |
|
|
z-index: 1030; |
|
|
} |
|
|
|
|
|
.sidebar-header { |
|
|
padding: 0 1.5rem 1.5rem; |
|
|
border-bottom: 1px solid rgba(255,255,255,0.1); |
|
|
} |
|
|
|
|
|
.sidebar-brand { font-size: 1.5rem; font-weight: 700; color: white; text-decoration: none; } |
|
|
.sidebar-brand i { color: #818cf8; } |
|
|
|
|
|
.sidebar-nav { padding: 1rem 0; } |
|
|
.sidebar .nav-link { |
|
|
display: flex; align-items: center; |
|
|
color: var(--sidebar-link-color); |
|
|
font-weight: 500; |
|
|
padding: 0.75rem 1.5rem; |
|
|
margin: 0.125rem 0.5rem; |
|
|
border-radius: 0.375rem; |
|
|
transition: color 0.2s, background-color 0.2s; |
|
|
} |
|
|
.sidebar .nav-link:hover { color: var(--sidebar-link-hover-color); background-color: var(--sidebar-link-active-bg); } |
|
|
.sidebar .nav-link.active { color: var(--sidebar-link-active-color); background-color: #4f46e5; } |
|
|
.sidebar .nav-link i.fa-fw { width: 1.25em; margin-right: 0.75rem; } |
|
|
|
|
|
.main-content { |
|
|
margin-left: var(--sidebar-width); |
|
|
padding: calc(var(--topbar-height) + 1.5rem) 1.5rem 1.5rem; |
|
|
transition: margin-left 0.3s ease-in-out; |
|
|
} |
|
|
|
|
|
.topbar { |
|
|
position: fixed; top: 0; right: 0; left: var(--sidebar-width); |
|
|
height: var(--topbar-height); |
|
|
background: var(--card-bg); |
|
|
border-bottom: 1px solid var(--card-border-color); |
|
|
padding: 0 1.5rem; |
|
|
display: flex; align-items: center; justify-content: space-between; |
|
|
z-index: 1020; |
|
|
transition: left 0.3s ease-in-out; |
|
|
} |
|
|
|
|
|
.card { |
|
|
background-color: var(--card-bg); |
|
|
border: 1px solid var(--card-border-color); |
|
|
box-shadow: 0 1px 3px 0 rgba(0,0,0,0.1), 0 1px 2px -1px rgba(0,0,0,0.1); |
|
|
border-radius: 0.75rem; |
|
|
} |
|
|
.card-header { |
|
|
background-color: var(--bs-tertiary-bg, #f9fafb); |
|
|
border-bottom: 1px solid var(--card-border-color); |
|
|
padding: 1rem 1.25rem; |
|
|
font-weight: 600; |
|
|
} |
|
|
|
|
|
.table { --bs-table-hover-bg: rgba(var(--bs-primary-rgb), 0.05); } |
|
|
.table-bordered { --bs-table-border-color: var(--card-border-color); } |
|
|
.table-light { --bs-table-bg: var(--bs-tertiary-bg); } |
|
|
.table th { font-weight: 600; } |
|
|
|
|
|
.badge { font-size: 0.75em; font-weight: 600; padding: 0.4em 0.7em; } |
|
|
.status-pending { background-color: #3b82f6 !important; } |
|
|
.status-completed { background-color: #10b981 !important; } |
|
|
.status-pending-qc { background-color: #f59e0b !important; color: #1f2937 !important; } |
|
|
.status-ready-ship { background-color: #8b5cf6 !important; } |
|
|
.status-shipped-client { background-color: #6366f1 !important; } |
|
|
.status-shipped-dordoi { background-color: #2dd4bf !important; } |
|
|
.status-pending-procurement { background-color: #f97316 !important; } |
|
|
|
|
|
.btn-outline-primary { --bs-btn-border-color: #c7d2fe; --bs-btn-color: #4f46e5; --bs-btn-hover-bg: #4f46e5; --bs-btn-hover-color: #fff;} |
|
|
.btn-primary { --bs-btn-bg: #4f46e5; --bs-btn-border-color: #4f46e5; --bs-btn-hover-bg: #4338ca; --bs-btn-hover-border-color: #4338ca; } |
|
|
|
|
|
.form-control, .form-select { |
|
|
background-color: var(--bs-secondary-bg); |
|
|
border-color: var(--bs-border-color); |
|
|
} |
|
|
.form-control:focus, .form-select:focus { |
|
|
border-color: #a5b4fc; |
|
|
box-shadow: 0 0 0 0.25rem rgba(var(--bs-primary-rgb), 0.25); |
|
|
background-color: var(--bs-secondary-bg); |
|
|
} |
|
|
|
|
|
@media (max-width: 992px) { |
|
|
.sidebar { transform: translateX(calc(-1 * var(--sidebar-width))); } |
|
|
.sidebar.active { transform: translateX(0); } |
|
|
.main-content { margin-left: 0; } |
|
|
.topbar { left: 0; } |
|
|
} |
|
|
|
|
|
.theme-switch { display: flex; align-items: center; justify-content: center; width: 36px; height: 36px; border-radius: 50%; background: transparent; border: 0; color: var(--text-muted-color); } |
|
|
.theme-switch:hover { background: var(--main-bg); } |
|
|
|
|
|
.table th i.fa-sort { color: #9ca3af; } |
|
|
.table th:hover { cursor: pointer; } |
|
|
.table th:hover i { color: #374151; } |
|
|
[data-bs-theme="dark"] .table th:hover i { color: #e5e7eb; } |
|
|
</style> |
|
|
</head> |
|
|
<body> |
|
|
<div class="sidebar"> |
|
|
<div class="sidebar-header"> |
|
|
<a href="{{ url_for('admin_panel') }}" class="sidebar-brand"> |
|
|
<i class="fas fa-industry"></i> КШП |
|
|
</a> |
|
|
</div> |
|
|
<nav class="nav flex-column sidebar-nav"> |
|
|
<a class="nav-link {% if request.endpoint == 'admin_panel' %}active{% endif %}" href="{{ url_for('admin_panel') }}"><i class="fas fa-fw fa-tachometer-alt"></i>Админ-панель</a> |
|
|
<a class="nav-link {% if request.endpoint == 'orders' %}active{% endif %}" href="{{ url_for('orders') }}"><i class="fas fa-fw fa-clipboard-list"></i>Заказы</a> |
|
|
<a class="nav-link {% if request.endpoint == 'procurement' %}active{% endif %}" href="{{ url_for('procurement') }}"><i class="fas fa-fw fa-shopping-cart"></i>Закуп</a> |
|
|
<a class="nav-link {% if request.endpoint == 'cutting' %}active{% endif %}" href="{{ url_for('cutting') }}"><i class="fas fa-fw fa-cut"></i>Раскрой</a> |
|
|
<a class="nav-link {% if request.endpoint == 'sewing' %}active{% endif %}" href="{{ url_for('sewing') }}"><i class="fas fa-fw fa-tshirt"></i>Пошив</a> |
|
|
<a class="nav-link {% if request.endpoint == 'qc_packing' %}active{% endif %}" href="{{ url_for('qc_packing') }}"><i class="fas fa-fw fa-box-open"></i>ОТК и Упаковка</a> |
|
|
<a class="nav-link {% if request.endpoint == 'clients_panel' %}active{% endif %}" href="{{ url_for('clients_panel') }}"><i class="fas fa-fw fa-users"></i>Клиенты</a> |
|
|
<a class="nav-link {% if request.endpoint == 'reports' %}active{% endif %}" href="{{ url_for('reports') }}"><i class="fas fa-fw fa-chart-line"></i>Отчеты</a> |
|
|
<a class="nav-link {% if request.endpoint == 'cloud_storage' %}active{% endif %}" href="{{ url_for('cloud_storage') }}"><i class="fas fa-fw fa-cloud"></i>Облако</a> |
|
|
<a class="nav-link {% if request.endpoint == 'advances' %}active{% endif %}" href="{{ url_for('advances') }}"><i class="fas fa-fw fa-hand-holding-usd"></i>Авансы</a> |
|
|
</nav> |
|
|
<div class="mt-auto p-3"> |
|
|
<p class="text-body-secondary small mb-0"> © {{ get_current_time().year }} КШП <br> {{ get_current_time().strftime('%Y-%m-%d %H:%M:%S') }}</p> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="main-content"> |
|
|
<header class="topbar"> |
|
|
<button class="btn btn-outline-secondary d-lg-none" id="sidebar-toggle"><i class="fas fa-bars"></i></button> |
|
|
<h4 class="h5 mb-0 d-none d-lg-block">__TITLE__</h4> |
|
|
<div class="d-flex align-items-center"> |
|
|
<form method="POST" action="{{ url_for('backup_hf') }}" class="me-2 mb-0"> |
|
|
<button type="submit" class="btn btn-sm btn-outline-secondary" title="Backup to Hugging Face"><i class="fas fa-cloud-upload-alt me-1"></i> Backup</button> |
|
|
</form> |
|
|
<form method="GET" action="{{ url_for('download_hf') }}" onsubmit="return confirm('ОСТОРОЖНО! Перезапишет локальные данные с Hugging Face. Уверены?');" class="mb-0 me-2"> |
|
|
<button type="submit" class="btn btn-sm btn-outline-secondary" title="Download DBs from Hugging Face"><i class="fas fa-cloud-download-alt me-1"></i> Download</button> |
|
|
</form> |
|
|
<button class="theme-switch" id="theme-toggle" title="Toggle theme"> |
|
|
<i class="fas fa-sun"></i> |
|
|
</button> |
|
|
</div> |
|
|
</header> |
|
|
|
|
|
<main> |
|
|
{% with messages = get_flashed_messages(with_categories=true) %} |
|
|
{% if messages %} |
|
|
<div class="flash-messages mb-4"> |
|
|
{% for category, message in messages %} |
|
|
{% set alert_class = 'alert-' + category if category in ['danger', 'success', 'warning', 'info'] else 'alert-info' %} |
|
|
<div class="alert {{ alert_class }} alert-dismissible fade show" role="alert"> |
|
|
{{ message }} |
|
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button> |
|
|
</div> |
|
|
{% endfor %} |
|
|
</div> |
|
|
{% endif %} |
|
|
{% endwith %} |
|
|
|
|
|
__CONTENT__ |
|
|
</main> |
|
|
</div> |
|
|
|
|
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script> |
|
|
<script src="https://cdn.jsdelivr.net/npm/flatpickr"></script> |
|
|
<script src="https://npmcdn.com/flatpickr/dist/l10n/ru.js"></script> |
|
|
<script> |
|
|
const getStoredTheme = () => localStorage.getItem('theme'); |
|
|
const setStoredTheme = theme => localStorage.setItem('theme', theme); |
|
|
const getPreferredTheme = () => { |
|
|
const storedTheme = getStoredTheme(); |
|
|
if (storedTheme) { |
|
|
return storedTheme; |
|
|
} |
|
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; |
|
|
}; |
|
|
|
|
|
const setTheme = theme => { |
|
|
document.documentElement.setAttribute('data-bs-theme', theme); |
|
|
const toggleBtnIcon = document.querySelector('#theme-toggle i'); |
|
|
if (toggleBtnIcon) { |
|
|
toggleBtnIcon.className = theme === 'dark' ? 'fas fa-moon' : 'fas fa-sun'; |
|
|
} |
|
|
}; |
|
|
|
|
|
setTheme(getPreferredTheme()); |
|
|
|
|
|
window.addEventListener('DOMContentLoaded', () => { |
|
|
const themeToggle = document.getElementById('theme-toggle'); |
|
|
if(themeToggle) { |
|
|
themeToggle.addEventListener('click', () => { |
|
|
const currentTheme = getStoredTheme() || getPreferredTheme(); |
|
|
const newTheme = currentTheme === 'light' ? 'dark' : 'light'; |
|
|
setStoredTheme(newTheme); |
|
|
setTheme(newTheme); |
|
|
}); |
|
|
} |
|
|
|
|
|
const sidebarToggle = document.getElementById('sidebar-toggle'); |
|
|
const sidebar = document.querySelector('.sidebar'); |
|
|
if(sidebarToggle && sidebar) { |
|
|
sidebarToggle.addEventListener('click', () => { |
|
|
sidebar.classList.toggle('active'); |
|
|
}); |
|
|
} |
|
|
|
|
|
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]')); |
|
|
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) { |
|
|
return new bootstrap.Tooltip(tooltipTriggerEl); |
|
|
}); |
|
|
}); |
|
|
|
|
|
function formatCurrencyJS(value) { try { const number = parseFloat(String(value).replace(/\\s/g, '').replace(',', '.')); if (isNaN(number)) { return '0,00'; } return number.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); } catch (e) { return '0,00'; } } |
|
|
function formatIntegerJS(value) { try { const number = parseInt(String(value).replace(/\\s/g, '').replace(',', '.').split('.')[0]); if (isNaN(number)) { return '0'; } return number.toLocaleString('ru-RU'); } catch (e) { return '0'; } } |
|
|
function getStatusTextJS(statusKey) { const map = {'pending': 'Ожидает пошива','completed': 'Завершено','pending_qc': 'Ожидает ОТК','packed_ready_to_ship': 'Готово к отправке','shipped_client': 'Отправлено клиенту','shipped_dor_doi': 'Отправлено на Дордой', 'pending_procurement': 'Ожидает закупа'}; return map[statusKey] || statusKey; } |
|
|
function getStatusClassJS(statusKey) { const map = {'pending': 'status-pending','completed': 'status-completed','pending_qc': 'status-pending-qc','packed_ready_to_ship': 'status-ready-ship','shipped_client': 'status-shipped-client','shipped_dor_doi': 'status-shipped-dordoi', 'pending_procurement': 'status-pending-procurement'}; return map[statusKey] || ''; } |
|
|
function sortTable(columnIndex, tableId, isNumeric = false) { |
|
|
const table = document.getElementById(tableId); if (!table) return; |
|
|
const tbody = table.querySelector('tbody'); const headerRow = table.querySelector('thead tr'); if (!tbody || !headerRow) return; |
|
|
const rows = Array.from(tbody.querySelectorAll('tr:not(.no-result-row)')); if (rows.length < 2) return; |
|
|
const headerCell = headerRow.querySelector(`th:nth-child(${columnIndex + 1})`); if (!headerCell) return; |
|
|
let currentDir = headerCell.dataset.sortDir || 'asc'; let newDir = currentDir === 'asc' ? 'desc' : 'asc'; |
|
|
headerRow.querySelectorAll('th').forEach((th, index) => { |
|
|
const icon = th.querySelector('i.fa-sort, i.fa-sort-up, i.fa-sort-down'); |
|
|
if (icon) { icon.className = (index === columnIndex) ? `fas fa-sort-${newDir === 'asc' ? 'up' : 'down'}` : 'fas fa-sort'; } |
|
|
th.dataset.sortDir = (index === columnIndex) ? newDir : ''; |
|
|
}); |
|
|
rows.sort((a, b) => { |
|
|
let cellA = a.querySelector(`td:nth-child(${columnIndex + 1})`); let cellB = b.querySelector(`td:nth-child(${columnIndex + 1})`); |
|
|
let valA = cellA ? (cellA.dataset.sort || cellA.textContent || '').trim() : ''; let valB = cellB ? (cellB.dataset.sort || cellB.textContent || '').trim() : ''; |
|
|
let comparison = 0; |
|
|
if (isNumeric) { |
|
|
valA = parseFloat(String(valA).replace(/[^\\d,.-]/g, '').replace(',', '.')) || 0; |
|
|
valB = parseFloat(String(valB).replace(/[^\\d,.-]/g, '').replace(',', '.')) || 0; |
|
|
comparison = valA - valB; |
|
|
} else { valA = valA.toLowerCase(); valB = valB.toLowerCase(); comparison = valA.localeCompare(valB, 'ru'); } |
|
|
return newDir === 'asc' ? comparison : -comparison; |
|
|
}); |
|
|
rows.forEach(row => tbody.appendChild(row)); |
|
|
} |
|
|
</script> |
|
|
__SCRIPTS__ |
|
|
</body> |
|
|
</html> |
|
|
""" |
|
|
|
|
|
PROCUREMENT_CONTENT = """ |
|
|
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom"> |
|
|
<h1 class="h2">Закуп материалов</h1> |
|
|
</div> |
|
|
<div class="card mb-4"> |
|
|
<div class="card-header"><h5 class="mb-0"><i class="fas fa-clipboard-list me-2"></i>Активные заказы (Ожидают закупа)</h5></div> |
|
|
<div class="card-body"> |
|
|
{% if orders %} |
|
|
<div class="table-responsive"> |
|
|
<table class="table table-striped table-hover table-bordered"> |
|
|
<thead class="table-light"> |
|
|
<tr><th>ID</th><th>Клиент</th><th>Изделие</th><th>Ткань</th><th>Кол-во</th><th>Фурнитура</th><th>Создан</th><th>Действия</th></tr> |
|
|
</thead> |
|
|
<tbody> |
|
|
{% for order in orders %} |
|
|
<tr> |
|
|
<td><small class="font-monospace">{{ order.id[:8] }}</small></td> |
|
|
<td>{{ order.client_name }}</td> |
|
|
<td>{{ order.model_name }}</td> |
|
|
<td>{{ order.fabric_name }} ({{ order.fabric_quantity }})</td> |
|
|
<td>{{ order.items_quantity }} шт.</td> |
|
|
<td> |
|
|
{% if order.fittings %} |
|
|
<ul class="list-unstyled mb-0 small"> |
|
|
{% for f in order.fittings %}<li>{{ f.fitting_name }}: {{ f.quantity }} шт.</li>{% endfor %} |
|
|
</ul> |
|
|
{% else %}<span class="text-body-secondary">—</span>{% endif %} |
|
|
</td> |
|
|
<td><span data-bs-toggle="tooltip" title="{{ order.timestamp_created }}">{{ order.timestamp_created[:10] }}</span></td> |
|
|
<td> |
|
|
<form method="POST" action="{{ url_for('procurement') }}" class="d-inline"> |
|
|
<input type="hidden" name="order_id" value="{{ order.id }}"> |
|
|
<button type="submit" class="btn btn-sm btn-success" onclick="return confirm('Отметить заказ \'{{ order.model_name }}\' как закупленный?')"><i class="fas fa-check me-1"></i>Закуплено</button> |
|
|
</form> |
|
|
</td> |
|
|
</tr> |
|
|
{% endfor %} |
|
|
</tbody> |
|
|
</table> |
|
|
</div> |
|
|
{% else %} |
|
|
<p class="text-center text-body-secondary py-3 mb-0">Нет заказов, ожидающих закупа.</p> |
|
|
{% endif %} |
|
|
</div> |
|
|
</div> |
|
|
<div class="card"> |
|
|
<div class="card-header"><h5 class="mb-0"><i class="fas fa-plus-circle me-2"></i>Добавить закупленные материалы</h5></div> |
|
|
<div class="card-body"> |
|
|
<form method="POST" id="procurement-form"> |
|
|
<div id="material-rows"> |
|
|
<div class="dynamic-row p-3 mb-3 border rounded"> |
|
|
<div class="row g-2 align-items-end"> |
|
|
<div class="col-md-3"><label class="form-label">Название</label><input type="text" name="item_name[]" class="form-control" required></div> |
|
|
<div class="col-md-2"><label class="form-label">Кол-во</label><input type="text" name="item_quantity[]" class="form-control quantity-input" required inputmode="decimal"></div> |
|
|
<div class="col-md-1"><label class="form-label">Ед.</label><select name="item_unit[]" class="form-select" required><option value="м">м</option><option value="кг">кг</option><option value="шт">шт</option><option value="пач">пач</option><option value="рул">рул</option><option value="упак">упак</option></select></div> |
|
|
<div class="col-md-2"><label class="form-label">Цена за ед.</label><input type="text" name="item_price_per_unit[]" class="form-control" required inputmode="decimal"></div> |
|
|
<div class="col-md-2"><label class="form-label">Тип</label><select name="item_type[]" class="form-select item-type-select" required><option value="fabric">Ткань</option><option value="fittings">Фурнитура</option></select></div> |
|
|
<div class="col-md-2"><label class="form-label">Категория</label><select name="item_category[]" class="form-select category-select"><option value="Без категории">Без категории</option>{% for category in categories %}<option value="{{ category }}">{{ category }}</option>{% endfor %}<option value="__new__">-- Новая --</option></select></div> |
|
|
<div class="col-md-3"><label class="form-label">Расход на 1 ед.</label><div class="input-group"><input type="text" name="item_per_unit[]" class="form-control per-unit-input" inputmode="decimal"><span class="input-group-text calculation-result text-success small"></span></div></div> |
|
|
<div class="col-md-3 new-category-col" style="display: none;"><label class="form-label">Новая категория</label><input type="text" name="item_new_category[]" class="form-control new-category-input"></div> |
|
|
<div class="col-12 text-end"><button type="button" class="btn btn-sm btn-outline-danger remove-row-btn" onclick="removeRow(this)"><i class="fas fa-trash"></i></button></div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
<button type="button" class="btn btn-outline-secondary me-2" onclick="addRow()"><i class="fas fa-plus"></i> Добавить строку</button> |
|
|
<button type="submit" class="btn btn-primary"><i class="fas fa-check me-1"></i>Засчитать закуп</button> |
|
|
</form> |
|
|
</div> |
|
|
</div> |
|
|
""" |
|
|
|
|
|
PROCUREMENT_SCRIPTS = """ |
|
|
<script> |
|
|
function calculateItemsFromMaterial(row) { |
|
|
const quantityInput = row.querySelector('.quantity-input'); |
|
|
const perUnitInput = row.querySelector('.per-unit-input'); |
|
|
const calculationResult = row.querySelector('.calculation-result'); |
|
|
const itemTypeSelect = row.querySelector('.item-type-select'); |
|
|
if (!quantityInput || !perUnitInput || !calculationResult || !itemTypeSelect) return; |
|
|
|
|
|
const itemType = itemTypeSelect.value; |
|
|
perUnitInput.disabled = itemType !== 'fabric'; |
|
|
perUnitInput.closest('.input-group').style.display = itemType === 'fabric' ? '' : 'none'; |
|
|
|
|
|
if (itemType === 'fabric') { |
|
|
const quantityStr = quantityInput.value.replace(',', '.'); |
|
|
const materialPerUnitStr = perUnitInput.value.replace(',', '.'); |
|
|
const quantity = parseFloat(quantityStr) || 0; |
|
|
const materialPerUnit = parseFloat(materialPerUnitStr) || 0; |
|
|
if (quantity > 0 && materialPerUnit > 0) { |
|
|
const totalItems = Math.floor(quantity / materialPerUnit); |
|
|
calculationResult.textContent = `~${totalItems} шт.`; |
|
|
} else { |
|
|
calculationResult.textContent = '...'; |
|
|
} |
|
|
} else { |
|
|
perUnitInput.value = ''; |
|
|
calculationResult.textContent = '...'; |
|
|
} |
|
|
} |
|
|
function handleCategoryChange(selectElement) { |
|
|
const row = selectElement.closest('.dynamic-row'); if (!row) return; |
|
|
const newCategoryCol = row.querySelector('.new-category-col'); if (!newCategoryCol) return; |
|
|
const newCategoryInput = newCategoryCol.querySelector('.new-category-input'); |
|
|
const isNew = selectElement.value === '__new__'; |
|
|
newCategoryCol.style.display = isNew ? 'block' : 'none'; |
|
|
newCategoryInput.required = isNew; |
|
|
if (!isNew) newCategoryInput.value = ''; |
|
|
} |
|
|
|
|
|
function setupRow(row) { |
|
|
row.querySelector('.category-select').addEventListener('change', (e) => handleCategoryChange(e.target)); |
|
|
row.querySelectorAll('.quantity-input, .per-unit-input, .item-type-select').forEach(el => { |
|
|
el.addEventListener('input', () => calculateItemsFromMaterial(row)); |
|
|
}); |
|
|
calculateItemsFromMaterial(row); |
|
|
handleCategoryChange(row.querySelector('.category-select')); |
|
|
} |
|
|
function addRow() { |
|
|
const container = document.getElementById('material-rows'); |
|
|
const firstRow = container.querySelector('.dynamic-row'); if (!firstRow) return; |
|
|
const newRow = firstRow.cloneNode(true); |
|
|
newRow.querySelectorAll('input').forEach(i => i.value = ''); |
|
|
newRow.querySelectorAll('select').forEach(s => s.selectedIndex = 0); |
|
|
newRow.querySelector('.remove-row-btn').style.display = 'inline-block'; |
|
|
container.appendChild(newRow); |
|
|
setupRow(newRow); |
|
|
} |
|
|
function removeRow(button) { |
|
|
const row = button.closest('.dynamic-row'); |
|
|
if (row && document.querySelectorAll('#material-rows .dynamic-row').length > 1) { |
|
|
row.remove(); |
|
|
} |
|
|
} |
|
|
document.addEventListener('DOMContentLoaded', () => { |
|
|
document.querySelectorAll('.dynamic-row').forEach(row => setupRow(row)); |
|
|
if (!document.querySelector('#material-rows .dynamic-row')) { addRow(); } |
|
|
}); |
|
|
</script> |
|
|
""" |
|
|
|
|
|
CUTTING_CONTENT = """ |
|
|
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom"> |
|
|
<h1 class="h2">Регистрация раскроя</h1> |
|
|
</div> |
|
|
<div class="card"> |
|
|
<div class="card-body"> |
|
|
<form method="POST" id="cutting-form"> |
|
|
<div class="row g-3 align-items-end"> |
|
|
<div class="col-md-5"> |
|
|
<label for="fabric_id" class="form-label">Выберите ткань</label> |
|
|
<select id="fabric_id" name="fabric_id" class="form-select" required> |
|
|
<option value="" disabled selected>-- Выберите ткань из наличия --</option> |
|
|
{% for fabric in fabrics %} |
|
|
<option value="{{ fabric.id }}" data-unit="{{ fabric.unit }}" data-quantity="{{ fabric.quantity }}" data-material-per-unit="{{ fabric.material_per_unit|string|replace('.', ',') }}"> |
|
|
{{ fabric.name }} ({{ fabric.category | default('Без категории') }}) - {{ fabric.quantity_str }} {{ fabric.unit }} |
|
|
</option> |
|
|
{% endfor %} |
|
|
</select> |
|
|
<div id="available-quantity" class="form-text mt-1"></div> |
|
|
</div> |
|
|
<div class="col-md-3"> |
|
|
<label for="cut_items_quantity" class="form-label">Кол-во раскроенных изделий</label> |
|
|
<div class="input-group"> |
|
|
<input type="number" id="cut_items_quantity" name="cut_items_quantity" class="form-control" min="1" step="1" required> |
|
|
<span class="input-group-text">шт.</span> |
|
|
</div> |
|
|
</div> |
|
|
<div class="col-md-3"> |
|
|
<label for="fabric_used" class="form-label">Использовано ткани</label> |
|
|
<div class="input-group"> |
|
|
<input type="text" id="fabric_used" name="fabric_used" class="form-control" required inputmode="decimal"> |
|
|
<span class="input-group-text" id="fabric-unit">м</span> |
|
|
</div> |
|
|
</div> |
|
|
<div class="col-md-1"> |
|
|
<button type="submit" class="btn btn-primary w-100">Ok</button> |
|
|
</div> |
|
|
</div> |
|
|
<div class="form-text text-success mt-2" id="items-per-unit-info"></div> |
|
|
</form> |
|
|
</div> |
|
|
</div> |
|
|
""" |
|
|
|
|
|
CUTTING_SCRIPTS = """ |
|
|
<script> |
|
|
function updateCuttingInfo() { |
|
|
const select = document.getElementById('fabric_id'); |
|
|
const selectedOption = select ? select.options[select.selectedIndex] : null; |
|
|
const availableDiv = document.getElementById('available-quantity'); |
|
|
const itemsPerUnitInfoDiv = document.getElementById('items-per-unit-info'); |
|
|
const unitSpan = document.getElementById('fabric-unit'); |
|
|
|
|
|
if (!selectedOption || !selectedOption.value) { |
|
|
if(availableDiv) availableDiv.textContent = 'Выберите ткань для отображения информации.'; |
|
|
if(itemsPerUnitInfoDiv) itemsPerUnitInfoDiv.textContent = ''; |
|
|
if(unitSpan) unitSpan.textContent = 'м'; |
|
|
return; |
|
|
} |
|
|
|
|
|
const quantityRaw = selectedOption.getAttribute('data-quantity') || '0'; |
|
|
const unit = selectedOption.getAttribute('data-unit') || '?'; |
|
|
const materialPerUnitStr = selectedOption.getAttribute('data-material-per-unit') || '0'; |
|
|
const materialPerUnit = parseFloat(materialPerUnitStr.replace(',', '.')) || 0; |
|
|
const qtyNum = parseFloat(quantityRaw.replace(',', '.')) || 0; |
|
|
const quantityFormatted = formatCurrencyJS(qtyNum); |
|
|
|
|
|
if(availableDiv) availableDiv.innerHTML = `В наличии: <strong class="text-primary">${quantityFormatted} ${unit}</strong>`; |
|
|
if(unitSpan) unitSpan.textContent = unit; |
|
|
|
|
|
if (itemsPerUnitInfoDiv) { |
|
|
if (materialPerUnit > 0) { |
|
|
const totalPossibleItems = Math.floor(qtyNum / materialPerUnit); |
|
|
const materialPerUnitFormatted = formatCurrencyJS(materialPerUnit); |
|
|
itemsPerUnitInfoDiv.innerHTML = `Справочно: при расходе <strong>${materialPerUnitFormatted} ${unit}</strong> на изделие, хватит на <strong>~${totalPossibleItems} шт</strong>.`; |
|
|
} else { |
|
|
itemsPerUnitInfoDiv.innerHTML = '<span class="text-warning">Расход на 1 ед. не указан в закупе!</span>'; |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
document.addEventListener('DOMContentLoaded', () => { |
|
|
const fabricSelect = document.getElementById('fabric_id'); |
|
|
if(fabricSelect) { |
|
|
fabricSelect.addEventListener('change', updateCuttingInfo); |
|
|
} |
|
|
updateCuttingInfo(); |
|
|
|
|
|
const cuttingForm = document.getElementById('cutting-form'); |
|
|
if (cuttingForm) { |
|
|
cuttingForm.addEventListener('submit', function(event) { |
|
|
const fabricUsedInput = document.getElementById('fabric_used'); |
|
|
const selectedOption = fabricSelect ? fabricSelect.options[fabricSelect.selectedIndex] : null; |
|
|
if (selectedOption && selectedOption.value && fabricUsedInput) { |
|
|
const availableQtyRaw = selectedOption.getAttribute('data-quantity') || '0'; |
|
|
const availableQty = parseFloat(availableQtyRaw.replace(',', '.')) || 0; |
|
|
const usedQty = parseFloat(fabricUsedInput.value.replace(',', '.')) || 0; |
|
|
if (usedQty > availableQty) { |
|
|
alert(`Ошибка: Использовано ткани (${usedQty}) больше, чем доступно (${availableQty}).`); |
|
|
event.preventDefault(); |
|
|
} |
|
|
} |
|
|
}); |
|
|
} |
|
|
}); |
|
|
</script> |
|
|
""" |
|
|
|
|
|
NEW_SEWING_CONTENT = """ |
|
|
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom"> |
|
|
<h1 class="h2">Пошив</h1> |
|
|
</div> |
|
|
<div class="row"> |
|
|
<div class="col-lg-6 mb-4"> |
|
|
<div class="card h-100"> |
|
|
<div class="card-header"><h5 class="mb-0"><i class="fas fa-cut me-2"></i>Задания (Ожидают пошива)</h5></div> |
|
|
<div class="card-body"> |
|
|
{% if cutting_tasks %} |
|
|
<div class="table-responsive"> |
|
|
<table class="table table-hover table-sm"> |
|
|
<thead><tr><th>Ткань</th><th class="text-center">Осталось сшить</th><th>Дата</th><th></th></tr></thead> |
|
|
<tbody> |
|
|
{% for task in cutting_tasks %} |
|
|
<tr> |
|
|
<td>{{ task.fabric_name }}<br><small class="text-body-secondary font-monospace">{{ task.id[:8] }}</small></td> |
|
|
<td class="text-center fs-5 fw-bold text-danger">{{ task.remaining_quantity }}</td> |
|
|
<td><span data-bs-toggle="tooltip" title="{{ task.timestamp_created }}">{{ task.timestamp_created[:10] }}</span></td> |
|
|
<td class="text-end"><button type="button" class="btn btn-sm btn-primary" data-bs-toggle="modal" data-bs-target="#sewModal" data-task-id="{{ task.id }}" data-fabric-name="{{ task.fabric_name }}" data-remaining-qty="{{ task.remaining_quantity }}">Сшить <i class="fas fa-arrow-right"></i></button></td> |
|
|
</tr> |
|
|
{% endfor %} |
|
|
</tbody> |
|
|
</table> |
|
|
</div> |
|
|
{% else %} |
|
|
<p class="text-center text-body-secondary py-3 mb-0">Нет заданий, ожидающих пошива.</p> |
|
|
{% endif %} |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
<div class="col-lg-6 mb-4"> |
|
|
<div class="card h-100"> |
|
|
<div class="card-header"><h5 class="mb-0"><i class="fas fa-toolbox me-2"></i>Доступная фурнитура</h5></div> |
|
|
<div class="card-body"> |
|
|
<input type="text" id="fitting-search" class="form-control form-control-sm mb-3" placeholder="Поиск фурнитуры..."> |
|
|
{% if available_fittings %} |
|
|
<div class="table-responsive" style="max-height: 400px;"> |
|
|
<table class="table table-hover table-sm" id="fittings-table"> |
|
|
<thead class="table-light"><tr><th onclick="sortTable(0, 'fittings-table')">Название <i class="fas fa-sort"></i></th><th onclick="sortTable(1, 'fittings-table')">Категория <i class="fas fa-sort"></i></th><th onclick="sortTable(2, 'fittings-table', true)" class="text-end">В наличии <i class="fas fa-sort"></i></th></tr></thead> |
|
|
<tbody> |
|
|
{% for fitting in available_fittings %} |
|
|
<tr class="fitting-row" data-search="{{ fitting.name|lower }} {{ fitting.category|default('Без категории')|lower }}"> |
|
|
<td>{{ fitting.name }}</td><td><span class="badge bg-secondary-subtle border border-secondary-subtle text-secondary-emphasis rounded-pill">{{ fitting.category | default('Без категории') }}</span></td><td data-sort="{{ fitting.quantity }}" class="text-end">{{ fitting.quantity_str }} {{ fitting.unit }}</td> |
|
|
</tr> |
|
|
{% endfor %} |
|
|
<tr class="no-result-row" style="display: none;"><td colspan="3" class="text-center text-body-secondary py-3"></td></tr> |
|
|
</tbody> |
|
|
</table> |
|
|
</div> |
|
|
{% else %} |
|
|
<p class="text-center text-body-secondary py-3 mb-0">Нет доступной фурнитуры на складе.</p> |
|
|
{% endif %} |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="modal fade" id="sewModal" tabindex="-1" aria-labelledby="sewModalLabel" aria-hidden="true"> |
|
|
<div class="modal-dialog modal-lg"> |
|
|
<div class="modal-content"> |
|
|
<div class="modal-header"> |
|
|
<h5 class="modal-title" id="sewModalLabel">Регистрация пошива</h5> |
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> |
|
|
</div> |
|
|
<form method="POST" action="{{ url_for('sewing') }}" id="sewing-form"> |
|
|
<input type="hidden" name="cutting_task_id" id="sew_cutting_task_id"> |
|
|
<div class="modal-body"> |
|
|
<div class="alert alert-primary" role="alert"><strong>Задание на раскрой:</strong> <span id="sew_task_info"></span></div> |
|
|
<div class="row mb-3"> |
|
|
<div class="col-md-6"><label for="product_name" class="form-label">Название готового изделия <span class="text-danger">*</span></label><input type="text" id="product_name" name="product_name" class="form-control" required></div> |
|
|
<div class="col-md-6"><label for="quantity_to_sew" class="form-label">Количество для пошива <span class="text-danger">*</span></label><input type="number" id="quantity_to_sew" name="quantity_to_sew" class="form-control" min="1" step="1" required><div id="sew-qty-warning" class="form-text text-danger"></div></div> |
|
|
</div> |
|
|
<hr> |
|
|
<h6>Использованная фурнитура:</h6> |
|
|
<div id="sew-fittings-container"> |
|
|
<div class="row g-2 dynamic-fitting-row mb-2 align-items-center"> |
|
|
<div class="col-7"> |
|
|
<select name="fitting_ids[]" class="form-select form-select-sm fitting-select"> |
|
|
<option value="">-- Выберите фурнитуру --</option> |
|
|
{% for fitting in available_fittings %} |
|
|
<option value="{{ fitting.id }}" data-available="{{ fitting.quantity }}" data-unit="{{ fitting.unit }}"> |
|
|
{{ fitting.name }} (Доступно: {{ fitting.quantity_str }} {{ fitting.unit }}) |
|
|
</option> |
|
|
{% endfor %} |
|
|
</select> |
|
|
</div> |
|
|
<div class="col-3"><input type="number" name="fitting_quantities[]" class="form-control form-control-sm" min="1" step="1" placeholder="Кол-во" disabled></div> |
|
|
<div class="col-2 text-end"><button type="button" class="btn btn-sm btn-outline-danger" onclick="removeFittingRow(this)"><i class="fas fa-trash"></i></button></div> |
|
|
<div class="col-12"><div class="form-text fitting-qty-warning text-danger ps-1"></div></div> |
|
|
</div> |
|
|
</div> |
|
|
<button type="button" class="btn btn-sm btn-outline-secondary mt-2" onclick="addFittingRow()"><i class="fas fa-plus"></i> Добавить фурнитуру</button> |
|
|
</div> |
|
|
<div class="modal-footer"> |
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button> |
|
|
<button type="submit" class="btn btn-primary">Зарегистрировать пошив</button> |
|
|
</div> |
|
|
</form> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
""" |
|
|
|
|
|
NEW_SEWING_SCRIPTS = """ |
|
|
<script> |
|
|
document.addEventListener('DOMContentLoaded', () => { |
|
|
const searchInputFittings = document.getElementById('fitting-search'); |
|
|
const tableBodyFittings = document.getElementById('fittings-table')?.querySelector('tbody'); |
|
|
if (searchInputFittings && tableBodyFittings) { |
|
|
searchInputFittings.addEventListener('input', function() { |
|
|
const searchTerm = searchInputFittings.value.toLowerCase().trim(); |
|
|
const rows = tableBodyFittings.querySelectorAll('tr.fitting-row'); |
|
|
const noResultRow = tableBodyFittings.querySelector('.no-result-row'); |
|
|
let found = false; |
|
|
rows.forEach(row => { |
|
|
if ((row.dataset.search || '').includes(searchTerm)) { |
|
|
row.style.display = ''; found = true; |
|
|
} else { row.style.display = 'none'; } |
|
|
}); |
|
|
if (noResultRow) { |
|
|
if (!found && searchTerm) { |
|
|
noResultRow.querySelector('td').textContent = `Фурнитура "${searchTerm}" не найдена.`; |
|
|
noResultRow.style.display = ''; |
|
|
} else { noResultRow.style.display = 'none'; } |
|
|
} |
|
|
}); |
|
|
} |
|
|
|
|
|
const sewModal = document.getElementById('sewModal'); |
|
|
let maxSewQuantity = 0; |
|
|
if (sewModal) { |
|
|
sewModal.addEventListener('show.bs.modal', function (event) { |
|
|
const button = event.relatedTarget; |
|
|
const taskId = button.getAttribute('data-task-id'); |
|
|
const fabricName = button.getAttribute('data-fabric-name'); |
|
|
maxSewQuantity = parseInt(button.getAttribute('data-remaining-qty') || '0'); |
|
|
|
|
|
sewModal.querySelector('#sew_cutting_task_id').value = taskId; |
|
|
sewModal.querySelector('#sew_task_info').textContent = `#${taskId.substring(0, 8)}... (${fabricName}), доступно: ${maxSewQuantity} шт.`; |
|
|
const qtyInput = sewModal.querySelector('#quantity_to_sew'); |
|
|
qtyInput.value = maxSewQuantity; |
|
|
qtyInput.max = maxSewQuantity; |
|
|
qtyInput.dispatchEvent(new Event('input')); |
|
|
sewModal.querySelector('#product_name').value = fabricName; |
|
|
|
|
|
const fittingsContainer = sewModal.querySelector('#sew-fittings-container'); |
|
|
const firstFittingRow = fittingsContainer.querySelector('.dynamic-fitting-row').cloneNode(true); |
|
|
fittingsContainer.innerHTML = ''; |
|
|
firstFittingRow.querySelectorAll('select, input').forEach(el => el.value = ''); |
|
|
firstFittingRow.querySelector('input[type=number]').disabled = true; |
|
|
firstFittingRow.querySelector('.fitting-qty-warning').textContent = ''; |
|
|
fittingsContainer.appendChild(firstFittingRow); |
|
|
}); |
|
|
|
|
|
sewModal.querySelector('#quantity_to_sew').addEventListener('input', function() { |
|
|
const warningDiv = sewModal.querySelector('#sew-qty-warning'); |
|
|
const currentVal = parseInt(this.value) || 0; |
|
|
if (currentVal > maxSewQuantity) { |
|
|
warningDiv.textContent = `Максимум ${maxSewQuantity} шт. для этого задания!`; |
|
|
this.value = maxSewQuantity; |
|
|
} else if (currentVal <= 0 && this.value !== '') { |
|
|
warningDiv.textContent = `Количество должно быть больше нуля.`; |
|
|
this.value = 1; |
|
|
} else { warningDiv.textContent = ''; } |
|
|
}); |
|
|
|
|
|
sewModal.querySelector('#sew-fittings-container').addEventListener('change', e => { |
|
|
if(e.target.matches('.fitting-select')) updateFittingInfo(e.target); |
|
|
}); |
|
|
sewModal.querySelector('#sew-fittings-container').addEventListener('input', e => { |
|
|
if(e.target.matches('input[name="fitting_quantities[]"]')) validateFittingQty(e.target); |
|
|
}); |
|
|
} |
|
|
|
|
|
const sewingForm = document.getElementById('sewing-form'); |
|
|
if (sewingForm) { |
|
|
sewingForm.addEventListener('submit', function(event) { |
|
|
let valid = true; |
|
|
const qtyToSew = parseInt(document.getElementById('quantity_to_sew').value) || 0; |
|
|
if (qtyToSew <= 0 || qtyToSew > maxSewQuantity) { |
|
|
alert('Проверьте количество изделий для пошива.'); |
|
|
valid = false; |
|
|
} |
|
|
document.querySelectorAll('#sew-fittings-container .dynamic-fitting-row').forEach(row => { |
|
|
if (!validateFittingRow(row)) valid = false; |
|
|
}); |
|
|
if (!valid) event.preventDefault(); |
|
|
}); |
|
|
} |
|
|
}); |
|
|
|
|
|
function addFittingRow() { |
|
|
const container = document.getElementById('sew-fittings-container'); |
|
|
const firstRow = container.querySelector('.dynamic-fitting-row'); |
|
|
if (!firstRow) return; |
|
|
const newRow = firstRow.cloneNode(true); |
|
|
newRow.querySelectorAll('select, input').forEach(el => el.value = ''); |
|
|
newRow.querySelector('input[type=number]').disabled = true; |
|
|
newRow.querySelector('.fitting-qty-warning').textContent = ''; |
|
|
container.appendChild(newRow); |
|
|
} |
|
|
|
|
|
function removeFittingRow(button) { |
|
|
const row = button.closest('.dynamic-fitting-row'); |
|
|
if (row.parentElement.children.length > 1) { |
|
|
row.remove(); |
|
|
} else { |
|
|
row.querySelectorAll('select, input').forEach(el => el.value = ''); |
|
|
row.querySelector('input[type=number]').disabled = true; |
|
|
row.querySelector('.fitting-qty-warning').textContent = ''; |
|
|
} |
|
|
} |
|
|
function updateFittingInfo(select) { |
|
|
const row = select.closest('.dynamic-fitting-row'); |
|
|
const qtyInput = row.querySelector('input[name="fitting_quantities[]"]'); |
|
|
const selectedOption = select.options[select.selectedIndex]; |
|
|
if (selectedOption && selectedOption.value) { |
|
|
qtyInput.disabled = false; |
|
|
qtyInput.max = parseInt(selectedOption.getAttribute('data-available') || '0'); |
|
|
} else { |
|
|
qtyInput.disabled = true; |
|
|
qtyInput.value = ''; |
|
|
} |
|
|
validateFittingQty(qtyInput); |
|
|
} |
|
|
function validateFittingQty(input) { |
|
|
const row = input.closest('.dynamic-fitting-row'); |
|
|
const warningDiv = row.querySelector('.fitting-qty-warning'); |
|
|
const available = parseInt(input.max || '0'); |
|
|
const currentVal = parseInt(input.value) || 0; |
|
|
warningDiv.textContent = ''; |
|
|
if(input.value) { |
|
|
if (currentVal > available) { |
|
|
warningDiv.textContent = `Максимум ${available} шт. доступно!`; |
|
|
input.value = available; |
|
|
} else if (currentVal <= 0) { |
|
|
warningDiv.textContent = `Количество должно быть > 0.`; |
|
|
input.value = 1; |
|
|
} |
|
|
} |
|
|
} |
|
|
function validateFittingRow(row) { |
|
|
const select = row.querySelector('.fitting-select'); |
|
|
const qtyInput = row.querySelector('input[name="fitting_quantities[]"]'); |
|
|
if ((select.value && !qtyInput.value) || (!select.value && qtyInput.value)) { |
|
|
alert('Заполните и фурнитуру, и ее количество, или оставьте оба поля пустыми.'); |
|
|
return false; |
|
|
} |
|
|
return true; |
|
|
} |
|
|
</script> |
|
|
""" |
|
|
|
|
|
QC_PACKING_CONTENT = """ |
|
|
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom"> |
|
|
<h1 class="h2">ОТК и Упаковка</h1> |
|
|
</div> |
|
|
<div class="card"> |
|
|
<div class="card-body"> |
|
|
{% if sewing_tasks %} |
|
|
<form method="POST" id="qc-form" onsubmit="return validateQcForm()"> |
|
|
<div class="row mb-3"> |
|
|
<div class="col-md-8"> |
|
|
<label for="sewing_task_id" class="form-label">Выберите сшитое изделие</label> |
|
|
<select id="sewing_task_id" name="sewing_task_id" class="form-select" required> |
|
|
<option value="" disabled selected>-- Выберите из списка --</option> |
|
|
{% for task in sewing_tasks %} |
|
|
<option value="{{ task.id }}" data-sewn-quantity="{{ task.sewn_quantity }}" data-remaining-quantity="{{ task.remaining_quantity }}"> |
|
|
{{ task.product_name }} - Осталось: {{ task.remaining_quantity }} (Пошив от {{ task.timestamp_created[:10] }}) |
|
|
</option> |
|
|
{% endfor %} |
|
|
</select> |
|
|
</div> |
|
|
</div> |
|
|
<div class="row g-3"> |
|
|
<div class="col-md-4"> |
|
|
<label for="quantity_packed" class="form-label">Прошло ОТК (упаковано)</label> |
|
|
<input type="number" id="quantity_packed" name="quantity_packed" class="form-control" min="0" step="1" required disabled> |
|
|
</div> |
|
|
<div class="col-md-4"> |
|
|
<label for="quantity_defective" class="form-label">Брак (на этом этапе)</label> |
|
|
<input type="number" id="quantity_defective" name="quantity_defective" class="form-control" min="0" step="1" value="0" disabled> |
|
|
</div> |
|
|
<div class="col-md-4"> |
|
|
<label for="defect_reason" class="form-label">Причина брака</label> |
|
|
<input type="text" id="defect_reason" name="defect_reason" class="form-control" placeholder="Брак при ОТК/упаковке" disabled> |
|
|
</div> |
|
|
</div> |
|
|
<div id="qc-total-info" class="form-text mt-2 ps-1"></div> |
|
|
<button type="submit" class="btn btn-primary mt-3"><i class="fas fa-check-double me-1"></i>Отправить</button> |
|
|
</form> |
|
|
{% else %} |
|
|
<p class="text-center text-body-secondary py-3 mb-0">Нет изделий, ожидающих ОТК и упаковки.</p> |
|
|
{% endif %} |
|
|
</div> |
|
|
</div> |
|
|
""" |
|
|
|
|
|
QC_PACKING_SCRIPTS = """ |
|
|
<script> |
|
|
let maxAllowedQuantity = 0; |
|
|
|
|
|
function setupQcListeners() { |
|
|
const select = document.getElementById('sewing_task_id'); |
|
|
const packedInput = document.getElementById('quantity_packed'); |
|
|
const defectiveInput = document.getElementById('quantity_defective'); |
|
|
const reasonInput = document.getElementById('defect_reason'); |
|
|
|
|
|
if(select) select.addEventListener('change', updateQcQuantities); |
|
|
if(packedInput) packedInput.addEventListener('input', validateQcSum); |
|
|
if(defectiveInput) defectiveInput.addEventListener('input', validateQcSum); |
|
|
|
|
|
function updateQcQuantities() { |
|
|
const selectedOption = select ? select.options[select.selectedIndex] : null; |
|
|
const isTaskSelected = selectedOption && selectedOption.value; |
|
|
|
|
|
[packedInput, defectiveInput, reasonInput].forEach(el => el.disabled = !isTaskSelected); |
|
|
|
|
|
if (isTaskSelected) { |
|
|
maxAllowedQuantity = parseInt(selectedOption.getAttribute('data-remaining-quantity')) || 0; |
|
|
packedInput.max = maxAllowedQuantity; |
|
|
packedInput.value = maxAllowedQuantity > 0 ? maxAllowedQuantity : ''; |
|
|
defectiveInput.value = '0'; |
|
|
} else { |
|
|
maxAllowedQuantity = 0; |
|
|
[packedInput, defectiveInput, reasonInput].forEach(el => el.value = ''); |
|
|
} |
|
|
validateQcSum(); |
|
|
} |
|
|
|
|
|
function validateQcSum() { |
|
|
const totalInfo = document.getElementById('qc-total-info'); |
|
|
if (!packedInput || !defectiveInput || !totalInfo || select.value === "") { |
|
|
if (totalInfo) totalInfo.textContent = ''; |
|
|
return false; |
|
|
} |
|
|
|
|
|
const packed = parseInt(packedInput.value) || 0; |
|
|
const defective = parseInt(defectiveInput.value) || 0; |
|
|
const totalProcessed = packed + defective; |
|
|
|
|
|
totalInfo.classList.remove('text-danger', 'text-success', 'text-warning'); |
|
|
|
|
|
if (totalProcessed > maxAllowedQuantity) { |
|
|
totalInfo.textContent = `Ошибка: Сумма (${totalProcessed}) превышает ОСТАТОК (${maxAllowedQuantity})!`; |
|
|
totalInfo.classList.add('text-danger'); |
|
|
return false; |
|
|
} else if (totalProcessed <= 0 && (packedInput.value || defectiveInput.value)) { |
|
|
totalInfo.textContent = `Ошибка: Введите корректные положительные числа.`; |
|
|
totalInfo.classList.add('text-danger'); |
|
|
return false; |
|
|
} else if (totalProcessed === 0) { |
|
|
totalInfo.textContent = `Укажите кол-во упакованных или брака. Осталось обработать: ${maxAllowedQuantity} ед.`; |
|
|
totalInfo.classList.add('text-warning'); |
|
|
return false; |
|
|
} else { |
|
|
totalInfo.textContent = `Сумма (${totalProcessed}) корректна. Останется для обработки: ${maxAllowedQuantity - totalProcessed} ед.`; |
|
|
totalInfo.classList.add('text-success'); |
|
|
return true; |
|
|
} |
|
|
} |
|
|
|
|
|
window.validateQcForm = function() { |
|
|
if (!select || !select.value) { alert('Пожалуйста, выберите задание на пошив.'); return false; } |
|
|
if (!validateQcSum()) { alert('Ошибка в количестве. Проверьте введенные значения.'); return false; } |
|
|
return true; |
|
|
} |
|
|
|
|
|
updateQcQuantities(); |
|
|
} |
|
|
|
|
|
document.addEventListener('DOMContentLoaded', setupQcListeners); |
|
|
</script> |
|
|
""" |
|
|
|
|
|
CLIENTS_CONTENT = """ |
|
|
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom"> |
|
|
<h1 class="h2">База клиентов</h1> |
|
|
</div> |
|
|
<div class="card mb-4"> |
|
|
<div class="card-header"><h5 class="mb-0"><i class="fas fa-user-plus me-2"></i>Добавить нового клиента</h5></div> |
|
|
<div class="card-body"> |
|
|
<form method="POST" action="{{ url_for('clients_panel') }}"> |
|
|
<div class="row g-3 align-items-end"> |
|
|
<div class="col-md-4"><label for="client_name" class="form-label">Имя / Организация <span class="text-danger">*</span></label><input type="text" id="client_name" name="client_name" class="form-control" required></div> |
|
|
<div class="col-md-3"><label for="client_phone" class="form-label">Телефон <span class="text-danger">*</span></label><input type="tel" id="client_phone" name="client_phone" class="form-control" required></div> |
|
|
<div class="col-md-4"><label for="client_address" class="form-label">Адрес</label><input type="text" id="client_address" name="client_address" class="form-control"></div> |
|
|
<div class="col-md-1"><button type="submit" class="btn btn-primary w-100">Добавить</button></div> |
|
|
</div> |
|
|
</form> |
|
|
</div> |
|
|
</div> |
|
|
<div class="card"> |
|
|
<div class="card-header"><h5 class="mb-0"><i class="fas fa-list me-2"></i>Список клиентов</h5></div> |
|
|
<div class="card-body"> |
|
|
<div class="mb-3"><input type="text" id="client-search" class="form-control" placeholder="Поиск по имени, телефону или адресу..."></div> |
|
|
<div class="table-responsive"> |
|
|
<table class="table table-hover table-bordered" id="clients-table"> |
|
|
<thead class="table-light"><tr><th onclick="sortTable(0, 'clients-table')">Имя / Организация <i class="fas fa-sort"></i></th><th onclick="sortTable(1, 'clients-table')">Телефон <i class="fas fa-sort"></i></th><th onclick="sortTable(2, 'clients-table')">Адрес <i class="fas fa-sort"></i></th><th style="width: 12%;">Действия</th></tr></thead> |
|
|
<tbody> |
|
|
{% for client in clients %} |
|
|
<tr class="client-row" data-search="{{ client.name|lower }} {{ client.phone }} {{ client.address|default('')|lower }}"> |
|
|
<td>{{ client.name }}</td><td>{{ client.phone }}</td><td>{{ client.address | default('<span class="text-body-secondary">—</span>') | safe }}</td> |
|
|
<td> |
|
|
<div class="btn-group btn-group-sm"> |
|
|
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#historyModal-{{ client.id }}" title="История отправок"><i class="fas fa-history"></i></button> |
|
|
<button type="button" class="btn btn-outline-primary" onclick="editClient('{{ client.id }}', '{{ client.name }}', '{{ client.phone }}', '{{ client.address|default('')|replace("'", "\\'") }}')" title="Редактировать"><i class="fas fa-edit"></i></button> |
|
|
{% if not client.history %} |
|
|
<form method="POST" action="{{ url_for('clients_panel') }}" class="d-inline" onsubmit="return confirm('Удалить клиента \'{{ client.name }}\'?');"> |
|
|
<input type="hidden" name="action" value="delete"><input type="hidden" name="client_id" value="{{ client.id }}"> |
|
|
<button type="submit" class="btn btn-outline-danger" title="Удалить"><i class="fas fa-trash"></i></button> |
|
|
</form> |
|
|
{% endif %} |
|
|
</div> |
|
|
</td> |
|
|
</tr> |
|
|
{% else %}<tr><td colspan="4" class="text-center text-body-secondary py-4">Клиенты еще не добавлены.</td></tr>{% endfor %} |
|
|
<tr class="no-result-row" style="display: none;"><td colspan="4" class="text-center text-body-secondary py-4"></td></tr> |
|
|
</tbody> |
|
|
</table> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
<div class="modal fade" id="editClientModal" tabindex="-1" aria-labelledby="editClientModalLabel" aria-hidden="true"> |
|
|
<div class="modal-dialog"><div class="modal-content"> |
|
|
<div class="modal-header"><h5 class="modal-title" id="editClientModalLabel">Редактировать клиента</h5><button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button></div> |
|
|
<form method="POST" action="{{ url_for('clients_panel') }}"> |
|
|
<input type="hidden" name="action" value="edit"><input type="hidden" name="client_id" id="edit_client_id"> |
|
|
<div class="modal-body"> |
|
|
<div class="mb-3"><label for="edit_client_name" class="form-label">Имя / Организация <span class="text-danger">*</span></label><input type="text" class="form-control" id="edit_client_name" name="client_name" required></div> |
|
|
<div class="mb-3"><label for="edit_client_phone" class="form-label">Номер телефона <span class="text-danger">*</span></label><input type="tel" class="form-control" id="edit_client_phone" name="client_phone" required></div> |
|
|
<div class="mb-3"><label for="edit_client_address" class="form-label">Адрес</label><input type="text" class="form-control" id="edit_client_address" name="client_address"></div> |
|
|
</div> |
|
|
<div class="modal-footer"><button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button><button type="submit" class="btn btn-primary">Сохранить</button></div> |
|
|
</form> |
|
|
</div></div> |
|
|
</div> |
|
|
{% for client in clients %} |
|
|
<div class="modal fade" id="historyModal-{{ client.id }}" tabindex="-1" aria-labelledby="historyModalLabel-{{ client.id }}" aria-hidden="true"> |
|
|
<div class="modal-dialog modal-lg modal-dialog-scrollable"><div class="modal-content"> |
|
|
<div class="modal-header"><h5 class="modal-title" id="historyModalLabel-{{ client.id }}">История отправок: {{ client.name }}</h5><button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button></div> |
|
|
<div class="modal-body"> |
|
|
{% if client.history is iterable and client.history is not string and client.history %} |
|
|
<ul class="list-group list-group-flush"> |
|
|
{% for record in client.history %} |
|
|
<li class="list-group-item"> |
|
|
<div class="d-flex w-100 justify-content-between"> |
|
|
<h6 class="mb-1">Отправка от {{ record.timestamp_dt.strftime('%Y-%m-%d %H:%M') if record.timestamp_dt else record.timestamp[:16]|replace('T',' ') }}</h6> |
|
|
<small class="font-monospace" data-bs-toggle="tooltip" title="ID отправки: {{ record.get('shipment_id', 'N/A') }}">{{ record.get('shipment_id', 'N/A')[:8] }}</small> |
|
|
</div> |
|
|
{% if record.items is iterable and record.items is not string and record.items %} |
|
|
<ul class="mb-1">{% for item in record.items %}<li>{{ item.get('product_name', '?') }}: <strong>{{ item.get('quantity', '?') }}</strong> шт.</li>{% endfor %}</ul> |
|
|
{% else %}<p class="text-body-secondary small mb-1">(Нет данных о товарах)</p>{% endif %} |
|
|
<small class="text-body-secondary">Исходный ID упаковки: {{ record.get('packed_item_id', 'N/A')[:8] }}</small> |
|
|
</li> |
|
|
{% endfor %} |
|
|
</ul> |
|
|
{% else %}<p class="text-center text-body-secondary py-3 mb-0">Для этого клиента еще не было отправок.</p>{% endif %} |
|
|
</div> |
|
|
<div class="modal-footer"><button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Закрыть</button></div> |
|
|
</div></div> |
|
|
</div> |
|
|
{% endfor %} |
|
|
""" |
|
|
|
|
|
CLIENTS_SCRIPTS = """ |
|
|
<script> |
|
|
document.addEventListener('DOMContentLoaded', function () { |
|
|
const searchInput = document.getElementById('client-search'); |
|
|
const tableBody = document.getElementById('clients-table')?.querySelector('tbody'); |
|
|
if (searchInput && tableBody) { |
|
|
searchInput.addEventListener('input', function() { |
|
|
const searchTerm = searchInput.value.toLowerCase().trim(); |
|
|
const rows = tableBody.querySelectorAll('tr.client-row'); |
|
|
const noResultRow = tableBody.querySelector('.no-result-row'); |
|
|
let found = false; |
|
|
rows.forEach(row => { |
|
|
const searchData = row.dataset.search || ''; |
|
|
if (searchData.includes(searchTerm)) { row.style.display = ''; found = true; } |
|
|
else { row.style.display = 'none'; } |
|
|
}); |
|
|
if (noResultRow) { |
|
|
if (!found && searchTerm) { |
|
|
noResultRow.querySelector('td').textContent = `Клиенты не найдены по запросу "${searchTerm}".`; |
|
|
noResultRow.style.display = ''; |
|
|
} else { noResultRow.style.display = 'none'; } |
|
|
} |
|
|
}); |
|
|
} |
|
|
}); |
|
|
|
|
|
function editClient(id, name, phone, address) { |
|
|
document.getElementById('edit_client_id').value = id; |
|
|
document.getElementById('edit_client_name').value = name; |
|
|
document.getElementById('edit_client_phone').value = phone; |
|
|
document.getElementById('edit_client_address').value = address; |
|
|
new bootstrap.Modal(document.getElementById('editClientModal')).show(); |
|
|
} |
|
|
</script> |
|
|
""" |
|
|
|
|
|
ADMIN_CONTENT = """ |
|
|
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom"> |
|
|
<h1 class="h2">Обзор производства</h1> |
|
|
</div> |
|
|
<div class="row"> |
|
|
<div class="col-xl-8"> |
|
|
<div class="row"> |
|
|
<div class="col-lg-4 mb-4"><div class="card h-100"><div class="card-body"> <h6 class="card-subtitle mb-2 text-body-secondary"><i class="fas fa-boxes text-primary me-2"></i>Материалы</h6> <p class="card-text display-5 fw-bold">{{ materials_count }}</p><small>Позиций на складе (>0)</small></div></div></div> |
|
|
<div class="col-lg-4 mb-4"><div class="card h-100"><div class="card-body"> <h6 class="card-subtitle mb-2 text-body-secondary"><i class="fas fa-cut text-info me-2"></i>Ожидают пошива</h6> <p class="card-text display-5 fw-bold">{{ pending_cutting_count }}</p><small>Заданий раскроя</small></div></div></div> |
|
|
<div class="col-lg-4 mb-4"><div class="card h-100"><div class="card-body"> <h6 class="card-subtitle mb-2 text-body-secondary"><i class="fas fa-hourglass-half text-warning me-2"></i>Ожидают ОТК</h6> <p class="card-text display-5 fw-bold">{{ pending_qc_count }}</p><small>Заданий ({{ format_integer_py(pending_qc_quantity) }} шт.)</small></div></div></div> |
|
|
<div class="col-lg-4 mb-4"><div class="card h-100"><div class="card-body"> <h6 class="card-subtitle mb-2 text-body-secondary"><i class="fas fa-check-double text-success me-2"></i>Упаковано</h6> <p class="card-text display-5 fw-bold">{{ format_integer_py(total_packed_count) }}</p><small>Единиц товара</small></div></div></div> |
|
|
<div class="col-lg-4 mb-4"><div class="card h-100"><div class="card-body"> <h6 class="card-subtitle mb-2 text-body-secondary" style="color: #8b5cf6 !important;"><i class="fas fa-shipping-fast me-2"></i>Готово к отправке</h6> <p class="card-text display-5 fw-bold">{{ format_integer_py(items_ready_ship_qty) }}</p><small>{{ items_ready_ship_count }} партий</small></div></div></div> |
|
|
<div class="col-lg-4 mb-4"><div class="card h-100"><div class="card-body"> <h6 class="card-subtitle mb-2 text-body-secondary"><i class="fas fa-store text-info-emphasis me-2"></i>Отправлено на Дордой</h6> <p class="card-text display-5 fw-bold">{{ dordoi_shipments|length }}</p><small>Отправок</small></div></div></div> |
|
|
</div> |
|
|
</div> |
|
|
<div class="col-xl-4 mb-4"> |
|
|
<div class="card h-100 bg-danger-subtle border-danger"> |
|
|
<div class="card-header bg-transparent border-danger"><h5 class="mb-0 text-danger-emphasis"><i class="fas fa-exclamation-triangle me-2"></i>Брак (Всего)</h5></div> |
|
|
<div class="card-body"> |
|
|
<ul class="list-group list-group-flush"> |
|
|
<li class="list-group-item d-flex justify-content-between align-items-center bg-transparent px-0">Ткань:<span class="fw-bold">{{ total_defect_fabric_m }} м</span></li> |
|
|
<li class="list-group-item d-flex justify-content-between align-items-center bg-transparent px-0">Фурнитура:<span class="fw-bold">{{ total_defect_fittings_pcs }} шт.</span></li> |
|
|
<li class="list-group-item d-flex justify-content-between align-items-center bg-transparent px-0">Готовые изделия:<span class="fw-bold">{{ total_defect_finished_pcs }} шт.</span></li> |
|
|
<li class="list-group-item d-flex justify-content-between align-items-center bg-transparent px-0 border-top pt-3 mt-2">Общая стоимость брака:<span class="fw-bold fs-5">{{ total_defect_cost }} сом</span></li> |
|
|
</ul> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom"> |
|
|
<h1 class="h2">Настройки и Журналы</h1> |
|
|
</div> |
|
|
<div class="row"> |
|
|
<div class="col-lg-6 mb-4"> |
|
|
<div class="card"><div class="card-header"><h5 class="mb-0"><i class="fas fa-coins me-2"></i>Зарплаты и маржа</h5></div><div class="card-body"> |
|
|
<form action="{{ url_for('update_config') }}" method="POST"> |
|
|
<div class="row g-3"> |
|
|
<div class="col-6"><label class="form-label">ЗП Раскрой (сом/ед)</label><input type="text" name="salary_cutter" class="form-control" value="{{ config.salary_cutter_per_unit|string|replace('.', ',') }}" inputmode="decimal" required></div> |
|
|
<div class="col-6"><label class="form-label">ЗП Пошив (сом/изд)</label><input type="text" name="salary_sewer" class="form-control" value="{{ config.salary_sewer_per_unit|string|replace('.', ',') }}" inputmode="decimal" required></div> |
|
|
<div class="col-6"><label class="form-label">ЗП Упаковка (сом/изд)</label><input type="text" name="salary_packer" class="form-control" value="{{ config.salary_packer_per_unit|string|replace('.', ',') }}" inputmode="decimal" required></div> |
|
|
<div class="col-6"><label class="form-label">Маржа (сом/изд)</label><input type="text" name="margin" class="form-control" value="{{ config.margin_per_item|string|replace('.', ',') }}" inputmode="decimal" required></div> |
|
|
</div> |
|
|
<button type="submit" class="btn btn-primary mt-3"><i class="fas fa-save me-1"></i>Сохранить</button> |
|
|
</form> |
|
|
</div></div> |
|
|
</div> |
|
|
<div class="col-lg-6 mb-4"> |
|
|
<div class="card"><div class="card-header"><h5 class="mb-0"><i class="fas fa-tags me-2"></i>Категории материалов</h5></div><div class="card-body"> |
|
|
<h6>Добавить</h6><form action="{{ url_for('add_category') }}" method="POST" class="input-group mb-3"><input type="text" name="new_category_name" class="form-control" placeholder="Название новой категории" required><button type="submit" class="btn btn-outline-secondary"><i class="fas fa-plus"></i></button></form> |
|
|
<h6>Удалить</h6> |
|
|
{% if categories and categories|reject('equalto', 'Без категории')|list %} |
|
|
<form action="{{ url_for('delete_category') }}" method="POST" class="input-group"><select name="category_to_delete" class="form-select" required><option value="" disabled selected>-- Выберите --</option>{% for category in categories %}{% if category != 'Без категории' %}<option value="{{ category }}">{{ category }}</option>{% endif %}{% endfor %}</select><button type="submit" class="btn btn-outline-danger" onclick="return confirm('Удалить категорию? Материалы перейдут в \\'Без категории\\'. Необратимо!')"><i class="fas fa-trash"></i></button></form> |
|
|
{% else %}<p class="text-body-secondary small mb-0">Нет категорий для удаления.</p>{% endif %} |
|
|
</div></div> |
|
|
</div> |
|
|
</div> |
|
|
<ul class="nav nav-tabs mb-3" id="adminTabs" role="tablist"> |
|
|
<li class="nav-item" role="presentation"><button class="nav-link active" id="dispatch-tab" data-bs-toggle="tab" data-bs-target="#dispatch-content" type="button"><i class="fas fa-shipping-fast me-1"></i>Отправка ({{ items_ready_ship_count }})</button></li> |
|
|
<li class="nav-item" role="presentation"><button class="nav-link" id="materials-tab" data-bs-toggle="tab" data-bs-target="#materials-content" type="button"><i class="fas fa-boxes me-1"></i>Склад ({{ materials_count }})</button></li> |
|
|
<li class="nav-item" role="presentation"><button class="nav-link" id="cutting-tab" data-bs-toggle="tab" data-bs-target="#cutting-content" type="button"><i class="fas fa-cut me-1"></i>Раскрой ({{ cutting_tasks|length }})</button></li> |
|
|
<li class="nav-item" role="presentation"><button class="nav-link" id="sewing-tab" data-bs-toggle="tab" data-bs-target="#sewing-content" type="button"><i class="fas fa-tshirt me-1"></i>Пошив ({{ sewing_tasks|length }})</button></li> |
|
|
<li class="nav-item" role="presentation"><button class="nav-link" id="packed-tab" data-bs-toggle="tab" data-bs-target="#packed-content" type="button"><i class="fas fa-box-open me-1"></i>Упаковано ({{ packed_items|length }})</button></li> |
|
|
<li class="nav-item" role="presentation"><button class="nav-link" id="dordoi-history-tab" data-bs-toggle="tab" data-bs-target="#dordoi-history-content" type="button"><i class="fas fa-store me-1"></i>История Дордой ({{ dordoi_shipments|length }})</button></li> |
|
|
<li class="nav-item" role="presentation"><button class="nav-link" id="defects-tab" data-bs-toggle="tab" data-bs-target="#defects-content" type="button"><i class="fas fa-exclamation-triangle me-1"></i>Брак ({{ defect_log|length }})</button></li> |
|
|
</ul> |
|
|
<div class="tab-content" id="adminTabsContent"> |
|
|
<div class="tab-pane fade show active" id="dispatch-content" role="tabpanel"><div class="card"><div class="card-body"> |
|
|
{% if items_ready_to_ship %} |
|
|
<div class="table-responsive"><table class="table table-hover"><thead><tr><th>Изделие</th><th>В наличии</th><th>Дата упак.</th><th>Действия</th></tr></thead><tbody> |
|
|
{% for item in items_ready_to_ship|sort(attribute='timestamp_packed', reverse=True) %} |
|
|
<tr> |
|
|
<td>{{ item.product_name }}<br><small class="font-monospace text-body-secondary" title="{{ item.id }}">{{ item.id[:8] }}</small></td> |
|
|
<td class="fw-bold fs-5">{{ format_integer_py(item.quantity) }}</td> |
|
|
<td>{{ item.timestamp_packed[:16] | replace('T', ' ') if item.timestamp_packed else 'N/A' }}</td> |
|
|
<td> |
|
|
<form action="{{ url_for('dispatch_item') }}" method="POST" class="dispatch-form"> |
|
|
<input type="hidden" name="item_id" value="{{ item.id }}"> |
|
|
<div class="input-group"> |
|
|
<input type="number" name="quantity_to_dispatch" class="form-control dispatch-quantity-input" value="{{ item.quantity }}" min="1" max="{{ item.quantity }}" step="1" required data-max="{{ item.quantity }}" style="max-width: 100px;"> |
|
|
<select name="destination_type" class="form-select destination-select" required style="max-width: 150px;"><option value="" disabled selected>-- Куда --</option><option value="client">Клиенту</option><option value="dor_doi_point">На Дордой</option></select> |
|
|
<select name="client_id" class="form-select client-select" style="display: none; max-width: 200px;"><option value="">-- Выберите клиента --</option>{% for client in clients %}<option value="{{ client.id }}">{{ client.name }}</option>{% endfor %}</select> |
|
|
<button type="submit" class="btn btn-primary" title="Отправить"><i class="fas fa-truck"></i></button> |
|
|
</div> |
|
|
</form> |
|
|
</td> |
|
|
</tr>{% endfor %} |
|
|
</tbody></table></div> |
|
|
{% else %}<p class="text-center text-body-secondary py-3 mb-0">Нет товаров, готовых к отправке.</p>{% endif %} |
|
|
</div></div></div> |
|
|
<div class="tab-pane fade" id="materials-content" role="tabpanel"><div class="card"><div class="card-body"> |
|
|
<input type="text" id="material-search" class="form-control mb-3" placeholder="Поиск по названию или категории..."> |
|
|
<div class="table-responsive"><table class="table table-hover table-sm table-bordered" id="materials-table"><thead class="table-light"><tr> |
|
|
<th onclick="sortTable(0, 'materials-table')">Название <i class="fas fa-sort"></i></th><th onclick="sortTable(1, 'materials-table')">Категория <i class="fas fa-sort"></i></th><th onclick="sortTable(2, 'materials-table')">Тип <i class="fas fa-sort"></i></th><th onclick="sortTable(3, 'materials-table', true)" class="text-end">Кол-во <i class="fas fa-sort"></i></th> |
|
|
</tr></thead><tbody> |
|
|
{% for m in materials|sort(attribute='name') %} |
|
|
<tr class="material-row" data-search="{{ m.name|lower }} {{ m.category|default('Без категории')|lower }}"> |
|
|
<td>{{ m.name }}<br><small class="font-monospace text-body-secondary" title="{{ m.id }}">{{ m.id[:8] }}</small></td> |
|
|
<td><span class="badge bg-secondary-subtle border border-secondary-subtle text-secondary-emphasis rounded-pill">{{ m.category | default('Без категории') }}</span></td> |
|
|
<td><span class="badge rounded-pill text-bg-{{ 'primary' if m.type == 'fabric' else 'info' }}">{{ 'Ткань' if m.type == 'fabric' else 'Фурнитура' }}</span></td> |
|
|
<td data-sort="{{ m.quantity }}" class="text-end">{{ format_currency_py(m.quantity) if m.type == 'fabric' else format_integer_py(m.quantity) }} {{ m.unit }}</td> |
|
|
</tr>{% else %}<tr><td colspan="4" class="text-center text-body-secondary py-4">Нет материалов в наличии.</td></tr>{% endfor %} |
|
|
<tr class="no-result-row" style="display: none;"><td colspan="4" class="text-center text-body-secondary py-4"></td></tr> |
|
|
</tbody></table></div></div></div></div> |
|
|
<div class="tab-pane fade" id="cutting-content" role="tabpanel"><div class="card"><div class="card-body"> |
|
|
<div class="table-responsive"><table class="table table-hover table-sm table-bordered"><thead><tr><th>ID</th><th>Ткань</th><th>Раскроено</th><th>Сшито</th><th>Расход ткани</th><th>Статус</th><th>Даты</th></tr></thead><tbody> |
|
|
{% for task in cutting_tasks|sort(attribute='timestamp_created', reverse=True) %} |
|
|
<tr> |
|
|
<td class="font-monospace" title="{{ task.id }}">{{ task.id[:8] }}</td><td>{{ task.fabric_name }}</td><td>{{ format_integer_py(task.cut_items_quantity) }}</td><td>{{ format_integer_py(task.sewn_quantity) }}</td><td>{{ format_currency_py(task.fabric_used) }} {{ task.fabric_unit }}</td><td><span class="badge rounded-pill {{ getStatusClass(task.status) }}">{{ getStatusText(task.status) }}</span></td><td><small>Создано: {{ task.timestamp_created[:10] if task.timestamp_created else '-' }}<br>Завершено: {{ task.timestamp_completed[:10] if task.timestamp_completed else '-' }}</small></td> |
|
|
</tr>{% else %}<tr><td colspan="7" class="text-center text-body-secondary py-4">Нет заданий на раскрой.</td></tr>{% endfor %} |
|
|
</tbody></table></div></div></div></div> |
|
|
<div class="tab-pane fade" id="sewing-content" role="tabpanel"><div class="card"><div class="card-body"> |
|
|
<div class="table-responsive"><table class="table table-hover table-sm table-bordered"><thead><tr><th>ID</th><th>Изделие</th><th>Сшито</th><th>Упак./Брак</th><th>Статус</th><th>Даты</th></tr></thead><tbody> |
|
|
{% for task in sewing_tasks|sort(attribute='timestamp_created', reverse=True) %} |
|
|
<tr> |
|
|
<td class="font-monospace" title="{{ task.id }}">{{ task.id[:8] }}</td><td>{{ task.product_name }}</td><td>{{ format_integer_py(task.sewn_quantity) }}</td><td><span class="text-success">{{ format_integer_py(task.qc_packed_quantity) }}</span> / <span class="text-danger">{{ format_integer_py(task.qc_defective_quantity) }}</span></td><td><span class="badge rounded-pill {{ getStatusClass(task.status) }}">{{ getStatusText(task.status) }}</span></td><td><small>Создано: {{ task.timestamp_created[:10] if task.timestamp_created else '-' }}<br>Завершено: {{ task.timestamp_completed[:10] if task.timestamp_completed else '-' }}</small></td> |
|
|
</tr>{% else %}<tr><td colspan="6" class="text-center text-body-secondary py-4">Нет заданий на пошив.</td></tr>{% endfor %} |
|
|
</tbody></table></div></div></div></div> |
|
|
<div class="tab-pane fade" id="packed-content" role="tabpanel"><div class="card"><div class="card-body"> |
|
|
<div class="table-responsive"><table class="table table-hover table-sm table-bordered"><thead><tr><th>ID</th><th>Название</th><th>Кол-во</th><th>Статус</th><th>Дата упак.</th><th>Детали отправки</th></tr></thead><tbody> |
|
|
{% for item in packed_items|sort(attribute='timestamp_packed', reverse=True) %} |
|
|
<tr> |
|
|
<td class="font-monospace" title="{{ item.id }}">{{ item.id[:8] }}</td><td>{{ item.product_name }}</td><td>{{ format_integer_py(item.quantity) }}</td><td><span class="badge rounded-pill {{ getStatusClass(item.status) }}">{{ getStatusText(item.status) }}</span></td><td>{{ item.timestamp_packed[:10] if item.timestamp_packed else '-' }}</td> |
|
|
<td>{% set details = item.shipment_details %}{% if details is mapping %}<small>{{ details.get('timestamp')[:10] if details.get('timestamp') }}{% if details.get('type') == 'client' %} → {{ details.get('client_name', 'N/A')}}{% elif details.get('type') == 'dor_doi_point' %} → {{ details.get('destination', 'Дордой') }}{% endif %}</small>{% else %}<span class="text-body-secondary small">—</span>{% endif %}</td> |
|
|
</tr>{% else %}<tr><td colspan="6" class="text-center text-body-secondary py-4">Нет упакованных изделий.</td></tr>{% endfor %} |
|
|
</tbody></table></div></div></div></div> |
|
|
<div class="tab-pane fade" id="dordoi-history-content" role="tabpanel"><div class="card"><div class="card-body"> |
|
|
{% if dordoi_shipments %} |
|
|
<div class="table-responsive"><table class="table table-hover table-sm"><thead><tr><th>ID</th><th>Дата</th><th>Товары</th></tr></thead><tbody> |
|
|
{% for shipment in dordoi_shipments %} |
|
|
<tr> |
|
|
<td class="font-monospace" title="{{ shipment.shipment_id }}">{{ shipment.shipment_id[:8] }}</td><td>{{ shipment.timestamp_dt.strftime('%Y-%m-%d %H:%M') if shipment.timestamp_dt else '-' }}</td> |
|
|
<td>{% if shipment.items is iterable and shipment.items is not string and shipment.items %}{% for item in shipment.items %}{{ item.get('product_name', '?') }} - <strong>{{ item.get('quantity', '?') }} шт.</strong><br>{% endfor %}{% endif %}</td> |
|
|
</tr>{% endfor %} |
|
|
</tbody></table></div> |
|
|
{% else %}<p class="text-center text-body-secondary py-3 mb-0">Нет записей об отправках на Дордой.</p>{% endif %} |
|
|
</div></div></div> |
|
|
<div class="tab-pane fade" id="defects-content" role="tabpanel"><div class="card"><div class="card-body"> |
|
|
<div class="table-responsive"><table class="table table-hover table-sm table-bordered"><thead><tr><th>ID</th><th>Материал/Изделие</th><th>Тип</th><th>Кол-во</th><th>Стоимость</th><th>Этап</th><th>Дата</th></tr></thead><tbody> |
|
|
{% for defect in defect_log|sort(attribute='timestamp', reverse=True) %} |
|
|
<tr> |
|
|
<td class="font-monospace" title="{{ defect.log_id }}">{{ defect.log_id[:8] }}</td><td>{{ defect.material_name }}</td><td><span class="badge bg-dark">{{ defect.type|replace('_', ' ')|title }}</span></td><td>{{ defect.quantity_view }} {{ defect.unit }}</td><td>{{ format_currency_py(defect.cost_dec) }}</td><td><span class="badge rounded-pill text-bg-{{ 'warning' if defect.stage == 'qc_packing' else 'danger' }}">{{ defect.stage|replace('_', ' ')|title }}</span></td><td>{{ defect.timestamp[:10] if defect.timestamp else '-' }}</td> |
|
|
</tr>{% else %}<tr><td colspan="7" class="text-center text-body-secondary py-4">Записи о браке отсутствуют.</td></tr>{% endfor %} |
|
|
</tbody></table></div></div></div></div> |
|
|
</div> |
|
|
""" |
|
|
|
|
|
ADMIN_SCRIPTS = """ |
|
|
<script> |
|
|
document.addEventListener('DOMContentLoaded', function () { |
|
|
const searchInputMaterials = document.getElementById('material-search'); |
|
|
const tableBodyMaterials = document.getElementById('materials-table')?.querySelector('tbody'); |
|
|
if (searchInputMaterials && tableBodyMaterials) { |
|
|
searchInputMaterials.addEventListener('input', function() { |
|
|
const searchTerm = this.value.toLowerCase().trim(); |
|
|
const rows = tableBodyMaterials.querySelectorAll('tr.material-row'); |
|
|
const noResultRow = tableBodyMaterials.querySelector('.no-result-row'); |
|
|
let found = false; |
|
|
rows.forEach(row => { |
|
|
const shouldShow = (row.dataset.search || '').includes(searchTerm); |
|
|
row.style.display = shouldShow ? '' : 'none'; |
|
|
if (shouldShow) found = true; |
|
|
}); |
|
|
if (noResultRow) { |
|
|
noResultRow.style.display = found ? 'none' : ''; |
|
|
if (!found) noResultRow.querySelector('td').textContent = `Материалы не найдены по запросу "${searchTerm}".`; |
|
|
} |
|
|
}); |
|
|
} |
|
|
|
|
|
function handleDestinationChange(selectElement) { |
|
|
const form = selectElement.closest('.dispatch-form'); if (!form) return; |
|
|
const clientSelect = form.querySelector('.client-select'); |
|
|
if (!clientSelect) return; |
|
|
const isClient = selectElement.value === 'client'; |
|
|
clientSelect.style.display = isClient ? '' : 'none'; |
|
|
clientSelect.required = isClient; |
|
|
if (!isClient) clientSelect.value = ''; |
|
|
} |
|
|
|
|
|
document.querySelectorAll('.dispatch-form .destination-select').forEach(select => { |
|
|
select.addEventListener('change', () => handleDestinationChange(select)); |
|
|
handleDestinationChange(select); |
|
|
}); |
|
|
|
|
|
document.querySelectorAll('.dispatch-form').forEach(form => { |
|
|
form.addEventListener('submit', function(event) { |
|
|
if (!validateDispatchForm(this)) event.preventDefault(); |
|
|
}); |
|
|
}); |
|
|
|
|
|
function validateDispatchForm(form) { |
|
|
const quantityInput = form.querySelector('.dispatch-quantity-input'); |
|
|
const destinationSelect = form.querySelector('.destination-select'); |
|
|
const clientSelect = form.querySelector('.client-select'); |
|
|
const quantity = parseInt(quantityInput.value); |
|
|
const maxQuantity = parseInt(quantityInput.dataset.max); |
|
|
|
|
|
if (isNaN(quantity) || quantity <= 0) { alert('Укажите корректное количество.'); return false; } |
|
|
if (quantity > maxQuantity) { alert(`В наличии только ${maxQuantity} шт.`); return false; } |
|
|
if (!destinationSelect.value) { alert('Выберите назначение.'); return false; } |
|
|
if (destinationSelect.value === 'client' && !clientSelect.value) { alert('Выберите клиента.'); return false; } |
|
|
|
|
|
return confirm(`Отправить ${quantity} шт. (останется ${maxQuantity - quantity})? Действие необратимо.`); |
|
|
} |
|
|
|
|
|
var triggerTabList = [].slice.call(document.querySelectorAll('#adminTabs button[data-bs-toggle="tab"]')); |
|
|
triggerTabList.forEach(function (triggerEl) { |
|
|
var tab = new bootstrap.Tab(triggerEl); |
|
|
triggerEl.addEventListener('click', function (event) { |
|
|
event.preventDefault(); |
|
|
tab.show(); |
|
|
window.history.replaceState(null, null, window.location.pathname + window.location.search + this.getAttribute('data-bs-target')); |
|
|
}); |
|
|
}); |
|
|
|
|
|
var hash = window.location.hash; |
|
|
if (hash) { |
|
|
var tabEl = document.querySelector(`button[data-bs-target="${hash}"]`); |
|
|
if (tabEl) new bootstrap.Tab(tabEl).show(); |
|
|
} |
|
|
}); |
|
|
</script> |
|
|
""" |
|
|
|
|
|
REPORTS_CONTENT = """ |
|
|
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom"> |
|
|
<h1 class="h2">Отчеты</h1> |
|
|
</div> |
|
|
<div class="card mb-4"> |
|
|
<div class="card-header"><h5 class="mb-0"><i class="fas fa-filter me-2"></i>Фильтр периода</h5></div> |
|
|
<div class="card-body"> |
|
|
<form method="GET" action="{{ url_for('reports') }}" id="report-filter-form"> |
|
|
<div class="row g-3 align-items-end"> |
|
|
<div class="col-md-auto"><label for="filter_type" class="form-label">Период</label><select id="filter_type" name="filter" class="form-select"><option value="month" {% if report.filter_type == 'month' %}selected{% endif %}>Этот месяц</option><option value="week" {% if report.filter_type == 'week' %}selected{% endif %}>Эта неделя</option><option value="day" {% if report.filter_type == 'day' %}selected{% endif %}>День</option><option value="year" {% if report.filter_type == 'year' %}selected{% endif %}>Год</option><option value="custom" {% if report.filter_type == 'custom' %}selected{% endif %}>Произвольный</option></select></div> |
|
|
<div class="col-md-auto" id="date-selector-day" style="display: none;"><label for="filter_date_day" class="form-label">Дата</label><input type="text" id="filter_date_day" name="date" class="form-control flatpickr-date" value="{{ report.filter_values.get('date', report.current_day) }}"></div> |
|
|
<div class="col-md-auto" id="date-selector-month" style="display: none;"><label for="filter_date_month" class="form-label">Месяц</label><input type="month" id="filter_date_month" name="month" class="form-control" value="{{ report.filter_values.get('month', report.current_month) }}"></div> |
|
|
<div class="col-md-auto" id="date-selector-year" style="display: none;"><label for="filter_date_year" class="form-label">Год</label><input type="number" id="filter_date_year" name="year" class="form-control" value="{{ report.filter_values.get('year', report.current_year) }}" min="2020" max="2099" step="1"></div> |
|
|
<div class="col-md-auto" id="custom-start-date" style="display: none;"><label for="start_date" class="form-label">Начало</label><input type="text" id="start_date" name="start_date" class="form-control flatpickr-date" value="{{ report.start_date }}"></div> |
|
|
<div class="col-md-auto" id="custom-end-date" style="display: none;"><label for="end_date" class="form-label">Конец</label><input type="text" id="end_date" name="end_date" class="form-control flatpickr-date" value="{{ report.end_date }}"></div> |
|
|
<div class="col-md-auto"><button type="submit" class="btn btn-primary w-100"><i class="fas fa-search me-1"></i>Показать</button></div> |
|
|
</div> |
|
|
</form> |
|
|
</div> |
|
|
</div> |
|
|
<h4>Отчет за период: <span class="text-primary">{{ report.start_date }} - {{ report.end_date }}</span></h4> |
|
|
<div class="row row-cols-1 row-cols-md-2 row-cols-xl-3 g-4 my-3"> |
|
|
<div class="col"><div class="card h-100 border-start border-5 border-success"><div class="card-body"><h6 class="card-subtitle text-success">Выручка</h6><p class="card-text display-5 fw-bold">{{ format_currency_py(report.total_revenue) }}</p><small class="text-body-secondary">{{ format_integer_py(report.total_packed_qty) }} шт. упаковано</small></div></div></div> |
|
|
<div class="col"><div class="card h-100 border-start border-5 border-warning"><div class="card-body"><h6 class="card-subtitle text-warning">Прибыль</h6><p class="card-text display-5 fw-bold">{{ format_currency_py(report.total_profit) }}</p><small class="text-body-secondary">Выручка - Затраты</small></div></div></div> |
|
|
<div class="col"><div class="card h-100 border-start border-5 border-danger"><div class="card-body"><h6 class="card-subtitle text-danger">Затраты (Общие)</h6><p class="card-text display-5 fw-bold">{{ format_currency_py(report.total_overall_cost) }}</p><small class="text-body-secondary" data-bs-toggle="modal" data-bs-target="#costBreakdownModal" style="cursor: pointer;">Себест.+Брак+Доп. <i class="fas fa-info-circle"></i></small></div></div></div> |
|
|
</div> |
|
|
<ul class="nav nav-tabs mb-3" id="reportDetailsTabs" role="tablist"> |
|
|
<li class="nav-item" role="presentation"><button class="nav-link active" id="prod-summary-tab" data-bs-toggle="tab" data-bs-target="#prod-summary-content" type="button"><i class="fas fa-tags me-1"></i>Сводка по продуктам</button></li> |
|
|
<li class="nav-item" role="presentation"><button class="nav-link" id="packed-items-tab" data-bs-toggle="tab" data-bs-target="#packed-items-content" type="button"><i class="fas fa-check-double me-1"></i>Упакованные изделия</button></li> |
|
|
<li class="nav-item" role="presentation"><button class="nav-link" id="defects-report-tab" data-bs-toggle="tab" data-bs-target="#defects-report-content" type="button"><i class="fas fa-exclamation-triangle me-1"></i>Брак</button></li> |
|
|
<li class="nav-item" role="presentation"><button class="nav-link" id="expenses-report-tab" data-bs-toggle="tab" data-bs-target="#expenses-report-content" type="button"><i class="fas fa-file-invoice-dollar me-1"></i>Доп. расходы</button></li> |
|
|
</ul> |
|
|
<div class="tab-content card"> |
|
|
<div class="tab-pane fade show active p-3" id="prod-summary-content" role="tabpanel"><div class="table-responsive"><table class="table table-sm table-hover table-bordered"><thead><tr><th>Продукт</th><th>Упаковано (шт)</th><th>Выручка (сом)</th><th>Себестоимость (сом)</th><th>Прибыль (сом)</th><th>Средняя прибыль/шт</th></tr></thead><tbody> |
|
|
{% for name, summary in report.production_summary.items() %}{% set avg_profit = (summary.profit / summary.quantity) if summary.quantity > 0 else 0 %}<tr><td>{{ name }}</td><td>{{ format_integer_py(summary.quantity) }}</td><td>{{ format_currency_py(summary.revenue) }}</td><td>{{ format_currency_py(summary.cost) }}</td><td>{{ format_currency_py(summary.profit) }}</td><td>{{ format_currency_py(avg_profit) }}</td></tr>{% else %}<tr><td colspan="6" class="text-center text-body-secondary py-4">Нет данных.</td></tr>{% endfor %} |
|
|
</tbody></table></div></div> |
|
|
<div class="tab-pane fade p-3" id="packed-items-content" role="tabpanel"><div class="table-responsive"><table class="table table-sm table-hover table-bordered"><thead><tr><th>ID</th><th>Название</th><th>Кол-во</th><th>Себест. (ед.)</th><th>Цена (ед.)</th><th>Дата упак.</th><th>Статус</th><th>Детали отправки</th></tr></thead><tbody> |
|
|
{% for item in report.filtered_packed_items|sort(attribute='timestamp_packed', reverse=True) %}{% set qty = item.quantity if item.quantity > 0 else 1 %}{% set cost_per_item = item.packed_total_cost / qty if qty > 0 else 0 %}{% set price_per_item = item.packed_final_price / qty if qty > 0 else 0 %} |
|
|
<tr><td class="font-monospace" title="{{ item.id }}">{{ item.id[:8] }}</td><td>{{ item.product_name }}</td><td>{{ format_integer_py(item.quantity) }}</td><td>{{ format_currency_py(cost_per_item) }}</td><td>{{ format_currency_py(price_per_item) }}</td><td>{{ item.timestamp_packed[:16] | replace('T', ' ') if item.timestamp_packed else 'N/A' }}</td><td><span class="badge rounded-pill {{ getStatusClass(item.status) }}">{{ getStatusText(item.status) }}</span></td><td>{% set details = item.shipment_details %}{% if details is mapping %}<small>{{ item.shipment_time_dt.strftime('%Y-%m-%d') if item.shipment_time_dt else '' }} → {{ details.get('client_name', details.get('destination', '?')) }}</small>{% else %}<span class="text-body-secondary small">—</span>{% endif %}</td></tr> |
|
|
{% else %}<tr><td colspan="8" class="text-center text-body-secondary py-4">Нет упакованных изделий за этот период.</td></tr>{% endfor %} |
|
|
</tbody></table></div></div> |
|
|
<div class="tab-pane fade p-3" id="defects-report-content" role="tabpanel"><div class="table-responsive"><table class="table table-sm table-hover table-bordered"><thead><tr><th>ID</th><th>Материал/Изделие</th><th>Тип</th><th>Кол-во</th><th>Стоимость</th><th>Этап</th><th>Дата</th></tr></thead><tbody> |
|
|
{% for defect in report.filtered_defects|sort(attribute='timestamp', reverse=True) %}<tr><td class="font-monospace" title="{{ defect.log_id }}">{{ defect.log_id[:8] }}</td><td>{{ defect.material_name }}</td><td><span class="badge bg-dark">{{ defect.type|replace('_', ' ')|title }}</span></td><td>{{ defect.quantity_view }} {{ defect.unit }}</td><td>{{ format_currency_py(defect.cost_dec) }}</td><td><span class="badge rounded-pill text-bg-{{ 'warning' if defect.stage == 'qc_packing' else 'danger' }}">{{ defect.stage|replace('_', ' ')|title }}</span></td><td>{{ defect.timestamp[:16] | replace('T', ' ') if defect.timestamp else 'N/A' }}</td></tr>{% else %}<tr><td colspan="7" class="text-center text-body-secondary py-4">Нет записей о браке.</td></tr>{% endfor %} |
|
|
</tbody></table></div></div> |
|
|
<div class="tab-pane fade p-3" id="expenses-report-content" role="tabpanel"><div class="table-responsive"><table class="table table-sm table-hover table-bordered"><thead><tr><th>ID</th><th>Описание</th><th>Сумма (сом)</th><th>Дата</th><th>Действия</th></tr></thead><tbody> |
|
|
{% for expense in report.filtered_expenses|sort(attribute='timestamp', reverse=True) %}<tr><td class="font-monospace" title="{{ expense.id }}">{{ expense.id[:8] }}</td><td>{{ expense.description }}</td><td>{{ format_currency_py(expense.amount) }}</td><td>{{ expense.timestamp[:16]|replace('T',' ') if expense.timestamp else 'N/A' }}</td><td><form method="POST" action="{{ url_for('delete_expense', expense_id=expense.id) }}" class="d-inline" onsubmit="return confirm('Удалить?');"><button type="submit" class="btn btn-sm btn-outline-danger"><i class="fas fa-trash"></i></button></form></td></tr>{% else %}<tr><td colspan="5" class="text-center text-body-secondary py-4">Нет доп. расходов.</td></tr>{% endfor %} |
|
|
</tbody></table></div></div> |
|
|
</div> |
|
|
<div class="modal fade" id="costBreakdownModal" tabindex="-1" aria-labelledby="costBreakdownModalLabel" aria-hidden="true"><div class="modal-dialog"><div class="modal-content"> |
|
|
<div class="modal-header"><h5 class="modal-title" id="costBreakdownModalLabel">Детализация общих затрат</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div> |
|
|
<div class="modal-body"><ul class="list-group list-group-flush"> |
|
|
<li class="list-group-item d-flex justify-content-between align-items-center">Себестоимость упак.:<span class="badge bg-light-subtle text-emphasis-light border rounded-pill">{{ format_currency_py(report.total_cost_packed) }} сом</span></li> |
|
|
<li class="list-group-item d-flex justify-content-between align-items-center">ЗП (внутри себест.):<span class="badge bg-primary-subtle text-primary-emphasis border rounded-pill" data-bs-toggle="modal" data-bs-target="#salaryBreakdownModal" style="cursor: pointer;">{{ format_currency_py(report.total_salary_cost) }} сом <i class="fas fa-info-circle"></i></span></li> |
|
|
<li class="list-group-item d-flex justify-content-between align-items-center">Стоимость брака:<span class="badge bg-danger-subtle text-danger-emphasis border rounded-pill">{{ format_currency_py(report.total_defect_cost) }} сом</span></li> |
|
|
<li class="list-group-item d-flex justify-content-between align-items-center">Доп. расходы:<span class="badge bg-info-subtle text-info-emphasis border rounded-pill">{{ format_currency_py(report.total_expenses) }} сом</span></li> |
|
|
<li class="list-group-item d-flex justify-content-between align-items-center fw-bold">Итого общие затраты:<span class="badge bg-secondary-subtle text-secondary-emphasis border fs-6 rounded-pill">{{ format_currency_py(report.total_overall_cost) }} сом</span></li> |
|
|
</ul></div><div class="modal-footer"><button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Закрыть</button></div></div></div></div> |
|
|
<div class="modal fade" id="salaryBreakdownModal" tabindex="-1" aria-labelledby="salaryBreakdownModalLabel" aria-hidden="true"><div class="modal-dialog"><div class="modal-content"> |
|
|
<div class="modal-header"><h5 class="modal-title" id="salaryBreakdownModalLabel">Детализация зарплат</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div> |
|
|
<div class="modal-body"><ul class="list-group list-group-flush"> |
|
|
<li class="list-group-item d-flex justify-content-between">ЗП Раскройщиков:<span>{{ format_currency_py(report.total_cutter_salary) }} сом</span></li> |
|
|
<li class="list-group-item d-flex justify-content-between">ЗП Швей:<span>{{ format_currency_py(report.total_sewer_salary) }} сом</span></li> |
|
|
<li class="list-group-item d-flex justify-content-between">ЗП Упаковщиков:<span>{{ format_currency_py(report.total_packer_salary) }} сом</span></li> |
|
|
<li class="list-group-item d-flex justify-content-between fw-bold border-top pt-3 mt-2">Итого ЗП (Упакованные):<span>{{ format_currency_py(report.total_salary_cost) }} сом</span></li> |
|
|
</ul><small class="d-block text-body-secondary mt-2">Расчет основан на кол-ве упакованных изделий в период и текущих ставках.</small></div> |
|
|
<div class="modal-footer"><button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Закрыть</button></div></div></div></div> |
|
|
""" |
|
|
|
|
|
REPORTS_SCRIPTS = """ |
|
|
<script> |
|
|
document.addEventListener('DOMContentLoaded', function () { |
|
|
flatpickr(".flatpickr-date", { dateFormat: "Y-m-d", locale: "ru" }); |
|
|
const filterTypeSelect = document.getElementById('filter_type'); |
|
|
|
|
|
function toggleDateSelectors() { |
|
|
const selectedType = filterTypeSelect.value; |
|
|
const selectors = { |
|
|
'day': document.getElementById('date-selector-day'), |
|
|
'month': document.getElementById('date-selector-month'), |
|
|
'year': document.getElementById('date-selector-year'), |
|
|
'custom': [document.getElementById('custom-start-date'), document.getElementById('custom-end-date')] |
|
|
}; |
|
|
Object.values(selectors).flat().forEach(el => { if(el) el.style.display = 'none'; }); |
|
|
|
|
|
if (selectors[selectedType]) { |
|
|
(Array.isArray(selectors[selectedType]) ? selectors[selectedType] : [selectors[selectedType]]) |
|
|
.forEach(el => { if(el) el.style.display = 'block'; }); |
|
|
} |
|
|
} |
|
|
|
|
|
if (filterTypeSelect) { |
|
|
filterTypeSelect.addEventListener('change', toggleDateSelectors); |
|
|
toggleDateSelectors(); |
|
|
} |
|
|
}); |
|
|
</script> |
|
|
""" |
|
|
|
|
|
CLOUD_CONTENT = """ |
|
|
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom"> |
|
|
<h1 class="h2">Облачное хранилище</h1> |
|
|
</div> |
|
|
<div class="card mb-4"> |
|
|
<div class="card-header"><h5 class="mb-0"><i class="fas fa-upload me-2"></i>Загрузить файл</h5></div> |
|
|
<div class="card-body"> |
|
|
<form method="POST" action="{{ url_for('cloud_storage') }}" enctype="multipart/form-data"> |
|
|
<div class="mb-3"><label for="fileDescription" class="form-label">Описание файла</label><input type="text" class="form-control" id="fileDescription" name="description" placeholder="Необязательно"></div> |
|
|
<div class="mb-3"><label for="fileUpload" class="form-label">Выберите файл</label><input class="form-control" type="file" id="fileUpload" name="file" required><div class="form-text">Допустимо: png, jpg, gif, pdf, txt, doc(x), xls(x). Макс. 16MB.</div></div> |
|
|
<button type="submit" class="btn btn-primary"><i class="fas fa-upload me-1"></i>Загрузить</button> |
|
|
</form> |
|
|
</div> |
|
|
</div> |
|
|
<div class="card"> |
|
|
<div class="card-header"><h5 class="mb-0"><i class="fas fa-file-alt me-2"></i>Загруженные файлы</h5></div> |
|
|
<div class="card-body"> |
|
|
<div class="mb-3"><input type="search" class="form-control" id="cloud-search" placeholder="Поиск по описанию или имени файла..." value="{{ search_query }}"></div> |
|
|
{% if files %} |
|
|
<div class="table-responsive"> |
|
|
<table class="table table-hover" id="cloud-files-table"> |
|
|
<thead><tr><th>Предпросмотр</th><th>Имя файла</th><th>Описание</th><th>Размер</th><th>Загружен</th><th>Действия</th></tr></thead> |
|
|
<tbody> |
|
|
{% for file in files %} |
|
|
<tr class="file-row" data-search="{{ file.original_filename|lower }} {{ file.description|lower }}"> |
|
|
<td> |
|
|
<a href="{{ url_for('download_file', filename=file.stored_filename) }}" target="_blank"> |
|
|
{% if file.thumbnail_filename %} |
|
|
<img src="{{ url_for('get_thumbnail', filename=file.thumbnail_filename) }}" class="rounded" alt="Миниатюра" style="width: 60px; height: 60px; object-fit: cover;"> |
|
|
{% else %} |
|
|
<div class="d-flex align-items-center justify-content-center bg-body-secondary rounded" style="width: 60px; height: 60px;"> |
|
|
<i class="fas fa-file fa-2x text-body-tertiary"></i> |
|
|
</div> |
|
|
{% endif %} |
|
|
</a> |
|
|
</td> |
|
|
<td class="align-middle"><strong>{{ file.original_filename }}</strong></td> |
|
|
<td class="align-middle">{{ file.description or '<span class="text-body-secondary">—</span>' | safe }}</td> |
|
|
<td class="align-middle">{{ (file.size/1024)|round(1) }} КБ</td> |
|
|
<td class="align-middle">{{ file.timestamp[:10] }}</td> |
|
|
<td class="align-middle"> |
|
|
<div class="btn-group btn-group-sm"> |
|
|
<a href="{{ url_for('download_file', filename=file.stored_filename) }}" class="btn btn-outline-secondary" title="Скачать"><i class="fas fa-download"></i></a> |
|
|
<form method="post" action="{{ url_for('delete_cloud_file', file_id=file.file_id) }}" onsubmit="return confirm('Удалить файл \'{{ file.original_filename }}\'?')" class="d-inline"> |
|
|
<button type="submit" class="btn btn-outline-danger" title="Удалить"><i class="fas fa-trash"></i></button> |
|
|
</form> |
|
|
</div> |
|
|
</td> |
|
|
</tr> |
|
|
{% endfor %} |
|
|
<tr class="no-result-row" style="display: none;"><td colspan="6" class="text-center text-body-secondary py-4"></td></tr> |
|
|
</tbody> |
|
|
</table> |
|
|
</div> |
|
|
{% else %}<p class="text-center text-body-secondary py-3 mb-0">Файлы еще не загружены.</p>{% endif %} |
|
|
</div> |
|
|
</div> |
|
|
""" |
|
|
|
|
|
CLOUD_SCRIPTS = """ |
|
|
<script> |
|
|
document.addEventListener('DOMContentLoaded', function () { |
|
|
const searchInput = document.getElementById('cloud-search'); |
|
|
const tableBody = document.getElementById('cloud-files-table')?.querySelector('tbody'); |
|
|
if (searchInput && tableBody) { |
|
|
function filterFiles() { |
|
|
const searchTerm = searchInput.value.toLowerCase().trim(); |
|
|
const rows = tableBody.querySelectorAll('tr.file-row'); |
|
|
const noResultRow = tableBody.querySelector('.no-result-row'); |
|
|
let visibleCount = 0; |
|
|
rows.forEach(row => { |
|
|
const searchData = row.dataset.search || ''; |
|
|
const shouldShow = searchTerm === '' || searchData.includes(searchTerm); |
|
|
row.style.display = shouldShow ? '' : 'none'; |
|
|
if (shouldShow) visibleCount++; |
|
|
}); |
|
|
if (noResultRow) { |
|
|
if (visibleCount === 0 && searchTerm !== '') { |
|
|
noResultRow.querySelector('td').textContent = `Файлы не найдены по запросу "${searchTerm}".`; |
|
|
noResultRow.style.display = ''; |
|
|
} else { |
|
|
noResultRow.style.display = 'none'; |
|
|
} |
|
|
} |
|
|
} |
|
|
searchInput.addEventListener('input', filterFiles); |
|
|
if (searchInput.value) { filterFiles(); } |
|
|
} |
|
|
}); |
|
|
</script> |
|
|
""" |
|
|
|
|
|
ADVANCES_CONTENT = """ |
|
|
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom"> |
|
|
<h1 class="h2">Авансы</h1> |
|
|
</div> |
|
|
<div class="card mb-4"> |
|
|
<div class="card-header"><h5 class="mb-0"><i class="fas fa-money-bill-wave me-2"></i>Выдача аванса</h5></div> |
|
|
<div class="card-body"> |
|
|
<form method="POST" action="{{ url_for('advances') }}"> |
|
|
<div class="row g-3 align-items-end"> |
|
|
<div class="col-md-4"> |
|
|
<label for="employee_name" class="form-label">Имя сотрудника</label> |
|
|
<input type="text" id="employee_name" name="employee_name" class="form-control" required> |
|
|
</div> |
|
|
<div class="col-md-3"> |
|
|
<label for="role" class="form-label">Должность</label> |
|
|
<select id="role" name="role" class="form-select" required> |
|
|
<option value="" disabled selected>-- Выберите --</option><option value="cutter">Раскройщик</option><option value="sewer">Швея</option><option value="packer">Упаковщик</option> |
|
|
</select> |
|
|
</div> |
|
|
<div class="col-md-3"> |
|
|
<label for="amount" class="form-label">Сумма аванса</label> |
|
|
<div class="input-group"> |
|
|
<input type="text" id="amount" name="amount" class="form-control" required inputmode="decimal"> |
|
|
<span class="input-group-text">сом</span> |
|
|
</div> |
|
|
</div> |
|
|
<div class="col-md-2"> |
|
|
<button type="submit" class="btn btn-primary w-100"><i class="fas fa-hand-holding-usd me-1"></i>Выдать</button> |
|
|
</div> |
|
|
</div> |
|
|
</form> |
|
|
</div> |
|
|
</div> |
|
|
<div class="card"> |
|
|
<div class="card-header"><h5 class="mb-0"><i class="fas fa-list me-2"></i>История авансов</h5></div> |
|
|
<div class="card-body"> |
|
|
<div class="table-responsive"> |
|
|
<table class="table table-hover table-bordered"> |
|
|
<thead class="table-light"><tr><th>Дата выдачи</th><th>Сотрудник</th><th>Должность</th><th>Сумма</th><th>Статус</th><th>Действия</th></tr></thead> |
|
|
<tbody> |
|
|
{% for advance in advances %} |
|
|
<tr> |
|
|
<td>{{ advance.timestamp[:16]|replace('T', ' ') }}</td><td>{{ advance.employee_name }}</td> |
|
|
<td>{% if advance.role == 'cutter' %}Раскройщик{% elif advance.role == 'sewer' %}Швея{% elif advance.role == 'packer' %}Упаковщик{% else %}{{ advance.role }}{% endif %}</td> |
|
|
<td>{{ format_currency_py(advance.amount) }} сом</td> |
|
|
<td><span class="badge rounded-pill text-bg-{{ 'success' if advance.is_processed else 'warning' }}">{% if advance.is_processed %}Вычтено{% else %}Не вычтено{% endif %}</span></td> |
|
|
<td><form method="POST" action="{{ url_for('delete_advance', advance_id=advance.id) }}" class="d-inline" onsubmit="return confirm('Удалить запись об авансе?');"><button type="submit" class="btn btn-sm btn-outline-danger"><i class="fas fa-trash"></i></button></form></td> |
|
|
</tr> |
|
|
{% else %}<tr><td colspan="6" class="text-center text-body-secondary py-4">История авансов пуста</td></tr>{% endfor %} |
|
|
</tbody> |
|
|
</table> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
""" |
|
|
|
|
|
ADVANCES_SCRIPTS = """ |
|
|
<script> |
|
|
document.addEventListener('DOMContentLoaded', function() {}); |
|
|
</script> |
|
|
""" |
|
|
|
|
|
@app.context_processor |
|
|
def inject_utils(): |
|
|
return { |
|
|
'get_current_time': get_current_time, |
|
|
'getStatusText': getStatusText, |
|
|
'getStatusClass': getStatusClass, |
|
|
'format_currency_py': format_currency_py, |
|
|
'format_integer_py': format_integer_py, |
|
|
'to_decimal': to_decimal |
|
|
} |
|
|
|
|
|
if __name__ == '__main__': |
|
|
backup_thread = threading.Thread(target=periodic_backup, daemon=True) |
|
|
backup_thread.start() |
|
|
|
|
|
try: |
|
|
logging.info("Initial data loading...") |
|
|
load_data() |
|
|
load_client_data() |
|
|
logging.info("Data loaded/initialized successfully.") |
|
|
except Exception as e: |
|
|
logging.critical(f"Failed to load databases on startup: {e}", exc_info=True) |
|
|
|
|
|
logging.info("Starting Flask application on http://0.0.0.0:7860") |
|
|
app.run(debug=False, host='0.0.0.0', port=7860, use_reloader=False) |