@open-mercato/core 0.10.1-develop.7723.1.3b2ea82ea7 → 0.10.1-develop.7726.1.a447cc5525
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/.turbo/turbo-build.log +1 -1
- package/dist/generated/entities/sales_order/index.js +2 -0
- package/dist/generated/entities/sales_order/index.js.map +2 -2
- package/dist/generated/entities/sales_order_line/index.js +2 -0
- package/dist/generated/entities/sales_order_line/index.js.map +2 -2
- package/dist/generated/entity-fields-registry.js +2 -0
- package/dist/generated/entity-fields-registry.js.map +2 -2
- package/dist/modules/planner/api/availability-date-specific.js +2 -7
- package/dist/modules/planner/api/availability-date-specific.js.map +2 -2
- package/dist/modules/planner/commands/availability-date-specific.js +14 -31
- package/dist/modules/planner/commands/availability-date-specific.js.map +2 -2
- package/dist/modules/planner/commands/availability-weekly.js +8 -22
- package/dist/modules/planner/commands/availability-weekly.js.map +2 -2
- package/dist/modules/planner/lib/availabilityMerge.js +10 -18
- package/dist/modules/planner/lib/availabilityMerge.js.map +2 -2
- package/dist/modules/planner/lib/availabilityTimezone.js +161 -0
- package/dist/modules/planner/lib/availabilityTimezone.js.map +7 -0
- package/dist/modules/sales/api/documents/factory.js +3 -0
- package/dist/modules/sales/api/documents/factory.js.map +2 -2
- package/dist/modules/sales/api/order-lines/route.js +2 -1
- package/dist/modules/sales/api/order-lines/route.js.map +2 -2
- package/dist/modules/sales/backend/sales/documents/[id]/page.js +83 -6
- package/dist/modules/sales/backend/sales/documents/[id]/page.js.map +2 -2
- package/dist/modules/sales/commands/documents.js +141 -18
- package/dist/modules/sales/commands/documents.js.map +2 -2
- package/dist/modules/sales/commands/returns.js +24 -7
- package/dist/modules/sales/commands/returns.js.map +2 -2
- package/dist/modules/sales/components/documents/AdjustmentsSection.js +17 -11
- package/dist/modules/sales/components/documents/AdjustmentsSection.js.map +2 -2
- package/dist/modules/sales/components/documents/DocumentTotals.js +21 -3
- package/dist/modules/sales/components/documents/DocumentTotals.js.map +2 -2
- package/dist/modules/sales/components/documents/ItemsSection.js +23 -12
- package/dist/modules/sales/components/documents/ItemsSection.js.map +2 -2
- package/dist/modules/sales/data/entities.js +10 -0
- package/dist/modules/sales/data/entities.js.map +2 -2
- package/dist/modules/sales/data/validators.js +9 -1
- package/dist/modules/sales/data/validators.js.map +2 -2
- package/dist/modules/sales/lib/calculations.js +85 -6
- package/dist/modules/sales/lib/calculations.js.map +2 -2
- package/dist/modules/sales/lib/externalAmounts.js +143 -0
- package/dist/modules/sales/lib/externalAmounts.js.map +7 -0
- package/dist/modules/sales/lib/lineSnapshots.js +6 -1
- package/dist/modules/sales/lib/lineSnapshots.js.map +2 -2
- package/dist/modules/sales/lib/makeSalesLineRoute.js +8 -2
- package/dist/modules/sales/lib/makeSalesLineRoute.js.map +2 -2
- package/dist/modules/sales/lib/providers/totals.js +3 -2
- package/dist/modules/sales/lib/providers/totals.js.map +2 -2
- package/dist/modules/sales/migrations/Migration20260910120000_sales_external_amounts_mode.js +19 -0
- package/dist/modules/sales/migrations/Migration20260910120000_sales_external_amounts_mode.js.map +7 -0
- package/generated/entities/sales_order/index.ts +1 -0
- package/generated/entities/sales_order_line/index.ts +1 -0
- package/generated/entity-fields-registry.ts +2 -0
- package/package.json +8 -7
- package/src/modules/planner/api/availability-date-specific.ts +5 -10
- package/src/modules/planner/commands/availability-date-specific.ts +14 -33
- package/src/modules/planner/commands/availability-weekly.ts +15 -24
- package/src/modules/planner/lib/availabilityMerge.ts +13 -19
- package/src/modules/planner/lib/availabilityTimezone.ts +242 -0
- package/src/modules/sales/api/documents/factory.ts +3 -0
- package/src/modules/sales/api/order-lines/route.ts +2 -1
- package/src/modules/sales/backend/sales/documents/[id]/page.tsx +96 -0
- package/src/modules/sales/commands/documents.ts +219 -44
- package/src/modules/sales/commands/returns.ts +27 -3
- package/src/modules/sales/components/documents/AdjustmentsSection.tsx +34 -25
- package/src/modules/sales/components/documents/DocumentTotals.tsx +26 -2
- package/src/modules/sales/components/documents/ItemsSection.tsx +27 -12
- package/src/modules/sales/data/entities.ts +14 -0
- package/src/modules/sales/data/validators.ts +10 -0
- package/src/modules/sales/i18n/de.json +12 -0
- package/src/modules/sales/i18n/en.json +12 -0
- package/src/modules/sales/i18n/es.json +12 -0
- package/src/modules/sales/i18n/ko.json +12 -0
- package/src/modules/sales/i18n/pl.json +12 -0
- package/src/modules/sales/lib/calculations.ts +99 -4
- package/src/modules/sales/lib/externalAmounts.ts +174 -0
- package/src/modules/sales/lib/lineSnapshots.ts +6 -1
- package/src/modules/sales/lib/makeSalesLineRoute.ts +8 -0
- package/src/modules/sales/lib/providers/totals.ts +5 -2
- package/src/modules/sales/lib/types.ts +19 -2
- package/src/modules/sales/migrations/.snapshot-open-mercato.json +33 -1
- package/src/modules/sales/migrations/Migration20260910120000_sales_external_amounts_mode.ts +25 -0
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
2
|
+
|
|
3
|
+
const logger = createLogger('planner').child({ component: 'availability' })
|
|
4
|
+
|
|
5
|
+
const FALLBACK_TIME_ZONE = 'UTC'
|
|
6
|
+
const DATE_KEY_PATTERN = /^\d{4}-\d{2}-\d{2}$/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Availability rows store `timezone` as free-form `text`, so a malformed value
|
|
10
|
+
* must not turn a read or a write into a 500. Unknown zones degrade to UTC,
|
|
11
|
+
* which is already how the expander interprets every stored instant.
|
|
12
|
+
*/
|
|
13
|
+
export function resolveTimeZone(value: string | null | undefined): string {
|
|
14
|
+
if (!isResolvableTimeZone(value)) return FALLBACK_TIME_ZONE
|
|
15
|
+
return value.trim()
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function isResolvableTimeZone(value: string | null | undefined): value is string {
|
|
19
|
+
if (typeof value !== 'string' || !value.trim().length) return false
|
|
20
|
+
try {
|
|
21
|
+
new Intl.DateTimeFormat('en-US', { timeZone: value.trim() })
|
|
22
|
+
return true
|
|
23
|
+
} catch {
|
|
24
|
+
return false
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The replace endpoints anchor `DTSTART` in the declared zone, so an
|
|
30
|
+
* unresolvable value is persisted as a UTC-anchored instant under a label
|
|
31
|
+
* nothing can resolve. Rejecting it with a 400 would narrow the replace
|
|
32
|
+
* schemas (a `data/validators.ts` contract surface), so for now the write is
|
|
33
|
+
* accepted and flagged; the 400 is announced in UPGRADE_NOTES.md.
|
|
34
|
+
*/
|
|
35
|
+
export function warnOnUnresolvableTimeZone(value: string, commandId: string): void {
|
|
36
|
+
if (isResolvableTimeZone(value)) return
|
|
37
|
+
logger.warn('[deprecated] Unresolvable availability timezone anchored to UTC; this will be rejected with 400 in a future minor release', {
|
|
38
|
+
commandId,
|
|
39
|
+
timezone: value,
|
|
40
|
+
fallback: FALLBACK_TIME_ZONE,
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function parseTimeOfDay(value: string): { hours: number; minutes: number } | null {
|
|
45
|
+
const [hours, minutes] = String(value).split(':').map((part) => Number(part))
|
|
46
|
+
if (!Number.isFinite(hours) || !Number.isFinite(minutes)) return null
|
|
47
|
+
if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return null
|
|
48
|
+
return { hours, minutes }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type WallClock = { year: number; month: number; day: number; hours: number; minutes: number; seconds: number }
|
|
52
|
+
|
|
53
|
+
const MINUTE_MS = 60 * 1000
|
|
54
|
+
const DAY_MS = 24 * 60 * MINUTE_MS
|
|
55
|
+
const formatterCache = new Map<string, Intl.DateTimeFormat>()
|
|
56
|
+
|
|
57
|
+
function zonedFormatter(timeZone: string): Intl.DateTimeFormat {
|
|
58
|
+
let formatter = formatterCache.get(timeZone)
|
|
59
|
+
if (!formatter) {
|
|
60
|
+
formatter = new Intl.DateTimeFormat('en-US', {
|
|
61
|
+
timeZone,
|
|
62
|
+
hourCycle: 'h23',
|
|
63
|
+
year: 'numeric',
|
|
64
|
+
month: '2-digit',
|
|
65
|
+
day: '2-digit',
|
|
66
|
+
hour: '2-digit',
|
|
67
|
+
minute: '2-digit',
|
|
68
|
+
second: '2-digit',
|
|
69
|
+
})
|
|
70
|
+
formatterCache.set(timeZone, formatter)
|
|
71
|
+
}
|
|
72
|
+
return formatter
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Wall clock of an instant in `timeZone`, read through `Intl` only. `date-fns-tz`
|
|
77
|
+
* seeds its offset search with host-local getters, so around DST transitions
|
|
78
|
+
* the same input resolved differently depending on the serving machine's `TZ`.
|
|
79
|
+
*/
|
|
80
|
+
function zonedWallClock(epochMs: number, timeZone: string): WallClock {
|
|
81
|
+
const values: Record<string, number> = {}
|
|
82
|
+
for (const part of zonedFormatter(timeZone).formatToParts(new Date(epochMs))) {
|
|
83
|
+
if (part.type !== 'literal') values[part.type] = Number(part.value)
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
year: values.year,
|
|
87
|
+
month: values.month,
|
|
88
|
+
day: values.day,
|
|
89
|
+
hours: values.hour,
|
|
90
|
+
minutes: values.minute,
|
|
91
|
+
seconds: values.second,
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function wallClockAsUtc(clock: WallClock): number {
|
|
96
|
+
return Date.UTC(clock.year, clock.month - 1, clock.day, clock.hours, clock.minutes, clock.seconds)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function offsetAt(epochMs: number, timeZone: string): number {
|
|
100
|
+
const flooredToMinute = Math.floor(epochMs / MINUTE_MS) * MINUTE_MS
|
|
101
|
+
return wallClockAsUtc(zonedWallClock(flooredToMinute, timeZone)) - flooredToMinute
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function parseDateKey(dateKey: string): { year: number; month: number; day: number } | null {
|
|
105
|
+
if (!DATE_KEY_PATTERN.test(String(dateKey))) return null
|
|
106
|
+
const [year, month, day] = dateKey.split('-').map((part) => Number(part))
|
|
107
|
+
const probe = new Date(Date.UTC(year, month - 1, day))
|
|
108
|
+
if (probe.getUTCMonth() !== month - 1 || probe.getUTCDate() !== day) return null
|
|
109
|
+
return { year, month, day }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function formatUtcDateKey(epochMs: number): string {
|
|
113
|
+
return new Date(epochMs).toISOString().slice(0, 10)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* `2026-06-15` + `09:00` + `Europe/Warsaw` → the absolute instant that wall
|
|
118
|
+
* time denotes. Replaces `new Date(y, m, d, h, min)`, whose result depended on
|
|
119
|
+
* the host process's `TZ` rather than on the zone the caller declared (#5862).
|
|
120
|
+
*
|
|
121
|
+
* DST transitions follow the `compatible` disambiguation of RFC 5545 §3.3.5 and
|
|
122
|
+
* Temporal: a repeated wall time resolves to its EARLIER occurrence, and a
|
|
123
|
+
* skipped one moves forward by the length of the gap (`Africa/Cairo` has no
|
|
124
|
+
* 2026-04-24 00:00, so it becomes 01:00 — the first instant of that date).
|
|
125
|
+
*/
|
|
126
|
+
export function zonedWallTimeToInstant(dateKey: string, time: string, timeZone: string): Date | null {
|
|
127
|
+
const date = parseDateKey(dateKey)
|
|
128
|
+
const parsed = parseTimeOfDay(time)
|
|
129
|
+
if (!date || !parsed) return null
|
|
130
|
+
const wall = new Date(Date.UTC(date.year, date.month - 1, date.day, parsed.hours, parsed.minutes))
|
|
131
|
+
return zonedWallDateToInstant(wall, timeZone)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Resolve a UTC-encoded wall clock with compatible DST disambiguation. */
|
|
135
|
+
export function zonedWallDateToInstant(wall: Date, timeZone: string): Date {
|
|
136
|
+
const zone = resolveTimeZone(timeZone)
|
|
137
|
+
const wallAsUtc = wall.getTime()
|
|
138
|
+
const offsetBefore = offsetAt(wallAsUtc - DAY_MS, zone)
|
|
139
|
+
const offsetAfter = offsetAt(wallAsUtc + DAY_MS, zone)
|
|
140
|
+
if (offsetBefore === offsetAfter) return new Date(wallAsUtc - offsetBefore)
|
|
141
|
+
const candidates = [wallAsUtc - offsetBefore, wallAsUtc - offsetAfter].sort((left, right) => left - right)
|
|
142
|
+
const matching = candidates.find((candidate) => candidate + offsetAt(candidate, zone) === wallAsUtc)
|
|
143
|
+
return new Date(matching ?? wallAsUtc - offsetBefore)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The span a full-day block covers: from the first instant of `dateKey` to the
|
|
148
|
+
* first instant of the following date, both in `timeZone`. That is 23 or 25
|
|
149
|
+
* hours on DST days, so the block neither bleeds into nor falls short of its
|
|
150
|
+
* neighbours.
|
|
151
|
+
*/
|
|
152
|
+
export function zonedDayBounds(dateKey: string, timeZone: string): { start: Date; end: Date } | null {
|
|
153
|
+
const date = parseDateKey(dateKey)
|
|
154
|
+
if (!date) return null
|
|
155
|
+
const nextDateKey = formatUtcDateKey(Date.UTC(date.year, date.month - 1, date.day) + DAY_MS)
|
|
156
|
+
const start = zonedWallTimeToInstant(dateKey, '00:00', timeZone)
|
|
157
|
+
const end = zonedWallTimeToInstant(nextDateKey, '00:00', timeZone)
|
|
158
|
+
if (!start || !end || end <= start) return null
|
|
159
|
+
return { start, end }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The calendar date an instant falls on, as seen from `timeZone`.
|
|
164
|
+
*
|
|
165
|
+
* Which day an existing rule belongs to has to be read in that rule's own zone,
|
|
166
|
+
* the same zone its `DTSTART` was anchored in. Derived from the host clock
|
|
167
|
+
* instead, replace-by-date selected a different set of rows — and the 403 gate
|
|
168
|
+
* in `api/availability-date-specific.ts` admitted or refused the same request —
|
|
169
|
+
* depending on which machine served it.
|
|
170
|
+
*/
|
|
171
|
+
export function zonedDateKey(instant: Date, timeZone: string): string {
|
|
172
|
+
const clock = zonedWallClock(instant.getTime(), resolveTimeZone(timeZone))
|
|
173
|
+
return formatUtcDateKey(Date.UTC(clock.year, clock.month - 1, clock.day))
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** `Date.getDay()` index (0 = Sunday) of an instant, as seen from `timeZone`. */
|
|
177
|
+
export function zonedWeekday(instant: Date, timeZone: string): number {
|
|
178
|
+
const clock = zonedWallClock(instant.getTime(), resolveTimeZone(timeZone))
|
|
179
|
+
return new Date(Date.UTC(clock.year, clock.month - 1, clock.day)).getUTCDay()
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The next occurrence of `weekday` (0 = Sunday) at or after `from`, as a
|
|
184
|
+
* calendar date key in `timeZone`. The zone matters: at 12:00 UTC on a Monday
|
|
185
|
+
* it is already Tuesday in `Pacific/Auckland`, which shifted the whole series
|
|
186
|
+
* by a week before this was zone-aware.
|
|
187
|
+
*/
|
|
188
|
+
export function nextWeekdayDateKey(weekday: number, timeZone: string, from: Date = new Date()): string {
|
|
189
|
+
const clock = zonedWallClock(from.getTime(), resolveTimeZone(timeZone))
|
|
190
|
+
const localMidnight = Date.UTC(clock.year, clock.month - 1, clock.day)
|
|
191
|
+
const diff = (weekday - new Date(localMidnight).getUTCDay() + 7) % 7
|
|
192
|
+
return formatUtcDateKey(localMidnight + diff * DAY_MS)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The start and end instants of the occurrence a weekly window is anchored on.
|
|
197
|
+
*
|
|
198
|
+
* The anchor's exact length becomes the rule's `DURATION`, which every reader
|
|
199
|
+
* applies to every week. An anchor that spans a DST transition (Europe/Warsaw
|
|
200
|
+
* Sunday 00:00–08:00 on 2026-10-25 lasts 9 hours) or starts in a gap would
|
|
201
|
+
* therefore misstate the window for all ordinary weeks, so the upcoming
|
|
202
|
+
* occurrence is used only when both local endpoints and its exact length match
|
|
203
|
+
* the request; otherwise the previous week's occurrence anchors the series. Taking
|
|
204
|
+
* the earlier week keeps the upcoming occurrence inside the series for readers
|
|
205
|
+
* that expand forward from `DTSTART`.
|
|
206
|
+
*
|
|
207
|
+
* `from` is threaded in rather than defaulted per call: resolving a window's
|
|
208
|
+
* start and end against two separate `new Date()` reads can straddle midnight
|
|
209
|
+
* in the declared zone, which lands the anchors seven days apart.
|
|
210
|
+
*/
|
|
211
|
+
export function zonedWeeklyAnchor(
|
|
212
|
+
weekday: number,
|
|
213
|
+
startTime: string,
|
|
214
|
+
endTime: string,
|
|
215
|
+
timeZone: string,
|
|
216
|
+
from: Date,
|
|
217
|
+
): { start: Date; end: Date } | null {
|
|
218
|
+
const startClock = parseTimeOfDay(startTime)
|
|
219
|
+
const endClock = parseTimeOfDay(endTime)
|
|
220
|
+
if (!startClock || !endClock) return null
|
|
221
|
+
const zone = resolveTimeZone(timeZone)
|
|
222
|
+
const startMinutes = startClock.hours * 60 + startClock.minutes
|
|
223
|
+
const endMinutes = endClock.hours * 60 + endClock.minutes
|
|
224
|
+
const localLengthMs = (endMinutes - startMinutes) * MINUTE_MS
|
|
225
|
+
const upcoming = parseDateKey(nextWeekdayDateKey(weekday, timeZone, from))
|
|
226
|
+
if (!upcoming) return null
|
|
227
|
+
const upcomingMidnight = Date.UTC(upcoming.year, upcoming.month - 1, upcoming.day)
|
|
228
|
+
let fallback: { start: Date; end: Date } | null = null
|
|
229
|
+
for (const weekOffset of [0, -1]) {
|
|
230
|
+
const midnight = upcomingMidnight + weekOffset * 7 * DAY_MS
|
|
231
|
+
const dateKey = formatUtcDateKey(midnight)
|
|
232
|
+
const start = zonedWallTimeToInstant(dateKey, startTime, timeZone)
|
|
233
|
+
const end = zonedWallTimeToInstant(dateKey, endTime, timeZone)
|
|
234
|
+
if (!start || !end) return null
|
|
235
|
+
const startWall = wallClockAsUtc(zonedWallClock(start.getTime(), zone))
|
|
236
|
+
const endWall = wallClockAsUtc(zonedWallClock(end.getTime(), zone))
|
|
237
|
+
if (startWall !== midnight + startMinutes * MINUTE_MS || endWall !== midnight + endMinutes * MINUTE_MS) continue
|
|
238
|
+
if (end.getTime() - start.getTime() === localLengthMs) return { start, end }
|
|
239
|
+
fallback ??= { start, end }
|
|
240
|
+
}
|
|
241
|
+
return fallback
|
|
242
|
+
}
|
|
@@ -424,6 +424,7 @@ export function buildDocumentCrudOptions(binding: DocumentBinding) {
|
|
|
424
424
|
|
|
425
425
|
const orderOnlyFields = [
|
|
426
426
|
'expected_delivery_at',
|
|
427
|
+
'totals_mode',
|
|
427
428
|
'shipping_net_amount',
|
|
428
429
|
'shipping_gross_amount',
|
|
429
430
|
'surcharge_total_amount',
|
|
@@ -535,6 +536,7 @@ export function buildDocumentCrudOptions(binding: DocumentBinding) {
|
|
|
535
536
|
paidTotalAmount: toNumber(item.paid_total_amount),
|
|
536
537
|
refundedTotalAmount: toNumber(item.refunded_total_amount),
|
|
537
538
|
outstandingAmount: toNumber(item.outstanding_amount),
|
|
539
|
+
...(binding.kind === 'order' ? { totalsMode: item.totals_mode ?? 'computed' } : {}),
|
|
538
540
|
customerSnapshot: normalizeJsonRecord(item.customer_snapshot),
|
|
539
541
|
billingAddressSnapshot: normalizeJsonRecord(item.billing_address_snapshot),
|
|
540
542
|
shippingAddressSnapshot: normalizeJsonRecord(item.shipping_address_snapshot),
|
|
@@ -665,6 +667,7 @@ export function buildDocumentOpenApi(binding: DocumentBinding) {
|
|
|
665
667
|
paidTotalAmount: z.number().nullable().optional(),
|
|
666
668
|
refundedTotalAmount: z.number().nullable().optional(),
|
|
667
669
|
outstandingAmount: z.number().nullable().optional(),
|
|
670
|
+
totalsMode: z.enum(['computed', 'external']).optional(),
|
|
668
671
|
createdAt: z.string(),
|
|
669
672
|
updatedAt: z.string(),
|
|
670
673
|
customFields: z.record(z.string(), z.unknown()).optional(),
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { SalesOrderLine } from "../../data/entities";
|
|
2
|
-
import { orderLineCreateSchema } from "../../data/validators";
|
|
2
|
+
import { orderHeaderTotalsSchema, orderLineCreateSchema } from "../../data/validators";
|
|
3
3
|
import { E } from "#generated/entities.ids.generated";
|
|
4
4
|
import * as F from "#generated/entities/sales_order_line";
|
|
5
5
|
import { makeSalesLineRoute } from "../../lib/makeSalesLineRoute";
|
|
@@ -11,6 +11,7 @@ const route = makeSalesLineRoute({
|
|
|
11
11
|
parentFkColumn: "order_id",
|
|
12
12
|
parentFkParam: "orderId",
|
|
13
13
|
createSchema: orderLineCreateSchema,
|
|
14
|
+
writeExtensionShape: { orderTotals: orderHeaderTotalsSchema.optional() },
|
|
14
15
|
features: { view: "sales.orders.view", manage: "sales.orders.manage" },
|
|
15
16
|
commandPrefix: "sales.orders.lines",
|
|
16
17
|
openApi: {
|
|
@@ -77,6 +77,7 @@ import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuarde
|
|
|
77
77
|
import { buildRecordInjectionContext, useSetCurrentRecordInjectionContext } from '@open-mercato/ui/backend/injection/recordContext'
|
|
78
78
|
import { useSalesChannelsEnabled } from '@open-mercato/core/modules/sales/components/useSalesChannelsEnabled'
|
|
79
79
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
80
|
+
import { getTelemetryRuntime } from '@open-mercato/shared/lib/telemetry/runtime'
|
|
80
81
|
|
|
81
82
|
const logger = createLogger('sales')
|
|
82
83
|
|
|
@@ -899,6 +900,7 @@ type DocumentRecord = {
|
|
|
899
900
|
paidTotalAmount?: number | null
|
|
900
901
|
refundedTotalAmount?: number | null
|
|
901
902
|
outstandingAmount?: number | null
|
|
903
|
+
totalsMode?: 'computed' | 'external' | null
|
|
902
904
|
createdAt?: string
|
|
903
905
|
updatedAt?: string
|
|
904
906
|
metadata?: Record<string, unknown> | null
|
|
@@ -1478,6 +1480,7 @@ function MethodInlineEditor({
|
|
|
1478
1480
|
emptyResultsLabel,
|
|
1479
1481
|
selectedHint,
|
|
1480
1482
|
icon,
|
|
1483
|
+
note,
|
|
1481
1484
|
allowClear = true,
|
|
1482
1485
|
}: {
|
|
1483
1486
|
label: string
|
|
@@ -1494,6 +1497,7 @@ function MethodInlineEditor({
|
|
|
1494
1497
|
emptyResultsLabel: string
|
|
1495
1498
|
selectedHint: (id: string) => string
|
|
1496
1499
|
icon: React.ReactNode
|
|
1500
|
+
note?: string | null
|
|
1497
1501
|
allowClear?: boolean
|
|
1498
1502
|
}) {
|
|
1499
1503
|
const t = useT()
|
|
@@ -1607,6 +1611,7 @@ function MethodInlineEditor({
|
|
|
1607
1611
|
<div className="flex items-start justify-between gap-2">
|
|
1608
1612
|
<div className="flex-1 min-w-0">
|
|
1609
1613
|
<p className="text-xs uppercase tracking-wide text-muted-foreground">{label}</p>
|
|
1614
|
+
{note ? <p className="mt-1 text-xs text-muted-foreground">{note}</p> : null}
|
|
1610
1615
|
{editing ? (
|
|
1611
1616
|
<div
|
|
1612
1617
|
className="mt-2 space-y-2"
|
|
@@ -1937,6 +1942,7 @@ export default function SalesDocumentDetailPage({
|
|
|
1937
1942
|
const [generating, setGenerating] = React.useState(false)
|
|
1938
1943
|
const [converting, setConverting] = React.useState(false)
|
|
1939
1944
|
const [deleting, setDeleting] = React.useState(false)
|
|
1945
|
+
const [switchingToComputedAmounts, setSwitchingToComputedAmounts] = React.useState(false)
|
|
1940
1946
|
const [sending, setSending] = React.useState(false)
|
|
1941
1947
|
const [sendOpen, setSendOpen] = React.useState(false)
|
|
1942
1948
|
const [validForDays, setValidForDays] = React.useState(DEFAULT_QUOTE_VALID_FOR_DAYS)
|
|
@@ -2844,6 +2850,8 @@ export default function SalesDocumentDetailPage({
|
|
|
2844
2850
|
},
|
|
2845
2851
|
[t]
|
|
2846
2852
|
)
|
|
2853
|
+
const amountsAreExternal = kind === 'order' && record?.totalsMode === 'external'
|
|
2854
|
+
|
|
2847
2855
|
const totalsItems = React.useMemo(() => {
|
|
2848
2856
|
if (!record) return []
|
|
2849
2857
|
const items: { key: string; label: string; amount: number | null | undefined; emphasize?: boolean }[] = [
|
|
@@ -3791,6 +3799,48 @@ export default function SalesDocumentDetailPage({
|
|
|
3791
3799
|
}
|
|
3792
3800
|
}, [fetchDocumentByKind, kind, record, runMutationWithContext, t, validForDays])
|
|
3793
3801
|
|
|
3802
|
+
const handleSwitchToComputedAmounts = React.useCallback(async () => {
|
|
3803
|
+
if (!record || kind !== 'order') return
|
|
3804
|
+
const ok = await confirm({
|
|
3805
|
+
title: t(
|
|
3806
|
+
'sales.documents.amountsSwitchConfirm',
|
|
3807
|
+
'Recompute this order\u2019s amounts from its lines? The totals supplied by the source system will be replaced and cannot be restored from here.',
|
|
3808
|
+
),
|
|
3809
|
+
variant: 'default',
|
|
3810
|
+
})
|
|
3811
|
+
if (!ok) return
|
|
3812
|
+
setSwitchingToComputedAmounts(true)
|
|
3813
|
+
try {
|
|
3814
|
+
await runMutationWithContext(async () => {
|
|
3815
|
+
await withScopedApiRequestHeaders(buildOptimisticLockHeader(record.updatedAt), () =>
|
|
3816
|
+
apiCallOrThrow('/api/sales/orders', {
|
|
3817
|
+
method: 'PUT',
|
|
3818
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3819
|
+
body: JSON.stringify({ id: record.id, totalsMode: 'computed' }),
|
|
3820
|
+
}),
|
|
3821
|
+
)
|
|
3822
|
+
}, { id: record.id, totalsMode: 'computed' })
|
|
3823
|
+
flash(t('sales.documents.amountsSwitchDone', 'Amounts are now computed from the lines.'), 'success')
|
|
3824
|
+
await refreshDocumentTotals()
|
|
3825
|
+
} catch (err) {
|
|
3826
|
+
logger.error('sales.documents.amounts.switchToComputed', { err })
|
|
3827
|
+
try {
|
|
3828
|
+
getTelemetryRuntime()?.reportError(err, {
|
|
3829
|
+
module: 'sales',
|
|
3830
|
+
code: 'sales.amounts_switch_to_computed_failed',
|
|
3831
|
+
attributes: { orderId: record.id },
|
|
3832
|
+
})
|
|
3833
|
+
} catch (reportFailure) {
|
|
3834
|
+
logger.warn('sales.documents.amounts.switchToComputed.reportFailed', { err: reportFailure })
|
|
3835
|
+
}
|
|
3836
|
+
if (!handleDocumentMutationError(err, t, () => setReloadKey((prev) => prev + 1))) {
|
|
3837
|
+
flash(t('sales.documents.amountsSwitchFailed', 'Could not switch to computed amounts.'), 'error')
|
|
3838
|
+
}
|
|
3839
|
+
} finally {
|
|
3840
|
+
setSwitchingToComputedAmounts(false)
|
|
3841
|
+
}
|
|
3842
|
+
}, [confirm, kind, record, refreshDocumentTotals, runMutationWithContext, t])
|
|
3843
|
+
|
|
3794
3844
|
const handleDelete = React.useCallback(async () => {
|
|
3795
3845
|
if (!record) return
|
|
3796
3846
|
const ok = await confirm({
|
|
@@ -3851,6 +3901,14 @@ export default function SalesDocumentDetailPage({
|
|
|
3851
3901
|
t('sales.documents.detail.shippingMethod.selected', 'Selected shipping method: {{id}}', { id })
|
|
3852
3902
|
}
|
|
3853
3903
|
icon={<Truck className="h-5 w-5 text-muted-foreground" />}
|
|
3904
|
+
note={
|
|
3905
|
+
amountsAreExternal
|
|
3906
|
+
? t(
|
|
3907
|
+
'sales.documents.amountsExternalMethodNote',
|
|
3908
|
+
'The method is recorded, but no charge is added \u2014 this order\u2019s totals come from the source system.',
|
|
3909
|
+
)
|
|
3910
|
+
: null
|
|
3911
|
+
}
|
|
3854
3912
|
/>
|
|
3855
3913
|
),
|
|
3856
3914
|
},
|
|
@@ -3877,6 +3935,14 @@ export default function SalesDocumentDetailPage({
|
|
|
3877
3935
|
t('sales.documents.detail.paymentMethod.selected', 'Selected payment method: {{id}}', { id })
|
|
3878
3936
|
}
|
|
3879
3937
|
icon={<CreditCard className="h-5 w-5 text-muted-foreground" />}
|
|
3938
|
+
note={
|
|
3939
|
+
amountsAreExternal
|
|
3940
|
+
? t(
|
|
3941
|
+
'sales.documents.amountsExternalMethodNote',
|
|
3942
|
+
'The method is recorded, but no charge is added \u2014 this order\u2019s totals come from the source system.',
|
|
3943
|
+
)
|
|
3944
|
+
: null
|
|
3945
|
+
}
|
|
3880
3946
|
/>
|
|
3881
3947
|
),
|
|
3882
3948
|
},
|
|
@@ -3949,6 +4015,7 @@ export default function SalesDocumentDetailPage({
|
|
|
3949
4015
|
)
|
|
3950
4016
|
return fields
|
|
3951
4017
|
}, [
|
|
4018
|
+
amountsAreExternal,
|
|
3952
4019
|
handleUpdateComment,
|
|
3953
4020
|
handleUpdateCustomerReference,
|
|
3954
4021
|
handleUpdateExpectedDeliveryAt,
|
|
@@ -4266,6 +4333,9 @@ export default function SalesDocumentDetailPage({
|
|
|
4266
4333
|
if (activeTab === 'items') {
|
|
4267
4334
|
return (
|
|
4268
4335
|
<SalesDocumentItemsSection
|
|
4336
|
+
// Remount on a mode flip: the switch rewrites every line server-side, and the
|
|
4337
|
+
// section loads its lines only once per `documentId`, which does not change.
|
|
4338
|
+
key={`items:${amountsAreExternal ? 'external' : 'computed'}`}
|
|
4269
4339
|
documentId={record.id}
|
|
4270
4340
|
kind={kind}
|
|
4271
4341
|
currencyCode={record.currencyCode ?? null}
|
|
@@ -4274,6 +4344,7 @@ export default function SalesDocumentDetailPage({
|
|
|
4274
4344
|
tenantId={(record as any)?.tenantId ?? (record as any)?.tenant_id ?? null}
|
|
4275
4345
|
onActionChange={handleSectionActionChange}
|
|
4276
4346
|
onItemsChange={(items) => setHasItems(items.length > 0)}
|
|
4347
|
+
amountsReadOnly={amountsAreExternal}
|
|
4277
4348
|
/>
|
|
4278
4349
|
)
|
|
4279
4350
|
}
|
|
@@ -4332,6 +4403,7 @@ export default function SalesDocumentDetailPage({
|
|
|
4332
4403
|
tenantId={(record as any)?.tenantId ?? (record as any)?.tenant_id ?? null}
|
|
4333
4404
|
onActionChange={handleSectionActionChange}
|
|
4334
4405
|
onRowsChange={setAdjustmentRows}
|
|
4406
|
+
amountsReadOnly={amountsAreExternal}
|
|
4335
4407
|
/>
|
|
4336
4408
|
)
|
|
4337
4409
|
}
|
|
@@ -4914,6 +4986,30 @@ export default function SalesDocumentDetailPage({
|
|
|
4914
4986
|
title={t('sales.documents.detail.totals.title', 'Totals')}
|
|
4915
4987
|
currency={record.currencyCode ?? null}
|
|
4916
4988
|
items={totalsItems}
|
|
4989
|
+
sourceBadge={
|
|
4990
|
+
amountsAreExternal
|
|
4991
|
+
? {
|
|
4992
|
+
label: t('sales.documents.amountsExternal', 'Amounts from source'),
|
|
4993
|
+
hint: t(
|
|
4994
|
+
'sales.documents.amountsExternalHint',
|
|
4995
|
+
'Totals on this document come from an external system and are stored as supplied.',
|
|
4996
|
+
),
|
|
4997
|
+
}
|
|
4998
|
+
: null
|
|
4999
|
+
}
|
|
5000
|
+
footerAction={
|
|
5001
|
+
amountsAreExternal ? (
|
|
5002
|
+
<Button
|
|
5003
|
+
type="button"
|
|
5004
|
+
variant="outline"
|
|
5005
|
+
size="sm"
|
|
5006
|
+
disabled={switchingToComputedAmounts}
|
|
5007
|
+
onClick={handleSwitchToComputedAmounts}
|
|
5008
|
+
>
|
|
5009
|
+
{t('sales.documents.amountsSwitchToComputed', 'Switch to computed amounts')}
|
|
5010
|
+
</Button>
|
|
5011
|
+
) : null
|
|
5012
|
+
}
|
|
4917
5013
|
/>
|
|
4918
5014
|
|
|
4919
5015
|
<div className="space-y-4" ref={detailSectionRef}>
|