Add offer workflow with Terminus and graph sync

This commit is contained in:
Ruslan Bakiev
2025-12-30 10:33:17 +07:00
parent 73da622143
commit 805a05a86d
22 changed files with 956 additions and 66 deletions

View File

@@ -0,0 +1,127 @@
"""
M2M (Machine-to-Machine) GraphQL schema for Exchange.
Used by internal services (Temporal workflows, etc.) without user authentication.
"""
import graphene
import logging
from graphene_django import DjangoObjectType
from offers.models import Offer
logger = logging.getLogger(__name__)
class OfferType(DjangoObjectType):
class Meta:
model = Offer
fields = "__all__"
class M2MQuery(graphene.ObjectType):
offer = graphene.Field(OfferType, offerUuid=graphene.String(required=True))
def resolve_offer(self, info, offerUuid):
try:
return Offer.objects.get(uuid=offerUuid)
except Offer.DoesNotExist:
return None
class CreateOfferFromWorkflowInput(graphene.InputObjectType):
offerUuid = graphene.String(required=True)
teamUuid = graphene.String(required=True)
productUuid = graphene.String(required=True)
productName = graphene.String(required=True)
categoryName = graphene.String()
locationUuid = graphene.String()
locationName = graphene.String()
locationCountry = graphene.String()
locationCountryCode = graphene.String()
locationLatitude = graphene.Float()
locationLongitude = graphene.Float()
quantity = graphene.Decimal(required=True)
unit = graphene.String()
pricePerUnit = graphene.Decimal()
currency = graphene.String()
description = graphene.String()
validUntil = graphene.Date()
terminusSchemaId = graphene.String()
terminusDocumentId = graphene.String()
class CreateOfferFromWorkflow(graphene.Mutation):
class Arguments:
input = CreateOfferFromWorkflowInput(required=True)
success = graphene.Boolean()
message = graphene.String()
offer = graphene.Field(OfferType)
def mutate(self, info, input):
try:
offer = Offer.objects.filter(uuid=input.offerUuid).first()
if offer:
logger.info("Offer %s already exists, returning existing", input.offerUuid)
return CreateOfferFromWorkflow(success=True, message="Offer exists", offer=offer)
offer = Offer.objects.create(
uuid=input.offerUuid,
team_uuid=input.teamUuid,
product_uuid=input.productUuid,
product_name=input.productName,
category_name=input.categoryName or '',
location_uuid=input.locationUuid or '',
location_name=input.locationName or '',
location_country=input.locationCountry or '',
location_country_code=input.locationCountryCode or '',
location_latitude=input.locationLatitude,
location_longitude=input.locationLongitude,
quantity=input.quantity,
unit=input.unit or 'ton',
price_per_unit=input.pricePerUnit,
currency=input.currency or 'USD',
description=input.description or '',
valid_until=input.validUntil,
terminus_schema_id=input.terminusSchemaId or '',
terminus_document_id=input.terminusDocumentId or '',
workflow_status='pending',
)
logger.info("Created offer %s via workflow", offer.uuid)
return CreateOfferFromWorkflow(success=True, message="Offer created", offer=offer)
except Exception as exc:
logger.exception("Failed to create offer %s", input.offerUuid)
return CreateOfferFromWorkflow(success=False, message=str(exc), offer=None)
class UpdateOfferWorkflowStatusInput(graphene.InputObjectType):
offerUuid = graphene.String(required=True)
status = graphene.String(required=True) # pending | active | error
errorMessage = graphene.String()
class UpdateOfferWorkflowStatus(graphene.Mutation):
class Arguments:
input = UpdateOfferWorkflowStatusInput(required=True)
success = graphene.Boolean()
message = graphene.String()
offer = graphene.Field(OfferType)
def mutate(self, info, input):
try:
offer = Offer.objects.get(uuid=input.offerUuid)
offer.workflow_status = input.status
if input.errorMessage is not None:
offer.workflow_error = input.errorMessage
offer.save(update_fields=["workflow_status", "workflow_error", "updated_at"])
logger.info("Offer %s workflow_status updated to %s", input.offerUuid, input.status)
return UpdateOfferWorkflowStatus(success=True, message="Status updated", offer=offer)
except Offer.DoesNotExist:
return UpdateOfferWorkflowStatus(success=False, message="Offer not found", offer=None)
class M2MMutation(graphene.ObjectType):
createOfferFromWorkflow = CreateOfferFromWorkflow.Field()
updateOfferWorkflowStatus = UpdateOfferWorkflowStatus.Field()
m2m_schema = graphene.Schema(query=M2MQuery, mutation=M2MMutation)

View File

@@ -46,6 +46,8 @@ class OfferInput(graphene.InputObjectType):
# Прочее # Прочее
description = graphene.String() description = graphene.String()
valid_until = graphene.Date() valid_until = graphene.Date()
terminus_schema_id = graphene.String()
terminus_payload = graphene.JSONString()
class TeamQuery(graphene.ObjectType): class TeamQuery(graphene.ObjectType):
@@ -93,35 +95,46 @@ class CreateOffer(graphene.Mutation):
class Arguments: class Arguments:
input = OfferInput(required=True) input = OfferInput(required=True)
offer = graphene.Field(OfferType) success = graphene.Boolean()
message = graphene.String()
workflowId = graphene.String()
offerUuid = graphene.String()
@require_scopes("teams:member") @require_scopes("teams:member")
def mutate(self, info, input): def mutate(self, info, input):
offer = Offer( from ..temporal_client import start_offer_workflow
uuid=str(uuid_lib.uuid4()),
team_uuid=input.team_uuid, try:
# Товар offer_uuid = str(uuid_lib.uuid4())
product_uuid=input.product_uuid, workflow_id, _ = start_offer_workflow(
product_name=input.product_name, offer_uuid=offer_uuid,
category_name=input.category_name or '', team_uuid=input.team_uuid,
# Локация product_uuid=input.product_uuid,
location_uuid=input.location_uuid or '', product_name=input.product_name,
location_name=input.location_name or '', category_name=input.category_name,
location_country=input.location_country or '', location_uuid=input.location_uuid,
location_country_code=input.location_country_code or '', location_name=input.location_name,
location_latitude=input.location_latitude, location_country=input.location_country,
location_longitude=input.location_longitude, location_country_code=input.location_country_code,
# Цена и количество location_latitude=input.location_latitude,
quantity=input.quantity, location_longitude=input.location_longitude,
unit=input.unit or 'ton', quantity=input.quantity,
price_per_unit=input.price_per_unit, unit=input.unit,
currency=input.currency or 'USD', price_per_unit=input.price_per_unit,
# Прочее currency=input.currency,
description=input.description or '', description=input.description,
valid_until=input.valid_until, valid_until=input.valid_until,
) terminus_schema_id=getattr(input, "terminus_schema_id", None),
offer.save() terminus_payload=getattr(input, "terminus_payload", None),
return CreateOffer(offer=offer) )
return CreateOffer(
success=True,
message="Offer workflow started",
workflowId=workflow_id,
offerUuid=offer_uuid,
)
except Exception as exc:
return CreateOffer(success=False, message=str(exc), workflowId=None, offerUuid=None)
class UpdateOffer(graphene.Mutation): class UpdateOffer(graphene.Mutation):
@@ -154,6 +167,8 @@ class UpdateOffer(graphene.Mutation):
offer.currency = input.currency or 'USD' offer.currency = input.currency or 'USD'
offer.description = input.description or '' offer.description = input.description or ''
offer.valid_until = input.valid_until offer.valid_until = input.valid_until
if input.terminus_schema_id is not None:
offer.terminus_schema_id = input.terminus_schema_id
offer.save() offer.save()
return UpdateOffer(offer=offer) return UpdateOffer(offer=offer)

