feat(accounting): add invoice preparation workflow
This commit is contained in:
parent
ffffb68898
commit
46eeaa1f2e
17 changed files with 519 additions and 44 deletions
|
|
@ -0,0 +1,53 @@
|
|||
"""add accounting export status
|
||||
|
||||
Revision ID: d2e3f4a5b6c7
|
||||
Revises: c9d4e5f6a7b8
|
||||
Create Date: 2026-07-05 17:20:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "d2e3f4a5b6c7"
|
||||
down_revision: Union[str, Sequence[str], None] = "c9d4e5f6a7b8"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("lexware_sync_records", sa.Column("export_status", sa.String(length=40), server_default="prepared", nullable=False))
|
||||
op.add_column("lexware_sync_records", sa.Column("accounting_note", sa.Text(), nullable=True))
|
||||
op.add_column("lexware_sync_records", sa.Column("transferred_at", sa.DateTime(timezone=True), nullable=True))
|
||||
op.add_column("lexware_sync_records", sa.Column("transferred_by_user_id", sa.Integer(), nullable=True))
|
||||
op.create_index(op.f("ix_lexware_sync_records_export_status"), "lexware_sync_records", ["export_status"], unique=False)
|
||||
op.create_index(op.f("ix_lexware_sync_records_transferred_by_user_id"), "lexware_sync_records", ["transferred_by_user_id"], unique=False)
|
||||
|
||||
op.add_column("repair_estimates", sa.Column("accounting_export_status", sa.String(length=40), nullable=True))
|
||||
op.add_column("repair_estimates", sa.Column("accounting_note", sa.Text(), nullable=True))
|
||||
op.add_column("repair_estimates", sa.Column("accounting_transferred_at", sa.DateTime(timezone=True), nullable=True))
|
||||
op.add_column("repair_estimates", sa.Column("accounting_transferred_by_user_id", sa.Integer(), nullable=True))
|
||||
op.create_foreign_key(
|
||||
"fk_repair_estimates_accounting_transferred_by_user_id",
|
||||
"repair_estimates",
|
||||
"users",
|
||||
["accounting_transferred_by_user_id"],
|
||||
["id"],
|
||||
ondelete="SET NULL",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint("fk_repair_estimates_accounting_transferred_by_user_id", "repair_estimates", type_="foreignkey")
|
||||
op.drop_column("repair_estimates", "accounting_transferred_by_user_id")
|
||||
op.drop_column("repair_estimates", "accounting_transferred_at")
|
||||
op.drop_column("repair_estimates", "accounting_note")
|
||||
op.drop_column("repair_estimates", "accounting_export_status")
|
||||
op.drop_index(op.f("ix_lexware_sync_records_transferred_by_user_id"), table_name="lexware_sync_records")
|
||||
op.drop_index(op.f("ix_lexware_sync_records_export_status"), table_name="lexware_sync_records")
|
||||
op.drop_column("lexware_sync_records", "transferred_by_user_id")
|
||||
op.drop_column("lexware_sync_records", "transferred_at")
|
||||
op.drop_column("lexware_sync_records", "accounting_note")
|
||||
op.drop_column("lexware_sync_records", "export_status")
|
||||
|
|
@ -64,6 +64,8 @@ def can_read_activity(action: str, permissions: set[str]) -> bool:
|
|||
return "inventory.read" in permissions
|
||||
if action.startswith("lexware."):
|
||||
return "lexware.read" in permissions
|
||||
if action.startswith("accounting."):
|
||||
return "lexware.read" in permissions
|
||||
if action.startswith("audit_logs."):
|
||||
return "audit_logs.read" in permissions
|
||||
if action.startswith("auth."):
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ from app.core.rbac import get_user_permission_names, require_permission
|
|||
from app.db.database import get_db
|
||||
from app.models.rbac import Role
|
||||
from app.models.audit import AuditLog
|
||||
from app.models.lexware import LexwareSyncRecord
|
||||
from app.models.user import User
|
||||
from app.repositories.customer_repository import CustomerRepository
|
||||
from app.repositories.inventory_repository import InventoryRepository
|
||||
|
|
@ -85,6 +86,34 @@ def get_dashboard_summary(
|
|||
MetricCard(label="Heute zurückgenommene KV", value=RepairEstimateRepository.count_revoked_today(db)),
|
||||
])
|
||||
|
||||
if "lexware.read" in permissions:
|
||||
repairs.extend([
|
||||
MetricCard(
|
||||
label="Vorbereitete Rechnungen",
|
||||
value=db.scalar(
|
||||
select(func.count(LexwareSyncRecord.id))
|
||||
.where(LexwareSyncRecord.lexware_resource_type == "invoice")
|
||||
.where(LexwareSyncRecord.export_status == "prepared")
|
||||
) or 0,
|
||||
),
|
||||
MetricCard(
|
||||
label="An Buchhaltung übergeben",
|
||||
value=db.scalar(
|
||||
select(func.count(LexwareSyncRecord.id))
|
||||
.where(LexwareSyncRecord.lexware_resource_type == "invoice")
|
||||
.where(LexwareSyncRecord.export_status == "transferred")
|
||||
) or 0,
|
||||
),
|
||||
MetricCard(
|
||||
label="Noch nicht übertragen",
|
||||
value=db.scalar(
|
||||
select(func.count(LexwareSyncRecord.id))
|
||||
.where(LexwareSyncRecord.lexware_resource_type == "invoice")
|
||||
.where(LexwareSyncRecord.export_status == "prepared")
|
||||
) or 0,
|
||||
),
|
||||
])
|
||||
|
||||
if "inventory.read" in permissions:
|
||||
inventory = [
|
||||
MetricCard(label="Aktive Ersatzteile", value=InventoryRepository.count_active_items(db)),
|
||||
|
|
|
|||
|
|
@ -9,7 +9,13 @@ from app.models.repair_estimate import RepairEstimate
|
|||
from app.models.user import User
|
||||
from app.repositories.repair_estimate_repository import RepairEstimateRepository
|
||||
from app.repositories.repair_repository import RepairRepository
|
||||
from app.schemas.lexware import LexwareInvoicePreparationResponse, LexwareSettingsResponse, LexwareSettingsUpdate, LexwareTestConnectionResponse
|
||||
from app.schemas.lexware import (
|
||||
AccountingTransferUpdate,
|
||||
LexwareInvoicePreparationResponse,
|
||||
LexwareSettingsResponse,
|
||||
LexwareSettingsUpdate,
|
||||
LexwareTestConnectionResponse,
|
||||
)
|
||||
from app.services.lexware_service import LexwareService
|
||||
|
||||
router = APIRouter(tags=["Lexware"])
|
||||
|
|
@ -67,3 +73,17 @@ def prepare_lexware_invoice(
|
|||
repair = get_repair_or_404(db, repair_id)
|
||||
estimate = get_estimate_or_404(db, repair_id, estimate_id)
|
||||
return LexwareService.prepare_invoice(db, repair, estimate, actor=current_user, request=request)
|
||||
|
||||
|
||||
@router.post("/repairs/{repair_id}/estimates/{estimate_id}/accounting/mark-transferred", response_model=LexwareInvoicePreparationResponse)
|
||||
def mark_accounting_transferred(
|
||||
repair_id: int,
|
||||
estimate_id: int,
|
||||
payload: AccountingTransferUpdate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_permission("lexware.export")),
|
||||
):
|
||||
repair = get_repair_or_404(db, repair_id)
|
||||
estimate = get_estimate_or_404(db, repair_id, estimate_id)
|
||||
return LexwareService.mark_transferred(db, repair, estimate, payload, actor=current_user, request=request)
|
||||
|
|
|
|||
|
|
@ -16,8 +16,12 @@ class LexwareSyncRecord(Base):
|
|||
lexware_resource_id: Mapped[str | None] = mapped_column(String(120), nullable=True, index=True)
|
||||
status: Mapped[str] = mapped_column(String(40), default="pending", server_default="pending", index=True)
|
||||
direction: Mapped[str] = mapped_column(String(40), default="push", server_default="push", index=True)
|
||||
export_status: Mapped[str] = mapped_column(String(40), default="prepared", server_default="prepared", index=True)
|
||||
accounting_note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
payload_summary: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
transferred_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
transferred_by_user_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||
|
|
|
|||
|
|
@ -31,6 +31,10 @@ class RepairEstimate(Base):
|
|||
lexware_invoice_number: Mapped[str | None] = mapped_column(String(80), nullable=True)
|
||||
lexware_invoice_status: Mapped[str | None] = mapped_column(String(80), nullable=True)
|
||||
lexware_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
accounting_export_status: Mapped[str | None] = mapped_column(String(40), nullable=True)
|
||||
accounting_note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
accounting_transferred_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
accounting_transferred_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ from app.schemas.system_setting import SettingsSource, normalize_text
|
|||
|
||||
LexwareSyncStatus = Literal["pending", "success", "failed", "skipped"]
|
||||
LexwareSyncDirection = Literal["push", "pull"]
|
||||
AccountingExportStatus = Literal["prepared", "transferred", "booked", "cancelled"]
|
||||
|
||||
|
||||
class LexwareSettingsResponse(BaseModel):
|
||||
|
|
@ -73,9 +74,23 @@ class LexwareLineItemMapping(BaseModel):
|
|||
|
||||
class LexwareInvoicePreparationResponse(BaseModel):
|
||||
ready_for_export: bool
|
||||
export_status: AccountingExportStatus
|
||||
payload_summary: dict
|
||||
customer_mapping: LexwareCustomerMapping
|
||||
line_item_mapping: list[LexwareLineItemMapping]
|
||||
tax_mapping: dict
|
||||
warnings: list[str]
|
||||
sync_record_id: int
|
||||
accounting_note: str = ""
|
||||
transferred_at: str | None = None
|
||||
transferred_by_user_id: int | None = None
|
||||
|
||||
|
||||
class AccountingTransferUpdate(BaseModel):
|
||||
accounting_note: str | None = Field(default=None, max_length=2000)
|
||||
|
||||
@field_validator("accounting_note", mode="before")
|
||||
@classmethod
|
||||
def normalize_note(cls, value: object) -> str | None:
|
||||
text = normalize_text(value)
|
||||
return text or None
|
||||
|
|
|
|||
|
|
@ -146,6 +146,10 @@ class RepairEstimateResponse(BaseModel):
|
|||
lexware_invoice_number: str | None
|
||||
lexware_invoice_status: str | None
|
||||
lexware_synced_at: datetime | None
|
||||
accounting_export_status: str | None
|
||||
accounting_note: str | None
|
||||
accounting_transferred_at: datetime | None
|
||||
accounting_transferred_by_user_id: int | None
|
||||
created_by_user_id: int | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
|
|
|||
|
|
@ -212,6 +212,9 @@ def action_title(action: str) -> str:
|
|||
"lexware.connection.test_failed": "Lexware Verbindungstest fehlgeschlagen",
|
||||
"lexware.invoice.prepare": "Lexware Rechnung vorbereitet",
|
||||
"lexware.invoice.export_failed": "Lexware Export fehlgeschlagen",
|
||||
"accounting.invoice.handoff": "Rechnung an Buchhaltung übergeben",
|
||||
"accounting.invoice.mark_transferred": "Rechnung als übertragen markiert",
|
||||
"accounting.invoice.note_update": "Buchhaltungsnotiz geändert",
|
||||
}
|
||||
return labels.get(action, action)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import json
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.request import Request as UrlRequest
|
||||
|
|
@ -16,6 +17,7 @@ from app.models.repair_estimate import RepairEstimate
|
|||
from app.models.user import User
|
||||
from app.repositories.system_settings_repository import SystemSettingsRepository
|
||||
from app.schemas.lexware import (
|
||||
AccountingTransferUpdate,
|
||||
LexwareCustomerMapping,
|
||||
LexwareInvoicePreparationResponse,
|
||||
LexwareLineItemMapping,
|
||||
|
|
@ -276,7 +278,160 @@ class LexwareService:
|
|||
)
|
||||
for item in estimate.items
|
||||
]
|
||||
payload_summary = {
|
||||
payload_summary = LexwareService._invoice_payload_summary(config, repair, estimate, line_items)
|
||||
record = LexwareSyncRecord(
|
||||
entity_type="repair_estimate",
|
||||
entity_id=estimate.id,
|
||||
lexware_resource_type="invoice",
|
||||
status="pending" if not warnings else "skipped",
|
||||
direction="push",
|
||||
export_status="prepared",
|
||||
accounting_note=estimate.accounting_note,
|
||||
payload_summary=json.dumps(payload_summary, ensure_ascii=True),
|
||||
error_message="; ".join(warnings) if warnings else None,
|
||||
)
|
||||
estimate.accounting_export_status = "prepared"
|
||||
db.add(record)
|
||||
db.commit()
|
||||
db.refresh(record)
|
||||
db.refresh(estimate)
|
||||
write_audit_log(
|
||||
db,
|
||||
action="accounting.invoice.handoff",
|
||||
entity_type="repair_estimates",
|
||||
entity_id=estimate.id,
|
||||
entity_label=f"{estimate.estimate_number} · {estimate.title}",
|
||||
actor=actor,
|
||||
request=request,
|
||||
metadata={
|
||||
"repair_id": repair.id,
|
||||
"repair_number": repair.repair_number,
|
||||
"ready_for_export": not warnings,
|
||||
"sync_record_id": record.id,
|
||||
"export_status": record.export_status,
|
||||
},
|
||||
)
|
||||
return LexwareService._invoice_preparation_response(
|
||||
record=record,
|
||||
payload_summary=payload_summary,
|
||||
customer_mapping=customer_mapping,
|
||||
line_items=line_items,
|
||||
estimate=estimate,
|
||||
config=config,
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def mark_transferred(
|
||||
db: Session,
|
||||
repair: Repair,
|
||||
estimate: RepairEstimate,
|
||||
payload: AccountingTransferUpdate,
|
||||
*,
|
||||
actor: User,
|
||||
request: Request,
|
||||
) -> LexwareInvoicePreparationResponse:
|
||||
record = LexwareService._latest_invoice_record(db, estimate.id)
|
||||
if record is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Keine Rechnungsvorbereitung gefunden")
|
||||
if record.export_status not in {"prepared", "transferred"}:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Diese Rechnungsvorbereitung kann nicht als übertragen markiert werden")
|
||||
|
||||
note_changed = payload.accounting_note is not None and payload.accounting_note != (estimate.accounting_note or "")
|
||||
now = datetime.now(UTC)
|
||||
record.export_status = "transferred"
|
||||
record.status = "success"
|
||||
record.accounting_note = payload.accounting_note if payload.accounting_note is not None else record.accounting_note
|
||||
record.transferred_at = now
|
||||
record.transferred_by_user_id = actor.id
|
||||
record.synced_at = now
|
||||
estimate.accounting_export_status = "transferred"
|
||||
estimate.accounting_note = record.accounting_note
|
||||
estimate.accounting_transferred_at = now
|
||||
estimate.accounting_transferred_by_user_id = actor.id
|
||||
db.commit()
|
||||
db.refresh(record)
|
||||
db.refresh(estimate)
|
||||
|
||||
write_audit_log(
|
||||
db,
|
||||
action="accounting.invoice.mark_transferred",
|
||||
entity_type="repair_estimates",
|
||||
entity_id=estimate.id,
|
||||
entity_label=f"{estimate.estimate_number} · {estimate.title}",
|
||||
actor=actor,
|
||||
request=request,
|
||||
metadata={
|
||||
"repair_id": repair.id,
|
||||
"repair_number": repair.repair_number,
|
||||
"sync_record_id": record.id,
|
||||
"export_status": record.export_status,
|
||||
"transferred_at": record.transferred_at,
|
||||
},
|
||||
)
|
||||
if note_changed:
|
||||
write_audit_log(
|
||||
db,
|
||||
action="accounting.invoice.note_update",
|
||||
entity_type="repair_estimates",
|
||||
entity_id=estimate.id,
|
||||
entity_label=f"{estimate.estimate_number} · {estimate.title}",
|
||||
actor=actor,
|
||||
request=request,
|
||||
metadata={"repair_id": repair.id, "repair_number": repair.repair_number, "sync_record_id": record.id},
|
||||
)
|
||||
|
||||
config = LexwareService.get_runtime_config(db)
|
||||
customer_mapping, line_items = LexwareService._invoice_mapping(repair, estimate)
|
||||
warnings: list[str] = []
|
||||
payload_summary = LexwareService._invoice_payload_summary(config, repair, estimate, line_items)
|
||||
return LexwareService._invoice_preparation_response(
|
||||
record=record,
|
||||
payload_summary=payload_summary,
|
||||
customer_mapping=customer_mapping,
|
||||
line_items=line_items,
|
||||
estimate=estimate,
|
||||
config=config,
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _invoice_mapping(repair: Repair, estimate: RepairEstimate) -> tuple[LexwareCustomerMapping, list[LexwareLineItemMapping]]:
|
||||
customer_payload = {
|
||||
"roles": {"customer": {}},
|
||||
"company": {"name": repair.customer_name},
|
||||
"emailAddresses": {"business": [repair.customer_email]} if repair.customer_email else {},
|
||||
"phoneNumbers": {"business": [repair.customer_phone]} if repair.customer_phone else {},
|
||||
}
|
||||
customer_mapping = LexwareCustomerMapping(
|
||||
name=repair.customer_name,
|
||||
email=repair.customer_email,
|
||||
phone=repair.customer_phone,
|
||||
search_strategy="email" if repair.customer_email else "name",
|
||||
create_payload=customer_payload,
|
||||
)
|
||||
line_items = [
|
||||
LexwareLineItemMapping(
|
||||
title=item.title,
|
||||
description=item.description,
|
||||
quantity=item.quantity,
|
||||
unit=item.unit,
|
||||
unit_price=_euros(item.unit_price_cents),
|
||||
tax_rate=estimate.tax_rate_percent,
|
||||
total=_euros(item.total_cents),
|
||||
)
|
||||
for item in estimate.items
|
||||
]
|
||||
return customer_mapping, line_items
|
||||
|
||||
@staticmethod
|
||||
def _invoice_payload_summary(
|
||||
config: LexwareRuntimeConfig,
|
||||
repair: Repair,
|
||||
estimate: RepairEstimate,
|
||||
line_items: list[LexwareLineItemMapping],
|
||||
) -> dict:
|
||||
return {
|
||||
"type": "invoice",
|
||||
"title": f"Rechnung zu Reparatur {repair.repair_number}",
|
||||
"introduction": f"Rechnung zu Reparatur {repair.repair_number} gemäß Kostenvoranschlag {estimate.estimate_number}.",
|
||||
|
|
@ -289,35 +444,21 @@ class LexwareService:
|
|||
"total": str(_euros(estimate.total_cents)),
|
||||
"line_item_count": len(line_items),
|
||||
}
|
||||
record = LexwareSyncRecord(
|
||||
entity_type="repair_estimate",
|
||||
entity_id=estimate.id,
|
||||
lexware_resource_type="invoice",
|
||||
status="pending" if not warnings else "skipped",
|
||||
direction="push",
|
||||
payload_summary=json.dumps(payload_summary, ensure_ascii=True),
|
||||
error_message="; ".join(warnings) if warnings else None,
|
||||
)
|
||||
db.add(record)
|
||||
db.commit()
|
||||
db.refresh(record)
|
||||
write_audit_log(
|
||||
db,
|
||||
action="lexware.invoice.prepare",
|
||||
entity_type="repair_estimates",
|
||||
entity_id=estimate.id,
|
||||
entity_label=f"{estimate.estimate_number} · {estimate.title}",
|
||||
actor=actor,
|
||||
request=request,
|
||||
metadata={
|
||||
"repair_id": repair.id,
|
||||
"repair_number": repair.repair_number,
|
||||
"ready_for_export": not warnings,
|
||||
"sync_record_id": record.id,
|
||||
},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _invoice_preparation_response(
|
||||
*,
|
||||
record: LexwareSyncRecord,
|
||||
payload_summary: dict,
|
||||
customer_mapping: LexwareCustomerMapping,
|
||||
line_items: list[LexwareLineItemMapping],
|
||||
estimate: RepairEstimate,
|
||||
config: LexwareRuntimeConfig,
|
||||
warnings: list[str],
|
||||
) -> LexwareInvoicePreparationResponse:
|
||||
return LexwareInvoicePreparationResponse(
|
||||
ready_for_export=not warnings,
|
||||
export_status=record.export_status,
|
||||
payload_summary=payload_summary,
|
||||
customer_mapping=customer_mapping,
|
||||
line_item_mapping=line_items,
|
||||
|
|
@ -329,6 +470,22 @@ class LexwareService:
|
|||
},
|
||||
warnings=warnings,
|
||||
sync_record_id=record.id,
|
||||
accounting_note=record.accounting_note or "",
|
||||
transferred_at=record.transferred_at.isoformat() if record.transferred_at else None,
|
||||
transferred_by_user_id=record.transferred_by_user_id,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _latest_invoice_record(db: Session, estimate_id: int) -> LexwareSyncRecord | None:
|
||||
from sqlalchemy import select
|
||||
|
||||
return db.scalar(
|
||||
select(LexwareSyncRecord)
|
||||
.where(LexwareSyncRecord.entity_type == "repair_estimate")
|
||||
.where(LexwareSyncRecord.entity_id == estimate_id)
|
||||
.where(LexwareSyncRecord.lexware_resource_type == "invoice")
|
||||
.order_by(LexwareSyncRecord.created_at.desc(), LexwareSyncRecord.id.desc())
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue