Add offer workflow with Terminus and graph sync
This commit is contained in:
127
backends/exchange/exchange/schemas/m2m_schema.py
Normal file
127
backends/exchange/exchange/schemas/m2m_schema.py
Normal 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)
|
||||
@@ -46,6 +46,8 @@ class OfferInput(graphene.InputObjectType):
|
||||
# Прочее
|
||||
description = graphene.String()
|
||||
valid_until = graphene.Date()
|
||||
terminus_schema_id = graphene.String()
|
||||
terminus_payload = graphene.JSONString()
|
||||
|
||||
|
||||
class TeamQuery(graphene.ObjectType):
|
||||
@@ -93,35 +95,46 @@ class CreateOffer(graphene.Mutation):
|
||||
class Arguments:
|
||||
input = OfferInput(required=True)
|
||||
|
||||
offer = graphene.Field(OfferType)
|
||||
success = graphene.Boolean()
|
||||
message = graphene.String()
|
||||
workflowId = graphene.String()
|
||||
offerUuid = graphene.String()
|
||||
|
||||
@require_scopes("teams:member")
|
||||
def mutate(self, info, input):
|
||||
offer = Offer(
|
||||
uuid=str(uuid_lib.uuid4()),
|
||||
team_uuid=input.team_uuid,
|
||||
# Товар
|
||||
product_uuid=input.product_uuid,
|
||||
product_name=input.product_name,
|
||||
category_name=input.category_name or '',
|
||||
# Локация
|
||||
location_uuid=input.location_uuid or '',
|
||||
location_name=input.location_name or '',
|
||||
location_country=input.location_country or '',
|
||||
location_country_code=input.location_country_code or '',
|
||||
location_latitude=input.location_latitude,
|
||||
location_longitude=input.location_longitude,
|
||||
# Цена и количество
|
||||
quantity=input.quantity,
|
||||
unit=input.unit or 'ton',
|
||||
price_per_unit=input.price_per_unit,
|
||||
currency=input.currency or 'USD',
|
||||
# Прочее
|
||||
description=input.description or '',
|
||||
valid_until=input.valid_until,
|
||||
)
|
||||
offer.save()
|
||||
return CreateOffer(offer=offer)
|
||||
from ..temporal_client import start_offer_workflow
|
||||
|
||||
try:
|
||||
offer_uuid = str(uuid_lib.uuid4())
|
||||
workflow_id, _ = start_offer_workflow(
|
||||
offer_uuid=offer_uuid,
|
||||
team_uuid=input.team_uuid,
|
||||
product_uuid=input.product_uuid,
|
||||
product_name=input.product_name,
|
||||
category_name=input.category_name,
|
||||
location_uuid=input.location_uuid,
|
||||
location_name=input.location_name,
|
||||
location_country=input.location_country,
|
||||
location_country_code=input.location_country_code,
|
||||
location_latitude=input.location_latitude,
|
||||
location_longitude=input.location_longitude,
|
||||
quantity=input.quantity,
|
||||
unit=input.unit,
|
||||
price_per_unit=input.price_per_unit,
|
||||
currency=input.currency,
|
||||
description=input.description,
|
||||
valid_until=input.valid_until,
|
||||
terminus_schema_id=getattr(input, "terminus_schema_id", None),
|
||||
terminus_payload=getattr(input, "terminus_payload", None),
|
||||
)
|
||||
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):
|
||||
@@ -154,6 +167,8 @@ class UpdateOffer(graphene.Mutation):
|
||||
offer.currency = input.currency or 'USD'
|
||||
offer.description = input.description or ''
|
||||
offer.valid_until = input.valid_until
|
||||
if input.terminus_schema_id is not None:
|
||||
offer.terminus_schema_id = input.terminus_schema_id
|
||||
offer.save()
|
||||
|
||||
return UpdateOffer(offer=offer)
|
||||
|
||||
79
backends/exchange/exchange/temporal_client.py
Normal file
79
backends/exchange/exchange/temporal_client.py
Normal 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
|
||||
@@ -1,14 +1,16 @@
|
||||
from django.contrib import admin
|
||||
from django.urls import path
|
||||
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.user_schema import user_schema
|
||||
from .schemas.team_schema import team_schema
|
||||
from .schemas.m2m_schema import m2m_schema
|
||||
|
||||
urlpatterns = [
|
||||
path('admin/', admin.site.urls),
|
||||
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/team/', csrf_exempt(TeamGraphQLView.as_view(graphiql=True, schema=team_schema))),
|
||||
path('graphql/m2m/', csrf_exempt(M2MGraphQLView.as_view(graphiql=True, schema=m2m_schema))),
|
||||
]
|
||||
|
||||
@@ -19,3 +19,8 @@ class UserGraphQLView(GraphQLView):
|
||||
class TeamGraphQLView(GraphQLView):
|
||||
"""Team endpoint - requires Organization Access Token."""
|
||||
pass
|
||||
|
||||
|
||||
class M2MGraphQLView(GraphQLView):
|
||||
"""M2M endpoint - internal services only."""
|
||||
pass
|
||||
|
||||
@@ -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=''),
|
||||
),
|
||||
]
|
||||
@@ -24,6 +24,7 @@ class Offer(models.Model):
|
||||
choices=WORKFLOW_STATUS_CHOICES,
|
||||
default='pending',
|
||||
)
|
||||
workflow_error = models.TextField(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='')
|
||||
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)
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
from .odoo import add_kyc_to_odoo, set_buyer_logto_org
|
||||
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 .address import (
|
||||
create_address_in_django,
|
||||
@@ -14,6 +17,10 @@ __all__ = [
|
||||
"set_buyer_logto_org",
|
||||
"set_logto_org_id",
|
||||
"create_logto_org",
|
||||
"create_offer_in_exchange",
|
||||
"update_offer_workflow_status",
|
||||
"save_offer_description",
|
||||
"trigger_prefect_offer_sync",
|
||||
"create_address_in_django",
|
||||
"update_address_status",
|
||||
"trigger_prefect_sync",
|
||||
|
||||
113
temporal/platform_worker/platform_worker/activities/exchange.py
Normal file
113
temporal/platform_worker/platform_worker/activities/exchange.py
Normal 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")
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
@@ -65,6 +65,10 @@ class Settings(BaseSettings):
|
||||
TEAMS_API_URL: str = "http://teams:8000"
|
||||
TEAMS_TIMEOUT: float = 15.0
|
||||
|
||||
# Exchange service
|
||||
EXCHANGE_API_URL: str = "http://exchange:8000"
|
||||
EXCHANGE_TIMEOUT: float = 15.0
|
||||
|
||||
# Logto
|
||||
LOGTO_API_URL: str = "http://logto:3001"
|
||||
LOGTO_M2M_APP_ID: str = ""
|
||||
@@ -80,5 +84,11 @@ class Settings(BaseSettings):
|
||||
# Prefect (loaded from Infisical /shared)
|
||||
PREFECT_API_URL: str
|
||||
|
||||
# TerminusDB
|
||||
TERMINUS_DOCUMENT_URL: str = ""
|
||||
TERMINUS_BASIC_USER: str = ""
|
||||
TERMINUS_BASIC_PASS: str = ""
|
||||
TERMINUS_TIMEOUT: float = 15.0
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
@@ -18,7 +18,7 @@ from concurrent.futures import ThreadPoolExecutor
|
||||
from temporalio.client import Client
|
||||
from temporalio.worker import Worker
|
||||
|
||||
from optovia_workflows import KycApplicationWorkflow, AddressWorkflow
|
||||
from optovia_workflows import KycApplicationWorkflow, AddressWorkflow, OfferWorkflow
|
||||
|
||||
from . import activities
|
||||
from .config import settings
|
||||
@@ -65,12 +65,16 @@ async def run_worker() -> None:
|
||||
worker = Worker(
|
||||
client,
|
||||
task_queue=settings.TASK_QUEUE,
|
||||
workflows=[KycApplicationWorkflow, AddressWorkflow],
|
||||
workflows=[KycApplicationWorkflow, AddressWorkflow, OfferWorkflow],
|
||||
activities=[
|
||||
activities.add_kyc_to_odoo,
|
||||
activities.set_buyer_logto_org,
|
||||
activities.create_logto_org,
|
||||
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.update_address_status,
|
||||
activities.trigger_prefect_sync,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from .kyc import KycApplicationWorkflow
|
||||
from .address import AddressWorkflow
|
||||
from .offer import OfferWorkflow
|
||||
from .types import (
|
||||
KycApprovalData,
|
||||
KycData,
|
||||
@@ -9,6 +10,8 @@ from .types import (
|
||||
KycWorkflowResult,
|
||||
AddressData,
|
||||
AddressWorkflowResult,
|
||||
OfferData,
|
||||
OfferWorkflowResult,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
@@ -20,4 +23,7 @@ __all__ = [
|
||||
"AddressWorkflow",
|
||||
"AddressData",
|
||||
"AddressWorkflowResult",
|
||||
"OfferWorkflow",
|
||||
"OfferData",
|
||||
"OfferWorkflowResult",
|
||||
]
|
||||
|
||||
5
temporal/workflows/optovia_workflows/offer/__init__.py
Normal file
5
temporal/workflows/optovia_workflows/offer/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Offer workflow module."""
|
||||
|
||||
from .workflow import OfferWorkflow
|
||||
|
||||
__all__ = ["OfferWorkflow"]
|
||||
174
temporal/workflows/optovia_workflows/offer/workflow.py
Normal file
174
temporal/workflows/optovia_workflows/offer/workflow.py
Normal 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)
|
||||
@@ -123,6 +123,139 @@ class SetLogtoOrgIdResult:
|
||||
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
|
||||
# ============================================================================
|
||||
|
||||
@@ -16,11 +16,15 @@ export type Scalars = {
|
||||
Date: { input: any; output: any; }
|
||||
DateTime: { input: string; output: string; }
|
||||
Decimal: { input: any; output: any; }
|
||||
JSONString: { input: any; output: any; }
|
||||
};
|
||||
|
||||
export type 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 = {
|
||||
@@ -48,6 +52,8 @@ export type OfferInput = {
|
||||
productUuid: Scalars['String']['input'];
|
||||
quantity: Scalars['Decimal']['input'];
|
||||
teamUuid: Scalars['String']['input'];
|
||||
terminusPayload?: InputMaybe<Scalars['JSONString']['input']>;
|
||||
terminusSchemaId?: InputMaybe<Scalars['String']['input']>;
|
||||
unit?: InputMaybe<Scalars['String']['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<{
|
||||
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 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 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>;
|
||||
|
||||
@@ -52,6 +52,22 @@
|
||||
<Text v-if="schemaDescription" tone="muted">{{ schemaDescription }}</Text>
|
||||
</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" />
|
||||
|
||||
<!-- FormKit dynamic form -->
|
||||
@@ -100,6 +116,8 @@
|
||||
import { FormKitSchema } from '@formkit/vue'
|
||||
import type { FormKitSchemaNode } from '@formkit/core'
|
||||
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({
|
||||
middleware: ['auth-oidc'],
|
||||
@@ -114,6 +132,7 @@ const localePath = useLocalePath()
|
||||
const route = useRoute()
|
||||
const { execute } = useGraphQL()
|
||||
const { getSchema, getEnums, schemaToFormKit } = useTerminus()
|
||||
const { activeTeamId } = useActiveTeam()
|
||||
|
||||
// State
|
||||
const isLoading = ref(true)
|
||||
@@ -126,9 +145,24 @@ const productName = ref<string>('')
|
||||
const schemaId = ref<string | null>(null)
|
||||
const schemaDescription = ref<string | null>(null)
|
||||
const formkitSchema = ref<FormKitSchemaNode[]>([])
|
||||
const addresses = ref<any[]>([])
|
||||
const selectedAddressUuid = ref<string | null>(null)
|
||||
|
||||
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
|
||||
const loadData = async () => {
|
||||
try {
|
||||
@@ -166,6 +200,7 @@ const loadData = async () => {
|
||||
// 3. Load enums and convert to FormKit schema
|
||||
const enums = await getEnums()
|
||||
formkitSchema.value = schemaToFormKit(terminusClass, enums)
|
||||
await loadAddresses()
|
||||
|
||||
} catch (err: any) {
|
||||
hasError.value = true
|
||||
@@ -181,17 +216,41 @@ const handleSubmit = async (data: Record<string, unknown>) => {
|
||||
try {
|
||||
isSubmitting.value = true
|
||||
|
||||
console.log('Form data:', data)
|
||||
if (!activeTeamId.value) {
|
||||
throw new Error(t('clientOfferForm.error.load'))
|
||||
}
|
||||
|
||||
// TODO: Save data to TerminusDB
|
||||
// const result = await saveToTerminus(schemaId.value, data)
|
||||
const selectedAddress = addresses.value.find((address: any) => address.uuid === selectedAddressUuid.value)
|
||||
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
|
||||
alert(t('clientOfferForm.success.saved', { payload: JSON.stringify(data, null, 2) }))
|
||||
const result = await execute(CreateOfferDocument, { input }, 'team', 'exchange')
|
||||
if (!result.createOffer?.success) {
|
||||
throw new Error(result.createOffer?.message || t('clientOfferForm.error.save'))
|
||||
}
|
||||
|
||||
// Redirect to offers list
|
||||
await navigateTo(localePath('/clientarea/offers'))
|
||||
|
||||
} catch (err: any) {
|
||||
|
||||
@@ -1,30 +1,8 @@
|
||||
mutation CreateOffer($input: OfferInput!) {
|
||||
createOffer(input: $input) {
|
||||
offer {
|
||||
uuid
|
||||
teamUuid
|
||||
status
|
||||
# Location
|
||||
locationUuid
|
||||
locationName
|
||||
locationCountry
|
||||
locationCountryCode
|
||||
locationLatitude
|
||||
locationLongitude
|
||||
# Product
|
||||
productUuid
|
||||
productName
|
||||
categoryName
|
||||
# Price
|
||||
quantity
|
||||
unit
|
||||
pricePerUnit
|
||||
currency
|
||||
# Misc
|
||||
description
|
||||
validUntil
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
success
|
||||
message
|
||||
workflowId
|
||||
offerUuid
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,10 @@
|
||||
"productNotFound": "Product with UUID {uuid} not found",
|
||||
"schemaNotFound": "Schema \"{schema}\" not found in TerminusDB"
|
||||
},
|
||||
"labels": {
|
||||
"location": "Location",
|
||||
"location_empty": "No locations available"
|
||||
},
|
||||
"success": {
|
||||
"saved": "Data saved!\\n\\n{payload}"
|
||||
},
|
||||
|
||||
@@ -26,6 +26,10 @@
|
||||
"productNotFound": "Продукт с UUID {uuid} не найден",
|
||||
"schemaNotFound": "Схема \"{schema}\" не найдена в TerminusDB"
|
||||
},
|
||||
"labels": {
|
||||
"location": "Локация",
|
||||
"location_empty": "Локации отсутствуют"
|
||||
},
|
||||
"success": {
|
||||
"saved": "Данные сохранены!\\n\\n{payload}"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user