View File

@@ -0,0 +1,79 @@
import asyncio
import logging
import os
from typing import Tuple
from temporalio.client import Client
logger = logging.getLogger(__name__)
TEMPORAL_ADDRESS = os.getenv("TEMPORAL_ADDRESS", "temporal:7233")
TEMPORAL_NAMESPACE = os.getenv("TEMPORAL_NAMESPACE", "default")
TEMPORAL_TASK_QUEUE = os.getenv("TEMPORAL_TASK_QUEUE", "platform-worker")
async def _start_offer_workflow_async(payload: dict) -> Tuple[str, str]:
client = await Client.connect(TEMPORAL_ADDRESS, namespace=TEMPORAL_NAMESPACE)
workflow_id = f"offer-{payload['offer_uuid']}"
handle = await client.start_workflow(
"create_offer",
payload,
id=workflow_id,
task_queue=TEMPORAL_TASK_QUEUE,
)
logger.info("Started offer workflow %s", workflow_id)
return handle.id, handle.result_run_id
def start_offer_workflow(
*,
offer_uuid: str,
team_uuid: str,
product_uuid: str,
product_name: str,
category_name: str | None = None,
location_uuid: str | None = None,
location_name: str | None = None,
location_country: str | None = None,
location_country_code: str | None = None,
location_latitude: float | None = None,
location_longitude: float | None = None,
quantity=None,
unit: str | None = None,
price_per_unit=None,
currency: str | None = None,
description: str | None = None,
valid_until=None,
terminus_schema_id: str | None = None,
terminus_payload: dict | None = None,
) -> Tuple[str, str]:
payload = {
"offer_uuid": offer_uuid,
"team_uuid": team_uuid,
"product_uuid": product_uuid,
"product_name": product_name,
"category_name": category_name,
"location_uuid": location_uuid,
"location_name": location_name,
"location_country": location_country,
"location_country_code": location_country_code,
"location_latitude": location_latitude,
"location_longitude": location_longitude,
"quantity": str(quantity) if quantity is not None else None,
"unit": unit,
"price_per_unit": str(price_per_unit) if price_per_unit is not None else None,
"currency": currency,
"description": description,
"valid_until": valid_until.isoformat() if hasattr(valid_until, "isoformat") else valid_until,
"terminus_schema_id": terminus_schema_id,
"terminus_payload": terminus_payload,
}
try:
return asyncio.run(_start_offer_workflow_async(payload))
except Exception:
logger.exception("Failed to start offer workflow %s", offer_uuid)
raise

View File

@@ -1,14 +1,16 @@
from django.contrib import admin from django.contrib import admin
from django.urls import path from django.urls import path
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from .views import PublicGraphQLView, UserGraphQLView, TeamGraphQLView from .views import PublicGraphQLView, UserGraphQLView, TeamGraphQLView, M2MGraphQLView
from .schemas.public_schema import public_schema from .schemas.public_schema import public_schema
from .schemas.user_schema import user_schema from .schemas.user_schema import user_schema
from .schemas.team_schema import team_schema from .schemas.team_schema import team_schema
from .schemas.m2m_schema import m2m_schema
urlpatterns = [ urlpatterns = [
path('admin/', admin.site.urls), path('admin/', admin.site.urls),
path('graphql/public/', csrf_exempt(PublicGraphQLView.as_view(graphiql=True, schema=public_schema))), path('graphql/public/', csrf_exempt(PublicGraphQLView.as_view(graphiql=True, schema=public_schema))),
path('graphql/user/', csrf_exempt(UserGraphQLView.as_view(graphiql=True, schema=user_schema))), path('graphql/user/', csrf_exempt(UserGraphQLView.as_view(graphiql=True, schema=user_schema))),
path('graphql/team/', csrf_exempt(TeamGraphQLView.as_view(graphiql=True, schema=team_schema))), path('graphql/team/', csrf_exempt(TeamGraphQLView.as_view(graphiql=True, schema=team_schema))),
path('graphql/m2m/', csrf_exempt(M2MGraphQLView.as_view(graphiql=True, schema=m2m_schema))),
] ]

View File

@@ -19,3 +19,8 @@ class UserGraphQLView(GraphQLView):
class TeamGraphQLView(GraphQLView): class TeamGraphQLView(GraphQLView):
"""Team endpoint - requires Organization Access Token.""" """Team endpoint - requires Organization Access Token."""
pass pass
class M2MGraphQLView(GraphQLView):
"""M2M endpoint - internal services only."""
pass

View File

