Harden admin ops: integrations UI, uploads, pick/analytics polish.
continuous-integration/drone/push Build is passing
continuous-integration/drone/push Build is passing
Move PayU and carrier credentials into Settings, raise nginx upload limits, fix analytics Query translation, and improve orders pick/channel UX plus missing i18n. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
da85370936
commit
bca7ef9dfa
36 files changed
+4508
-236
No files matched your search
@@ -4,6 +4,11 @@ server {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Product image uploads are up to 10 MB (API RequestSizeLimit).
|
||||
# Nginx default is 1m and returns 413 for larger bodies.
|
||||
client_max_body_size 12m;
|
||||
client_body_timeout 120s;
|
||||
|
||||
# Browser calls same-origin /api and /uploads; proxy to API container.
|
||||
location /api/ {
|
||||
proxy_pass http://api:8080/api/;
|
||||
@@ -12,6 +17,10 @@ server {
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_request_buffering on;
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 120s;
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
|
||||
location /uploads/ {
|
||||
|
||||
@@ -53,14 +53,40 @@ export const CS_TRANSLATIONS: TranslationMap = {
|
||||
'dashboard.errorLoad': 'Nepodařilo se načíst dashboard',
|
||||
'dashboard.alertLowStock': 'SKU s nízkým stavem',
|
||||
'dashboard.alertPending': 'čeká na platbu',
|
||||
'dashboard.alertReviews': 'recenzí k moderaci',
|
||||
'dashboard.viewOrders': 'Zobrazit objednávky',
|
||||
'dashboard.openPickList': 'Otevřít pick list',
|
||||
'dashboard.pendingFulfillment': 'K vyřízení',
|
||||
'dashboard.pendingFulfillmentHint': 'Vyžaduje pozornost',
|
||||
'dashboard.lowStockHint': 'Práh ≤ 5',
|
||||
'dashboard.needsAction': 'Vyžaduje akci — objednávky',
|
||||
'dashboard.pendingReviews': 'Recenze k moderaci',
|
||||
'dashboard.moderate': 'Moderovat',
|
||||
'dashboard.restock': 'Doplnit',
|
||||
'dashboard.noActionOrders': 'Žádné objednávky nevyžadují akci.',
|
||||
'dashboard.noLowStock': 'Stavy skladu vypadají v pořádku.',
|
||||
'dashboard.noPendingReviews': 'Žádné recenze nečekají.',
|
||||
'orders.purpose':
|
||||
'Vyřízení objednávek — vyberte více pro kompletaci, faktury nebo štítky.',
|
||||
'orders.order': 'Objednávka',
|
||||
'orders.bulkSelected': 'Vybráno {count}',
|
||||
'orders.bulkPick': 'Kompletovat / pick',
|
||||
'orders.bulkInvoices': 'Generovat faktury',
|
||||
'orders.bulkLabels': 'Generovat štítky',
|
||||
'orders.bulkLabelsSoon': 'Hromadné štítky vygenerují přepravní štítky pro výběr.',
|
||||
'orders.bulkLabelsEmpty': 'Nejdřív vyberte objednávky',
|
||||
'orders.bulkLabelsDone':
|
||||
'Štítky: {{ok}} vytvořeno, {{fail}} chyb, {{skip}} přeskočeno',
|
||||
'orders.bulkLabelsError': 'Hromadné generování štítků selhalo',
|
||||
'orders.selectAll': 'Vybrat vše',
|
||||
'orders.selectRow': 'Vybrat objednávku',
|
||||
'products.title': 'Produkty',
|
||||
'products.purpose': 'Správa prodejního katalogu a skladů.',
|
||||
'promotions.purpose': 'Kampaně a promo kódy bez zmatku.',
|
||||
'newsletter.purpose':
|
||||
'Komunikace s odběrateli — sestavení, náhled, bezpečné odeslání.',
|
||||
'reviews.purpose':
|
||||
'Fronta moderace — ochrana důvěry na obchodě.',
|
||||
'products.add': 'Přidat produkt',
|
||||
'products.edit': 'Upravit',
|
||||
'products.editTitle': 'Upravit produkt',
|
||||
@@ -237,6 +263,11 @@ export const CS_TRANSLATIONS: TranslationMap = {
|
||||
'orders.markCollected': 'Označit jako sestavené',
|
||||
'orders.collected': 'Sestaveno',
|
||||
'orders.showCollected': 'Zobrazit sestavené',
|
||||
'orders.pickByOrder': 'Podle objednávek',
|
||||
'orders.pickByProduct': 'Podle produktů',
|
||||
'orders.pickTotalQty': 'Celkem k vyzvednutí: {{count}}',
|
||||
'orders.collectProduct': 'Kompletovat všechny',
|
||||
'orders.productCollected': 'Všechny kompletovány',
|
||||
'orders.pendingItems': 'Čekající',
|
||||
'orders.collectedItems': 'Sestavené',
|
||||
'orders.orderWarehouse': 'Sklad pro objednávku',
|
||||
@@ -294,6 +325,7 @@ export const CS_TRANSLATIONS: TranslationMap = {
|
||||
'promotions.kpiActive': 'Aktivní akce',
|
||||
'promotions.kpiRedemptions': 'Uplatnění (stránka)',
|
||||
'promotions.kpiCodes': 'Kódy (stránka)',
|
||||
'promotions.kpiDiscount': 'Poskytnuté slevy (30 dní)',
|
||||
'promotions.sectionBenefit': 'Výhoda',
|
||||
'promotions.sectionRequirements': 'Požadavky',
|
||||
'promotions.sectionLimits': 'Limity použití',
|
||||
@@ -330,6 +362,7 @@ export const CS_TRANSLATIONS: TranslationMap = {
|
||||
'newsletter.searchCampaign': 'Hledat kampaně…',
|
||||
'newsletter.joined': 'Připojen',
|
||||
'newsletter.sent': 'Odesláno',
|
||||
'newsletter.recipients': 'Příjemci',
|
||||
'newsletter.unsubscribed': 'Odhlášen',
|
||||
'newsletter.status.sent': 'Odesláno',
|
||||
'newsletter.status.draft': 'Koncept',
|
||||
@@ -429,6 +462,8 @@ export const CS_TRANSLATIONS: TranslationMap = {
|
||||
'settings.title': 'Nastavení',
|
||||
'settings.purpose':
|
||||
'Konfigurace obchodu — lokálně, dokud nebudou API nastavení.',
|
||||
'settings.errorLoad': 'Nepodařilo se načíst nastavení',
|
||||
'settings.errorSave': 'Nepodařilo se uložit nastavení',
|
||||
'settings.save': 'Uložit změny',
|
||||
'settings.navStore': 'Profil obchodu',
|
||||
'settings.navLocales': 'Jazyky a měna',
|
||||
@@ -437,10 +472,36 @@ export const CS_TRANSLATIONS: TranslationMap = {
|
||||
'settings.navIntegrations': 'Integrace',
|
||||
'settings.navEmail': 'E-mail',
|
||||
'settings.navAccount': 'Můj účet',
|
||||
'settings.integrationsTitle': 'Marketplace a faktury',
|
||||
'settings.integrationsTitle': 'Platby, dopravci a marketplace',
|
||||
'settings.integrationsHint':
|
||||
'Nastavení Allegro, Amazon a iFirma. Sync objednávek a auto-faktury použijí tato nastavení po zapnutí konektorů.',
|
||||
'PayU, InPost, DHL, DPD, Allegro, Amazon a iFirma — uloženo v databázi. Prázdné tajné pole ponechá uloženou hodnotu.',
|
||||
'settings.secretSaved': 'uloženo',
|
||||
'settings.shippingSandbox': 'Použít sandbox / testovací API dopravy',
|
||||
'settings.live': 'Produkce',
|
||||
'settings.payuPosId': 'PayU POS ID',
|
||||
'settings.payuClientId': 'PayU client ID',
|
||||
'settings.payuClientSecret': 'PayU client secret',
|
||||
'settings.payuSecondKey': 'PayU second key (MD5)',
|
||||
'settings.payuOauthUrl': 'PayU OAuth URL',
|
||||
'settings.payuOrdersUrl': 'PayU orders URL',
|
||||
'settings.payuNotifyUrl': 'PayU notify (webhook) URL',
|
||||
'settings.payuContinueUrl': 'PayU continue URL (základ)',
|
||||
'settings.inPostOrgId': 'InPost organization ID',
|
||||
'settings.inPostShipXToken': 'InPost ShipX token',
|
||||
'settings.inPostShipXBaseUrl': 'InPost ShipX base URL',
|
||||
'settings.inPostGeowidgetConfig': 'Geowidget config',
|
||||
'settings.inPostGeowidgetSdk': 'Geowidget SDK base URL',
|
||||
'settings.inPostGeowidgetPublic': 'Geowidget public token',
|
||||
'settings.inPostGeowidgetSandbox': 'Geowidget sandbox token',
|
||||
'settings.dhlApiKey': 'DHL API key',
|
||||
'settings.dhlApiSecret': 'DHL API secret',
|
||||
'settings.dhlApiBaseUrl': 'DHL API base URL',
|
||||
'settings.dhlWidgetUrl': 'DHL mapa Parcelshop',
|
||||
'settings.dpdLogin': 'DPD login',
|
||||
'settings.dpdPassword': 'DPD heslo',
|
||||
'settings.dpdFid': 'DPD FID',
|
||||
'settings.dpdApiBaseUrl': 'DPD API base URL',
|
||||
'settings.dpdWidgetUrl': 'DPD pudofinder URL',
|
||||
'settings.allegroClientId': 'Allegro client ID',
|
||||
'settings.allegroClientSecret': 'Allegro client secret',
|
||||
'settings.allegroSellerId': 'Allegro seller ID',
|
||||
@@ -514,7 +575,7 @@ export const CS_TRANSLATIONS: TranslationMap = {
|
||||
'settings.mode': 'Režim',
|
||||
'settings.sandbox': 'Sandbox',
|
||||
'settings.shippingHint':
|
||||
'Přihlašovací údaje zůstávají v konfiguraci serveru — zde jen flagy viditelnosti.',
|
||||
'Dopravce zapněte zde. API tokeny a URL widgetů nastavíte v Integracích.',
|
||||
'settings.emailTitle': 'E-mail / newsletter',
|
||||
'settings.fromName': 'Jméno odesílatele',
|
||||
'settings.fromAddress': 'Adresa odesílatele',
|
||||
|
||||
@@ -53,14 +53,40 @@ export const DE_TRANSLATIONS: TranslationMap = {
|
||||
'dashboard.errorLoad': 'Dashboard konnte nicht geladen werden',
|
||||
'dashboard.alertLowStock': 'SKU mit niedrigem Bestand',
|
||||
'dashboard.alertPending': 'warten auf Zahlung',
|
||||
'dashboard.alertReviews': 'Bewertungen zur Moderation',
|
||||
'dashboard.viewOrders': 'Bestellungen anzeigen',
|
||||
'dashboard.openPickList': 'Kommissionierung öffnen',
|
||||
'dashboard.pendingFulfillment': 'Offene Erfüllung',
|
||||
'dashboard.pendingFulfillmentHint': 'Braucht Aufmerksamkeit',
|
||||
'dashboard.lowStockHint': 'Schwelle ≤ 5',
|
||||
'dashboard.needsAction': 'Handlungsbedarf — Bestellungen',
|
||||
'dashboard.pendingReviews': 'Offene Bewertungen',
|
||||
'dashboard.moderate': 'Moderieren',
|
||||
'dashboard.restock': 'Auffüllen',
|
||||
'dashboard.noActionOrders': 'Keine Bestellungen brauchen gerade Aktion.',
|
||||
'dashboard.noLowStock': 'Bestände sehen gesund aus.',
|
||||
'dashboard.noPendingReviews': 'Keine Bewertungen warten.',
|
||||
'orders.purpose':
|
||||
'Erfüllen und klären — mehrere auswählen zum Kommissionieren, Rechnungen oder Labels.',
|
||||
'orders.order': 'Bestellung',
|
||||
'orders.bulkSelected': '{count} ausgewählt',
|
||||
'orders.bulkPick': 'Kommissionieren',
|
||||
'orders.bulkInvoices': 'Rechnungen erzeugen',
|
||||
'orders.bulkLabels': 'Labels erzeugen',
|
||||
'orders.bulkLabelsSoon': 'Massen-Labels erzeugen Versandetiketten für die Auswahl.',
|
||||
'orders.bulkLabelsEmpty': 'Zuerst Bestellungen auswählen',
|
||||
'orders.bulkLabelsDone':
|
||||
'Labels: {{ok}} erstellt, {{fail}} fehlgeschlagen, {{skip}} übersprungen',
|
||||
'orders.bulkLabelsError': 'Massen-Labels fehlgeschlagen',
|
||||
'orders.selectAll': 'Alle auswählen',
|
||||
'orders.selectRow': 'Bestellung auswählen',
|
||||
'products.title': 'Produkte',
|
||||
'products.purpose': 'Verkaufskatalog und Lager verwalten.',
|
||||
'promotions.purpose': 'Kampagnen und Codes ohne Verwirrung führen.',
|
||||
'newsletter.purpose':
|
||||
'Mit Abonnenten kommunizieren — verfassen, Vorschau, sicher senden.',
|
||||
'reviews.purpose':
|
||||
'Moderationswarteschlange — Vertrauen im Shop schützen.',
|
||||
'products.add': 'Produkt hinzufügen',
|
||||
'products.edit': 'Bearbeiten',
|
||||
'products.editTitle': 'Produkt bearbeiten',
|
||||
@@ -237,6 +263,11 @@ export const DE_TRANSLATIONS: TranslationMap = {
|
||||
'orders.markCollected': 'Kommissioniert',
|
||||
'orders.collected': 'Kommissioniert',
|
||||
'orders.showCollected': 'Kommissionierte anzeigen',
|
||||
'orders.pickByOrder': 'Nach Bestellung',
|
||||
'orders.pickByProduct': 'Nach Produkt',
|
||||
'orders.pickTotalQty': 'Gesamt zu holen: {{count}}',
|
||||
'orders.collectProduct': 'Alle Positionen holen',
|
||||
'orders.productCollected': 'Alle Positionen geholt',
|
||||
'orders.pendingItems': 'Offen',
|
||||
'orders.collectedItems': 'Kommissioniert',
|
||||
'orders.orderWarehouse': 'Lager für Bestellung',
|
||||
@@ -294,6 +325,7 @@ export const DE_TRANSLATIONS: TranslationMap = {
|
||||
'promotions.kpiActive': 'Aktive Aktionen',
|
||||
'promotions.kpiRedemptions': 'Einlösungen (Seite)',
|
||||
'promotions.kpiCodes': 'Codes (Seite)',
|
||||
'promotions.kpiDiscount': 'Gewährte Rabatte (30 Tage)',
|
||||
'promotions.sectionBenefit': 'Vorteil',
|
||||
'promotions.sectionRequirements': 'Voraussetzungen',
|
||||
'promotions.sectionLimits': 'Nutzungslimits',
|
||||
@@ -330,6 +362,7 @@ export const DE_TRANSLATIONS: TranslationMap = {
|
||||
'newsletter.searchCampaign': 'Kampagnen suchen…',
|
||||
'newsletter.joined': 'Beigetreten',
|
||||
'newsletter.sent': 'Gesendet',
|
||||
'newsletter.recipients': 'Empfänger',
|
||||
'newsletter.unsubscribed': 'Abgemeldet',
|
||||
'newsletter.status.sent': 'Gesendet',
|
||||
'newsletter.status.draft': 'Entwurf',
|
||||
@@ -429,6 +462,8 @@ export const DE_TRANSLATIONS: TranslationMap = {
|
||||
'settings.title': 'Einstellungen',
|
||||
'settings.purpose':
|
||||
'Shop-Konfiguration — lokal gespeichert bis Settings-APIs existieren.',
|
||||
'settings.errorLoad': 'Einstellungen konnten nicht geladen werden',
|
||||
'settings.errorSave': 'Einstellungen konnten nicht gespeichert werden',
|
||||
'settings.save': 'Änderungen speichern',
|
||||
'settings.navStore': 'Shop-Profil',
|
||||
'settings.navLocales': 'Sprachen & Währung',
|
||||
@@ -437,10 +472,36 @@ export const DE_TRANSLATIONS: TranslationMap = {
|
||||
'settings.navIntegrations': 'Integrationen',
|
||||
'settings.navEmail': 'E-Mail',
|
||||
'settings.navAccount': 'Mein Konto',
|
||||
'settings.integrationsTitle': 'Marktplätze & Rechnungen',
|
||||
'settings.integrationsTitle': 'Zahlungen, Carrier & Marktplätze',
|
||||
'settings.integrationsHint':
|
||||
'Allegro-, Amazon- und iFirma-Zugangsdaten. Sync und Auto-Rechnung nutzen diese Einstellungen, sobald die Connectoren aktiv sind.',
|
||||
'PayU, InPost, DHL, DPD, Allegro, Amazon und iFirma — in der Datenbank. Leeres Geheimnisfeld behält den gespeicherten Wert.',
|
||||
'settings.secretSaved': 'gespeichert',
|
||||
'settings.shippingSandbox': 'Shipping-Sandbox / Test-APIs nutzen',
|
||||
'settings.live': 'Live',
|
||||
'settings.payuPosId': 'PayU POS-ID',
|
||||
'settings.payuClientId': 'PayU Client-ID',
|
||||
'settings.payuClientSecret': 'PayU Client-Secret',
|
||||
'settings.payuSecondKey': 'PayU Second Key (MD5)',
|
||||
'settings.payuOauthUrl': 'PayU OAuth-URL',
|
||||
'settings.payuOrdersUrl': 'PayU Orders-URL',
|
||||
'settings.payuNotifyUrl': 'PayU Notify-(Webhook)-URL',
|
||||
'settings.payuContinueUrl': 'PayU Continue-URL (Basis)',
|
||||
'settings.inPostOrgId': 'InPost Organisations-ID',
|
||||
'settings.inPostShipXToken': 'InPost ShipX-Token',
|
||||
'settings.inPostShipXBaseUrl': 'InPost ShipX Base-URL',
|
||||
'settings.inPostGeowidgetConfig': 'Geowidget-Config',
|
||||
'settings.inPostGeowidgetSdk': 'Geowidget SDK Base-URL',
|
||||
'settings.inPostGeowidgetPublic': 'Geowidget Public-Token',
|
||||
'settings.inPostGeowidgetSandbox': 'Geowidget Sandbox-Token',
|
||||
'settings.dhlApiKey': 'DHL API-Key',
|
||||
'settings.dhlApiSecret': 'DHL API-Secret',
|
||||
'settings.dhlApiBaseUrl': 'DHL API Base-URL',
|
||||
'settings.dhlWidgetUrl': 'DHL Parcelshop-Karten-URL',
|
||||
'settings.dpdLogin': 'DPD Login',
|
||||
'settings.dpdPassword': 'DPD Passwort',
|
||||
'settings.dpdFid': 'DPD FID',
|
||||
'settings.dpdApiBaseUrl': 'DPD API Base-URL',
|
||||
'settings.dpdWidgetUrl': 'DPD Pudofinder-URL',
|
||||
'settings.allegroClientId': 'Allegro Client-ID',
|
||||
'settings.allegroClientSecret': 'Allegro Client-Secret',
|
||||
'settings.allegroSellerId': 'Allegro Verkäufer-ID',
|
||||
@@ -514,7 +575,7 @@ export const DE_TRANSLATIONS: TranslationMap = {
|
||||
'settings.mode': 'Modus',
|
||||
'settings.sandbox': 'Sandbox',
|
||||
'settings.shippingHint':
|
||||
'Zugangsdaten bleiben in der Server-Konfiguration — hier nur Sichtbarkeitsflags.',
|
||||
'Carrier hier aktivieren. API-Tokens und Widget-URLs unter Integrationen.',
|
||||
'settings.emailTitle': 'E-Mail / Newsletter',
|
||||
'settings.fromName': 'Absendername',
|
||||
'settings.fromAddress': 'Absenderadresse',
|
||||
|
||||
@@ -261,6 +261,11 @@ export const EN_TRANSLATIONS: TranslationMap = {
|
||||
'orders.markCollected': 'Mark collected',
|
||||
'orders.collected': 'Collected',
|
||||
'orders.showCollected': 'Show collected',
|
||||
'orders.pickByOrder': 'By order',
|
||||
'orders.pickByProduct': 'By product',
|
||||
'orders.pickTotalQty': 'Total to pick: {{count}}',
|
||||
'orders.collectProduct': 'Collect all lines',
|
||||
'orders.productCollected': 'All lines collected',
|
||||
'orders.pendingItems': 'Pending',
|
||||
'orders.collectedItems': 'Collected',
|
||||
'orders.orderWarehouse': 'Warehouse for order',
|
||||
@@ -463,10 +468,36 @@ export const EN_TRANSLATIONS: TranslationMap = {
|
||||
'settings.navIntegrations': 'Integrations',
|
||||
'settings.navEmail': 'Email',
|
||||
'settings.navAccount': 'My account',
|
||||
'settings.integrationsTitle': 'Marketplace & invoicing',
|
||||
'settings.integrationsTitle': 'Payments, carriers & marketplaces',
|
||||
'settings.integrationsHint':
|
||||
'Configure Allegro, Amazon, and iFirma credentials. Order sync and auto-invoicing use these settings once connectors are live.',
|
||||
'PayU, InPost, DHL, DPD, Allegro, Amazon, and iFirma — stored in the database. Leave a secret blank to keep the saved value.',
|
||||
'settings.secretSaved': 'saved',
|
||||
'settings.shippingSandbox': 'Use shipping sandbox / test APIs',
|
||||
'settings.live': 'Live',
|
||||
'settings.payuPosId': 'PayU POS ID',
|
||||
'settings.payuClientId': 'PayU client ID',
|
||||
'settings.payuClientSecret': 'PayU client secret',
|
||||
'settings.payuSecondKey': 'PayU second key (MD5)',
|
||||
'settings.payuOauthUrl': 'PayU OAuth URL',
|
||||
'settings.payuOrdersUrl': 'PayU orders URL',
|
||||
'settings.payuNotifyUrl': 'PayU notify (webhook) URL',
|
||||
'settings.payuContinueUrl': 'PayU continue URL base',
|
||||
'settings.inPostOrgId': 'InPost organization ID',
|
||||
'settings.inPostShipXToken': 'InPost ShipX token',
|
||||
'settings.inPostShipXBaseUrl': 'InPost ShipX base URL',
|
||||
'settings.inPostGeowidgetConfig': 'Geowidget config',
|
||||
'settings.inPostGeowidgetSdk': 'Geowidget SDK base URL',
|
||||
'settings.inPostGeowidgetPublic': 'Geowidget public token',
|
||||
'settings.inPostGeowidgetSandbox': 'Geowidget sandbox token',
|
||||
'settings.dhlApiKey': 'DHL API key',
|
||||
'settings.dhlApiSecret': 'DHL API secret',
|
||||
'settings.dhlApiBaseUrl': 'DHL API base URL',
|
||||
'settings.dhlWidgetUrl': 'DHL parcel shop map URL',
|
||||
'settings.dpdLogin': 'DPD login',
|
||||
'settings.dpdPassword': 'DPD password',
|
||||
'settings.dpdFid': 'DPD FID',
|
||||
'settings.dpdApiBaseUrl': 'DPD API base URL',
|
||||
'settings.dpdWidgetUrl': 'DPD pudofinder URL',
|
||||
'settings.allegroClientId': 'Allegro client ID',
|
||||
'settings.allegroClientSecret': 'Allegro client secret',
|
||||
'settings.allegroSellerId': 'Allegro seller ID',
|
||||
@@ -540,7 +571,7 @@ export const EN_TRANSLATIONS: TranslationMap = {
|
||||
'settings.mode': 'Mode',
|
||||
'settings.sandbox': 'Sandbox',
|
||||
'settings.shippingHint':
|
||||
'Credentials stay in server config — this UI only stores visibility flags locally.',
|
||||
'Enable carriers here. API tokens and widget URLs are configured under Integrations.',
|
||||
'settings.emailTitle': 'Email / newsletter',
|
||||
'settings.fromName': 'From name',
|
||||
'settings.fromAddress': 'From address',
|
||||
|
||||
@@ -53,6 +53,20 @@ export const PL_TRANSLATIONS: TranslationMap = {
|
||||
'dashboard.errorLoad': 'Nie udało się wczytać pulpitu',
|
||||
'dashboard.alertLowStock': 'SKU z niskim stanem',
|
||||
'dashboard.alertPending': 'oczekuje na płatność',
|
||||
'dashboard.alertReviews': 'opinii do moderacji',
|
||||
'dashboard.viewOrders': 'Zobacz zamówienia',
|
||||
'dashboard.openPickList': 'Otwórz listę kompletacji',
|
||||
'dashboard.pendingFulfillment': 'Do realizacji',
|
||||
'dashboard.pendingFulfillmentHint': 'Wymaga uwagi',
|
||||
'dashboard.lowStockHint': 'Próg ≤ 5',
|
||||
'dashboard.needsAction': 'Wymaga działania — zamówienia',
|
||||
'dashboard.pendingReviews': 'Opinie do moderacji',
|
||||
'dashboard.moderate': 'Moderuj',
|
||||
'dashboard.restock': 'Uzupełnij',
|
||||
'dashboard.noActionOrders': 'Brak zamówień wymagających działania.',
|
||||
'dashboard.noLowStock': 'Stany magazynowe wyglądają dobrze.',
|
||||
'dashboard.noPendingReviews': 'Brak opinii oczekujących.',
|
||||
'orders.order': 'Zamówienie',
|
||||
'orders.bulkSelected': 'Wybrano {count}',
|
||||
'orders.bulkPick': 'Kompletuj / pick',
|
||||
'orders.bulkInvoices': 'Generuj faktury',
|
||||
@@ -247,6 +261,11 @@ export const PL_TRANSLATIONS: TranslationMap = {
|
||||
'orders.markCollected': 'Skompletowano',
|
||||
'orders.collected': 'Skompletowane',
|
||||
'orders.showCollected': 'Pokaż skompletowane',
|
||||
'orders.pickByOrder': 'Wg zamówień',
|
||||
'orders.pickByProduct': 'Wg produktów',
|
||||
'orders.pickTotalQty': 'Razem do pobrania: {{count}}',
|
||||
'orders.collectProduct': 'Skompletuj wszystkie',
|
||||
'orders.productCollected': 'Wszystkie skompletowane',
|
||||
'orders.pendingItems': 'Do kompletacji',
|
||||
'orders.collectedItems': 'Skompletowane',
|
||||
'orders.orderWarehouse': 'Magazyn dla zamówienia',
|
||||
@@ -341,6 +360,7 @@ export const PL_TRANSLATIONS: TranslationMap = {
|
||||
'newsletter.searchCampaign': 'Szukaj kampanii…',
|
||||
'newsletter.joined': 'Dołączył',
|
||||
'newsletter.sent': 'Wysłano',
|
||||
'newsletter.recipients': 'Odbiorcy',
|
||||
'newsletter.unsubscribed': 'Wypisany',
|
||||
'newsletter.status.sent': 'Wysłana',
|
||||
'newsletter.status.draft': 'Szkic',
|
||||
@@ -451,10 +471,36 @@ export const PL_TRANSLATIONS: TranslationMap = {
|
||||
'settings.navIntegrations': 'Integracje',
|
||||
'settings.navEmail': 'Email',
|
||||
'settings.navAccount': 'Moje konto',
|
||||
'settings.integrationsTitle': 'Marketplace i faktury',
|
||||
'settings.integrationsTitle': 'Płatności, przewoźnicy i marketplace',
|
||||
'settings.integrationsHint':
|
||||
'Konfiguracja Allegro, Amazon i iFirma. Synchronizacja zamówień i auto-faktury użyją tych ustawień po włączeniu konektorów.',
|
||||
'PayU, InPost, DHL, DPD, Allegro, Amazon i iFirma — zapis w bazie. Puste tajne pole zostawia dotychczasową wartość.',
|
||||
'settings.secretSaved': 'zapisany',
|
||||
'settings.shippingSandbox': 'Używaj sandbox / testowych API wysyłki',
|
||||
'settings.live': 'Produkcja',
|
||||
'settings.payuPosId': 'PayU POS ID',
|
||||
'settings.payuClientId': 'PayU client ID',
|
||||
'settings.payuClientSecret': 'PayU client secret',
|
||||
'settings.payuSecondKey': 'PayU second key (MD5)',
|
||||
'settings.payuOauthUrl': 'PayU OAuth URL',
|
||||
'settings.payuOrdersUrl': 'PayU orders URL',
|
||||
'settings.payuNotifyUrl': 'PayU notify (webhook) URL',
|
||||
'settings.payuContinueUrl': 'PayU continue URL (baza)',
|
||||
'settings.inPostOrgId': 'InPost organization ID',
|
||||
'settings.inPostShipXToken': 'InPost ShipX token',
|
||||
'settings.inPostShipXBaseUrl': 'InPost ShipX base URL',
|
||||
'settings.inPostGeowidgetConfig': 'Geowidget config',
|
||||
'settings.inPostGeowidgetSdk': 'Geowidget SDK base URL',
|
||||
'settings.inPostGeowidgetPublic': 'Geowidget public token',
|
||||
'settings.inPostGeowidgetSandbox': 'Geowidget sandbox token',
|
||||
'settings.dhlApiKey': 'DHL API key',
|
||||
'settings.dhlApiSecret': 'DHL API secret',
|
||||
'settings.dhlApiBaseUrl': 'DHL API base URL',
|
||||
'settings.dhlWidgetUrl': 'DHL mapa Parcelshop',
|
||||
'settings.dpdLogin': 'DPD login',
|
||||
'settings.dpdPassword': 'DPD hasło',
|
||||
'settings.dpdFid': 'DPD FID',
|
||||
'settings.dpdApiBaseUrl': 'DPD API base URL',
|
||||
'settings.dpdWidgetUrl': 'DPD pudofinder URL',
|
||||
'settings.allegroClientId': 'Allegro client ID',
|
||||
'settings.allegroClientSecret': 'Allegro client secret',
|
||||
'settings.allegroSellerId': 'Allegro seller ID',
|
||||
@@ -528,7 +574,7 @@ export const PL_TRANSLATIONS: TranslationMap = {
|
||||
'settings.mode': 'Tryb',
|
||||
'settings.sandbox': 'Sandbox',
|
||||
'settings.shippingHint':
|
||||
'Dane dostępowe zostają w konfiguracji serwera — tu tylko flagi widoczności.',
|
||||
'Włącz przewoźników tutaj. Tokeny API i URL widgetów ustawisz w Integracjach.',
|
||||
'settings.emailTitle': 'Email / newsletter',
|
||||
'settings.fromName': 'Nazwa nadawcy',
|
||||
'settings.fromAddress': 'Adres nadawcy',
|
||||
|
||||
@@ -222,7 +222,11 @@
|
||||
>
|
||||
{{ 'products.save' | translate }}
|
||||
</button>
|
||||
<a class="btn btn-secondary" routerLink="/products">
|
||||
<a
|
||||
class="btn btn-secondary"
|
||||
routerLink="/products"
|
||||
[queryParams]="selectedId() ? { categoryId: selectedId() } : {}"
|
||||
>
|
||||
{{ 'categories.viewProducts' | translate }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
||||
import {
|
||||
LucideMapPinned,
|
||||
LucidePackage,
|
||||
LucidePenLine,
|
||||
LucideStore,
|
||||
LucideTag,
|
||||
LucideTruck,
|
||||
} from '@lucide/angular';
|
||||
import { TranslatePipe } from '../../core/i18n/translate.pipe';
|
||||
import {
|
||||
OrderSource,
|
||||
ShippingCarrier,
|
||||
ShippingServiceType,
|
||||
} from './order.models';
|
||||
|
||||
/**
|
||||
* Compact channel + delivery icons for order tables and headers.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-order-channel-icons',
|
||||
standalone: true,
|
||||
imports: [
|
||||
TranslatePipe,
|
||||
LucideStore,
|
||||
LucideTag,
|
||||
LucidePackage,
|
||||
LucidePenLine,
|
||||
LucideTruck,
|
||||
LucideMapPinned,
|
||||
],
|
||||
template: `
|
||||
<div class="order-meta-icons" [class.order-meta-icons--with-labels]="showLabels()">
|
||||
<span
|
||||
class="meta-icon channel-badge"
|
||||
[attr.data-channel]="channel()"
|
||||
[title]="'orders.source.' + channel() | translate"
|
||||
>
|
||||
@switch (channel()) {
|
||||
@case ('allegro') {
|
||||
<svg lucideTag [size]="iconSize()" aria-hidden="true"></svg>
|
||||
}
|
||||
@case ('amazon') {
|
||||
<svg lucidePackage [size]="iconSize()" aria-hidden="true"></svg>
|
||||
}
|
||||
@case ('manual') {
|
||||
<svg lucidePenLine [size]="iconSize()" aria-hidden="true"></svg>
|
||||
}
|
||||
@default {
|
||||
<svg lucideStore [size]="iconSize()" aria-hidden="true"></svg>
|
||||
}
|
||||
}
|
||||
@if (showLabels()) {
|
||||
<span class="meta-icon__label">
|
||||
{{ 'orders.source.' + channel() | translate }}
|
||||
</span>
|
||||
}
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="meta-icon delivery-badge"
|
||||
[attr.data-carrier]="carrier()"
|
||||
[attr.data-service]="service()"
|
||||
[title]="
|
||||
('orders.carrier.' + carrier() | translate) +
|
||||
' · ' +
|
||||
('orders.service.' + service() | translate)
|
||||
"
|
||||
>
|
||||
@if (service() === 'courier') {
|
||||
<svg lucideTruck [size]="iconSize()" aria-hidden="true"></svg>
|
||||
} @else {
|
||||
<svg lucideMapPinned [size]="iconSize()" aria-hidden="true"></svg>
|
||||
}
|
||||
@if (showLabels()) {
|
||||
<span class="meta-icon__label">
|
||||
{{ 'orders.carrier.' + carrier() | translate }}
|
||||
·
|
||||
{{ 'orders.service.' + service() | translate }}
|
||||
</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class OrderChannelIconsComponent {
|
||||
readonly source = input<OrderSource | string | null | undefined>('shop');
|
||||
readonly shippingCarrier = input<
|
||||
ShippingCarrier | string | null | undefined
|
||||
>('inPost');
|
||||
readonly shippingServiceType = input<
|
||||
ShippingServiceType | string | null | undefined
|
||||
>('parcelLocker');
|
||||
readonly showLabels = input(false);
|
||||
readonly iconSize = input(14);
|
||||
|
||||
protected channel(): OrderSource {
|
||||
const value = this.source() || 'shop';
|
||||
return value as OrderSource;
|
||||
}
|
||||
|
||||
protected carrier(): ShippingCarrier {
|
||||
const value = this.shippingCarrier() || 'inPost';
|
||||
return value as ShippingCarrier;
|
||||
}
|
||||
|
||||
protected service(): ShippingServiceType {
|
||||
const value = this.shippingServiceType() || 'parcelLocker';
|
||||
return value as ShippingServiceType;
|
||||
}
|
||||
}
|
||||
@@ -18,22 +18,14 @@
|
||||
}
|
||||
</h2>
|
||||
@if (detail(); as d) {
|
||||
<div class="order-meta-icons" style="margin-top: 6px">
|
||||
<span
|
||||
class="channel-badge"
|
||||
[attr.data-channel]="d.source || 'shop'"
|
||||
>
|
||||
{{ 'orders.source.' + (d.source || 'shop') | translate }}
|
||||
</span>
|
||||
<span
|
||||
class="delivery-badge"
|
||||
[attr.data-carrier]="d.shippingCarrier"
|
||||
[attr.data-service]="d.shippingServiceType"
|
||||
>
|
||||
{{ 'orders.carrier.' + d.shippingCarrier | translate }}
|
||||
·
|
||||
{{ 'orders.service.' + d.shippingServiceType | translate }}
|
||||
</span>
|
||||
<div style="margin-top: 6px; display: flex; flex-wrap: wrap; align-items: center; gap: 8px">
|
||||
<app-order-channel-icons
|
||||
[source]="d.source"
|
||||
[shippingCarrier]="d.shippingCarrier"
|
||||
[shippingServiceType]="d.shippingServiceType"
|
||||
[showLabels]="true"
|
||||
[iconSize]="15"
|
||||
/>
|
||||
@if (d.externalOrderId) {
|
||||
<span class="cell-muted text-xs font-mono">
|
||||
{{ d.externalOrderId }}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { TranslatePipe } from '../../core/i18n/translate.pipe';
|
||||
import { ApiService } from '../../core/api.service';
|
||||
import { API_BASE_URL } from '../../core/constants';
|
||||
import { AdminSkeletonComponent } from '../../shared/admin-skeleton.component';
|
||||
import { OrderChannelIconsComponent } from './order-channel-icons.component';
|
||||
import {
|
||||
AdminOrderDetail,
|
||||
AdminOrderDetailLine,
|
||||
@@ -40,6 +41,7 @@ import {
|
||||
TranslatePipe,
|
||||
I18nCurrencyPipe,
|
||||
AdminSkeletonComponent,
|
||||
OrderChannelIconsComponent,
|
||||
],
|
||||
templateUrl: './order-detail-modal.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { PickListLine, PickListOrderGroup, PickListWarehouseQty } from './order.models';
|
||||
import {
|
||||
PickListLine,
|
||||
PickListOrderGroup,
|
||||
PickListProductGroup,
|
||||
PickListWarehouseQty,
|
||||
} from './order.models';
|
||||
|
||||
export interface CollectLinePayload {
|
||||
orderLineId: string;
|
||||
@@ -36,6 +41,66 @@ export function groupPickListByOrder(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregates pick lines by product variant so pickers can
|
||||
* pull total units once (e.g. Gold 1 + Gold 2 → Gold 3).
|
||||
*/
|
||||
export function groupPickListByProduct(
|
||||
items: PickListLine[],
|
||||
): PickListProductGroup[] {
|
||||
const groups = new Map<string, PickListProductGroup>();
|
||||
|
||||
for (const line of items) {
|
||||
const key = line.productVariantId || `${line.sku}|${line.productName}`;
|
||||
const existing = groups.get(key);
|
||||
if (existing) {
|
||||
existing.lines.push(line);
|
||||
existing.totalQuantity += line.quantity;
|
||||
if (line.isCollected) {
|
||||
existing.collectedQuantity += line.quantity;
|
||||
} else {
|
||||
existing.pendingQuantity += line.quantity;
|
||||
}
|
||||
mergeWarehouseStock(existing.warehouseStock, line.warehouseStock);
|
||||
continue;
|
||||
}
|
||||
|
||||
groups.set(key, {
|
||||
productVariantId: line.productVariantId,
|
||||
productName: line.productName,
|
||||
variantColor: line.variantColor,
|
||||
sku: line.sku,
|
||||
totalQuantity: line.quantity,
|
||||
pendingQuantity: line.isCollected ? 0 : line.quantity,
|
||||
collectedQuantity: line.isCollected ? line.quantity : 0,
|
||||
lines: [line],
|
||||
warehouseStock: line.warehouseStock.map((ws) => ({ ...ws })),
|
||||
});
|
||||
}
|
||||
|
||||
return [...groups.values()].sort((a, b) => {
|
||||
const byName = a.productName.localeCompare(b.productName);
|
||||
if (byName !== 0) {
|
||||
return byName;
|
||||
}
|
||||
return a.sku.localeCompare(b.sku);
|
||||
});
|
||||
}
|
||||
|
||||
function mergeWarehouseStock(
|
||||
target: PickListWarehouseQty[],
|
||||
incoming: PickListWarehouseQty[],
|
||||
): void {
|
||||
const map = new Map(target.map((ws) => [ws.warehouseId, ws]));
|
||||
for (const ws of incoming) {
|
||||
if (!map.has(ws.warehouseId)) {
|
||||
const copy = { ...ws };
|
||||
map.set(ws.warehouseId, copy);
|
||||
target.push(copy);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function canCollectPickLine(
|
||||
line: PickListLine,
|
||||
warehouseId: string,
|
||||
|
||||
@@ -159,6 +159,18 @@ export interface PickListOrderGroup {
|
||||
collectedCount: number;
|
||||
}
|
||||
|
||||
export interface PickListProductGroup {
|
||||
productVariantId: string;
|
||||
productName: string;
|
||||
variantColor: string;
|
||||
sku: string;
|
||||
totalQuantity: number;
|
||||
pendingQuantity: number;
|
||||
collectedQuantity: number;
|
||||
lines: PickListLine[];
|
||||
warehouseStock: PickListWarehouseQty[];
|
||||
}
|
||||
|
||||
export interface OrderInvoice {
|
||||
invoiceNumber: string;
|
||||
issueDate: string;
|
||||
|
||||
@@ -175,33 +175,11 @@
|
||||
</td>
|
||||
<td class="px-4 py-3 font-mono text-xs">{{ o.orderNumber }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="order-meta-icons">
|
||||
<span
|
||||
class="channel-badge"
|
||||
[attr.data-channel]="o.source || 'shop'"
|
||||
[title]="'orders.source.' + (o.source || 'shop') | translate"
|
||||
>
|
||||
{{ 'orders.sourceShort.' + (o.source || 'shop') | translate }}
|
||||
</span>
|
||||
<span
|
||||
class="delivery-badge"
|
||||
[attr.data-carrier]="o.shippingCarrier || 'inPost'"
|
||||
[attr.data-service]="o.shippingServiceType || 'parcelLocker'"
|
||||
[title]="
|
||||
('orders.carrier.' + (o.shippingCarrier || 'inPost') | translate) +
|
||||
' · ' +
|
||||
('orders.service.' + (o.shippingServiceType || 'parcelLocker') | translate)
|
||||
"
|
||||
>
|
||||
{{ 'orders.carrierShort.' + (o.shippingCarrier || 'inPost') | translate }}
|
||||
·
|
||||
{{
|
||||
o.shippingServiceType === 'courier'
|
||||
? ('orders.serviceShort.courier' | translate)
|
||||
: ('orders.serviceShort.parcelLocker' | translate)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<app-order-channel-icons
|
||||
[source]="o.source"
|
||||
[shippingCarrier]="o.shippingCarrier"
|
||||
[shippingServiceType]="o.shippingServiceType"
|
||||
/>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-iled-grey-dark">
|
||||
{{ o.createdAt | date: 'yyyy-MM-dd HH:mm' }}
|
||||
@@ -268,7 +246,25 @@
|
||||
· {{ 'orders.pendingItems' | translate }}: {{ pl.pendingCount }}
|
||||
· {{ 'orders.collectedItems' | translate }}: {{ pl.collectedCount }}
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div class="tabs" role="tablist" style="margin: 0">
|
||||
<button
|
||||
type="button"
|
||||
class="tab"
|
||||
[class.is-active]="pickListMode() === 'order'"
|
||||
(click)="setPickListMode('order')"
|
||||
>
|
||||
{{ 'orders.pickByOrder' | translate }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="tab"
|
||||
[class.is-active]="pickListMode() === 'product'"
|
||||
(click)="setPickListMode('product')"
|
||||
>
|
||||
{{ 'orders.pickByProduct' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -287,6 +283,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (pickListMode() === 'order') {
|
||||
@for (group of pickListGroups(pl.items); track group.orderId) {
|
||||
<section class="mb-6 border border-iled-grey-light">
|
||||
<div
|
||||
@@ -419,6 +416,155 @@
|
||||
{{ 'orders.pickListEmpty' | translate }}
|
||||
</p>
|
||||
}
|
||||
} @else {
|
||||
@for (
|
||||
group of pickListProductGroups(pl.items);
|
||||
track group.productVariantId || group.sku
|
||||
) {
|
||||
<section class="mb-6 border border-iled-grey-light">
|
||||
<div
|
||||
class="flex flex-wrap items-center justify-between gap-3 border-b border-iled-grey-light bg-iled-bg px-4 py-3"
|
||||
>
|
||||
<div>
|
||||
<h3 class="font-semibold">
|
||||
{{ group.productName }}
|
||||
@if (group.variantColor) {
|
||||
<span class="font-normal text-iled-grey-dark">
|
||||
({{ group.variantColor }})
|
||||
</span>
|
||||
}
|
||||
</h3>
|
||||
<p class="text-xs text-iled-grey-dark font-mono">{{ group.sku }}</p>
|
||||
<p class="mt-1 text-sm">
|
||||
{{
|
||||
'orders.pickTotalQty'
|
||||
| translate: { count: group.totalQuantity }
|
||||
}}
|
||||
· {{ 'orders.pendingItems' | translate }}:
|
||||
{{ group.pendingQuantity }}
|
||||
· {{ 'orders.collectedItems' | translate }}:
|
||||
{{ group.collectedQuantity }}
|
||||
</p>
|
||||
</div>
|
||||
@if (group.pendingQuantity > 0) {
|
||||
<div class="flex flex-wrap items-end gap-2">
|
||||
<div>
|
||||
<label class="mb-1 block text-xs text-iled-grey-dark">
|
||||
{{ 'orders.orderWarehouse' | translate }}
|
||||
</label>
|
||||
<select
|
||||
class="border border-iled-grey-light px-2 py-1.5 text-xs"
|
||||
[value]="getProductWarehouse(group)"
|
||||
(change)="
|
||||
setProductWarehouse(group, $any($event.target).value)
|
||||
"
|
||||
>
|
||||
<option value="">{{ 'orders.selectWarehouse' | translate }}</option>
|
||||
@for (ws of group.warehouseStock; track ws.warehouseId) {
|
||||
<option [value]="ws.warehouseId">
|
||||
{{ ws.warehouseName }} ({{ ws.warehouseCode }}) —
|
||||
{{ ws.quantity }}
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm"
|
||||
[disabled]="
|
||||
!canCollectProductGroup(group) ||
|
||||
collectingOrderId() === (group.productVariantId || group.sku)
|
||||
"
|
||||
(click)="handleCollectProduct(group)"
|
||||
>
|
||||
{{ 'orders.collectProduct' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<span class="rounded bg-green-100 px-2 py-1 text-xs font-medium text-green-800">
|
||||
{{ 'orders.productCollected' | translate }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead class="border-b border-iled-grey-light">
|
||||
<tr>
|
||||
<th class="px-3 py-2">{{ 'orders.order' | translate }}</th>
|
||||
<th class="px-3 py-2">{{ 'orders.qty' | translate }}</th>
|
||||
<th class="px-3 py-2">{{ 'orders.warehouseStock' | translate }}</th>
|
||||
<th class="px-3 py-2">{{ 'orders.pickFrom' | translate }}</th>
|
||||
<th class="px-3 py-2">{{ 'orders.status' | translate }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (line of group.lines; track line.orderLineId) {
|
||||
<tr
|
||||
class="border-b border-iled-grey-light"
|
||||
[class.bg-green-50]="line.isCollected"
|
||||
>
|
||||
<td class="px-3 py-2 font-mono text-xs">
|
||||
#{{ line.orderNumber }}
|
||||
</td>
|
||||
<td class="px-3 py-2 font-bold">{{ line.quantity }}</td>
|
||||
<td class="px-3 py-2">
|
||||
@for (ws of line.warehouseStock; track ws.warehouseId) {
|
||||
<div class="text-xs">
|
||||
{{ ws.warehouseName }} ({{ ws.warehouseCode }}):
|
||||
<strong>{{ ws.quantity }}</strong>
|
||||
</div>
|
||||
} @empty {
|
||||
<span class="text-iled-grey-dark">—</span>
|
||||
}
|
||||
</td>
|
||||
<td class="px-3 py-2">
|
||||
@if (!line.isCollected) {
|
||||
<select
|
||||
class="border border-iled-grey-light px-2 py-1 text-xs"
|
||||
[value]="getWarehouse(line.orderLineId)"
|
||||
(change)="setWarehouse(line.orderLineId, $any($event.target).value)"
|
||||
>
|
||||
<option value="">{{ 'orders.selectWarehouse' | translate }}</option>
|
||||
@for (ws of line.warehouseStock; track ws.warehouseId) {
|
||||
<option [value]="ws.warehouseId">
|
||||
{{ ws.warehouseName }} ({{ ws.quantity }})
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
} @else if (line.pickedFromWarehouseName) {
|
||||
<span class="text-xs font-medium">
|
||||
{{ line.pickedFromWarehouseName }}
|
||||
({{ line.pickedFromWarehouseCode }})
|
||||
</span>
|
||||
}
|
||||
</td>
|
||||
<td class="px-3 py-2">
|
||||
@if (line.isCollected) {
|
||||
<span class="inline-block rounded bg-green-100 px-2 py-0.5 text-xs font-medium text-green-800">
|
||||
{{ 'orders.collected' | translate }}
|
||||
</span>
|
||||
} @else {
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary btn-sm"
|
||||
[disabled]="!canCollect(line) || collectingLineId() === line.orderLineId"
|
||||
(click)="handleCollect(line)"
|
||||
>
|
||||
{{ 'orders.markCollected' | translate }}
|
||||
</button>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
} @empty {
|
||||
<p class="py-8 text-center text-iled-grey-dark">
|
||||
{{ 'orders.pickListEmpty' | translate }}
|
||||
</p>
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { ActivatedRoute } from '@angular/router';
|
||||
import { I18nCurrencyPipe } from '../../core/i18n/i18n-currency.pipe';
|
||||
import { TranslatePipe } from '../../core/i18n/translate.pipe';
|
||||
import { ApiService } from '../../core/api.service';
|
||||
import { OrderChannelIconsComponent } from './order-channel-icons.component';
|
||||
import { OrderDetailModalComponent } from './order-detail-modal.component';
|
||||
import { OrderInvoiceModalComponent } from './order-invoice-modal.component';
|
||||
import {
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
PickListResult,
|
||||
PickListLine,
|
||||
PickListOrderGroup,
|
||||
PickListProductGroup,
|
||||
} from './order.models';
|
||||
import {
|
||||
applyOrderWarehouse,
|
||||
@@ -35,6 +37,7 @@ import {
|
||||
canUseWarehouseForOrder,
|
||||
defaultWarehouseForLine,
|
||||
groupPickListByOrder,
|
||||
groupPickListByProduct,
|
||||
orderWarehouseOptions,
|
||||
} from './order-pick.utils';
|
||||
import { AdminListToolbarComponent } from '../../shared/admin-list-toolbar.component';
|
||||
@@ -43,6 +46,7 @@ import { AdminSkeletonComponent } from '../../shared/admin-skeleton.component';
|
||||
import { ToastService } from '../../shared/toast.service';
|
||||
|
||||
type OrdersTab = 'list' | 'pickList';
|
||||
type PickListMode = 'order' | 'product';
|
||||
|
||||
type SortBy = 'date' | 'total' | 'number';
|
||||
type SortDir = 'asc' | 'desc';
|
||||
@@ -58,6 +62,7 @@ type SortDir = 'asc' | 'desc';
|
||||
AdminListToolbarComponent,
|
||||
AdminPaginationComponent,
|
||||
AdminSkeletonComponent,
|
||||
OrderChannelIconsComponent,
|
||||
OrderDetailModalComponent,
|
||||
OrderInvoiceModalComponent,
|
||||
],
|
||||
@@ -78,6 +83,7 @@ export class OrdersAdminComponent implements OnInit {
|
||||
|
||||
readonly pickList = signal<PickListResult | null>(null);
|
||||
readonly pickListLoading = signal(false);
|
||||
readonly pickListMode = signal<PickListMode>('order');
|
||||
readonly showCollected = signal(false);
|
||||
readonly collectingLineId = signal<string | null>(null);
|
||||
readonly collectingOrderId = signal<string | null>(null);
|
||||
@@ -200,6 +206,10 @@ export class OrdersAdminComponent implements OnInit {
|
||||
this.loadPickList();
|
||||
}
|
||||
|
||||
setPickListMode(mode: PickListMode): void {
|
||||
this.pickListMode.set(mode);
|
||||
}
|
||||
|
||||
setWarehouse(lineId: string, warehouseId: string): void {
|
||||
this.selectedWarehouse.set(lineId, warehouseId);
|
||||
this.#cdr.markForCheck();
|
||||
@@ -217,6 +227,99 @@ export class OrdersAdminComponent implements OnInit {
|
||||
return groupPickListByOrder(items);
|
||||
}
|
||||
|
||||
pickListProductGroups(items: PickListLine[]): PickListProductGroup[] {
|
||||
return groupPickListByProduct(items);
|
||||
}
|
||||
|
||||
setProductWarehouse(
|
||||
group: PickListProductGroup,
|
||||
warehouseId: string,
|
||||
): void {
|
||||
for (const line of group.lines) {
|
||||
if (!line.isCollected) {
|
||||
this.selectedWarehouse.set(line.orderLineId, warehouseId);
|
||||
}
|
||||
}
|
||||
this.#cdr.markForCheck();
|
||||
}
|
||||
|
||||
getProductWarehouse(group: PickListProductGroup): string {
|
||||
const pending = group.lines.find((line) => !line.isCollected);
|
||||
if (!pending) {
|
||||
return '';
|
||||
}
|
||||
return this.getWarehouse(pending.orderLineId);
|
||||
}
|
||||
|
||||
canCollectProductGroup(group: PickListProductGroup): boolean {
|
||||
const pending = group.lines.filter((line) => !line.isCollected);
|
||||
if (pending.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const warehouseId = this.getWarehouse(pending[0].orderLineId);
|
||||
if (!warehouseId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
!pending.every(
|
||||
(line) => this.getWarehouse(line.orderLineId) === warehouseId,
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const needed = pending.reduce((sum, line) => sum + line.quantity, 0);
|
||||
const stock = group.warehouseStock.find(
|
||||
(ws) => ws.warehouseId === warehouseId,
|
||||
);
|
||||
return !!stock && stock.quantity >= needed;
|
||||
}
|
||||
|
||||
handleCollectProduct(group: PickListProductGroup): void {
|
||||
if (!this.canCollectProductGroup(group)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pending = group.lines.filter((line) => !line.isCollected);
|
||||
if (pending.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.collectingOrderId.set(group.productVariantId || group.sku);
|
||||
let remaining = pending.length;
|
||||
let failed = false;
|
||||
|
||||
for (const line of pending) {
|
||||
const warehouseId = this.getWarehouse(line.orderLineId);
|
||||
this.#api
|
||||
.post<void>(`/orders/admin/lines/${line.orderLineId}/collect`, {
|
||||
warehouseId,
|
||||
})
|
||||
.subscribe({
|
||||
next: () => {
|
||||
remaining -= 1;
|
||||
if (remaining === 0) {
|
||||
this.collectingOrderId.set(null);
|
||||
if (!failed) {
|
||||
this.loadPickList();
|
||||
this.loadOrders();
|
||||
}
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
failed = true;
|
||||
remaining -= 1;
|
||||
if (remaining === 0) {
|
||||
this.collectingOrderId.set(null);
|
||||
this.loadPickList();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getOrderWarehouse(orderId: string): string {
|
||||
return this.selectedOrderWarehouse.get(orderId) ?? '';
|
||||
}
|
||||
|
||||
@@ -89,6 +89,8 @@ export class ProductsAdminComponent implements OnInit {
|
||||
|
||||
this.filterLowStock =
|
||||
this.#route.snapshot.queryParamMap.get('lowStock') === '1';
|
||||
this.filterCategoryId =
|
||||
this.#route.snapshot.queryParamMap.get('categoryId') ?? '';
|
||||
this.loadProducts();
|
||||
}
|
||||
|
||||
@@ -309,7 +311,11 @@ export class ProductsAdminComponent implements OnInit {
|
||||
#syncLowStockQuery(): void {
|
||||
void this.#router.navigate([], {
|
||||
relativeTo: this.#route,
|
||||
queryParams: this.filterLowStock ? { lowStock: '1' } : {},
|
||||
queryParams: {
|
||||
lowStock: this.filterLowStock ? '1' : null,
|
||||
categoryId: this.filterCategoryId || null,
|
||||
},
|
||||
queryParamsHandling: 'merge',
|
||||
replaceUrl: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -234,7 +234,16 @@
|
||||
<div class="card-head">
|
||||
<h2>{{ 'settings.shippingTitle' | translate }}</h2>
|
||||
</div>
|
||||
<div class="card-pad">
|
||||
<div class="card-pad stack" style="gap: 16px">
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="check"
|
||||
name="shippingUseSandbox"
|
||||
[(ngModel)]="form.shippingUseSandbox"
|
||||
/>
|
||||
{{ 'settings.shippingSandbox' | translate }}
|
||||
</label>
|
||||
<div class="table-wrap">
|
||||
<table class="data">
|
||||
<thead>
|
||||
@@ -256,8 +265,17 @@
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-warning">
|
||||
{{ 'settings.sandbox' | translate }}
|
||||
<span
|
||||
class="badge"
|
||||
[class.badge-warning]="form.shippingUseSandbox"
|
||||
[class.badge-success]="!form.shippingUseSandbox"
|
||||
>
|
||||
{{
|
||||
(form.shippingUseSandbox
|
||||
? 'settings.sandbox'
|
||||
: 'settings.live'
|
||||
) | translate
|
||||
}}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -272,8 +290,17 @@
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-warning">
|
||||
{{ 'settings.sandbox' | translate }}
|
||||
<span
|
||||
class="badge"
|
||||
[class.badge-warning]="form.shippingUseSandbox"
|
||||
[class.badge-success]="!form.shippingUseSandbox"
|
||||
>
|
||||
{{
|
||||
(form.shippingUseSandbox
|
||||
? 'settings.sandbox'
|
||||
: 'settings.live'
|
||||
) | translate
|
||||
}}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -288,15 +315,24 @@
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-warning">
|
||||
{{ 'settings.sandbox' | translate }}
|
||||
<span
|
||||
class="badge"
|
||||
[class.badge-warning]="form.shippingUseSandbox"
|
||||
[class.badge-success]="!form.shippingUseSandbox"
|
||||
>
|
||||
{{
|
||||
(form.shippingUseSandbox
|
||||
? 'settings.sandbox'
|
||||
: 'settings.live'
|
||||
) | translate
|
||||
}}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="field-hint" style="margin: 14px 0 0">
|
||||
<p class="field-hint" style="margin: 0">
|
||||
{{ 'settings.shippingHint' | translate }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -313,6 +349,335 @@
|
||||
{{ 'settings.integrationsHint' | translate }}
|
||||
</p>
|
||||
|
||||
<section class="stack" style="gap: 12px">
|
||||
<div style="display: flex; align-items: center; gap: 10px">
|
||||
<span class="badge badge-info">PayU</span>
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="check"
|
||||
name="payuSandbox"
|
||||
[(ngModel)]="form.payU.useSandbox"
|
||||
/>
|
||||
{{ 'settings.sandbox' | translate }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div class="field">
|
||||
<label for="payu-pos">{{ 'settings.payuPosId' | translate }}</label>
|
||||
<input
|
||||
id="payu-pos"
|
||||
class="input"
|
||||
name="payuPosId"
|
||||
[(ngModel)]="form.payU.posId"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="payu-client">{{ 'settings.payuClientId' | translate }}</label>
|
||||
<input
|
||||
id="payu-client"
|
||||
class="input"
|
||||
name="payuClientId"
|
||||
[(ngModel)]="form.payU.clientId"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="payu-secret">
|
||||
{{ 'settings.payuClientSecret' | translate }}
|
||||
@if (form.payU.hasClientSecret) {
|
||||
<span class="cell-muted">({{ 'settings.secretSaved' | translate }})</span>
|
||||
}
|
||||
</label>
|
||||
<input
|
||||
id="payu-secret"
|
||||
class="input"
|
||||
type="password"
|
||||
name="payuClientSecret"
|
||||
autocomplete="new-password"
|
||||
[(ngModel)]="form.payU.clientSecret"
|
||||
[placeholder]="form.payU.hasClientSecret ? '••••••••' : ''"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="payu-second">
|
||||
{{ 'settings.payuSecondKey' | translate }}
|
||||
@if (form.payU.hasSecondKey) {
|
||||
<span class="cell-muted">({{ 'settings.secretSaved' | translate }})</span>
|
||||
}
|
||||
</label>
|
||||
<input
|
||||
id="payu-second"
|
||||
class="input"
|
||||
type="password"
|
||||
name="payuSecondKey"
|
||||
autocomplete="new-password"
|
||||
[(ngModel)]="form.payU.secondKey"
|
||||
[placeholder]="form.payU.hasSecondKey ? '••••••••' : ''"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="payu-oauth">{{ 'settings.payuOauthUrl' | translate }}</label>
|
||||
<input
|
||||
id="payu-oauth"
|
||||
class="input"
|
||||
name="payuOauthUrl"
|
||||
[(ngModel)]="form.payU.oauthUrl"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="payu-orders">{{ 'settings.payuOrdersUrl' | translate }}</label>
|
||||
<input
|
||||
id="payu-orders"
|
||||
class="input"
|
||||
name="payuOrdersUrl"
|
||||
[(ngModel)]="form.payU.ordersUrl"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="payu-notify">{{ 'settings.payuNotifyUrl' | translate }}</label>
|
||||
<input
|
||||
id="payu-notify"
|
||||
class="input"
|
||||
name="payuNotifyUrl"
|
||||
[(ngModel)]="form.payU.notifyUrl"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="payu-continue">{{ 'settings.payuContinueUrl' | translate }}</label>
|
||||
<input
|
||||
id="payu-continue"
|
||||
class="input"
|
||||
name="payuContinueUrl"
|
||||
[(ngModel)]="form.payU.continueUrl"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<hr style="border: 0; border-top: 1px solid var(--border)" />
|
||||
|
||||
<section class="stack" style="gap: 12px">
|
||||
<div style="display: flex; align-items: center; gap: 10px">
|
||||
<span class="delivery-badge" data-carrier="inPost">InPost</span>
|
||||
</div>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div class="field">
|
||||
<label for="inpost-org">{{ 'settings.inPostOrgId' | translate }}</label>
|
||||
<input
|
||||
id="inpost-org"
|
||||
class="input"
|
||||
name="inPostOrgId"
|
||||
[(ngModel)]="form.inPost.organizationId"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="inpost-token">
|
||||
{{ 'settings.inPostShipXToken' | translate }}
|
||||
@if (form.inPost.hasShipXToken) {
|
||||
<span class="cell-muted">({{ 'settings.secretSaved' | translate }})</span>
|
||||
}
|
||||
</label>
|
||||
<input
|
||||
id="inpost-token"
|
||||
class="input"
|
||||
type="password"
|
||||
name="inPostShipXToken"
|
||||
autocomplete="new-password"
|
||||
[(ngModel)]="form.inPost.shipXToken"
|
||||
[placeholder]="form.inPost.hasShipXToken ? '••••••••' : ''"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="inpost-base">{{ 'settings.inPostShipXBaseUrl' | translate }}</label>
|
||||
<input
|
||||
id="inpost-base"
|
||||
class="input"
|
||||
name="inPostShipXBaseUrl"
|
||||
[(ngModel)]="form.inPost.shipXBaseUrl"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="inpost-config">{{ 'settings.inPostGeowidgetConfig' | translate }}</label>
|
||||
<input
|
||||
id="inpost-config"
|
||||
class="input"
|
||||
name="inPostGeowidgetConfig"
|
||||
[(ngModel)]="form.inPost.geowidgetConfig"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="inpost-sdk">{{ 'settings.inPostGeowidgetSdk' | translate }}</label>
|
||||
<input
|
||||
id="inpost-sdk"
|
||||
class="input"
|
||||
name="inPostGeowidgetSdk"
|
||||
[(ngModel)]="form.inPost.geowidgetSdkBaseUrl"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="inpost-geo-pub">
|
||||
{{ 'settings.inPostGeowidgetPublic' | translate }}
|
||||
@if (form.inPost.hasGeowidgetPublicToken) {
|
||||
<span class="cell-muted">({{ 'settings.secretSaved' | translate }})</span>
|
||||
}
|
||||
</label>
|
||||
<input
|
||||
id="inpost-geo-pub"
|
||||
class="input"
|
||||
type="password"
|
||||
name="inPostGeowidgetPublic"
|
||||
autocomplete="new-password"
|
||||
[(ngModel)]="form.inPost.geowidgetPublicToken"
|
||||
[placeholder]="form.inPost.hasGeowidgetPublicToken ? '••••••••' : ''"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="inpost-geo-sb">
|
||||
{{ 'settings.inPostGeowidgetSandbox' | translate }}
|
||||
@if (form.inPost.hasGeowidgetSandboxToken) {
|
||||
<span class="cell-muted">({{ 'settings.secretSaved' | translate }})</span>
|
||||
}
|
||||
</label>
|
||||
<input
|
||||
id="inpost-geo-sb"
|
||||
class="input"
|
||||
type="password"
|
||||
name="inPostGeowidgetSandbox"
|
||||
autocomplete="new-password"
|
||||
[(ngModel)]="form.inPost.geowidgetSandboxToken"
|
||||
[placeholder]="form.inPost.hasGeowidgetSandboxToken ? '••••••••' : ''"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<hr style="border: 0; border-top: 1px solid var(--border)" />
|
||||
|
||||
<section class="stack" style="gap: 12px">
|
||||
<div style="display: flex; align-items: center; gap: 10px">
|
||||
<span class="delivery-badge" data-carrier="dhl">DHL</span>
|
||||
</div>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div class="field">
|
||||
<label for="dhl-key">
|
||||
{{ 'settings.dhlApiKey' | translate }}
|
||||
@if (form.dhl.hasApiKey) {
|
||||
<span class="cell-muted">({{ 'settings.secretSaved' | translate }})</span>
|
||||
}
|
||||
</label>
|
||||
<input
|
||||
id="dhl-key"
|
||||
class="input"
|
||||
type="password"
|
||||
name="dhlApiKey"
|
||||
autocomplete="new-password"
|
||||
[(ngModel)]="form.dhl.apiKey"
|
||||
[placeholder]="form.dhl.hasApiKey ? '••••••••' : ''"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="dhl-secret">
|
||||
{{ 'settings.dhlApiSecret' | translate }}
|
||||
@if (form.dhl.hasApiSecret) {
|
||||
<span class="cell-muted">({{ 'settings.secretSaved' | translate }})</span>
|
||||
}
|
||||
</label>
|
||||
<input
|
||||
id="dhl-secret"
|
||||
class="input"
|
||||
type="password"
|
||||
name="dhlApiSecret"
|
||||
autocomplete="new-password"
|
||||
[(ngModel)]="form.dhl.apiSecret"
|
||||
[placeholder]="form.dhl.hasApiSecret ? '••••••••' : ''"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="dhl-base">{{ 'settings.dhlApiBaseUrl' | translate }}</label>
|
||||
<input
|
||||
id="dhl-base"
|
||||
class="input"
|
||||
name="dhlApiBaseUrl"
|
||||
[(ngModel)]="form.dhl.apiBaseUrl"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="dhl-widget">{{ 'settings.dhlWidgetUrl' | translate }}</label>
|
||||
<input
|
||||
id="dhl-widget"
|
||||
class="input"
|
||||
name="dhlWidgetUrl"
|
||||
[(ngModel)]="form.dhl.widgetUrl"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<hr style="border: 0; border-top: 1px solid var(--border)" />
|
||||
|
||||
<section class="stack" style="gap: 12px">
|
||||
<div style="display: flex; align-items: center; gap: 10px">
|
||||
<span class="delivery-badge" data-carrier="dpd">DPD</span>
|
||||
</div>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div class="field">
|
||||
<label for="dpd-login">{{ 'settings.dpdLogin' | translate }}</label>
|
||||
<input
|
||||
id="dpd-login"
|
||||
class="input"
|
||||
name="dpdLogin"
|
||||
[(ngModel)]="form.dpd.login"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="dpd-pass">
|
||||
{{ 'settings.dpdPassword' | translate }}
|
||||
@if (form.dpd.hasPassword) {
|
||||
<span class="cell-muted">({{ 'settings.secretSaved' | translate }})</span>
|
||||
}
|
||||
</label>
|
||||
<input
|
||||
id="dpd-pass"
|
||||
class="input"
|
||||
type="password"
|
||||
name="dpdPassword"
|
||||
autocomplete="new-password"
|
||||
[(ngModel)]="form.dpd.password"
|
||||
[placeholder]="form.dpd.hasPassword ? '••••••••' : ''"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="dpd-fid">{{ 'settings.dpdFid' | translate }}</label>
|
||||
<input
|
||||
id="dpd-fid"
|
||||
class="input"
|
||||
name="dpdFid"
|
||||
[(ngModel)]="form.dpd.fid"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="dpd-base">{{ 'settings.dpdApiBaseUrl' | translate }}</label>
|
||||
<input
|
||||
id="dpd-base"
|
||||
class="input"
|
||||
name="dpdApiBaseUrl"
|
||||
[(ngModel)]="form.dpd.apiBaseUrl"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="dpd-widget">{{ 'settings.dpdWidgetUrl' | translate }}</label>
|
||||
<input
|
||||
id="dpd-widget"
|
||||
class="input"
|
||||
name="dpdWidgetUrl"
|
||||
[(ngModel)]="form.dpd.widgetUrl"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<hr style="border: 0; border-top: 1px solid var(--border)" />
|
||||
|
||||
<section class="stack" style="gap: 12px">
|
||||
<div style="display: flex; align-items: center; gap: 10px">
|
||||
<span class="channel-badge" data-channel="allegro">Allegro</span>
|
||||
|
||||
@@ -36,6 +36,7 @@ interface StoreSettingsApi {
|
||||
locales: { pl: boolean; en: boolean; de: boolean; cs: boolean };
|
||||
lowStockThreshold: number;
|
||||
carriers: { inPost: boolean; dhl: boolean; dpd: boolean };
|
||||
shippingUseSandbox: boolean;
|
||||
emailFromName: string;
|
||||
emailFromAddress: string;
|
||||
emailReplyTo: string;
|
||||
@@ -61,6 +62,39 @@ interface StoreSettingsApi {
|
||||
invoiceSeries?: string | null;
|
||||
autoInvoice: boolean;
|
||||
};
|
||||
inPost: {
|
||||
hasShipXToken: boolean;
|
||||
organizationId?: string | null;
|
||||
shipXBaseUrl?: string | null;
|
||||
hasGeowidgetPublicToken: boolean;
|
||||
hasGeowidgetSandboxToken: boolean;
|
||||
geowidgetConfig?: string | null;
|
||||
geowidgetSdkBaseUrl?: string | null;
|
||||
};
|
||||
dhl: {
|
||||
hasApiKey: boolean;
|
||||
hasApiSecret: boolean;
|
||||
apiBaseUrl?: string | null;
|
||||
widgetUrl?: string | null;
|
||||
};
|
||||
dpd: {
|
||||
login?: string | null;
|
||||
hasPassword: boolean;
|
||||
fid?: string | null;
|
||||
apiBaseUrl?: string | null;
|
||||
widgetUrl?: string | null;
|
||||
};
|
||||
payU: {
|
||||
useSandbox: boolean;
|
||||
posId?: string | null;
|
||||
clientId?: string | null;
|
||||
hasClientSecret: boolean;
|
||||
hasSecondKey: boolean;
|
||||
oauthUrl?: string | null;
|
||||
ordersUrl?: string | null;
|
||||
notifyUrl?: string | null;
|
||||
continueUrl?: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
@Component({
|
||||
@@ -77,12 +111,7 @@ export class SettingsAdminComponent implements OnInit {
|
||||
readonly section = signal<SettingsSection>('store');
|
||||
readonly loading = signal(false);
|
||||
readonly saving = signal(false);
|
||||
form: AdminSettings = {
|
||||
...DEFAULT_ADMIN_SETTINGS,
|
||||
allegro: { ...DEFAULT_ADMIN_SETTINGS.allegro },
|
||||
amazon: { ...DEFAULT_ADMIN_SETTINGS.amazon },
|
||||
ifirma: { ...DEFAULT_ADMIN_SETTINGS.ifirma },
|
||||
};
|
||||
form: AdminSettings = this.#cloneDefaults();
|
||||
|
||||
ngOnInit(): void {
|
||||
this.load();
|
||||
@@ -133,6 +162,19 @@ export class SettingsAdminComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
#cloneDefaults(): AdminSettings {
|
||||
return {
|
||||
...DEFAULT_ADMIN_SETTINGS,
|
||||
allegro: { ...DEFAULT_ADMIN_SETTINGS.allegro },
|
||||
amazon: { ...DEFAULT_ADMIN_SETTINGS.amazon },
|
||||
ifirma: { ...DEFAULT_ADMIN_SETTINGS.ifirma },
|
||||
inPost: { ...DEFAULT_ADMIN_SETTINGS.inPost },
|
||||
dhl: { ...DEFAULT_ADMIN_SETTINGS.dhl },
|
||||
dpd: { ...DEFAULT_ADMIN_SETTINGS.dpd },
|
||||
payU: { ...DEFAULT_ADMIN_SETTINGS.payU },
|
||||
};
|
||||
}
|
||||
|
||||
#fromApi(res: StoreSettingsApi): AdminSettings {
|
||||
return {
|
||||
storeName: res.storeName,
|
||||
@@ -153,6 +195,7 @@ export class SettingsAdminComponent implements OnInit {
|
||||
dhl: res.carriers.dhl,
|
||||
dpd: res.carriers.dpd,
|
||||
},
|
||||
shippingUseSandbox: res.shippingUseSandbox ?? true,
|
||||
emailFromName: res.emailFromName,
|
||||
emailFromAddress: res.emailFromAddress,
|
||||
emailReplyTo: res.emailReplyTo,
|
||||
@@ -182,6 +225,48 @@ export class SettingsAdminComponent implements OnInit {
|
||||
autoInvoice: res.ifirma?.autoInvoice ?? false,
|
||||
hasApiKey: res.ifirma?.hasApiKey ?? false,
|
||||
},
|
||||
inPost: {
|
||||
shipXToken: '',
|
||||
organizationId: res.inPost?.organizationId ?? '',
|
||||
shipXBaseUrl: res.inPost?.shipXBaseUrl ?? '',
|
||||
geowidgetPublicToken: '',
|
||||
geowidgetSandboxToken: '',
|
||||
geowidgetConfig: res.inPost?.geowidgetConfig ?? 'parcelCollect',
|
||||
geowidgetSdkBaseUrl: res.inPost?.geowidgetSdkBaseUrl ?? '',
|
||||
hasShipXToken: res.inPost?.hasShipXToken ?? false,
|
||||
hasGeowidgetPublicToken: res.inPost?.hasGeowidgetPublicToken ?? false,
|
||||
hasGeowidgetSandboxToken:
|
||||
res.inPost?.hasGeowidgetSandboxToken ?? false,
|
||||
},
|
||||
dhl: {
|
||||
apiKey: '',
|
||||
apiSecret: '',
|
||||
apiBaseUrl: res.dhl?.apiBaseUrl ?? '',
|
||||
widgetUrl: res.dhl?.widgetUrl ?? 'https://parcelshop.dhl.pl/mapa',
|
||||
hasApiKey: res.dhl?.hasApiKey ?? false,
|
||||
hasApiSecret: res.dhl?.hasApiSecret ?? false,
|
||||
},
|
||||
dpd: {
|
||||
login: res.dpd?.login ?? '',
|
||||
password: '',
|
||||
fid: res.dpd?.fid ?? '',
|
||||
apiBaseUrl: res.dpd?.apiBaseUrl ?? '',
|
||||
widgetUrl: res.dpd?.widgetUrl ?? 'https://pudofinder.dpd.com.pl/',
|
||||
hasPassword: res.dpd?.hasPassword ?? false,
|
||||
},
|
||||
payU: {
|
||||
useSandbox: res.payU?.useSandbox ?? true,
|
||||
posId: res.payU?.posId ?? '',
|
||||
clientId: res.payU?.clientId ?? '',
|
||||
clientSecret: '',
|
||||
secondKey: '',
|
||||
oauthUrl: res.payU?.oauthUrl ?? '',
|
||||
ordersUrl: res.payU?.ordersUrl ?? '',
|
||||
notifyUrl: res.payU?.notifyUrl ?? '',
|
||||
continueUrl: res.payU?.continueUrl ?? '',
|
||||
hasClientSecret: res.payU?.hasClientSecret ?? false,
|
||||
hasSecondKey: res.payU?.hasSecondKey ?? false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -196,6 +281,7 @@ export class SettingsAdminComponent implements OnInit {
|
||||
locales: form.locales,
|
||||
lowStockThreshold: form.lowStockThreshold,
|
||||
carriers: form.carriers,
|
||||
shippingUseSandbox: form.shippingUseSandbox,
|
||||
emailFromName: form.emailFromName,
|
||||
emailFromAddress: form.emailFromAddress,
|
||||
emailReplyTo: form.emailReplyTo,
|
||||
@@ -219,6 +305,39 @@ export class SettingsAdminComponent implements OnInit {
|
||||
invoiceSeries: form.ifirma.invoiceSeries || null,
|
||||
autoInvoice: form.ifirma.autoInvoice,
|
||||
},
|
||||
inPost: {
|
||||
shipXToken: form.inPost.shipXToken || null,
|
||||
organizationId: form.inPost.organizationId || null,
|
||||
shipXBaseUrl: form.inPost.shipXBaseUrl || null,
|
||||
geowidgetPublicToken: form.inPost.geowidgetPublicToken || null,
|
||||
geowidgetSandboxToken: form.inPost.geowidgetSandboxToken || null,
|
||||
geowidgetConfig: form.inPost.geowidgetConfig || null,
|
||||
geowidgetSdkBaseUrl: form.inPost.geowidgetSdkBaseUrl || null,
|
||||
},
|
||||
dhl: {
|
||||
apiKey: form.dhl.apiKey || null,
|
||||
apiSecret: form.dhl.apiSecret || null,
|
||||
apiBaseUrl: form.dhl.apiBaseUrl || null,
|
||||
widgetUrl: form.dhl.widgetUrl || null,
|
||||
},
|
||||
dpd: {
|
||||
login: form.dpd.login || null,
|
||||
password: form.dpd.password || null,
|
||||
fid: form.dpd.fid || null,
|
||||
apiBaseUrl: form.dpd.apiBaseUrl || null,
|
||||
widgetUrl: form.dpd.widgetUrl || null,
|
||||
},
|
||||
payU: {
|
||||
useSandbox: form.payU.useSandbox,
|
||||
posId: form.payU.posId || null,
|
||||
clientId: form.payU.clientId || null,
|
||||
clientSecret: form.payU.clientSecret || null,
|
||||
secondKey: form.payU.secondKey || null,
|
||||
oauthUrl: form.payU.oauthUrl || null,
|
||||
ordersUrl: form.payU.ordersUrl || null,
|
||||
notifyUrl: form.payU.notifyUrl || null,
|
||||
continueUrl: form.payU.continueUrl || null,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,51 @@ export interface IfirmaSettingsForm {
|
||||
hasApiKey: boolean;
|
||||
}
|
||||
|
||||
export interface InPostSettingsForm {
|
||||
shipXToken: string;
|
||||
organizationId: string;
|
||||
shipXBaseUrl: string;
|
||||
geowidgetPublicToken: string;
|
||||
geowidgetSandboxToken: string;
|
||||
geowidgetConfig: string;
|
||||
geowidgetSdkBaseUrl: string;
|
||||
hasShipXToken: boolean;
|
||||
hasGeowidgetPublicToken: boolean;
|
||||
hasGeowidgetSandboxToken: boolean;
|
||||
}
|
||||
|
||||
export interface DhlSettingsForm {
|
||||
apiKey: string;
|
||||
apiSecret: string;
|
||||
apiBaseUrl: string;
|
||||
widgetUrl: string;
|
||||
hasApiKey: boolean;
|
||||
hasApiSecret: boolean;
|
||||
}
|
||||
|
||||
export interface DpdSettingsForm {
|
||||
login: string;
|
||||
password: string;
|
||||
fid: string;
|
||||
apiBaseUrl: string;
|
||||
widgetUrl: string;
|
||||
hasPassword: boolean;
|
||||
}
|
||||
|
||||
export interface PayUSettingsForm {
|
||||
useSandbox: boolean;
|
||||
posId: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
secondKey: string;
|
||||
oauthUrl: string;
|
||||
ordersUrl: string;
|
||||
notifyUrl: string;
|
||||
continueUrl: string;
|
||||
hasClientSecret: boolean;
|
||||
hasSecondKey: boolean;
|
||||
}
|
||||
|
||||
export interface AdminSettings {
|
||||
storeName: string;
|
||||
supportEmail: string;
|
||||
@@ -40,12 +85,17 @@ export interface AdminSettings {
|
||||
locales: { pl: boolean; en: boolean; de: boolean; cs: boolean };
|
||||
lowStockThreshold: number;
|
||||
carriers: { inPost: boolean; dhl: boolean; dpd: boolean };
|
||||
shippingUseSandbox: boolean;
|
||||
emailFromName: string;
|
||||
emailFromAddress: string;
|
||||
emailReplyTo: string;
|
||||
allegro: AllegroSettingsForm;
|
||||
amazon: AmazonSettingsForm;
|
||||
ifirma: IfirmaSettingsForm;
|
||||
inPost: InPostSettingsForm;
|
||||
dhl: DhlSettingsForm;
|
||||
dpd: DpdSettingsForm;
|
||||
payU: PayUSettingsForm;
|
||||
}
|
||||
|
||||
export const DEFAULT_ADMIN_SETTINGS: AdminSettings = {
|
||||
@@ -58,6 +108,7 @@ export const DEFAULT_ADMIN_SETTINGS: AdminSettings = {
|
||||
locales: { pl: true, en: true, de: true, cs: true },
|
||||
lowStockThreshold: 5,
|
||||
carriers: { inPost: true, dhl: true, dpd: true },
|
||||
shippingUseSandbox: true,
|
||||
emailFromName: 'iLED',
|
||||
emailFromAddress: 'newsletter@iled.pl',
|
||||
emailReplyTo: 'hello@iled.pl',
|
||||
@@ -87,18 +138,54 @@ export const DEFAULT_ADMIN_SETTINGS: AdminSettings = {
|
||||
autoInvoice: false,
|
||||
hasApiKey: false,
|
||||
},
|
||||
inPost: {
|
||||
shipXToken: '',
|
||||
organizationId: '',
|
||||
shipXBaseUrl: '',
|
||||
geowidgetPublicToken: '',
|
||||
geowidgetSandboxToken: '',
|
||||
geowidgetConfig: 'parcelCollect',
|
||||
geowidgetSdkBaseUrl: '',
|
||||
hasShipXToken: false,
|
||||
hasGeowidgetPublicToken: false,
|
||||
hasGeowidgetSandboxToken: false,
|
||||
},
|
||||
dhl: {
|
||||
apiKey: '',
|
||||
apiSecret: '',
|
||||
apiBaseUrl: '',
|
||||
widgetUrl: 'https://parcelshop.dhl.pl/mapa',
|
||||
hasApiKey: false,
|
||||
hasApiSecret: false,
|
||||
},
|
||||
dpd: {
|
||||
login: '',
|
||||
password: '',
|
||||
fid: '',
|
||||
apiBaseUrl: '',
|
||||
widgetUrl: 'https://pudofinder.dpd.com.pl/',
|
||||
hasPassword: false,
|
||||
},
|
||||
payU: {
|
||||
useSandbox: true,
|
||||
posId: '',
|
||||
clientId: '',
|
||||
clientSecret: '',
|
||||
secondKey: '',
|
||||
oauthUrl: '',
|
||||
ordersUrl: '',
|
||||
notifyUrl: '',
|
||||
continueUrl: '',
|
||||
hasClientSecret: false,
|
||||
hasSecondKey: false,
|
||||
},
|
||||
};
|
||||
|
||||
export function loadAdminSettings(): AdminSettings {
|
||||
try {
|
||||
const raw = localStorage.getItem(ADMIN_SETTINGS_KEY);
|
||||
if (!raw) {
|
||||
return {
|
||||
...DEFAULT_ADMIN_SETTINGS,
|
||||
allegro: { ...DEFAULT_ADMIN_SETTINGS.allegro },
|
||||
amazon: { ...DEFAULT_ADMIN_SETTINGS.amazon },
|
||||
ifirma: { ...DEFAULT_ADMIN_SETTINGS.ifirma },
|
||||
};
|
||||
return cloneDefaults();
|
||||
}
|
||||
const parsed = JSON.parse(raw) as Partial<AdminSettings>;
|
||||
return {
|
||||
@@ -109,14 +196,13 @@ export function loadAdminSettings(): AdminSettings {
|
||||
allegro: { ...DEFAULT_ADMIN_SETTINGS.allegro, ...parsed.allegro },
|
||||
amazon: { ...DEFAULT_ADMIN_SETTINGS.amazon, ...parsed.amazon },
|
||||
ifirma: { ...DEFAULT_ADMIN_SETTINGS.ifirma, ...parsed.ifirma },
|
||||
inPost: { ...DEFAULT_ADMIN_SETTINGS.inPost, ...parsed.inPost },
|
||||
dhl: { ...DEFAULT_ADMIN_SETTINGS.dhl, ...parsed.dhl },
|
||||
dpd: { ...DEFAULT_ADMIN_SETTINGS.dpd, ...parsed.dpd },
|
||||
payU: { ...DEFAULT_ADMIN_SETTINGS.payU, ...parsed.payU },
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
...DEFAULT_ADMIN_SETTINGS,
|
||||
allegro: { ...DEFAULT_ADMIN_SETTINGS.allegro },
|
||||
amazon: { ...DEFAULT_ADMIN_SETTINGS.amazon },
|
||||
ifirma: { ...DEFAULT_ADMIN_SETTINGS.ifirma },
|
||||
};
|
||||
return cloneDefaults();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,3 +213,16 @@ export function saveAdminSettings(settings: AdminSettings): void {
|
||||
String(settings.lowStockThreshold),
|
||||
);
|
||||
}
|
||||
|
||||
function cloneDefaults(): AdminSettings {
|
||||
return {
|
||||
...DEFAULT_ADMIN_SETTINGS,
|
||||
allegro: { ...DEFAULT_ADMIN_SETTINGS.allegro },
|
||||
amazon: { ...DEFAULT_ADMIN_SETTINGS.amazon },
|
||||
ifirma: { ...DEFAULT_ADMIN_SETTINGS.ifirma },
|
||||
inPost: { ...DEFAULT_ADMIN_SETTINGS.inPost },
|
||||
dhl: { ...DEFAULT_ADMIN_SETTINGS.dhl },
|
||||
dpd: { ...DEFAULT_ADMIN_SETTINGS.dpd },
|
||||
payU: { ...DEFAULT_ADMIN_SETTINGS.payU },
|
||||
};
|
||||
}
|
||||
@@ -1775,21 +1775,42 @@ table.data tbody tr.is-selected {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.meta-icon,
|
||||
.channel-badge,
|
||||
.delivery-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border-radius: 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 1.4;
|
||||
line-height: 1;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-muted);
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.order-meta-icons--with-labels .meta-icon,
|
||||
.order-meta-icons--with-labels .channel-badge,
|
||||
.order-meta-icons--with-labels .delivery-badge {
|
||||
width: auto;
|
||||
height: auto;
|
||||
min-height: 28px;
|
||||
padding: 4px 10px 4px 8px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.meta-icon__label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.channel-badge[data-channel='shop'] {
|
||||
|
||||
@@ -48,6 +48,7 @@ public sealed class AdminProductsController(IMediator mediator) : ControllerBase
|
||||
|
||||
[HttpPost("images/upload")]
|
||||
[RequestSizeLimit(10 * 1024 * 1024)]
|
||||
[RequestFormLimits(MultipartBodyLengthLimit = 10 * 1024 * 1024)]
|
||||
public async Task<IActionResult> UploadImage(
|
||||
IFormFile file,
|
||||
CancellationToken cancellationToken)
|
||||
|
||||
@@ -47,6 +47,14 @@ builder.Services.AddAuthentication(options =>
|
||||
});
|
||||
|
||||
builder.Services.AddAuthorization();
|
||||
builder.Services.Configure<Microsoft.AspNetCore.Http.Features.FormOptions>(options =>
|
||||
{
|
||||
options.MultipartBodyLengthLimit = 10 * 1024 * 1024;
|
||||
});
|
||||
builder.WebHost.ConfigureKestrel(options =>
|
||||
{
|
||||
options.Limits.MaxRequestBodySize = 10 * 1024 * 1024;
|
||||
});
|
||||
builder.Services.AddControllers()
|
||||
.AddJsonOptions(options =>
|
||||
{
|
||||
|
||||
@@ -58,79 +58,87 @@ public sealed class GetAnalyticsQueryHandler(IAppDbContext db)
|
||||
var start = end.AddDays(-days);
|
||||
var prevStart = start.AddDays(-days);
|
||||
|
||||
var orders = db.Orders
|
||||
var baseQuery = db.Orders
|
||||
.AsNoTracking()
|
||||
.Where(o => !Excluded.Contains(o.Status));
|
||||
|
||||
if (request.Source.HasValue)
|
||||
{
|
||||
orders = orders.Where(o => o.Source == request.Source.Value);
|
||||
baseQuery = baseQuery.Where(o => o.Source == request.Source.Value);
|
||||
}
|
||||
|
||||
var current = await AggregateAsync(
|
||||
orders.Where(o => o.CreatedAt >= start && o.CreatedAt < end),
|
||||
cancellationToken);
|
||||
|
||||
var previous = await AggregateAsync(
|
||||
orders.Where(o => o.CreatedAt >= prevStart && o.CreatedAt < start),
|
||||
cancellationToken);
|
||||
|
||||
var trendRaw = await orders
|
||||
var currentRows = await baseQuery
|
||||
.Where(o => o.CreatedAt >= start && o.CreatedAt < end)
|
||||
.GroupBy(o => o.CreatedAt.Date)
|
||||
.Select(g => new
|
||||
{
|
||||
Date = g.Key,
|
||||
Revenue = g.Sum(o => o.Total),
|
||||
Orders = g.Count(),
|
||||
})
|
||||
.Select(o => new OrderAggRow(
|
||||
o.Id,
|
||||
o.CreatedAt,
|
||||
o.Total,
|
||||
o.Source))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var trendLookup = trendRaw.ToDictionary(x => x.Date);
|
||||
var previousRows = await baseQuery
|
||||
.Where(o => o.CreatedAt >= prevStart && o.CreatedAt < start)
|
||||
.Select(o => new OrderAggRow(
|
||||
o.Id,
|
||||
o.CreatedAt,
|
||||
o.Total,
|
||||
o.Source))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var currentOrderIds = currentRows.Select(r => r.Id).ToList();
|
||||
var previousOrderIds = previousRows.Select(r => r.Id).ToList();
|
||||
|
||||
var currentUnits = await SumUnitsAsync(
|
||||
currentOrderIds,
|
||||
cancellationToken);
|
||||
var previousUnits = await SumUnitsAsync(
|
||||
previousOrderIds,
|
||||
cancellationToken);
|
||||
|
||||
var current = ToKpi(currentRows, currentUnits);
|
||||
var previous = ToKpi(previousRows, previousUnits);
|
||||
|
||||
// Group by calendar day in-memory — Npgsql cannot reliably
|
||||
// translate CreatedAt.Date for timestamp with time zone.
|
||||
var trendLookup = currentRows
|
||||
.GroupBy(r => DateOnly.FromDateTime(r.CreatedAt.ToUniversalTime()))
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => (Revenue: g.Sum(x => x.Total), Orders: g.Count()));
|
||||
|
||||
var trend = new List<AnalyticsTrendPointDto>(days);
|
||||
for (var d = start; d < end; d = d.AddDays(1))
|
||||
for (var d = DateOnly.FromDateTime(start); d < DateOnly.FromDateTime(end); d = d.AddDays(1))
|
||||
{
|
||||
trendLookup.TryGetValue(d, out var row);
|
||||
trend.Add(new AnalyticsTrendPointDto(
|
||||
d.ToString("yyyy-MM-dd"),
|
||||
row?.Revenue ?? 0m,
|
||||
row?.Orders ?? 0));
|
||||
row.Revenue,
|
||||
row.Orders));
|
||||
}
|
||||
|
||||
var paidOrderIds = await orders
|
||||
.Where(o => o.CreatedAt >= start && o.CreatedAt < end)
|
||||
.Select(o => o.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var topRaw = await db.Orders
|
||||
.AsNoTracking()
|
||||
.Where(o => paidOrderIds.Contains(o.Id))
|
||||
.SelectMany(o => o.Lines)
|
||||
.GroupBy(l => new { l.ProductVariantId, l.ProductName, l.Sku })
|
||||
.Select(g => new
|
||||
{
|
||||
g.Key.ProductName,
|
||||
g.Key.Sku,
|
||||
Units = g.Sum(l => l.Quantity),
|
||||
Revenue = g.Sum(l => l.UnitPrice * l.Quantity),
|
||||
VariantId = g.Key.ProductVariantId,
|
||||
})
|
||||
.OrderByDescending(x => x.Revenue)
|
||||
.Take(10)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var variantIds = topRaw.Select(t => t.VariantId).ToList();
|
||||
var productIds = await db.ProductVariants
|
||||
.AsNoTracking()
|
||||
.Where(v => variantIds.Contains(v.Id))
|
||||
.Select(v => new { v.Id, v.ProductId })
|
||||
.ToListAsync(cancellationToken);
|
||||
var productLookup = productIds.ToDictionary(v => v.Id, v => v.ProductId);
|
||||
var topRaw = currentOrderIds.Count == 0
|
||||
? []
|
||||
: await db.Orders
|
||||
.AsNoTracking()
|
||||
.Where(o => currentOrderIds.Contains(o.Id))
|
||||
.SelectMany(o => o.Lines)
|
||||
.GroupBy(l => new { l.ProductId, l.ProductName, l.Sku })
|
||||
.Select(g => new
|
||||
{
|
||||
g.Key.ProductId,
|
||||
g.Key.ProductName,
|
||||
g.Key.Sku,
|
||||
Units = g.Sum(l => l.Quantity),
|
||||
Revenue = g.Sum(l => l.UnitPrice * l.Quantity),
|
||||
})
|
||||
.OrderByDescending(x => x.Revenue)
|
||||
.Take(10)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var totalRevenue = current.Revenue == 0 ? 1m : current.Revenue;
|
||||
var topProducts = topRaw
|
||||
.Select(t => new AnalyticsTopProductDto(
|
||||
productLookup.GetValueOrDefault(t.VariantId),
|
||||
t.ProductId,
|
||||
t.ProductName,
|
||||
t.Sku,
|
||||
t.Units,
|
||||
@@ -138,15 +146,14 @@ public sealed class GetAnalyticsQueryHandler(IAppDbContext db)
|
||||
Math.Round(t.Revenue / totalRevenue * 100m, 1)))
|
||||
.ToList();
|
||||
|
||||
var byChannel = await orders
|
||||
.Where(o => o.CreatedAt >= start && o.CreatedAt < end)
|
||||
.GroupBy(o => o.Source)
|
||||
var byChannel = currentRows
|
||||
.GroupBy(r => r.Source)
|
||||
.Select(g => new AnalyticsChannelBreakdownDto(
|
||||
g.Key,
|
||||
g.Count(),
|
||||
g.Sum(o => o.Total)))
|
||||
g.Sum(x => x.Total)))
|
||||
.OrderByDescending(x => x.Revenue)
|
||||
.ToListAsync(cancellationToken);
|
||||
.ToList();
|
||||
|
||||
return new AnalyticsDto(
|
||||
current,
|
||||
@@ -156,20 +163,35 @@ public sealed class GetAnalyticsQueryHandler(IAppDbContext db)
|
||||
byChannel);
|
||||
}
|
||||
|
||||
private static async Task<AnalyticsKpiDto> AggregateAsync(
|
||||
IQueryable<Domain.Orders.Order> query,
|
||||
private async Task<int> SumUnitsAsync(
|
||||
IReadOnlyList<Guid> orderIds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var orders = await query.CountAsync(cancellationToken);
|
||||
var revenue = orders == 0
|
||||
? 0m
|
||||
: await query.SumAsync(o => o.Total, cancellationToken);
|
||||
var units = orders == 0
|
||||
? 0
|
||||
: await query.SelectMany(o => o.Lines)
|
||||
.SumAsync(l => l.Quantity, cancellationToken);
|
||||
var aov = orders == 0 ? 0m : Math.Round(revenue / orders, 2);
|
||||
if (orderIds.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return await db.Orders
|
||||
.AsNoTracking()
|
||||
.Where(o => orderIds.Contains(o.Id))
|
||||
.SelectMany(o => o.Lines)
|
||||
.SumAsync(l => (int?)l.Quantity, cancellationToken) ?? 0;
|
||||
}
|
||||
|
||||
private static AnalyticsKpiDto ToKpi(
|
||||
IReadOnlyList<OrderAggRow> rows,
|
||||
int units)
|
||||
{
|
||||
var orders = rows.Count;
|
||||
var revenue = orders == 0 ? 0m : rows.Sum(r => r.Total);
|
||||
var aov = orders == 0 ? 0m : Math.Round(revenue / orders, 2);
|
||||
return new AnalyticsKpiDto(revenue, orders, aov, units);
|
||||
}
|
||||
|
||||
private sealed record OrderAggRow(
|
||||
Guid Id,
|
||||
DateTime CreatedAt,
|
||||
decimal Total,
|
||||
OrderSource Source);
|
||||
}
|
||||
@@ -20,7 +20,12 @@ public sealed record StoreSettingsDto(
|
||||
string EmailReplyTo,
|
||||
AllegroSettingsDto Allegro,
|
||||
AmazonSettingsDto Amazon,
|
||||
IfirmaSettingsDto Ifirma);
|
||||
IfirmaSettingsDto Ifirma,
|
||||
InPostSettingsDto InPost,
|
||||
DhlSettingsDto Dhl,
|
||||
DpdSettingsDto Dpd,
|
||||
PayUSettingsDto PayU,
|
||||
bool ShippingUseSandbox);
|
||||
|
||||
public sealed record LocaleFlagsDto(bool Pl, bool En, bool De, bool Cs);
|
||||
|
||||
@@ -48,6 +53,39 @@ public sealed record IfirmaSettingsDto(
|
||||
string? InvoiceSeries,
|
||||
bool AutoInvoice);
|
||||
|
||||
public sealed record InPostSettingsDto(
|
||||
bool HasShipXToken,
|
||||
string? OrganizationId,
|
||||
string? ShipXBaseUrl,
|
||||
bool HasGeowidgetPublicToken,
|
||||
bool HasGeowidgetSandboxToken,
|
||||
string? GeowidgetConfig,
|
||||
string? GeowidgetSdkBaseUrl);
|
||||
|
||||
public sealed record DhlSettingsDto(
|
||||
bool HasApiKey,
|
||||
bool HasApiSecret,
|
||||
string? ApiBaseUrl,
|
||||
string? WidgetUrl);
|
||||
|
||||
public sealed record DpdSettingsDto(
|
||||
string? Login,
|
||||
bool HasPassword,
|
||||
string? Fid,
|
||||
string? ApiBaseUrl,
|
||||
string? WidgetUrl);
|
||||
|
||||
public sealed record PayUSettingsDto(
|
||||
bool UseSandbox,
|
||||
string? PosId,
|
||||
string? ClientId,
|
||||
bool HasClientSecret,
|
||||
bool HasSecondKey,
|
||||
string? OauthUrl,
|
||||
string? OrdersUrl,
|
||||
string? NotifyUrl,
|
||||
string? ContinueUrl);
|
||||
|
||||
public sealed record GetStoreSettingsQuery() : IRequest<StoreSettingsDto>;
|
||||
|
||||
public sealed class GetStoreSettingsQueryHandler(IAppDbContext db)
|
||||
@@ -98,7 +136,37 @@ public sealed class GetStoreSettingsQueryHandler(IAppDbContext db)
|
||||
s.IfirmaLogin,
|
||||
!string.IsNullOrEmpty(s.IfirmaApiKey),
|
||||
s.IfirmaInvoiceSeries,
|
||||
s.IfirmaAutoInvoice));
|
||||
s.IfirmaAutoInvoice),
|
||||
new InPostSettingsDto(
|
||||
!string.IsNullOrEmpty(s.InPostShipXToken),
|
||||
s.InPostOrganizationId,
|
||||
s.InPostShipXBaseUrl,
|
||||
!string.IsNullOrEmpty(s.InPostGeowidgetPublicToken),
|
||||
!string.IsNullOrEmpty(s.InPostGeowidgetSandboxToken),
|
||||
s.InPostGeowidgetConfig,
|
||||
s.InPostGeowidgetSdkBaseUrl),
|
||||
new DhlSettingsDto(
|
||||
!string.IsNullOrEmpty(s.DhlApiKey),
|
||||
!string.IsNullOrEmpty(s.DhlApiSecret),
|
||||
s.DhlApiBaseUrl,
|
||||
s.DhlWidgetUrl),
|
||||
new DpdSettingsDto(
|
||||
s.DpdLogin,
|
||||
!string.IsNullOrEmpty(s.DpdPassword),
|
||||
s.DpdFid,
|
||||
s.DpdApiBaseUrl,
|
||||
s.DpdWidgetUrl),
|
||||
new PayUSettingsDto(
|
||||
s.PayUUseSandbox,
|
||||
s.PayUPosId,
|
||||
s.PayUClientId,
|
||||
!string.IsNullOrEmpty(s.PayUClientSecret),
|
||||
!string.IsNullOrEmpty(s.PayUSecondKey),
|
||||
s.PayUOauthUrl,
|
||||
s.PayUOrdersUrl,
|
||||
s.PayUNotifyUrl,
|
||||
s.PayUContinueUrl),
|
||||
s.ShippingUseSandbox);
|
||||
}
|
||||
|
||||
public sealed record UpdateStoreSettingsCommand(
|
||||
@@ -116,7 +184,12 @@ public sealed record UpdateStoreSettingsCommand(
|
||||
string EmailReplyTo,
|
||||
UpdateAllegroSettingsDto? Allegro,
|
||||
UpdateAmazonSettingsDto? Amazon,
|
||||
UpdateIfirmaSettingsDto? Ifirma) : IRequest<StoreSettingsDto>;
|
||||
UpdateIfirmaSettingsDto? Ifirma,
|
||||
UpdateInPostSettingsDto? InPost,
|
||||
UpdateDhlSettingsDto? Dhl,
|
||||
UpdateDpdSettingsDto? Dpd,
|
||||
UpdatePayUSettingsDto? PayU,
|
||||
bool ShippingUseSandbox) : IRequest<StoreSettingsDto>;
|
||||
|
||||
public sealed record UpdateAllegroSettingsDto(
|
||||
bool Enabled,
|
||||
@@ -138,6 +211,39 @@ public sealed record UpdateIfirmaSettingsDto(
|
||||
string? InvoiceSeries,
|
||||
bool AutoInvoice);
|
||||
|
||||
public sealed record UpdateInPostSettingsDto(
|
||||
string? ShipXToken,
|
||||
string? OrganizationId,
|
||||
string? ShipXBaseUrl,
|
||||
string? GeowidgetPublicToken,
|
||||
string? GeowidgetSandboxToken,
|
||||
string? GeowidgetConfig,
|
||||
string? GeowidgetSdkBaseUrl);
|
||||
|
||||
public sealed record UpdateDhlSettingsDto(
|
||||
string? ApiKey,
|
||||
string? ApiSecret,
|
||||
string? ApiBaseUrl,
|
||||
string? WidgetUrl);
|
||||
|
||||
public sealed record UpdateDpdSettingsDto(
|
||||
string? Login,
|
||||
string? Password,
|
||||
string? Fid,
|
||||
string? ApiBaseUrl,
|
||||
string? WidgetUrl);
|
||||
|
||||
public sealed record UpdatePayUSettingsDto(
|
||||
bool UseSandbox,
|
||||
string? PosId,
|
||||
string? ClientId,
|
||||
string? ClientSecret,
|
||||
string? SecondKey,
|
||||
string? OauthUrl,
|
||||
string? OrdersUrl,
|
||||
string? NotifyUrl,
|
||||
string? ContinueUrl);
|
||||
|
||||
public sealed class UpdateStoreSettingsCommandHandler(IAppDbContext db)
|
||||
: IRequestHandler<UpdateStoreSettingsCommand, StoreSettingsDto>
|
||||
{
|
||||
@@ -175,16 +281,14 @@ public sealed class UpdateStoreSettingsCommandHandler(IAppDbContext db)
|
||||
row.EmailFromName = request.EmailFromName.Trim();
|
||||
row.EmailFromAddress = request.EmailFromAddress.Trim();
|
||||
row.EmailReplyTo = request.EmailReplyTo.Trim();
|
||||
row.ShippingUseSandbox = request.ShippingUseSandbox;
|
||||
|
||||
if (request.Allegro is not null)
|
||||
{
|
||||
row.AllegroEnabled = request.Allegro.Enabled;
|
||||
row.AllegroClientId = TrimOrNull(request.Allegro.ClientId);
|
||||
row.AllegroSellerId = TrimOrNull(request.Allegro.SellerId);
|
||||
if (!string.IsNullOrWhiteSpace(request.Allegro.ClientSecret))
|
||||
{
|
||||
row.AllegroClientSecret = request.Allegro.ClientSecret.Trim();
|
||||
}
|
||||
SetSecret(request.Allegro.ClientSecret, v => row.AllegroClientSecret = v);
|
||||
}
|
||||
|
||||
if (request.Amazon is not null)
|
||||
@@ -192,15 +296,8 @@ public sealed class UpdateStoreSettingsCommandHandler(IAppDbContext db)
|
||||
row.AmazonEnabled = request.Amazon.Enabled;
|
||||
row.AmazonSellerId = TrimOrNull(request.Amazon.SellerId);
|
||||
row.AmazonMarketplaceId = TrimOrNull(request.Amazon.MarketplaceId);
|
||||
if (!string.IsNullOrWhiteSpace(request.Amazon.AccessKey))
|
||||
{
|
||||
row.AmazonAccessKey = request.Amazon.AccessKey.Trim();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Amazon.SecretKey))
|
||||
{
|
||||
row.AmazonSecretKey = request.Amazon.SecretKey.Trim();
|
||||
}
|
||||
SetSecret(request.Amazon.AccessKey, v => row.AmazonAccessKey = v);
|
||||
SetSecret(request.Amazon.SecretKey, v => row.AmazonSecretKey = v);
|
||||
}
|
||||
|
||||
if (request.Ifirma is not null)
|
||||
@@ -209,10 +306,52 @@ public sealed class UpdateStoreSettingsCommandHandler(IAppDbContext db)
|
||||
row.IfirmaLogin = TrimOrNull(request.Ifirma.Login);
|
||||
row.IfirmaInvoiceSeries = TrimOrNull(request.Ifirma.InvoiceSeries);
|
||||
row.IfirmaAutoInvoice = request.Ifirma.AutoInvoice;
|
||||
if (!string.IsNullOrWhiteSpace(request.Ifirma.ApiKey))
|
||||
{
|
||||
row.IfirmaApiKey = request.Ifirma.ApiKey.Trim();
|
||||
}
|
||||
SetSecret(request.Ifirma.ApiKey, v => row.IfirmaApiKey = v);
|
||||
}
|
||||
|
||||
if (request.InPost is not null)
|
||||
{
|
||||
row.InPostOrganizationId = TrimOrNull(request.InPost.OrganizationId);
|
||||
row.InPostShipXBaseUrl = TrimOrNull(request.InPost.ShipXBaseUrl);
|
||||
row.InPostGeowidgetConfig = TrimOrNull(request.InPost.GeowidgetConfig);
|
||||
row.InPostGeowidgetSdkBaseUrl = TrimOrNull(request.InPost.GeowidgetSdkBaseUrl);
|
||||
SetSecret(request.InPost.ShipXToken, v => row.InPostShipXToken = v);
|
||||
SetSecret(
|
||||
request.InPost.GeowidgetPublicToken,
|
||||
v => row.InPostGeowidgetPublicToken = v);
|
||||
SetSecret(
|
||||
request.InPost.GeowidgetSandboxToken,
|
||||
v => row.InPostGeowidgetSandboxToken = v);
|
||||
}
|
||||
|
||||
if (request.Dhl is not null)
|
||||
{
|
||||
row.DhlApiBaseUrl = TrimOrNull(request.Dhl.ApiBaseUrl);
|
||||
row.DhlWidgetUrl = TrimOrNull(request.Dhl.WidgetUrl);
|
||||
SetSecret(request.Dhl.ApiKey, v => row.DhlApiKey = v);
|
||||
SetSecret(request.Dhl.ApiSecret, v => row.DhlApiSecret = v);
|
||||
}
|
||||
|
||||
if (request.Dpd is not null)
|
||||
{
|
||||
row.DpdLogin = TrimOrNull(request.Dpd.Login);
|
||||
row.DpdFid = TrimOrNull(request.Dpd.Fid);
|
||||
row.DpdApiBaseUrl = TrimOrNull(request.Dpd.ApiBaseUrl);
|
||||
row.DpdWidgetUrl = TrimOrNull(request.Dpd.WidgetUrl);
|
||||
SetSecret(request.Dpd.Password, v => row.DpdPassword = v);
|
||||
}
|
||||
|
||||
if (request.PayU is not null)
|
||||
{
|
||||
row.PayUUseSandbox = request.PayU.UseSandbox;
|
||||
row.PayUPosId = TrimOrNull(request.PayU.PosId);
|
||||
row.PayUClientId = TrimOrNull(request.PayU.ClientId);
|
||||
row.PayUOauthUrl = TrimOrNull(request.PayU.OauthUrl);
|
||||
row.PayUOrdersUrl = TrimOrNull(request.PayU.OrdersUrl);
|
||||
row.PayUNotifyUrl = TrimOrNull(request.PayU.NotifyUrl);
|
||||
row.PayUContinueUrl = TrimOrNull(request.PayU.ContinueUrl);
|
||||
SetSecret(request.PayU.ClientSecret, v => row.PayUClientSecret = v);
|
||||
SetSecret(request.PayU.SecondKey, v => row.PayUSecondKey = v);
|
||||
}
|
||||
|
||||
row.UpdatedAt = DateTime.UtcNow;
|
||||
@@ -244,4 +383,12 @@ public sealed class UpdateStoreSettingsCommandHandler(IAppDbContext db)
|
||||
|
||||
private static string? TrimOrNull(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private static void SetSecret(string? incoming, Action<string> assign)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(incoming))
|
||||
{
|
||||
assign(incoming.Trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
namespace iLED.Application.Common.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Runtime PayU + carrier credentials from StoreSettings,
|
||||
/// falling back to appsettings when a DB field is empty.
|
||||
/// </summary>
|
||||
public interface IIntegrationSettingsAccessor
|
||||
{
|
||||
Task<IntegrationRuntimeSettings> GetAsync(
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed class IntegrationRuntimeSettings
|
||||
{
|
||||
public bool ShippingUseSandbox { get; init; } = true;
|
||||
|
||||
public bool CarrierInPost { get; init; } = true;
|
||||
|
||||
public bool CarrierDhl { get; init; } = true;
|
||||
|
||||
public bool CarrierDpd { get; init; } = true;
|
||||
|
||||
public string? InPostShipXToken { get; init; }
|
||||
|
||||
public string? InPostOrganizationId { get; init; }
|
||||
|
||||
public string? InPostShipXBaseUrl { get; init; }
|
||||
|
||||
public string? InPostGeowidgetPublicToken { get; init; }
|
||||
|
||||
public string? InPostGeowidgetSandboxToken { get; init; }
|
||||
|
||||
public string? InPostGeowidgetConfig { get; init; }
|
||||
|
||||
public string? InPostGeowidgetSdkBaseUrl { get; init; }
|
||||
|
||||
public string? DhlApiKey { get; init; }
|
||||
|
||||
public string? DhlApiSecret { get; init; }
|
||||
|
||||
public string? DhlApiBaseUrl { get; init; }
|
||||
|
||||
public string? DhlWidgetUrl { get; init; }
|
||||
|
||||
public string? DpdLogin { get; init; }
|
||||
|
||||
public string? DpdPassword { get; init; }
|
||||
|
||||
public string? DpdFid { get; init; }
|
||||
|
||||
public string? DpdApiBaseUrl { get; init; }
|
||||
|
||||
public string? DpdWidgetUrl { get; init; }
|
||||
|
||||
public bool PayUUseSandbox { get; init; } = true;
|
||||
|
||||
public string? PayUPosId { get; init; }
|
||||
|
||||
public string? PayUClientId { get; init; }
|
||||
|
||||
public string? PayUClientSecret { get; init; }
|
||||
|
||||
public string? PayUSecondKey { get; init; }
|
||||
|
||||
public string? PayUOauthUrl { get; init; }
|
||||
|
||||
public string? PayUOrdersUrl { get; init; }
|
||||
|
||||
public string? PayUNotifyUrl { get; init; }
|
||||
|
||||
public string? PayUContinueUrl { get; init; }
|
||||
}
|
||||
@@ -12,7 +12,8 @@ public interface IShippingService
|
||||
ShipmentRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
ShippingWidgetConfigDto GetWidgetConfig();
|
||||
Task<ShippingWidgetConfigDto> GetWidgetConfigAsync(
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed record ShippingParcelPointSearch(
|
||||
|
||||
@@ -17,7 +17,8 @@ public sealed class InitiatePaymentCommandHandler(
|
||||
IOrderService orderService,
|
||||
IPaymentService paymentService,
|
||||
IAppDbContext db,
|
||||
IConfiguration configuration)
|
||||
IConfiguration configuration,
|
||||
IIntegrationSettingsAccessor integrationSettings)
|
||||
: IRequestHandler<InitiatePaymentCommand, PaymentResultDto>
|
||||
{
|
||||
public async Task<PaymentResultDto> Handle(
|
||||
@@ -27,10 +28,11 @@ public sealed class InitiatePaymentCommandHandler(
|
||||
var order = await orderService.GetOrderAsync(
|
||||
request.OrderId, cancellationToken);
|
||||
|
||||
var settings = await integrationSettings.GetAsync(cancellationToken);
|
||||
var shopBase = configuration["Seo:ShopBaseUrl"] ?? "http://localhost:4200";
|
||||
var notifyUrl = configuration["PayU:NotifyUrl"]
|
||||
var notifyUrl = settings.PayUNotifyUrl
|
||||
?? "http://localhost:5000/api/v1/payments/webhook";
|
||||
var continueBase = configuration["PayU:ContinueUrl"] ?? $"{shopBase}/order";
|
||||
var continueBase = settings.PayUContinueUrl ?? $"{shopBase}/order";
|
||||
|
||||
var paymentRequest = new PaymentInitiationRequest(
|
||||
order.Id,
|
||||
@@ -69,14 +71,15 @@ public sealed record CompleteSandboxPaymentCommand(Guid OrderId) : IRequest<Paym
|
||||
public sealed class CompleteSandboxPaymentCommandHandler(
|
||||
IOrderService orderService,
|
||||
IAppDbContext db,
|
||||
IConfiguration configuration)
|
||||
IIntegrationSettingsAccessor integrationSettings)
|
||||
: IRequestHandler<CompleteSandboxPaymentCommand, PaymentResultDto>
|
||||
{
|
||||
public async Task<PaymentResultDto> Handle(
|
||||
CompleteSandboxPaymentCommand request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!configuration.GetValue("PayU:UseSandbox", true))
|
||||
var settings = await integrationSettings.GetAsync(cancellationToken);
|
||||
if (!settings.PayUUseSandbox)
|
||||
{
|
||||
return new PaymentResultDto(
|
||||
false,
|
||||
|
||||
@@ -37,7 +37,7 @@ public sealed class GetShippingWidgetConfigQueryHandler(IShippingService shippin
|
||||
public Task<ShippingWidgetConfigDto> Handle(
|
||||
GetShippingWidgetConfigQuery request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult(shipping.GetWidgetConfig());
|
||||
shipping.GetWidgetConfigAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public sealed record CreateShipmentLabelCommand(Guid OrderId)
|
||||
|
||||
@@ -73,4 +73,58 @@ public class StoreSettings : BaseEntity
|
||||
public string? IfirmaInvoiceSeries { get; set; }
|
||||
|
||||
public bool IfirmaAutoInvoice { get; set; }
|
||||
|
||||
// ── Shipping credentials ─────────────────────────────────
|
||||
public bool ShippingUseSandbox { get; set; } = true;
|
||||
|
||||
public string? InPostShipXToken { get; set; }
|
||||
|
||||
public string? InPostOrganizationId { get; set; }
|
||||
|
||||
public string? InPostShipXBaseUrl { get; set; }
|
||||
|
||||
public string? InPostGeowidgetPublicToken { get; set; }
|
||||
|
||||
public string? InPostGeowidgetSandboxToken { get; set; }
|
||||
|
||||
public string? InPostGeowidgetConfig { get; set; }
|
||||
|
||||
public string? InPostGeowidgetSdkBaseUrl { get; set; }
|
||||
|
||||
public string? DhlApiKey { get; set; }
|
||||
|
||||
public string? DhlApiSecret { get; set; }
|
||||
|
||||
public string? DhlApiBaseUrl { get; set; }
|
||||
|
||||
public string? DhlWidgetUrl { get; set; }
|
||||
|
||||
public string? DpdLogin { get; set; }
|
||||
|
||||
public string? DpdPassword { get; set; }
|
||||
|
||||
public string? DpdFid { get; set; }
|
||||
|
||||
public string? DpdApiBaseUrl { get; set; }
|
||||
|
||||
public string? DpdWidgetUrl { get; set; }
|
||||
|
||||
// ── PayU ─────────────────────────────────────────────────
|
||||
public bool PayUUseSandbox { get; set; } = true;
|
||||
|
||||
public string? PayUPosId { get; set; }
|
||||
|
||||
public string? PayUClientId { get; set; }
|
||||
|
||||
public string? PayUClientSecret { get; set; }
|
||||
|
||||
public string? PayUSecondKey { get; set; }
|
||||
|
||||
public string? PayUOauthUrl { get; set; }
|
||||
|
||||
public string? PayUOrdersUrl { get; set; }
|
||||
|
||||
public string? PayUNotifyUrl { get; set; }
|
||||
|
||||
public string? PayUContinueUrl { get; set; }
|
||||
}
|
||||
@@ -56,6 +56,7 @@ public static class DependencyInjection
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(60);
|
||||
});
|
||||
services.AddScoped<IIntegrationSettingsAccessor, IntegrationSettingsAccessor>();
|
||||
services.AddScoped<IPaymentService, PayUPaymentService>();
|
||||
services.AddHostedService<PayUPaymentSyncBackgroundService>();
|
||||
services.AddScoped<ShippingCarrierClients>();
|
||||
|
||||
@@ -213,6 +213,33 @@ namespace iLED.Infrastructure.Migrations
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("DhlApiBaseUrl")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("DhlApiKey")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("DhlApiSecret")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("DhlWidgetUrl")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("DpdApiBaseUrl")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("DpdFid")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("DpdLogin")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("DpdPassword")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("DpdWidgetUrl")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("EmailFromAddress")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
@@ -240,6 +267,27 @@ namespace iLED.Infrastructure.Migrations
|
||||
b.Property<string>("IfirmaLogin")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("InPostGeowidgetConfig")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("InPostGeowidgetPublicToken")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("InPostGeowidgetSandboxToken")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("InPostGeowidgetSdkBaseUrl")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("InPostOrganizationId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("InPostShipXBaseUrl")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("InPostShipXToken")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("LegalName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
@@ -259,6 +307,36 @@ namespace iLED.Infrastructure.Migrations
|
||||
b.Property<int>("LowStockThreshold")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("PayUClientId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PayUClientSecret")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PayUContinueUrl")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PayUNotifyUrl")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PayUOauthUrl")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PayUOrdersUrl")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PayUPosId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PayUSecondKey")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("PayUUseSandbox")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("ShippingUseSandbox")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("StoreName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
+2229
File diff suppressed because it is too large.
Load diff
+280
@@ -0,0 +1,280 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace iLED.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class ShippingPayUIntegrations : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "DhlApiBaseUrl",
|
||||
table: "StoreSettings",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "DhlApiKey",
|
||||
table: "StoreSettings",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "DhlApiSecret",
|
||||
table: "StoreSettings",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "DhlWidgetUrl",
|
||||
table: "StoreSettings",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "DpdApiBaseUrl",
|
||||
table: "StoreSettings",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "DpdFid",
|
||||
table: "StoreSettings",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "DpdLogin",
|
||||
table: "StoreSettings",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "DpdPassword",
|
||||
table: "StoreSettings",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "DpdWidgetUrl",
|
||||
table: "StoreSettings",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "InPostGeowidgetConfig",
|
||||
table: "StoreSettings",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "InPostGeowidgetPublicToken",
|
||||
table: "StoreSettings",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "InPostGeowidgetSandboxToken",
|
||||
table: "StoreSettings",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "InPostGeowidgetSdkBaseUrl",
|
||||
table: "StoreSettings",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "InPostOrganizationId",
|
||||
table: "StoreSettings",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "InPostShipXBaseUrl",
|
||||
table: "StoreSettings",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "InPostShipXToken",
|
||||
table: "StoreSettings",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "PayUClientId",
|
||||
table: "StoreSettings",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "PayUClientSecret",
|
||||
table: "StoreSettings",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "PayUContinueUrl",
|
||||
table: "StoreSettings",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "PayUNotifyUrl",
|
||||
table: "StoreSettings",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "PayUOauthUrl",
|
||||
table: "StoreSettings",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "PayUOrdersUrl",
|
||||
table: "StoreSettings",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "PayUPosId",
|
||||
table: "StoreSettings",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "PayUSecondKey",
|
||||
table: "StoreSettings",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "PayUUseSandbox",
|
||||
table: "StoreSettings",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "ShippingUseSandbox",
|
||||
table: "StoreSettings",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DhlApiBaseUrl",
|
||||
table: "StoreSettings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DhlApiKey",
|
||||
table: "StoreSettings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DhlApiSecret",
|
||||
table: "StoreSettings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DhlWidgetUrl",
|
||||
table: "StoreSettings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DpdApiBaseUrl",
|
||||
table: "StoreSettings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DpdFid",
|
||||
table: "StoreSettings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DpdLogin",
|
||||
table: "StoreSettings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DpdPassword",
|
||||
table: "StoreSettings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DpdWidgetUrl",
|
||||
table: "StoreSettings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "InPostGeowidgetConfig",
|
||||
table: "StoreSettings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "InPostGeowidgetPublicToken",
|
||||
table: "StoreSettings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "InPostGeowidgetSandboxToken",
|
||||
table: "StoreSettings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "InPostGeowidgetSdkBaseUrl",
|
||||
table: "StoreSettings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "InPostOrganizationId",
|
||||
table: "StoreSettings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "InPostShipXBaseUrl",
|
||||
table: "StoreSettings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "InPostShipXToken",
|
||||
table: "StoreSettings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PayUClientId",
|
||||
table: "StoreSettings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PayUClientSecret",
|
||||
table: "StoreSettings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PayUContinueUrl",
|
||||
table: "StoreSettings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PayUNotifyUrl",
|
||||
table: "StoreSettings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PayUOauthUrl",
|
||||
table: "StoreSettings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PayUOrdersUrl",
|
||||
table: "StoreSettings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PayUPosId",
|
||||
table: "StoreSettings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PayUSecondKey",
|
||||
table: "StoreSettings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PayUUseSandbox",
|
||||
table: "StoreSettings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ShippingUseSandbox",
|
||||
table: "StoreSettings");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.RegularExpressions;
|
||||
using iLED.Application.Common.Interfaces;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace iLED.Infrastructure.Services;
|
||||
@@ -15,7 +14,7 @@ namespace iLED.Infrastructure.Services;
|
||||
/// </summary>
|
||||
public sealed class DpdPudoClient(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IConfiguration configuration,
|
||||
IIntegrationSettingsAccessor integrationSettings,
|
||||
ILogger<DpdPudoClient> logger)
|
||||
{
|
||||
private const string DefaultBaseUrl = "https://pudofinder.dpd.com.pl";
|
||||
@@ -90,7 +89,7 @@ public sealed class DpdPudoClient(
|
||||
private async Task<IReadOnlyList<PudoPoint>> GetCatalogueAsync(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var baseUrl = GetBaseUrl();
|
||||
var baseUrl = await GetBaseUrlAsync(cancellationToken);
|
||||
if (Cache.TryGetValue(baseUrl, out var cached) &&
|
||||
cached.ExpiresAt > DateTimeOffset.UtcNow)
|
||||
{
|
||||
@@ -130,7 +129,7 @@ public sealed class DpdPudoClient(
|
||||
}
|
||||
|
||||
var client = httpClientFactory.CreateClient(nameof(DpdPudoClient));
|
||||
var baseUrl = GetBaseUrl();
|
||||
var baseUrl = await GetBaseUrlAsync(cancellationToken);
|
||||
var url = $"{baseUrl}/ajax/points/details?lang=pl";
|
||||
|
||||
using var content = new FormUrlEncodedContent(
|
||||
@@ -161,9 +160,10 @@ public sealed class DpdPudoClient(
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private string GetBaseUrl()
|
||||
private async Task<string> GetBaseUrlAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var configured = configuration["Shipping:Dpd:WidgetUrl"];
|
||||
var settings = await integrationSettings.GetAsync(cancellationToken);
|
||||
var configured = settings.DpdWidgetUrl;
|
||||
if (string.IsNullOrWhiteSpace(configured))
|
||||
{
|
||||
return DefaultBaseUrl;
|
||||
|
||||
@@ -11,19 +11,20 @@ namespace iLED.Infrastructure.Services;
|
||||
|
||||
public sealed class PayUPaymentService(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IConfiguration configuration,
|
||||
IIntegrationSettingsAccessor integrationSettings,
|
||||
ILogger<PayUPaymentService> logger) : IPaymentService
|
||||
{
|
||||
public async Task<PaymentInitiationResult> InitiatePaymentAsync(
|
||||
PaymentInitiationRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var posId = configuration["PayU:PosId"];
|
||||
var clientId = configuration["PayU:ClientId"];
|
||||
var clientSecret = configuration["PayU:ClientSecret"];
|
||||
var oauthUrl = configuration["PayU:OAuthUrl"];
|
||||
var ordersUrl = configuration["PayU:OrdersUrl"];
|
||||
var useSandbox = configuration.GetValue("PayU:UseSandbox", true);
|
||||
var settings = await integrationSettings.GetAsync(cancellationToken);
|
||||
var posId = settings.PayUPosId;
|
||||
var clientId = settings.PayUClientId;
|
||||
var clientSecret = settings.PayUClientSecret;
|
||||
var oauthUrl = settings.PayUOauthUrl;
|
||||
var ordersUrl = settings.PayUOrdersUrl;
|
||||
var useSandbox = settings.PayUUseSandbox;
|
||||
var hasCredentials =
|
||||
!string.IsNullOrWhiteSpace(posId) &&
|
||||
!string.IsNullOrWhiteSpace(clientId) &&
|
||||
@@ -40,7 +41,7 @@ public sealed class PayUPaymentService(
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
"Configure PayU:PosId, ClientId, ClientSecret and URLs.");
|
||||
"Configure PayU credentials in Settings → Integrations.");
|
||||
}
|
||||
|
||||
var externalId = $"PAYU-{Guid.NewGuid():N}";
|
||||
@@ -211,10 +212,11 @@ public sealed class PayUPaymentService(
|
||||
"No PayU order id to sync.");
|
||||
}
|
||||
|
||||
var clientId = configuration["PayU:ClientId"];
|
||||
var clientSecret = configuration["PayU:ClientSecret"];
|
||||
var oauthUrl = configuration["PayU:OAuthUrl"];
|
||||
var ordersUrl = configuration["PayU:OrdersUrl"];
|
||||
var settings = await integrationSettings.GetAsync(cancellationToken);
|
||||
var clientId = settings.PayUClientId;
|
||||
var clientSecret = settings.PayUClientSecret;
|
||||
var oauthUrl = settings.PayUOauthUrl;
|
||||
var ordersUrl = settings.PayUOrdersUrl;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(clientId) ||
|
||||
string.IsNullOrWhiteSpace(clientSecret) ||
|
||||
@@ -318,20 +320,21 @@ public sealed class PayUPaymentService(
|
||||
}
|
||||
}
|
||||
|
||||
public Task<PaymentWebhookResult> HandleWebhookAsync(
|
||||
public async Task<PaymentWebhookResult> HandleWebhookAsync(
|
||||
string payload,
|
||||
string signature,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var secondKey = configuration["PayU:SecondKey"];
|
||||
var settings = await integrationSettings.GetAsync(cancellationToken);
|
||||
var secondKey = settings.PayUSecondKey;
|
||||
if (!string.IsNullOrWhiteSpace(secondKey) &&
|
||||
!VerifySignature(payload, signature, secondKey))
|
||||
{
|
||||
logger.LogWarning("PayU webhook signature mismatch");
|
||||
return Task.FromResult(new PaymentWebhookResult(
|
||||
false, null, null, false, "Invalid signature"));
|
||||
return new PaymentWebhookResult(
|
||||
false, null, null, false, "Invalid signature");
|
||||
}
|
||||
|
||||
using var doc = JsonDocument.Parse(payload);
|
||||
@@ -362,18 +365,18 @@ public sealed class PayUPaymentService(
|
||||
"COMPLETED",
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
return Task.FromResult(new PaymentWebhookResult(
|
||||
return new PaymentWebhookResult(
|
||||
true,
|
||||
orderId,
|
||||
payuOrderId ?? extId,
|
||||
completed,
|
||||
null));
|
||||
null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "PayU webhook parse error");
|
||||
return Task.FromResult(new PaymentWebhookResult(
|
||||
false, null, null, false, ex.Message));
|
||||
return new PaymentWebhookResult(
|
||||
false, null, null, false, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
using iLED.Application.Common.Interfaces;
|
||||
using iLED.Domain.Admin;
|
||||
using iLED.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace iLED.Infrastructure.Services;
|
||||
|
||||
public sealed class IntegrationSettingsAccessor(
|
||||
ApplicationDbContext db,
|
||||
IConfiguration configuration) : IIntegrationSettingsAccessor
|
||||
{
|
||||
public async Task<IntegrationRuntimeSettings> GetAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var row = await db.StoreSettings
|
||||
.AsNoTracking()
|
||||
.OrderBy(s => s.CreatedAt)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
return From(row, configuration);
|
||||
}
|
||||
|
||||
internal static IntegrationRuntimeSettings From(
|
||||
StoreSettings? row,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
return new IntegrationRuntimeSettings
|
||||
{
|
||||
ShippingUseSandbox = row?.ShippingUseSandbox
|
||||
?? configuration.GetValue("Shipping:UseSandbox", true),
|
||||
CarrierInPost = row?.CarrierInPost ?? true,
|
||||
CarrierDhl = row?.CarrierDhl ?? true,
|
||||
CarrierDpd = row?.CarrierDpd ?? true,
|
||||
InPostShipXToken = Prefer(
|
||||
row?.InPostShipXToken,
|
||||
configuration["Shipping:InPost:ShipXToken"]),
|
||||
InPostOrganizationId = Prefer(
|
||||
row?.InPostOrganizationId,
|
||||
configuration["Shipping:InPost:OrganizationId"]),
|
||||
InPostShipXBaseUrl = Prefer(
|
||||
row?.InPostShipXBaseUrl,
|
||||
configuration["Shipping:InPost:ShipXBaseUrl"]),
|
||||
InPostGeowidgetPublicToken = Prefer(
|
||||
row?.InPostGeowidgetPublicToken,
|
||||
configuration["Shipping:InPost:GeowidgetPublicToken"]),
|
||||
InPostGeowidgetSandboxToken = Prefer(
|
||||
row?.InPostGeowidgetSandboxToken,
|
||||
configuration["Shipping:InPost:GeowidgetSandboxToken"]),
|
||||
InPostGeowidgetConfig = Prefer(
|
||||
row?.InPostGeowidgetConfig,
|
||||
configuration["Shipping:InPost:GeowidgetConfig"]),
|
||||
InPostGeowidgetSdkBaseUrl = Prefer(
|
||||
row?.InPostGeowidgetSdkBaseUrl,
|
||||
configuration["Shipping:InPost:GeowidgetSdkBaseUrl"]),
|
||||
DhlApiKey = Prefer(
|
||||
row?.DhlApiKey,
|
||||
configuration["Shipping:Dhl:ApiKey"]),
|
||||
DhlApiSecret = Prefer(
|
||||
row?.DhlApiSecret,
|
||||
configuration["Shipping:Dhl:ApiSecret"]),
|
||||
DhlApiBaseUrl = Prefer(
|
||||
row?.DhlApiBaseUrl,
|
||||
configuration["Shipping:Dhl:ApiBaseUrl"]),
|
||||
DhlWidgetUrl = Prefer(
|
||||
row?.DhlWidgetUrl,
|
||||
configuration["Shipping:Dhl:WidgetUrl"]),
|
||||
DpdLogin = Prefer(
|
||||
row?.DpdLogin,
|
||||
configuration["Shipping:Dpd:Login"]),
|
||||
DpdPassword = Prefer(
|
||||
row?.DpdPassword,
|
||||
configuration["Shipping:Dpd:Password"]),
|
||||
DpdFid = Prefer(
|
||||
row?.DpdFid,
|
||||
configuration["Shipping:Dpd:Fid"]),
|
||||
DpdApiBaseUrl = Prefer(
|
||||
row?.DpdApiBaseUrl,
|
||||
configuration["Shipping:Dpd:ApiBaseUrl"]),
|
||||
DpdWidgetUrl = Prefer(
|
||||
row?.DpdWidgetUrl,
|
||||
configuration["Shipping:Dpd:WidgetUrl"]),
|
||||
PayUUseSandbox = row?.PayUUseSandbox
|
||||
?? configuration.GetValue("PayU:UseSandbox", true),
|
||||
PayUPosId = Prefer(row?.PayUPosId, configuration["PayU:PosId"]),
|
||||
PayUClientId = Prefer(
|
||||
row?.PayUClientId,
|
||||
configuration["PayU:ClientId"]),
|
||||
PayUClientSecret = Prefer(
|
||||
row?.PayUClientSecret,
|
||||
configuration["PayU:ClientSecret"]),
|
||||
PayUSecondKey = Prefer(
|
||||
row?.PayUSecondKey,
|
||||
configuration["PayU:SecondKey"]),
|
||||
PayUOauthUrl = Prefer(
|
||||
row?.PayUOauthUrl,
|
||||
configuration["PayU:OAuthUrl"]),
|
||||
PayUOrdersUrl = Prefer(
|
||||
row?.PayUOrdersUrl,
|
||||
configuration["PayU:OrdersUrl"]),
|
||||
PayUNotifyUrl = Prefer(
|
||||
row?.PayUNotifyUrl,
|
||||
configuration["PayU:NotifyUrl"]),
|
||||
PayUContinueUrl = Prefer(
|
||||
row?.PayUContinueUrl,
|
||||
configuration["PayU:ContinueUrl"]),
|
||||
};
|
||||
}
|
||||
|
||||
private static string? Prefer(string? primary, string? fallback) =>
|
||||
!string.IsNullOrWhiteSpace(primary) ? primary.Trim() : fallback;
|
||||
}
|
||||
@@ -12,6 +12,7 @@ namespace iLED.Infrastructure.Services;
|
||||
public sealed class ShippingCarrierClients(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IConfiguration configuration,
|
||||
IIntegrationSettingsAccessor integrationSettings,
|
||||
ILogger<ShippingCarrierClients> logger,
|
||||
ShippingLabelStorage labelStorage)
|
||||
{
|
||||
@@ -31,14 +32,15 @@ public sealed class ShippingCarrierClients(
|
||||
ShipmentRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var token = configuration["Shipping:InPost:ShipXToken"];
|
||||
var orgId = configuration["Shipping:InPost:OrganizationId"];
|
||||
var baseUrl = configuration["Shipping:InPost:ShipXBaseUrl"]
|
||||
var settings = await integrationSettings.GetAsync(cancellationToken);
|
||||
var token = settings.InPostShipXToken;
|
||||
var orgId = settings.InPostOrganizationId;
|
||||
var baseUrl = settings.InPostShipXBaseUrl
|
||||
?? "https://api-shipx-pl.easypack24.net";
|
||||
|
||||
if (string.IsNullOrWhiteSpace(token) || string.IsNullOrWhiteSpace(orgId))
|
||||
{
|
||||
return Fail("Configure Shipping:InPost:ShipXToken and OrganizationId.");
|
||||
return Fail("Configure InPost ShipX token and organization in Settings → Integrations.");
|
||||
}
|
||||
|
||||
var isLocker = request.ServiceType == ShippingServiceType.ParcelLocker;
|
||||
@@ -188,15 +190,16 @@ public sealed class ShippingCarrierClients(
|
||||
ShipmentRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var apiKey = configuration["Shipping:Dhl:ApiKey"];
|
||||
var apiSecret = configuration["Shipping:Dhl:ApiSecret"];
|
||||
var baseUrl = configuration["Shipping:Dhl:ApiBaseUrl"];
|
||||
var settings = await integrationSettings.GetAsync(cancellationToken);
|
||||
var apiKey = settings.DhlApiKey;
|
||||
var apiSecret = settings.DhlApiSecret;
|
||||
var baseUrl = settings.DhlApiBaseUrl;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(apiKey) ||
|
||||
string.IsNullOrWhiteSpace(apiSecret) ||
|
||||
string.IsNullOrWhiteSpace(baseUrl))
|
||||
{
|
||||
return Fail("Configure Shipping:Dhl:ApiKey, ApiSecret and ApiBaseUrl.");
|
||||
return Fail("Configure DHL API key, secret and base URL in Settings → Integrations.");
|
||||
}
|
||||
|
||||
var payload = new
|
||||
@@ -301,17 +304,18 @@ public sealed class ShippingCarrierClients(
|
||||
ShipmentRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var login = configuration["Shipping:Dpd:Login"];
|
||||
var password = configuration["Shipping:Dpd:Password"];
|
||||
var fid = configuration["Shipping:Dpd:Fid"];
|
||||
var baseUrl = configuration["Shipping:Dpd:ApiBaseUrl"];
|
||||
var settings = await integrationSettings.GetAsync(cancellationToken);
|
||||
var login = settings.DpdLogin;
|
||||
var password = settings.DpdPassword;
|
||||
var fid = settings.DpdFid;
|
||||
var baseUrl = settings.DpdApiBaseUrl;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(login) ||
|
||||
string.IsNullOrWhiteSpace(password) ||
|
||||
string.IsNullOrWhiteSpace(fid) ||
|
||||
string.IsNullOrWhiteSpace(baseUrl))
|
||||
{
|
||||
return Fail("Configure Shipping:Dpd:Login, Password, Fid and ApiBaseUrl.");
|
||||
return Fail("Configure DPD login, password, FID and API URL in Settings → Integrations.");
|
||||
}
|
||||
|
||||
// DPD PL PackageService-style XML envelope (simplified create).
|
||||
|
||||
@@ -9,6 +9,7 @@ namespace iLED.Infrastructure.Services;
|
||||
|
||||
public sealed class ShippingService(
|
||||
IConfiguration configuration,
|
||||
IIntegrationSettingsAccessor integrationSettings,
|
||||
ILogger<ShippingService> logger,
|
||||
ShippingLabelStorage labelStorage,
|
||||
ShippingCarrierClients carrierClients,
|
||||
@@ -50,16 +51,17 @@ public sealed class ShippingService(
|
||||
return points;
|
||||
}
|
||||
|
||||
public ShippingWidgetConfigDto GetWidgetConfig()
|
||||
public async Task<ShippingWidgetConfigDto> GetWidgetConfigAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var useSandbox = configuration.GetValue("Shipping:UseSandbox", true);
|
||||
var settings = await integrationSettings.GetAsync(cancellationToken);
|
||||
var useSandbox = settings.ShippingUseSandbox;
|
||||
var inPostToken = useSandbox
|
||||
? configuration["Shipping:InPost:GeowidgetSandboxToken"]
|
||||
?? configuration["Shipping:InPost:GeowidgetPublicToken"]
|
||||
: configuration["Shipping:InPost:GeowidgetPublicToken"];
|
||||
var inPostConfig = configuration["Shipping:InPost:GeowidgetConfig"]
|
||||
?? "parcelCollect";
|
||||
var inPostSdk = configuration["Shipping:InPost:GeowidgetSdkBaseUrl"];
|
||||
? settings.InPostGeowidgetSandboxToken
|
||||
?? settings.InPostGeowidgetPublicToken
|
||||
: settings.InPostGeowidgetPublicToken;
|
||||
var inPostConfig = settings.InPostGeowidgetConfig ?? "parcelCollect";
|
||||
var inPostSdk = settings.InPostGeowidgetSdkBaseUrl;
|
||||
if (string.IsNullOrWhiteSpace(inPostSdk))
|
||||
{
|
||||
inPostSdk = useSandbox
|
||||
@@ -67,11 +69,11 @@ public sealed class ShippingService(
|
||||
: "https://geowidget.inpost.pl";
|
||||
}
|
||||
|
||||
var dhlEnabled = configuration.GetValue("Shipping:Dhl:WidgetEnabled", true);
|
||||
var dhlUrl = configuration["Shipping:Dhl:WidgetUrl"]
|
||||
var dhlEnabled = settings.CarrierDhl;
|
||||
var dhlUrl = settings.DhlWidgetUrl
|
||||
?? "https://parcelshop.dhl.pl/mapa";
|
||||
var dpdEnabled = configuration.GetValue("Shipping:Dpd:WidgetEnabled", true);
|
||||
var dpdUrl = configuration["Shipping:Dpd:WidgetUrl"]
|
||||
var dpdEnabled = settings.CarrierDpd;
|
||||
var dpdUrl = settings.DpdWidgetUrl
|
||||
?? "https://pudofinder.dpd.com.pl/";
|
||||
if (dpdUrl.Contains("pickup.dpd.pl", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
@@ -79,9 +81,10 @@ public sealed class ShippingService(
|
||||
dpdUrl = "https://pudofinder.dpd.com.pl/";
|
||||
}
|
||||
|
||||
var inPostEnabled = settings.CarrierInPost;
|
||||
return new ShippingWidgetConfigDto(
|
||||
useSandbox,
|
||||
string.IsNullOrWhiteSpace(inPostToken)
|
||||
!inPostEnabled || string.IsNullOrWhiteSpace(inPostToken)
|
||||
? null
|
||||
: new InPostGeowidgetConfigDto(
|
||||
inPostToken,
|
||||
|
||||
Reference in new issue
Block a user