@open-mercato/core 0.10.1-develop.7723.1.3b2ea82ea7 → 0.10.1-develop.7724.1.95e4e7d38e

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/generated/entities/sales_order/index.js +2 -0
  3. package/dist/generated/entities/sales_order/index.js.map +2 -2
  4. package/dist/generated/entities/sales_order_line/index.js +2 -0
  5. package/dist/generated/entities/sales_order_line/index.js.map +2 -2
  6. package/dist/generated/entity-fields-registry.js +2 -0
  7. package/dist/generated/entity-fields-registry.js.map +2 -2
  8. package/dist/modules/sales/api/documents/factory.js +3 -0
  9. package/dist/modules/sales/api/documents/factory.js.map +2 -2
  10. package/dist/modules/sales/api/order-lines/route.js +2 -1
  11. package/dist/modules/sales/api/order-lines/route.js.map +2 -2
  12. package/dist/modules/sales/backend/sales/documents/[id]/page.js +83 -6
  13. package/dist/modules/sales/backend/sales/documents/[id]/page.js.map +2 -2
  14. package/dist/modules/sales/commands/documents.js +141 -18
  15. package/dist/modules/sales/commands/documents.js.map +2 -2
  16. package/dist/modules/sales/commands/returns.js +24 -7
  17. package/dist/modules/sales/commands/returns.js.map +2 -2
  18. package/dist/modules/sales/components/documents/AdjustmentsSection.js +17 -11
  19. package/dist/modules/sales/components/documents/AdjustmentsSection.js.map +2 -2
  20. package/dist/modules/sales/components/documents/DocumentTotals.js +21 -3
  21. package/dist/modules/sales/components/documents/DocumentTotals.js.map +2 -2
  22. package/dist/modules/sales/components/documents/ItemsSection.js +23 -12
  23. package/dist/modules/sales/components/documents/ItemsSection.js.map +2 -2
  24. package/dist/modules/sales/data/entities.js +10 -0
  25. package/dist/modules/sales/data/entities.js.map +2 -2
  26. package/dist/modules/sales/data/validators.js +9 -1
  27. package/dist/modules/sales/data/validators.js.map +2 -2
  28. package/dist/modules/sales/lib/calculations.js +85 -6
  29. package/dist/modules/sales/lib/calculations.js.map +2 -2
  30. package/dist/modules/sales/lib/externalAmounts.js +143 -0
  31. package/dist/modules/sales/lib/externalAmounts.js.map +7 -0
  32. package/dist/modules/sales/lib/lineSnapshots.js +6 -1
  33. package/dist/modules/sales/lib/lineSnapshots.js.map +2 -2
  34. package/dist/modules/sales/lib/makeSalesLineRoute.js +8 -2
  35. package/dist/modules/sales/lib/makeSalesLineRoute.js.map +2 -2
  36. package/dist/modules/sales/lib/providers/totals.js +3 -2
  37. package/dist/modules/sales/lib/providers/totals.js.map +2 -2
  38. package/dist/modules/sales/migrations/Migration20260910120000_sales_external_amounts_mode.js +19 -0
  39. package/dist/modules/sales/migrations/Migration20260910120000_sales_external_amounts_mode.js.map +7 -0
  40. package/generated/entities/sales_order/index.ts +1 -0
  41. package/generated/entities/sales_order_line/index.ts +1 -0
  42. package/generated/entity-fields-registry.ts +2 -0
  43. package/package.json +7 -7
  44. package/src/modules/sales/api/documents/factory.ts +3 -0
  45. package/src/modules/sales/api/order-lines/route.ts +2 -1
  46. package/src/modules/sales/backend/sales/documents/[id]/page.tsx +96 -0
  47. package/src/modules/sales/commands/documents.ts +219 -44
  48. package/src/modules/sales/commands/returns.ts +27 -3
  49. package/src/modules/sales/components/documents/AdjustmentsSection.tsx +34 -25
  50. package/src/modules/sales/components/documents/DocumentTotals.tsx +26 -2
  51. package/src/modules/sales/components/documents/ItemsSection.tsx +27 -12
  52. package/src/modules/sales/data/entities.ts +14 -0
  53. package/src/modules/sales/data/validators.ts +10 -0
  54. package/src/modules/sales/i18n/de.json +12 -0
  55. package/src/modules/sales/i18n/en.json +12 -0
  56. package/src/modules/sales/i18n/es.json +12 -0
  57. package/src/modules/sales/i18n/ko.json +12 -0
  58. package/src/modules/sales/i18n/pl.json +12 -0
  59. package/src/modules/sales/lib/calculations.ts +99 -4
  60. package/src/modules/sales/lib/externalAmounts.ts +174 -0
  61. package/src/modules/sales/lib/lineSnapshots.ts +6 -1
  62. package/src/modules/sales/lib/makeSalesLineRoute.ts +8 -0
  63. package/src/modules/sales/lib/providers/totals.ts +5 -2
  64. package/src/modules/sales/lib/types.ts +19 -2
  65. package/src/modules/sales/migrations/.snapshot-open-mercato.json +33 -1
  66. package/src/modules/sales/migrations/Migration20260910120000_sales_external_amounts_mode.ts +25 -0
@@ -42,6 +42,8 @@ type SalesDocumentAdjustmentsSectionProps = {
42
42
  tenantId?: string | null
43
43
  onActionChange?: (action: SectionAction | null) => void
44
44
  onRowsChange?: (rows: AdjustmentRow[]) => void
45
+ /** Withdraws adjustment writes: on an external order they would all be refused. */
46
+ amountsReadOnly?: boolean
45
47
  }
46
48
 