@@ -0,0 +1,28 @@
# Generated by Django 5.2.9 on 2025-12-30 03:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('offers', '0003_offer_workflow_status'),
]
operations = [
migrations.AddField(
model_name='offer',
name='terminus_document_id',
field=models.CharField(blank=True, default='', max_length=255),
),
migrations.AddField(
model_name='offer',
name='terminus_schema_id',
field=models.CharField(blank=True, default='', max_length=255),
),
migrations.AddField(
model_name='offer',
name='workflow_error',
field=models.TextField(blank=True, default=''),
),
]

View File

@@ -24,6 +24,7 @@ class Offer(models.Model):
choices=WORKFLOW_STATUS_CHOICES, choices=WORKFLOW_STATUS_CHOICES,
default='pending', default='pending',
) )
workflow_error = models.TextField(blank=True, default='')
# Локация отгрузки # Локация отгрузки
location_uuid = models.CharField(max_length=100, blank=True, default='') location_uuid = models.CharField(max_length=100, blank=True, default='')
@@ -46,6 +47,8 @@ class Offer(models.Model):
# Описание (опционально) # Описание (опционально)
description = models.TextField(blank=True, default='') description = models.TextField(blank=True, default='')
terminus_schema_id = models.CharField(max_length=255, blank=True, default='')
terminus_document_id = models.CharField(max_length=255, blank=True, default='')
# Срок действия # Срок действия
valid_until = models.DateField(null=True, blank=True) valid_until = models.DateField(null=True, blank=True)

View File

