@open-mercato/core 0.10.1-develop.7833.1.d6cbf0e3ab → 0.10.1-develop.7836.1.0ef0f453c6
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/modules/cart/api/current/lines/bulk/route.js +1 -1
- package/dist/modules/cart/api/current/lines/bulk/route.js.map +2 -2
- package/dist/modules/cart/api/current/lines/route.js +1 -1
- package/dist/modules/cart/api/current/lines/route.js.map +2 -2
- package/dist/modules/cart/commands/lines.js +5 -10
- package/dist/modules/cart/commands/lines.js.map +2 -2
- package/dist/modules/cart/lib/currentCartRoute.js +8 -0
- package/dist/modules/cart/lib/currentCartRoute.js.map +3 -3
- package/dist/modules/cart/lib/lineMutationLock.js +52 -0
- package/dist/modules/cart/lib/lineMutationLock.js.map +7 -0
- package/dist/modules/sales/commands/documents.js +3 -3
- package/dist/modules/sales/commands/documents.js.map +2 -2
- package/dist/modules/sales/components/documents/ItemsSection.js +75 -21
- package/dist/modules/sales/components/documents/ItemsSection.js.map +2 -2
- package/dist/modules/sales/components/documents/lineItemUtils.js +2 -2
- package/dist/modules/sales/components/documents/lineItemUtils.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/cart/api/current/lines/bulk/route.ts +1 -1
- package/src/modules/cart/api/current/lines/route.ts +1 -1
- package/src/modules/cart/commands/lines.ts +5 -10
- package/src/modules/cart/i18n/de.json +1 -0
- package/src/modules/cart/i18n/en.json +1 -0
- package/src/modules/cart/i18n/es.json +1 -0
- package/src/modules/cart/i18n/ko.json +1 -0
- package/src/modules/cart/i18n/pl.json +1 -0
- package/src/modules/cart/lib/currentCartRoute.ts +8 -0
- package/src/modules/cart/lib/lineMutationLock.ts +59 -0
- package/src/modules/sales/commands/documents.ts +3 -3
- package/src/modules/sales/components/documents/ItemsSection.tsx +82 -23
- package/src/modules/sales/components/documents/lineItemUtils.ts +2 -2
- package/src/modules/sales/i18n/de.json +1 -0
- package/src/modules/sales/i18n/en.json +1 -0
- package/src/modules/sales/i18n/es.json +1 -0
- package/src/modules/sales/i18n/ko.json +1 -0
- package/src/modules/sales/i18n/pl.json +1 -0
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
"cart.audit.lines.bulkAdd": "Add cart lines (bulk)",
|
|
5
5
|
"cart.audit.lines.remove": "Remove cart line",
|
|
6
6
|
"cart.audit.merge": "Merge cart",
|
|
7
|
+
"cart.errors.busy": "Der Warenkorb ist ausgelastet. Bitte versuche es erneut.",
|
|
7
8
|
"cart.errors.store_context_unavailable": "Cart is unavailable for this store right now",
|
|
8
9
|
"cart.errors.unexpected": "Unexpected error",
|
|
9
10
|
"cart.warnings.code_no_longer_valid": "A previously applied code is no longer valid",
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
"cart.audit.lines.bulkAdd": "Add cart lines (bulk)",
|
|
5
5
|
"cart.audit.lines.remove": "Remove cart line",
|
|
6
6
|
"cart.audit.merge": "Merge cart",
|
|
7
|
+
"cart.errors.busy": "Cart is busy. Please try again.",
|
|
7
8
|
"cart.errors.store_context_unavailable": "Cart is unavailable for this store right now",
|
|
8
9
|
"cart.errors.unexpected": "Unexpected error",
|
|
9
10
|
"cart.warnings.code_no_longer_valid": "A previously applied code is no longer valid",
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
"cart.audit.lines.bulkAdd": "Add cart lines (bulk)",
|
|
5
5
|
"cart.audit.lines.remove": "Remove cart line",
|
|
6
6
|
"cart.audit.merge": "Merge cart",
|
|
7
|
+
"cart.errors.busy": "El carrito está ocupado. Inténtalo de nuevo.",
|
|
7
8
|
"cart.errors.store_context_unavailable": "Cart is unavailable for this store right now",
|
|
8
9
|
"cart.errors.unexpected": "Unexpected error",
|
|
9
10
|
"cart.warnings.code_no_longer_valid": "A previously applied code is no longer valid",
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
"cart.audit.lines.bulkAdd": "Add cart lines (bulk)",
|
|
5
5
|
"cart.audit.lines.remove": "Remove cart line",
|
|
6
6
|
"cart.audit.merge": "Merge cart",
|
|
7
|
+
"cart.errors.busy": "장바구니가 사용 중입니다. 다시 시도해 주세요.",
|
|
7
8
|
"cart.errors.store_context_unavailable": "Cart is unavailable for this store right now",
|
|
8
9
|
"cart.errors.unexpected": "Unexpected error",
|
|
9
10
|
"cart.warnings.code_no_longer_valid": "A previously applied code is no longer valid",
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
"cart.audit.lines.bulkAdd": "Add cart lines (bulk)",
|
|
5
5
|
"cart.audit.lines.remove": "Remove cart line",
|
|
6
6
|
"cart.audit.merge": "Merge cart",
|
|
7
|
+
"cart.errors.busy": "Koszyk jest zajęty. Spróbuj ponownie.",
|
|
7
8
|
"cart.errors.store_context_unavailable": "Cart is unavailable for this store right now",
|
|
8
9
|
"cart.errors.unexpected": "Unexpected error",
|
|
9
10
|
"cart.warnings.code_no_longer_valid": "A previously applied code is no longer valid",
|
|
@@ -10,6 +10,7 @@ import { getTelemetryRuntime } from '@open-mercato/shared/lib/telemetry/runtime'
|
|
|
10
10
|
import { getCartCommand } from '../commands/cart'
|
|
11
11
|
import type { CartGetByTokenInput, CartSummary } from '../commands/cart'
|
|
12
12
|
import type { CartPriceChange } from './reprice'
|
|
13
|
+
import { isCartLineMutationLockTimeoutError } from './lineMutationLock'
|
|
13
14
|
import { isCartStoreContextUnavailableError, resolveCartRouteContext } from './routeContext'
|
|
14
15
|
import type { ResolveCartRouteContextOptions, ResolvedCartRouteContext } from './routeContext'
|
|
15
16
|
|
|
@@ -173,6 +174,13 @@ const logger = createLogger('cart').child({ component: 'api' })
|
|
|
173
174
|
* unexpected 500, logged and reported.
|
|
174
175
|
*/
|
|
175
176
|
export async function mapRouteErrorToResponse(error: unknown): Promise<NextResponse> {
|
|
177
|
+
if (isCartLineMutationLockTimeoutError(error)) {
|
|
178
|
+
const { translate } = await resolveTranslations()
|
|
179
|
+
return NextResponse.json(
|
|
180
|
+
{ error: translate('cart.errors.busy', 'Cart is busy. Please try again.'), code: 'cart_busy' },
|
|
181
|
+
{ status: 503, headers: { 'Retry-After': '1', 'Cache-Control': 'no-store' } },
|
|
182
|
+
)
|
|
183
|
+
}
|
|
176
184
|
if (isCrudHttpError(error)) return NextResponse.json(error.body, { status: error.status })
|
|
177
185
|
const interceptorRejection = getCommandInterceptorHttpRejection(error)
|
|
178
186
|
if (interceptorRejection) return NextResponse.json(interceptorRejection.body, { status: interceptorRejection.status })
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
2
|
+
import { withAtomicFlush } from '@open-mercato/shared/lib/commands/flush'
|
|
3
|
+
import { calculateBackoffDelayMs } from '@open-mercato/shared/lib/delivery/retry'
|
|
4
|
+
|
|
5
|
+
const CART_LINE_LOCK_TIMEOUT = Symbol.for('@open-mercato/cart:LineMutationLockTimeout')
|
|
6
|
+
|
|
7
|
+
export class CartLineMutationLockTimeoutError extends Error {
|
|
8
|
+
readonly [CART_LINE_LOCK_TIMEOUT] = true
|
|
9
|
+
|
|
10
|
+
constructor() {
|
|
11
|
+
super('[internal] Cart line mutation lock wait deadline exceeded')
|
|
12
|
+
this.name = 'CartLineMutationLockTimeoutError'
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function isCartLineMutationLockTimeoutError(error: unknown): error is CartLineMutationLockTimeoutError {
|
|
17
|
+
return typeof error === 'object' && error !== null && Reflect.get(error, CART_LINE_LOCK_TIMEOUT) === true
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function withCartLineMutationLock(
|
|
21
|
+
em: EntityManager,
|
|
22
|
+
cartId: string,
|
|
23
|
+
phases: Array<() => void | Promise<void>>,
|
|
24
|
+
label: string,
|
|
25
|
+
waitDeadlineMs = 15_000,
|
|
26
|
+
): Promise<void> {
|
|
27
|
+
const key = `cart:lines:${cartId}`
|
|
28
|
+
const deadline = Date.now() + waitDeadlineMs
|
|
29
|
+
const busy = new Error('[internal] Cart line mutation lock is busy')
|
|
30
|
+
const joinsTransaction = em.isInTransaction()
|
|
31
|
+
|
|
32
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
33
|
+
try {
|
|
34
|
+
await withAtomicFlush(em, [
|
|
35
|
+
async () => {
|
|
36
|
+
if (joinsTransaction) {
|
|
37
|
+
await em.execute('select pg_advisory_xact_lock(hashtextextended(?::text, 0))', [key])
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
const rows = await em.execute<Array<{ locked: boolean }>>(
|
|
41
|
+
'select pg_try_advisory_xact_lock(hashtextextended(?::text, 0)) as locked',
|
|
42
|
+
[key],
|
|
43
|
+
)
|
|
44
|
+
if (!rows[0]?.locked) throw busy
|
|
45
|
+
},
|
|
46
|
+
...phases,
|
|
47
|
+
], { transaction: true, label })
|
|
48
|
+
return
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (error !== busy || em.isInTransaction()) throw error
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const remainingMs = deadline - Date.now()
|
|
54
|
+
if (remainingMs <= 0) throw new CartLineMutationLockTimeoutError()
|
|
55
|
+
const delayMs = calculateBackoffDelayMs(Math.min(attempt, 5), { baseDelayMs: 50, maxJitterMs: 25 })
|
|
56
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(delayMs, remainingMs)))
|
|
57
|
+
if (Date.now() >= deadline) throw new CartLineMutationLockTimeoutError()
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -646,8 +646,9 @@ const documentUpdateFieldsSchema = z
|
|
|
646
646
|
currencyCode: currencyCodeSchema.optional(),
|
|
647
647
|
// Order-only columns — SalesQuote declares none of them. Declared here
|
|
648
648
|
// because the create schema declares them and a caller reusing its create
|
|
649
|
-
// payload on an update otherwise lost them silently
|
|
650
|
-
// rejects
|
|
649
|
+
// payload on an update otherwise lost them silently. `applyDocumentUpdate`
|
|
650
|
+
// rejects them on a quote, except `totalsMode`, which is ignored to preserve
|
|
651
|
+
// quote-create compatibility without enabling external quote amounts.
|
|
651
652
|
exchangeRate: z.union([z.null(), exchangeRateSchema]).optional(),
|
|
652
653
|
paymentStatusEntryId: z.string().uuid().nullable().optional(),
|
|
653
654
|
fulfillmentStatusEntryId: z.string().uuid().nullable().optional(),
|
|
@@ -5894,7 +5895,6 @@ const updateQuoteCommand: CommandHandler<
|
|
|
5894
5895
|
},
|
|
5895
5896
|
async execute(rawInput, ctx) {
|
|
5896
5897
|
const parsed = documentUpdateSchema.parse(rawInput ?? {});
|
|
5897
|
-
await assertAmountsModeUnsupportedOnQuote([parsed]);
|
|
5898
5898
|
documentEditSchema.parse(rawInput ?? {});
|
|
5899
5899
|
const em = (ctx.container.resolve("em") as EntityManager).fork();
|
|
5900
5900
|
let quote!: SalesQuote;
|
|
@@ -7,7 +7,8 @@ import { buildOptimisticLockHeader } from "@open-mercato/ui/backend/utils/optimi
|
|
|
7
7
|
import { deleteCrud } from "@open-mercato/ui/backend/utils/crud";
|
|
8
8
|
import { normalizeCrudServerError } from "@open-mercato/ui/backend/utils/serverErrors";
|
|
9
9
|
import { ErrorMessage, LoadingMessage, TabEmptyState } from "@open-mercato/ui/backend/detail";
|
|
10
|
-
import {
|
|
10
|
+
import { IconButton } from "@open-mercato/ui/primitives/icon-button";
|
|
11
|
+
import { SimpleTooltip } from "@open-mercato/ui/primitives/tooltip";
|
|
11
12
|
import { flash } from "@open-mercato/ui/backend/FlashMessages";
|
|
12
13
|
import { Pencil, Trash2 } from "lucide-react";
|
|
13
14
|
import {
|
|
@@ -41,6 +42,53 @@ import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
|
41
42
|
|
|
42
43
|
const logger = createLogger('sales')
|
|
43
44
|
|
|
45
|
+
function DisabledActionHint({
|
|
46
|
+
label,
|
|
47
|
+
reason,
|
|
48
|
+
children,
|
|
49
|
+
}: {
|
|
50
|
+
label: string
|
|
51
|
+
reason?: string
|
|
52
|
+
children: React.ReactNode
|
|
53
|
+
}) {
|
|
54
|
+
const [open, setOpen] = React.useState(false)
|
|
55
|
+
const touchOpenRef = React.useRef(false)
|
|
56
|
+
if (!reason) return <>{children}</>
|
|
57
|
+
return (
|
|
58
|
+
<SimpleTooltip
|
|
59
|
+
content={reason}
|
|
60
|
+
open={open}
|
|
61
|
+
onOpenChange={(nextOpen) => {
|
|
62
|
+
if (nextOpen || !touchOpenRef.current) setOpen(nextOpen)
|
|
63
|
+
}}
|
|
64
|
+
>
|
|
65
|
+
<span
|
|
66
|
+
className="inline-flex rounded-md focus-visible:outline-none focus-visible:shadow-focus"
|
|
67
|
+
tabIndex={0}
|
|
68
|
+
aria-label={`${label}: ${reason}`}
|
|
69
|
+
onClick={(event) => event.stopPropagation()}
|
|
70
|
+
onPointerDown={(event) => {
|
|
71
|
+
if (event.pointerType !== 'touch') return
|
|
72
|
+
touchOpenRef.current = true
|
|
73
|
+
setOpen(true)
|
|
74
|
+
event.currentTarget.focus()
|
|
75
|
+
}}
|
|
76
|
+
onBlur={() => {
|
|
77
|
+
touchOpenRef.current = false
|
|
78
|
+
setOpen(false)
|
|
79
|
+
}}
|
|
80
|
+
onKeyDown={(event) => {
|
|
81
|
+
if (event.key !== 'Escape') return
|
|
82
|
+
touchOpenRef.current = false
|
|
83
|
+
setOpen(false)
|
|
84
|
+
}}
|
|
85
|
+
>
|
|
86
|
+
{children}
|
|
87
|
+
</span>
|
|
88
|
+
</SimpleTooltip>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
44
92
|
type ResolvedUnitPriceReference = {
|
|
45
93
|
grossPerReference: number;
|
|
46
94
|
referenceUnitCode: string;
|
|
@@ -932,11 +980,13 @@ export function SalesDocumentItemsSection({
|
|
|
932
980
|
{discount.amount !== null ? (
|
|
933
981
|
<span className="font-mono text-sm">
|
|
934
982
|
{t(
|
|
935
|
-
|
|
936
|
-
|
|
983
|
+
discount.amount < 0
|
|
984
|
+
? "sales.documents.items.table.markupAmount"
|
|
985
|
+
: "sales.documents.items.table.discountAmount",
|
|
986
|
+
discount.amount < 0 ? "+{{value}}" : "−{{value}}",
|
|
937
987
|
{
|
|
938
988
|
value: formatMoney(
|
|
939
|
-
discount.amount,
|
|
989
|
+
Math.abs(discount.amount),
|
|
940
990
|
item.currencyCode ??
|
|
941
991
|
currencyCode ??
|
|
942
992
|
undefined,
|
|
@@ -1005,30 +1055,39 @@ export function SalesDocumentItemsSection({
|
|
|
1005
1055
|
})}
|
|
1006
1056
|
<td className="px-3 py-3">
|
|
1007
1057
|
<div className="flex items-center gap-2 justify-end">
|
|
1008
|
-
<
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
disabled={amountsReadOnly}
|
|
1014
|
-
onClick={(event) => {
|
|
1015
|
-
event.stopPropagation();
|
|
1016
|
-
handleEdit(item);
|
|
1017
|
-
}}
|
|
1058
|
+
<DisabledActionHint
|
|
1059
|
+
label={t('ui.actions.edit', 'Edit')}
|
|
1060
|
+
reason={amountsReadOnly
|
|
1061
|
+
? t('sales.documents.amountsExternalHint', 'Totals on this document come from an external system and are stored as supplied.')
|
|
1062
|
+
: undefined}
|
|
1018
1063
|
>
|
|
1019
|
-
<
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1064
|
+
<IconButton
|
|
1065
|
+
type="button"
|
|
1066
|
+
size="default"
|
|
1067
|
+
variant="ghost"
|
|
1068
|
+
aria-label={t('ui.actions.edit', 'Edit')}
|
|
1069
|
+
disabled={amountsReadOnly}
|
|
1070
|
+
onClick={(event) => {
|
|
1071
|
+
event.stopPropagation();
|
|
1072
|
+
handleEdit(item);
|
|
1073
|
+
}}
|
|
1074
|
+
>
|
|
1075
|
+
<Pencil className="h-4 w-4" />
|
|
1076
|
+
</IconButton>
|
|
1077
|
+
</DisabledActionHint>
|
|
1078
|
+
<DisabledActionHint
|
|
1079
|
+
label={t('ui.actions.delete', 'Delete')}
|
|
1080
|
+
reason={amountsReadOnly
|
|
1023
1081
|
? t('sales.documents.amountsExternalHint', 'Totals on this document come from an external system and are stored as supplied.')
|
|
1024
1082
|
: kind === 'order' && items.length === 1
|
|
1025
1083
|
? t('sales.documents.items.errorDeleteLast', 'An order must contain at least one line item.')
|
|
1026
1084
|
: undefined}
|
|
1027
1085
|
>
|
|
1028
|
-
<
|
|
1029
|
-
|
|
1086
|
+
<IconButton
|
|
1087
|
+
type="button"
|
|
1088
|
+
size="default"
|
|
1030
1089
|
variant="ghost"
|
|
1031
|
-
className="
|
|
1090
|
+
className="text-destructive"
|
|
1032
1091
|
aria-label={t('ui.actions.delete', 'Delete')}
|
|
1033
1092
|
disabled={amountsReadOnly || (kind === 'order' && items.length === 1)}
|
|
1034
1093
|
onClick={(event) => {
|
|
@@ -1037,8 +1096,8 @@ export function SalesDocumentItemsSection({
|
|
|
1037
1096
|
}}
|
|
1038
1097
|
>
|
|
1039
1098
|
<Trash2 className="h-4 w-4" />
|
|
1040
|
-
</
|
|
1041
|
-
</
|
|
1099
|
+
</IconButton>
|
|
1100
|
+
</DisabledActionHint>
|
|
1042
1101
|
</div>
|
|
1043
1102
|
</td>
|
|
1044
1103
|
</tr>
|
|
@@ -73,10 +73,10 @@ export function resolveLineDiscountDisplay(
|
|
|
73
73
|
): LineDiscountDisplay | null {
|
|
74
74
|
const amount = normalizeNumber(line.discountAmount, 0)
|
|
75
75
|
const percent = normalizeNumber(line.discountPercent, 0)
|
|
76
|
-
if (amount
|
|
76
|
+
if (amount !== 0) {
|
|
77
77
|
return {
|
|
78
78
|
amount,
|
|
79
|
-
percent: percentAccountsForAmount(line, percent, amount) ? percent : null,
|
|
79
|
+
percent: amount > 0 && percentAccountsForAmount(line, percent, amount) ? percent : null,
|
|
80
80
|
}
|
|
81
81
|
}
|
|
82
82
|
if (percent > 0) return { amount: null, percent }
|
|
@@ -1091,6 +1091,7 @@
|
|
|
1091
1091
|
"sales.documents.items.table.discountAmount": "−{{value}}",
|
|
1092
1092
|
"sales.documents.items.table.discountPercent": "{{value}} %",
|
|
1093
1093
|
"sales.documents.items.table.gross": "brutto",
|
|
1094
|
+
"sales.documents.items.table.markupAmount": "+{{value}}",
|
|
1094
1095
|
"sales.documents.items.table.net": "netto",
|
|
1095
1096
|
"sales.documents.items.table.product": "Produkt",
|
|
1096
1097
|
"sales.documents.items.table.quantity": "Menge",
|
|
@@ -1091,6 +1091,7 @@
|
|
|
1091
1091
|
"sales.documents.items.table.discountAmount": "−{{value}}",
|
|
1092
1092
|
"sales.documents.items.table.discountPercent": "{{value}}%",
|
|
1093
1093
|
"sales.documents.items.table.gross": "gross",
|
|
1094
|
+
"sales.documents.items.table.markupAmount": "+{{value}}",
|
|
1094
1095
|
"sales.documents.items.table.net": "net",
|
|
1095
1096
|
"sales.documents.items.table.product": "Product",
|
|
1096
1097
|
"sales.documents.items.table.quantity": "Qty",
|
|
@@ -1091,6 +1091,7 @@
|
|
|
1091
1091
|
"sales.documents.items.table.discountAmount": "−{{value}}",
|
|
1092
1092
|
"sales.documents.items.table.discountPercent": "{{value}} %",
|
|
1093
1093
|
"sales.documents.items.table.gross": "bruto",
|
|
1094
|
+
"sales.documents.items.table.markupAmount": "+{{value}}",
|
|
1094
1095
|
"sales.documents.items.table.net": "neto",
|
|
1095
1096
|
"sales.documents.items.table.product": "Producto",
|
|
1096
1097
|
"sales.documents.items.table.quantity": "Cant.",
|
|
@@ -1091,6 +1091,7 @@
|
|
|
1091
1091
|
"sales.documents.items.table.discountAmount": "−{{value}}",
|
|
1092
1092
|
"sales.documents.items.table.discountPercent": "{{value}}%",
|
|
1093
1093
|
"sales.documents.items.table.gross": "세금 포함",
|
|
1094
|
+
"sales.documents.items.table.markupAmount": "+{{value}}",
|
|
1094
1095
|
"sales.documents.items.table.net": "세금 제외",
|
|
1095
1096
|
"sales.documents.items.table.product": "상품",
|
|
1096
1097
|
"sales.documents.items.table.quantity": "수량",
|
|
@@ -1091,6 +1091,7 @@
|
|
|
1091
1091
|
"sales.documents.items.table.discountAmount": "−{{value}}",
|
|
1092
1092
|
"sales.documents.items.table.discountPercent": "{{value}}%",
|
|
1093
1093
|
"sales.documents.items.table.gross": "brutto",
|
|
1094
|
+
"sales.documents.items.table.markupAmount": "+{{value}}",
|
|
1094
1095
|
"sales.documents.items.table.net": "netto",
|
|
1095
1096
|
"sales.documents.items.table.product": "Produkt",
|
|
1096
1097
|
"sales.documents.items.table.quantity": "Ilość",
|