47
49
  const FALLBACK_ADJUSTMENT_KIND_VALUES: SalesAdjustmentKind[] = [
@@ -76,6 +78,7 @@ export function SalesDocumentAdjustmentsSection({
76
78
  tenantId: tenantFromProps,
77
79
  onActionChange,
78
80
  onRowsChange,
81
+ amountsReadOnly = false,
79
82
  }: SalesDocumentAdjustmentsSectionProps) {
80
83
  const t = useT()
81
84
  const runDocumentMutation = useSalesDocumentMutation(kind, documentId)
@@ -281,10 +284,14 @@ export function SalesDocumentAdjustmentsSection({
281
284
  setDialogOpen(true)
282
285
  }, [])
283
286
 
284
- const handleEdit = React.useCallback((row: AdjustmentRow) => {
285
- setActiveAdjustment(row)
286
- setDialogOpen(true)
287
- }, [])
287
+ const handleEdit = React.useCallback(
288
+ (row: AdjustmentRow) => {
289
+ if (amountsReadOnly) return
290
+ setActiveAdjustment(row)
291
+ setDialogOpen(true)
292
+ },
293
+ [amountsReadOnly]
294
+ )
288
295
 
289
296
  const handleCloseDialog = React.useCallback(() => {
290
297
  setDialogOpen(false)
@@ -296,10 +303,10 @@ export function SalesDocumentAdjustmentsSection({
296
303
  onActionChange({
297
304
  label: t('sales.documents.adjustments.add', 'Add adjustment'),
298
305
  onClick: handleOpenCreate,
299
- disabled: false,
306
+ disabled: amountsReadOnly,
300
307
  })
301
308
  return () => onActionChange(null)
302
- }, [handleOpenCreate, onActionChange, t])
309
+ }, [amountsReadOnly, handleOpenCreate, onActionChange, t])
303
310
 
304
311
  const handleFormSubmit = React.useCallback(
305
312
  async (values: AdjustmentSubmitPayload) => {
@@ -451,24 +458,25 @@ export function SalesDocumentAdjustmentsSection({
451
458
  const showLoadingState = loading && rows.length === 0
452
459
 
453
460
  const renderRowActions = React.useCallback(
454
- (row: AdjustmentRow) => (
455
- <RowActions
456
- items={[
457
- {
458
- id: 'edit',
459
- label: t('ui.actions.edit', 'Edit'),
460
- onSelect: () => handleEdit(row),
461
- },
462
- {
463
- id: 'delete',
464
- label: t('ui.actions.delete', 'Delete'),
465
- destructive: true,
466
- onSelect: () => handleDelete(row),
467
- },
468
- ]}
469
- />
470
- ),
471
- [handleDelete, handleEdit, t]
461
+ (row: AdjustmentRow) =>
462
+ amountsReadOnly ? null : (
463
+ <RowActions
464
+ items={[
465
+ {
466
+ id: 'edit',
467
+ label: t('ui.actions.edit', 'Edit'),
468
+ onSelect: () => handleEdit(row),
469
+ },
470
+ {
471
+ id: 'delete',
472
+ label: t('ui.actions.delete', 'Delete'),
473
+ destructive: true,
474
+ onSelect: () => handleDelete(row),
475
+ },
476
+ ]}
477
+ />
478
+ ),
479
+ [amountsReadOnly, handleDelete, handleEdit, t]
472
480
  )
473
481
 
474
482
  return (
@@ -487,7 +495,7 @@ export function SalesDocumentAdjustmentsSection({
487
495
  columns={columns}
488
496
  isLoading={loading && rows.length > 0}
489
497
  embedded
490
- onRowClick={handleEdit}
498
+ onRowClick={amountsReadOnly ? undefined : handleEdit}
491
499
  rowActions={renderRowActions}
492
500
  emptyState={
493
501
  <TabEmptyState
@@ -495,6 +503,7 @@ export function SalesDocumentAdjustmentsSection({
495
503
  description={t('sales.documents.empty.adjustments.description', 'Add discounts, fees, or taxes to refine totals.')}
496
504
  actionLabel={t('sales.documents.adjustments.add', 'Add adjustment')}
497
505
  onAction={handleOpenCreate}
506
+ disabled={amountsReadOnly}
498
507
  />
499
508
  }
500
509
  />
@@ -17,9 +17,20 @@ type DocumentTotalsProps = {
17
17
  currency: string | null | undefined
18
18
  items: DocumentTotalItem[]
19
19
  className?: string
20
+ /** Marks the header as caller-asserted, e.g. "Amounts from source". */
21
+ sourceBadge?: { label: string; hint?: string } | null
22
+ /** Rendered under the table — the explicit action that leaves the source-owned mode. */
23
+ footerAction?: React.ReactNode
20
24
  }
21
25
 
22
- export function DocumentTotals({ title, currency, items, className }: DocumentTotalsProps) {
26
+ export function DocumentTotals({
27
+ title,
28
+ currency,
29
+ items,
30
+ className,
31
+ sourceBadge,
32
+ footerAction,
33
+ }: DocumentTotalsProps) {
23
34
  const t = useT()
24
35
  const emphasizedRows = items.filter((item) => item.emphasize)
25
36
  const heading = title ?? t('sales.documents.detail.totals.title')
@@ -56,7 +67,17 @@ export function DocumentTotals({ title, currency, items, className }: DocumentTo
56
67
  <div className={cn('space-y-3', className)}>
57
68
  <div className="overflow-hidden rounded-xl border bg-card shadow-sm">
58
69
  <div className="flex items-center justify-between border-b bg-muted/50 px-4 py-3">
59
- <span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{heading}</span>
70
+ <span className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
71
+ {heading}
72
+ {sourceBadge ? (
73
+ <span
74
+ title={sourceBadge.hint}
75
+ className="rounded-full bg-status-info-bg px-2 py-0.5 text-xs font-semibold normal-case tracking-normal text-status-info-text"
76
+ >
77
+ {sourceBadge.label}
78
+ </span>
79
+ ) : null}
80
+ </span>
60
81
  {currency ? (
61
82
  <span className="rounded-full border border-border/70 bg-background/80 px-3 py-1 text-xs font-semibold tracking-wide text-foreground">
62
83
  {currency}
@@ -113,6 +134,9 @@ export function DocumentTotals({ title, currency, items, className }: DocumentTo
113
134
  </button>
114
135
  </div>
115
136
  ) : null}
137
+ {footerAction ? (
138
+ <div className="flex items-center justify-end border-t bg-muted/30 px-4 py-3">{footerAction}</div>
139
+ ) : null}
116
140
  </div>
117
141
  </div>
118
142
  )
@@ -16,6 +16,7 @@ import {
16
16
  createDictionaryMap,
17
17
  normalizeDictionaryEntries,
18
18
  } from "@open-mercato/core/modules/dictionaries/components/dictionaryAppearance";
19
+ import { cn } from "@open-mercato/shared/lib/utils";
19
20
  import { useT, useLocale } from "@open-mercato/shared/lib/i18n/context";
20
21
  import { useOrganizationScopeDetail } from "@open-mercato/shared/lib/frontend/useOrganizationScope";
21
22
  import { useConfirmDialog } from "@open-mercato/ui/backend/confirm-dialog";
@@ -151,6 +152,8 @@ type SalesDocumentItemsSectionProps = {
151
152
  tenantId?: string | null;
152
153
  onActionChange?: (action: SectionAction | null) => void;
153
154
  onItemsChange?: (items: SalesLineRecord[]) => void;
155
+ /** Disables line writes: on an external order they would all be rejected. */
156
+ amountsReadOnly?: boolean;
154
157
  };
155
158
 
156
159
  export function SalesDocumentItemsSection({
@@ -162,6 +165,7 @@ export function SalesDocumentItemsSection({
162
165
  tenantId: tenantFromProps,
163
166
  onActionChange,
164
167
  onItemsChange,
168
+ amountsReadOnly = false,
165
169
  }: SalesDocumentItemsSectionProps) {
166
170
  const t = useT();
167
171
  const runDocumentMutation = useSalesDocumentMutation(kind, documentId);
@@ -535,15 +539,19 @@ export function SalesDocumentItemsSection({
535
539
  onActionChange({
536
540
  label: t("sales.documents.items.add", "Add item"),
537
541
  onClick: openCreate,
538
- disabled: false,
542
+ disabled: amountsReadOnly,
539
543
  });
540
544
  return () => onActionChange(null);
541
- }, [onActionChange, openCreate, t]);
545
+ }, [amountsReadOnly, onActionChange, openCreate, t]);
542
546
 
543
- const handleEdit = React.useCallback((line: SalesLineRecord) => {
544
- setLineForEdit(line);
545
- setDialogOpen(true);
546
- }, []);
547
+ const handleEdit = React.useCallback(
548
+ (line: SalesLineRecord) => {
549
+ if (amountsReadOnly) return;
550
+ setLineForEdit(line);
551
+ setDialogOpen(true);
552
+ },
553
+ [amountsReadOnly],
554
+ );
547
555
 
548
556
  const resolveVariantInfo = React.useCallback((record: SalesLineRecord) => {
549
557
  const meta =
@@ -728,10 +736,11 @@ export function SalesDocumentItemsSection({
728
736
  action={{
729
737
  label: t("sales.documents.items.add", "Add item"),
730
738
  onClick: openCreate,
739
+ disabled: amountsReadOnly,
731
740
  }}
732
741
  />
733
742
  ) : (
734
- <div className="overflow-hidden rounded border">
743
+ <div className="relative overflow-x-auto rounded border">
735
744
  <table className="w-full text-sm">
736
745
  <thead className="bg-muted">
737
746
  <tr className="text-left">
@@ -810,7 +819,10 @@ export function SalesDocumentItemsSection({
810
819
  return (
811
820
  <tr
812
821
  key={item.id}
813
- className="border-t hover:bg-muted/50 cursor-pointer transition-colors"
822
+ className={cn(
823
+ "border-t transition-colors",
824
+ amountsReadOnly ? "" : "cursor-pointer hover:bg-muted/50",
825
+ )}
814
826
  onClick={() => handleEdit(item)}
815
827
  >
816
828
  <td className="px-3 py-3">
@@ -995,6 +1007,7 @@ export function SalesDocumentItemsSection({
995
1007
  variant="ghost"
996
1008
  className="h-8 w-8"
997
1009
  aria-label={t('ui.actions.edit', 'Edit')}
1010
+ disabled={amountsReadOnly}
998
1011
  onClick={(event) => {
999
1012
  event.stopPropagation();
1000
1013
  handleEdit(item);
@@ -1003,16 +1016,18 @@ export function SalesDocumentItemsSection({
1003
1016
  <Pencil className="h-4 w-4" />
1004
1017
  </Button>
1005
1018
  <span
1006
- title={kind === 'order' && items.length === 1
1007
- ? t('sales.documents.items.errorDeleteLast', 'An order must contain at least one line item.')
1008
- : undefined}
1019
+ title={amountsReadOnly
1020
+ ? t('sales.documents.amountsExternalHint', 'Totals on this document come from an external system and are stored as supplied.')
1021
+ : kind === 'order' && items.length === 1
1022
+ ? t('sales.documents.items.errorDeleteLast', 'An order must contain at least one line item.')
1023
+ : undefined}
1009
1024
  >
1010
1025
  <Button
1011
1026
  size="icon"
1012
1027
  variant="ghost"
1013
1028
  className="h-8 w-8 text-destructive"
1014
1029
  aria-label={t('ui.actions.delete', 'Delete')}
1015
- disabled={kind === 'order' && items.length === 1}
1030
+ disabled={amountsReadOnly || (kind === 'order' && items.length === 1)}
1016
1031
  onClick={(event) => {
1017
1032
  event.stopPropagation();
1018
1033
  void handleDelete(item);
@@ -8,6 +8,7 @@ export type SalesDocumentKind = 'order' | 'quote' | 'invoice' | 'credit_memo'
8
8
  export type SalesLineKind = 'product' | 'service' | 'shipping' | 'discount' | 'adjustment'
9
9
  export const DEFAULT_SALES_ADJUSTMENT_KINDS = ['discount', 'tax', 'shipping', 'surcharge', 'return', 'custom'] as const
10
10
  export type SalesAdjustmentKind = (typeof DEFAULT_SALES_ADJUSTMENT_KINDS)[number] | string
11
+ export type SalesAmountsMode = 'computed' | 'external'
11
12
 
12
13
  @Entity({ tableName: 'sales_channels' })
13
14
  @Index({ name: 'sales_channels_org_tenant_idx', properties: ['organizationId', 'tenantId'] })
@@ -331,6 +332,9 @@ export class SalesTaxRate {
331
332
  @Index({ name: 'sales_orders_payment_status_idx', properties: ['organizationId', 'tenantId', 'paymentStatus'] })
332
333
  @Unique({ name: 'sales_orders_number_unique', properties: ['organizationId', 'tenantId', 'orderNumber'] })
333
334
  export class SalesOrder {
335
+ // Optional so existing `em.create(SalesOrder, ...)` callers keep compiling.
336
+ [OptionalProps]?: 'totalsMode'
337
+
334
338
  @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
335
339
  id!: string
336
340
 
@@ -466,6 +470,10 @@ export class SalesOrder {
466
470
  @Property({ name: 'outstanding_amount', type: 'numeric', precision: 18, scale: 4, default: '0' })
467
471
  outstandingAmount: string = '0'
468
472
 
473
+ /** `external`: the header totals are the caller's and never rebuilt; payment totals stay core-owned. */
474
+ @Property({ name: 'totals_mode', type: 'text', default: 'computed' })
475
+ totalsMode: SalesAmountsMode = 'computed'
476
+
469
477
  @Property({ name: 'line_item_count', type: 'integer', default: 0 })
470
478
  lineItemCount: number = 0
471
479
 
@@ -553,6 +561,8 @@ export class SalesOrder {
553
561
  properties: ['organizationId', 'tenantId', 'normalizedUnit', 'normalizedQuantity'],
554
562
  })
555
563
  export class SalesOrderLine {
564
+ [OptionalProps]?: 'amountsMode'
565
+
556
566
  @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
557
567
  id!: string
558
568
 
@@ -649,6 +659,10 @@ export class SalesOrderLine {
649
659
  @Property({ name: 'total_gross_amount', type: 'numeric', precision: 18, scale: 4, default: '0' })
650
660
  totalGrossAmount: string = '0'
651
661
 
662
+ /** `external`: amounts are the caller's; `discount_amount` is then signed. Matches the order's `totals_mode`. */
663
+ @Property({ name: 'amounts_mode', type: 'text', default: 'computed' })
664
+ amountsMode: SalesAmountsMode = 'computed'
665
+
652
666
  @Property({ name: 'configuration', type: 'jsonb', nullable: true })
653
667
  configuration?: Record<string, unknown> | null
654
668
 
@@ -329,6 +329,8 @@ const lineKindSchema = z.enum(['product', 'service', 'shipping', 'discount', 'ad
329
329
 
330
330
  const adjustmentKindSchema = z.string().trim().min(1).max(150)
331
331
 
332
+ export const amountsModeSchema = z.enum(['computed', 'external'])
333
+
332
334
  const linePricingSchema = z.object({
333
335
  quantity: decimal({ min: 0, max: MAX_QUANTITY, message: 'Quantity is too large.' }),
334
336
  quantityUnit: z.string().trim().max(25).optional(),
@@ -348,6 +350,9 @@ const linePricingSchema = z.object({
348
350
  taxAmount: decimal({ min: 0 }).optional(),
349
351
  totalNetAmount: decimal({ min: 0 }).optional(),
350
352
  totalGrossAmount: decimal({ min: 0 }).optional(),
353
+ // Omitted inherits the document's mode; defaulting to `computed` would make an
354
+ // external order mixed. Order lines only — quote commands reject it.
355
+ amountsMode: amountsModeSchema.optional(),
351
356
  })
352
357
 
353
358
  const uomSnapshotSchema = z.object({
@@ -672,6 +677,10 @@ const orderTotalsSchema = z.object({
672
677
  lineItemCount: z.coerce.number().int().min(0).optional(),
673
678
  })
674
679
 
680
+ // `lineItemCount` is core's count of the rows it persisted, not one of the amounts
681
+ // a caller owns; asserting it could make a document disagree with its own lines.
682
+ export const orderHeaderTotalsSchema = orderTotalsSchema.omit({ lineItemCount: true })
683
+
675
684
  const quoteTotalsSchema = z.object({
676
685
  subtotalNetAmount: decimal({ min: 0 }).optional(),
677
686
  subtotalGrossAmount: decimal({ min: 0 }).optional(),
@@ -728,6 +737,7 @@ export const orderCreateSchema = scoped.extend({
728
737
  .min(1, SALES_ORDER_LINES_REQUIRED_MESSAGE_KEY),
729
738
  adjustments: z.array(orderAdjustmentCreateSchema.omit({ organizationId: true, tenantId: true, orderId: true })).optional(),
730
739
  tags: z.array(uuid()).optional(),
740
+ totalsMode: amountsModeSchema.optional(),
731
741
  ...orderTotalsSchema.shape,
732
742
  ...orderPaymentLedgerShape,
733
743
  })
@@ -497,6 +497,13 @@
497
497
  "sales.documents.adjustments.title": "Anpassungen",
498
498
  "sales.documents.adjustments.untitled": "Ohne Titel",
499
499
  "sales.documents.adjustments.updated": "Anpassung aktualisiert.",
500
+ "sales.documents.amountsExternal": "Beträge aus Quellsystem",
501
+ "sales.documents.amountsExternalHint": "Die Summen dieses Belegs stammen aus einem externen System und werden unverändert gespeichert.",
502
+ "sales.documents.amountsExternalMethodNote": "Die Methode wird gespeichert, aber es wird keine Gebühr berechnet — die Summen dieses Auftrags stammen aus dem Quellsystem.",
503
+ "sales.documents.amountsSwitchConfirm": "Die Beträge dieses Auftrags aus seinen Positionen neu berechnen? Die vom Quellsystem gelieferten Summen werden ersetzt und lassen sich von hier aus nicht wiederherstellen.",
504
+ "sales.documents.amountsSwitchDone": "Die Beträge werden jetzt aus den Positionen berechnet.",
505
+ "sales.documents.amountsSwitchFailed": "Umstellung auf berechnete Beträge fehlgeschlagen.",
506
+ "sales.documents.amountsSwitchToComputed": "Auf berechnete Beträge umstellen",
500
507
  "sales.documents.create.title": "Verkaufsdokument erstellen",
501
508
  "sales.documents.detail.addresses.add": "Adresse hinzufügen",
502
509
  "sales.documents.detail.addresses.additional": "Weitere Adressen",
@@ -1240,6 +1247,11 @@
1240
1247
  "sales.documents.status.documentPlaceholder": "Neuen Bestell-/Angebotsstatus auswählen",
1241
1248
  "sales.documents.status.linePlaceholder": "Positionsstatus auswählen",
1242
1249
  "sales.documents.status.sectionTitle": "Statusänderungen",
1250
+ "sales.errors.externalAdjustmentRefused": "Dieser Auftrag führt Beträge aus einem externen System, daher können keine Anpassungen hinzugefügt oder entfernt werden. Stellen Sie ihn zuerst auf berechnete Beträge um.",
1251
+ "sales.errors.externalAmountsIncomplete": "Position {{line}} hat externe Beträge, aber es fehlen: {{fields}}. Eine unvollständige Angabe ist kein Modus.",
1252
+ "sales.errors.externalModeMixed": "Ein Auftrag und alle seine Positionen müssen denselben Betragsmodus verwenden.",
1253
+ "sales.errors.externalModeUnsupportedOnQuote": "Angebote können keinen Betragsmodus führen; sie verwenden immer berechnete Beträge.",
1254
+ "sales.errors.externalTotalsRequired": "Ein Auftrag mit externen Beträgen muss eigene Belegsummen angeben: {{fields}}.",
1243
1255
  "sales.errors.invalid_query": "Ungültige Abfrageparameter",
1244
1256
  "sales.errors.organization_required": "Organisationskontext ist erforderlich",
1245
1257
  "sales.errors.tag_invalid": "Ungültiges Tag-Format.",
@@ -497,6 +497,13 @@
497
497
  "sales.documents.adjustments.title": "Adjustments",
498
498
  "sales.documents.adjustments.untitled": "Untitled",
499
499
  "sales.documents.adjustments.updated": "Adjustment updated.",
500
+ "sales.documents.amountsExternal": "Amounts from source",
501
+ "sales.documents.amountsExternalHint": "Totals on this document come from an external system and are stored as supplied.",
502
+ "sales.documents.amountsExternalMethodNote": "The method is recorded, but no charge is added — this order’s totals come from the source system.",
503
+ "sales.documents.amountsSwitchConfirm": "Recompute this order’s amounts from its lines? The totals supplied by the source system will be replaced and cannot be restored from here.",
504
+ "sales.documents.amountsSwitchDone": "Amounts are now computed from the lines.",
505
+ "sales.documents.amountsSwitchFailed": "Could not switch to computed amounts.",
506
+ "sales.documents.amountsSwitchToComputed": "Switch to computed amounts",
500
507
  "sales.documents.create.title": "Create sales document",
501
508
  "sales.documents.detail.addresses.add": "Add address",
502
509
  "sales.documents.detail.addresses.additional": "Additional addresses",
@@ -1240,6 +1247,11 @@
1240
1247
  "sales.documents.status.documentPlaceholder": "Select new order/quote status",
1241
1248
  "sales.documents.status.linePlaceholder": "Select item status",
1242
1249
  "sales.documents.status.sectionTitle": "Status changes",
1250
+ "sales.errors.externalAdjustmentRefused": "This order carries amounts from an external system, so adjustments cannot be added or removed. Switch it to computed amounts first.",
1251
+ "sales.errors.externalAmountsIncomplete": "Line {{line}} has external amounts but is missing: {{fields}}. Partial specification is not a mode.",
1252
+ "sales.errors.externalModeMixed": "An order and all of its lines must share the same amounts mode.",
1253
+ "sales.errors.externalModeUnsupportedOnQuote": "Quotes cannot carry an amounts mode; they always use computed amounts.",
1254
+ "sales.errors.externalTotalsRequired": "An order with external amounts must supply its own document totals: {{fields}}.",
1243
1255
  "sales.errors.invalid_query": "Invalid query parameters",
1244
1256
  "sales.errors.organization_required": "Organization context is required",
1245
1257
  "sales.errors.tag_invalid": "Invalid tag format.",
@@ -497,6 +497,13 @@
497
497
  "sales.documents.adjustments.title": "Ajustes",
498
498
  "sales.documents.adjustments.untitled": "Sin título",
499
499
  "sales.documents.adjustments.updated": "Ajuste actualizado.",
500
+ "sales.documents.amountsExternal": "Importes del origen",
501
+ "sales.documents.amountsExternalHint": "Los totales de este documento provienen de un sistema externo y se guardan tal cual.",
502
+ "sales.documents.amountsExternalMethodNote": "El método se guarda, pero no añade ningún cargo: los totales de este pedido provienen del sistema de origen.",
503
+ "sales.documents.amountsSwitchConfirm": "¿Recalcular los importes de este pedido a partir de sus líneas? Los totales aportados por el sistema de origen se reemplazarán y no se podrán restaurar desde aquí.",
504
+ "sales.documents.amountsSwitchDone": "Los importes ahora se calculan a partir de las líneas.",
505
+ "sales.documents.amountsSwitchFailed": "No se pudo cambiar a importes calculados.",
506
+ "sales.documents.amountsSwitchToComputed": "Cambiar a importes calculados",
500
507
  "sales.documents.create.title": "Crear documento de ventas",
501
508
  "sales.documents.detail.addresses.add": "Agregar dirección",
502
509
  "sales.documents.detail.addresses.additional": "Direcciones adicionales",
@@ -1240,6 +1247,11 @@
1240
1247
  "sales.documents.status.documentPlaceholder": "Seleccionar nuevo estado de pedido/cotización",
1241
1248
  "sales.documents.status.linePlaceholder": "Seleccionar estado del artículo",
1242
1249
  "sales.documents.status.sectionTitle": "Cambios de estado",
1250
+ "sales.errors.externalAdjustmentRefused": "Este pedido lleva importes de un sistema externo, por lo que no se pueden añadir ni eliminar ajustes. Cámbialo primero a importes calculados.",
1251
+ "sales.errors.externalAmountsIncomplete": "La línea {{line}} tiene importes externos, pero faltan: {{fields}}. Una especificación parcial no es un modo.",
1252
+ "sales.errors.externalModeMixed": "Un pedido y todas sus líneas deben compartir el mismo modo de importes.",
1253
+ "sales.errors.externalModeUnsupportedOnQuote": "Los presupuestos no pueden llevar un modo de importes; siempre usan importes calculados.",
1254
+ "sales.errors.externalTotalsRequired": "Un pedido con importes externos debe aportar sus propios totales de documento: {{fields}}.",
1243
1255
  "sales.errors.invalid_query": "Parámetros de consulta inválidos",
1244
1256
  "sales.errors.organization_required": "Se requiere el contexto de la organización",
1245
1257
  "sales.errors.tag_invalid": "Formato de etiqueta no válido.",
@@ -497,6 +497,13 @@
497
497
  "sales.documents.adjustments.title": "조정 항목",
498
498
  "sales.documents.adjustments.untitled": "제목 없음",
499
499
  "sales.documents.adjustments.updated": "조정 항목이 수정되었습니다.",
500
+ "sales.documents.amountsExternal": "원본 시스템 금액",
501
+ "sales.documents.amountsExternalHint": "이 문서의 합계는 외부 시스템에서 제공된 값 그대로 저장됩니다.",
502
+ "sales.documents.amountsExternalMethodNote": "수단은 저장되지만 요금은 추가되지 않습니다. 이 주문의 합계는 원본 시스템에서 옵니다.",
503
+ "sales.documents.amountsSwitchConfirm": "이 주문의 금액을 품목에서 다시 계산할까요? 원본 시스템이 제공한 합계는 대체되며 여기서는 복원할 수 없습니다.",
504
+ "sales.documents.amountsSwitchDone": "이제 금액이 품목에서 계산됩니다.",
505
+ "sales.documents.amountsSwitchFailed": "계산 금액으로 전환하지 못했습니다.",
506
+ "sales.documents.amountsSwitchToComputed": "계산 금액으로 전환",
500
507
  "sales.documents.create.title": "판매 문서 생성",
501
508
  "sales.documents.detail.addresses.add": "주소 추가",
502
509
  "sales.documents.detail.addresses.additional": "추가 주소",
@@ -1240,6 +1247,11 @@
1240
1247
  "sales.documents.status.documentPlaceholder": "새 주문/견적 상태 선택",
1241
1248
  "sales.documents.status.linePlaceholder": "항목 상태 선택",
1242
1249
  "sales.documents.status.sectionTitle": "상태 변경",
1250
+ "sales.errors.externalAdjustmentRefused": "이 주문의 금액은 외부 시스템에서 온 것이므로 조정을 추가하거나 삭제할 수 없습니다. 먼저 계산 금액으로 전환하세요.",
1251
+ "sales.errors.externalAmountsIncomplete": "{{line}}번 품목에 외부 금액이 있지만 누락된 항목이 있습니다: {{fields}}. 부분 지정은 모드가 아닙니다.",
1252
+ "sales.errors.externalModeMixed": "주문과 모든 품목은 동일한 금액 모드를 사용해야 합니다.",
1253
+ "sales.errors.externalModeUnsupportedOnQuote": "견적에는 금액 모드를 지정할 수 없습니다. 견적은 항상 계산 금액을 사용합니다.",
1254
+ "sales.errors.externalTotalsRequired": "외부 금액을 사용하는 주문은 문서 합계를 직접 제공해야 합니다: {{fields}}.",
1243
1255
  "sales.errors.invalid_query": "잘못된 조회 파라미터입니다",
1244
1256
  "sales.errors.organization_required": "조직 컨텍스트가 필요합니다",
1245
1257
  "sales.errors.tag_invalid": "잘못된 태그 형식입니다.",
@@ -497,6 +497,13 @@
497
497
  "sales.documents.adjustments.title": "Korekty",
498
498
  "sales.documents.adjustments.untitled": "Bez tytułu",
499
499
  "sales.documents.adjustments.updated": "Korekta zaktualizowana.",
500
+ "sales.documents.amountsExternal": "Kwoty ze źródła",
501
+ "sales.documents.amountsExternalHint": "Sumy tego dokumentu pochodzą z systemu zewnętrznego i są zapisane w podanej postaci.",
502
+ "sales.documents.amountsExternalMethodNote": "Metoda zostanie zapisana, ale nie doliczy opłaty — sumy tego zamówienia pochodzą z systemu źródłowego.",
503
+ "sales.documents.amountsSwitchConfirm": "Przeliczyć kwoty tego zamówienia z pozycji? Sumy podane przez system źródłowy zostaną zastąpione i nie da się ich stąd przywrócić.",
504
+ "sales.documents.amountsSwitchDone": "Kwoty są teraz wyliczane z pozycji.",
505
+ "sales.documents.amountsSwitchFailed": "Nie udało się przełączyć na kwoty wyliczane.",
506
+ "sales.documents.amountsSwitchToComputed": "Przełącz na kwoty wyliczane",
500
507
  "sales.documents.create.title": "Utwórz dokument sprzedaży",
501
508
  "sales.documents.detail.addresses.add": "Dodaj adres",
502
509
  "sales.documents.detail.addresses.additional": "Dodatkowe adresy",
@@ -1240,6 +1247,11 @@
1240
1247
  "sales.documents.status.documentPlaceholder": "Wybierz nowy status zamówienia/oferty",
1241
1248
  "sales.documents.status.linePlaceholder": "Wybierz status pozycji",
1242
1249
  "sales.documents.status.sectionTitle": "Zmiany statusu",
1250
+ "sales.errors.externalAdjustmentRefused": "To zamówienie ma kwoty z systemu zewnętrznego, więc nie można dodawać ani usuwać korekt. Najpierw przełącz je na kwoty wyliczane.",
1251
+ "sales.errors.externalAmountsIncomplete": "Pozycja {{line}} ma kwoty zewnętrzne, ale brakuje: {{fields}}. Częściowa specyfikacja nie jest trybem.",
1252
+ "sales.errors.externalModeMixed": "Zamówienie i wszystkie jego pozycje muszą mieć ten sam tryb kwot.",
1253
+ "sales.errors.externalModeUnsupportedOnQuote": "Oferty nie mogą mieć trybu kwot; zawsze używają kwot wyliczanych.",
1254
+ "sales.errors.externalTotalsRequired": "Zamówienie z kwotami zewnętrznymi musi podać własne sumy dokumentu: {{fields}}.",
1243
1255
  "sales.errors.invalid_query": "Nieprawidłowe parametry zapytania",
1244
1256
  "sales.errors.organization_required": "Wymagany jest kontekst organizacji",
1245
1257
  "sales.errors.tag_invalid": "Nieprawidłowy format tagu.",
@@ -1,6 +1,8 @@
1
1
  import { createLogger } from '@open-mercato/shared/lib/logger'
2
2
  import {
3
3
  type SalesAdjustmentDraft,
4
+ type SalesAmountsMode,
5
+ type SalesDocumentAmounts,
4
6
  type SalesCalculationContext,
5
7
  type CalculateDocumentOptions,
6
8
  type CalculateLineOptions,
@@ -115,7 +117,49 @@ function resolveLineDiscountTotal(
115
117
  return line.discountAmountBasis === 'line' ? amount : amount * quantity
116
118
  }
117
119
 
120
+ /** The header fields a caller owns under `external`; the rest stay core-derived. */
121
+ export const EXTERNAL_HEADER_FIELDS = [
122
+ 'subtotalNetAmount',
123
+ 'subtotalGrossAmount',
124
+ 'discountTotalAmount',
125
+ 'taxTotalAmount',
126
+ 'shippingNetAmount',
127
+ 'shippingGrossAmount',
128
+ 'surchargeTotalAmount',
129
+ 'grandTotalNetAmount',
130
+ 'grandTotalGrossAmount',
131
+ ] as const
132
+
133
+ export function isExternalAmountsMode(mode: SalesAmountsMode | null | undefined): boolean {
134
+ return mode === 'external'
135
+ }
136
+
137
+ function resolveSuppliedTotals(
138
+ supplied: Partial<SalesDocumentAmounts> | null | undefined
139
+ ): Pick<SalesDocumentAmounts, (typeof EXTERNAL_HEADER_FIELDS)[number]> {
140
+ const resolved = {} as Record<(typeof EXTERNAL_HEADER_FIELDS)[number], number>
141
+ for (const field of EXTERNAL_HEADER_FIELDS) {
142
+ resolved[field] = round(toNumber(supplied?.[field], 0))
143
+ }
144
+ return resolved
145
+ }
146
+
147
+ // The derived discount is signed and unclamped: a markup is a negative discount.
148
+ function buildExternalLineResult(line: SalesLineSnapshot): SalesLineCalculationResult {
149
+ const quantity = Math.max(toNumber(line.quantity, 0), 0)
150
+ const netAmount = round(toNumber(line.totalNetAmount, 0))
151
+ return {
152
+ line,
153
+ netAmount,
154
+ grossAmount: round(toNumber(line.totalGrossAmount, 0)),
155
+ taxAmount: round(toNumber(line.taxAmount, 0)),
156
+ discountAmount: round(toNumber(line.unitPriceNet, 0) * quantity - netAmount),
157
+ adjustments: [],
158
+ }
159
+ }
160
+
118
161
  function buildBaseLineResult(line: SalesLineSnapshot): SalesLineCalculationResult {
162
+ if (isExternalAmountsMode(line.amountsMode)) return buildExternalLineResult(line)
119
163
  const quantity = Math.max(toNumber(line.quantity, 0), 0)
120
164
  const taxRate = toNumber(line.taxRate, 0) / 100
121
165
  const unitNet =
@@ -195,6 +239,8 @@ function buildBaseDocumentResult(params: {
195
239
  adjustments: SalesAdjustmentDraft[]
196
240
  currencyCode: string
197
241
  existingTotals?: { paidTotalAmount?: number | null; refundedTotalAmount?: number | null }
242
+ totalsMode?: SalesAmountsMode | null
243
+ suppliedTotals?: Partial<SalesDocumentAmounts> | null
198
244
  }): SalesDocumentCalculationResult {
199
245
  const { documentKind, lines, adjustments, currencyCode } = params
200
246
  const orderedAdjustments = [...(adjustments ?? [])].sort(
@@ -313,6 +359,25 @@ function buildBaseDocumentResult(params: {
313
359
  const refundedTotalAmount = Math.max(toNumber(params.existingTotals?.refundedTotalAmount, 0), 0)
314
360
  const outstandingAmount = Math.max(grandTotalGross - paidTotalAmount + refundedTotalAmount, 0)
315
361
 
362
+ if (isExternalAmountsMode(params.totalsMode)) {
363
+ const suppliedTotals = resolveSuppliedTotals(params.suppliedTotals)
364
+ return {
365
+ kind: documentKind,
366
+ currencyCode,
367
+ lines,
368
+ adjustments: resolvedAdjustments,
369
+ metadata: {},
370
+ totals: {
371
+ ...suppliedTotals,
372
+ paidTotalAmount,
373
+ refundedTotalAmount,
374
+ outstandingAmount: round(
375
+ Math.max(suppliedTotals.grandTotalGrossAmount - paidTotalAmount + refundedTotalAmount, 0)
376
+ ),
377
+ },
378
+ }
379
+ }
380
+
316
381
  return {
317
382
  kind: documentKind,
318
383
  currencyCode,
@@ -389,11 +454,32 @@ class SalesCalculationRegistry {
389
454
  })
390
455
  }
391
456
 
457
+ // Re-applied after the registry and events, which may otherwise overwrite it.
458
+ if (isExternalAmountsMode(line.amountsMode)) {
459
+ const supplied = buildExternalLineResult(line)
460
+ current = {
461
+ ...current,
462
+ netAmount: supplied.netAmount,
463
+ grossAmount: supplied.grossAmount,
464
+ taxAmount: supplied.taxAmount,
465
+ discountAmount: supplied.discountAmount,
466
+ }
467
+ }
468
+
392
469
  return current
393
470
  }
394
471
 
395
472
  async calculateDocument(opts: CalculateDocumentOptions): Promise<SalesDocumentCalculationResult> {
396
- const { documentKind, lines, adjustments = [], context, eventBus, existingTotals } = opts
473
+ const {
474
+ documentKind,
475
+ lines,
476
+ adjustments = [],
477
+ context,
478
+ eventBus,
479
+ existingTotals,
480
+ totalsMode,
481
+ suppliedTotals,
482
+ } = opts
397
483
  const resolvedLines: SalesLineCalculationResult[] = []
398
484
 
399
485
  for (const line of lines) {
@@ -407,6 +493,8 @@ class SalesCalculationRegistry {
407
493
  adjustments,
408
494
  currencyCode: context.currencyCode,
409
495
  existingTotals,
496
+ totalsMode,
497
+ suppliedTotals,
410
498
  })
411
499
 
412
500
  if (eventBus) {
@@ -430,6 +518,7 @@ class SalesCalculationRegistry {
430
518
  context,
431
519
  current,
432
520
  eventBus,
521
+ totalsMode,
433
522
  })
434
523
  if (next) current = next
435
524
  }
@@ -453,9 +542,15 @@ class SalesCalculationRegistry {
453
542
  // outstanding back to the full grand total), producing a stale paid/
454
543
  // outstanding display after a payment. Re-apply the input totals last and
455
544
  // recompute outstanding against the post-calculation grand total.
456
- if (existingTotals) {
457
- const paidTotalAmount = Math.max(toNumber(existingTotals.paidTotalAmount, 0), 0)
458
- const refundedTotalAmount = Math.max(toNumber(existingTotals.refundedTotalAmount, 0), 0)
545
+ // Totals calculators rebuild the header from the lines, and core registers one
546
+ // itself by module side effect (lib/providers/index.ts) — so this always runs.
547
+ if (isExternalAmountsMode(totalsMode)) {
548
+ current.totals = { ...current.totals, ...resolveSuppliedTotals(suppliedTotals) }
549
+ }
550
+
551
+ if (existingTotals || isExternalAmountsMode(totalsMode)) {
552
+ const paidTotalAmount = Math.max(toNumber(existingTotals?.paidTotalAmount, 0), 0)
553
+ const refundedTotalAmount = Math.max(toNumber(existingTotals?.refundedTotalAmount, 0), 0)
459
554
  current.totals = {
460
555
  ...current.totals,
461
556
  paidTotalAmount,