@@ -2,6 +2,9 @@
from .odoo import add_kyc_to_odoo, set_buyer_logto_org from .odoo import add_kyc_to_odoo, set_buyer_logto_org
from .teams import set_logto_org_id from .teams import set_logto_org_id
from .exchange import create_offer_in_exchange, update_offer_workflow_status
from .terminus import save_offer_description
from .offers import trigger_prefect_offer_sync
from .logto import create_logto_org from .logto import create_logto_org
from .address import ( from .address import (
create_address_in_django, create_address_in_django,
@@ -14,6 +17,10 @@ __all__ = [
"set_buyer_logto_org", "set_buyer_logto_org",
"set_logto_org_id", "set_logto_org_id",
"create_logto_org", "create_logto_org",
"create_offer_in_exchange",
"update_offer_workflow_status",
"save_offer_description",
"trigger_prefect_offer_sync",
"create_address_in_django", "create_address_in_django",
"update_address_status", "update_address_status",
"trigger_prefect_sync", "trigger_prefect_sync",

View File

@@ -0,0 +1,113 @@
"""Activities for Exchange service via GraphQL M2M endpoint."""
import logging
import httpx
from temporalio import activity
from optovia_workflows.types import (
CreateOfferInExchangeInput,
CreateOfferInExchangeResult,
UpdateOfferWorkflowStatusInput,
UpdateOfferWorkflowStatusResult,
)
from ..config import settings
logger = logging.getLogger(__name__)
CREATE_OFFER_MUTATION = """
mutation CreateOfferFromWorkflow($input: CreateOfferFromWorkflowInput!) {
createOfferFromWorkflow(input: $input) {
success
message
offer {
uuid
}
}
}
"""
UPDATE_OFFER_STATUS_MUTATION = """
mutation UpdateOfferWorkflowStatus($input: UpdateOfferWorkflowStatusInput!) {
updateOfferWorkflowStatus(input: $input) {
success
message
offer {
uuid
}
}
}
"""
@activity.defn(name="create_offer_in_exchange")
def create_offer_in_exchange(data: CreateOfferInExchangeInput) -> CreateOfferInExchangeResult:
url = f"{settings.EXCHANGE_API_URL}/graphql/m2m/"
payload = {
"offerUuid": data.offer_uuid,
"teamUuid": data.team_uuid,
"productUuid": data.product_uuid,
"productName": data.product_name,
"categoryName": data.category_name,
"locationUuid": data.location_uuid,
"locationName": data.location_name,
"locationCountry": data.location_country,
"locationCountryCode": data.location_country_code,
"locationLatitude": data.location_latitude,
"locationLongitude": data.location_longitude,
"quantity": data.quantity,
"unit": data.unit,
"pricePerUnit": data.price_per_unit,
"currency": data.currency,
"description": data.description,
"validUntil": data.valid_until,
"terminusSchemaId": data.terminus_schema_id,
"terminusDocumentId": data.terminus_document_id,
}
with httpx.Client(timeout=settings.EXCHANGE_TIMEOUT) as client:
response = client.post(
url,
json={"query": CREATE_OFFER_MUTATION, "variables": {"input": payload}},
headers={"Content-Type": "application/json"},
)
response.raise_for_status()
result = response.json()
if "errors" in result:
return CreateOfferInExchangeResult(success=False, offer_uuid=data.offer_uuid, message=str(result["errors"]))
mutation_result = result.get("data", {}).get("createOfferFromWorkflow", {})
if not mutation_result.get("success"):
return CreateOfferInExchangeResult(
success=False,
offer_uuid=data.offer_uuid,
message=mutation_result.get("message", "Unknown error"),
)
return CreateOfferInExchangeResult(success=True, offer_uuid=data.offer_uuid)
@activity.defn(name="update_offer_workflow_status")
def update_offer_workflow_status(data: UpdateOfferWorkflowStatusInput) -> UpdateOfferWorkflowStatusResult:
url = f"{settings.EXCHANGE_API_URL}/graphql/m2m/"
payload = {
"offerUuid": data.offer_uuid,
"status": data.status,
"errorMessage": data.error_message,
}
with httpx.Client(timeout=settings.EXCHANGE_TIMEOUT) as client:
response = client.post(
url,
json={"query": UPDATE_OFFER_STATUS_MUTATION, "variables": {"input": payload}},
headers={"Content-Type": "application/json"},
)
response.raise_for_status()
result = response.json()
if "errors" in result:
return UpdateOfferWorkflowStatusResult(success=False, message=str(result["errors"]))
mutation_result = result.get("data", {}).get("updateOfferWorkflowStatus", {})
if not mutation_result.get("success"):
return UpdateOfferWorkflowStatusResult(success=False, message=mutation_result.get("message", "Unknown error"))
return UpdateOfferWorkflowStatusResult(success=True, message="OK")

View File

@@ -0,0 +1,79 @@
"""Activities to sync offers into graph via Prefect."""
import logging
import time
import httpx
from temporalio import activity
from optovia_workflows.types import TriggerPrefectOfferSyncInput, TriggerPrefectOfferSyncResult
from ..config import settings
logger = logging.getLogger(__name__)
@activity.defn(name="trigger_prefect_offer_sync")
def trigger_prefect_offer_sync(data: TriggerPrefectOfferSyncInput) -> TriggerPrefectOfferSyncResult:
"""
Trigger Prefect sync-offer flow to create offer node and edge to location.
"""
try:
deployment_url = f"{settings.PREFECT_API_URL}/deployments/name/sync-offer/sync-offer-deployment"
with httpx.Client(timeout=30) as client:
response = client.get(deployment_url)
response.raise_for_status()
deployment_id = response.json()["id"]
node_data = {
"uuid": data.offer_uuid,
"node_type": "offer",
"location_uuid": data.location_uuid,
"location_latitude": data.location_latitude,
"location_longitude": data.location_longitude,
"team_uuid": data.team_uuid,
"product_uuid": data.product_uuid,
"price_per_unit": data.price_per_unit,
"currency": data.currency,
"quantity": data.quantity,
"unit": data.unit,
}
run_url = f"{settings.PREFECT_API_URL}/deployments/{deployment_id}/create_flow_run"
response = client.post(run_url, json={"parameters": {"offer_data": node_data}})
response.raise_for_status()
flow_run_id = response.json()["id"]
logger.info("Created Prefect offer flow run %s", flow_run_id)
poll_interval = 60
max_polls = 20
polls = 0
while polls < max_polls:
with httpx.Client(timeout=30) as client:
flow_run_url = f"{settings.PREFECT_API_URL}/flow_runs/{flow_run_id}"
response = client.get(flow_run_url)
response.raise_for_status()
flow_run = response.json()
state_type = flow_run.get("state", {}).get("type")
state_name = flow_run.get("state", {}).get("name", "")
if state_type == "COMPLETED":
return TriggerPrefectOfferSyncResult(success=True, flow_run_id=flow_run_id)
if state_type in ("FAILED", "CANCELLED", "CRASHED"):
return TriggerPrefectOfferSyncResult(
success=False,
flow_run_id=flow_run_id,
message=f"Flow run {state_type}: {state_name}",
)
polls += 1
time.sleep(poll_interval)
return TriggerPrefectOfferSyncResult(
success=False,
flow_run_id=flow_run_id,
message=f"Timeout waiting for flow run {flow_run_id}",
)
except Exception as exc:
logger.exception("Failed to trigger Prefect offer sync")
return TriggerPrefectOfferSyncResult(success=False, message=str(exc))

View File

@@ -0,0 +1,49 @@
"""Activities for TerminusDB document storage."""
import base64
import logging
import httpx
from temporalio import activity
from optovia_workflows.types import SaveOfferDescriptionInput, SaveOfferDescriptionResult
from ..config import settings
logger = logging.getLogger(__name__)
def _basic_auth_header() -> str:
if settings.TERMINUS_BASIC_USER and settings.TERMINUS_BASIC_PASS:
token = f"{settings.TERMINUS_BASIC_USER}:{settings.TERMINUS_BASIC_PASS}".encode("utf-8")
return "Basic " + base64.b64encode(token).decode("utf-8")
return ""
@activity.defn(name="save_offer_description")
def save_offer_description(data: SaveOfferDescriptionInput) -> SaveOfferDescriptionResult:
if not settings.TERMINUS_DOCUMENT_URL:
return SaveOfferDescriptionResult(success=False, message="Terminus URL not configured")
headers = {"Content-Type": "application/json"}
auth_header = _basic_auth_header()
if auth_header:
headers["Authorization"] = auth_header
document = {
"@type": data.schema_id,
"@id": data.offer_uuid,
"offer_uuid": data.offer_uuid,
**data.payload,
}
try:
with httpx.Client(timeout=settings.TERMINUS_TIMEOUT) as client:
response = client.post(
settings.TERMINUS_DOCUMENT_URL,
json=document,
headers=headers,
params={"graph_type": "instance"},
)
response.raise_for_status()
return SaveOfferDescriptionResult(success=True, document_id=data.offer_uuid)
except Exception as exc:
logger.exception("Failed to save offer description to Terminus")
return SaveOfferDescriptionResult(success=False, message=str(exc))

View File

@@ -65,6 +65,10 @@ class Settings(BaseSettings):
TEAMS_API_URL: str = "http://teams:8000" TEAMS_API_URL: str = "http://teams:8000"
TEAMS_TIMEOUT: float = 15.0 TEAMS_TIMEOUT: float = 15.0
# Exchange service
EXCHANGE_API_URL: str = "http://exchange:8000"
EXCHANGE_TIMEOUT: float = 15.0
# Logto # Logto
LOGTO_API_URL: str = "http://logto:3001" LOGTO_API_URL: str = "http://logto:3001"
LOGTO_M2M_APP_ID: str = "" LOGTO_M2M_APP_ID: str = ""
@@ -80,5 +84,11 @@ class Settings(BaseSettings):
# Prefect (loaded from Infisical /shared) # Prefect (loaded from Infisical /shared)
PREFECT_API_URL: str PREFECT_API_URL: str
# TerminusDB
TERMINUS_DOCUMENT_URL: str = ""
TERMINUS_BASIC_USER: str = ""
TERMINUS_BASIC_PASS: str = ""
TERMINUS_TIMEOUT: float = 15.0
settings = Settings() settings = Settings()

View File

@@ -18,7 +18,7 @@ from concurrent.futures import ThreadPoolExecutor
from temporalio.client import Client from temporalio.client import Client
from temporalio.worker import Worker from temporalio.worker import Worker
from optovia_workflows import KycApplicationWorkflow, AddressWorkflow from optovia_workflows import KycApplicationWorkflow, AddressWorkflow, OfferWorkflow
from . import activities from . import activities
from .config import settings from .config import settings
@@ -65,12 +65,16 @@ async def run_worker() -> None:
worker = Worker( worker = Worker(
client, client,
task_queue=settings.TASK_QUEUE, task_queue=settings.TASK_QUEUE,
workflows=[KycApplicationWorkflow, AddressWorkflow], workflows=[KycApplicationWorkflow, AddressWorkflow, OfferWorkflow],
activities=[ activities=[
activities.add_kyc_to_odoo, activities.add_kyc_to_odoo,
activities.set_buyer_logto_org, activities.set_buyer_logto_org,
activities.create_logto_org, activities.create_logto_org,
activities.set_logto_org_id, activities.set_logto_org_id,
activities.create_offer_in_exchange,
activities.update_offer_workflow_status,
activities.save_offer_description,
activities.trigger_prefect_offer_sync,
activities.create_address_in_django, activities.create_address_in_django,
activities.update_address_status, activities.update_address_status,
activities.trigger_prefect_sync, activities.trigger_prefect_sync,

View File

@@ -2,6 +2,7 @@
from .kyc import KycApplicationWorkflow from .kyc import KycApplicationWorkflow
from .address import AddressWorkflow from .address import AddressWorkflow
from .offer import OfferWorkflow
from .types import ( from .types import (
KycApprovalData, KycApprovalData,
KycData, KycData,
@@ -9,6 +10,8 @@ from .types import (
KycWorkflowResult, KycWorkflowResult,
AddressData, AddressData,
AddressWorkflowResult, AddressWorkflowResult,
OfferData,
OfferWorkflowResult,
) )
__all__ = [ __all__ = [
@@ -20,4 +23,7 @@ __all__ = [
"AddressWorkflow", "AddressWorkflow",
"AddressData", "AddressData",
"AddressWorkflowResult", "AddressWorkflowResult",
"OfferWorkflow",
"OfferData",
"OfferWorkflowResult",
] ]

View File

@@ -0,0 +1,5 @@
"""Offer workflow module."""
from .workflow import OfferWorkflow
__all__ = ["OfferWorkflow"]

View File

@@ -0,0 +1,174 @@
"""
Offer Workflow.
Creates an offer in Exchange, stores description in Terminus, and
syncs offer node/edge in graph via Prefect.
"""
from datetime import timedelta
from temporalio import workflow
from temporalio.common import RetryPolicy
with workflow.unsafe.imports_passed_through():
from ..types import (
OfferData,
OfferWorkflowResult,
CreateOfferInExchangeInput,
CreateOfferInExchangeResult,
SaveOfferDescriptionInput,
SaveOfferDescriptionResult,
TriggerPrefectOfferSyncInput,
TriggerPrefectOfferSyncResult,
UpdateOfferWorkflowStatusInput,
)
EXTERNAL_API_RETRY = RetryPolicy(
maximum_attempts=5,
initial_interval=timedelta(seconds=2),
backoff_coefficient=2.0,
maximum_interval=timedelta(seconds=30),
)
INTERNAL_API_RETRY = RetryPolicy(
maximum_attempts=3,
initial_interval=timedelta(seconds=1),
backoff_coefficient=2.0,
maximum_interval=timedelta(seconds=10),
)
@workflow.defn(name="create_offer")
class OfferWorkflow:
@workflow.run
async def run(self, data: OfferData) -> OfferWorkflowResult:
workflow.logger.info("Starting offer workflow %s", data.offer_uuid)
create_result: CreateOfferInExchangeResult = await workflow.execute_activity(
"create_offer_in_exchange",
CreateOfferInExchangeInput(
offer_uuid=data.offer_uuid,
team_uuid=data.team_uuid,
product_uuid=data.product_uuid,
product_name=data.product_name,
category_name=data.category_name,
location_uuid=data.location_uuid,
location_name=data.location_name,
location_country=data.location_country,
location_country_code=data.location_country_code,
location_latitude=data.location_latitude,
location_longitude=data.location_longitude,
quantity=data.quantity,
unit=data.unit,
price_per_unit=data.price_per_unit,
currency=data.currency,
description=data.description,
valid_until=data.valid_until,
terminus_schema_id=data.terminus_schema_id,
terminus_document_id=data.offer_uuid,
),
schedule_to_close_timeout=timedelta(seconds=30),
start_to_close_timeout=timedelta(seconds=15),
retry_policy=INTERNAL_API_RETRY,
)
success = create_result.success if hasattr(create_result, "success") else create_result["success"]
if not success:
message = create_result.message if hasattr(create_result, "message") else create_result.get("message", "")
workflow.logger.error("Failed to create offer in Exchange: %s", message)
return OfferWorkflowResult(status="failed", offer_uuid=data.offer_uuid, message=message)
# Save description to Terminus
if data.terminus_schema_id and data.terminus_payload:
save_result: SaveOfferDescriptionResult = await workflow.execute_activity(
"save_offer_description",
SaveOfferDescriptionInput(
offer_uuid=data.offer_uuid,
schema_id=data.terminus_schema_id,
payload=data.terminus_payload,
),
schedule_to_close_timeout=timedelta(seconds=30),
start_to_close_timeout=timedelta(seconds=15),
retry_policy=EXTERNAL_API_RETRY,
)
save_success = save_result.success if hasattr(save_result, "success") else save_result.get("success", False)
if not save_success:
message = save_result.message if hasattr(save_result, "message") else save_result.get("message", "")
await workflow.execute_activity(
"update_offer_workflow_status",
UpdateOfferWorkflowStatusInput(
offer_uuid=data.offer_uuid,
status="error",
error_message=message,
),
schedule_to_close_timeout=timedelta(seconds=30),
start_to_close_timeout=timedelta(seconds=15),
retry_policy=INTERNAL_API_RETRY,
)
return OfferWorkflowResult(status="failed", offer_uuid=data.offer_uuid, message=message)
else:
workflow.logger.warning("No Terminus payload for offer %s", data.offer_uuid)
# Sync offer node + edge in graph
if data.location_uuid and data.location_latitude is not None and data.location_longitude is not None:
sync_result: TriggerPrefectOfferSyncResult = await workflow.execute_activity(
"trigger_prefect_offer_sync",
TriggerPrefectOfferSyncInput(
offer_uuid=data.offer_uuid,
location_uuid=data.location_uuid,
location_latitude=data.location_latitude,
location_longitude=data.location_longitude,
team_uuid=data.team_uuid,
product_uuid=data.product_uuid,
price_per_unit=data.price_per_unit,
currency=data.currency,
quantity=data.quantity,
unit=data.unit,
),
schedule_to_close_timeout=timedelta(minutes=20),
start_to_close_timeout=timedelta(minutes=20),
retry_policy=EXTERNAL_API_RETRY,
)
sync_success = sync_result.success if hasattr(sync_result, "success") else sync_result.get("success", False)
if not sync_success:
message = sync_result.message if hasattr(sync_result, "message") else sync_result.get("message", "")
await workflow.execute_activity(
"update_offer_workflow_status",
UpdateOfferWorkflowStatusInput(
offer_uuid=data.offer_uuid,
status="error",
error_message=message,
),
schedule_to_close_timeout=timedelta(seconds=30),
start_to_close_timeout=timedelta(seconds=15),
retry_policy=INTERNAL_API_RETRY,
)
return OfferWorkflowResult(status="failed", offer_uuid=data.offer_uuid, message=message)
else:
message = "Offer location coordinates are missing"
await workflow.execute_activity(
"update_offer_workflow_status",
UpdateOfferWorkflowStatusInput(
offer_uuid=data.offer_uuid,
status="error",
error_message=message,
),
schedule_to_close_timeout=timedelta(seconds=30),
start_to_close_timeout=timedelta(seconds=15),
retry_policy=INTERNAL_API_RETRY,
)
return OfferWorkflowResult(status="failed", offer_uuid=data.offer_uuid, message=message)
await workflow.execute_activity(
"update_offer_workflow_status",
UpdateOfferWorkflowStatusInput(
offer_uuid=data.offer_uuid,
status="active",
),
schedule_to_close_timeout=timedelta(seconds=30),
start_to_close_timeout=timedelta(seconds=15),
retry_policy=INTERNAL_API_RETRY,
)
workflow.logger.info("Offer workflow completed: %s", data.offer_uuid)
return OfferWorkflowResult(status="completed", offer_uuid=data.offer_uuid)

View File

@@ -123,6 +123,139 @@ class SetLogtoOrgIdResult:
updated: bool updated: bool
# ============================================================================
# Offer Workflow Types
# ============================================================================
@dataclass
class OfferData:
"""Input data for offer creation workflow."""
offer_uuid: str
team_uuid: str
product_uuid: str
product_name: str
category_name: str | None = None
location_uuid: str | None = None
location_name: str | None = None
location_country: str | None = None
location_country_code: str | None = None
location_latitude: float | None = None
location_longitude: float | None = None
quantity: str | None = None
unit: str | None = None
price_per_unit: str | None = None
currency: str | None = None
description: str | None = None
valid_until: str | None = None
terminus_schema_id: str | None = None
terminus_payload: dict | None = None
@dataclass
class OfferWorkflowResult:
"""Output of offer workflow."""
status: str # completed | failed
offer_uuid: str
message: str = ""
@dataclass
class CreateOfferInExchangeInput:
"""Input for create_offer_in_exchange activity."""
offer_uuid: str
team_uuid: str
product_uuid: str
product_name: str
category_name: str | None = None
location_uuid: str | None = None
location_name: str | None = None
location_country: str | None = None
location_country_code: str | None = None
location_latitude: float | None = None
location_longitude: float | None = None
quantity: str | None = None
unit: str | None = None
price_per_unit: str | None = None
currency: str | None = None
description: str | None = None
valid_until: str | None = None
terminus_schema_id: str | None = None
terminus_document_id: str | None = None
@dataclass
class CreateOfferInExchangeResult:
"""Result of create_offer_in_exchange activity."""
success: bool
offer_uuid: str
message: str = ""
@dataclass
class UpdateOfferWorkflowStatusInput:
"""Input for update_offer_workflow_status activity."""
offer_uuid: str
status: str # pending | active | error
error_message: str | None = None
@dataclass
class UpdateOfferWorkflowStatusResult:
"""Result of update_offer_workflow_status activity."""
success: bool
message: str = ""
@dataclass
class SaveOfferDescriptionInput:
"""Input for save_offer_description activity."""
offer_uuid: str
schema_id: str
payload: dict
@dataclass
class SaveOfferDescriptionResult:
"""Result of save_offer_description activity."""
success: bool
document_id: str = ""
message: str = ""
@dataclass
class TriggerPrefectOfferSyncInput:
"""Input for trigger_prefect_offer_sync activity."""
offer_uuid: str
location_uuid: str
location_latitude: float
location_longitude: float
team_uuid: str
product_uuid: str
price_per_unit: str | None = None
currency: str | None = None
quantity: str | None = None
unit: str | None = None
@dataclass
class TriggerPrefectOfferSyncResult:
"""Result of trigger_prefect_offer_sync activity."""
success: bool
flow_run_id: str = ""
message: str = ""
# ============================================================================ # ============================================================================
# KYC Django Activity Types # KYC Django Activity Types
# ============================================================================ # ============================================================================

View File

@@ -16,11 +16,15 @@ export type Scalars = {
Date: { input: any; output: any; } Date: { input: any; output: any; }
DateTime: { input: string; output: string; } DateTime: { input: string; output: string; }
Decimal: { input: any; output: any; } Decimal: { input: any; output: any; }
JSONString: { input: any; output: any; }
}; };
export type CreateOffer = { export type CreateOffer = {
__typename?: 'CreateOffer'; __typename?: 'CreateOffer';
offer?: Maybe<OfferType>; message?: Maybe<Scalars['String']['output']>;
offerUuid?: Maybe<Scalars['String']['output']>;
success?: Maybe<Scalars['Boolean']['output']>;
workflowId?: Maybe<Scalars['String']['output']>;
}; };
export type CreateRequest = { export type CreateRequest = {
@@ -48,6 +52,8 @@ export type OfferInput = {
productUuid: Scalars['String']['input']; productUuid: Scalars['String']['input'];
quantity: Scalars['Decimal']['input']; quantity: Scalars['Decimal']['input'];
teamUuid: Scalars['String']['input']; teamUuid: Scalars['String']['input'];
terminusPayload?: InputMaybe<Scalars['JSONString']['input']>;
terminusSchemaId?: InputMaybe<Scalars['String']['input']>;
unit?: InputMaybe<Scalars['String']['input']>; unit?: InputMaybe<Scalars['String']['input']>;
validUntil?: InputMaybe<Scalars['Date']['input']>; validUntil?: InputMaybe<Scalars['Date']['input']>;
}; };
@@ -189,7 +195,7 @@ export type CreateOfferMutationVariables = Exact<{
}>; }>;
export type CreateOfferMutation = { __typename?: 'TeamMutation', createOffer?: { __typename?: 'CreateOffer', offer?: { __typename?: 'OfferType', uuid: string, teamUuid: string, status: OffersOfferStatusChoices, locationUuid: string, locationName: string, locationCountry: string, locationCountryCode: string, locationLatitude?: number | null, locationLongitude?: number | null, productUuid: string, productName: string, categoryName: string, quantity: any, unit: string, pricePerUnit?: any | null, currency: string, description: string, validUntil?: any | null, createdAt: string, updatedAt: string } | null } | null }; export type CreateOfferMutation = { __typename?: 'TeamMutation', createOffer?: { __typename?: 'CreateOffer', success?: boolean | null, message?: string | null, workflowId?: string | null, offerUuid?: string | null } | null };
export type CreateRequestMutationVariables = Exact<{ export type CreateRequestMutationVariables = Exact<{
input: RequestInput; input: RequestInput;
@@ -206,6 +212,6 @@ export type GetRequestsQueryVariables = Exact<{
export type GetRequestsQuery = { __typename?: 'TeamQuery', getRequests?: Array<{ __typename?: 'RequestType', uuid: string, productUuid: string, quantity: any, sourceLocationUuid: string, userId: string } | null> | null }; export type GetRequestsQuery = { __typename?: 'TeamQuery', getRequests?: Array<{ __typename?: 'RequestType', uuid: string, productUuid: string, quantity: any, sourceLocationUuid: string, userId: string } | null> | null };
export const CreateOfferDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateOffer"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"OfferInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createOffer"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"offer"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"uuid"}},{"kind":"Field","name":{"kind":"Name","value":"teamUuid"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"locationUuid"}},{"kind":"Field","name":{"kind":"Name","value":"locationName"}},{"kind":"Field","name":{"kind":"Name","value":"locationCountry"}},{"kind":"Field","name":{"kind":"Name","value":"locationCountryCode"}},{"kind":"Field","name":{"kind":"Name","value":"locationLatitude"}},{"kind":"Field","name":{"kind":"Name","value":"locationLongitude"}},{"kind":"Field","name":{"kind":"Name","value":"productUuid"}},{"kind":"Field","name":{"kind":"Name","value":"productName"}},{"kind":"Field","name":{"kind":"Name","value":"categoryName"}},{"kind":"Field","name":{"kind":"Name","value":"quantity"}},{"kind":"Field","name":{"kind":"Name","value":"unit"}},{"kind":"Field","name":{"kind":"Name","value":"pricePerUnit"}},{"kind":"Field","name":{"kind":"Name","value":"currency"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"validUntil"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]}}]} as unknown as DocumentNode<CreateOfferMutation, CreateOfferMutationVariables>; export const CreateOfferDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateOffer"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"OfferInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createOffer"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}},{"kind":"Field","name":{"kind":"Name","value":"message"}},{"kind":"Field","name":{"kind":"Name","value":"workflowId"}},{"kind":"Field","name":{"kind":"Name","value":"offerUuid"}}]}}]}}]} as unknown as DocumentNode<CreateOfferMutation, CreateOfferMutationVariables>;
export const CreateRequestDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateRequest"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"RequestInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createRequest"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"request"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"uuid"}},{"kind":"Field","name":{"kind":"Name","value":"productUuid"}},{"kind":"Field","name":{"kind":"Name","value":"quantity"}},{"kind":"Field","name":{"kind":"Name","value":"sourceLocationUuid"}},{"kind":"Field","name":{"kind":"Name","value":"userId"}}]}}]}}]}}]} as unknown as DocumentNode<CreateRequestMutation, CreateRequestMutationVariables>; export const CreateRequestDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateRequest"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"RequestInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createRequest"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"request"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"uuid"}},{"kind":"Field","name":{"kind":"Name","value":"productUuid"}},{"kind":"Field","name":{"kind":"Name","value":"quantity"}},{"kind":"Field","name":{"kind":"Name","value":"sourceLocationUuid"}},{"kind":"Field","name":{"kind":"Name","value":"userId"}}]}}]}}]}}]} as unknown as DocumentNode<CreateRequestMutation, CreateRequestMutationVariables>;
export const GetRequestsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetRequests"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"userId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getRequests"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"userId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"userId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"uuid"}},{"kind":"Field","name":{"kind":"Name","value":"productUuid"}},{"kind":"Field","name":{"kind":"Name","value":"quantity"}},{"kind":"Field","name":{"kind":"Name","value":"sourceLocationUuid"}},{"kind":"Field","name":{"kind":"Name","value":"userId"}}]}}]}}]} as unknown as DocumentNode<GetRequestsQuery, GetRequestsQueryVariables>; export const GetRequestsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetRequests"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"userId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getRequests"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"userId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"userId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"uuid"}},{"kind":"Field","name":{"kind":"Name","value":"productUuid"}},{"kind":"Field","name":{"kind":"Name","value":"quantity"}},{"kind":"Field","name":{"kind":"Name","value":"sourceLocationUuid"}},{"kind":"Field","name":{"kind":"Name","value":"userId"}}]}}]}}]} as unknown as DocumentNode<GetRequestsQuery, GetRequestsQueryVariables>;

