parity(api,apps): delivery-mode gaps — master OTP, non-blocking SMS, faToEn, v2 cart cap

D20: OTP_MASTER_CODE login bypass read only from the environment, disabled when
unset, audited on every use, with a boot banner while armed. SMS no longer
aborts the boot in production — it warns and declines silently, so no message
can stop an operation.

D19: port Utility::faTOen onto admin-entered mobiles (before the uniqueness
lookup, which could previously be bypassed with Persian digits), and the
create-only welcome SMS, byte-identical to the reference and fire-and-forget.

user-app-v2 was missing the cart stock ceiling entirely: capQty/maxQty, the
un-orderable guard and the qty clamp are ported, leaving v2's own design intact.
The two cart stores are now byte-identical. isVariantOrderable and the
orderable-first default selection ported too.

jest 45/45; typecheck clean on api, admin-panel, user-app, user-app-v2.

[ref: henza_server/app/Http/Controllers/Adm/AdminController.php:333,348,364,378-387]
[ref: henza_server/app/Models/Utility.php:26]

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
s.esmaeili
2026-09-22 18:35:33 +03:30
co-authored by Claude Opus 5
parent 2194b3d4d9
commit 460cf276b6
128 changed files with 756 additions and 181 deletions
+12 -1
View File
@@ -21,9 +21,20 @@
import { computed, ref, unref, type Ref } from 'vue'
import type { ProductSummary, ProductVariantSummary } from '../api/client'
/** A variant can be ordered when it has stock, or when it is flagged pre-order. */
export function isVariantOrderable(v: ProductVariantSummary): boolean {
return (v.qty ?? 0) > 0 || Boolean(v.isPreOrder)
}
function getCheapestVariant(variants: ProductVariantSummary[] | undefined): ProductVariantSummary | null {
if (!variants || variants.length === 0) return null
return variants.reduce((cheapest, v) =>
// Prefer something the customer can actually order: defaulting to an
// out-of-stock variant means the page opens on a disabled button even though
// other weights are available. Falls back to the full list when nothing is
// orderable, so the page still shows a selection.
const pool = variants.filter(isVariantOrderable)
const candidates = pool.length ? pool : variants
return candidates.reduce((cheapest, v) =>
parseFloat(v.defaultWagePercent) < parseFloat(cheapest.defaultWagePercent) ? v : cheapest,
)
}