PR feedback integrated

This commit is contained in:
behrinml
2026-07-30 21:35:01 +02:00
parent 0d6a28cd67
commit b74862d7d2
20 changed files with 1309 additions and 15 deletions
+19 -1
View File
@@ -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)
+5
View File
@@ -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,
+227 -3
View File
@@ -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():
+58 -7
View File
@@ -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),
+73 -1
View File
@@ -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,
):
+106
View File
@@ -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
+3 -1
View File
@@ -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',
+2
View File
@@ -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...',
},
+2
View File
@@ -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: '実行中...',
},
+107 -2
View File
@@ -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 비밀을 복호화할 수 있습니다.',
+2
View File
@@ -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...',
},
+105
View File
@@ -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.",
+106
View File
@@ -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
+106
View File
@@ -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
+1
View File
@@ -124,6 +124,7 @@ export default {
duplicate: '複製',
left: '左',
right: '右',
user: '使用者',
run: '執行',
running: '執行中...',
},