View File

@@ -52,6 +52,22 @@
<Text v-if="schemaDescription" tone="muted">{{ schemaDescription }}</Text> <Text v-if="schemaDescription" tone="muted">{{ schemaDescription }}</Text>
</Stack> </Stack>
<Stack gap="2">
<Text weight="semibold">{{ t('clientOfferForm.labels.location') }}</Text>
<select v-model="selectedAddressUuid" class="select select-bordered w-full">
<option v-if="!addresses.length" :value="null">
{{ t('clientOfferForm.labels.location_empty') }}
</option>
<option
v-for="address in addresses"
:key="address.uuid"
:value="address.uuid"
>
{{ address.name }} {{ address.address }}
</option>
</select>
</Stack>
<hr class="border-base-300" /> <hr class="border-base-300" />
<!-- FormKit dynamic form --> <!-- FormKit dynamic form -->
@@ -100,6 +116,8 @@
import { FormKitSchema } from '@formkit/vue' import { FormKitSchema } from '@formkit/vue'
import type { FormKitSchemaNode } from '@formkit/core' import type { FormKitSchemaNode } from '@formkit/core'
import { GetProductsDocument } from '~/composables/graphql/public/exchange-generated' import { GetProductsDocument } from '~/composables/graphql/public/exchange-generated'
import { CreateOfferDocument } from '~/composables/graphql/team/exchange-generated'
import { GetTeamAddressesDocument } from '~/composables/graphql/team/teams-generated'
definePageMeta({ definePageMeta({
middleware: ['auth-oidc'], middleware: ['auth-oidc'],
@@ -114,6 +132,7 @@ const localePath = useLocalePath()
const route = useRoute() const route = useRoute()
const { execute } = useGraphQL() const { execute } = useGraphQL()
const { getSchema, getEnums, schemaToFormKit } = useTerminus() const { getSchema, getEnums, schemaToFormKit } = useTerminus()
const { activeTeamId } = useActiveTeam()
// State // State
const isLoading = ref(true) const isLoading = ref(true)
@@ -126,9 +145,24 @@ const productName = ref<string>('')
const schemaId = ref<string | null>(null) const schemaId = ref<string | null>(null)
const schemaDescription = ref<string | null>(null) const schemaDescription = ref<string | null>(null)
const formkitSchema = ref<FormKitSchemaNode[]>([]) const formkitSchema = ref<FormKitSchemaNode[]>([])
const addresses = ref<any[]>([])
const selectedAddressUuid = ref<string | null>(null)
const isDev = process.dev const isDev = process.dev
const loadAddresses = async () => {
try {
const result = await execute(GetTeamAddressesDocument, {}, 'team', 'teams')
addresses.value = result.teamAddresses || []
const defaultAddress = addresses.value.find((address: any) => address.isDefault)
selectedAddressUuid.value = defaultAddress?.uuid || addresses.value[0]?.uuid || null
} catch (err) {
console.error('Failed to load addresses:', err)
addresses.value = []
selectedAddressUuid.value = null
}
}
// Load data // Load data
const loadData = async () => { const loadData = async () => {
try { try {
@@ -166,6 +200,7 @@ const loadData = async () => {
// 3. Load enums and convert to FormKit schema // 3. Load enums and convert to FormKit schema
const enums = await getEnums() const enums = await getEnums()
formkitSchema.value = schemaToFormKit(terminusClass, enums) formkitSchema.value = schemaToFormKit(terminusClass, enums)
await loadAddresses()
} catch (err: any) { } catch (err: any) {
hasError.value = true hasError.value = true
@@ -181,17 +216,41 @@ const handleSubmit = async (data: Record<string, unknown>) => {
try { try {
isSubmitting.value = true isSubmitting.value = true
console.log('Form data:', data) if (!activeTeamId.value) {
throw new Error(t('clientOfferForm.error.load'))
}
// TODO: Save data to TerminusDB const selectedAddress = addresses.value.find((address: any) => address.uuid === selectedAddressUuid.value)
// const result = await saveToTerminus(schemaId.value, data) if (!selectedAddress) {
throw new Error(t('clientOfferForm.error.save'))
}
// TODO: Create OfferLine with product_uuid and characteristics const input = {
teamUuid: activeTeamId.value,
productUuid: productUuid.value,
productName: productName.value,
categoryName: undefined,
locationUuid: selectedAddress.uuid,
locationName: selectedAddress.name,
locationCountry: '',
locationCountryCode: selectedAddress.countryCode || '',
locationLatitude: selectedAddress.latitude,
locationLongitude: selectedAddress.longitude,
quantity: data.quantity || 0,
unit: data.unit || 'ton',
pricePerUnit: data.price_per_unit || data.pricePerUnit || null,
currency: data.currency || 'USD',
description: data.description || '',
validUntil: data.valid_until || data.validUntil || null,
terminusSchemaId: schemaId.value,
terminusPayload: data,
}
// Temporary success alert const result = await execute(CreateOfferDocument, { input }, 'team', 'exchange')
alert(t('clientOfferForm.success.saved', { payload: JSON.stringify(data, null, 2) })) if (!result.createOffer?.success) {
throw new Error(result.createOffer?.message || t('clientOfferForm.error.save'))
}
// Redirect to offers list
await navigateTo(localePath('/clientarea/offers')) await navigateTo(localePath('/clientarea/offers'))
} catch (err: any) { } catch (err: any) {

View File

@@ -1,30 +1,8 @@
mutation CreateOffer($input: OfferInput!) { mutation CreateOffer($input: OfferInput!) {
createOffer(input: $input) { createOffer(input: $input) {
offer { success
uuid message
teamUuid workflowId
status offerUuid
# Location
locationUuid
locationName
locationCountry
locationCountryCode
locationLatitude
locationLongitude
# Product
productUuid
productName
categoryName
# Price
quantity
unit
pricePerUnit
currency
# Misc
description
validUntil
createdAt
updatedAt
}
} }
} }

View File

@@ -26,6 +26,10 @@
"productNotFound": "Product with UUID {uuid} not found", "productNotFound": "Product with UUID {uuid} not found",
"schemaNotFound": "Schema \"{schema}\" not found in TerminusDB" "schemaNotFound": "Schema \"{schema}\" not found in TerminusDB"
}, },
"labels": {
"location": "Location",
"location_empty": "No locations available"
},
"success": { "success": {
"saved": "Data saved!\\n\\n{payload}" "saved": "Data saved!\\n\\n{payload}"
}, },

View File

@@ -26,6 +26,10 @@
"productNotFound": "Продукт с UUID {uuid} не найден", "productNotFound": "Продукт с UUID {uuid} не найден",
"schemaNotFound": "Схема \"{schema}\" не найдена в TerminusDB" "schemaNotFound": "Схема \"{schema}\" не найдена в TerminusDB"
}, },
"labels": {
"location": "Локация",
"location_empty": "Локации отсутствуют"
},
"success": { "success": {
"saved": "Данные сохранены!\\n\\n{payload}" "saved": "Данные сохранены!\\n\\n{payload}"
}, },