@open-mercato/core 0.4.8-develop-d16e2f51dc → 0.4.8-develop-4b58cde65d
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.
- package/dist/generated/entities/sales_return/index.js +31 -0
- package/dist/generated/entities/sales_return/index.js.map +7 -0
- package/dist/generated/entities/sales_return_line/index.js +29 -0
- package/dist/generated/entities/sales_return_line/index.js.map +7 -0
- package/dist/generated/entities.ids.generated.js +2 -0
- package/dist/generated/entities.ids.generated.js.map +2 -2
- package/dist/generated/entity-fields-registry.js +4 -0
- package/dist/generated/entity-fields-registry.js.map +2 -2
- package/dist/modules/sales/acl.js +2 -0
- package/dist/modules/sales/acl.js.map +2 -2
- package/dist/modules/sales/api/document-history/route.js +20 -2
- package/dist/modules/sales/api/document-history/route.js.map +2 -2
- package/dist/modules/sales/api/documents/factory.js +34 -0
- package/dist/modules/sales/api/documents/factory.js.map +2 -2
- package/dist/modules/sales/api/returns/[id]/route.js +147 -0
- package/dist/modules/sales/api/returns/[id]/route.js.map +7 -0
- package/dist/modules/sales/api/returns/route.js +158 -0
- package/dist/modules/sales/api/returns/route.js.map +7 -0
- package/dist/modules/sales/backend/sales/documents/[id]/page.js +20 -1
- package/dist/modules/sales/backend/sales/documents/[id]/page.js.map +2 -2
- package/dist/modules/sales/commands/index.js +1 -0
- package/dist/modules/sales/commands/index.js.map +2 -2
- package/dist/modules/sales/commands/returns.js +467 -0
- package/dist/modules/sales/commands/returns.js.map +7 -0
- package/dist/modules/sales/components/documents/ReturnDialog.js +176 -0
- package/dist/modules/sales/components/documents/ReturnDialog.js.map +7 -0
- package/dist/modules/sales/components/documents/ReturnsSection.js +188 -0
- package/dist/modules/sales/components/documents/ReturnsSection.js.map +7 -0
- package/dist/modules/sales/data/entities.js +115 -1
- package/dist/modules/sales/data/entities.js.map +2 -2
- package/dist/modules/sales/data/validators.js +13 -0
- package/dist/modules/sales/data/validators.js.map +2 -2
- package/dist/modules/sales/events.js +4 -0
- package/dist/modules/sales/events.js.map +2 -2
- package/dist/modules/sales/lib/calculations.js +7 -0
- package/dist/modules/sales/lib/calculations.js.map +2 -2
- package/dist/modules/sales/lib/dictionaries.js +1 -0
- package/dist/modules/sales/lib/dictionaries.js.map +2 -2
- package/dist/modules/sales/lib/documentNumberTokens.js +2 -0
- package/dist/modules/sales/lib/documentNumberTokens.js.map +2 -2
- package/dist/modules/sales/lib/historyHelpers.js +15 -7
- package/dist/modules/sales/lib/historyHelpers.js.map +2 -2
- package/dist/modules/sales/lib/makeSalesLineRoute.js +42 -37
- package/dist/modules/sales/lib/makeSalesLineRoute.js.map +2 -2
- package/dist/modules/sales/migrations/Migration20260309073310.js +23 -0
- package/dist/modules/sales/migrations/Migration20260309073310.js.map +7 -0
- package/dist/modules/sales/services/salesDocumentNumberGenerator.js +8 -6
- package/dist/modules/sales/services/salesDocumentNumberGenerator.js.map +2 -2
- package/dist/modules/sales/setup.js +1 -1
- package/dist/modules/sales/setup.js.map +2 -2
- package/dist/modules/sales/widgets/injection/document-history/widget.client.js +25 -16
- package/dist/modules/sales/widgets/injection/document-history/widget.client.js.map +2 -2
- package/generated/entities/sales_return/index.ts +14 -0
- package/generated/entities/sales_return_line/index.ts +13 -0
- package/generated/entities.ids.generated.ts +2 -0
- package/generated/entity-fields-registry.ts +4 -0
- package/package.json +3 -3
- package/src/modules/sales/AGENTS.md +1 -0
- package/src/modules/sales/acl.ts +2 -0
- package/src/modules/sales/api/document-history/route.ts +25 -1
- package/src/modules/sales/api/documents/factory.ts +35 -0
- package/src/modules/sales/api/returns/[id]/route.ts +156 -0
- package/src/modules/sales/api/returns/route.ts +171 -0
- package/src/modules/sales/backend/sales/documents/[id]/page.tsx +18 -0
- package/src/modules/sales/commands/index.ts +1 -0
- package/src/modules/sales/commands/returns.ts +540 -0
- package/src/modules/sales/components/documents/ReturnDialog.tsx +216 -0
- package/src/modules/sales/components/documents/ReturnsSection.tsx +270 -0
- package/src/modules/sales/data/entities.ts +99 -3
- package/src/modules/sales/data/validators.ts +16 -0
- package/src/modules/sales/events.ts +5 -0
- package/src/modules/sales/i18n/de.json +32 -0
- package/src/modules/sales/i18n/en.json +32 -0
- package/src/modules/sales/i18n/es.json +32 -0
- package/src/modules/sales/i18n/pl.json +32 -0
- package/src/modules/sales/lib/calculations.ts +9 -0
- package/src/modules/sales/lib/dictionaries.ts +1 -0
- package/src/modules/sales/lib/documentNumberTokens.ts +2 -1
- package/src/modules/sales/lib/historyHelpers.ts +20 -9
- package/src/modules/sales/lib/makeSalesLineRoute.ts +42 -37
- package/src/modules/sales/migrations/.snapshot-open-mercato.json +398 -0
- package/src/modules/sales/migrations/Migration20260309073310.ts +26 -0
- package/src/modules/sales/services/salesDocumentNumberGenerator.ts +15 -4
- package/src/modules/sales/setup.ts +1 -1
- package/src/modules/sales/widgets/injection/document-history/widget.client.tsx +26 -17
|
@@ -40,6 +40,7 @@ import { SalesDocumentPaymentsSection } from '@open-mercato/core/modules/sales/c
|
|
|
40
40
|
import { SalesDocumentAdjustmentsSection } from '@open-mercato/core/modules/sales/components/documents/AdjustmentsSection'
|
|
41
41
|
import type { AdjustmentRowData } from '@open-mercato/core/modules/sales/components/documents/AdjustmentDialog'
|
|
42
42
|
import { SalesShipmentsSection } from '@open-mercato/core/modules/sales/components/documents/ShipmentsSection'
|
|
43
|
+
import { SalesReturnsSection } from '@open-mercato/core/modules/sales/components/documents/ReturnsSection'
|
|
43
44
|
import { DocumentTotals } from '@open-mercato/core/modules/sales/components/documents/DocumentTotals'
|
|
44
45
|
import { E } from '#generated/entities.ids.generated'
|
|
45
46
|
import type { DictionarySelectLabels } from '@open-mercato/core/modules/dictionaries/components/DictionaryEntrySelect'
|
|
@@ -3945,6 +3946,7 @@ export default function SalesDocumentDetailPage({
|
|
|
3945
3946
|
tabs.push(
|
|
3946
3947
|
{ id: 'shipments', label: t('sales.documents.detail.tabs.shipments', 'Shipments') },
|
|
3947
3948
|
{ id: 'payments', label: t('sales.documents.detail.tabs.payments', 'Payments') },
|
|
3949
|
+
{ id: 'returns', label: t('sales.documents.detail.tabs.returns', 'Returns') },
|
|
3948
3950
|
)
|
|
3949
3951
|
}
|
|
3950
3952
|
tabs.push({ id: 'adjustments', label: t('sales.documents.detail.tabs.adjustments', 'Adjustments') })
|
|
@@ -4083,6 +4085,10 @@ export default function SalesDocumentDetailPage({
|
|
|
4083
4085
|
title: t('sales.documents.detail.empty.payments.title', 'No payments yet.'),
|
|
4084
4086
|
description: t('sales.documents.detail.empty.payments.description', 'Payments are work in progress.'),
|
|
4085
4087
|
},
|
|
4088
|
+
returns: {
|
|
4089
|
+
title: t('sales.returns.empty.title', 'No returns yet.'),
|
|
4090
|
+
description: t('sales.returns.empty.description', 'Create a return to generate credit adjustments for returned items.'),
|
|
4091
|
+
},
|
|
4086
4092
|
adjustments: {
|
|
4087
4093
|
title: t('sales.documents.detail.empty.adjustments.title', 'No adjustments yet.'),
|
|
4088
4094
|
description: t(
|
|
@@ -4173,6 +4179,18 @@ export default function SalesDocumentDetailPage({
|
|
|
4173
4179
|
/>
|
|
4174
4180
|
)
|
|
4175
4181
|
}
|
|
4182
|
+
if (activeTab === 'returns') {
|
|
4183
|
+
if (kind !== 'order') {
|
|
4184
|
+
const placeholder = tabEmptyStates.returns
|
|
4185
|
+
return <TabEmptyState title={placeholder.title} description={placeholder.description} />
|
|
4186
|
+
}
|
|
4187
|
+
return (
|
|
4188
|
+
<SalesReturnsSection
|
|
4189
|
+
orderId={record.id}
|
|
4190
|
+
currencyCode={record.currencyCode ?? null}
|
|
4191
|
+
/>
|
|
4192
|
+
)
|
|
4193
|
+
}
|
|
4176
4194
|
if (activeTab === 'adjustments') {
|
|
4177
4195
|
return (
|
|
4178
4196
|
<SalesDocumentAdjustmentsSection
|
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
import { randomUUID } from 'crypto'
|
|
2
|
+
import { registerCommand, type CommandHandler } from '@open-mercato/shared/lib/commands'
|
|
3
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
4
|
+
import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
5
|
+
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
6
|
+
import { emitCrudSideEffects } from '@open-mercato/shared/lib/commands/helpers'
|
|
7
|
+
import type { CrudEventsConfig } from '@open-mercato/shared/lib/crud/types'
|
|
8
|
+
import type { DataEngine } from '@open-mercato/shared/lib/data/engine'
|
|
9
|
+
import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
10
|
+
import { SalesDocumentNumberGenerator } from '../services/salesDocumentNumberGenerator'
|
|
11
|
+
import type { SalesCalculationService } from '../services/salesCalculationService'
|
|
12
|
+
import type { SalesAdjustmentDraft, SalesLineSnapshot, SalesDocumentCalculationResult } from '../lib/types'
|
|
13
|
+
import { cloneJson, ensureOrganizationScope, ensureSameScope, ensureTenantScope, extractUndoPayload, toNumericString } from './shared'
|
|
14
|
+
import { SalesOrder, SalesOrderAdjustment, SalesOrderLine, SalesReturn, SalesReturnLine } from '../data/entities'
|
|
15
|
+
import { returnCreateSchema, type ReturnCreateInput } from '../data/validators'
|
|
16
|
+
import { E } from '#generated/entities.ids.generated'
|
|
17
|
+
|
|
18
|
+
type ReturnLineInput = { orderLineId: string; quantity: number }
|
|
19
|
+
|
|
20
|
+
type ReturnSnapshot = {
|
|
21
|
+
id: string
|
|
22
|
+
orderId: string
|
|
23
|
+
organizationId: string
|
|
24
|
+
tenantId: string
|
|
25
|
+
returnNumber: string
|
|
26
|
+
returnedAt: string | null
|
|
27
|
+
reason: string | null
|
|
28
|
+
notes: string | null
|
|
29
|
+
lines: Array<{
|
|
30
|
+
id: string
|
|
31
|
+
orderLineId: string
|
|
32
|
+
quantityReturned: number
|
|
33
|
+
unitPriceNet: number
|
|
34
|
+
unitPriceGross: number
|
|
35
|
+
totalNetAmount: number
|
|
36
|
+
totalGrossAmount: number
|
|
37
|
+
}>
|
|
38
|
+
adjustmentIds: string[]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type ReturnUndoPayload = {
|
|
42
|
+
after?: ReturnSnapshot | null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const returnCrudEvents: CrudEventsConfig = {
|
|
46
|
+
module: 'sales',
|
|
47
|
+
entity: 'return',
|
|
48
|
+
persistent: true,
|
|
49
|
+
buildPayload: (ctx) => ({
|
|
50
|
+
id: ctx.identifiers.id,
|
|
51
|
+
organizationId: ctx.identifiers.organizationId,
|
|
52
|
+
tenantId: ctx.identifiers.tenantId,
|
|
53
|
+
}),
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function toNumeric(value: unknown): number {
|
|
57
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value
|
|
58
|
+
if (typeof value === 'string' && value.trim().length) {
|
|
59
|
+
const parsed = Number(value)
|
|
60
|
+
if (Number.isFinite(parsed)) return parsed
|
|
61
|
+
}
|
|
62
|
+
return 0
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function round(value: number): number {
|
|
66
|
+
return Math.round((value + Number.EPSILON) * 1e4) / 1e4
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function applyOrderTotals(order: SalesOrder, totals: SalesDocumentCalculationResult['totals'], lineCount: number): void {
|
|
70
|
+
order.subtotalNetAmount = toNumericString(totals.subtotalNetAmount) ?? '0'
|
|
71
|
+
order.subtotalGrossAmount = toNumericString(totals.subtotalGrossAmount) ?? '0'
|
|
72
|
+
order.discountTotalAmount = toNumericString(totals.discountTotalAmount) ?? '0'
|
|
73
|
+
order.taxTotalAmount = toNumericString(totals.taxTotalAmount) ?? '0'
|
|
74
|
+
order.shippingNetAmount = toNumericString(totals.shippingNetAmount) ?? '0'
|
|
75
|
+
order.shippingGrossAmount = toNumericString(totals.shippingGrossAmount) ?? '0'
|
|
76
|
+
order.surchargeTotalAmount = toNumericString(totals.surchargeTotalAmount) ?? '0'
|
|
77
|
+
order.grandTotalNetAmount = toNumericString(totals.grandTotalNetAmount) ?? '0'
|
|
78
|
+
order.grandTotalGrossAmount = toNumericString(totals.grandTotalGrossAmount) ?? '0'
|
|
79
|
+
order.paidTotalAmount = toNumericString(totals.paidTotalAmount) ?? '0'
|
|
80
|
+
order.refundedTotalAmount = toNumericString(totals.refundedTotalAmount) ?? '0'
|
|
81
|
+
order.outstandingAmount = toNumericString(totals.outstandingAmount) ?? '0'
|
|
82
|
+
order.totalsSnapshot = cloneJson(totals)
|
|
83
|
+
order.lineItemCount = lineCount
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function mapOrderLineEntityToSnapshot(line: SalesOrderLine): SalesLineSnapshot {
|
|
87
|
+
return {
|
|
88
|
+
id: line.id,
|
|
89
|
+
lineNumber: line.lineNumber,
|
|
90
|
+
kind: line.kind,
|
|
91
|
+
productId: line.productId ?? null,
|
|
92
|
+
productVariantId: line.productVariantId ?? null,
|
|
93
|
+
name: line.name ?? null,
|
|
94
|
+
description: line.description ?? null,
|
|
95
|
+
comment: line.comment ?? null,
|
|
96
|
+
quantity: toNumeric(line.quantity),
|
|
97
|
+
quantityUnit: line.quantityUnit ?? null,
|
|
98
|
+
normalizedQuantity: toNumeric(line.normalizedQuantity ?? line.quantity),
|
|
99
|
+
normalizedUnit: line.normalizedUnit ?? line.quantityUnit ?? null,
|
|
100
|
+
uomSnapshot: line.uomSnapshot ? cloneJson(line.uomSnapshot) : null,
|
|
101
|
+
currencyCode: line.currencyCode,
|
|
102
|
+
unitPriceNet: toNumeric(line.unitPriceNet),
|
|
103
|
+
unitPriceGross: toNumeric(line.unitPriceGross),
|
|
104
|
+
discountAmount: toNumeric(line.discountAmount),
|
|
105
|
+
discountPercent: toNumeric(line.discountPercent),
|
|
106
|
+
taxRate: toNumeric(line.taxRate),
|
|
107
|
+
taxAmount: toNumeric(line.taxAmount),
|
|
108
|
+
totalNetAmount: toNumeric(line.totalNetAmount),
|
|
109
|
+
totalGrossAmount: toNumeric(line.totalGrossAmount),
|
|
110
|
+
configuration: line.configuration ? cloneJson(line.configuration) : null,
|
|
111
|
+
promotionCode: line.promotionCode ?? null,
|
|
112
|
+
metadata: line.metadata ? cloneJson(line.metadata) : null,
|
|
113
|
+
customFieldSetId: line.customFieldSetId ?? null,
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function mapOrderAdjustmentToDraft(adjustment: SalesOrderAdjustment): SalesAdjustmentDraft {
|
|
118
|
+
return {
|
|
119
|
+
id: adjustment.id,
|
|
120
|
+
scope: adjustment.scope ?? 'order',
|
|
121
|
+
kind: adjustment.kind,
|
|
122
|
+
code: adjustment.code ?? null,
|
|
123
|
+
label: adjustment.label ?? null,
|
|
124
|
+
calculatorKey: adjustment.calculatorKey ?? null,
|
|
125
|
+
promotionId: adjustment.promotionId ?? null,
|
|
126
|
+
rate: toNumeric(adjustment.rate),
|
|
127
|
+
amountNet: toNumeric(adjustment.amountNet),
|
|
128
|
+
amountGross: toNumeric(adjustment.amountGross),
|
|
129
|
+
currencyCode: adjustment.currencyCode ?? null,
|
|
130
|
+
metadata: adjustment.metadata ? cloneJson(adjustment.metadata) : null,
|
|
131
|
+
position: adjustment.position ?? 0,
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function buildCalculationContext(order: SalesOrder) {
|
|
136
|
+
return {
|
|
137
|
+
tenantId: order.tenantId,
|
|
138
|
+
organizationId: order.organizationId,
|
|
139
|
+
currencyCode: order.currencyCode,
|
|
140
|
+
metadata: {
|
|
141
|
+
shippingMethod: order.shippingMethodSnapshot
|
|
142
|
+
? cloneJson(order.shippingMethodSnapshot as Record<string, unknown>)
|
|
143
|
+
: null,
|
|
144
|
+
paymentMethod: order.paymentMethodSnapshot ? cloneJson(order.paymentMethodSnapshot as Record<string, unknown>) : null,
|
|
145
|
+
},
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Recalculates order totals (including line-scoped return adjustments) for display.
|
|
151
|
+
* Returns the totals object to merge into an order API response, or null if order not found.
|
|
152
|
+
*/
|
|
153
|
+
export async function recalculateOrderTotalsForDisplay(
|
|
154
|
+
em: EntityManager,
|
|
155
|
+
container: { resolve: (key: string) => unknown },
|
|
156
|
+
orderId: string,
|
|
157
|
+
scope: { tenantId: string; organizationId: string },
|
|
158
|
+
): Promise<SalesDocumentCalculationResult['totals'] | null> {
|
|
159
|
+
const order = await findOneWithDecryption(
|
|
160
|
+
em,
|
|
161
|
+
SalesOrder,
|
|
162
|
+
{ id: orderId, deletedAt: null },
|
|
163
|
+
{},
|
|
164
|
+
scope,
|
|
165
|
+
)
|
|
166
|
+
if (!order) return null
|
|
167
|
+
const [orderLines, adjustments] = await Promise.all([
|
|
168
|
+
findWithDecryption(em, SalesOrderLine, { order: order.id, deletedAt: null }, {}, scope),
|
|
169
|
+
findWithDecryption(
|
|
170
|
+
em,
|
|
171
|
+
SalesOrderAdjustment,
|
|
172
|
+
{ order: order.id, deletedAt: null },
|
|
173
|
+
{ orderBy: { position: 'asc' } },
|
|
174
|
+
scope,
|
|
175
|
+
),
|
|
176
|
+
])
|
|
177
|
+
const lineSnapshots: SalesLineSnapshot[] = orderLines.map(mapOrderLineEntityToSnapshot)
|
|
178
|
+
const adjustmentDrafts: SalesAdjustmentDraft[] = adjustments.map(mapOrderAdjustmentToDraft)
|
|
179
|
+
const salesCalculationService = container.resolve('salesCalculationService') as SalesCalculationService
|
|
180
|
+
const calculation = await salesCalculationService.calculateDocumentTotals({
|
|
181
|
+
documentKind: 'order',
|
|
182
|
+
lines: lineSnapshots,
|
|
183
|
+
adjustments: adjustmentDrafts,
|
|
184
|
+
context: buildCalculationContext(order),
|
|
185
|
+
existingTotals: {
|
|
186
|
+
paidTotalAmount: toNumeric(order.paidTotalAmount),
|
|
187
|
+
refundedTotalAmount: toNumeric(order.refundedTotalAmount),
|
|
188
|
+
},
|
|
189
|
+
})
|
|
190
|
+
return calculation.totals
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export async function loadReturnSnapshot(em: EntityManager, id: string): Promise<ReturnSnapshot | null> {
|
|
194
|
+
const header = await findOneWithDecryption(
|
|
195
|
+
em,
|
|
196
|
+
SalesReturn,
|
|
197
|
+
{ id, deletedAt: null },
|
|
198
|
+
{ populate: ['order'] },
|
|
199
|
+
{},
|
|
200
|
+
)
|
|
201
|
+
if (!header || !header.order) return null
|
|
202
|
+
const orderId = typeof header.order === 'string' ? header.order : header.order.id
|
|
203
|
+
const lines = await findWithDecryption(
|
|
204
|
+
em,
|
|
205
|
+
SalesReturnLine,
|
|
206
|
+
{ salesReturn: header.id, deletedAt: null },
|
|
207
|
+
{ populate: ['orderLine'] },
|
|
208
|
+
{ tenantId: header.tenantId, organizationId: header.organizationId },
|
|
209
|
+
)
|
|
210
|
+
const adjustmentIds: string[] = []
|
|
211
|
+
const adjustments = await findWithDecryption(
|
|
212
|
+
em,
|
|
213
|
+
SalesOrderAdjustment,
|
|
214
|
+
{ order: orderId, kind: 'return', deletedAt: null },
|
|
215
|
+
{},
|
|
216
|
+
{ tenantId: header.tenantId, organizationId: header.organizationId },
|
|
217
|
+
)
|
|
218
|
+
adjustments.forEach((adj) => {
|
|
219
|
+
const meta = adj.metadata as Record<string, unknown> | null | undefined
|
|
220
|
+
if (meta && meta.returnId === header.id) adjustmentIds.push(adj.id)
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
id: header.id,
|
|
225
|
+
orderId,
|
|
226
|
+
organizationId: header.organizationId,
|
|
227
|
+
tenantId: header.tenantId,
|
|
228
|
+
returnNumber: header.returnNumber,
|
|
229
|
+
returnedAt: header.returnedAt ? header.returnedAt.toISOString() : null,
|
|
230
|
+
reason: header.reason ?? null,
|
|
231
|
+
notes: header.notes ?? null,
|
|
232
|
+
lines: lines.map((line) => ({
|
|
233
|
+
id: line.id,
|
|
234
|
+
orderLineId: typeof line.orderLine === 'string' ? line.orderLine : line.orderLine?.id ?? null,
|
|
235
|
+
quantityReturned: toNumeric(line.quantityReturned),
|
|
236
|
+
unitPriceNet: toNumeric(line.unitPriceNet),
|
|
237
|
+
unitPriceGross: toNumeric(line.unitPriceGross),
|
|
238
|
+
totalNetAmount: toNumeric(line.totalNetAmount),
|
|
239
|
+
totalGrossAmount: toNumeric(line.totalGrossAmount),
|
|
240
|
+
})),
|
|
241
|
+
adjustmentIds,
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function normalizeLinesInput(lines: ReturnCreateInput['lines']): ReturnLineInput[] {
|
|
246
|
+
const seen = new Set<string>()
|
|
247
|
+
const result: ReturnLineInput[] = []
|
|
248
|
+
for (const line of lines) {
|
|
249
|
+
const orderLineId = line.orderLineId
|
|
250
|
+
if (!orderLineId || seen.has(orderLineId)) continue
|
|
251
|
+
const quantity = toNumeric(line.quantity)
|
|
252
|
+
if (!Number.isFinite(quantity) || quantity <= 0) continue
|
|
253
|
+
seen.add(orderLineId)
|
|
254
|
+
result.push({ orderLineId, quantity })
|
|
255
|
+
}
|
|
256
|
+
return result
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const createReturnCommand: CommandHandler<ReturnCreateInput, { returnId: string }> = {
|
|
260
|
+
id: 'sales.returns.create',
|
|
261
|
+
async execute(rawInput, ctx) {
|
|
262
|
+
const input = returnCreateSchema.parse(rawInput ?? {})
|
|
263
|
+
ensureTenantScope(ctx, input.tenantId)
|
|
264
|
+
ensureOrganizationScope(ctx, input.organizationId)
|
|
265
|
+
|
|
266
|
+
const { translate } = await resolveTranslations()
|
|
267
|
+
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
268
|
+
|
|
269
|
+
const order = await findOneWithDecryption(
|
|
270
|
+
em,
|
|
271
|
+
SalesOrder,
|
|
272
|
+
{ id: input.orderId, deletedAt: null },
|
|
273
|
+
{},
|
|
274
|
+
{ tenantId: input.tenantId, organizationId: input.organizationId },
|
|
275
|
+
)
|
|
276
|
+
if (!order) {
|
|
277
|
+
throw new CrudHttpError(404, { error: translate('sales.returns.orderMissing', 'Order not found.') })
|
|
278
|
+
}
|
|
279
|
+
ensureSameScope(order, input.organizationId, input.tenantId)
|
|
280
|
+
|
|
281
|
+
const requested = normalizeLinesInput(input.lines)
|
|
282
|
+
if (!requested.length) {
|
|
283
|
+
throw new CrudHttpError(400, { error: translate('sales.returns.linesRequired', 'Select at least one line to return.') })
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const orderLines = await findWithDecryption(
|
|
287
|
+
em,
|
|
288
|
+
SalesOrderLine,
|
|
289
|
+
{ order: order.id, deletedAt: null },
|
|
290
|
+
{},
|
|
291
|
+
{ tenantId: input.tenantId, organizationId: input.organizationId },
|
|
292
|
+
)
|
|
293
|
+
const lineMap = new Map(orderLines.map((line) => [line.id, line]))
|
|
294
|
+
|
|
295
|
+
requested.forEach(({ orderLineId, quantity }) => {
|
|
296
|
+
const line = lineMap.get(orderLineId)
|
|
297
|
+
if (!line) {
|
|
298
|
+
throw new CrudHttpError(404, { error: translate('sales.returns.lineMissing', 'Order line not found.') })
|
|
299
|
+
}
|
|
300
|
+
const available = toNumeric(line.quantity) - toNumeric(line.returnedQuantity)
|
|
301
|
+
if (quantity - 1e-6 > available) {
|
|
302
|
+
throw new CrudHttpError(400, { error: translate('sales.returns.quantityExceeded', 'Cannot return more than the remaining quantity.') })
|
|
303
|
+
}
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
const existingAdjustments = await findWithDecryption(
|
|
307
|
+
em,
|
|
308
|
+
SalesOrderAdjustment,
|
|
309
|
+
{ order: order.id, deletedAt: null },
|
|
310
|
+
{ orderBy: { position: 'asc' } },
|
|
311
|
+
{ tenantId: input.tenantId, organizationId: input.organizationId },
|
|
312
|
+
)
|
|
313
|
+
const positionStart = existingAdjustments.reduce((acc, adj) => Math.max(acc, adj.position ?? 0), 0) + 1
|
|
314
|
+
|
|
315
|
+
const numberGenerator = new SalesDocumentNumberGenerator(em)
|
|
316
|
+
const generated = await numberGenerator.generate({
|
|
317
|
+
kind: 'return',
|
|
318
|
+
tenantId: input.tenantId,
|
|
319
|
+
organizationId: input.organizationId,
|
|
320
|
+
})
|
|
321
|
+
const returnId = randomUUID()
|
|
322
|
+
const header = em.create(SalesReturn, {
|
|
323
|
+
id: returnId,
|
|
324
|
+
order,
|
|
325
|
+
organizationId: input.organizationId,
|
|
326
|
+
tenantId: input.tenantId,
|
|
327
|
+
returnNumber: generated.number,
|
|
328
|
+
reason: input.reason ?? null,
|
|
329
|
+
notes: input.notes ?? null,
|
|
330
|
+
returnedAt: input.returnedAt ?? new Date(),
|
|
331
|
+
createdAt: new Date(),
|
|
332
|
+
updatedAt: new Date(),
|
|
333
|
+
})
|
|
334
|
+
em.persist(header)
|
|
335
|
+
|
|
336
|
+
const createdAdjustments: SalesOrderAdjustment[] = []
|
|
337
|
+
const createdLines: SalesReturnLine[] = []
|
|
338
|
+
requested.forEach((lineInput, index) => {
|
|
339
|
+
const line = lineMap.get(lineInput.orderLineId)
|
|
340
|
+
if (!line) return
|
|
341
|
+
const quantity = lineInput.quantity
|
|
342
|
+
const lineQuantity = Math.max(toNumeric(line.quantity), 0)
|
|
343
|
+
const unitNet = lineQuantity > 0 ? toNumeric(line.totalNetAmount) / lineQuantity : toNumeric(line.unitPriceNet)
|
|
344
|
+
const unitGross = lineQuantity > 0 ? toNumeric(line.totalGrossAmount) / lineQuantity : toNumeric(line.unitPriceGross)
|
|
345
|
+
const totalNet = -round(Math.max(unitNet, 0) * quantity)
|
|
346
|
+
const totalGross = -round(Math.max(unitGross, 0) * quantity)
|
|
347
|
+
|
|
348
|
+
const returnLineId = randomUUID()
|
|
349
|
+
const returnLine = em.create(SalesReturnLine, {
|
|
350
|
+
id: returnLineId,
|
|
351
|
+
salesReturn: header,
|
|
352
|
+
orderLine: em.getReference(SalesOrderLine, line.id),
|
|
353
|
+
organizationId: input.organizationId,
|
|
354
|
+
tenantId: input.tenantId,
|
|
355
|
+
quantityReturned: quantity.toString(),
|
|
356
|
+
unitPriceNet: round(unitNet).toString(),
|
|
357
|
+
unitPriceGross: round(unitGross).toString(),
|
|
358
|
+
totalNetAmount: totalNet.toString(),
|
|
359
|
+
totalGrossAmount: totalGross.toString(),
|
|
360
|
+
createdAt: new Date(),
|
|
361
|
+
updatedAt: new Date(),
|
|
362
|
+
})
|
|
363
|
+
createdLines.push(returnLine)
|
|
364
|
+
em.persist(returnLine)
|
|
365
|
+
|
|
366
|
+
const adjustment = em.create(SalesOrderAdjustment, {
|
|
367
|
+
id: randomUUID(),
|
|
368
|
+
order,
|
|
369
|
+
orderLine: em.getReference(SalesOrderLine, line.id),
|
|
370
|
+
organizationId: input.organizationId,
|
|
371
|
+
tenantId: input.tenantId,
|
|
372
|
+
scope: 'line',
|
|
373
|
+
kind: 'return',
|
|
374
|
+
rate: '0',
|
|
375
|
+
amountNet: totalNet.toString(),
|
|
376
|
+
amountGross: totalGross.toString(),
|
|
377
|
+
currencyCode: order.currencyCode,
|
|
378
|
+
metadata: { returnId, returnLineId },
|
|
379
|
+
position: positionStart + index,
|
|
380
|
+
createdAt: new Date(),
|
|
381
|
+
updatedAt: new Date(),
|
|
382
|
+
})
|
|
383
|
+
createdAdjustments.push(adjustment)
|
|
384
|
+
em.persist(adjustment)
|
|
385
|
+
|
|
386
|
+
line.returnedQuantity = (toNumeric(line.returnedQuantity) + quantity).toString()
|
|
387
|
+
line.updatedAt = new Date()
|
|
388
|
+
em.persist(line)
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
const salesCalculationService = ctx.container.resolve<SalesCalculationService>('salesCalculationService')
|
|
392
|
+
const lineSnapshots: SalesLineSnapshot[] = orderLines.map(mapOrderLineEntityToSnapshot)
|
|
393
|
+
const adjustmentDrafts: SalesAdjustmentDraft[] = [...existingAdjustments, ...createdAdjustments].map(mapOrderAdjustmentToDraft)
|
|
394
|
+
const calculation = await salesCalculationService.calculateDocumentTotals({
|
|
395
|
+
documentKind: 'order',
|
|
396
|
+
lines: lineSnapshots,
|
|
397
|
+
adjustments: adjustmentDrafts,
|
|
398
|
+
context: buildCalculationContext(order),
|
|
399
|
+
})
|
|
400
|
+
applyOrderTotals(order, calculation.totals, calculation.lines.length)
|
|
401
|
+
order.updatedAt = new Date()
|
|
402
|
+
em.persist(order)
|
|
403
|
+
|
|
404
|
+
await em.flush()
|
|
405
|
+
|
|
406
|
+
const dataEngine = ctx.container.resolve('dataEngine') as DataEngine
|
|
407
|
+
await emitCrudSideEffects({
|
|
408
|
+
dataEngine,
|
|
409
|
+
action: 'created',
|
|
410
|
+
entity: header,
|
|
411
|
+
identifiers: { id: header.id, organizationId: header.organizationId, tenantId: header.tenantId },
|
|
412
|
+
indexer: { entityType: E.sales.sales_return },
|
|
413
|
+
events: returnCrudEvents,
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
if (createdLines.length) {
|
|
417
|
+
await Promise.all(
|
|
418
|
+
createdLines.map((line) =>
|
|
419
|
+
emitCrudSideEffects({
|
|
420
|
+
dataEngine,
|
|
421
|
+
action: 'created',
|
|
422
|
+
entity: line,
|
|
423
|
+
identifiers: { id: line.id, organizationId: line.organizationId, tenantId: line.tenantId },
|
|
424
|
+
indexer: { entityType: E.sales.sales_return_line },
|
|
425
|
+
}),
|
|
426
|
+
),
|
|
427
|
+
)
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
return { returnId }
|
|
431
|
+
},
|
|
432
|
+
captureAfter: async (_input, result, ctx) => {
|
|
433
|
+
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
434
|
+
return loadReturnSnapshot(em, result.returnId)
|
|
435
|
+
},
|
|
436
|
+
buildLog: async ({ result, snapshots }) => {
|
|
437
|
+
const after = snapshots.after as ReturnSnapshot | undefined
|
|
438
|
+
if (!after) return null
|
|
439
|
+
const { translate } = await resolveTranslations()
|
|
440
|
+
return {
|
|
441
|
+
actionLabel: translate('sales.audit.returns.create', 'Create return'),
|
|
442
|
+
resourceKind: 'sales.return',
|
|
443
|
+
resourceId: result.returnId,
|
|
444
|
+
parentResourceKind: 'sales.order',
|
|
445
|
+
parentResourceId: after.orderId ?? null,
|
|
446
|
+
tenantId: after.tenantId,
|
|
447
|
+
organizationId: after.organizationId,
|
|
448
|
+
snapshotAfter: after,
|
|
449
|
+
payload: {
|
|
450
|
+
undo: { after } satisfies ReturnUndoPayload,
|
|
451
|
+
},
|
|
452
|
+
}
|
|
453
|
+
},
|
|
454
|
+
undo: async ({ logEntry, ctx }) => {
|
|
455
|
+
const payload = extractUndoPayload<ReturnUndoPayload>(logEntry)
|
|
456
|
+
const after = payload?.after
|
|
457
|
+
if (!after) return
|
|
458
|
+
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
459
|
+
const order = await findOneWithDecryption(
|
|
460
|
+
em,
|
|
461
|
+
SalesOrder,
|
|
462
|
+
{ id: after.orderId, deletedAt: null },
|
|
463
|
+
{},
|
|
464
|
+
{ tenantId: after.tenantId, organizationId: after.organizationId },
|
|
465
|
+
)
|
|
466
|
+
if (!order) return
|
|
467
|
+
|
|
468
|
+
const lines = await findWithDecryption(
|
|
469
|
+
em,
|
|
470
|
+
SalesOrderLine,
|
|
471
|
+
{ order: order.id, deletedAt: null },
|
|
472
|
+
{},
|
|
473
|
+
{ tenantId: after.tenantId, organizationId: after.organizationId },
|
|
474
|
+
)
|
|
475
|
+
const lineMap = new Map(lines.map((line) => [line.id, line]))
|
|
476
|
+
after.lines.forEach((entry) => {
|
|
477
|
+
const line = lineMap.get(entry.orderLineId)
|
|
478
|
+
if (!line) return
|
|
479
|
+
const next = Math.max(0, toNumeric(line.returnedQuantity) - entry.quantityReturned)
|
|
480
|
+
line.returnedQuantity = next.toString()
|
|
481
|
+
line.updatedAt = new Date()
|
|
482
|
+
em.persist(line)
|
|
483
|
+
})
|
|
484
|
+
|
|
485
|
+
if (after.adjustmentIds.length) {
|
|
486
|
+
const adjustments = await findWithDecryption(
|
|
487
|
+
em,
|
|
488
|
+
SalesOrderAdjustment,
|
|
489
|
+
{ id: { $in: after.adjustmentIds }, deletedAt: null },
|
|
490
|
+
{},
|
|
491
|
+
{ tenantId: after.tenantId, organizationId: after.organizationId },
|
|
492
|
+
)
|
|
493
|
+
adjustments.forEach((adj) => em.remove(adj))
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const header = await findOneWithDecryption(
|
|
497
|
+
em,
|
|
498
|
+
SalesReturn,
|
|
499
|
+
{ id: after.id, deletedAt: null },
|
|
500
|
+
{},
|
|
501
|
+
{ tenantId: after.tenantId, organizationId: after.organizationId },
|
|
502
|
+
)
|
|
503
|
+
const returnLines = await findWithDecryption(
|
|
504
|
+
em,
|
|
505
|
+
SalesReturnLine,
|
|
506
|
+
{ salesReturn: after.id, deletedAt: null },
|
|
507
|
+
{},
|
|
508
|
+
{ tenantId: after.tenantId, organizationId: after.organizationId },
|
|
509
|
+
)
|
|
510
|
+
returnLines.forEach((line) => em.remove(line))
|
|
511
|
+
if (header) em.remove(header)
|
|
512
|
+
|
|
513
|
+
const existingAdjustments = await findWithDecryption(
|
|
514
|
+
em,
|
|
515
|
+
SalesOrderAdjustment,
|
|
516
|
+
{ order: order.id, deletedAt: null },
|
|
517
|
+
{ orderBy: { position: 'asc' } },
|
|
518
|
+
{ tenantId: after.tenantId, organizationId: after.organizationId },
|
|
519
|
+
)
|
|
520
|
+
const salesCalculationService = ctx.container.resolve<SalesCalculationService>('salesCalculationService')
|
|
521
|
+
const lineSnapshots: SalesLineSnapshot[] = lines.map(mapOrderLineEntityToSnapshot)
|
|
522
|
+
const adjustmentDrafts: SalesAdjustmentDraft[] = existingAdjustments.map(mapOrderAdjustmentToDraft)
|
|
523
|
+
const calculation = await salesCalculationService.calculateDocumentTotals({
|
|
524
|
+
documentKind: 'order',
|
|
525
|
+
lines: lineSnapshots,
|
|
526
|
+
adjustments: adjustmentDrafts,
|
|
527
|
+
context: buildCalculationContext(order),
|
|
528
|
+
})
|
|
529
|
+
applyOrderTotals(order, calculation.totals, calculation.lines.length)
|
|
530
|
+
order.updatedAt = new Date()
|
|
531
|
+
em.persist(order)
|
|
532
|
+
|
|
533
|
+
await em.flush()
|
|
534
|
+
},
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
registerCommand(createReturnCommand)
|
|
538
|
+
|
|
539
|
+
export const returnCommands = [createReturnCommand]
|
|
540
|
+
|