diff --git a/backend/app/api/routes/print_queue.py b/backend/app/api/routes/print_queue.py index 9e78ccb23..c0555e59a 100644 --- a/backend/app/api/routes/print_queue.py +++ b/backend/app/api/routes/print_queue.py @@ -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) diff --git a/backend/app/core/auth.py b/backend/app/core/auth.py index 2e37d722c..c3d0ef053 100644 --- a/backend/app/core/auth.py +++ b/backend/app/core/auth.py @@ -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, diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 00f0b8591..52f52d489 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -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(): diff --git a/backend/app/services/finance_budget.py b/backend/app/services/finance_budget.py index 444be3f06..d321efa84 100644 --- a/backend/app/services/finance_budget.py +++ b/backend/app/services/finance_budget.py @@ -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), diff --git a/backend/app/services/print_scheduler.py b/backend/app/services/print_scheduler.py index a0492083e..fe265c691 100644 --- a/backend/app/services/print_scheduler.py +++ b/backend/app/services/print_scheduler.py @@ -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" diff --git a/backend/tests/integration/test_auth_apikey_rbac.py b/backend/tests/integration/test_auth_apikey_rbac.py index d921a2a90..e24ffcdfe 100644 --- a/backend/tests/integration/test_auth_apikey_rbac.py +++ b/backend/tests/integration/test_auth_apikey_rbac.py @@ -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, diff --git a/backend/tests/integration/test_scheduler_budget_reservation.py b/backend/tests/integration/test_scheduler_budget_reservation.py new file mode 100644 index 000000000..b24f0ad07 --- /dev/null +++ b/backend/tests/integration/test_scheduler_budget_reservation.py @@ -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 diff --git a/backend/tests/unit/test_finance_table_migration.py b/backend/tests/unit/test_finance_table_migration.py new file mode 100644 index 000000000..449caf341 --- /dev/null +++ b/backend/tests/unit/test_finance_table_migration.py @@ -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() diff --git a/backend/tests/unit/test_printer_kill_switch.py b/backend/tests/unit/test_printer_kill_switch.py index 94c90181f..86812dfa9 100644 --- a/backend/tests/unit/test_printer_kill_switch.py +++ b/backend/tests/unit/test_printer_kill_switch.py @@ -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={}, diff --git a/backend/tests/unit/test_scheduler_cleanup_library.py b/backend/tests/unit/test_scheduler_cleanup_library.py index 1ab41e8ed..e3c70d4e7 100644 --- a/backend/tests/unit/test_scheduler_cleanup_library.py +++ b/backend/tests/unit/test_scheduler_cleanup_library.py @@ -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, ): diff --git a/frontend/src/i18n/locales/es.ts b/frontend/src/i18n/locales/es.ts index 19aa777c9..c5987377b 100644 --- a/frontend/src/i18n/locales/es.ts +++ b/frontend/src/i18n/locales/es.ts @@ -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 diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index 6c8257eb3..7be09b65a 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/frontend/src/i18n/locales/fr.ts @@ -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', diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index 3395d1237..031da1d51 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/frontend/src/i18n/locales/it.ts @@ -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...', }, diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index d4218e55e..cb26b7dfc 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/frontend/src/i18n/locales/ja.ts @@ -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: '実行中...', }, diff --git a/frontend/src/i18n/locales/ko.ts b/frontend/src/i18n/locales/ko.ts index 2827a00bb..fa6c11905 100644 --- a/frontend/src/i18n/locales/ko.ts +++ b/frontend/src/i18n/locales/ko.ts @@ -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 비밀을 복호화할 수 있습니다.', diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts index 56d96f276..bdfa3a844 100644 --- a/frontend/src/i18n/locales/pt-BR.ts +++ b/frontend/src/i18n/locales/pt-BR.ts @@ -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...', }, diff --git a/frontend/src/i18n/locales/ru.ts b/frontend/src/i18n/locales/ru.ts index 9646eb16e..64f6f5d2c 100644 --- a/frontend/src/i18n/locales/ru.ts +++ b/frontend/src/i18n/locales/ru.ts @@ -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.", diff --git a/frontend/src/i18n/locales/tr.ts b/frontend/src/i18n/locales/tr.ts index 998d47167..3b7717225 100644 --- a/frontend/src/i18n/locales/tr.ts +++ b/frontend/src/i18n/locales/tr.ts @@ -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 diff --git a/frontend/src/i18n/locales/uk.ts b/frontend/src/i18n/locales/uk.ts index 99d9496cc..4c8d16afe 100644 --- a/frontend/src/i18n/locales/uk.ts +++ b/frontend/src/i18n/locales/uk.ts @@ -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 diff --git a/frontend/src/i18n/locales/zh-TW.ts b/frontend/src/i18n/locales/zh-TW.ts index a3f63d1f7..836019b71 100644 --- a/frontend/src/i18n/locales/zh-TW.ts +++ b/frontend/src/i18n/locales/zh-TW.ts @@ -124,6 +124,7 @@ export default { duplicate: '複製', left: '左', right: '右', + user: '使用者', run: '執行', running: '執行中...', },