mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-07 23:01:06 +02:00
PR feedback integrated
This commit is contained in:
@@ -36,7 +36,7 @@ from backend.app.schemas.print_queue import (
|
||||
)
|
||||
from backend.app.services.filament_deficit import compute_deficit_for_queue_item
|
||||
from backend.app.services.filament_requirements import overrides_for_plate
|
||||
from backend.app.services.finance_budget import validate_print_budget
|
||||
from backend.app.services.finance_budget import release_budget_reservation, validate_print_budget
|
||||
from backend.app.services.notification_service import notification_service
|
||||
from backend.app.utils.printer_models import (
|
||||
is_gcode_compatible,
|
||||
@@ -1000,6 +1000,12 @@ async def cancel_batch(
|
||||
cancelled_count = 0
|
||||
for item in pending_items:
|
||||
item.status = "cancelled"
|
||||
await release_budget_reservation(
|
||||
db,
|
||||
source_type="print_queue",
|
||||
source_id=item.id,
|
||||
status="released",
|
||||
)
|
||||
cancelled_count += 1
|
||||
|
||||
batch.status = "cancelled"
|
||||
@@ -1242,6 +1248,12 @@ async def delete_queue_item(
|
||||
if item.status == "printing":
|
||||
raise HTTPException(400, "Cannot delete item that is currently printing")
|
||||
|
||||
await release_budget_reservation(
|
||||
db,
|
||||
source_type="print_queue",
|
||||
source_id=item.id,
|
||||
status="released",
|
||||
)
|
||||
await db.delete(item)
|
||||
await db.commit()
|
||||
|
||||
@@ -1354,6 +1366,12 @@ async def cancel_queue_item(
|
||||
|
||||
item.status = "cancelled"
|
||||
item.completed_at = datetime.now(timezone.utc)
|
||||
await release_budget_reservation(
|
||||
db,
|
||||
source_type="print_queue",
|
||||
source_id=item.id,
|
||||
status="released",
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
logger.info("Cancelled queue item %s", item_id)
|
||||
|
||||
@@ -216,6 +216,11 @@ _APIKEY_DENIED_PERMISSIONS: frozenset[Permission] = frozenset(
|
||||
Permission.API_KEYS_UPDATE,
|
||||
Permission.API_KEYS_DELETE,
|
||||
Permission.API_KEYS_READ,
|
||||
# Finance / cost-center data has no dedicated API-key scope.
|
||||
Permission.COST_CENTERS_READ_OWN,
|
||||
Permission.COST_CENTERS_READ_ALL,
|
||||
Permission.COST_CENTERS_MODIFY,
|
||||
Permission.COST_CENTERS_CREATE,
|
||||
# GitHub backup admin + firmware OTA.
|
||||
Permission.GITHUB_BACKUP,
|
||||
Permission.GITHUB_RESTORE,
|
||||
|
||||
@@ -1024,6 +1024,217 @@ async def _migrate_widen_spoolman_slot_ams_id_range(conn) -> None:
|
||||
raise
|
||||
|
||||
|
||||
async def _migrate_create_finance_tables(conn) -> None:
|
||||
"""Create finance tables missing from databases that predate billing.
|
||||
|
||||
``Base.metadata.create_all()`` covers fresh installs, but upgrade and
|
||||
restore paths can run the handwritten migrations against an existing
|
||||
PostgreSQL schema. The finance column migrations below must therefore not
|
||||
assume these tables already exist.
|
||||
|
||||
``UserWallet`` is mapped to ``user_wallets``. The invitation table is kept
|
||||
as a compatibility table for pre-release billing databases; current code
|
||||
does not map or query it.
|
||||
"""
|
||||
if is_sqlite():
|
||||
statements = [
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS cost_centers (
|
||||
id INTEGER PRIMARY KEY,
|
||||
code VARCHAR(32) NOT NULL UNIQUE,
|
||||
name VARCHAR(150) NOT NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT 1,
|
||||
is_private BOOLEAN NOT NULL DEFAULT 0,
|
||||
owner_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
total_budget FLOAT,
|
||||
monthly_budget FLOAT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS user_wallets (
|
||||
id INTEGER PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
|
||||
balance FLOAT NOT NULL DEFAULT 0.0,
|
||||
currency VARCHAR(3) NOT NULL DEFAULT 'EUR',
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS cost_center_members (
|
||||
id INTEGER PRIMARY KEY,
|
||||
cost_center_id INTEGER NOT NULL REFERENCES cost_centers(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
can_print BOOLEAN NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT uq_cost_center_members_cc_user UNIQUE (cost_center_id, user_id)
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS cost_center_invitations (
|
||||
id INTEGER PRIMARY KEY,
|
||||
cost_center_id INTEGER NOT NULL REFERENCES cost_centers(id) ON DELETE CASCADE,
|
||||
email VARCHAR(320) NOT NULL,
|
||||
token VARCHAR(255) NOT NULL UNIQUE,
|
||||
invited_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
expires_at DATETIME,
|
||||
accepted_at DATETIME,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS wallet_transactions (
|
||||
id INTEGER PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
cost_center_id INTEGER REFERENCES cost_centers(id) ON DELETE SET NULL,
|
||||
transaction_type VARCHAR(40) NOT NULL,
|
||||
amount FLOAT NOT NULL,
|
||||
balance_after FLOAT,
|
||||
description TEXT,
|
||||
created_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
print_run_id VARCHAR(100),
|
||||
print_archive_id INTEGER REFERENCES print_archives(id) ON DELETE SET NULL,
|
||||
print_queue_id INTEGER REFERENCES print_queue(id) ON DELETE SET NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT ck_wallet_transactions_transaction_type CHECK (
|
||||
transaction_type IN ('print_charge', 'deposit', 'withdraw', 'manual_adjustment')
|
||||
)
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS budget_reservations (
|
||||
id INTEGER PRIMARY KEY,
|
||||
cost_center_id INTEGER NOT NULL REFERENCES cost_centers(id) ON DELETE CASCADE,
|
||||
amount FLOAT NOT NULL,
|
||||
status VARCHAR(20) NOT NULL,
|
||||
source_type VARCHAR(50) NOT NULL,
|
||||
source_id INTEGER,
|
||||
print_archive_id INTEGER REFERENCES print_archives(id) ON DELETE SET NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
released_at DATETIME
|
||||
)
|
||||
""",
|
||||
]
|
||||
else:
|
||||
statements = [
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS cost_centers (
|
||||
id SERIAL PRIMARY KEY,
|
||||
code VARCHAR(32) NOT NULL UNIQUE,
|
||||
name VARCHAR(150) NOT NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
is_private BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
owner_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
total_budget FLOAT,
|
||||
monthly_budget FLOAT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS user_wallets (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
|
||||
balance FLOAT NOT NULL DEFAULT 0.0,
|
||||
currency VARCHAR(3) NOT NULL DEFAULT 'EUR',
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS cost_center_members (
|
||||
id SERIAL PRIMARY KEY,
|
||||
cost_center_id INTEGER NOT NULL REFERENCES cost_centers(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
can_print BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT uq_cost_center_members_cc_user UNIQUE (cost_center_id, user_id)
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS cost_center_invitations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
cost_center_id INTEGER NOT NULL REFERENCES cost_centers(id) ON DELETE CASCADE,
|
||||
email VARCHAR(320) NOT NULL,
|
||||
token VARCHAR(255) NOT NULL UNIQUE,
|
||||
invited_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
expires_at TIMESTAMP,
|
||||
accepted_at TIMESTAMP,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS wallet_transactions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
cost_center_id INTEGER REFERENCES cost_centers(id) ON DELETE SET NULL,
|
||||
transaction_type VARCHAR(40) NOT NULL,
|
||||
amount FLOAT NOT NULL,
|
||||
balance_after FLOAT,
|
||||
description TEXT,
|
||||
created_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
print_run_id VARCHAR(100),
|
||||
print_archive_id INTEGER REFERENCES print_archives(id) ON DELETE SET NULL,
|
||||
print_queue_id INTEGER REFERENCES print_queue(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT ck_wallet_transactions_transaction_type CHECK (
|
||||
transaction_type IN ('print_charge', 'deposit', 'withdraw', 'manual_adjustment')
|
||||
)
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS budget_reservations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
cost_center_id INTEGER NOT NULL REFERENCES cost_centers(id) ON DELETE CASCADE,
|
||||
amount FLOAT NOT NULL,
|
||||
status VARCHAR(20) NOT NULL,
|
||||
source_type VARCHAR(50) NOT NULL,
|
||||
source_id INTEGER,
|
||||
print_archive_id INTEGER REFERENCES print_archives(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
released_at TIMESTAMP
|
||||
)
|
||||
""",
|
||||
]
|
||||
|
||||
for statement in statements:
|
||||
await _safe_execute(conn, statement)
|
||||
|
||||
|
||||
async def _migrate_create_finance_indexes(conn) -> None:
|
||||
"""Create finance indexes after legacy tables have received new columns."""
|
||||
indexes = [
|
||||
"CREATE INDEX IF NOT EXISTS ix_cost_centers_code ON cost_centers (code)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_cost_centers_name ON cost_centers (name)",
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS ix_user_wallets_user_id ON user_wallets (user_id)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_cost_center_members_cost_center_id ON cost_center_members (cost_center_id)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_cost_center_members_user_id ON cost_center_members (user_id)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_cost_center_invitations_cost_center_id "
|
||||
"ON cost_center_invitations (cost_center_id)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_cost_center_invitations_email ON cost_center_invitations (email)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_wallet_transactions_user_id ON wallet_transactions (user_id)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_wallet_transactions_cost_center_id ON wallet_transactions (cost_center_id)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_wallet_transactions_transaction_type "
|
||||
"ON wallet_transactions (transaction_type)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_wallet_transactions_created_by_user_id "
|
||||
"ON wallet_transactions (created_by_user_id)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_wallet_transactions_print_run_id ON wallet_transactions (print_run_id)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_wallet_transactions_print_archive_id "
|
||||
"ON wallet_transactions (print_archive_id)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_wallet_transactions_print_queue_id ON wallet_transactions (print_queue_id)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_wallet_transactions_created_at ON wallet_transactions (created_at)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_budget_reservations_cost_center_id "
|
||||
"ON budget_reservations (cost_center_id)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_budget_reservations_status ON budget_reservations (status)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_budget_reservations_source_type ON budget_reservations (source_type)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_budget_reservations_source_id ON budget_reservations (source_id)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_budget_reservations_print_archive_id "
|
||||
"ON budget_reservations (print_archive_id)",
|
||||
]
|
||||
for statement in indexes:
|
||||
await _safe_execute(conn, statement)
|
||||
|
||||
|
||||
async def run_migrations(conn):
|
||||
"""Run all schema migrations and data backfills on startup.
|
||||
|
||||
@@ -1038,6 +1249,11 @@ async def run_migrations(conn):
|
||||
"""
|
||||
from sqlalchemy import text
|
||||
|
||||
# Existing PostgreSQL databases predate the finance ORM tables. These must
|
||||
# exist before any ALTER TABLE / CREATE INDEX statements below reference
|
||||
# them. Fresh installs remain idempotent because create_all() runs first.
|
||||
await _migrate_create_finance_tables(conn)
|
||||
|
||||
# Migration: Add parent_run_id column to pipeline_runs (#1425 PR C).
|
||||
# Links a retry-failed run back to its parent so the dashboard can show
|
||||
# "Retry of run #N" inline. Idempotent on both SQLite and Postgres.
|
||||
@@ -1100,15 +1316,19 @@ async def run_migrations(conn):
|
||||
|
||||
# Migration: Add cost_center columns expected by current finance model
|
||||
await _safe_execute(conn, "ALTER TABLE cost_centers ADD COLUMN code VARCHAR(32)")
|
||||
await _safe_execute(conn, "ALTER TABLE cost_centers ADD COLUMN is_private BOOLEAN DEFAULT 0")
|
||||
if is_sqlite():
|
||||
await _safe_execute(conn, "ALTER TABLE cost_centers ADD COLUMN is_private BOOLEAN DEFAULT 0")
|
||||
else:
|
||||
await _safe_execute(conn, "ALTER TABLE cost_centers ADD COLUMN is_private BOOLEAN DEFAULT FALSE")
|
||||
await _safe_execute(
|
||||
conn,
|
||||
"ALTER TABLE cost_centers ADD COLUMN owner_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL",
|
||||
)
|
||||
await _safe_execute(conn, "ALTER TABLE cost_centers ADD COLUMN total_budget FLOAT")
|
||||
await _safe_execute(conn, "ALTER TABLE cost_centers ADD COLUMN monthly_budget FLOAT")
|
||||
await _safe_execute(conn, "ALTER TABLE cost_centers ADD COLUMN created_at DATETIME")
|
||||
await _safe_execute(conn, "ALTER TABLE cost_centers ADD COLUMN updated_at DATETIME")
|
||||
timestamp_type = "DATETIME" if is_sqlite() else "TIMESTAMP"
|
||||
await _safe_execute(conn, f"ALTER TABLE cost_centers ADD COLUMN created_at {timestamp_type}")
|
||||
await _safe_execute(conn, f"ALTER TABLE cost_centers ADD COLUMN updated_at {timestamp_type}")
|
||||
|
||||
# Backfill empty cost center codes on upgraded databases.
|
||||
if is_sqlite():
|
||||
@@ -1145,6 +1365,10 @@ async def run_migrations(conn):
|
||||
# can be billed independently without mutating archive history.
|
||||
await _safe_execute(conn, "ALTER TABLE wallet_transactions ADD COLUMN print_run_id VARCHAR(100)")
|
||||
|
||||
# CREATE TABLE IF NOT EXISTS is a no-op for an older, incomplete table.
|
||||
# Delay indexes until every legacy column they reference has been added.
|
||||
await _migrate_create_finance_indexes(conn)
|
||||
|
||||
# Migration: Add missing-spool-assignment print-start notification toggle
|
||||
try:
|
||||
async with conn.begin_nested():
|
||||
|
||||
@@ -90,9 +90,19 @@ async def _cost_center_open_queue_reservations(
|
||||
*,
|
||||
exclude_queue_item_id: int | None = None,
|
||||
) -> float:
|
||||
active_queue_reservation = (
|
||||
select(BudgetReservation.id)
|
||||
.where(
|
||||
BudgetReservation.status == "active",
|
||||
BudgetReservation.source_type == "print_queue",
|
||||
BudgetReservation.source_id == PrintQueueItem.id,
|
||||
)
|
||||
.exists()
|
||||
)
|
||||
conditions = [
|
||||
PrintQueueItem.cost_center_id == cost_center_id,
|
||||
PrintQueueItem.status.in_(("pending", "printing")),
|
||||
~active_queue_reservation,
|
||||
]
|
||||
if exclude_queue_item_id is not None:
|
||||
conditions.append(PrintQueueItem.id != exclude_queue_item_id)
|
||||
@@ -101,13 +111,25 @@ async def _cost_center_open_queue_reservations(
|
||||
return float(result.scalar() or 0.0)
|
||||
|
||||
|
||||
async def _cost_center_active_budget_reservations(db: AsyncSession, cost_center_id: int) -> float:
|
||||
result = await db.execute(
|
||||
select(func.coalesce(func.sum(BudgetReservation.amount), 0.0)).where(
|
||||
BudgetReservation.cost_center_id == cost_center_id,
|
||||
BudgetReservation.status == "active",
|
||||
async def _cost_center_active_budget_reservations(
|
||||
db: AsyncSession,
|
||||
cost_center_id: int,
|
||||
*,
|
||||
exclude_source_type: str | None = None,
|
||||
exclude_source_id: int | None = None,
|
||||
) -> float:
|
||||
conditions = [
|
||||
BudgetReservation.cost_center_id == cost_center_id,
|
||||
BudgetReservation.status == "active",
|
||||
]
|
||||
if exclude_source_type is not None and exclude_source_id is not None:
|
||||
conditions.append(
|
||||
~(
|
||||
(BudgetReservation.source_type == exclude_source_type)
|
||||
& (BudgetReservation.source_id == exclude_source_id)
|
||||
)
|
||||
)
|
||||
)
|
||||
result = await db.execute(select(func.coalesce(func.sum(BudgetReservation.amount), 0.0)).where(*conditions))
|
||||
return float(result.scalar() or 0.0)
|
||||
|
||||
|
||||
@@ -119,6 +141,8 @@ async def validate_print_budget(
|
||||
current_user: User | None,
|
||||
quantity: int = 1,
|
||||
exclude_queue_item_id: int | None = None,
|
||||
exclude_reservation_source_type: str | None = None,
|
||||
exclude_reservation_source_id: int | None = None,
|
||||
) -> None:
|
||||
"""Validate that a print can be assigned to a cost center budget."""
|
||||
if not await is_billing_enabled(db):
|
||||
@@ -160,7 +184,12 @@ async def validate_print_budget(
|
||||
cost_center_id,
|
||||
exclude_queue_item_id=exclude_queue_item_id,
|
||||
)
|
||||
reserved += await _cost_center_active_budget_reservations(db, cost_center_id)
|
||||
reserved += await _cost_center_active_budget_reservations(
|
||||
db,
|
||||
cost_center_id,
|
||||
exclude_source_type=exclude_reservation_source_type,
|
||||
exclude_source_id=exclude_reservation_source_id,
|
||||
)
|
||||
requested = estimated_cost * max(1, quantity)
|
||||
available = float(budget_limit) - used - reserved
|
||||
if requested > available:
|
||||
@@ -179,6 +208,7 @@ async def create_budget_reservation(
|
||||
source_type: str,
|
||||
source_id: int | None,
|
||||
print_archive_id: int | None = None,
|
||||
exclude_queue_item_id: int | None = None,
|
||||
) -> BudgetReservation | None:
|
||||
if not await is_billing_enabled(db):
|
||||
return None
|
||||
@@ -191,7 +221,28 @@ async def create_budget_reservation(
|
||||
cost_center_id=cost_center_id,
|
||||
estimated_cost=estimated_cost,
|
||||
current_user=current_user,
|
||||
exclude_queue_item_id=exclude_queue_item_id,
|
||||
exclude_reservation_source_type=source_type,
|
||||
exclude_reservation_source_id=source_id,
|
||||
)
|
||||
|
||||
existing = None
|
||||
if source_id is not None:
|
||||
existing = await db.scalar(
|
||||
select(BudgetReservation).where(
|
||||
BudgetReservation.status == "active",
|
||||
BudgetReservation.source_type == source_type,
|
||||
BudgetReservation.source_id == source_id,
|
||||
)
|
||||
)
|
||||
if existing is not None:
|
||||
existing.cost_center_id = cost_center_id
|
||||
existing.amount = float(estimated_cost or 0.0)
|
||||
if print_archive_id is not None:
|
||||
existing.print_archive_id = print_archive_id
|
||||
await db.flush()
|
||||
return existing
|
||||
|
||||
reservation = BudgetReservation(
|
||||
cost_center_id=cost_center_id,
|
||||
amount=float(estimated_cost or 0.0),
|
||||
|
||||
@@ -33,7 +33,11 @@ from backend.app.services.bambu_ftp import (
|
||||
with_ftp_retry,
|
||||
)
|
||||
from backend.app.services.filament_deficit import compute_deficit_for_queue_item
|
||||
from backend.app.services.finance_budget import validate_print_budget
|
||||
from backend.app.services.finance_budget import (
|
||||
create_budget_reservation,
|
||||
release_budget_reservation,
|
||||
validate_print_budget,
|
||||
)
|
||||
from backend.app.services.notification_service import notification_service
|
||||
from backend.app.services.printer_manager import (
|
||||
printer_manager,
|
||||
@@ -289,6 +293,11 @@ class PrintScheduler:
|
||||
# sequential caller, callbacks on the same loop, so no lock.
|
||||
# item_id -> (printer_id, remote_filename, archive_id)
|
||||
self._unconfirmed_expected_print: dict[int, tuple[int, str, int]] = {}
|
||||
# Budget reservations created for a dispatch whose print command has
|
||||
# not been confirmed yet. `_dispatch_one` releases these on every
|
||||
# unsuccessful exit; a successful start removes the item id and leaves
|
||||
# the reservation for finance_billing to consume with the archive.
|
||||
self._unconfirmed_budget_reservations: set[int] = set()
|
||||
|
||||
async def run(self):
|
||||
"""Main loop - check queue every interval."""
|
||||
@@ -970,6 +979,11 @@ class PrintScheduler:
|
||||
# A confirmed send removes the entry itself, so this is a no-op
|
||||
# on the happy path.
|
||||
self._rollback_unconfirmed_expected_print(item_id)
|
||||
# Mirror the pre-#1625 background-dispatch lifecycle: a
|
||||
# reservation survives only after start_print() accepted the
|
||||
# command. Failure, cancellation, deferral, and exceptions all
|
||||
# release it here.
|
||||
await asyncio.shield(self._release_unconfirmed_budget_reservation(item_db, item_id))
|
||||
# Release the claim on every exit. Once dispatch has finished the
|
||||
# row's status carries the lock (printing/failed/cancelled are all
|
||||
# != pending), so the token is only needed for the duration of the
|
||||
@@ -1001,6 +1015,38 @@ class PrintScheduler:
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
async def _release_unconfirmed_budget_reservation(self, db: AsyncSession, item_id: int) -> None:
|
||||
"""Release a queue reservation when dispatch ended before MQTT send."""
|
||||
if item_id not in self._unconfirmed_budget_reservations:
|
||||
return
|
||||
|
||||
for attempt in range(1, 4):
|
||||
try:
|
||||
await db.rollback()
|
||||
await release_budget_reservation(
|
||||
db,
|
||||
source_type="print_queue",
|
||||
source_id=item_id,
|
||||
status="released",
|
||||
)
|
||||
await db.commit()
|
||||
self._unconfirmed_budget_reservations.discard(item_id)
|
||||
return
|
||||
except Exception as exc:
|
||||
try:
|
||||
await db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
if attempt == 3:
|
||||
logger.error(
|
||||
"Queue item %s: failed to release budget reservation after %d attempts: %s",
|
||||
item_id,
|
||||
attempt,
|
||||
exc,
|
||||
)
|
||||
return
|
||||
await asyncio.sleep(0.5 * attempt)
|
||||
|
||||
async def _claim_for_dispatch(self, db: AsyncSession, item_id: int) -> bool:
|
||||
"""Atomically stamp ``dispatching_at`` on a still-pending, unclaimed row.
|
||||
|
||||
@@ -3094,6 +3140,10 @@ class PrintScheduler:
|
||||
"""
|
||||
logger.info("Starting queue item %s", item.id)
|
||||
|
||||
# Also covers a reservation left active by a process interruption
|
||||
# during an earlier attempt. `_dispatch_one` releases this marker on
|
||||
# every exit unless start_print() confirms that the command was sent.
|
||||
self._unconfirmed_budget_reservations.add(item.id)
|
||||
try:
|
||||
from backend.app.models.user import User
|
||||
|
||||
@@ -3104,7 +3154,20 @@ class PrintScheduler:
|
||||
estimated_cost=item.estimated_cost,
|
||||
current_user=queue_user,
|
||||
exclude_queue_item_id=item.id,
|
||||
exclude_reservation_source_type="print_queue",
|
||||
exclude_reservation_source_id=item.id,
|
||||
)
|
||||
budget_reservation = await create_budget_reservation(
|
||||
db,
|
||||
cost_center_id=item.cost_center_id,
|
||||
estimated_cost=item.estimated_cost,
|
||||
current_user=queue_user,
|
||||
source_type="print_queue",
|
||||
source_id=item.id,
|
||||
print_archive_id=item.archive_id,
|
||||
exclude_queue_item_id=item.id,
|
||||
)
|
||||
await db.commit()
|
||||
except HTTPException as exc:
|
||||
item.status = "failed"
|
||||
item.error_message = str(exc.detail)
|
||||
@@ -3240,6 +3303,8 @@ class PrintScheduler:
|
||||
)
|
||||
if archive:
|
||||
item.archive_id = archive.id
|
||||
if budget_reservation is not None:
|
||||
budget_reservation.print_archive_id = archive.id
|
||||
if item.cleanup_library_after_dispatch and not library_file.is_external:
|
||||
item.library_file_id = None
|
||||
cleanup_disk_paths.append(file_path)
|
||||
@@ -3680,6 +3745,7 @@ class PrintScheduler:
|
||||
# survive. Anything still in this dict when _dispatch_one exits gets
|
||||
# rolled back.
|
||||
self._unconfirmed_expected_print.pop(item.id, None)
|
||||
self._unconfirmed_budget_reservations.discard(item.id)
|
||||
logger.info("Queue item %s: Print started successfully - %s", item.id, filename)
|
||||
# No dispatch-toast event here: the legacy bg-dispatch path kept
|
||||
# status='processing' from upload start until the printer acked
|
||||
@@ -3967,6 +4033,12 @@ class PrintScheduler:
|
||||
f"prompt or error, confirm its SD card is readable, and start the job again."
|
||||
)
|
||||
item.completed_at = datetime.now(timezone.utc)
|
||||
await release_budget_reservation(
|
||||
db,
|
||||
source_type="print_queue",
|
||||
source_id=item.id,
|
||||
status="released",
|
||||
)
|
||||
await db.commit()
|
||||
return "gave_up"
|
||||
item.status = "pending"
|
||||
|
||||
@@ -134,6 +134,10 @@ class TestApiKeyDenylistIntegrity:
|
||||
Permission.API_KEYS_CREATE,
|
||||
Permission.API_KEYS_UPDATE,
|
||||
Permission.API_KEYS_DELETE,
|
||||
Permission.COST_CENTERS_READ_OWN,
|
||||
Permission.COST_CENTERS_READ_ALL,
|
||||
Permission.COST_CENTERS_MODIFY,
|
||||
Permission.COST_CENTERS_CREATE,
|
||||
Permission.GITHUB_BACKUP,
|
||||
Permission.GITHUB_RESTORE,
|
||||
Permission.FIRMWARE_UPDATE,
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
"""Budget-reservation lifecycle through the unified queue scheduler."""
|
||||
|
||||
from contextlib import ExitStack
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
import backend.app.models # noqa: F401 - populate Base.metadata
|
||||
import backend.app.services.print_scheduler as scheduler_module
|
||||
from backend.app.core.database import Base
|
||||
from backend.app.models.archive import PrintArchive
|
||||
from backend.app.models.finance import BudgetReservation, CostCenter
|
||||
from backend.app.models.print_queue import PrintQueueItem
|
||||
from backend.app.models.printer import Printer
|
||||
from backend.app.models.settings import Settings
|
||||
from backend.app.models.user import User
|
||||
from backend.app.services.finance_budget import validate_print_budget
|
||||
from backend.app.services.print_scheduler import PrintScheduler
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def billing_dispatch_case(tmp_path):
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
session_maker = async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
base_dir = tmp_path / "billing-dispatch"
|
||||
archive_rel = Path("archives") / "job.3mf"
|
||||
archive_abs = base_dir / archive_rel
|
||||
archive_abs.parent.mkdir(parents=True)
|
||||
archive_abs.write_bytes(b"archive payload")
|
||||
|
||||
async with session_maker() as db:
|
||||
db.add(Settings(key="billing_enabled", value="true"))
|
||||
user = User(username="scheduler-budget-admin", role="admin", is_active=True)
|
||||
cost_center = CostCenter(name="Scheduler Budget", is_active=True, monthly_budget=10.0)
|
||||
printer = Printer(
|
||||
name="Budget Printer",
|
||||
serial_number="BUDGET-SERIAL",
|
||||
ip_address="127.0.0.1",
|
||||
access_code="access-code",
|
||||
model="X1C",
|
||||
)
|
||||
db.add_all([user, cost_center, printer])
|
||||
await db.flush()
|
||||
|
||||
archive = PrintArchive(
|
||||
printer_id=printer.id,
|
||||
filename="job.3mf",
|
||||
file_path=str(archive_rel),
|
||||
file_size=archive_abs.stat().st_size,
|
||||
status="completed",
|
||||
cost=4.0,
|
||||
created_by_id=user.id,
|
||||
cost_center_id=cost_center.id,
|
||||
)
|
||||
db.add(archive)
|
||||
await db.flush()
|
||||
|
||||
item = PrintQueueItem(
|
||||
printer_id=printer.id,
|
||||
archive_id=archive.id,
|
||||
cost_center_id=cost_center.id,
|
||||
estimated_cost=4.0,
|
||||
created_by_id=user.id,
|
||||
status="pending",
|
||||
)
|
||||
db.add(item)
|
||||
await db.commit()
|
||||
|
||||
ids = SimpleNamespace(
|
||||
user_id=user.id,
|
||||
cost_center_id=cost_center.id,
|
||||
printer_id=printer.id,
|
||||
archive_id=archive.id,
|
||||
item_id=item.id,
|
||||
)
|
||||
|
||||
try:
|
||||
yield SimpleNamespace(session_maker=session_maker, base_dir=base_dir, ids=ids)
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def _dispatch(ctx, *, uploaded: bool = True, cancel_during_upload: bool = False):
|
||||
scheduler = PrintScheduler()
|
||||
start_print = MagicMock(return_value=True)
|
||||
|
||||
async def upload(*_args, **_kwargs):
|
||||
if cancel_during_upload:
|
||||
async with ctx.session_maker() as other_db:
|
||||
item = await other_db.get(PrintQueueItem, ctx.ids.item_id)
|
||||
item.status = "cancelled"
|
||||
await other_db.commit()
|
||||
return uploaded
|
||||
|
||||
patches = [
|
||||
patch.object(scheduler_module, "async_session", ctx.session_maker),
|
||||
patch.object(scheduler_module.settings, "base_dir", ctx.base_dir),
|
||||
patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=True)),
|
||||
patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock(return_value=None)),
|
||||
patch("backend.app.services.print_scheduler.printer_manager.start_print", start_print),
|
||||
patch("backend.app.services.print_scheduler.printer_manager.set_awaiting_plate_clear", MagicMock()),
|
||||
patch(
|
||||
"backend.app.services.print_scheduler.get_ftp_retry_settings",
|
||||
AsyncMock(return_value=(False, 0, 0, 1.0)),
|
||||
),
|
||||
patch("backend.app.services.print_scheduler.delete_file_async", AsyncMock(return_value=True)),
|
||||
patch("backend.app.services.print_scheduler.upload_file_async", upload),
|
||||
patch("backend.app.services.print_scheduler.cache_3mf_download", MagicMock()),
|
||||
patch("backend.app.services.print_scheduler.spawn_background_task", MagicMock()),
|
||||
patch("backend.app.services.notification_service.notification_service.on_queue_job_started", AsyncMock()),
|
||||
patch("backend.app.services.notification_service.notification_service.on_queue_job_failed", AsyncMock()),
|
||||
patch("backend.app.services.mqtt_relay.mqtt_relay.on_queue_job_started", AsyncMock()),
|
||||
patch.object(scheduler, "_propagate_owner_to_printer_manager", AsyncMock()),
|
||||
patch.object(scheduler, "_power_off_if_needed", AsyncMock()),
|
||||
patch.object(scheduler, "_preheat_and_soak", AsyncMock()),
|
||||
]
|
||||
with ExitStack() as stack:
|
||||
for patcher in patches:
|
||||
stack.enter_context(patcher)
|
||||
await scheduler._dispatch_one(ctx.ids.item_id)
|
||||
|
||||
return start_print
|
||||
|
||||
|
||||
async def _reservation(ctx):
|
||||
async with ctx.session_maker() as db:
|
||||
return await db.scalar(
|
||||
select(BudgetReservation).where(
|
||||
BudgetReservation.source_type == "print_queue",
|
||||
BudgetReservation.source_id == ctx.ids.item_id,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_successful_scheduler_dispatch_keeps_one_active_reservation(billing_dispatch_case):
|
||||
start_print = await _dispatch(billing_dispatch_case)
|
||||
|
||||
reservation = await _reservation(billing_dispatch_case)
|
||||
assert reservation is not None
|
||||
assert reservation.status == "active"
|
||||
assert reservation.amount == 4.0
|
||||
assert reservation.print_archive_id == billing_dispatch_case.ids.archive_id
|
||||
start_print.assert_called_once()
|
||||
|
||||
# The printing queue row and its persisted reservation represent the same
|
||||
# €4 hold. A second €6 job must fit exactly; €6.01 must not.
|
||||
async with billing_dispatch_case.session_maker() as db:
|
||||
user = await db.get(User, billing_dispatch_case.ids.user_id)
|
||||
second = PrintQueueItem(
|
||||
printer_id=billing_dispatch_case.ids.printer_id,
|
||||
archive_id=billing_dispatch_case.ids.archive_id,
|
||||
cost_center_id=billing_dispatch_case.ids.cost_center_id,
|
||||
estimated_cost=6.0,
|
||||
created_by_id=user.id,
|
||||
status="pending",
|
||||
)
|
||||
db.add(second)
|
||||
await db.commit()
|
||||
await validate_print_budget(
|
||||
db,
|
||||
cost_center_id=second.cost_center_id,
|
||||
estimated_cost=6.0,
|
||||
current_user=user,
|
||||
exclude_queue_item_id=second.id,
|
||||
)
|
||||
with pytest.raises(HTTPException, match="exceeds available"):
|
||||
await validate_print_budget(
|
||||
db,
|
||||
cost_center_id=second.cost_center_id,
|
||||
estimated_cost=6.01,
|
||||
current_user=user,
|
||||
exclude_queue_item_id=second.id,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_failure_releases_scheduler_reservation(billing_dispatch_case):
|
||||
start_print = await _dispatch(billing_dispatch_case, uploaded=False)
|
||||
|
||||
reservation = await _reservation(billing_dispatch_case)
|
||||
assert reservation is not None
|
||||
assert reservation.status == "released"
|
||||
assert reservation.released_at is not None
|
||||
start_print.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retried_dispatch_reuses_active_reservation(billing_dispatch_case):
|
||||
await _dispatch(billing_dispatch_case)
|
||||
|
||||
# Simulate startup recovery after the process stopped with a persisted
|
||||
# reservation and the queue row was made dispatchable again.
|
||||
async with billing_dispatch_case.session_maker() as db:
|
||||
item = await db.get(PrintQueueItem, billing_dispatch_case.ids.item_id)
|
||||
item.status = "pending"
|
||||
item.started_at = None
|
||||
item.dispatching_at = None
|
||||
await db.commit()
|
||||
|
||||
await _dispatch(billing_dispatch_case)
|
||||
|
||||
async with billing_dispatch_case.session_maker() as db:
|
||||
reservations = (
|
||||
(
|
||||
await db.execute(
|
||||
select(BudgetReservation).where(
|
||||
BudgetReservation.source_type == "print_queue",
|
||||
BudgetReservation.source_id == billing_dispatch_case.ids.item_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
assert len(reservations) == 1
|
||||
assert reservations[0].status == "active"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_during_upload_releases_scheduler_reservation(billing_dispatch_case):
|
||||
start_print = await _dispatch(billing_dispatch_case, cancel_during_upload=True)
|
||||
|
||||
reservation = await _reservation(billing_dispatch_case)
|
||||
assert reservation is not None
|
||||
assert reservation.status == "released"
|
||||
assert reservation.released_at is not None
|
||||
start_print.assert_not_called()
|
||||
|
||||
async with billing_dispatch_case.session_maker() as db:
|
||||
item = await db.get(PrintQueueItem, billing_dispatch_case.ids.item_id)
|
||||
active_count = await db.scalar(
|
||||
select(func.count()).select_from(BudgetReservation).where(BudgetReservation.status == "active")
|
||||
)
|
||||
assert item.status == "cancelled"
|
||||
assert active_count == 0
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Regression tests for finance tables on upgraded databases."""
|
||||
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from backend.app.core.database import _migrate_create_finance_indexes, _migrate_create_finance_tables
|
||||
|
||||
EXPECTED_TABLES = {
|
||||
"cost_centers",
|
||||
"wallet_transactions",
|
||||
"budget_reservations",
|
||||
"cost_center_members",
|
||||
"cost_center_invitations",
|
||||
"user_wallets",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_finance_tables_are_created_idempotently_on_sqlite():
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
with patch("backend.app.core.database.is_sqlite", return_value=True):
|
||||
await _migrate_create_finance_tables(conn)
|
||||
await _migrate_create_finance_tables(conn)
|
||||
|
||||
rows = await conn.execute(
|
||||
text(
|
||||
"SELECT name FROM sqlite_master "
|
||||
"WHERE type = 'table' AND name IN "
|
||||
"('cost_centers', 'wallet_transactions', 'budget_reservations', "
|
||||
"'cost_center_members', 'cost_center_invitations', 'user_wallets')"
|
||||
)
|
||||
)
|
||||
|
||||
assert {row[0] for row in rows} == EXPECTED_TABLES
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_legacy_cost_center_indexes_are_delayed_until_columns_exist():
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(text("CREATE TABLE cost_centers (id INTEGER PRIMARY KEY, name VARCHAR(150) NOT NULL)"))
|
||||
|
||||
with patch("backend.app.core.database.is_sqlite", return_value=True):
|
||||
await _migrate_create_finance_tables(conn)
|
||||
|
||||
await conn.execute(text("ALTER TABLE cost_centers ADD COLUMN code VARCHAR(32)"))
|
||||
await _migrate_create_finance_indexes(conn)
|
||||
|
||||
result = await conn.execute(
|
||||
text("SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'ix_cost_centers_code'")
|
||||
)
|
||||
|
||||
assert result.scalar_one() == "ix_cost_centers_code"
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_postgres_finance_ddl_uses_postgres_types():
|
||||
statements: list[str] = []
|
||||
|
||||
async def capture_statement(_conn, sql: str) -> None:
|
||||
statements.append(sql)
|
||||
|
||||
with (
|
||||
patch("backend.app.core.database.is_sqlite", return_value=False),
|
||||
patch("backend.app.core.database._safe_execute", side_effect=capture_statement),
|
||||
):
|
||||
await _migrate_create_finance_tables(object())
|
||||
|
||||
create_statements = [sql for sql in statements if "CREATE TABLE" in sql]
|
||||
assert len(create_statements) == len(EXPECTED_TABLES)
|
||||
assert all("IF NOT EXISTS" in sql for sql in create_statements)
|
||||
assert all("DATETIME" not in sql for sql in create_statements)
|
||||
assert all("id SERIAL PRIMARY KEY" in sql for sql in create_statements)
|
||||
assert "TIMESTAMP" in "\n".join(create_statements)
|
||||
|
||||
created_tables = {sql.split("CREATE TABLE IF NOT EXISTS", 1)[1].split("(", 1)[0].strip() for sql in create_statements}
|
||||
assert created_tables == EXPECTED_TABLES
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_finance_tables_are_created_idempotently_on_postgres():
|
||||
database_url = os.getenv("BAMBUDDY_TEST_POSTGRES_URL")
|
||||
if not database_url:
|
||||
pytest.skip("BAMBUDDY_TEST_POSTGRES_URL is not configured")
|
||||
|
||||
engine = create_async_engine(database_url)
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
# Minimal pre-billing schema: these are the only tables referenced
|
||||
# by foreign keys in the new finance tables.
|
||||
await conn.execute(text("CREATE TABLE users (id SERIAL PRIMARY KEY)"))
|
||||
await conn.execute(text("CREATE TABLE print_archives (id SERIAL PRIMARY KEY)"))
|
||||
await conn.execute(text("CREATE TABLE print_queue (id SERIAL PRIMARY KEY)"))
|
||||
|
||||
with patch("backend.app.core.database.is_sqlite", return_value=False):
|
||||
await _migrate_create_finance_tables(conn)
|
||||
await _migrate_create_finance_tables(conn)
|
||||
await _migrate_create_finance_indexes(conn)
|
||||
await _migrate_create_finance_indexes(conn)
|
||||
|
||||
rows = await conn.execute(
|
||||
text(
|
||||
"SELECT table_name FROM information_schema.tables "
|
||||
"WHERE table_schema = 'public' AND table_name = ANY(:tables)"
|
||||
),
|
||||
{"tables": sorted(EXPECTED_TABLES)},
|
||||
)
|
||||
timestamp_type = await conn.execute(
|
||||
text(
|
||||
"SELECT data_type FROM information_schema.columns "
|
||||
"WHERE table_schema = 'public' "
|
||||
"AND table_name = 'cost_centers' AND column_name = 'created_at'"
|
||||
)
|
||||
)
|
||||
|
||||
assert {row[0] for row in rows} == EXPECTED_TABLES
|
||||
assert timestamp_type.scalar_one() == "timestamp without time zone"
|
||||
finally:
|
||||
await engine.dispose()
|
||||
@@ -56,6 +56,7 @@ async def test_unauthorized_active_print_triggers_stop(monkeypatch):
|
||||
connected=True,
|
||||
state="RUNNING",
|
||||
progress=0,
|
||||
remaining_time=0,
|
||||
layer_num=0,
|
||||
temperatures={},
|
||||
raw_data={},
|
||||
@@ -106,6 +107,7 @@ async def test_bambuddy_authorized_print_is_not_stopped(monkeypatch):
|
||||
connected=True,
|
||||
state="RUNNING",
|
||||
progress=0,
|
||||
remaining_time=0,
|
||||
layer_num=0,
|
||||
temperatures={},
|
||||
raw_data={},
|
||||
@@ -154,6 +156,7 @@ async def test_unauthorized_print_state_is_cleared_when_print_ends(monkeypatch):
|
||||
connected=True,
|
||||
state="RUNNING",
|
||||
progress=0,
|
||||
remaining_time=0,
|
||||
layer_num=0,
|
||||
temperatures={},
|
||||
raw_data={},
|
||||
@@ -175,6 +178,7 @@ async def test_unauthorized_print_state_is_cleared_when_print_ends(monkeypatch):
|
||||
connected=True,
|
||||
state="IDLE",
|
||||
progress=0,
|
||||
remaining_time=0,
|
||||
layer_num=0,
|
||||
temperatures={},
|
||||
raw_data={},
|
||||
|
||||
@@ -118,6 +118,7 @@ async def _dispatch_library_item(ctx, *, archive_failure=False, unlink_side_effe
|
||||
original_filename,
|
||||
created_by_id=None,
|
||||
project_id=None,
|
||||
cost_center_id=None,
|
||||
plate_id=None,
|
||||
library_file_id=None,
|
||||
):
|
||||
|
||||
@@ -9,6 +9,7 @@ export default {
|
||||
maintenance: 'Mantenimiento',
|
||||
projects: 'Proyectos',
|
||||
inventory: 'Filamento',
|
||||
finance: 'Finanzas',
|
||||
files: 'Gestor de archivos',
|
||||
makerworld: 'MakerWorld',
|
||||
notifications: 'Notificaciones',
|
||||
@@ -123,6 +124,96 @@ export default {
|
||||
duplicate: 'Duplicar',
|
||||
left: 'Izquierda',
|
||||
right: 'Derecha',
|
||||
user: 'Usuario',
|
||||
run: 'Ejecutar',
|
||||
running: 'Ejecutando...',
|
||||
},
|
||||
|
||||
finance: {
|
||||
allTypes: 'Todos los tipos',
|
||||
allCostCenters: 'Todos los centros de costes',
|
||||
title: 'Finanzas',
|
||||
subtitle: 'Monedero, transacciones personales y centros de costes',
|
||||
noAccess: 'No tienes permiso para ver los datos financieros.',
|
||||
personalView: 'Vista personal',
|
||||
adminView: 'Vista de administración',
|
||||
createCostCenter: 'Crear centro de costes',
|
||||
adjustWallet: 'Ajustar monedero',
|
||||
addManualPrint: 'Añadir impresión manual',
|
||||
manageMembers: 'Gestionar miembros del centro de costes',
|
||||
currentBalance: 'Saldo personal',
|
||||
transactions: 'Transacciones',
|
||||
personalTransactions: 'Transacciones personales',
|
||||
costCenters: 'Centros de costes',
|
||||
availableForPrinting: 'Disponible para asignar impresiones',
|
||||
costCenterName: 'Nombre',
|
||||
budgetType: 'Tipo de presupuesto',
|
||||
monthlyBudget: 'Presupuesto mensual',
|
||||
totalBudget: 'Presupuesto total',
|
||||
noBudget: 'Sin presupuesto',
|
||||
create: 'Crear',
|
||||
selectUser: 'Seleccionar usuario',
|
||||
transactionType: 'Tipo',
|
||||
amount: 'Importe',
|
||||
noCostCenter: 'Sin centro de costes',
|
||||
descriptionOptional: 'Descripción (opcional)',
|
||||
applyAdjustment: 'Aplicar ajuste',
|
||||
memberCanPrint: 'El miembro puede imprimir',
|
||||
addMember: 'Añadir miembro',
|
||||
canPrint: 'Puede imprimir',
|
||||
noMembers: 'No hay miembros asignados.',
|
||||
editCostCenter: 'Editar centro de costes',
|
||||
myCostCenters: 'Mis centros de costes',
|
||||
costCentersHint: 'Revisa los límites presupuestarios y mantén los costes bajo control',
|
||||
noCostCenters: 'No se encontraron centros de costes.',
|
||||
owner: 'Propietario',
|
||||
balance: 'Saldo',
|
||||
budget: 'Presupuesto',
|
||||
shared: 'Compartido',
|
||||
cannotEditPrivateCostCenter: 'Los centros de costes privados no se pueden editar aquí',
|
||||
recentTransactions: 'Transacciones recientes',
|
||||
transactionsHint: 'Filtrar por tipo y centro de costes',
|
||||
first: 'Primera',
|
||||
prev: 'Anterior',
|
||||
next: 'Siguiente',
|
||||
last: 'Última',
|
||||
pageNumberOf: 'Página {{page}} de {{total}}',
|
||||
noTransactions: 'No hay transacciones disponibles.',
|
||||
noTransactionsForFilter: 'Ninguna transacción coincide con los filtros seleccionados.',
|
||||
costCenter: 'Centro de costes',
|
||||
balanceAfter: 'Saldo posterior',
|
||||
userWithId: 'Usuario n.º {{id}}',
|
||||
partial: 'Parcial',
|
||||
editTransaction: 'Editar transacción',
|
||||
selectCostCenter: 'Seleccionar centro de costes...',
|
||||
costCenterRequired: 'Selecciona un centro de costes',
|
||||
amountExample: 'p. ej., 4,00',
|
||||
manualAdjustmentExample: 'p. ej., Ajuste manual',
|
||||
userRequired: 'Selecciona un usuario',
|
||||
amountInvalid: 'El importe debe ser un número válido',
|
||||
createdCostCenter: 'Centro de costes creado',
|
||||
createCostCenterFailed: 'No se pudo crear el centro de costes',
|
||||
transactionDeleted: 'Transacción eliminada',
|
||||
deleteTransactionFailed: 'No se pudo eliminar la transacción',
|
||||
transactionEdited: 'Transacción actualizada y libro mayor recalculado',
|
||||
editTransactionFailed: 'No se pudo editar la transacción',
|
||||
manualPrintCreated: 'Cargo de impresión manual añadido y libro mayor recalculado',
|
||||
manualPrintFailed: 'No se pudo crear la impresión manual',
|
||||
memberSaved: 'Miembro guardado',
|
||||
memberSaveFailed: 'No se pudo guardar el miembro',
|
||||
memberRemoved: 'Miembro eliminado',
|
||||
memberRemoveFailed: 'No se pudo eliminar el miembro',
|
||||
costCenterNameRequired: 'El nombre del centro de costes es obligatorio',
|
||||
costCenterUpdated: 'Centro de costes actualizado',
|
||||
costCenterUpdateFailed: 'No se pudo actualizar el centro de costes',
|
||||
confirmDeleteCostCenter: '¿Eliminar el centro de costes «{{name}}»?',
|
||||
costCenterDeleted: 'Centro de costes eliminado',
|
||||
costCenterDeleteFailed: 'No se pudo eliminar el centro de costes',
|
||||
deleteTransactionConfirm: '¿Eliminar esta transacción? Los saldos se recalcularán automáticamente.',
|
||||
deposit: 'Ingreso',
|
||||
withdraw: 'Retirada',
|
||||
printCharge: 'Cargo de impresión',
|
||||
deleteTransaction: 'Eliminar transacción',
|
||||
},
|
||||
|
||||
// Printers page
|
||||
@@ -2243,6 +2334,20 @@ export default {
|
||||
},
|
||||
externalCameras: 'Cámaras externas',
|
||||
costTracking: 'Seguimiento de costes',
|
||||
billingEnabled: 'Activar facturación',
|
||||
billingEnabledDescription: 'Cobrar las impresiones a los usuarios y activar las funciones financieras',
|
||||
printerKillSwitch: 'Parada de impresiones no autorizadas',
|
||||
printerKillSwitchDescription: 'Detiene inmediatamente las impresiones que comiencen sin autorización.',
|
||||
financeBudgetReset: 'Reinicio mensual del presupuesto financiero',
|
||||
financeBudgetResetDay: 'Día de reinicio',
|
||||
financeBudgetResetDayHelp: 'En los meses cortos, se usa el último día del mes.',
|
||||
financeBudgetResetTimezone: 'Zona horaria del reinicio',
|
||||
financeBudgetResetTimezoneHelp: 'El inicio del período presupuestario se calcula en esta zona horaria.',
|
||||
rebuildLedger: 'Reconstruir libro mayor del monedero',
|
||||
rebuildLedgerStarted: 'Se inició la reconstrucción del libro mayor',
|
||||
rebuildLedgerConfirmTitle: '¿Reconstruir el libro mayor del monedero?',
|
||||
rebuildLedgerConfirmMessage: 'Esto reconstruirá el libro mayor para reparar saldos históricos. Ejecútalo solo si sabes lo que estás haciendo.',
|
||||
rebuildLedgerInProgress: 'Iniciando reconstrucción...',
|
||||
printsOnly: 'Solo impresiones',
|
||||
totalConsumption: 'Consumo total',
|
||||
dataManagement: 'Gestión de datos',
|
||||
@@ -4678,6 +4783,7 @@ export default {
|
||||
staggerTotal: 'total: {{minutes}} min',
|
||||
staggerToPrinters: 'Escalonar en {{count}} impresoras',
|
||||
gcodeInjection: 'Inyectar G-code de impresión automática',
|
||||
insufficientBudget: 'Presupuesto insuficiente',
|
||||
},
|
||||
|
||||
// Backup
|
||||
|
||||
@@ -9,6 +9,7 @@ export default {
|
||||
maintenance: 'Maintenance',
|
||||
projects: 'Projets',
|
||||
inventory: 'Filament',
|
||||
finance: 'Finances',
|
||||
files: 'Gestionnaire de fichiers',
|
||||
makerworld: 'MakerWorld',
|
||||
notifications: 'Notifications',
|
||||
@@ -123,6 +124,7 @@ export default {
|
||||
duplicate: 'Dupliquer',
|
||||
left: 'Gauche',
|
||||
right: 'Droite',
|
||||
user: 'Utilisateur',
|
||||
run: 'Exécuter',
|
||||
running: 'En cours d\'exécution...',
|
||||
},
|
||||
@@ -140,7 +142,7 @@ export default {
|
||||
addManualPrint: 'Ajouter un tirage manuel',
|
||||
manageMembers: 'Gérer les membres du centre de coûts',
|
||||
currentBalance: 'Solde personnel',
|
||||
transactions: 'Transactions',
|
||||
transactions: 'Opérations',
|
||||
personalTransactions: 'Transactions personnelles',
|
||||
costCenters: 'Centres de coûts',
|
||||
availableForPrinting: 'Disponible pour l\'attribution d\'impression',
|
||||
|
||||
@@ -9,6 +9,7 @@ export default {
|
||||
maintenance: 'Manutenzione',
|
||||
projects: 'Progetti',
|
||||
inventory: 'Filamento',
|
||||
finance: 'Finanze',
|
||||
files: 'File',
|
||||
makerworld: 'MakerWorld',
|
||||
notifications: 'Notifiche',
|
||||
@@ -123,6 +124,7 @@ export default {
|
||||
duplicate: 'Duplica',
|
||||
left: 'Sinistra',
|
||||
right: 'Destra',
|
||||
user: 'Utente',
|
||||
run: 'Esegui',
|
||||
running: 'In esecuzione...',
|
||||
},
|
||||
|
||||
@@ -9,6 +9,7 @@ export default {
|
||||
maintenance: 'メンテナンス',
|
||||
projects: 'プロジェクト',
|
||||
inventory: 'フィラメント',
|
||||
finance: 'ファイナンス',
|
||||
files: 'ファイル管理',
|
||||
makerworld: 'MakerWorld',
|
||||
notifications: '通知',
|
||||
@@ -123,6 +124,7 @@ export default {
|
||||
duplicate: '複製',
|
||||
left: '左',
|
||||
right: '右',
|
||||
user: 'ユーザー',
|
||||
run: '実行',
|
||||
running: '実行中...',
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@ export default {
|
||||
maintenance: '유지보수',
|
||||
projects: '프로젝트',
|
||||
inventory: '필라멘트',
|
||||
finance: '재무',
|
||||
files: '파일 관리자',
|
||||
makerworld: 'MakerWorld',
|
||||
notifications: '알림',
|
||||
@@ -119,7 +120,96 @@ export default {
|
||||
create: '만들기',
|
||||
duplicate: '복제',
|
||||
left: '왼쪽',
|
||||
right: '오른쪽'
|
||||
right: '오른쪽',
|
||||
user: '사용자',
|
||||
run: '실행',
|
||||
running: '실행 중...'
|
||||
},
|
||||
finance: {
|
||||
allTypes: '모든 유형',
|
||||
allCostCenters: '모든 비용 센터',
|
||||
title: '재무',
|
||||
subtitle: '지갑, 개인 거래 및 비용 센터',
|
||||
noAccess: '재무 데이터를 볼 권한이 없습니다.',
|
||||
personalView: '개인 보기',
|
||||
adminView: '관리자 보기',
|
||||
createCostCenter: '비용 센터 만들기',
|
||||
adjustWallet: '지갑 조정',
|
||||
addManualPrint: '수동 인쇄 추가',
|
||||
manageMembers: '비용 센터 구성원 관리',
|
||||
currentBalance: '개인 잔액',
|
||||
transactions: '거래',
|
||||
personalTransactions: '개인 거래',
|
||||
costCenters: '비용 센터',
|
||||
availableForPrinting: '인쇄 할당 가능',
|
||||
costCenterName: '이름',
|
||||
budgetType: '예산 유형',
|
||||
monthlyBudget: '월 예산',
|
||||
totalBudget: '총예산',
|
||||
noBudget: '예산 없음',
|
||||
create: '만들기',
|
||||
selectUser: '사용자 선택',
|
||||
transactionType: '유형',
|
||||
amount: '금액',
|
||||
noCostCenter: '비용 센터 없음',
|
||||
descriptionOptional: '설명(선택 사항)',
|
||||
applyAdjustment: '조정 적용',
|
||||
memberCanPrint: '구성원이 인쇄할 수 있음',
|
||||
addMember: '구성원 추가',
|
||||
canPrint: '인쇄 가능',
|
||||
noMembers: '할당된 구성원이 없습니다.',
|
||||
editCostCenter: '비용 센터 편집',
|
||||
myCostCenters: '내 비용 센터',
|
||||
costCentersHint: '예산 한도를 검토하고 비용을 관리하세요',
|
||||
noCostCenters: '비용 센터를 찾을 수 없습니다.',
|
||||
owner: '소유자',
|
||||
balance: '잔액',
|
||||
budget: '예산',
|
||||
shared: '공유',
|
||||
cannotEditPrivateCostCenter: '비공개 비용 센터는 여기에서 편집할 수 없습니다',
|
||||
recentTransactions: '최근 거래',
|
||||
transactionsHint: '유형 및 비용 센터별 필터링',
|
||||
first: '처음',
|
||||
prev: '이전',
|
||||
next: '다음',
|
||||
last: '마지막',
|
||||
pageNumberOf: '{{total}}페이지 중 {{page}}페이지',
|
||||
noTransactions: '사용 가능한 거래가 없습니다.',
|
||||
noTransactionsForFilter: '선택한 필터와 일치하는 거래가 없습니다.',
|
||||
costCenter: '비용 센터',
|
||||
balanceAfter: '거래 후 잔액',
|
||||
userWithId: '사용자 #{{id}}',
|
||||
partial: '일부',
|
||||
editTransaction: '거래 편집',
|
||||
selectCostCenter: '비용 센터 선택...',
|
||||
costCenterRequired: '비용 센터를 선택하세요',
|
||||
amountExample: '예: 4.00',
|
||||
manualAdjustmentExample: '예: 수동 조정',
|
||||
userRequired: '사용자를 선택하세요',
|
||||
amountInvalid: '금액은 유효한 숫자여야 합니다',
|
||||
createdCostCenter: '비용 센터를 만들었습니다',
|
||||
createCostCenterFailed: '비용 센터를 만들지 못했습니다',
|
||||
transactionDeleted: '거래를 삭제했습니다',
|
||||
deleteTransactionFailed: '거래를 삭제하지 못했습니다',
|
||||
transactionEdited: '거래를 업데이트하고 원장을 다시 계산했습니다',
|
||||
editTransactionFailed: '거래를 편집하지 못했습니다',
|
||||
manualPrintCreated: '수동 인쇄 비용을 추가하고 원장을 다시 계산했습니다',
|
||||
manualPrintFailed: '수동 인쇄를 만들지 못했습니다',
|
||||
memberSaved: '구성원을 저장했습니다',
|
||||
memberSaveFailed: '구성원을 저장하지 못했습니다',
|
||||
memberRemoved: '구성원을 제거했습니다',
|
||||
memberRemoveFailed: '구성원을 제거하지 못했습니다',
|
||||
costCenterNameRequired: '비용 센터 이름은 필수입니다',
|
||||
costCenterUpdated: '비용 센터를 업데이트했습니다',
|
||||
costCenterUpdateFailed: '비용 센터를 업데이트하지 못했습니다',
|
||||
confirmDeleteCostCenter: '비용 센터 "{{name}}"을(를) 삭제하시겠습니까?',
|
||||
costCenterDeleted: '비용 센터를 삭제했습니다',
|
||||
costCenterDeleteFailed: '비용 센터를 삭제하지 못했습니다',
|
||||
deleteTransactionConfirm: '이 거래를 삭제하시겠습니까? 잔액은 자동으로 다시 계산됩니다.',
|
||||
deposit: '입금',
|
||||
withdraw: '출금',
|
||||
printCharge: '인쇄 비용',
|
||||
deleteTransaction: '거래 삭제',
|
||||
},
|
||||
printers: {
|
||||
title: '프린터',
|
||||
@@ -2113,6 +2203,20 @@ export default {
|
||||
},
|
||||
externalCameras: '외부 카메라',
|
||||
costTracking: '비용 추적',
|
||||
billingEnabled: '결제 기능 사용',
|
||||
billingEnabledDescription: '사용자에게 인쇄 비용을 청구하고 재무 기능을 사용합니다',
|
||||
printerKillSwitch: '무단 인쇄 자동 중지',
|
||||
printerKillSwitchDescription: '승인 없이 시작된 인쇄를 즉시 중지합니다.',
|
||||
financeBudgetReset: '월별 재무 예산 초기화',
|
||||
financeBudgetResetDay: '초기화 날짜',
|
||||
financeBudgetResetDayHelp: '해당 날짜가 없는 짧은 달에는 그 달의 마지막 날에 초기화합니다.',
|
||||
financeBudgetResetTimezone: '초기화 시간대',
|
||||
financeBudgetResetTimezoneHelp: '예산 기간의 시작은 이 시간대를 기준으로 계산됩니다.',
|
||||
rebuildLedger: '지갑 원장 재구성',
|
||||
rebuildLedgerStarted: '원장 재구성을 시작했습니다',
|
||||
rebuildLedgerConfirmTitle: '지갑 원장을 재구성하시겠습니까?',
|
||||
rebuildLedgerConfirmMessage: '과거 잔액 값을 복구하기 위해 지갑 원장을 재구성합니다. 수행 내용을 정확히 아는 경우에만 실행하세요.',
|
||||
rebuildLedgerInProgress: '재구성 시작 중...',
|
||||
printsOnly: '인쇄만',
|
||||
totalConsumption: '총 소비',
|
||||
dataManagement: '데이터 관리',
|
||||
@@ -4441,7 +4545,8 @@ export default {
|
||||
staggerLastGroup: '마지막 그룹: {{count}}',
|
||||
staggerTotal: '합계: {{minutes}}분',
|
||||
staggerToPrinters: '{{count}}대 프린터에 분산',
|
||||
gcodeInjection: '자동 인쇄 G-code 삽입'
|
||||
gcodeInjection: '자동 인쇄 G-code 삽입',
|
||||
insufficientBudget: '예산 부족'
|
||||
},
|
||||
backup: {
|
||||
includesEncryptionKey: '로컬 백업에는 MFA 암호화 키 파일(DATA_DIR/.mfa_encryption_key)이 포함되어 백업 ZIP이 자체 완결됩니다. ZIP 파일을 민감하게 취급하세요 — 파일을 가진 누구나 내부에 저장된 OIDC 클라이언트 비밀과 TOTP 비밀을 복호화할 수 있습니다.',
|
||||
|
||||
@@ -9,6 +9,7 @@ export default {
|
||||
maintenance: 'Manutenção',
|
||||
projects: 'Projetos',
|
||||
inventory: 'Inventário',
|
||||
finance: 'Finanças',
|
||||
files: 'Gerenciador de Arquivos',
|
||||
makerworld: 'MakerWorld',
|
||||
notifications: 'Notificações',
|
||||
@@ -123,6 +124,7 @@ export default {
|
||||
duplicate: 'Duplicar',
|
||||
left: 'Esquerda',
|
||||
right: 'Direita',
|
||||
user: 'Usuário',
|
||||
run: 'Executar',
|
||||
running: 'Executando...',
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@ export default {
|
||||
maintenance: "Обслуживание",
|
||||
projects: "Проекты",
|
||||
inventory: "Филамент",
|
||||
finance: "Финансы",
|
||||
files: "Файловый менеджер",
|
||||
makerworld: "MakerWorld",
|
||||
notifications: "Уведомления",
|
||||
@@ -120,6 +121,95 @@ export default {
|
||||
duplicate: "Дублировать",
|
||||
left: "Левый",
|
||||
right: "Правый",
|
||||
user: "Пользователь",
|
||||
run: "Запустить",
|
||||
running: "Выполняется...",
|
||||
},
|
||||
finance: {
|
||||
allTypes: "Все типы",
|
||||
allCostCenters: "Все центры затрат",
|
||||
title: "Финансы",
|
||||
subtitle: "Кошелёк, личные операции и центры затрат",
|
||||
noAccess: "У вас нет разрешения на просмотр финансовых данных.",
|
||||
personalView: "Личный режим",
|
||||
adminView: "Режим администратора",
|
||||
createCostCenter: "Создать центр затрат",
|
||||
adjustWallet: "Изменить кошелёк",
|
||||
addManualPrint: "Добавить печать вручную",
|
||||
manageMembers: "Управление участниками центра затрат",
|
||||
currentBalance: "Личный баланс",
|
||||
transactions: "Операции",
|
||||
personalTransactions: "Личные операции",
|
||||
costCenters: "Центры затрат",
|
||||
availableForPrinting: "Доступен для назначения печати",
|
||||
costCenterName: "Название",
|
||||
budgetType: "Тип бюджета",
|
||||
monthlyBudget: "Месячный бюджет",
|
||||
totalBudget: "Общий бюджет",
|
||||
noBudget: "Без бюджета",
|
||||
create: "Создать",
|
||||
selectUser: "Выберите пользователя",
|
||||
transactionType: "Тип",
|
||||
amount: "Сумма",
|
||||
noCostCenter: "Без центра затрат",
|
||||
descriptionOptional: "Описание (необязательно)",
|
||||
applyAdjustment: "Применить изменение",
|
||||
memberCanPrint: "Участник может печатать",
|
||||
addMember: "Добавить участника",
|
||||
canPrint: "Может печатать",
|
||||
noMembers: "Участники не назначены.",
|
||||
editCostCenter: "Изменить центр затрат",
|
||||
myCostCenters: "Мои центры затрат",
|
||||
costCentersHint: "Проверяйте бюджетные лимиты и контролируйте расходы",
|
||||
noCostCenters: "Центры затрат не найдены.",
|
||||
owner: "Владелец",
|
||||
balance: "Баланс",
|
||||
budget: "Бюджет",
|
||||
shared: "Общий",
|
||||
cannotEditPrivateCostCenter: "Личные центры затрат нельзя редактировать здесь",
|
||||
recentTransactions: "Недавние операции",
|
||||
transactionsHint: "Фильтр по типу и центру затрат",
|
||||
first: "Первая",
|
||||
prev: "Предыдущая",
|
||||
next: "Следующая",
|
||||
last: "Последняя",
|
||||
pageNumberOf: "Страница {{page}} из {{total}}",
|
||||
noTransactions: "Нет доступных операций.",
|
||||
noTransactionsForFilter: "Нет операций, соответствующих выбранным фильтрам.",
|
||||
costCenter: "Центр затрат",
|
||||
balanceAfter: "Баланс после операции",
|
||||
userWithId: "Пользователь №{{id}}",
|
||||
partial: "Частично",
|
||||
editTransaction: "Изменить операцию",
|
||||
selectCostCenter: "Выберите центр затрат...",
|
||||
costCenterRequired: "Выберите центр затрат",
|
||||
amountExample: "например, 4,00",
|
||||
manualAdjustmentExample: "например, ручная корректировка",
|
||||
userRequired: "Выберите пользователя",
|
||||
amountInvalid: "Сумма должна быть допустимым числом",
|
||||
createdCostCenter: "Центр затрат создан",
|
||||
createCostCenterFailed: "Не удалось создать центр затрат",
|
||||
transactionDeleted: "Операция удалена",
|
||||
deleteTransactionFailed: "Не удалось удалить операцию",
|
||||
transactionEdited: "Операция обновлена, бухгалтерская книга пересчитана",
|
||||
editTransactionFailed: "Не удалось изменить операцию",
|
||||
manualPrintCreated: "Расход на печать добавлен, бухгалтерская книга пересчитана",
|
||||
manualPrintFailed: "Не удалось добавить печать вручную",
|
||||
memberSaved: "Участник сохранён",
|
||||
memberSaveFailed: "Не удалось сохранить участника",
|
||||
memberRemoved: "Участник удалён",
|
||||
memberRemoveFailed: "Не удалось удалить участника",
|
||||
costCenterNameRequired: "Необходимо указать название центра затрат",
|
||||
costCenterUpdated: "Центр затрат обновлён",
|
||||
costCenterUpdateFailed: "Не удалось обновить центр затрат",
|
||||
confirmDeleteCostCenter: "Удалить центр затрат «{{name}}»?",
|
||||
costCenterDeleted: "Центр затрат удалён",
|
||||
costCenterDeleteFailed: "Не удалось удалить центр затрат",
|
||||
deleteTransactionConfirm: "Удалить эту операцию? Балансы будут пересчитаны автоматически.",
|
||||
deposit: "Пополнение",
|
||||
withdraw: "Списание",
|
||||
printCharge: "Расход на печать",
|
||||
deleteTransaction: "Удалить операцию",
|
||||
},
|
||||
printers: {
|
||||
title: "Принтеры",
|
||||
@@ -2114,6 +2204,20 @@ export default {
|
||||
},
|
||||
externalCameras: "Внешние камеры",
|
||||
costTracking: "Учёт затрат",
|
||||
billingEnabled: "Включить расчёты",
|
||||
billingEnabledDescription: "Списывать с пользователей стоимость печати и включить финансовые функции",
|
||||
printerKillSwitch: "Автоостановка несанкционированной печати",
|
||||
printerKillSwitchDescription: "Немедленно останавливает печать, начатую без разрешения.",
|
||||
financeBudgetReset: "Ежемесячный сброс финансового бюджета",
|
||||
financeBudgetResetDay: "День сброса",
|
||||
financeBudgetResetDayHelp: "В коротких месяцах сброс выполняется в последний день месяца.",
|
||||
financeBudgetResetTimezone: "Часовой пояс сброса",
|
||||
financeBudgetResetTimezoneHelp: "Начало бюджетного периода рассчитывается в этом часовом поясе.",
|
||||
rebuildLedger: "Пересчитать книгу кошелька",
|
||||
rebuildLedgerStarted: "Пересчёт книги запущен",
|
||||
rebuildLedgerConfirmTitle: "Пересчитать книгу кошелька?",
|
||||
rebuildLedgerConfirmMessage: "Книга кошелька будет пересчитана для исправления исторических значений баланса. Запускайте это действие только если понимаете его последствия.",
|
||||
rebuildLedgerInProgress: "Запуск пересчёта...",
|
||||
printsOnly: "Только печать",
|
||||
totalConsumption: "Общее потребление",
|
||||
dataManagement: "Управление данными",
|
||||
@@ -4431,6 +4535,7 @@ export default {
|
||||
staggerTotal: "всего: {{minutes}} мин",
|
||||
staggerToPrinters: "Распределить запуск для {{count}} принтеров",
|
||||
gcodeInjection: "Добавить G-code автозапуска",
|
||||
insufficientBudget: "Недостаточно бюджета",
|
||||
},
|
||||
backup: {
|
||||
includesEncryptionKey: "Локальные резервные копии включают файл ключа шифрования MFA (DATA_DIR/.mfa_encryption_key), поэтому ZIP-архив является самодостаточным. Считайте этот ZIP конфиденциальным: любой, у кого есть файл, сможет расшифровать сохранённые в нём секреты клиента OIDC и TOTP.",
|
||||
|
||||
@@ -9,6 +9,7 @@ export default {
|
||||
maintenance: 'Bakım',
|
||||
projects: 'Projeler',
|
||||
inventory: 'Filament',
|
||||
finance: 'Finans',
|
||||
files: 'Dosya Yöneticisi',
|
||||
makerworld: 'MakerWorld',
|
||||
notifications: 'Bildirimler',
|
||||
@@ -123,6 +124,96 @@ export default {
|
||||
duplicate: 'Çoğalt',
|
||||
left: 'Sol',
|
||||
right: 'Sağ',
|
||||
user: 'Kullanıcı',
|
||||
run: 'Çalıştır',
|
||||
running: 'Çalışıyor...',
|
||||
},
|
||||
|
||||
finance: {
|
||||
allTypes: 'Tüm türler',
|
||||
allCostCenters: 'Tüm masraf merkezleri',
|
||||
title: 'Finans',
|
||||
subtitle: 'Cüzdan, kişisel işlemler ve masraf merkezleri',
|
||||
noAccess: 'Finans verilerini görüntüleme izniniz yok.',
|
||||
personalView: 'Kişisel görünüm',
|
||||
adminView: 'Yönetici görünümü',
|
||||
createCostCenter: 'Masraf merkezi oluştur',
|
||||
adjustWallet: 'Cüzdanı ayarla',
|
||||
addManualPrint: 'Manuel baskı ekle',
|
||||
manageMembers: 'Masraf merkezi üyelerini yönet',
|
||||
currentBalance: 'Kişisel bakiye',
|
||||
transactions: 'İşlemler',
|
||||
personalTransactions: 'Kişisel işlemler',
|
||||
costCenters: 'Masraf merkezleri',
|
||||
availableForPrinting: 'Baskı ataması için kullanılabilir',
|
||||
costCenterName: 'Ad',
|
||||
budgetType: 'Bütçe türü',
|
||||
monthlyBudget: 'Aylık bütçe',
|
||||
totalBudget: 'Toplam bütçe',
|
||||
noBudget: 'Bütçe yok',
|
||||
create: 'Oluştur',
|
||||
selectUser: 'Kullanıcı seç',
|
||||
transactionType: 'Tür',
|
||||
amount: 'Tutar',
|
||||
noCostCenter: 'Masraf merkezi yok',
|
||||
descriptionOptional: 'Açıklama (isteğe bağlı)',
|
||||
applyAdjustment: 'Ayarlamayı uygula',
|
||||
memberCanPrint: 'Üye baskı yapabilir',
|
||||
addMember: 'Üye ekle',
|
||||
canPrint: 'Baskı yapabilir',
|
||||
noMembers: 'Atanmış üye yok.',
|
||||
editCostCenter: 'Masraf merkezini düzenle',
|
||||
myCostCenters: 'Masraf merkezlerim',
|
||||
costCentersHint: 'Bütçe sınırlarını gözden geçirin ve maliyetleri kontrol altında tutun',
|
||||
noCostCenters: 'Masraf merkezi bulunamadı.',
|
||||
owner: 'Sahip',
|
||||
balance: 'Bakiye',
|
||||
budget: 'Bütçe',
|
||||
shared: 'Paylaşılan',
|
||||
cannotEditPrivateCostCenter: 'Özel masraf merkezleri burada düzenlenemez',
|
||||
recentTransactions: 'Son işlemler',
|
||||
transactionsHint: 'Türe ve masraf merkezine göre filtrele',
|
||||
first: 'İlk',
|
||||
prev: 'Önceki',
|
||||
next: 'Sonraki',
|
||||
last: 'Son',
|
||||
pageNumberOf: '{{total}} sayfanın {{page}}. sayfası',
|
||||
noTransactions: 'Kullanılabilir işlem yok.',
|
||||
noTransactionsForFilter: 'Seçilen filtrelerle eşleşen işlem yok.',
|
||||
costCenter: 'Masraf merkezi',
|
||||
balanceAfter: 'İşlem sonrası bakiye',
|
||||
userWithId: 'Kullanıcı #{{id}}',
|
||||
partial: 'Kısmi',
|
||||
editTransaction: 'İşlemi düzenle',
|
||||
selectCostCenter: 'Masraf merkezi seç...',
|
||||
costCenterRequired: 'Lütfen bir masraf merkezi seçin',
|
||||
amountExample: 'örn. 4,00',
|
||||
manualAdjustmentExample: 'örn. Manuel ayarlama',
|
||||
userRequired: 'Lütfen bir kullanıcı seçin',
|
||||
amountInvalid: 'Tutar geçerli bir sayı olmalıdır',
|
||||
createdCostCenter: 'Masraf merkezi oluşturuldu',
|
||||
createCostCenterFailed: 'Masraf merkezi oluşturulamadı',
|
||||
transactionDeleted: 'İşlem silindi',
|
||||
deleteTransactionFailed: 'İşlem silinemedi',
|
||||
transactionEdited: 'İşlem güncellendi ve hesap defteri yeniden hesaplandı',
|
||||
editTransactionFailed: 'İşlem düzenlenemedi',
|
||||
manualPrintCreated: 'Manuel baskı ücreti eklendi ve hesap defteri yeniden hesaplandı',
|
||||
manualPrintFailed: 'Manuel baskı oluşturulamadı',
|
||||
memberSaved: 'Üye kaydedildi',
|
||||
memberSaveFailed: 'Üye kaydedilemedi',
|
||||
memberRemoved: 'Üye kaldırıldı',
|
||||
memberRemoveFailed: 'Üye kaldırılamadı',
|
||||
costCenterNameRequired: 'Masraf merkezi adı gereklidir',
|
||||
costCenterUpdated: 'Masraf merkezi güncellendi',
|
||||
costCenterUpdateFailed: 'Masraf merkezi güncellenemedi',
|
||||
confirmDeleteCostCenter: '"{{name}}" masraf merkezi silinsin mi?',
|
||||
costCenterDeleted: 'Masraf merkezi silindi',
|
||||
costCenterDeleteFailed: 'Masraf merkezi silinemedi',
|
||||
deleteTransactionConfirm: 'Bu işlem silinsin mi? Bakiyeler otomatik olarak yeniden hesaplanacaktır.',
|
||||
deposit: 'Para yatırma',
|
||||
withdraw: 'Para çekme',
|
||||
printCharge: 'Baskı ücreti',
|
||||
deleteTransaction: 'İşlemi sil',
|
||||
},
|
||||
|
||||
// Yazıcılar sayfası
|
||||
@@ -2244,6 +2335,20 @@ export default {
|
||||
},
|
||||
externalCameras: 'Harici Kameralar',
|
||||
costTracking: 'Maliyet Takibi',
|
||||
billingEnabled: 'Faturalandırmayı etkinleştir',
|
||||
billingEnabledDescription: 'Kullanıcılardan baskı ücreti alın ve finans özelliklerini etkinleştirin',
|
||||
printerKillSwitch: 'Yetkisiz baskıyı otomatik durdurma',
|
||||
printerKillSwitchDescription: 'Yetkilendirme olmadan başlayan baskıları hemen durdurur.',
|
||||
financeBudgetReset: 'Aylık finans bütçesi sıfırlama',
|
||||
financeBudgetResetDay: 'Sıfırlama günü',
|
||||
financeBudgetResetDayHelp: 'Kısa aylarda sıfırlama ayın son gününde yapılır.',
|
||||
financeBudgetResetTimezone: 'Sıfırlama saat dilimi',
|
||||
financeBudgetResetTimezoneHelp: 'Bütçe döneminin başlangıcı bu saat dilimine göre hesaplanır.',
|
||||
rebuildLedger: 'Cüzdan hesap defterini yeniden oluştur',
|
||||
rebuildLedgerStarted: 'Hesap defteri yeniden oluşturulmaya başlandı',
|
||||
rebuildLedgerConfirmTitle: 'Cüzdan hesap defteri yeniden oluşturulsun mu?',
|
||||
rebuildLedgerConfirmMessage: 'Geçmiş bakiye değerlerini onarmak için cüzdan hesap defteri yeniden oluşturulacaktır. Bunu yalnızca ne yaptığınızı biliyorsanız çalıştırın.',
|
||||
rebuildLedgerInProgress: 'Yeniden oluşturma başlatılıyor...',
|
||||
printsOnly: 'Yalnızca Baskılar',
|
||||
totalConsumption: 'Toplam Tüketim',
|
||||
dataManagement: 'Veri Yönetimi',
|
||||
@@ -4648,6 +4753,7 @@ export default {
|
||||
staggerTotal: 'toplam: {{minutes}} dk',
|
||||
staggerToPrinters: '{{count}} yazıcıya kademelendir',
|
||||
gcodeInjection: 'Otomatik baskı G-kodu enjekte et',
|
||||
insufficientBudget: 'Yetersiz bütçe',
|
||||
},
|
||||
|
||||
// Yedekleme
|
||||
|
||||
@@ -9,6 +9,7 @@ export default {
|
||||
maintenance: "Технічне обслуговування",
|
||||
projects: "Проєкти",
|
||||
inventory: "Філамент",
|
||||
finance: "Фінанси",
|
||||
files: "Менеджер файлів",
|
||||
makerworld: "MakerWorld",
|
||||
notifications: "Сповіщення",
|
||||
@@ -123,6 +124,96 @@ export default {
|
||||
duplicate: "Дублювати",
|
||||
left: "Ліворуч",
|
||||
right: "Праворуч",
|
||||
user: "Користувач",
|
||||
run: "Запустити",
|
||||
running: "Виконується...",
|
||||
},
|
||||
|
||||
finance: {
|
||||
allTypes: "Усі типи",
|
||||
allCostCenters: "Усі центри витрат",
|
||||
title: "Фінанси",
|
||||
subtitle: "Гаманець, особисті операції та центри витрат",
|
||||
noAccess: "У вас немає дозволу на перегляд фінансових даних.",
|
||||
personalView: "Особистий режим",
|
||||
adminView: "Режим адміністратора",
|
||||
createCostCenter: "Створити центр витрат",
|
||||
adjustWallet: "Скоригувати гаманець",
|
||||
addManualPrint: "Додати ручний друк",
|
||||
manageMembers: "Керувати учасниками центру витрат",
|
||||
currentBalance: "Особистий баланс",
|
||||
transactions: "Операції",
|
||||
personalTransactions: "Особисті операції",
|
||||
costCenters: "Центри витрат",
|
||||
availableForPrinting: "Доступний для призначення друку",
|
||||
costCenterName: "Назва",
|
||||
budgetType: "Тип бюджету",
|
||||
monthlyBudget: "Місячний бюджет",
|
||||
totalBudget: "Загальний бюджет",
|
||||
noBudget: "Без бюджету",
|
||||
create: "Створити",
|
||||
selectUser: "Виберіть користувача",
|
||||
transactionType: "Тип",
|
||||
amount: "Сума",
|
||||
noCostCenter: "Без центру витрат",
|
||||
descriptionOptional: "Опис (необов’язково)",
|
||||
applyAdjustment: "Застосувати коригування",
|
||||
memberCanPrint: "Учасник може друкувати",
|
||||
addMember: "Додати учасника",
|
||||
canPrint: "Може друкувати",
|
||||
noMembers: "Учасників не призначено.",
|
||||
editCostCenter: "Редагувати центр витрат",
|
||||
myCostCenters: "Мої центри витрат",
|
||||
costCentersHint: "Переглядайте бюджетні ліміти та контролюйте витрати",
|
||||
noCostCenters: "Центрів витрат не знайдено.",
|
||||
owner: "Власник",
|
||||
balance: "Баланс",
|
||||
budget: "Бюджет",
|
||||
shared: "Спільний",
|
||||
cannotEditPrivateCostCenter: "Особисті центри витрат не можна редагувати тут",
|
||||
recentTransactions: "Останні операції",
|
||||
transactionsHint: "Фільтрувати за типом і центром витрат",
|
||||
first: "Перша",
|
||||
prev: "Попередня",
|
||||
next: "Наступна",
|
||||
last: "Остання",
|
||||
pageNumberOf: "Сторінка {{page}} з {{total}}",
|
||||
noTransactions: "Немає доступних операцій.",
|
||||
noTransactionsForFilter: "Немає операцій, що відповідають вибраним фільтрам.",
|
||||
costCenter: "Центр витрат",
|
||||
balanceAfter: "Баланс після операції",
|
||||
userWithId: "Користувач №{{id}}",
|
||||
partial: "Частково",
|
||||
editTransaction: "Редагувати операцію",
|
||||
selectCostCenter: "Виберіть центр витрат...",
|
||||
costCenterRequired: "Виберіть центр витрат",
|
||||
amountExample: "наприклад, 4,00",
|
||||
manualAdjustmentExample: "наприклад, ручне коригування",
|
||||
userRequired: "Виберіть користувача",
|
||||
amountInvalid: "Сума має бути дійсним числом",
|
||||
createdCostCenter: "Центр витрат створено",
|
||||
createCostCenterFailed: "Не вдалося створити центр витрат",
|
||||
transactionDeleted: "Операцію видалено",
|
||||
deleteTransactionFailed: "Не вдалося видалити операцію",
|
||||
transactionEdited: "Операцію оновлено, бухгалтерську книгу перераховано",
|
||||
editTransactionFailed: "Не вдалося редагувати операцію",
|
||||
manualPrintCreated: "Витрати на ручний друк додано, бухгалтерську книгу перераховано",
|
||||
manualPrintFailed: "Не вдалося додати ручний друк",
|
||||
memberSaved: "Учасника збережено",
|
||||
memberSaveFailed: "Не вдалося зберегти учасника",
|
||||
memberRemoved: "Учасника видалено",
|
||||
memberRemoveFailed: "Не вдалося видалити учасника",
|
||||
costCenterNameRequired: "Назва центру витрат є обов’язковою",
|
||||
costCenterUpdated: "Центр витрат оновлено",
|
||||
costCenterUpdateFailed: "Не вдалося оновити центр витрат",
|
||||
confirmDeleteCostCenter: "Видалити центр витрат «{{name}}»?",
|
||||
costCenterDeleted: "Центр витрат видалено",
|
||||
costCenterDeleteFailed: "Не вдалося видалити центр витрат",
|
||||
deleteTransactionConfirm: "Видалити цю операцію? Баланси буде перераховано автоматично.",
|
||||
deposit: "Поповнення",
|
||||
withdraw: "Списання",
|
||||
printCharge: "Витрати на друк",
|
||||
deleteTransaction: "Видалити операцію",
|
||||
},
|
||||
|
||||
// Printers page
|
||||
@@ -2259,6 +2350,20 @@ export default {
|
||||
},
|
||||
externalCameras: "Зовнішні камери",
|
||||
costTracking: "Відстеження витрат",
|
||||
billingEnabled: "Увімкнути розрахунки",
|
||||
billingEnabledDescription: "Стягувати з користувачів вартість друку та ввімкнути фінансові функції",
|
||||
printerKillSwitch: "Автозупинка несанкціонованого друку",
|
||||
printerKillSwitchDescription: "Негайно зупиняє друк, розпочатий без дозволу.",
|
||||
financeBudgetReset: "Щомісячне скидання фінансового бюджету",
|
||||
financeBudgetResetDay: "День скидання",
|
||||
financeBudgetResetDayHelp: "У коротких місяцях скидання виконується в останній день місяця.",
|
||||
financeBudgetResetTimezone: "Часовий пояс скидання",
|
||||
financeBudgetResetTimezoneHelp: "Початок бюджетного періоду обчислюється в цьому часовому поясі.",
|
||||
rebuildLedger: "Перебудувати книгу гаманця",
|
||||
rebuildLedgerStarted: "Перебудову книги розпочато",
|
||||
rebuildLedgerConfirmTitle: "Перебудувати книгу гаманця?",
|
||||
rebuildLedgerConfirmMessage: "Книгу гаманця буде перебудовано для виправлення історичних значень балансу. Запускайте цю дію лише якщо розумієте її наслідки.",
|
||||
rebuildLedgerInProgress: "Запуск перебудови...",
|
||||
printsOnly: "Лише друк",
|
||||
totalConsumption: "Загальне споживання",
|
||||
dataManagement: "Управління даними",
|
||||
@@ -4713,6 +4818,7 @@ export default {
|
||||
staggerTotal: "всього: {{minutes}} хв",
|
||||
staggerToPrinters: "Розподілити запуск між {{count}} принтерами",
|
||||
gcodeInjection: "Додати G-код автоматичного друку",
|
||||
insufficientBudget: "Недостатньо бюджету",
|
||||
},
|
||||
|
||||
// Backup
|
||||
|
||||
@@ -124,6 +124,7 @@ export default {
|
||||
duplicate: '複製',
|
||||
left: '左',
|
||||
right: '右',
|
||||
user: '使用者',
|
||||
run: '執行',
|
||||
running: '執行中...',
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user